feat: openai-compatible tool call boundary hardening changes
This commit is contained in:
parent
fcd6068501
commit
0ca170013d
10 changed files with 924 additions and 154 deletions
|
|
@ -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}
|
||||
</${tag}>`;
|
||||
})
|
||||
.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)}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"extensions": ["iop-rule-loader.ts"]
|
||||
"extensions": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ OpenAI-compatible provider 응답의 tool-call 후보를 요청 schema 기준으
|
|||
- [ ] [provider-raw-parse] provider-pool/provider route에서도 요청 `tools[]`가 있고 assistant content에 raw `<tool_call>` 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 `<tool_call>` 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 `<tool_call>` 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 기반
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
<!-- task=m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary plan=0 tag=API -->
|
||||
|
||||
# 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.
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
넌 <!-- task=m-openai-compatible-tool-call-boundary-hardening/01_provider_text_boundary plan=0 tag=API -->
|
||||
|
||||
# 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 `<tool_call>` 텍스트는 성공 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 "<name>" 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 `<tool_call>` 후보가 마지막 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
<!-- task=m-openai-compatible-tool-call-boundary-hardening/02+01_contract_dev_smoke plan=0 tag=API -->
|
||||
|
||||
# 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 `<tool_call>`, 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)
|
||||
|
||||
$ <credentialed dev-runtime known/unknown tool request commands with token redacted>
|
||||
(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.
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
<!-- task=m-openai-compatible-tool-call-boundary-hardening/02+01_contract_dev_smoke plan=0 tag=API -->
|
||||
|
||||
# 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에 `<tool_call>`, `{{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에 `<tool_call>`, `{{...}}`, `<|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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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: "<tool_call>\n<function=run_commands>\n<parameter=commands>[\"git status\"]</parameter>\n</function>\n</tool_call><|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"}
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Reference in a new issue