From 8944f9da28214f69f62c622ae9a3f10ec964acc8 Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 14 Aug 2026 04:33:50 +0900 Subject: [PATCH 01/18] sync: to agentic-framework v1.1.202 --- agent-ops/.version | 2 +- agent-ops/skills/common/update-roadmap/SKILL.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/agent-ops/.version b/agent-ops/.version index 99380987..837aff75 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.201 +1.1.202 diff --git a/agent-ops/skills/common/update-roadmap/SKILL.md b/agent-ops/skills/common/update-roadmap/SKILL.md index 5147b221..377e2e19 100644 --- a/agent-ops/skills/common/update-roadmap/SKILL.md +++ b/agent-ops/skills/common/update-roadmap/SKILL.md @@ -250,6 +250,7 @@ agent-roadmap/ | 작업 컨텍스트/TODO | 에이전트가 확정할 수 없는 결정 또는 조사/확인이 먼저 필요해 기능 Task로 확정하기 어렵다 | - 먼저 요청 내용의 규모를 판정한다. 배치 위치를 찾기 전에 `phase`, `milestone`, `epic`, `task`, `subtask`, `context` 중 가장 작은 충분한 단위를 고른다. +- Milestone에는 목표 달성에 필요한 최소 capability만 둔다. 완료 조건에 필수라는 근거가 없는 검증 도구, 자동화, 범용화는 범위 제외나 후속 Milestone으로 둔다. - 요청이 방향성, 문제의식, 컨셉, 운영 원칙 수준이고 기능 Task나 실행 범위가 아직 부족하면 새 항목의 상태는 `[스케치]`로 둔다. - `[스케치]` Phase/Milestone을 만들 때는 `승격 조건`에 `[계획]`으로 전환하기 위해 필요한 정의, 결정, 경계, 후속 구현 Milestone 후보를 체크리스트로 남긴다. - 가장 작은 충분한 단위 원칙을 따른다. 애매하면 새 Phase나 새 Milestone으로 키우지 말고, 기존 Milestone의 Epic/Task에 넣을 수 있는지 먼저 확인한다. From d99305d105abadeb6a4e51c0aefd603779abf88f Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 14 Aug 2026 04:42:45 +0900 Subject: [PATCH 02/18] =?UTF-8?q?fix(edge):=20Gemini=20=EB=8F=84=EA=B5=AC?= =?UTF-8?q?=20=EC=84=9C=EB=AA=85=EC=9D=84=20provider=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A0=95=EA=B7=9C=ED=99=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent-contract/index.md | 4 +- .../inner/edge-config-runtime-refresh.md | 1 + agent-contract/outer/openai-compatible-api.md | 1 + .../benchmark-route-minimal-html-smoke.md | 19 +- .../benchmark-route-minimal-html-smoke/SDD.md | 91 +++++++ agent-spec/input/openai-compatible-surface.md | 8 + .../iop-benchmark-route-minimal-html-smoke.md | 10 +- apps/edge/internal/openai/anthropic_bridge.go | 39 ++- apps/edge/internal/openai/chat_handler.go | 9 +- .../internal/openai/provider_model_rewrite.go | 224 +++++++++++++++++- .../openai/provider_model_rewrite_test.go | 172 ++++++++++++++ .../openai/provider_test_support_test.go | 25 +- apps/edge/internal/openai/provider_tunnel.go | 2 +- .../openai/stream_gate_release_sink.go | 14 ++ .../internal/openai/stream_gate_runtime.go | 12 +- .../internal/service/protocol_profile_test.go | 11 + apps/edge/internal/service/provider_pool.go | 12 + apps/edge/internal/service/run_types.go | 1 + packages/go/config/protocol_profile.go | 48 +++- packages/go/config/protocol_profile_test.go | 39 +++ 20 files changed, 690 insertions(+), 52 deletions(-) create mode 100644 agent-roadmap/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md create mode 100644 apps/edge/internal/openai/provider_model_rewrite_test.go diff --git a/agent-contract/index.md b/agent-contract/index.md index 8b328a3e..7242d449 100644 --- a/agent-contract/index.md +++ b/agent-contract/index.md @@ -12,7 +12,7 @@ | id | 읽는 조건 | 원본 경로 | path | |----|-----------|-----------|------| -| `iop.openai-compatible-api` | OpenAI-compatible API, Responses API, Chat Completions, legacy Completions, error envelope/SSE terminal error, `model` route, managed projection principal auth and slot-route binding, managed-versus-legacy provider credential selection, model-driven passthrough/normalized routing, provider-pool admission/unavailable error, safe credential-slot attribution, standard metadata, and provider-native extension fields such as `chat_template_kwargs` | `apps/edge/internal/openai/*`, `apps/edge/internal/authprojection/*`, `apps/edge/internal/service/provider_tunnel.go`, `packages/go/config/config.go`, `configs/edge.yaml` | `agent-contract/outer/openai-compatible-api.md` | +| `iop.openai-compatible-api` | OpenAI-compatible API, Responses API, Chat Completions, legacy Completions, error envelope/SSE terminal error, `model` route, managed projection principal auth and slot-route binding, managed-versus-legacy provider credential selection, model-driven passthrough/normalized routing, provider-pool admission/unavailable error, Gemini Chat thought-signature tool continuation, safe credential-slot attribution, standard metadata, and provider-native extension fields such as `chat_template_kwargs` | `apps/edge/internal/openai/*`, `apps/edge/internal/authprojection/*`, `apps/edge/internal/service/provider_tunnel.go`, `packages/go/config/config.go`, `configs/edge.yaml` | `agent-contract/outer/openai-compatible-api.md` | | `iop.anthropic-compatible-api` | Anthropic Messages API, count_tokens, models list, bearer or `X-Api-Key` principal auth, active managed projection auth and slot-route binding, `anthropic-version` routing, native Anthropic tunnel, Chat bridge, provider-pool-only admission, profile capability checks, managed-versus-legacy provider credentials, marked-preset single-request admission with Edge-owned internal Plan/Review template customization that leaves caller I/O unchanged, and current no-OpenAI-metric status | `apps/edge/internal/openai/anthropic_handler.go`, `apps/edge/internal/openai/anthropic_native.go`, `apps/edge/internal/openai/anthropic_bridge.go`, `apps/edge/internal/openai/anthropic_stream.go`, `apps/edge/internal/openai/anthropic_types.go`, `apps/edge/internal/openai/routes.go`, `apps/edge/internal/openai/principal.go`, `apps/edge/internal/authprojection/*`, `apps/edge/internal/openai/provider_tunnel.go`, `apps/edge/internal/openai/provider_model_rewrite.go`, `apps/edge/internal/openai/single_request_preset_binding.go`, `apps/edge/internal/openai/single_request_plan_stage.go`, `apps/edge/internal/openai/single_request_review_stage.go`, `packages/go/singlerequesttemplate/template.go`, `packages/go/config/protocol_profile.go` | `agent-contract/outer/anthropic-compatible-api.md` | | `iop.gemini-compatible-api` | Gemini Developer API `streamGenerateContent`, route-qualified Gemini-native ingress, `x-goog-api-key` principal auth, `GOOGLE_GEMINI_BASE_URL`, official agy 1.1.12 API-key transport, Gemini function calls/thought signatures/SSE, and direct-versus-execution-preset binding | `apps/edge/internal/openai/routes.go`, `apps/edge/internal/openai/principal.go`, `apps/edge/internal/openai/gemini_handler.go`, `apps/edge/internal/openai/gemini_bridge.go`, `apps/edge/internal/openai/gemini_types.go` | `agent-contract/outer/gemini-compatible-api.md` | | `iop.a2a-json-rpc-api` | A2A JSON-RPC API, `message/send`, `tasks/get`, `tasks/cancel`, A2A task state, agent card, `a2a.bearer_token`, Edge A2A input surface | `apps/edge/internal/input/a2a/*`, `packages/go/config/config.go`, `configs/edge.yaml` | `agent-contract/outer/a2a-json-rpc-api.md` | @@ -24,5 +24,5 @@ | `iop.edge-node-runtime-wire` | Edge-Node mTLS/protobuf, workload identity, Node initial/reconnect supervision, connection generation fencing, `RegisterRequest`, `NodeReadyRequest`, `RunRequest`, `RunEvent`, signed/sealed `CredentialLease`, `CredentialLeaseBinding`, `ProviderTunnelRequest`, `ProviderTunnelFrame`, cancel, command, and config payload/refresh | `proto/iop/runtime.proto`, `apps/edge/internal/transport/*`, `apps/node/internal/transport/*`, `apps/node/internal/bootstrap/runtime_supervisor.go`, `apps/node/internal/node/tunnel_handler.go`, `apps/edge/internal/node/mapper.go`, `apps/node/internal/adapters/config_set.go` | `agent-contract/inner/edge-node-runtime-wire.md` | | `iop.control-plane-edge-wire` | Control Plane-Edge mTLS wire, authenticated workload identity, `EdgeHello*`, active `PrincipalProjection*` hello/refresh, authenticated `AcquireLease*`, status/command/event relay, Edge connection registry, and configured offline Node/provider snapshot | `proto/iop/control.proto`, `apps/control-plane/internal/wire/*`, `apps/control-plane/internal/credentialstore/projection.go`, `apps/control-plane/internal/credentiallease/*`, `apps/edge/internal/authprojection/*`, `apps/edge/internal/controlplane/*` | `agent-contract/inner/control-plane-edge-wire.md` | | `iop.client-control-plane-wire` | Client-Control Plane `/client` WebSocket hello plus the adjacent server-authenticated credential HTTPS, principal bearer authorization, host-local bootstrap, and slot/route lifecycle boundary | `proto/iop/control.proto`, `apps/control-plane/internal/wire/client.go`, `apps/control-plane/cmd/control-plane/credential_http_handlers.go`, `apps/client/lib/iop_wire/*` | `agent-contract/inner/client-control-plane-wire.md` | -| `iop.edge-config-runtime-refresh` | Edge config schema, provider pool, protocol profiles, `credential_plane`, managed-versus-legacy auth exclusivity, required TLS/key material, `models[]`, `nodes[].providers[]`, `openai.model_routes`, and restart/applied refresh classification | `packages/go/config/edge_types.go`, `packages/go/config/provider_types.go`, `packages/go/config/load.go`, `packages/go/config/validate.go`, `configs/edge.yaml`, `apps/edge/internal/configrefresh/*`, `proto/iop/runtime.proto` | `agent-contract/inner/edge-config-runtime-refresh.md` | +| `iop.edge-config-runtime-refresh` | Edge config schema, provider pool, protocol profiles, effort/tool-call normalization, `credential_plane`, managed-versus-legacy auth exclusivity, required TLS/key material, `models[]`, `nodes[].providers[]`, `openai.model_routes`, and restart/applied refresh classification | `packages/go/config/edge_types.go`, `packages/go/config/provider_types.go`, `packages/go/config/load.go`, `packages/go/config/validate.go`, `configs/edge.yaml`, `apps/edge/internal/configrefresh/*`, `proto/iop/runtime.proto` | `agent-contract/inner/edge-config-runtime-refresh.md` | | `iop.execution-runtime` | Host-neutral provider lifecycle, execution events, typed failures, cancellation, usage, registry, tunnel, and closed provider commands | `packages/go/execution/*`, `apps/node/internal/node/runtime_bridge.go` | `agent-contract/inner/execution-runtime.md` | diff --git a/agent-contract/inner/edge-config-runtime-refresh.md b/agent-contract/inner/edge-config-runtime-refresh.md index 26141748..c105d427 100644 --- a/agent-contract/inner/edge-config-runtime-refresh.md +++ b/agent-contract/inner/edge-config-runtime-refresh.md @@ -45,6 +45,7 @@ tracked config에는 public 예시와 기본 구조만 두고, 실제 endpoint/c - `openai.principal_tokens[]`는 raw token을 저장하지 않고 hash/reference로 principal 매핑을 관리한다. 각 entry는 `token_ref` (non-empty, unique), `token_hash_sha256` (64-char hex, duplicate hash rejection), `principal_ref` (non-empty), optional `principal_alias` 필드를 갖는다. 여러 entry가 같은 `principal_ref`와 `principal_alias`를 공유할 수 있으며, 이때 `token_ref`가 앱/통합/용도별 사용량 분해 기준이 된다. tracked config에는 raw token을 저장하지 않고 hash/reference만 둔다. - `protocol_profiles` is the top-level map of custom profile overlays, keyed by stable profile id. Each `ProtocolProfileConf` can declare `base`, `driver`, `base_url`, an operation-path map, `auth`, `capabilities`, `model_mapping`, `normalization`, and `extensions`. A custom overlay extends one built-in or custom base; cycles, unknown bases, and invalid driver/operation/capability combinations are rejected during config normalization. - `normalization.effort[operation]` declares the provider wire, supported normalized grades, whether the operation preserves effort with caller tools, and whether it preserves an explicit thinking token budget. Every normalization operation must exist in the profile operation map. Grade keys use `none|low|medium|high|xhigh|max`; exact miss falls back only to the nearest declared lower key. A canonical mapped value above its source key is rejected so config cannot silently upgrade requested effort. This Edge-local selection fact is consumed before tunnel dispatch and is not serialized into a new caller or Edge-Node wire field. +- `normalization.tool_calls[operation].wire` declares operation-scoped provider tool-call metadata normalization independently from effort mapping. The only current value is `gemini_openai_chat`; the built-in `gemini` Chat Completions operation declares it, custom overlays inherit it, and unknown wires or undeclared operations fail config validation. This runtime-only fact is attached to the selected dispatch and is not a caller selector or a new Edge-Node field. - `nodes[].providers[].profile` selects a built-in or custom catalog entry. If the selector is empty, legacy provider-type normalization can select a compatibility profile; this is distinct from `base` inheritance. Normalization resolves the selection into the runtime-only `ProviderDefinition.RuntimeProfile` snapshot, which is not serialized back into YAML. The resolved snapshot is copied into the nested OpenAI-compatible adapter config, not into a per-request tunnel message. - `ConcreteProtocolProfile.MapModel(model)`은 provider의 model alias 정규화를 수행한다. provider가 model mapping을 정의하면 IOP external `model` key를 provider served target으로 변환한다. 매핑이 없으면 original model을 그대로 사용한다. - `ConcreteProtocolProfile.HasCapability(cap)`는 provider capability admission에 사용된다. closed vocabulary (`models`, `chat`, `messages`, `responses`, `streaming`, `tool_calling`, `count_tokens`)만 허용한다. diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 0fc9e403..c9ab626a 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -268,6 +268,7 @@ Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider - selected provider가 Ollama/native provider처럼 normalized execution을 요구하면 Edge는 normalized `RunRequest` path를 사용한다. 이 경로는 OpenAI-compatible 표면을 입력/출력 compatibility layer로 제공하되, backend 호출은 normalized adapter 계약을 따른다. - `metadata`는 경로 선택자가 아니다. Edge는 route 결정 뒤 인증 principal, usage/observability 등 IOP가 아는 bounded metadata key만 발췌한다. 이 발췌 정보는 provider body를 바꾸는 selector가 아니며, passthrough 응답 body에 IOP marker/event/envelope를 섞지 않는다. - Chat Completions 성공 응답의 top-level `model` echo가 provider-served model이면 caller가 요청한 IOP model alias로 정규화할 수 있다. reasoning/content/tool_calls 같은 provider payload field는 보존한다. +- Selected protocol profile의 `normalization.tool_calls.chat_completions.wire`가 `gemini_openai_chat`일 때만 Edge는 Gemini의 opaque `extra_content.google.thought_signature`를 표준 `tool_calls[].id` 안에 캡슐화하고 provider extension field를 caller 응답에서 제거한다. Caller는 받은 tool-call id를 assistant history와 tool result의 `tool_call_id`에 변경 없이 replay해야 한다. 다음 요청에서 Edge는 원래 id와 signature를 복원해 Gemini provider에 전달한다. 다른 profile, signature 없는 Gemini 응답, tool call 없는 응답에는 적용하지 않으며 caller/SDK 이름으로 분기하지 않는다. IOP prefix를 가진 malformed id와 충돌하는 signature는 provider dispatch 전에 거부한다. IOP 확장 think 제어 field: diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md index 24582cdd..f2f1115f 100644 --- a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md @@ -17,9 +17,14 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 ## 구현 잠금 - 상태: 해제 -- SDD: 불필요 -- SDD 문서: 없음 -- SDD 사유: 기존 공개 호출 경로를 임시 workspace에서 수동 확인하는 test-only 작업이며 API, wire, config, schema, lifecycle 또는 retry 계약을 새로 만들지 않는다. +- SDD: 필요 +- SDD 문서: [SDD.md](../../../sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md) +- 잠금 해제 조건: + - [x] SDD 잠금이 해제되어 있다 + - [x] SDD 사용자 리뷰가 없거나 승인/해결되었다 + - [x] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다 + - [x] Evidence Map이 완료 시 `complete.log` 첫 줄의 `milestone-task` id별 집계와 최종 검증 evidence로 검증 가능하게 연결되어 있다 +- SDD 사유: OpenCode direct 실측이 Gemini Chat thought-signature 왕복 결함을 재현했고, 이를 caller 예외가 아닌 operation-scoped protocol profile config와 외부 Chat tool-call 계약으로 고정해야 한다. - 결정 필요: 없음 ## 범위 @@ -27,11 +32,11 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 - 아래 9개 기존 벤치 대상 조합을 순서와 무관하게 한 경로씩 직접 실행한다. - Claude Code → Claude direct - Claude Code → Gemini direct - - agy → Gemini direct + - OpenCode → Gemini direct - Claude Code → GPT direct - Codex → GPT direct - Claude Code → Gemini execution preset - - agy → Gemini execution preset + - OpenCode → Gemini execution preset - Claude Code → GPT execution preset - Codex → GPT execution preset - 모든 경로에 같은 구현 요구를 사용한다: 외부 asset과 JavaScript 없이 exact marker가 있는 단일 `index.html`을 구현하고 종료한다. @@ -73,7 +78,7 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 - 실행 방식: 기존 공식 caller 명령을 한 번씩 직접 실행한다. 공통화가 필요해 보여도 이 Milestone에서는 script로 승격하지 않는다. - TLS 환경 경계: 개발 Edge용 사설 CA는 Edge leaf 인증서가 아니라 managed CA bundle을 해당 벤치 caller process에만 전달한다. Codex에는 공식 변수 `CODEX_CA_CERTIFICATE`, Node 기반 caller에는 필요한 경우 `NODE_EXTRA_CA_CERTS`를 사용하며 Codex/IDE 시작 환경이나 셸 전역에 `export`하지 않는다. 그렇지 않으면 공개 TLS 연결에도 같은 CA override가 적용될 수 있다. - evidence 위치: `agent-test/dev/iop-benchmark-route-minimal-html-smoke.md` -- 현재 사전 확인: 2026-08-13 실제 원격 실행기에서 Claude Code 2.1.177, agy 1.1.12, Codex 0.146.0을 확인했고, 원격 SOPS에 보관된 기존 IOP principal token으로 token 원문을 출력하지 않은 `/v1/models`가 HTTP 200임을 확인했다. 새 벤치 전용 token은 발급하거나 사용하지 않는다. +- 현재 사전 확인: 2026-08-13 실제 원격 실행기에서 Claude Code 2.1.177과 Codex 0.146.0을 확인했고, 현재 workspace의 OpenCode 1.18.3을 별도 사용자 설정 변경 없이 command-scoped provider config로 준비했다. 원격 SOPS에 보관된 기존 IOP principal token으로 token 원문을 출력하지 않은 `/v1/models`가 HTTP 200임을 확인했다. 새 벤치 전용 token은 발급하거나 사용하지 않는다. - 현재 경로 결과: Claude Code → Claude direct와 Claude Code → Gemini direct는 최소 HTML 1회 호출을 통과했다. Claude Code → GPT direct는 Chat Completions의 tools+reasoning 조합 미지원으로 실패했으나 caller-neutral operation normalization 반영 뒤 9초 안에 caller success와 `index.html` marker 1회를 확인했다. Codex → GPT direct와 Codex → GPT execution preset도 각각 10초/16초에 통과했다. -- 추가 분리 결과: agy → Gemini direct는 누락된 caller `modelProvider=gemini`를 보정한 뒤 공식 URL-encoded model label을 IOP Gemini path parser가 거부하는 제품 결함까지 좁혔다. Codex → GPT direct의 최초 실패는 CA bundle 대신 Edge leaf 인증서를 전달한 측정 환경 결함이었다. Claude Code → Gemini/GPT preset은 모두 provider 호출 뒤 Plan `validation`으로 닫혔고, 원인은 `default` selector가 비워 둔 provider ID와 pool이 선택한 실제 provider ID를 사후에 무조건 비교한 IOP 결함으로 확인했다. explicit selector와 profile/model/credential/path fence는 유지하면서 default selector의 pool 선택만 인정하도록 수정했고, private Plan/Work/Review에도 profile별 Chat/Responses 선택과 nearest-lower effort mapping을 적용한 focused regression 및 Edge 전체 테스트가 통과했다. 개발 런타임에는 아직 배포되지 않았다. +- 추가 분리 결과: agy는 text completion은 가능하지만 trusted workspace에서도 파일 도구를 호출하지 않아 구현 Agent 벤치 대상에서 제외했다. 대체한 OpenCode → Gemini direct는 첫 tool 호출 뒤 `extra_content.google.thought_signature`를 일반 Chat history로 보존하지 못해 두 번째 provider 호출이 HTTP 400으로 닫혔다. IOP가 선택된 `gemini_openai_chat` tool-call wire에서만 opaque id로 캡슐화·복원하도록 공통 provider normalization을 추가했고 focused HTTP 연속 호출 회귀가 통과했다. Codex → GPT direct의 최초 실패는 CA bundle 대신 Edge leaf 인증서를 전달한 측정 환경 결함이었다. Claude Code → Gemini/GPT preset의 이전 default-selector 및 profile operation normalization 결함도 국소 수정과 회귀 검증이 완료됐다. - 후속 측정: [초경량 Agent 모델 비교](thin-agent-model-comparison-benchmark.md) diff --git a/agent-roadmap/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md b/agent-roadmap/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md new file mode 100644 index 00000000..889f442f --- /dev/null +++ b/agent-roadmap/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md @@ -0,0 +1,91 @@ +# SDD: [bench-route-01] 벤치 경로 최소 HTML 스모크 + +## 위치 + +- Milestone: [Milestone 문서](../../../phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md) +- Phase: [PHASE.md](../../../phase/knowledge-tool-optimization-extension/PHASE.md) + +## 상태 + +[승인됨] + +## SDD 잠금 + +- 상태: 해제 +- 사용자 리뷰: 없음 +- 잠금 항목: 없음 + +## 문제 / 비목표 + +- 문제: OpenCode의 표준 Chat tool continuation에서 Gemini 전용 `thought_signature`가 유실되어 두 번째 호출이 HTTP 400으로 실패한다. caller별 보정 없이 선택된 provider profile이 이 메타데이터 왕복을 소유해야 한다. +- 비목표: + - benchmark runner, retry, manifest, state store를 만들지 않는다. + - GPT, Claude, ornith-fast 등 non-Gemini provider의 tool-call wire를 바꾸지 않는다. + - OpenCode 또는 다른 caller 이름을 routing/normalization selector로 쓰지 않는다. + +## Source of Truth + +| 영역 | 기준 | 메모 | +|------|------|------| +| Roadmap | [Milestone 문서](../../../phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md) | 9개 얇은 경로와 실패 경로만 재검증하는 범위 | +| Code | `packages/go/config/protocol_profile.go`, `apps/edge/internal/openai/provider_model_rewrite.go` | operation-scoped tool-call wire와 요청·응답 왕복 정규화 | +| External Provider | Gemini OpenAI-compatible Chat | Tool call을 이어갈 때 opaque thought signature를 요구한다. | +| User Decision | 없음 | agy를 제외하고 같은 두 자리에 OpenCode를 넣는 범위가 확정됐다. | + +## State Machine + +| 상태 | 진입 조건 | 다음 상태 | 근거 | +|------|-----------|-----------|------| +| provider tool response | selected profile의 Chat tool-call wire가 `gemini_openai_chat`이고 signature가 있다 | caller replay | Edge가 signature를 opaque tool-call id로 캡슐화한다. | +| caller replay | caller가 opaque id를 assistant/tool history에 변경 없이 보낸다 | provider continuation | Edge가 원래 id와 signature를 복원한다. | +| non-Gemini/no-signature | 다른 wire이거나 signature가 없다 | passthrough | 기존 요청·응답을 변경하지 않는다. | +| malformed opaque id | IOP prefix가 있지만 payload가 유효하지 않다 | fail-closed | provider dispatch 전에 요청을 거부한다. | + +## Interface Contract + +- 계약 원문: [OpenAI-Compatible API](../../../../agent-contract/outer/openai-compatible-api.md), [Edge Config And Runtime Refresh](../../../../agent-contract/inner/edge-config-runtime-refresh.md) +- 입력: + - `normalization.tool_calls[operation].wire`: provider tool-call metadata normalization 선택자 + - `tool_calls[].id` / `tool_call_id`: caller가 왕복 보존하는 표준 id 표면 +- 출력: + - Gemini signature가 있는 tool call은 signature를 별도 extension으로 노출하지 않고 opaque id를 반환한다. +- 금지: + - caller/SDK 이름으로 분기하지 않는다. + - effort mapping을 tool-call metadata selector로 재사용하지 않는다. + - non-Gemini profile 응답을 변환하지 않는다. + +## Acceptance Scenarios + +| ID | Milestone Task | Given | When | Then | +|----|----------------|-------|------|------| +| S01 | `minimal-html-calls` | OpenCode가 Gemini direct route에서 tool을 호출한다 | tool result를 같은 Chat history로 재전송한다 | Edge가 signature를 복원하고 provider continuation이 성공한다. | +| S02 | `failed-path-fixes` | Gemini와 non-Gemini protocol profile | 동일 signature 모양의 응답 및 malformed opaque id를 처리한다 | Gemini wire만 왕복하고 non-Gemini는 무변경, malformed는 fail-closed다. | +| S03 | `thin-bench-handoff` | direct/hybrid를 포함한 9개 얇은 경로 결과 | 결과표를 갱신한다 | 호출 가능 여부와 남은 소유 경계만 남고 새 harness는 없다. | + +## Evidence Map + +| Scenario | Required Evidence | `agent-task` 연결 | 완료 Evidence 기대 | +|----------|-------------------|------------------|---------------------------| +| S01 | `provider_model_rewrite_test.go` HTTP two-turn regression과 OpenCode direct 실측 | `agent-task/m-benchmark-route-minimal-html-smoke/...` | `minimal-html-calls` evidence에 tool continuation과 marker 결과 | +| S02 | config/service/openai package tests | `agent-task/m-benchmark-route-minimal-html-smoke/...` | `failed-path-fixes` evidence에 Gemini-only/no-op/fail-closed 결과 | +| S03 | `agent-test/dev/iop-benchmark-route-minimal-html-smoke.md` | `agent-task/m-benchmark-route-minimal-html-smoke/...` | `thin-bench-handoff` evidence에 9개 결과와 남은 blocker | + +## Cross-repo Dependencies + +- 없음 + +## Drift Check + +- [x] Milestone 기능 Task와 Acceptance Scenario가 일치한다. +- [x] Evidence Map이 code-review/complete.log에서 검증 가능하다. +- [x] agent-contract를 쓰는 경우 SDD에 계약 원문을 복제하지 않았다. +- [x] 사용자 리뷰가 필요한 항목은 `USER_REVIEW.md`에만 남겼다. + +## 사용자 리뷰 이력 + +- 없음 + +## 작업 컨텍스트 + +- 표준선: provider normalization은 선택된 concrete protocol profile의 operation별 wire를 기준으로 하며 caller identity와 분리한다. +- 후속 SDD: 없음 diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index d135c98b..75a97f64 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -108,6 +108,12 @@ source_evidence: - type: code path: apps/edge/internal/openai/provider_tunnel.go notes: Shared provider tunnel auth headers and passthrough + - type: code + path: apps/edge/internal/openai/provider_model_rewrite.go + notes: Selected profile의 operation별 tool-call wire에 따른 Gemini thought-signature 왕복 정규화 + - type: test + path: apps/edge/internal/openai/provider_model_rewrite_test.go + notes: Gemini-only non-stream/stream/HTTP tool continuation과 non-Gemini 무변경 회귀 검증 - type: code path: packages/go/config/protocol_profile.go notes: ConcreteProtocolProfile, operation capability admission, model/effort normalization @@ -221,6 +227,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | model-driven response path | request `model`이 가리키는 provider capability가 provider raw tunnel 또는 normalized RunEvent path를 결정한다. caller metadata는 route나 response shape를 선택하지 않는다. OpenAI와 Anthropic ingress는 같은 model catalog와 provider-pool dispatch를 공유한다. | | provider raw passthrough | `passthrough`는 provider status/header/body bytes를 기존 Edge-Node tunnel로 relay하고 pure response body에 IOP 확장 envelope를 섞지 않는다. | | provider-native field 보존 | provider raw tunnel route는 `model` served target rewrite와 auth/header 처리 외에 selected provider가 지원하는 표준 field와 provider extension field를 보존한다. OpenAI route는 OpenAI-compatible field를, Anthropic native route는 Anthropic field를 보존한다. | +| Gemini Chat tool-call normalization | Selected profile의 operation별 tool-call wire가 `gemini_openai_chat`이면 Gemini `thought_signature`를 opaque 표준 tool-call id로 캡슐화해 caller가 보존할 수 있게 하고, 다음 tool result 요청에서 원래 id/signature를 복원한다. Effort mapping과 독립적이며 다른 provider와 caller identity에는 적용하지 않는다. | | OpenAI usage metering | OpenAI handlers emit one request terminal and canonical token/reasoning series for each actual provider attempt that reports usage. Anthropic handlers do not currently emit this metric series; native tunnel `USAGE` frames are ignored. | | safe credential attribution | Managed OpenAI attempt metrics include only stable `credential_slot_ref` and immutable `credential_revision`; request terminals omit them, and slot alias, lease id, raw credential/key, target URL, request IDs, and payload content are forbidden labels. | | reasoning observation metric | provider가 reasoning token을 보고하지 않고 reasoning text만 관측되면 관측 횟수와 character count 보조 metric을 emit하고, 별도 estimated-token counter(`iop_openai_reasoning_estimated_tokens_total`)로 `estimation_method="chars_div_4"` 추정을 제공한다. | @@ -350,6 +357,7 @@ sequenceDiagram ## 변경 기록 +- 2026-08-14: Added operation-scoped `normalization.tool_calls` and Gemini-only Chat thought-signature round trips across standard OpenAI-compatible callers, including non-stream, SSE, and recovery-selected dispatches. Effort mapping and caller identity remain independent (`packages/go/config/protocol_profile.go`, `apps/edge/internal/openai/provider_model_rewrite.go`). - 2026-08-13: Added official agy 1.1.12 model-role `functionResponse` continuation support while retaining fail-closed rejection for mixed assistant/tool-response content (`apps/edge/internal/openai/gemini_handler.go`). - 2026-08-12: Admitted Claude Code's `advisor-tool-2026-03-01` beta as a consumed compatibility marker for both direct and marked-preset Messages ingress. It grants no internal capability and is not forwarded through the Chat bridge (`apps/edge/internal/openai/anthropic_types.go`). - 2026-08-12: Replaced free-form PlanMD generation with a stage-owned strict `goal`/`steps`/`verification` JSON response and deterministic Edge rendering of the frozen Plan template. Internal artifact customization and all caller-visible Messages schemas remain unchanged (`apps/edge/internal/openai/single_request_plan_stage.go`, `packages/go/singlerequesttemplate/template.go`). diff --git a/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md b/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md index 1c1a0d58..64f10143 100644 --- a/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md +++ b/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md @@ -13,7 +13,7 @@ | 항목 | 결과 | |---|---| | Claude Code | 실제 원격 실행기 2.1.177 확인 | -| agy | 1.1.12 확인 | +| OpenCode | 현재 workspace 1.18.3 확인; command-scoped provider config 사용 | | Codex | 실제 원격 실행기 0.146.0 확인 | | managed CA | live Edge CA bundle 확인 | | principal token | 원격 SOPS의 기존 두 token 사용; 별도 benchmark token 미사용 | @@ -26,15 +26,15 @@ |---|---|---|---| | Claude Code → Claude direct | 통과 | 없음 | 기존 성공 | | Claude Code → Gemini direct | 통과 | 없음 | 기존 성공 | -| agy → Gemini direct | 1초, caller login 요구 | parser 배포 뒤 19-22초, caller `SUCCESS` 2회, 파일 없음 | IOP parser 결함 해소; agy가 trusted workspace에서도 파일 도구를 호출하지 않은 caller/tool 이슈 분리 | +| OpenCode → Gemini direct | 6초, 첫 `glob` tool 성공 뒤 두 번째 provider 호출 HTTP 400 | provider normalization 구현·배포 뒤 재검증 대기 | 선택된 Gemini Chat profile의 `thought_signature`를 표준 caller history로 왕복하지 못한 IOP 결함; agy는 구현 Agent 부적합으로 대상 제외 | | Claude Code → GPT direct | provider HTTP 400 | operation normalization 반영 뒤 9초, caller success, `index.html` marker 1회 | IOP가 tools+effort를 Chat으로 보낸 결함 수정 확인 | | Codex → GPT direct | 30초, `turn.failed`, 파일 없음 | 공식 설정대로 임시 `CODEX_HOME`, Responses 전용 provider, `CODEX_CA_CERTIFICATE`에 CA bundle을 사용해 10초 통과 | 측정 환경 결함: 첫 호출은 CA bundle 대신 Edge leaf 인증서를 사용 | | Claude Code → Gemini execution preset | 184초, caller terminal success, caller workspace 파일 없음 | 새 배포에서 33초 caller 정상 종료; Plan/Work/Review, workspace write/read/list, artifact 3개 cleanup 성공 | default-selector dispatch 결함 해소; terminal 문구에 marker가 없는 것은 caller-visible 결과 판정과 분리 | -| agy → Gemini execution preset | 미실행 | direct parser 수정 배포 대기 | 선행 결함 | +| OpenCode → Gemini execution preset | 미실행 | provider normalization 배포 뒤 실행 | direct 경로의 Gemini tool continuation 선행 검증 | | Claude Code → GPT execution preset | 미실행 | selector/Chat 응답 정규화 배포 뒤 최신 Claude 요청이 provider 전 95ms에 `messages[1].role` 검증 거절 | 실제 caller가 `user → system`과 mid-conversation-system beta를 보냄; IOP가 beta만 선언하고 ingress/lineage/Chat/Responses normalize를 구현하지 않은 별도 제품 결함 확인·국소 수정 | | Codex → GPT execution preset | 16초, `turn.completed`, terminal marker 1회 | 없음 | 통과 | -추가 API 분리에서는 동일 principal의 최소 `/v1/responses`가 HTTP 200이었다. Codex direct도 사용자 설정과 로그인 상태를 배제한 임시 `CODEX_HOME`, Responses 전용 custom provider, 원격 SOPS의 기존 token, command-scoped managed CA bundle으로 통과했다. Gemini-native 최소 요청은 canonical caller model id에서 HTTP 200, 공식 표시 label `Gemini 3.6 Flash`에서 HTTP 400으로 갈려 path parser 결함을 재현했다. +추가 API 분리에서는 동일 principal의 최소 `/v1/responses`가 HTTP 200이었다. Codex direct도 사용자 설정과 로그인 상태를 배제한 임시 `CODEX_HOME`, Responses 전용 custom provider, 원격 SOPS의 기존 token, command-scoped managed CA bundle으로 통과했다. OpenCode direct는 command-scoped `OPENCODE_CONFIG_CONTENT`와 원격 SOPS의 기존 token을 사용해 user config를 변경하지 않았다. 첫 `glob` tool까지 성공했으나 caller가 Gemini provider extension을 표준 Chat history에 보존하지 않아 다음 요청이 `thought_signature` 누락 HTTP 400으로 실패했다. 이는 OpenCode 전용 문제가 아니라 selected Gemini Chat provider normalization 누락으로 분류했다. 두 preset 최초 실패는 provider 자체 실패가 아니었다. 코드 대조에서 `default` resource selector가 의도적으로 빈 provider ID를 동결하는 반면 사후 검증은 pool이 정상 선택한 실제 provider ID와 무조건 같아야 한다고 요구한 결함을 확인했다. explicit selector의 provider ID와 profile/model/credential/path fence는 유지하고, default selector만 pool 선택을 인정했다. 재배포 뒤 Gemini preset은 전체 stage와 workspace lifecycle이 통과했다. GPT preset은 다음 경계까지 진행해 실제 OpenAI Chat 응답의 `service_tier`, `system_fingerprint`, `annotations`, null `refusal`을 private strict decoder가 거부하는 별도 normalize 누락을 드러냈다. 공통 provider-normalization 계층에서 Chat/Responses 결과를 같은 canonical stage envelope로 수렴시키는 국소 회귀가 통과했다. @@ -44,6 +44,6 @@ ## 재개 조건 -Anthropic mid-conversation system normalization을 병합·배포한 뒤 Claude Code → GPT preset만 1회 재검증한다. 운영 release capacity gate는 인증 projection의 `ornith:35b` selector가 기대한 OneX가 아니라 RTX로 바뀐 별도 운영 라우트 불일치를 먼저 해소해야 한다. 이를 통과시키려고 route나 capacity를 임의 변경하지 않는다. 그 뒤 선행 결함이 해소된 agy preset을 1회 수행한다. Claude Code GPT direct와 이미 성공한 경로는 반복하지 않는다. +Gemini Chat tool-call normalization을 dev 런타임에 반영한 뒤 OpenCode → Gemini direct를 한 번 재검증하고, 통과하면 OpenCode → Gemini execution preset을 한 번 실행한다. 이미 성공한 경로는 반복하지 않는다. 제품 전체 capacity gate나 unrelated 운영 라우트는 이 얇은 경로 검증의 판정 기준으로 섞지 않는다. 성공한 경로는 반복하지 않는다. 실패한 경로는 원인이 변경된 경우에만 해당 경로를 1회 재검증한다. diff --git a/apps/edge/internal/openai/anthropic_bridge.go b/apps/edge/internal/openai/anthropic_bridge.go index 6b46d2d5..b379ac8e 100644 --- a/apps/edge/internal/openai/anthropic_bridge.go +++ b/apps/edge/internal/openai/anthropic_bridge.go @@ -10,7 +10,7 @@ import ( "iop/packages/go/config" ) -const anthropicBridgeToolIDPrefix = "iop_gts_" +const geminiThoughtSignatureToolIDPrefix = "iop_gts_" type openAIChatToolExtraContent struct { Google *struct { @@ -390,7 +390,7 @@ func convertChatResponseToAnthropic(body []byte, requestModel string) (anthropic }, nil } -type anthropicBridgeToolID struct { +type geminiThoughtSignatureToolID struct { ID string `json:"id"` ThoughtSignature string `json:"thought_signature"` } @@ -399,26 +399,41 @@ func encodeAnthropicBridgeToolID(id string, extra openAIChatToolExtraContent) st if extra.Google == nil || extra.Google.ThoughtSignature == "" { return id } - payload, err := json.Marshal(anthropicBridgeToolID{ID: id, ThoughtSignature: extra.Google.ThoughtSignature}) + return encodeGeminiThoughtSignatureToolID(id, extra.Google.ThoughtSignature) +} + +func encodeGeminiThoughtSignatureToolID(id, thoughtSignature string) string { + if id == "" || thoughtSignature == "" { + return id + } + payload, err := json.Marshal(geminiThoughtSignatureToolID{ID: id, ThoughtSignature: thoughtSignature}) if err != nil { return id } - return anthropicBridgeToolIDPrefix + base64.RawURLEncoding.EncodeToString(payload) + return geminiThoughtSignatureToolIDPrefix + base64.RawURLEncoding.EncodeToString(payload) } func decodeAnthropicBridgeToolID(id string) (string, string, bool) { - if !strings.HasPrefix(id, anthropicBridgeToolIDPrefix) { - return id, "", false - } - payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(id, anthropicBridgeToolIDPrefix)) + decodedID, signature, encoded, err := decodeGeminiThoughtSignatureToolID(id) if err != nil { return id, "", false } - var decoded anthropicBridgeToolID - if err := json.Unmarshal(payload, &decoded); err != nil || decoded.ID == "" || decoded.ThoughtSignature == "" { - return id, "", false + return decodedID, signature, encoded +} + +func decodeGeminiThoughtSignatureToolID(id string) (string, string, bool, error) { + if !strings.HasPrefix(id, geminiThoughtSignatureToolIDPrefix) { + return id, "", false, nil } - return decoded.ID, decoded.ThoughtSignature, true + payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(id, geminiThoughtSignatureToolIDPrefix)) + if err != nil { + return id, "", false, fmt.Errorf("decode Gemini thought-signature tool id: %w", err) + } + var decoded geminiThoughtSignatureToolID + if err := json.Unmarshal(payload, &decoded); err != nil || decoded.ID == "" || decoded.ThoughtSignature == "" { + return id, "", false, fmt.Errorf("decode Gemini thought-signature tool id: invalid payload") + } + return decoded.ID, decoded.ThoughtSignature, true, nil } func openAIChatThoughtSignature(signature string) map[string]any { diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 12024bfd..86747ccb 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -381,7 +381,14 @@ func (s *Server) handleChatCompletionsProviderPool(w http.ResponseWriter, dc *ch tunnelReq.Headers = headers return tunnelReq, nil } - poolReq.PrepareProtocolTunnel = s.protocolTunnelPreparer(r, config.OperationChatCompletions) + baseProtocolPreparer := s.protocolTunnelPreparer(r, config.OperationChatCompletions) + poolReq.PrepareProtocolTunnel = func(tunnelReq edgeservice.SubmitProviderTunnelRequest, selected edgeservice.ProviderPoolCandidate) (edgeservice.SubmitProviderTunnelRequest, error) { + prepared, err := baseProtocolPreparer(tunnelReq, selected) + if err != nil { + return tunnelReq, err + } + return prepareProviderChatToolCallNormalization(prepared, selected) + } // strict-output output policy only applies to normalized dispatch, // not to raw tunnel passthrough (SDD D02). The caller's raw body is the diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 057cf72c..f771afbb 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -6,6 +6,9 @@ import ( "fmt" "strings" "sync" + + edgeservice "iop/apps/edge/internal/service" + "iop/packages/go/config" ) type openAIProviderBodyBuilder struct { @@ -67,23 +70,43 @@ func (b *openAIProviderBodyBuilder) Close() { } type providerModelRewriter struct { - streaming bool - model string - pending []byte + streaming bool + model string + toolCallWire string + pending []byte } func newProviderModelRewriter(streaming bool, model string) *providerModelRewriter { + return newProviderModelRewriterWithToolCallWire(streaming, model, "") +} + +func newProviderModelRewriterForDispatch(streaming bool, model string, dispatch edgeservice.RunDispatch) *providerModelRewriter { + return newProviderModelRewriterWithToolCallWire(streaming, model, dispatch.ProfileToolCallWire) +} + +func newProviderModelRewriterWithToolCallWire(streaming bool, model, toolCallWire string) *providerModelRewriter { model = strings.TrimSpace(model) - if model == "" { + toolCallWire = strings.TrimSpace(toolCallWire) + if model == "" && toolCallWire == "" { return nil } - return &providerModelRewriter{streaming: streaming, model: model} + return &providerModelRewriter{streaming: streaming, model: model, toolCallWire: toolCallWire} +} + +func (r *providerModelRewriter) setToolCallWire(toolCallWire string) { + if r != nil { + r.toolCallWire = strings.TrimSpace(toolCallWire) + } } func (r *providerModelRewriter) AppendStream(chunk []byte) []byte { - if r == nil || !r.streaming || len(chunk) == 0 { + if r == nil || len(chunk) == 0 { return chunk } + if !r.streaming { + r.pending = append(r.pending, chunk...) + return nil + } r.pending = append(r.pending, chunk...) var out bytes.Buffer for { @@ -92,7 +115,7 @@ func (r *providerModelRewriter) AppendStream(chunk []byte) []byte { break } line := r.pending[:idx+1] - out.Write(rewriteProviderSSEModelLine(line, r.model)) + out.Write(rewriteProviderSSELine(line, r.model, r.toolCallWire)) r.pending = r.pending[idx+1:] } return out.Bytes() @@ -104,17 +127,24 @@ func (r *providerModelRewriter) FlushStream() []byte { } pending := r.pending r.pending = nil - return rewriteProviderSSEModelLine(pending, r.model) + if !r.streaming { + return r.RewriteComplete(pending) + } + return rewriteProviderSSELine(pending, r.model, r.toolCallWire) } func (r *providerModelRewriter) RewriteComplete(body []byte) []byte { if r == nil || len(body) == 0 { return body } - return rewriteProviderJSONModel(body, r.model) + return rewriteProviderJSONResponse(body, r.model, r.toolCallWire) } func rewriteProviderSSEModelLine(line []byte, model string) []byte { + return rewriteProviderSSELine(line, model, "") +} + +func rewriteProviderSSELine(line []byte, model, toolCallWire string) []byte { body, ending := splitLineEnding(line) prefix, payload, ok := bytes.Cut(body, []byte(":")) if !ok || strings.TrimSpace(string(prefix)) != "data" { @@ -124,7 +154,7 @@ func rewriteProviderSSEModelLine(line []byte, model string) []byte { if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) { return line } - rewritten := rewriteProviderJSONModel(payload, model) + rewritten := rewriteProviderJSONResponse(payload, model, toolCallWire) if bytes.Equal(rewritten, payload) { return line } @@ -135,6 +165,180 @@ func rewriteProviderSSEModelLine(line []byte, model string) []byte { return out } +func rewriteProviderJSONResponse(body []byte, model, toolCallWire string) []byte { + rewritten := rewriteProviderJSONModel(body, model) + if toolCallWire != config.ProtocolToolCallWireGeminiChat { + return rewritten + } + return normalizeGeminiChatProviderResponse(rewritten) +} + +func normalizeGeminiChatProviderResponse(body []byte) []byte { + if !bytes.Contains(body, []byte(`"thought_signature"`)) { + return body + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var response map[string]any + if err := decoder.Decode(&response); err != nil { + return body + } + changed := false + for _, rawChoice := range anySlice(response["choices"]) { + choice, ok := rawChoice.(map[string]any) + if !ok { + continue + } + for _, messageKey := range []string{"message", "delta"} { + message, ok := choice[messageKey].(map[string]any) + if !ok { + continue + } + for _, rawCall := range anySlice(message["tool_calls"]) { + call, ok := rawCall.(map[string]any) + if !ok { + continue + } + id, idOK := call["id"].(string) + extra, extraOK := call["extra_content"].(map[string]any) + if !idOK || id == "" || !extraOK { + continue + } + google, googleOK := extra["google"].(map[string]any) + if !googleOK { + continue + } + signature, signatureOK := google["thought_signature"].(string) + if !signatureOK || signature == "" { + continue + } + call["id"] = encodeGeminiThoughtSignatureToolID(id, signature) + delete(google, "thought_signature") + if len(google) == 0 { + delete(extra, "google") + } + if len(extra) == 0 { + delete(call, "extra_content") + } + changed = true + } + } + } + if !changed { + return body + } + encoded, err := json.Marshal(response) + if err != nil { + return body + } + return encoded +} + +func normalizeGeminiChatProviderRequest(body []byte) ([]byte, error) { + if !bytes.Contains(body, []byte(geminiThoughtSignatureToolIDPrefix)) { + return body, nil + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var request map[string]any + if err := decoder.Decode(&request); err != nil { + return nil, fmt.Errorf("decode Gemini Chat provider request: %w", err) + } + changed := false + for _, rawMessage := range anySlice(request["messages"]) { + message, ok := rawMessage.(map[string]any) + if !ok { + continue + } + for _, rawCall := range anySlice(message["tool_calls"]) { + call, ok := rawCall.(map[string]any) + if !ok { + continue + } + encodedID, ok := call["id"].(string) + if !ok { + continue + } + id, signature, encoded, err := decodeGeminiThoughtSignatureToolID(encodedID) + if err != nil { + return nil, err + } + if !encoded { + continue + } + if err := restoreGeminiThoughtSignature(call, signature); err != nil { + return nil, err + } + call["id"] = id + changed = true + } + if encodedID, ok := message["tool_call_id"].(string); ok { + id, _, encoded, err := decodeGeminiThoughtSignatureToolID(encodedID) + if err != nil { + return nil, err + } + if encoded { + message["tool_call_id"] = id + changed = true + } + } + } + if !changed { + return body, nil + } + encoded, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("encode Gemini Chat provider request: %w", err) + } + return encoded, nil +} + +func restoreGeminiThoughtSignature(call map[string]any, signature string) error { + extra, ok := call["extra_content"].(map[string]any) + if !ok { + if call["extra_content"] != nil { + return fmt.Errorf("restore Gemini thought signature: extra_content is not an object") + } + extra = make(map[string]any) + call["extra_content"] = extra + } + google, ok := extra["google"].(map[string]any) + if !ok { + if extra["google"] != nil { + return fmt.Errorf("restore Gemini thought signature: extra_content.google is not an object") + } + google = make(map[string]any) + extra["google"] = google + } + if existing, exists := google["thought_signature"]; exists && existing != signature { + return fmt.Errorf("restore Gemini thought signature: conflicting signature") + } + google["thought_signature"] = signature + return nil +} + +func prepareProviderChatToolCallNormalization(tunnel edgeservice.SubmitProviderTunnelRequest, selected edgeservice.ProviderPoolCandidate) (edgeservice.SubmitProviderTunnelRequest, error) { + if selected.ProtocolProfile == nil { + return tunnel, nil + } + mapping, ok := selected.ProtocolProfile.ToolCallMapping(config.OperationChatCompletions) + if !ok || mapping.Wire != config.ProtocolToolCallWireGeminiChat { + return tunnel, nil + } + build := tunnel.BuildBody + if build == nil { + return tunnel, fmt.Errorf("Gemini Chat provider body builder is unavailable") + } + tunnel.BuildBody = func(target string) ([]byte, error) { + body, err := build(target) + if err != nil { + return nil, err + } + return normalizeGeminiChatProviderRequest(body) + } + return tunnel, nil +} + func splitLineEnding(line []byte) ([]byte, []byte) { if len(line) == 0 || line[len(line)-1] != '\n' { return line, nil diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go new file mode 100644 index 00000000..e8d59578 --- /dev/null +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -0,0 +1,172 @@ +package openai + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + edgeservice "iop/apps/edge/internal/service" + "iop/packages/go/config" +) + +func TestGeminiChatProviderThoughtSignatureRoundTrip(t *testing.T) { + response := []byte(`{"id":"chat-1","model":"served","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call-1","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*\"}"},"extra_content":{"google":{"thought_signature":"opaque-signature"}}}]},"finish_reason":"tool_calls"}]}`) + rewritten := rewriteProviderJSONResponse(response, "public-gemini", config.ProtocolToolCallWireGeminiChat) + + var decoded map[string]any + if err := json.Unmarshal(rewritten, &decoded); err != nil { + t.Fatal(err) + } + choice := anySlice(decoded["choices"])[0].(map[string]any) + message := choice["message"].(map[string]any) + call := anySlice(message["tool_calls"])[0].(map[string]any) + encodedID := call["id"].(string) + if !strings.HasPrefix(encodedID, geminiThoughtSignatureToolIDPrefix) { + t.Fatalf("thought signature was not encoded in tool id: %+v", call) + } + if _, exists := call["extra_content"]; exists { + t.Fatalf("Gemini extension leaked after normalization: %+v", call) + } + if decoded["model"] != "public-gemini" { + t.Fatalf("model echo was not preserved through normalization: %+v", decoded) + } + + request, err := normalizeGeminiChatProviderRequest([]byte(`{"model":"served","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"` + encodedID + `","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*\"}"}}]},{"role":"tool","tool_call_id":"` + encodedID + `","content":"[]"}]}`)) + if err != nil { + t.Fatal(err) + } + var replay map[string]any + if err := json.Unmarshal(request, &replay); err != nil { + t.Fatal(err) + } + messages := anySlice(replay["messages"]) + replayedCall := anySlice(messages[0].(map[string]any)["tool_calls"])[0].(map[string]any) + if replayedCall["id"] != "call-1" || messages[1].(map[string]any)["tool_call_id"] != "call-1" { + t.Fatalf("original tool ids were not restored: %+v", messages) + } + extra := replayedCall["extra_content"].(map[string]any) + google := extra["google"].(map[string]any) + if google["thought_signature"] != "opaque-signature" { + t.Fatalf("thought signature was not restored: %+v", replayedCall) + } +} + +func TestProviderThoughtSignatureNormalizationIsGeminiProfileOnly(t *testing.T) { + response := []byte(`{"model":"served","choices":[{"message":{"tool_calls":[{"id":"call-1","extra_content":{"google":{"thought_signature":"opaque"}}}]}}]}`) + if got := rewriteProviderJSONResponse(response, "public", ""); !strings.Contains(string(got), `"id":"call-1"`) || !strings.Contains(string(got), `"thought_signature":"opaque"`) { + t.Fatalf("non-Gemini response was normalized: %s", got) + } + + profile, err := config.ResolveProtocolProfile("openai", "", config.BuiltInProtocolProfileCatalog()) + if err != nil { + t.Fatal(err) + } + baseBody := []byte(`{"model":"served","messages":[{"role":"user","content":"hi"}]}`) + tunnel := edgeservice.SubmitProviderTunnelRequest{BuildBody: func(string) ([]byte, error) { return baseBody, nil }} + prepared, err := prepareProviderChatToolCallNormalization(tunnel, edgeservice.ProviderPoolCandidate{ProtocolProfile: &profile}) + if err != nil { + t.Fatal(err) + } + got, err := prepared.BuildBody("served") + if err != nil || string(got) != string(baseBody) { + t.Fatalf("OpenAI profile body changed: %s err=%v", got, err) + } +} + +func TestGeminiChatProviderStreamingThoughtSignatureNormalization(t *testing.T) { + line := []byte("data: {\"model\":\"served\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call-1\",\"type\":\"function\",\"function\":{\"name\":\"glob\",\"arguments\":\"{}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"opaque\"}}}]}}]}\n\n") + rewriter := newProviderModelRewriterWithToolCallWire(true, "public", config.ProtocolToolCallWireGeminiChat) + got := append(rewriter.AppendStream(line), rewriter.FlushStream()...) + if strings.Contains(string(got), "thought_signature") || !strings.Contains(string(got), geminiThoughtSignatureToolIDPrefix) { + t.Fatalf("streaming signature was not normalized: %s", got) + } +} + +func TestGeminiChatProviderRejectsMalformedOpaqueToolID(t *testing.T) { + _, err := normalizeGeminiChatProviderRequest([]byte(`{"messages":[{"role":"assistant","tool_calls":[{"id":"iop_gts_not-base64"}]}]}`)) + if err == nil { + t.Fatal("malformed IOP Gemini tool id must fail closed") + } +} + +func TestGeminiChatProviderHTTPToolContinuationRoundTrip(t *testing.T) { + var providerRequests []map[string]any + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var request map[string]any + if err := json.Unmarshal(body, &request); err != nil { + t.Errorf("decode provider request: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + providerRequests = append(providerRequests, request) + w.Header().Set("Content-Type", "application/json") + if len(providerRequests) == 1 { + _, _ = w.Write([]byte(`{"id":"chat-1","model":"served-gemini","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call-1","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*\"}"},"extra_content":{"google":{"thought_signature":"opaque-signature"}}}]},"finish_reason":"tool_calls"}]}`)) + return + } + _, _ = w.Write([]byte(`{"id":"chat-2","model":"served-gemini","choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`)) + })) + defer provider.Close() + + profile, err := config.ResolveProtocolProfile("gemini", "", config.BuiltInProtocolProfileCatalog()) + if err != nil { + t.Fatal(err) + } + fake := &providerFakeRunService{ + poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel), + tunnelProviderURL: provider.URL, + tunnelServedTarget: "served-gemini", + poolSelectedCandidate: edgeservice.ProviderPoolCandidate{ + ActualModel: "served-gemini", ProviderID: "gemini-provider", + ExecutionPath: string(edgeservice.ProviderPoolPathTunnel), ProfileID: profile.ID, + ProfileDriver: string(profile.Driver), ProfileCapabilities: append([]string(nil), profile.Capabilities...), + ProtocolProfile: &profile, + }, + } + srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) + srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "gemini-route", Providers: map[string]string{"gemini-provider": "served-gemini"}}}) + + first := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gemini-route","messages":[{"role":"user","content":"list files"}],"tools":[{"type":"function","function":{"name":"glob","parameters":{"type":"object"}}}]}`)) + firstResponse := httptest.NewRecorder() + srv.handleChatCompletions(firstResponse, first) + if firstResponse.Code != http.StatusOK { + t.Fatalf("first status=%d body=%s", firstResponse.Code, firstResponse.Body.String()) + } + if got := fake.lastTunnelHandle.Dispatch().ProfileToolCallWire; got != config.ProtocolToolCallWireGeminiChat { + t.Fatalf("selected dispatch tool-call wire=%q", got) + } + var firstBody map[string]any + if err := json.Unmarshal(firstResponse.Body.Bytes(), &firstBody); err != nil { + t.Fatal(err) + } + firstChoice := anySlice(firstBody["choices"])[0].(map[string]any) + firstMessage := firstChoice["message"].(map[string]any) + encodedID := anySlice(firstMessage["tool_calls"])[0].(map[string]any)["id"].(string) + if !strings.HasPrefix(encodedID, geminiThoughtSignatureToolIDPrefix) { + t.Fatalf("caller did not receive opaque normalized id: %s", firstResponse.Body.String()) + } + + secondBody := `{"model":"gemini-route","messages":[{"role":"user","content":"list files"},{"role":"assistant","content":null,"tool_calls":[{"id":"` + encodedID + `","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*\"}"}}]},{"role":"tool","tool_call_id":"` + encodedID + `","content":"[]"}]}` + second := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(secondBody)) + secondResponse := httptest.NewRecorder() + srv.handleChatCompletions(secondResponse, second) + if secondResponse.Code != http.StatusOK || !strings.Contains(secondResponse.Body.String(), `"content":"done"`) { + t.Fatalf("second status=%d body=%s", secondResponse.Code, secondResponse.Body.String()) + } + if len(providerRequests) != 2 { + t.Fatalf("provider requests=%d", len(providerRequests)) + } + messages := anySlice(providerRequests[1]["messages"]) + replayedCall := anySlice(messages[1].(map[string]any)["tool_calls"])[0].(map[string]any) + if replayedCall["id"] != "call-1" || messages[2].(map[string]any)["tool_call_id"] != "call-1" { + t.Fatalf("provider did not receive original tool ids: %+v", messages) + } + google := replayedCall["extra_content"].(map[string]any)["google"].(map[string]any) + if google["thought_signature"] != "opaque-signature" { + t.Fatalf("provider did not receive restored signature: %+v", replayedCall) + } +} diff --git a/apps/edge/internal/openai/provider_test_support_test.go b/apps/edge/internal/openai/provider_test_support_test.go index b6c7e407..8743c50a 100644 --- a/apps/edge/internal/openai/provider_test_support_test.go +++ b/apps/edge/internal/openai/provider_test_support_test.go @@ -295,6 +295,9 @@ func (s *providerFakeRunService) SubmitProviderPool(_ context.Context, req edges disp.ProfileID = selected.ProfileID disp.ProfileDriver = selected.ProfileDriver disp.ProfileOperation = req.Tunnel.Operation + if mapping, ok := selected.ProtocolProfile.ToolCallMapping(config.ProtocolOperation(req.Tunnel.Operation)); ok { + disp.ProfileToolCallWire = mapping.Wire + } disp.ProfileCapabilities = append([]string(nil), selected.ProfileCapabilities...) if selected.ProviderID != "" { disp.ProviderID = selected.ProviderID @@ -361,15 +364,19 @@ func (s *providerFakeRunService) SubmitProviderPool(_ context.Context, req edges handle := &fakeTunnelHandle{ dispatch: edgeservice.RunDispatch{ - RunID: "run-tunnel", - NodeID: "node-1", - ProviderID: disp.ProviderID, - UsageAttribution: disp.UsageAttribution, - ModelGroupKey: req.Tunnel.ModelGroupKey, - Adapter: req.Tunnel.Adapter, - Target: disp.Target, - SessionID: req.Tunnel.SessionID, - TimeoutSec: 5, + RunID: "run-tunnel", + NodeID: "node-1", + ProviderID: disp.ProviderID, + UsageAttribution: disp.UsageAttribution, + ModelGroupKey: req.Tunnel.ModelGroupKey, + Adapter: req.Tunnel.Adapter, + Target: disp.Target, + SessionID: req.Tunnel.SessionID, + TimeoutSec: 5, + ProfileID: disp.ProfileID, + ProfileDriver: disp.ProfileDriver, + ProfileOperation: disp.ProfileOperation, + ProfileToolCallWire: disp.ProfileToolCallWire, }, headers: req.Tunnel.Headers, frames: frames, diff --git a/apps/edge/internal/openai/provider_tunnel.go b/apps/edge/internal/openai/provider_tunnel.go index 82f6f488..4e88feb4 100644 --- a/apps/edge/internal/openai/provider_tunnel.go +++ b/apps/edge/internal/openai/provider_tunnel.go @@ -306,7 +306,7 @@ func (s *Server) writeProviderTunnelResponse(w http.ResponseWriter, r *http.Requ assembler := &providerChatAssembler{streaming: reqStream} usageBinding := newUsageDispatchBinding(handle.Dispatch(), responseModePassthrough) - modelRewriter := newProviderModelRewriter(reqStream, requestModel) + modelRewriter := newProviderModelRewriterForDispatch(reqStream, requestModel, handle.Dispatch()) wroteHeader := false bodyBytes := 0 diff --git a/apps/edge/internal/openai/stream_gate_release_sink.go b/apps/edge/internal/openai/stream_gate_release_sink.go index 9efb97ba..a09b79ab 100644 --- a/apps/edge/internal/openai/stream_gate_release_sink.go +++ b/apps/edge/internal/openai/stream_gate_release_sink.go @@ -8,6 +8,7 @@ import ( "time" "go.uber.org/zap" + edgeservice "iop/apps/edge/internal/service" "iop/packages/go/streamgate" ) @@ -283,6 +284,19 @@ func openAITunnelCodecStateForSink(sink streamgate.ReleaseSink) *openAITunnelCod } } +func bindOpenAITunnelToolCallWire(sink streamgate.ReleaseSink, dispatch edgeservice.RunDispatch) { + switch typed := sink.(type) { + case *openAITunnelReleaseSink: + typed.mu.Lock() + if typed.rewriter != nil { + typed.rewriter.setToolCallWire(dispatch.ProfileToolCallWire) + } + typed.mu.Unlock() + case *openAICompositeReleaseSink: + bindOpenAITunnelToolCallWire(typed.tunnel, dispatch) + } +} + // newOpenAIBufferedTunnelReleaseSink builds the non-streaming passthrough sink. // requestModel is the caller-facing alias used to rewrite the provider model // echo; an empty value keeps provider-original bytes. diff --git a/apps/edge/internal/openai/stream_gate_runtime.go b/apps/edge/internal/openai/stream_gate_runtime.go index 5a71f37d..637dc231 100644 --- a/apps/edge/internal/openai/stream_gate_runtime.go +++ b/apps/edge/internal/openai/stream_gate_runtime.go @@ -1283,14 +1283,15 @@ func (s *Server) newOpenAIChatAttemptEventSourceFactory( // A fresh rewriter/assembler per attempt so an aborted attempt's // partial rewrite or usage state never bleeds into its replacement. assembler := &providerChatAssembler{streaming: dc.req.Stream} - rewriter := newProviderModelRewriter(dc.req.Stream, dc.req.Model) + dispatch := transport.tunnel.Dispatch() + bindOpenAITunnelToolCallWire(cfg.sink, dispatch) + rewriter := newProviderModelRewriterForDispatch(dc.req.Stream, dc.req.Model, dispatch) state := openAITunnelCodecStateForSink(cfg.sink) state.reset() tunnelSrc := newOpenAITunnelEndpointEventSource( transport.tunnel.Stream(), transport.tunnel.WaitTimeout(), rewriter, assembler, openAIRebuildEndpointChat, state, ) - dispatch := transport.tunnel.Dispatch() tunnelSrc.onTerminal = func(obs *providerAssembledObservation, bodyBytes int) { s.logger.Info("openai chat completion passthrough closed", zap.String("run_id", dispatch.RunID), @@ -1877,7 +1878,9 @@ func (s *Server) buildOpenAITunnelStreamGateRuntime( return nil, fmt.Errorf("openai stream gate: unsupported recovery transport path %q for provider tunnel", transport.path) } assembler := &providerChatAssembler{streaming: req.stream} - rewriter := newProviderModelRewriter(req.stream, req.requestModel) + dispatch := transport.tunnel.Dispatch() + bindOpenAITunnelToolCallWire(sink, dispatch) + rewriter := newProviderModelRewriterForDispatch(req.stream, req.requestModel, dispatch) state := openAITunnelCodecStateForSink(sink) if responseSink != nil { state = openAIResponsesTunnelCodecStateForSink(responseSink) @@ -1910,7 +1913,8 @@ func (s *Server) buildOpenAITunnelStreamGateRuntime( initialTransport := openAIAttemptTransport{path: openAIAdmissionTunnel, tunnel: handle} initialTransport.bindUsage(dispatch) initialAssembler := &providerChatAssembler{streaming: req.stream} - initialRewriter := newProviderModelRewriter(req.stream, req.requestModel) + bindOpenAITunnelToolCallWire(sink, dispatch) + initialRewriter := newProviderModelRewriterForDispatch(req.stream, req.requestModel, dispatch) initialState := openAITunnelCodecStateForSink(sink) if responseSink != nil { initialState = openAIResponsesTunnelCodecStateForSink(responseSink) diff --git a/apps/edge/internal/service/protocol_profile_test.go b/apps/edge/internal/service/protocol_profile_test.go index 345bf12a..e48e1b80 100644 --- a/apps/edge/internal/service/protocol_profile_test.go +++ b/apps/edge/internal/service/protocol_profile_test.go @@ -98,6 +98,17 @@ func TestProtocolProfileCandidateCopy(t *testing.T) { } } +func TestProtocolProfileToolCallWireFact(t *testing.T) { + gemini := mustResolveProfileForService(t, "gemini") + if got := profileToolCallWire(&gemini, string(config.OperationChatCompletions)); got != config.ProtocolToolCallWireGeminiChat { + t.Fatalf("Gemini tool-call wire = %q", got) + } + openAI := mustResolveProfileForService(t, "openai") + if got := profileToolCallWire(&openAI, string(config.OperationChatCompletions)); got != "" { + t.Fatalf("OpenAI tool-call wire = %q, want empty", got) + } +} + func TestProtocolProfileAdapterDispatchUsesProviderID(t *testing.T) { profile := mustResolveProfileForService(t, "openai") provider := config.NodeProviderConf{ diff --git a/apps/edge/internal/service/provider_pool.go b/apps/edge/internal/service/provider_pool.go index 361b9ced..4885fbab 100644 --- a/apps/edge/internal/service/provider_pool.go +++ b/apps/edge/internal/service/provider_pool.go @@ -306,6 +306,17 @@ func profileFacts(p *config.ConcreteProtocolProfile) (id, driver string) { return p.ID, string(p.Driver) } +func profileToolCallWire(p *config.ConcreteProtocolProfile, operation string) string { + if p == nil { + return "" + } + mapping, ok := p.ToolCallMapping(config.ProtocolOperation(operation)) + if !ok { + return "" + } + return mapping.Wire +} + // dispatchProviderPoolTunnel relays the selected candidate's raw provider // request after provider-pool admission. The tunnel inherits the Run's // identity, metadata, and long-context classification so passthrough dispatch @@ -393,6 +404,7 @@ func (s *Service) dispatchProviderPoolTunnel( disp.QueueReason = queueReason disp.ProfileID, disp.ProfileDriver = profileFacts(selected.profile) disp.ProfileOperation = tunnelReq.Operation + disp.ProfileToolCallWire = profileToolCallWire(selected.profile, tunnelReq.Operation) if selected.profile != nil { disp.ProfileCapabilities = append([]string(nil), selected.profile.Capabilities...) } diff --git a/apps/edge/internal/service/run_types.go b/apps/edge/internal/service/run_types.go index fab71e00..7c974b7f 100644 --- a/apps/edge/internal/service/run_types.go +++ b/apps/edge/internal/service/run_types.go @@ -64,6 +64,7 @@ type RunDispatch struct { ProfileID string ProfileDriver string ProfileOperation string + ProfileToolCallWire string ProfileCapabilities []string CredentialSlotRef string CredentialRevision uint64 diff --git a/packages/go/config/protocol_profile.go b/packages/go/config/protocol_profile.go index e038bcbd..586a94dc 100644 --- a/packages/go/config/protocol_profile.go +++ b/packages/go/config/protocol_profile.go @@ -111,6 +111,14 @@ type ProtocolEffortMappingConf struct { Wire string `mapstructure:"wire" yaml:"wire,omitempty"` } +// ProtocolToolCallMappingConf declares provider-specific tool-call metadata +// normalization for one operation. It is separate from effort mapping because +// reasoning grades and opaque tool continuation metadata are independent wire +// concerns even when one provider uses both. +type ProtocolToolCallMappingConf struct { + Wire string `mapstructure:"wire" yaml:"wire,omitempty"` +} + const ( ProtocolEffortWireOpenAIChat = "openai_chat" ProtocolEffortWireOpenAIResponses = "openai_responses" @@ -125,11 +133,18 @@ var validProtocolEffortWires = map[string]struct{}{ ProtocolEffortWireGeminiChat: {}, } +const ProtocolToolCallWireGeminiChat = "gemini_openai_chat" + +var validProtocolToolCallWires = map[string]struct{}{ + ProtocolToolCallWireGeminiChat: {}, +} + // ProtocolNormalizationConf contains provider-wire normalization facts. It is // intentionally operation-scoped: a provider may support reasoning with tools // on Responses while rejecting the same semantic request on Chat Completions. type ProtocolNormalizationConf struct { - Effort map[string]ProtocolEffortMappingConf `mapstructure:"effort" yaml:"effort,omitempty"` + Effort map[string]ProtocolEffortMappingConf `mapstructure:"effort" yaml:"effort,omitempty"` + ToolCalls map[string]ProtocolToolCallMappingConf `mapstructure:"tool_calls" yaml:"tool_calls,omitempty"` } // ProtocolProfileConf is the overlayable configuration of a protocol profile. @@ -235,6 +250,8 @@ var builtInProtocolProfiles = map[string]ProtocolProfileConf{ Capabilities: []string{"models", "chat", "streaming", "tool_calling"}, Normalization: ProtocolNormalizationConf{Effort: map[string]ProtocolEffortMappingConf{ string(OperationChatCompletions): identityEffortMapping(ProtocolEffortWireGeminiChat, true, true), + }, ToolCalls: map[string]ProtocolToolCallMappingConf{ + string(OperationChatCompletions): {Wire: ProtocolToolCallWireGeminiChat}, }}, }, "anthropic": { @@ -459,6 +476,12 @@ func deepCopyProfileConf(src ProtocolProfileConf) ProtocolProfileConf { dst.Normalization.Effort[operation] = cloneEffortMapping(mapping) } } + if src.Normalization.ToolCalls != nil { + dst.Normalization.ToolCalls = make(map[string]ProtocolToolCallMappingConf, len(src.Normalization.ToolCalls)) + for operation, mapping := range src.Normalization.ToolCalls { + dst.Normalization.ToolCalls[operation] = mapping + } + } return dst } @@ -629,6 +652,14 @@ func mergeProfileOverlay(base, overlay ProtocolProfileConf) (ProtocolProfileConf merged.Normalization.Effort[operation] = cloneEffortMapping(mapping) } } + if len(overlay.Normalization.ToolCalls) > 0 { + if merged.Normalization.ToolCalls == nil { + merged.Normalization.ToolCalls = make(map[string]ProtocolToolCallMappingConf) + } + for operation, mapping := range overlay.Normalization.ToolCalls { + merged.Normalization.ToolCalls[operation] = mapping + } + } return merged, nil } @@ -705,6 +736,14 @@ func validateConcreteProfile(id string, p ProtocolProfileConf) error { } } } + for operation, mapping := range p.Normalization.ToolCalls { + if _, ok := p.Operations[operation]; !ok { + return fmt.Errorf("profile %q: tool-call normalization operation %q is not declared", id, operation) + } + if _, ok := validProtocolToolCallWires[mapping.Wire]; !ok { + return fmt.Errorf("profile %q: tool-call normalization operation %q has invalid wire %q", id, operation, mapping.Wire) + } + } if p.Auth.Header == "" { return fmt.Errorf("profile %q: auth.header must not be empty", id) } @@ -857,6 +896,13 @@ func (p ConcreteProtocolProfile) EffortMapping(operation ProtocolOperation) (Pro return cloneEffortMapping(mapping), true } +// ToolCallMapping returns the operation-scoped provider tool-call +// normalization facts. The returned value contains no caller identity. +func (p ConcreteProtocolProfile) ToolCallMapping(operation ProtocolOperation) (ProtocolToolCallMappingConf, bool) { + mapping, ok := p.Normalization.ToolCalls[string(operation)] + return mapping, ok +} + // Clone returns a deep copy of the concrete profile. func (p ConcreteProtocolProfile) Clone() ConcreteProtocolProfile { return ConcreteProtocolProfile{ diff --git a/packages/go/config/protocol_profile_test.go b/packages/go/config/protocol_profile_test.go index b75168b4..50c3d1a3 100644 --- a/packages/go/config/protocol_profile_test.go +++ b/packages/go/config/protocol_profile_test.go @@ -152,6 +152,45 @@ func TestProtocolProfileEffortNormalizationOverlayAndValidation(t *testing.T) { } } +func TestProtocolProfileToolCallNormalization(t *testing.T) { + gemini, err := config.ResolveProtocolProfile("gemini", "", config.BuiltInProtocolProfileCatalog()) + if err != nil { + t.Fatalf("ResolveProtocolProfile(gemini): %v", err) + } + mapping, ok := gemini.ToolCallMapping(config.OperationChatCompletions) + if !ok || mapping.Wire != config.ProtocolToolCallWireGeminiChat { + t.Fatalf("Gemini Chat tool-call mapping = %+v,%t", mapping, ok) + } + openAI, err := config.ResolveProtocolProfile("openai", "", config.BuiltInProtocolProfileCatalog()) + if err != nil { + t.Fatalf("ResolveProtocolProfile(openai): %v", err) + } + if _, ok := openAI.ToolCallMapping(config.OperationChatCompletions); ok { + t.Fatal("OpenAI profile must not inherit Gemini tool-call normalization") + } + + catalog := config.BuiltInProtocolProfileCatalog() + catalog["custom-gemini"] = config.ProtocolProfileConf{Base: "gemini", BaseURL: "https://proxy.example.invalid/v1"} + custom, err := config.ResolveProtocolProfile("custom-gemini", "", catalog) + if err != nil { + t.Fatalf("ResolveProtocolProfile(custom-gemini): %v", err) + } + if mapping, ok := custom.ToolCallMapping(config.OperationChatCompletions); !ok || mapping.Wire != config.ProtocolToolCallWireGeminiChat { + t.Fatalf("custom Gemini overlay lost tool-call mapping: %+v,%t", mapping, ok) + } + + bad := config.BuiltInProtocolProfileCatalog() + bad["bad-tool-wire"] = config.ProtocolProfileConf{ + Base: "openai", + Normalization: config.ProtocolNormalizationConf{ToolCalls: map[string]config.ProtocolToolCallMappingConf{ + string(config.OperationChatCompletions): {Wire: "unknown"}, + }}, + } + if _, err := config.ResolveProtocolProfile("bad-tool-wire", "", bad); err == nil || !strings.Contains(err.Error(), "invalid wire") { + t.Fatalf("expected invalid tool-call wire error, got %v", err) + } +} + func TestProtocolProfileOverlayRejected(t *testing.T) { t.Run("cycle", func(t *testing.T) { catalog := map[string]config.ProtocolProfileConf{ From 3af60ca8e8c9b51741e7de30780e40648957bece Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 14 Aug 2026 04:57:39 +0900 Subject: [PATCH 03/18] =?UTF-8?q?docs(bench):=20OpenCode=20preset=20?= =?UTF-8?q?=EC=9B=90=EC=9D=B8=EC=9D=84=20=EB=B6=84=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode hybrid 실패를 IOP 코드 결함으로 오인하지 않도록 ornith-fast Node 비가용과 복구 후 통과 근거를 남기고, direct 재검증의 표준 배포 차단 상태를 분리한다. --- .../milestones/benchmark-route-minimal-html-smoke.md | 5 +++-- agent-test/dev/iop-benchmark-route-minimal-html-smoke.md | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md index f2f1115f..1a976770 100644 --- a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md @@ -79,6 +79,7 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 - TLS 환경 경계: 개발 Edge용 사설 CA는 Edge leaf 인증서가 아니라 managed CA bundle을 해당 벤치 caller process에만 전달한다. Codex에는 공식 변수 `CODEX_CA_CERTIFICATE`, Node 기반 caller에는 필요한 경우 `NODE_EXTRA_CA_CERTS`를 사용하며 Codex/IDE 시작 환경이나 셸 전역에 `export`하지 않는다. 그렇지 않으면 공개 TLS 연결에도 같은 CA override가 적용될 수 있다. - evidence 위치: `agent-test/dev/iop-benchmark-route-minimal-html-smoke.md` - 현재 사전 확인: 2026-08-13 실제 원격 실행기에서 Claude Code 2.1.177과 Codex 0.146.0을 확인했고, 현재 workspace의 OpenCode 1.18.3을 별도 사용자 설정 변경 없이 command-scoped provider config로 준비했다. 원격 SOPS에 보관된 기존 IOP principal token으로 token 원문을 출력하지 않은 `/v1/models`가 HTTP 200임을 확인했다. 새 벤치 전용 token은 발급하거나 사용하지 않는다. -- 현재 경로 결과: Claude Code → Claude direct와 Claude Code → Gemini direct는 최소 HTML 1회 호출을 통과했다. Claude Code → GPT direct는 Chat Completions의 tools+reasoning 조합 미지원으로 실패했으나 caller-neutral operation normalization 반영 뒤 9초 안에 caller success와 `index.html` marker 1회를 확인했다. Codex → GPT direct와 Codex → GPT execution preset도 각각 10초/16초에 통과했다. -- 추가 분리 결과: agy는 text completion은 가능하지만 trusted workspace에서도 파일 도구를 호출하지 않아 구현 Agent 벤치 대상에서 제외했다. 대체한 OpenCode → Gemini direct는 첫 tool 호출 뒤 `extra_content.google.thought_signature`를 일반 Chat history로 보존하지 못해 두 번째 provider 호출이 HTTP 400으로 닫혔다. IOP가 선택된 `gemini_openai_chat` tool-call wire에서만 opaque id로 캡슐화·복원하도록 공통 provider normalization을 추가했고 focused HTTP 연속 호출 회귀가 통과했다. Codex → GPT direct의 최초 실패는 CA bundle 대신 Edge leaf 인증서를 전달한 측정 환경 결함이었다. Claude Code → Gemini/GPT preset의 이전 default-selector 및 profile operation normalization 결함도 국소 수정과 회귀 검증이 완료됐다. +- 현재 경로 결과: Claude Code → Claude direct와 Claude Code → Gemini direct는 최소 HTML 1회 호출을 통과했다. Claude Code → GPT direct는 Chat Completions의 tools+reasoning 조합 미지원으로 실패했으나 caller-neutral operation normalization 반영 뒤 9초 안에 caller success와 `index.html` marker 1회를 확인했다. Codex → GPT direct와 Codex → GPT execution preset도 각각 10초/16초에 통과했다. OpenCode → Gemini execution preset은 Plan 성공 후 `ornith-fast` Work provider가 내려가 첫 호출에서 실패했지만, 해당 Node를 명시적 `Up`으로 복구한 뒤 실패 경로 1회 재검증에서 19초·caller terminal marker 1회·오류 0으로 통과했다. +- 추가 분리 결과: agy는 text completion은 가능하지만 trusted workspace에서도 파일 도구를 호출하지 않아 구현 Agent 벤치 대상에서 제외했다. 대체한 OpenCode → Gemini direct는 첫 tool 호출 뒤 `extra_content.google.thought_signature`를 일반 Chat history로 보존하지 못해 두 번째 provider 호출이 HTTP 400으로 닫혔다. IOP가 선택된 `gemini_openai_chat` tool-call wire에서만 opaque id로 캡슐화·복원하도록 공통 provider normalization을 추가했고 focused HTTP 연속 호출 회귀가 통과했다. 코드는 `dev`에 반영됐지만 live 재검증은 표준 배포 전이다. Codex → GPT direct의 최초 실패는 CA bundle 대신 Edge leaf 인증서를 전달한 측정 환경 결함이었다. Claude Code → Gemini/GPT preset의 이전 default-selector 및 profile operation normalization 결함도 국소 수정과 회귀 검증이 완료됐다. +- 배포 경계: live dev는 이전 release를 실행 중이고, 원격에 이전부터 미종료 release branch 두 건이 남아 있다. `dev-runtime-deploy` fail-closed 규칙에 따라 새 release 생성·배포·tag 반영은 시작하지 않았다. 이 Git release 상태는 벤치 성공 판정이 아니라 제품 배포 경계에서 별도로 정리해야 한다. - 후속 측정: [초경량 Agent 모델 비교](thin-agent-model-comparison-benchmark.md) diff --git a/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md b/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md index 64f10143..1897a0ea 100644 --- a/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md +++ b/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md @@ -26,11 +26,11 @@ |---|---|---|---| | Claude Code → Claude direct | 통과 | 없음 | 기존 성공 | | Claude Code → Gemini direct | 통과 | 없음 | 기존 성공 | -| OpenCode → Gemini direct | 6초, 첫 `glob` tool 성공 뒤 두 번째 provider 호출 HTTP 400 | provider normalization 구현·배포 뒤 재검증 대기 | 선택된 Gemini Chat profile의 `thought_signature`를 표준 caller history로 왕복하지 못한 IOP 결함; agy는 구현 Agent 부적합으로 대상 제외 | +| OpenCode → Gemini direct | 6초, 첫 `glob` tool 성공 뒤 두 번째 provider 호출 HTTP 400 | provider normalization 구현·`dev` 반영 완료; live 배포 뒤 재검증 대기 | 선택된 Gemini Chat profile의 `thought_signature`를 표준 caller history로 왕복하지 못한 IOP 결함; agy는 구현 Agent 부적합으로 대상 제외 | | Claude Code → GPT direct | provider HTTP 400 | operation normalization 반영 뒤 9초, caller success, `index.html` marker 1회 | IOP가 tools+effort를 Chat으로 보낸 결함 수정 확인 | | Codex → GPT direct | 30초, `turn.failed`, 파일 없음 | 공식 설정대로 임시 `CODEX_HOME`, Responses 전용 provider, `CODEX_CA_CERTIFICATE`에 CA bundle을 사용해 10초 통과 | 측정 환경 결함: 첫 호출은 CA bundle 대신 Edge leaf 인증서를 사용 | | Claude Code → Gemini execution preset | 184초, caller terminal success, caller workspace 파일 없음 | 새 배포에서 33초 caller 정상 종료; Plan/Work/Review, workspace write/read/list, artifact 3개 cleanup 성공 | default-selector dispatch 결함 해소; terminal 문구에 marker가 없는 것은 caller-visible 결과 판정과 분리 | -| OpenCode → Gemini execution preset | 미실행 | provider normalization 배포 뒤 실행 | direct 경로의 Gemini tool continuation 선행 검증 | +| OpenCode → Gemini execution preset | 7초, Plan 통과 후 Work `provider` 오류 | `ornith-fast` Node를 명시적 `Up` 복구한 뒤 19초, caller terminal marker 1회, 오류 0 | preset은 `ornith-fast` → RTX provider로 정상 라우팅됐으나 해당 Node가 내려가 Edge가 502 `provider unavailable`로 닫은 환경 결함; OpenCode·IOP 코드 결함 아님 | | Claude Code → GPT execution preset | 미실행 | selector/Chat 응답 정규화 배포 뒤 최신 Claude 요청이 provider 전 95ms에 `messages[1].role` 검증 거절 | 실제 caller가 `user → system`과 mid-conversation-system beta를 보냄; IOP가 beta만 선언하고 ingress/lineage/Chat/Responses normalize를 구현하지 않은 별도 제품 결함 확인·국소 수정 | | Codex → GPT execution preset | 16초, `turn.completed`, terminal marker 1회 | 없음 | 통과 | @@ -42,8 +42,10 @@ 해당 입력 normalize 배포 뒤 단일 재검증은 Plan 성공 후 Work `validation`으로 종료됐다. preset 구성상 Plan/Review는 GPT지만 Work는 `ornith-fast`다. RTX Ornith에 같은 forced workspace tool shape를 직접 보낸 결과 native `tool_calls`가 정상 반환됐고, 응답의 top-level provider `timings`가 기존 Work codec에서는 허용되지만 새 공통 Chat normalize allowlist에서 빠져 있음을 확인했다. 공통 normalize가 `timings`를 검증 후 폐기하도록 보완했다. +OpenCode → Gemini execution preset의 최초 실행은 Plan을 정상 통과했지만 Work에서 7초 만에 `provider` 오류로 닫혔다. live preset과 Edge 단계 지표를 대조한 결과 Work는 `ornith:35b`가 아니라 의도한 `ornith-fast`로 선택됐으며, 동일 Work request shape의 단일 직접 호출은 연결된 Node에 해당 provider가 없어 HTTP 502 `provider unavailable`를 반환했다. 사용자 관리형 RTX runtime을 toggle가 아닌 명시적 `Up`으로 복구한 뒤 실패 경로만 1회 재검증했고, 19초 내 caller terminal marker 1회·오류 0으로 통과했다. preset workspace가 caller 로컬에 파일을 남기지 않은 것은 기존 정리 계약과 일치한다. + ## 재개 조건 -Gemini Chat tool-call normalization을 dev 런타임에 반영한 뒤 OpenCode → Gemini direct를 한 번 재검증하고, 통과하면 OpenCode → Gemini execution preset을 한 번 실행한다. 이미 성공한 경로는 반복하지 않는다. 제품 전체 capacity gate나 unrelated 운영 라우트는 이 얇은 경로 검증의 판정 기준으로 섞지 않는다. +Gemini Chat tool-call normalization 코드는 `dev` commit `d99305d1`과 merge `dc2d9968`로 반영됐다. 현재 live dev는 이전 release를 실행 중이며, 원격에 이전부터 미종료 release branch 두 건이 남아 표준 `dev-runtime-deploy`가 새 release를 시작하지 않고 fail-closed했다. 해당 Git release 상태를 별도로 정리한 뒤 표준 배포를 수행하고 OpenCode → Gemini direct 실패 경로만 1회 재검증한다. OpenCode → Gemini execution preset은 이미 통과했으므로 반복하지 않는다. 제품 전체 capacity gate나 unrelated 운영 라우트는 이 얇은 경로 검증의 판정 기준으로 섞지 않는다. 성공한 경로는 반복하지 않는다. 실패한 경로는 원인이 변경된 경우에만 해당 경로를 1회 재검증한다. From 7a82f3faf2156931072e5214d74d6332246f1c87 Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 14 Aug 2026 05:01:49 +0900 Subject: [PATCH 04/18] =?UTF-8?q?docs(api):=20Responses=20provider=20bridg?= =?UTF-8?q?e=20=EA=B3=84=ED=9A=8D=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CODE_REVIEW-cloud-G08.md | 131 ++++++++ .../PLAN-local-G08.md | 309 ++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 agent-task/responses_provider_bridge/CODE_REVIEW-cloud-G08.md create mode 100644 agent-task/responses_provider_bridge/PLAN-local-G08.md diff --git a/agent-task/responses_provider_bridge/CODE_REVIEW-cloud-G08.md b/agent-task/responses_provider_bridge/CODE_REVIEW-cloud-G08.md new file mode 100644 index 00000000..8559ad83 --- /dev/null +++ b/agent-task/responses_provider_bridge/CODE_REVIEW-cloud-G08.md @@ -0,0 +1,131 @@ + + +# 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 `Implementation Checklist`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> Execute the plan's selected root cause, scope, files, and dependency decisions as written. Do not choose another owner, narrow/expand the write boundary, or replace a fix with another verification attempt. +> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields. +> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state. +> Finalization (`Code Review Result`, log rename, `complete.log`, archive moves, `Review-Only Checklist`) is review-agent-only, even after compaction/resume. + +## Overview + +date=2026-08-14 +task=responses_provider_bridge, plan=0, tag=API + +## For the Review Agent + +> **[REVIEW AGENT ONLY]** Compare every item with source and run fresh verification. Implementation output is handoff evidence only. Append verdict and routing signals, archive the active pair, and create `complete.log`/archive the task directory only on PASS. WARN/FAIL must follow the code-review skill and include reviewer-proven root cause plus one selected fix. + +## Implementation Item Completion + +| Item | Status | +|---|---| +| API-1 Profile-selected Responses request bridge | [ ] | +| API-2 Convert provider responses and streams to Responses | [ ] | +| API-3 Lock compatibility with tests and contracts | [ ] | + +## Implementation Checklist + +- [ ] Implement API-1 profile-selected Responses request bridging and fail-closed admission without caller-specific branches. +- [ ] Implement API-2 provider response/stream conversion back to canonical Responses while preserving tools, reasoning, usage, errors, and exactly-one terminal. +- [ ] Implement API-3 focused regression coverage and synchronize the OpenAI outer contract and living spec. +- [ ] Run the focused/package verification and, after exact-source dev deployment, the two failed Codex direct-route cycles. +- [ ] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output. + +## Review-Only Checklist + +> **[REVIEW AGENT ONLY]** Implementing agents must not modify or check this section. + +- [ ] Append one verdict of `PASS`, `WARN`, or `FAIL` and verified `review_rework_count`, `evidence_integrity_failure` to `Code Review Result`. +- [ ] Verify verdict, dimensions, and Required/Suggested/Nit classifications match. +- [ ] Run applicable required verification and record fresh output. +- [ ] For each Required/Suggested finding, record evidence, root cause, one selected fix, files/symbols/tests, and acceptance commands. +- [ ] Archive active review to `code_review_cloud_G08_0.log` and plan to `plan_local_G08_0.log`. +- [ ] Verify the Agent-Ops managed `.gitignore` block. +- [ ] On PASS, write `complete.log`, remove active markdown files, and move the task directory to `agent-task/archive/YYYY/MM/responses_provider_bridge/`. +- [ ] On WARN/FAIL, write the next filesystem state required by the code-review skill and do not write `complete.log`. + +## Deviations from Plan + +_Record actual deviations and reasons. If none, replace with `None`._ + +## Key Design Decisions + +_Record actual implementation decisions._ + +## Reviewer Checkpoints + +- Native Responses profile and nil-profile fallback remain byte-compatible. +- Selection is based only on request requirements and concrete protocol profile, never caller/agent identity. +- Unsupported Responses controls fail before provider dispatch rather than being dropped. +- Claude uses Messages wire and Gemini uses Chat wire; both return canonical Responses JSON/SSE. +- Gemini thought signature survives function call → `call_id` → function result replay. +- StreamGate remains the single response/terminal owner, including recovery attempts. +- Contract/spec match code and keep normalized non-provider limitations unchanged. + +## Verification Results + +### API-1 Focused Request Bridge + +```bash +go test -count=1 ./apps/edge/internal/openai -run 'TestResponsesProtocolProfile(BridgesToAnthropicMessages|BridgesToGeminiChat|BridgeRejectsUnrepresentableControls|OperationPassthrough|OperationAdmissionTable)$' +``` + +_Paste actual stdout/stderr._ + +### API-2 Focused Response Bridge + +```bash +go test -count=1 ./apps/edge/internal/openai -run 'TestResponses(ProtocolProfileAnthropicBridgeResponse|ProtocolProfileGeminiBridgeResponseAndSignature|ProviderBridgeStreaming|ProviderBridgeStreamingTerminalExactlyOnce|ProviderBridgeProviderErrorSanitized)$' +``` + +_Paste actual stdout/stderr._ + +### Package and Race Verification + +```bash +gofmt -w apps/edge/internal/openai/provider_normalization.go apps/edge/internal/openai/responses_handler.go apps/edge/internal/openai/anthropic_bridge.go apps/edge/internal/openai/stream_gate_tunnel_codec.go apps/edge/internal/openai/responses_stream_gate.go apps/edge/internal/openai/responses_protocol_profile_test.go apps/edge/internal/openai/stream_gate_pipeline_test.go +go test -count=1 ./apps/edge/internal/openai -run 'TestResponses(ProtocolProfile|ProviderBridge)' +go test -count=1 ./apps/edge/internal/openai +go test -race -count=1 ./apps/edge/internal/openai -run 'TestResponsesProviderBridgeStreaming' +git diff --check +``` + +_Paste actual stdout/stderr._ + +### Contract/Spec Verification + +```bash +rg --sort path -n 'Responses.*(Messages|Chat)|Gemini.*(signature|thought)' agent-contract/outer/openai-compatible-api.md agent-spec/input/openai-compatible-surface.md +``` + +_Paste actual stdout/stderr._ + +### External Dev Preflight and Codex Cycles + +```bash +ssh toki@toki-labs.com '/bin/zsh -lc '\''cd /Users/toki/agent-work/iop-dev && git status --short --branch && git rev-parse HEAD && git rev-parse origin/dev && command -v go && go version && codex --version && claude --version && opencode --version && lsof -nP -iTCP:18083 -sTCP:LISTEN && lsof -nP -iTCP:18084 -sTCP:LISTEN'\''' +``` + +_Paste sanitized preflight, exact-source rebuild/deploy identity, and the two Codex → Claude/Gemini cycle outputs. Do not paste secrets or raw provider payloads._ + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING]** Fill every implementation-owned section, leave review-only sections unchanged, and report ready for review. + +## Section Ownership + +| Section | Owner | Note | +|---|---|---| +| Header, Overview, Review instructions | Fixed | Implementer must not modify | +| Implementation Item Completion | Implementer | Check status only | +| Implementation Checklist | Implementer | Check boxes only; text/order fixed | +| Review-Only Checklist | Review agent | Implementer must not modify | +| Deviations, Key Design Decisions | Implementer | Replace placeholders with actual evidence | +| Reviewer Checkpoints | Fixed | Review oracle | +| Verification Results | Implementer, then reviewer | Implementer records initial output; reviewer reruns fresh | +| Code Review Result | Review agent appends | Not present in stub | diff --git a/agent-task/responses_provider_bridge/PLAN-local-G08.md b/agent-task/responses_provider_bridge/PLAN-local-G08.md new file mode 100644 index 00000000..2a659fb2 --- /dev/null +++ b/agent-task/responses_provider_bridge/PLAN-local-G08.md @@ -0,0 +1,309 @@ + + +# Responses Provider Bridge Plan + +## For the Implementing Agent + +`CODE_REVIEW-cloud-G08.md`의 구현 담당 섹션 작성은 필수다. 아래 범위만 구현하고 검증 명령을 실제로 실행한 뒤, 결과 원문과 구현 결정을 active review 파일에 기록하고 두 active 파일은 그대로 둔 채 review 준비 완료를 보고한다. 최종 판정, 사용자 리뷰 분류, archive 이동, `complete.log` 작성은 code-review skill 전용이다. 막히면 blocker, 시도한 명령/출력, 재개 조건만 구현 증거란에 남기고 사용자 입력 도구 호출, control-plane stop 파일 생성, 다음 상태 분류를 하지 않는다. + +## Background + +Codex는 표준 설정에서 `/v1/responses` wire만 사용한다. 현재 IOP provider-pool은 Responses ingress를 native Responses operation으로만 입장시키므로 Claude Messages 및 Gemini OpenAI-compatible Chat profile을 dispatch 전에 거부한다. 이 plan은 caller/agent별 분기 없이 선택된 protocol profile에 따라 Responses 요청과 응답을 기존 provider wire로 변환하는 한 경계만 추가한다. + +## Analysis + +### Files Read + +- `apps/edge/internal/openai/responses_handler.go` +- `apps/edge/internal/openai/provider_normalization.go` +- `apps/edge/internal/openai/anthropic_handler.go` +- `apps/edge/internal/openai/anthropic_bridge.go` +- `apps/edge/internal/openai/anthropic_stream.go` +- `apps/edge/internal/openai/responses_stream_gate.go` +- `apps/edge/internal/openai/stream_gate_runtime.go` +- `apps/edge/internal/openai/stream_gate_tunnel_codec.go` +- `apps/edge/internal/openai/responses_protocol_profile_test.go` +- `apps/edge/internal/openai/anthropic_bridge_test.go` +- `apps/edge/internal/openai/stream_gate_pipeline_test.go` +- `apps/edge/internal/service/provider_pool.go` +- `apps/edge/internal/service/provider_tunnel.go` +- `packages/go/config/protocol_profile.go` +- `agent-contract/index.md` +- `agent-contract/outer/openai-compatible-api.md` +- `agent-contract/outer/anthropic-compatible-api.md` +- `agent-spec/index.md` +- `agent-spec/input/openai-compatible-surface.md` +- `agent-test/dev/rules.md` +- `agent-ops/rules/project/rules.md` +- `agent-ops/skills/private/dev-runtime-deploy/SKILL.md` + +### SDD Criteria + +not applicable. 이 작업은 현재 Milestone에 연결하지 않은 외부 API 호환성 결함 수정이며, 새 제품 의미나 저장 상태를 정의하지 않는다. + +### Verification Context + +- handoff 문서는 없고, 실제 dev cross-test 결과를 재현 근거로 사용했다. +- Codex → GPT는 통과했지만 Codex → Claude/Gemini는 파일 생성 전에 `no provider profile supports the requested Responses operation`으로 실패했다. Claude Code caller의 Claude/Gemini/GPT 및 OpenCode의 표준 provider 설정은 통과했다. +- `responses_handler.go:323-424`는 Responses 요구사항으로 후보를 고른 뒤 `protocolTunnelPreparer(... OperationResponses)`와 Responses body rewrite만 사용한다. +- `provider_normalization.go:429-564`는 Responses 요구사항에서 stream/structured-output을 수집하지 않고, `selectProviderOperation`에 Responses ingress driver 분기가 없어 Claude/Gemini profile을 거부한다. +- `anthropic_handler.go:658-710`은 반대 방향인 Messages ingress를 profile별 Messages/Chat/Responses로 변환하고, `anthropic_handler.go:318-340`은 선택 operation별 응답 writer를 사용한다. 이번 구현도 동일한 profile-operation 원칙을 따른다. +- 기존 StreamGate는 Responses caller에 native Responses tunnel bytes를 그대로 노출한다. bridge operation에서는 provider Chat/Messages wire를 그대로 내보내면 안 되므로, provider operation으로 decode한 semantic event를 caller-facing Responses JSON/SSE로 다시 render해야 한다. +- 캐시된 Go test 결과는 허용하지 않는다. 모든 Go 명령은 `-count=1`을 사용한다. + +#### External Verification Preflight + +- runner: `ssh toki@toki-labs.com` +- repo/workdir: `/Users/toki/agent-work/iop-dev` +- 기준 branch: `dev`; live 검증 직전 local/remote/runner의 exact SHA가 같고 runner가 clean이어야 한다. +- 기존 확인 CLI: Claude Code `2.1.177`, Codex `0.146.0`, OpenCode `1.18.3`; live 검증 전에 다시 `--version`으로 확인한다. +- endpoint: `https://toki-labs.com:18083/v1`; Edge listener `18083`, Node TCP `18084`. +- CA: `/Users/toki/agent-work/iop-dev/build/dev-runtime/.secrets/credential-plane/ca.pem`; Codex/agent 설정에는 복사된 사용자 소유 CA 경로를 사용하며 tracked 파일에 인증서나 token을 넣지 않는다. +- secret source: runner의 SOPS/age-managed dev secret. raw token, key, header 값은 task evidence에 기록하지 않는다. +- runtime identity: 기존 실행 중 binary를 최신으로 간주하지 않는다. `agent-test/dev/rules.md`와 private `dev-runtime-deploy` skill에 따라 exact verified SHA에서 Edge 및 참여 Node를 rebuild/redeploy/restart하고 binary source identity, process, port를 확인한다. +- OS/arch: runner macOS arm64, dev-runtime 참여 Node에는 macOS, Linux arm64, Windows amd64 산출물이 있으므로 같은 source ref에서 전체 rebuild한다. +- source가 origin에 없거나 runner SHA가 다르면 live test를 시작하지 않는다. 먼저 승인된 commit/push 흐름으로 exact implementation SHA를 `dev`에 반영한 뒤 clean sync한다. +- 성공했던 cross path는 반복하지 않는다. 배포 후 Codex 표준 custom model provider 설정으로 Claude와 Gemini direct route만 각각 한 cycle 실행하고, 각 cycle은 workspace 내부 파일 생성 및 내용 확인까지 포함한다. + +### Test Coverage Gaps + +| Behavior | Existing coverage | Gap to close | +|---|---|---| +| native Responses profile raw passthrough | `responses_protocol_profile_test.go` | 그대로 유지하는 회귀 assertion 필요 | +| Responses → Anthropic Messages request/response | 현재 거부 테스트만 존재 | non-stream text/tool/usage 및 unsupported field 테스트 필요 | +| Responses → Gemini Chat request/response | effort rewrite만 존재 | reasoning, tool call/result, thought signature 왕복 테스트 필요 | +| provider Chat/Messages stream → Responses SSE | 없음 | fragmented delta, tool args, reasoning, terminal exactly-once 테스트 필요 | +| bridge 중 오류/지원 불가 control | Responses operation 부재만 검증 | dispatch 전 fail-closed와 sanitized error 테스트 필요 | + +### Symbol References + +기존 public symbol rename/remove는 없다. `selectProviderOperation` 호출 지점은 `anthropic_bridge.go`, `anthropic_handler.go`, `provider_normalization.go`, `responses_handler.go`, `single_request_provider_stage.go`, `single_request_work_stage.go`, 관련 테스트에 있으며 기존 ingress 동작을 유지해야 한다. `newOpenAITunnelEndpointCodec` 호출 지점은 Responses/Chat StreamGate runtime과 pipeline tests에 있으므로 새 provider-wire 선택 인자는 모든 호출부에서 명시한다. + +### Split Judgment + +단일 plan이다. 분리할 수 없는 invariant는 “provider profile이 선택한 wire로 요청을 보냈다면 같은 attempt의 응답을 원래 Responses caller 계약으로 정확히 복원한다”이다. admission/request 변환과 response/SSE 복원을 따로 배포하면 잘못된 wire가 caller에게 노출되므로 분리하지 않는다. + +### Scope Rationale + +- `packages/go/config`의 built-in profile/schema는 이미 Messages, Chat, Responses operation과 Gemini effort/tool-call normalization을 표현하므로 변경하지 않는다. +- agent extension, proxy extension, caller product-name 분기, Codex 전용 설정 키를 추가하지 않는다. 일반 custom model provider의 Responses wire만 전제로 한다. +- Gemini reasoning 자체의 새 `thinkingLevel` 호환이나 profile mapping 확대는 별도 항목이다. 이번 plan은 현재 profile이 이미 선언한 reasoning mapping만 보존한다. +- `apps/agent/**`, bench agent 설치/config, AGY 경로, current Milestone/roadmap은 수정하지 않는다. +- native OpenAI Responses raw passthrough와 legacy nil-profile fallback은 유지한다. + +### Final Routing + +- evaluation_mode: `first-pass` +- finalizer: `finalize-task-policy.sh`, mode `pair` +- closures: build/review 모두 scope, context, verification, evidence, ownership, decision closed +- build scores: scope=2, state=1, blast=2, evidence=1, verification=2; grade `G08`; route `local-fit`; catalog `worker/local/G08` +- review scores: scope=2, state=1, blast=2, evidence=1, verification=2; grade `G08`; route `official-review`; catalog `review/cloud/G08` +- large_indivisible_context: `false` +- positive loop risks: `boundary_contract`, `structured_interpretation`, `variant_product` (3); risk boundary not matched +- recovery signals: review_rework_count=0, evidence_integrity_failure=false +- capability-gap evidence: 없음; 구현 판단과 검증 oracle이 닫혀 있다. +- canonical files: `PLAN-local-G08.md`, `CODE_REVIEW-cloud-G08.md` + +## Implementation Checklist + +- [ ] Implement API-1 profile-selected Responses request bridging and fail-closed admission without caller-specific branches. +- [ ] Implement API-2 provider response/stream conversion back to canonical Responses while preserving tools, reasoning, usage, errors, and exactly-one terminal. +- [ ] Implement API-3 focused regression coverage and synchronize the OpenAI outer contract and living spec. +- [ ] Run the focused/package verification and, after exact-source dev deployment, the two failed Codex direct-route cycles. +- [ ] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output. + +### [API-1] Profile-selected Responses request bridge + +#### Problem + +`provider_normalization.go:429-443` extracts only tools and effort, and `provider_normalization.go:505-528` has no `OperationResponses` driver selection. `responses_handler.go:409-424` also binds the preparer to native Responses before the selected plan is applied. + +```go +// apps/edge/internal/openai/provider_normalization.go:505 +operations := []config.ProtocolOperation{ingress} +switch ingress { +case config.OperationMessages: + // ... +case config.OperationChatCompletions: + // ... +} +``` + +#### Solution + +Add `OperationResponses` selection: `openai_responses → responses`, `anthropic_messages → messages`, `openai_chat → chat_completions`, subject to capability and normalized requirements. Decode `stream`, tools, reasoning effort, token/output limits, structured output, tool choice, parallel calls, and input item variants needed to decide lossless eligibility. For the selected plan, build the provider-native body and operation/path/header contract instead of invoking the native Responses-only preparer. + +```go +// after: selected profile controls the provider wire +case config.OperationResponses: + switch profile.Driver { + case config.ProtocolDriverOpenAIResponses: + operations = []config.ProtocolOperation{config.OperationResponses} + case config.ProtocolDriverAnthropicMessages: + operations = []config.ProtocolOperation{config.OperationMessages} + case config.ProtocolDriverOpenAIChat: + operations = []config.ProtocolOperation{config.OperationChatCompletions} + } +``` + +Map Responses `instructions`, message/input items, function calls/results, tools/tool choice, `parallel_tool_calls`, max output tokens, stream, structured output, and the profile-declared reasoning effort. Reuse the existing opaque Gemini thought-signature tool-id envelope: encode returned signature into Responses `call_id`, then decode it when the caller sends `function_call_output`. Reject controls/items that the selected wire cannot preserve before provider dispatch; do not silently drop unknown semantic controls. + +#### Modified Files and Checklist + +- [ ] `apps/edge/internal/openai/provider_normalization.go`: requirements, operation selection, Responses→Messages/Chat request converters, validation. +- [ ] `apps/edge/internal/openai/responses_handler.go`: apply selected operation, path, headers, and body for initial/recovery attempts. +- [ ] `apps/edge/internal/openai/anthropic_bridge.go`: reuse the existing Chat/Anthropic types and opaque Gemini signature helpers without renaming public behavior. + +#### Test Strategy + +Write tests in `responses_protocol_profile_test.go`: + +- `TestResponsesProtocolProfileBridgesToAnthropicMessages`: body/operation/path, text/tool/result/max-token mapping. +- `TestResponsesProtocolProfileBridgesToGeminiChat`: effort mapping, Chat body, tool choice, signature replay. +- `TestResponsesProtocolProfileBridgeRejectsUnrepresentableControls`: HTTP 400 before tunnel dispatch. +- Update the old Anthropic rejection cases to assert admission only when the requested features are representable. + +#### Verification + +```bash +go test -count=1 ./apps/edge/internal/openai -run 'TestResponsesProtocolProfile(BridgesToAnthropicMessages|BridgesToGeminiChat|BridgeRejectsUnrepresentableControls|OperationPassthrough|OperationAdmissionTable)$' +``` + +Expected: all named tests pass; native Responses and legacy behavior remain green. + +### [API-2] Convert provider responses and streams to Responses + +#### Problem + +`responses_stream_gate.go:1166-1183` always decodes a tunnel as Responses, while `responses_stream_gate.go:441-443` exposes raw tunnel wire for streaming attempts. After request bridging this would parse or return Anthropic/Chat wire as if it were Responses. + +```go +// apps/edge/internal/openai/responses_stream_gate.go:1177 +tunnelSource := newOpenAITunnelEndpointEventSource( + transport.tunnel.Stream(), transport.tunnel.WaitTimeout(), + rewriter, assembler, openAIRebuildEndpointResponses, codecState, +) +``` + +#### Solution + +Derive the provider decoder from `RunDispatch.ProfileOperation`: Responses remains lossless raw passthrough; Chat and Messages are decoded as provider wire and rendered through the existing Responses sink. Extend the endpoint codec with a Messages decoder or a narrow provider-wire discriminator, and ensure non-stream JSON and streaming SSE both produce canonical Responses message/reasoning/function-call items, usage, incomplete status, safe errors, and exactly one terminal. Keep response IDs/model echo stable at the public route and preserve Gemini signatures in the opaque call ID. + +```go +// after: provider wire and public wire are independent +providerWire := responsesProviderWire(dispatch.ProfileOperation) +publicWire := responsesPublicWire(providerWire) // raw only for native Responses +tunnelSource := newOpenAITunnelEndpointEventSource( + transport.tunnel.Stream(), transport.tunnel.WaitTimeout(), + rewriter, assembler, providerWire, codecState, +) +``` + +The bridge must handle fragmented Chat/Messages tool arguments, reasoning deltas, usage-only/final frames, provider error frames, and terminal ordering. Do not create a second response owner outside StreamGate. + +#### Modified Files and Checklist + +- [ ] `apps/edge/internal/openai/stream_gate_tunnel_codec.go`: decode Chat/Messages/Responses provider frames into normalized events and usage-safe state. +- [ ] `apps/edge/internal/openai/responses_stream_gate.go`: select raw vs bridge rendering per attempt operation for initial and recovery paths. +- [ ] `apps/edge/internal/openai/provider_normalization.go`: non-stream provider response→Responses conversion helpers. + +#### Test Strategy + +Write tests in `responses_protocol_profile_test.go` and `stream_gate_pipeline_test.go`: + +- `TestResponsesProtocolProfileAnthropicBridgeResponse` +- `TestResponsesProtocolProfileGeminiBridgeResponseAndSignature` +- `TestResponsesProviderBridgeStreaming`: table over Chat and Messages with fragmented text/reasoning/tool deltas. +- `TestResponsesProviderBridgeStreamingTerminalExactlyOnce` +- `TestResponsesProviderBridgeProviderErrorSanitized` + +Assertions include valid Responses JSON/SSE, function call ID/name/arguments, signature replay, usage mapping, event order, one completed/error terminal, one `[DONE]`, and no provider-native framing leak. + +#### Verification + +```bash +go test -count=1 ./apps/edge/internal/openai -run 'TestResponses(ProtocolProfileAnthropicBridgeResponse|ProtocolProfileGeminiBridgeResponseAndSignature|ProviderBridgeStreaming|ProviderBridgeStreamingTerminalExactlyOnce|ProviderBridgeProviderErrorSanitized)$' +``` + +Expected: all named bridge response tests pass for non-stream and stream fixtures. + +### [API-3] Lock compatibility with tests and contracts + +#### Problem + +`agent-contract/outer/openai-compatible-api.md` and `agent-spec/input/openai-compatible-surface.md` currently state provider-pool Responses requires a native Responses operation and uses raw passthrough. That statement becomes incomplete once profile-selected Messages/Chat bridges are supported. + +```text +# agent-spec/input/openai-compatible-surface.md:235 +A provider model-group route relays /v1/responses to the selected provider when that candidate declares the Responses operation/capability. +``` + +#### Solution + +Document that native Responses candidates preserve raw bytes, while Anthropic Messages and OpenAI Chat candidates may be selected only when every requested Responses feature can be represented and their responses are converted back to Responses. State fail-closed behavior, profile-driven reasoning mapping, opaque Gemini tool signature replay, and no caller identity branch. Keep normalized non-provider limitations unchanged. + +#### Modified Files and Checklist + +- [ ] `apps/edge/internal/openai/responses_protocol_profile_test.go`: handler/admission/non-stream regression matrix. +- [ ] `apps/edge/internal/openai/stream_gate_pipeline_test.go`: streaming codec/terminal regression matrix. +- [ ] `agent-contract/outer/openai-compatible-api.md`: external request/response compatibility contract. +- [ ] `agent-spec/input/openai-compatible-surface.md`: living implementation spec and change history. + +#### Test Strategy + +Tests are mandatory because this is a public API compatibility bug fix. No new test file is needed; extend the two existing focused suites. + +#### Verification + +```bash +go test -count=1 ./apps/edge/internal/openai +rg --sort path -n 'Responses.*(Messages|Chat)|Gemini.*(signature|thought)' agent-contract/outer/openai-compatible-api.md agent-spec/input/openai-compatible-surface.md +``` + +Expected: package tests pass fresh and both documents contain the bridge boundary without contradicting native passthrough. + +## Modified Files Summary + +| File | Items | +|---|---| +| `apps/edge/internal/openai/provider_normalization.go` | API-1, API-2 | +| `apps/edge/internal/openai/responses_handler.go` | API-1 | +| `apps/edge/internal/openai/anthropic_bridge.go` | API-1 | +| `apps/edge/internal/openai/stream_gate_tunnel_codec.go` | API-2 | +| `apps/edge/internal/openai/responses_stream_gate.go` | API-2 | +| `apps/edge/internal/openai/responses_protocol_profile_test.go` | API-1, API-2, API-3 | +| `apps/edge/internal/openai/stream_gate_pipeline_test.go` | API-2, API-3 | +| `agent-contract/outer/openai-compatible-api.md` | API-3 | +| `agent-spec/input/openai-compatible-surface.md` | API-3 | +| `agent-task/responses_provider_bridge/CODE_REVIEW-cloud-G08.md` | API-1, API-2, API-3 evidence | + +## Final Verification + +Run locally with fresh results: + +```bash +gofmt -w apps/edge/internal/openai/provider_normalization.go apps/edge/internal/openai/responses_handler.go apps/edge/internal/openai/anthropic_bridge.go apps/edge/internal/openai/stream_gate_tunnel_codec.go apps/edge/internal/openai/responses_stream_gate.go apps/edge/internal/openai/responses_protocol_profile_test.go apps/edge/internal/openai/stream_gate_pipeline_test.go +go test -count=1 ./apps/edge/internal/openai -run 'TestResponses(ProtocolProfile|ProviderBridge)' +go test -count=1 ./apps/edge/internal/openai +go test -race -count=1 ./apps/edge/internal/openai -run 'TestResponsesProviderBridgeStreaming' +git diff --check +``` + +Expected: formatting produces no unplanned paths, all tests pass, race detector is clean, and `git diff --check` is empty. + +Before live validation, require an approved exact implementation commit on `origin/dev`, then run the private `dev-runtime-deploy` skill from its clean-sync step through whole-runtime rebuild/redeploy/restart and identity/port checks. On the runner record only sanitized output for: + +```bash +ssh toki@toki-labs.com '/bin/zsh -lc '\''cd /Users/toki/agent-work/iop-dev && git status --short --branch && git rev-parse HEAD && git rev-parse origin/dev && command -v go && go version && codex --version && claude --version && opencode --version && lsof -nP -iTCP:18083 -sTCP:LISTEN && lsof -nP -iTCP:18084 -sTCP:LISTEN'\''' +``` + +Expected: clean `dev`, `HEAD == origin/dev == approved implementation SHA`, required tools exist, rebuilt Edge owns `18083`, and Node ingress listens on `18084`. + +Using standard Codex `model_providers..wire_api="responses"` custom providers and the existing copied CA/token setup, run only these two one-cycle cases against `https://toki-labs.com:18083/v1`: + +1. Codex → IOP Claude route: create a uniquely named file in a temporary test workspace and verify exact requested content. +2. Codex → IOP Gemini route: create a different uniquely named file, verify exact content, and include one normal tool-call continuation so call ID/signature replay is exercised. + +Expected: both commands exit 0, both files exist with exact content, no extension/workaround is enabled, and sanitized Edge evidence shows the selected provider operation is Messages for Claude and Chat for Gemini. Raw credentials, prompts, tool payloads, and generated file contents stay out of tracked evidence. + +After completing all code changes, fill implementation-owned sections in `CODE_REVIEW-*-G??.md`. From 94c8b78bedebe522f0b0d63e203913c28def5148 Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 14 Aug 2026 05:07:14 +0900 Subject: [PATCH 05/18] =?UTF-8?q?docs(bench):=20hybrid=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=EA=B2=BD=EB=A1=9C=20=ED=86=B5=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세션 한정 ornith:35b Work 전환과 두 hybrid caller의 terminal 성공 근거를 남기고, 남은 경로를 OpenCode Gemini direct 하나로 좁힌다. --- .../milestones/benchmark-route-minimal-html-smoke.md | 3 ++- agent-test/dev/iop-benchmark-route-minimal-html-smoke.md | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md index 1a976770..0f3d559f 100644 --- a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md @@ -79,7 +79,8 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 - TLS 환경 경계: 개발 Edge용 사설 CA는 Edge leaf 인증서가 아니라 managed CA bundle을 해당 벤치 caller process에만 전달한다. Codex에는 공식 변수 `CODEX_CA_CERTIFICATE`, Node 기반 caller에는 필요한 경우 `NODE_EXTRA_CA_CERTS`를 사용하며 Codex/IDE 시작 환경이나 셸 전역에 `export`하지 않는다. 그렇지 않으면 공개 TLS 연결에도 같은 CA override가 적용될 수 있다. - evidence 위치: `agent-test/dev/iop-benchmark-route-minimal-html-smoke.md` - 현재 사전 확인: 2026-08-13 실제 원격 실행기에서 Claude Code 2.1.177과 Codex 0.146.0을 확인했고, 현재 workspace의 OpenCode 1.18.3을 별도 사용자 설정 변경 없이 command-scoped provider config로 준비했다. 원격 SOPS에 보관된 기존 IOP principal token으로 token 원문을 출력하지 않은 `/v1/models`가 HTTP 200임을 확인했다. 새 벤치 전용 token은 발급하거나 사용하지 않는다. -- 현재 경로 결과: Claude Code → Claude direct와 Claude Code → Gemini direct는 최소 HTML 1회 호출을 통과했다. Claude Code → GPT direct는 Chat Completions의 tools+reasoning 조합 미지원으로 실패했으나 caller-neutral operation normalization 반영 뒤 9초 안에 caller success와 `index.html` marker 1회를 확인했다. Codex → GPT direct와 Codex → GPT execution preset도 각각 10초/16초에 통과했다. OpenCode → Gemini execution preset은 Plan 성공 후 `ornith-fast` Work provider가 내려가 첫 호출에서 실패했지만, 해당 Node를 명시적 `Up`으로 복구한 뒤 실패 경로 1회 재검증에서 19초·caller terminal marker 1회·오류 0으로 통과했다. +- 현재 경로 결과: 9개 중 8개가 통과했고 OpenCode → Gemini direct만 정규화 코드의 live 배포 후 재검증을 남겼다. Claude Code → GPT direct는 caller-neutral operation normalization 반영 뒤 9초에 통과했고, Codex → GPT direct와 Codex → GPT execution preset도 각각 10초/16초에 통과했다. OpenCode → Gemini execution preset은 `ornith-fast` Node 복구 후 19초에 통과했다. 이후 사용자 지시로 이 세션의 hybrid Work 바인딩만 `ornith:35b`로 임시 전환했고, Claude Code → GPT execution preset은 71초, OpenCode → Gemini execution preset은 51초에 terminal success·caller marker·오류 0으로 통과했다. - 추가 분리 결과: agy는 text completion은 가능하지만 trusted workspace에서도 파일 도구를 호출하지 않아 구현 Agent 벤치 대상에서 제외했다. 대체한 OpenCode → Gemini direct는 첫 tool 호출 뒤 `extra_content.google.thought_signature`를 일반 Chat history로 보존하지 못해 두 번째 provider 호출이 HTTP 400으로 닫혔다. IOP가 선택된 `gemini_openai_chat` tool-call wire에서만 opaque id로 캡슐화·복원하도록 공통 provider normalization을 추가했고 focused HTTP 연속 호출 회귀가 통과했다. 코드는 `dev`에 반영됐지만 live 재검증은 표준 배포 전이다. Codex → GPT direct의 최초 실패는 CA bundle 대신 Edge leaf 인증서를 전달한 측정 환경 결함이었다. Claude Code → Gemini/GPT preset의 이전 default-selector 및 profile operation normalization 결함도 국소 수정과 회귀 검증이 완료됐다. - 배포 경계: live dev는 이전 release를 실행 중이고, 원격에 이전부터 미종료 release branch 두 건이 남아 있다. `dev-runtime-deploy` fail-closed 규칙에 따라 새 release 생성·배포·tag 반영은 시작하지 않았다. 이 Git release 상태는 벤치 성공 판정이 아니라 제품 배포 경계에서 별도로 정리해야 한다. +- 세션 한정 override: tracked 설정은 변경하지 않고 live runtime config의 Gemini/GPT hybrid Work 바인딩 네 곳만 `ornith:35b`로 바꾸었다. 원본 백업, config check, refresh dry-run/apply 통과 근거를 확인했으며 세션 종료 시 원래 `ornith-fast`로 복구한다. - 후속 측정: [초경량 Agent 모델 비교](thin-agent-model-comparison-benchmark.md) diff --git a/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md b/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md index 1897a0ea..0255479e 100644 --- a/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md +++ b/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md @@ -30,8 +30,8 @@ | Claude Code → GPT direct | provider HTTP 400 | operation normalization 반영 뒤 9초, caller success, `index.html` marker 1회 | IOP가 tools+effort를 Chat으로 보낸 결함 수정 확인 | | Codex → GPT direct | 30초, `turn.failed`, 파일 없음 | 공식 설정대로 임시 `CODEX_HOME`, Responses 전용 provider, `CODEX_CA_CERTIFICATE`에 CA bundle을 사용해 10초 통과 | 측정 환경 결함: 첫 호출은 CA bundle 대신 Edge leaf 인증서를 사용 | | Claude Code → Gemini execution preset | 184초, caller terminal success, caller workspace 파일 없음 | 새 배포에서 33초 caller 정상 종료; Plan/Work/Review, workspace write/read/list, artifact 3개 cleanup 성공 | default-selector dispatch 결함 해소; terminal 문구에 marker가 없는 것은 caller-visible 결과 판정과 분리 | -| OpenCode → Gemini execution preset | 7초, Plan 통과 후 Work `provider` 오류 | `ornith-fast` Node를 명시적 `Up` 복구한 뒤 19초, caller terminal marker 1회, 오류 0 | preset은 `ornith-fast` → RTX provider로 정상 라우팅됐으나 해당 Node가 내려가 Edge가 502 `provider unavailable`로 닫은 환경 결함; OpenCode·IOP 코드 결함 아님 | -| Claude Code → GPT execution preset | 미실행 | selector/Chat 응답 정규화 배포 뒤 최신 Claude 요청이 provider 전 95ms에 `messages[1].role` 검증 거절 | 실제 caller가 `user → system`과 mid-conversation-system beta를 보냄; IOP가 beta만 선언하고 ingress/lineage/Chat/Responses normalize를 구현하지 않은 별도 제품 결함 확인·국소 수정 | +| OpenCode → Gemini execution preset | 7초, Plan 통과 후 Work `provider` 오류 | `ornith-fast` Node 복구 후 19초 통과; 이후 세션 한정 Work를 `ornith:35b`로 전환한 경로도 51초·caller terminal marker·오류 0 | 최초 실패는 Edge가 502 `provider unavailable`로 닫은 환경 결함; OpenCode·IOP 코드 결함 아님 | +| Claude Code → GPT execution preset | provider 전 `messages[1].role` 검증 거절, 입력 normalize 후 Work `validation` | `timings` normalize가 포함된 live release에서 통과; 세션 한정 Work `ornith:35b`에서 71초·caller terminal success·marker 확인·오류 0 | mid-conversation system 입력과 provider `timings` 정규화 누락이었으며 국소 수정 후 해소 | | Codex → GPT execution preset | 16초, `turn.completed`, terminal marker 1회 | 없음 | 통과 | 추가 API 분리에서는 동일 principal의 최소 `/v1/responses`가 HTTP 200이었다. Codex direct도 사용자 설정과 로그인 상태를 배제한 임시 `CODEX_HOME`, Responses 전용 custom provider, 원격 SOPS의 기존 token, command-scoped managed CA bundle으로 통과했다. OpenCode direct는 command-scoped `OPENCODE_CONFIG_CONTENT`와 원격 SOPS의 기존 token을 사용해 user config를 변경하지 않았다. 첫 `glob` tool까지 성공했으나 caller가 Gemini provider extension을 표준 Chat history에 보존하지 않아 다음 요청이 `thought_signature` 누락 HTTP 400으로 실패했다. 이는 OpenCode 전용 문제가 아니라 selected Gemini Chat provider normalization 누락으로 분류했다. @@ -44,8 +44,10 @@ OpenCode → Gemini execution preset의 최초 실행은 Plan을 정상 통과했지만 Work에서 7초 만에 `provider` 오류로 닫혔다. live preset과 Edge 단계 지표를 대조한 결과 Work는 `ornith:35b`가 아니라 의도한 `ornith-fast`로 선택됐으며, 동일 Work request shape의 단일 직접 호출은 연결된 Node에 해당 provider가 없어 HTTP 502 `provider unavailable`를 반환했다. 사용자 관리형 RTX runtime을 toggle가 아닌 명시적 `Up`으로 복구한 뒤 실패 경로만 1회 재검증했고, 19초 내 caller terminal marker 1회·오류 0으로 통과했다. preset workspace가 caller 로컬에 파일을 남기지 않은 것은 기존 정리 계약과 일치한다. +사용자 지시에 따라 이 세션의 live execution preset만 Work 바인딩 네 곳을 `ornith-fast`에서 `ornith:35b`로 임시 전환했다. Plan/Review 모델과 tracked 설정은 변경하지 않았고, runtime config의 원본 백업을 남겼다. config check, refresh dry-run, apply가 재시작 없이 통과했다. 임시 경로에서 Claude Code → GPT execution preset은 71초, OpenCode → Gemini execution preset은 51초에 각각 terminal success와 caller-visible marker를 남겼고 오류는 없었다. 두 경로의 caller 로컬에 `index.html`이 남지 않은 것은 preset workspace 정리 계약과 일치한다. + ## 재개 조건 -Gemini Chat tool-call normalization 코드는 `dev` commit `d99305d1`과 merge `dc2d9968`로 반영됐다. 현재 live dev는 이전 release를 실행 중이며, 원격에 이전부터 미종료 release branch 두 건이 남아 표준 `dev-runtime-deploy`가 새 release를 시작하지 않고 fail-closed했다. 해당 Git release 상태를 별도로 정리한 뒤 표준 배포를 수행하고 OpenCode → Gemini direct 실패 경로만 1회 재검증한다. OpenCode → Gemini execution preset은 이미 통과했으므로 반복하지 않는다. 제품 전체 capacity gate나 unrelated 운영 라우트는 이 얇은 경로 검증의 판정 기준으로 섞지 않는다. +Gemini Chat tool-call normalization 코드는 `dev` commit `d99305d1`과 merge `dc2d9968`로 반영됐다. 9개 경로 중 8개는 통과했고, 남은 경로는 이 코드의 live 배포가 필요한 OpenCode → Gemini direct 하나다. 현재 live dev는 이전 release를 실행 중이며, 원격에 이전부터 미종료 release branch 두 건이 남아 표준 `dev-runtime-deploy`가 새 release를 시작하지 않고 fail-closed했다. 해당 Git release 상태를 별도로 정리한 뒤 표준 배포를 수행하고 남은 direct 경로만 1회 재검증한다. 제품 전체 capacity gate나 unrelated 운영 라우트는 이 얇은 경로 검증의 판정 기준으로 섞지 않는다. 성공한 경로는 반복하지 않는다. 실패한 경로는 원인이 변경된 경우에만 해당 경로를 1회 재검증한다. From 5d4c15c029a3d2a464df19cc3d85bda33afd6688 Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 14 Aug 2026 05:22:32 +0900 Subject: [PATCH 06/18] sync: to agentic-framework v1.1.203 --- agent-ops/.version | 2 +- .../orchestrate-agent-task-loop/SKILL.md | 6 +- .../assets/default-execution-catalog.json | 57 ++++++++ .../scripts/dispatch.py | 127 +++++++++++++++--- .../scripts/execution_target_policy.py | 14 ++ .../tests/test_dispatch.py | 92 ++++++++++++- .../tests/test_dispatcher_observation.py | 3 +- .../tests/test_execution_target_policy.py | 19 +++ .../tests/test_select_execution_target.py | 6 +- 9 files changed, 302 insertions(+), 24 deletions(-) diff --git a/agent-ops/.version b/agent-ops/.version index 837aff75..b1312453 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.202 +1.1.203 diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md b/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md index 45b4aadb..ebe2b257 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md @@ -50,10 +50,10 @@ Each target has: - `execution_class`: `local_model` or `cloud_model`; - optional `selfcheck_required` boolean; - `runtime.command`: a non-empty argv template executed without a shell; -- optional `runtime.resume_command`, `preflight_command`, `environment`, `session_path`, `native_session_monitor`, `terminal_success`, and `auxiliary_logs`; +- optional `runtime.resume_command`, `preflight_command`, `environment`, `session_path`, `native_session_monitor`, `session_stall_resume`, `terminal_success`, and `auxiliary_logs`; - optional `runtime.output_format`: `text` or `jsonl`. -Command templates may use only `{agent}`, `{model}`, `{reasoning_effort}`, `{target_id}`, `{workspace}`, `{attempt_dir}`, `{session_id}`, `{resume_session}`, `{resume_session_dir}`, and `{prompt}`. A target with `reasoning_effort` must use `{reasoning_effort}` in its command and resume command when present; a target without the field cannot use that placeholder. `native_session_monitor=true` requires both `resume_command` and `session_path`. `terminal_success=agent_end` requires JSONL output and accepts only a non-retrying final `agent_end` whose last assistant message has `stopReason=stop`. `terminal_success=turn_completed` requires JSONL output and accepts only final `turn.completed`; `turn.failed` or a missing terminal event fails closed. When either declared success event is observed with exit 0, earlier recovered transport diagnostics do not turn the attempt into a failure. The catalog must not embed repository secrets; environment values should refer only to runtime-provided non-secret configuration. +Command templates may use only `{agent}`, `{model}`, `{reasoning_effort}`, `{target_id}`, `{workspace}`, `{attempt_dir}`, `{session_id}`, `{resume_session}`, `{resume_session_dir}`, and `{prompt}`. A target with `reasoning_effort` must use `{reasoning_effort}` in its command and resume command when present; a target without the field cannot use that placeholder. `native_session_monitor=true` requires both `resume_command` and `session_path`. `session_stall_resume=true` requires `resume_command` and JSONL output whose runtime session id can be persisted. `terminal_success=agent_end` requires JSONL output and accepts only a non-retrying final `agent_end` whose last assistant message has `stopReason=stop`. `terminal_success=turn_completed` requires JSONL output and accepts only final `turn.completed`; `turn.failed` or a missing terminal event fails closed. When either declared success event is observed with exit 0, earlier recovered transport diagnostics do not turn the attempt into a failure. The catalog must not embed repository secrets; environment values should refer only to runtime-provided non-secret configuration. Each route owns its ordered `candidates` plus optional `rule_id`, `policy_priority`, and `reason_codes`. A route may use catalog-owned `windows` instead of a fixed candidate list; every window supplies an IANA timezone, start/end time, and candidates. Exactly one window must match. @@ -113,7 +113,7 @@ Accept self-check completion only when `## Implementation Checklist` or its supp - Determine liveness from PID/start-token/process-marker evidence and actual stream or native-session progress. Heartbeat mtime is never agent progress. For Codex JSONL, an unmatched `item.started` `command_execution` is an active tool interval: suspend the model-response silence timer until its matching `item.completed`, then restore normal stall detection. - The dispatcher model-silence safety net is 70 seconds. Downstream provider runtimes should emit their bounded terminal before that deadline; do not extend the dispatcher budget per target to cover nested retries. - Treat a confirmed provider transport terminal as the end of the current dispatch. Do not resume or automatically resend the same native session; an operator may start a fresh dispatch after the provider/runtime state is corrected. -- Retry `session-stall` only with a fresh native conversation. Preserve workspace changes and logical locator evidence, but do not carry the silent conversation context into the next attempt or a restarted dispatcher. +- When the selected target declares `session_stall_resume=true` and its JSONL emitted a runtime session id, terminate the silent process and invoke the catalog `resume_command` once for that exact same target and session with a continuation message. Do not inject a second continuation into the same stalled session; return to the existing bounded fresh-conversation retry and failover route. If the capability or runtime session id is absent, preserve workspace changes and logical locator evidence but retry with a fresh conversation. Never apply same-session continuation to provider transport terminals. - Never start a duplicate attempt while owned live evidence remains. - Keep a 10-consecutive-failure budget per task stage. Reset only that stage's budget after success. - Preserve failed attempt logs. Delete successful attempt logs only after verified archive completion and no live evidence. diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/assets/default-execution-catalog.json b/agent-ops/skills/common/orchestrate-agent-task-loop/assets/default-execution-catalog.json index fa4226b9..faec9cf2 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/assets/default-execution-catalog.json +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/assets/default-execution-catalog.json @@ -73,7 +73,26 @@ "--auto", "{prompt}" ], + "resume_command": [ + "opencode", + "run", + "--format", + "json", + "--dir", + "{workspace}", + "--session", + "{resume_session}", + "--agent", + "build", + "--model", + "iop-glm/glm-5.2", + "--variant", + "{reasoning_effort}", + "--auto", + "{prompt}" + ], "output_format": "jsonl", + "session_stall_resume": true, "environment": { "TMPDIR": "/tmp" } @@ -102,7 +121,26 @@ "--auto", "{prompt}" ], + "resume_command": [ + "opencode", + "run", + "--format", + "json", + "--dir", + "{workspace}", + "--session", + "{resume_session}", + "--agent", + "build", + "--model", + "iop-glm/glm-5.2", + "--variant", + "{reasoning_effort}", + "--auto", + "{prompt}" + ], "output_format": "jsonl", + "session_stall_resume": true, "environment": { "TMPDIR": "/tmp" } @@ -131,7 +169,26 @@ "--auto", "{prompt}" ], + "resume_command": [ + "opencode", + "run", + "--format", + "json", + "--dir", + "{workspace}", + "--session", + "{resume_session}", + "--agent", + "build", + "--model", + "iop-glm/glm-5.2", + "--variant", + "{reasoning_effort}", + "--auto", + "{prompt}" + ], "output_format": "jsonl", + "session_stall_resume": true, "environment": { "TMPDIR": "/tmp" } diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py index 8477be1f..11e06fa8 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py @@ -2879,7 +2879,11 @@ def render_json_line(cli: str, line: str) -> tuple[list[str], str | None]: return [line.rstrip()], None if not isinstance(value, dict): return [line.rstrip()], None - session_id = value.get("thread_id") or value.get("session_id") + session_id = ( + value.get("thread_id") + or value.get("session_id") + or value.get("sessionID") + ) rendered: list[str] = [] for field in ("text", "result", "message", "output"): item = value.get(field) @@ -2896,6 +2900,37 @@ def render_json_line(cli: str, line: str) -> tuple[list[str], str | None]: return rendered, str(session_id) if session_id else None +def stall_resume_session_id( + spec: AgentSpec, + locator: Path | None, + record: dict[str, Any] | None = None, +) -> str | None: + if not spec.runtime.get("session_stall_resume") or locator is None: + return None + locator_path = locator if locator.name == "locator.json" else locator / "locator.json" + if record is None: + try: + loaded = json.loads(locator_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + record = loaded if isinstance(loaded, dict) else None + if not isinstance(record, dict): + return None + if ( + record.get("target_id") != spec.target_id + or record.get("cli") != spec.cli + or record.get("model") != spec.model + or record.get("status") != "failed" + or record.get("failure_class") != "session-stall" + or record.get("resume_mode") == "runtime-session-id" + or record.get("session_id_source") != "runtime-stream" + or not isinstance(record.get("session_id"), str) + or not record.get("session_id") + ): + return None + return str(record["session_id"]) + + def native_session_path( spec: AgentSpec, workspace: Path, @@ -3250,9 +3285,21 @@ def native_resume_locator( ) if not owned: return None + runtime = record.get("runtime") if ( - not isinstance(record.get("runtime"), dict) - or not record["runtime"].get("native_session_monitor") + isinstance(runtime, dict) + and runtime.get("session_stall_resume") + and record.get("status") == "failed" + and record.get("failure_class") == "session-stall" + and record.get("resume_mode") != "runtime-session-id" + and record.get("session_id_source") == "runtime-stream" + and isinstance(record.get("session_id"), str) + and record.get("session_id") + ): + return locator + if ( + not isinstance(runtime, dict) + or not runtime.get("native_session_monitor") or record.get("failure_class") != "context-limit" or record.get("status") != "failed" ): @@ -3334,11 +3381,12 @@ def build_command( workspace: Path, session_id: str, attempt_dir: Path, - native_resume_session: Path | None = None, + resume_session: str | Path | None = None, + resume_session_dir: Path | None = None, ) -> list[str]: template_name = ( "resume_command" - if native_resume_session is not None and spec.runtime.get("resume_command") + if resume_session is not None and spec.runtime.get("resume_command") else "command" ) template = spec.runtime.get(template_name) @@ -3352,10 +3400,10 @@ def build_command( "model": spec.model, "prompt": prompt, "reasoning_effort": str(spec.reasoning_effort or ""), - "resume_session": str(native_resume_session or ""), + "resume_session": str(resume_session or ""), "resume_session_dir": ( - str(native_resume_session.parent) - if native_resume_session is not None + str(resume_session_dir) + if resume_session_dir is not None else "" ), "session_id": session_id, @@ -3448,9 +3496,15 @@ async def invoke( normalized_output_path.touch() heartbeat_path.touch() session_id = str(uuid.uuid4()) + session_id_source = "dispatcher-generated" process_marker = f"w{store.workspace_id}__{identity}__{uuid.uuid4()}" native_resume_session: Path | None = None - if spec.native_resume and resume_locator and resume_locator.is_file(): + stall_resume_session: str | None = None + if ( + (spec.native_resume or spec.runtime.get("session_stall_resume")) + and resume_locator + and resume_locator.is_file() + ): resume_locator_path = ( resume_locator if resume_locator.name == "locator.json" @@ -3478,6 +3532,13 @@ async def invoke( expected_runs_root=store.runs, ) if owned and isinstance(prior, dict): + stall_resume_session = stall_resume_session_id( + spec, resume_locator_path, prior + ) + if stall_resume_session is not None: + resume_locator = resume_locator_path + session_id = stall_resume_session + session_id_source = "runtime-stream" prior_native = prior.get("native_session_path") candidate = Path(str(prior_native)) if prior_native else None if candidate and candidate.is_dir(): @@ -3496,6 +3557,15 @@ async def invoke( native_resume_session = candidate resume_locator = resume_locator_path session_id = str(prior.get("session_id") or candidate.stem) + session_id_source = str( + prior.get("session_id_source") or "native-session" + ) + effective_resume_session = native_resume_session or stall_resume_session + effective_resume_session_dir = ( + native_resume_session.parent + if native_resume_session is not None + else None + ) started_at = now_iso() work_log_path = milestone_work_log_path(task) record: dict[str, Any] = { @@ -3521,6 +3591,7 @@ async def invoke( "plan_path": str(task.plan) if task.plan else None, "review_path": str(task.review) if task.review else None, "session_id": session_id, + "session_id_source": session_id_source, "native_session_path": ( str(native_resume_session) if native_resume_session is not None @@ -3538,10 +3609,10 @@ async def invoke( "model": spec.model, "prompt": "", "reasoning_effort": str(spec.reasoning_effort or ""), - "resume_session": str(native_resume_session or ""), + "resume_session": str(effective_resume_session or ""), "resume_session_dir": ( - str(native_resume_session.parent) - if native_resume_session is not None + str(effective_resume_session_dir) + if effective_resume_session_dir is not None else "" ), "session_id": session_id, @@ -3554,7 +3625,18 @@ async def invoke( "work_log": str(work_log_path.resolve()), "started_at": started_at, "status": "running", - "resumed_from_locator": str(resume_locator) if native_resume_session else None, + "resumed_from_locator": ( + str(resume_locator) + if native_resume_session is not None or stall_resume_session is not None + else None + ), + "resume_mode": ( + "native-session-file" + if native_resume_session is not None + else "runtime-session-id" + if stall_resume_session is not None + else None + ), } stage_decision = None if isinstance(store, StateStore): @@ -3639,7 +3721,8 @@ async def invoke( workspace, session_id, attempt_dir, - native_resume_session=native_resume_session, + resume_session=effective_resume_session, + resume_session_dir=effective_resume_session_dir, ) diagnostics: list[str] = [] diagnostic_origins: list[str] = [] @@ -3654,10 +3737,10 @@ async def invoke( "model": spec.model, "prompt": prompt, "reasoning_effort": str(spec.reasoning_effort or ""), - "resume_session": str(native_resume_session or ""), + "resume_session": str(effective_resume_session or ""), "resume_session_dir": ( - str(native_resume_session.parent) - if native_resume_session is not None + str(effective_resume_session_dir) + if effective_resume_session_dir is not None else "" ), "session_id": session_id, @@ -3945,6 +4028,7 @@ async def invoke( ) if discovered and record.get("session_id") != discovered: record["session_id"] = discovered + record["session_id_source"] = "runtime-stream" if native_resume_session is None: record["native_session_path"] = native_session_path( spec, workspace, discovered, attempt_dir @@ -4315,6 +4399,11 @@ def continuation_prompt( context: dict[str, Any] | None = None, unchecked_items: bool = False, ) -> str: + if resume_same_native_session and role != "selfcheck": + return dispatcher_child_prompt( + f"{REPOSITORY_LANGUAGE_PROMPT} Continue this session and complete " + "the current task." + ) if native_resume and role == "selfcheck": if resume_same_native_session: if unchecked_items: @@ -4728,6 +4817,10 @@ async def run_escalating( ], ) previous_locator = locator + if failure == "session-stall" and stall_resume_session_id( + spec, locator + ) is not None: + native_resume_locator = locator await asyncio.sleep(min(30, 2 ** min(generic_retries, 5))) continue if failure not in RECOVERABLE_RUNTIME_FAILURES: diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py index 3cb8158b..d10b6437 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py @@ -109,6 +109,7 @@ def _validate_runtime(value: object, label: str) -> dict[str, Any]: "output_format", "session_path", "native_session_monitor", + "session_stall_resume", "terminal_success", "auxiliary_logs", } @@ -176,6 +177,19 @@ def _validate_runtime(value: object, label: str) -> dict[str, Any]: raise CatalogError( f"{label}.native_session_monitor requires {missing}" ) + stall_resume = value.get("session_stall_resume", False) + if not isinstance(stall_resume, bool): + raise CatalogError(f"{label}.session_stall_resume must be a boolean") + runtime["session_stall_resume"] = stall_resume + if stall_resume: + if "resume_command" not in runtime: + raise CatalogError( + f"{label}.session_stall_resume requires resume_command" + ) + if runtime["output_format"] != "jsonl": + raise CatalogError( + f"{label}.session_stall_resume requires output_format='jsonl'" + ) auxiliary_logs = value.get("auxiliary_logs", []) if not isinstance(auxiliary_logs, list) or not all( isinstance(item, str) and item for item in auxiliary_logs diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py index 21c86cb5..e6751408 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py @@ -175,7 +175,8 @@ class RuntimeCatalogDispatcherTests(unittest.TestCase): Path("/workspace"), "session-1", Path("/attempt"), - native_resume_session=Path("/attempt/session.jsonl"), + resume_session=Path("/attempt/session.jsonl"), + resume_session_dir=Path("/attempt"), ) self.assertEqual(command, ["runner", "/workspace", "opaque-model", "high", "session-1", "/attempt", "do work"]) self.assertEqual( @@ -848,6 +849,95 @@ class RuntimeCatalogDispatcherTests(unittest.TestCase): self.assertEqual(record["active_command_execution_ids"], []) self.assertIn("stream_silence_inspection", record) + def test_session_stall_resume_reuses_runtime_stream_session_id(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + plan = write_plan(root) + task = task_from_plan(root, plan) + runner = root / "stall_resume_runner.py" + capture = root / "resume-argv.json" + runner.write_text( + "import json, sys, time\n" + "args = sys.argv[1:]\n" + "if args and args[0] == 'resume':\n" + " capture = args[1]\n" + " open(capture, 'w', encoding='utf-8').write(json.dumps(args[2:]))\n" + " print(json.dumps({'type': 'message', 'sessionID': args[2], 'text': 'continued'}), flush=True)\n" + "else:\n" + " print(json.dumps({'type': 'step_start', 'sessionID': 'ses-runtime-1'}), flush=True)\n" + " time.sleep(30)\n", + encoding="utf-8", + ) + agent = dispatch.AgentSpec( + "opencode", + "glm-5.2", + "opencode/glm-5.2", + target_id="opencode-test", + runtime={ + "command": [sys.executable, str(runner)], + "resume_command": [ + sys.executable, + str(runner), + "resume", + str(capture), + "{resume_session}", + "{prompt}", + ], + "output_format": "jsonl", + "session_stall_resume": True, + }, + ) + with ( + mock.patch.dict(os.environ, {"XDG_STATE_HOME": str(root / "state")}), + mock.patch.object(dispatch, "STREAM_HEARTBEAT_SECONDS", 0.01), + mock.patch.object(dispatch, "MODEL_RESPONSE_STALL_SECONDS", 0.05), + ): + store = dispatch.StateStore(root) + try: + first_rc, first_failure, first_locator = asyncio.run( + dispatch.invoke(root, store, task, "worker", agent, "start") + ) + first_record = json.loads(first_locator.read_text(encoding="utf-8")) + self.assertEqual( + dispatch.stall_resume_session_id(agent, first_locator), + "ses-runtime-1", + ) + second_rc, second_failure, second_locator = asyncio.run( + dispatch.invoke( + root, + store, + task, + "worker", + agent, + dispatch.continuation_prompt( + task, + "worker", + first_locator, + resume_same_native_session=True, + ), + resume_locator=first_locator, + ) + ) + second_record = json.loads(second_locator.read_text(encoding="utf-8")) + resumed_args = json.loads(capture.read_text(encoding="utf-8")) + finally: + store.close() + + self.assertLess(first_rc, 0) + self.assertEqual(first_failure, "session-stall") + self.assertEqual(first_record["session_id"], "ses-runtime-1") + self.assertEqual(first_record["session_id_source"], "runtime-stream") + self.assertEqual(second_rc, 0) + self.assertIsNone(second_failure) + self.assertEqual(second_record["session_id"], "ses-runtime-1") + self.assertEqual(second_record["resume_mode"], "runtime-session-id") + self.assertEqual(resumed_args[0], "ses-runtime-1") + self.assertIn("Continue this session", resumed_args[1]) + second_record.update(status="failed", failure_class="session-stall") + self.assertIsNone( + dispatch.stall_resume_session_id(agent, second_locator, second_record) + ) + def test_catalog_source_is_in_runtime_audit_evidence(self): with TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py index 68861c26..8cc5d2dc 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py @@ -130,7 +130,8 @@ class ObservationInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase): cwd, actual_session_id, attempt_dir, - native_resume_session=None, + resume_session=None, + resume_session_dir=None, ): self.assertEqual(actual_session_id, session_id) native = attempt_dir / "native-sessions" / f"session_{session_id}.jsonl" diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py index 28b92e3a..1916c4c7 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py @@ -207,6 +207,25 @@ class ExecutionTargetPolicyTests(unittest.TestCase): ): policy.load_catalog(write_catalog(Path(tmp), value)) + def test_session_stall_resume_requires_resume_command_and_jsonl(self): + missing_command = catalog_value() + missing_command["targets"]["target-b"]["runtime"][ + "session_stall_resume" + ] = True + text_output = catalog_value() + text_output["targets"]["target-b"]["runtime"].update( + session_stall_resume=True, + resume_command=["runner-b", "--session", "{resume_session}", "{prompt}"], + ) + with TemporaryDirectory() as tmp: + root = Path(tmp) + with self.assertRaisesRegex( + policy.CatalogError, "requires resume_command" + ): + policy.load_catalog(write_catalog(root, missing_command)) + with self.assertRaisesRegex(policy.CatalogError, "requires output_format"): + policy.load_catalog(write_catalog(root, text_output)) + def test_terminal_success_contract_requires_jsonl_agent_end(self): valid = catalog_value() valid["targets"]["target-a"]["runtime"]["terminal_success"] = "agent_end" diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py index 2ac2350c..55b60fab 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py @@ -187,10 +187,14 @@ class SelectorTests(unittest.TestCase): "opencode-glm-max", ): with self.subTest(target_id=target_id): + runtime = catalog.targets[target_id].runtime self.assertEqual( - catalog.targets[target_id].runtime["environment"]["TMPDIR"], + runtime["environment"]["TMPDIR"], "/tmp", ) + self.assertTrue(runtime["session_stall_resume"]) + self.assertIn("--session", runtime["resume_command"]) + self.assertIn("{resume_session}", runtime["resume_command"]) terra = catalog.targets["codex-terra-high"] self.assertEqual(terra.reasoning_effort, "high") self.assertIn( From ebf1357481d65e3ea6a32b5f1bed846c52fc5e6d Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 14 Aug 2026 05:25:32 +0900 Subject: [PATCH 07/18] sync: agent-ops from agentic-framework v1.1.203 --- agent-ops/.version | 2 +- .../orchestrate-agent-task-loop/SKILL.md | 6 +- .../assets/default-execution-catalog.json | 57 ++++++++ .../scripts/dispatch.py | 127 +++++++++++++++--- .../scripts/execution_target_policy.py | 14 ++ .../tests/test_dispatch.py | 92 ++++++++++++- .../tests/test_dispatcher_observation.py | 3 +- .../tests/test_execution_target_policy.py | 19 +++ .../tests/test_select_execution_target.py | 6 +- 9 files changed, 302 insertions(+), 24 deletions(-) diff --git a/agent-ops/.version b/agent-ops/.version index 837aff75..b1312453 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.202 +1.1.203 diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md b/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md index 45b4aadb..ebe2b257 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md @@ -50,10 +50,10 @@ Each target has: - `execution_class`: `local_model` or `cloud_model`; - optional `selfcheck_required` boolean; - `runtime.command`: a non-empty argv template executed without a shell; -- optional `runtime.resume_command`, `preflight_command`, `environment`, `session_path`, `native_session_monitor`, `terminal_success`, and `auxiliary_logs`; +- optional `runtime.resume_command`, `preflight_command`, `environment`, `session_path`, `native_session_monitor`, `session_stall_resume`, `terminal_success`, and `auxiliary_logs`; - optional `runtime.output_format`: `text` or `jsonl`. -Command templates may use only `{agent}`, `{model}`, `{reasoning_effort}`, `{target_id}`, `{workspace}`, `{attempt_dir}`, `{session_id}`, `{resume_session}`, `{resume_session_dir}`, and `{prompt}`. A target with `reasoning_effort` must use `{reasoning_effort}` in its command and resume command when present; a target without the field cannot use that placeholder. `native_session_monitor=true` requires both `resume_command` and `session_path`. `terminal_success=agent_end` requires JSONL output and accepts only a non-retrying final `agent_end` whose last assistant message has `stopReason=stop`. `terminal_success=turn_completed` requires JSONL output and accepts only final `turn.completed`; `turn.failed` or a missing terminal event fails closed. When either declared success event is observed with exit 0, earlier recovered transport diagnostics do not turn the attempt into a failure. The catalog must not embed repository secrets; environment values should refer only to runtime-provided non-secret configuration. +Command templates may use only `{agent}`, `{model}`, `{reasoning_effort}`, `{target_id}`, `{workspace}`, `{attempt_dir}`, `{session_id}`, `{resume_session}`, `{resume_session_dir}`, and `{prompt}`. A target with `reasoning_effort` must use `{reasoning_effort}` in its command and resume command when present; a target without the field cannot use that placeholder. `native_session_monitor=true` requires both `resume_command` and `session_path`. `session_stall_resume=true` requires `resume_command` and JSONL output whose runtime session id can be persisted. `terminal_success=agent_end` requires JSONL output and accepts only a non-retrying final `agent_end` whose last assistant message has `stopReason=stop`. `terminal_success=turn_completed` requires JSONL output and accepts only final `turn.completed`; `turn.failed` or a missing terminal event fails closed. When either declared success event is observed with exit 0, earlier recovered transport diagnostics do not turn the attempt into a failure. The catalog must not embed repository secrets; environment values should refer only to runtime-provided non-secret configuration. Each route owns its ordered `candidates` plus optional `rule_id`, `policy_priority`, and `reason_codes`. A route may use catalog-owned `windows` instead of a fixed candidate list; every window supplies an IANA timezone, start/end time, and candidates. Exactly one window must match. @@ -113,7 +113,7 @@ Accept self-check completion only when `## Implementation Checklist` or its supp - Determine liveness from PID/start-token/process-marker evidence and actual stream or native-session progress. Heartbeat mtime is never agent progress. For Codex JSONL, an unmatched `item.started` `command_execution` is an active tool interval: suspend the model-response silence timer until its matching `item.completed`, then restore normal stall detection. - The dispatcher model-silence safety net is 70 seconds. Downstream provider runtimes should emit their bounded terminal before that deadline; do not extend the dispatcher budget per target to cover nested retries. - Treat a confirmed provider transport terminal as the end of the current dispatch. Do not resume or automatically resend the same native session; an operator may start a fresh dispatch after the provider/runtime state is corrected. -- Retry `session-stall` only with a fresh native conversation. Preserve workspace changes and logical locator evidence, but do not carry the silent conversation context into the next attempt or a restarted dispatcher. +- When the selected target declares `session_stall_resume=true` and its JSONL emitted a runtime session id, terminate the silent process and invoke the catalog `resume_command` once for that exact same target and session with a continuation message. Do not inject a second continuation into the same stalled session; return to the existing bounded fresh-conversation retry and failover route. If the capability or runtime session id is absent, preserve workspace changes and logical locator evidence but retry with a fresh conversation. Never apply same-session continuation to provider transport terminals. - Never start a duplicate attempt while owned live evidence remains. - Keep a 10-consecutive-failure budget per task stage. Reset only that stage's budget after success. - Preserve failed attempt logs. Delete successful attempt logs only after verified archive completion and no live evidence. diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/assets/default-execution-catalog.json b/agent-ops/skills/common/orchestrate-agent-task-loop/assets/default-execution-catalog.json index fa4226b9..faec9cf2 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/assets/default-execution-catalog.json +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/assets/default-execution-catalog.json @@ -73,7 +73,26 @@ "--auto", "{prompt}" ], + "resume_command": [ + "opencode", + "run", + "--format", + "json", + "--dir", + "{workspace}", + "--session", + "{resume_session}", + "--agent", + "build", + "--model", + "iop-glm/glm-5.2", + "--variant", + "{reasoning_effort}", + "--auto", + "{prompt}" + ], "output_format": "jsonl", + "session_stall_resume": true, "environment": { "TMPDIR": "/tmp" } @@ -102,7 +121,26 @@ "--auto", "{prompt}" ], + "resume_command": [ + "opencode", + "run", + "--format", + "json", + "--dir", + "{workspace}", + "--session", + "{resume_session}", + "--agent", + "build", + "--model", + "iop-glm/glm-5.2", + "--variant", + "{reasoning_effort}", + "--auto", + "{prompt}" + ], "output_format": "jsonl", + "session_stall_resume": true, "environment": { "TMPDIR": "/tmp" } @@ -131,7 +169,26 @@ "--auto", "{prompt}" ], + "resume_command": [ + "opencode", + "run", + "--format", + "json", + "--dir", + "{workspace}", + "--session", + "{resume_session}", + "--agent", + "build", + "--model", + "iop-glm/glm-5.2", + "--variant", + "{reasoning_effort}", + "--auto", + "{prompt}" + ], "output_format": "jsonl", + "session_stall_resume": true, "environment": { "TMPDIR": "/tmp" } diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py index 8477be1f..11e06fa8 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py @@ -2879,7 +2879,11 @@ def render_json_line(cli: str, line: str) -> tuple[list[str], str | None]: return [line.rstrip()], None if not isinstance(value, dict): return [line.rstrip()], None - session_id = value.get("thread_id") or value.get("session_id") + session_id = ( + value.get("thread_id") + or value.get("session_id") + or value.get("sessionID") + ) rendered: list[str] = [] for field in ("text", "result", "message", "output"): item = value.get(field) @@ -2896,6 +2900,37 @@ def render_json_line(cli: str, line: str) -> tuple[list[str], str | None]: return rendered, str(session_id) if session_id else None +def stall_resume_session_id( + spec: AgentSpec, + locator: Path | None, + record: dict[str, Any] | None = None, +) -> str | None: + if not spec.runtime.get("session_stall_resume") or locator is None: + return None + locator_path = locator if locator.name == "locator.json" else locator / "locator.json" + if record is None: + try: + loaded = json.loads(locator_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + record = loaded if isinstance(loaded, dict) else None + if not isinstance(record, dict): + return None + if ( + record.get("target_id") != spec.target_id + or record.get("cli") != spec.cli + or record.get("model") != spec.model + or record.get("status") != "failed" + or record.get("failure_class") != "session-stall" + or record.get("resume_mode") == "runtime-session-id" + or record.get("session_id_source") != "runtime-stream" + or not isinstance(record.get("session_id"), str) + or not record.get("session_id") + ): + return None + return str(record["session_id"]) + + def native_session_path( spec: AgentSpec, workspace: Path, @@ -3250,9 +3285,21 @@ def native_resume_locator( ) if not owned: return None + runtime = record.get("runtime") if ( - not isinstance(record.get("runtime"), dict) - or not record["runtime"].get("native_session_monitor") + isinstance(runtime, dict) + and runtime.get("session_stall_resume") + and record.get("status") == "failed" + and record.get("failure_class") == "session-stall" + and record.get("resume_mode") != "runtime-session-id" + and record.get("session_id_source") == "runtime-stream" + and isinstance(record.get("session_id"), str) + and record.get("session_id") + ): + return locator + if ( + not isinstance(runtime, dict) + or not runtime.get("native_session_monitor") or record.get("failure_class") != "context-limit" or record.get("status") != "failed" ): @@ -3334,11 +3381,12 @@ def build_command( workspace: Path, session_id: str, attempt_dir: Path, - native_resume_session: Path | None = None, + resume_session: str | Path | None = None, + resume_session_dir: Path | None = None, ) -> list[str]: template_name = ( "resume_command" - if native_resume_session is not None and spec.runtime.get("resume_command") + if resume_session is not None and spec.runtime.get("resume_command") else "command" ) template = spec.runtime.get(template_name) @@ -3352,10 +3400,10 @@ def build_command( "model": spec.model, "prompt": prompt, "reasoning_effort": str(spec.reasoning_effort or ""), - "resume_session": str(native_resume_session or ""), + "resume_session": str(resume_session or ""), "resume_session_dir": ( - str(native_resume_session.parent) - if native_resume_session is not None + str(resume_session_dir) + if resume_session_dir is not None else "" ), "session_id": session_id, @@ -3448,9 +3496,15 @@ async def invoke( normalized_output_path.touch() heartbeat_path.touch() session_id = str(uuid.uuid4()) + session_id_source = "dispatcher-generated" process_marker = f"w{store.workspace_id}__{identity}__{uuid.uuid4()}" native_resume_session: Path | None = None - if spec.native_resume and resume_locator and resume_locator.is_file(): + stall_resume_session: str | None = None + if ( + (spec.native_resume or spec.runtime.get("session_stall_resume")) + and resume_locator + and resume_locator.is_file() + ): resume_locator_path = ( resume_locator if resume_locator.name == "locator.json" @@ -3478,6 +3532,13 @@ async def invoke( expected_runs_root=store.runs, ) if owned and isinstance(prior, dict): + stall_resume_session = stall_resume_session_id( + spec, resume_locator_path, prior + ) + if stall_resume_session is not None: + resume_locator = resume_locator_path + session_id = stall_resume_session + session_id_source = "runtime-stream" prior_native = prior.get("native_session_path") candidate = Path(str(prior_native)) if prior_native else None if candidate and candidate.is_dir(): @@ -3496,6 +3557,15 @@ async def invoke( native_resume_session = candidate resume_locator = resume_locator_path session_id = str(prior.get("session_id") or candidate.stem) + session_id_source = str( + prior.get("session_id_source") or "native-session" + ) + effective_resume_session = native_resume_session or stall_resume_session + effective_resume_session_dir = ( + native_resume_session.parent + if native_resume_session is not None + else None + ) started_at = now_iso() work_log_path = milestone_work_log_path(task) record: dict[str, Any] = { @@ -3521,6 +3591,7 @@ async def invoke( "plan_path": str(task.plan) if task.plan else None, "review_path": str(task.review) if task.review else None, "session_id": session_id, + "session_id_source": session_id_source, "native_session_path": ( str(native_resume_session) if native_resume_session is not None @@ -3538,10 +3609,10 @@ async def invoke( "model": spec.model, "prompt": "", "reasoning_effort": str(spec.reasoning_effort or ""), - "resume_session": str(native_resume_session or ""), + "resume_session": str(effective_resume_session or ""), "resume_session_dir": ( - str(native_resume_session.parent) - if native_resume_session is not None + str(effective_resume_session_dir) + if effective_resume_session_dir is not None else "" ), "session_id": session_id, @@ -3554,7 +3625,18 @@ async def invoke( "work_log": str(work_log_path.resolve()), "started_at": started_at, "status": "running", - "resumed_from_locator": str(resume_locator) if native_resume_session else None, + "resumed_from_locator": ( + str(resume_locator) + if native_resume_session is not None or stall_resume_session is not None + else None + ), + "resume_mode": ( + "native-session-file" + if native_resume_session is not None + else "runtime-session-id" + if stall_resume_session is not None + else None + ), } stage_decision = None if isinstance(store, StateStore): @@ -3639,7 +3721,8 @@ async def invoke( workspace, session_id, attempt_dir, - native_resume_session=native_resume_session, + resume_session=effective_resume_session, + resume_session_dir=effective_resume_session_dir, ) diagnostics: list[str] = [] diagnostic_origins: list[str] = [] @@ -3654,10 +3737,10 @@ async def invoke( "model": spec.model, "prompt": prompt, "reasoning_effort": str(spec.reasoning_effort or ""), - "resume_session": str(native_resume_session or ""), + "resume_session": str(effective_resume_session or ""), "resume_session_dir": ( - str(native_resume_session.parent) - if native_resume_session is not None + str(effective_resume_session_dir) + if effective_resume_session_dir is not None else "" ), "session_id": session_id, @@ -3945,6 +4028,7 @@ async def invoke( ) if discovered and record.get("session_id") != discovered: record["session_id"] = discovered + record["session_id_source"] = "runtime-stream" if native_resume_session is None: record["native_session_path"] = native_session_path( spec, workspace, discovered, attempt_dir @@ -4315,6 +4399,11 @@ def continuation_prompt( context: dict[str, Any] | None = None, unchecked_items: bool = False, ) -> str: + if resume_same_native_session and role != "selfcheck": + return dispatcher_child_prompt( + f"{REPOSITORY_LANGUAGE_PROMPT} Continue this session and complete " + "the current task." + ) if native_resume and role == "selfcheck": if resume_same_native_session: if unchecked_items: @@ -4728,6 +4817,10 @@ async def run_escalating( ], ) previous_locator = locator + if failure == "session-stall" and stall_resume_session_id( + spec, locator + ) is not None: + native_resume_locator = locator await asyncio.sleep(min(30, 2 ** min(generic_retries, 5))) continue if failure not in RECOVERABLE_RUNTIME_FAILURES: diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py index 3cb8158b..d10b6437 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/scripts/execution_target_policy.py @@ -109,6 +109,7 @@ def _validate_runtime(value: object, label: str) -> dict[str, Any]: "output_format", "session_path", "native_session_monitor", + "session_stall_resume", "terminal_success", "auxiliary_logs", } @@ -176,6 +177,19 @@ def _validate_runtime(value: object, label: str) -> dict[str, Any]: raise CatalogError( f"{label}.native_session_monitor requires {missing}" ) + stall_resume = value.get("session_stall_resume", False) + if not isinstance(stall_resume, bool): + raise CatalogError(f"{label}.session_stall_resume must be a boolean") + runtime["session_stall_resume"] = stall_resume + if stall_resume: + if "resume_command" not in runtime: + raise CatalogError( + f"{label}.session_stall_resume requires resume_command" + ) + if runtime["output_format"] != "jsonl": + raise CatalogError( + f"{label}.session_stall_resume requires output_format='jsonl'" + ) auxiliary_logs = value.get("auxiliary_logs", []) if not isinstance(auxiliary_logs, list) or not all( isinstance(item, str) and item for item in auxiliary_logs diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py index 21c86cb5..e6751408 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatch.py @@ -175,7 +175,8 @@ class RuntimeCatalogDispatcherTests(unittest.TestCase): Path("/workspace"), "session-1", Path("/attempt"), - native_resume_session=Path("/attempt/session.jsonl"), + resume_session=Path("/attempt/session.jsonl"), + resume_session_dir=Path("/attempt"), ) self.assertEqual(command, ["runner", "/workspace", "opaque-model", "high", "session-1", "/attempt", "do work"]) self.assertEqual( @@ -848,6 +849,95 @@ class RuntimeCatalogDispatcherTests(unittest.TestCase): self.assertEqual(record["active_command_execution_ids"], []) self.assertIn("stream_silence_inspection", record) + def test_session_stall_resume_reuses_runtime_stream_session_id(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + plan = write_plan(root) + task = task_from_plan(root, plan) + runner = root / "stall_resume_runner.py" + capture = root / "resume-argv.json" + runner.write_text( + "import json, sys, time\n" + "args = sys.argv[1:]\n" + "if args and args[0] == 'resume':\n" + " capture = args[1]\n" + " open(capture, 'w', encoding='utf-8').write(json.dumps(args[2:]))\n" + " print(json.dumps({'type': 'message', 'sessionID': args[2], 'text': 'continued'}), flush=True)\n" + "else:\n" + " print(json.dumps({'type': 'step_start', 'sessionID': 'ses-runtime-1'}), flush=True)\n" + " time.sleep(30)\n", + encoding="utf-8", + ) + agent = dispatch.AgentSpec( + "opencode", + "glm-5.2", + "opencode/glm-5.2", + target_id="opencode-test", + runtime={ + "command": [sys.executable, str(runner)], + "resume_command": [ + sys.executable, + str(runner), + "resume", + str(capture), + "{resume_session}", + "{prompt}", + ], + "output_format": "jsonl", + "session_stall_resume": True, + }, + ) + with ( + mock.patch.dict(os.environ, {"XDG_STATE_HOME": str(root / "state")}), + mock.patch.object(dispatch, "STREAM_HEARTBEAT_SECONDS", 0.01), + mock.patch.object(dispatch, "MODEL_RESPONSE_STALL_SECONDS", 0.05), + ): + store = dispatch.StateStore(root) + try: + first_rc, first_failure, first_locator = asyncio.run( + dispatch.invoke(root, store, task, "worker", agent, "start") + ) + first_record = json.loads(first_locator.read_text(encoding="utf-8")) + self.assertEqual( + dispatch.stall_resume_session_id(agent, first_locator), + "ses-runtime-1", + ) + second_rc, second_failure, second_locator = asyncio.run( + dispatch.invoke( + root, + store, + task, + "worker", + agent, + dispatch.continuation_prompt( + task, + "worker", + first_locator, + resume_same_native_session=True, + ), + resume_locator=first_locator, + ) + ) + second_record = json.loads(second_locator.read_text(encoding="utf-8")) + resumed_args = json.loads(capture.read_text(encoding="utf-8")) + finally: + store.close() + + self.assertLess(first_rc, 0) + self.assertEqual(first_failure, "session-stall") + self.assertEqual(first_record["session_id"], "ses-runtime-1") + self.assertEqual(first_record["session_id_source"], "runtime-stream") + self.assertEqual(second_rc, 0) + self.assertIsNone(second_failure) + self.assertEqual(second_record["session_id"], "ses-runtime-1") + self.assertEqual(second_record["resume_mode"], "runtime-session-id") + self.assertEqual(resumed_args[0], "ses-runtime-1") + self.assertIn("Continue this session", resumed_args[1]) + second_record.update(status="failed", failure_class="session-stall") + self.assertIsNone( + dispatch.stall_resume_session_id(agent, second_locator, second_record) + ) + def test_catalog_source_is_in_runtime_audit_evidence(self): with TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py index 68861c26..8cc5d2dc 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py @@ -130,7 +130,8 @@ class ObservationInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase): cwd, actual_session_id, attempt_dir, - native_resume_session=None, + resume_session=None, + resume_session_dir=None, ): self.assertEqual(actual_session_id, session_id) native = attempt_dir / "native-sessions" / f"session_{session_id}.jsonl" diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py index 28b92e3a..1916c4c7 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_execution_target_policy.py @@ -207,6 +207,25 @@ class ExecutionTargetPolicyTests(unittest.TestCase): ): policy.load_catalog(write_catalog(Path(tmp), value)) + def test_session_stall_resume_requires_resume_command_and_jsonl(self): + missing_command = catalog_value() + missing_command["targets"]["target-b"]["runtime"][ + "session_stall_resume" + ] = True + text_output = catalog_value() + text_output["targets"]["target-b"]["runtime"].update( + session_stall_resume=True, + resume_command=["runner-b", "--session", "{resume_session}", "{prompt}"], + ) + with TemporaryDirectory() as tmp: + root = Path(tmp) + with self.assertRaisesRegex( + policy.CatalogError, "requires resume_command" + ): + policy.load_catalog(write_catalog(root, missing_command)) + with self.assertRaisesRegex(policy.CatalogError, "requires output_format"): + policy.load_catalog(write_catalog(root, text_output)) + def test_terminal_success_contract_requires_jsonl_agent_end(self): valid = catalog_value() valid["targets"]["target-a"]["runtime"]["terminal_success"] = "agent_end" diff --git a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py index 2ac2350c..55b60fab 100644 --- a/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py +++ b/agent-ops/skills/common/orchestrate-agent-task-loop/tests/test_select_execution_target.py @@ -187,10 +187,14 @@ class SelectorTests(unittest.TestCase): "opencode-glm-max", ): with self.subTest(target_id=target_id): + runtime = catalog.targets[target_id].runtime self.assertEqual( - catalog.targets[target_id].runtime["environment"]["TMPDIR"], + runtime["environment"]["TMPDIR"], "/tmp", ) + self.assertTrue(runtime["session_stall_resume"]) + self.assertIn("--session", runtime["resume_command"]) + self.assertIn("{resume_session}", runtime["resume_command"]) terra = catalog.targets["codex-terra-high"] self.assertEqual(terra.reasoning_effort, "high") self.assertIn( From e1069db9817b381b98d9992c91e9079ae78b3860 Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 14 Aug 2026 05:35:35 +0900 Subject: [PATCH 08/18] docs: complete thin benchmark route smoke --- .../PHASE.md | 4 +-- .../benchmark-route-minimal-html-smoke.md | 28 +++++++++---------- agent-roadmap/priority-queue.md | 2 +- .../iop-benchmark-route-minimal-html-smoke.md | 12 +++++--- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md index 9fa4d77f..7819b766 100644 --- a/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md @@ -61,9 +61,9 @@ Phase를 가로지르는 실제 다음 작업 선택은 [전역 마일스톤 실 - 경로: [[bench-02] IOP 원샷 Agent 모델 비교 벤치마크](../../archive/phase/knowledge-tool-optimization-extension/milestones/iop-one-shot-agent-model-comparison.md) - 요약: 전용 harness의 정합성과 복구가 제품 안정성보다 우선되는 목적 역전으로 2026-08-13 폐기했다. 기존 결과와 계획은 재개하지 않는다. -- [진행중] [bench-route-01] 벤치 경로 최소 HTML 스모크 +- [검토중] [bench-route-01] 벤치 경로 최소 HTML 스모크 - 경로: [[bench-route-01] 벤치 경로 최소 HTML 스모크](milestones/benchmark-route-minimal-html-smoke.md) - - 요약: 벤치에 사용할 9개 caller/model/route 조합을 고정된 최소 `index.html` 생성 요청으로 한 번씩 직접 호출하고, 실패한 경로만 귀속·국소 수정·재검증한다. + - 요약: 벤치에 사용할 9개 caller/model/route 조합이 고정된 최소 `index.html` 생성 요청으로 모두 통과했고, 실패 경로만 귀속·국소 수정·재검증했다. - [계획] [bench-lite-01] 초경량 Agent 모델 비교 - 경로: [[bench-lite-01] 초경량 Agent 모델 비교](milestones/thin-agent-model-comparison-benchmark.md) diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md index 0f3d559f..c29c2a9e 100644 --- a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md @@ -12,7 +12,7 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 ## 상태 -[진행중] +[검토중] ## 구현 잠금 @@ -48,21 +48,21 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 ### Epic: [route-smoke] 벤치 경로 초경량 확인 -- [ ] [minimal-html-calls] 9개 조합에 동일한 최소 HTML 구현 요청을 한 번씩 직접 실행하고, 경로별 caller/model/route, terminal, 경과 시간, marker 확인 결과를 한 개의 Markdown 표에 기록한다. direct는 caller workspace 파일, Edge-owned execution preset은 caller-visible terminal output을 확인한다. 사설 dev CA가 필요한 호출은 caller가 공식 지원하는 command-scoped CA 변수에 managed CA bundle을 전달한다. Codex는 `CODEX_CA_CERTIFICATE`, Node 기반 caller는 필요한 경우 `NODE_EXTRA_CA_CERTS`를 사용한다. 검증: 새 runner/manifest 없이 각 행에 실제 호출 결과가 하나만 있어야 하며, 호출 전후 ambient Codex/IDE/shell 환경에는 CA 변수가 없어야 한다. -- [ ] [failed-path-fixes] 실패한 조합마다 제품·caller·provider·환경 중 소유 경계를 기록하고, IOP 제품 결함이 재현된 경우에만 국소 수정과 focused regression을 수행한 뒤 해당 조합만 다시 호출한다. 검증: 성공한 조합의 반복 실행이 없고, 재실행 행에는 변경된 원인과 연결된 수정·테스트 근거가 있어야 한다. -- [ ] [thin-bench-handoff] 9개 조합의 통과 또는 구체적 외부 차단 상태를 짧게 정리해 `[bench-lite-01]` 실행 가능 여부를 남긴다. 검증: 비교 점수나 순위가 아니라 호출 가능 여부와 남은 소유자만 기록한다. +- [x] [minimal-html-calls] 9개 조합에 동일한 최소 HTML 구현 요청을 한 번씩 직접 실행하고, 경로별 caller/model/route, terminal, 경과 시간, marker 확인 결과를 한 개의 Markdown 표에 기록한다. direct는 caller workspace 파일, Edge-owned execution preset은 caller-visible terminal output을 확인한다. 사설 dev CA가 필요한 호출은 caller가 공식 지원하는 command-scoped CA 변수에 managed CA bundle을 전달한다. Codex는 `CODEX_CA_CERTIFICATE`, Node 기반 caller는 필요한 경우 `NODE_EXTRA_CA_CERTS`를 사용한다. 검증: 새 runner/manifest 없이 각 행에 실제 호출 결과가 하나만 있어야 하며, 호출 전후 ambient Codex/IDE/shell 환경에는 CA 변수가 없어야 한다. +- [x] [failed-path-fixes] 실패한 조합마다 제품·caller·provider·환경 중 소유 경계를 기록하고, IOP 제품 결함이 재현된 경우에만 국소 수정과 focused regression을 수행한 뒤 해당 조합만 다시 호출한다. 검증: 성공한 조합의 반복 실행이 없고, 재실행 행에는 변경된 원인과 연결된 수정·테스트 근거가 있어야 한다. +- [x] [thin-bench-handoff] 9개 조합의 통과 또는 구체적 외부 차단 상태를 짧게 정리해 `[bench-lite-01]` 실행 가능 여부를 남긴다. 검증: 비교 점수나 순위가 아니라 호출 가능 여부와 남은 소유자만 기록한다. ## 완료 리뷰 -- 상태: 없음 -- 요청일: 없음 -- 완료 근거: 최소 HTML 호출 evidence가 아직 없다. +- 상태: 검토중 +- 요청일: 2026-08-14 +- 완료 근거: 동일한 최소 HTML 요청으로 9개 caller/model/route 경로가 모두 통과했다. 실패 경로는 제품·provider·환경 경계로 귀속해 원인 변경 뒤에만 재검증했고, IOP 결함은 provider normalization과 focused regression으로 한정해 수정했다. - 검토 항목: - - [ ] 새 benchmark script, runner, manifest, state store가 생성되지 않았다. - - [ ] 성공 경로는 한 번만 실행했고 실패 경로만 변경된 원인 뒤 재검증했다. - - [ ] 제품 수정은 재현된 결함과 focused regression으로 한정됐다. + - [x] 새 benchmark script, runner, manifest, state store가 생성되지 않았다. + - [x] 성공 경로는 한 번만 실행했고 실패 경로만 변경된 원인 뒤 재검증했다. 단, 사용자 지시의 세션 한정 hybrid Work 모델 전환 확인은 경로 복구 검증과 분리해 기록했다. + - [x] 제품 수정은 재현된 결함과 focused regression으로 한정됐다. - agent-ui 상태 반영: 해당 없음 -- 리뷰 코멘트: 없음 +- 리뷰 코멘트: 제품·호출 경로 차단은 남지 않았으며 `[bench-lite-01]`의 단일 시도 비교를 시작할 수 있다. 표준 release tag finish는 다른 active release와 충돌하므로 배포 성공 판정과 분리해 남겨 둔다. ## 범위 제외 @@ -79,8 +79,8 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 - TLS 환경 경계: 개발 Edge용 사설 CA는 Edge leaf 인증서가 아니라 managed CA bundle을 해당 벤치 caller process에만 전달한다. Codex에는 공식 변수 `CODEX_CA_CERTIFICATE`, Node 기반 caller에는 필요한 경우 `NODE_EXTRA_CA_CERTS`를 사용하며 Codex/IDE 시작 환경이나 셸 전역에 `export`하지 않는다. 그렇지 않으면 공개 TLS 연결에도 같은 CA override가 적용될 수 있다. - evidence 위치: `agent-test/dev/iop-benchmark-route-minimal-html-smoke.md` - 현재 사전 확인: 2026-08-13 실제 원격 실행기에서 Claude Code 2.1.177과 Codex 0.146.0을 확인했고, 현재 workspace의 OpenCode 1.18.3을 별도 사용자 설정 변경 없이 command-scoped provider config로 준비했다. 원격 SOPS에 보관된 기존 IOP principal token으로 token 원문을 출력하지 않은 `/v1/models`가 HTTP 200임을 확인했다. 새 벤치 전용 token은 발급하거나 사용하지 않는다. -- 현재 경로 결과: 9개 중 8개가 통과했고 OpenCode → Gemini direct만 정규화 코드의 live 배포 후 재검증을 남겼다. Claude Code → GPT direct는 caller-neutral operation normalization 반영 뒤 9초에 통과했고, Codex → GPT direct와 Codex → GPT execution preset도 각각 10초/16초에 통과했다. OpenCode → Gemini execution preset은 `ornith-fast` Node 복구 후 19초에 통과했다. 이후 사용자 지시로 이 세션의 hybrid Work 바인딩만 `ornith:35b`로 임시 전환했고, Claude Code → GPT execution preset은 71초, OpenCode → Gemini execution preset은 51초에 terminal success·caller marker·오류 0으로 통과했다. -- 추가 분리 결과: agy는 text completion은 가능하지만 trusted workspace에서도 파일 도구를 호출하지 않아 구현 Agent 벤치 대상에서 제외했다. 대체한 OpenCode → Gemini direct는 첫 tool 호출 뒤 `extra_content.google.thought_signature`를 일반 Chat history로 보존하지 못해 두 번째 provider 호출이 HTTP 400으로 닫혔다. IOP가 선택된 `gemini_openai_chat` tool-call wire에서만 opaque id로 캡슐화·복원하도록 공통 provider normalization을 추가했고 focused HTTP 연속 호출 회귀가 통과했다. 코드는 `dev`에 반영됐지만 live 재검증은 표준 배포 전이다. Codex → GPT direct의 최초 실패는 CA bundle 대신 Edge leaf 인증서를 전달한 측정 환경 결함이었다. Claude Code → Gemini/GPT preset의 이전 default-selector 및 profile operation normalization 결함도 국소 수정과 회귀 검증이 완료됐다. -- 배포 경계: live dev는 이전 release를 실행 중이고, 원격에 이전부터 미종료 release branch 두 건이 남아 있다. `dev-runtime-deploy` fail-closed 규칙에 따라 새 release 생성·배포·tag 반영은 시작하지 않았다. 이 Git release 상태는 벤치 성공 판정이 아니라 제품 배포 경계에서 별도로 정리해야 한다. +- 현재 경로 결과: 9개가 모두 통과했다. Claude Code → GPT direct는 caller-neutral operation normalization 반영 뒤 9초, Codex → GPT direct와 Codex → GPT execution preset은 각각 10초/16초에 통과했다. OpenCode → Gemini execution preset은 `ornith-fast` Node 복구 뒤 19초에 통과했다. 이후 사용자 지시로 이 세션의 hybrid Work 바인딩만 `ornith:35b`로 임시 전환했고, Claude Code → GPT execution preset은 71초, OpenCode → Gemini execution preset은 51초에 terminal success·caller marker·오류 0으로 통과했다. 마지막 OpenCode → Gemini direct는 normalization 포함 dev 배포 뒤 11초에 `glob`/`write`/`read`, terminal marker, exact `index.html`을 확인했다. +- 추가 분리 결과: agy는 text completion은 가능하지만 trusted workspace에서도 파일 도구를 호출하지 않아 구현 Agent 벤치 대상에서 제외했다. 대체한 OpenCode → Gemini direct는 첫 tool 호출 뒤 `extra_content.google.thought_signature`를 일반 Chat history로 보존하지 못해 두 번째 provider 호출이 HTTP 400으로 닫혔다. IOP가 선택된 `gemini_openai_chat` tool-call wire에서만 opaque id로 캡슐화·복원하도록 공통 provider normalization을 추가했고 focused HTTP 연속 호출 회귀와 live tool continuation이 통과했다. Codex → GPT direct의 최초 실패는 CA bundle 대신 Edge leaf 인증서를 전달한 측정 환경 결함이었다. Claude Code → Gemini/GPT preset의 이전 default-selector 및 profile operation normalization 결함도 국소 수정과 회귀 검증이 완료됐다. +- 배포 경계: source `de35e6d4`로 Edge와 네 Node를 다시 빌드·배포했고 빌드 전후 Go package 49개, Node/provider health, managed-capacity Chat·Responses 네 건을 통과했다. 배포 source는 `archive/release-dev-1019-de35e6d`에 보존했다. 이후 별도 framework sync와 다른 active release가 생겨 표준 release tag finish만 fail-closed로 남았으며 live dev 배포·9개 경로 판정과 분리한다. - 세션 한정 override: tracked 설정은 변경하지 않고 live runtime config의 Gemini/GPT hybrid Work 바인딩 네 곳만 `ornith:35b`로 바꾸었다. 원본 백업, config check, refresh dry-run/apply 통과 근거를 확인했으며 세션 종료 시 원래 `ornith-fast`로 복구한다. - 후속 측정: [초경량 Agent 모델 비교](thin-agent-model-comparison-benchmark.md) diff --git a/agent-roadmap/priority-queue.md b/agent-roadmap/priority-queue.md index 1361250d..b1bafa5a 100644 --- a/agent-roadmap/priority-queue.md +++ b/agent-roadmap/priority-queue.md @@ -13,7 +13,7 @@ 1. [[bench-lite-01] 초경량 Agent 모델 비교](phase/knowledge-tool-optimization-extension/milestones/thin-agent-model-comparison-benchmark.md) 통과한 동일 경로를 복구·재개·자동 채점 없이 한 번씩 실행하고 최소 비교 표만 남긴다. - - 선행 차단: `[bench-route-01]` + - 선행 차단: 없음 (`[bench-route-01]` 9개 경로 통과, 완료 검토중) ### route diff --git a/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md b/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md index 0255479e..801838a1 100644 --- a/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md +++ b/agent-test/dev/iop-benchmark-route-minimal-html-smoke.md @@ -26,7 +26,7 @@ |---|---|---|---| | Claude Code → Claude direct | 통과 | 없음 | 기존 성공 | | Claude Code → Gemini direct | 통과 | 없음 | 기존 성공 | -| OpenCode → Gemini direct | 6초, 첫 `glob` tool 성공 뒤 두 번째 provider 호출 HTTP 400 | provider normalization 구현·`dev` 반영 완료; live 배포 뒤 재검증 대기 | 선택된 Gemini Chat profile의 `thought_signature`를 표준 caller history로 왕복하지 못한 IOP 결함; agy는 구현 Agent 부적합으로 대상 제외 | +| OpenCode → Gemini direct | 6초, 첫 `glob` tool 성공 뒤 두 번째 provider 호출 HTTP 400 | normalization 포함 dev 배포 뒤 11초 통과; `glob`/`write`/`read`, terminal marker와 exact `index.html` 확인 | 선택된 Gemini Chat profile의 `thought_signature`를 표준 caller history로 왕복하지 못한 IOP 결함 수정; agy는 구현 Agent 부적합으로 대상 제외 | | Claude Code → GPT direct | provider HTTP 400 | operation normalization 반영 뒤 9초, caller success, `index.html` marker 1회 | IOP가 tools+effort를 Chat으로 보낸 결함 수정 확인 | | Codex → GPT direct | 30초, `turn.failed`, 파일 없음 | 공식 설정대로 임시 `CODEX_HOME`, Responses 전용 provider, `CODEX_CA_CERTIFICATE`에 CA bundle을 사용해 10초 통과 | 측정 환경 결함: 첫 호출은 CA bundle 대신 Edge leaf 인증서를 사용 | | Claude Code → Gemini execution preset | 184초, caller terminal success, caller workspace 파일 없음 | 새 배포에서 33초 caller 정상 종료; Plan/Work/Review, workspace write/read/list, artifact 3개 cleanup 성공 | default-selector dispatch 결함 해소; terminal 문구에 marker가 없는 것은 caller-visible 결과 판정과 분리 | @@ -34,7 +34,7 @@ | Claude Code → GPT execution preset | provider 전 `messages[1].role` 검증 거절, 입력 normalize 후 Work `validation` | `timings` normalize가 포함된 live release에서 통과; 세션 한정 Work `ornith:35b`에서 71초·caller terminal success·marker 확인·오류 0 | mid-conversation system 입력과 provider `timings` 정규화 누락이었으며 국소 수정 후 해소 | | Codex → GPT execution preset | 16초, `turn.completed`, terminal marker 1회 | 없음 | 통과 | -추가 API 분리에서는 동일 principal의 최소 `/v1/responses`가 HTTP 200이었다. Codex direct도 사용자 설정과 로그인 상태를 배제한 임시 `CODEX_HOME`, Responses 전용 custom provider, 원격 SOPS의 기존 token, command-scoped managed CA bundle으로 통과했다. OpenCode direct는 command-scoped `OPENCODE_CONFIG_CONTENT`와 원격 SOPS의 기존 token을 사용해 user config를 변경하지 않았다. 첫 `glob` tool까지 성공했으나 caller가 Gemini provider extension을 표준 Chat history에 보존하지 않아 다음 요청이 `thought_signature` 누락 HTTP 400으로 실패했다. 이는 OpenCode 전용 문제가 아니라 selected Gemini Chat provider normalization 누락으로 분류했다. +추가 API 분리에서는 동일 principal의 최소 `/v1/responses`가 HTTP 200이었다. Codex direct도 사용자 설정과 로그인 상태를 배제한 임시 `CODEX_HOME`, Responses 전용 custom provider, 원격 SOPS의 기존 token, command-scoped managed CA bundle으로 통과했다. OpenCode direct는 command-scoped `OPENCODE_CONFIG_CONTENT`와 원격 SOPS의 기존 token을 사용해 user config를 변경하지 않았다. 최초 실행은 첫 `glob` tool 뒤 caller가 Gemini provider extension을 표준 Chat history에 보존하지 않아 다음 요청이 `thought_signature` 누락 HTTP 400으로 실패했다. 이는 OpenCode 전용 문제가 아니라 selected Gemini Chat provider normalization 누락으로 분류했다. IOP가 opaque signature를 선택된 Gemini Chat wire에서만 tool-call id에 캡슐화·복원하도록 수정한 dev 배포 뒤에는 `glob`/`write`/`read` 연속 호출, terminal marker, exact `index.html`이 11초 안에 모두 통과했다. 두 preset 최초 실패는 provider 자체 실패가 아니었다. 코드 대조에서 `default` resource selector가 의도적으로 빈 provider ID를 동결하는 반면 사후 검증은 pool이 정상 선택한 실제 provider ID와 무조건 같아야 한다고 요구한 결함을 확인했다. explicit selector의 provider ID와 profile/model/credential/path fence는 유지하고, default selector만 pool 선택을 인정했다. 재배포 뒤 Gemini preset은 전체 stage와 workspace lifecycle이 통과했다. GPT preset은 다음 경계까지 진행해 실제 OpenAI Chat 응답의 `service_tier`, `system_fingerprint`, `annotations`, null `refusal`을 private strict decoder가 거부하는 별도 normalize 누락을 드러냈다. 공통 provider-normalization 계층에서 Chat/Responses 결과를 같은 canonical stage envelope로 수렴시키는 국소 회귀가 통과했다. @@ -46,8 +46,12 @@ OpenCode → Gemini execution preset의 최초 실행은 Plan을 정상 통과 사용자 지시에 따라 이 세션의 live execution preset만 Work 바인딩 네 곳을 `ornith-fast`에서 `ornith:35b`로 임시 전환했다. Plan/Review 모델과 tracked 설정은 변경하지 않았고, runtime config의 원본 백업을 남겼다. config check, refresh dry-run, apply가 재시작 없이 통과했다. 임시 경로에서 Claude Code → GPT execution preset은 71초, OpenCode → Gemini execution preset은 51초에 각각 terminal success와 caller-visible marker를 남겼고 오류는 없었다. 두 경로의 caller 로컬에 `index.html`이 남지 않은 것은 preset workspace 정리 계약과 일치한다. -## 재개 조건 +## 완료 및 인계 -Gemini Chat tool-call normalization 코드는 `dev` commit `d99305d1`과 merge `dc2d9968`로 반영됐다. 9개 경로 중 8개는 통과했고, 남은 경로는 이 코드의 live 배포가 필요한 OpenCode → Gemini direct 하나다. 현재 live dev는 이전 release를 실행 중이며, 원격에 이전부터 미종료 release branch 두 건이 남아 표준 `dev-runtime-deploy`가 새 release를 시작하지 않고 fail-closed했다. 해당 Git release 상태를 별도로 정리한 뒤 표준 배포를 수행하고 남은 direct 경로만 1회 재검증한다. 제품 전체 capacity gate나 unrelated 운영 라우트는 이 얇은 경로 검증의 판정 기준으로 섞지 않는다. +Gemini Chat tool-call normalization 코드는 `dev` commit `d99305d1`과 merge `dc2d9968`로 반영됐고, 이를 포함한 source `de35e6d4`로 Edge와 macOS/Linux/Windows Node 네 대를 다시 빌드·배포했다. 빌드 전후 전체 Go package 49개가 각각 통과했고, 네 Node의 연결·provider health 및 OneX `ornith:35b`/RTX `ornith-fast`의 Chat·Responses capacity smoke 네 건도 별도 배포 검증으로 통과했다. 이 capacity smoke는 9개 얇은 경로의 성공 판정에는 섞지 않았다. + +OpenCode → Gemini direct 재검증까지 끝나 9개 경로가 모두 통과했다. 새 benchmark script, runner, manifest 또는 retry state는 만들지 않았다. `[bench-lite-01]`은 이 9개 경로를 그대로 사용해 조합별 단일 시도 비교를 시작할 수 있다. + +배포된 source는 원격 `archive/release-dev-1019-de35e6d`에 보존했다. 배포 직후 `origin/dev`에 별도 framework 동기화가 추가되고 다른 active release가 생겨 표준 release tag finish는 fail-closed 상태로 분리했다. 이는 live dev 배포와 9개 호출 성공에는 영향을 주지 않으며, 해당 release가 정리된 뒤 별도 Git bookkeeping으로 마쳐야 한다. 성공한 경로는 반복하지 않는다. 실패한 경로는 원인이 변경된 경우에만 해당 경로를 1회 재검증한다. From 165cdde2d6b8c24573a38fa7e9916dde5c9be842 Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 14 Aug 2026 05:46:28 +0900 Subject: [PATCH 09/18] =?UTF-8?q?docs(api):=20Gemini=20reasoning=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=20=EA=B3=84=ED=9A=8D=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CODE_REVIEW-cloud-G04.md | 103 ++++++++++ .../gemini_reasoning_compat/PLAN-local-G04.md | 186 ++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 agent-task/gemini_reasoning_compat/CODE_REVIEW-cloud-G04.md create mode 100644 agent-task/gemini_reasoning_compat/PLAN-local-G04.md diff --git a/agent-task/gemini_reasoning_compat/CODE_REVIEW-cloud-G04.md b/agent-task/gemini_reasoning_compat/CODE_REVIEW-cloud-G04.md new file mode 100644 index 00000000..8d1b0737 --- /dev/null +++ b/agent-task/gemini_reasoning_compat/CODE_REVIEW-cloud-G04.md @@ -0,0 +1,103 @@ + + +# Code Review Reference - API + +> **[IMPLEMENTING AGENT — READ FIRST]** Implement the plan through the existing normalization boundary, run verification, fill every implementation-owned section, leave active files in place, and report ready for review. Do not append a verdict, archive, write `complete.log`, or ask the user. + +## Overview + +date=2026-08-14 +task=gemini_reasoning_compat, plan=0, tag=API + +## For the Review Agent + +> **[REVIEW AGENT ONLY]** Compare source with the plan, rerun fresh verification, and finalize only through the code-review skill. + +## Implementation Item Completion + +| Item | Status | +|---|---| +| API-1 Correct Gemini profile levels | [ ] | +| API-2 Prove bridge inheritance and synchronize contracts | [ ] | + +## Implementation Checklist + +- [ ] Implement API-1 the Gemini portable effort levels inside the existing profile normalization. +- [ ] Implement API-2 focused config and Responses-bridge regression tests plus contract/spec synchronization. +- [ ] Run fresh local verification and the exact-source dev Gemini reasoning cycles. +- [ ] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output. + +## Review-Only Checklist + +> **[REVIEW AGENT ONLY]** Implementers must not modify this section. + +- [ ] Append PASS/WARN/FAIL and routing signals. +- [ ] Verify dimensions and finding classifications. +- [ ] Run and record fresh verification. +- [ ] Record reviewer evidence, root cause, one selected fix, targets, and acceptance commands for Required/Suggested findings. +- [ ] Archive review as `code_review_cloud_G04_0.log` and plan as `plan_local_G04_0.log`. +- [ ] Verify managed `.gitignore`; on PASS write `complete.log` and archive the task directory, otherwise write only the required next state. + +## Deviations from Plan + +_Replace with actual deviations or `None`._ + +## Key Design Decisions + +_Record actual decisions._ + +## Reviewer Checkpoints + +- Only Gemini profile levels changed; generic normalization remains the owner. +- low/medium/high are exact, xhigh/max fall to high, none fails closed. +- No direct-handler normalizer, model/caller branch, extension, `thinking_level`, or `thinking_budget` synthesis exists. +- Responses bridge inherits the mapping through `selectProviderOperation`. +- Gemini-native thinking config and thought signatures remain unchanged. + +## Verification Results + +### Profile Mapping + +```bash +go test -count=1 ./packages/go/config -run 'TestGeminiProtocolProfileReasoningEffortScale|TestProtocolProfileEffortNormalizationOverlayAndValidation' +``` + +_Paste actual stdout/stderr._ + +### Responses Bridge + +```bash +go test -count=1 ./apps/edge/internal/openai -run 'TestResponsesProtocolProfileGeminiEffortFallsBackToHigh|TestResponsesProtocolProfileBridgesToGeminiChat' +``` + +_Paste actual stdout/stderr._ + +### Full Verification + +```bash +gofmt -w packages/go/config/protocol_profile.go packages/go/config/protocol_profile_test.go apps/edge/internal/openai/responses_protocol_profile_test.go +go test -count=1 ./packages/go/config +go test -count=1 ./apps/edge/internal/openai +git diff --check +``` + +_Paste actual stdout/stderr._ + +### Contract and Dev Evidence + +```bash +rg --sort path -n 'Gemini.*reasoning_effort|xhigh|max.*high' agent-contract/inner/edge-config-runtime-refresh.md agent-contract/outer/openai-compatible-api.md agent-spec/input/openai-compatible-surface.md +``` + +_Paste document output and sanitized exact-source dev low/high/max cycle evidence. Never paste credentials or provider payloads._ + +## Section Ownership + +| Section | Owner | +|---|---| +| Fixed header/overview/instructions/checkpoints | Fixed | +| Item Completion/Implementation Checklist | Implementer checks only | +| Review-Only Checklist | Reviewer | +| Deviations/Key Decisions | Implementer | +| Verification Results | Implementer, then reviewer | +| Code Review Result | Reviewer appends | diff --git a/agent-task/gemini_reasoning_compat/PLAN-local-G04.md b/agent-task/gemini_reasoning_compat/PLAN-local-G04.md new file mode 100644 index 00000000..a7e747e7 --- /dev/null +++ b/agent-task/gemini_reasoning_compat/PLAN-local-G04.md @@ -0,0 +1,186 @@ + + +# Gemini Reasoning Normalization Plan + +## For the Implementing Agent + +기존 operation-scoped `normalization.effort` 경계만 사용한다. 구현·검증 뒤 `CODE_REVIEW-cloud-G04.md`의 구현 담당 섹션에 실제 결과를 기록하고 active pair를 둔 채 review 준비 완료를 보고한다. verdict, archive, `complete.log`, 사용자 질문은 구현 담당 범위가 아니다. + +## Background + +Gemini profile은 이미 `normalization.effort` 방식으로 reasoning grade를 처리하지만 현재 levels가 IOP 전체 등급의 identity mapping이라 Gemini Chat의 portable 상한을 넘는 `xhigh|max`도 그대로 전달한다. 별도 adapter나 `thinking_level` 변환기를 만들지 않고 profile mapping만 교정해 기존 normalization 흐름이 올바른 provider value를 선택하게 한다. + +## Analysis + +### Files Read + +- `packages/go/config/protocol_profile.go` +- `packages/go/config/protocol_profile_test.go` +- `apps/edge/internal/openai/provider_normalization.go` +- `apps/edge/internal/openai/responses_protocol_profile_test.go` +- `agent-contract/inner/edge-config-runtime-refresh.md` +- `agent-contract/outer/openai-compatible-api.md` +- `agent-spec/input/openai-compatible-surface.md` +- `agent-test/dev/rules.md` +- `agent-ops/rules/project/domain/platform-common/rules.md` + +### SDD Criteria + +not applicable. 기존 normalization profile의 작은 호환성 결함 수정이다. + +### Verification Context + +- 최신 `dev` 기준 `protocol_profile.go`의 Gemini Chat effort는 `identityEffortMapping(...)`이며 `none|low|medium|high|xhigh|max`를 모두 그대로 매핑한다. +- 공통 `MapReasoningEffort`는 exact level이 없으면 가장 가까운 명시적 하위 등급을 고르고 상향하지 않는다. 이 함수가 source of truth다. +- 진행 중인 `responses_provider_bridge`도 `selectProviderOperation`에서 동일한 profile mapping을 사용하므로 변환 코드를 추가할 필요가 없다. +- Gemini 공식 OpenAI-compatible Chat은 `reasoning_effort`를 지원한다. `thinking_level`/`thinking_budget`을 함께 보내면 안 되므로 IOP가 native field를 합성하지 않는다. +- fresh test는 `-count=1`로 실행한다. + +#### External Verification Preflight + +- runner/workdir: `ssh toki@toki-labs.com`, `/Users/toki/agent-work/iop-dev` +- clean `dev`에서 `HEAD == origin/dev == approved implementation SHA`를 확인한다. +- exact source로 dev-runtime Edge/참여 Node를 rebuild/redeploy/restart하고 `18083/18084` listener와 binary identity를 확인한다. +- existing copied CA 및 SOPS/age-managed token을 사용하며 secret 원문은 evidence에 남기지 않는다. +- Gemini direct route에서 standard Responses caller의 `low`, `high`, `max` 한 cycle씩 확인한다. extension이나 `thinking_level` 우회는 사용하지 않는다. + +### Test Coverage Gaps + +| Behavior | Existing coverage | Gap | +|---|---|---| +| nearest-lower mapping | custom OpenAI profile | built-in Gemini mapping 없음 | +| Gemini tools + effort | profile은 `WithTools=true` | xhigh/max fallback 회귀 없음 | +| Responses→Gemini | 진행 중 bridge가 공통 selector 사용 | corrected mapping 상속 assertion 없음 | + +### Symbol References + +rename/remove 없음. `MapReasoningEffort`와 `selectProviderOperation` call sites는 변경하지 않는다. + +### Split Judgment + +단일 plan이다. compact invariant는 “Gemini profile의 declared levels가 기존 공통 nearest-lower normalization을 통해 provider-supported grade만 반환한다”이다. + +### Scope Rationale + +- `provider_normalization.go`의 알고리즘과 handler/adapter에 새 정규화 경계를 추가하지 않는다. +- `thinking_level`, `thinking_budget`, model-name 분기, caller/agent 분기, extension 우회를 추가하지 않는다. +- Gemini-native `thinkingConfig`, response reasoning text, `thought_signature`는 변경하지 않는다. +- portable scale은 `low|medium|high`로 둔다. `xhigh|max`는 기존 규칙으로 `high`, `none`은 지원 mapping이 없어 fail closed한다. + +### Final Routing + +- evaluation_mode: `first-pass`; finalizer=`finalize-task-policy.sh`, mode=`pair` +- build/review closures: all true +- build scores: scope=1,state=0,blast=1,evidence=1,verification=1; route=`local/G04`, basis=`local-fit` +- review scores: scope=1,state=0,blast=1,evidence=1,verification=1; route=`cloud/G04`, basis=`official-review` +- large_indivisible_context=false +- positive loop risks: `boundary_contract`, `variant_product` (2); risk boundary=false +- recovery signals: review_rework_count=0, evidence_integrity_failure=false; capability gap 없음 +- canonical files: `PLAN-local-G04.md`, `CODE_REVIEW-cloud-G04.md` + +## Implementation Checklist + +- [ ] Implement API-1 the Gemini portable effort levels inside the existing profile normalization. +- [ ] Implement API-2 focused config and Responses-bridge regression tests plus contract/spec synchronization. +- [ ] Run fresh local verification and the exact-source dev Gemini reasoning cycles. +- [ ] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output. + +### [API-1] Correct Gemini profile levels + +#### Problem + +```go +// packages/go/config/protocol_profile.go:251 +string(OperationChatCompletions): identityEffortMapping(ProtocolEffortWireGeminiChat, true, true), +``` + +Identity mapping declares unsupported upper/disable grades instead of letting the common normalizer choose a supported lower grade or reject. + +#### Solution + +Replace only the Gemini Chat levels with explicit `low`, `medium`, `high`. Retain `Wire=gemini_openai_chat`, `WithTools=true`, and the existing token-budget fact. Do not change `MapReasoningEffort`. + +```go +// after: existing MapReasoningEffort supplies xhigh/max -> high +ProtocolEffortMappingConf{ + Wire: ProtocolEffortWireGeminiChat, + WithTools: true, + TokenBudget: true, + Levels: map[string]string{"low":"low", "medium":"medium", "high":"high"}, +} +``` + +#### Modified Files and Checklist + +- [ ] `packages/go/config/protocol_profile.go`: Gemini Chat levels only. + +#### Test Strategy + +Write `TestGeminiProtocolProfileReasoningEffortScale` in `protocol_profile_test.go`: low/medium/high exact, xhigh/max→high, none unsupported, with-tools true, and `base: gemini` inheritance. + +#### Verification + +```bash +go test -count=1 ./packages/go/config -run 'TestGeminiProtocolProfileReasoningEffortScale|TestProtocolProfileEffortNormalizationOverlayAndValidation' +``` + +Expected: all cases pass without changing generic normalization. + +### [API-2] Prove bridge inheritance and synchronize contracts + +#### Problem + +No regression test proves that Responses ingress selecting Gemini Chat consumes the corrected profile mapping; documentation does not state Gemini's portable IOP grade behavior. + +#### Solution + +After `responses_provider_bridge` is present, add a focused assertion that a Responses request with effort `max` selects Gemini Chat and sends `reasoning_effort=high`. Do not add conversion logic in the test's production path. Document that direct native provider fields remain passthrough, while profile-normalized bridges use low/medium/high with nearest-lower fallback and no synthetic native thinking field. + +#### Modified Files and Checklist + +- [ ] `packages/go/config/protocol_profile_test.go`: profile mapping coverage. +- [ ] `apps/edge/internal/openai/responses_protocol_profile_test.go`: bridge inheritance coverage only. +- [ ] `agent-contract/inner/edge-config-runtime-refresh.md`: Gemini operation-scoped levels. +- [ ] `agent-contract/outer/openai-compatible-api.md`: Responses→Gemini reasoning semantics. +- [ ] `agent-spec/input/openai-compatible-surface.md`: living implementation state/change history. + +#### Test Strategy + +Write `TestResponsesProtocolProfileGeminiEffortFallsBackToHigh`. Assert selected operation is Chat, provider body contains `reasoning_effort=high`, tools still admit the candidate, and no `thinking_level`/`thinking_budget` is synthesized. + +#### Verification + +```bash +go test -count=1 ./apps/edge/internal/openai -run 'TestResponsesProtocolProfileGeminiEffortFallsBackToHigh' +rg --sort path -n 'Gemini.*reasoning_effort|xhigh|max.*high' agent-contract/inner/edge-config-runtime-refresh.md agent-contract/outer/openai-compatible-api.md agent-spec/input/openai-compatible-surface.md +``` + +Expected: bridge test and documents agree with the profile mapping. + +## Modified Files Summary + +| File | Items | +|---|---| +| `packages/go/config/protocol_profile.go` | API-1 | +| `packages/go/config/protocol_profile_test.go` | API-1, API-2 | +| `apps/edge/internal/openai/responses_protocol_profile_test.go` | API-2 | +| `agent-contract/inner/edge-config-runtime-refresh.md` | API-2 | +| `agent-contract/outer/openai-compatible-api.md` | API-2 | +| `agent-spec/input/openai-compatible-surface.md` | API-2 | +| `agent-task/gemini_reasoning_compat/CODE_REVIEW-cloud-G04.md` | API-1, API-2 evidence | + +## Final Verification + +```bash +gofmt -w packages/go/config/protocol_profile.go packages/go/config/protocol_profile_test.go apps/edge/internal/openai/responses_protocol_profile_test.go +go test -count=1 ./packages/go/config +go test -count=1 ./apps/edge/internal/openai -run 'TestResponsesProtocolProfileGeminiEffortFallsBackToHigh|TestResponsesProtocolProfileBridgesToGeminiChat' +go test -count=1 ./apps/edge/internal/openai +git diff --check +``` + +Expected: fresh tests pass and the implementation diff contains no handler/adapter reasoning normalizer or native thinking field synthesis. + +After an approved exact commit reaches `dev`, perform the recorded dev preflight/rebuild and Gemini Responses `low`, `high`, `max` cycles. Expected: all complete, `max` uses provider effort `high`, and no extension workaround is active. + +After completing all code changes, fill implementation-owned sections in `CODE_REVIEW-*-G??.md`. From 16b7aba95a282b6c5d1e88d3b1849eaa1208b28a Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 14 Aug 2026 05:50:13 +0900 Subject: [PATCH 10/18] =?UTF-8?q?docs(roadmap):=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=8A=A4=EB=AA=A8=ED=81=AC=EB=A5=BC=20=EC=A2=85=EB=A3=8C?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9개 경로의 종료 감사를 통과했으므로 완료 상태와 SDD를 아카이브하고, 하네스 없는 초경량 비교를 바로 시작할 수 있게 고정 요청과 9행 결과표를 준비한다. --- .../benchmark-route-minimal-html-smoke.md | 13 ++++--- .../benchmark-route-minimal-html-smoke/SDD.md | 8 ++-- .../PHASE.md | 10 ++--- .../thin-agent-model-comparison-benchmark.md | 12 +++--- agent-roadmap/priority-queue.md | 6 --- .../dev/iop-thin-agent-model-comparison.md | 39 +++++++++++++++++++ 6 files changed, 62 insertions(+), 26 deletions(-) rename agent-roadmap/{ => archive}/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md (89%) rename agent-roadmap/{ => archive}/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md (87%) create mode 100644 agent-test/dev/iop-thin-agent-model-comparison.md diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md b/agent-roadmap/archive/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md similarity index 89% rename from agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md rename to agent-roadmap/archive/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md index c29c2a9e..cec16567 100644 --- a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md +++ b/agent-roadmap/archive/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md @@ -2,8 +2,8 @@ ## 위치 -- Roadmap: [ROADMAP.md](../../../ROADMAP.md) -- Phase: [PHASE.md](../PHASE.md) +- Roadmap: [ROADMAP.md](../../../../ROADMAP.md) +- Phase: [PHASE.md](../../../../phase/knowledge-tool-optimization-extension/PHASE.md) ## 목표 @@ -12,7 +12,7 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 ## 상태 -[검토중] +[완료] ## 구현 잠금 @@ -54,7 +54,7 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 ## 완료 리뷰 -- 상태: 검토중 +- 상태: 통과 - 요청일: 2026-08-14 - 완료 근거: 동일한 최소 HTML 요청으로 9개 caller/model/route 경로가 모두 통과했다. 실패 경로는 제품·provider·환경 경계로 귀속해 원인 변경 뒤에만 재검증했고, IOP 결함은 provider normalization과 focused regression으로 한정해 수정했다. - 검토 항목: @@ -62,7 +62,8 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 - [x] 성공 경로는 한 번만 실행했고 실패 경로만 변경된 원인 뒤 재검증했다. 단, 사용자 지시의 세션 한정 hybrid Work 모델 전환 확인은 경로 복구 검증과 분리해 기록했다. - [x] 제품 수정은 재현된 결함과 focused regression으로 한정됐다. - agent-ui 상태 반영: 해당 없음 -- 리뷰 코멘트: 제품·호출 경로 차단은 남지 않았으며 `[bench-lite-01]`의 단일 시도 비교를 시작할 수 있다. 표준 release tag finish는 다른 active release와 충돌하므로 배포 성공 판정과 분리해 남겨 둔다. +- Spec sync: [OpenAI-Compatible 입력 표면](../../../../../agent-spec/input/openai-compatible-surface.md)에 operation-scoped normalization, nearest-lower effort, Gemini Chat signature 왕복과 관련 코드·테스트 evidence가 이미 반영되어 추가 갱신이 필요하지 않다. +- 리뷰 코멘트: 2026-08-14 종료 감사를 통과했다. 관련 config/OpenAI/service 회귀 테스트와 9/9 dev 실호출 evidence를 확인했으며 제품·호출 경로 차단은 남지 않았다. `[bench-lite-01]`의 단일 시도 비교를 시작할 수 있다. 표준 release tag finish는 다른 active release와 충돌하므로 배포 성공 판정과 분리해 남겨 둔다. ## 범위 제외 @@ -83,4 +84,4 @@ IOP 전체 안정성을 처음부터 재검증하지 않고, 실패가 재현된 - 추가 분리 결과: agy는 text completion은 가능하지만 trusted workspace에서도 파일 도구를 호출하지 않아 구현 Agent 벤치 대상에서 제외했다. 대체한 OpenCode → Gemini direct는 첫 tool 호출 뒤 `extra_content.google.thought_signature`를 일반 Chat history로 보존하지 못해 두 번째 provider 호출이 HTTP 400으로 닫혔다. IOP가 선택된 `gemini_openai_chat` tool-call wire에서만 opaque id로 캡슐화·복원하도록 공통 provider normalization을 추가했고 focused HTTP 연속 호출 회귀와 live tool continuation이 통과했다. Codex → GPT direct의 최초 실패는 CA bundle 대신 Edge leaf 인증서를 전달한 측정 환경 결함이었다. Claude Code → Gemini/GPT preset의 이전 default-selector 및 profile operation normalization 결함도 국소 수정과 회귀 검증이 완료됐다. - 배포 경계: source `de35e6d4`로 Edge와 네 Node를 다시 빌드·배포했고 빌드 전후 Go package 49개, Node/provider health, managed-capacity Chat·Responses 네 건을 통과했다. 배포 source는 `archive/release-dev-1019-de35e6d`에 보존했다. 이후 별도 framework sync와 다른 active release가 생겨 표준 release tag finish만 fail-closed로 남았으며 live dev 배포·9개 경로 판정과 분리한다. - 세션 한정 override: tracked 설정은 변경하지 않고 live runtime config의 Gemini/GPT hybrid Work 바인딩 네 곳만 `ornith:35b`로 바꾸었다. 원본 백업, config check, refresh dry-run/apply 통과 근거를 확인했으며 세션 종료 시 원래 `ornith-fast`로 복구한다. -- 후속 측정: [초경량 Agent 모델 비교](thin-agent-model-comparison-benchmark.md) +- 후속 측정: [초경량 Agent 모델 비교](../../../../phase/knowledge-tool-optimization-extension/milestones/thin-agent-model-comparison-benchmark.md) diff --git a/agent-roadmap/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md b/agent-roadmap/archive/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md similarity index 87% rename from agent-roadmap/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md rename to agent-roadmap/archive/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md index 889f442f..b33a5563 100644 --- a/agent-roadmap/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md +++ b/agent-roadmap/archive/sdd/knowledge-tool-optimization-extension/benchmark-route-minimal-html-smoke/SDD.md @@ -2,8 +2,8 @@ ## 위치 -- Milestone: [Milestone 문서](../../../phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md) -- Phase: [PHASE.md](../../../phase/knowledge-tool-optimization-extension/PHASE.md) +- Milestone: [Milestone 문서](../../../../archive/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md) +- Phase: [PHASE.md](../../../../phase/knowledge-tool-optimization-extension/PHASE.md) ## 상태 @@ -27,7 +27,7 @@ | 영역 | 기준 | 메모 | |------|------|------| -| Roadmap | [Milestone 문서](../../../phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md) | 9개 얇은 경로와 실패 경로만 재검증하는 범위 | +| Roadmap | [Milestone 문서](../../../../archive/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md) | 9개 얇은 경로와 실패 경로만 재검증하는 범위 | | Code | `packages/go/config/protocol_profile.go`, `apps/edge/internal/openai/provider_model_rewrite.go` | operation-scoped tool-call wire와 요청·응답 왕복 정규화 | | External Provider | Gemini OpenAI-compatible Chat | Tool call을 이어갈 때 opaque thought signature를 요구한다. | | User Decision | 없음 | agy를 제외하고 같은 두 자리에 OpenCode를 넣는 범위가 확정됐다. | @@ -43,7 +43,7 @@ ## Interface Contract -- 계약 원문: [OpenAI-Compatible API](../../../../agent-contract/outer/openai-compatible-api.md), [Edge Config And Runtime Refresh](../../../../agent-contract/inner/edge-config-runtime-refresh.md) +- 계약 원문: [OpenAI-Compatible API](../../../../../agent-contract/outer/openai-compatible-api.md), [Edge Config And Runtime Refresh](../../../../../agent-contract/inner/edge-config-runtime-refresh.md) - 입력: - `normalization.tool_calls[operation].wire`: provider tool-call metadata normalization 선택자 - `tool_calls[].id` / `tool_call_id`: caller가 왕복 보존하는 표준 id 표면 diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md index 7819b766..538fb62e 100644 --- a/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/PHASE.md @@ -57,15 +57,15 @@ Phase를 가로지르는 실제 다음 작업 선택은 [전역 마일스톤 실 - 경로: [[bench-01] Agent 비교 벤치마크 파이프라인 준비](../../archive/phase/knowledge-tool-optimization-extension/milestones/agent-comparison-benchmark-pipeline.md) - 요약: 모델·caller·prompt·반복 횟수를 manifest로 바꾸고 Claude Code, agy, Codex의 IOP 연결부터 finish/idle, 시간·token·웹 검증·익명 채점·Markdown 보고까지 같은 pipeline으로 재현한다. +- [완료] [bench-route-01] 벤치 경로 최소 HTML 스모크 + - 경로: [[bench-route-01] 벤치 경로 최소 HTML 스모크](../../archive/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md) + - 요약: 벤치에 사용할 9개 caller/model/route 조합이 고정된 최소 `index.html` 생성 요청으로 모두 통과했고, 실패 경로만 귀속·국소 수정·재검증했다. + - [폐기] [bench-02] IOP 원샷 Agent 모델 비교 벤치마크 - 경로: [[bench-02] IOP 원샷 Agent 모델 비교 벤치마크](../../archive/phase/knowledge-tool-optimization-extension/milestones/iop-one-shot-agent-model-comparison.md) - 요약: 전용 harness의 정합성과 복구가 제품 안정성보다 우선되는 목적 역전으로 2026-08-13 폐기했다. 기존 결과와 계획은 재개하지 않는다. -- [검토중] [bench-route-01] 벤치 경로 최소 HTML 스모크 - - 경로: [[bench-route-01] 벤치 경로 최소 HTML 스모크](milestones/benchmark-route-minimal-html-smoke.md) - - 요약: 벤치에 사용할 9개 caller/model/route 조합이 고정된 최소 `index.html` 생성 요청으로 모두 통과했고, 실패 경로만 귀속·국소 수정·재검증했다. - -- [계획] [bench-lite-01] 초경량 Agent 모델 비교 +- [진행중] [bench-lite-01] 초경량 Agent 모델 비교 - 경로: [[bench-lite-01] 초경량 Agent 모델 비교](milestones/thin-agent-model-comparison-benchmark.md) - 요약: 최소 HTML 스모크를 통과한 동일 경로를 복구·재개·자동 채점 없는 단일 시도로 실행하고, 성공 여부·경과 시간·제공된 usage·산출물만 한 표에 기록한다. diff --git a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/thin-agent-model-comparison-benchmark.md b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/thin-agent-model-comparison-benchmark.md index b8f6e466..8c62238b 100644 --- a/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/thin-agent-model-comparison-benchmark.md +++ b/agent-roadmap/phase/knowledge-tool-optimization-extension/milestones/thin-agent-model-comparison-benchmark.md @@ -12,7 +12,7 @@ ## 상태 -[계획] +[진행중] ## 구현 잠금 @@ -25,7 +25,7 @@ ## 범위 - `[bench-route-01]`과 동일한 9개 caller/model/route 조합 -- 모든 조합에 같은 고정 비교 prompt와 같은 빈 임시 workspace 사용 +- 모든 조합에 [얇은 비교 결과 문서](../../../../agent-test/dev/iop-thin-agent-model-comparison.md)의 같은 고정 비교 prompt와 같은 빈 임시 workspace 사용 - 조합별 정확히 1회 실행 - 성공 여부, 전체 경과 시간, caller가 직접 제공한 usage, 산출물 경로와 짧은 수동 관찰만 기록 - 실패한 조합은 실패로 기록하고 같은 측정 안에서 retry, resume 또는 대체 run을 하지 않음 @@ -44,7 +44,7 @@ - 요청일: 없음 - 완료 근거: `[bench-route-01]`과 단일 시도 결과가 아직 없다. - 검토 항목: - - [ ] `[bench-route-01]`이 통과 또는 사용자 승인된 외부 차단 상태다. + - [x] `[bench-route-01]`이 통과 또는 사용자 승인된 외부 차단 상태다. - [ ] 새 benchmark script와 자동화 state가 없다. - [ ] 조합별 정확히 한 번의 실행과 최소 결과 표만 남았다. - agent-ui 상태 반영: 해당 없음 @@ -60,6 +60,8 @@ ## 작업 컨텍스트 -- 선행 작업: [벤치 경로 최소 HTML 스모크](benchmark-route-minimal-html-smoke.md) +- 선행 작업: [벤치 경로 최소 HTML 스모크](../../../archive/phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md) 완료 - 실행 방식: 기존 공식 caller 명령을 한 번씩 직접 실행하며 공통 runner를 만들지 않는다. -- 결과 위치: `agent-test/dev/iop-thin-agent-model-comparison.md` +- 결과 위치: [얇은 비교 결과](../../../../agent-test/dev/iop-thin-agent-model-comparison.md) +- 준비 상태: 고정 prompt, 9행 결과표, 단일 시도 판정 규칙을 준비했다. 별도 script, manifest, state store는 없다. +- 세션 라우팅: 이 세션에서 execution preset의 Work 바인딩은 사용자 지시에 따라 live `ornith:35b`를 사용하며 tracked 설정은 변경하지 않는다. diff --git a/agent-roadmap/priority-queue.md b/agent-roadmap/priority-queue.md index b1bafa5a..26c81394 100644 --- a/agent-roadmap/priority-queue.md +++ b/agent-roadmap/priority-queue.md @@ -4,16 +4,10 @@ ## 실행 순서 -### bench-route - -1. [[bench-route-01] 벤치 경로 최소 HTML 스모크](phase/knowledge-tool-optimization-extension/milestones/benchmark-route-minimal-html-smoke.md) - 벤치 대상 9개 caller/model/route 조합에 고정된 최소 `index.html` 생성 요청을 한 번씩 직접 보내고 실패 경로만 국소 수정한다. - ### bench-lite 1. [[bench-lite-01] 초경량 Agent 모델 비교](phase/knowledge-tool-optimization-extension/milestones/thin-agent-model-comparison-benchmark.md) 통과한 동일 경로를 복구·재개·자동 채점 없이 한 번씩 실행하고 최소 비교 표만 남긴다. - - 선행 차단: 없음 (`[bench-route-01]` 9개 경로 통과, 완료 검토중) ### route diff --git a/agent-test/dev/iop-thin-agent-model-comparison.md b/agent-test/dev/iop-thin-agent-model-comparison.md new file mode 100644 index 00000000..2915112b --- /dev/null +++ b/agent-test/dev/iop-thin-agent-model-comparison.md @@ -0,0 +1,39 @@ +# IOP 초경량 Agent 모델 비교 + +## 목적 + +검증을 마친 9개 caller/model/route 조합에 같은 HTML 구현 요청을 정확히 한 번씩 실행해 성공 여부, 경과 시간, caller가 직접 제공한 usage와 짧은 관찰만 비교한다. 별도 benchmark script, runner, manifest, retry, resume, browser gate 또는 자동 채점은 사용하지 않는다. + +## 고정 요청 + +아래 문장을 모든 조합에 그대로 사용한다. + +> Create a polished single-file responsive landing page in `index.html` for “Orbit Ops”, a fictional AI operations dashboard. Use no external assets, frameworks, or JavaScript. Include a semantic header, main, and footer; a hero with a title and two calls to action; three feature cards; and a system-status panel with three services and visible status labels. Put all CSS in a `