diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md index d53db59..355f6ff 100644 --- a/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md @@ -15,10 +15,18 @@ Ollama serving 경로와 운영 기반이 안정화된 뒤, 단계 호출, tool/ 완료된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획, 스케치 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다. 완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다. +- [계획] Tool Call Runtime 검증 재시도 MVP + - 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-runtime-validation-retry.md` + - 요약: 명시적 `tools[]`/`tool_choice` 계약이 있는 OpenAI-compatible 요청에서 IOP 내부 provider 응답의 마지막 tool call을 검증하고, schema 불일치 시 같은 요청을 bounded exact replay로 재시도하는 runtime-only MVP를 구현한다. + - [스케치] 단계 호출과 검증 최적화 MVP - 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/knowledge-tool-validation-optimization.md` - 요약: 요청 의도 분석, 실제 작업, 검증/schema 강제, 오류 시 회귀를 단계 호출 실행 모드의 MVP 후보로 스케치한다. +- [스케치] Tool Call 판정 모델 Gate 리뷰 + - 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-validator-model-gate-review.md` + - 요약: 명시적 tool schema만으로 판정할 수 없는 자연어/텍스트/agent-specific tool call 후보를 별도 validator 모델로 분류할지, 어떤 조건에서 허용할지 사용자 리뷰가 필요한 결정 항목으로 스케치한다. + - [스케치] 장기 기억과 RAG 업데이트 사이클 (2차) - 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/long-term-memory-rag-second-wave.md` - 요약: 특정 repo 장기 기억, RAG 저장소, update cycle, MCP 기반 context 절약은 MVP 이후 2차 후보로 스케치한다. diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-runtime-validation-retry.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-runtime-validation-retry.md new file mode 100644 index 0000000..a0f81af --- /dev/null +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-runtime-validation-retry.md @@ -0,0 +1,77 @@ +# Milestone: Tool Call Runtime 검증 재시도 MVP + +## 위치 + +- Roadmap: `agent-roadmap/ROADMAP.md` +- Phase: `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md` + +## 목표 + +명시적 `tools[]`와 `tool_choice` 계약이 있는 OpenAI-compatible 요청에서 IOP가 provider 응답의 마지막 tool call을 반환 직전에 검증한다. +tool name 또는 `function.arguments`가 요청 schema와 맞지 않으면 caller agent에 실패를 노출하기 전에 IOP 내부에서 같은 요청을 bounded exact replay로 재시도한다. +이 Milestone은 validator 모델 없이 runtime이 결정적으로 판단할 수 있는 케이스만 다룬다. + +## 상태 + +[계획] + +## 승격 조건 + +- 없음 + +## 구현 잠금 + +- 상태: 해제 +- SDD: 불필요 +- SDD 문서: 없음 +- SDD 사유: 기존 OpenAI-compatible 요청/응답 계약 안에서 명시적 `tools[]` schema를 검증하고 같은 요청을 내부 재제출하는 bounded MVP로 제한하며, 새 proto/config/API field나 외부 계약 확장은 만들지 않는다. +- 잠금 해제 조건: 없음 +- 결정 필요: 없음 + +## 범위 + +- OpenAI-compatible Chat Completions 요청의 `tools[]`, `tool_choice` 기반 runtime validation contract 판정 +- native OpenAI `tool_calls`와 IOP text tool-call fallback 결과를 공통 `ToolCallCandidate` 형태로 해석 +- 요청 `tools[].function.parameters`를 기준으로 tool name, arguments JSON, required/type/object/array/enum/additionalProperties 중심의 JSON Schema subset 검증 +- 검증 실패 시 caller에게 응답을 내보내기 전 같은 `SubmitRunRequest`를 bounded exact replay로 재제출 +- non-stream 또는 buffered stream처럼 아직 caller에게 malformed 응답을 흘리지 않은 경로 우선 적용 +- retry attempt와 validation failure reason을 run metadata 또는 로그로 구분 가능하게 남기는 최소 추적 + +## 기능 + +### Epic: [runtime-validate] Runtime Tool Call Validation + +명시적 schema가 있는 요청에서 모델 응답의 tool call shape를 runtime이 결정적으로 검증하고 재시도하는 capability를 묶는다. + +- [ ] [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 경로를 테스트한다. 검증: `go test ./apps/edge/internal/openai` + +## 완료 리뷰 + +- 상태: 없음 +- 요청일: 없음 +- 완료 근거: 계획 Milestone이며 기능 Task가 아직 충족되지 않았다. +- 검토 항목: 기능 Task와 검증 통과, 구현 잠금 해제 유지, malformed tool call 비노출 확인 +- agent-ui 상태 반영: 해당 없음 +- 리뷰 코멘트: 없음 + +## 범위 제외 + +- `tools[]`가 없는 요청에서 tool 사용 필요 여부를 추론하는 validator 모델 +- agent-specific 자연어 또는 임의 텍스트를 tool call로 추정하는 휴리스틱 확장 +- caller agent까지 실패를 되돌려 repair instruction을 넣는 외부 loop +- live SSE로 이미 content를 방출한 뒤의 투명 재시도 +- 새 public API/config/proto field와 장기 ledger schema 확정 + +## 작업 컨텍스트 + +- 관련 경로: `apps/edge/internal/openai`, `apps/edge/internal/service`, `apps/node/internal/adapters/openai_compat`, `apps/node/internal/adapters/vllm`, `agent-contract/outer/openai-compatible-api.md` +- 표준선(선택): 명시적 `tools[]`가 있는 요청은 별도 validator 모델 없이 요청 schema를 source of truth로 삼는다. +- 표준선(선택): 재시도는 작업 전체 재실행이 아니라 IOP 내부 provider 응답 sampling의 bounded exact replay로 제한한다. +- 선행 작업: OpenAI-compatible tool call passthrough와 text fallback +- 후속 작업: Tool Call 판정 모델 Gate 리뷰, 요청 실행 로그와 Usage Ledger 기반 +- 확인 필요: 없음 diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-validator-model-gate-review.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-validator-model-gate-review.md new file mode 100644 index 0000000..79177b0 --- /dev/null +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-validator-model-gate-review.md @@ -0,0 +1,86 @@ +# Milestone: Tool Call 판정 모델 Gate 리뷰 + +## 위치 + +- Roadmap: `agent-roadmap/ROADMAP.md` +- Phase: `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md` + +## 목표 + +명시적 `tools[]` schema만으로 판정할 수 없는 tool-call 후보를 별도 validator 모델 또는 classifier 단계로 판정할지 검토한다. +자연어 설명, agent-specific 텍스트, schema 부재, tool 사용 필요성 추론처럼 runtime-only 검증이 확정적으로 처리할 수 없는 케이스를 사용자 리뷰 대상 결정으로 분리한다. +사용자가 gate 조건과 실패 처리 정책을 승인하기 전까지 구현 계획과 코드 구현을 시작하지 않는다. + +## 상태 + +[스케치] + +## 승격 조건 + +- [ ] validator 모델이 필요한 케이스와 runtime-only 검증으로 충분한 케이스의 경계를 확정한다. +- [ ] 모델 판정 결과를 advisory로 둘지 blocking gate로 둘지 결정한다. +- [ ] false positive/false negative가 발생했을 때 retry, 실패 응답, 원문 통과 중 어떤 정책을 적용할지 결정한다. +- [ ] validator 모델 사용 비용, latency, default enable 여부, 호출 표면을 결정한다. +- [ ] 사용자 리뷰를 통해 MVP 포함 여부와 후속 구현 Milestone 분리 방식을 확정한다. + +## 구현 잠금 + +- 상태: 잠금 +- SDD: 불필요 +- SDD 문서: 없음 +- SDD 사유: 현재 Milestone은 모델 판정 gate의 제품/정책 스케치이며, API/config/schema 구현으로 승격할 때 SDD 필요 여부를 재판정한다. +- 잠금 해제 조건: 아래 체크리스트 + - [ ] 아래 결정 필요 항목이 사용자 리뷰로 해소되어 있다. + - [ ] runtime-only Milestone과 겹치지 않는 구현 범위가 정리되어 있다. +- 결정 필요: 아래 체크리스트 + - [ ] 명시적 `tools[]`가 없는 요청에서 validator 모델을 호출할지 결정한다. + - [ ] 모델이 tool call 필요성, tool call 후보 여부, schema 위반 여부 중 무엇을 판정할지 결정한다. + - [ ] validator 모델 판정을 실패 처리의 source of truth로 삼을지 advisory signal로만 둘지 결정한다. + - [ ] 오판 시 caller agent에 미칠 영향과 원문 통과/차단/retry 기준을 결정한다. + - [ ] validator 모델 사용을 기본 활성화할지 opt-in metadata/config로 둘지 결정한다. + +## 범위 + +- `tools[]` 또는 명시 schema가 없는 요청의 tool-call 판정 후보 +- 자연어/텍스트 기반 tool call처럼 runtime이 deterministic하게 schema validation을 시작하기 어려운 케이스 분류 +- validator 모델의 입력, 출력, 신뢰도, 실패 처리 정책 후보 +- runtime-only exact replay retry와 모델-assisted gate의 책임 경계 +- 사용자 리뷰가 필요한 제품/비용/latency/오판 위험 결정 + +## 기능 + +### Epic: [model-gate] Model-Assisted Tool Call Gate + +runtime-only validation으로 해결되지 않는 애매한 tool-call 후보를 모델 판정 gate로 다룰지 검토하는 산출물을 묶는다. + +- [ ] [case-map] runtime-only 검증이 처리하지 않는 케이스와 validator 모델 후보 케이스가 분리되어 있다. +- [ ] [classifier-role] validator 모델이 판정할 역할과 출력 형태 후보가 정리되어 있다. +- [ ] [risk-policy] false positive/false negative, retry 반복, 원문 통과/차단 정책 후보가 정리되어 있다. +- [ ] [surface-policy] opt-in metadata, config, route policy, default off 여부 후보가 정리되어 있다. +- [ ] [user-review] 사용자가 모델 gate 범위, 비용/latency, 오판 정책을 검토했다. + +## 완료 리뷰 + +- 상태: 없음 +- 요청일: 없음 +- 완료 근거: 스케치 Milestone이며 기능 Task가 아직 충족되지 않았다. +- 검토 항목: 사용자 리뷰 통과, 잠금 해제 여부, 후속 구현 Milestone 분리 여부 +- agent-ui 상태 반영: 해당 없음 +- 리뷰 코멘트: 없음 + +## 범위 제외 + +- 명시적 `tools[]` schema가 있는 runtime-only validation/retry 구현 +- validator 모델 호출 코드 구현 +- OpenAI-compatible public API/config/proto 변경 +- 임의 자연어 tool inference를 기본 동작으로 켜는 결정 +- 비용/latency 측정 없이 default-on 정책을 확정하는 일 + +## 작업 컨텍스트 + +- 관련 경로: `apps/edge/internal/openai`, `apps/edge/internal/service`, `packages/go/policy`, `agent-contract/outer/openai-compatible-api.md` +- 표준선(선택): 명시 schema가 있는 요청은 runtime-only validator가 우선이며, 모델 gate는 schema가 없거나 출력 의도가 애매한 케이스에 한정해 검토한다. +- 표준선(선택): 모델 판정은 오판 가능성이 있으므로 사용자 승인 전에는 blocking source of truth로 삼지 않는다. +- 선행 작업: Tool Call Runtime 검증 재시도 MVP +- 후속 작업: 단계 호출과 검증 최적화 MVP 또는 별도 model-assisted validation 구현 Milestone +- 확인 필요: `구현 잠금 > 결정 필요` 항목 diff --git a/agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log b/agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log new file mode 100644 index 0000000..6be7a80 --- /dev/null +++ b/agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log @@ -0,0 +1,206 @@ + + +# Code Review Reference - API + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record only Milestone lock decisions in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-07-03 +task=m-tool-call-runtime-validation-retry, plan=0, tag=API + +## 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 + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G05.md` → `code_review_local_G05_N.log`, `PLAN-local-G05.md` → `plan_local_G05_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-tool-call-runtime-validation-retry/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Stream Validation Routing | [x] | +| [API-2] Buffered Stream Retry And Failure | [x] | +| [API-3] Final Validation Evidence | [x] | + +## 구현 체크리스트 + +- [x] 기존 non-stream validation 기반(`toolValidationContractFromRequest`, `validateToolCallResponse`, `completeChatCompletion` retry loop)을 보존하고 stream mode와 충돌하지 않게 정리한다. +- [x] live SSE는 `tools[]`가 있어도 이미 응답을 방출할 수 있는 경로로 명시적으로 validation/retry 대상에서 제외하고, validation attempt metadata를 남기지 않는다. +- [x] strict buffered stream은 tool call chunk를 쓰기 전에 candidate normalize/schema validation을 수행하고, 실패 시 같은 `SubmitRunRequest`를 최대 1회 재제출한다. +- [x] strict buffered stream retry 한도 초과나 재시도 불가 상태에서는 malformed `tool_calls` chunk를 성공으로 내보내지 않고 SSE error로 구분한다. +- [x] `apps/edge/internal/openai/server_test.go`에 buffered retry 성공, buffered retry 한도 초과 실패, live stream validation 제외 테스트를 추가/갱신하고 `go test -count=1 ./apps/edge/internal/openai`를 통과시킨다. +- [x] `go test -count=1 ./apps/edge/...`를 통과시킨다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- 검증 명령은 계획대로 실행했다. 대체 없음. +- API-2에서 `streamBufferedChatCompletion`이 `collectRunResult`를 직접 호출해 native/text tool call을 다시 조립하던 방식을 제거하고, non-stream 경로와 동일한 `collectChatCompletionOutput` 공유 helper를 재사용하도록 바꿨다. 계획이 제시한 "shared completion output helper 사용" 옵션을 택한 것이며, tool call normalize/finish reason 로직 중복을 없애고 non-stream과 stream buffered가 같은 후보 정규화·검증 입력을 쓰게 한다. +- role chunk 방출 위치를 옮겼다. 기존에는 `streamChatCompletion` 최상단에서 모든 stream mode에 role-only chunk를 먼저 썼다. 이제 live SSE는 종전대로 즉시 role chunk를 쓰지만, strict buffered stream은 collect/validation/retry가 성공한 뒤 `writeBufferedStreamOutput` 안에서 role chunk를 쓴다. 계획의 "role chunk는 validation-enabled buffered stream에서 collect/retry 이후에 쓴다" 허용 범위 안이며, retry로 run id가 바뀔 때 첫 chunk의 id가 최종 run과 어긋나는 문제를 막는다. +- `handleChatCompletions`의 stream 분기에 있던 `defer handle.Close()`를 제거하고 handle 종료 책임을 `streamChatCompletion`로 옮겼다. live 경로는 `defer handle.Close()`로, buffered 경로는 retry loop의 각 종료 지점에서 직접 닫는다. non-stream `completeChatCompletion`과 동일하게 retry 시 이전 handle을 명시적으로 닫기 위한 정리다. + +## 주요 설계 결정 + +- stream mode별 validation 계약은 `toolValidationContractForChatRequest(req, outputPolicy)` 하나로 판정한다. non-stream과 strict buffered stream은 base 계약을 유지(enabled)하고, live SSE(`stream:true`이며 strict buffered가 아님)는 disabled로 접어 attempt metadata를 붙이지 않는다. 기존 `toolValidationContractFromRequest`는 base 판정으로 보존한다. +- strict buffered stream retry는 non-stream과 같은 `maxToolValidationAttempts`(=2) 예산, 같은 `toolValidationAttemptMetadata`(attempt/retry_of/failure) 키, 같은 `SubmitRunRequest` exact replay를 공유한다. 첫 user-visible chunk를 쓰기 전에만 재제출하므로 exact replay 의미가 non-stream과 동일하다. +- 실패 표면은 SSE 안에서 성공 tool_calls chunk와 구분한다. `writeSSEError`를 `writeSSEErrorWithType`로 일반화해 retry 한도 초과는 `tool_validation_error`, 재제출 dispatch 실패는 `tool_validation_retry_error` error type으로 방출하고, malformed `tool_calls` chunk는 절대 쓰지 않는다. SSE는 항상 HTTP 200 + body 안 error 계약을 유지한다. +- edge domain 경계 유지: 모든 변경은 `apps/edge/internal/openai` 입력 표면 안에 있고 `service.SubmitRun`의 `adapter + target` dispatch 계약을 그대로 쓴다. node adapter, proto, config, 외부 계약은 건드리지 않는다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- live SSE가 validation attempt metadata나 retry를 암시하지 않는지 확인한다. +- strict buffered stream에서 invalid tool call chunk가 성공 SSE로 노출되지 않는지 확인한다. +- retry attempt metadata가 원 run id와 failure reason을 보존하는지 확인한다. +- `Roadmap Targets`의 여섯 task id가 테스트 evidence와 맞는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### API-1 중간 검증 +``` +$ go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletionsLiveStreamSkipsRuntimeToolValidation|TestChatCompletionsRetriesMalformedToolCallBeforeResponse' +ok iop/apps/edge/internal/openai 0.006s +``` +(별도 `-v` 실행: `TestChatCompletionsRetriesMalformedToolCallBeforeResponse` PASS, `TestChatCompletionsLiveStreamSkipsRuntimeToolValidation` PASS) + +### API-2 중간 검증 +``` +$ go test -count=1 ./apps/edge/internal/openai -run 'TestChatCompletionsStrictBufferedStream' +ok iop/apps/edge/internal/openai 0.005s +``` +(별도 `-v` 실행: `TestChatCompletionsStrictBufferedStreamRetriesMalformedToolCallBeforeChunk` PASS, `TestChatCompletionsStrictBufferedStreamFailsMalformedToolCallAfterRetryLimit` PASS) + +### API-3 중간 검증 +``` +$ go test -count=1 ./apps/edge/internal/openai +ok iop/apps/edge/internal/openai 1.567s +``` + +### 최종 검증 +``` +$ go test -count=1 ./apps/edge/internal/openai +ok iop/apps/edge/internal/openai 1.567s + +$ go test -count=1 ./apps/edge/... +ok iop/apps/edge/cmd/edge 0.039s +ok iop/apps/edge/internal/bootstrap 0.286s +ok iop/apps/edge/internal/configrefresh 0.020s +ok iop/apps/edge/internal/controlplane 4.448s +ok iop/apps/edge/internal/edgecmd 0.018s +ok iop/apps/edge/internal/edgevalidate 0.006s +ok iop/apps/edge/internal/events 0.004s +ok iop/apps/edge/internal/input 0.006s +ok iop/apps/edge/internal/input/a2a 0.007s +ok iop/apps/edge/internal/node 0.011s +ok iop/apps/edge/internal/openai 1.569s +ok iop/apps/edge/internal/opsconsole 0.006s +ok iop/apps/edge/internal/service 0.441s +ok iop/apps/edge/internal/transport 2.037s +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +Sections and their ownership: + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) | +| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS | +| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` -> `[x]` only | +| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` -> `[x]` only; final checkbox is mandatory before saving | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless a selected Milestone `구현 잠금 > 결정 필요` item blocks implementation; do not ask the user directly during implementation | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry | +| 코드리뷰 결과 | Review agent appends | Not included in stub | + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - implementation deviation: Pass + - verification trust: Pass + - spec conformance: Pass +- 발견된 문제: 없음 +- 리뷰 검증: + - `go test -count=1 ./apps/edge/internal/openai`: PASS (`ok iop/apps/edge/internal/openai 1.586s`) + - `go test -count=1 ./apps/edge/...`: PASS (`apps/edge/internal/openai` 포함 전체 edge package 통과) +- 다음 단계: PASS 종결 절차를 수행한다. `complete.log`를 작성하고 task directory를 `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/`로 이동한다. diff --git a/agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/complete.log b/agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/complete.log new file mode 100644 index 0000000..a09aac9 --- /dev/null +++ b/agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/complete.log @@ -0,0 +1,47 @@ +# Complete - m-tool-call-runtime-validation-retry + +## 완료 일시 + +2026-07-03 + +## 요약 + +Runtime tool call validation/retry MVP stream completion review completed in 1 loop with final verdict PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | non-stream validation preservation, live SSE exclusion, strict buffered stream retry/failure, and edge package verification passed | + +## 구현/정리 내용 + +- Added stream-aware runtime tool validation routing so non-stream and strict buffered stream validate tool calls, while live SSE remains excluded from exact retry metadata. +- Shared chat completion output collection between non-stream and strict buffered stream paths, then validated normalized native/text tool call candidates before emitting caller-visible responses. +- Added bounded exact replay metadata for validation retries and OpenAI-compatible/SSE failure surfaces for retry exhaustion or retry dispatch failure. +- Added package tests for non-stream retry/failure, `tool_choice: none` exclusion, live SSE exclusion, strict buffered stream retry/failure, and JSON Schema subset validation. + +## 최종 검증 + +- `go test -count=1 ./apps/edge/internal/openai` - PASS; `ok iop/apps/edge/internal/openai 1.586s` +- `go test -count=1 ./apps/edge/...` - PASS; edge package suite passed, including `iop/apps/edge/internal/openai 1.566s` + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/tool-call-runtime-validation-retry.md` +- Completed task ids: + - `contract-detect`: PASS; evidence=`agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log`, `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...` + - `candidate-normalize`: PASS; evidence=`agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log`, `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...` + - `schema-validate`: PASS; evidence=`agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log`, `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...` + - `exact-retry`: PASS; evidence=`agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log`, `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...` + - `failure-surface`: PASS; evidence=`agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log`, `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...` + - `validation-tests`: PASS; evidence=`agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log`, `agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/internal/openai`, `go test -count=1 ./apps/edge/...` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log b/agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log new file mode 100644 index 0000000..3ca5f34 --- /dev/null +++ b/agent-task/archive/2026/07/m-tool-call-runtime-validation-retry/plan_local_G05_0.log @@ -0,0 +1,239 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 4e5acc7..1e35d46 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -70,32 +70,23 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { zap.Any("input_keys", mapKeys(input)), ) - handle, err := s.service.SubmitRun(r.Context(), edgeservice.SubmitRunRequest{ - NodeRef: dispatch.NodeRef, - ModelGroupKey: strings.TrimSpace(req.Model), - Adapter: dispatch.Adapter, - Target: dispatch.Target, - SessionID: dispatch.SessionID, - Workspace: workspace, - Prompt: prompt, - Input: input, - TimeoutSec: dispatch.TimeoutSec, - MaxQueue: dispatch.MaxQueue, - QueueTimeoutMS: dispatch.QueueTimeoutMS, - Metadata: chatRunMetadata(runMeta, req, outputPolicy), - ProviderPool: dispatch.ProviderPool, - }) + validation := toolValidationContractForChatRequest(req, outputPolicy) + metadata := chatRunMetadata(runMeta, req, outputPolicy) + if validation.enabled { + metadata = toolValidationAttemptMetadata(metadata, 1, "", "") + } + submitReq := chatSubmitRunRequest(dispatch, req, workspace, prompt, input, metadata) + handle, err := s.service.SubmitRun(r.Context(), submitReq) if err != nil { writeError(w, http.StatusBadGateway, "node_dispatch_error", err.Error()) return } - defer handle.Close() if req.Stream { - s.streamChatCompletion(w, r, req, handle, outputPolicy) + s.streamChatCompletion(w, r, req, submitReq, handle, outputPolicy, validation) return } - s.completeChatCompletion(w, r, req, handle, outputPolicy) + s.completeChatCompletion(w, r, req, submitReq, handle, outputPolicy, validation) } func decodeChatCompletionRequest(dec *json.Decoder, req *chatCompletionRequest) error { @@ -158,51 +149,138 @@ func shouldSynthesizeTextToolCalls(dispatch edgeservice.RunDispatch, textToolFal return textToolFallback || strings.TrimSpace(dispatch.Adapter) == "cli" } -func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) { - text, reasoning, finishReason, nativeToolCalls, usage, textToolFallback, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout()) - if err != nil { - s.cancelRunOnHTTPGiveUp(handle.Dispatch(), err) - writeError(w, httpStatusForRunError(err), "run_error", err.Error()) +func chatSubmitRunRequest(dispatch routeDispatch, req chatCompletionRequest, workspace, prompt string, input map[string]any, metadata map[string]string) edgeservice.SubmitRunRequest { + return edgeservice.SubmitRunRequest{ + NodeRef: dispatch.NodeRef, + ModelGroupKey: strings.TrimSpace(req.Model), + Adapter: dispatch.Adapter, + Target: dispatch.Target, + SessionID: dispatch.SessionID, + Workspace: workspace, + Prompt: prompt, + Input: input, + TimeoutSec: dispatch.TimeoutSec, + MaxQueue: dispatch.MaxQueue, + QueueTimeoutMS: dispatch.QueueTimeoutMS, + Metadata: metadata, + ProviderPool: dispatch.ProviderPool, + } +} + +func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, submitReq edgeservice.SubmitRunRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy, validation toolValidationContract) { + attempt := 1 + for { + result, err := collectChatCompletionOutput(r.Context(), req, handle, outputPolicy) + if err != nil { + s.cancelRunOnHTTPGiveUp(handle.Dispatch(), err) + handle.Close() + writeError(w, httpStatusForRunError(err), "run_error", err.Error()) + return + } + if err := validateToolCallResponse(validation, result.toolCalls, result.toolCallOrigin); err != nil { + failedRunID := handle.Dispatch().RunID + if attempt < maxToolValidationAttempts { + handle.Close() + attempt++ + retryReq := submitReq + retryReq.Metadata = toolValidationAttemptMetadata(submitReq.Metadata, attempt, failedRunID, err.Error()) + s.logger.Warn("openai chat completion tool validation retry", + zap.String("run_id", failedRunID), + zap.Int("attempt", attempt), + zap.String("reason", err.Error()), + ) + next, submitErr := s.service.SubmitRun(r.Context(), retryReq) + if submitErr != nil { + writeError(w, http.StatusBadGateway, "tool_validation_retry_error", submitErr.Error()) + return + } + handle = next + continue + } + handle.Close() + s.logger.Warn("openai chat completion tool validation failed", + zap.String("run_id", failedRunID), + zap.Int("attempt", attempt), + zap.String("reason", err.Error()), + ) + writeError(w, http.StatusBadGateway, "tool_validation_error", err.Error()) + return + } + handle.Close() + s.logger.Info("openai chat completion output", + zap.String("run_id", handle.Dispatch().RunID), + zap.Bool("strict_output", outputPolicy.Strict), + zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer), + zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool), + zap.Bool("normalized", result.normalized), + zap.Int("content_len", result.contentLen), + zap.Int("reasoning_len", result.reasoningLen), + zap.String("content_preview", previewString(result.message.Content, 1000)), + ) + created := time.Now().Unix() + writeJSON(w, http.StatusOK, chatCompletionResponse{ + ID: "chatcmpl-" + handle.Dispatch().RunID, + Object: "chat.completion", + Created: created, + Model: responseModel(req.Model, handle.Dispatch().Target), + Choices: []chatCompletionChoice{{ + Index: 0, + Message: result.message, + FinishReason: result.finishReason, + }}, + Usage: result.usage, + }) return } +} + +type chatCompletionOutput struct { + message chatMessage + finishReason string + usage *openAIUsage + normalized bool + contentLen int + reasoningLen int + toolCalls []any + toolCallOrigin string +} + +func collectChatCompletionOutput(ctx context.Context, req chatCompletionRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) (chatCompletionOutput, error) { + text, reasoning, finishReason, nativeToolCalls, usage, textToolFallback, err := collectRunResult(ctx, handle.Stream(), handle.WaitTimeout()) + if err != nil { + return chatCompletionOutput{}, err + } text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning) message := chatMessage{Role: "assistant", Content: text, ReasoningContent: reasoning} + var toolCalls []any + toolCallOrigin := "" if len(nativeToolCalls) > 0 { nativeToolCalls = normalizeNativeToolCallsForResponse(nativeToolCalls, req.Tools) message.ToolCalls = nativeToolCalls + toolCalls = nativeToolCalls + toolCallOrigin = toolCallOriginNative finishReason = "tool_calls" } else if shouldSynthesizeTextToolCalls(handle.Dispatch(), textToolFallback) { - cleaned, toolCalls := synthesizeToolCallsFromText(text, req.Tools, handle.Dispatch().RunID) - if len(toolCalls) > 0 { + cleaned, synthesizedToolCalls := synthesizeToolCallsFromText(text, req.Tools, handle.Dispatch().RunID) + if len(synthesizedToolCalls) > 0 { message.Content = cleaned message.ReasoningContent = "" - message.ToolCalls = toolCalls + message.ToolCalls = synthesizedToolCalls + toolCalls = synthesizedToolCalls + toolCallOrigin = toolCallOriginText finishReason = "tool_calls" } } - s.logger.Info("openai chat completion output", - zap.String("run_id", handle.Dispatch().RunID), - zap.Bool("strict_output", outputPolicy.Strict), - zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer), - zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool), - zap.Bool("normalized", normalized), - zap.Int("content_len", len(text)), - zap.Int("reasoning_len", len(reasoning)), - zap.String("content_preview", previewString(text, 1000)), - ) - created := time.Now().Unix() - writeJSON(w, http.StatusOK, chatCompletionResponse{ - ID: "chatcmpl-" + handle.Dispatch().RunID, - Object: "chat.completion", - Created: created, - Model: responseModel(req.Model, handle.Dispatch().Target), - Choices: []chatCompletionChoice{{ - Index: 0, - Message: message, - FinishReason: finishReason, - }}, - Usage: usage, - }) + return chatCompletionOutput{ + message: message, + finishReason: finishReason, + usage: usage, + normalized: normalized, + contentLen: len(text), + reasoningLen: len(reasoning), + toolCalls: toolCalls, + toolCallOrigin: toolCallOrigin, + }, nil } func (s *Server) resolveAdapter() string { diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 99d950e..4469885 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -18,10 +19,14 @@ import ( type fakeRunService struct { req edgeservice.SubmitRunRequest + reqs []edgeservice.SubmitRunRequest ollamaReq edgeservice.OllamaAPIRequest ollamaResp edgeservice.OllamaAPIView events chan *iop.RunEvent + eventRuns []chan *iop.RunEvent + runIDs []string + submitMu sync.Mutex cancelMu sync.Mutex cancelCalls []edgeservice.CancelRunRequest cancelErr error @@ -43,6 +48,12 @@ func (s *fakeRunService) cancelCallsSnapshot() []edgeservice.CancelRunRequest { return append([]edgeservice.CancelRunRequest(nil), s.cancelCalls...) } +func (s *fakeRunService) reqsSnapshot() []edgeservice.SubmitRunRequest { + s.submitMu.Lock() + defer s.submitMu.Unlock() + return append([]edgeservice.SubmitRunRequest(nil), s.reqs...) +} + func TestRoutesRequireBearerTokenWhenConfigured(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{ BearerToken: "secret-token", @@ -87,10 +98,24 @@ func TestHealthzDoesNotRequireBearerToken(t *testing.T) { } func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) { + s.submitMu.Lock() s.req = req + s.reqs = append(s.reqs, req) + attempt := len(s.reqs) + events := s.events + if attempt-1 < len(s.eventRuns) { + events = s.eventRuns[attempt-1] + } + runID := "run-test" + if attempt-1 < len(s.runIDs) { + runID = s.runIDs[attempt-1] + } else if attempt > 1 { + runID = fmt.Sprintf("run-test-%d", attempt) + } + s.submitMu.Unlock() return &edgeservice.RunHandle{ RunDispatch: edgeservice.RunDispatch{ - RunID: "run-test", + RunID: runID, ModelGroupKey: req.ModelGroupKey, Adapter: req.Adapter, Target: req.Target, @@ -98,7 +123,7 @@ func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunR TimeoutSec: 5, }, RunStream: edgeservice.RunStream{ - Events: s.events, + Events: events, NodeEvents: make(chan *iop.EdgeNodeEvent), }, }, nil @@ -112,6 +137,14 @@ func (s *fakeRunService) OllamaAPI(_ context.Context, req edgeservice.OllamaAPIR return s.ollamaResp, nil } +func bufferedRunEvents(events ...*iop.RunEvent) chan *iop.RunEvent { + ch := make(chan *iop.RunEvent, len(events)) + for _, event := range events { + ch <- event + } + return ch +} + func TestChatCompletionsDispatchesConfiguredOllamaTarget(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello"} @@ -639,6 +672,351 @@ func TestChatCompletionsNormalizesNativeToolCallArgumentsForProviderRoute(t *tes } } +func TestChatCompletionsRetriesMalformedToolCallBeforeResponse(t *testing.T) { + fake := &fakeRunService{ + eventRuns: []chan *iop.RunEvent{ + bufferedRunEvents(&iop.RunEvent{ + Type: "complete", + Metadata: map[string]string{ + "finish_reason": "tool_calls", + runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"git status\"}"}}]`, + }, + }), + bufferedRunEvents(&iop.RunEvent{ + Type: "complete", + Metadata: map[string]string{ + "finish_reason": "tool_calls", + runtimeMetadataOpenAIToolCalls: `[{"id":"call_good","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`, + }, + }), + }, + runIDs: []string{"run-bad", "run-good"}, + } + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"qwen3.6:35b", + "messages":[{"role":"user","content":"status"}], + "tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}], + "tool_choice":"auto" + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + reqs := fake.reqsSnapshot() + if len(reqs) != 2 { + t.Fatalf("submit attempts: got %d want 2", len(reqs)) + } + if reqs[0].Metadata[runtimeMetadataToolValidationAttempt] != "1" { + t.Fatalf("first attempt metadata: %+v", reqs[0].Metadata) + } + if reqs[1].Metadata[runtimeMetadataToolValidationAttempt] != "2" || reqs[1].Metadata[runtimeMetadataToolValidationRetryOf] != "run-bad" { + t.Fatalf("retry metadata: %+v", reqs[1].Metadata) + } + if !strings.Contains(reqs[1].Metadata[runtimeMetadataToolValidationFailure], "$.arguments.commands type string") { + t.Fatalf("retry failure reason: %+v", reqs[1].Metadata) + } + + var resp chatCompletionResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.ID != "chatcmpl-run-good" { + t.Fatalf("response id: got %q", resp.ID) + } + call := resp.Choices[0].Message.ToolCalls[0].(map[string]any) + if call["id"] != "call_good" { + t.Fatalf("tool call id: got %+v", call["id"]) + } +} + +func TestChatCompletionsFailsMalformedToolCallAfterRetryLimit(t *testing.T) { + invalidComplete := func(id string) *iop.RunEvent { + return &iop.RunEvent{ + Type: "complete", + Metadata: map[string]string{ + "finish_reason": "tool_calls", + runtimeMetadataOpenAIToolCalls: fmt.Sprintf(`[{"id":%q,"type":"function","function":{"name":"run_commands","arguments":"{\"unexpected\":true}"}}]`, id), + }, + } + } + fake := &fakeRunService{ + eventRuns: []chan *iop.RunEvent{ + bufferedRunEvents(invalidComplete("call_bad_1")), + bufferedRunEvents(invalidComplete("call_bad_2")), + }, + runIDs: []string{"run-bad-1", "run-bad-2"}, + } + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"qwen3.6:35b", + "messages":[{"role":"user","content":"status"}], + "tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}], + "tool_choice":{"type":"function","function":{"name":"run_commands"}} + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusBadGateway { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"type":"tool_validation_error"`) || !strings.Contains(w.Body.String(), "$.arguments.commands is required") { + t.Fatalf("expected tool validation error body, got %s", w.Body.String()) + } + if reqs := fake.reqsSnapshot(); len(reqs) != 2 { + t.Fatalf("submit attempts: got %d want 2", len(reqs)) + } +} + +func TestChatCompletionsToolChoiceNoneSkipsRuntimeToolValidation(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)} + fake.events <- &iop.RunEvent{ + Type: "complete", + Metadata: map[string]string{ + "finish_reason": "tool_calls", + runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"unknown_tool","arguments":"not-json"}}]`, + }, + } + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"qwen3.6:35b", + "messages":[{"role":"user","content":"status"}], + "tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"]}}}], + "tool_choice":"none" + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + reqs := fake.reqsSnapshot() + if len(reqs) != 1 { + t.Fatalf("submit attempts: got %d want 1", len(reqs)) + } + if _, ok := reqs[0].Metadata[runtimeMetadataToolValidationAttempt]; ok { + t.Fatalf("tool_choice none should not enable validation metadata: %+v", reqs[0].Metadata) + } +} + +func TestChatCompletionsLiveStreamSkipsRuntimeToolValidation(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)} + fake.events <- &iop.RunEvent{ + Type: "complete", + Metadata: map[string]string{ + "finish_reason": "tool_calls", + runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"git status\"}"}}]`, + }, + } + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"qwen3.6:35b", + "stream":true, + "messages":[{"role":"user","content":"status"}], + "tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}], + "tool_choice":"auto" + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + reqs := fake.reqsSnapshot() + if len(reqs) != 1 { + t.Fatalf("submit attempts: got %d want 1 (live stream must not retry)", len(reqs)) + } + if _, ok := reqs[0].Metadata[runtimeMetadataToolValidationAttempt]; ok { + t.Fatalf("live stream should not attach validation attempt metadata: %+v", reqs[0].Metadata) + } + body := w.Body.String() + if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"finish_reason":"tool_calls"`) || !strings.Contains(body, "data: [DONE]") { + t.Fatalf("live stream did not preserve existing tool_calls semantics:\n%s", body) + } +} + +func TestChatCompletionsStrictBufferedStreamRetriesMalformedToolCallBeforeChunk(t *testing.T) { + fake := &fakeRunService{ + eventRuns: []chan *iop.RunEvent{ + bufferedRunEvents(&iop.RunEvent{ + Type: "complete", + Metadata: map[string]string{ + "finish_reason": "tool_calls", + runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"git status\"}"}}]`, + }, + }), + bufferedRunEvents(&iop.RunEvent{ + Type: "complete", + Metadata: map[string]string{ + "finish_reason": "tool_calls", + runtimeMetadataOpenAIToolCalls: `[{"id":"call_good","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`, + }, + }), + }, + runIDs: []string{"run-bad", "run-good"}, + } + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat", StrictOutput: true, StrictStreamBuffer: true}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"qwen3.6:35b", + "stream":true, + "messages":[{"role":"user","content":"status"}], + "tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}], + "tool_choice":"auto" + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + reqs := fake.reqsSnapshot() + if len(reqs) != 2 { + t.Fatalf("submit attempts: got %d want 2", len(reqs)) + } + if reqs[0].Metadata[runtimeMetadataToolValidationAttempt] != "1" { + t.Fatalf("first attempt metadata: %+v", reqs[0].Metadata) + } + if reqs[1].Metadata[runtimeMetadataToolValidationAttempt] != "2" || reqs[1].Metadata[runtimeMetadataToolValidationRetryOf] != "run-bad" { + t.Fatalf("retry metadata: %+v", reqs[1].Metadata) + } + body := w.Body.String() + if strings.Contains(body, "call_bad") { + t.Fatalf("buffered stream leaked invalid tool call before retry:\n%s", body) + } + if !strings.Contains(body, "call_good") || !strings.Contains(body, `"finish_reason":"tool_calls"`) || !strings.Contains(body, "data: [DONE]") { + t.Fatalf("buffered stream did not emit validated tool call:\n%s", body) + } +} + +func TestChatCompletionsStrictBufferedStreamFailsMalformedToolCallAfterRetryLimit(t *testing.T) { + invalidComplete := func(id string) *iop.RunEvent { + return &iop.RunEvent{ + Type: "complete", + Metadata: map[string]string{ + "finish_reason": "tool_calls", + runtimeMetadataOpenAIToolCalls: fmt.Sprintf(`[{"id":%q,"type":"function","function":{"name":"run_commands","arguments":"{\"unexpected\":true}"}}]`, id), + }, + } + } + fake := &fakeRunService{ + eventRuns: []chan *iop.RunEvent{ + bufferedRunEvents(invalidComplete("call_bad_1")), + bufferedRunEvents(invalidComplete("call_bad_2")), + }, + runIDs: []string{"run-bad-1", "run-bad-2"}, + } + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat", StrictOutput: true, StrictStreamBuffer: true}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ + "model":"qwen3.6:35b", + "stream":true, + "messages":[{"role":"user","content":"status"}], + "tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}], + "tool_choice":{"type":"function","function":{"name":"run_commands"}} + }`)) + w := httptest.NewRecorder() + + srv.handleChatCompletions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, `"type":"tool_validation_error"`) || !strings.Contains(body, "$.arguments.commands is required") { + t.Fatalf("expected tool_validation_error SSE body, got:\n%s", body) + } + if strings.Contains(body, "call_bad") { + t.Fatalf("buffered stream leaked invalid tool call chunk after retry limit:\n%s", body) + } + if !strings.Contains(body, "data: [DONE]") { + t.Fatalf("buffered stream error should terminate with [DONE]:\n%s", body) + } + if reqs := fake.reqsSnapshot(); len(reqs) != 2 { + t.Fatalf("submit attempts: got %d want 2", len(reqs)) + } +} + +func TestValidateJSONSchemaSubsetCoversObjectArrayEnumAdditionalProperties(t *testing.T) { + schema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "mode": map[string]any{ + "type": "string", + "enum": []any{"read", "write"}, + }, + "commands": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + }, + "options": map[string]any{ + "type": "object", + "properties": map[string]any{ + "dry_run": map[string]any{"type": "boolean"}, + }, + "additionalProperties": false, + }, + }, + "required": []any{"mode", "commands"}, + "additionalProperties": false, + } + + valid := map[string]any{ + "mode": "read", + "commands": []any{"git status"}, + "options": map[string]any{"dry_run": true}, + } + if err := validateJSONSchemaSubset(valid, schema, "$.arguments"); err != nil { + t.Fatalf("valid schema rejected: %v", err) + } + + cases := []struct { + name string + args map[string]any + want string + }{ + { + name: "enum", + args: map[string]any{"mode": "delete", "commands": []any{"git status"}}, + want: "$.arguments.mode value", + }, + { + name: "array item type", + args: map[string]any{"mode": "read", "commands": []any{1}}, + want: "$.arguments.commands[0] type integer does not match schema type string", + }, + { + name: "additional properties", + args: map[string]any{"mode": "read", "commands": []any{"git status"}, "extra": true}, + want: "$.arguments.extra is not allowed", + }, + { + name: "nested additional properties", + args: map[string]any{"mode": "read", "commands": []any{"git status"}, "options": map[string]any{"dry_run": true, "trace": true}}, + want: "$.arguments.options.trace is not allowed", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateJSONSchemaSubset(tc.args, schema, "$.arguments") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error got %v, want substring %q", err, tc.want) + } + }) + } +} + func TestSynthesizesTemplateToolCallConvertsCommandObjectsToShellStrings(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'command -v git && echo \"found\" || echo \"not found\"', 'runInTerminal': True}])}}" tools := []any{map[string]any{ @@ -2206,7 +2584,7 @@ func TestStreamChatCompletionTimeoutSendsCancelRun(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) w := httptest.NewRecorder() - srv.streamChatCompletion(w, req, chatCompletionRequest{Model: "llama3"}, handle, strictOutputPolicy{}) + srv.streamChatCompletion(w, req, chatCompletionRequest{Model: "llama3"}, edgeservice.SubmitRunRequest{}, handle, strictOutputPolicy{}, toolValidationContract{}) calls := fake.cancelCallsSnapshot() if len(calls) != 1 { diff --git a/apps/edge/internal/openai/stream.go b/apps/edge/internal/openai/stream.go index c0ae293..3be0496 100644 --- a/apps/edge/internal/openai/stream.go +++ b/apps/edge/internal/openai/stream.go @@ -13,9 +13,10 @@ import ( edgeservice "iop/apps/edge/internal/service" ) -func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy) { +func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, submitReq edgeservice.SubmitRunRequest, handle edgeservice.RunResult, outputPolicy strictOutputPolicy, validation toolValidationContract) { flusher, ok := w.(http.Flusher) if !ok { + handle.Close() writeError(w, http.StatusInternalServerError, "streaming_not_supported", "response writer does not support streaming") return } @@ -23,6 +24,17 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") + // Strict buffered streams collect and validate the full response before + // emitting any user-visible chunk, so they own handle lifecycle, retries, + // and the initial role chunk internally. + if outputPolicy.Strict && outputPolicy.StreamBuffer { + s.streamBufferedChatCompletion(w, r, req, submitReq, handle, flusher, outputPolicy, validation) + return + } + + // Live SSE may emit content deltas before the terminal event, so runtime + // tool validation is excluded upstream; write the role chunk immediately. + defer handle.Close() created := time.Now().Unix() id := "chatcmpl-" + handle.Dispatch().RunID model := responseModel(req.Model, handle.Dispatch().Target) @@ -37,11 +49,6 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re }}, }) - if outputPolicy.Strict && outputPolicy.StreamBuffer { - s.streamBufferedChatCompletion(w, r, handle, id, created, model, flusher, outputPolicy, req.Tools) - return - } - var contentBuilder strings.Builder var reasoningBuilder strings.Builder var emittedContent strings.Builder @@ -301,100 +308,88 @@ func writeToolCallsDeltaSSE(w http.ResponseWriter, flusher http.Flusher, id stri }) } -func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Request, handle edgeservice.RunResult, id string, created int64, model string, flusher http.Flusher, outputPolicy strictOutputPolicy, tools []any) { - text, reasoning, finishReason, nativeToolCalls, _, textToolFallback, err := collectRunResult(r.Context(), handle.Stream(), handle.WaitTimeout()) - if err != nil { - s.cancelRunOnHTTPGiveUp(handle.Dispatch(), err) - writeSSEError(w, flusher, err.Error()) +// streamBufferedChatCompletion collects the full run output, validates tool +// calls against the request schema before emitting anything, and — when +// validation fails on a run that has not yet written a user-visible chunk — +// resubmits the same request as a bounded exact replay. Only a validated (or +// validation-disabled) result reaches the SSE writer; a malformed tool call +// that survives the retry budget is surfaced as an SSE error instead of a +// successful tool_calls chunk. +func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Request, req chatCompletionRequest, submitReq edgeservice.SubmitRunRequest, handle edgeservice.RunResult, flusher http.Flusher, outputPolicy strictOutputPolicy, validation toolValidationContract) { + attempt := 1 + for { + result, err := collectChatCompletionOutput(r.Context(), req, handle, outputPolicy) + if err != nil { + s.cancelRunOnHTTPGiveUp(handle.Dispatch(), err) + handle.Close() + writeSSEError(w, flusher, err.Error()) + return + } + if verr := validateToolCallResponse(validation, result.toolCalls, result.toolCallOrigin); verr != nil { + failedRunID := handle.Dispatch().RunID + if attempt < maxToolValidationAttempts { + handle.Close() + attempt++ + retryReq := submitReq + retryReq.Metadata = toolValidationAttemptMetadata(submitReq.Metadata, attempt, failedRunID, verr.Error()) + s.logger.Warn("openai chat completion stream tool validation retry", + zap.String("run_id", failedRunID), + zap.Int("attempt", attempt), + zap.String("reason", verr.Error()), + ) + next, submitErr := s.service.SubmitRun(r.Context(), retryReq) + if submitErr != nil { + writeSSEErrorWithType(w, flusher, "tool_validation_retry_error", submitErr.Error()) + return + } + handle = next + continue + } + handle.Close() + s.logger.Warn("openai chat completion stream tool validation failed", + zap.String("run_id", failedRunID), + zap.Int("attempt", attempt), + zap.String("reason", verr.Error()), + ) + writeSSEErrorWithType(w, flusher, "tool_validation_error", verr.Error()) + return + } + handle.Close() + s.writeBufferedStreamOutput(w, flusher, req, handle.Dispatch(), result, outputPolicy) return } - text, reasoning, normalized := normalizeCompletionOutput(outputPolicy, text, reasoning) +} + +// writeBufferedStreamOutput emits a validated buffered result as SSE chunks: +// the role chunk, an optional content chunk, an optional tool_calls chunk, and +// the terminal finish chunk. +func (s *Server) writeBufferedStreamOutput(w http.ResponseWriter, flusher http.Flusher, req chatCompletionRequest, dispatch edgeservice.RunDispatch, result chatCompletionOutput, outputPolicy strictOutputPolicy) { + created := time.Now().Unix() + id := "chatcmpl-" + dispatch.RunID + model := responseModel(req.Model, dispatch.Target) + content := result.message.Content s.logger.Info("openai chat completion stream closed", - zap.String("run_id", handle.Dispatch().RunID), + zap.String("run_id", dispatch.RunID), zap.Bool("strict_output", outputPolicy.Strict), zap.Bool("strict_stream_buffer", outputPolicy.StreamBuffer), zap.String("xml_completion_tool", outputPolicy.XMLCompletionTool), - zap.Bool("normalized", normalized), - zap.Int("content_len", len(text)), - zap.Int("reasoning_len", len(reasoning)), - zap.String("content_preview", previewString(text, 1000)), - zap.String("reasoning_preview", previewString(reasoning, 1000)), + zap.Bool("normalized", result.normalized), + zap.Int("content_len", len(content)), + zap.Int("reasoning_len", result.reasoningLen), + zap.String("content_preview", previewString(content, 1000)), ) - if len(nativeToolCalls) > 0 { - nativeToolCalls = normalizeNativeToolCallsForResponse(nativeToolCalls, tools) - if text != "" { - writeSSE(w, flusher, chatCompletionChunk{ - ID: id, - Object: "chat.completion.chunk", - Created: created, - Model: model, - Choices: []chatCompletionChunkChoice{{ - Index: 0, - Delta: chatDelta{Content: text}, - }}, - }) - } - writeSSE(w, flusher, chatCompletionChunk{ - ID: id, - Object: "chat.completion.chunk", - Created: created, - Model: model, - Choices: []chatCompletionChunkChoice{{ - Index: 0, - Delta: chatDelta{ToolCalls: toolCallsForStreamDelta(nativeToolCalls)}, - }}, - }) - finishReason = "tool_calls" - } else if shouldSynthesizeTextToolCalls(handle.Dispatch(), textToolFallback) { - cleaned, toolCalls := synthesizeToolCallsFromText(text, tools, handle.Dispatch().RunID) - if len(toolCalls) > 0 { - if cleaned != "" { - writeSSE(w, flusher, chatCompletionChunk{ - ID: id, - Object: "chat.completion.chunk", - Created: created, - Model: model, - Choices: []chatCompletionChunkChoice{{ - Index: 0, - Delta: chatDelta{Content: cleaned}, - }}, - }) - } - writeSSE(w, flusher, chatCompletionChunk{ - ID: id, - Object: "chat.completion.chunk", - Created: created, - Model: model, - Choices: []chatCompletionChunkChoice{{ - Index: 0, - Delta: chatDelta{ToolCalls: toolCallsForStreamDelta(toolCalls)}, - }}, - }) - finishReason = "tool_calls" - } else if text != "" { - writeSSE(w, flusher, chatCompletionChunk{ - ID: id, - Object: "chat.completion.chunk", - Created: created, - Model: model, - Choices: []chatCompletionChunkChoice{{ - Index: 0, - Delta: chatDelta{Content: text}, - }}, - }) - } - } else if text != "" { - writeSSE(w, flusher, chatCompletionChunk{ - ID: id, - Object: "chat.completion.chunk", - Created: created, - Model: model, - Choices: []chatCompletionChunkChoice{{ - Index: 0, - Delta: chatDelta{Content: text}, - }}, - }) - } + writeSSE(w, flusher, chatCompletionChunk{ + ID: id, + Object: "chat.completion.chunk", + Created: created, + Model: model, + Choices: []chatCompletionChunkChoice{{ + Index: 0, + Delta: chatDelta{Role: "assistant"}, + }}, + }) + writeContentDeltaSSE(w, flusher, id, created, model, content) + writeToolCallsDeltaSSE(w, flusher, id, created, model, result.toolCalls) writeSSE(w, flusher, chatCompletionChunk{ ID: id, Object: "chat.completion.chunk", @@ -403,7 +398,7 @@ func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Req Choices: []chatCompletionChunkChoice{{ Index: 0, Delta: chatDelta{}, - FinishReason: finishReason, + FinishReason: result.finishReason, }}, }) fmt.Fprint(w, "data: [DONE]\n\n") @@ -420,7 +415,11 @@ func writeSSE(w http.ResponseWriter, flusher http.Flusher, v any) { } func writeSSEError(w http.ResponseWriter, flusher http.Flusher, message string) { - writeSSE(w, flusher, errorResponse{Error: errorBody{Type: "run_error", Message: message}}) + writeSSEErrorWithType(w, flusher, "run_error", message) +} + +func writeSSEErrorWithType(w http.ResponseWriter, flusher http.Flusher, errType, message string) { + writeSSE(w, flusher, errorResponse{Error: errorBody{Type: errType, Message: message}}) fmt.Fprint(w, "data: [DONE]\n\n") flusher.Flush() } diff --git a/apps/edge/internal/openai/tool_validation.go b/apps/edge/internal/openai/tool_validation.go new file mode 100644 index 0000000..7d114e4 --- /dev/null +++ b/apps/edge/internal/openai/tool_validation.go @@ -0,0 +1,410 @@ +package openai + +import ( + "encoding/json" + "fmt" + "math" + "sort" + "strings" +) + +const ( + maxToolValidationAttempts = 2 + + runtimeMetadataToolValidationAttempt = "openai_tool_validation_attempt" + runtimeMetadataToolValidationRetryOf = "openai_tool_validation_retry_of" + runtimeMetadataToolValidationFailure = "openai_tool_validation_failure" + + toolCallOriginNative = "native" + toolCallOriginText = "text" +) + +type toolValidationContract struct { + enabled bool + specs map[string]textToolSpec +} + +type toolCallCandidate struct { + name string + arguments any + origin string + raw any +} + +func toolValidationContractFromRequest(req chatCompletionRequest) toolValidationContract { + if len(req.Tools) == 0 || toolChoiceDisablesToolValidation(req.ToolChoice) { + return toolValidationContract{} + } + specs := requestedToolSpecs(req.Tools) + if len(specs) == 0 { + return toolValidationContract{} + } + return toolValidationContract{enabled: true, specs: specs} +} + +// toolValidationContractForChatRequest resolves the runtime tool validation +// contract for a request, accounting for its stream mode. Non-stream and +// strict buffered stream responses validate tool calls before emitting any +// user-visible payload, so validation stays enabled. Live SSE streams may +// already emit content deltas before the terminal event, so exact retry is not +// possible and runtime validation is explicitly excluded. +func toolValidationContractForChatRequest(req chatCompletionRequest, outputPolicy strictOutputPolicy) toolValidationContract { + contract := toolValidationContractFromRequest(req) + if !contract.enabled || !req.Stream { + return contract + } + if outputPolicy.Strict && outputPolicy.StreamBuffer { + return contract + } + return toolValidationContract{} +} + +func toolChoiceDisablesToolValidation(toolChoice any) bool { + choice, ok := toolChoice.(string) + return ok && strings.EqualFold(strings.TrimSpace(choice), "none") +} + +func toolValidationAttemptMetadata(metadata map[string]string, attempt int, retryOf, failureReason string) map[string]string { + out := make(map[string]string, len(metadata)+3) + for key, value := range metadata { + out[key] = value + } + out[runtimeMetadataToolValidationAttempt] = fmt.Sprintf("%d", attempt) + if strings.TrimSpace(retryOf) != "" { + out[runtimeMetadataToolValidationRetryOf] = retryOf + } + if strings.TrimSpace(failureReason) != "" { + out[runtimeMetadataToolValidationFailure] = failureReason + } + return out +} + +func validateToolCallResponse(contract toolValidationContract, toolCalls []any, origin string) error { + if !contract.enabled || len(toolCalls) == 0 { + return nil + } + candidates, err := toolCallCandidatesFromResponse(toolCalls, origin) + if err != nil { + return err + } + if len(candidates) == 0 { + return nil + } + return validateToolCallCandidate(contract, candidates[len(candidates)-1]) +} + +func toolCallCandidatesFromResponse(toolCalls []any, origin string) ([]toolCallCandidate, error) { + candidates := make([]toolCallCandidate, 0, len(toolCalls)) + for i, raw := range toolCalls { + call, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("tool call %d is not an object", i) + } + fn, ok := call["function"].(map[string]any) + if !ok { + return nil, fmt.Errorf("tool call %d function is not an object", i) + } + name, _ := fn["name"].(string) + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("tool call %d function.name is required", i) + } + arguments, err := parseToolCallArguments(fn["arguments"]) + if err != nil { + return nil, fmt.Errorf("tool call %d function.arguments: %w", i, err) + } + candidates = append(candidates, toolCallCandidate{ + name: name, + arguments: arguments, + origin: origin, + raw: raw, + }) + } + return candidates, nil +} + +func parseToolCallArguments(raw any) (any, error) { + switch v := raw.(type) { + case string: + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return nil, fmt.Errorf("must be valid JSON") + } + var decoded any + if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil { + return nil, fmt.Errorf("must be valid JSON: %w", err) + } + return decoded, nil + case map[string]any: + return v, nil + default: + return nil, fmt.Errorf("must be a JSON string or object") + } +} + +func validateToolCallCandidate(contract toolValidationContract, candidate toolCallCandidate) error { + spec, ok := contract.specs[candidate.name] + if !ok { + return fmt.Errorf("tool %q is not in request tools", candidate.name) + } + if err := validateJSONSchemaSubset(candidate.arguments, spec.parameters, "$.arguments"); err != nil { + return fmt.Errorf("tool %q %s", candidate.name, err.Error()) + } + return nil +} + +func validateJSONSchemaSubset(value any, schema any, path string) error { + m, ok := schema.(map[string]any) + if !ok || len(m) == 0 { + return nil + } + if branches := schemaBranches(m["anyOf"]); len(branches) > 0 { + return validateAnySchemaBranch(value, branches, path, "anyOf") + } + if branches := schemaBranches(m["oneOf"]); len(branches) > 0 { + return validateAnySchemaBranch(value, branches, path, "oneOf") + } + for _, branch := range schemaBranches(m["allOf"]) { + if err := validateJSONSchemaSubset(value, branch, path); err != nil { + return err + } + } + if values := schemaEnumValues(m["enum"]); len(values) > 0 && !valueInJSONEnum(value, values) { + return fmt.Errorf("%s value %s is not in enum", path, jsonValuePreview(value)) + } + types := schemaTypeNames(m["type"]) + if len(types) > 0 && !jsonValueMatchesAnyType(value, types) { + return fmt.Errorf("%s type %s does not match schema type %s", path, jsonValueTypeName(value), strings.Join(types, "|")) + } + if shouldValidateObjectSubset(value, m, types) { + obj, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("%s type %s does not match schema type object", path, jsonValueTypeName(value)) + } + if err := validateJSONObjectSubset(obj, m, path); err != nil { + return err + } + } + if shouldValidateArraySubset(value, m, types) { + items, ok := value.([]any) + if !ok { + return fmt.Errorf("%s type %s does not match schema type array", path, jsonValueTypeName(value)) + } + if itemSchema := schemaArrayItemSchema(m); itemSchema != nil { + for i, item := range items { + if err := validateJSONSchemaSubset(item, itemSchema, fmt.Sprintf("%s[%d]", path, i)); err != nil { + return err + } + } + } + } + return nil +} + +func validateAnySchemaBranch(value any, branches []any, path, branchType string) error { + var reasons []string + for _, branch := range branches { + if err := validateJSONSchemaSubset(value, branch, path); err == nil { + return nil + } else { + reasons = append(reasons, err.Error()) + } + } + sort.Strings(reasons) + if len(reasons) == 0 { + return fmt.Errorf("%s does not match %s", path, branchType) + } + return fmt.Errorf("%s does not match %s: %s", path, branchType, reasons[0]) +} + +func shouldValidateObjectSubset(value any, schema map[string]any, types []string) bool { + return containsString(types, "object") || len(schemaObjectProperties(schema)) > 0 || len(schemaStringList(schema["required"])) > 0 || isAdditionalPropertiesFalse(schema) +} + +func validateJSONObjectSubset(obj map[string]any, schema map[string]any, path string) error { + props := schemaObjectProperties(schema) + for _, key := range schemaStringList(schema["required"]) { + if _, ok := obj[key]; !ok { + return fmt.Errorf("%s.%s is required", path, key) + } + } + if isAdditionalPropertiesFalse(schema) { + for key := range obj { + if _, ok := props[key]; !ok { + return fmt.Errorf("%s.%s is not allowed by additionalProperties=false", path, key) + } + } + } + for key, propSchema := range props { + child, ok := obj[key] + if !ok { + continue + } + if err := validateJSONSchemaSubset(child, propSchema, path+"."+key); err != nil { + return err + } + } + return nil +} + +func shouldValidateArraySubset(value any, schema map[string]any, types []string) bool { + return containsString(types, "array") || schemaArrayItemSchema(schema) != nil +} + +func isAdditionalPropertiesFalse(schema map[string]any) bool { + allowed, ok := schema["additionalProperties"].(bool) + return ok && !allowed +} + +func schemaTypeNames(value any) []string { + switch v := value.(type) { + case string: + if strings.TrimSpace(v) == "" { + return nil + } + return []string{strings.TrimSpace(v)} + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + return out + default: + return nil + } +} + +func schemaStringList(value any) []string { + raw, ok := value.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, item := range raw { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + return out +} + +func schemaEnumValues(value any) []any { + raw, ok := value.([]any) + if !ok { + return nil + } + return raw +} + +func valueInJSONEnum(value any, allowed []any) bool { + for _, item := range allowed { + if jsonValuesEqual(value, item) { + return true + } + } + return false +} + +func jsonValuesEqual(a, b any) bool { + encodedA, errA := json.Marshal(a) + encodedB, errB := json.Marshal(b) + return errA == nil && errB == nil && string(encodedA) == string(encodedB) +} + +func jsonValueMatchesAnyType(value any, types []string) bool { + for _, t := range types { + if jsonValueMatchesType(value, t) { + return true + } + } + return false +} + +func jsonValueMatchesType(value any, typ string) bool { + switch typ { + case "object": + _, ok := value.(map[string]any) + return ok + case "array": + _, ok := value.([]any) + return ok + case "string": + _, ok := value.(string) + return ok + case "boolean": + _, ok := value.(bool) + return ok + case "number": + return isJSONNumber(value) + case "integer": + return isJSONInteger(value) + case "null": + return value == nil + default: + return true + } +} + +func isJSONNumber(value any) bool { + switch value.(type) { + case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return true + default: + return false + } +} + +func isJSONInteger(value any) bool { + switch v := value.(type) { + case float64: + return math.Trunc(v) == v + case float32: + return math.Trunc(float64(v)) == float64(v) + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return true + default: + return false + } +} + +func jsonValueTypeName(value any) string { + switch value.(type) { + case nil: + return "null" + case map[string]any: + return "object" + case []any: + return "array" + case string: + return "string" + case bool: + return "boolean" + default: + if isJSONInteger(value) { + return "integer" + } + if isJSONNumber(value) { + return "number" + } + return fmt.Sprintf("%T", value) + } +} + +func jsonValuePreview(value any) string { + encoded, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(encoded) +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/openai.test b/openai.test new file mode 100755 index 0000000..f84bc24 Binary files /dev/null and b/openai.test differ