<!-- task=openai_text_tool_call_boundary plan=0 tag=BUGFIX_TOOLCALL -->

# Plan - BUGFIX_TOOLCALL

## 이 파일을 읽는 구현 에이전트에게

이 계획은 구현 루프의 입력이다. 구현 후 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우고 active 파일을 그대로 둔 뒤 review ready를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다.

선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막는 경우에만 review stub의 `사용자 리뷰 요청` 섹션을 정확한 근거로 채우고 멈춘다. 구현 중 직접 사용자에게 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경/secret/service 준비, 일반 범위 조정, 반복 실패, 증거 공백은 사용자 리뷰 요청이 아니라 검증 결과 또는 후속 plan으로 남긴다.

## 배경

2026-07-06 dev-runtime의 Pi 호출에서 `qwen3.6:35b` 응답이 raw XML text tool-call로 끝났고, IOP Edge가 이를 OpenAI-compatible `tool_calls`로 합성하는 중 `malformed tool call: missing function block or definition inside tool_call`를 냈다. dev Edge/Node/provider pool은 살아 있었고, client는 SSE error를 받은 쪽이다. 이번 작업은 같은 형태의 `edit` payload를 회귀 fixture로 고정하고, Edge의 raw text tool-call normalization이 복잡한 인자 본문과 streaming tool-use turn을 안전하게 처리하도록 한다.

## 사용자 리뷰 요청 흐름

사용자 리뷰 요청은 선택된 Milestone lock decision만 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 사용자 prompt를 만들지 않고, code-review가 정당성 검증과 `USER_REVIEW.md` 작성을 소유한다.

## 분석 결과

### 읽은 파일

- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-roadmap/ROADMAP.md`
- `agent-roadmap/phase/operational-observability-provider-management/PHASE.md`
- `agent-roadmap/phase/operational-observability-provider-management/milestones/usage-token-log-ops-mvp.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-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-contract/index.md`
- `agent-contract/outer/openai-compatible-api.md`
- `agent-test/local/rules.md`
- `agent-test/dev/rules.md`
- `agent-test/dev/edge-smoke.md`
- `agent-test/dev/node-smoke.md`
- `agent-test/dev/inventory.yaml`
- `Makefile`
- `apps/edge/internal/openai/types.go`
- `apps/edge/internal/openai/stream.go`
- `apps/edge/internal/openai/chat_handler.go`
- `apps/edge/internal/openai/tool_validation.go`
- `apps/edge/internal/openai/server_test.go`
- `apps/edge/internal/openai/log_safety_test.go`

### SDD 기준

not applicable. 이 작업은 활성 `[스케치]` Milestone의 기능 Task 완료를 주장하지 않는 non-roadmap dev 회귀 bugfix다. 활성 Milestone `usage-token-log-ops-mvp`는 `구현 잠금: 잠금`이고 사용량/로그 운영 범위 스케치라, 이번 OpenAI-compatible raw text tool-call parser 회귀 수정과 직접 연결하지 않는다.

### 테스트 환경 규칙

- 선택 test_env: `dev`. 버그가 dev-runtime OpenAI-compatible `18083`에서 발생했고 `agent-test/dev/edge-smoke.md`가 `apps/edge/internal/openai/**` text tool-call 합성/validation 변경 시 non-stream/stream raw boundary smoke를 요구한다.
- 적용 rule/profile: `agent-test/dev/rules.md`, `agent-test/dev/edge-smoke.md`, `agent-test/dev/node-smoke.md`, `agent-test/dev/inventory.yaml`.
- local quick check 기준도 확인했다: `agent-test/local/rules.md`.
- concrete local command: `go test ./apps/edge/internal/openai`는 현재 checkout에서 실행 가능하며 계획 작성 중 PASS 확인됨.
- dev smoke source: `agent-test/dev/edge-smoke.md`의 raw text tool-call boundary 기준. token/secret 원문은 tracked artifact에 남기지 않는다.

테스트 환경 프리플라이트:

- runner: `ssh toki@toki-labs.com`
- repo root: `/Users/toki/agent-work/iop-dev`
- dev runtime identity: Edge id `edge-toki-labs-dev`, OpenAI base URL `http://toki-labs.com:18083/v1`, runner-local base URL `http://127.0.0.1:18083/v1`, Edge-Node TCP `toki-labs.com:18084`, admin `127.0.0.1:19093`
- binary/config paths: `build/dev-runtime/bin/edge`, `build/dev-runtime/edge.yaml`
- 2026-07-06 조사 증거: `/v1/models`는 auth 401로 port alive, Control Plane status는 3개 node healthy/idle, `18083`/`18084` TCP reachable.
- 구현 후 dev smoke는 원격 checkout과 dev-runtime binary가 구현 commit을 반영한 뒤에만 현재 변경 검증으로 인정한다. 원격 HEAD가 다르거나 binary가 stale이면 dev-runtime clean sync/rebuild 또는 별도 배포 작업이 먼저 필요하다.

### 테스트 커버리지 공백

- 기존 `server_test.go`는 단순 XML raw text tool-call, mustache tool-call, unknown/malformed 차단, non-stream retry, strict buffered stream retry를 다룬다.
- 공백 1: 이번 실제 실패처럼 `edit` tool의 `edits` JSON 배열 안에 Markdown code fence, escaped JSON request body, `http://<edge-host>:18081/...` angle-bracket placeholder가 들어간 XML parameter fixture가 없다.
- 공백 2: `collectXMLCandidateMatches`와 `collectXMLTextToolCallMatches`가 같은 scanner를 공유해야 하는 회귀가 없다.
- 공백 3: `tools[]`가 있는 streaming 요청을 live SSE로 둬서 retry가 불가능한 경로가 계속 선택되는지 막는 정책 테스트가 없다.

### 심볼 참조

- renamed/removed symbol: none.
- parser 교체 시 함께 확인할 참조: `textToolCallBlockRE`, `textToolFunctionRE`, `textToolParameterRE`, `collectXMLCandidateMatches`, `collectXMLTextToolCallMatches`, `parseTextToolCallBlock`.
- `rg --sort path -n "synthesizeToolCallsFromTextResult|collectXMLCandidateMatches|textTool|tool_validation_error|strict_stream_buffer|malformed tool call" apps/edge/internal/openai`로 call site와 테스트 위치를 확인했다.

### 분할 판단

split decision policy를 적용했다. 단일 plan으로 둔다.

- ownership boundary: `apps/edge/internal/openai` 한 패키지 안의 OpenAI-compatible raw text tool-call boundary다.
- API-vs-call-site boundary: parser helper와 stream routing call site가 같은 public behavior를 형성하며, parser fixture 없이는 stream 완화만 따로 완료하기 어렵다.
- test strategy: 모든 회귀가 `server_test.go`의 fake run service와 `go test ./apps/edge/internal/openai`로 검증된다.
- risk profile: 성공 content에 raw `<tool_call>`이 새면 안 된다는 동일 계약 리스크다.
- split하면 parser와 stream policy가 서로의 테스트 fixture를 공유해야 해서 review coordination 비용이 더 크다.

### 범위 결정 근거

- 포함: `apps/edge/internal/openai/types.go`, `stream.go`, 필요 시 `tool_validation.go`, `server_test.go`.
- 제외: node provider adapter, provider pool routing, token usage/log ledger, docs 포트 수정, Pi client 설정. 이번 증상은 client가 받은 Edge `tool_validation_error`이며, provider/Node outage 또는 client-side parse failure가 아니다.
- 제외: secret/API key logging. 기존 `log_safety_test.go`의 preview field 금지를 유지한다.

### 빌드 등급

`local-G06`. 변경은 한 Go 패키지에 갇히고 deterministic unit/regression test로 검증 가능하지만, parser state machine과 streaming retry 경로가 OpenAI-compatible 외부 계약에 닿아 중간 수준 리스크가 있다.

## 구현 체크리스트

- [ ] [BUGFIX_TOOLCALL-1] 실제 dev 실패 형태의 `edit` raw XML tool-call 회귀 테스트를 추가한다.
- [ ] [BUGFIX_TOOLCALL-2] XML raw text tool-call parser를 regex-only에서 parameter body를 opaque text로 다루는 scanner로 강화하고 기존 합성 경로를 모두 같은 parser로 연결한다.
- [ ] [BUGFIX_TOOLCALL-3] `tools[]`가 있는 streaming chat completions를 retry 가능한 buffered validation 경로로 보내는 완화책을 추가한다.
- [ ] [BUGFIX_TOOLCALL-4] 로컬 회귀 테스트와 dev-runtime smoke 계약을 실행하고 결과를 review stub에 남긴다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.

## BUGFIX_TOOLCALL-1 실제 실패 fixture 추가

### 문제

실제 dev failure는 `apps/edge/internal/openai/types.go:256`의 XML candidate parser가 모델의 raw `<tool_call>` 응답을 읽는 과정에서 발생했다. 현재 테스트는 단순 `<parameter=commands>` 중심이라, `edit` tool payload의 큰 JSON/Markdown/angle-bracket 본문이 같은 error를 재현하는지 고정하지 못한다.

실제 에러 케이스 예시:

````text
Pi session: 2026-07-06T09:13:01.814Z
provider/model: iop / qwen3.6:35b
response_id: chatcmpl-manual-1783329323005263000
stopReason: error
errorMessage: malformed tool call: missing function block or definition inside tool_call

assistant content preview:
두 줄을 `18083` -> `18081`로 수정하고 검증을 진행한다.

<tool_call>
<function=edit>
<parameter=path>
/config/workspace/iop/docs/edge-local-dev-guide.md
</parameter>
<parameter=edits>
[{"newText": "```bash\ncurl -fsS http://<edge-host>:18081/v1/chat/completions ..."}]
</parameter>
</function>
</tool_call>
````

핵심은 `<parameter=edits>` 본문이 JSON string array이고 그 안에 Markdown code fence, escaped JSON request body, literal `<edge-host>` placeholder가 들어간다는 점이다. 이 literal angle-bracket text는 tool-call XML tag로 해석하면 안 된다.

### 해결 방법

`apps/edge/internal/openai/server_test.go`에 다음 성격의 테스트를 추가한다.

Before:

```go
// apps/edge/internal/openai/server_test.go:514
func TestChatCompletionsSynthesizesTextToolCallsForProviderRoute(t *testing.T) {
    fake.events <- &iop.RunEvent{Type: "delta", Delta: "...\n<parameter=commands>\n[\"git status\"]\n</parameter>..."}
}
```

After:

```go
func TestChatCompletionsSynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder(t *testing.T) {
    fake.events <- &iop.RunEvent{Type: "delta", Delta: "두 줄을 `18083` -> `18081`로 수정하고 검증을 진행한다.\n\n" + editToolCallFixture}
    // expect finish_reason tool_calls, function.name edit, path preserved,
    // edits decoded as JSON array, and no raw <tool_call> leak.
}
```

fixture 요구:

- tool schema: `edit` with required `path` string and `edits` array.
- `edits[0].newText` contains `curl -fsS http://<edge-host>:18081/v1/chat/completions`.
- response body must not contain raw `<tool_call>` or escaped `\u003ctool_call`.
- `function.arguments` JSON must decode and preserve the literal `<edge-host>` inside `newText`.

### 수정 파일 및 체크리스트

- [ ] `apps/edge/internal/openai/server_test.go`: actual failure fixture helper/test 추가.
- [ ] `apps/edge/internal/openai/server_test.go`: stream variant도 같은 fixture로 추가하거나 BUGFIX_TOOLCALL-3 테스트에서 재사용.

### 테스트 작성

작성한다. Test names:

- `TestChatCompletionsSynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder`
- `TestChatCompletionsStreamSynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder`

### 중간 검증

```bash
go test ./apps/edge/internal/openai -run 'TestChatCompletions(SynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder|StreamSynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder)$'
```

Expected: PASS, no raw `<tool_call>` leak, decoded `edits[0].newText` contains literal `<edge-host>`.

## BUGFIX_TOOLCALL-2 XML parser scanner 강화

### 문제

`apps/edge/internal/openai/types.go:214`-`217`는 XML-ish tool-call parsing을 regex로 수행한다. `collectXMLCandidateMatches`는 `textToolCallBlockRE`로 block을 자르고 `textToolFunctionRE`와 `textToolParameterRE`를 별도로 적용한다. `collectXMLTextToolCallMatches`와 `parseTextToolCallBlock`도 같은 regex 계열을 다시 사용한다.

```go
// apps/edge/internal/openai/types.go:214
textToolCallBlockRE = regexp.MustCompile(`(?is)<\s*tool_call\s*>(.*?)<\s*/\s*tool_call\s*>`)
// apps/edge/internal/openai/types.go:216
textToolFunctionRE = regexp.MustCompile(`(?is)<\s*function\s*=\s*...`)
// apps/edge/internal/openai/types.go:217
textToolParameterRE = regexp.MustCompile(`(?is)<\s*parameter\s*=\s*...`)
```

이 방식은 parameter 본문에 code fence, JSON string, angle-bracket placeholder가 섞인 실제 agent payload를 opaque body로 다루지 못하는 취약점이 있다.

### 해결 방법

XML 형식만 작은 scanner로 교체한다. mustache scanner는 이미 `findMustacheToolCallEnd`에서 quote/depth를 고려하므로 범위를 넓히지 않는다.

After approach:

```go
type xmlTextToolCallBlock struct {
    start int
    end   int
    name  string
    params map[string]string
    err   error
}

func scanXMLTextToolCallBlocks(content string) ([]xmlTextToolCallBlock, bool) {
    // 1. top-level <tool_call> open을 찾는다.
    // 2. matching </tool_call>까지 block을 잡는다.
    // 3. block 안에서 top-level <function=name>...</function>을 찾는다.
    // 4. function body에서 <parameter=key>...</parameter>를 찾고,
    //    parameter body는 opaque string으로 둔다.
}
```

구현 세부:

- `<tool_call>` open/close와 `<function=name>` open/close는 tag boundary로만 인식한다.
- `<parameter=key>` body 내부의 `<edge-host>`, shell redirect `<`, JSON string, Markdown fence는 tag로 해석하지 않는다.
- 닫히지 않은 `<tool_call>`은 기존처럼 `malformed tool call: unclosed tool_call block`.
- `<tool_call>` 안에 function block이 없으면 기존 error string `malformed tool call: missing function block or definition inside tool_call`를 유지한다.
- unknown tool은 기존 `tool %q is not in request tools`를 유지한다.
- `collectXMLCandidateMatches`와 `collectXMLTextToolCallMatches`가 같은 scanner 결과를 사용하게 한다.

### 수정 파일 및 체크리스트

- [ ] `apps/edge/internal/openai/types.go`: XML regex block/function/parameter parser를 scanner helper로 교체.
- [ ] `apps/edge/internal/openai/types.go`: `collectXMLCandidateMatches`와 `collectXMLTextToolCallMatches`가 parser 결과를 공유.
- [ ] `apps/edge/internal/openai/types.go`: existing error string과 return semantics 유지.
- [ ] `apps/edge/internal/openai/server_test.go`: 기존 XML/mustache/unknown/malformed 테스트가 계속 통과하는지 확인.

### 테스트 작성

작성한다. BUGFIX_TOOLCALL-1 fixture가 scanner 회귀를 검증한다. 추가로 `</parameter>`가 JSON string 안에 들어가는 케이스는 이번 범위에서 필수로 만들지 않는다. 실제 실패는 `<edge-host>` literal이며, 임의 close-tag escaping 정책은 별도 parser hardening 후속으로 둔다.

### 중간 검증

```bash
go test ./apps/edge/internal/openai -run 'TestChatCompletions(SynthesizesToolCallsFromClineTextBlock|SynthesizesTextToolCallsForProviderRoute|SynthesizesProviderTextToolCallsWhenFallbackMarked|FailsMalformedTextToolCallAfterRetryLimit|SynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder)$'
```

Expected: PASS. 기존 raw text tool-call normalization behavior와 실제 edit fixture가 모두 통과한다.

## BUGFIX_TOOLCALL-3 tools streaming을 buffered validation 경로로 완화

### 문제

live SSE 경로는 `apps/edge/internal/openai/stream.go:22`에서 `outputPolicy.Strict && outputPolicy.StreamBuffer`일 때만 buffered retry 경로로 간다. 그렇지 않으면 `stream.go:217`-`220`에서 complete 시점에 raw text tool-call 합성을 시도하고 실패하면 SSE `tool_validation_error`를 낸다. 이 경로는 이미 content delta를 보냈을 수 있어 bounded retry가 불가능하다. `tool_validation.go:45`-`50`도 live SSE의 retry 제한을 설명한다.

```go
// apps/edge/internal/openai/stream.go:36
if outputPolicy.Strict && outputPolicy.StreamBuffer {
    s.streamBufferedChatCompletion(...)
}

// apps/edge/internal/openai/stream.go:217
} else if len(req.Tools) > 0 {
    res := synthesizeToolCallsFromTextResult(...)
    if res.validationErr != nil {
        writeSSEErrorWithType(...)
        return
    }
}
```

이번 dev failure는 `strict_output=false`, `strict_stream_buffer=false`인 streaming tool-use turn에서 발생해 재시도 없이 client-visible error가 되었다.

### 해결 방법

`tools[]`가 있는 chat completions streaming 요청은 strict output 여부와 무관하게 buffered validation 경로로 보낸다.

After:

```go
if outputPolicy.Strict && outputPolicy.StreamBuffer || len(req.Tools) > 0 {
    s.streamBufferedChatCompletion(w, r, req, submitReq, handle, flusher, outputPolicy, validation)
    return
}
```

동시에 `toolValidationContractForChatRequest`도 `req.Stream && len(req.Tools)>0` buffered policy와 일치하도록 validation contract를 활성화한다. live SSE에서 raw tool-call filter를 유지하는 기존 방어는 no-tools streaming과 혹시 남는 live path용으로 보존한다.

### 수정 파일 및 체크리스트

- [ ] `apps/edge/internal/openai/stream.go`: `len(req.Tools)>0` stream은 `streamBufferedChatCompletion`을 사용.
- [ ] `apps/edge/internal/openai/tool_validation.go`: stream+tools validation contract가 buffered path에서 활성화되도록 조건 정렬.
- [ ] `apps/edge/internal/openai/server_test.go`: 기존 `TestChatCompletionsLiveStreamSkipsRuntimeToolValidation` 기대값을 새 정책에 맞게 수정하거나 새 테스트로 대체.
- [ ] `apps/edge/internal/openai/server_test.go`: invalid first stream attempt -> valid retry -> only valid tool_call emitted을 strict flag 없이 검증.

### 테스트 작성

작성한다. Test name:

- `TestChatCompletionsToolsStreamRetriesMalformedToolCallBeforeChunk`

목표:

- config `StrictOutput:false`, `StrictStreamBuffer:false`.
- request `stream:true`, `tools[]`.
- first fake run malformed native/text tool-call, second run valid.
- `fake.reqsSnapshot()` length 2.
- SSE body contains valid second call and `[DONE]`, first bad call/raw `<tool_call>` does not leak.

### 중간 검증

```bash
go test ./apps/edge/internal/openai -run 'TestChatCompletions(ToolsStreamRetriesMalformedToolCallBeforeChunk|StrictBufferedStreamRetriesMalformedToolCallBeforeChunk|ProviderStreamBlocksMalformedTextToolCall)$'
```

Expected: PASS. tool-bearing stream retry policy works without requiring `StrictOutput:true`.

## BUGFIX_TOOLCALL-4 검증과 dev smoke 계약

### 문제

OpenAI-compatible raw text tool-call boundary는 외부 API 계약이다. local unit regression만으로 parser는 검증 가능하지만, dev-runtime `18083` smoke는 원격 checkout과 binary가 변경 commit을 반영해야 의미가 있다.

### 해결 방법

local regression을 필수로 실행하고, dev-runtime이 구현 commit으로 배포된 뒤 non-stream/stream smoke를 수행한다. dev smoke payload는 tracked 파일에 secret을 남기지 않고 `/tmp`에서 생성한다.

### 수정 파일 및 체크리스트

- [ ] `CODE_REVIEW-local-G06.md`: local test stdout/stderr 붙여넣기.
- [ ] `CODE_REVIEW-local-G06.md`: dev smoke를 수행했으면 `/tmp` output path와 PASS 조건 기록.
- [ ] dev smoke를 수행하지 못하면 원격 sync/binary stale/token 부재 중 정확한 blocker를 기록.

### 테스트 작성

추가 테스트 파일은 만들지 않는다. `server_test.go` 회귀 테스트가 구현 검증이다.

### 중간 검증

```bash
go test ./apps/edge/internal/openai
```

Expected: PASS.

## 수정 파일 요약

| 파일 | 항목 |
|---|---|
| `apps/edge/internal/openai/server_test.go` | BUGFIX_TOOLCALL-1, BUGFIX_TOOLCALL-3 |
| `apps/edge/internal/openai/types.go` | BUGFIX_TOOLCALL-2 |
| `apps/edge/internal/openai/stream.go` | BUGFIX_TOOLCALL-3 |
| `apps/edge/internal/openai/tool_validation.go` | BUGFIX_TOOLCALL-3 |
| `agent-task/openai_text_tool_call_boundary/CODE_REVIEW-local-G06.md` | BUGFIX_TOOLCALL-4 |

## 최종 검증

```bash
go test ./apps/edge/internal/openai
```

Expected: PASS. Go test cache output is acceptable only if the new test names appear in the package test list during implementation; if in doubt run `go test -count=1 ./apps/edge/internal/openai`.

Dev-runtime smoke after the implementation commit is deployed to `/Users/toki/agent-work/iop-dev`:

```bash
ssh 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 >/tmp/iop-tool-boundary-refresh-help.txt && curl -fsS http://127.0.0.1:18001/edges/edge-toki-labs-dev/status >/tmp/iop-tool-boundary-status.json'
```

Expected: remote checkout is the implementation commit or documented deployed commit, edge binary exists, refresh help succeeds, status JSON exists. If the checkout/binary is stale, do not treat the following smoke as current-change verification.

```bash
ssh toki@toki-labs.com 'cd /Users/toki/agent-work/iop-dev && test -n "${IOP_OPENAI_TOKEN:-}" && cat >/tmp/iop-tool-boundary-nonstream.json <<'"'"'JSON'"'"'
{"model":"qwen3.6:35b","stream":false,"messages":[{"role":"user","content":"Reply with exactly one edit tool call for /tmp/example.md that writes a curl example using http://<edge-host>:18081/v1/chat/completions inside a markdown bash fence."}],"tools":[{"type":"function","function":{"name":"edit","parameters":{"type":"object","properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"newText":{"type":"string"}},"required":["newText"]}}},"required":["path","edits"],"additionalProperties":false}}}],"tool_choice":"auto"}
JSON
curl -sS http://127.0.0.1:18083/v1/chat/completions -H "Authorization: Bearer ${IOP_OPENAI_TOKEN}" -H "Content-Type: application/json" --data-binary @/tmp/iop-tool-boundary-nonstream.json >/tmp/iop-tool-boundary-nonstream.out
python3 - <<'"'"'PY'"'"'
from pathlib import Path
s=Path("/tmp/iop-tool-boundary-nonstream.out").read_text()
assert "<tool_call" not in s and "\\u003ctool_call" not in s, s
assert "tool_calls" in s or "tool_validation_error" in s, s
PY'
```

Expected: no raw `<tool_call>` leak. A valid raw text tool-call is normalized to `tool_calls`; if the live model refuses or emits malformed output after retry, response must be controlled `tool_validation_error`, not leaked success content.

```bash
ssh toki@toki-labs.com 'cd /Users/toki/agent-work/iop-dev && test -n "${IOP_OPENAI_TOKEN:-}" && python3 - <<'"'"'PY'"'"' >/tmp/iop-tool-boundary-stream.json
import json
print(json.dumps({"model":"qwen3.6:35b","stream":True,"messages":[{"role":"user","content":"Reply with exactly one edit tool call for /tmp/example.md that writes a curl example using http://<edge-host>:18081/v1/chat/completions inside a markdown bash fence."}],"tools":[{"type":"function","function":{"name":"edit","parameters":{"type":"object","properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"newText":{"type":"string"}},"required":["newText"]}}},"required":["path","edits"],"additionalProperties":False}}}],"tool_choice":"auto"}))
PY
curl -sS http://127.0.0.1:18083/v1/chat/completions -H "Authorization: Bearer ${IOP_OPENAI_TOKEN}" -H "Content-Type: application/json" --data-binary @/tmp/iop-tool-boundary-stream.json >/tmp/iop-tool-boundary-stream.sse
python3 - <<'"'"'PY'"'"'
from pathlib import Path
s=Path("/tmp/iop-tool-boundary-stream.sse").read_text()
assert "<tool_call" not in s and "\\u003ctool_call" not in s, s
assert "data: [DONE]" in s, s
assert "tool_calls" in s or "tool_validation_error" in s, s
PY'
```

Expected: SSE terminates with `[DONE]`, no raw `<tool_call>` leak, and either normalized `delta.tool_calls` or controlled `tool_validation_error`.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
