<!-- task=m-tool-call-runtime-validation-retry plan=0 tag=API -->

# Runtime Tool Validation Stream Completion - API

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

`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수 마지막 단계다. 검증을 실행하고 실제 stdout/stderr를 남긴 뒤 active 파일을 유지한 채 review ready로 보고한다. finalization, `complete.log`, archive 이동은 code-review-skill 전용이다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막을 때만 review stub의 `사용자 리뷰 요청` 섹션에 근거를 기록하고 멈춘다. 직접 사용자 질문, 채팅 선택지, `request_user_input`, `USER_REVIEW.md` 생성, log/archive/complete 작성은 금지다. 환경/secret/service blocker, 일반 범위 조정, 후속 agent가 닫을 수 있는 evidence gap은 사용자 리뷰 요청이 아니다.

## 배경

`runtime-validate` 에픽의 작은 non-stream 기반 변경은 현재 working tree에 먼저 들어가 있다. 남은 큰 작업은 stream 경로다. live SSE는 이미 content를 내보낼 수 있어 exact retry 의미가 다르고, strict buffered stream은 아직 `streamBufferedChatCompletion` 내부에서 validation/retry를 공유하지 않는다. 이 계획은 기존 non-stream 기반을 보존하면서 stream mode별 validation contract를 명확히 완성한다.

## 사용자 리뷰 요청 흐름

사용자 리뷰 요청은 선택된 Milestone lock decision만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 섹션 형식은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`에서 복사된 기본값을 유지한다. 구현 중 직접 사용자에게 묻지 말고, code-review가 사용자 리뷰 필요성을 검증하고 실제 `USER_REVIEW.md` 작성을 소유한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-runtime-validation-retry.md`
- Task ids:
  - `contract-detect`: `tools[]`가 있는 OpenAI-compatible 요청을 runtime validation 대상으로 판정하고 `tool_choice: none` 등 검증 제외 조건을 정리한다.
  - `candidate-normalize`: native `tool_calls`와 IOP text fallback 결과를 tool name, arguments, origin, raw payload를 가진 공통 후보로 정규화한다.
  - `schema-validate`: 요청 tool schema를 기준으로 tool name, arguments JSON parse, required/type/object/array/enum/additionalProperties subset을 검증한다.
  - `exact-retry`: validation 실패가 응답 방출 전 발견되면 같은 요청을 bounded exact replay로 재제출하고, 새 attempt run과 원 요청의 상관관계를 남긴다.
  - `failure-surface`: retry 한도 초과 또는 재시도 불가 경로에서는 malformed tool call을 그대로 성공 응답으로 내보내지 않고 OpenAI-compatible 실패로 구분한다.
  - `validation-tests`: runtime validation, retry 성공, retry 한도 초과, stream 제외 또는 buffer 경로를 테스트한다.
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-roadmap/current.md`
- `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md`
- `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-runtime-validation-retry.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.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-test/local/rules.md`
- `agent-test/local/edge-smoke.md`
- `agent-contract/index.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-contract/inner/edge-node-runtime-wire.md`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/run_result.go`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/strict_output.go`
- `apps/edge/internal/openai/server.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/service/run_dispatch.go`
- `apps/edge/internal/service/service.go`
- `go.mod`

### SDD 기준

선택 Milestone은 `SDD: 불필요`이다. 기록된 사유: 기존 OpenAI-compatible 요청/응답 계약 안에서 명시적 `tools[]` schema를 검증하고 같은 요청을 내부 재제출하는 bounded MVP로 제한하며, 새 proto/config/API field나 외부 계약 확장은 만들지 않는다.

### 테스트 환경 규칙

- `test_env`: local.
- `agent-test/local/rules.md`: present/read. Go quick check는 local checkout에서 실행한다.
- matched profile: `agent-test/local/edge-smoke.md`. `apps/edge/internal/openai/**` 변경이므로 edge smoke profile을 적용한다.
- 적용 명령: 변경 패키지에는 `go test -count=1 ./apps/edge/internal/openai`, edge 범위 회귀에는 `go test -count=1 ./apps/edge/...`.
- OpenAI-compatible 경계 변경이므로 최종 보고에서 full-cycle 또는 smoke 생략 여부를 명시한다. 이 계획의 필수 자동 검증은 local Go tests이며, 외부 runtime/field runner preflight는 요구하지 않는다.

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

- non-stream validation/retry/failure: 현재 working tree의 `server_test.go`에 성공 retry, 한도 초과 실패, `tool_choice: none` 제외, schema subset 단위 테스트가 있다.
- strict buffered stream: 아직 malformed native/text tool call을 validation/retry하는 테스트가 없다.
- live SSE stream: 이미 content를 방출할 수 있는 경로의 validation 제외 또는 실패 표면 정책 테스트가 없다.
- malformed `openai_tool_calls` metadata JSON parse error는 기존 `toolCallsFromRunMetadata`가 nil로 접는 구조다. 이 계획은 runtime candidate가 존재하는 malformed shape/schema를 다루고, metadata parse error 정책 확장은 범위 제외한다.

### 심볼 참조

renamed/removed symbol 없음. 새 helper는 `openai` package 내부 호출로 한정한다.

### 분할 판단

split decision policy를 평가했다. 단일 plan을 사용한다. 변경은 `apps/edge/internal/openai` 한 ownership boundary에 있고, 새 public API/proto/config foundation과 broad call-site rollout을 만들지 않는다. stream contract routing과 buffered retry는 같은 handler/stream lifecycle의 한 구현 단위이며, tests도 같은 package command로 검증된다. 별도 split은 active working tree의 non-stream 기반과 stream 마무리 사이 coordination 비용만 늘린다.

### 범위 결정 근거

- 포함: OpenAI Chat Completions non-stream 기반 보존, stream mode별 validation 적용/제외, strict buffered stream retry/failure, package tests.
- 제외: Node adapter native tool retry 정책 변경, provider-specific prompt repair, live SSE에서 이미 방출한 content를 되감는 투명 재시도, 새 public API/config/proto field, docs/contract 원문 변경.
- `apps/node/**`, `proto/**`, `packages/go/config/**`는 이번 구현에서 수정하지 않는다.

### 빌드 등급

`local-G05`. 한 Go package 안의 bounded behavior이고 unit tests로 검증 가능하지만, SSE write timing과 retry semantics가 얽혀 있어 중간 난이도다.

## 구현 체크리스트

- [ ] 기존 non-stream validation 기반(`toolValidationContractFromRequest`, `validateToolCallResponse`, `completeChatCompletion` retry loop)을 보존하고 stream mode와 충돌하지 않게 정리한다.
- [ ] live SSE는 `tools[]`가 있어도 이미 응답을 방출할 수 있는 경로로 명시적으로 validation/retry 대상에서 제외하고, validation attempt metadata를 남기지 않는다.
- [ ] strict buffered stream은 tool call chunk를 쓰기 전에 candidate normalize/schema validation을 수행하고, 실패 시 같은 `SubmitRunRequest`를 최대 1회 재제출한다.
- [ ] strict buffered stream retry 한도 초과나 재시도 불가 상태에서는 malformed `tool_calls` chunk를 성공으로 내보내지 않고 SSE error로 구분한다.
- [ ] `apps/edge/internal/openai/server_test.go`에 buffered retry 성공, buffered retry 한도 초과 실패, live stream validation 제외 테스트를 추가/갱신하고 `go test -count=1 ./apps/edge/internal/openai`를 통과시킨다.
- [ ] `go test -count=1 ./apps/edge/...`를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

### [API-1] Stream Validation Routing

#### 문제

[apps/edge/internal/openai/chat_handler.go](/config/workspace/iop/apps/edge/internal/openai/chat_handler.go:73)는 `tools[]`와 `tool_choice`만으로 validation metadata를 붙인다. [apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:29)는 live stream에서 role chunk를 즉시 쓰고, [apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:105)는 content delta도 즉시 방출한다. 이 상태에서 live stream까지 validation attempt로 표시하면 실제 검증/재시도와 metadata가 불일치한다.

Before:

```go
// apps/edge/internal/openai/chat_handler.go:73
validation := toolValidationContractFromRequest(req)
metadata := chatRunMetadata(runMeta, req, outputPolicy)
if validation.enabled {
	metadata = toolValidationAttemptMetadata(metadata, 1, "", "")
}
```

#### 해결 방법

stream mode를 반영한 validation contract helper를 둔다. non-stream은 현재대로 enabled, strict buffered stream은 enabled, live SSE는 disabled with explicit exclusion reason로 처리한다. `SubmitRunRequest.Metadata`에는 enabled일 때만 attempt key를 둔다.

After:

```go
validation := toolValidationContractForChatRequest(req, outputPolicy)
metadata := chatRunMetadata(runMeta, req, outputPolicy)
if validation.enabled {
	metadata = toolValidationAttemptMetadata(metadata, 1, "", "")
}
```

#### 수정 파일 및 체크리스트

- [ ] `apps/edge/internal/openai/tool_validation.go`: stream-aware contract helper 추가.
- [ ] `apps/edge/internal/openai/chat_handler.go`: helper 호출 전환.
- [ ] `apps/edge/internal/openai/server_test.go`: live stream exclusion assertion 추가.

#### 테스트 작성

`TestChatCompletionsLiveStreamSkipsRuntimeToolValidation`를 추가한다. `stream:true`, `tools[]`, invalid provider tool call metadata를 주고 submit attempts가 1회이며 validation metadata가 없고 SSE가 기존 live semantics를 유지하는지 확인한다.

#### 중간 검증

`go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletionsLiveStreamSkipsRuntimeToolValidation|TestChatCompletionsRetriesMalformedToolCallBeforeResponse'`

기대: PASS.

### [API-2] Buffered Stream Retry And Failure

#### 문제

[apps/edge/internal/openai/stream.go](/config/workspace/iop/apps/edge/internal/openai/stream.go:304)의 `streamBufferedChatCompletion`은 `collectRunResult` 뒤 바로 native/text tool calls를 SSE로 쓴다. non-stream path의 `validateToolCallResponse`와 retry loop를 공유하지 않아서 strict buffered stream에서 malformed tool call이 그대로 성공 chunk로 나갈 수 있다.

Before:

```go
// apps/edge/internal/openai/stream.go:323
if len(nativeToolCalls) > 0 {
	nativeToolCalls = normalizeNativeToolCallsForResponse(nativeToolCalls, tools)
	...
	writeSSE(w, flusher, chatCompletionChunk{... ToolCalls: toolCallsForStreamDelta(nativeToolCalls) ...})
	finishReason = "tool_calls"
}
```

#### 해결 방법

buffered stream도 non-stream의 collection/build/validation helper를 공유하거나 같은 결과 DTO를 사용한다. validation 실패 시 첫 user-visible payload를 쓰기 전에 handle을 닫고 `SubmitRunRequest`를 같은 input/prompt/options로 재제출한다. retry 한도 초과 시 `writeSSEError`를 사용하되 error type을 `tool_validation_error`로 구분하는 helper가 필요하면 추가한다. role chunk는 validation-enabled buffered stream에서 collect/retry 이후에 쓰거나, 초기 role-only chunk가 허용되는지 test로 고정한다. malformed tool call chunk는 절대 쓰지 않는다.

#### 수정 파일 및 체크리스트

- [ ] `apps/edge/internal/openai/stream.go`: buffered stream에 `submitReq`와 `validation`을 전달하거나 shared completion output helper를 사용한다.
- [ ] `apps/edge/internal/openai/chat_handler.go`: stream call signature에 필요한 context를 전달한다.
- [ ] `apps/edge/internal/openai/tool_validation.go`: retry metadata helper 재사용.
- [ ] `apps/edge/internal/openai/server_test.go`: buffered success/failure tests 추가.

#### 테스트 작성

다음 테스트를 작성한다.

- `TestChatCompletionsStrictBufferedStreamRetriesMalformedToolCallBeforeChunk`: 첫 run은 schema mismatch native tool call, 두 번째 run은 valid tool call. SSE body에 valid call만 있고 attempts metadata가 1/2인지 확인.
- `TestChatCompletionsStrictBufferedStreamFailsMalformedToolCallAfterRetryLimit`: 두 run 모두 invalid. SSE body에 `tool_validation_error`가 있고 invalid `tool_calls` chunk가 없는지 확인.

#### 중간 검증

`go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletionsStrictBufferedStream'`

기대: PASS.

### [API-3] Final Validation Evidence

#### 문제

Milestone `validation-tests`는 runtime validation, retry 성공, retry 한도 초과, stream 제외 또는 buffer 경로 테스트를 요구한다. 현재 target package test는 non-stream 기반만 검증한다.

#### 해결 방법

API-1/API-2 테스트가 추가된 뒤 전체 package와 edge package를 fresh run으로 검증한다. cached output은 사용하지 않는다.

#### 수정 파일 및 체크리스트

- [ ] `apps/edge/internal/openai/server_test.go`: final behavior tests가 모두 포함되어 있는지 확인.
- [ ] `agent-task/m-tool-call-runtime-validation-retry/CODE_REVIEW-local-G05.md`: 실제 검증 stdout/stderr 기록.

#### 테스트 작성

별도 테스트 파일은 만들지 않는다. 기존 `server_test.go`가 OpenAI-compatible server behavior의 중심 test file이고, 같은 fake service가 retry attempts를 검증한다.

#### 중간 검증

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

기대: PASS.

## 수정 파일 요약

| 파일 | 항목 |
|---|---|
| `apps/edge/internal/openai/chat_handler.go` | API-1, API-2 |
| `apps/edge/internal/openai/stream.go` | API-1, API-2 |
| `apps/edge/internal/openai/tool_validation.go` | API-1, API-2 |
| `apps/edge/internal/openai/server_test.go` | API-1, API-2, API-3 |
| `agent-task/m-tool-call-runtime-validation-retry/CODE_REVIEW-local-G05.md` | API-3 |

## 최종 검증

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

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

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