diff --git a/agent-roadmap/archive/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md b/agent-roadmap/archive/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md
new file mode 100644
index 0000000..f22fbfd
--- /dev/null
+++ b/agent-roadmap/archive/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md
@@ -0,0 +1,81 @@
+# Milestone: OpenAI-compatible Think 제어 MVP
+
+## 위치
+
+- Roadmap: `agent-roadmap/ROADMAP.md`
+- Phase: `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md`
+
+## 목표
+
+OpenAI-compatible Chat Completions 요청에서 thinking/reasoning 생성을 요청별로 켜고 끌 수 있게 한다.
+provider runtime은 reasoning-capable 상태로 유지하되, Edge가 공개 요청 필드를 검증하고 Node provider adapter가 vLLM, vLLM-MLX, Lemonade의 지원 필드로 매핑한다.
+호출자가 reasoning 생성을 끄거나 reasoning 응답 노출만 숨길 수 있어야 하며, 지원하지 않는 조합은 조용히 무시하지 않고 명확한 compatibility error 또는 정의된 fallback으로 처리한다.
+
+## 상태
+
+[완료]
+
+## 승격 조건
+
+- 없음
+
+## 구현 잠금
+
+- 상태: 해제
+- SDD: 필요
+- SDD 문서: `agent-roadmap/archive/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md`
+- SDD 사유: OpenAI-compatible request schema, provider option 매핑, streaming response 노출 정책이 바뀌는 API 계약 Milestone이다.
+- 잠금 해제 조건:
+ - [x] SDD 잠금이 해제되어 있다.
+ - [x] SDD 사용자 리뷰가 없거나 승인/해결되었다.
+ - [x] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다.
+ - [x] Evidence Map이 완료 시 `Roadmap Completion`과 최종 검증 evidence로 검증 가능하게 연결되어 있다.
+- 결정 필요: 없음
+
+## 범위
+
+- `/v1/chat/completions` 요청의 thinking/reasoning 제어 field 수신, 검증, 내부 run input 전달
+- `think`, `reasoning_effort`, `thinking_token_budget`, `include_reasoning`의 의미와 충돌 정책
+- raw `chat_template_kwargs`는 공개 request field로 직접 열지 않고, Edge/Node가 provider별 내부 option으로 생성하는 매핑 정책
+- vLLM Qwen3, vLLM-MLX Qwen3, Lemonade provider의 reasoning-capable runtime에 대한 요청별 option 매핑
+- streaming/non-streaming 응답의 `reasoning_content` 노출/억제 정책
+- OpenAI-compatible 계약 문서, edge/node 단위 테스트, dev-runtime smoke
+
+## 기능
+
+### Epic: [think-control] Request-Level Think Control
+
+OpenAI-compatible 호출자가 요청 단위로 thinking/reasoning 생성과 노출을 제어하는 capability를 묶는다.
+
+- [x] [request-contract] `/v1/chat/completions`가 `think`, `reasoning_effort`, `thinking_token_budget`, `include_reasoning`을 허용하고 타입, 범위, 충돌 조합을 검증한다. 검증: 알 수 없는 provider 전용 wrapper는 계속 400으로 거부되고, 허용 필드는 Edge request decoder와 계약 테스트를 통과한다.
+- [x] [provider-mapping] Edge run input과 Node `openai_compat` adapter가 요청별 think 설정을 provider별 top-level field 또는 내부 `chat_template_kwargs.enable_thinking`으로 매핑한다. 검증: vLLM Qwen3, vLLM-MLX, Lemonade mock이 같은 요청 플래그에 대해 기대 request body를 받는다.
+- [x] [reasoning-visibility] `include_reasoning=false`가 provider reasoning 생성 여부와 별개로 OpenAI-compatible 응답의 `reasoning_content` 노출을 억제한다. 검증: stream과 non-stream 응답 모두 reasoning delta/message field가 빠지고 content/tool output은 유지된다.
+- [x] [default-compat] think 관련 필드가 생략된 기존 요청은 현재 provider 기본 thinking 동작을 유지한다. 검증: 기존 chat completion, tool calling, provider pool 테스트가 회귀 없이 통과한다.
+- [x] [dev-smoke] dev-runtime 세 provider(mac vLLM-MLX, GX10 vLLM, OneXPlayer Lemonade)에서 `think=false`, 기본값, `include_reasoning=false` smoke가 통과한다. 검증: provider raw trace와 Edge OpenAI-compatible SSE를 비교해 reasoning 생성/노출 상태가 기대와 일치한다.
+- [x] [contract-docs] OpenAI-compatible 계약 문서와 dev 운영 문서가 요청별 thinking/reasoning 제어 field, provider별 unsupported 정책, 기본값을 설명한다.
+
+## 완료 리뷰
+
+- 상태: 통과
+- 요청일: 2026-07-04
+- 완료 근거: `agent-task/archive/2026/07/m-openai-compatible-think-control/**/complete.log` 5개가 모든 기능 Task의 `Roadmap Completion` PASS를 기록했고, 종료 전 코드레벨 감사에서 provider-first OpenAI-compatible provider label 기본값과 README 계약 문구 드리프트를 보정했다.
+- 검토 항목: `go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/openai ./apps/node/internal/adapters/openai_compat`, `go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/configrefresh ./packages/go/config`, `go test ./...`, `make test-e2e` 통과. 남은 기능 Task와 구현 잠금 차단 항목 없음.
+- agent-ui 상태 반영: 해당 없음
+- 리뷰 코멘트: 코드레벨 종료 감사 통과. 큰 이슈나 별도 plan 필요 없음.
+
+## 범위 제외
+
+- `/v1/responses`의 streaming 지원 또는 reasoning output item 확장
+- provider runtime 기동 옵션 자동 조정과 lifecycle 관리
+- hidden reasoning token의 정확한 usage 산출
+- 단계 호출 planner/generator/verifier 모드 전반의 제품 UX
+- raw provider payload 장기 저장과 운영 ledger schema 구현
+
+## 작업 컨텍스트
+
+- 관련 경로: `apps/edge/internal/openai`, `apps/node/internal/adapters/openai_compat`, `agent-contract/outer/openai-compatible-api.md`, `docs/openai-compatible-api-contract.md`, `agent-test/dev`
+- 표준선(선택): provider runtime은 reasoning-capable 상태로 띄우고, 요청별 제어는 OpenAI-compatible Edge request 계약에서 검증한 뒤 provider별 지원 option으로 매핑한다.
+- 표준선(선택): 공개 request 표면은 provider raw wrapper를 그대로 열지 않고, IOP가 검증 가능한 OpenAI-compatible field와 내부 provider option 매핑으로 둔다.
+- 선행 작업: OpenAI-compatible reasoning raw trace와 dev-runtime provider reasoning 활성화
+- 후속 작업: 단계 호출과 검증 최적화 MVP, 요청 실행 로그와 Usage Ledger 기반
+- 확인 필요: 없음
diff --git a/agent-roadmap/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md b/agent-roadmap/archive/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md
similarity index 96%
rename from agent-roadmap/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md
rename to agent-roadmap/archive/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md
index 237a17a..3b38d90 100644
--- a/agent-roadmap/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md
+++ b/agent-roadmap/archive/sdd/knowledge-tool-optimization-extension/openai-compatible-think-control/SDD.md
@@ -2,7 +2,7 @@
## 위치
-- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md`
+- Milestone: `agent-roadmap/archive/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md`
- Phase: `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md`
## 상태
@@ -29,7 +29,7 @@
| 영역 | 기준 | 메모 |
|------|------|------|
-| Roadmap | `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md` | 기능 Task와 완료 evidence 기준 |
+| Roadmap | `agent-roadmap/archive/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md` | 기능 Task와 완료 evidence 기준 |
| Code | `apps/edge/internal/openai`, `apps/node/internal/adapters/openai_compat` | request decode, run input, provider request body, response filtering 구현 기준 |
| Contract | `agent-contract/outer/openai-compatible-api.md` | 공개 OpenAI-compatible request/response 계약 |
| External Provider | vLLM Qwen3, vLLM-MLX Qwen3, Lemonade | provider별 reasoning option 지원 여부는 dev smoke와 mock 테스트로 확인 |
diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md
index 325b1fb..0a6117c 100644
--- a/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md
+++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md
@@ -19,8 +19,8 @@ Ollama serving 경로와 운영 기반이 안정화된 뒤, 단계 호출, tool/
- 경로: `agent-roadmap/archive/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를 구현한다.
-- [계획] OpenAI-compatible Think 제어 MVP
- - 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md`
+- [완료] OpenAI-compatible Think 제어 MVP
+ - 경로: `agent-roadmap/archive/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md`
- 요약: OpenAI-compatible Chat Completions 요청에서 thinking/reasoning 생성과 응답 노출을 요청별로 제어하고 provider별 option 매핑과 unsupported 정책을 구현한다.
- [계획] OpenAI-compatible Tool Call Boundary Hardening
diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md
index 505978d..0c12661 100644
--- a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md
+++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-think-control.md
@@ -13,7 +13,7 @@ provider runtime은 reasoning-capable 상태로 유지하되, Edge가 공개 요
## 상태
-[계획]
+[완료]
## 승격 조건
@@ -47,21 +47,21 @@ provider runtime은 reasoning-capable 상태로 유지하되, Edge가 공개 요
OpenAI-compatible 호출자가 요청 단위로 thinking/reasoning 생성과 노출을 제어하는 capability를 묶는다.
-- [ ] [request-contract] `/v1/chat/completions`가 `think`, `reasoning_effort`, `thinking_token_budget`, `include_reasoning`을 허용하고 타입, 범위, 충돌 조합을 검증한다. 검증: 알 수 없는 provider 전용 wrapper는 계속 400으로 거부되고, 허용 필드는 Edge request decoder와 계약 테스트를 통과한다.
-- [ ] [provider-mapping] Edge run input과 Node `openai_compat` adapter가 요청별 think 설정을 provider별 top-level field 또는 내부 `chat_template_kwargs.enable_thinking`으로 매핑한다. 검증: vLLM Qwen3, vLLM-MLX, Lemonade mock이 같은 요청 플래그에 대해 기대 request body를 받는다.
-- [ ] [reasoning-visibility] `include_reasoning=false`가 provider reasoning 생성 여부와 별개로 OpenAI-compatible 응답의 `reasoning_content` 노출을 억제한다. 검증: stream과 non-stream 응답 모두 reasoning delta/message field가 빠지고 content/tool output은 유지된다.
-- [ ] [default-compat] think 관련 필드가 생략된 기존 요청은 현재 provider 기본 thinking 동작을 유지한다. 검증: 기존 chat completion, tool calling, provider pool 테스트가 회귀 없이 통과한다.
-- [ ] [dev-smoke] dev-runtime 세 provider(mac vLLM-MLX, GX10 vLLM, OneXPlayer Lemonade)에서 `think=false`, 기본값, `include_reasoning=false` smoke가 통과한다. 검증: provider raw trace와 Edge OpenAI-compatible SSE를 비교해 reasoning 생성/노출 상태가 기대와 일치한다.
-- [ ] [contract-docs] OpenAI-compatible 계약 문서와 dev 운영 문서가 요청별 thinking/reasoning 제어 field, provider별 unsupported 정책, 기본값을 설명한다.
+- [x] [request-contract] `/v1/chat/completions`가 `think`, `reasoning_effort`, `thinking_token_budget`, `include_reasoning`을 허용하고 타입, 범위, 충돌 조합을 검증한다. 검증: 알 수 없는 provider 전용 wrapper는 계속 400으로 거부되고, 허용 필드는 Edge request decoder와 계약 테스트를 통과한다.
+- [x] [provider-mapping] Edge run input과 Node `openai_compat` adapter가 요청별 think 설정을 provider별 top-level field 또는 내부 `chat_template_kwargs.enable_thinking`으로 매핑한다. 검증: vLLM Qwen3, vLLM-MLX, Lemonade mock이 같은 요청 플래그에 대해 기대 request body를 받는다.
+- [x] [reasoning-visibility] `include_reasoning=false`가 provider reasoning 생성 여부와 별개로 OpenAI-compatible 응답의 `reasoning_content` 노출을 억제한다. 검증: stream과 non-stream 응답 모두 reasoning delta/message field가 빠지고 content/tool output은 유지된다.
+- [x] [default-compat] think 관련 필드가 생략된 기존 요청은 현재 provider 기본 thinking 동작을 유지한다. 검증: 기존 chat completion, tool calling, provider pool 테스트가 회귀 없이 통과한다.
+- [x] [dev-smoke] dev-runtime 세 provider(mac vLLM-MLX, GX10 vLLM, OneXPlayer Lemonade)에서 `think=false`, 기본값, `include_reasoning=false` smoke가 통과한다. 검증: provider raw trace와 Edge OpenAI-compatible SSE를 비교해 reasoning 생성/노출 상태가 기대와 일치한다.
+- [x] [contract-docs] OpenAI-compatible 계약 문서와 dev 운영 문서가 요청별 thinking/reasoning 제어 field, provider별 unsupported 정책, 기본값을 설명한다.
## 완료 리뷰
-- 상태: 없음
-- 요청일: 없음
-- 완료 근거: 계획 Milestone이며 기능 Task가 아직 충족되지 않았다.
-- 검토 항목: 모든 기능 Task의 테스트와 dev-runtime smoke evidence가 필요하다.
+- 상태: 통과
+- 요청일: 2026-07-04
+- 완료 근거: `agent-task/archive/2026/07/m-openai-compatible-think-control/**/complete.log` 5개가 모든 기능 Task의 `Roadmap Completion` PASS를 기록했고, 종료 전 코드레벨 감사에서 provider-first OpenAI-compatible provider label 기본값과 README 계약 문구 드리프트를 보정했다.
+- 검토 항목: `go test -count=1 ./apps/edge/internal/node ./apps/edge/internal/openai ./apps/node/internal/adapters/openai_compat`, `go test -count=1 ./apps/edge/internal/bootstrap ./apps/edge/internal/configrefresh ./packages/go/config`, `go test ./...` 통과. 남은 기능 Task와 구현 잠금 차단 항목 없음.
- agent-ui 상태 반영: 해당 없음
-- 리뷰 코멘트: 없음
+- 리뷰 코멘트: 코드레벨 종료 감사 통과. 큰 이슈나 별도 plan 필요 없음.
## 범위 제외
diff --git a/agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/CODE_REVIEW-local-G07.md b/agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_0.log
similarity index 64%
rename from agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/CODE_REVIEW-local-G07.md
rename to agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_0.log
index 1b9e6fc..3558c45 100644
--- a/agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/CODE_REVIEW-local-G07.md
+++ b/agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_0.log
@@ -45,50 +45,49 @@ task=m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary,
| 항목 | 완료 여부 |
|------|---------|
-| [API-1] Text Tool Candidate Result | [ ] |
-| [API-2] Non-Stream Provider Text Boundary | [ ] |
-| [API-3] Streaming Flush Guard | [ ] |
-| [API-4] Native Preserve Regression | [ ] |
+| [API-1] Text Tool Candidate Result | [x] |
+| [API-2] Non-Stream Provider Text Boundary | [x] |
+| [API-3] Streaming Flush Guard | [x] |
+| [API-4] Native Preserve Regression | [x] |
## 구현 체크리스트
-- [ ] Provider route/provider-pool raw text tool-call 후보를 request `tools[]` 기준으로 구조화하고, valid raw block이 `message.tool_calls` 또는 stream `delta.tool_calls`로 나가며 raw block이 content에 남지 않게 한다.
-- [ ] Unknown/malformed text tool-call 후보를 성공 content로 flush하지 않고 non-stream/strict buffered stream에서는 bounded retry 후 `tool_validation_error`, live SSE에서는 terminal SSE error로 종료하게 한다.
-- [ ] Existing native provider `tool_calls` pass-through와 schema-normalization 동작을 보존하고 회귀 테스트를 유지한다.
-- [ ] Provider raw parse, unknown tool, live SSE, strict buffered SSE, native preserve 회귀 테스트를 갱신한다.
-- [ ] `go test ./apps/edge/internal/openai -count=1`와 `go test ./apps/edge/... -count=1`를 실행해 통과시킨다.
-- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
+- [x] Provider route/provider-pool raw text tool-call 후보를 request `tools[]` 기준으로 구조화하고, valid raw block이 `message.tool_calls` 또는 stream `delta.tool_calls`로 나가며 raw block이 content에 남지 않게 한다.
+- [x] Unknown/malformed text tool-call 후보를 성공 content로 flush하지 않고 non-stream/strict buffered stream에서는 bounded retry 후 `tool_validation_error`, live SSE에서는 terminal SSE error로 종료하게 한다.
+- [x] Existing native provider `tool_calls` pass-through와 schema-normalization 동작을 보존하고 회귀 테스트를 유지한다.
+- [x] Provider raw parse, unknown tool, live SSE, strict buffered SSE, native preserve 회귀 테스트를 갱신한다.
+- [x] `go test ./apps/edge/internal/openai -count=1`와 `go test ./apps/edge/... -count=1`를 실행해 통과시킨다.
+- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
-- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
-- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
-- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_N.log`로 아카이브한다.
-- [ ] active `PLAN-*-G??.md`를 `plan_local_G07_M.log`로 아카이브한다.
-- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
+- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
+- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
+- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_N.log`로 아카이브한다.
+- [x] active `PLAN-*-G??.md`를 `plan_local_G07_M.log`로 아카이브한다.
+- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/`를 `agent-task/archive/YYYY/MM/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-openai-compatible-tool-call-boundary-hardening`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-compatible-tool-call-boundary-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
-- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active plan/review 파일 또는 follow-up을 작성하고 `complete.log`를 작성하지 않는다.
+- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active plan/review 파일 또는 follow-up을 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 template 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
-_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
+- `TestChatCompletionsProviderStreamBlocksUnknownTextToolCall` 테스트에서 누수 탐지 검사 조건 `strings.Contains(body, "unknown_tool") && strings.Contains(body, "\"content\"")` 가 에러 정보 및 thought text 출력과 겹쳐 오탐을 발생시켰기 때문에, 실제 누수되는 JSON-escaped raw block 형식인 `strings.Contains(body, "\\u003ctool_call\\u003e")` 로 검사 방식을 변경하였습니다.
## 주요 설계 결정
-_구현 에이전트가 주요 설계 결정 사항을 기록한다._
+- 기존의 `synthesizeToolCallsFromText` 시그니처를 깨지 않기 위해, 파싱 상세 결과와 에러를 함께 반환할 수 있는 내부 헬퍼 `synthesizeToolCallsFromTextResult`를 정의하였습니다.
+- non-stream뿐만 아니라 buffered stream(`streamBufferedChatCompletion`) 흐름에서도 `collectChatCompletionOutput`이 내뱉은 `toolValidationErr`를 먼저 체크하도록 하여, 에러가 발생한 unknown tool call이 validation retry와 에러 터미널 처리를 동일하게 거치도록 통일했습니다.
## 사용자 리뷰 요청
-_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._
-
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
@@ -107,8 +106,6 @@ _기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직
## 검증 결과
-_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
-
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
@@ -117,34 +114,47 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
### API-1 중간 검증
```bash
$ go test ./apps/edge/internal/openai -run 'TestSynthesizes.*ToolCall|TestChatCompletions.*Unknown' -count=1
-(output)
+ok iop/apps/edge/internal/openai 0.006s
```
### API-2 중간 검증
```bash
$ go test ./apps/edge/internal/openai -run 'TestChatCompletions.*Provider.*Tool|TestChatCompletions.*Unknown.*Retry|TestChatCompletions.*MalformedToolCall' -count=1
-(output)
+ok iop/apps/edge/internal/openai 0.008s
```
### API-3 중간 검증
```bash
$ go test ./apps/edge/internal/openai -run 'TestChatCompletions.*Stream.*Tool|TestChatCompletionsStrictBufferedStream' -count=1
-(output)
+ok iop/apps/edge/internal/openai 0.008s
```
### API-4 중간 검증
```bash
$ go test ./apps/edge/internal/openai -run 'TestChatCompletions.*NativeToolCall' -count=1
-(output)
+ok iop/apps/edge/internal/openai 0.007s
```
### 최종 검증
```bash
$ go test ./apps/edge/internal/openai -count=1
-(output)
+ok iop/apps/edge/internal/openai 1.565s
$ go test ./apps/edge/... -count=1
-(output)
+ok iop/apps/edge/cmd/edge 0.040s
+ok iop/apps/edge/internal/bootstrap 0.276s
+ok iop/apps/edge/internal/configrefresh 0.030s
+ok iop/apps/edge/internal/controlplane 4.450s
+ok iop/apps/edge/internal/edgecmd 0.015s
+ok iop/apps/edge/internal/edgevalidate 0.007s
+ok iop/apps/edge/internal/events 0.006s
+ok iop/apps/edge/internal/input 0.017s
+ok iop/apps/edge/internal/input/a2a 0.012s
+ok iop/apps/edge/internal/node 0.006s
+ok iop/apps/edge/internal/openai 1.575s
+ok iop/apps/edge/internal/opsconsole 0.016s
+ok iop/apps/edge/internal/service 0.444s
+ok iop/apps/edge/internal/transport 2.039s
```
---
@@ -152,3 +162,20 @@ $ go test ./apps/edge/... -count=1
> **[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.
+
+## 코드리뷰 결과
+
+- 종합 판정: FAIL
+- 차원별 평가:
+ - correctness: Fail
+ - completeness: Fail
+ - test coverage: Fail
+ - API contract: Fail
+ - code quality: Pass
+ - implementation deviation: Fail
+ - verification trust: Warn
+- 발견된 문제:
+ - Required: `apps/edge/internal/openai/types.go:267`에서 `...` block을 찾았더라도 내부 ``가 없으면 `continue`로 무시하고 `candidateFound=false`로 끝납니다. 그 결과 non-stream은 `apps/edge/internal/openai/chat_handler.go:366` 경로에서 raw malformed ``을 성공 content로 보존하고, live SSE는 `apps/edge/internal/openai/stream.go:242`의 `!res.candidateFound` branch에서 마지막 pending raw block을 flush할 수 있습니다. 계획의 “unknown/malformed text tool-call 후보를 성공 content로 flush하지 않음”과 `stream-flush-guard` 목표를 충족하지 못합니다.
+ - Required: `apps/edge/internal/openai/tool_validation.go:93`이 여러 `tool_calls` 중 마지막 candidate만 검증합니다. `synthesizeToolCallsFromTextResult`가 여러 known raw text tool-call을 구조화한 경우 앞쪽 call의 schema 오류나 forbidden argument가 뒤쪽 valid call에 가려져 200 `tool_calls`로 노출될 수 있어, “request tools[] schema 기준 구조화/차단” 경계가 불완전합니다.
+- 다음 단계:
+ - FAIL follow-up: user-review gate는 트리거하지 않고, malformed raw candidate 탐지와 전체 tool-call validation을 보완하는 다음 `PLAN-*-G??.md` / `CODE_REVIEW-*-G??.md`를 작성한다.
diff --git a/agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_1.log b/agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_1.log
new file mode 100644
index 0000000..9fdb0fb
--- /dev/null
+++ b/agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_1.log
@@ -0,0 +1,177 @@
+
+
+# Code Review Reference - REVIEW_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-04
+task=m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary, plan=1, tag=REVIEW_API
+
+## Roadmap Targets
+
+- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md`
+- Task ids:
+ - `provider-raw-parse`: provider-pool/provider route raw text tool-call parser 적용
+ - `unknown-tool-error`: unknown raw tool-call을 validation error 또는 retry로 처리
+ - `stream-flush-guard`: streaming raw tool-call leak 방지
+ - `native-preserve`: native `tool_calls` pass-through와 schema normalization 보존
+- Completion mode: check-on-pass
+
+## Archive Evidence Snapshot
+
+- Prior active task: `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary`
+- Archived plan: `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_0.log`
+- Archived review: `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_0.log`
+- Verdict: FAIL
+- Required summary:
+ - malformed raw `` blocks with missing function or incomplete candidate can be treated as no candidate and leak through success content/SSE pending flush.
+ - multiple `tool_calls` validation checks only the last candidate, so an earlier schema-invalid call can be emitted when a later call is valid.
+- Suggested/Nit summary: none blocking; first-line artifact drift in prior active files was repaired before archive.
+- Affected files: `apps/edge/internal/openai/types.go`, `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/stream.go`, `apps/edge/internal/openai/tool_validation.go`, `apps/edge/internal/openai/server_test.go`.
+- Verification evidence from prior loop and reviewer rerun:
+ - `go test ./apps/edge/internal/openai -count=1`: PASS
+ - `go test ./apps/edge/... -count=1`: PASS
+- Roadmap carryover: same four Task ids as prior plan; this follow-up should still complete the 01 provider text boundary subtask on PASS.
+- More detail, if needed: read only the two archived files listed above; do not search `agent-task/archive/**`.
+
+## 이 파일을 읽는 리뷰 에이전트에게
+
+> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
+
+각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
+리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
+
+1. 판정을 append한다.
+2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_M.log`로 아카이브한다.
+3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/`로 이동한다. 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-openai-compatible-tool-call-boundary-hardening`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
+5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
+
+---
+
+## 구현 항목별 완료 여부
+
+| 항목 | 완료 여부 |
+|------|---------|
+| [REVIEW_API-1] Malformed Raw Candidate Guard | [x] |
+| [REVIEW_API-2] Validate Every Tool Call | [x] |
+
+## 구현 체크리스트
+
+- [x] malformed raw text tool-call 후보를 candidate로 인식해 non-stream/strict buffered stream은 bounded retry 후 `tool_validation_error`, live SSE is terminal SSE error로 종료하고 raw block을 content로 flush하지 않게 한다.
+- [x] `validateToolCallResponse`가 모든 tool call candidate를 검증해 여러 raw/native tool calls 중 어느 하나라도 schema/shape 오류가 있으면 retry/error로 처리하게 한다.
+- [x] malformed raw block, live SSE malformed block, multiple tool-call partial invalid 회귀 테스트를 추가하고 native preserve 회귀를 유지한다.
+- [x] `go test ./apps/edge/internal/openai -count=1`와 `go test ./apps/edge/... -count=1`를 실행해 통과시킨다.
+- [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_local_G07_N.log`로 아카이브한다.
+- [x] active `PLAN-*-G??.md`를 `plan_local_G07_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/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/`를 `agent-task/archive/YYYY/MM/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
+- [x] PASS이고 task group이 `m-openai-compatible-tool-call-boundary-hardening`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
+- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-openai-compatible-tool-call-boundary-hardening/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
+- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.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로 이동한다.
+
+## 계획 대비 변경 사항
+
+- `TestValidateToolCallResponseValidatesEveryToolCall` 테스트 작성 시 정의되지 않은 `toolValidationSpec` 구조체 타입 대신 `textToolSpec` 구조체 타입을 사용해 빌드 오류를 수정함.
+
+## 주요 설계 결정
+
+- XML parser (`collectXMLCandidateMatches`) 수정: ``이 매칭되지 않는 unclosed block을 `malformed tool call: unclosed tool_call block` `validationErr`를 담은 `candidateMatch`로 처리해 strict buffered stream이나 live SSE에서 leak 현상이 발생하는 것을 차단함.
+- Mustache parser (`collectMustacheCandidateMatches`) 수정: `{{`로 시작한 block이 닫히지 않았거나(괄호 짝 포함) 인자 파싱 에러인 경우에도 `validationErr` candidate로 수집하도록 스캔 구조 보완.
+- Tool validation (`validateToolCallResponse`) 수정: 첫 번째(혹은 마지막) 툴콜만 검증하는 대신 전체 툴콜을 순회 검증하도록 변경하고, 실패 시 실패한 candidate의 0-based 인덱스를 에러 메시지에 접두어로 기입 (`"tool call %d: %w"`) 하여 여러 툴콜 동시 제출 시의 validation boundary를 안전하게 보정함.
+
+## 사용자 리뷰 요청
+
+- 상태: 없음
+- 사유 유형: 없음
+- 연결 대상: 없음
+- 결정 필요: 없음
+- 차단 근거: 없음
+- 실행한 검증/명령: 없음
+- 자동 후속 불가 이유: 없음
+- 재개 조건: 없음
+
+## 리뷰어를 위한 체크포인트
+
+- malformed raw `` opening/block이 no-candidate로 취급되지 않는지 확인한다.
+- non-stream/strict buffered stream malformed raw candidate가 retry budget을 거친 뒤 `tool_validation_error`로 끝나는지 확인한다.
+- live SSE malformed raw candidate가 terminal SSE error로 끝나고 raw pending content를 flush하지 않는지 확인한다.
+- multiple synthesized/native `tool_calls` 중 앞쪽 invalid call도 validation failure로 잡히는지 확인한다.
+- native `tool_calls` pass-through와 schema normalization 회귀가 유지되는지 확인한다.
+
+## 검증 결과
+
+### REVIEW_API-1 중간 검증
+```bash
+$ go test ./apps/edge/internal/openai -run 'TestChatCompletions.*MalformedTextToolCall|TestChatCompletionsProviderStreamBlocksMalformedTextToolCall' -count=1
+ok iop/apps/edge/internal/openai 0.006s
+```
+
+### REVIEW_API-2 중간 검증
+```bash
+$ go test ./apps/edge/internal/openai -run 'TestValidateToolCallResponseValidatesEveryToolCall|TestChatCompletionsFailsWhenAnySynthesizedTextToolCallViolatesSchema|TestChatCompletions.*NativeToolCall' -count=1
+ok iop/apps/edge/internal/openai 0.008s
+```
+
+### 최종 검증
+```bash
+$ go test ./apps/edge/internal/openai -count=1
+ok iop/apps/edge/internal/openai 1.568s
+
+$ go test ./apps/edge/... -count=1
+ok iop/apps/edge/cmd/edge 0.043s
+ok iop/apps/edge/internal/bootstrap 0.279s
+ok iop/apps/edge/internal/configrefresh 0.026s
+ok iop/apps/edge/internal/controlplane 4.446s
+ok iop/apps/edge/internal/edgecmd 0.016s
+ok iop/apps/edge/internal/edgevalidate 0.005s
+ok iop/apps/edge/internal/events 0.003s
+ok iop/apps/edge/internal/input 0.009s
+ok iop/apps/edge/internal/input/a2a 0.011s
+ok iop/apps/edge/internal/node 0.007s
+ok iop/apps/edge/internal/openai 1.571s
+ok iop/apps/edge/internal/opsconsole 0.014s
+ok iop/apps/edge/internal/service 0.440s
+ok iop/apps/edge/internal/transport 2.041s
+```
+
+---
+
+> **[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.
+
+## 코드리뷰 결과
+
+- 종합 판정: PASS
+- 차원별 평가:
+ - correctness: Pass
+ - completeness: Pass
+ - test coverage: Pass
+ - API contract: Pass
+ - code quality: Pass
+ - implementation deviation: Pass
+ - verification trust: Pass
+- 발견된 문제: 없음
+- 다음 단계: PASS 종결 처리. active plan/review를 로그로 아카이브하고 `complete.log`를 작성한 뒤 task directory를 `agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/`로 이동한다.
diff --git a/agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/complete.log b/agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/complete.log
new file mode 100644
index 0000000..608ee1b
--- /dev/null
+++ b/agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/complete.log
@@ -0,0 +1,49 @@
+# Complete - m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary
+
+## 완료 일시
+
+2026-07-04
+
+## 요약
+
+Provider raw text tool-call boundary follow-up completed after 2 review loops; final verdict PASS.
+
+## 루프 이력
+
+| Plan | Review | Verdict | 메모 |
+|------|--------|---------|------|
+| `plan_local_G07_0.log` | `code_review_local_G07_0.log` | FAIL | malformed raw block no-candidate leak and last-candidate-only validation gaps required follow-up. |
+| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | PASS | malformed raw candidates now produce validation errors/retry paths, all tool-call candidates are validated, and targeted/final Go tests passed. |
+
+## 구현/정리 내용
+
+- Provider route/provider-pool text tool-call synthesis now treats malformed XML/mustache candidates as validation failures instead of no-candidate content.
+- Non-stream and strict buffered stream paths carry synthesis validation errors through the existing bounded retry and `tool_validation_error` response flow.
+- Live SSE terminal handling blocks malformed/unknown raw text tool-call candidates without flushing raw pending content.
+- `validateToolCallResponse` validates every native/synthesized tool call candidate instead of only the last candidate.
+- Regression tests cover malformed raw blocks, live SSE malformed blocking, multiple candidate validation, and native tool-call preservation.
+
+## 최종 검증
+
+- `go test ./apps/edge/internal/openai -run 'TestChatCompletions.*MalformedTextToolCall|TestChatCompletionsProviderStreamBlocksMalformedTextToolCall' -count=1` - PASS; `ok iop/apps/edge/internal/openai 0.006s`
+- `go test ./apps/edge/internal/openai -run 'TestValidateToolCallResponseValidatesEveryToolCall|TestChatCompletionsFailsWhenAnySynthesizedTextToolCallViolatesSchema|TestChatCompletions.*NativeToolCall' -count=1` - PASS; `ok iop/apps/edge/internal/openai 0.008s`
+- `go test ./apps/edge/internal/openai -count=1` - PASS; `ok iop/apps/edge/internal/openai 1.574s`
+- `go test ./apps/edge/... -count=1` - PASS; edge packages passed, including `apps/edge/internal/openai`.
+
+## Roadmap Completion
+
+- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md`
+- Completed task ids:
+ - `provider-raw-parse`: PASS; evidence=`agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_1.log`, `agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_1.log`; verification=`go test ./apps/edge/internal/openai -count=1`, `go test ./apps/edge/... -count=1`
+ - `unknown-tool-error`: PASS; evidence=`agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_1.log`, `agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_1.log`; verification=`go test ./apps/edge/internal/openai -count=1`, `go test ./apps/edge/... -count=1`
+ - `stream-flush-guard`: PASS; evidence=`agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_1.log`, `agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_1.log`; verification=`go test ./apps/edge/internal/openai -count=1`, `go test ./apps/edge/... -count=1`
+ - `native-preserve`: PASS; evidence=`agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_1.log`, `agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_1.log`; verification=`go test ./apps/edge/internal/openai -run 'TestValidateToolCallResponseValidatesEveryToolCall|TestChatCompletionsFailsWhenAnySynthesizedTextToolCallViolatesSchema|TestChatCompletions.*NativeToolCall' -count=1`
+- Not completed task ids: 없음
+
+## 잔여 Nit
+
+- 없음
+
+## 후속 작업
+
+- 없음
diff --git a/agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/PLAN-local-G07.md b/agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_0.log
similarity index 99%
rename from agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/PLAN-local-G07.md
rename to agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_0.log
index 3f9ea37..4a6713c 100644
--- a/agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/PLAN-local-G07.md
+++ b/agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_0.log
@@ -1,4 +1,4 @@
-넌
+
# Provider Text Tool Boundary Plan
diff --git a/agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_1.log b/agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_1.log
new file mode 100644
index 0000000..adcc10b
--- /dev/null
+++ b/agent-task/archive/2026/07/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_1.log
@@ -0,0 +1,195 @@
+
+
+# Provider Text Tool Boundary Follow-Up Plan
+
+## 이 파일을 읽는 구현 에이전트에게
+
+`CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 아래 Required 두 건만 좁게 고치고, 검증 명령의 실제 stdout/stderr를 review stub에 남긴 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청`을 채우며, 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다. 환경/secret/service 차단, 일반 scope 조정, 증거 공백은 사용자 리뷰가 아니라 검증 결과나 후속 plan으로 남긴다.
+
+## 배경
+
+이전 구현은 provider route/provider-pool raw text tool-call 후보를 구조화하는 주 경로와 기존 테스트는 통과했다. 하지만 malformed raw block은 후보로 인식되지 않아 성공 content/SSE flush로 남을 수 있고, 여러 tool call 중 마지막만 schema validation을 받아 앞쪽 invalid call이 통과할 수 있다. 이번 follow-up은 그 두 경계만 보완한다.
+
+## 사용자 리뷰 요청 흐름
+
+사용자 리뷰 요청은 선택된 Milestone 잠금 결정이 구현을 차단할 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 중 직접 사용자 prompt, 선택지 제시, `request_user_input` 호출은 금지되며, code-review가 검증 후 실제 `USER_REVIEW.md` 작성 여부를 판단한다.
+
+## Roadmap Targets
+
+- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md`
+- Task ids:
+ - `provider-raw-parse`: provider-pool/provider route raw text tool-call parser 적용
+ - `unknown-tool-error`: unknown raw tool-call을 validation error 또는 retry로 처리
+ - `stream-flush-guard`: streaming raw tool-call leak 방지
+ - `native-preserve`: native `tool_calls` pass-through와 schema normalization 보존
+- Completion mode: check-on-pass
+
+## Archive Evidence Snapshot
+
+- Prior active task: `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary`
+- Archived plan: `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_0.log`
+- Archived review: `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_0.log`
+- Verdict: FAIL
+- Required summary:
+ - malformed raw `` blocks with missing function or incomplete candidate can be treated as no candidate and leak through success content/SSE pending flush.
+ - multiple `tool_calls` validation checks only the last candidate, so an earlier schema-invalid call can be emitted when a later call is valid.
+- Suggested/Nit summary: none blocking; first-line artifact drift in prior active files was repaired before archive.
+- Affected files: `apps/edge/internal/openai/types.go`, `apps/edge/internal/openai/chat_handler.go`, `apps/edge/internal/openai/stream.go`, `apps/edge/internal/openai/tool_validation.go`, `apps/edge/internal/openai/server_test.go`.
+- Verification evidence from prior loop and reviewer rerun:
+ - `go test ./apps/edge/internal/openai -count=1`: PASS
+ - `go test ./apps/edge/... -count=1`: PASS
+- Roadmap carryover: same four Task ids as prior plan; this follow-up should still complete the 01 provider text boundary subtask on PASS.
+- More detail, if needed: read only the two archived files listed above; do not search `agent-task/archive/**`.
+
+## 분석 결과
+
+### 읽은 파일
+
+- `agent-ops/rules/project/rules.md`
+- `agent-ops/rules/common/rules-roadmap.md`
+- `agent-ops/skills/common/router.md`
+- `agent-ops/skills/common/code-review/SKILL.md`
+- `agent-ops/skills/common/plan/SKILL.md`
+- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
+- `agent-roadmap/current.md`
+- `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md`
+- `agent-ops/rules/project/domain/edge/rules.md`
+- `agent-ops/rules/project/domain/testing/rules.md`
+- `agent-test/local/rules.md`
+- `agent-contract/index.md`
+- `agent-contract/outer/openai-compatible-api.md`
+- `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/plan_local_G07_0.log`
+- `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/code_review_local_G07_0.log`
+- `apps/edge/internal/openai/types.go`
+- `apps/edge/internal/openai/chat_handler.go`
+- `apps/edge/internal/openai/stream.go`
+- `apps/edge/internal/openai/tool_validation.go`
+- `apps/edge/internal/openai/run_result.go`
+- `apps/edge/internal/openai/strict_output.go`
+- `apps/edge/internal/openai/server_test.go`
+
+### SDD 기준
+
+선택 Milestone은 `SDD: 불필요`다. 사유는 기존 OpenAI-compatible `tools[]`, `tool_calls`, text-tool fallback, validation retry 계약을 강화하는 호환성 보정이며 새 공개 request/response field를 만들지 않는다는 것이다.
+
+### 테스트 환경 규칙
+
+`test_env=local`. `agent-test/local/rules.md`를 읽었고, edge 도메인 변경이므로 변경 패키지와 edge 패키지 기준 fresh Go test를 사용한다. 적용 명령은 `go test ./apps/edge/internal/openai -count=1`와 `go test ./apps/edge/... -count=1`이며, 중간 검증도 `-count=1`을 유지한다.
+
+### 테스트 커버리지 공백
+
+- malformed raw XML block: 기존 테스트 없음. `` opening 또는 complete block이 있지만 function parse가 실패하는 경우 validation error로 막는 회귀 테스트가 필요하다.
+- malformed raw live SSE: 기존 테스트 없음. pending filter가 잡은 raw candidate를 terminal에서 `tool_validation_error`로 끝내고 raw pending을 flush하지 않는지 검증해야 한다.
+- multiple synthesized tool calls: 기존 테스트 없음. 마지막 call만 valid한 경우 앞쪽 invalid call이 함께 emit되지 않는지 검증해야 한다.
+- native preserve: 기존 native pass-through/normalization tests는 유지하고 최종 패키지 테스트로 회귀를 확인한다.
+
+### 심볼 참조
+
+rename/remove 없음. 변경 후보 call site는 `synthesizeToolCallsFromTextResult` 호출 지점 `apps/edge/internal/openai/chat_handler.go:367`, `apps/edge/internal/openai/stream.go:222`와 `validateToolCallResponse` 호출 지점 `apps/edge/internal/openai/chat_handler.go:274`, `apps/edge/internal/openai/stream.go:439`다.
+
+### 분할 판단
+
+split 정책을 평가했다. 이미 `m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary` 안에서 prior loop가 실패한 좁은 follow-up이며, 두 Required 모두 같은 OpenAI package의 parser/validation boundary와 같은 tests로 검증된다. 새 subtask로 쪼개면 같은 실패 원인을 분산하므로 기존 subtask에서 plan 1로 이어간다.
+
+### 범위 결정 근거
+
+계약 문서/dev smoke는 sibling `02+01_contract_dev_smoke` 범위이므로 수정하지 않는다. Node adapter fallback 정책, provider별 chat template, 모델 prompt policy, `tools[]` 없는 자연어 tool 추론은 범위 밖이다. 기존 native `tool_calls` pass-through 동작은 보존만 확인한다.
+
+### 빌드 등급
+
+`local-G07`. 단일 Go package 안의 bounded parser/validation 보완이고 deterministic unit/package tests로 검증 가능하지만, stream/non-stream/retry/native preserve 경계가 함께 걸린다.
+
+## 구현 체크리스트
+
+- [ ] malformed raw text tool-call 후보를 candidate로 인식해 non-stream/strict buffered stream은 bounded retry 후 `tool_validation_error`, live SSE는 terminal SSE error로 종료하고 raw block을 content로 flush하지 않게 한다.
+- [ ] `validateToolCallResponse`가 모든 tool call candidate를 검증해 여러 raw/native tool calls 중 어느 하나라도 schema/shape 오류가 있으면 retry/error로 처리하게 한다.
+- [ ] malformed raw block, live SSE malformed block, multiple tool-call partial invalid 회귀 테스트를 추가하고 native preserve 회귀를 유지한다.
+- [ ] `go test ./apps/edge/internal/openai -count=1`와 `go test ./apps/edge/... -count=1`를 실행해 통과시킨다.
+- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
+
+### [REVIEW_API-1] Malformed Raw Candidate Guard
+
+문제: `apps/edge/internal/openai/types.go:267`은 `...` block 안에서 ``를 찾지 못하면 `continue`로 무시한다. `apps/edge/internal/openai/types.go:422`에서 toolCalls가 없고 error도 없으면 no-candidate처럼 반환되므로 non-stream `apps/edge/internal/openai/chat_handler.go:366`은 raw content를 성공 응답으로 보존하고, live SSE는 `apps/edge/internal/openai/stream.go:242`의 pending flush branch로 raw block을 내보낼 수 있다.
+
+해결 방법: XML candidate scanner가 `` content delta를 포함하지 않음을 검증한다.
+
+중간 검증:
+
+```bash
+go test ./apps/edge/internal/openai -run 'TestChatCompletions.*MalformedTextToolCall|TestChatCompletionsProviderStreamBlocksMalformedTextToolCall' -count=1
+```
+
+기대 결과: PASS.
+
+### [REVIEW_API-2] Validate Every Tool Call
+
+문제: `apps/edge/internal/openai/tool_validation.go:93`이 `candidates[len(candidates)-1]`만 검증한다. raw text synthesis가 여러 known tool calls를 반환할 때 첫 번째 call이 schema-invalid이고 마지막 call만 valid이면 전체 응답이 성공 `tool_calls`로 나갈 수 있다.
+
+해결 방법: `validateToolCallResponse`가 `toolCallCandidatesFromResponse` 결과 전체를 순서대로 검증하고 첫 번째 오류를 반환하게 한다. 오류 메시지에는 가능하면 candidate index를 포함해 어떤 call이 실패했는지 알 수 있게 하되, 기존 retry/error type은 유지한다.
+
+Before:
+
+```go
+// apps/edge/internal/openai/tool_validation.go:90
+if len(candidates) == 0 {
+ return nil
+}
+return validateToolCallCandidate(contract, candidates[len(candidates)-1])
+```
+
+수정 파일 및 체크리스트:
+
+- [ ] `apps/edge/internal/openai/tool_validation.go`: 모든 candidate 검증으로 변경.
+- [ ] `apps/edge/internal/openai/server_test.go`: multiple synthesized raw tool-call 중 일부 invalid case를 추가.
+- [ ] `apps/edge/internal/openai/server_test.go`: native pass-through/normalization tests가 계속 통과하는지 확인.
+
+테스트 작성: `TestValidateToolCallResponseValidatesEveryToolCall` 또는 HTTP 레벨 `TestChatCompletionsFailsWhenAnySynthesizedTextToolCallViolatesSchema`를 추가한다. 최소 하나는 첫 번째 call invalid, 두 번째 call valid인 응답이 retry/error로 끝나는지를 검증해야 한다.
+
+중간 검증:
+
+```bash
+go test ./apps/edge/internal/openai -run 'TestValidateToolCallResponseValidatesEveryToolCall|TestChatCompletionsFailsWhenAnySynthesizedTextToolCallViolatesSchema|TestChatCompletions.*NativeToolCall' -count=1
+```
+
+기대 결과: PASS.
+
+## 수정 파일 요약
+
+| 파일 | 항목 |
+|---|---|
+| `apps/edge/internal/openai/types.go` | REVIEW_API-1 |
+| `apps/edge/internal/openai/chat_handler.go` | REVIEW_API-1 확인 |
+| `apps/edge/internal/openai/stream.go` | REVIEW_API-1 확인 |
+| `apps/edge/internal/openai/tool_validation.go` | REVIEW_API-2 |
+| `apps/edge/internal/openai/server_test.go` | REVIEW_API-1, REVIEW_API-2 |
+
+## 최종 검증
+
+```bash
+go test ./apps/edge/internal/openai -count=1
+go test ./apps/edge/... -count=1
+```
+
+기대 결과: 두 명령 모두 PASS. Go test cache는 허용하지 않으므로 `-count=1`을 유지한다.
+
+모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
diff --git a/apps/edge/README.md b/apps/edge/README.md
index a838b87..3225017 100644
--- a/apps/edge/README.md
+++ b/apps/edge/README.md
@@ -235,7 +235,7 @@ openai:
target: "llama3:8b"
```
-`/v1/chat/completions`는 기본 non-streaming과 streaming SSE 응답을 모두 지원한다. `/v1/responses`는 현재 non-streaming 요청만 지원한다. 두 endpoint 모두 `metadata.workspace`를 run workspace로 전달한다. `metadata`는 OpenAI 표준의 caller-defined string metadata container로 보고, IOP가 특별히 해석하는 key는 `workspace`뿐이다. `/v1/responses`의 `max_output_tokens`, `temperature`, `top_p`, `instructions`, `background`와 `/v1/chat/completions`의 `max_tokens`, `max_completion_tokens`, `temperature`, `top_p` 등은 OpenAI API처럼 top-level field에 둔다. `metadata.source`, `metadata.cli`, `metadata.inference`, 소비자 전용 metadata wrapper, `options`, `think`, `format`, `keep_alive` 같은 provider/Ollama 전용 request field는 지원하지 않는다.
+`/v1/chat/completions`는 기본 non-streaming과 streaming SSE 응답을 모두 지원한다. `/v1/responses`는 현재 non-streaming 요청만 지원한다. 두 endpoint 모두 `metadata.workspace`를 run workspace로 전달한다. `metadata`는 OpenAI 표준의 caller-defined string metadata container로 보고, IOP가 특별히 해석하는 key는 `workspace`뿐이다. `/v1/responses`의 `max_output_tokens`, `temperature`, `top_p`, `instructions`, `background`와 `/v1/chat/completions`의 `max_tokens`, `max_completion_tokens`, `temperature`, `top_p`, `think`, `reasoning_effort`, `thinking_token_budget`, `include_reasoning` 등은 OpenAI API처럼 top-level field에 둔다. `metadata.source`, `metadata.cli`, `metadata.inference`, 소비자 전용 metadata wrapper, `options`, `chat_template_kwargs`, `format`, `keep_alive` 같은 provider/Ollama 전용 request field는 지원하지 않는다.
```bash
curl -s http://127.0.0.1:18081/v1/chat/completions \
diff --git a/apps/edge/internal/node/mapper.go b/apps/edge/internal/node/mapper.go
index bedea66..808467b 100644
--- a/apps/edge/internal/node/mapper.go
+++ b/apps/edge/internal/node/mapper.go
@@ -2,6 +2,7 @@ package node
import (
"fmt"
+ "strings"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
@@ -189,7 +190,7 @@ func providerToAdapterConfig(p config.NodeProviderConf) (*iop.AdapterConfig, err
Name: p.ID,
Config: &iop.AdapterConfig_OpenaiCompat{
OpenaiCompat: &iop.OpenAICompatAdapterConfig{
- Provider: p.Provider,
+ Provider: openAICompatProviderLabel(p),
Endpoint: p.Endpoint,
Headers: p.Headers,
Capacity: int32(p.Capacity),
@@ -227,6 +228,18 @@ func providerToAdapterConfig(p config.NodeProviderConf) (*iop.AdapterConfig, err
}
}
+func openAICompatProviderLabel(p config.NodeProviderConf) string {
+ if provider := strings.TrimSpace(p.Provider); provider != "" {
+ return provider
+ }
+ switch strings.TrimSpace(p.Type) {
+ case "vllm", "lemonade", "sglang", "openai_api":
+ return strings.TrimSpace(p.Type)
+ default:
+ return ""
+ }
+}
+
func cliProfileToProto(p config.CLIProfileConf) *iop.CLIProfileConfig {
out := &iop.CLIProfileConfig{
Command: p.Command,
diff --git a/apps/edge/internal/node/mapper_test.go b/apps/edge/internal/node/mapper_test.go
index c1fbeeb..09c9def 100644
--- a/apps/edge/internal/node/mapper_test.go
+++ b/apps/edge/internal/node/mapper_test.go
@@ -573,6 +573,62 @@ func TestBuildConfigPayload_ProviderFirstOpenAICompat(t *testing.T) {
}
}
+func TestBuildConfigPayload_ProviderFirstOpenAICompatDefaultsProviderFromType(t *testing.T) {
+ cases := []struct {
+ name string
+ typ string
+ provider string
+ want string
+ }{
+ {name: "vllm", typ: "vllm", want: "vllm"},
+ {name: "lemonade", typ: "lemonade", want: "lemonade"},
+ {name: "sglang", typ: "sglang", want: "sglang"},
+ {name: "openai api", typ: "openai_api", want: "openai_api"},
+ {name: "explicit override", typ: "openai_compat", provider: "vllm-mlx", want: "vllm-mlx"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := &edgenode.NodeRecord{
+ ID: "node-1",
+ Alias: "test",
+ Token: "token",
+ Providers: []config.NodeProviderConf{
+ {
+ ID: "provider-a",
+ Type: tc.typ,
+ Category: config.CategoryAPI,
+ Provider: tc.provider,
+ Endpoint: "http://127.0.0.1:8000/v1",
+ Models: []string{"served-model"},
+ },
+ },
+ }
+
+ payload, err := edgenode.BuildConfigPayload(rec)
+ if err != nil {
+ t.Fatalf("BuildConfigPayload failed: %v", err)
+ }
+ var found *iop.AdapterConfig
+ for _, a := range payload.Adapters {
+ if a.Name == "provider-a" {
+ found = a
+ break
+ }
+ }
+ if found == nil {
+ t.Fatal("expected adapter with name 'provider-a'")
+ }
+ oc := found.GetOpenaiCompat()
+ if oc == nil {
+ t.Fatal("expected typed OpenAICompatAdapterConfig")
+ }
+ if oc.GetProvider() != tc.want {
+ t.Fatalf("provider: got %q, want %q", oc.GetProvider(), tc.want)
+ }
+ })
+ }
+}
+
func TestBuildConfigPayload_ProviderFirstCLIProfile(t *testing.T) {
rec := &edgenode.NodeRecord{
ID: "node-1",
diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go
index 251c788..88b8a5e 100644
--- a/apps/edge/internal/openai/chat_handler.go
+++ b/apps/edge/internal/openai/chat_handler.go
@@ -269,17 +269,21 @@ func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request,
writeError(w, httpStatusForRunError(err), "run_error", err.Error())
return
}
- if err := validateToolCallResponse(validation, result.toolCalls, result.toolCallOrigin); err != nil {
+ valErr := result.toolValidationErr
+ if valErr == nil {
+ valErr = validateToolCallResponse(validation, result.toolCalls, result.toolCallOrigin)
+ }
+ if valErr != nil {
failedRunID := handle.Dispatch().RunID
if attempt < maxToolValidationAttempts {
handle.Close()
attempt++
retryReq := submitReq
- retryReq.Metadata = toolValidationAttemptMetadata(submitReq.Metadata, attempt, failedRunID, err.Error())
+ retryReq.Metadata = toolValidationAttemptMetadata(submitReq.Metadata, attempt, failedRunID, valErr.Error())
s.logger.Warn("openai chat completion tool validation retry",
zap.String("run_id", failedRunID),
zap.Int("attempt", attempt),
- zap.String("reason", err.Error()),
+ zap.String("reason", valErr.Error()),
)
next, submitErr := s.service.SubmitRun(r.Context(), retryReq)
if submitErr != nil {
@@ -293,9 +297,9 @@ func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request,
s.logger.Warn("openai chat completion tool validation failed",
zap.String("run_id", failedRunID),
zap.Int("attempt", attempt),
- zap.String("reason", err.Error()),
+ zap.String("reason", valErr.Error()),
)
- writeError(w, http.StatusBadGateway, "tool_validation_error", err.Error())
+ writeError(w, http.StatusBadGateway, "tool_validation_error", valErr.Error())
return
}
handle.Close()
@@ -327,18 +331,19 @@ func (s *Server) completeChatCompletion(w http.ResponseWriter, r *http.Request,
}
type chatCompletionOutput struct {
- message chatMessage
- finishReason string
- usage *openAIUsage
- normalized bool
- contentLen int
- reasoningLen int
- toolCalls []any
- toolCallOrigin string
+ message chatMessage
+ finishReason string
+ usage *openAIUsage
+ normalized bool
+ contentLen int
+ reasoningLen int
+ toolCalls []any
+ toolCallOrigin string
+ toolValidationErr error
}
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())
+ text, reasoning, finishReason, nativeToolCalls, usage, _, err := collectRunResult(ctx, handle.Stream(), handle.WaitTimeout())
if err != nil {
return chatCompletionOutput{}, err
}
@@ -350,19 +355,23 @@ func collectChatCompletionOutput(ctx context.Context, req chatCompletionRequest,
}
var toolCalls []any
toolCallOrigin := ""
+ var toolValidationErr error
+
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, synthesizedToolCalls := synthesizeToolCallsFromText(text, req.Tools, handle.Dispatch().RunID)
- if len(synthesizedToolCalls) > 0 {
- message.Content = cleaned
+ } else if len(req.Tools) > 0 {
+ res := synthesizeToolCallsFromTextResult(text, req.Tools, handle.Dispatch().RunID)
+ if res.validationErr != nil {
+ toolValidationErr = res.validationErr
+ } else if len(res.toolCalls) > 0 {
+ message.Content = res.cleaned
message.ReasoningContent = ""
- message.ToolCalls = synthesizedToolCalls
- toolCalls = synthesizedToolCalls
+ message.ToolCalls = res.toolCalls
+ toolCalls = res.toolCalls
toolCallOrigin = toolCallOriginText
finishReason = "tool_calls"
}
@@ -375,14 +384,15 @@ func collectChatCompletionOutput(ctx context.Context, req chatCompletionRequest,
}
}
return chatCompletionOutput{
- message: message,
- finishReason: finishReason,
- usage: usage,
- normalized: normalized,
- contentLen: len(message.Content),
- reasoningLen: len(reasoning),
- toolCalls: toolCalls,
- toolCallOrigin: toolCallOrigin,
+ message: message,
+ finishReason: finishReason,
+ usage: usage,
+ normalized: normalized,
+ contentLen: len(message.Content),
+ reasoningLen: len(reasoning),
+ toolCalls: toolCalls,
+ toolCallOrigin: toolCallOrigin,
+ toolValidationErr: toolValidationErr,
}, nil
}
diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go
index fb511d8..358062d 100644
--- a/apps/edge/internal/openai/server_test.go
+++ b/apps/edge/internal/openai/server_test.go
@@ -511,7 +511,7 @@ func TestChatCompletionsSynthesizesToolCallsFromClineTemplateCall(t *testing.T)
}
}
-func TestChatCompletionsDoesNotSynthesizeTextToolCallsForProviderRoute(t *testing.T) {
+func TestChatCompletionsSynthesizesTextToolCallsForProviderRoute(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n\n\n\n[\"git status\"]\n\n\n"}
fake.events <- &iop.RunEvent{Type: "complete"}
@@ -535,11 +535,11 @@ func TestChatCompletionsDoesNotSynthesizeTextToolCallsForProviderRoute(t *testin
t.Fatalf("decode response: %v", err)
}
choice := resp.Choices[0]
- if choice.FinishReason == "tool_calls" || len(choice.Message.ToolCalls) != 0 {
- t.Fatalf("provider text must not be synthesized: %+v", choice)
+ if choice.FinishReason != "tool_calls" || len(choice.Message.ToolCalls) == 0 {
+ t.Fatalf("provider text should be synthesized: %+v", choice)
}
- if !strings.Contains(choice.Message.Content, "") {
- t.Fatalf("provider raw content should be preserved: %q", choice.Message.Content)
+ if strings.Contains(choice.Message.Content, "") {
+ t.Fatalf("provider raw content should not be preserved: %q", choice.Message.Content)
}
}
@@ -1408,9 +1408,15 @@ func TestSynthesizesTemplateToolCallWithClosingMarkerInString(t *testing.T) {
}
func TestChatCompletionsLeavesUnknownTextToolCallAsContent(t *testing.T) {
- fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
- fake.events <- &iop.RunEvent{Type: "delta", Delta: "true"}
- fake.events <- &iop.RunEvent{Type: "complete"}
+ run1Events := make(chan *iop.RunEvent, 2)
+ run1Events <- &iop.RunEvent{Type: "delta", Delta: "true"}
+ run1Events <- &iop.RunEvent{Type: "complete"}
+
+ run2Events := make(chan *iop.RunEvent, 2)
+ run2Events <- &iop.RunEvent{Type: "delta", Delta: "true"}
+ run2Events <- &iop.RunEvent{Type: "complete"}
+
+ fake := &fakeRunService{eventRuns: []chan *iop.RunEvent{run1Events, run2Events}}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
@@ -1422,25 +1428,31 @@ func TestChatCompletionsLeavesUnknownTextToolCallAsContent(t *testing.T) {
srv.handleChatCompletions(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
+ if w.Code != http.StatusBadGateway {
+ t.Fatalf("status: got %d, want 502; body=%s", w.Code, w.Body.String())
}
- var resp chatCompletionResponse
+ var resp errorResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
- if resp.Choices[0].FinishReason == "tool_calls" || len(resp.Choices[0].Message.ToolCalls) != 0 {
- t.Fatalf("unexpected tool call conversion: %+v", resp.Choices[0])
+ if resp.Error.Type != "tool_validation_error" {
+ t.Fatalf("unexpected error type: got %q, want tool_validation_error", resp.Error.Type)
}
- if !strings.Contains(resp.Choices[0].Message.Content, "") {
- t.Fatalf("raw unknown tool call should remain content: %q", resp.Choices[0].Message.Content)
+ if !strings.Contains(resp.Error.Message, "delete_everything") {
+ t.Fatalf("unexpected error message: %q", resp.Error.Message)
}
}
func TestChatCompletionsLeavesUnknownTemplateToolCallAsContent(t *testing.T) {
- fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
- fake.events <- &iop.RunEvent{Type: "delta", Delta: "{{delete_everything(confirm=True)}}"}
- fake.events <- &iop.RunEvent{Type: "complete"}
+ run1Events := make(chan *iop.RunEvent, 2)
+ run1Events <- &iop.RunEvent{Type: "delta", Delta: "{{delete_everything(confirm=True)}}"}
+ run1Events <- &iop.RunEvent{Type: "complete"}
+
+ run2Events := make(chan *iop.RunEvent, 2)
+ run2Events <- &iop.RunEvent{Type: "delta", Delta: "{{delete_everything(confirm=True)}}"}
+ run2Events <- &iop.RunEvent{Type: "complete"}
+
+ fake := &fakeRunService{eventRuns: []chan *iop.RunEvent{run1Events, run2Events}}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
@@ -1452,18 +1464,18 @@ func TestChatCompletionsLeavesUnknownTemplateToolCallAsContent(t *testing.T) {
srv.handleChatCompletions(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
+ if w.Code != http.StatusBadGateway {
+ t.Fatalf("status: got %d, want 502; body=%s", w.Code, w.Body.String())
}
- var resp chatCompletionResponse
+ var resp errorResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
- if resp.Choices[0].FinishReason == "tool_calls" || len(resp.Choices[0].Message.ToolCalls) != 0 {
- t.Fatalf("unexpected tool call conversion: %+v", resp.Choices[0])
+ if resp.Error.Type != "tool_validation_error" {
+ t.Fatalf("unexpected error type: got %q, want tool_validation_error", resp.Error.Type)
}
- if !strings.Contains(resp.Choices[0].Message.Content, "{{delete_everything") {
- t.Fatalf("raw unknown template tool call should remain content: %q", resp.Choices[0].Message.Content)
+ if !strings.Contains(resp.Error.Message, "delete_everything") {
+ t.Fatalf("unexpected error message: %q", resp.Error.Message)
}
}
@@ -3797,3 +3809,347 @@ func TestChatCompletionsProviderPoolFallsBackToLegacyRoute(t *testing.T) {
t.Errorf("Target: got %q, want llama3", fake.req.Target)
}
}
+
+func TestChatCompletionsRetriesUnknownTextToolCallBeforeResponse(t *testing.T) {
+ // Attempt 1: unknown tool call (triggers retry)
+ run1Events := make(chan *iop.RunEvent, 2)
+ run1Events <- &iop.RunEvent{Type: "delta", Delta: "true"}
+ run1Events <- &iop.RunEvent{Type: "complete"}
+
+ // Attempt 2: valid tool call (succeeds)
+ run2Events := make(chan *iop.RunEvent, 2)
+ run2Events <- &iop.RunEvent{Type: "delta", Delta: "[\"ls\"]\n"}
+ run2Events <- &iop.RunEvent{Type: "complete"}
+
+ fake := &fakeRunService{eventRuns: []chan *iop.RunEvent{run1Events, run2Events}}
+ srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, 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"}}}}}}]
+ }`))
+ w := httptest.NewRecorder()
+
+ srv.handleChatCompletions(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
+ }
+ var resp chatCompletionResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(resp.Choices) == 0 {
+ t.Fatal("empty choices")
+ }
+ choice := resp.Choices[0]
+ if choice.FinishReason != "tool_calls" || len(choice.Message.ToolCalls) == 0 {
+ t.Fatalf("expected synthesized tool calls: %+v", choice)
+ }
+}
+
+func TestChatCompletionsFailsUnknownTextToolCallAfterRetryLimit(t *testing.T) {
+ run1Events := make(chan *iop.RunEvent, 2)
+ run1Events <- &iop.RunEvent{Type: "delta", Delta: "val"}
+ run1Events <- &iop.RunEvent{Type: "complete"}
+
+ run2Events := make(chan *iop.RunEvent, 2)
+ run2Events <- &iop.RunEvent{Type: "delta", Delta: "val"}
+ run2Events <- &iop.RunEvent{Type: "complete"}
+
+ fake := &fakeRunService{eventRuns: []chan *iop.RunEvent{run1Events, run2Events}}
+ srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, 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"}}]
+ }`))
+ w := httptest.NewRecorder()
+
+ srv.handleChatCompletions(w, req)
+
+ if w.Code != http.StatusBadGateway {
+ t.Fatalf("status: got %d, want 502; body=%s", w.Code, w.Body.String())
+ }
+ var resp errorResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if resp.Error.Type != "tool_validation_error" {
+ t.Fatalf("unexpected error type: got %q, want tool_validation_error", resp.Error.Type)
+ }
+}
+
+func TestChatCompletionsProviderStreamSynthesizesTextToolCalls(t *testing.T) {
+ fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
+ fake.events <- &iop.RunEvent{Type: "delta", Delta: "thought text "}
+ fake.events <- &iop.RunEvent{Type: "delta", Delta: "[\"echo 1\"]"}
+ fake.events <- &iop.RunEvent{Type: "complete"}
+
+ 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"}}}],
+ "stream": true
+ }`))
+ 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, `"tool_calls"`) {
+ t.Fatalf("expected stream to contain tool_calls: %s", body)
+ }
+ if strings.Contains(body, `""`) {
+ t.Fatalf("stream should not contain raw tool_call block: %s", body)
+ }
+}
+
+func TestChatCompletionsProviderStreamBlocksUnknownTextToolCall(t *testing.T) {
+ fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
+ fake.events <- &iop.RunEvent{Type: "delta", Delta: "thought text "}
+ fake.events <- &iop.RunEvent{Type: "delta", Delta: "val"}
+ fake.events <- &iop.RunEvent{Type: "complete"}
+
+ 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"}}],
+ "stream": true
+ }`))
+ 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, "tool_validation_error") {
+ t.Fatalf("expected stream to end with tool_validation_error: %s", body)
+ }
+ if strings.Contains(body, `\u003ctool_call\u003e`) {
+ t.Fatalf("unknown tool call leaked as content: %s", body)
+ }
+}
+
+func TestChatCompletionsStrictBufferedStreamFailsUnknownTextToolCallAfterRetryLimit(t *testing.T) {
+ run1Events := make(chan *iop.RunEvent, 2)
+ run1Events <- &iop.RunEvent{Type: "delta", Delta: "val"}
+ run1Events <- &iop.RunEvent{Type: "complete"}
+
+ run2Events := make(chan *iop.RunEvent, 2)
+ run2Events <- &iop.RunEvent{Type: "delta", Delta: "val"}
+ run2Events <- &iop.RunEvent{Type: "complete"}
+
+ fake := &fakeRunService{eventRuns: []chan *iop.RunEvent{run1Events, run2Events}}
+ srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex", StrictOutput: true, StrictStreamBuffer: true}, 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"}}],
+ "stream": true
+ }`))
+ 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, "tool_validation_error") {
+ t.Fatalf("expected stream to end with tool_validation_error: %s", body)
+ }
+}
+
+func TestChatCompletionsFailsMalformedTextToolCallAfterRetryLimit(t *testing.T) {
+ invalidComplete := func() chan *iop.RunEvent {
+ ch := make(chan *iop.RunEvent, 2)
+ ch <- &iop.RunEvent{Type: "delta", Delta: "여기 툴 콜입니다. "}
+ ch <- &iop.RunEvent{Type: "complete"}
+ return ch
+ }
+ fake := &fakeRunService{
+ eventRuns: []chan *iop.RunEvent{
+ invalidComplete(),
+ invalidComplete(),
+ },
+ 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"}}}],
+ "tool_choice":"auto"
+ }`))
+ 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(), "malformed tool call: unclosed tool_call block") {
+ 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 TestChatCompletionsProviderStreamBlocksMalformedTextToolCall(t *testing.T) {
+ fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
+ fake.events <- &iop.RunEvent{Type: "delta", Delta: "이것은 정상 텍스트 "}
+ fake.events <- &iop.RunEvent{Type: "delta", Delta: ""}
+ fake.events <- &iop.RunEvent{Type: "complete"}
+
+ 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"}}}],
+ "tool_choice":"auto",
+ "stream": true
+ }`))
+ 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, "["ls"]"/a/b"`}
+ ch <- &iop.RunEvent{Type: "complete"}
+ return ch
+ }
+ fake := &fakeRunService{
+ eventRuns: []chan *iop.RunEvent{
+ invalidComplete(),
+ invalidComplete(),
+ },
+ 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"]}}},
+ {"type":"function","function":{"name":"read_file","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}}
+ ],
+ "tool_choice":"auto"
+ }`))
+ 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"`) {
+ t.Fatalf("expected tool validation error body, got %s", w.Body.String())
+ }
+}
diff --git a/apps/edge/internal/openai/stream.go b/apps/edge/internal/openai/stream.go
index c8312b6..e14bbad 100644
--- a/apps/edge/internal/openai/stream.go
+++ b/apps/edge/internal/openai/stream.go
@@ -218,10 +218,14 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
}
writeToolCallsDeltaSSE(w, flusher, id, created, model, nativeToolCalls)
finishReason = "tool_calls"
- } else if toolTextFilter != nil && shouldSynthesizeTextToolCalls(handle.Dispatch(), isTextToolFallback(metadata)) {
- cleaned, toolCalls := synthesizeToolCallsFromText(contentBuilder.String(), req.Tools, handle.Dispatch().RunID)
- if len(toolCalls) > 0 {
- if remaining := unstreamedCleanedText(cleaned, emittedContent.String()); remaining != "" {
+ } else if len(req.Tools) > 0 {
+ res := synthesizeToolCallsFromTextResult(contentBuilder.String(), req.Tools, handle.Dispatch().RunID)
+ if res.validationErr != nil {
+ writeSSEErrorWithType(w, flusher, "tool_validation_error", res.validationErr.Error())
+ return
+ }
+ if len(res.toolCalls) > 0 {
+ if remaining := unstreamedCleanedText(res.cleaned, emittedContent.String()); remaining != "" {
emittedContent.WriteString(remaining)
writeTracedContentDelta(remaining, remaining, false)
}
@@ -230,14 +234,18 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re
zap.String("run_id", handle.Dispatch().RunID),
zap.Int("seq", traceSeq+1),
zap.String("type", "tool_calls"),
- zap.Int("tool_call_count", len(toolCalls)),
+ zap.Int("tool_call_count", len(res.toolCalls)),
)
}
- writeToolCallsDeltaSSE(w, flusher, id, created, model, toolCalls)
+ writeToolCallsDeltaSSE(w, flusher, id, created, model, res.toolCalls)
finishReason = "tool_calls"
- } else if pending := toolTextFilter.Flush(); pending != "" {
- emittedContent.WriteString(pending)
- writeTracedContentDelta(pending, pending, false)
+ } else if !res.candidateFound {
+ if toolTextFilter != nil {
+ if pending := toolTextFilter.Flush(); pending != "" {
+ emittedContent.WriteString(pending)
+ writeTracedContentDelta(pending, pending, false)
+ }
+ }
}
} else if toolTextFilter != nil {
if pending := toolTextFilter.Flush(); pending != "" {
@@ -426,7 +434,11 @@ func (s *Server) streamBufferedChatCompletion(w http.ResponseWriter, r *http.Req
writeSSEError(w, flusher, err.Error())
return
}
- if verr := validateToolCallResponse(validation, result.toolCalls, result.toolCallOrigin); verr != nil {
+ verr := result.toolValidationErr
+ if verr == nil {
+ verr = validateToolCallResponse(validation, result.toolCalls, result.toolCallOrigin)
+ }
+ if verr != nil {
failedRunID := handle.Dispatch().RunID
if attempt < maxToolValidationAttempts {
handle.Close()
diff --git a/apps/edge/internal/openai/tool_validation.go b/apps/edge/internal/openai/tool_validation.go
index 7d114e4..c336496 100644
--- a/apps/edge/internal/openai/tool_validation.go
+++ b/apps/edge/internal/openai/tool_validation.go
@@ -87,10 +87,12 @@ func validateToolCallResponse(contract toolValidationContract, toolCalls []any,
if err != nil {
return err
}
- if len(candidates) == 0 {
- return nil
+ for i, candidate := range candidates {
+ if err := validateToolCallCandidate(contract, candidate); err != nil {
+ return fmt.Errorf("tool call %d: %w", i, err)
+ }
}
- return validateToolCallCandidate(contract, candidates[len(candidates)-1])
+ return nil
}
func toolCallCandidatesFromResponse(toolCalls []any, origin string) ([]toolCallCandidate, error) {
diff --git a/apps/edge/internal/openai/types.go b/apps/edge/internal/openai/types.go
index 15f7fe3..7bb76f1 100644
--- a/apps/edge/internal/openai/types.go
+++ b/apps/edge/internal/openai/types.go
@@ -212,6 +212,7 @@ func contentToString(v any) string {
var (
textToolCallBlockRE = regexp.MustCompile(`(?is)<\s*tool_call\s*>(.*?)<\s*/\s*tool_call\s*>`)
+ textToolCallOpenRE = regexp.MustCompile(`(?is)<\s*tool_call(?:\s*[^>]*)?>`)
textToolFunctionRE = regexp.MustCompile(`(?is)<\s*function\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*function(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`)
textToolParameterRE = regexp.MustCompile(`(?is)<\s*parameter\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*parameter(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`)
textToolCallOpeningHint = "= rawMatch[0] && openMatch[0] < rawMatch[1] {
+ inBlock = true
+ break
+ }
+ }
+ if !inBlock {
+ out = append(out, candidateMatch{
+ start: openMatch[0],
+ end: len(content),
+ validationErr: fmt.Errorf("malformed tool call: unclosed tool_call block"),
+ })
+ }
+ }
+ return out, foundAny
+}
+
+func collectMustacheCandidateMatches(content string, specs map[string]textToolSpec, names map[string]struct{}) ([]candidateMatch, bool) {
+ if !strings.Contains(content, textMustacheToolCallOpenHint) {
+ return nil, false
+ }
+ out := make([]candidateMatch, 0)
+ foundAny := false
+ for start := 0; start < len(content); {
+ open := strings.Index(content[start:], textMustacheToolCallOpenHint)
+ if open < 0 {
+ break
+ }
+ blockStart := start + open
+ nameStart := skipASCIISpaces(content, blockStart+len(textMustacheToolCallOpenHint))
+ nameEnd := nameStart
+ for nameEnd < len(content) && isTextToolIdentifierChar(content[nameEnd], nameEnd == nameStart) {
+ nameEnd++
+ }
+ name := content[nameStart:nameEnd]
+ if name == "" {
+ start = blockStart + len(textMustacheToolCallOpenHint)
+ continue
+ }
+ callOpen := skipASCIISpaces(content, nameEnd)
+ if callOpen >= len(content) || content[callOpen] != '(' {
+ start = blockStart + len(textMustacheToolCallOpenHint)
+ continue
+ }
+ foundAny = true
+ argsStart := callOpen + 1
+ argsEnd, blockEnd, ok := findMustacheToolCallEnd(content, argsStart)
+ if !ok {
+ out = append(out, candidateMatch{
+ start: blockStart,
+ end: len(content),
+ name: name,
+ validationErr: fmt.Errorf("malformed tool call: unclosed mustache tool call %q", name),
+ })
+ break
+ }
+ if _, ok := names[name]; !ok {
+ out = append(out, candidateMatch{
+ start: blockStart,
+ end: blockEnd,
+ name: name,
+ validationErr: fmt.Errorf("tool %q is not in request tools", name),
+ })
+ start = blockEnd
+ continue
+ }
+ spec := specs[name]
+ args, ok := parseMustacheToolCallArguments(content[argsStart:argsEnd])
+ if !ok {
+ out = append(out, candidateMatch{
+ start: blockStart,
+ end: blockEnd,
+ name: name,
+ validationErr: fmt.Errorf("failed to parse arguments for tool %q", name),
+ })
+ start = blockEnd
+ continue
+ }
+ args = normalizeTextToolCallArguments(name, args, spec)
+ encoded, err := json.Marshal(args)
+ if err != nil {
+ out = append(out, candidateMatch{
+ start: blockStart,
+ end: blockEnd,
+ name: name,
+ validationErr: fmt.Errorf("failed to marshal arguments for tool %q: %w", name, err),
+ })
+ start = blockEnd
+ continue
+ }
+ out = append(out, candidateMatch{
+ start: blockStart,
+ end: blockEnd,
+ name: name,
+ arguments: string(encoded),
+ })
+ start = blockEnd
+ }
+ return out, foundAny
+}
+
+func synthesizeToolCallsFromTextResult(content string, tools []any, runID string) textToolSynthesisResult {
specs := requestedToolSpecs(tools)
- if len(specs) == 0 {
- return content, nil
- }
- matches := collectTextToolCallMatches(content, specs)
- if len(matches) == 0 {
- return content, nil
- }
+ names := requestedToolNames(tools)
+
+ xmlMatches, xmlFound := collectXMLCandidateMatches(content, specs, names)
+ mustacheMatches, mustacheFound := collectMustacheCandidateMatches(content, specs, names)
+ candidateFound := xmlFound || mustacheFound
+
+ var matches []candidateMatch
+ matches = append(matches, xmlMatches...)
+ matches = append(matches, mustacheMatches...)
+
+ sort.SliceStable(matches, func(i, j int) bool {
+ if matches[i].start == matches[j].start {
+ return matches[i].end > matches[j].end
+ }
+ return matches[i].start < matches[j].start
+ })
var cleaned strings.Builder
cursor := 0
- toolCalls := make([]any, 0, len(matches))
+ toolCalls := make([]any, 0)
+ var firstErr error
+
for _, match := range matches {
if match.start < cursor {
continue
}
+ if match.validationErr != nil {
+ if firstErr == nil {
+ firstErr = match.validationErr
+ }
+ continue
+ }
cleaned.WriteString(content[cursor:match.start])
cursor = match.end
toolCalls = append(toolCalls, map[string]any{
@@ -265,11 +459,40 @@ func synthesizeToolCallsFromText(content string, tools []any, runID string) (str
},
})
}
+
+ if firstErr != nil {
+ return textToolSynthesisResult{
+ cleaned: content,
+ toolCalls: nil,
+ candidateFound: candidateFound,
+ validationErr: firstErr,
+ }
+ }
+
if len(toolCalls) == 0 {
+ return textToolSynthesisResult{
+ cleaned: content,
+ toolCalls: nil,
+ candidateFound: candidateFound,
+ validationErr: nil,
+ }
+ }
+
+ cleaned.WriteString(content[cursor:])
+ return textToolSynthesisResult{
+ cleaned: strings.TrimSpace(cleaned.String()),
+ toolCalls: toolCalls,
+ candidateFound: candidateFound,
+ validationErr: nil,
+ }
+}
+
+func synthesizeToolCallsFromText(content string, tools []any, runID string) (string, []any) {
+ res := synthesizeToolCallsFromTextResult(content, tools, runID)
+ if res.validationErr != nil {
return content, nil
}
- cleaned.WriteString(content[cursor:])
- return strings.TrimSpace(cleaned.String()), toolCalls
+ return res.cleaned, res.toolCalls
}
func collectTextToolCallMatches(content string, specs map[string]textToolSpec) []textToolCallMatch {