<!-- task=m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary plan=1 tag=REVIEW_API -->

# Provider Text Tool Boundary Follow-Up Plan

## 이 파일을 읽는 구현 에이전트에게

`CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 아래 Required 두 건만 좁게 고치고, 검증 명령의 실제 stdout/stderr를 review stub에 남긴 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청`을 채우며, 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다. 환경/secret/service 차단, 일반 scope 조정, 증거 공백은 사용자 리뷰가 아니라 검증 결과나 후속 plan으로 남긴다.

## 배경

이전 구현은 provider route/provider-pool raw text tool-call 후보를 구조화하는 주 경로와 기존 테스트는 통과했다. 하지만 malformed raw block은 후보로 인식되지 않아 성공 content/SSE flush로 남을 수 있고, 여러 tool call 중 마지막만 schema validation을 받아 앞쪽 invalid call이 통과할 수 있다. 이번 follow-up은 그 두 경계만 보완한다.

## 사용자 리뷰 요청 흐름

사용자 리뷰 요청은 선택된 Milestone 잠금 결정이 구현을 차단할 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 중 직접 사용자 prompt, 선택지 제시, `request_user_input` 호출은 금지되며, code-review가 검증 후 실제 `USER_REVIEW.md` 작성 여부를 판단한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md`
- Task ids:
  - `provider-raw-parse`: provider-pool/provider route raw text tool-call parser 적용
  - `unknown-tool-error`: unknown raw tool-call을 validation error 또는 retry로 처리
  - `stream-flush-guard`: streaming raw tool-call leak 방지
  - `native-preserve`: native `tool_calls` pass-through와 schema normalization 보존
- Completion mode: check-on-pass

## Archive Evidence Snapshot

- Prior active task: `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary`
- Archived plan: `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_0.log`
- Archived review: `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_0.log`
- Verdict: FAIL
- Required summary:
  - malformed raw `<tool_call>` blocks with missing function or incomplete candidate can be treated as no candidate and leak through success content/SSE pending flush.
  - multiple `tool_calls` validation checks only the last candidate, so an earlier schema-invalid call can be emitted when a later call is valid.
- Suggested/Nit summary: none blocking; first-line artifact drift in prior active files was repaired before archive.
- Affected files: `apps/edge/internal/openai/types.go`, `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/stream.go`, `apps/edge/internal/openai/tool_validation.go`, `apps/edge/internal/openai/server_test.go`.
- Verification evidence from prior loop and reviewer rerun:
  - `go test ./apps/edge/internal/openai -count=1`: PASS
  - `go test ./apps/edge/... -count=1`: PASS
- Roadmap carryover: same four Task ids as prior plan; this follow-up should still complete the 01 provider text boundary subtask on PASS.
- More detail, if needed: read only the two archived files listed above; do not search `agent-task/archive/**`.

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-test/local/rules.md`
- `agent-contract/index.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_0.log`
- `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_0.log`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/tool_validation.go`
- `apps/edge/internal/openai/run_result.go`
- `apps/edge/internal/openai/strict_output.go`
- `apps/edge/internal/openai/server_test.go`

### SDD 기준

선택 Milestone은 `SDD: 불필요`다. 사유는 기존 OpenAI-compatible `tools[]`, `tool_calls`, text-tool fallback, validation retry 계약을 강화하는 호환성 보정이며 새 공개 request/response field를 만들지 않는다는 것이다.

### 테스트 환경 규칙

`test_env=local`. `agent-test/local/rules.md`를 읽었고, edge 도메인 변경이므로 변경 패키지와 edge 패키지 기준 fresh Go test를 사용한다. 적용 명령은 `go test ./apps/edge/internal/openai -count=1`와 `go test ./apps/edge/... -count=1`이며, 중간 검증도 `-count=1`을 유지한다.

### 테스트 커버리지 공백

- malformed raw XML block: 기존 테스트 없음. `<tool_call>` opening 또는 complete block이 있지만 function parse가 실패하는 경우 validation error로 막는 회귀 테스트가 필요하다.
- malformed raw live SSE: 기존 테스트 없음. pending filter가 잡은 raw candidate를 terminal에서 `tool_validation_error`로 끝내고 raw pending을 flush하지 않는지 검증해야 한다.
- multiple synthesized tool calls: 기존 테스트 없음. 마지막 call만 valid한 경우 앞쪽 invalid call이 함께 emit되지 않는지 검증해야 한다.
- native preserve: 기존 native pass-through/normalization tests는 유지하고 최종 패키지 테스트로 회귀를 확인한다.

### 심볼 참조

rename/remove 없음. 변경 후보 call site는 `synthesizeToolCallsFromTextResult` 호출 지점 `apps/edge/internal/openai/chat_handler.go:367`, `apps/edge/internal/openai/stream.go:222`와 `validateToolCallResponse` 호출 지점 `apps/edge/internal/openai/chat_handler.go:274`, `apps/edge/internal/openai/stream.go:439`다.

### 분할 판단

split 정책을 평가했다. 이미 `m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary` 안에서 prior loop가 실패한 좁은 follow-up이며, 두 Required 모두 같은 OpenAI package의 parser/validation boundary와 같은 tests로 검증된다. 새 subtask로 쪼개면 같은 실패 원인을 분산하므로 기존 subtask에서 plan 1로 이어간다.

### 범위 결정 근거

계약 문서/dev smoke는 sibling `02+01_contract_dev_smoke` 범위이므로 수정하지 않는다. Node adapter fallback 정책, provider별 chat template, 모델 prompt policy, `tools[]` 없는 자연어 tool 추론은 범위 밖이다. 기존 native `tool_calls` pass-through 동작은 보존만 확인한다.

### 빌드 등급

`local-G07`. 단일 Go package 안의 bounded parser/validation 보완이고 deterministic unit/package tests로 검증 가능하지만, stream/non-stream/retry/native preserve 경계가 함께 걸린다.

## 구현 체크리스트

- [ ] malformed raw text tool-call 후보를 candidate로 인식해 non-stream/strict buffered stream은 bounded retry 후 `tool_validation_error`, live SSE는 terminal SSE error로 종료하고 raw block을 content로 flush하지 않게 한다.
- [ ] `validateToolCallResponse`가 모든 tool call candidate를 검증해 여러 raw/native tool calls 중 어느 하나라도 schema/shape 오류가 있으면 retry/error로 처리하게 한다.
- [ ] malformed raw block, live SSE malformed block, multiple tool-call partial invalid 회귀 테스트를 추가하고 native preserve 회귀를 유지한다.
- [ ] `go test ./apps/edge/internal/openai -count=1`와 `go test ./apps/edge/... -count=1`를 실행해 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [REVIEW_API-1] Malformed Raw Candidate Guard

문제: `apps/edge/internal/openai/types.go:267`은 `<tool_call>...</tool_call>` block 안에서 `<function=...>`를 찾지 못하면 `continue`로 무시한다. `apps/edge/internal/openai/types.go:422`에서 toolCalls가 없고 error도 없으면 no-candidate처럼 반환되므로 non-stream `apps/edge/internal/openai/chat_handler.go:366`은 raw content를 성공 응답으로 보존하고, live SSE는 `apps/edge/internal/openai/stream.go:242`의 pending flush branch로 raw block을 내보낼 수 있다.

해결 방법: XML candidate scanner가 `<tool_call` opening을 발견했으면 malformed도 candidate로 취급한다. 닫힌 block이 없거나, 닫힌 block 안에 function tag/name이 없거나, mustache-like function call opening이 닫히지 않은 경우 `validationErr`를 반환한다. Valid block은 기존 정규화 경로를 유지한다.

Before:

```go
// apps/edge/internal/openai/types.go:267
match := textToolFunctionRE.FindStringSubmatchIndex(block)
if match == nil {
	continue
}
foundAny = true
```

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/openai/types.go`: malformed XML/mustache raw candidate를 `validationErr`로 반환.
- [ ] `apps/edge/internal/openai/chat_handler.go`: 기존 `toolValidationErr` retry/error 경로와 호환되는지 확인.
- [ ] `apps/edge/internal/openai/stream.go`: live SSE terminal error path가 malformed candidate에서 raw pending을 flush하지 않는지 확인.
- [ ] `apps/edge/internal/openai/server_test.go`: non-stream과 live SSE malformed raw block 회귀 테스트 추가.

테스트 작성: `TestChatCompletionsFailsMalformedTextToolCallAfterRetryLimit`와 `TestChatCompletionsProviderStreamBlocksMalformedTextToolCall`를 추가한다. 전자는 두 attempt 모두 malformed raw block을 반환해 502 `tool_validation_error`와 no raw success content를 검증하고, 후자는 SSE body가 `tool_validation_error`와 `[DONE]`을 포함하되 escaped raw `<tool_call>` content delta를 포함하지 않음을 검증한다.

중간 검증:

```bash
go test ./apps/edge/internal/openai -run 'TestChatCompletions.*MalformedTextToolCall|TestChatCompletionsProviderStreamBlocksMalformedTextToolCall' -count=1
```

기대 결과: PASS.

### [REVIEW_API-2] Validate Every Tool Call

문제: `apps/edge/internal/openai/tool_validation.go:93`이 `candidates[len(candidates)-1]`만 검증한다. raw text synthesis가 여러 known tool calls를 반환할 때 첫 번째 call이 schema-invalid이고 마지막 call만 valid이면 전체 응답이 성공 `tool_calls`로 나갈 수 있다.

해결 방법: `validateToolCallResponse`가 `toolCallCandidatesFromResponse` 결과 전체를 순서대로 검증하고 첫 번째 오류를 반환하게 한다. 오류 메시지에는 가능하면 candidate index를 포함해 어떤 call이 실패했는지 알 수 있게 하되, 기존 retry/error type은 유지한다.

Before:

```go
// apps/edge/internal/openai/tool_validation.go:90
if len(candidates) == 0 {
	return nil
}
return validateToolCallCandidate(contract, candidates[len(candidates)-1])
```

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/openai/tool_validation.go`: 모든 candidate 검증으로 변경.
- [ ] `apps/edge/internal/openai/server_test.go`: multiple synthesized raw tool-call 중 일부 invalid case를 추가.
- [ ] `apps/edge/internal/openai/server_test.go`: native pass-through/normalization tests가 계속 통과하는지 확인.

테스트 작성: `TestValidateToolCallResponseValidatesEveryToolCall` 또는 HTTP 레벨 `TestChatCompletionsFailsWhenAnySynthesizedTextToolCallViolatesSchema`를 추가한다. 최소 하나는 첫 번째 call invalid, 두 번째 call valid인 응답이 retry/error로 끝나는지를 검증해야 한다.

중간 검증:

```bash
go test ./apps/edge/internal/openai -run 'TestValidateToolCallResponseValidatesEveryToolCall|TestChatCompletionsFailsWhenAnySynthesizedTextToolCallViolatesSchema|TestChatCompletions.*NativeToolCall' -count=1
```

기대 결과: PASS.

## 수정 파일 요약

| 파일 | 항목 |
|---|---|
| `apps/edge/internal/openai/types.go` | REVIEW_API-1 |
| `apps/edge/internal/openai/chat_handler.go` | REVIEW_API-1 확인 |
| `apps/edge/internal/openai/stream.go` | REVIEW_API-1 확인 |
| `apps/edge/internal/openai/tool_validation.go` | REVIEW_API-2 |
| `apps/edge/internal/openai/server_test.go` | REVIEW_API-1, REVIEW_API-2 |

## 최종 검증

```bash
go test ./apps/edge/internal/openai -count=1
go test ./apps/edge/... -count=1
```

기대 결과: 두 명령 모두 PASS. Go test cache는 허용하지 않으므로 `-count=1`을 유지한다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
