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

# Provider Text Tool Boundary Plan

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

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

## 배경

현재 provider route와 provider-pool은 native `tool_calls`만 구조화하고, fallback marker 없는 raw `<tool_call>` 텍스트는 성공 content로 보존한다. 이 동작은 Pi/Cline형 OpenAI-compatible tools 클라이언트에서 hallucinated tool text가 화면으로 새는 원인이다. 이번 작업은 요청 `tools[]`가 있는 provider 응답의 raw text tool-call 후보를 기존 parser와 validation 경계로 끌어와 valid 후보는 구조화하고 invalid/unknown 후보는 retry 또는 error로 막는다.

## 사용자 리뷰 요청 흐름

사용자 리뷰 요청은 선택된 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

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/skills/common/router.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/PHASE.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-test/local/edge-smoke.md`
- `agent-contract/index.md`
- `agent-contract/outer/openai-compatible-api.md`
- `docs/openai-compatible-api-contract.md`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/types.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 도메인 매칭으로 `agent-test/local/edge-smoke.md`를 읽었다. 적용 명령은 변경 패키지/edge 패키지 기준 `go test ./apps/edge/internal/openai -count=1` 및 `go test ./apps/edge/... -count=1`이다. OpenAI-compatible 경계 변경의 live smoke는 02번 `contract_dev_smoke` plan에서 dev-runtime 동기화 후 수행한다.

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

- provider route raw text valid tool-call 구조화: 현재 `TestChatCompletionsDoesNotSynthesizeTextToolCallsForProviderRoute`가 반대 동작을 고정하므로 수정 필요.
- unknown XML/mustache tool-call 차단: 현재 `TestChatCompletionsLeavesUnknownTextToolCallAsContent`, `TestChatCompletionsLeavesUnknownTemplateToolCallAsContent`가 반대 동작을 고정하므로 수정 필요.
- live SSE raw candidate terminal 처리: CLI fallback stream tests는 있으나 provider route valid/invalid raw text stream tests가 없다.
- native `tool_calls` pass-through/schema normalization: 기존 server tests가 있으므로 보존 확인에 사용한다.

### 심볼 참조

이 plan은 symbol rename/remove를 요구하지 않는다. 변경 후보 call site는 `shouldSynthesizeTextToolCalls`가 `chat_handler.go:240`, `chat_handler.go:359`, `stream.go:221`에서 쓰이는 지점이다.

### 분할 판단

split 정책을 먼저 평가했다. 공유 task group은 `m-openai-compatible-tool-call-boundary-hardening`이다. `01_provider_text_boundary`는 코드/로컬 회귀 테스트를 담당하고 선행 의존이 없다. `02+01_contract_dev_smoke`는 01 완료 후 계약 문서와 dev-runtime live evidence를 담당한다. 원격 credential/runtime 증거와 코드 경계 구현은 검증 리스크가 달라 분리했다.

### 범위 결정 근거

`apps/node/internal/adapters/openai_compat`의 fallback 생성 정책, provider별 chat template 교체, 모델별 prompt policy, `tools[]` 없는 자연어 tool 추론, Pi 자체 provider/profile 소유권은 Milestone 범위 제외와 충돌하므로 건드리지 않는다. 이번 plan은 `apps/edge/internal/openai`의 response boundary와 server tests만 수정한다.

### 빌드 등급

`local-G07`. 단일 Edge OpenAI package 안의 bounded 변경이며 로컬 unit tests로 검증 가능하지만, streaming/non-stream/retry/native 보존 경계를 함께 바꾸므로 G07로 둔다.

## 구현 체크리스트

- [ ] Provider route/provider-pool raw text tool-call 후보를 request `tools[]` 기준으로 구조화하고, valid raw block이 `message.tool_calls` 또는 stream `delta.tool_calls`로 나가며 raw block이 content에 남지 않게 한다.
- [ ] Unknown/malformed text tool-call 후보를 성공 content로 flush하지 않고 non-stream/strict buffered stream에서는 bounded retry 후 `tool_validation_error`, live SSE에서는 terminal SSE error로 종료하게 한다.
- [ ] Existing native provider `tool_calls` pass-through와 schema-normalization 동작을 보존하고 회귀 테스트를 유지한다.
- [ ] Provider raw parse, unknown tool, live SSE, strict buffered SSE, native preserve 회귀 테스트를 갱신한다.
- [ ] `go test ./apps/edge/internal/openai -count=1`와 `go test ./apps/edge/... -count=1`를 실행해 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [API-1] Text Tool Candidate Result

문제: `types.go:288-310`과 `types.go:313-343`은 request specs에 없는 raw tool candidate를 조용히 무시한다. 그 결과 `server_test.go:1410-1437`과 `server_test.go:1440-1465`처럼 unknown tool text가 성공 content로 남는다.

해결 방법: 기존 public helper signature를 깨지 않도록 `synthesizeToolCallsFromText`는 wrapper로 유지하고, 내부에 `synthesizeToolCallsFromTextResult(content, tools, runID)`를 추가한다. result에는 `cleaned`, `toolCalls`, `candidateFound`, `validationErr`를 둔다. XML/mustache scanner는 request specs 존재 여부와 별도로 candidate name을 먼저 감지하고, name이 request tool 목록에 없으면 `validationErr=tool "<name>" is not in request tools`를 반환한다. known tool의 arguments parse/JSON marshal 실패도 `validationErr`로 반환한다.

Before:

```go
// apps/edge/internal/openai/types.go:240
func synthesizeToolCallsFromText(content string, tools []any, runID string) (string, []any) {
	specs := requestedToolSpecs(tools)
	if len(specs) == 0 {
		return content, nil
	}
	matches := collectTextToolCallMatches(content, specs)
	if len(matches) == 0 {
		return content, nil
	}
```

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/openai/types.go`: result struct와 strict candidate parser 추가.
- [ ] `apps/edge/internal/openai/server_test.go`: unknown XML/mustache tests를 content-preserve 기대에서 validation failure 기대값으로 전환.

테스트 작성: `TestSynthesizesTextToolCallReportsUnknownCandidate`와 `TestSynthesizesTemplateToolCallReportsUnknownCandidate`를 추가하거나 기존 unknown tests를 helper 단위와 HTTP 단위로 갱신한다.

중간 검증: `go test ./apps/edge/internal/openai -run 'TestSynthesizes.*ToolCall|TestChatCompletions.*Unknown' -count=1` PASS.

### [API-2] Non-Stream Provider Text Boundary

문제: `chat_handler.go:240-242`가 text synthesis를 fallback marker 또는 CLI adapter로만 제한하고, `chat_handler.go:359-368`은 provider route raw text를 검사하지 않는다. `server_test.go:514-544`가 이 반대 동작을 고정한다.

해결 방법: `chatCompletionOutput`에 `toolValidationErr error`를 추가하고, `collectChatCompletionOutput`에서 request tools가 있고 native tool_calls가 없으면 provider/CLI 모두 text candidate result를 검사한다. valid result는 `toolCallOriginText`로 반환해 기존 `validateToolCallResponse` retry 루프에 태운다. invalid result는 `toolValidationErr`로 반환하고 `completeChatCompletion`은 `validateToolCallResponse`와 같은 retry/error 경로를 사용한다.

Before:

```go
// apps/edge/internal/openai/chat_handler.go:359
} else if shouldSynthesizeTextToolCalls(handle.Dispatch(), textToolFallback) {
	cleaned, synthesizedToolCalls := synthesizeToolCallsFromText(text, req.Tools, handle.Dispatch().RunID)
	if len(synthesizedToolCalls) > 0 {
```

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/openai/chat_handler.go`: provider text inspection 조건과 retry/error 처리 추가.
- [ ] `apps/edge/internal/openai/tool_validation.go`: validation error helper가 result error와 native validation error를 같은 retry loop에서 다룰 수 있게 정리.
- [ ] `apps/edge/internal/openai/server_test.go`: provider raw valid test를 `tool_calls` 기대값으로 전환하고 retry metadata를 검증.

테스트 작성: `TestChatCompletionsSynthesizesTextToolCallsForProviderRoute`, `TestChatCompletionsRetriesUnknownTextToolCallBeforeResponse`, `TestChatCompletionsFailsUnknownTextToolCallAfterRetryLimit`.

중간 검증: `go test ./apps/edge/internal/openai -run 'TestChatCompletions.*Provider.*Tool|TestChatCompletions.*Unknown.*Retry|TestChatCompletions.*MalformedToolCall' -count=1` PASS.

### [API-3] Streaming Flush Guard

문제: live SSE는 runtime validation을 제외하고, `stream.go:221-247`에서 synthesis 조건이 false거나 candidate parse가 실패하면 pending raw block을 flush한다. 이 때문에 provider route raw `<tool_call>` 후보가 마지막 content delta로 새는 경로가 남는다.

해결 방법: live SSE는 retry하지 않는다. 대신 request tools가 있으면 provider/CLI 모두 terminal event에서 text candidate result를 검사한다. valid result는 `tool_calls` delta를 emit한다. invalid result는 `writeSSEErrorWithType(..., "tool_validation_error", ...)`로 종료하고 raw pending을 flush하지 않는다. candidate가 전혀 없을 때만 pending text를 기존처럼 flush한다. Strict buffered stream은 `collectChatCompletionOutput` 경로를 공유하므로 API-2의 retry/error 경로를 사용한다.

Before:

```go
// apps/edge/internal/openai/stream.go:238
} else if pending := toolTextFilter.Flush(); pending != "" {
	emittedContent.WriteString(pending)
	writeTracedContentDelta(pending, pending, false)
}
```

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/openai/stream.go`: terminal event의 pending flush 전에 candidate result를 검사.
- [ ] `apps/edge/internal/openai/server_test.go`: provider live SSE valid/invalid raw block tests 추가.

테스트 작성: `TestChatCompletionsProviderStreamSynthesizesTextToolCalls`, `TestChatCompletionsProviderStreamBlocksUnknownTextToolCall`, `TestChatCompletionsStrictBufferedStreamFailsUnknownTextToolCallAfterRetryLimit`.

중간 검증: `go test ./apps/edge/internal/openai -run 'TestChatCompletions.*Stream.*Tool|TestChatCompletionsStrictBufferedStream' -count=1` PASS.

### [API-4] Native Preserve Regression

문제: provider text synthesis를 넓히면 native `openai_tool_calls` metadata 처리(`chat_handler.go:353-358`, `stream.go:201-220`)와 schema normalization이 우발적으로 바뀔 수 있다.

해결 방법: native `tool_calls`가 있으면 text candidate inspection보다 항상 우선한다. 기존 `normalizeNativeToolCallsForResponse` 호출 순서와 `finish_reason=tool_calls` 설정을 보존한다.

수정 파일 및 체크리스트:

- [ ] `apps/edge/internal/openai/chat_handler.go`: native-first branch 유지.
- [ ] `apps/edge/internal/openai/stream.go`: native-first branch 유지.
- [ ] `apps/edge/internal/openai/server_test.go`: native pass-through/normalization tests가 계속 통과하는지 확인.

테스트 작성: 새 테스트는 필요 없고 기존 `TestChatCompletionsPassesThroughNativeToolCallsFromProviderRoute`, `TestChatCompletionsNormalizesNativeToolCallArgumentsForProviderRoute`, `TestChatCompletionsStreamPassesThroughNativeToolCallsFromProviderRoute`를 회귀 기준으로 사용한다.

중간 검증: `go test ./apps/edge/internal/openai -run 'TestChatCompletions.*NativeToolCall' -count=1` PASS.

## 수정 파일 요약

| 파일 | 항목 |
|---|---|
| `apps/edge/internal/openai/types.go` | API-1 |
| `apps/edge/internal/openai/chat_handler.go` | API-2, API-4 |
| `apps/edge/internal/openai/tool_validation.go` | API-2 |
| `apps/edge/internal/openai/stream.go` | API-3, API-4 |
| `apps/edge/internal/openai/server_test.go` | API-1, API-2, API-3, API-4 |

## 의존 관계 및 구현 순서

이 subtask는 선행 의존이 없다. `02+01_contract_dev_smoke`는 이 subtask의 `complete.log`가 생긴 뒤 시작한다.

## 최종 검증

```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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
