diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md index 3917e11..b02f53e 100644 --- a/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md @@ -23,7 +23,7 @@ Ollama serving 경로와 운영 기반이 안정화된 뒤, 단계 호출, tool/ - 경로: `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 +- [진행중] OpenAI-compatible Tool Call Boundary Hardening - 경로: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md` - 요약: provider-pool/OpenAI-compatible 응답에서 raw text tool-call block, unknown tool name, chat-template sentinel token이 클라이언트 화면으로 새지 않도록 Edge tool-call 경계를 검증/정규화한다. diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md index 38cd215..23abb93 100644 --- a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md @@ -12,7 +12,7 @@ OpenAI-compatible `tools[]`가 있는 요청에서 provider-pool 또는 provider ## 상태 -[검토중] +[진행중] ## 승격 조건 @@ -51,15 +51,15 @@ OpenAI-compatible provider 응답의 tool-call 후보를 요청 schema 기준으 ## 완료 리뷰 -- 상태: 검토중 +- 상태: 보완 필요 - 요청일: 2026-07-04 -- 완료 근거: `01_provider_text_boundary/complete.log`와 `02+01_contract_dev_smoke/complete.log`가 모든 기능 Task id의 PASS, Go 검증, dev-runtime smoke evidence를 기록한다. 현 파일/git sanity 확인에서도 `apps/edge/internal/openai`, `agent-contract/outer/openai-compatible-api.md`, `docs/edge-local-dev-guide.md`, `agent-test/dev/edge-smoke.md`가 해당 정책과 테스트를 유지한다. +- 완료 근거: `01_provider_text_boundary/complete.log`와 `02+01_contract_dev_smoke/complete.log`가 모든 기능 Task id의 PASS, Go 검증, dev-runtime smoke evidence를 기록한다. 다만 종료 전 코드레벨 재검토에서 native stream `tool_calls`와 raw text ``이 같이 올 때 보류 raw candidate가 flush될 수 있는 작은 누수 가능성을 local guard/test로 보강했으며, 이 guard가 반영된 source 기준 dev-runtime credentialed smoke는 아직 재수집되지 않았다. - 검토 항목: - [x] valid raw text tool call은 구조화되고 raw block이 노출되지 않는다 - [x] unknown/malformed tool call은 성공 content로 노출되지 않는다 - [x] Pi/Cline형 OpenAI-compatible tools smoke 근거가 남아 있다 - agent-ui 상태 반영: 해당 없음 -- 리뷰 코멘트: 기능 Task와 구현 잠금은 완료 후보 조건을 충족했으며, `[완료]` 전환과 archive 이동은 별도 완료 승인 후 처리한다. +- 리뷰 코멘트: `agent-task/m-openai-compatible-tool-call-boundary-hardening/03+02_closure_verification/PLAN-cloud-G07.md`에서 guarded source sync, dev-runtime rebuild/refresh, Pi/Cline형 non-stream/stream smoke evidence 재수집을 완료한 뒤 `[완료]` 전환과 archive 이동을 다시 판단한다. ## 범위 제외 diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 358062d..231e227 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -1700,6 +1700,107 @@ func TestChatCompletionsStreamPassesThroughNativeToolCallsFromProviderRoute(t *t } } +func TestChatCompletionsStreamDropsRawTextWhenNativeToolCallsArrive(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: `["git status"]`} + fake.events <- &iop.RunEvent{ + Type: "complete", + Metadata: map[string]string{ + "finish_reason": "tool_calls", + runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","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":"stream-model", + "stream":true, + "messages":[{"role":"user","content":"status"}], + "tools":[{"type":"function","function":{"name":"run_commands"}}], + "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()) + } + body := w.Body.String() + if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"call_1"`) { + t.Fatalf("stream did not pass through native tool_calls:\n%s", body) + } + if strings.Contains(body, " 0 { if toolTextFilter != nil { - pending := toolTextFilter.Flush() + pending := toolTextFilter.FlushNonCandidate() if pending != "" { emittedContent.WriteString(pending) writeTracedContentDelta(pending, pending, false) @@ -330,6 +330,21 @@ func (f *streamToolTextFilter) Flush() string { return out } +func (f *streamToolTextFilter) FlushNonCandidate() string { + if firstStreamToolTextCandidateIndex(f.pending) >= 0 { + f.pending = "" + return "" + } + // Even without a full candidate, chunk-boundary protection can leave a + // partial candidate suffix (e.g. "