diff --git a/.pi/iop-rule-loader.ts b/.pi/iop-rule-loader.ts deleted file mode 100644 index 0a9de53..0000000 --- a/.pi/iop-rule-loader.ts +++ /dev/null @@ -1,123 +0,0 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; - -type RuleSpec = { - relativePath: string; - enabled?: (projectRoot: string) => boolean; -}; - -type LoadedRule = { - relativePath: string; - content: string; -}; - -const commonRules: RuleSpec[] = [ - { relativePath: "agent-ops/rules/common/rules.md" }, -]; - -const commonRequiredSessionRules: RuleSpec[] = [ - { relativePath: "agent-ops/rules/project/rules.md" }, - { relativePath: "agent-ops/rules/private/rules.md" }, - { - relativePath: "agent-ops/rules/common/rules-roadmap.md", - enabled: (projectRoot) => fs.existsSync(path.join(projectRoot, "agent-roadmap")), - }, -]; - -function findProjectRoot(startDir: string): string { - let current = path.resolve(startDir); - - for (;;) { - const marker = path.join(current, "agent-ops", "rules", "common", "rules.md"); - if (fs.existsSync(marker)) { - return current; - } - - const parent = path.dirname(current); - if (parent === current) { - return path.resolve(startDir); - } - current = parent; - } -} - -function loadEntryRules(projectRoot: string): LoadedRule[] { - return [...commonRules, ...commonRequiredSessionRules].flatMap((rule) => { - if (rule.enabled && !rule.enabled(projectRoot)) { - return []; - } - - const fullPath = path.join(projectRoot, rule.relativePath); - if (!fs.existsSync(fullPath)) { - return []; - } - - return [ - { - relativePath: rule.relativePath, - content: fs.readFileSync(fullPath, "utf8").trimEnd(), - }, - ]; - }); -} - -function buildRulesPrompt(projectRoot: string, rules: LoadedRule[]): string { - const blocks = rules - .map((rule) => { - const tag = rule.relativePath === "agent-ops/rules/common/rules.md" - ? "common_rule" - : "common_required_session_rule"; - - return `<${tag} path="${rule.relativePath}"> -${rule.content} -`; - }) - .join("\n\n"); - - return `## Agent-Ops Common Rules Bootstrap - -아래 파일들은 이 turn 전에 ${projectRoot}에서 자동으로 로드되었다. - -- agent-ops/rules/common/rules.md -- agent-ops/rules/project/rules.md, 파일이 있으면 로드 -- agent-ops/rules/private/rules.md, 파일이 있으면 로드 -- agent-ops/rules/common/rules-roadmap.md, agent-roadmap/ 디렉터리가 있으면 로드 - -공통룰이 요구하는 순서대로 위 파일들을 이미 읽은 것으로 취급한다. 작업 행동 전에 AGENTS.md와 함께 이 규칙을 따른다. 이후 domain rule, skill, contract, test rule, agent-ui rule, philosophy, archive 접근 규칙의 트리거 조건이 발생하면 행동 전에 매칭되는 파일을 추가로 로드한다. - -${blocks}`; -} - -export default function iopRuleLoader(pi: ExtensionAPI) { - let projectRoot = ""; - let loadedRules: LoadedRule[] = []; - - pi.on("session_start", async (_event, ctx) => { - projectRoot = findProjectRoot(ctx.cwd); - loadedRules = loadEntryRules(projectRoot); - - if (loadedRules.length > 0) { - const names = loadedRules.map((rule) => rule.relativePath).join(", "); - ctx.ui.notify(`Loaded Agent-Ops common rules: ${names}`, "info"); - } - }); - - pi.on("before_agent_start", async (event, ctx) => { - const root = projectRoot || findProjectRoot(ctx.cwd); - const rules = loadEntryRules(root); - - if (rules.length === 0) { - return; - } - - loadedRules = rules; - projectRoot = root; - - return { - systemPrompt: `${event.systemPrompt} - -${buildRulesPrompt(projectRoot, loadedRules)}`, - }; - }); -} diff --git a/.pi/settings.json b/.pi/settings.json index e958630..98cd4a6 100644 --- a/.pi/settings.json +++ b/.pi/settings.json @@ -1,3 +1,3 @@ { - "extensions": ["iop-rule-loader.ts"] + "extensions": [] } 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 59882ff..927fd04 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 @@ -44,7 +44,7 @@ OpenAI-compatible provider 응답의 tool-call 후보를 요청 schema 기준으 - [ ] [provider-raw-parse] provider-pool/provider route에서도 요청 `tools[]`가 있고 assistant content에 raw `` block이 있으면 기존 text tool-call parser를 적용한다. 검증: `bash` raw block은 `message.tool_calls` 또는 stream `delta.tool_calls`로 정규화되고 raw block이 content에 남지 않는다. - [ ] [unknown-tool-error] raw tool-call block의 function name이 요청 tool 목록에 없으면 일반 assistant text로 flush하지 않고 validation error 또는 bounded retry로 처리한다. 검증: `TOOL_CALLS.push_changes` 형태의 unknown tool output이 Pi/Cline 화면으로 노출되지 않는다. - [ ] [stream-flush-guard] streaming 경로에서 tool-call 후보를 보류한 뒤 terminal event에서 구조화/차단하며, 실패한 raw block을 마지막 flush로 내보내지 않는다. 검증: live SSE와 buffered SSE 테스트가 모두 raw `` leak 없이 `tool_calls` 또는 error로 종료된다. -- [ ] [sentinel-sanitize] provider output normalization이 `<|mask_end|>` 같은 알려진 chat-template sentinel token을 assistant content/reasoning 최종 출력에서 제거한다. 검증: sentinel token이 tool-call 뒤나 단독 content로 들어와도 OpenAI-compatible 응답에 남지 않는다. +- [x] [sentinel-sanitize] provider output normalization이 `<|mask_end|>` 같은 알려진 chat-template sentinel token을 assistant content/reasoning 최종 출력에서 제거한다. 검증: sentinel token이 tool-call 뒤나 단독 content로 들어와도 OpenAI-compatible 응답에 남지 않는다. - [ ] [native-preserve] provider가 이미 native OpenAI-compatible `tool_calls`를 반환하는 경로는 기존 pass-through와 schema-normalization 동작을 유지한다. 검증: native `tool_calls` server tests와 forced/auto direct tool-call smoke가 회귀 없이 통과한다. - [ ] [client-smoke] Pi/Cline형 긴 agent prompt와 `tools[]` 요청을 직접 Edge에 호출해 unknown tool hallucination이 raw text로 노출되지 않는 근거를 남긴다. 검증: 재현 요청, response finish reason/error type, Edge trace를 `agent-test/dev`에 기록한다. - [ ] [contract-docs] OpenAI-compatible 계약 문서와 dev 운영 문서가 provider raw tool-call 후보의 정규화/차단, unknown tool 처리, sentinel sanitize 정책을 설명한다. @@ -76,7 +76,7 @@ OpenAI-compatible provider 응답의 tool-call 후보를 요청 schema 기준으 - 표준선(선택): provider-pool은 native `tool_calls`만 신뢰하지 않고, raw text tool-call 후보가 보이면 같은 OpenAI-compatible boundary 정책으로 검증한다. - 관측 메모(2026-07-04): 현재 구현/계약은 provider route 또는 provider-pool에서 fallback marker 없이 assistant content에 raw `` text가 들어온 경우 이를 `tool_calls`로 합성하지 않고 성공 content로 보존한다. 로컬 단위 재현은 `TestChatCompletionsDoesNotSynthesizeTextToolCallsForProviderRoute`가 고정하고 있다. - 관측 메모(2026-07-04): unknown text tool-call도 현재는 client-visible content로 남는다. `TestChatCompletionsLeavesUnknownTextToolCallAsContent`와 `TestChatCompletionsLeavesUnknownTemplateToolCallAsContent`가 이 동작을 고정한다. -- 관측 메모(2026-07-04): `<|mask_end|>` 같은 chat-template sentinel token 제거 경로는 현재 코드 검색 기준 확인되지 않았다. strict output normalization은 think tag/code fence/XML 정리에 머문다. +- 완료 메모(2026-07-04): [sentinel-sanitize]는 `apps/edge/internal/openai/strict_output.go`, `stream.go`, `server_test.go`에서 non-stream Chat Completions, live SSE, Responses 출력의 `<|mask_end|>` 제거와 tool-call 뒤 sentinel 제거 회귀 테스트를 추가해 완료했다. 검증: `go test ./apps/edge/internal/openai`, `go test ./apps/edge/...`. - 관측 메모(2026-07-04): dev-runtime Edge health는 응답했지만 `/v1/models`는 Bearer 인증을 요구해 secret 없이 live provider 재현은 수행하지 않았다. 실제 Pi/Cline형 긴 prompt에서 모델이 raw block을 자발 출력하는지는 credentialed client smoke로 확인해야 한다. - 선행 작업: Tool Call Runtime 검증 재시도 MVP, OpenAI-compatible Think 제어 MVP에서 확인된 provider-pool/Pi raw leak 진단 - 후속 작업: Tool Call 판정 모델 Gate 리뷰, 요청 실행 로그와 Usage Ledger 기반 diff --git a/agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/CODE_REVIEW-local-G07.md b/agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/CODE_REVIEW-local-G07.md new file mode 100644 index 0000000..1b9e6fc --- /dev/null +++ b/agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/CODE_REVIEW-local-G07.md @@ -0,0 +1,154 @@ + + +# 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-04 +task=m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary, plan=0, tag=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 + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[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/`로 이동한다. +4. PASS이고 task group이 `m-openai-compatible-tool-call-boundary-hardening`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Text Tool Candidate Result | [ ] | +| [API-2] Non-Stream Provider Text Boundary | [ ] | +| [API-3] Streaming Flush Guard | [ ] | +| [API-4] Native Preserve Regression | [ ] | + +## 구현 체크리스트 + +- [ ] 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의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[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하는지 확인한다. +- [ ] 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`를 작성하지 않는다. +- [ ] 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로 이동한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- provider route/provider-pool raw valid text tool-call이 content가 아니라 `tool_calls`로 반환되는지 확인한다. +- unknown/malformed raw text tool-call이 retry/error로 막히고 raw block이 성공 content나 마지막 SSE flush로 새지 않는지 확인한다. +- native `openai_tool_calls` metadata path가 text parsing보다 우선하는지 확인한다. +- final verification 출력이 실제 코드 상태와 일치하는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. + +### API-1 중간 검증 +```bash +$ go test ./apps/edge/internal/openai -run 'TestSynthesizes.*ToolCall|TestChatCompletions.*Unknown' -count=1 +(output) +``` + +### API-2 중간 검증 +```bash +$ go test ./apps/edge/internal/openai -run 'TestChatCompletions.*Provider.*Tool|TestChatCompletions.*Unknown.*Retry|TestChatCompletions.*MalformedToolCall' -count=1 +(output) +``` + +### API-3 중간 검증 +```bash +$ go test ./apps/edge/internal/openai -run 'TestChatCompletions.*Stream.*Tool|TestChatCompletionsStrictBufferedStream' -count=1 +(output) +``` + +### API-4 중간 검증 +```bash +$ go test ./apps/edge/internal/openai -run 'TestChatCompletions.*NativeToolCall' -count=1 +(output) +``` + +### 최종 검증 +```bash +$ go test ./apps/edge/internal/openai -count=1 +(output) + +$ go test ./apps/edge/... -count=1 +(output) +``` + +--- + +> **[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. diff --git a/agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/PLAN-local-G07.md b/agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/PLAN-local-G07.md new file mode 100644 index 0000000..3f9ea37 --- /dev/null +++ b/agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/PLAN-local-G07.md @@ -0,0 +1,213 @@ +넌 + +# Provider Text Tool Boundary Plan + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 검증 명령을 실행하고 실제 출력과 구현 메모를 남긴 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 review stub의 `사용자 리뷰 요청`을 채우며, 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다. 환경/secret/service 차단, 일반 scope 조정, 증거 공백은 사용자 리뷰가 아니라 검증 결과나 후속 plan으로 남긴다. + +## 배경 + +현재 provider route와 provider-pool은 native `tool_calls`만 구조화하고, fallback marker 없는 raw `` 텍스트는 성공 content로 보존한다. 이 동작은 Pi/Cline형 OpenAI-compatible tools 클라이언트에서 hallucinated tool text가 화면으로 새는 원인이다. 이번 작업은 요청 `tools[]`가 있는 provider 응답의 raw text tool-call 후보를 기존 parser와 validation 경계로 끌어와 valid 후보는 구조화하고 invalid/unknown 후보는 retry 또는 error로 막는다. + +## 사용자 리뷰 요청 흐름 + +사용자 리뷰 요청은 선택된 Milestone 잠금 결정이 구현을 차단할 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 중 직접 사용자 prompt, 선택지 제시, `request_user_input` 호출은 금지되며, code-review가 검증 후 실제 `USER_REVIEW.md` 작성 여부를 판단한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md` +- Task ids: + - `provider-raw-parse`: provider-pool/provider route raw text tool-call parser 적용 + - `unknown-tool-error`: unknown raw tool-call을 validation error 또는 retry로 처리 + - `stream-flush-guard`: streaming raw tool-call leak 방지 + - `native-preserve`: native `tool_calls` pass-through와 schema normalization 보존 +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md` +- `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md` +- `agent-ops/rules/project/domain/edge/rules.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-test/local/rules.md` +- `agent-test/local/edge-smoke.md` +- `agent-contract/index.md` +- `agent-contract/outer/openai-compatible-api.md` +- `docs/openai-compatible-api-contract.md` +- `apps/edge/internal/openai/chat_handler.go` +- `apps/edge/internal/openai/stream.go` +- `apps/edge/internal/openai/types.go` +- `apps/edge/internal/openai/tool_validation.go` +- `apps/edge/internal/openai/run_result.go` +- `apps/edge/internal/openai/strict_output.go` +- `apps/edge/internal/openai/server_test.go` + +### SDD 기준 + +선택 Milestone은 `SDD: 불필요`다. 사유는 기존 OpenAI-compatible `tools[]`, `tool_calls`, text-tool fallback, validation retry 계약을 강화하는 호환성 보정이며 새 공개 request/response field를 만들지 않는다는 것이다. + +### 테스트 환경 규칙 + +`test_env=local`. `agent-test/local/rules.md`를 읽었고, edge 도메인 매칭으로 `agent-test/local/edge-smoke.md`를 읽었다. 적용 명령은 변경 패키지/edge 패키지 기준 `go test ./apps/edge/internal/openai -count=1` 및 `go test ./apps/edge/... -count=1`이다. OpenAI-compatible 경계 변경의 live smoke는 02번 `contract_dev_smoke` plan에서 dev-runtime 동기화 후 수행한다. + +### 테스트 커버리지 공백 + +- provider route raw text valid tool-call 구조화: 현재 `TestChatCompletionsDoesNotSynthesizeTextToolCallsForProviderRoute`가 반대 동작을 고정하므로 수정 필요. +- unknown XML/mustache tool-call 차단: 현재 `TestChatCompletionsLeavesUnknownTextToolCallAsContent`, `TestChatCompletionsLeavesUnknownTemplateToolCallAsContent`가 반대 동작을 고정하므로 수정 필요. +- live SSE raw candidate terminal 처리: CLI fallback stream tests는 있으나 provider route valid/invalid raw text stream tests가 없다. +- native `tool_calls` pass-through/schema normalization: 기존 server tests가 있으므로 보존 확인에 사용한다. + +### 심볼 참조 + +이 plan은 symbol rename/remove를 요구하지 않는다. 변경 후보 call site는 `shouldSynthesizeTextToolCalls`가 `chat_handler.go:240`, `chat_handler.go:359`, `stream.go:221`에서 쓰이는 지점이다. + +### 분할 판단 + +split 정책을 먼저 평가했다. 공유 task group은 `m-openai-compatible-tool-call-boundary-hardening`이다. `01_provider_text_boundary`는 코드/로컬 회귀 테스트를 담당하고 선행 의존이 없다. `02+01_contract_dev_smoke`는 01 완료 후 계약 문서와 dev-runtime live evidence를 담당한다. 원격 credential/runtime 증거와 코드 경계 구현은 검증 리스크가 달라 분리했다. + +### 범위 결정 근거 + +`apps/node/internal/adapters/openai_compat`의 fallback 생성 정책, provider별 chat template 교체, 모델별 prompt policy, `tools[]` 없는 자연어 tool 추론, Pi 자체 provider/profile 소유권은 Milestone 범위 제외와 충돌하므로 건드리지 않는다. 이번 plan은 `apps/edge/internal/openai`의 response boundary와 server tests만 수정한다. + +### 빌드 등급 + +`local-G07`. 단일 Edge OpenAI package 안의 bounded 변경이며 로컬 unit tests로 검증 가능하지만, streaming/non-stream/retry/native 보존 경계를 함께 바꾸므로 G07로 둔다. + +## 구현 체크리스트 + +- [ ] Provider route/provider-pool raw text tool-call 후보를 request `tools[]` 기준으로 구조화하고, valid raw block이 `message.tool_calls` 또는 stream `delta.tool_calls`로 나가며 raw block이 content에 남지 않게 한다. +- [ ] Unknown/malformed text tool-call 후보를 성공 content로 flush하지 않고 non-stream/strict buffered stream에서는 bounded retry 후 `tool_validation_error`, live SSE에서는 terminal SSE error로 종료하게 한다. +- [ ] Existing native provider `tool_calls` pass-through와 schema-normalization 동작을 보존하고 회귀 테스트를 유지한다. +- [ ] Provider raw parse, unknown tool, live SSE, strict buffered SSE, native preserve 회귀 테스트를 갱신한다. +- [ ] `go test ./apps/edge/internal/openai -count=1`와 `go test ./apps/edge/... -count=1`를 실행해 통과시킨다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [API-1] Text Tool Candidate Result + +문제: `types.go:288-310`과 `types.go:313-343`은 request specs에 없는 raw tool candidate를 조용히 무시한다. 그 결과 `server_test.go:1410-1437`과 `server_test.go:1440-1465`처럼 unknown tool text가 성공 content로 남는다. + +해결 방법: 기존 public helper signature를 깨지 않도록 `synthesizeToolCallsFromText`는 wrapper로 유지하고, 내부에 `synthesizeToolCallsFromTextResult(content, tools, runID)`를 추가한다. result에는 `cleaned`, `toolCalls`, `candidateFound`, `validationErr`를 둔다. XML/mustache scanner는 request specs 존재 여부와 별도로 candidate name을 먼저 감지하고, name이 request tool 목록에 없으면 `validationErr=tool "" is not in request tools`를 반환한다. known tool의 arguments parse/JSON marshal 실패도 `validationErr`로 반환한다. + +Before: + +```go +// apps/edge/internal/openai/types.go:240 +func synthesizeToolCallsFromText(content string, tools []any, runID string) (string, []any) { + specs := requestedToolSpecs(tools) + if len(specs) == 0 { + return content, nil + } + matches := collectTextToolCallMatches(content, specs) + if len(matches) == 0 { + return content, nil + } +``` + +수정 파일 및 체크리스트: + +- [ ] `apps/edge/internal/openai/types.go`: result struct와 strict candidate parser 추가. +- [ ] `apps/edge/internal/openai/server_test.go`: unknown XML/mustache tests를 content-preserve 기대에서 validation failure 기대값으로 전환. + +테스트 작성: `TestSynthesizesTextToolCallReportsUnknownCandidate`와 `TestSynthesizesTemplateToolCallReportsUnknownCandidate`를 추가하거나 기존 unknown tests를 helper 단위와 HTTP 단위로 갱신한다. + +중간 검증: `go test ./apps/edge/internal/openai -run 'TestSynthesizes.*ToolCall|TestChatCompletions.*Unknown' -count=1` PASS. + +### [API-2] Non-Stream Provider Text Boundary + +문제: `chat_handler.go:240-242`가 text synthesis를 fallback marker 또는 CLI adapter로만 제한하고, `chat_handler.go:359-368`은 provider route raw text를 검사하지 않는다. `server_test.go:514-544`가 이 반대 동작을 고정한다. + +해결 방법: `chatCompletionOutput`에 `toolValidationErr error`를 추가하고, `collectChatCompletionOutput`에서 request tools가 있고 native tool_calls가 없으면 provider/CLI 모두 text candidate result를 검사한다. valid result는 `toolCallOriginText`로 반환해 기존 `validateToolCallResponse` retry 루프에 태운다. invalid result는 `toolValidationErr`로 반환하고 `completeChatCompletion`은 `validateToolCallResponse`와 같은 retry/error 경로를 사용한다. + +Before: + +```go +// apps/edge/internal/openai/chat_handler.go:359 +} else if shouldSynthesizeTextToolCalls(handle.Dispatch(), textToolFallback) { + cleaned, synthesizedToolCalls := synthesizeToolCallsFromText(text, req.Tools, handle.Dispatch().RunID) + if len(synthesizedToolCalls) > 0 { +``` + +수정 파일 및 체크리스트: + +- [ ] `apps/edge/internal/openai/chat_handler.go`: provider text inspection 조건과 retry/error 처리 추가. +- [ ] `apps/edge/internal/openai/tool_validation.go`: validation error helper가 result error와 native validation error를 같은 retry loop에서 다룰 수 있게 정리. +- [ ] `apps/edge/internal/openai/server_test.go`: provider raw valid test를 `tool_calls` 기대값으로 전환하고 retry metadata를 검증. + +테스트 작성: `TestChatCompletionsSynthesizesTextToolCallsForProviderRoute`, `TestChatCompletionsRetriesUnknownTextToolCallBeforeResponse`, `TestChatCompletionsFailsUnknownTextToolCallAfterRetryLimit`. + +중간 검증: `go test ./apps/edge/internal/openai -run 'TestChatCompletions.*Provider.*Tool|TestChatCompletions.*Unknown.*Retry|TestChatCompletions.*MalformedToolCall' -count=1` PASS. + +### [API-3] Streaming Flush Guard + +문제: live SSE는 runtime validation을 제외하고, `stream.go:221-247`에서 synthesis 조건이 false거나 candidate parse가 실패하면 pending raw block을 flush한다. 이 때문에 provider route raw `` 후보가 마지막 content delta로 새는 경로가 남는다. + +해결 방법: live SSE는 retry하지 않는다. 대신 request tools가 있으면 provider/CLI 모두 terminal event에서 text candidate result를 검사한다. valid result는 `tool_calls` delta를 emit한다. invalid result는 `writeSSEErrorWithType(..., "tool_validation_error", ...)`로 종료하고 raw pending을 flush하지 않는다. candidate가 전혀 없을 때만 pending text를 기존처럼 flush한다. Strict buffered stream은 `collectChatCompletionOutput` 경로를 공유하므로 API-2의 retry/error 경로를 사용한다. + +Before: + +```go +// apps/edge/internal/openai/stream.go:238 +} else if pending := toolTextFilter.Flush(); pending != "" { + emittedContent.WriteString(pending) + writeTracedContentDelta(pending, pending, false) +} +``` + +수정 파일 및 체크리스트: + +- [ ] `apps/edge/internal/openai/stream.go`: terminal event의 pending flush 전에 candidate result를 검사. +- [ ] `apps/edge/internal/openai/server_test.go`: provider live SSE valid/invalid raw block tests 추가. + +테스트 작성: `TestChatCompletionsProviderStreamSynthesizesTextToolCalls`, `TestChatCompletionsProviderStreamBlocksUnknownTextToolCall`, `TestChatCompletionsStrictBufferedStreamFailsUnknownTextToolCallAfterRetryLimit`. + +중간 검증: `go test ./apps/edge/internal/openai -run 'TestChatCompletions.*Stream.*Tool|TestChatCompletionsStrictBufferedStream' -count=1` PASS. + +### [API-4] Native Preserve Regression + +문제: provider text synthesis를 넓히면 native `openai_tool_calls` metadata 처리(`chat_handler.go:353-358`, `stream.go:201-220`)와 schema normalization이 우발적으로 바뀔 수 있다. + +해결 방법: native `tool_calls`가 있으면 text candidate inspection보다 항상 우선한다. 기존 `normalizeNativeToolCallsForResponse` 호출 순서와 `finish_reason=tool_calls` 설정을 보존한다. + +수정 파일 및 체크리스트: + +- [ ] `apps/edge/internal/openai/chat_handler.go`: native-first branch 유지. +- [ ] `apps/edge/internal/openai/stream.go`: native-first branch 유지. +- [ ] `apps/edge/internal/openai/server_test.go`: native pass-through/normalization tests가 계속 통과하는지 확인. + +테스트 작성: 새 테스트는 필요 없고 기존 `TestChatCompletionsPassesThroughNativeToolCallsFromProviderRoute`, `TestChatCompletionsNormalizesNativeToolCallArgumentsForProviderRoute`, `TestChatCompletionsStreamPassesThroughNativeToolCallsFromProviderRoute`를 회귀 기준으로 사용한다. + +중간 검증: `go test ./apps/edge/internal/openai -run 'TestChatCompletions.*NativeToolCall' -count=1` PASS. + +## 수정 파일 요약 + +| 파일 | 항목 | +|---|---| +| `apps/edge/internal/openai/types.go` | API-1 | +| `apps/edge/internal/openai/chat_handler.go` | API-2, API-4 | +| `apps/edge/internal/openai/tool_validation.go` | API-2 | +| `apps/edge/internal/openai/stream.go` | API-3, API-4 | +| `apps/edge/internal/openai/server_test.go` | API-1, API-2, API-3, API-4 | + +## 의존 관계 및 구현 순서 + +이 subtask는 선행 의존이 없다. `02+01_contract_dev_smoke`는 이 subtask의 `complete.log`가 생긴 뒤 시작한다. + +## 최종 검증 + +```bash +go test ./apps/edge/internal/openai -count=1 +go test ./apps/edge/... -count=1 +``` + +기대 결과: 두 명령 모두 PASS. Go test cache는 허용하지 않으므로 `-count=1`을 유지한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-openai-compatible-tool-call-boundary-hardening/02+01_contract_dev_smoke/CODE_REVIEW-cloud-G07.md b/agent-task/m-openai-compatible-tool-call-boundary-hardening/02+01_contract_dev_smoke/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..fc307f1 --- /dev/null +++ b/agent-task/m-openai-compatible-tool-call-boundary-hardening/02+01_contract_dev_smoke/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,134 @@ + + +# 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`. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. +> Finalization is review-agent-only. + +## 개요 + +date=2026-07-04 +task=m-openai-compatible-tool-call-boundary-hardening/02+01_contract_dev_smoke, plan=0, tag=API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md` +- Task ids: + - `client-smoke`: Pi/Cline형 긴 agent prompt와 `tools[]` dev Edge 호출 evidence + - `contract-docs`: OpenAI-compatible 계약 문서와 dev 운영 문서 정책 설명 +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 파일과 evidence path에 대조하고, secret 원문이 tracked 파일이나 review output에 남지 않았는지 확인하세요. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G07.md`와 `PLAN-cloud-G07.md`를 `.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다. +4. PASS이고 task group이 `m-openai-compatible-tool-call-boundary-hardening`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정은 런타임 책임이다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Contract Policy Update | [ ] | +| [API-2] Dev Smoke Guide And Test Rule | [ ] | +| [API-3] Credentialed Dev Runtime Evidence | [ ] | + +## 구현 체크리스트 + +- [ ] 01번 `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/complete.log`가 존재하고 Roadmap Completion에 `provider-raw-parse`, `unknown-tool-error`, `stream-flush-guard`, `native-preserve`가 포함됐는지 확인한다. +- [ ] `agent-contract/outer/openai-compatible-api.md`가 provider route/provider-pool raw text tool-call 후보의 구조화/차단, unknown/malformed 처리, sentinel sanitize 정책을 설명하도록 갱신한다. +- [ ] `docs/edge-local-dev-guide.md`와 `agent-test/dev/edge-smoke.md`에 dev-runtime Pi/Cline형 `tools[]` smoke 기준, evidence 보관 위치, secret 마스킹 원칙을 추가한다. +- [ ] 원격 dev-runtime source를 01 완료 source와 동기화하고 dev-runtime Edge/Node binaries를 rebuild한 뒤 `config check`와 필요한 refresh/restart를 수행한다. +- [ ] dev Edge OpenAI-compatible endpoint에 긴 agent prompt와 `tools[]` 요청을 호출해 valid raw text는 `tool_calls`, unknown hallucination은 `tool_validation_error` 또는 retry 후 차단으로 끝나며 raw block이 body/SSE에 남지 않는 evidence를 남긴다. +- [ ] `go test ./apps/edge/internal/openai -count=1`와 문서/evidence 검증 명령을 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다. +- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-openai-compatible-tool-call-boundary-hardening/02+01_contract_dev_smoke/`로 이동한다. +- [ ] PASS이고 task group이 `m-openai-compatible-tool-call-boundary-hardening`이면 런타임 완료 이벤트 메타데이터를 보고하고 roadmap을 직접 수정하지 않는다. +- [ ] WARN/FAIL이면 다음 active plan/review 파일 또는 unresolved verification report를 작성한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- 01 predecessor completion이 실제로 충족됐는지 확인한다. +- 계약 문서가 `docs/`에 중복 원문을 만들지 않고 `agent-contract`를 source of truth로 유지하는지 확인한다. +- dev smoke evidence가 token/secret 원문을 노출하지 않는지 확인한다. +- raw ``, unknown tool hallucination, `<|mask_end|>` leak 차단 evidence가 남았는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +### API-1 중간 검증 +```bash +$ rg --sort path -n "provider route에서|raw text|tool_validation_error|mask_end" agent-contract/outer/openai-compatible-api.md docs/openai-compatible-api-contract.md +(output) +``` + +### API-2 중간 검증 +```bash +$ rg --sort path -n "raw text tool|tool_validation_error|Pi/Cline|mask_end|agent-test/runs" docs/edge-local-dev-guide.md agent-test/dev/edge-smoke.md +(output) +``` + +### API-3 중간 검증 +```bash +$ ssh -o BatchMode=yes -o ConnectTimeout=5 toki@toki-labs.com 'cd /Users/toki/agent-work/iop-dev && git status --branch --short && git log --oneline -1 && test -x build/dev-runtime/bin/edge && build/dev-runtime/bin/edge --config build/dev-runtime/edge.yaml config refresh --help >/dev/null && for p in 18001 18083 18084 19093; do lsof -nP -iTCP:$p -sTCP:LISTEN >/dev/null || exit 20; done && curl -fsS -m 3 -o /dev/null http://127.0.0.1:18001/edges/edge-toki-labs-dev/status' +(output) +``` + +### 최종 검증 +```bash +$ go test ./apps/edge/internal/openai -count=1 +(output) + +$ rg --sort path -n "tool_validation_error|raw text tool|mask_end|Pi/Cline" agent-contract/outer/openai-compatible-api.md docs/edge-local-dev-guide.md agent-test/dev/edge-smoke.md +(output) + +$ +(output or saved evidence path) +``` + +--- + +> **[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. diff --git a/agent-task/m-openai-compatible-tool-call-boundary-hardening/02+01_contract_dev_smoke/PLAN-cloud-G07.md b/agent-task/m-openai-compatible-tool-call-boundary-hardening/02+01_contract_dev_smoke/PLAN-cloud-G07.md new file mode 100644 index 0000000..5c7b3f9 --- /dev/null +++ b/agent-task/m-openai-compatible-tool-call-boundary-hardening/02+01_contract_dev_smoke/PLAN-cloud-G07.md @@ -0,0 +1,171 @@ + + +# Contract And Dev Smoke Plan + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 검증 명령의 실제 출력과 remote preflight/evidence path를 남긴 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 선택된 Milestone `구현 잠금 > 결정 필요` 항목만 `사용자 리뷰 요청`에 기록하며, secret, token, private endpoint 원문을 tracked 문서나 review output에 남기지 않는다. + +## 배경 + +01번 core boundary가 완료되면 계약 문서와 dev 운영 문서는 provider raw text tool-call 후보의 정규화/차단 정책을 반영해야 한다. 또한 Pi/Cline형 긴 agent prompt와 `tools[]` 요청은 로컬 unit test로만 증명하기 어렵기 때문에 dev-runtime Edge에 직접 호출한 evidence가 필요하다. 이 작업은 문서 갱신과 credentialed dev smoke evidence를 한 task로 묶되, 선행 core 구현이 PASS된 뒤에만 실행한다. + +## 사용자 리뷰 요청 흐름 + +사용자 리뷰 요청은 선택된 Milestone 잠금 결정이 구현을 차단할 때만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 원격 credential 부재, source sync 필요, provider runtime 일시 장애는 사용자 리뷰가 아니라 blocker 또는 follow-up evidence로 기록한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md` +- Task ids: + - `client-smoke`: Pi/Cline형 긴 agent prompt와 `tools[]` dev Edge 호출 evidence + - `contract-docs`: OpenAI-compatible 계약 문서와 dev 운영 문서 정책 설명 +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md` +- `agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/openai-compatible-tool-call-boundary-hardening.md` +- `agent-ops/rules/project/domain/edge/rules.md` +- `agent-ops/rules/project/domain/platform-common/rules.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-test/local/rules.md` +- `agent-test/local/edge-smoke.md` +- `agent-test/local/platform-common-smoke.md` +- `agent-test/dev/rules.md` +- `agent-test/dev/edge-smoke.md` +- `agent-test/dev/platform-common-smoke.md` +- `agent-contract/index.md` +- `agent-contract/outer/openai-compatible-api.md` +- `docs/openai-compatible-api-contract.md` +- `docs/edge-local-dev-guide.md` +- `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=dev` evidence가 필요하다. `agent-test/dev/rules.md`와 `agent-test/dev/edge-smoke.md`를 읽었다. 원격 preflight 결과는 2026-07-04 기준 `ssh toki@toki-labs.com`, workdir `/Users/toki/agent-work/iop-dev`, remote branch `main...origin/main`, HEAD `b55d1a2 fix: document and verify provider priority leveling`, `build/dev-runtime/bin/edge` 존재, `edge config refresh` help 존재, `build/dev-runtime/edge.yaml`에 `refresh:` 존재, ports `18001/18083/18084/19093` listen, Control Plane status HTTP `200`이다. 현재 local checkout은 `main...origin/main` HEAD `fcd6068 chore: update openai-compatible tool call boundary hardening milestone`이며 uncommitted code/roadmap/task 변경이 있으므로 remote source는 local changes와 동기화되어 있지 않다. 구현은 먼저 01 PASS commit/branch 또는 명시적 sync 절차로 remote dev-runtime artifact를 현재 source에 맞춰 rebuild해야 한다. + +### 테스트 커버리지 공백 + +- 계약 문서: `agent-contract/outer/openai-compatible-api.md:227`이 provider raw text 합성 금지를 설명하므로 01 완료 후 정책이 stale해진다. +- dev 운영 문서: `docs/edge-local-dev-guide.md`는 provider-pool/think-control/capacity smoke는 설명하지만 raw text tool-call boundary smoke와 unknown tool hallucination 차단 판정은 없다. +- dev smoke: `agent-test/dev/edge-smoke.md:121-127`은 OpenAI-compatible와 provider-pool smoke 기준만 있고 Pi/Cline형 `tools[]` leak 차단 evidence 기준이 없다. + +### 심볼 참조 + +symbol rename/remove 없음. + +### 분할 판단 + +split 정책을 평가했다. 이 subtask는 `02+01_contract_dev_smoke`이며 predecessor `01_provider_text_boundary`의 `complete.log`가 필요하다. 현재 active sibling `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/complete.log`는 없고 archive predecessor도 확인하지 않았다. 따라서 실행은 01 PASS 이후로 제한한다. + +### 범위 결정 근거 + +이 plan은 계약/운영 문서와 dev smoke evidence만 다룬다. `apps/edge/internal/openai` behavior 수정은 01 범위다. Pi provider/profile 소유권 이전, CLI `adapter=cli,target=pi` 구현, private token 기록, tracked 문서에 secret 원문 기록은 제외한다. + +### 빌드 등급 + +`cloud-G07`. 원격 dev-runtime, credentialed OpenAI-compatible calls, source sync/rebuild, Edge trace/evidence 판단이 핵심이라 local 조건을 만족하지 않는다. + +## 구현 체크리스트 + +- [ ] 01번 `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/complete.log`가 존재하고 Roadmap Completion에 `provider-raw-parse`, `unknown-tool-error`, `stream-flush-guard`, `native-preserve`가 포함됐는지 확인한다. +- [ ] `agent-contract/outer/openai-compatible-api.md`가 provider route/provider-pool raw text tool-call 후보의 구조화/차단, unknown/malformed 처리, sentinel sanitize 정책을 설명하도록 갱신한다. +- [ ] `docs/edge-local-dev-guide.md`와 `agent-test/dev/edge-smoke.md`에 dev-runtime Pi/Cline형 `tools[]` smoke 기준, evidence 보관 위치, secret 마스킹 원칙을 추가한다. +- [ ] 원격 dev-runtime source를 01 완료 source와 동기화하고 dev-runtime Edge/Node binaries를 rebuild한 뒤 `config check`와 필요한 refresh/restart를 수행한다. +- [ ] dev Edge OpenAI-compatible endpoint에 긴 agent prompt와 `tools[]` 요청을 호출해 valid raw text는 `tool_calls`, unknown hallucination은 `tool_validation_error` 또는 retry 후 차단으로 끝나며 raw block이 body/SSE에 남지 않는 evidence를 남긴다. +- [ ] `go test ./apps/edge/internal/openai -count=1`와 문서/evidence 검증 명령을 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [API-1] Contract Policy Update + +문제: `agent-contract/outer/openai-compatible-api.md:227`은 provider route raw text tool-call을 합성하지 않는다고 명시한다. 01 완료 후 이 계약은 stale해진다. + +해결 방법: 해당 문단을 provider route/provider-pool 정책으로 갱신한다. 요청 `tools[]`가 있고 raw text 후보가 감지되면 request tool schema에 맞는 known valid 후보는 OpenAI `tool_calls`로 정규화하고, unknown/malformed 후보는 성공 content가 아니라 validation retry/error로 처리한다고 쓴다. `tools[]`가 없는 자연어 추론은 여전히 제외임을 유지한다. `docs/openai-compatible-api-contract.md`는 계약 pointer라 원문 복제 없이 그대로 둔다. + +Before: + +```markdown +agent-contract/outer/openai-compatible-api.md:227 +provider route에서 assistant content에 ``, `{{function(...)}}`, `[Calling tool: ...]` 같은 텍스트가 들어와도 Edge는 기본적으로 이를 파싱하거나 OpenAI `tool_calls`로 합성하지 않는다. +``` + +수정 파일 및 체크리스트: + +- [ ] `agent-contract/outer/openai-compatible-api.md`: provider raw candidate 정책 갱신. +- [ ] `docs/openai-compatible-api-contract.md`: pointer 유지 확인. + +테스트 작성: 문서 변경이므로 Go test 추가 없음. + +중간 검증: `rg --sort path -n "provider route에서|raw text|tool_validation_error|mask_end" agent-contract/outer/openai-compatible-api.md docs/openai-compatible-api-contract.md` 실행. + +### [API-2] Dev Smoke Guide And Test Rule + +문제: `docs/edge-local-dev-guide.md`와 `agent-test/dev/edge-smoke.md`는 dev-runtime capacity와 think-control 기준은 있지만 Pi/Cline형 `tools[]` raw leak 차단 기준이 없다. + +해결 방법: dev-runtime section에 raw text tool-call boundary smoke를 추가한다. 요청 payload는 tracked 문서에 secret 없이 남기고, token은 환경 변수에서 주입하도록 한다. 판정은 response body/SSE에 ``, `{{...}}`, `<|mask_end|>`가 없고, known valid tool은 `tool_calls` finish reason, unknown tool hallucination은 OpenAI-compatible error body 또는 SSE error `tool_validation_error`로 끝나는 것이다. evidence는 `agent-test/runs/` 같은 ignored run 위치 또는 review output path에 남기고 tracked docs에는 원문 secret을 남기지 않는다. + +수정 파일 및 체크리스트: + +- [ ] `docs/edge-local-dev-guide.md`: dev-runtime raw boundary smoke 절차 추가. +- [ ] `agent-test/dev/edge-smoke.md`: 필수/보조 검증과 판정 기준 보강. + +테스트 작성: 문서/test-rule 변경이므로 Go test 추가 없음. + +중간 검증: `rg --sort path -n "raw text tool|tool_validation_error|Pi/Cline|mask_end|agent-test/runs" docs/edge-local-dev-guide.md agent-test/dev/edge-smoke.md` 실행. + +### [API-3] Credentialed Dev Runtime Evidence + +문제: Milestone `client-smoke`는 Pi/Cline형 긴 prompt와 `tools[]` 요청이 raw text로 노출되지 않는 근거를 요구한다. local tests만으로 실제 provider-pool/Pi 스타일 prompt behavior를 대체할 수 없다. + +해결 방법: 선행 01 완료 source를 원격 `/Users/toki/agent-work/iop-dev`에 반영하고 dev-runtime binaries를 rebuild한다. preflight로 `git status --branch --short`, `git log --oneline -1`, edge binary/config refresh help, ports `18001/18083/18084/19093`, Control Plane status HTTP를 다시 확인한다. 이후 token은 원격 환경 변수나 ignored secret file에서 읽고 stdout에는 마스킹한다. known tool payload와 unknown tool 유도 payload를 각각 non-stream/stream 중 최소 하나씩 호출하고, response finish reason/error type 및 Edge trace/log path를 review에 남긴다. + +수정 파일 및 체크리스트: + +- [ ] 원격 preflight 출력 기록. +- [ ] ignored evidence 파일 path 기록. +- [ ] tracked 파일에 token/secret 원문 미기록 확인. + +테스트 작성: test code 추가 없음. dev smoke evidence가 검증이다. + +중간 검증: + +```bash +ssh -o BatchMode=yes -o ConnectTimeout=5 toki@toki-labs.com 'cd /Users/toki/agent-work/iop-dev && git status --branch --short && git log --oneline -1 && test -x build/dev-runtime/bin/edge && build/dev-runtime/bin/edge --config build/dev-runtime/edge.yaml config refresh --help >/dev/null && for p in 18001 18083 18084 19093; do lsof -nP -iTCP:$p -sTCP:LISTEN >/dev/null || exit 20; done && curl -fsS -m 3 -o /dev/null http://127.0.0.1:18001/edges/edge-toki-labs-dev/status' +``` + +## 수정 파일 요약 + +| 파일 | 항목 | +|---|---| +| `agent-contract/outer/openai-compatible-api.md` | API-1 | +| `docs/edge-local-dev-guide.md` | API-2 | +| `agent-test/dev/edge-smoke.md` | API-2 | +| `agent-test/runs/**` 또는 review output path | API-3 evidence only | + +## 의존 관계 및 구현 순서 + +이 directory 이름의 `+01`이 source of truth다. `agent-task/m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary/complete.log`가 생기기 전에는 구현을 시작하지 않는다. predecessor가 missing이면 review stub에 blocker로 기록하고 active files를 유지한다. + +## 최종 검증 + +```bash +go test ./apps/edge/internal/openai -count=1 +rg --sort path -n "tool_validation_error|raw text tool|mask_end|Pi/Cline" agent-contract/outer/openai-compatible-api.md docs/edge-local-dev-guide.md agent-test/dev/edge-smoke.md +ssh -o BatchMode=yes -o ConnectTimeout=5 toki@toki-labs.com 'cd /Users/toki/agent-work/iop-dev && git status --branch --short && git log --oneline -1 && test -x build/dev-runtime/bin/edge && build/dev-runtime/bin/edge --config build/dev-runtime/edge.yaml config refresh --help >/dev/null && curl -fsS -m 3 -o /dev/null http://127.0.0.1:18001/edges/edge-toki-labs-dev/status' +``` + +기대 결과: local Go test PASS, 문서 검색 결과가 계약/운영/test-rule 세 위치를 가리킴, remote preflight PASS. Credentialed request/response evidence는 review `검증 결과`에 실제 command와 redacted output 또는 saved output path로 남긴다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index ca24bf3..fb511d8 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -592,6 +592,69 @@ func TestChatCompletionsSynthesizesProviderTextToolCallsWhenFallbackMarked(t *te } } +func TestChatCompletionsSanitizesKnownSentinelToken(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} + fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking <|mask_end|>"} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer <|mask_end|>"} + 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":"hi"}] + }`)) + 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 strings.Contains(resp.Choices[0].Message.Content, "<|mask_end|>") || strings.Contains(resp.Choices[0].Message.ReasoningContent, "<|mask_end|>") { + t.Fatalf("sentinel leaked in response: %+v", resp.Choices[0].Message) + } + if resp.Choices[0].Message.Content != "answer" || resp.Choices[0].Message.ReasoningContent != "thinking" { + t.Fatalf("unexpected sanitized output: %+v", resp.Choices[0].Message) + } +} + +func TestChatCompletionsSynthesizesToolCallAndDropsTrailingSentinel(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "\n\n[\"git status\"]\n\n<|mask_end|>"} + fake.events <- &iop.RunEvent{Type: "complete"} + + 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"}}},"required":["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()) + } + var resp chatCompletionResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if strings.Contains(w.Body.String(), "<|mask_end|>") { + t.Fatalf("sentinel leaked in response body: %s", w.Body.String()) + } + if resp.Choices[0].FinishReason != "tool_calls" || len(resp.Choices[0].Message.ToolCalls) != 1 { + t.Fatalf("expected structured tool call, got %+v", resp.Choices[0]) + } + if resp.Choices[0].Message.Content != "" { + t.Fatalf("trailing sentinel should not become content: %q", resp.Choices[0].Message.Content) + } +} + func TestChatCompletionsPassesThroughNativeToolCallsFromProviderRoute(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)} fake.events <- &iop.RunEvent{ @@ -1869,6 +1932,39 @@ func TestChatCompletionsStreamSuppressesReasoningWhenExcluded(t *testing.T) { } } +func TestChatCompletionsStreamSanitizesKnownSentinelTokenAcrossDeltas(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 5)} + fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think <|mask"} + fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "_end|>"} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello <|mask"} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "_end|> world"} + 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":"stream-model", + "stream":true, + "messages":[{"role":"user","content":"hi"}] + }`)) + 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, "<|mask") || strings.Contains(body, "mask_end") { + t.Fatalf("stream leaked sentinel token:\n%s", body) + } + if !strings.Contains(body, `"content":"hello "`) || !strings.Contains(body, `"content":"world"`) { + t.Fatalf("stream did not preserve sanitized content chunks:\n%s", body) + } + if !strings.Contains(body, `"reasoning_content":"think "`) || !strings.Contains(body, "data: [DONE]") { + t.Fatalf("stream did not preserve sanitized reasoning or done marker:\n%s", body) + } +} + func TestChatCompletionsStreamReportsHiddenReasoningWhenExcludedAndContentEmpty(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think only"} @@ -2660,6 +2756,36 @@ func TestResponsesReturnsOpenAICompatibleShape(t *testing.T) { } } +func TestResponsesSanitizesKnownSentinelToken(t *testing.T) { + fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} + fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden <|mask_end|>"} + fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer <|mask_end|>"} + fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}} + + srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ + "model":"client-model", + "input":"say hello" + }`)) + w := httptest.NewRecorder() + + srv.routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) + } + var resp responsesResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if strings.Contains(resp.OutputText, "<|mask_end|>") || strings.Contains(w.Body.String(), "<|mask_end|>") { + t.Fatalf("sentinel leaked in responses output: %s", w.Body.String()) + } + if resp.OutputText != "answer" { + t.Fatalf("output_text: got %q, want answer", resp.OutputText) + } +} + func TestResponsesUsageDefaultsToZeroObject(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"} diff --git a/apps/edge/internal/openai/stream.go b/apps/edge/internal/openai/stream.go index a01ac0c..c8312b6 100644 --- a/apps/edge/internal/openai/stream.go +++ b/apps/edge/internal/openai/stream.go @@ -96,6 +96,8 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re var reasoningBuilder strings.Builder var emittedContent strings.Builder var toolTextFilter *streamToolTextFilter + contentSentinelFilter := &streamSentinelFilter{} + reasoningSentinelFilter := &streamSentinelFilter{} if len(req.Tools) > 0 { toolTextFilter = &streamToolTextFilter{} } @@ -116,6 +118,35 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re writeSSEError(w, flusher, "run stream unavailable") return } + processContentDelta := func(sourceDelta string) { + if sourceDelta == "" { + return + } + delta := contentSentinelFilter.Append(sourceDelta) + if delta == "" { + return + } + contentBuilder.WriteString(delta) + filteredDelta := delta + if toolTextFilter != nil { + filteredDelta = toolTextFilter.Append(filteredDelta) + } + if filteredDelta == "" { + if traceStream { + s.logger.Info("openai chat completion stream output suppressed", + zap.String("run_id", handle.Dispatch().RunID), + zap.String("type", "content"), + zap.Int("source_len", len(sourceDelta)), + zap.Bool("source_has_open_think", hasOpenThinkTag(sourceDelta)), + zap.Bool("source_has_close_think", hasCloseThinkTag(sourceDelta)), + zap.String("source_preview", previewString(sourceDelta, 2000)), + ) + } + return + } + emittedContent.WriteString(filteredDelta) + writeTracedContentDelta(filteredDelta, sourceDelta, filteredDelta != sourceDelta) + } for { select { case <-r.Context().Done(): @@ -140,40 +171,28 @@ func (s *Server) streamChatCompletion(w http.ResponseWriter, r *http.Request, re } switch event.GetType() { case "delta": - if event.GetDelta() == "" { - continue - } - sourceDelta := event.GetDelta() - delta := sourceDelta - contentBuilder.WriteString(sourceDelta) - if toolTextFilter != nil { - delta = toolTextFilter.Append(delta) - } - if delta == "" { - if traceStream { - s.logger.Info("openai chat completion stream output suppressed", - zap.String("run_id", handle.Dispatch().RunID), - zap.String("type", "content"), - zap.Int("source_len", len(sourceDelta)), - zap.Bool("source_has_open_think", hasOpenThinkTag(sourceDelta)), - zap.Bool("source_has_close_think", hasCloseThinkTag(sourceDelta)), - zap.String("source_preview", previewString(sourceDelta, 2000)), - ) - } - continue - } - emittedContent.WriteString(delta) - writeTracedContentDelta(delta, sourceDelta, delta != sourceDelta) + processContentDelta(event.GetDelta()) case "reasoning_delta": if event.GetDelta() == "" { continue } - reasoningBuilder.WriteString(event.GetDelta()) + reasoning := reasoningSentinelFilter.Append(event.GetDelta()) + if reasoning == "" { + continue + } + reasoningBuilder.WriteString(reasoning) if outputPolicy.Strict || !req.includeReasoning() { continue } - writeTracedReasoningDelta(event.GetDelta()) + writeTracedReasoningDelta(reasoning) case "complete": + processContentDelta(contentSentinelFilter.Flush()) + if reasoning := reasoningSentinelFilter.Flush(); reasoning != "" { + reasoningBuilder.WriteString(reasoning) + if !outputPolicy.Strict && req.includeReasoning() { + writeTracedReasoningDelta(reasoning) + } + } metadata := event.GetMetadata() finishReason := metadata["finish_reason"] if finishReason == "" { diff --git a/apps/edge/internal/openai/strict_output.go b/apps/edge/internal/openai/strict_output.go index 5f47291..bd9e656 100644 --- a/apps/edge/internal/openai/strict_output.go +++ b/apps/edge/internal/openai/strict_output.go @@ -26,8 +26,12 @@ func prependSystemMessage(messages []chatMessage, content string) []chatMessage } func normalizeCompletionOutput(policy strictOutputPolicy, content, reasoning string) (string, string, bool) { + var sanitized bool + content, sanitized = sanitizeKnownChatTemplateSentinels(content) + reasoning, reasoningSanitized := sanitizeKnownChatTemplateSentinels(reasoning) + sanitized = sanitized || reasoningSanitized if !policy.Strict { - return content, reasoning, false + return content, reasoning, sanitized } normalized := normalizeStrictAgentContent(content) if strings.TrimSpace(normalized) == "" && strings.TrimSpace(reasoning) != "" { @@ -39,7 +43,79 @@ func normalizeCompletionOutput(policy strictOutputPolicy, content, reasoning str if policy.XMLCompletionTool != "" && !startsWithXMLRoot(normalized) { normalized = wrapXMLCompletion(policy, normalized) } - return normalized, "", normalized != content || reasoning != "" + return normalized, "", sanitized || normalized != content || reasoning != "" +} + +var knownChatTemplateSentinels = []string{ + "<|mask_end|>", +} + +func sanitizeKnownChatTemplateSentinels(s string) (string, bool) { + cleaned := s + for _, token := range knownChatTemplateSentinels { + cleaned = strings.ReplaceAll(cleaned, token, "") + } + if cleaned == s { + return s, false + } + return strings.TrimSpace(cleaned), true +} + +type streamSentinelFilter struct { + pending string +} + +func (f *streamSentinelFilter) Append(delta string) string { + if delta == "" { + return "" + } + f.pending += delta + flushLen := streamSentinelSafeFlushLen(f.pending) + out := f.pending[:flushLen] + f.pending = f.pending[flushLen:] + out, _ = sanitizeKnownChatTemplateSentinels(out) + return out +} + +func (f *streamSentinelFilter) Flush() string { + out := f.pending + f.pending = "" + if isKnownSentinelPrefix(out) { + return "" + } + out, _ = sanitizeKnownChatTemplateSentinels(out) + return out +} + +func streamSentinelSafeFlushLen(s string) int { + hold := 0 + for _, token := range knownChatTemplateSentinels { + max := len(token) - 1 + if max > len(s) { + max = len(s) + } + for n := max; n > 0; n-- { + if strings.HasSuffix(s, token[:n]) { + if n > hold { + hold = n + } + break + } + } + } + return len(s) - hold +} + +func isKnownSentinelPrefix(s string) bool { + if s == "" { + return false + } + for _, token := range knownChatTemplateSentinels { + if strings.HasPrefix(token, s) { + return true + } + } + return false } var (