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

# Code Review Reference - BUGFIX_TOOLCALL

> **[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-06
task=openai_text_tool_call_boundary, plan=0, tag=BUGFIX_TOOLCALL

## 이 파일을 읽는 리뷰 에이전트에게

> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.

각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.

1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` → `code_review_local_G06_N.log`, `PLAN-local-G06.md` → `plan_local_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/openai_text_tool_call_boundary/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.

---

## 구현 항목별 완료 여부

| 항목 | 완료 여부 |
|------|---------|
| [BUGFIX_TOOLCALL-1] 실제 dev 실패 형태의 `edit` raw XML tool-call 회귀 테스트 | [x] |
| [BUGFIX_TOOLCALL-2] XML raw text tool-call parser scanner 강화 | [x] |
| [BUGFIX_TOOLCALL-3] tools streaming buffered validation 완화 | [x] |
| [BUGFIX_TOOLCALL-4] 검증과 dev smoke 계약 기록 | [x] |

## 구현 체크리스트

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

## 코드리뷰 전용 체크리스트

> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.

- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. (WARN)
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. (Suggested 1 + Nit 2 → WARN)
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다. (N=0)
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다. (M=0)
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/openai_text_tool_call_boundary/`를 `agent-task/archive/YYYY/MM/openai_text_tool_call_boundary/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active plan/review 파일을 작성하고 `complete.log`를 작성하지 않는다. (후속 lane/grade 재산정: `PLAN-local-G04.md`/`CODE_REVIEW-local-G04.md`, plan=1, tag=REVIEW_BUGFIX_TOOLCALL)
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.

## 계획 대비 변경 사항

- `apps/edge/internal/openai/chat_handler.go`도 수정했다. `tools[]` stream을 buffered path로 보내면 native `tool_calls`와 함께 수집된 raw text tool-call 후보가 content chunk로 다시 출력될 수 있어, 기존 live stream filter와 같은 `FlushNonCandidate()` 정리를 collect 단계에 적용했다.
- 기존 `TestChatCompletionsStreamWithToolsFlushesTextBeforeComplete`는 새 정책과 충돌하므로 `TestChatCompletionsStreamWithToolsBuffersTextUntilComplete`로 갱신했다. root fix에서는 tool-bearing stream이 complete 전 live content flush를 하지 않고 buffered validation을 우선한다.
- dev-runtime smoke는 실행하지 않았다. 현재 local 변경이 원격 `/Users/toki/agent-work/iop-dev` checkout과 dev-runtime binary에 배포된 상태가 아니므로, 실행하더라도 이번 변경 검증으로 인정할 수 없다.

## 주요 설계 결정

- `stream=true`라도 요청에 `tools[]`가 있으면 strict 설정과 무관하게 buffered stream 경로를 사용한다. 이 경로는 첫 SSE chunk 전 전체 run output을 수집하므로 schema validation 실패 시 bounded retry 또는 controlled `tool_validation_error`로 닫을 수 있다.
- runtime tool validation contract는 stream 여부로 끄지 않는다. tool-bearing stream이 buffered path로 라우팅되므로 non-stream과 같은 validation/retry 의미를 유지한다.
- XML text tool-call parser는 regex-only 매칭 대신 작은 scanner로 교체했다. `<parameter=...>` 본문은 close tag 전까지 opaque text로 다루어 Markdown fence, JSON string, literal `<edge-host>` 같은 angle-bracket placeholder를 tag로 오인하지 않는다.
- 모델이 잘못 만든 `edit` 인자를 자동으로 임의 보정하지는 않는다. 스키마 위반은 Edge가 Pi에 전달하기 전에 검증/재시도하고, 재시도 후에도 실패하면 controlled error로 반환하는 정책이다.

## 사용자 리뷰 요청

_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._

- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음

## 리뷰어를 위한 체크포인트

- 실제 dev 실패 fixture가 `edit` tool, `path`, `edits[]`, Markdown fence, literal `<edge-host>`를 포함하는지 확인한다.
- parser가 parameter body를 opaque text로 다루면서 기존 malformed/unknown tool error semantics를 유지하는지 확인한다.
- stream+tools 요청이 strict flag 없이도 retry 가능한 buffered validation 경로를 타는지 확인한다.
- 성공 응답/SSE에 raw `<tool_call>`, escaped `\u003ctool_call`, sentinel이 새지 않는지 확인한다.
- token/secret 원문이 plan, review, logs, test output에 남지 않았는지 확인한다.

## 검증 결과

_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._

필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.

### BUGFIX_TOOLCALL-1 중간 검증
```bash
$ go test ./apps/edge/internal/openai -run 'TestChatCompletions(SynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder|StreamSynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder)$'
ok  	iop/apps/edge/internal/openai	0.006s
```

### BUGFIX_TOOLCALL-2 중간 검증
```bash
$ go test ./apps/edge/internal/openai -run 'TestChatCompletions(SynthesizesToolCallsFromClineTextBlock|SynthesizesTextToolCallsForProviderRoute|SynthesizesProviderTextToolCallsWhenFallbackMarked|FailsMalformedTextToolCallAfterRetryLimit|SynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder)$'
ok  	iop/apps/edge/internal/openai	0.006s
```

### BUGFIX_TOOLCALL-3 중간 검증
```bash
$ go test ./apps/edge/internal/openai -run 'TestChatCompletions(ToolsStreamRetriesMalformedToolCallBeforeChunk|StrictBufferedStreamRetriesMalformedToolCallBeforeChunk|ProviderStreamBlocksMalformedTextToolCall)$'
ok  	iop/apps/edge/internal/openai	0.006s
```

### BUGFIX_TOOLCALL-4 중간 검증
```bash
$ go test ./apps/edge/internal/openai
ok  	iop/apps/edge/internal/openai	1.573s
```

### 최종 검증
```bash
$ go test -count=1 -timeout 60s ./apps/edge/internal/openai
ok  	iop/apps/edge/internal/openai	1.570s
```

```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'
not run: local implementation has not been committed/deployed to `/Users/toki/agent-work/iop-dev` and the dev-runtime binary has not been rebuilt from this change, so this would not be valid current-change evidence.
```

```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'
not run: dev-runtime smoke is gated on deploying this implementation to the remote dev checkout and rebuilding the dev-runtime Edge binary.
```

```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'
not run: dev-runtime smoke is gated on deploying this implementation to the remote dev checkout and rebuilding the dev-runtime Edge binary.
```

---

> **[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.

## Section Ownership

| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute archive/complete/archive move steps |
| 구현 항목별 완료 여부 | Implementing agent checks only | Item text/order fixed |
| 구현 체크리스트 | Implementing agent checks only | Final checkbox is mandatory |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless selected Milestone lock decision blocks implementation |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus list |
| 검증 결과 | Implementing agent | Paste actual stdout/stderr |
| 코드리뷰 결과 | Review agent appends | Not included until review |

## 코드리뷰 결과

date=2026-07-06
reviewer=code-review skill (local-G06)

### 종합 판정

WARN

### 차원별 평가

| 차원 | 판정 | 근거 |
|------|------|------|
| Correctness | Warn | scanner/버퍼링 핵심 로직은 정확하나, `chat_handler.go:383-386`의 native tool call + content 혼합 케이스에서 안전한 선행 prose까지 전부 유실됨 (아래 Suggested) |
| Completeness | Pass | BUGFIX_TOOLCALL-1~4 계획 항목 모두 구현·검증됨. dev smoke 미실행은 계획이 허용한 조건부 항목이며 정확한 blocker(로컬 변경 미배포)가 기록됨 |
| Test coverage | Pass | 실제 dev 실패 fixture(non-stream/stream), tools stream retry, provider stream retry(2회 시도), buffering 정책 테스트 모두 존재하고 의미 있는 assert 포함 |
| API contract | Pass | 기존 error string 3종(`missing function block...`, `unclosed tool_call block`, `tool %q is not in request tools`) 유지, raw `<tool_call>`/`<tool_call` 누출 없음, SSE `[DONE]` 종료 유지 |
| Code quality | Pass | Nit 2건 (dead code chain, `close` builtin shadowing). 디버그 출력/TODO 없음 |
| Plan deviation | Pass | `chat_handler.go` 추가 수정과 `TestChatCompletionsStreamWithToolsBuffersTextUntilComplete` 개명이 `계획 대비 변경 사항`에 사유와 함께 기록됨 |
| Verification trust | Pass | 리뷰에서 `go test -count=1 -timeout 120s ./apps/edge/internal/openai` 재실행 PASS(1.575s, 기록 1.570s와 일치), 신규 테스트명 `-v`로 실존 확인, `gofmt -l` clean, `go build ./apps/edge/...` OK. `go vet`의 `input_estimator.go:95` 경고는 이번 diff 밖 기존 이슈 |

### 리뷰 검증 근거

- scanner 동작 검토: `<parameter>` body를 close tag까지 opaque로 유지, `<edge-host>` literal/Markdown fence/JSON string이 tag로 해석되지 않음을 fixture 테스트로 확인.
- `collectXMLCandidateMatches`/`collectXMLTextToolCallMatches`/`parseTextToolCallBlock`이 모두 `scanXMLTextToolCallBlocks`를 공유함을 확인 (계획 BUGFIX_TOOLCALL-2 충족).
- stream 라우팅: `stream.go:37`에서 `len(req.Tools) > 0`이면 strict flag와 무관하게 `streamBufferedChatCompletion` 진입, `tool_validation.go:49`의 contract가 stream 여부로 꺼지지 않음을 확인 (계획 BUGFIX_TOOLCALL-3 충족).
- buffered 전환의 timeout 의미 변화(라이브 per-event → 전체 실행)는 Node가 `apps/node/internal/node/node.go:147`에서 `TimeoutSec` 전체 실행 제한을 이미 강제하므로 실질 회귀 아님을 확인.
- 제거된 regex 심볼(`textToolCallBlockRE` 등 4종) 잔존 참조 없음 확인. log safety 테스트 PASS.
- 혼합 케이스 probe 테스트로 Suggested 이슈 실동작 확인: prose+`<tool_call>` 혼합 시 content 전체 drop, `{{config}}` 같은 비후보 텍스트만으로도 전체 drop.

### 발견된 문제

- Suggested: `apps/edge/internal/openai/chat_handler.go:383-386` — native tool call과 content가 공존할 때 `streamToolTextFilter{pending: message.Content}.FlushNonCandidate()`가 content 안 어디든 후보 힌트(`<tool_call` 또는 `{{`)가 있으면 선행 prose까지 content 전체를 버린다. live stream 경로(`stream.go:200-205`)는 `Append`가 안전 prefix를 이미 방출한 뒤 잔여 pending만 버리는 구조라 의미가 다르다. 특히 `{{`는 mustache 후보 힌트일 뿐이라 `{{config}}` 같은 일반 템플릿 텍스트만 있어도 non-stream 성공 응답의 content가 통째로 빈 문자열이 된다(변경 전 non-stream은 content를 유지했음). 수정: filter를 빈 상태로 만들고 `prefix := filter.Append(message.Content); message.Content = prefix + filter.FlushNonCandidate()`로 후보 시작 전 prose를 보존한다. native tool call + prose + raw `<tool_call>` echo 혼합 회귀 테스트를 함께 추가한다.
- Nit: `apps/edge/internal/openai/types.go:477,490,712,1089` — `collectTextToolCallMatches`/`collectXMLTextToolCallMatches`/`collectMustacheTextToolCallMatches`/`parseTextToolCallBlock` 체인은 production/test 호출자가 전혀 없는 dead code다(HEAD에서도 dead였고 이번 변경으로 `parseTextToolCallBlock`이 완전히 고아가 됨). 삭제 후보.
- Nit: `apps/edge/internal/openai/types.go:608` — `close := strings.IndexByte(...)`가 builtin `close`를 shadowing. `closeIdx` 등으로 개명 권장. 같은 맥락에서 `tool_validation.go:49`의 `toolValidationContractForChatRequest`는 이제 인자 하나를 버리는 단순 passthrough라 호출부에서 `toolValidationContractFromRequest` 직접 호출로 정리 가능.

### 다음 단계

- WARN: user-review gate 미트리거(사용자 리뷰 요청 상태=없음, Milestone lock 무관). 후속 active `PLAN-local-G04.md`/`CODE_REVIEW-local-G04.md`(plan=1, tag=REVIEW_BUGFIX_TOOLCALL)를 작성한다.
