From 4368a31ea666f9fcbef017bd595fa158bb32293e Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 05:30:42 +0900 Subject: [PATCH 01/37] =?UTF-8?q?fix(openai):=20=EB=8F=84=EA=B5=AC=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20effort=EB=A5=BC=20Responses=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPT Chat wire는 함수 도구와 reasoning effort를 함께 지원하지 않는다. caller-workspace 내부 단계가 선택된 프로필의 Responses operation으로 전환해 두 요구를 모두 보존하도록 한다. --- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 4 +- apps/edge/internal/openai/chat_handler.go | 9 ++++- .../edge/internal/openai/hot_path_dispatch.go | 8 +++- .../internal/openai/provider_model_rewrite.go | 40 +++++++++++++++++++ .../openai/provider_model_rewrite_test.go | 38 ++++++++++++++++++ 6 files changed, 96 insertions(+), 5 deletions(-) diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 10aad050..4c1f5c6e 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review Chat 요청은 모두 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화하며 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 결과는 기존 hot-path 공통 stage shape로 복원하며 caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 2156d5db..9e09f84c 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless Responses operation and restores the common hot-path stage shape. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | @@ -371,7 +371,7 @@ sequenceDiagram ## 변경 기록 -- 2026-08-15: Normalized Chat output-token aliases by selected protocol profile across provider-pool and unmarked caller-workspace selector/Work/Review calls. OpenAI Chat uses `max_completion_tokens`, Gemini Chat uses `max_tokens`, target-native values win, and selector upstream HTTP 400 now terminates once as caller `invalid_request_error` instead of retryable 502. Command-mode workspace execution also carries the admitted containment guard in the actual outgoing command. +- 2026-08-15: Normalized Chat output-token aliases by selected protocol profile across provider-pool and unmarked caller-workspace selector/Work/Review calls. OpenAI Chat uses `max_completion_tokens`, Gemini Chat uses `max_tokens`, target-native values win, and unmarked internal stages use the selected OpenAI profile's Responses operation when tools plus effort cannot be represented by Chat. Selector upstream HTTP 400 now terminates once as caller `invalid_request_error` instead of retryable 502. Command-mode workspace execution also carries the admitted containment guard in the actual outgoing command. - 2026-08-14: Split the unmarked selector instruction into frontier-derived `prepare-only` and `pair-write` operations. Non-parent-creating bindings prepare the request job directory first and receive the exact artifact-pair grammar only after the successful receipt; parent-creating bindings receive the pair operation immediately. - 2026-08-14: Added the Edge-owned provider-side selector instruction for unmarked caller-workspace initial/resume turns. Actual Chat/Messages bodies now carry the exact request-local relative paths and accepted PLAN/pending REVIEW grammar without changing caller snapshots or public schemas. - 2026-08-14: Added the restored unmarked caller-workspace light route, including template-validated PLAN/pending REVIEW creation, worker-owned completed REVIEW handoff, reviewer reads/result inspection/repair, reviewer-owned non-empty terminal, and strict separation from marked Node-owned `single_request` presets. diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 256316e6..2b78df6a 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -393,6 +393,13 @@ func (s *Server) handleChatCompletionsProviderPool(w http.ResponseWriter, dc *ch if err != nil { return tunnelReq, err } + if strings.TrimSpace(selectorInstruction) != "" { + prepared, err = prepareHotPathSelectorProviderInstruction(prepared, selectorInstruction) + if err != nil { + return tunnelReq, err + } + return prepareHotPathChatProviderOperation(prepared, selected, chatProviderRequirements(req)) + } prepared, err = prepareProviderChatRequestNormalization(prepared, selected) if err != nil { return tunnelReq, err @@ -401,7 +408,7 @@ func (s *Server) handleChatCompletionsProviderPool(w http.ResponseWriter, dc *ch if err != nil { return tunnelReq, err } - return prepareHotPathSelectorProviderInstruction(prepared, selectorInstruction) + return prepared, nil } // strict-output output policy only applies to normalized dispatch, diff --git a/apps/edge/internal/openai/hot_path_dispatch.go b/apps/edge/internal/openai/hot_path_dispatch.go index 1f313695..6f9ea053 100644 --- a/apps/edge/internal/openai/hot_path_dispatch.go +++ b/apps/edge/internal/openai/hot_path_dispatch.go @@ -1556,7 +1556,13 @@ func (s *Server) prepareHotPathStageTunnel(r *http.Request, snapshot hotPathDisp prepared.BuildBody = func(target string) ([]byte, error) { return hotPathChatStageBody(snapshot, prompt, target) } - return prepareProviderChatRequestNormalization(prepared, selected) + requirements := providerRequestRequirements{ + HasTools: len(snapshot.Tools) > 0, Stream: snapshot.Stream, + } + if effort, ok := snapshot.Stage.Options["reasoning_effort"].(string); ok { + requirements.Effort = strings.TrimSpace(effort) + } + return prepareHotPathChatProviderOperation(prepared, selected, requirements) case config.ProtocolDriverAnthropicMessages: request := r.Clone(r.Context()) if strings.TrimSpace(request.Header.Get(anthropicVersionHeader)) == "" { diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 94d6aa0c..0542a189 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -567,6 +567,46 @@ func prepareProviderChatRequestNormalization(tunnel edgeservice.SubmitProviderTu return tunnel, nil } +// prepareHotPathChatProviderOperation lets an internal caller-workspace stage +// use the closest provider wire without changing the caller-facing Chat +// surface. In particular, OpenAI Chat cannot combine reasoning effort with +// function tools, while the same profile's Responses operation can. The +// internal stage result is already decoded into the common hot-path shape, so +// this operation switch remains private to IOP. +func prepareHotPathChatProviderOperation( + tunnel edgeservice.SubmitProviderTunnelRequest, + selected edgeservice.ProviderPoolCandidate, + requirements providerRequestRequirements, +) (edgeservice.SubmitProviderTunnelRequest, error) { + if selected.ProtocolProfile == nil || tunnel.BuildBody == nil { + return tunnel, errProviderStageMissingBinding + } + prepared, err := singleRequestProviderTunnelPreparer(requirements, tunnel.BuildBody)(tunnel, selected) + if err != nil { + return tunnel, err + } + if prepared.Operation != string(config.OperationChatCompletions) { + return prepared, nil + } + prepared, err = prepareProviderChatRequestNormalization(prepared, selected) + if err != nil { + return tunnel, err + } + return prepareProviderChatToolCallNormalization(prepared, selected) +} + +func chatProviderRequirements(req chatCompletionRequest) providerRequestRequirements { + requirements := providerRequestRequirements{ + HasTools: len(req.Tools) > 0, + Stream: req.Stream, + StructuredOutput: req.ResponseFormat != nil, + } + if req.ReasoningEffort != nil { + requirements.Effort = strings.TrimSpace(*req.ReasoningEffort) + } + return requirements +} + func normalizeChatTokenLimitField(body []byte, targetField string) ([]byte, error) { var limits struct { MaxTokens json.RawMessage `json:"max_tokens"` diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index 6c5fb911..bf430c0f 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -189,6 +189,44 @@ func TestOpenAIChatProviderHTTPNormalizesGenericTokenLimit(t *testing.T) { } } +func TestHotPathOpenAIToolsAndEffortUseResponsesOperation(t *testing.T) { + profile, err := config.ResolveProtocolProfile("openai", "", config.BuiltInProtocolProfileCatalog()) + if err != nil { + t.Fatal(err) + } + tunnel := edgeservice.SubmitProviderTunnelRequest{ + Operation: string(config.OperationChatCompletions), + Path: "/v1/chat/completions", + BuildBody: func(target string) ([]byte, error) { + return []byte(`{"model":"` + target + `","messages":[{"role":"system","content":"inspect"},{"role":"user","content":"work"}],"tools":[{"type":"function","function":{"name":"bash","parameters":{"type":"object"}}}],"max_tokens":32000,"reasoning_effort":"high","stream":true}`), nil + }, + } + prepared, err := prepareHotPathChatProviderOperation(tunnel, edgeservice.ProviderPoolCandidate{ProtocolProfile: &profile}, providerRequestRequirements{ + HasTools: true, Stream: true, Effort: "high", + }) + if err != nil { + t.Fatal(err) + } + if prepared.Operation != string(config.OperationResponses) || prepared.Path != "/v1/responses" { + t.Fatalf("operation=%q path=%q", prepared.Operation, prepared.Path) + } + body, err := prepared.BuildBody("gpt-5.6-terra") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(body, &request); err != nil { + t.Fatal(err) + } + if request["max_output_tokens"] != float64(32000) || request["messages"] != nil || request["max_tokens"] != nil { + t.Fatalf("Responses token/message conversion mismatch: %+v", request) + } + reasoning, _ := request["reasoning"].(map[string]any) + if reasoning["effort"] != "high" || len(anySlice(request["tools"])) != 1 { + t.Fatalf("Responses effort/tools conversion mismatch: %+v", request) + } +} + func TestGeminiChatProviderHTTPToolContinuationRoundTrip(t *testing.T) { var providerRequests []map[string]any provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From e9f043e4179361c22d1d1333b4870ac7d60d448c Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 05:40:45 +0900 Subject: [PATCH 02/37] =?UTF-8?q?fix(openai):=20reasoning=20Responses=20sa?= =?UTF-8?q?mpling=EC=9D=84=20=EC=A0=95=EA=B7=9C=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPT reasoning Responses wire가 generic SDK의 temperature와 top_p 기본값을 거부한다. mapped effort가 활성화된 내부 변환에서는 충돌하는 sampling defaults를 제거한다. --- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 4 ++-- .../internal/openai/provider_model_rewrite_test.go | 5 ++++- apps/edge/internal/openai/provider_normalization.go | 13 ++++++++++++- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 4c1f5c6e..7add50b1 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 결과는 기존 hot-path 공통 stage shape로 복원하며 caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 OpenAI reasoning Responses wire에서는 provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. 결과는 기존 hot-path 공통 stage shape로 복원하며 caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 9e09f84c..b8ecdd02 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless Responses operation and restores the common hot-path stage shape. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless Responses operation, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | @@ -371,7 +371,7 @@ sequenceDiagram ## 변경 기록 -- 2026-08-15: Normalized Chat output-token aliases by selected protocol profile across provider-pool and unmarked caller-workspace selector/Work/Review calls. OpenAI Chat uses `max_completion_tokens`, Gemini Chat uses `max_tokens`, target-native values win, and unmarked internal stages use the selected OpenAI profile's Responses operation when tools plus effort cannot be represented by Chat. Selector upstream HTTP 400 now terminates once as caller `invalid_request_error` instead of retryable 502. Command-mode workspace execution also carries the admitted containment guard in the actual outgoing command. +- 2026-08-15: Normalized Chat output-token aliases by selected protocol profile across provider-pool and unmarked caller-workspace selector/Work/Review calls. OpenAI Chat uses `max_completion_tokens`, Gemini Chat uses `max_tokens`, target-native values win, and unmarked internal stages use the selected OpenAI profile's Responses operation when tools plus effort cannot be represented by Chat. The reasoning Responses conversion omits incompatible generic SDK `temperature`/`top_p` defaults. Selector upstream HTTP 400 now terminates once as caller `invalid_request_error` instead of retryable 502. Command-mode workspace execution also carries the admitted containment guard in the actual outgoing command. - 2026-08-14: Split the unmarked selector instruction into frontier-derived `prepare-only` and `pair-write` operations. Non-parent-creating bindings prepare the request job directory first and receive the exact artifact-pair grammar only after the successful receipt; parent-creating bindings receive the pair operation immediately. - 2026-08-14: Added the Edge-owned provider-side selector instruction for unmarked caller-workspace initial/resume turns. Actual Chat/Messages bodies now carry the exact request-local relative paths and accepted PLAN/pending REVIEW grammar without changing caller snapshots or public schemas. - 2026-08-14: Added the restored unmarked caller-workspace light route, including template-validated PLAN/pending REVIEW creation, worker-owned completed REVIEW handoff, reviewer reads/result inspection/repair, reviewer-owned non-empty terminal, and strict separation from marked Node-owned `single_request` presets. diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index bf430c0f..00c2ea19 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -198,7 +198,7 @@ func TestHotPathOpenAIToolsAndEffortUseResponsesOperation(t *testing.T) { Operation: string(config.OperationChatCompletions), Path: "/v1/chat/completions", BuildBody: func(target string) ([]byte, error) { - return []byte(`{"model":"` + target + `","messages":[{"role":"system","content":"inspect"},{"role":"user","content":"work"}],"tools":[{"type":"function","function":{"name":"bash","parameters":{"type":"object"}}}],"max_tokens":32000,"reasoning_effort":"high","stream":true}`), nil + return []byte(`{"model":"` + target + `","messages":[{"role":"system","content":"inspect"},{"role":"user","content":"work"}],"tools":[{"type":"function","function":{"name":"bash","parameters":{"type":"object"}}}],"max_tokens":32000,"reasoning_effort":"high","temperature":0,"top_p":1,"stream":true}`), nil }, } prepared, err := prepareHotPathChatProviderOperation(tunnel, edgeservice.ProviderPoolCandidate{ProtocolProfile: &profile}, providerRequestRequirements{ @@ -225,6 +225,9 @@ func TestHotPathOpenAIToolsAndEffortUseResponsesOperation(t *testing.T) { if reasoning["effort"] != "high" || len(anySlice(request["tools"])) != 1 { t.Fatalf("Responses effort/tools conversion mismatch: %+v", request) } + if request["temperature"] != nil || request["top_p"] != nil { + t.Fatalf("reasoning Responses request retained incompatible sampling controls: %+v", request) + } } func TestGeminiChatProviderHTTPToolContinuationRoundTrip(t *testing.T) { diff --git a/apps/edge/internal/openai/provider_normalization.go b/apps/edge/internal/openai/provider_normalization.go index 5b412b27..4edd0799 100644 --- a/apps/edge/internal/openai/provider_normalization.go +++ b/apps/edge/internal/openai/provider_normalization.go @@ -194,11 +194,22 @@ func normalizeSingleRequestProviderRequest(chatBody []byte, plan providerOperati func singleRequestChatToResponses(chat map[string]any, plan providerOperationPlan) ([]byte, error) { responses := map[string]any{"model": chat["model"], "stream": false} - for _, key := range []string{"temperature", "top_p", "service_tier"} { + for _, key := range []string{"service_tier"} { if value, exists := chat[key]; exists { responses[key] = value } } + // OpenAI reasoning-capable Responses models reject Chat sampling controls. + // When the profile selected an explicit reasoning effort, the effort is the + // supported generation control and generic SDK temperature/top_p defaults + // must not leak onto the provider wire. + if plan.Effort == "" { + for _, key := range []string{"temperature", "top_p"} { + if value, exists := chat[key]; exists { + responses[key] = value + } + } + } for _, key := range []string{"max_completion_tokens", "max_tokens"} { if value, exists := chat[key]; exists { responses["max_output_tokens"] = value From dbf278e85465dbc9c5cc87194f002452e8fa51cc Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 05:45:04 +0900 Subject: [PATCH 03/37] =?UTF-8?q?fix(openai):=20preset=20selector=20effort?= =?UTF-8?q?=EB=A5=BC=20=EA=B2=B0=EC=86=8D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit caller body에 effort가 없어도 preset selector가 소유한 reasoning effort를 operation 요구사항에 포함해야 올바른 Responses wire를 선택할 수 있다. --- apps/edge/internal/openai/chat_handler.go | 6 +++++- .../internal/openai/provider_model_rewrite_test.go | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index 2b78df6a..e9832ddc 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -398,7 +398,11 @@ func (s *Server) handleChatCompletionsProviderPool(w http.ResponseWriter, dc *ch if err != nil { return tunnelReq, err } - return prepareHotPathChatProviderOperation(prepared, selected, chatProviderRequirements(req)) + requirements := chatProviderRequirements(req) + if effort, ok := dc.route.Preset.Selector.Options["reasoning_effort"].(string); ok && strings.TrimSpace(effort) != "" { + requirements.Effort = strings.TrimSpace(effort) + } + return prepareHotPathChatProviderOperation(prepared, selected, requirements) } prepared, err = prepareProviderChatRequestNormalization(prepared, selected) if err != nil { diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index 00c2ea19..82f7e366 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -230,6 +230,20 @@ func TestHotPathOpenAIToolsAndEffortUseResponsesOperation(t *testing.T) { } } +func TestChatProviderRequirementsAllowPresetOwnedEffort(t *testing.T) { + requirements := chatProviderRequirements(chatCompletionRequest{Tools: []any{map[string]any{"type": "function"}}}) + if requirements.Effort != "" || !requirements.HasTools { + t.Fatalf("caller requirements=%+v", requirements) + } + presetOptions := map[string]any{"reasoning_effort": "high"} + if effort, ok := presetOptions["reasoning_effort"].(string); ok { + requirements.Effort = strings.TrimSpace(effort) + } + if requirements.Effort != "high" || !requirements.HasTools { + t.Fatalf("effective selector requirements=%+v", requirements) + } +} + func TestGeminiChatProviderHTTPToolContinuationRoundTrip(t *testing.T) { var providerRequests []map[string]any provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 1ad03e0bd034b59ad9e4d7a5ec94cba4bc521e0d Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 05:55:06 +0900 Subject: [PATCH 04/37] =?UTF-8?q?fix(openai):=20selector=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=EC=98=88=EC=82=B0=EC=9D=84=20=EC=A0=9C=ED=95=9C?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bounded PLAN/REVIEW seed 생성에 caller SDK의 32000-token 기본값을 그대로 쓰면 high-effort selector가 provider timeout까지 점유한다. 내부 selector에만 4096-token 상한을 적용한다. --- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 2 +- apps/edge/internal/openai/chat_handler.go | 4 ++ .../internal/openai/provider_model_rewrite.go | 42 ++++++++++++++++++- .../openai/provider_model_rewrite_test.go | 23 ++++++++++ 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 7add50b1..310d3256 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 OpenAI reasoning Responses wire에서는 provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. 결과는 기존 hot-path 공통 stage shape로 복원하며 caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 OpenAI reasoning Responses wire에서는 provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. 결과는 기존 hot-path 공통 stage shape로 복원하며 caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index b8ecdd02..b18df0c1 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless Responses operation, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless Responses operation, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index e9832ddc..f4b72050 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -398,6 +398,10 @@ func (s *Server) handleChatCompletionsProviderPool(w http.ResponseWriter, dc *ch if err != nil { return tunnelReq, err } + prepared, err = prepareHotPathSelectorOutputLimit(prepared) + if err != nil { + return tunnelReq, err + } requirements := chatProviderRequirements(req) if effort, ok := dc.route.Preset.Selector.Options["reasoning_effort"].(string); ok && strings.TrimSpace(effort) != "" { requirements.Effort = strings.TrimSpace(effort) diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 0542a189..84ae6387 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -11,7 +11,10 @@ import ( "iop/packages/go/config" ) -const maxHotPathSelectorProviderInstructionBytes = 4096 +const ( + maxHotPathSelectorProviderInstructionBytes = 4096 + maxHotPathSelectorOutputTokens = 4096 +) func buildHotPathSelectorProviderInstruction(requestID string, state selectorInstructionState) (string, error) { if !validLogicalRequestID(requestID) { @@ -104,6 +107,43 @@ func prepareHotPathSelectorProviderInstruction(tunnel edgeservice.SubmitProvider return tunnel, nil } +func prepareHotPathSelectorOutputLimit(tunnel edgeservice.SubmitProviderTunnelRequest) (edgeservice.SubmitProviderTunnelRequest, error) { + rewrite := func(body []byte) ([]byte, error) { + limitJSON, err := json.Marshal(maxHotPathSelectorOutputTokens) + if err != nil { + return nil, err + } + plan, err := planTopLevelJSONPatches(body, []topLevelJSONPatch{ + {name: "max_tokens", value: limitJSON}, + {name: "max_completion_tokens"}, + }) + if err != nil { + return nil, err + } + return plan.apply(), nil + } + if tunnel.BuildBody != nil { + build := tunnel.BuildBody + tunnel.BuildBody = func(target string) ([]byte, error) { + body, err := build(target) + if err != nil { + return nil, err + } + return rewrite(body) + } + return tunnel, nil + } + if len(tunnel.Body) == 0 { + return tunnel, fmt.Errorf("selector provider body is unavailable") + } + body, err := rewrite(tunnel.Body) + if err != nil { + return tunnel, err + } + tunnel.Body = body + return tunnel, nil +} + func injectHotPathChatSelectorInstruction(body []byte, instruction string) ([]byte, error) { var envelope struct { Messages []json.RawMessage `json:"messages"` diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index 82f7e366..1fcb91bb 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -244,6 +244,29 @@ func TestChatProviderRequirementsAllowPresetOwnedEffort(t *testing.T) { } } +func TestHotPathSelectorOutputLimitIsBounded(t *testing.T) { + tunnel := edgeservice.SubmitProviderTunnelRequest{ + BuildBody: func(string) ([]byte, error) { + return []byte(`{"model":"selector","max_completion_tokens":32000,"future":true}`), nil + }, + } + prepared, err := prepareHotPathSelectorOutputLimit(tunnel) + if err != nil { + t.Fatal(err) + } + body, err := prepared.BuildBody("selector") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(body, &request); err != nil { + t.Fatal(err) + } + if request["max_tokens"] != float64(maxHotPathSelectorOutputTokens) || request["max_completion_tokens"] != nil || request["future"] != true { + t.Fatalf("bounded selector request=%+v", request) + } +} + func TestGeminiChatProviderHTTPToolContinuationRoundTrip(t *testing.T) { var providerRequests []map[string]any provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 417c54c0160bc86fc9e5ce29bc6ca10523e8c027 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 05:59:21 +0900 Subject: [PATCH 05/37] =?UTF-8?q?fix(openai):=20=EB=82=B4=EB=B6=80=20Respo?= =?UTF-8?q?nses=20stream=20=EA=B2=B0=EC=86=8D=EC=9D=84=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buffered Chat-to-Responses body와 tunnel metadata가 서로 다른 stream 값을 가지면 provider 종료 뒤에도 selector terminal이 수렴하지 않는다. 내부 wire를 일관된 non-stream으로 고정한다. --- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 2 +- apps/edge/internal/openai/provider_model_rewrite_test.go | 3 +++ apps/edge/internal/openai/provider_normalization.go | 5 +++++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 310d3256..77f26cf9 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 OpenAI reasoning Responses wire에서는 provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. 결과는 기존 hot-path 공통 stage shape로 복원하며 caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. 결과는 기존 hot-path 공통 stage shape로 복원하며 caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index b18df0c1..1fd06736 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless Responses operation, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index 1fcb91bb..8f700b29 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -210,6 +210,9 @@ func TestHotPathOpenAIToolsAndEffortUseResponsesOperation(t *testing.T) { if prepared.Operation != string(config.OperationResponses) || prepared.Path != "/v1/responses" { t.Fatalf("operation=%q path=%q", prepared.Operation, prepared.Path) } + if prepared.Stream { + t.Fatal("buffered internal Responses operation retained streaming tunnel metadata") + } body, err := prepared.BuildBody("gpt-5.6-terra") if err != nil { t.Fatal(err) diff --git a/apps/edge/internal/openai/provider_normalization.go b/apps/edge/internal/openai/provider_normalization.go index 4edd0799..92c3d246 100644 --- a/apps/edge/internal/openai/provider_normalization.go +++ b/apps/edge/internal/openai/provider_normalization.go @@ -161,6 +161,11 @@ func singleRequestProviderTunnelPreparer(requirements providerRequestRequirement tunnel.Path = "/v1/chat/completions" case config.OperationResponses: tunnel.Path = "/v1/responses" + // The internal Chat-to-Responses bridge intentionally buffers one + // canonical stage result. Keep transport metadata aligned with the + // generated body's stream=false contract even when caller ingress is + // streaming. + tunnel.Stream = false default: return tunnel, errProviderStageMissingBinding } From 4e441526b4160128f52e0bb9e92e1e1a8fdb2bda Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 06:07:16 +0900 Subject: [PATCH 06/37] =?UTF-8?q?fix(openai):=20Responses=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EB=A5=BC=20operation=EC=9C=BC=EB=A1=9C=20=ED=95=B4?= =?UTF-8?q?=EC=84=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI Chat ingress의 내부 wire가 Responses로 전환돼도 decoder가 caller protocol만 보고 Chat shape를 요구해 502를 만들었다. selected operation을 우선해 공통 stage로 복원한다. --- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 2 +- apps/edge/internal/openai/hot_path_dispatch.go | 14 +++++++++++--- .../internal/openai/provider_model_rewrite_test.go | 11 +++++++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 77f26cf9..3764b942 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. 결과는 기존 hot-path 공통 stage shape로 복원하며 caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 1fd06736..4852d891 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | diff --git a/apps/edge/internal/openai/hot_path_dispatch.go b/apps/edge/internal/openai/hot_path_dispatch.go index 6f9ea053..9b935de5 100644 --- a/apps/edge/internal/openai/hot_path_dispatch.go +++ b/apps/edge/internal/openai/hot_path_dispatch.go @@ -531,15 +531,23 @@ func unixSeconds(timestamp int64) int64 { func decodePresetTunnelBody(body []byte, contentType, protocol, operation, driver string) (normalizedStageOutput, error) { streaming := strings.Contains(strings.ToLower(contentType), "text/event-stream") || bytes.Contains(body, []byte("data:")) + if operation == string(config.OperationResponses) { + stage, err := decodeResponsesPresetBody(body, streaming) + if err != nil { + return normalizedStageOutput{}, err + } + if protocol == "anthropic" { + stage.Usage = openAIUsageToAnthropic(stage.Usage) + stage.TerminalReason = openAIReasonToAnthropic(stage.TerminalReason) + } + return stage, nil + } if protocol == "anthropic" && (operation == string(config.OperationMessages) || (operation == "" && driver == string(config.ProtocolDriverAnthropicMessages))) { if streaming { return decodeAnthropicPresetSSE(body) } return decodeAnthropicPresetJSON(body) } - if protocol == "anthropic" && operation == string(config.OperationResponses) { - return decodeResponsesPresetBody(body, streaming) - } var stage normalizedStageOutput var err error if streaming { diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index 8f700b29..3138009d 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -270,6 +270,17 @@ func TestHotPathSelectorOutputLimitIsBounded(t *testing.T) { } } +func TestOpenAIHotPathResponsesOperationUsesResponsesDecoder(t *testing.T) { + body := []byte(`{"id":"resp-selector","model":"gpt-5.6-terra","status":"completed","output":[{"type":"function_call","id":"fc-1","call_id":"call-1","name":"bash","arguments":"{\"command\":\"true\"}"}],"usage":{"input_tokens":10,"output_tokens":3}}`) + stage, err := decodePresetTunnelBody(body, "application/json", "openai", string(config.OperationResponses), string(config.ProtocolDriverOpenAIChat)) + if err != nil { + t.Fatal(err) + } + if stage.ResponseID != "resp-selector" || len(stage.ToolCalls) != 1 || stage.ToolCalls[0].Name != "bash" { + t.Fatalf("Responses stage=%+v", stage) + } +} + func TestGeminiChatProviderHTTPToolContinuationRoundTrip(t *testing.T) { var providerRequests []map[string]any provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 0bf0464ee35926f55ad0d744e5aa1e65d6ce9a00 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 06:14:10 +0900 Subject: [PATCH 07/37] =?UTF-8?q?fix(openai):=20selector=20canonical=20too?= =?UTF-8?q?l=20schema=EB=A5=BC=20=EC=A0=9C=EA=B3=B5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit caller command tool schema를 selector에게 그대로 보여주면서 path/content 호출을 요구해 reserved artifact가 거부됐다. Provider에는 canonical schema를 주고 caller projection에서 실제 command로 encode한다. --- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 2 +- apps/edge/internal/openai/chat_handler.go | 4 + .../internal/openai/provider_model_rewrite.go | 81 +++++++++++++++++++ .../openai/provider_model_rewrite_test.go | 33 ++++++++ 5 files changed, 120 insertions(+), 2 deletions(-) diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 3764b942..b20bafcf 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 현재 frontier의 canonical `prepare(path)` 또는 `write(path,content)` schema만 보여주고, validated result를 admitted caller tool의 실제 schema(예: `bash(command)`)로 encode한 뒤 caller에게 투영한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 4852d891..c7b11f7e 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only the current canonical `prepare(path)` or `write(path,content)` schema; Edge validates and encodes it into the admitted caller tool schema such as `bash(command)` before projection. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | diff --git a/apps/edge/internal/openai/chat_handler.go b/apps/edge/internal/openai/chat_handler.go index f4b72050..78ba1ca2 100644 --- a/apps/edge/internal/openai/chat_handler.go +++ b/apps/edge/internal/openai/chat_handler.go @@ -402,6 +402,10 @@ func (s *Server) handleChatCompletionsProviderPool(w http.ResponseWriter, dc *ch if err != nil { return tunnelReq, err } + prepared, err = prepareHotPathSelectorCanonicalTools(prepared, selectorInstruction, dc.route.Preset) + if err != nil { + return tunnelReq, err + } requirements := chatProviderRequirements(req) if effort, ok := dc.route.Preset.Selector.Options["reasoning_effort"].(string); ok && strings.TrimSpace(effort) != "" { requirements.Effort = strings.TrimSpace(effort) diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 84ae6387..5d6903f0 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "sort" "strings" "sync" @@ -144,6 +145,86 @@ func prepareHotPathSelectorOutputLimit(tunnel edgeservice.SubmitProviderTunnelRe return tunnel, nil } +func prepareHotPathSelectorCanonicalTools(tunnel edgeservice.SubmitProviderTunnelRequest, instruction string, preset config.ExecutionPreset) (edgeservice.SubmitProviderTunnelRequest, error) { + operation := "" + switch { + case strings.Contains(instruction, "Operation: prepare-only"): + operation = "prepare" + case strings.Contains(instruction, "Operation: pair-write"): + operation = "write" + default: + return tunnel, fmt.Errorf("selector canonical tool operation is unavailable") + } + toolNames := make(map[string]struct{}) + for _, alternative := range preset.WorkspaceTools { + configured, ok := alternative.Operations[operation] + if ok && strings.TrimSpace(configured.ToolName) != "" { + toolNames[strings.TrimSpace(configured.ToolName)] = struct{}{} + } + } + if len(toolNames) == 0 { + return tunnel, fmt.Errorf("selector canonical tool binding is unavailable") + } + rewrite := func(body []byte) ([]byte, error) { + var root map[string]any + if err := json.Unmarshal(body, &root); err != nil { + return nil, fmt.Errorf("decode selector provider tools: %w", err) + } + actual := make(map[string]map[string]any) + for _, raw := range anySlice(root["tools"]) { + tool, _ := raw.(map[string]any) + function, _ := tool["function"].(map[string]any) + name, _ := function["name"].(string) + if _, ok := toolNames[strings.TrimSpace(name)]; ok { + actual[strings.TrimSpace(name)] = function + } + } + names := make([]string, 0, len(actual)) + for name := range actual { + names = append(names, name) + } + sort.Strings(names) + canonical := make([]any, 0, len(actual)) + for _, name := range names { + function := actual[name] + properties := map[string]any{ + "path": map[string]any{"type": "string", "description": "IOP-issued relative workspace path"}, + } + required := []any{"path"} + if operation == "write" { + properties["content"] = map[string]any{"type": "string", "description": "Complete file content"} + required = append(required, "content") + } + description, _ := function["description"].(string) + canonical = append(canonical, map[string]any{ + "type": "function", + "function": map[string]any{ + "name": name, "description": strings.TrimSpace(description + " IOP canonical " + operation + " operation."), + "parameters": map[string]any{"type": "object", "properties": properties, "required": required, "additionalProperties": false}, + }, + }) + } + if len(canonical) == 0 { + return nil, fmt.Errorf("selector canonical tool is absent from caller tools") + } + root["tools"] = canonical + root["tool_choice"] = "auto" + return json.Marshal(root) + } + if tunnel.BuildBody == nil { + return tunnel, fmt.Errorf("selector provider body builder is unavailable") + } + build := tunnel.BuildBody + tunnel.BuildBody = func(target string) ([]byte, error) { + body, err := build(target) + if err != nil { + return nil, err + } + return rewrite(body) + } + return tunnel, nil +} + func injectHotPathChatSelectorInstruction(body []byte, instruction string) ([]byte, error) { var envelope struct { Messages []json.RawMessage `json:"messages"` diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index 3138009d..0820fd18 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -281,6 +281,39 @@ func TestOpenAIHotPathResponsesOperationUsesResponsesDecoder(t *testing.T) { } } +func TestHotPathSelectorCanonicalWriteToolReplacesCallerCommandSchema(t *testing.T) { + preset := config.ExecutionPreset{WorkspaceTools: []config.ExecutionWorkspaceToolAlternative{{ + Operations: map[string]config.ExecutionWorkspaceOperation{ + "write": {ToolName: "bash"}, + }, + }}} + tunnel := edgeservice.SubmitProviderTunnelRequest{BuildBody: func(string) ([]byte, error) { + return []byte(`{"model":"selector","tools":[{"type":"function","function":{"name":"bash","description":"shell","parameters":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}},{"type":"function","function":{"name":"webfetch","parameters":{"type":"object"}}}]}`), nil + }} + prepared, err := prepareHotPathSelectorCanonicalTools(tunnel, "Operation: pair-write", preset) + if err != nil { + t.Fatal(err) + } + body, err := prepared.BuildBody("selector") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(body, &request); err != nil { + t.Fatal(err) + } + tools := anySlice(request["tools"]) + if len(tools) != 1 { + t.Fatalf("canonical tools=%+v", tools) + } + function := tools[0].(map[string]any)["function"].(map[string]any) + parameters := function["parameters"].(map[string]any) + properties := parameters["properties"].(map[string]any) + if function["name"] != "bash" || properties["path"] == nil || properties["content"] == nil || properties["command"] != nil { + t.Fatalf("canonical write function=%+v", function) + } +} + func TestGeminiChatProviderHTTPToolContinuationRoundTrip(t *testing.T) { var providerRequests []map[string]any provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 619a05e0d4737a17fdb6606d2f698dbfed6b61e2 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 06:17:04 +0900 Subject: [PATCH 08/37] =?UTF-8?q?fix(openai):=20selector=20pair=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=9D=84=20=EB=B3=91=EB=A0=AC=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pair-write 계약이 두 canonical write를 한 응답에 요구하므로 provider wire에서도 해당 단계에만 parallel tool calls를 명시해야 partial pair를 피할 수 있다. --- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 2 +- apps/edge/internal/openai/provider_model_rewrite.go | 5 +++++ apps/edge/internal/openai/provider_model_rewrite_test.go | 3 +++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index b20bafcf..598e82f6 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 현재 frontier의 canonical `prepare(path)` 또는 `write(path,content)` schema만 보여주고, validated result를 admitted caller tool의 실제 schema(예: `bash(command)`)로 encode한 뒤 caller에게 투영한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 현재 frontier의 canonical `prepare(path)` 또는 `write(path,content)` schema만 보여주고, pair-write에는 `parallel_tool_calls=true`를 명시한다. Validated result는 admitted caller tool의 실제 schema(예: `bash(command)`)로 encode한 뒤 caller에게 투영한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index c7b11f7e..160d8040 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only the current canonical `prepare(path)` or `write(path,content)` schema; Edge validates and encodes it into the admitted caller tool schema such as `bash(command)` before projection. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only the current canonical `prepare(path)` or `write(path,content)` schema, with parallel tool calls enabled only for pair-write; Edge validates and encodes it into the admitted caller tool schema such as `bash(command)` before projection. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 5d6903f0..67061a37 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -209,6 +209,11 @@ func prepareHotPathSelectorCanonicalTools(tunnel edgeservice.SubmitProviderTunne } root["tools"] = canonical root["tool_choice"] = "auto" + if operation == "write" { + root["parallel_tool_calls"] = true + } else { + delete(root, "parallel_tool_calls") + } return json.Marshal(root) } if tunnel.BuildBody == nil { diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index 0820fd18..c70cfdc6 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -312,6 +312,9 @@ func TestHotPathSelectorCanonicalWriteToolReplacesCallerCommandSchema(t *testing if function["name"] != "bash" || properties["path"] == nil || properties["content"] == nil || properties["command"] != nil { t.Fatalf("canonical write function=%+v", function) } + if request["parallel_tool_calls"] != true { + t.Fatalf("pair-write did not enable parallel tool calls: %+v", request) + } } func TestGeminiChatProviderHTTPToolContinuationRoundTrip(t *testing.T) { From 70176afda68dd0595c0e1f4fefb0efb8fca50371 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 06:21:57 +0900 Subject: [PATCH 09/37] fix(edge): classify artifact lineage mismatches --- apps/edge/internal/openai/artifact_pair.go | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/edge/internal/openai/artifact_pair.go b/apps/edge/internal/openai/artifact_pair.go index be93a13b..b8f545fe 100644 --- a/apps/edge/internal/openai/artifact_pair.go +++ b/apps/edge/internal/openai/artifact_pair.go @@ -672,8 +672,11 @@ func (s *artifactFrontierStore) matchRecordLocked( if record.principalRef != principalRef { return nil, true, errLogicalRequestPrincipal } - if record.protocol != protocol || record.lineage != lineage.Prefix { - return nil, true, errLogicalRequestLineage + if record.protocol != protocol { + return nil, true, fmt.Errorf("%w: protocol changed", errLogicalRequestLineage) + } + if record.lineage != lineage.Prefix { + return nil, true, describeArtifactPrefixMismatch(record.lineage, lineage.Prefix) } return record, true, nil } @@ -685,6 +688,21 @@ func (s *artifactFrontierStore) matchRecordLocked( return nil, true, errLogicalRequestLineage } +func describeArtifactPrefixMismatch(want, got logicalRequestLineage) error { + switch { + case want.Endpoint != got.Endpoint: + return fmt.Errorf("%w: endpoint changed", errLogicalRequestLineage) + case want.HistoryDigest != got.HistoryDigest && want.ToolsetDigest != got.ToolsetDigest: + return fmt.Errorf("%w: request history and toolset changed", errLogicalRequestLineage) + case want.HistoryDigest != got.HistoryDigest: + return fmt.Errorf("%w: request history changed", errLogicalRequestLineage) + case want.ToolsetDigest != got.ToolsetDigest: + return fmt.Errorf("%w: toolset changed", errLogicalRequestLineage) + default: + return errLogicalRequestLineage + } +} + func artifactIDsIntersect(record *artifactFrontierRecord, ids []string) bool { for _, id := range ids { if record.pending[id] != nil { From 82bfcd432a25f9ccd7db499e6788582f50cc57f4 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 06:30:47 +0900 Subject: [PATCH 10/37] fix(openai): atomically author selector artifact pair --- .../outer/anthropic-compatible-api.md | 5 +- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 3 +- apps/edge/internal/openai/artifact_pair.go | 54 +++++++++++++++++++ .../internal/openai/artifact_pair_test.go | 23 ++++++++ .../internal/openai/hot_path_direct_test.go | 2 +- .../edge/internal/openai/hot_path_dispatch.go | 6 +++ .../internal/openai/provider_model_rewrite.go | 38 +++++++++---- .../openai/provider_model_rewrite_test.go | 6 +-- 9 files changed, 121 insertions(+), 18 deletions(-) diff --git a/agent-contract/outer/anthropic-compatible-api.md b/agent-contract/outer/anthropic-compatible-api.md index de48c1e0..4df15aa6 100644 --- a/agent-contract/outer/anthropic-compatible-api.md +++ b/agent-contract/outer/anthropic-compatible-api.md @@ -108,8 +108,9 @@ write operation cannot create parents receives a `prepare-only` initial instruct the provider must emit exactly one admitted prepare call for `.iop/job/` and must not write PLAN or REVIEW. After the successful prepare receipt, the resumed selector receives `pair-write` and must emit exactly -the two admitted writes for PLAN and REVIEW with the accepted default PLAN grammar -and deterministic pending REVIEW grammar. A parent-creating write binding receives +one atomic artifact-pair call containing the complete PLAN and REVIEW content with +the accepted default PLAN grammar and deterministic pending REVIEW grammar. Edge +expands that decision into two independently receipted admitted caller writes. A parent-creating write binding receives `pair-write` immediately on its initial selector turn. In Chat Completions this is the final leading `system` message; in native Messages it is the final top-level `system` text block. The instruction is not caller input, does not alter the retained diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 598e82f6..c4e476c7 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 현재 frontier의 canonical `prepare(path)` 또는 `write(path,content)` schema만 보여주고, pair-write에는 `parallel_tool_calls=true`를 명시한다. Validated result는 admitted caller tool의 실제 schema(예: `bash(command)`)로 encode한 뒤 caller에게 투영한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 160d8040..6248f397 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for exactly the PLAN/REVIEW writes and accepted grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only the current canonical `prepare(path)` or `write(path,content)` schema, with parallel tool calls enabled only for pair-write; Edge validates and encodes it into the admitted caller tool schema such as `bash(command)` before projection. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for the accepted artifact grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only canonical `prepare(path)` or one atomic `iop_write_artifact_pair(plan_content,review_content)` schema. Edge validates and expands the pair into two independently receipted writes encoded in the admitted caller tool schema such as `bash(command)`; the selector is not required to produce parallel tool calls. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | @@ -373,6 +373,7 @@ sequenceDiagram - 2026-08-15: Normalized Chat output-token aliases by selected protocol profile across provider-pool and unmarked caller-workspace selector/Work/Review calls. OpenAI Chat uses `max_completion_tokens`, Gemini Chat uses `max_tokens`, target-native values win, and unmarked internal stages use the selected OpenAI profile's Responses operation when tools plus effort cannot be represented by Chat. The reasoning Responses conversion omits incompatible generic SDK `temperature`/`top_p` defaults. Selector upstream HTTP 400 now terminates once as caller `invalid_request_error` instead of retryable 502. Command-mode workspace execution also carries the admitted containment guard in the actual outgoing command. - 2026-08-14: Split the unmarked selector instruction into frontier-derived `prepare-only` and `pair-write` operations. Non-parent-creating bindings prepare the request job directory first and receive the exact artifact-pair grammar only after the successful receipt; parent-creating bindings receive the pair operation immediately. +- 2026-08-15: Replaced probabilistic parallel PLAN/REVIEW selector calls with one canonical atomic artifact-pair call that Edge deterministically expands into the same two admitted caller writes. - 2026-08-14: Added the Edge-owned provider-side selector instruction for unmarked caller-workspace initial/resume turns. Actual Chat/Messages bodies now carry the exact request-local relative paths and accepted PLAN/pending REVIEW grammar without changing caller snapshots or public schemas. - 2026-08-14: Added the restored unmarked caller-workspace light route, including template-validated PLAN/pending REVIEW creation, worker-owned completed REVIEW handoff, reviewer reads/result inspection/repair, reviewer-owned non-empty terminal, and strict separation from marked Node-owned `single_request` presets. - 2026-08-14: Added bounded read/list-first workspace analysis inside the marked Plan stage. Successful inspection is required before structured Plan completion, while mutation tools, a separate analysis artifact, and caller-visible schema changes remain absent. diff --git a/apps/edge/internal/openai/artifact_pair.go b/apps/edge/internal/openai/artifact_pair.go index b8f545fe..a519250b 100644 --- a/apps/edge/internal/openai/artifact_pair.go +++ b/apps/edge/internal/openai/artifact_pair.go @@ -210,6 +210,60 @@ func (s *artifactFrontierStore) pairRequired(requestID, ownerEdgeID string) bool return record != nil && record.ownerEdgeID == ownerEdgeID && record.phase == artifactPhasePairReady } +// expandSelectorPair turns the selector-only atomic pair tool into the two +// caller-visible workspace writes. The provider never needs to coordinate two +// parallel calls, while the caller still executes and receipts each file +// independently through its admitted workspace tool contract. +func (s *artifactFrontierStore) expandSelectorPair( + requestID, ownerEdgeID string, + output normalizedStageOutput, +) (normalizedStageOutput, error) { + if s == nil || requestID == "" { + return output, nil + } + s.mu.Lock() + defer s.mu.Unlock() + record := s.records[requestID] + if record == nil || record.ownerEdgeID != ownerEdgeID || + (record.phase != artifactPhasePairReady && (record.phase != artifactPhasePinned || !record.binding.createsParents())) { + return output, nil + } + if len(output.ToolCalls) != 1 || output.ToolCalls[0].Name != hotPathArtifactPairToolName { + return output, nil + } + write := record.binding.operation(opKindWrite) + if write == nil || strings.TrimSpace(write.toolName) == "" { + return normalizedStageOutput{}, fmt.Errorf("artifact pair write binding is unavailable") + } + call := output.ToolCalls[0] + planContent, planOK := call.Arguments["plan_content"].(string) + reviewContent, reviewOK := call.Arguments["review_content"].(string) + if !planOK || strings.TrimSpace(planContent) == "" || !reviewOK || strings.TrimSpace(reviewContent) == "" { + return normalizedStageOutput{}, fmt.Errorf("artifact pair tool requires complete plan_content and review_content") + } + providerID := strings.TrimSpace(call.ProviderCallID) + if providerID == "" { + providerID = strings.TrimSpace(call.ID) + } + if !validLogicalRequestID(providerID) { + return normalizedStageOutput{}, fmt.Errorf("artifact pair provider tool id is invalid") + } + paths := newReservedPaths(requestID) + output.ToolCalls = []normalizedToolCall{ + {ID: pairProviderCallID(providerID, "plan"), ProviderCallID: pairProviderCallID(providerID, "plan"), Name: write.toolName, Arguments: map[string]any{"path": paths.PlanPath, "content": planContent}}, + {ID: pairProviderCallID(providerID, "review"), ProviderCallID: pairProviderCallID(providerID, "review"), Name: write.toolName, Arguments: map[string]any{"path": paths.ReviewPath, "content": reviewContent}}, + } + return output, nil +} + +func pairProviderCallID(base, role string) string { + suffix := "_" + role + if len(base)+len(suffix) > 256 { + base = base[:256-len(suffix)] + } + return base + suffix +} + func (s *artifactFrontierStore) issue( turn *hotPathTurn, output normalizedStageOutput, diff --git a/apps/edge/internal/openai/artifact_pair_test.go b/apps/edge/internal/openai/artifact_pair_test.go index 1f03badb..554daace 100644 --- a/apps/edge/internal/openai/artifact_pair_test.go +++ b/apps/edge/internal/openai/artifact_pair_test.go @@ -320,6 +320,29 @@ func newArtifactPairFixture(t *testing.T, endpoint string, createsParents bool) } } +func TestArtifactSelectorAtomicPairExpandsToCallerWrites(t *testing.T) { + fixture := newArtifactPairFixture(t, "openai", true) + output, err := fixture.server.artifactFrontiers.expandSelectorPair(fixture.requestID, fixture.ownerEdgeID, normalizedStageOutput{ + ToolCalls: []normalizedToolCall{{ + ID: "provider_pair", Name: hotPathArtifactPairToolName, + Arguments: map[string]any{"plan_content": testLightweightPlan(t), "review_content": testPendingReview(t)}, + }}, + }) + if err != nil { + t.Fatal(err) + } + if len(output.ToolCalls) != 2 { + t.Fatalf("expanded calls=%+v", output.ToolCalls) + } + if output.ToolCalls[0].Name != "workspace" || output.ToolCalls[0].Arguments["path"] != fixture.paths.PlanPath || + output.ToolCalls[1].Name != "workspace" || output.ToolCalls[1].Arguments["path"] != fixture.paths.ReviewPath { + t.Fatalf("expanded caller writes=%+v", output.ToolCalls) + } + if output.ToolCalls[0].ProviderCallID == output.ToolCalls[1].ProviderCallID { + t.Fatalf("expanded provider ids must be distinct: %+v", output.ToolCalls) + } +} + func (f *artifactPairFixture) issuePrepare() []string { f.t.Helper() ids, err := f.issue([]normalizedToolCall{{ diff --git a/apps/edge/internal/openai/hot_path_direct_test.go b/apps/edge/internal/openai/hot_path_direct_test.go index 920f7971..5b310706 100644 --- a/apps/edge/internal/openai/hot_path_direct_test.go +++ b/apps/edge/internal/openai/hot_path_direct_test.go @@ -461,7 +461,7 @@ func scriptedSelectorDirective(providerBody []byte, operation string) (string, s return requestID, state, nil } required := []string{ - "Return exactly two admitted write tool calls", + "Return exactly one iop_write_artifact_pair tool call", "PLAN path: " + paths.PlanPath, "REVIEW path: " + paths.ReviewPath, "The default PLAN grammar accepts 2-6 consecutive steps and 1-3 verification bullets.", diff --git a/apps/edge/internal/openai/hot_path_dispatch.go b/apps/edge/internal/openai/hot_path_dispatch.go index 9b935de5..352dcc22 100644 --- a/apps/edge/internal/openai/hot_path_dispatch.go +++ b/apps/edge/internal/openai/hot_path_dispatch.go @@ -1163,6 +1163,12 @@ func (s *Server) dispatchPresetTurn( preset = found } } + output, err := s.artifactFrontiers.expandSelectorPair(requestID, ownerEdgeID, output) + if err != nil { + s.terminalPresetRequest(requestID, ownerEdgeID) + writeHotPathPresetDispatchError(w, r, protocol, http.StatusBadRequest, "invalid_request_error", err.Error()) + return err + } decision, err := classifyHotPathOutput(preset, issued, output, gate) if err != nil { if initialAdmission { diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 67061a37..5a2f2da8 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -15,6 +15,7 @@ import ( const ( maxHotPathSelectorProviderInstructionBytes = 4096 maxHotPathSelectorOutputTokens = 4096 + hotPathArtifactPairToolName = "iop_write_artifact_pair" ) func buildHotPathSelectorProviderInstruction(requestID string, state selectorInstructionState) (string, error) { @@ -33,7 +34,7 @@ Do not write PLAN or REVIEW in this turn. Do not mention or infer an absolute wo case selectorInstructionPairWrite: instruction = fmt.Sprintf(`IOP caller-workspace selector instruction. Operation: pair-write -Return exactly two admitted write tool calls for the request-local artifact pair and no other tool call. Do not mention or infer an absolute workspace path. +Return exactly one iop_write_artifact_pair tool call containing both complete artifact contents and no other tool call. Do not mention or infer an absolute workspace path. PLAN path: %s REVIEW path: %s The default PLAN grammar accepts 2-6 consecutive steps and 1-3 verification bullets. For this deterministic seed, use exactly the following two-step, one-verification form and replace only angle-bracketed text: @@ -179,6 +180,30 @@ func prepareHotPathSelectorCanonicalTools(tunnel edgeservice.SubmitProviderTunne actual[strings.TrimSpace(name)] = function } } + if len(actual) == 0 { + return nil, fmt.Errorf("selector canonical tool is absent from caller tools") + } + if operation == "write" { + root["tools"] = []any{map[string]any{ + "type": "function", + "function": map[string]any{ + "name": hotPathArtifactPairToolName, + "description": "Author the complete IOP Plan and pending Review artifact pair in one atomic selector decision.", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "plan_content": map[string]any{"type": "string", "description": "Complete Plan markdown"}, + "review_content": map[string]any{"type": "string", "description": "Complete pending Review markdown"}, + }, + "required": []any{"plan_content", "review_content"}, + "additionalProperties": false, + }, + }, + }} + root["tool_choice"] = "required" + delete(root, "parallel_tool_calls") + return json.Marshal(root) + } names := make([]string, 0, len(actual)) for name := range actual { names = append(names, name) @@ -204,16 +229,9 @@ func prepareHotPathSelectorCanonicalTools(tunnel edgeservice.SubmitProviderTunne }, }) } - if len(canonical) == 0 { - return nil, fmt.Errorf("selector canonical tool is absent from caller tools") - } root["tools"] = canonical - root["tool_choice"] = "auto" - if operation == "write" { - root["parallel_tool_calls"] = true - } else { - delete(root, "parallel_tool_calls") - } + root["tool_choice"] = "required" + delete(root, "parallel_tool_calls") return json.Marshal(root) } if tunnel.BuildBody == nil { diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index c70cfdc6..053245be 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -309,11 +309,11 @@ func TestHotPathSelectorCanonicalWriteToolReplacesCallerCommandSchema(t *testing function := tools[0].(map[string]any)["function"].(map[string]any) parameters := function["parameters"].(map[string]any) properties := parameters["properties"].(map[string]any) - if function["name"] != "bash" || properties["path"] == nil || properties["content"] == nil || properties["command"] != nil { + if function["name"] != hotPathArtifactPairToolName || properties["plan_content"] == nil || properties["review_content"] == nil || properties["command"] != nil { t.Fatalf("canonical write function=%+v", function) } - if request["parallel_tool_calls"] != true { - t.Fatalf("pair-write did not enable parallel tool calls: %+v", request) + if request["tool_choice"] != "required" || request["parallel_tool_calls"] != nil { + t.Fatalf("pair-write must force one atomic tool call: %+v", request) } } From 51e5e4f35b07ab1622e45225278d93016a8790b8 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 06:34:45 +0900 Subject: [PATCH 11/37] fix(openai): normalize continuation tool identity --- .../outer/anthropic-compatible-api.md | 5 +++ agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 3 +- apps/edge/internal/openai/hot_path_direct.go | 14 +++++-- .../openai/request_coordinator_test.go | 23 ++++++++++ apps/edge/internal/openai/request_lineage.go | 42 ++++++++++++++++++- 6 files changed, 81 insertions(+), 8 deletions(-) diff --git a/agent-contract/outer/anthropic-compatible-api.md b/agent-contract/outer/anthropic-compatible-api.md index 4df15aa6..3db20042 100644 --- a/agent-contract/outer/anthropic-compatible-api.md +++ b/agent-contract/outer/anthropic-compatible-api.md @@ -118,6 +118,11 @@ caller request snapshot, and adds no public request/config field or raw workspac path. Work, Review, cleanup, and marked `single_request` provider bodies do not receive it. +Continuation issue correlation hashes only the emitted tool-use identity, name, and +arguments/input. SDK-added text, thinking, and provider metadata decorations are not +part of that issue identity; the retained request prefix and exact result IDs remain +independently validated. + A preset marked with `single_request` is excluded from this continuation before caller tool binding. Config validation rejects a preset that declares both `single_request` and `workspace_tools`, so the operator-owned Node workspace and diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index c4e476c7..c9fa7662 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 6248f397..784c1dd0 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for the accepted artifact grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only canonical `prepare(path)` or one atomic `iop_write_artifact_pair(plan_content,review_content)` schema. Edge validates and expands the pair into two independently receipted writes encoded in the admitted caller tool schema such as `bash(command)`; the selector is not required to produce parallel tool calls. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for the accepted artifact grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only canonical `prepare(path)` or one atomic `iop_write_artifact_pair(plan_content,review_content)` schema. Edge validates and expands the pair into two independently receipted writes encoded in the admitted caller tool schema such as `bash(command)`; the selector is not required to produce parallel tool calls. Continuation issue identity covers semantic tool-call ID/name/arguments rather than SDK-decorated assistant content, reasoning, or provider metadata, while request prefix and result IDs stay independently validated. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | @@ -374,6 +374,7 @@ sequenceDiagram - 2026-08-15: Normalized Chat output-token aliases by selected protocol profile across provider-pool and unmarked caller-workspace selector/Work/Review calls. OpenAI Chat uses `max_completion_tokens`, Gemini Chat uses `max_tokens`, target-native values win, and unmarked internal stages use the selected OpenAI profile's Responses operation when tools plus effort cannot be represented by Chat. The reasoning Responses conversion omits incompatible generic SDK `temperature`/`top_p` defaults. Selector upstream HTTP 400 now terminates once as caller `invalid_request_error` instead of retryable 502. Command-mode workspace execution also carries the admitted containment guard in the actual outgoing command. - 2026-08-14: Split the unmarked selector instruction into frontier-derived `prepare-only` and `pair-write` operations. Non-parent-creating bindings prepare the request job directory first and receive the exact artifact-pair grammar only after the successful receipt; parent-creating bindings receive the pair operation immediately. - 2026-08-15: Replaced probabilistic parallel PLAN/REVIEW selector calls with one canonical atomic artifact-pair call that Edge deterministically expands into the same two admitted caller writes. +- 2026-08-15: Bound continuation issue identity to semantic tool calls so compatible SDK decoration of assistant messages does not break an otherwise exact caller receipt. - 2026-08-14: Added the Edge-owned provider-side selector instruction for unmarked caller-workspace initial/resume turns. Actual Chat/Messages bodies now carry the exact request-local relative paths and accepted PLAN/pending REVIEW grammar without changing caller snapshots or public schemas. - 2026-08-14: Added the restored unmarked caller-workspace light route, including template-validated PLAN/pending REVIEW creation, worker-owned completed REVIEW handoff, reviewer reads/result inspection/repair, reviewer-owned non-empty terminal, and strict separation from marked Node-owned `single_request` presets. - 2026-08-14: Added bounded read/list-first workspace analysis inside the marked Plan stage. Successful inspection is required before structured Plan completion, while mutation tools, a separate analysis artifact, and caller-visible schema changes remain absent. diff --git a/apps/edge/internal/openai/hot_path_direct.go b/apps/edge/internal/openai/hot_path_direct.go index dbfca346..5f8fb758 100644 --- a/apps/edge/internal/openai/hot_path_direct.go +++ b/apps/edge/internal/openai/hot_path_direct.go @@ -133,11 +133,17 @@ func (s *Server) runDirectTurn(ctx context.Context, turn *hotPathTurn, output no func directIssuedCallHash(protocol string, output normalizedStageOutput) (string, error) { if protocol == "anthropic" { - return fingerprintCanonicalJSON(logicalRequestEndpointAnthropic, map[string]any{ - "role": "assistant", "content": anthropicDirectBlocks(output), - }) + blocks, err := json.Marshal(map[string]any{"role": "assistant", "content": anthropicDirectBlocks(output)}) + if err != nil { + return "", err + } + return anthropicIssuedCallHash(blocks) } - return fingerprintCanonicalJSON(logicalRequestEndpointChat, openAIDirectMessage(output)) + message, err := json.Marshal(openAIDirectMessage(output)) + if err != nil { + return "", err + } + return chatIssuedCallHash(message) } func (s *Server) writeDirectError(turn *hotPathTurn, status int, errorType, message string) error { diff --git a/apps/edge/internal/openai/request_coordinator_test.go b/apps/edge/internal/openai/request_coordinator_test.go index e1ba6d88..981ad674 100644 --- a/apps/edge/internal/openai/request_coordinator_test.go +++ b/apps/edge/internal/openai/request_coordinator_test.go @@ -376,6 +376,29 @@ func TestLogicalRequestEndpointContinuationLineage(t *testing.T) { } } +func TestIssuedToolHashIgnoresSDKAssistantDecoration(t *testing.T) { + output := normalizedStageOutput{Content: "provider preface", Reasoning: "private", ToolCalls: []normalizedToolCall{{ + ID: "call_1", Name: "bash", RawArgs: `{"command":"printf ok"}`, + }}} + want, err := directIssuedCallHash("openai", output) + if err != nil { + t.Fatal(err) + } + got, err := chatIssuedCallHash(json.RawMessage(`{ + "role":"assistant", + "content":null, + "reasoning_content":"sdk-normalized", + "provider_metadata":{"ignored":true}, + "tool_calls":[{"id":"call_1","type":"function","function":{"name":"bash","arguments":"{\"command\":\"printf ok\"}"}}] + }`)) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("semantic issued call hash mismatch: got %q want %q", got, want) + } +} + func TestLogicalRequestMultiTurnValidControl(t *testing.T) { // Chat 2-turn multi-turn valid control chatTurn1Raw := []byte(`{ diff --git a/apps/edge/internal/openai/request_lineage.go b/apps/edge/internal/openai/request_lineage.go index 2b41eae3..82149832 100644 --- a/apps/edge/internal/openai/request_lineage.go +++ b/apps/edge/internal/openai/request_lineage.go @@ -361,7 +361,7 @@ func newChatContinuationLineage(raw json.RawMessage) (logicalRequestContinuation } } - issuedCallHash, err := fingerprintCanonicalJSON(logicalRequestEndpointChat, msgList[assistantIndex]) + issuedCallHash, err := chatIssuedCallHash(msgList[assistantIndex]) if err != nil { return logicalRequestContinuationLineage{}, fmt.Errorf("fingerprint issued assistant call: %w", err) } @@ -521,7 +521,7 @@ func newAnthropicContinuationLineage(raw json.RawMessage) (logicalRequestContinu } } - issuedCallHash, err := fingerprintCanonicalJSON(logicalRequestEndpointAnthropic, msgList[assistantIndex]) + issuedCallHash, err := anthropicIssuedCallHash(msgList[assistantIndex]) if err != nil { return logicalRequestContinuationLineage{}, fmt.Errorf("fingerprint issued assistant call: %w", err) } @@ -573,6 +573,44 @@ func newAnthropicContinuationLineage(raw json.RawMessage) (logicalRequestContinu }, nil } +func chatIssuedCallHash(raw json.RawMessage) (string, error) { + var message struct { + Role string `json:"role"` + ToolCalls []json.RawMessage `json:"tool_calls"` + } + if err := json.Unmarshal(raw, &message); err != nil { + return "", err + } + return fingerprintCanonicalJSON(logicalRequestEndpointChat, map[string]any{ + "role": message.Role, "tool_calls": message.ToolCalls, + }) +} + +func anthropicIssuedCallHash(raw json.RawMessage) (string, error) { + var message struct { + Role string `json:"role"` + Content []json.RawMessage `json:"content"` + } + if err := json.Unmarshal(raw, &message); err != nil { + return "", err + } + toolUses := make([]json.RawMessage, 0, len(message.Content)) + for _, block := range message.Content { + var header struct { + Type string `json:"type"` + } + if err := json.Unmarshal(block, &header); err != nil { + return "", err + } + if header.Type == "tool_use" { + toolUses = append(toolUses, block) + } + } + return fingerprintCanonicalJSON(logicalRequestEndpointAnthropic, map[string]any{ + "role": message.Role, "content": toolUses, + }) +} + func newLogicalRequestLineageFromRaw(raw json.RawMessage, endpoint logicalRequestEndpoint, historyFields []string) (logicalRequestLineage, error) { fields, err := decodeLogicalRequestLineageEnvelope(raw) if err != nil { From fe08764fcff386036707ab0dfccc5b04b2fa014f Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 06:39:36 +0900 Subject: [PATCH 12/37] fix(openai): canonicalize issued tool calls --- apps/edge/internal/openai/artifact_pair.go | 11 +++++ .../openai/request_coordinator_test.go | 2 +- .../openai/request_identity_handler_test.go | 6 +-- apps/edge/internal/openai/request_lineage.go | 47 +++++++++++++++---- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/apps/edge/internal/openai/artifact_pair.go b/apps/edge/internal/openai/artifact_pair.go index a519250b..6d47942f 100644 --- a/apps/edge/internal/openai/artifact_pair.go +++ b/apps/edge/internal/openai/artifact_pair.go @@ -739,6 +739,17 @@ func (s *artifactFrontierStore) matchRecordLocked( return record, true, nil } } + for _, record := range candidates { + if record.ownerEdgeID != ownerEdgeID || record.principalRef != principalRef || record.protocol != protocol { + continue + } + if record.lineage != lineage.Prefix { + return nil, true, describeArtifactPrefixMismatch(record.lineage, lineage.Prefix) + } + if record.pendingHash != lineage.IssuedCallHash { + return nil, true, fmt.Errorf("%w: issued tool calls changed", errLogicalRequestLineage) + } + } return nil, true, errLogicalRequestLineage } diff --git a/apps/edge/internal/openai/request_coordinator_test.go b/apps/edge/internal/openai/request_coordinator_test.go index 981ad674..51cd93e4 100644 --- a/apps/edge/internal/openai/request_coordinator_test.go +++ b/apps/edge/internal/openai/request_coordinator_test.go @@ -389,7 +389,7 @@ func TestIssuedToolHashIgnoresSDKAssistantDecoration(t *testing.T) { "content":null, "reasoning_content":"sdk-normalized", "provider_metadata":{"ignored":true}, - "tool_calls":[{"id":"call_1","type":"function","function":{"name":"bash","arguments":"{\"command\":\"printf ok\"}"}}] + "tool_calls":[{"id":"call_1","type":"function","provider_metadata":{"ignored":true},"function":{"name":"bash","arguments":"{ \"command\" : \"printf ok\" }"}}] }`)) if err != nil { t.Fatal(err) diff --git a/apps/edge/internal/openai/request_identity_handler_test.go b/apps/edge/internal/openai/request_identity_handler_test.go index fcdcf069..ceaa4c1a 100644 --- a/apps/edge/internal/openai/request_identity_handler_test.go +++ b/apps/edge/internal/openai/request_identity_handler_test.go @@ -106,7 +106,7 @@ func TestPresetRequestIdentityAcrossChatTurns(t *testing.T) { // Simulate stage 1 assistant issuing tool call "call_c1" assistantMsg := json.RawMessage(`{"role":"assistant","tool_calls":[{"id":"call_c1","type":"function","function":{"name":"search"}}]}`) - issuedHash, err := fingerprintCanonicalJSON(logicalRequestEndpointChat, assistantMsg) + issuedHash, err := chatIssuedCallHash(assistantMsg) if err != nil { t.Fatalf("fingerprintCanonicalJSON: %v", err) } @@ -249,7 +249,7 @@ func TestPresetRequestIdentityAcrossAnthropicTurns(t *testing.T) { // Simulate assistant issuing tool_use block tu_a1 assistantMsg := json.RawMessage(`{"role":"assistant","content":[{"type":"tool_use","id":"tu_a1","name":"search","input":{}}]}`) - issuedHash, err := fingerprintCanonicalJSON(logicalRequestEndpointAnthropic, assistantMsg) + issuedHash, err := anthropicIssuedCallHash(assistantMsg) if err != nil { t.Fatalf("fingerprintCanonicalJSON: %v", err) } @@ -362,7 +362,7 @@ func TestPresetRequestIdentityRejectionCases(t *testing.T) { coord.mu.Unlock() assistantMsg := json.RawMessage(`{"role":"assistant","tool_calls":[{"id":"call_r1","type":"function","function":{"name":"search"}}]}`) - issuedHash, err := fingerprintCanonicalJSON(logicalRequestEndpointChat, assistantMsg) + issuedHash, err := chatIssuedCallHash(assistantMsg) if err != nil { t.Fatalf("fingerprintCanonicalJSON: %v", err) } diff --git a/apps/edge/internal/openai/request_lineage.go b/apps/edge/internal/openai/request_lineage.go index 82149832..0d021d4d 100644 --- a/apps/edge/internal/openai/request_lineage.go +++ b/apps/edge/internal/openai/request_lineage.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "strings" ) // logicalRequestEndpoint keeps fingerprints from incompatible wire formats @@ -575,14 +576,36 @@ func newAnthropicContinuationLineage(raw json.RawMessage) (logicalRequestContinu func chatIssuedCallHash(raw json.RawMessage) (string, error) { var message struct { - Role string `json:"role"` - ToolCalls []json.RawMessage `json:"tool_calls"` + Role string `json:"role"` + ToolCalls []struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` } if err := json.Unmarshal(raw, &message); err != nil { return "", err } + semanticCalls := make([]map[string]any, 0, len(message.ToolCalls)) + for _, call := range message.ToolCalls { + var arguments any = map[string]any{} + if strings.TrimSpace(call.Function.Arguments) != "" { + decoder := json.NewDecoder(bytes.NewBufferString(call.Function.Arguments)) + decoder.UseNumber() + if err := decoder.Decode(&arguments); err != nil { + return "", fmt.Errorf("decode issued tool arguments: %w", err) + } + } + semanticCalls = append(semanticCalls, map[string]any{ + "id": call.ID, "type": call.Type, + "function": map[string]any{"name": call.Function.Name, "arguments": arguments}, + }) + } return fingerprintCanonicalJSON(logicalRequestEndpointChat, map[string]any{ - "role": message.Role, "tool_calls": message.ToolCalls, + "role": message.Role, "tool_calls": semanticCalls, }) } @@ -594,16 +617,20 @@ func anthropicIssuedCallHash(raw json.RawMessage) (string, error) { if err := json.Unmarshal(raw, &message); err != nil { return "", err } - toolUses := make([]json.RawMessage, 0, len(message.Content)) + type semanticToolUse struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` + } + toolUses := make([]semanticToolUse, 0, len(message.Content)) for _, block := range message.Content { - var header struct { - Type string `json:"type"` - } - if err := json.Unmarshal(block, &header); err != nil { + var toolUse semanticToolUse + if err := json.Unmarshal(block, &toolUse); err != nil { return "", err } - if header.Type == "tool_use" { - toolUses = append(toolUses, block) + if toolUse.Type == "tool_use" { + toolUses = append(toolUses, toolUse) } } return fingerprintCanonicalJSON(logicalRequestEndpointAnthropic, map[string]any{ From 9e13304d65b6895338dd6bf7addd45503755529c Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 06:43:01 +0900 Subject: [PATCH 13/37] fix(openai): bind canonical artifact operations --- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 1 + apps/edge/internal/openai/hot_path_light.go | 8 ++++++- .../internal/openai/hot_path_light_test.go | 21 +++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index c9fa7662..2d1a8e6b 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work/Review provider가 반환한 canonical reserved-artifact operation 이름도 선택된 caller binding의 실제 tool 이름으로 정규화한 뒤 같은 codec으로 encode한다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 784c1dd0..e3683113 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -375,6 +375,7 @@ sequenceDiagram - 2026-08-14: Split the unmarked selector instruction into frontier-derived `prepare-only` and `pair-write` operations. Non-parent-creating bindings prepare the request job directory first and receive the exact artifact-pair grammar only after the successful receipt; parent-creating bindings receive the pair operation immediately. - 2026-08-15: Replaced probabilistic parallel PLAN/REVIEW selector calls with one canonical atomic artifact-pair call that Edge deterministically expands into the same two admitted caller writes. - 2026-08-15: Bound continuation issue identity to semantic tool calls so compatible SDK decoration of assistant messages does not break an otherwise exact caller receipt. +- 2026-08-15: Normalized Work/Review canonical reserved-artifact operation names through the selected caller binding before encoding them into structured or command-mode caller tools. - 2026-08-14: Added the Edge-owned provider-side selector instruction for unmarked caller-workspace initial/resume turns. Actual Chat/Messages bodies now carry the exact request-local relative paths and accepted PLAN/pending REVIEW grammar without changing caller snapshots or public schemas. - 2026-08-14: Added the restored unmarked caller-workspace light route, including template-validated PLAN/pending REVIEW creation, worker-owned completed REVIEW handoff, reviewer reads/result inspection/repair, reviewer-owned non-empty terminal, and strict separation from marked Node-owned `single_request` presets. - 2026-08-14: Added bounded read/list-first workspace analysis inside the marked Plan stage. Successful inspection is required before structured Plan completion, while mutation tools, a separate analysis artifact, and caller-visible schema changes remain absent. diff --git a/apps/edge/internal/openai/hot_path_light.go b/apps/edge/internal/openai/hot_path_light.go index ae5aea50..82206e44 100644 --- a/apps/edge/internal/openai/hot_path_light.go +++ b/apps/edge/internal/openai/hot_path_light.go @@ -532,7 +532,13 @@ func mapHotPathStageCalls(record *hotPathLightRecord, output normalizedStageOutp var mapped normalizedToolCall var payload *workspaceEncodedPayload if reserved { - mapped, payload, err = mapArtifactCall(record.binding, call, operation, requiredPath, coordinator) + bound := record.binding.operation(operation) + if bound == nil || strings.TrimSpace(bound.toolName) == "" { + return normalizedStageOutput{}, nil, fmt.Errorf("artifact %s binding is unavailable", operation) + } + callerCall := call + callerCall.Name = bound.toolName + mapped, payload, err = mapArtifactCall(record.binding, callerCall, operation, requiredPath, coordinator) if err != nil { return normalizedStageOutput{}, nil, err } diff --git a/apps/edge/internal/openai/hot_path_light_test.go b/apps/edge/internal/openai/hot_path_light_test.go index de7b7278..cf3ced88 100644 --- a/apps/edge/internal/openai/hot_path_light_test.go +++ b/apps/edge/internal/openai/hot_path_light_test.go @@ -33,6 +33,27 @@ func TestHotPathLightLocalTransition(t *testing.T) { } } +func TestHotPathStageCanonicalReadMapsToCallerCommandTool(t *testing.T) { + binding := mustBinding(t, fullWorkspaceAlternative("command", "bash", true), []any{openAIChatTool("bash", commandSchema())}) + record := &hotPathLightRecord{ + requestID: "req_stage_map", phase: hotPathPhaseLocalActive, binding: binding, + } + coordinator := newLogicalRequestCoordinator(logicalRequestCoordinatorOptions{IDSource: func() (string, error) { return "call_public", nil }}) + paths := newReservedPaths(record.requestID) + mapped, pending, err := mapHotPathStageCalls(record, normalizedStageOutput{ToolCalls: []normalizedToolCall{{ + ID: "provider_read", Name: "read", Arguments: map[string]any{"path": paths.PlanPath}, + }}}, hotPathPendingLocalTools, coordinator, nil) + if err != nil { + t.Fatal(err) + } + if len(mapped.ToolCalls) != 1 || mapped.ToolCalls[0].Name != "bash" || mapped.ToolCalls[0].Arguments["command"] == nil { + t.Fatalf("mapped caller command=%+v", mapped.ToolCalls) + } + if pending[mapped.ToolCalls[0].ID].payload == nil { + t.Fatalf("reserved read pending payload=%+v", pending) + } +} + func TestHotPathLightRequiresCompletedWorkerHandoff(t *testing.T) { for _, endpoint := range []string{"openai", "anthropic"} { endpoint := endpoint From deea8d95923fba3ce35f6438635c83ba6f6ef011 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 06:51:03 +0900 Subject: [PATCH 14/37] fix(openai): validate stages before caller projection --- .../outer/anthropic-compatible-api.md | 4 ++++ agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 1 + apps/edge/internal/openai/hot_path_dispatch.go | 18 +++++++++++++----- apps/edge/internal/openai/hot_path_light.go | 10 ++++++---- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/agent-contract/outer/anthropic-compatible-api.md b/agent-contract/outer/anthropic-compatible-api.md index 3db20042..5f1b8388 100644 --- a/agent-contract/outer/anthropic-compatible-api.md +++ b/agent-contract/outer/anthropic-compatible-api.md @@ -123,6 +123,10 @@ arguments/input. SDK-added text, thinking, and provider metadata decorations are part of that issue identity; the retained request prefix and exact result IDs remain independently validated. +Work/Review provider deltas are collected through the bounded internal stage before +caller projection. Edge validates and maps canonical operations to the selected caller +binding first, so an internal tool name is never released early on a Messages stream. + A preset marked with `single_request` is excluded from this continuation before caller tool binding. Config validation rejects a preset that declares both `single_request` and `workspace_tools`, so the operator-owned Node workspace and diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 2d1a8e6b..634f0152 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work/Review provider가 반환한 canonical reserved-artifact operation 이름도 선택된 caller binding의 실제 tool 이름으로 정규화한 뒤 같은 codec으로 encode한다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work/Review provider가 반환한 canonical reserved-artifact operation 이름도 선택된 caller binding의 실제 tool 이름으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 내부 stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영한다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index e3683113..11ee2398 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -376,6 +376,7 @@ sequenceDiagram - 2026-08-15: Replaced probabilistic parallel PLAN/REVIEW selector calls with one canonical atomic artifact-pair call that Edge deterministically expands into the same two admitted caller writes. - 2026-08-15: Bound continuation issue identity to semantic tool calls so compatible SDK decoration of assistant messages does not break an otherwise exact caller receipt. - 2026-08-15: Normalized Work/Review canonical reserved-artifact operation names through the selected caller binding before encoding them into structured or command-mode caller tools. +- 2026-08-15: Buffered each bounded Work/Review provider stage until canonical operation validation and caller-binding projection complete, preventing internal tool deltas from escaping before normalization. - 2026-08-14: Added the Edge-owned provider-side selector instruction for unmarked caller-workspace initial/resume turns. Actual Chat/Messages bodies now carry the exact request-local relative paths and accepted PLAN/pending REVIEW grammar without changing caller snapshots or public schemas. - 2026-08-14: Added the restored unmarked caller-workspace light route, including template-validated PLAN/pending REVIEW creation, worker-owned completed REVIEW handoff, reviewer reads/result inspection/repair, reviewer-owned non-empty terminal, and strict separation from marked Node-owned `single_request` presets. - 2026-08-14: Added bounded read/list-first workspace analysis inside the marked Plan stage. Successful inspection is required before structured Plan completion, while mutation tools, a separate analysis artifact, and caller-visible schema changes remain absent. diff --git a/apps/edge/internal/openai/hot_path_dispatch.go b/apps/edge/internal/openai/hot_path_dispatch.go index 352dcc22..4f63fecf 100644 --- a/apps/edge/internal/openai/hot_path_dispatch.go +++ b/apps/edge/internal/openai/hot_path_dispatch.go @@ -1316,10 +1316,11 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh if shouldProgressivelyReleaseHotPathStage(snapshot, outer) { return s.runHotPathLiveTunnelStage(ctx, snapshot, outer, handle, dispatch) } - output, collectErr := s.collectHotPathOwnedTunnelStage(ctx, snapshot.StageID, outer, handle, dispatch, "openai") + output, collectErr := s.collectHotPathOwnedTunnelStage(ctx, snapshot.StageID, nil, handle, dispatch, "openai") if collectErr != nil { return normalizedStageOutput{}, hotPathStageCorrelation{}, collectErr } + output.CallerStageOnly = true return output, stageCorrelation(snapshot.StageID, output, dispatch), nil } handle, submitErr := s.service.SubmitRun(ctx, runReq) @@ -1330,10 +1331,11 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh if shouldProgressivelyReleaseHotPathStage(snapshot, outer) { return s.runHotPathLiveNormalizedStage(ctx, snapshot, outer, handle, dispatch) } - output, collectErr := s.collectHotPathOwnedNormalizedStage(ctx, snapshot.StageID, outer, handle, dispatch) + output, collectErr := s.collectHotPathOwnedNormalizedStage(ctx, snapshot.StageID, nil, handle, dispatch) if collectErr != nil { return normalizedStageOutput{}, hotPathStageCorrelation{}, collectErr } + output.CallerStageOnly = true return output, stageCorrelation(snapshot.StageID, output, dispatch), nil } @@ -1384,10 +1386,10 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh } switch result.Path { case edgeservice.ProviderPoolPathNormalized: - output, err = s.collectHotPathOwnedNormalizedStage(ctx, snapshot.StageID, outer, result.Run, result.DispatchInfo) + output, err = s.collectHotPathOwnedNormalizedStage(ctx, snapshot.StageID, nil, result.Run, result.DispatchInfo) case edgeservice.ProviderPoolPathTunnel: output, err = s.collectHotPathOwnedTunnelStage( - ctx, snapshot.StageID, outer, result.Tunnel, result.DispatchInfo, hotPathStageWireProtocol(result.DispatchInfo), + ctx, snapshot.StageID, nil, result.Tunnel, result.DispatchInfo, hotPathStageWireProtocol(result.DispatchInfo), ) default: s.abortHotPathRejectedDispatch(rejection) @@ -1399,6 +1401,7 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh if err != nil { return normalizedStageOutput{}, hotPathStageCorrelation{}, err } + output.CallerStageOnly = true if strings.TrimSpace(output.ResponseID) == "" { return normalizedStageOutput{}, hotPathStageCorrelation{}, fmt.Errorf("hot path stage completion is missing provider identity") } @@ -1406,7 +1409,12 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh } func shouldProgressivelyReleaseHotPathStage(snapshot hotPathDispatchSnapshot, outer *hotPathOuterTurn) bool { - return snapshot.Stream && (snapshot.Protocol == "openai" || snapshot.Protocol == "anthropic") && outer != nil + // Work/Review provider calls use canonical workspace operations that may + // need caller-binding normalization (for example read(path) -> bash(command)). + // Releasing provider deltas before classification would let the caller run + // an unbound internal tool call. Collect the bounded stage first, then project + // only the validated caller-visible output. + return false } // newHotPathRejectedDispatchOwner builds one result-scoped disposal owner for a diff --git a/apps/edge/internal/openai/hot_path_light.go b/apps/edge/internal/openai/hot_path_light.go index 82206e44..4d5b8487 100644 --- a/apps/edge/internal/openai/hot_path_light.go +++ b/apps/edge/internal/openai/hot_path_light.go @@ -468,7 +468,7 @@ func (s *hotPathLightStore) issueTools( stageID = record.reviewStageID } if outer != nil { - if !output.ProgressivelyReleased { + if !output.ProgressivelyReleased && !output.CallerStageOnly { if err := runHotPathCollectedStage(ctx, outer, stageID, mapped); err != nil { return normalizedStageOutput{}, fmt.Errorf("collect light tool outer turn: %w", err) } @@ -478,8 +478,10 @@ func (s *hotPathLightStore) issueTools( outer.commitLengthTerminal() return hotPathCompatibilityOutput(outer, mapped.StageResponseOverlay(visible), record.protocol), nil } - if err := outer.projectToolIdentities(mapped.ToolCalls); err != nil { - return normalizedStageOutput{}, err + if !output.CallerStageOnly { + if err := outer.projectToolIdentities(mapped.ToolCalls); err != nil { + return normalizedStageOutput{}, err + } } } mapped = mapped.StageResponseOverlay(visible) @@ -1015,7 +1017,7 @@ func (s *Server) runHotPathLightStage(w http.ResponseWriter, r *http.Request, di // endpoint codecs consume released deltas directly. Feed the same // output into the sequencer now so its usage and terminal boundary span // local→review transitions in this HTTP turn. - if len(output.ToolCalls) == 0 && !output.ProgressivelyReleased { + if len(output.ToolCalls) == 0 && !output.ProgressivelyReleased && !output.CallerStageOnly { if err := runHotPathCollectedStage(r.Context(), outer, snapshot.StageID, output); err != nil { return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID, hotPathLightEndpointError(protocol, http.StatusBadGateway, err.Error())) From 0b037d0367e3f4d7d0135c20d847ab6c243030af Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 06:57:26 +0900 Subject: [PATCH 15/37] fix(openai): buffer command-mode stage projection --- .../outer/anthropic-compatible-api.md | 7 +++--- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 2 +- .../edge/internal/openai/hot_path_dispatch.go | 21 +++++++++++------- apps/edge/internal/openai/hot_path_light.go | 22 +++++++++++++++++-- .../internal/openai/workspace_tool_binding.go | 12 ++++++++++ .../openai/workspace_tool_binding_test.go | 11 ++++++++++ 7 files changed, 62 insertions(+), 15 deletions(-) diff --git a/agent-contract/outer/anthropic-compatible-api.md b/agent-contract/outer/anthropic-compatible-api.md index 5f1b8388..22b1896d 100644 --- a/agent-contract/outer/anthropic-compatible-api.md +++ b/agent-contract/outer/anthropic-compatible-api.md @@ -123,9 +123,10 @@ arguments/input. SDK-added text, thinking, and provider metadata decorations are part of that issue identity; the retained request prefix and exact result IDs remain independently validated. -Work/Review provider deltas are collected through the bounded internal stage before -caller projection. Edge validates and maps canonical operations to the selected caller -binding first, so an internal tool name is never released early on a Messages stream. +For a command-mode caller binding, Work/Review provider deltas are collected through +the bounded internal stage before caller projection. Edge validates and maps canonical +operations first, so an internal tool name is never released early on a Messages stream; +directly projectable structured bindings retain progressive streaming. A preset marked with `single_request` is excluded from this continuation before caller tool binding. Config validation rejects a preset that declares both diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 634f0152..19930c93 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work/Review provider가 반환한 canonical reserved-artifact operation 이름도 선택된 caller binding의 실제 tool 이름으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 내부 stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영한다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work/Review provider가 반환한 canonical reserved-artifact operation 이름도 선택된 caller binding의 실제 tool 이름으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 11ee2398..972485bb 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -376,7 +376,7 @@ sequenceDiagram - 2026-08-15: Replaced probabilistic parallel PLAN/REVIEW selector calls with one canonical atomic artifact-pair call that Edge deterministically expands into the same two admitted caller writes. - 2026-08-15: Bound continuation issue identity to semantic tool calls so compatible SDK decoration of assistant messages does not break an otherwise exact caller receipt. - 2026-08-15: Normalized Work/Review canonical reserved-artifact operation names through the selected caller binding before encoding them into structured or command-mode caller tools. -- 2026-08-15: Buffered each bounded Work/Review provider stage until canonical operation validation and caller-binding projection complete, preventing internal tool deltas from escaping before normalization. +- 2026-08-15: Buffered command-mode Work/Review provider stages until canonical operation validation and caller-binding projection complete, preventing internal tool deltas from escaping before normalization while retaining structured-binding progressive streams. - 2026-08-14: Added the Edge-owned provider-side selector instruction for unmarked caller-workspace initial/resume turns. Actual Chat/Messages bodies now carry the exact request-local relative paths and accepted PLAN/pending REVIEW grammar without changing caller snapshots or public schemas. - 2026-08-14: Added the restored unmarked caller-workspace light route, including template-validated PLAN/pending REVIEW creation, worker-owned completed REVIEW handoff, reviewer reads/result inspection/repair, reviewer-owned non-empty terminal, and strict separation from marked Node-owned `single_request` presets. - 2026-08-14: Added bounded read/list-first workspace analysis inside the marked Plan stage. Successful inspection is required before structured Plan completion, while mutation tools, a separate analysis artifact, and caller-visible schema changes remain absent. diff --git a/apps/edge/internal/openai/hot_path_dispatch.go b/apps/edge/internal/openai/hot_path_dispatch.go index 4f63fecf..1ad3835c 100644 --- a/apps/edge/internal/openai/hot_path_dispatch.go +++ b/apps/edge/internal/openai/hot_path_dispatch.go @@ -1294,6 +1294,10 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh MaxQueue: route.MaxQueue, QueueTimeoutMS: route.QueueTimeoutMS, Metadata: metadata, EstimatedInputTokens: estimate, ContextClass: contextClass, ProviderPool: route.ProviderPool, } + collectedOuter := outer + if snapshot.RequiresCollectedProjection { + collectedOuter = nil + } if !route.ProviderPool { if routeUsesProviderTunnel(route) { @@ -1316,11 +1320,11 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh if shouldProgressivelyReleaseHotPathStage(snapshot, outer) { return s.runHotPathLiveTunnelStage(ctx, snapshot, outer, handle, dispatch) } - output, collectErr := s.collectHotPathOwnedTunnelStage(ctx, snapshot.StageID, nil, handle, dispatch, "openai") + output, collectErr := s.collectHotPathOwnedTunnelStage(ctx, snapshot.StageID, collectedOuter, handle, dispatch, "openai") if collectErr != nil { return normalizedStageOutput{}, hotPathStageCorrelation{}, collectErr } - output.CallerStageOnly = true + output.CallerStageOnly = snapshot.RequiresCollectedProjection return output, stageCorrelation(snapshot.StageID, output, dispatch), nil } handle, submitErr := s.service.SubmitRun(ctx, runReq) @@ -1331,11 +1335,11 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh if shouldProgressivelyReleaseHotPathStage(snapshot, outer) { return s.runHotPathLiveNormalizedStage(ctx, snapshot, outer, handle, dispatch) } - output, collectErr := s.collectHotPathOwnedNormalizedStage(ctx, snapshot.StageID, nil, handle, dispatch) + output, collectErr := s.collectHotPathOwnedNormalizedStage(ctx, snapshot.StageID, collectedOuter, handle, dispatch) if collectErr != nil { return normalizedStageOutput{}, hotPathStageCorrelation{}, collectErr } - output.CallerStageOnly = true + output.CallerStageOnly = snapshot.RequiresCollectedProjection return output, stageCorrelation(snapshot.StageID, output, dispatch), nil } @@ -1386,10 +1390,10 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh } switch result.Path { case edgeservice.ProviderPoolPathNormalized: - output, err = s.collectHotPathOwnedNormalizedStage(ctx, snapshot.StageID, nil, result.Run, result.DispatchInfo) + output, err = s.collectHotPathOwnedNormalizedStage(ctx, snapshot.StageID, collectedOuter, result.Run, result.DispatchInfo) case edgeservice.ProviderPoolPathTunnel: output, err = s.collectHotPathOwnedTunnelStage( - ctx, snapshot.StageID, nil, result.Tunnel, result.DispatchInfo, hotPathStageWireProtocol(result.DispatchInfo), + ctx, snapshot.StageID, collectedOuter, result.Tunnel, result.DispatchInfo, hotPathStageWireProtocol(result.DispatchInfo), ) default: s.abortHotPathRejectedDispatch(rejection) @@ -1401,7 +1405,7 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh if err != nil { return normalizedStageOutput{}, hotPathStageCorrelation{}, err } - output.CallerStageOnly = true + output.CallerStageOnly = snapshot.RequiresCollectedProjection if strings.TrimSpace(output.ResponseID) == "" { return normalizedStageOutput{}, hotPathStageCorrelation{}, fmt.Errorf("hot path stage completion is missing provider identity") } @@ -1414,7 +1418,8 @@ func shouldProgressivelyReleaseHotPathStage(snapshot hotPathDispatchSnapshot, ou // Releasing provider deltas before classification would let the caller run // an unbound internal tool call. Collect the bounded stage first, then project // only the validated caller-visible output. - return false + return !snapshot.RequiresCollectedProjection && snapshot.Stream && + (snapshot.Protocol == "openai" || snapshot.Protocol == "anthropic") && outer != nil } // newHotPathRejectedDispatchOwner builds one result-scoped disposal owner for a diff --git a/apps/edge/internal/openai/hot_path_light.go b/apps/edge/internal/openai/hot_path_light.go index 4d5b8487..927b939f 100644 --- a/apps/edge/internal/openai/hot_path_light.go +++ b/apps/edge/internal/openai/hot_path_light.go @@ -145,6 +145,9 @@ type hotPathDispatchSnapshot struct { Tools []any Transcript []hotPathStageExchange Stream bool + // RequiresCollectedProjection prevents canonical provider tool deltas from + // escaping before a command-mode caller binding can encode them. + RequiresCollectedProjection bool // OutputBudget is recalculated from the request-local outer accumulator // before every stage. Limited, remaining, and exhausted are distinct so an // exhausted turn cannot be encoded as a one-token provider request. @@ -346,6 +349,7 @@ func (s *hotPathLightStore) beginDispatch(requestID, ownerEdgeID string, stream Protocol: record.protocol, Phase: record.phase, StageID: stageID, Stage: stage, Route: route, PresetRoute: cloneHotPathDispatch(record.dispatch), Input: input, Tools: cloneAnySlice(record.tools), Transcript: cloneStageTranscript(transcript), Stream: stream, + RequiresCollectedProjection: record.binding.requiresCollectedProjection(), }, nil } @@ -805,11 +809,25 @@ func (s *hotPathLightStore) matchRecordLocked(ownerEdgeID, principalRef, protoco if record.principalRef != principalRef { return nil, true, errLogicalRequestPrincipal } - if record.protocol != protocol || record.lineage != lineage.Prefix { - return nil, true, errLogicalRequestLineage + if record.protocol != protocol { + return nil, true, fmt.Errorf("%w: protocol changed", errLogicalRequestLineage) + } + if record.lineage != lineage.Prefix { + return nil, true, describeArtifactPrefixMismatch(record.lineage, lineage.Prefix) } return record, true, nil } + for _, record := range candidates { + if record.ownerEdgeID != ownerEdgeID || record.principalRef != principalRef || record.protocol != protocol { + continue + } + if record.lineage != lineage.Prefix { + return nil, true, describeArtifactPrefixMismatch(record.lineage, lineage.Prefix) + } + if record.pendingHash != lineage.IssuedCallHash { + return nil, true, fmt.Errorf("%w: issued tool calls changed", errLogicalRequestLineage) + } + } return nil, true, errLogicalRequestLineage } diff --git a/apps/edge/internal/openai/workspace_tool_binding.go b/apps/edge/internal/openai/workspace_tool_binding.go index 0b726427..2f00a06a 100644 --- a/apps/edge/internal/openai/workspace_tool_binding.go +++ b/apps/edge/internal/openai/workspace_tool_binding.go @@ -91,6 +91,18 @@ type workspaceBinding struct { fingerprint string } +func (b *workspaceBinding) requiresCollectedProjection() bool { + if b == nil { + return false + } + for _, operation := range b.operations { + if operation != nil && operation.mode == modeCommand { + return true + } + } + return false +} + // compileWorkspaceBinding selects the first configured alternative whose every // declared operation matches an actual decoded tool by exact tool name and // recursive schema matcher. It never infers workspace roles from tool-name diff --git a/apps/edge/internal/openai/workspace_tool_binding_test.go b/apps/edge/internal/openai/workspace_tool_binding_test.go index d20473d5..2c583c26 100644 --- a/apps/edge/internal/openai/workspace_tool_binding_test.go +++ b/apps/edge/internal/openai/workspace_tool_binding_test.go @@ -306,6 +306,17 @@ func TestWorkspaceCommandEncodingAndGuards(t *testing.T) { }) } +func TestWorkspaceBindingCommandModeRequiresCollectedProjection(t *testing.T) { + command := mustBinding(t, fullWorkspaceAlternative("command", "bash", true), []any{openAIChatTool("bash", commandSchema())}) + if !command.requiresCollectedProjection() { + t.Fatal("command-mode binding must collect before caller projection") + } + structured := mustBinding(t, fullWorkspaceAlternative("structured", "workspace", false), []any{openAIChatTool("workspace", structuredSchema())}) + if structured.requiresCollectedProjection() { + t.Fatal("structured binding unexpectedly disabled progressive projection") + } +} + func TestWorkspaceBindingReceipts(t *testing.T) { binding := mustBinding(t, workspaceAlternative("structured", "write_file", false, true), []any{openAIChatTool("write_file", structuredSchema())}) payload, err := encodeWorkspaceCall(binding, opKindWrite, normalizedToolCall{ From 6f809f99b039c177a06c9bbafe177abac83966df Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 07:00:09 +0900 Subject: [PATCH 16/37] fix(openai): preserve cleanup call identity --- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 1 + apps/edge/internal/openai/hot_path_cleanup.go | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 19930c93..09faabac 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work/Review provider가 반환한 canonical reserved-artifact operation 이름도 선택된 caller binding의 실제 tool 이름으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work/Review provider가 반환한 canonical reserved-artifact operation 이름도 선택된 caller binding의 실제 tool 이름으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 972485bb..d69958c9 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -377,6 +377,7 @@ sequenceDiagram - 2026-08-15: Bound continuation issue identity to semantic tool calls so compatible SDK decoration of assistant messages does not break an otherwise exact caller receipt. - 2026-08-15: Normalized Work/Review canonical reserved-artifact operation names through the selected caller binding before encoding them into structured or command-mode caller tools. - 2026-08-15: Buffered command-mode Work/Review provider stages until canonical operation validation and caller-binding projection complete, preventing internal tool deltas from escaping before normalization while retaining structured-binding progressive streams. +- 2026-08-15: Removed the redundant outer identity projection from caller-stage-only cleanup so its issued semantic hash and caller-visible call ID remain identical. - 2026-08-14: Added the Edge-owned provider-side selector instruction for unmarked caller-workspace initial/resume turns. Actual Chat/Messages bodies now carry the exact request-local relative paths and accepted PLAN/pending REVIEW grammar without changing caller snapshots or public schemas. - 2026-08-14: Added the restored unmarked caller-workspace light route, including template-validated PLAN/pending REVIEW creation, worker-owned completed REVIEW handoff, reviewer reads/result inspection/repair, reviewer-owned non-empty terminal, and strict separation from marked Node-owned `single_request` presets. - 2026-08-14: Added bounded read/list-first workspace analysis inside the marked Plan stage. Successful inspection is required before structured Plan completion, while mutation tools, a separate analysis artifact, and caller-visible schema changes remain absent. diff --git a/apps/edge/internal/openai/hot_path_cleanup.go b/apps/edge/internal/openai/hot_path_cleanup.go index 87dfd48d..6cca5ec2 100644 --- a/apps/edge/internal/openai/hot_path_cleanup.go +++ b/apps/edge/internal/openai/hot_path_cleanup.go @@ -219,7 +219,7 @@ func (s *hotPathLightStore) beginCleanupLocked( if record.protocol == "anthropic" { cleanupOutput.TerminalReason = "tool_use" } - if outer != nil { + if outer != nil && !cleanupOutput.CallerStageOnly { if err := runHotPathCollectedStage(ctx, outer, cleanupStageID, cleanupOutput); err != nil { return normalizedStageOutput{}, fmt.Errorf("collect cleanup outer turn: %w", err) } From 9baf54a026539759be20bbf686ee0ae18999a6eb Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 07:03:06 +0900 Subject: [PATCH 17/37] fix(openai): normalize reserved path aliases --- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 1 + apps/edge/internal/openai/hot_path_light.go | 2 ++ apps/edge/internal/openai/hot_path_light_test.go | 2 +- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 09faabac..9fc566b2 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work/Review provider가 반환한 canonical reserved-artifact operation 이름도 선택된 caller binding의 실제 tool 이름으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work/Review provider가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index d69958c9..d5a43f30 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -378,6 +378,7 @@ sequenceDiagram - 2026-08-15: Normalized Work/Review canonical reserved-artifact operation names through the selected caller binding before encoding them into structured or command-mode caller tools. - 2026-08-15: Buffered command-mode Work/Review provider stages until canonical operation validation and caller-binding projection complete, preventing internal tool deltas from escaping before normalization while retaining structured-binding progressive streams. - 2026-08-15: Removed the redundant outer identity projection from caller-stage-only cleanup so its issued semantic hash and caller-visible call ID remain identical. +- 2026-08-15: Canonicalized reserved Work/Review path aliases such as caller `filePath` back to the issued path field before command-mode binding encode. - 2026-08-14: Added the Edge-owned provider-side selector instruction for unmarked caller-workspace initial/resume turns. Actual Chat/Messages bodies now carry the exact request-local relative paths and accepted PLAN/pending REVIEW grammar without changing caller snapshots or public schemas. - 2026-08-14: Added the restored unmarked caller-workspace light route, including template-validated PLAN/pending REVIEW creation, worker-owned completed REVIEW handoff, reviewer reads/result inspection/repair, reviewer-owned non-empty terminal, and strict separation from marked Node-owned `single_request` presets. - 2026-08-14: Added bounded read/list-first workspace analysis inside the marked Plan stage. Successful inspection is required before structured Plan completion, while mutation tools, a separate analysis artifact, and caller-visible schema changes remain absent. diff --git a/apps/edge/internal/openai/hot_path_light.go b/apps/edge/internal/openai/hot_path_light.go index 927b939f..e46a87f0 100644 --- a/apps/edge/internal/openai/hot_path_light.go +++ b/apps/edge/internal/openai/hot_path_light.go @@ -544,6 +544,8 @@ func mapHotPathStageCalls(record *hotPathLightRecord, output normalizedStageOutp } callerCall := call callerCall.Name = bound.toolName + callerCall.Arguments = cloneAnyMap(call.Arguments) + setMappedArgument(callerCall.Arguments, bound.pathField, requiredPath) mapped, payload, err = mapArtifactCall(record.binding, callerCall, operation, requiredPath, coordinator) if err != nil { return normalizedStageOutput{}, nil, err diff --git a/apps/edge/internal/openai/hot_path_light_test.go b/apps/edge/internal/openai/hot_path_light_test.go index cf3ced88..80e2e408 100644 --- a/apps/edge/internal/openai/hot_path_light_test.go +++ b/apps/edge/internal/openai/hot_path_light_test.go @@ -41,7 +41,7 @@ func TestHotPathStageCanonicalReadMapsToCallerCommandTool(t *testing.T) { coordinator := newLogicalRequestCoordinator(logicalRequestCoordinatorOptions{IDSource: func() (string, error) { return "call_public", nil }}) paths := newReservedPaths(record.requestID) mapped, pending, err := mapHotPathStageCalls(record, normalizedStageOutput{ToolCalls: []normalizedToolCall{{ - ID: "provider_read", Name: "read", Arguments: map[string]any{"path": paths.PlanPath}, + ID: "provider_read", Name: "read", Arguments: map[string]any{"filePath": paths.PlanPath}, }}}, hotPathPendingLocalTools, coordinator, nil) if err != nil { t.Fatal(err) From bfaa9e868473a77645f54715400d7017267334fb Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 07:09:13 +0900 Subject: [PATCH 18/37] fix(openai): canonicalize worker review handoff --- .../outer/anthropic-compatible-api.md | 3 +- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 1 + .../edge/internal/openai/hot_path_dispatch.go | 27 +++++++++++-- apps/edge/internal/openai/hot_path_light.go | 40 +++++++++++++++++++ .../internal/openai/hot_path_light_test.go | 25 ++++++++++++ .../internal/openai/hot_path_stage_input.go | 4 +- 7 files changed, 96 insertions(+), 6 deletions(-) diff --git a/agent-contract/outer/anthropic-compatible-api.md b/agent-contract/outer/anthropic-compatible-api.md index 22b1896d..5841583b 100644 --- a/agent-contract/outer/anthropic-compatible-api.md +++ b/agent-contract/outer/anthropic-compatible-api.md @@ -96,7 +96,8 @@ admitted by the caller request. Edge never receives or configures a raw caller workspace path. Before issuing caller writes, Edge validates the selector's default lightweight PLAN and pending REVIEW seed and retains the PLAN `P1..Pn` inventory request-locally. Work must read PLAN, perform and verify caller-workspace -work, and replace REVIEW with a completed handoff covering every PLAN item. Review +work, and use the Edge-owned canonical `iop_write_worker_review(content)` tool once +to replace REVIEW with a completed handoff covering every PLAN item. Review must read both artifacts and successfully inspect an ordinary caller result before it can return a non-empty caller-visible terminal. It never rewrites REVIEW; repair and re-verification remain in the same Review binding. Cleanup targets only the diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 9fc566b2..56d614af 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work/Review provider가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work provider에는 ordinary caller tools와 함께 단일 canonical `iop_write_worker_review(content)` handoff tool을 제공하며, 완료된 REVIEW는 이 도구만 사용한다. Work/Review provider가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index d5a43f30..4ec9f903 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -379,6 +379,7 @@ sequenceDiagram - 2026-08-15: Buffered command-mode Work/Review provider stages until canonical operation validation and caller-binding projection complete, preventing internal tool deltas from escaping before normalization while retaining structured-binding progressive streams. - 2026-08-15: Removed the redundant outer identity projection from caller-stage-only cleanup so its issued semantic hash and caller-visible call ID remain identical. - 2026-08-15: Canonicalized reserved Work/Review path aliases such as caller `filePath` back to the issued path field before command-mode binding encode. +- 2026-08-15: Added one Work-only canonical REVIEW handoff tool so completed control content is normalized into the caller binding instead of depending on an arbitrary caller edit or shell schema. - 2026-08-14: Added the Edge-owned provider-side selector instruction for unmarked caller-workspace initial/resume turns. Actual Chat/Messages bodies now carry the exact request-local relative paths and accepted PLAN/pending REVIEW grammar without changing caller snapshots or public schemas. - 2026-08-14: Added the restored unmarked caller-workspace light route, including template-validated PLAN/pending REVIEW creation, worker-owned completed REVIEW handoff, reviewer reads/result inspection/repair, reviewer-owned non-empty terminal, and strict separation from marked Node-owned `single_request` presets. - 2026-08-14: Added bounded read/list-first workspace analysis inside the marked Plan stage. Successful inspection is required before structured Plan completion, while mutation tools, a separate analysis artifact, and caller-visible schema changes remain absent. diff --git a/apps/edge/internal/openai/hot_path_dispatch.go b/apps/edge/internal/openai/hot_path_dispatch.go index 1ad3835c..d0e0c627 100644 --- a/apps/edge/internal/openai/hot_path_dispatch.go +++ b/apps/edge/internal/openai/hot_path_dispatch.go @@ -1712,7 +1712,7 @@ func hotPathStageMetaFromDispatch(stageID string, dispatch edgeservice.RunDispat func hotPathStageRunInput(snapshot hotPathDispatchSnapshot, prompt string) map[string]any { messages := hotPathChatStageMessages(snapshot, prompt) input := map[string]any{"prompt": prompt, "messages": messages} - if tools := hotPathChatTools(snapshot.Tools); len(tools) > 0 { + if tools := hotPathChatTools(hotPathStageProviderTools(snapshot)); len(tools) > 0 { input["tools"] = tools input["tool_choice"] = "auto" } @@ -1733,7 +1733,7 @@ func hotPathChatStageBody(snapshot hotPathDispatchSnapshot, prompt, target strin body := map[string]any{ "model": target, "messages": hotPathChatStageMessages(snapshot, prompt), "stream": snapshot.Stream, } - if tools := hotPathChatTools(snapshot.Tools); len(tools) > 0 { + if tools := hotPathChatTools(hotPathStageProviderTools(snapshot)); len(tools) > 0 { body["tools"] = tools body["tool_choice"] = "auto" } @@ -1750,7 +1750,7 @@ func hotPathAnthropicStageBody(snapshot hotPathDispatchSnapshot, prompt, target body := map[string]any{ "model": target, "max_tokens": 4096, "messages": hotPathAnthropicStageMessages(snapshot, prompt), "stream": snapshot.Stream, } - if tools := hotPathAnthropicTools(snapshot.Tools); len(tools) > 0 { + if tools := hotPathAnthropicTools(hotPathStageProviderTools(snapshot)); len(tools) > 0 { body["tools"] = tools body["tool_choice"] = map[string]any{"type": "auto"} } @@ -1763,6 +1763,27 @@ func hotPathAnthropicStageBody(snapshot hotPathDispatchSnapshot, prompt, target return json.Marshal(body) } +func hotPathStageProviderTools(snapshot hotPathDispatchSnapshot) []any { + tools := cloneAnySlice(snapshot.Tools) + if snapshot.Phase != hotPathPhaseLocalActive { + return tools + } + return append(tools, map[string]any{ + "type": "function", + "function": map[string]any{ + "name": hotPathWorkerReviewToolName, + "description": "Commit the complete validated worker REVIEW handoff after implementation and verification.", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "content": map[string]any{"type": "string", "description": "Complete REVIEW markdown"}, + }, + "required": []any{"content"}, "additionalProperties": false, + }, + }, + }) +} + func applyHotPathStageOptions(body map[string]any, options map[string]any, reserved map[string]struct{}) { for key, value := range options { if _, blocked := reserved[key]; blocked { diff --git a/apps/edge/internal/openai/hot_path_light.go b/apps/edge/internal/openai/hot_path_light.go index e46a87f0..47e377f8 100644 --- a/apps/edge/internal/openai/hot_path_light.go +++ b/apps/edge/internal/openai/hot_path_light.go @@ -881,6 +881,39 @@ func (s *hotPathLightStore) commitLocal(requestID, ownerEdgeID string, output no return hotPathLightDisposition{RequestID: requestID, StageID: reviewStageID, Phase: record.phase}, nil } +func (s *hotPathLightStore) annotateWorkerReviewHandoff( + requestID, ownerEdgeID string, + output normalizedStageOutput, +) (normalizedStageOutput, error) { + if s == nil { + return normalizedStageOutput{}, fmt.Errorf("light flow is unavailable") + } + s.mu.Lock() + defer s.mu.Unlock() + record := s.records[requestID] + if record == nil || record.ownerEdgeID != ownerEdgeID || record.phase != hotPathPhaseLocalActive { + return normalizedStageOutput{}, fmt.Errorf("local flow state is unavailable") + } + paths := newReservedPaths(requestID) + seen := 0 + for index := range output.ToolCalls { + call := &output.ToolCalls[index] + if call.Name != hotPathWorkerReviewToolName { + continue + } + content, ok := call.Arguments["content"].(string) + if !ok || strings.TrimSpace(content) == "" { + return normalizedStageOutput{}, fmt.Errorf("worker REVIEW handoff content is required") + } + call.Path = paths.ReviewPath + seen++ + } + if seen > 1 { + return normalizedStageOutput{}, fmt.Errorf("worker REVIEW handoff must be emitted exactly once") + } + return output, nil +} + func (s *hotPathLightStore) classifyLocalOutput(requestID, ownerEdgeID string, output normalizedStageOutput) (hotPathPendingKind, error) { if s == nil { return "", fmt.Errorf("light flow is unavailable") @@ -1032,6 +1065,13 @@ func (s *Server) runHotPathLightStage(w http.ResponseWriter, r *http.Request, di return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID, hotPathLightEndpointErrorForCause(protocol, http.StatusBadGateway, snapshot.StageID, err)) } + if snapshot.Phase == hotPathPhaseLocalActive { + output, err = s.lightFlows.annotateWorkerReviewHandoff(requestID, s.edgeIDValue(), output) + if err != nil { + return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID, + hotPathLightEndpointError(protocol, http.StatusBadRequest, err.Error())) + } + } visible = mergeVisibleStageOutput(visible, output) // The collector compatibility path remains the endpoint renderer until // endpoint codecs consume released deltas directly. Feed the same diff --git a/apps/edge/internal/openai/hot_path_light_test.go b/apps/edge/internal/openai/hot_path_light_test.go index 80e2e408..7304d0f1 100644 --- a/apps/edge/internal/openai/hot_path_light_test.go +++ b/apps/edge/internal/openai/hot_path_light_test.go @@ -54,6 +54,31 @@ func TestHotPathStageCanonicalReadMapsToCallerCommandTool(t *testing.T) { } } +func TestWorkerReviewHandoffUsesCanonicalStageTool(t *testing.T) { + tools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseLocalActive}) + schemas, err := normalizeToolSchemas(tools) + if err != nil || schemas[hotPathWorkerReviewToolName] == nil { + t.Fatalf("local canonical handoff tool=%+v err=%v", schemas, err) + } + if reviewTools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseReviewActive}); len(reviewTools) != 0 { + t.Fatalf("review stage unexpectedly received worker handoff tool: %+v", reviewTools) + } + + store := newHotPathLightStore(1) + store.records["req_handoff"] = &hotPathLightRecord{ + requestID: "req_handoff", ownerEdgeID: "edge", phase: hotPathPhaseLocalActive, + } + output, err := store.annotateWorkerReviewHandoff("req_handoff", "edge", normalizedStageOutput{ToolCalls: []normalizedToolCall{{ + ID: "provider_handoff", Name: hotPathWorkerReviewToolName, Arguments: map[string]any{"content": "# Review\ncomplete"}, + }}}) + if err != nil { + t.Fatal(err) + } + if output.ToolCalls[0].Path != newReservedPaths("req_handoff").ReviewPath { + t.Fatalf("annotated handoff=%+v", output.ToolCalls[0]) + } +} + func TestHotPathLightRequiresCompletedWorkerHandoff(t *testing.T) { for _, endpoint := range []string{"openai", "anthropic"} { endpoint := endpoint diff --git a/apps/edge/internal/openai/hot_path_stage_input.go b/apps/edge/internal/openai/hot_path_stage_input.go index 80070eff..f223c3dc 100644 --- a/apps/edge/internal/openai/hot_path_stage_input.go +++ b/apps/edge/internal/openai/hot_path_stage_input.go @@ -7,6 +7,8 @@ import ( "unicode" ) +const hotPathWorkerReviewToolName = "iop_write_worker_review" + type hotPathArtifactPaths struct { PlanPath string ReviewPath string @@ -125,7 +127,7 @@ func (in hotPathStageInput) prompt(phase hotPathLightPhase) (string, error) { switch in.Role { case "local": - b.WriteString("Read the exact issued PLAN before doing any work. Perform the task and verification in the caller workspace with ordinary caller tools. Then overwrite the exact issued REVIEW path using this closed handoff grammar: '# Review', '## Worker Item Status' with every PLAN item exactly once as '- Pn: completed', '## Worker Changes', '## Worker Verification', and '## Deviations'; every section must be non-empty. Do not complete without a successful PLAN read and REVIEW write.") + b.WriteString("Read the exact issued PLAN before doing any work. Perform the task and verification in the caller workspace with ordinary caller tools. Then call iop_write_worker_review exactly once with the complete REVIEW content using this closed handoff grammar: '# Review', '## Worker Item Status' with every PLAN item exactly once as '- Pn: completed', '## Worker Changes', '## Worker Verification', and '## Deviations'; every section must be non-empty. Do not use an ordinary caller tool for the issued REVIEW path. Do not complete without a successful PLAN read and this REVIEW handoff call.") case "review": switch phase { case hotPathPhaseReviewActive: From c1a402fe50503d6e8cd8210668a6085fdc1919e5 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 07:53:31 +0900 Subject: [PATCH 19/37] =?UTF-8?q?fix(openai):=20=EB=A1=9C=EC=BB=AC=20Work?= =?UTF-8?q?=20=EC=9D=B8=EA=B3=84=EB=A5=BC=20=EB=8B=A8=EC=88=9C=ED=99=94?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로컬 모델이 내부 REVIEW 프로토콜에 결합되지 않고 PLAN만 읽어 작업하도록 책임 경계를 Edge로 옮긴다. --- .../outer/anthropic-compatible-api.md | 10 +- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 3 +- apps/edge/internal/openai/artifact_pair.go | 12 ++ .../internal/openai/artifact_pair_test.go | 9 ++ .../openai/hot_path_anthropic_gate_test.go | 31 +++-- .../openai/hot_path_chat_gate_test.go | 13 +- .../internal/openai/hot_path_cleanup_test.go | 12 +- .../edge/internal/openai/hot_path_dispatch.go | 19 +-- apps/edge/internal/openai/hot_path_light.go | 116 ++++++++++++++---- .../internal/openai/hot_path_light_test.go | 113 ++++++----------- .../internal/openai/hot_path_stage_input.go | 5 +- .../internal/openai/provider_model_rewrite.go | 1 + .../openai/request_identity_ingress.go | 4 + 14 files changed, 204 insertions(+), 146 deletions(-) diff --git a/agent-contract/outer/anthropic-compatible-api.md b/agent-contract/outer/anthropic-compatible-api.md index 5841583b..06204a88 100644 --- a/agent-contract/outer/anthropic-compatible-api.md +++ b/agent-contract/outer/anthropic-compatible-api.md @@ -95,9 +95,13 @@ An unmarked `light` preset with `workspace_tools` uses only the tool schemas admitted by the caller request. Edge never receives or configures a raw caller workspace path. Before issuing caller writes, Edge validates the selector's default lightweight PLAN and pending REVIEW seed and retains the PLAN `P1..Pn` -inventory request-locally. Work must read PLAN, perform and verify caller-workspace -work, and use the Edge-owned canonical `iop_write_worker_review(content)` tool once -to replace REVIEW with a completed handoff covering every PLAN item. Review +inventory request-locally. Selector instructions require every PLAN field to be +English ASCII, and Edge rejects a non-ASCII PLAN before issuing caller writes. +Work receives only the relative PLAN path followed by the fixed English sentence +`Read it and complete the task.` and sees only ordinary caller tools. It must read +PLAN and perform and verify caller-workspace work. After a successful PLAN read and +a non-empty terminal worker report, Edge renders and validates the completed REVIEW +handoff and projects its single caller write; Work does not own that protocol. Review must read both artifacts and successfully inspect an ordinary caller result before it can return a non-empty caller-visible terminal. It never rewrites REVIEW; repair and re-verification remain in the same Review binding. Cleanup targets only the diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 56d614af..ab6cb45f 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work provider에는 ordinary caller tools와 함께 단일 canonical `iop_write_worker_review(content)` handoff tool을 제공하며, 완료된 REVIEW는 이 도구만 사용한다. Work/Review provider가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Selector instruction은 모든 PLAN field를 English ASCII로 작성하도록 요구하고 Edge는 non-ASCII PLAN을 caller write 전에 거부한다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work provider의 전체 task prompt는 relative PLAN path와 고정 영문 `Read it and complete the task.`뿐이며 ordinary caller tools만 보인다. Work가 PLAN을 성공적으로 읽은 뒤 non-empty terminal report를 반환하면 Edge가 completed REVIEW template을 렌더·검증하고 단일 caller write로 투영한다. Work provider는 REVIEW handoff tool이나 protocol을 소유하지 않는다. Work/Review provider가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 4ec9f903..0da54bfd 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for the accepted artifact grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only canonical `prepare(path)` or one atomic `iop_write_artifact_pair(plan_content,review_content)` schema. Edge validates and expands the pair into two independently receipted writes encoded in the admitted caller tool schema such as `bash(command)`; the selector is not required to produce parallel tool calls. Continuation issue identity covers semantic tool-call ID/name/arguments rather than SDK-decorated assistant content, reasoning, or provider metadata, while request prefix and result IDs stay independently validated. The instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block, does not mutate the retained caller snapshot or add a public field, and is absent from Work, Review, cleanup, and marked `single_request` bodies. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. The selector's lightweight PLAN and pending REVIEW seed are validated before caller writes. Work reads PLAN, executes/verifies in the caller workspace, and replaces REVIEW with a completed `P1..Pn` handoff. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for the accepted artifact grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only canonical `prepare(path)` or one atomic `iop_write_artifact_pair(plan_content,review_content)` schema. The selector must write every PLAN field in English ASCII; Edge rejects non-ASCII PLAN content. Edge validates and expands the pair into two independently receipted writes encoded in the admitted caller tool schema such as `bash(command)`; the selector is not required to produce parallel tool calls. Continuation issue identity covers semantic tool-call ID/name/arguments rather than SDK-decorated assistant content, reasoning, or provider metadata, while request prefix and result IDs stay independently validated. The selector instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block and does not mutate the retained caller snapshot or add a public field. Work receives exactly the relative PLAN path plus `Read it and complete the task.` and only ordinary caller tools. After a successful PLAN read and a non-empty terminal worker report, Edge renders and validates the completed `P1..Pn` REVIEW handoff and projects its single caller write; Work does not manage that protocol. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | @@ -420,6 +420,7 @@ sequenceDiagram - 2026-08-14: Applied the same provider-normalization boundary to private single-request Plan/Work/Review stages. Removed pre-selection Chat operation pinning, added Chat↔Responses stage conversion and nearest-lower effort mapping, and corrected default-selector dispatch validation so the pool-selected provider is accepted without weakening explicit provider, profile, credential, target, or execution-path fences. - 2026-08-14: Canonicalized private Chat Completions stage responses before strict Plan/Work/Review decoding. Standard OpenAI bookkeeping fields are stripped centrally, a non-null refusal remains fail-closed, and Responses/Chat now converge on the same private envelope instead of making stage codecs provider-specific. - 2026-08-14: Added provider `timings` to the bounded Chat response bookkeeping stripped before private stage decoding, aligning the common normalizer with the existing Ornith Work codec without admitting unknown response fields. +- 2026-08-15: Reduced the unmarked light Work input to the relative PLAN path plus one fixed English sentence, limited Work to caller tools, moved completed REVIEW projection into Edge after the worker terminal report, and required English-ASCII PLAN content. - 2026-08-13: Gemini-native path parsing now keeps route IDs strict while accepting the bounded URL-encoded official caller model label `Gemini 3.6 Flash`. - 2026-08-06: Synchronized always-owned Chat/Responses typed-stall recovery, provider avoidance/fallback admission, and closed-label liveness operational evidence with the current runtime, contracts, and deterministic recovery tests. - 2026-08-06: Added marked single-request Messages admission through the separate service coordinator capability, one unlabeled runtime ingress counter, buffered sanitized terminal acknowledgement, and deterministic real-POST compatibility evidence. diff --git a/apps/edge/internal/openai/artifact_pair.go b/apps/edge/internal/openai/artifact_pair.go index 6d47942f..8fd8c642 100644 --- a/apps/edge/internal/openai/artifact_pair.go +++ b/apps/edge/internal/openai/artifact_pair.go @@ -402,6 +402,9 @@ func mapArtifactOutput( return normalizedStageOutput{}, nil, err } if cleanRelativePath(requiredPath) == cleanRelativePath(issued.PlanPath) { + if err := validateEnglishOnlyPlan(content); err != nil { + return normalizedStageOutput{}, nil, err + } plan, err := singlerequesttemplate.ParsePlan( singlerequesttemplate.DefaultPlanTemplate, content, @@ -424,6 +427,15 @@ func mapArtifactOutput( return artifactResponseOutput(output, mappedCalls), payloads, nil } +func validateEnglishOnlyPlan(content string) error { + for _, value := range []byte(content) { + if value >= 0x80 { + return fmt.Errorf("artifact plan must contain English ASCII text only") + } + } + return nil +} + func artifactWriteContent(binding *workspaceBinding, call normalizedToolCall) (string, error) { if binding == nil { return "", fmt.Errorf("artifact write binding is unavailable") diff --git a/apps/edge/internal/openai/artifact_pair_test.go b/apps/edge/internal/openai/artifact_pair_test.go index 554daace..e9cdd32b 100644 --- a/apps/edge/internal/openai/artifact_pair_test.go +++ b/apps/edge/internal/openai/artifact_pair_test.go @@ -15,6 +15,15 @@ import ( "iop/packages/go/singlerequesttemplate" ) +func TestValidateEnglishOnlyPlan(t *testing.T) { + if err := validateEnglishOnlyPlan("# Plan\n\n## Goal\nCreate index.html.\n"); err != nil { + t.Fatalf("English PLAN rejected: %v", err) + } + if err := validateEnglishOnlyPlan("# Plan\n\n## Goal\n파일을 생성한다.\n"); err == nil { + t.Fatal("non-English PLAN was accepted") + } +} + func testLightweightPlan(t *testing.T) string { t.Helper() plan, err := singlerequesttemplate.RenderPlan(singlerequesttemplate.DefaultPlanTemplate, singlerequesttemplate.PlanFields{ diff --git a/apps/edge/internal/openai/hot_path_anthropic_gate_test.go b/apps/edge/internal/openai/hot_path_anthropic_gate_test.go index a3b90e32..6100ac44 100644 --- a/apps/edge/internal/openai/hot_path_anthropic_gate_test.go +++ b/apps/edge/internal/openai/hot_path_anthropic_gate_test.go @@ -165,29 +165,28 @@ func TestHotPathAnthropicLightStreamAggregatesStages(t *testing.T) { fixture.consumeToolResponse(pair, []string{`{"written":true}`, `{"written":true}`}) localRead := fixture.request() fixture.consumeToolResponse(localRead, []string{`{"written":true}`}) - workerHandoff := fixture.request() - fixture.consumeToolResponse(workerHandoff, []string{`{"ok":true}`, `{"written":true}`}) + localWork := fixture.request() + fixture.consumeToolResponse(localWork, []string{`{"ok":true}`}) + workerReview := fixture.request() + fixture.consumeToolResponse(workerReview, []string{`{"written":true}`}) before := len(fixture.service.snapshots()) response := fixture.requestWithOptions(64, true) if response.Code != http.StatusOK { t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) } - if got := len(fixture.service.snapshots()) - before; got != 2 { - t.Fatalf("same-turn provider stages=%d, want 2", got) + if got := len(fixture.service.snapshots()) - before; got != 1 { + t.Fatalf("same-turn provider stages=%d, want 1", got) } requests := fixture.service.snapshots() - assertCapturedHotPathBudget(t, requests[len(requests)-2], fixture.service.candidate, 64) - assertCapturedHotPathBudget(t, requests[len(requests)-1], fixture.service.candidate, 61) + assertCapturedHotPathBudget(t, requests[len(requests)-1], fixture.service.candidate, 64) events := decodeHotPathAnthropicSSE(t, response.Body.String()) - assertHotPathAnthropicBlockIndexes(t, events, 7) + assertHotPathAnthropicBlockIndexes(t, events, 5) wantNames := []string{ "message_start", "content_block_start", "content_block_delta", "content_block_delta", "content_block_stop", "content_block_start", "content_block_delta", "content_block_stop", - "content_block_start", "content_block_delta", "content_block_delta", "content_block_stop", - "content_block_start", "content_block_delta", "content_block_stop", "content_block_start", "content_block_delta", "content_block_stop", "content_block_start", "content_block_delta", "content_block_stop", "content_block_start", "content_block_delta", "content_block_stop", @@ -198,11 +197,11 @@ func TestHotPathAnthropicLightStreamAggregatesStages(t *testing.T) { } startMessage := hotPathAnthropicMap(t, events[0].payload["message"]) requestID, snapshot := soleHotPathSnapshot(t, fixture.server) - if startMessage["id"] != "msg-light-complete" || startMessage["id"] == requestID || startMessage["model"] != "virtual-model" { + if startMessage["id"] != "msg-review-inspection" || startMessage["id"] == requestID || startMessage["model"] != "virtual-model" { t.Fatalf("outer identity mismatch: message=%+v logical_request=%s", startMessage, requestID) } - wantKinds := []string{"thinking", "text", "thinking", "text", "tool_use", "tool_use", "tool_use"} + wantKinds := []string{"thinking", "text", "tool_use", "tool_use", "tool_use"} var gotKinds, thinking, text []string var toolIDs, toolNames, toolArgs []string var stopReason string @@ -229,13 +228,13 @@ func TestHotPathAnthropicLightStreamAggregatesStages(t *testing.T) { delta := hotPathAnthropicMap(t, event.payload["delta"]) stopReason, _ = delta["stop_reason"].(string) usage := hotPathAnthropicMap(t, event.payload["usage"]) - if usage["input_tokens"] != float64(12) || usage["output_tokens"] != float64(7) { - t.Fatalf("aggregate usage=%+v, want input=12 output=7", usage) + if usage["input_tokens"] != float64(7) || usage["output_tokens"] != float64(4) { + t.Fatalf("review usage=%+v, want input=7 output=4", usage) } } } if strings.Join(gotKinds, ",") != strings.Join(wantKinds, ",") || - strings.Join(thinking, "") != "local-reasonreview-reason" || strings.Join(text, "") != "local-visiblereview-visible" || + strings.Join(thinking, "") != "review-reason" || strings.Join(text, "") != "review-visible" || strings.Join(toolNames, ",") != "read_file,read_file,run_command" || len(toolArgs) != 3 || stopReason != "tool_use" { t.Fatalf("multi-stage output mismatch: kinds=%v thinking=%v text=%v tool=%q/%q/%q stop=%q body=%s", gotKinds, thinking, text, toolIDs, toolNames, toolArgs, stopReason, response.Body.String()) @@ -243,8 +242,8 @@ func TestHotPathAnthropicLightStreamAggregatesStages(t *testing.T) { if len(snapshot.ExpectedCallIDs) != 3 { t.Fatalf("tool correlation mismatch: tools=%q snapshot=%+v", toolIDs, snapshot) } - if strings.Join(toolIDs, ",") != "msg-light-complete-tool-1,msg-light-complete-tool-2,msg-light-complete-tool-3" || strings.Contains(response.Body.String(), "msg-review-inspection") { - t.Fatalf("public identity/tool namespace leaked a later provider id: tools=%q body=%s", toolIDs, response.Body.String()) + if strings.Join(toolIDs, ",") != "msg-review-inspection-tool-1,msg-review-inspection-tool-2,msg-review-inspection-tool-3" { + t.Fatalf("public tool namespace mismatch: tools=%q body=%s", toolIDs, response.Body.String()) } } diff --git a/apps/edge/internal/openai/hot_path_chat_gate_test.go b/apps/edge/internal/openai/hot_path_chat_gate_test.go index c370ca40..bae1f434 100644 --- a/apps/edge/internal/openai/hot_path_chat_gate_test.go +++ b/apps/edge/internal/openai/hot_path_chat_gate_test.go @@ -152,7 +152,7 @@ func TestHotPathChatMixedProviderStages(t *testing.T) { {candidate: openAICandidate, body: func(requestID string) string { return scriptedArtifactPrepare("openai", requestID) }}, {candidate: openAICandidate, body: func(requestID string) string { return scriptedArtifactPair("openai", requestID) }}, {candidate: openAICandidate, body: func(requestID string) string { return scriptedArtifactLocalRead("openai", requestID) }}, - {candidate: openAICandidate, body: func(requestID string) string { return scriptedLocalWorkAndHandoff("openai", requestID) }}, + {candidate: openAICandidate, body: func(string) string { return scriptedLocalWorkTool("openai") }}, {candidate: openAICandidate, contentType: "text/event-stream", body: func(string) string { return hotPathChatMixedLocalSSE() }}, {candidate: anthropicCandidate, body: func(requestID string) string { return scriptedReviewReadsAndInspectionWithUsage(requestID, 7, 3) }}, } @@ -192,7 +192,8 @@ func TestHotPathChatMixedProviderStages(t *testing.T) { consume(request(false), []string{`{"written":true}`}) consume(request(false), []string{`{"written":true}`, `{"written":true}`}) consume(request(false), []string{`{"written":true}`}) - consume(request(false), []string{`{"ok":true}`, `{"written":true}`}) + consume(request(false), []string{`{"ok":true}`}) + consume(request(false), []string{`{"written":true}`}) response := request(true) if response.Code != http.StatusOK { t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) @@ -203,16 +204,16 @@ func TestHotPathChatMixedProviderStages(t *testing.T) { } requestID, snapshot := soleHotPathSnapshot(t, srv) assertHotPathChatChunks(t, chunks, hotPathChatChunkExpectation{ - ResponseID: "chatcmpl-mixed-local", Model: "virtual-model", Content: "local-A local-Breview-visible", Reasoning: "local-think review-reason", - Kinds: []string{"content", "reasoning", "content", "reasoning", "content", "tool", "tool", "tool", "terminal"}, - FinishReason: "tool_calls", PromptTokens: 12, CompletionTokens: 7, + ResponseID: "msg-review-inspection", Model: "virtual-model", Content: "review-visible", Reasoning: "review-reason", + Kinds: []string{"reasoning", "content", "tool", "tool", "tool", "terminal"}, + FinishReason: "tool_calls", PromptTokens: 7, CompletionTokens: 3, }) for _, chunk := range chunks { if chunk["id"] == requestID { t.Fatalf("logical request identity became the public response id: %+v", chunk) } } - for _, internalID := range []string{snapshot.ActiveStageID, "run-chat-gate-5", "run-chat-gate-6", "msg-review-inspection"} { + for _, internalID := range []string{snapshot.ActiveStageID, "run-chat-gate-5", "run-chat-gate-6"} { if strings.Contains(response.Body.String(), internalID) { t.Fatalf("internal or later-stage identity %q leaked: %s", internalID, response.Body.String()) } diff --git a/apps/edge/internal/openai/hot_path_cleanup_test.go b/apps/edge/internal/openai/hot_path_cleanup_test.go index 30057e54..4ffed818 100644 --- a/apps/edge/internal/openai/hot_path_cleanup_test.go +++ b/apps/edge/internal/openai/hot_path_cleanup_test.go @@ -347,8 +347,10 @@ func preparePrimaryErrorStage(t *testing.T, fixture *scriptedLightFixture, revie if review { localRead := fixture.request() fixture.consumeToolResponse(localRead, []string{`{"written":true}`}) - workerHandoff := fixture.request() - fixture.consumeToolResponse(workerHandoff, []string{`{"ok":true}`, `{"written":true}`}) + localWork := fixture.request() + fixture.consumeToolResponse(localWork, []string{`{"ok":true}`}) + workerReview := fixture.request() + fixture.consumeToolResponse(workerReview, []string{`{"written":true}`}) } } @@ -434,8 +436,10 @@ func TestHotPathCleanupCancellationStopsWork(t *testing.T) { fixture.consumeToolResponse(pair, []string{`{"written":true}`, `{"written":true}`}) localRead := fixture.request() fixture.consumeToolResponse(localRead, []string{`{"written":true}`}) - workerHandoff := fixture.request() - fixture.consumeToolResponse(workerHandoff, []string{`{"ok":true}`, `{"written":true}`}) + localWork := fixture.request() + fixture.consumeToolResponse(localWork, []string{`{"ok":true}`}) + workerReview := fixture.request() + fixture.consumeToolResponse(workerReview, []string{`{"written":true}`}) reviewInspection := fixture.request() fixture.consumeToolResponse(reviewInspection, []string{`{"written":true}`, `{"written":true}`, `{"ok":true}`}) diff --git a/apps/edge/internal/openai/hot_path_dispatch.go b/apps/edge/internal/openai/hot_path_dispatch.go index d0e0c627..1b17a6b6 100644 --- a/apps/edge/internal/openai/hot_path_dispatch.go +++ b/apps/edge/internal/openai/hot_path_dispatch.go @@ -1764,24 +1764,7 @@ func hotPathAnthropicStageBody(snapshot hotPathDispatchSnapshot, prompt, target } func hotPathStageProviderTools(snapshot hotPathDispatchSnapshot) []any { - tools := cloneAnySlice(snapshot.Tools) - if snapshot.Phase != hotPathPhaseLocalActive { - return tools - } - return append(tools, map[string]any{ - "type": "function", - "function": map[string]any{ - "name": hotPathWorkerReviewToolName, - "description": "Commit the complete validated worker REVIEW handoff after implementation and verification.", - "parameters": map[string]any{ - "type": "object", - "properties": map[string]any{ - "content": map[string]any{"type": "string", "description": "Complete REVIEW markdown"}, - }, - "required": []any{"content"}, "additionalProperties": false, - }, - }, - }) + return cloneAnySlice(snapshot.Tools) } func applyHotPathStageOptions(body map[string]any, options map[string]any, reserved map[string]struct{}) { diff --git a/apps/edge/internal/openai/hot_path_light.go b/apps/edge/internal/openai/hot_path_light.go index 47e377f8..c3318d30 100644 --- a/apps/edge/internal/openai/hot_path_light.go +++ b/apps/edge/internal/openai/hot_path_light.go @@ -9,6 +9,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" edgeservice "iop/apps/edge/internal/service" "iop/packages/go/config" @@ -111,6 +112,7 @@ type hotPathLightRecord struct { reviewerPlanRead bool reviewerReviewRead bool reviewerInspected bool + pendingLocalCommit *hotPathStageCorrelation running bool pendingKind hotPathPendingKind pending map[string]hotPathPendingCall @@ -744,7 +746,21 @@ func (s *hotPathLightStore) consume(ownerEdgeID, principalRef, protocol string, record.pendingHash = "" record.pendingOutput = normalizedStageOutput{} previousPhase := record.phase - record.phase = phaseAfterHotPathResult(pendingKind) + if pendingKind == hotPathPendingLocalHandoff { + if !record.workerReviewWritten || record.pendingLocalCommit == nil { + return logicalRequestSnapshot{}, hotPathLightDisposition{}, true, fmt.Errorf("worker completion review projection failed") + } + reviewStageID, err := coordinator.newStageID() + if err != nil { + return logicalRequestSnapshot{}, hotPathLightDisposition{}, true, err + } + record.localCommit = *record.pendingLocalCommit + record.pendingLocalCommit = nil + record.reviewStageID = reviewStageID + record.phase = hotPathPhaseReviewActive + } else { + record.phase = phaseAfterHotPathResult(pendingKind) + } record.pendingKind = "" stageID := record.localStageID if record.phase != hotPathPhaseLocalActive { @@ -881,37 +897,80 @@ func (s *hotPathLightStore) commitLocal(requestID, ownerEdgeID string, output no return hotPathLightDisposition{RequestID: requestID, StageID: reviewStageID, Phase: record.phase}, nil } -func (s *hotPathLightStore) annotateWorkerReviewHandoff( +func (s *hotPathLightStore) projectWorkerCompletionReview( requestID, ownerEdgeID string, output normalizedStageOutput, + correlation hotPathStageCorrelation, + coordinator *logicalRequestCoordinator, ) (normalizedStageOutput, error) { if s == nil { return normalizedStageOutput{}, fmt.Errorf("light flow is unavailable") } + if coordinator == nil { + return normalizedStageOutput{}, fmt.Errorf("logical request coordinator is unavailable") + } s.mu.Lock() defer s.mu.Unlock() record := s.records[requestID] - if record == nil || record.ownerEdgeID != ownerEdgeID || record.phase != hotPathPhaseLocalActive { + if record == nil || record.ownerEdgeID != ownerEdgeID || record.phase != hotPathPhaseLocalActive || !record.running || len(output.ToolCalls) != 0 { return normalizedStageOutput{}, fmt.Errorf("local flow state is unavailable") } - paths := newReservedPaths(requestID) - seen := 0 - for index := range output.ToolCalls { - call := &output.ToolCalls[index] - if call.Name != hotPathWorkerReviewToolName { - continue - } - content, ok := call.Arguments["content"].(string) - if !ok || strings.TrimSpace(content) == "" { - return normalizedStageOutput{}, fmt.Errorf("worker REVIEW handoff content is required") - } - call.Path = paths.ReviewPath - seen++ + if !record.localPlanRead { + return normalizedStageOutput{}, fmt.Errorf("worker completion requires a successful plan read") } - if seen > 1 { - return normalizedStageOutput{}, fmt.Errorf("worker REVIEW handoff must be emitted exactly once") + summary := boundedWorkerCompletionSummary(output.Content) + if summary == "" { + return normalizedStageOutput{}, fmt.Errorf("worker completion summary is required") } - return output, nil + statuses := make([]string, len(record.planItemIDs)) + for index, itemID := range record.planItemIDs { + statuses[index] = fmt.Sprintf("- %s: completed", itemID) + } + review, err := singlerequesttemplate.RenderReview( + singlerequesttemplate.DefaultReviewTemplate, + singlerequesttemplate.ReviewFields{ + ItemStatus: strings.Join(statuses, "\n"), + Changes: summary, + Verification: "Worker completion report: " + summary, + Deviations: "None reported by the worker.", + }, + singlerequesttemplate.MaxTemplateBytes, + ) + if err != nil || singlerequesttemplate.ValidateReviewHandoff(review, record.planItemIDs) != nil { + return normalizedStageOutput{}, fmt.Errorf("project worker completion review") + } + providerCallID, err := coordinator.newCallID() + if err != nil { + return normalizedStageOutput{}, err + } + correlation.StageID = record.localStageID + correlation.ResponseID = output.ResponseID + correlation.Terminal = output.TerminalReason + record.pendingLocalCommit = &correlation + projected := cloneNormalizedStageOutput(output) + projected.Content = "" + projected.Reasoning = "" + projected.ProgressivelyReleased = false + projected.CallerStageOnly = true + projected.ToolCalls = []normalizedToolCall{{ + ID: providerCallID, ProviderCallID: providerCallID, Name: hotPathWorkerReviewToolName, + Path: newReservedPaths(requestID).ReviewPath, + Arguments: map[string]any{"content": string(review)}, + }} + return projected, nil +} + +func boundedWorkerCompletionSummary(content string) string { + const maximum = 2048 + content = strings.TrimSpace(content) + if len(content) <= maximum { + return content + } + limit := maximum + for limit > 0 && !utf8.RuneStart(content[limit]) { + limit-- + } + return strings.TrimSpace(content[:limit]) + "..." } func (s *hotPathLightStore) classifyLocalOutput(requestID, ownerEdgeID string, output normalizedStageOutput) (hotPathPendingKind, error) { @@ -1065,8 +1124,21 @@ func (s *Server) runHotPathLightStage(w http.ResponseWriter, r *http.Request, di return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID, hotPathLightEndpointErrorForCause(protocol, http.StatusBadGateway, snapshot.StageID, err)) } - if snapshot.Phase == hotPathPhaseLocalActive { - output, err = s.lightFlows.annotateWorkerReviewHandoff(requestID, s.edgeIDValue(), output) + collected := false + if snapshot.Phase == hotPathPhaseLocalActive && len(output.ToolCalls) == 0 && !output.ProgressivelyReleased && !output.CallerStageOnly { + if err := runHotPathCollectedStage(r.Context(), outer, snapshot.StageID, output); err != nil { + return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID, + hotPathLightEndpointError(protocol, http.StatusBadGateway, err.Error())) + } + collected = true + } + if snapshot.Phase == hotPathPhaseLocalActive && len(output.ToolCalls) == 0 && outer.outputBudget().Exhausted { + return s.writeHotPathLightLengthTerminal(w, r, dispatch, protocol, stream, requestID, output) + } + if snapshot.Phase == hotPathPhaseLocalActive && len(output.ToolCalls) == 0 && !hotPathIsProviderLengthTerminal(output.TerminalReason) { + output, err = s.lightFlows.projectWorkerCompletionReview( + requestID, s.edgeIDValue(), output, correlation, s.requestCoordinator, + ) if err != nil { return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID, hotPathLightEndpointError(protocol, http.StatusBadRequest, err.Error())) @@ -1077,7 +1149,7 @@ func (s *Server) runHotPathLightStage(w http.ResponseWriter, r *http.Request, di // endpoint codecs consume released deltas directly. Feed the same // output into the sequencer now so its usage and terminal boundary span // local→review transitions in this HTTP turn. - if len(output.ToolCalls) == 0 && !output.ProgressivelyReleased && !output.CallerStageOnly { + if len(output.ToolCalls) == 0 && !output.ProgressivelyReleased && !output.CallerStageOnly && !collected { if err := runHotPathCollectedStage(r.Context(), outer, snapshot.StageID, output); err != nil { return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID, hotPathLightEndpointError(protocol, http.StatusBadGateway, err.Error())) diff --git a/apps/edge/internal/openai/hot_path_light_test.go b/apps/edge/internal/openai/hot_path_light_test.go index 7304d0f1..51624041 100644 --- a/apps/edge/internal/openai/hot_path_light_test.go +++ b/apps/edge/internal/openai/hot_path_light_test.go @@ -54,50 +54,31 @@ func TestHotPathStageCanonicalReadMapsToCallerCommandTool(t *testing.T) { } } -func TestWorkerReviewHandoffUsesCanonicalStageTool(t *testing.T) { - tools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseLocalActive}) +func TestWorkerProviderReceivesOnlyCallerTools(t *testing.T) { + callerTools := []any{openAIChatTool("run_command", commandSchema())} + tools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseLocalActive, Tools: callerTools}) schemas, err := normalizeToolSchemas(tools) - if err != nil || schemas[hotPathWorkerReviewToolName] == nil { - t.Fatalf("local canonical handoff tool=%+v err=%v", schemas, err) - } - if reviewTools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseReviewActive}); len(reviewTools) != 0 { - t.Fatalf("review stage unexpectedly received worker handoff tool: %+v", reviewTools) - } - - store := newHotPathLightStore(1) - store.records["req_handoff"] = &hotPathLightRecord{ - requestID: "req_handoff", ownerEdgeID: "edge", phase: hotPathPhaseLocalActive, - } - output, err := store.annotateWorkerReviewHandoff("req_handoff", "edge", normalizedStageOutput{ToolCalls: []normalizedToolCall{{ - ID: "provider_handoff", Name: hotPathWorkerReviewToolName, Arguments: map[string]any{"content": "# Review\ncomplete"}, - }}}) if err != nil { t.Fatal(err) } - if output.ToolCalls[0].Path != newReservedPaths("req_handoff").ReviewPath { - t.Fatalf("annotated handoff=%+v", output.ToolCalls[0]) + if schemas["run_command"] == nil || schemas[hotPathWorkerReviewToolName] != nil || len(schemas) != 1 { + t.Fatalf("worker provider tools=%+v", schemas) } } -func TestHotPathLightRequiresCompletedWorkerHandoff(t *testing.T) { +func TestHotPathLightRequiresPlanReadBeforeWorkerCompletion(t *testing.T) { for _, endpoint := range []string{"openai", "anthropic"} { endpoint := endpoint - t.Run(endpoint+" missing handoff", func(t *testing.T) { + t.Run(endpoint, func(t *testing.T) { fixture := newScriptedLightFixture(t, endpoint, false) - fixture.service.responses[3] = func(string) string { + fixture.service.responses[2] = func(string) string { return scriptedLightCompletion(endpoint, "worker tried to finish early") } - driveScriptedLightToLocalAfterPlanRead(t, fixture) - assertLightFailureAfterCleanup(t, fixture, "completed review handoff") - }) - - t.Run(endpoint+" malformed handoff", func(t *testing.T) { - fixture := newScriptedLightFixture(t, endpoint, false) - fixture.service.responses[3] = func(requestID string) string { - return scriptedMalformedWorkerHandoff(endpoint, requestID) - } - driveScriptedLightToLocalAfterPlanRead(t, fixture) - assertLightFailureAfterCleanup(t, fixture, "handoff is malformed") + prepare := fixture.request() + fixture.consumeToolResponse(prepare, []string{`{"written":true}`}) + pair := fixture.request() + fixture.consumeToolResponse(pair, []string{`{"written":true}`, `{"written":true}`}) + assertLightFailureAfterCleanup(t, fixture, "successful plan read") }) } } @@ -146,6 +127,18 @@ func TestHotPathStageInputIsolation(t *testing.T) { t.Fatalf("stage prompt leaked %q: %s", forbidden, prompt) } } + if input.Role == "local" { + want := paths.PlanPath + "\nRead it and complete the task." + if prompt != want { + t.Fatalf("local prompt got=%q want=%q", prompt, want) + } + for _, forbidden := range []string{"immutable user task", paths.ReviewPath, "Committed selector stage success:", "Committed local stage success:", selector.StageID, local.StageID} { + if strings.Contains(prompt, forbidden) { + t.Fatalf("local prompt leaked %q: %s", forbidden, prompt) + } + } + continue + } if !strings.Contains(prompt, "immutable user task") || !strings.Contains(prompt, paths.PlanPath) || !strings.Contains(prompt, paths.ReviewPath) { t.Fatalf("stage prompt omitted immutable input: %s", prompt) } @@ -175,16 +168,6 @@ func TestHotPathStageInputIsolation(t *testing.T) { t.Fatalf("decoded selector correlation mismatch: got %#v want %#v", selDecoded, selector) } - // Local stage must NOT carry a local correlation. - if input.Role == "local" { - if strings.Contains(prompt, "Committed local stage success:") { - t.Fatalf("local prompt leaked local correlation: %s", prompt) - } - if strings.Contains(prompt, local.StageID) { - t.Fatalf("local prompt contained local correlation fields: %s", prompt) - } - } - // Review stage must carry both selector and local correlations. if input.Role == "review" { if !strings.Contains(prompt, "Committed local stage success:") { @@ -390,7 +373,7 @@ func newScriptedLightFixture(t *testing.T, endpoint string, repair bool) *script func(requestID string) string { return scriptedArtifactPrepare(endpoint, requestID) }, func(requestID string) string { return scriptedArtifactPair(endpoint, requestID) }, func(requestID string) string { return scriptedArtifactLocalRead(endpoint, requestID) }, - func(requestID string) string { return scriptedLocalWorkAndHandoff(endpoint, requestID) }, + func(string) string { return scriptedLocalWorkTool(endpoint) }, func(string) string { return scriptedLightCompletion(endpoint, "local-complete-visible") }, func(requestID string) string { return scriptedReviewReadsAndInspection(endpoint, requestID) }, } @@ -460,8 +443,10 @@ func (f *scriptedLightFixture) runToCleanup() *httptest.ResponseRecorder { f.consumeToolResponse(pair, []string{`{"written":true}`, `{"written":true}`}) localRead := f.request() f.consumeToolResponse(localRead, []string{`{"written":true}`}) - workerHandoff := f.request() - f.consumeToolResponse(workerHandoff, []string{`{"ok":true}`, `{"written":true}`}) + localWork := f.request() + f.consumeToolResponse(localWork, []string{`{"ok":true}`}) + workerReview := f.request() + f.consumeToolResponse(workerReview, []string{`{"written":true}`}) reviewInspection := f.request() f.consumeToolResponse(reviewInspection, []string{`{"written":true}`, `{"written":true}`, `{"ok":true}`}) resolution := f.request() @@ -590,8 +575,7 @@ func (f *scriptedLightFixture) assertCleanupStoresRemoved() { } // assertLocalCorrelationRegression verifies that a captured local-stage request -// carries the committed selector correlation in Run.Prompt, Run.Input["prompt"], -// and the decoded tunnel body, while omitting any local-stage correlation. +// contains only the issued PLAN path and the fixed short worker instruction. func assertLocalCorrelationRegression(t *testing.T, req edgeservice.ProviderPoolDispatchRequest, selected edgeservice.ProviderPoolCandidate, selectorStage, selectorResponse string) { t.Helper() prompt := req.Run.Prompt @@ -604,25 +588,14 @@ func assertLocalCorrelationRegression(t *testing.T, req edgeservice.ProviderPool } inputStr := fmt.Sprint(input) - if !strings.Contains(prompt, "Committed selector stage success:") { - t.Fatalf("local Run.Prompt missing selector correlation: %s", prompt) + want := newReservedPaths(req.Run.Metadata["iop_logical_request_id"]).PlanPath + "\nRead it and complete the task." + if prompt != want || inputStr != want { + t.Fatalf("local prompt got=%q input=%q want=%q", prompt, inputStr, want) } - if !strings.Contains(prompt, selectorStage) || !strings.Contains(prompt, selectorResponse) { - t.Fatalf("local Run.Prompt missing exact selector stage/response %q/%q: %s", selectorStage, selectorResponse, prompt) - } - - if !strings.Contains(inputStr, "Committed selector stage success:") { - t.Fatalf("local Run.Input[\"prompt\"] missing selector correlation: %v", input) - } - if !strings.Contains(inputStr, selectorStage) || !strings.Contains(inputStr, selectorResponse) { - t.Fatalf("local Run.Input[\"prompt\"] missing exact selector stage/response %q/%q: %v", selectorStage, selectorResponse, input) - } - - if strings.Contains(prompt, "Committed local stage success:") { - t.Fatalf("local Run.Prompt leaked local correlation: %s", prompt) - } - if strings.Contains(inputStr, "Committed local stage success:") { - t.Fatalf("local Run.Input[\"prompt\"] leaked local correlation: %v", input) + for _, forbidden := range []string{selectorStage, selectorResponse, "Committed selector stage success:", "Committed local stage success:", "User task:", "REVIEW"} { + if forbidden != "" && (strings.Contains(prompt, forbidden) || strings.Contains(inputStr, forbidden)) { + t.Fatalf("local prompt leaked %q: prompt=%q input=%q", forbidden, prompt, inputStr) + } } // Mandatory: decode and verify selected protocol tunnel prompt. @@ -633,14 +606,8 @@ func assertLocalCorrelationRegression(t *testing.T, req edgeservice.ProviderPool if tunnelPrompt != prompt { t.Fatalf("local decoded tunnel prompt mismatch: got %q want %q", tunnelPrompt, prompt) } - if !strings.Contains(tunnelPrompt, "Committed selector stage success:") { - t.Fatalf("local tunnel body missing selector correlation: %s", tunnelPrompt) - } - if !strings.Contains(tunnelPrompt, selectorStage) || !strings.Contains(tunnelPrompt, selectorResponse) { - t.Fatalf("local tunnel body missing exact selector stage/response %q/%q: %s", selectorStage, selectorResponse, tunnelPrompt) - } - if strings.Contains(tunnelPrompt, "Committed local stage success:") { - t.Fatalf("local tunnel body leaked local correlation: %s", tunnelPrompt) + if tunnelPrompt != want { + t.Fatalf("local tunnel prompt got=%q want=%q", tunnelPrompt, want) } } diff --git a/apps/edge/internal/openai/hot_path_stage_input.go b/apps/edge/internal/openai/hot_path_stage_input.go index f223c3dc..567a1270 100644 --- a/apps/edge/internal/openai/hot_path_stage_input.go +++ b/apps/edge/internal/openai/hot_path_stage_input.go @@ -112,6 +112,9 @@ func (in hotPathStageInput) prompt(phase hotPathLightPhase) (string, error) { if err := in.validate(); err != nil { return "", err } + if in.Role == "local" { + return in.Artifacts.PlanPath + "\nRead it and complete the task.", nil + } var b strings.Builder b.WriteString("User task:\n") b.WriteString(in.ImmutableTask) @@ -126,8 +129,6 @@ func (in hotPathStageInput) prompt(phase hotPathLightPhase) (string, error) { b.WriteString("\n\n") switch in.Role { - case "local": - b.WriteString("Read the exact issued PLAN before doing any work. Perform the task and verification in the caller workspace with ordinary caller tools. Then call iop_write_worker_review exactly once with the complete REVIEW content using this closed handoff grammar: '# Review', '## Worker Item Status' with every PLAN item exactly once as '- Pn: completed', '## Worker Changes', '## Worker Verification', and '## Deviations'; every section must be non-empty. Do not use an ordinary caller tool for the issued REVIEW path. Do not complete without a successful PLAN read and this REVIEW handoff call.") case "review": switch phase { case hotPathPhaseReviewActive: diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 5a2f2da8..e878c3c2 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -38,6 +38,7 @@ Return exactly one iop_write_artifact_pair tool call containing both complete ar PLAN path: %s REVIEW path: %s The default PLAN grammar accepts 2-6 consecutive steps and 1-3 verification bullets. For this deterministic seed, use exactly the following two-step, one-verification form and replace only angle-bracketed text: +Write every PLAN field in English using ASCII characters only. # Plan ## Goal diff --git a/apps/edge/internal/openai/request_identity_ingress.go b/apps/edge/internal/openai/request_identity_ingress.go index 342b835a..92230ef7 100644 --- a/apps/edge/internal/openai/request_identity_ingress.go +++ b/apps/edge/internal/openai/request_identity_ingress.go @@ -341,6 +341,10 @@ func (s *Server) applyLightDisposition(snap logicalRequestSnapshot, disposition metadata["iop_logical_request_id"] = disposition.RequestID metadata["iop_call_id"] = callID metadata["iop_stage_id"] = disposition.StageID + if disposition.TransitionFrom == hotPathPhaseLocalActive && disposition.Phase == hotPathPhaseReviewActive { + s.observeHotPathLightTransition(context.Background(), hotPathStageKindReview, hotPathAttemptFirst, + disposition.RequestID, disposition.StageID, presetID) + } if disposition.TransitionFrom == hotPathPhaseReviewActive && disposition.Phase == hotPathPhaseReviewRepair { s.observeHotPathLightTransition(context.Background(), hotPathStageKindReview, hotPathAttemptRetry, disposition.RequestID, disposition.StageID, presetID) From 71c66aeaab8dcb4ea84b349182956a605c43ef52 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 08:10:53 +0900 Subject: [PATCH 20/37] =?UTF-8?q?fix(openai):=20caller=20workspace=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=B0=A8=EB=8B=A8=EC=9D=84=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan과 Review의 정확한 접근만 상태로 기록하고 일반 작업 경로는 모델과 caller 도구에 그대로 맡긴다. --- .../outer/anthropic-compatible-api.md | 4 ++- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 2 +- apps/edge/internal/openai/hot_path_light.go | 29 +++++++------------ .../internal/openai/hot_path_light_test.go | 22 ++++++++++++++ 5 files changed, 37 insertions(+), 22 deletions(-) diff --git a/agent-contract/outer/anthropic-compatible-api.md b/agent-contract/outer/anthropic-compatible-api.md index 06204a88..d4297b0e 100644 --- a/agent-contract/outer/anthropic-compatible-api.md +++ b/agent-contract/outer/anthropic-compatible-api.md @@ -101,7 +101,9 @@ Work receives only the relative PLAN path followed by the fixed English sentence `Read it and complete the task.` and sees only ordinary caller tools. It must read PLAN and perform and verify caller-workspace work. After a successful PLAN read and a non-empty terminal worker report, Edge renders and validates the completed REVIEW -handoff and projects its single caller write; Work does not own that protocol. Review +handoff and projects its single caller write; Work does not own that protocol. Only +exact PLAN/REVIEW accesses contribute artifact state; every other caller-workspace +tool call passes through unchanged. Review must read both artifacts and successfully inspect an ordinary caller result before it can return a non-empty caller-visible terminal. It never rewrites REVIEW; repair and re-verification remain in the same Review binding. Cleanup targets only the diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index ab6cb45f..93c7a787 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Selector instruction은 모든 PLAN field를 English ASCII로 작성하도록 요구하고 Edge는 non-ASCII PLAN을 caller write 전에 거부한다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work provider의 전체 task prompt는 relative PLAN path와 고정 영문 `Read it and complete the task.`뿐이며 ordinary caller tools만 보인다. Work가 PLAN을 성공적으로 읽은 뒤 non-empty terminal report를 반환하면 Edge가 completed REVIEW template을 렌더·검증하고 단일 caller write로 투영한다. Work provider는 REVIEW handoff tool이나 protocol을 소유하지 않는다. Work/Review provider가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Selector instruction은 모든 PLAN field를 English ASCII로 작성하도록 요구하고 Edge는 non-ASCII PLAN을 caller write 전에 거부한다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work provider의 전체 task prompt는 relative PLAN path와 고정 영문 `Read it and complete the task.`뿐이며 ordinary caller tools만 보인다. Work가 PLAN을 성공적으로 읽은 뒤 non-empty terminal report를 반환하면 Edge가 completed REVIEW template을 렌더·검증하고 단일 caller write로 투영한다. Work provider는 REVIEW handoff tool이나 protocol을 소유하지 않는다. 정확한 PLAN/REVIEW 접근만 artifact state로 기록하고 그 외 caller-workspace tool call은 경로 검사 없이 그대로 통과시킨다. Work/Review provider가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 0da54bfd..df86f96a 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for the accepted artifact grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only canonical `prepare(path)` or one atomic `iop_write_artifact_pair(plan_content,review_content)` schema. The selector must write every PLAN field in English ASCII; Edge rejects non-ASCII PLAN content. Edge validates and expands the pair into two independently receipted writes encoded in the admitted caller tool schema such as `bash(command)`; the selector is not required to produce parallel tool calls. Continuation issue identity covers semantic tool-call ID/name/arguments rather than SDK-decorated assistant content, reasoning, or provider metadata, while request prefix and result IDs stay independently validated. The selector instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block and does not mutate the retained caller snapshot or add a public field. Work receives exactly the relative PLAN path plus `Read it and complete the task.` and only ordinary caller tools. After a successful PLAN read and a non-empty terminal worker report, Edge renders and validates the completed `P1..Pn` REVIEW handoff and projects its single caller write; Work does not manage that protocol. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for the accepted artifact grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only canonical `prepare(path)` or one atomic `iop_write_artifact_pair(plan_content,review_content)` schema. The selector must write every PLAN field in English ASCII; Edge rejects non-ASCII PLAN content. Edge validates and expands the pair into two independently receipted writes encoded in the admitted caller tool schema such as `bash(command)`; the selector is not required to produce parallel tool calls. Continuation issue identity covers semantic tool-call ID/name/arguments rather than SDK-decorated assistant content, reasoning, or provider metadata, while request prefix and result IDs stay independently validated. The selector instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block and does not mutate the retained caller snapshot or add a public field. Work receives exactly the relative PLAN path plus `Read it and complete the task.` and only ordinary caller tools. After a successful PLAN read and a non-empty terminal worker report, Edge renders and validates the completed `P1..Pn` REVIEW handoff and projects its single caller write; Work does not manage that protocol. Exact PLAN/REVIEW access updates artifact state, while every other caller-workspace tool call passes through unchanged. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | diff --git a/apps/edge/internal/openai/hot_path_light.go b/apps/edge/internal/openai/hot_path_light.go index c3318d30..48b6a16a 100644 --- a/apps/edge/internal/openai/hot_path_light.go +++ b/apps/edge/internal/openai/hot_path_light.go @@ -533,7 +533,7 @@ func mapHotPathStageCalls(record *hotPathLightRecord, output normalizedStageOutp return normalizedStageOutput{}, nil, fmt.Errorf("stage public tool id is invalid") } - operation, requiredPath, reserved, err := hotPathWorkspaceCall(record.phase, kind, paths, call) + operation, requiredPath, reserved, err := hotPathWorkspaceCall(kind, paths, call) if err != nil { return normalizedStageOutput{}, nil, err } @@ -595,36 +595,27 @@ func hotPathToolAllowed(tools []any, name string) bool { return ok } -func hotPathWorkspaceCall(phase hotPathLightPhase, kind hotPathPendingKind, paths reservedPaths, call normalizedToolCall) (workspaceOperationKind, string, bool, error) { +func hotPathWorkspaceCall(kind hotPathPendingKind, paths reservedPaths, call normalizedToolCall) (workspaceOperationKind, string, bool, error) { reserved := reservedPathsFromToolCall(call) - if len(reserved) == 0 { - return "", "", false, nil - } if len(reserved) != 1 { - return "", "", false, fmt.Errorf("stage tool call contains ambiguous reserved paths") + return "", "", false, nil } observed := cleanRelativePath(reserved[0]) switch kind { case hotPathPendingLocalTools: - if observed != cleanRelativePath(paths.PlanPath) { - return "", "", false, fmt.Errorf("worker may only read the issued plan artifact") + if observed == cleanRelativePath(paths.PlanPath) { + return opKindRead, observed, true, nil } - return opKindRead, observed, true, nil case hotPathPendingLocalHandoff: - if observed != cleanRelativePath(paths.ReviewPath) { - return "", "", false, fmt.Errorf("worker handoff targets a non-review path") + if observed == cleanRelativePath(paths.ReviewPath) { + return opKindWrite, paths.ReviewPath, true, nil } - return opKindWrite, paths.ReviewPath, true, nil case hotPathPendingReviewInspection: - if observed != cleanRelativePath(paths.PlanPath) && observed != cleanRelativePath(paths.ReviewPath) { - return "", "", false, fmt.Errorf("stage read targets an unissued reserved path") + if observed == cleanRelativePath(paths.PlanPath) || observed == cleanRelativePath(paths.ReviewPath) { + return opKindRead, observed, true, nil } - return opKindRead, observed, true, nil - case hotPathPendingReviewRepair: - return "", "", false, fmt.Errorf("repair cannot start a second reserved review cycle") - default: - return "", "", false, fmt.Errorf("unknown light tool frontier %q in phase %q", kind, phase) } + return "", "", false, nil } func (s *hotPathLightStore) consumeChat(ownerEdgeID, principalRef string, rawBody []byte, lineage logicalRequestContinuationLineage, coordinator *logicalRequestCoordinator) (logicalRequestSnapshot, hotPathLightDisposition, bool, error) { diff --git a/apps/edge/internal/openai/hot_path_light_test.go b/apps/edge/internal/openai/hot_path_light_test.go index 51624041..9c433d28 100644 --- a/apps/edge/internal/openai/hot_path_light_test.go +++ b/apps/edge/internal/openai/hot_path_light_test.go @@ -54,6 +54,28 @@ func TestHotPathStageCanonicalReadMapsToCallerCommandTool(t *testing.T) { } } +func TestHotPathStageOrdinaryWorkspacePathPassesThrough(t *testing.T) { + callerTools := []any{openAIChatTool("bash", commandSchema())} + record := &hotPathLightRecord{ + requestID: "req_stage_ordinary", phase: hotPathPhaseLocalActive, tools: callerTools, + } + coordinator := newLogicalRequestCoordinator(logicalRequestCoordinatorOptions{IDSource: func() (string, error) { return "call_public", nil }}) + call := normalizedToolCall{ + ID: "provider_work", Name: "bash", + Arguments: map[string]any{"command": "inspect .iop/job/ordinary-workspace-path and continue"}, + } + mapped, pending, err := mapHotPathStageCalls(record, normalizedStageOutput{ToolCalls: []normalizedToolCall{call}}, hotPathPendingLocalTools, coordinator, nil) + if err != nil { + t.Fatal(err) + } + if len(mapped.ToolCalls) != 1 || mapped.ToolCalls[0].Name != "bash" { + t.Fatalf("ordinary caller tool changed: %+v", mapped.ToolCalls) + } + if pending[mapped.ToolCalls[0].ID].payload != nil { + t.Fatalf("ordinary workspace path became an artifact operation: %+v", pending) + } +} + func TestWorkerProviderReceivesOnlyCallerTools(t *testing.T) { callerTools := []any{openAIChatTool("run_command", commandSchema())} tools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseLocalActive, Tools: callerTools}) From 864a930b55a6d458dde00f6c39e9fc5895da3f88 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 08:15:15 +0900 Subject: [PATCH 21/37] =?UTF-8?q?fix(openai):=20=EB=B0=9C=EA=B8=89?= =?UTF-8?q?=EB=90=9C=20artifact=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로컬 모델이 선행 점을 슬래시로 바꾼 경우에도 정확한 Plan과 Review만 식별하고 일반 workspace 호출은 그대로 유지한다. --- apps/edge/internal/openai/hot_path_light.go | 29 ++++++++++++++++++- .../internal/openai/hot_path_light_test.go | 18 ++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/edge/internal/openai/hot_path_light.go b/apps/edge/internal/openai/hot_path_light.go index 48b6a16a..de559e6b 100644 --- a/apps/edge/internal/openai/hot_path_light.go +++ b/apps/edge/internal/openai/hot_path_light.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "net/http" + "path/filepath" + "sort" "strconv" "strings" "sync" @@ -596,7 +598,7 @@ func hotPathToolAllowed(tools []any, name string) bool { } func hotPathWorkspaceCall(kind hotPathPendingKind, paths reservedPaths, call normalizedToolCall) (workspaceOperationKind, string, bool, error) { - reserved := reservedPathsFromToolCall(call) + reserved := workspaceArtifactPathsFromToolCall(call, paths) if len(reserved) != 1 { return "", "", false, nil } @@ -618,6 +620,31 @@ func hotPathWorkspaceCall(kind hotPathPendingKind, paths reservedPaths, call nor return "", "", false, nil } +func workspaceArtifactPathsFromToolCall(call normalizedToolCall, paths reservedPaths) []string { + set := make(map[string]struct{}) + for _, path := range reservedPathsFromToolCall(call) { + set[cleanRelativePath(path)] = struct{}{} + } + addAlias := func(value string) { + normalized := filepath.ToSlash(strings.TrimSpace(value)) + if strings.HasPrefix(normalized, "/iop/job/") { + normalized = "." + strings.TrimPrefix(normalized, "/") + } + cleaned := cleanRelativePath(normalized) + if cleaned == cleanRelativePath(paths.PlanPath) || cleaned == cleanRelativePath(paths.ReviewPath) { + set[cleaned] = struct{}{} + } + } + addAlias(call.Path) + collectReservedStrings(call.Arguments, addAlias) + result := make([]string, 0, len(set)) + for path := range set { + result = append(result, path) + } + sort.Strings(result) + return result +} + func (s *hotPathLightStore) consumeChat(ownerEdgeID, principalRef string, rawBody []byte, lineage logicalRequestContinuationLineage, coordinator *logicalRequestCoordinator) (logicalRequestSnapshot, hotPathLightDisposition, bool, error) { results, err := decodeChatWorkspaceResults(rawBody) if err != nil { diff --git a/apps/edge/internal/openai/hot_path_light_test.go b/apps/edge/internal/openai/hot_path_light_test.go index 9c433d28..b5d00e30 100644 --- a/apps/edge/internal/openai/hot_path_light_test.go +++ b/apps/edge/internal/openai/hot_path_light_test.go @@ -76,6 +76,24 @@ func TestHotPathStageOrdinaryWorkspacePathPassesThrough(t *testing.T) { } } +func TestHotPathStageIssuedPlanSlashAliasMapsToCallerTool(t *testing.T) { + binding := mustBinding(t, fullWorkspaceAlternative("command", "bash", true), []any{openAIChatTool("bash", commandSchema())}) + record := &hotPathLightRecord{ + requestID: "req_stage_alias", phase: hotPathPhaseLocalActive, binding: binding, + } + coordinator := newLogicalRequestCoordinator(logicalRequestCoordinatorOptions{IDSource: func() (string, error) { return "call_public", nil }}) + alias := "/" + strings.TrimPrefix(newReservedPaths(record.requestID).PlanPath, ".") + mapped, pending, err := mapHotPathStageCalls(record, normalizedStageOutput{ToolCalls: []normalizedToolCall{{ + ID: "provider_read", Name: "read", Arguments: map[string]any{"filePath": alias}, + }}}, hotPathPendingLocalTools, coordinator, nil) + if err != nil { + t.Fatal(err) + } + if len(mapped.ToolCalls) != 1 || mapped.ToolCalls[0].Name != "bash" || pending[mapped.ToolCalls[0].ID].payload == nil { + t.Fatalf("issued plan alias was not normalized: calls=%+v pending=%+v", mapped.ToolCalls, pending) + } +} + func TestWorkerProviderReceivesOnlyCallerTools(t *testing.T) { callerTools := []any{openAIChatTool("run_command", commandSchema())} tools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseLocalActive, Tools: callerTools}) From 64ab12b895cbcc3117f541aaf9f86759d56fd3ce Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 08:31:20 +0900 Subject: [PATCH 22/37] =?UTF-8?q?fix(openai):=20Work=EC=97=90=20=EC=A0=88?= =?UTF-8?q?=EB=8C=80=20Plan=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 호출자가 이미 제공하는 working directory를 사용해 Work 입력의 Plan 경로를 절대경로로 해석한다. 잘못 추가했던 /iop 추정 alias는 제거하고 일반 workspace 호출은 그대로 통과시킨다. --- .../outer/anthropic-compatible-api.md | 2 +- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 4 +- .../openai/hot_path_chat_gate_test.go | 6 +-- .../internal/openai/hot_path_direct_test.go | 2 +- apps/edge/internal/openai/hot_path_light.go | 29 +------------- .../internal/openai/hot_path_light_test.go | 34 +++++----------- .../internal/openai/hot_path_stage_input.go | 40 ++++++++++++++++++- .../openai/hot_path_terminal_control_test.go | 2 +- 9 files changed, 57 insertions(+), 64 deletions(-) diff --git a/agent-contract/outer/anthropic-compatible-api.md b/agent-contract/outer/anthropic-compatible-api.md index d4297b0e..6fad9424 100644 --- a/agent-contract/outer/anthropic-compatible-api.md +++ b/agent-contract/outer/anthropic-compatible-api.md @@ -97,7 +97,7 @@ workspace path. Before issuing caller writes, Edge validates the selector's default lightweight PLAN and pending REVIEW seed and retains the PLAN `P1..Pn` inventory request-locally. Selector instructions require every PLAN field to be English ASCII, and Edge rejects a non-ASCII PLAN before issuing caller writes. -Work receives only the relative PLAN path followed by the fixed English sentence +Work receives only the absolute PLAN path resolved from the caller's existing working-directory system context, followed by the fixed English sentence `Read it and complete the task.` and sees only ordinary caller tools. It must read PLAN and perform and verify caller-workspace work. After a successful PLAN read and a non-empty terminal worker report, Edge renders and validates the completed REVIEW diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 93c7a787..ce8feea3 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Selector instruction은 모든 PLAN field를 English ASCII로 작성하도록 요구하고 Edge는 non-ASCII PLAN을 caller write 전에 거부한다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work provider의 전체 task prompt는 relative PLAN path와 고정 영문 `Read it and complete the task.`뿐이며 ordinary caller tools만 보인다. Work가 PLAN을 성공적으로 읽은 뒤 non-empty terminal report를 반환하면 Edge가 completed REVIEW template을 렌더·검증하고 단일 caller write로 투영한다. Work provider는 REVIEW handoff tool이나 protocol을 소유하지 않는다. 정확한 PLAN/REVIEW 접근만 artifact state로 기록하고 그 외 caller-workspace tool call은 경로 검사 없이 그대로 통과시킨다. Work/Review provider가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Selector instruction은 모든 PLAN field를 English ASCII로 작성하도록 요구하고 Edge는 non-ASCII PLAN을 caller write 전에 거부한다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work provider의 전체 task prompt는 caller의 기존 working-directory system context에서 해석한 absolute PLAN path와 고정 영문 `Read it and complete the task.`뿐이며 ordinary caller tools만 보인다. Work가 PLAN을 성공적으로 읽은 뒤 non-empty terminal report를 반환하면 Edge가 completed REVIEW template을 렌더·검증하고 단일 caller write로 투영한다. Work provider는 REVIEW handoff tool이나 protocol을 소유하지 않는다. 정확한 PLAN/REVIEW 접근만 artifact state로 기록하고 그 외 caller-workspace tool call은 경로 검사 없이 그대로 통과시킨다. Work/Review provider가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index df86f96a..f84349d0 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for the accepted artifact grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only canonical `prepare(path)` or one atomic `iop_write_artifact_pair(plan_content,review_content)` schema. The selector must write every PLAN field in English ASCII; Edge rejects non-ASCII PLAN content. Edge validates and expands the pair into two independently receipted writes encoded in the admitted caller tool schema such as `bash(command)`; the selector is not required to produce parallel tool calls. Continuation issue identity covers semantic tool-call ID/name/arguments rather than SDK-decorated assistant content, reasoning, or provider metadata, while request prefix and result IDs stay independently validated. The selector instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block and does not mutate the retained caller snapshot or add a public field. Work receives exactly the relative PLAN path plus `Read it and complete the task.` and only ordinary caller tools. After a successful PLAN read and a non-empty terminal worker report, Edge renders and validates the completed `P1..Pn` REVIEW handoff and projects its single caller write; Work does not manage that protocol. Exact PLAN/REVIEW access updates artifact state, while every other caller-workspace tool call passes through unchanged. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for the accepted artifact grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only canonical `prepare(path)` or one atomic `iop_write_artifact_pair(plan_content,review_content)` schema. The selector must write every PLAN field in English ASCII; Edge rejects non-ASCII PLAN content. Edge validates and expands the pair into two independently receipted writes encoded in the admitted caller tool schema such as `bash(command)`; the selector is not required to produce parallel tool calls. Continuation issue identity covers semantic tool-call ID/name/arguments rather than SDK-decorated assistant content, reasoning, or provider metadata, while request prefix and result IDs stay independently validated. The selector instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block and does not mutate the retained caller snapshot or add a public field. Work receives exactly the absolute PLAN path resolved from the caller's existing working-directory system context plus `Read it and complete the task.` and only ordinary caller tools. After a successful PLAN read and a non-empty terminal worker report, Edge renders and validates the completed `P1..Pn` REVIEW handoff and projects its single caller write; Work does not manage that protocol. Exact PLAN/REVIEW access updates artifact state, while every other caller-workspace tool call passes through unchanged. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | @@ -420,7 +420,7 @@ sequenceDiagram - 2026-08-14: Applied the same provider-normalization boundary to private single-request Plan/Work/Review stages. Removed pre-selection Chat operation pinning, added Chat↔Responses stage conversion and nearest-lower effort mapping, and corrected default-selector dispatch validation so the pool-selected provider is accepted without weakening explicit provider, profile, credential, target, or execution-path fences. - 2026-08-14: Canonicalized private Chat Completions stage responses before strict Plan/Work/Review decoding. Standard OpenAI bookkeeping fields are stripped centrally, a non-null refusal remains fail-closed, and Responses/Chat now converge on the same private envelope instead of making stage codecs provider-specific. - 2026-08-14: Added provider `timings` to the bounded Chat response bookkeeping stripped before private stage decoding, aligning the common normalizer with the existing Ornith Work codec without admitting unknown response fields. -- 2026-08-15: Reduced the unmarked light Work input to the relative PLAN path plus one fixed English sentence, limited Work to caller tools, moved completed REVIEW projection into Edge after the worker terminal report, and required English-ASCII PLAN content. +- 2026-08-15: Reduced the unmarked light Work input to the caller-workspace absolute PLAN path plus one fixed English sentence, limited Work to caller tools, moved completed REVIEW projection into Edge after the worker terminal report, and required English-ASCII PLAN content. - 2026-08-13: Gemini-native path parsing now keeps route IDs strict while accepting the bounded URL-encoded official caller model label `Gemini 3.6 Flash`. - 2026-08-06: Synchronized always-owned Chat/Responses typed-stall recovery, provider avoidance/fallback admission, and closed-label liveness operational evidence with the current runtime, contracts, and deterministic recovery tests. - 2026-08-06: Added marked single-request Messages admission through the separate service coordinator capability, one unlabeled runtime ingress counter, buffered sanitized terminal acknowledgement, and deterministic real-POST compatibility evidence. diff --git a/apps/edge/internal/openai/hot_path_chat_gate_test.go b/apps/edge/internal/openai/hot_path_chat_gate_test.go index bae1f434..4021ccf6 100644 --- a/apps/edge/internal/openai/hot_path_chat_gate_test.go +++ b/apps/edge/internal/openai/hot_path_chat_gate_test.go @@ -173,7 +173,7 @@ func TestHotPathChatMixedProviderStages(t *testing.T) { }) tools := scriptedLightTools("openai") - history := []any{map[string]any{"role": "user", "content": "mixed provider task"}} + history := []any{map[string]any{"role": "user", "content": "Working directory: /workspace\nmixed provider task"}} consume := func(response *httptest.ResponseRecorder, results []string) { t.Helper() assistant, ids, err := artifactAssistantFromResponse("openai", response.Body.Bytes()) @@ -295,7 +295,7 @@ func TestHotPathChatFlushesVisibleDeltaBeforeProviderTerminal(t *testing.T) { }) tools := scriptedLightTools("openai") - history := []any{map[string]any{"role": "user", "content": "flush before terminal"}} + history := []any{map[string]any{"role": "user", "content": "Working directory: /workspace\nflush before terminal"}} consume := func(response *httptest.ResponseRecorder, results []string) { t.Helper() assistant, ids, err := artifactAssistantFromResponse("openai", response.Body.Bytes()) @@ -499,7 +499,7 @@ func TestHotPathChatProviderLengthFlushesBeforeTerminalAndStopsLight(t *testing. }) tools := scriptedLightTools("openai") - history := []any{map[string]any{"role": "user", "content": "provider length terminal"}} + history := []any{map[string]any{"role": "user", "content": "Working directory: /workspace\nprovider length terminal"}} consume := func(response *httptest.ResponseRecorder, results []string) { t.Helper() assistant, ids, err := artifactAssistantFromResponse("openai", response.Body.Bytes()) diff --git a/apps/edge/internal/openai/hot_path_direct_test.go b/apps/edge/internal/openai/hot_path_direct_test.go index 5b310706..146661a9 100644 --- a/apps/edge/internal/openai/hot_path_direct_test.go +++ b/apps/edge/internal/openai/hot_path_direct_test.go @@ -261,7 +261,7 @@ func TestArtifactPairHandlerDisposition(t *testing.T) { } srv := newScriptedArtifactHandlerServer(t, service) tools := scriptedArtifactTools(endpoint) - history := []any{map[string]any{"role": "user", "content": "write a plan"}} + history := []any{map[string]any{"role": "user", "content": "Working directory: /workspace\nwrite a plan"}} first := serveScriptedArtifactRequest(t, srv, endpoint, scriptedArtifactRequestBody(t, endpoint, tools, history)) if first.Code != http.StatusOK || service.calls != 1 { diff --git a/apps/edge/internal/openai/hot_path_light.go b/apps/edge/internal/openai/hot_path_light.go index de559e6b..48b6a16a 100644 --- a/apps/edge/internal/openai/hot_path_light.go +++ b/apps/edge/internal/openai/hot_path_light.go @@ -5,8 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "path/filepath" - "sort" "strconv" "strings" "sync" @@ -598,7 +596,7 @@ func hotPathToolAllowed(tools []any, name string) bool { } func hotPathWorkspaceCall(kind hotPathPendingKind, paths reservedPaths, call normalizedToolCall) (workspaceOperationKind, string, bool, error) { - reserved := workspaceArtifactPathsFromToolCall(call, paths) + reserved := reservedPathsFromToolCall(call) if len(reserved) != 1 { return "", "", false, nil } @@ -620,31 +618,6 @@ func hotPathWorkspaceCall(kind hotPathPendingKind, paths reservedPaths, call nor return "", "", false, nil } -func workspaceArtifactPathsFromToolCall(call normalizedToolCall, paths reservedPaths) []string { - set := make(map[string]struct{}) - for _, path := range reservedPathsFromToolCall(call) { - set[cleanRelativePath(path)] = struct{}{} - } - addAlias := func(value string) { - normalized := filepath.ToSlash(strings.TrimSpace(value)) - if strings.HasPrefix(normalized, "/iop/job/") { - normalized = "." + strings.TrimPrefix(normalized, "/") - } - cleaned := cleanRelativePath(normalized) - if cleaned == cleanRelativePath(paths.PlanPath) || cleaned == cleanRelativePath(paths.ReviewPath) { - set[cleaned] = struct{}{} - } - } - addAlias(call.Path) - collectReservedStrings(call.Arguments, addAlias) - result := make([]string, 0, len(set)) - for path := range set { - result = append(result, path) - } - sort.Strings(result) - return result -} - func (s *hotPathLightStore) consumeChat(ownerEdgeID, principalRef string, rawBody []byte, lineage logicalRequestContinuationLineage, coordinator *logicalRequestCoordinator) (logicalRequestSnapshot, hotPathLightDisposition, bool, error) { results, err := decodeChatWorkspaceResults(rawBody) if err != nil { diff --git a/apps/edge/internal/openai/hot_path_light_test.go b/apps/edge/internal/openai/hot_path_light_test.go index b5d00e30..8e03a04b 100644 --- a/apps/edge/internal/openai/hot_path_light_test.go +++ b/apps/edge/internal/openai/hot_path_light_test.go @@ -15,6 +15,8 @@ import ( "iop/packages/go/singlerequesttemplate" ) +const scriptedAbsoluteWorkspaceTask = "system: Working directory: /workspace\nuser: immutable user task" + func TestHotPathLightLocalTransition(t *testing.T) { for _, endpoint := range []string{"openai", "anthropic"} { endpoint := endpoint @@ -76,24 +78,6 @@ func TestHotPathStageOrdinaryWorkspacePathPassesThrough(t *testing.T) { } } -func TestHotPathStageIssuedPlanSlashAliasMapsToCallerTool(t *testing.T) { - binding := mustBinding(t, fullWorkspaceAlternative("command", "bash", true), []any{openAIChatTool("bash", commandSchema())}) - record := &hotPathLightRecord{ - requestID: "req_stage_alias", phase: hotPathPhaseLocalActive, binding: binding, - } - coordinator := newLogicalRequestCoordinator(logicalRequestCoordinatorOptions{IDSource: func() (string, error) { return "call_public", nil }}) - alias := "/" + strings.TrimPrefix(newReservedPaths(record.requestID).PlanPath, ".") - mapped, pending, err := mapHotPathStageCalls(record, normalizedStageOutput{ToolCalls: []normalizedToolCall{{ - ID: "provider_read", Name: "read", Arguments: map[string]any{"filePath": alias}, - }}}, hotPathPendingLocalTools, coordinator, nil) - if err != nil { - t.Fatal(err) - } - if len(mapped.ToolCalls) != 1 || mapped.ToolCalls[0].Name != "bash" || pending[mapped.ToolCalls[0].ID].payload == nil { - t.Fatalf("issued plan alias was not normalized: calls=%+v pending=%+v", mapped.ToolCalls, pending) - } -} - func TestWorkerProviderReceivesOnlyCallerTools(t *testing.T) { callerTools := []any{openAIChatTool("run_command", commandSchema())} tools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseLocalActive, Tools: callerTools}) @@ -150,8 +134,8 @@ func TestHotPathStageInputIsolation(t *testing.T) { paths := newReservedPaths("req_stage_isolation") selector := hotPathStageCorrelation{StageID: "stg_selector", ResponseID: "provider:selector.actual/1", RunID: "run-selector", ProviderID: "provider.actual", Terminal: "stop,done\"quoted\""} local := hotPathStageCorrelation{StageID: "stg_local", ResponseID: "provider:local.actual/2", RunID: "run-local", ProviderID: "provider.actual", Terminal: "tool_calls,stop"} - localInput := buildLocalStageInput("immutable user task", paths, selector) - reviewInput := buildReviewStageInput("immutable user task", paths, selector, local) + localInput := buildLocalStageInput(scriptedAbsoluteWorkspaceTask, paths, selector) + reviewInput := buildReviewStageInput(scriptedAbsoluteWorkspaceTask, paths, selector, local) for _, input := range []hotPathStageInput{localInput, reviewInput} { phase := hotPathPhaseLocalActive @@ -168,7 +152,7 @@ func TestHotPathStageInputIsolation(t *testing.T) { } } if input.Role == "local" { - want := paths.PlanPath + "\nRead it and complete the task." + want := "/workspace/" + paths.PlanPath + "\nRead it and complete the task." if prompt != want { t.Fatalf("local prompt got=%q want=%q", prompt, want) } @@ -307,13 +291,13 @@ func TestHotPathStageInputIsolation(t *testing.T) { for _, invalid := range invalidLogicalIDs { selBadStage := selector selBadStage.StageID = invalid - if p, err := buildLocalStageInput("immutable user task", paths, selBadStage).prompt(hotPathPhaseLocalActive); err == nil || p != "" { + if p, err := buildLocalStageInput(scriptedAbsoluteWorkspaceTask, paths, selBadStage).prompt(hotPathPhaseLocalActive); err == nil || p != "" { t.Fatalf("selector StageID %q accepted: prompt=%q, err=%v", invalid, p, err) } selBadRun := selector selBadRun.RunID = invalid - if p, err := buildLocalStageInput("immutable user task", paths, selBadRun).prompt(hotPathPhaseLocalActive); err == nil || p != "" { + if p, err := buildLocalStageInput(scriptedAbsoluteWorkspaceTask, paths, selBadRun).prompt(hotPathPhaseLocalActive); err == nil || p != "" { t.Fatalf("selector RunID %q accepted: prompt=%q, err=%v", invalid, p, err) } } @@ -442,7 +426,7 @@ func newScriptedLightFixture(t *testing.T, endpoint string, repair bool) *script tools := scriptedLightTools(endpoint) return &scriptedLightFixture{ t: t, endpoint: endpoint, server: server, service: service, tools: tools, - history: []any{map[string]any{"role": "user", "content": "immutable user task"}}, repair: repair, + history: []any{map[string]any{"role": "user", "content": "Working directory: /workspace\nimmutable user task"}}, repair: repair, } } @@ -628,7 +612,7 @@ func assertLocalCorrelationRegression(t *testing.T, req edgeservice.ProviderPool } inputStr := fmt.Sprint(input) - want := newReservedPaths(req.Run.Metadata["iop_logical_request_id"]).PlanPath + "\nRead it and complete the task." + want := "/workspace/" + newReservedPaths(req.Run.Metadata["iop_logical_request_id"]).PlanPath + "\nRead it and complete the task." if prompt != want || inputStr != want { t.Fatalf("local prompt got=%q input=%q want=%q", prompt, inputStr, want) } diff --git a/apps/edge/internal/openai/hot_path_stage_input.go b/apps/edge/internal/openai/hot_path_stage_input.go index 567a1270..78fd3e8b 100644 --- a/apps/edge/internal/openai/hot_path_stage_input.go +++ b/apps/edge/internal/openai/hot_path_stage_input.go @@ -3,6 +3,7 @@ package openai import ( "encoding/json" "fmt" + "path/filepath" "strings" "unicode" ) @@ -23,7 +24,7 @@ type hotPathStageCorrelation struct { } // hotPathStageInput is the complete cross-stage input boundary. It contains -// only caller-owned immutable task text, issued relative paths, and committed +// only caller-owned immutable task text, issued paths, and committed // provider correlations. Workspace contents, credentials, provider targets, // and prior control prompts never enter this value. type hotPathStageInput struct { @@ -113,7 +114,12 @@ func (in hotPathStageInput) prompt(phase hotPathLightPhase) (string, error) { return "", err } if in.Role == "local" { - return in.Artifacts.PlanPath + "\nRead it and complete the task.", nil + root, err := callerWorkingDirectory(in.ImmutableTask) + if err != nil { + return "", err + } + planPath := filepath.ToSlash(filepath.Join(root, filepath.FromSlash(in.Artifacts.PlanPath))) + return planPath + "\nRead it and complete the task.", nil } var b strings.Builder b.WriteString("User task:\n") @@ -144,6 +150,36 @@ func (in hotPathStageInput) prompt(phase hotPathLightPhase) (string, error) { return b.String(), nil } +func callerWorkingDirectory(task string) (string, error) { + for _, line := range strings.Split(task, "\n") { + line = strings.TrimSpace(line) + for _, role := range []string{"system:", "user:"} { + if strings.HasPrefix(line, role) { + line = strings.TrimSpace(strings.TrimPrefix(line, role)) + break + } + } + for _, prefix := range []string{"Working directory:", "Current working directory:"} { + if !strings.HasPrefix(line, prefix) { + continue + } + root := filepath.Clean(strings.TrimSpace(strings.TrimPrefix(line, prefix))) + if filepath.IsAbs(root) { + return root, nil + } + } + if start := strings.Index(line, ""); start >= 0 { + if end := strings.Index(line[start+len(""):], ""); end >= 0 { + root := filepath.Clean(strings.TrimSpace(line[start+len("") : start+len("")+end])) + if filepath.IsAbs(root) { + return root, nil + } + } + } + } + return "", fmt.Errorf("caller workspace absolute path is unavailable") +} + type correlationPromptValue struct { StageID string `json:"stage"` ResponseID string `json:"response"` diff --git a/apps/edge/internal/openai/hot_path_terminal_control_test.go b/apps/edge/internal/openai/hot_path_terminal_control_test.go index d484b9f6..f28ecb92 100644 --- a/apps/edge/internal/openai/hot_path_terminal_control_test.go +++ b/apps/edge/internal/openai/hot_path_terminal_control_test.go @@ -994,7 +994,7 @@ func rejectedStageSnapshot(stream bool) hotPathDispatchSnapshot { selector := hotPathStageCorrelation{StageID: "stg-s", ResponseID: "r:s/1", RunID: "run-s", ProviderID: "p", Terminal: "t"} return hotPathDispatchSnapshot{ Protocol: "openai", Stream: stream, StageID: "stage-r", Stage: config.ExecutionRouteStage{Model: "m"}, - Input: buildLocalStageInput("immutable user task", paths, selector), + Input: buildLocalStageInput(scriptedAbsoluteWorkspaceTask, paths, selector), Route: routeDispatch{NodeRef: "node-stage", ProviderID: "p", Adapter: "a-stage", Target: "t-stage", SessionID: "s-stage", TimeoutSec: 5, ProviderPool: true}, } } From c051529c054a73764a11bcbd09689e28c14a281f Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 08:42:16 +0900 Subject: [PATCH 23/37] =?UTF-8?q?fix(openai):=20Review=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20handoff=20=EC=93=B0=EA=B8=B0=EC=99=80=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work 도구 호출에 review 경로가 포함됐다는 이유만으로 handoff write로 오분류하지 않는다. 실제 write content가 있는 호출만 handoff로 판정하고 단순 read/inspection은 caller 도구로 통과시킨다. --- apps/edge/internal/openai/hot_path_light.go | 38 +++++++++++-------- .../internal/openai/hot_path_light_test.go | 13 +++++++ 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/apps/edge/internal/openai/hot_path_light.go b/apps/edge/internal/openai/hot_path_light.go index 48b6a16a..20401872 100644 --- a/apps/edge/internal/openai/hot_path_light.go +++ b/apps/edge/internal/openai/hot_path_light.go @@ -981,29 +981,19 @@ func (s *hotPathLightStore) classifyLocalOutput(requestID, ownerEdgeID string, o return "", nil } paths := newReservedPaths(requestID) - reviewWrites := 0 + reviewWrites := make([]normalizedToolCall, 0, 1) for _, call := range output.ToolCalls { - for _, observed := range reservedPathsFromToolCall(call) { - if cleanRelativePath(observed) == cleanRelativePath(paths.ReviewPath) { - reviewWrites++ - } + if isWorkerReviewHandoffCall(record.binding, paths, call) { + reviewWrites = append(reviewWrites, call) } } - if reviewWrites == 0 { + if len(reviewWrites) == 0 { return hotPathPendingLocalTools, nil } - if reviewWrites != 1 || !record.localPlanRead { + if len(reviewWrites) != 1 || !record.localPlanRead { return "", fmt.Errorf("worker review handoff must contain one exact review write after reading the plan") } - var reviewCall normalizedToolCall - for _, call := range output.ToolCalls { - for _, observed := range reservedPathsFromToolCall(call) { - if cleanRelativePath(observed) == cleanRelativePath(paths.ReviewPath) { - reviewCall = call - } - } - } - content, err := artifactWriteContent(record.binding, reviewCall) + content, err := artifactWriteContent(record.binding, reviewWrites[0]) if err != nil { return "", err } @@ -1013,6 +1003,22 @@ func (s *hotPathLightStore) classifyLocalOutput(requestID, ownerEdgeID string, o return hotPathPendingLocalHandoff, nil } +func isWorkerReviewHandoffCall(binding *workspaceBinding, paths reservedPaths, call normalizedToolCall) bool { + write := binding.operation(opKindWrite) + if write == nil || strings.TrimSpace(write.contentField) == "" { + return false + } + if _, ok := lookupMappedArgument(call.Arguments, write.contentField); !ok { + return false + } + for _, observed := range reservedPathsFromToolCall(call) { + if cleanRelativePath(observed) == cleanRelativePath(paths.ReviewPath) { + return true + } + } + return false +} + type hotPathReviewEvidence struct { planRead bool reviewRead bool diff --git a/apps/edge/internal/openai/hot_path_light_test.go b/apps/edge/internal/openai/hot_path_light_test.go index 8e03a04b..43881c52 100644 --- a/apps/edge/internal/openai/hot_path_light_test.go +++ b/apps/edge/internal/openai/hot_path_light_test.go @@ -78,6 +78,19 @@ func TestHotPathStageOrdinaryWorkspacePathPassesThrough(t *testing.T) { } } +func TestWorkerReviewReadIsNotClassifiedAsHandoffWrite(t *testing.T) { + binding := mustBinding(t, fullWorkspaceAlternative("workspace", "workspace", false), []any{openAIChatTool("workspace", structuredSchema())}) + paths := newReservedPaths("req_review_read") + read := normalizedToolCall{Name: "workspace", Arguments: map[string]any{"path": paths.ReviewPath}} + if isWorkerReviewHandoffCall(binding, paths, read) { + t.Fatal("review read was classified as a handoff write") + } + write := normalizedToolCall{Name: "workspace", Arguments: map[string]any{"path": paths.ReviewPath, "content": testCompletedReviewText()}} + if !isWorkerReviewHandoffCall(binding, paths, write) { + t.Fatal("review write was not classified as a handoff write") + } +} + func TestWorkerProviderReceivesOnlyCallerTools(t *testing.T) { callerTools := []any{openAIChatTool("run_command", commandSchema())} tools := hotPathStageProviderTools(hotPathDispatchSnapshot{Phase: hotPathPhaseLocalActive, Tools: callerTools}) From 4f1faa56e38332d73a6b78734ff5ee797f6e6bc9 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 09:24:28 +0900 Subject: [PATCH 24/37] =?UTF-8?q?fix(edge):=20=ED=95=98=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=EB=8B=A8=EA=B3=84=20=EC=B1=85=EC=9E=84?= =?UTF-8?q?=EC=9D=84=20=EB=B3=B5=EC=9B=90=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Planner와 Reviewer의 축약 계약을 system 경계에 두고 Worker가 실제 Review 증거를 직접 남기도록 해 terminal 요약에 의한 인계 누락을 막는다. --- .../outer/anthropic-compatible-api.md | 31 +-- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 4 +- .../CODE_REVIEW-cloud-G07.md | 75 +++++++ .../PLAN-local-G07.md | 196 ++++++++++++++++++ .../openai/hot_path_anthropic_gate_test.go | 4 +- .../openai/hot_path_chat_gate_test.go | 2 +- .../internal/openai/hot_path_direct_test.go | 7 +- .../edge/internal/openai/hot_path_dispatch.go | 19 +- apps/edge/internal/openai/hot_path_light.go | 97 +-------- .../internal/openai/hot_path_light_test.go | 71 +++++-- .../openai/hot_path_observation_test.go | 2 +- apps/edge/internal/openai/hot_path_review.go | 2 +- .../internal/openai/hot_path_stage_input.go | 24 ++- .../internal/openai/provider_model_rewrite.go | 21 +- .../openai/provider_model_rewrite_test.go | 22 ++ 16 files changed, 447 insertions(+), 132 deletions(-) create mode 100644 agent-task/hybrid_pipeline_contract/CODE_REVIEW-cloud-G07.md create mode 100644 agent-task/hybrid_pipeline_contract/PLAN-local-G07.md diff --git a/agent-contract/outer/anthropic-compatible-api.md b/agent-contract/outer/anthropic-compatible-api.md index 6fad9424..1dba1700 100644 --- a/agent-contract/outer/anthropic-compatible-api.md +++ b/agent-contract/outer/anthropic-compatible-api.md @@ -94,19 +94,24 @@ response model semantics. An unmarked `light` preset with `workspace_tools` uses only the tool schemas admitted by the caller request. Edge never receives or configures a raw caller workspace path. Before issuing caller writes, Edge validates the selector's -default lightweight PLAN and pending REVIEW seed and retains the PLAN `P1..Pn` -inventory request-locally. Selector instructions require every PLAN field to be -English ASCII, and Edge rejects a non-ASCII PLAN before issuing caller writes. +lightweight PLAN and pending REVIEW seed and retains the PLAN `P1..Pn` inventory +request-locally. Its Edge-owned system instruction applies a compact Plan contract: +analyze the immutable task first, preserve explicit requirements and acceptance, +close 2-6 executable steps and deterministic verification, and put the exact REVIEW +handoff duty in the final step. Every PLAN field remains English ASCII and Edge +rejects a non-ASCII PLAN before issuing caller writes. Work receives only the absolute PLAN path resolved from the caller's existing working-directory system context, followed by the fixed English sentence `Read it and complete the task.` and sees only ordinary caller tools. It must read -PLAN and perform and verify caller-workspace work. After a successful PLAN read and -a non-empty terminal worker report, Edge renders and validates the completed REVIEW -handoff and projects its single caller write; Work does not own that protocol. Only +PLAN, perform and verify caller-workspace work, and replace the exact REVIEW artifact +with completed item statuses, actual changes, verification evidence, and deviations. +Edge validates and projects that Worker-authored REVIEW write; it never synthesizes +Review evidence from terminal prose. Only exact PLAN/REVIEW accesses contribute artifact state; every other caller-workspace -tool call passes through unchanged. Review -must read both artifacts and successfully inspect an ordinary caller result before -it can return a non-empty caller-visible terminal. It never rewrites REVIEW; repair -and re-verification remain in the same Review binding. Cleanup targets only the +tool call passes through unchanged. Review alone receives an Edge-owned compact +code-review system instruction. It must read both artifacts, inspect the actual +caller result, and rerun applicable verification before returning a non-empty +caller-visible terminal. It never rewrites REVIEW or creates a Result document; +root-cause repair and re-verification remain in the same Review binding. Cleanup targets only the request's `.iop/job/` directory. After Edge issues the request-local artifact paths, it adds one bounded Edge-owned, @@ -116,14 +121,14 @@ the provider must emit exactly one admitted prepare call for `.iop/job/` and must not write PLAN or REVIEW. After the successful prepare receipt, the resumed selector receives `pair-write` and must emit exactly one atomic artifact-pair call containing the complete PLAN and REVIEW content with -the accepted default PLAN grammar and deterministic pending REVIEW grammar. Edge +the accepted bounded PLAN grammar and matching pending REVIEW inventory. Edge expands that decision into two independently receipted admitted caller writes. A parent-creating write binding receives `pair-write` immediately on its initial selector turn. In Chat Completions this is the final leading `system` message; in native Messages it is the final top-level `system` text block. The instruction is not caller input, does not alter the retained caller request snapshot, and adds no public request/config field or raw workspace -path. Work, Review, cleanup, and marked `single_request` provider bodies do not -receive it. +path. Work, cleanup, and marked `single_request` provider bodies do not receive it; +Review receives only its separate compact code-review system instruction. Continuation issue correlation hashes only the emitted tool-use identity, name, and arguments/input. SDK-added text, thinking, and provider metadata decorations are not diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index ce8feea3..7652772d 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. 형식이 bounded인 selector PLAN/REVIEW seed는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Selector instruction은 모든 PLAN field를 English ASCII로 작성하도록 요구하고 Edge는 non-ASCII PLAN을 caller write 전에 거부한다. Edge는 검증된 atomic pair를 PLAN/REVIEW 두 write로 확장하고 admitted caller tool의 실제 schema(예: `bash(command)`)로 각각 encode해 caller에게 투영한다. 따라서 selector에게 두 parallel tool call 생성을 요구하지 않는다. Work provider의 전체 task prompt는 caller의 기존 working-directory system context에서 해석한 absolute PLAN path와 고정 영문 `Read it and complete the task.`뿐이며 ordinary caller tools만 보인다. Work가 PLAN을 성공적으로 읽은 뒤 non-empty terminal report를 반환하면 Edge가 completed REVIEW template을 렌더·검증하고 단일 caller write로 투영한다. Work provider는 REVIEW handoff tool이나 protocol을 소유하지 않는다. 정확한 PLAN/REVIEW 접근만 artifact state로 기록하고 그 외 caller-workspace tool call은 경로 검사 없이 그대로 통과시킨다. Work/Review provider가 반환한 reserved-artifact operation은 실제 provider tool의 path alias(예: `filePath`)를 발행된 canonical path로 복원하고, 선택된 caller binding의 실제 tool 이름·argument mapping으로 정규화한 뒤 같은 codec으로 encode한다. 이 변환이 필요한 command-mode binding stage는 provider delta를 caller에게 선행 방출하지 않고 bounded stage를 수집·검증·매핑한 뒤 endpoint-native 응답으로 한 번 투영하며, 직접 투영 가능한 structured binding의 기존 progressive stream은 유지한다. Cleanup은 이미 caller-stage-only인 단일 call ID를 그대로 발행·해시하며 outer sequencer로 재투영하지 않는다. Caller continuation은 SDK가 assistant message에 추가·생략하는 content/reasoning/provider metadata가 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Unmarked 내부 stage가 function tools와 reasoning effort를 함께 요구하고 selected OpenAI profile의 Chat wire가 이를 지원하지 않으면, 같은 profile의 Responses operation이 두 요구를 보존할 때 내부 wire만 Responses로 전환한다. 이 내부 operation은 body와 tunnel metadata 모두 `stream=false`인 단일 buffered stage이며, provider가 거부하는 generic SDK sampling defaults인 `temperature`와 `top_p`를 전달하지 않고 mapped effort를 generation control로 사용한다. Result decoder는 caller protocol이 아니라 selected operation을 우선하여 Responses JSON을 기존 hot-path 공통 stage shape로 복원하며, caller-visible Chat surface와 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. Bounded selector PLAN/REVIEW pair는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Selector system instruction은 compact Plan 계약에 따라 immutable task를 먼저 분석하고 explicit requirement/constraint/acceptance를 보존한 2-6개 closed step, deterministic verification, exact REVIEW handoff duty를 작성하도록 하며 PLAN field를 English ASCII로 제한한다. Edge는 검증된 atomic pair를 admitted caller tool schema의 PLAN/REVIEW 두 write로 확장한다. Work provider의 전체 task prompt는 caller의 기존 working-directory system context에서 해석한 absolute PLAN path와 고정 영문 `Read it and complete the task.`뿐이며 ordinary caller tools만 보인다. Work는 PLAN을 읽고 구현·검증한 뒤 completed item status, actual changes, verification evidence, deviation을 담은 exact REVIEW write를 직접 반환한다. Edge는 이 Worker-authored handoff를 검증·투영하며 terminal prose로 REVIEW evidence를 합성하지 않는다. Review provider에만 별도 compact code-review system instruction을 주입한다. Review는 PLAN/REVIEW와 actual caller result를 읽고 applicable verification을 재실행하며, defect가 있으면 같은 binding에서 root cause를 확정해 repair/re-verify한 후에만 terminal을 반환한다. REVIEW를 다시 쓰거나 별도 Result 문서를 만들지 않는다. 정확한 PLAN/REVIEW 접근만 artifact state로 기록하고 그 외 caller-workspace tool call은 그대로 통과시킨다. Reserved-artifact operation은 provider tool의 path alias를 canonical path로 복원하고 caller binding의 tool/argument mapping으로 정규화한다. Command-mode binding stage는 provider delta를 선행 방출하지 않고 bounded stage를 수집·검증·매핑하며 structured binding의 progressive stream은 유지한다. Cleanup은 request-local job directory만 제거한다. Caller continuation은 SDK 장식이 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Function tools와 reasoning effort를 Chat wire가 함께 표현하지 못하면 같은 selected profile의 Responses operation으로 내부 wire만 전환한다. Result decoder는 selected operation을 우선하며 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index f84349d0..9d765c4c 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives a phase-specific selector instruction from the locked artifact frontier. A non-parent-creating write binding first receives `prepare-only` for exactly one admitted `.iop/job/` prepare call with PLAN/REVIEW writes prohibited; its successful receipt resumes the same selector with `pair-write` for the accepted artifact grammar. A parent-creating binding receives `pair-write` immediately. The selector provider sees only canonical `prepare(path)` or one atomic `iop_write_artifact_pair(plan_content,review_content)` schema. The selector must write every PLAN field in English ASCII; Edge rejects non-ASCII PLAN content. Edge validates and expands the pair into two independently receipted writes encoded in the admitted caller tool schema such as `bash(command)`; the selector is not required to produce parallel tool calls. Continuation issue identity covers semantic tool-call ID/name/arguments rather than SDK-decorated assistant content, reasoning, or provider metadata, while request prefix and result IDs stay independently validated. The selector instruction is the final leading Chat `system` message or final Anthropic top-level `system` text block and does not mutate the retained caller snapshot or add a public field. Work receives exactly the absolute PLAN path resolved from the caller's existing working-directory system context plus `Read it and complete the task.` and only ordinary caller tools. After a successful PLAN read and a non-empty terminal worker report, Edge renders and validates the completed `P1..Pn` REVIEW handoff and projects its single caller write; Work does not manage that protocol. Exact PLAN/REVIEW access updates artifact state, while every other caller-workspace tool call passes through unchanged. Selector/Work/Review Chat bodies normalize `max_tokens`/`max_completion_tokens` to the selected profile wire; target-native values win when both aliases exist. The bounded selector PLAN/REVIEW seed is capped at 4096 output tokens independently of a caller SDK's larger completion default. When tools plus reasoning effort are not representable on the selected OpenAI Chat wire, the internal stage alone uses that profile's lossless buffered Responses operation with body/tunnel `stream=false`, omits incompatible generic SDK `temperature`/`top_p` defaults, and restores the common hot-path stage shape by selected operation rather than caller protocol. A selector upstream HTTP 400 becomes one non-retryable caller HTTP 400 `invalid_request_error`, while provider details remain private. Review reads both artifacts, successfully inspects an ordinary caller result, repairs/re-verifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW. Cleanup removes only the request `.iop/job/` directory. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives `prepare-only` or atomic `pair-write` from the locked artifact frontier. The selector receives a compact Plan system contract: analyze the immutable task, preserve explicit requirements/constraints/acceptance, author 2-6 closed steps plus deterministic verification, and put the exact REVIEW handoff duty in the final step. PLAN remains English ASCII and pending REVIEW carries the same P1..Pn inventory. Work receives only the absolute PLAN path plus `Read it and complete the task.` and ordinary caller tools; no Plan/Review skill system prompt is added to Work. Work implements and verifies, then directly replaces REVIEW with completed statuses, actual changes, verification evidence, and deviations. Edge validates that Worker-authored write and never synthesizes it from terminal prose. Review alone receives the compact code-review system contract. It rereads PLAN/REVIEW, inspects the actual result, reruns applicable verification, repairs and reverifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW or creating a Result document. Exact artifact accesses update request-local state, ordinary caller tool calls pass through, and cleanup removes only `.iop/job/`. Provider operation/effort/tool normalization remains selected-profile driven and caller identity independent. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | @@ -371,6 +371,8 @@ sequenceDiagram ## 변경 기록 +- 2026-08-15: Corrected the unmarked caller-workspace hybrid responsibility contract. Planner and Reviewer now receive separate compact system contracts, Worker still receives only the absolute PLAN path plus the fixed sentence, Worker directly authors REVIEW evidence, and Review retains inspection/repair/re-verification without a Result artifact. + - 2026-08-15: Normalized Chat output-token aliases by selected protocol profile across provider-pool and unmarked caller-workspace selector/Work/Review calls. OpenAI Chat uses `max_completion_tokens`, Gemini Chat uses `max_tokens`, target-native values win, and unmarked internal stages use the selected OpenAI profile's Responses operation when tools plus effort cannot be represented by Chat. The reasoning Responses conversion omits incompatible generic SDK `temperature`/`top_p` defaults. Selector upstream HTTP 400 now terminates once as caller `invalid_request_error` instead of retryable 502. Command-mode workspace execution also carries the admitted containment guard in the actual outgoing command. - 2026-08-14: Split the unmarked selector instruction into frontier-derived `prepare-only` and `pair-write` operations. Non-parent-creating bindings prepare the request job directory first and receive the exact artifact-pair grammar only after the successful receipt; parent-creating bindings receive the pair operation immediately. - 2026-08-15: Replaced probabilistic parallel PLAN/REVIEW selector calls with one canonical atomic artifact-pair call that Edge deterministically expands into the same two admitted caller writes. diff --git a/agent-task/hybrid_pipeline_contract/CODE_REVIEW-cloud-G07.md b/agent-task/hybrid_pipeline_contract/CODE_REVIEW-cloud-G07.md new file mode 100644 index 00000000..120ad718 --- /dev/null +++ b/agent-task/hybrid_pipeline_contract/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,75 @@ + + +# Code Review Reference - REFACTOR + +> Implementing agent: complete every implementation-owned section, run the Plan verification, and leave this active file in place. Review agent owns verdict and finalization. + +## Overview + +date=2026-08-15 +task=hybrid_pipeline_contract, plan=0, tag=REFACTOR + +## Implementation Item Completion + +| Item | Status | +|---|---| +| REFACTOR-1 Restore Planner Contract | completed | +| REFACTOR-2 Make Reviewer Contract System-Owned | completed | +| REFACTOR-3 Restore Worker-Owned Review Handoff | completed | +| REFACTOR-4 Synchronize Contract And Run Qualification | in progress: local evidence complete, live qualification pending reviewed deployment | + +## Implementation Checklist + +- [x] Add the compact Agent-Ops Plan contract to the Planner system instruction while retaining atomic Plan/Review pair output and bounded grammar. +- [x] Inject the compact Agent-Ops Review/Repair contract as Reviewer-only system guidance across normalized, Chat, and Messages stage bodies without changing Worker input. +- [x] Require Worker-authored Review evidence as the local-stage handoff and remove Edge synthesis from terminal prose. +- [ ] Update focused tests, contracts, and current specs; run package, full Go, and live Gemini hybrid verification. +- [x] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output. + +## Deviations from Plan + +- Added `hot_path_review.go` and three existing cross-path fixture files to the exact write boundary because the `issueTools` signature and direct handoff semantics required their mechanical updates. +- `agent-spec/runtime/edge-node-execution.md` was not changed: its matching text describes the separate marked `single_request` Node-workspace path, while this change is limited to the unmarked caller-workspace light route. +- Live Gemini qualification remains after reviewed dev deployment as planned. + +## Key Design Decisions + +- Planner keeps the existing selector system-message injection and atomic pair tool; only its authored contract changed. +- Reviewer guidance is a separate system value on normalized input, a leading Chat system message, or Anthropic top-level system. Dynamic task/artifact/correlation data remains user-stage input. +- Worker prompt remains byte-for-byte `absolute PLAN path + fixed sentence` and receives no compact skill system prompt. +- A validated Worker Review tool call now supplies the local commit correlation. The receipt transitions directly to Review; terminal prose can no longer manufacture Review evidence. + +## Reviewer Checkpoints + +- Planner/Reviewer skill contracts are system-owned; Worker prompt remains exact and minimal. +- Worker writes Review evidence directly; Edge does not infer it from terminal prose. +- Reviewer reads artifacts and actual result, verifies, repairs if necessary, and reverifies in the same stage. +- Existing stage routing, paths, cleanup, and provider normalization remain intact. + +## Verification Results + +### Targeted Edge tests + +Command: `go test ./apps/edge/internal/openai -count=1` + +Result: PASS. + +Output: `ok iop/apps/edge/internal/openai 8.719s` + +### Full Go regression + +Command: `go test ./... -count=1` + +Result: PASS. + +All Go packages passed, including `apps/edge/internal/openai`, `apps/edge/internal/service`, Node workspace/transport, config, and `packages/go/singlerequesttemplate`. + +### Gemini hybrid qualification + +Pending reviewed deployment and isolated live execution. No credential, provider endpoint, or raw model output has been written to this artifact. + +## Review-Only Checklist + +- [ ] Run applicable verification and inspect actual changed files. +- [ ] Append PASS/WARN/FAIL with findings, root cause, and selected fix when needed. +- [ ] Finalize the Agent-Ops task state according to the code-review skill. diff --git a/agent-task/hybrid_pipeline_contract/PLAN-local-G07.md b/agent-task/hybrid_pipeline_contract/PLAN-local-G07.md new file mode 100644 index 00000000..291710d5 --- /dev/null +++ b/agent-task/hybrid_pipeline_contract/PLAN-local-G07.md @@ -0,0 +1,196 @@ + + +# Lightweight Hybrid Plan/Review Contract + +## For the Implementing Agent + +Follow this plan as written. Run every listed verification command and fill the implementation-owned sections in `CODE_REVIEW-cloud-G07.md` with actual changes and command output. Keep both active files in place and report ready for review. If blocked, record the exact blocker, attempted command/output, and resume condition only in the review evidence; do not archive task files or create another control-plane state. + +## Background + +The caller-workspace light pipeline already has selector, local worker, review, repair, and cleanup stages. Its current prompts and handoff ownership diverge from the intended compact Agent-Ops Plan/Review workflow: Plan is forced into a deterministic seed, Reviewer guidance is carried as ordinary stage text, and Edge can synthesize Review evidence from a worker terminal summary. This task preserves the state machine and corrects only those internal contracts before rerunning the Gemini hybrid benchmark. + +## Analysis + +### Files Read + +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/code-review/SKILL.md` +- `agent-ops/skills/common/finalize-task-routing/SKILL.md` +- `agent-ops/skills/common/plan/templates/review-stub-template.md` +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/project/domain/edge/rules.md` +- `agent-ops/rules/project/domain/platform-common/rules.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-test/local/rules.md` +- `agent-contract/index.md` +- `agent-contract/outer/anthropic-compatible-api.md` +- `agent-contract/outer/openai-compatible-api.md` +- `agent-spec/index.md` +- `agent-spec/runtime/edge-node-execution.md` +- `agent-spec/input/openai-compatible-surface.md` +- `apps/edge/internal/openai/provider_model_rewrite.go` +- `apps/edge/internal/openai/hot_path_stage_input.go` +- `apps/edge/internal/openai/hot_path_dispatch.go` +- `apps/edge/internal/openai/hot_path_light.go` +- `apps/edge/internal/openai/hot_path_review.go` +- `apps/edge/internal/openai/artifact_pair.go` +- `packages/go/singlerequesttemplate/template.go` +- `apps/edge/internal/openai/provider_model_rewrite_test.go` +- `apps/edge/internal/openai/hot_path_direct_test.go` +- `apps/edge/internal/openai/hot_path_light_test.go` +- `apps/edge/internal/openai/hot_path_anthropic_gate_test.go` +- `apps/edge/internal/openai/hot_path_chat_gate_test.go` +- `apps/edge/internal/openai/hot_path_observation_test.go` + +### SDD Criteria + +Not applicable. This is a non-Milestone correction to an existing runtime contract. + +### Verification Context + +No external handoff was supplied. Repository source, existing hot-path tests, outer contracts, and current Gemini hybrid qualification requirements are the evidence sources. Unit verification uses the Edge package and full Go suite; live verification uses the existing dev deployment and the same Gemini hybrid benchmark path after implementation review. Live credentials and endpoints remain external runtime inputs and must not be written to tracked files or test output. + +### Test Coverage Gaps + +- Existing selector tests assert the deterministic two-step seed but do not assert compact Plan-skill analysis/acceptance/handoff instructions. +- Existing stage-input tests assert Worker prompt isolation but do not prove Reviewer guidance is a system message on both Chat and Messages wires. +- Existing light-flow fixtures exercise Edge-projected worker Review completion; they must instead prove the Worker-authored Review write is the local-stage terminal handoff. +- Existing review/repair tests cover artifact reads and repair transitions and should remain valid after system-prompt injection. + +### Symbol References + +- `projectWorkerCompletionReview` and `boundedWorkerCompletionSummary` are local to `hot_path_light.go`; removing them has no external call sites. +- `pendingLocalCommit` is used only by the local-handoff transition and can be populated from the Worker Review tool-call dispatch correlation instead of a synthesized terminal projection. +- `hotPathChatStageMessages`, `hotPathAnthropicStageBody`, and `hotPathStageRunInput` are the three internal provider shapes that must carry Reviewer-only system guidance without changing Worker input. + +### Split Judgment + +Keep one plan. Planner guidance, Worker handoff ownership, and Reviewer guidance form one cross-stage correctness invariant; splitting would allow an intermediate state in which artifacts or transitions no longer agree. + +### Scope Rationale + +Do not redesign execution presets, stage routing, provider normalization, artifact paths, cleanup, benchmark scoring, or the shared Plan/Review Markdown parser. Do not inject a Plan/Review skill prompt into Worker. Do not add a Result artifact. The current lightweight Plan/Review grammar remains bounded; only its authored content and stage ownership are corrected. + +### Final Routing + +- evaluation_mode: `first-pass` +- finalizer: `finalize-task-policy.sh`, mode `pair` +- build: closures all true; scores `scope=1,state=2,blast=1,evidence=1,verification=2`; `local-fit`, `local/G07`, `PLAN-local-G07.md` +- review: closures all true; scores `scope=1,state=2,blast=1,evidence=1,verification=2`; `official-review`, `cloud/G07`, `CODE_REVIEW-cloud-G07.md` +- large_indivisible_context: `false` +- matched_loop_risk_signatures: `temporal_state`, `boundary_contract`, `structured_interpretation` (count 3) +- review_rework_count: `0` +- evidence_integrity_failure: `false` + +## Implementation Checklist + +- [ ] Add the compact Agent-Ops Plan contract to the Planner system instruction while retaining atomic Plan/Review pair output and bounded grammar. +- [ ] Inject the compact Agent-Ops Review/Repair contract as Reviewer-only system guidance across normalized, Chat, and Messages stage bodies without changing Worker input. +- [ ] Require Worker-authored Review evidence as the local-stage handoff and remove Edge synthesis from terminal prose. +- [ ] Update focused tests, contracts, and current specs; run package, full Go, and live Gemini hybrid verification. +- [ ] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output. + +### [REFACTOR-1] Restore Planner Contract + +**Problem:** `apps/edge/internal/openai/provider_model_rewrite.go:36` tells the selector to replace a deterministic two-step seed, so explicit requirements and acceptance constraints can be lost before Work. + +**Solution:** Keep the existing selector system-message injection and atomic pair tool. Replace the seed-oriented prose with a concise Plan-skill contract: analyze the immutable task before authoring, preserve all explicit requirements/constraints/acceptance, choose 2-6 closed executable steps, specify deterministic verification, and make the final step name the exact Review path and Worker evidence responsibility. Generate the pending Review status inventory for exactly the chosen P1..Pn ids. + +**Modified Files and Checklist:** + +- [ ] `apps/edge/internal/openai/provider_model_rewrite.go`: replace deterministic seed guidance with the compact Plan system contract. +- [ ] `apps/edge/internal/openai/provider_model_rewrite_test.go`: assert analysis/acceptance/handoff guidance and bounded atomic output. +- [ ] `apps/edge/internal/openai/hot_path_direct_test.go`: update the selector fixture's required compact-contract fragments. + +**Test Strategy:** Update the existing selector instruction/canonical tool tests; no new harness. + +**Verification:** `go test ./apps/edge/internal/openai -run 'TestHotPathSelector|TestProvider' -count=1` passes. + +### [REFACTOR-2] Make Reviewer Contract System-Owned + +**Problem:** `apps/edge/internal/openai/hot_path_stage_input.go:140` embeds review behavior in the ordinary stage prompt, while Chat and Messages provider bodies have no Reviewer-specific system contract. + +**Solution:** Add one concise Reviewer system contract derived from the code-review skill: read Plan and worker Review, inspect actual output, rerun verification, establish findings/root cause/one fix, repair and reverify in the same stage, and return the final result without a separate Result document. Inject it only for Reviewer in normalized input, Chat system messages, and Anthropic top-level system. Keep dynamic task/artifact/correlation data in the ordinary prompt. Leave the local Worker prompt byte-for-byte unchanged. + +**Modified Files and Checklist:** + +- [ ] `apps/edge/internal/openai/hot_path_stage_input.go`: define Reviewer system guidance and reduce dynamic prompt to stage data/action. +- [ ] `apps/edge/internal/openai/hot_path_dispatch.go`: inject Reviewer-only system guidance into all internal provider request shapes. +- [ ] `apps/edge/internal/openai/hot_path_light_test.go`: prove system/user separation and unchanged Worker prompt for Chat and Messages. + +**Test Strategy:** Extend existing stage-input and captured tunnel-body assertions. + +**Verification:** `go test ./apps/edge/internal/openai -run 'TestHotPathStageInput|TestHotPathLight' -count=1` passes. + +### [REFACTOR-3] Restore Worker-Owned Review Handoff + +**Problem:** `apps/edge/internal/openai/hot_path_light.go:891` converts Worker terminal prose into completed Review fields, making Edge—not Worker—the evidence author and losing actual commands/deviations. + +**Solution:** Remove terminal-summary Review projection. When the Worker emits the exact validated Review write after reading Plan, retain that provider dispatch correlation as the local commit; after the caller receipts the write, transition directly to Review. A terminal without a successful Plan read and Worker-authored Review remains a validation failure. Preserve ordinary caller tool pass-through and existing Review/Repair transitions. + +**Modified Files and Checklist:** + +- [ ] `apps/edge/internal/openai/hot_path_light.go`: commit direct Worker Review handoff and remove synthesized Review projection. +- [ ] `apps/edge/internal/openai/hot_path_review.go`: pass no Worker commit correlation through Reviewer tool dispatch. +- [ ] `apps/edge/internal/openai/hot_path_light_test.go`: update fixtures and add regression coverage for missing/malformed Worker evidence. +- [ ] `apps/edge/internal/openai/hot_path_anthropic_gate_test.go`: make the streaming fixture use a Worker-authored Review handoff. +- [ ] `apps/edge/internal/openai/hot_path_chat_gate_test.go`: keep mixed-provider coverage with a Worker-authored Review handoff. +- [ ] `apps/edge/internal/openai/hot_path_observation_test.go`: record the Review handoff as the local stage's final tool turn. + +**Test Strategy:** Use existing scripted OpenAI and Anthropic fixtures with direct Worker Review writes; assert no local terminal summary is needed and malformed/missing handoff fails. + +**Verification:** `go test ./apps/edge/internal/openai -run 'TestHotPathLight|TestWorker' -count=1` passes. + +### [REFACTOR-4] Synchronize Contract And Run Qualification + +**Problem:** Outer contracts/specs currently state that Edge renders completed Review from a Worker report, which would become stale after ownership is corrected. + +**Solution:** Update only the affected current contract/spec statements. After code review and deployment, run the same Gemini hybrid benchmark in an isolated execution and capture request/start/completion timing, artifacts, actual output, review/repair behavior, and terminal result without secrets. + +**Modified Files and Checklist:** + +- [ ] `agent-contract/outer/anthropic-compatible-api.md`: record Planner/Reviewer system contracts and Worker-authored Review handoff. +- [ ] `agent-contract/outer/openai-compatible-api.md`: update the unmarked caller-workspace light route. +- [ ] `agent-spec/runtime/edge-node-execution.md`: synchronize the current hybrid stage responsibility summary if it contains the affected behavior. +- [ ] `agent-spec/input/openai-compatible-surface.md`: synchronize the caller-workspace hybrid behavior. +- [ ] `agent-task/hybrid_pipeline_contract/CODE_REVIEW-cloud-G07.md`: record actual implementation and verification evidence. + +**Test Strategy:** Targeted and full Go regression, then existing live Gemini hybrid benchmark only; do not create another benchmark harness. + +**Verification:** `go test ./... -count=1` passes, deployment health is confirmed, and one isolated Gemini hybrid execution produces worker-authored Review evidence plus a reviewer-verified final workspace result. + +## Modified Files Summary + +| File | Items | +|---|---| +| `apps/edge/internal/openai/provider_model_rewrite.go` | REFACTOR-1 | +| `apps/edge/internal/openai/provider_model_rewrite_test.go` | REFACTOR-1 | +| `apps/edge/internal/openai/hot_path_direct_test.go` | REFACTOR-1 | +| `apps/edge/internal/openai/hot_path_stage_input.go` | REFACTOR-2 | +| `apps/edge/internal/openai/hot_path_dispatch.go` | REFACTOR-2 | +| `apps/edge/internal/openai/hot_path_light.go` | REFACTOR-3 | +| `apps/edge/internal/openai/hot_path_review.go` | REFACTOR-3 | +| `apps/edge/internal/openai/hot_path_light_test.go` | REFACTOR-2, REFACTOR-3 | +| `apps/edge/internal/openai/hot_path_anthropic_gate_test.go` | REFACTOR-3 | +| `apps/edge/internal/openai/hot_path_chat_gate_test.go` | REFACTOR-3 | +| `apps/edge/internal/openai/hot_path_observation_test.go` | REFACTOR-3 | +| `agent-contract/outer/anthropic-compatible-api.md` | REFACTOR-4 | +| `agent-contract/outer/openai-compatible-api.md` | REFACTOR-4 | +| `agent-spec/runtime/edge-node-execution.md` | REFACTOR-4 | +| `agent-spec/input/openai-compatible-surface.md` | REFACTOR-4 | +| `agent-task/hybrid_pipeline_contract/CODE_REVIEW-cloud-G07.md` | REFACTOR-4 | + +## Dependencies and Execution Order + +REFACTOR-1 and REFACTOR-2 establish the prompt contracts. REFACTOR-3 changes the matching Worker handoff transition. REFACTOR-4 follows the implemented behavior and must not document unverified semantics. + +## Final Verification + +1. `gofmt -w apps/edge/internal/openai/provider_model_rewrite.go apps/edge/internal/openai/provider_model_rewrite_test.go apps/edge/internal/openai/hot_path_stage_input.go apps/edge/internal/openai/hot_path_dispatch.go apps/edge/internal/openai/hot_path_light.go apps/edge/internal/openai/hot_path_light_test.go` +2. `go test ./apps/edge/internal/openai -count=1` +3. `go test ./... -count=1` +4. Deploy the reviewed build to the existing dev environment using the project deployment skill. +5. Run one isolated Gemini hybrid benchmark through the existing path and verify the absolute Plan-only Worker prompt, Worker-authored Review evidence, actual workspace result, Reviewer inspection/repair if needed, one terminal, and cleanup. + +After completing all code changes, fill implementation-owned sections in `CODE_REVIEW-*-G??.md`. diff --git a/apps/edge/internal/openai/hot_path_anthropic_gate_test.go b/apps/edge/internal/openai/hot_path_anthropic_gate_test.go index 6100ac44..78cf3abc 100644 --- a/apps/edge/internal/openai/hot_path_anthropic_gate_test.go +++ b/apps/edge/internal/openai/hot_path_anthropic_gate_test.go @@ -152,8 +152,8 @@ func TestHotPathAnthropicDirectStreamPreservesEmptyToolInput(t *testing.T) { func TestHotPathAnthropicLightStreamAggregatesStages(t *testing.T) { fixture := newScriptedLightFixture(t, "anthropic", false) - fixture.service.responses[4] = func(string) string { - return scriptedLightCompletionWithUsage("anthropic", "local-visible", "local-reason", 5, 3) + fixture.service.responses[4] = func(requestID string) string { + return scriptedReviewWriteWithUsage("anthropic", requestID, 5, 3) } fixture.service.responses[5] = func(requestID string) string { return scriptedReviewReadsAndInspectionWithUsage(requestID, 7, 4) diff --git a/apps/edge/internal/openai/hot_path_chat_gate_test.go b/apps/edge/internal/openai/hot_path_chat_gate_test.go index 4021ccf6..bedc9381 100644 --- a/apps/edge/internal/openai/hot_path_chat_gate_test.go +++ b/apps/edge/internal/openai/hot_path_chat_gate_test.go @@ -153,7 +153,7 @@ func TestHotPathChatMixedProviderStages(t *testing.T) { {candidate: openAICandidate, body: func(requestID string) string { return scriptedArtifactPair("openai", requestID) }}, {candidate: openAICandidate, body: func(requestID string) string { return scriptedArtifactLocalRead("openai", requestID) }}, {candidate: openAICandidate, body: func(string) string { return scriptedLocalWorkTool("openai") }}, - {candidate: openAICandidate, contentType: "text/event-stream", body: func(string) string { return hotPathChatMixedLocalSSE() }}, + {candidate: openAICandidate, body: func(requestID string) string { return scriptedReviewWrite("openai", requestID) }}, {candidate: anthropicCandidate, body: func(requestID string) string { return scriptedReviewReadsAndInspectionWithUsage(requestID, 7, 3) }}, } diff --git a/apps/edge/internal/openai/hot_path_direct_test.go b/apps/edge/internal/openai/hot_path_direct_test.go index 146661a9..2e05da22 100644 --- a/apps/edge/internal/openai/hot_path_direct_test.go +++ b/apps/edge/internal/openai/hot_path_direct_test.go @@ -464,13 +464,16 @@ func scriptedSelectorDirective(providerBody []byte, operation string) (string, s "Return exactly one iop_write_artifact_pair tool call", "PLAN path: " + paths.PlanPath, "REVIEW path: " + paths.ReviewPath, - "The default PLAN grammar accepts 2-6 consecutive steps and 1-3 verification bullets.", + "You are the Planner.", + "Analyze the immutable user task first.", + "Preserve every explicit requirement, constraint, deliverable, and acceptance condition.", "# Plan\n\n## Goal\n\n\n## Steps\n- [P1] \n- [P2] ", - "The pending REVIEW content must be exactly this deterministic seed:", + "The pending REVIEW must use exactly the Plan's P1..Pn inventory in order", "# Review\n\n## Worker Item Status\n- P1: pending\n- P2: pending", "## Worker Changes\nPending worker execution.", "## Worker Verification\nPending worker verification.", "## Deviations\nNone recorded.", + "Do not copy the explanatory optional-status text into REVIEW.", } for _, fragment := range required { if !strings.Contains(instruction, fragment) { diff --git a/apps/edge/internal/openai/hot_path_dispatch.go b/apps/edge/internal/openai/hot_path_dispatch.go index 1b17a6b6..bccbe96f 100644 --- a/apps/edge/internal/openai/hot_path_dispatch.go +++ b/apps/edge/internal/openai/hot_path_dispatch.go @@ -1284,7 +1284,12 @@ func (s *Server) submitHotPathStage(ctx context.Context, r *http.Request, snapsh metadata[principalMetaRef] = snapshot.PrincipalRef } applyTrustedManagedBindingMetadata(metadata, route) - estimate := estimateInputTokensBytes([]byte(prompt), metadata, snapshot.Tools, nil) + systemPrompt := snapshot.Input.systemPrompt(snapshot.Phase) + estimateInput := prompt + if systemPrompt != "" { + estimateInput = systemPrompt + "\n" + prompt + } + estimate := estimateInputTokensBytes([]byte(estimateInput), metadata, snapshot.Tools, nil) contextClass := classifyContext(estimate, s.longContextThreshold()) runInput := hotPathStageRunInput(snapshot, prompt) runReq := edgeservice.SubmitRunRequest{ @@ -1712,6 +1717,9 @@ func hotPathStageMetaFromDispatch(stageID string, dispatch edgeservice.RunDispat func hotPathStageRunInput(snapshot hotPathDispatchSnapshot, prompt string) map[string]any { messages := hotPathChatStageMessages(snapshot, prompt) input := map[string]any{"prompt": prompt, "messages": messages} + if systemPrompt := snapshot.Input.systemPrompt(snapshot.Phase); systemPrompt != "" { + input["system"] = systemPrompt + } if tools := hotPathChatTools(hotPathStageProviderTools(snapshot)); len(tools) > 0 { input["tools"] = tools input["tool_choice"] = "auto" @@ -1750,6 +1758,9 @@ func hotPathAnthropicStageBody(snapshot hotPathDispatchSnapshot, prompt, target body := map[string]any{ "model": target, "max_tokens": 4096, "messages": hotPathAnthropicStageMessages(snapshot, prompt), "stream": snapshot.Stream, } + if systemPrompt := snapshot.Input.systemPrompt(snapshot.Phase); systemPrompt != "" { + body["system"] = systemPrompt + } if tools := hotPathAnthropicTools(hotPathStageProviderTools(snapshot)); len(tools) > 0 { body["tools"] = tools body["tool_choice"] = map[string]any{"type": "auto"} @@ -1777,7 +1788,11 @@ func applyHotPathStageOptions(body map[string]any, options map[string]any, reser } func hotPathChatStageMessages(snapshot hotPathDispatchSnapshot, prompt string) []any { - messages := []any{map[string]any{"role": "user", "content": prompt}} + messages := make([]any, 0, len(snapshot.Transcript)*2+2) + if systemPrompt := snapshot.Input.systemPrompt(snapshot.Phase); systemPrompt != "" { + messages = append(messages, map[string]any{"role": "system", "content": systemPrompt}) + } + messages = append(messages, map[string]any{"role": "user", "content": prompt}) for _, exchange := range snapshot.Transcript { assistant := map[string]any{"role": "assistant", "content": exchange.Output.Content} if exchange.Output.Reasoning != "" { diff --git a/apps/edge/internal/openai/hot_path_light.go b/apps/edge/internal/openai/hot_path_light.go index 20401872..c4730a8c 100644 --- a/apps/edge/internal/openai/hot_path_light.go +++ b/apps/edge/internal/openai/hot_path_light.go @@ -9,7 +9,6 @@ import ( "strings" "sync" "time" - "unicode/utf8" edgeservice "iop/apps/edge/internal/service" "iop/packages/go/config" @@ -449,6 +448,7 @@ func (s *hotPathLightStore) issueTools( kind hotPathPendingKind, outer *hotPathOuterTurn, coordinator *logicalRequestCoordinator, + localCommit *hotPathStageCorrelation, ) (normalizedStageOutput, error) { if s == nil || coordinator == nil { return normalizedStageOutput{}, fmt.Errorf("light flow is unavailable") @@ -505,6 +505,14 @@ func (s *hotPathLightStore) issueTools( if _, err := coordinator.awaitToolResults(requestID, ownerEdgeID, stageID, expected, issuedHash); err != nil { return normalizedStageOutput{}, err } + if kind == hotPathPendingLocalHandoff { + if localCommit == nil { + return normalizedStageOutput{}, fmt.Errorf("worker review handoff commit correlation is unavailable") + } + commit := *localCommit + commit.StageID = record.localStageID + record.pendingLocalCommit = &commit + } record.pendingKind = kind record.pending = pending record.pendingHash = issuedHash @@ -888,82 +896,6 @@ func (s *hotPathLightStore) commitLocal(requestID, ownerEdgeID string, output no return hotPathLightDisposition{RequestID: requestID, StageID: reviewStageID, Phase: record.phase}, nil } -func (s *hotPathLightStore) projectWorkerCompletionReview( - requestID, ownerEdgeID string, - output normalizedStageOutput, - correlation hotPathStageCorrelation, - coordinator *logicalRequestCoordinator, -) (normalizedStageOutput, error) { - if s == nil { - return normalizedStageOutput{}, fmt.Errorf("light flow is unavailable") - } - if coordinator == nil { - return normalizedStageOutput{}, fmt.Errorf("logical request coordinator is unavailable") - } - s.mu.Lock() - defer s.mu.Unlock() - record := s.records[requestID] - if record == nil || record.ownerEdgeID != ownerEdgeID || record.phase != hotPathPhaseLocalActive || !record.running || len(output.ToolCalls) != 0 { - return normalizedStageOutput{}, fmt.Errorf("local flow state is unavailable") - } - if !record.localPlanRead { - return normalizedStageOutput{}, fmt.Errorf("worker completion requires a successful plan read") - } - summary := boundedWorkerCompletionSummary(output.Content) - if summary == "" { - return normalizedStageOutput{}, fmt.Errorf("worker completion summary is required") - } - statuses := make([]string, len(record.planItemIDs)) - for index, itemID := range record.planItemIDs { - statuses[index] = fmt.Sprintf("- %s: completed", itemID) - } - review, err := singlerequesttemplate.RenderReview( - singlerequesttemplate.DefaultReviewTemplate, - singlerequesttemplate.ReviewFields{ - ItemStatus: strings.Join(statuses, "\n"), - Changes: summary, - Verification: "Worker completion report: " + summary, - Deviations: "None reported by the worker.", - }, - singlerequesttemplate.MaxTemplateBytes, - ) - if err != nil || singlerequesttemplate.ValidateReviewHandoff(review, record.planItemIDs) != nil { - return normalizedStageOutput{}, fmt.Errorf("project worker completion review") - } - providerCallID, err := coordinator.newCallID() - if err != nil { - return normalizedStageOutput{}, err - } - correlation.StageID = record.localStageID - correlation.ResponseID = output.ResponseID - correlation.Terminal = output.TerminalReason - record.pendingLocalCommit = &correlation - projected := cloneNormalizedStageOutput(output) - projected.Content = "" - projected.Reasoning = "" - projected.ProgressivelyReleased = false - projected.CallerStageOnly = true - projected.ToolCalls = []normalizedToolCall{{ - ID: providerCallID, ProviderCallID: providerCallID, Name: hotPathWorkerReviewToolName, - Path: newReservedPaths(requestID).ReviewPath, - Arguments: map[string]any{"content": string(review)}, - }} - return projected, nil -} - -func boundedWorkerCompletionSummary(content string) string { - const maximum = 2048 - content = strings.TrimSpace(content) - if len(content) <= maximum { - return content - } - limit := maximum - for limit > 0 && !utf8.RuneStart(content[limit]) { - limit-- - } - return strings.TrimSpace(content[:limit]) + "..." -} - func (s *hotPathLightStore) classifyLocalOutput(requestID, ownerEdgeID string, output normalizedStageOutput) (hotPathPendingKind, error) { if s == nil { return "", fmt.Errorf("light flow is unavailable") @@ -1132,15 +1064,6 @@ func (s *Server) runHotPathLightStage(w http.ResponseWriter, r *http.Request, di if snapshot.Phase == hotPathPhaseLocalActive && len(output.ToolCalls) == 0 && outer.outputBudget().Exhausted { return s.writeHotPathLightLengthTerminal(w, r, dispatch, protocol, stream, requestID, output) } - if snapshot.Phase == hotPathPhaseLocalActive && len(output.ToolCalls) == 0 && !hotPathIsProviderLengthTerminal(output.TerminalReason) { - output, err = s.lightFlows.projectWorkerCompletionReview( - requestID, s.edgeIDValue(), output, correlation, s.requestCoordinator, - ) - if err != nil { - return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID, - hotPathLightEndpointError(protocol, http.StatusBadRequest, err.Error())) - } - } visible = mergeVisibleStageOutput(visible, output) // The collector compatibility path remains the endpoint renderer until // endpoint codecs consume released deltas directly. Feed the same @@ -1164,7 +1087,7 @@ func (s *Server) runHotPathLightStage(w http.ResponseWriter, r *http.Request, di return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID, hotPathLightEndpointError(protocol, http.StatusBadRequest, err.Error())) } - mapped, err := s.lightFlows.issueTools(r.Context(), requestID, s.edgeIDValue(), output, visible, kind, outer, s.requestCoordinator) + mapped, err := s.lightFlows.issueTools(r.Context(), requestID, s.edgeIDValue(), output, visible, kind, outer, s.requestCoordinator, &correlation) if err != nil { return s.writeHotPathPrimaryError(w, r, dispatch, protocol, stream, requestID, hotPathLightEndpointError(protocol, http.StatusBadRequest, err.Error())) diff --git a/apps/edge/internal/openai/hot_path_light_test.go b/apps/edge/internal/openai/hot_path_light_test.go index 43881c52..f03c9d4f 100644 --- a/apps/edge/internal/openai/hot_path_light_test.go +++ b/apps/edge/internal/openai/hot_path_light_test.go @@ -27,8 +27,8 @@ func TestHotPathLightLocalTransition(t *testing.T) { t.Fatalf("final response: status=%d body=%s", final.Code, final.Body.String()) } history, _ := json.Marshal(fixture.history) - if !strings.Contains(string(history), "local-complete-visible") { - t.Fatalf("local completion was not visible before review: history=%s", history) + if !strings.Contains(string(history), "worker-handoff-visible") { + t.Fatalf("worker-authored review handoff was not issued: history=%s", history) } fixture.assertCleanupCommitted(7) }) @@ -98,7 +98,7 @@ func TestWorkerProviderReceivesOnlyCallerTools(t *testing.T) { if err != nil { t.Fatal(err) } - if schemas["run_command"] == nil || schemas[hotPathWorkerReviewToolName] != nil || len(schemas) != 1 { + if schemas["run_command"] == nil || schemas["iop_write_worker_review"] != nil || len(schemas) != 1 { t.Fatalf("worker provider tools=%+v", schemas) } } @@ -120,6 +120,22 @@ func TestHotPathLightRequiresPlanReadBeforeWorkerCompletion(t *testing.T) { } } +func TestHotPathLightRequiresWorkerAuthoredReviewAfterPlanRead(t *testing.T) { + for _, endpoint := range []string{"openai", "anthropic"} { + endpoint := endpoint + t.Run(endpoint, func(t *testing.T) { + fixture := newScriptedLightFixture(t, endpoint, false) + fixture.service.responses[4] = func(string) string { + return scriptedLightCompletion(endpoint, "terminal prose cannot replace review evidence") + } + driveScriptedLightToLocalAfterPlanRead(t, fixture) + localWork := fixture.request() + fixture.consumeToolResponse(localWork, []string{`{"ok":true}`}) + assertLightFailureAfterCleanup(t, fixture, "completed review handoff") + }) + } +} + func assertLightFailureAfterCleanup(t *testing.T, fixture *scriptedLightFixture, message string) { t.Helper() cleanup := fixture.request() @@ -411,7 +427,7 @@ func newScriptedLightFixture(t *testing.T, endpoint string, repair bool) *script func(requestID string) string { return scriptedArtifactPair(endpoint, requestID) }, func(requestID string) string { return scriptedArtifactLocalRead(endpoint, requestID) }, func(string) string { return scriptedLocalWorkTool(endpoint) }, - func(string) string { return scriptedLightCompletion(endpoint, "local-complete-visible") }, + func(requestID string) string { return scriptedReviewWrite(endpoint, requestID) }, func(requestID string) string { return scriptedReviewReadsAndInspection(endpoint, requestID) }, } if repair { @@ -555,9 +571,9 @@ func (f *scriptedLightFixture) assertCleanupCommitted(wantCalls int) { selectorResponse = "msg-scripted-pair" } - localResponse := "chatcmpl-light-complete" + localResponse := "chatcmpl-review-write" if f.endpoint == "anthropic" { - localResponse = "msg-light-complete" + localResponse = "msg-review-write" } // Regression: local stage must carry selector correlation and must NOT @@ -662,6 +678,9 @@ func assertReviewCorrelationRegression(t *testing.T, req edgeservice.ProviderPoo t.Fatalf("review Run.Input[\"prompt\"] is missing") } inputStr := fmt.Sprint(input) + if got := fmt.Sprint(req.Run.Input["system"]); got != hotPathReviewSystemPrompt { + t.Fatalf("review Run.Input system prompt mismatch: %q", got) + } if !strings.Contains(prompt, "Committed selector stage success:") { t.Fatalf("review Run.Prompt missing selector correlation: %s", prompt) @@ -690,13 +709,35 @@ func assertReviewCorrelationRegression(t *testing.T, req edgeservice.ProviderPoo } // Mandatory: decode and verify selected protocol tunnel prompt. - _, tunnelPrompt, err := decodeSelectedTunnelPrompt(req, selected) + prepared, tunnelPrompt, err := decodeSelectedTunnelPrompt(req, selected) if err != nil { t.Fatalf("review tunnel decode error: %v", err) } if tunnelPrompt != prompt { t.Fatalf("review decoded tunnel prompt mismatch: got %q want %q", tunnelPrompt, prompt) } + body, err := prepared.BuildBody("target-model") + if err != nil { + t.Fatal(err) + } + if selected.ProfileDriver == string(config.ProtocolDriverAnthropicMessages) { + var payload struct { + System string `json:"system"` + } + if err := json.Unmarshal(body, &payload); err != nil || payload.System != hotPathReviewSystemPrompt { + t.Fatalf("review Messages system prompt mismatch: system=%q err=%v body=%s", payload.System, err, body) + } + } else { + var payload struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(body, &payload); err != nil || len(payload.Messages) < 2 || payload.Messages[0].Role != "system" || payload.Messages[0].Content != hotPathReviewSystemPrompt { + t.Fatalf("review Chat system prompt mismatch: messages=%+v err=%v body=%s", payload.Messages, err, body) + } + } if !strings.Contains(tunnelPrompt, "Committed selector stage success:") { t.Fatalf("review tunnel body missing selector correlation: %s", tunnelPrompt) } @@ -742,10 +783,12 @@ func decodeSelectedTunnelPrompt(req edgeservice.ProviderPoolDispatchRequest, sel if err := json.Unmarshal(bodyBytes, &payload); err != nil { return prepared, "", fmt.Errorf("unmarshal anthropic payload: %w (body=%s)", err, string(bodyBytes)) } - if len(payload.Messages) == 0 || payload.Messages[0].Role != "user" { - return prepared, "", fmt.Errorf("anthropic body missing first user message: %s", string(bodyBytes)) + for _, message := range payload.Messages { + if message.Role == "user" { + return prepared, extractMessageContentString(message.Content), nil + } } - return prepared, extractMessageContentString(payload.Messages[0].Content), nil + return prepared, "", fmt.Errorf("anthropic body missing user message: %s", string(bodyBytes)) } else { if prepared.Path != "/v1/chat/completions" || prepared.Operation != string(config.OperationChatCompletions) { return prepared, "", fmt.Errorf("openai tunnel path/op mismatch: path=%q op=%q", prepared.Path, prepared.Operation) @@ -759,10 +802,12 @@ func decodeSelectedTunnelPrompt(req edgeservice.ProviderPoolDispatchRequest, sel if err := json.Unmarshal(bodyBytes, &payload); err != nil { return prepared, "", fmt.Errorf("unmarshal openai payload: %w (body=%s)", err, string(bodyBytes)) } - if len(payload.Messages) == 0 || payload.Messages[0].Role != "user" { - return prepared, "", fmt.Errorf("openai body missing first user message: %s", string(bodyBytes)) + for _, message := range payload.Messages { + if message.Role == "user" { + return prepared, extractMessageContentString(message.Content), nil + } } - return prepared, extractMessageContentString(payload.Messages[0].Content), nil + return prepared, "", fmt.Errorf("openai body missing user message: %s", string(bodyBytes)) } } diff --git a/apps/edge/internal/openai/hot_path_observation_test.go b/apps/edge/internal/openai/hot_path_observation_test.go index 783ec406..eb3f9973 100644 --- a/apps/edge/internal/openai/hot_path_observation_test.go +++ b/apps/edge/internal/openai/hot_path_observation_test.go @@ -1497,7 +1497,7 @@ func hotPathPassTrace() []hotPathTracePoint { {Event: hotPathEventClassDispatch}, {Event: hotPathEventClassStage, Stage: hotPathStageKindLocal, Attempt: hotPathAttemptFirst, Disposition: hotPathTerminalDispositionToolTurn}, {Event: hotPathEventClassStage, Stage: hotPathStageKindLocal, Attempt: hotPathAttemptRetry, Disposition: hotPathTerminalDispositionToolTurn}, - {Event: hotPathEventClassStage, Stage: hotPathStageKindLocal, Attempt: hotPathAttemptRetry, Disposition: hotPathTerminalDispositionSuccess}, + {Event: hotPathEventClassStage, Stage: hotPathStageKindLocal, Attempt: hotPathAttemptRetry, Disposition: hotPathTerminalDispositionToolTurn}, {Event: hotPathEventClassLight, Stage: hotPathStageKindReview, Attempt: hotPathAttemptFirst}, {Event: hotPathEventClassStage, Stage: hotPathStageKindReview, Attempt: hotPathAttemptFirst, Disposition: hotPathTerminalDispositionToolTurn}, {Event: hotPathEventClassStage, Stage: hotPathStageKindReview, Attempt: hotPathAttemptRetry, Disposition: hotPathTerminalDispositionSuccess}, diff --git a/apps/edge/internal/openai/hot_path_review.go b/apps/edge/internal/openai/hot_path_review.go index b02af66c..9ac60998 100644 --- a/apps/edge/internal/openai/hot_path_review.go +++ b/apps/edge/internal/openai/hot_path_review.go @@ -35,7 +35,7 @@ func (s *Server) advanceHotPathReview( } return mapped, true, nil } - mapped, err := s.lightFlows.issueTools(ctx, requestID, s.edgeIDValue(), output, visible, kind, outer, s.requestCoordinator) + mapped, err := s.lightFlows.issueTools(ctx, requestID, s.edgeIDValue(), output, visible, kind, outer, s.requestCoordinator, nil) if err != nil { return normalizedStageOutput{}, false, err } diff --git a/apps/edge/internal/openai/hot_path_stage_input.go b/apps/edge/internal/openai/hot_path_stage_input.go index 78fd3e8b..11b75473 100644 --- a/apps/edge/internal/openai/hot_path_stage_input.go +++ b/apps/edge/internal/openai/hot_path_stage_input.go @@ -8,7 +8,13 @@ import ( "unicode" ) -const hotPathWorkerReviewToolName = "iop_write_worker_review" +const hotPathReviewSystemPrompt = `You are the Reviewer in a compact Plan -> Work -> Review -> Repair pipeline. +Read the exact issued Plan and worker-filled Review before judging the task. Treat Review as worker evidence, not proof. +Inspect the actual caller-workspace result and rerun the Plan's applicable verification with ordinary caller tools. +Check correctness, completeness, requirement coverage, verification trust, and unjustified deviations. +If a defect exists, establish its evidence and root cause, choose one concrete fix, repair it with ordinary caller tools, and reverify in this same Review stage. +Do not rewrite the reserved Plan or Review artifacts. Do not create a separate Result or final-review document. +Return a concise non-empty final result only after the result is verified; use no tool call in that terminal response.` type hotPathArtifactPaths struct { PlanPath string @@ -138,9 +144,9 @@ func (in hotPathStageInput) prompt(phase hotPathLightPhase) (string, error) { case "review": switch phase { case hotPathPhaseReviewActive: - b.WriteString("Read both exact issued PLAN and REVIEW artifacts. Inspect at least one actual caller-workspace result with an ordinary caller tool. Do not rewrite REVIEW. If the work is correct, return a concise non-empty final result without a tool call. If repair is needed, use ordinary caller tools to repair and re-verify in this same review stage.") + b.WriteString("Review the task using the exact issued artifacts and caller workspace.") case hotPathPhaseReviewRepair: - b.WriteString("Continue the same review-stage repair and verification with ordinary caller tools. Do not read or rewrite the reserved artifacts again. When finished, return a concise non-empty final result without a tool call.") + b.WriteString("Continue the same repair and verification from the retained Review-stage context.") default: return "", fmt.Errorf("review input cannot run in phase %q", phase) } @@ -150,6 +156,18 @@ func (in hotPathStageInput) prompt(phase hotPathLightPhase) (string, error) { return b.String(), nil } +func (in hotPathStageInput) systemPrompt(phase hotPathLightPhase) string { + if in.Role != "review" { + return "" + } + switch phase { + case hotPathPhaseReviewActive, hotPathPhaseReviewRepair: + return hotPathReviewSystemPrompt + default: + return "" + } +} + func callerWorkingDirectory(task string) (string, error) { for _, line := range strings.Split(task, "\n") { line = strings.TrimSpace(line) diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index e878c3c2..25e8ff14 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -37,8 +37,14 @@ Operation: pair-write Return exactly one iop_write_artifact_pair tool call containing both complete artifact contents and no other tool call. Do not mention or infer an absolute workspace path. PLAN path: %s REVIEW path: %s -The default PLAN grammar accepts 2-6 consecutive steps and 1-3 verification bullets. For this deterministic seed, use exactly the following two-step, one-verification form and replace only angle-bracketed text: -Write every PLAN field in English using ASCII characters only. +You are the Planner. Apply this compact Plan workflow before authoring the pair: +1. Analyze the immutable user task first. Do not create a separate analysis artifact. +2. Preserve every explicit requirement, constraint, deliverable, and acceptance condition. Do not invent extra scope. +3. Convert that analysis into 2-6 closed, executable steps. The Worker must be able to implement without rediscovering requirements or choosing among alternatives. +4. Write 1-3 deterministic verification bullets with observable pass conditions. +5. Make the final Plan step require the Worker to replace the exact REVIEW path above with completed item statuses, actual changes, actual verification evidence, and deviations. That Review write is the Work stage's final action. + +Write every PLAN field in English using ASCII characters only and use exactly this grammar: # Plan ## Goal @@ -47,15 +53,18 @@ Write every PLAN field in English using ASCII characters only. ## Steps - [P1] - [P2] +- [P3] ## Verification -- -The pending REVIEW content must be exactly this deterministic seed: +- + +Omit optional step lines that are not needed. The pending REVIEW must use exactly the Plan's P1..Pn inventory in order and exactly this grammar: # Review ## Worker Item Status - P1: pending - P2: pending +- P3: pending only when P3 exists in PLAN; continue for every chosen Plan id ## Worker Changes Pending worker execution. @@ -64,7 +73,9 @@ Pending worker execution. Pending worker verification. ## Deviations -None recorded.`, paths.PlanPath, paths.ReviewPath) +None recorded. + +Do not copy the explanatory optional-status text into REVIEW. Emit one pending status line for every and only the actual Plan ids.`, paths.PlanPath, paths.ReviewPath) default: return "", fmt.Errorf("selector provider instruction state is invalid") } diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index 053245be..627a1983 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -270,6 +270,28 @@ func TestHotPathSelectorOutputLimitIsBounded(t *testing.T) { } } +func TestHotPathSelectorPairInstructionCarriesCompactPlanContract(t *testing.T) { + instruction, err := buildHotPathSelectorProviderInstruction("req_compact_plan", selectorInstructionPairWrite) + if err != nil { + t.Fatal(err) + } + for _, fragment := range []string{ + "You are the Planner.", + "Analyze the immutable user task first.", + "Preserve every explicit requirement, constraint, deliverable, and acceptance condition.", + "The Worker must be able to implement without rediscovering requirements", + "Make the final Plan step require the Worker to replace the exact REVIEW path", + "actual verification evidence", + } { + if !strings.Contains(instruction, fragment) { + t.Fatalf("selector instruction omitted compact Plan contract %q: %s", fragment, instruction) + } + } + if strings.Contains(instruction, "deterministic seed") || strings.Contains(instruction, "exactly following two-step") { + t.Fatalf("selector instruction retained seed-oriented Plan guidance: %s", instruction) + } +} + func TestOpenAIHotPathResponsesOperationUsesResponsesDecoder(t *testing.T) { body := []byte(`{"id":"resp-selector","model":"gpt-5.6-terra","status":"completed","output":[{"type":"function_call","id":"fc-1","call_id":"call-1","name":"bash","arguments":"{\"command\":\"true\"}"}],"usage":{"input_tokens":10,"output_tokens":3}}`) stage, err := decodePresetTunnelBody(body, "application/json", "openai", string(config.OperationResponses), string(config.ProtocolDriverOpenAIChat)) From 0feb3b5e833f392ad8ae111177376d1cc68ac535 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 09:37:28 +0900 Subject: [PATCH 25/37] =?UTF-8?q?fix(edge):=20worker=20review=20handoff?= =?UTF-8?q?=EB=A5=BC=20plan=EC=97=90=20=EB=AA=85=EC=8B=9C=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/edge/internal/openai/provider_model_rewrite.go | 2 +- apps/edge/internal/openai/provider_model_rewrite_test.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 25e8ff14..30c87196 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -42,7 +42,7 @@ You are the Planner. Apply this compact Plan workflow before authoring the pair: 2. Preserve every explicit requirement, constraint, deliverable, and acceptance condition. Do not invent extra scope. 3. Convert that analysis into 2-6 closed, executable steps. The Worker must be able to implement without rediscovering requirements or choosing among alternatives. 4. Write 1-3 deterministic verification bullets with observable pass conditions. -5. Make the final Plan step require the Worker to replace the exact REVIEW path above with completed item statuses, actual changes, actual verification evidence, and deviations. That Review write is the Work stage's final action. +5. Make the final Plan step require the Worker to read the pending REVIEW path above, then as the Work stage's final action replace that same file while preserving its exact headings. Every Plan item status must be exactly "completed"; actual changes, actual verification evidence, and deviations must be non-empty. Write every PLAN field in English using ASCII characters only and use exactly this grammar: # Plan diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index 627a1983..edcd929c 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -280,7 +280,8 @@ func TestHotPathSelectorPairInstructionCarriesCompactPlanContract(t *testing.T) "Analyze the immutable user task first.", "Preserve every explicit requirement, constraint, deliverable, and acceptance condition.", "The Worker must be able to implement without rediscovering requirements", - "Make the final Plan step require the Worker to replace the exact REVIEW path", + "Make the final Plan step require the Worker to read the pending REVIEW path", + "Every Plan item status must be exactly \"completed\"", "actual verification evidence", } { if !strings.Contains(instruction, fragment) { From 5dfce5fd900e0276e72439f6fa6b7a0f5c665873 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 10:08:00 +0900 Subject: [PATCH 26/37] =?UTF-8?q?fix(edge):=20review=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=A7=84=EC=9E=85=EC=9D=84=20=EA=B0=95=EC=A0=9C=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/edge/internal/openai/hot_path_dispatch.go | 13 ++++++++++--- apps/edge/internal/openai/hot_path_light_test.go | 15 +++++++++++++++ apps/edge/internal/openai/hot_path_stage_input.go | 1 + .../internal/openai/provider_model_rewrite.go | 1 + .../openai/provider_model_rewrite_test.go | 1 + 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/edge/internal/openai/hot_path_dispatch.go b/apps/edge/internal/openai/hot_path_dispatch.go index bccbe96f..1f5fbac3 100644 --- a/apps/edge/internal/openai/hot_path_dispatch.go +++ b/apps/edge/internal/openai/hot_path_dispatch.go @@ -1722,7 +1722,7 @@ func hotPathStageRunInput(snapshot hotPathDispatchSnapshot, prompt string) map[s } if tools := hotPathChatTools(hotPathStageProviderTools(snapshot)); len(tools) > 0 { input["tools"] = tools - input["tool_choice"] = "auto" + input["tool_choice"] = hotPathChatStageToolChoice(snapshot) } options := cloneAnyMap(snapshot.Stage.Options) if options == nil { @@ -1743,7 +1743,7 @@ func hotPathChatStageBody(snapshot hotPathDispatchSnapshot, prompt, target strin } if tools := hotPathChatTools(hotPathStageProviderTools(snapshot)); len(tools) > 0 { body["tools"] = tools - body["tool_choice"] = "auto" + body["tool_choice"] = hotPathChatStageToolChoice(snapshot) } reserved := map[string]struct{}{"model": {}, "messages": {}, "tools": {}, "stream": {}} if snapshot.OutputBudget.Limited { @@ -1763,7 +1763,7 @@ func hotPathAnthropicStageBody(snapshot hotPathDispatchSnapshot, prompt, target } if tools := hotPathAnthropicTools(hotPathStageProviderTools(snapshot)); len(tools) > 0 { body["tools"] = tools - body["tool_choice"] = map[string]any{"type": "auto"} + body["tool_choice"] = map[string]any{"type": hotPathChatStageToolChoice(snapshot)} } reserved := map[string]struct{}{"model": {}, "messages": {}, "tools": {}, "stream": {}} if snapshot.OutputBudget.Limited { @@ -1774,6 +1774,13 @@ func hotPathAnthropicStageBody(snapshot hotPathDispatchSnapshot, prompt, target return json.Marshal(body) } +func hotPathChatStageToolChoice(snapshot hotPathDispatchSnapshot) string { + if snapshot.Phase == hotPathPhaseReviewActive && len(snapshot.Transcript) == 0 { + return "required" + } + return "auto" +} + func hotPathStageProviderTools(snapshot hotPathDispatchSnapshot) []any { return cloneAnySlice(snapshot.Tools) } diff --git a/apps/edge/internal/openai/hot_path_light_test.go b/apps/edge/internal/openai/hot_path_light_test.go index f03c9d4f..99a6e075 100644 --- a/apps/edge/internal/openai/hot_path_light_test.go +++ b/apps/edge/internal/openai/hot_path_light_test.go @@ -103,6 +103,21 @@ func TestWorkerProviderReceivesOnlyCallerTools(t *testing.T) { } } +func TestInitialReviewRequiresToolUseBeforeTerminal(t *testing.T) { + initial := hotPathDispatchSnapshot{Phase: hotPathPhaseReviewActive} + if got := hotPathChatStageToolChoice(initial); got != "required" { + t.Fatalf("initial review tool choice = %q, want required", got) + } + retry := initial + retry.Transcript = []hotPathStageExchange{{}} + if got := hotPathChatStageToolChoice(retry); got != "auto" { + t.Fatalf("continued review tool choice = %q, want auto", got) + } + if got := hotPathChatStageToolChoice(hotPathDispatchSnapshot{Phase: hotPathPhaseLocalActive}); got != "auto" { + t.Fatalf("worker tool choice = %q, want auto", got) + } +} + func TestHotPathLightRequiresPlanReadBeforeWorkerCompletion(t *testing.T) { for _, endpoint := range []string{"openai", "anthropic"} { endpoint := endpoint diff --git a/apps/edge/internal/openai/hot_path_stage_input.go b/apps/edge/internal/openai/hot_path_stage_input.go index 11b75473..c20acf49 100644 --- a/apps/edge/internal/openai/hot_path_stage_input.go +++ b/apps/edge/internal/openai/hot_path_stage_input.go @@ -11,6 +11,7 @@ import ( const hotPathReviewSystemPrompt = `You are the Reviewer in a compact Plan -> Work -> Review -> Repair pipeline. Read the exact issued Plan and worker-filled Review before judging the task. Treat Review as worker evidence, not proof. Inspect the actual caller-workspace result and rerun the Plan's applicable verification with ordinary caller tools. +Your first response must issue tool calls that read both exact artifact paths and inspect the actual caller-workspace result; do not return terminal prose first. Check correctness, completeness, requirement coverage, verification trust, and unjustified deviations. If a defect exists, establish its evidence and root cause, choose one concrete fix, repair it with ordinary caller tools, and reverify in this same Review stage. Do not rewrite the reserved Plan or Review artifacts. Do not create a separate Result or final-review document. diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 30c87196..05803ea5 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -40,6 +40,7 @@ REVIEW path: %s You are the Planner. Apply this compact Plan workflow before authoring the pair: 1. Analyze the immutable user task first. Do not create a separate analysis artifact. 2. Preserve every explicit requirement, constraint, deliverable, and acceptance condition. Do not invent extra scope. + Copy every exact literal, filename, command, and required output string from the task verbatim into an executable Plan step or Verification bullet. 3. Convert that analysis into 2-6 closed, executable steps. The Worker must be able to implement without rediscovering requirements or choosing among alternatives. 4. Write 1-3 deterministic verification bullets with observable pass conditions. 5. Make the final Plan step require the Worker to read the pending REVIEW path above, then as the Work stage's final action replace that same file while preserving its exact headings. Every Plan item status must be exactly "completed"; actual changes, actual verification evidence, and deviations must be non-empty. diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index edcd929c..f93d899e 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -279,6 +279,7 @@ func TestHotPathSelectorPairInstructionCarriesCompactPlanContract(t *testing.T) "You are the Planner.", "Analyze the immutable user task first.", "Preserve every explicit requirement, constraint, deliverable, and acceptance condition.", + "Copy every exact literal, filename, command, and required output string", "The Worker must be able to implement without rediscovering requirements", "Make the final Plan step require the Worker to read the pending REVIEW path", "Every Plan item status must be exactly \"completed\"", From 1e2aaf4c6496db214669b627067632c76453161e Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 10:13:02 +0900 Subject: [PATCH 27/37] =?UTF-8?q?fix(edge):=20artifact=20content=EC=99=80?= =?UTF-8?q?=20=EC=A0=9C=EC=96=B4=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/edge/internal/openai/hot_path_selector.go | 18 +++++++++++------- .../internal/openai/hot_path_selector_test.go | 7 +++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/edge/internal/openai/hot_path_selector.go b/apps/edge/internal/openai/hot_path_selector.go index d0f84fff..043815f9 100644 --- a/apps/edge/internal/openai/hot_path_selector.go +++ b/apps/edge/internal/openai/hot_path_selector.go @@ -342,7 +342,7 @@ func reservedPathSourcesFromToolCall(tc normalizedToolCall) []string { } add(tc.Path) if tc.Arguments != nil { - collectReservedStrings(tc.Arguments, add) + collectReservedArgumentStrings(tc.Arguments, add) if tc.RawArgs == "" { return paths } @@ -351,7 +351,7 @@ func reservedPathSourcesFromToolCall(tc normalizedToolCall) []string { decoder.UseNumber() if decoder.Decode(&decoded) == nil && decoded != nil { if !reflect.DeepEqual(decoded, tc.Arguments) { - collectReservedStrings(decoded, add) + collectReservedArgumentStrings(decoded, add) } return paths } @@ -365,24 +365,28 @@ func reservedPathSourcesFromToolCall(tc normalizedToolCall) []string { decoder := json.NewDecoder(strings.NewReader(tc.RawArgs)) decoder.UseNumber() if decoder.Decode(&decoded) == nil { - collectReservedStrings(decoded, add) + collectReservedArgumentStrings(decoded, add) } else { add(tc.RawArgs) } return paths } -func collectReservedStrings(value any, add func(string)) { +func collectReservedArgumentStrings(value any, add func(string)) { switch typed := value.(type) { case string: add(typed) case map[string]any: - for _, item := range typed { - collectReservedStrings(item, add) + for key, item := range typed { + switch strings.ToLower(strings.TrimSpace(key)) { + case "content", "plan_content", "review_content": + continue + } + collectReservedArgumentStrings(item, add) } case []any: for _, item := range typed { - collectReservedStrings(item, add) + collectReservedArgumentStrings(item, add) } } } diff --git a/apps/edge/internal/openai/hot_path_selector_test.go b/apps/edge/internal/openai/hot_path_selector_test.go index 183dadd6..cf86c59d 100644 --- a/apps/edge/internal/openai/hot_path_selector_test.go +++ b/apps/edge/internal/openai/hot_path_selector_test.go @@ -42,6 +42,13 @@ func TestHotPathSelectorDecisionMatrix(t *testing.T) { {ID: "call_review", Name: "write_file", Arguments: map[string]any{"path": issued.ReviewPath}}, }}, }, + { + name: "ExactPairContentMayMentionReservedPaths", preset: preset, gate: validGate, wantMode: modeLight, wantReason: reasonLightExactPair, + output: normalizedStageOutput{ToolCalls: []normalizedToolCall{ + {ID: "call_plan", Name: "write_file", Arguments: map[string]any{"path": issued.PlanPath, "content": "Read " + issued.ReviewPath + " before handoff."}}, + {ID: "call_review", Name: "write_file", Arguments: map[string]any{"path": issued.ReviewPath, "content": "Template for " + issued.PlanPath}}, + }}, + }, { name: "PartialPair", preset: preset, gate: validGate, wantReason: reasonMalformedPartialPair, wantErr: true, output: normalizedStageOutput{ToolCalls: []normalizedToolCall{{ID: "call_plan", Name: "write_file", Arguments: map[string]any{"path": issued.PlanPath}}}}, From 03aff0b8f8e3a7a9a798d49976d5a686bd5b388a Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 10:25:34 +0900 Subject: [PATCH 28/37] =?UTF-8?q?fix(edge):=20Gemini=20=EB=B3=91=EB=A0=AC?= =?UTF-8?q?=20=EB=8F=84=EA=B5=AC=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/openai/provider_model_rewrite.go | 16 +++++++++--- .../openai/provider_model_rewrite_test.go | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 05803ea5..0fc9a883 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -510,9 +510,6 @@ func rewriteProviderJSONResponse(body []byte, model, toolCallWire string) []byte } 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 @@ -530,11 +527,22 @@ func normalizeGeminiChatProviderResponse(body []byte) []byte { if !ok { continue } - for _, rawCall := range anySlice(message["tool_calls"]) { + toolCalls := anySlice(message["tool_calls"]) + for index, rawCall := range toolCalls { call, ok := rawCall.(map[string]any) if !ok { continue } + // Gemini's Chat-compatible stream may emit several complete tool + // calls in one delta without OpenAI's per-call index. Preserve their + // positional identity so the stream decoder does not concatenate + // independent argument objects into index zero. + if messageKey == "delta" && len(toolCalls) > 1 { + if _, present := call["index"]; !present { + call["index"] = index + changed = true + } + } id, idOK := call["id"].(string) extra, extraOK := call["extra_content"].(map[string]any) if !idOK || id == "" || !extraOK { diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index f93d899e..782ff586 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -76,6 +76,32 @@ func TestProviderThoughtSignatureNormalizationIsGeminiProfileOnly(t *testing.T) } } +func TestGeminiChatProviderResponseAddsMissingParallelToolIndices(t *testing.T) { + response := []byte(`{"id":"chat-1","choices":[{"delta":{"tool_calls":[{"id":"call-1","function":{"name":"read_file","arguments":"{\"path\":\"plan.md\"}"}},{"id":"call-2","function":{"name":"read_file","arguments":"{\"path\":\"review.md\"}"}},{"id":"call-3","function":{"name":"bash","arguments":"{\"command\":\"test -f index.html\"}"}}]}}]}`) + normalized := normalizeGeminiChatProviderResponse(response) + var body map[string]any + if err := json.Unmarshal(normalized, &body); err != nil { + t.Fatal(err) + } + choice := anySlice(body["choices"])[0].(map[string]any) + delta := choice["delta"].(map[string]any) + calls := anySlice(delta["tool_calls"]) + for index, raw := range calls { + call := raw.(map[string]any) + if got := int(call["index"].(float64)); got != index { + t.Fatalf("tool call %d index = %d", index, got) + } + } + + stage, err := decodeOpenAIPresetSSE([]byte("data: " + string(normalized) + "\n\ndata: {\"id\":\"chat-1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\ndata: [DONE]\n\n")) + if err != nil { + t.Fatal(err) + } + if len(stage.ToolCalls) != 3 { + t.Fatalf("decoded tool calls = %+v", stage.ToolCalls) + } +} + func TestProviderChatTokenLimitNormalizationUsesSelectedProfile(t *testing.T) { tests := []struct { name string From 81439001f9f4774d76f54c2a9aec3e989bb10436 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 10:29:48 +0900 Subject: [PATCH 29/37] =?UTF-8?q?fix(edge):=20=EB=B6=84=EB=A6=AC=EB=90=9C?= =?UTF-8?q?=20Gemini=20=EB=8F=84=EA=B5=AC=20=ED=98=B8=EC=B6=9C=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../edge/internal/openai/hot_path_dispatch.go | 21 +++++++++++++++++-- .../openai/provider_model_rewrite_test.go | 14 +++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/edge/internal/openai/hot_path_dispatch.go b/apps/edge/internal/openai/hot_path_dispatch.go index 1f5fbac3..fb4589f5 100644 --- a/apps/edge/internal/openai/hot_path_dispatch.go +++ b/apps/edge/internal/openai/hot_path_dispatch.go @@ -695,10 +695,27 @@ func decodeOpenAIPresetSSE(body []byte) (normalizedStageOutput, error) { stage.Deltas = append(stage.Deltas, normalizedStageDelta{Kind: normalizedStageDeltaReasoning, Text: reasoning}) } for _, delta := range choice.Delta.ToolCalls { - state := tools[delta.Index] + toolIndex := delta.Index + if toolIndex == 0 && delta.ID != "" { + if first := tools[0]; first != nil && first.id != "" && first.id != delta.ID { + matched := false + for existingIndex, existing := range tools { + if existing.id == delta.ID { + toolIndex = existingIndex + matched = true + break + } + } + if !matched { + for toolIndex = 1; tools[toolIndex] != nil; toolIndex++ { + } + } + } + } + state := tools[toolIndex] if state == nil { state = &toolState{} - tools[delta.Index] = state + tools[toolIndex] = state } if delta.ID != "" { state.id = delta.ID diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index 782ff586..b1693909 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -102,6 +102,20 @@ func TestGeminiChatProviderResponseAddsMissingParallelToolIndices(t *testing.T) } } +func TestOpenAIPresetSSESeparatesNewToolIDsWithoutIndices(t *testing.T) { + body := []byte("data: {\"id\":\"chat-1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call-1\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"plan.md\\\"}\"}}]}}]}\n\n" + + "data: {\"id\":\"chat-1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call-2\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"review.md\\\"}\"}}]}}]}\n\n" + + "data: {\"id\":\"chat-1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call-3\",\"function\":{\"name\":\"bash\",\"arguments\":\"{\\\"command\\\":\\\"test -f index.html\\\"}\"}}]}}]}\n\n" + + "data: {\"id\":\"chat-1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\ndata: [DONE]\n\n") + stage, err := decodeOpenAIPresetSSE(body) + if err != nil { + t.Fatal(err) + } + if len(stage.ToolCalls) != 3 { + t.Fatalf("decoded tool calls = %+v", stage.ToolCalls) + } +} + func TestProviderChatTokenLimitNormalizationUsesSelectedProfile(t *testing.T) { tests := []struct { name string From b6ae39bf3e81112a1877187e81106550fca64384 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 10:35:32 +0900 Subject: [PATCH 30/37] =?UTF-8?q?fix(edge):=20worker=20=EC=82=B0=EC=B6=9C?= =?UTF-8?q?=EB=AC=BC=20=EA=B2=BD=EA=B3=84=EB=A5=BC=20plan=EC=97=90=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/edge/internal/openai/provider_model_rewrite.go | 3 ++- apps/edge/internal/openai/provider_model_rewrite_test.go | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 0fc9a883..28ee48fb 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -43,7 +43,8 @@ You are the Planner. Apply this compact Plan workflow before authoring the pair: Copy every exact literal, filename, command, and required output string from the task verbatim into an executable Plan step or Verification bullet. 3. Convert that analysis into 2-6 closed, executable steps. The Worker must be able to implement without rediscovering requirements or choosing among alternatives. 4. Write 1-3 deterministic verification bullets with observable pass conditions. -5. Make the final Plan step require the Worker to read the pending REVIEW path above, then as the Work stage's final action replace that same file while preserving its exact headings. Every Plan item status must be exactly "completed"; actual changes, actual verification evidence, and deviations must be non-empty. +5. Resolve every requested output path against the caller workspace current working directory. Never place a task output beside PLAN or anywhere under .iop unless the immutable user task explicitly requests that exact path. +6. Make the final Plan step require the Worker to read the pending REVIEW path above, then as the Work stage's final action replace the whole file with one full-file Write operation while preserving its exact headings. Never use Edit or Patch for this handoff. Every Plan item status must be exactly "completed"; actual changes, actual verification evidence, and deviations must be non-empty. Write every PLAN field in English using ASCII characters only and use exactly this grammar: # Plan diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index b1693909..f5b42576 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -321,7 +321,11 @@ func TestHotPathSelectorPairInstructionCarriesCompactPlanContract(t *testing.T) "Preserve every explicit requirement, constraint, deliverable, and acceptance condition.", "Copy every exact literal, filename, command, and required output string", "The Worker must be able to implement without rediscovering requirements", + "Resolve every requested output path against the caller workspace current working directory.", + "Never place a task output beside PLAN or anywhere under .iop", "Make the final Plan step require the Worker to read the pending REVIEW path", + "replace the whole file with one full-file Write operation", + "Never use Edit or Patch for this handoff.", "Every Plan item status must be exactly \"completed\"", "actual verification evidence", } { From ead8b500567bd1c5ae966e3995c5b106ea6a0f31 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 10:40:27 +0900 Subject: [PATCH 31/37] =?UTF-8?q?fix(edge):=20plan=20task=20root=EB=A5=BC?= =?UTF-8?q?=20=EC=A0=95=EA=B7=9C=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/edge/internal/openai/artifact_pair.go | 21 +++++++++++++++++++ .../internal/openai/artifact_pair_test.go | 12 +++++++++++ .../internal/openai/hot_path_direct_test.go | 2 +- .../internal/openai/provider_model_rewrite.go | 3 ++- .../openai/provider_model_rewrite_test.go | 1 + 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/edge/internal/openai/artifact_pair.go b/apps/edge/internal/openai/artifact_pair.go index 8fd8c642..18df606a 100644 --- a/apps/edge/internal/openai/artifact_pair.go +++ b/apps/edge/internal/openai/artifact_pair.go @@ -241,6 +241,7 @@ func (s *artifactFrontierStore) expandSelectorPair( if !planOK || strings.TrimSpace(planContent) == "" || !reviewOK || strings.TrimSpace(reviewContent) == "" { return normalizedStageOutput{}, fmt.Errorf("artifact pair tool requires complete plan_content and review_content") } + planContent = normalizeCallerWorkspacePlanBoundary(planContent) providerID := strings.TrimSpace(call.ProviderCallID) if providerID == "" { providerID = strings.TrimSpace(call.ID) @@ -256,6 +257,26 @@ func (s *artifactFrontierStore) expandSelectorPair( return output, nil } +func normalizeCallerWorkspacePlanBoundary(content string) string { + const marker = "## Goal\n" + start := strings.Index(content, marker) + if start < 0 { + return content + } + goalStart := start + len(marker) + goalEnd := strings.IndexByte(content[goalStart:], '\n') + if goalEnd < 0 { + goalEnd = len(content) + } else { + goalEnd += goalStart + } + goal := strings.TrimSpace(content[goalStart:goalEnd]) + if strings.HasPrefix(goal, hotPathCallerWorkspacePlanBoundary) { + return content + } + return content[:goalStart] + hotPathCallerWorkspacePlanBoundary + " " + goal + content[goalEnd:] +} + func pairProviderCallID(base, role string) string { suffix := "_" + role if len(base)+len(suffix) > 256 { diff --git a/apps/edge/internal/openai/artifact_pair_test.go b/apps/edge/internal/openai/artifact_pair_test.go index e9cdd32b..82257762 100644 --- a/apps/edge/internal/openai/artifact_pair_test.go +++ b/apps/edge/internal/openai/artifact_pair_test.go @@ -36,6 +36,18 @@ func testLightweightPlan(t *testing.T) string { return string(plan) } +func TestNormalizeCallerWorkspacePlanBoundary(t *testing.T) { + plan := testLightweightPlan(t) + normalized := normalizeCallerWorkspacePlanBoundary(plan) + want := "## Goal\n" + hotPathCallerWorkspacePlanBoundary + " Complete the caller workspace task" + if !strings.Contains(normalized, want) { + t.Fatalf("normalized plan omitted workspace boundary: %s", normalized) + } + if second := normalizeCallerWorkspacePlanBoundary(normalized); second != normalized { + t.Fatalf("workspace boundary normalization is not idempotent: %s", second) + } +} + func testPendingReview(t *testing.T) string { t.Helper() review, err := pendingReviewSeed([]string{"P1", "P2"}) diff --git a/apps/edge/internal/openai/hot_path_direct_test.go b/apps/edge/internal/openai/hot_path_direct_test.go index 2e05da22..cae075a7 100644 --- a/apps/edge/internal/openai/hot_path_direct_test.go +++ b/apps/edge/internal/openai/hot_path_direct_test.go @@ -467,7 +467,7 @@ func scriptedSelectorDirective(providerBody []byte, operation string) (string, s "You are the Planner.", "Analyze the immutable user task first.", "Preserve every explicit requirement, constraint, deliverable, and acceptance condition.", - "# Plan\n\n## Goal\n\n\n## Steps\n- [P1] \n- [P2] ", + "# Plan\n\n## Goal\n" + hotPathCallerWorkspacePlanBoundary + " \n\n## Steps\n- [P1] \n- [P2] ", "The pending REVIEW must use exactly the Plan's P1..Pn inventory in order", "# Review\n\n## Worker Item Status\n- P1: pending\n- P2: pending", "## Worker Changes\nPending worker execution.", diff --git a/apps/edge/internal/openai/provider_model_rewrite.go b/apps/edge/internal/openai/provider_model_rewrite.go index 28ee48fb..5b2d4f1a 100644 --- a/apps/edge/internal/openai/provider_model_rewrite.go +++ b/apps/edge/internal/openai/provider_model_rewrite.go @@ -16,6 +16,7 @@ const ( maxHotPathSelectorProviderInstructionBytes = 4096 maxHotPathSelectorOutputTokens = 4096 hotPathArtifactPairToolName = "iop_write_artifact_pair" + hotPathCallerWorkspacePlanBoundary = "Use the caller workspace current working directory as the task root; never place task outputs under .iop." ) func buildHotPathSelectorProviderInstruction(requestID string, state selectorInstructionState) (string, error) { @@ -50,7 +51,7 @@ Write every PLAN field in English using ASCII characters only and use exactly th # Plan ## Goal - +Use the caller workspace current working directory as the task root; never place task outputs under .iop. ## Steps - [P1] diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index f5b42576..99d98479 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -323,6 +323,7 @@ func TestHotPathSelectorPairInstructionCarriesCompactPlanContract(t *testing.T) "The Worker must be able to implement without rediscovering requirements", "Resolve every requested output path against the caller workspace current working directory.", "Never place a task output beside PLAN or anywhere under .iop", + hotPathCallerWorkspacePlanBoundary, "Make the final Plan step require the Worker to read the pending REVIEW path", "replace the whole file with one full-file Write operation", "Never use Edit or Patch for this handoff.", From bb0b916bf572ef82022cb9bca9e9acc6636a0ce6 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 10:45:45 +0900 Subject: [PATCH 32/37] =?UTF-8?q?fix(edge):=20Gemini=20=EC=A7=80=EC=97=B0?= =?UTF-8?q?=20=EC=84=9C=EB=AA=85=EC=9D=84=20=EB=B3=B4=EC=A1=B4=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/edge/internal/openai/hot_path_dispatch.go | 11 +++++++++-- .../internal/openai/provider_model_rewrite_test.go | 13 +++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/edge/internal/openai/hot_path_dispatch.go b/apps/edge/internal/openai/hot_path_dispatch.go index fb4589f5..85027e62 100644 --- a/apps/edge/internal/openai/hot_path_dispatch.go +++ b/apps/edge/internal/openai/hot_path_dispatch.go @@ -644,8 +644,9 @@ func decodeOpenAIPresetSSE(body []byte) (normalizedStageOutput, error) { stage := normalizedStageOutput{} identity := &hotPathProviderIdentity{} type toolState struct { - id, name string - args strings.Builder + id, name string + signature string + args strings.Builder } tools := make(map[int]*toolState) for _, payload := range sseDataPayloads(body) { @@ -723,6 +724,9 @@ func decodeOpenAIPresetSSE(body []byte) (normalizedStageOutput, error) { if delta.Function.Name != "" { state.name = delta.Function.Name } + if delta.ExtraContent.Google != nil && delta.ExtraContent.Google.ThoughtSignature != "" { + state.signature = delta.ExtraContent.Google.ThoughtSignature + } state.args.WriteString(delta.Function.Arguments) if delta.Function.Arguments != "" { stage.Deltas = append(stage.Deltas, normalizedStageDelta{ @@ -746,6 +750,9 @@ func decodeOpenAIPresetSSE(body []byte) (normalizedStageOutput, error) { if !ok { return normalizedStageOutput{}, fmt.Errorf("preset Chat stream tool indices are not contiguous") } + if state.signature != "" && !strings.HasPrefix(state.id, geminiThoughtSignatureToolIDPrefix) { + state.id = encodeGeminiThoughtSignatureToolID(state.id, state.signature) + } call, err := normalizedToolCallFromParts(state.id, state.name, state.args.String()) if err != nil { return normalizedStageOutput{}, err diff --git a/apps/edge/internal/openai/provider_model_rewrite_test.go b/apps/edge/internal/openai/provider_model_rewrite_test.go index 99d98479..c7b515a1 100644 --- a/apps/edge/internal/openai/provider_model_rewrite_test.go +++ b/apps/edge/internal/openai/provider_model_rewrite_test.go @@ -116,6 +116,19 @@ func TestOpenAIPresetSSESeparatesNewToolIDsWithoutIndices(t *testing.T) { } } +func TestOpenAIPresetSSECarriesLateGeminiThoughtSignature(t *testing.T) { + body := []byte("data: {\"id\":\"chat-1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"plan.md\\\"}\"}}]}}]}\n\n" + + "data: {\"id\":\"chat-1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"extra_content\":{\"google\":{\"thought_signature\":\"late-signature\"}},\"function\":{}}]}}]}\n\n" + + "data: {\"id\":\"chat-1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\ndata: [DONE]\n\n") + stage, err := decodeOpenAIPresetSSE(body) + if err != nil { + t.Fatal(err) + } + if len(stage.ToolCalls) != 1 || !strings.HasPrefix(stage.ToolCalls[0].ProviderCallID, geminiThoughtSignatureToolIDPrefix) { + t.Fatalf("late Gemini signature was not retained: %+v", stage.ToolCalls) + } +} + func TestProviderChatTokenLimitNormalizationUsesSelectedProfile(t *testing.T) { tests := []struct { name string From 48b69740df9e66bd363526ae8387048c25ce3259 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 10:51:38 +0900 Subject: [PATCH 33/37] =?UTF-8?q?fix(edge):=20Gemini=20selector=20?= =?UTF-8?q?=EC=84=9C=EB=AA=85=20=EA=B2=BD=EA=B3=84=EB=A5=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/edge/internal/openai/artifact_pair.go | 10 ++++++++++ apps/edge/internal/openai/artifact_pair_test.go | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/edge/internal/openai/artifact_pair.go b/apps/edge/internal/openai/artifact_pair.go index 18df606a..0f5a8c4e 100644 --- a/apps/edge/internal/openai/artifact_pair.go +++ b/apps/edge/internal/openai/artifact_pair.go @@ -246,6 +246,16 @@ func (s *artifactFrontierStore) expandSelectorPair( if providerID == "" { providerID = strings.TrimSpace(call.ID) } + // The selector stage ends after its atomic pair is expanded into caller + // workspace writes, so its Gemini thought signature is never replayed to + // the selector provider. Keep only the provider's original tool id before + // deriving the two internal write ids; the opaque signature envelope can be + // larger than the logical-request id contract permits. + if decodedID, _, encoded, err := decodeGeminiThoughtSignatureToolID(providerID); err != nil { + return normalizedStageOutput{}, err + } else if encoded { + providerID = decodedID + } if !validLogicalRequestID(providerID) { return normalizedStageOutput{}, fmt.Errorf("artifact pair provider tool id is invalid") } diff --git a/apps/edge/internal/openai/artifact_pair_test.go b/apps/edge/internal/openai/artifact_pair_test.go index 82257762..a55503aa 100644 --- a/apps/edge/internal/openai/artifact_pair_test.go +++ b/apps/edge/internal/openai/artifact_pair_test.go @@ -364,6 +364,23 @@ func TestArtifactSelectorAtomicPairExpandsToCallerWrites(t *testing.T) { } } +func TestArtifactSelectorAtomicPairDropsGeminiThoughtSignatureEnvelope(t *testing.T) { + fixture := newArtifactPairFixture(t, "openai", true) + providerID := encodeGeminiThoughtSignatureToolID("provider_pair", strings.Repeat("signature", 64)) + output, err := fixture.server.artifactFrontiers.expandSelectorPair(fixture.requestID, fixture.ownerEdgeID, normalizedStageOutput{ + ToolCalls: []normalizedToolCall{{ + ID: providerID, ProviderCallID: providerID, Name: hotPathArtifactPairToolName, + Arguments: map[string]any{"plan_content": testLightweightPlan(t), "review_content": testPendingReview(t)}, + }}, + }) + if err != nil { + t.Fatal(err) + } + if len(output.ToolCalls) != 2 || output.ToolCalls[0].ProviderCallID != "provider_pair_plan" || output.ToolCalls[1].ProviderCallID != "provider_pair_review" { + t.Fatalf("expanded provider ids=%+v", output.ToolCalls) + } +} + func (f *artifactPairFixture) issuePrepare() []string { f.t.Helper() ids, err := f.issue([]normalizedToolCall{{ From 00761ba0c85c66831562862064ec12bcf5015a9b Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 10:56:07 +0900 Subject: [PATCH 34/37] =?UTF-8?q?fix(edge):=20Gemini=20provider=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20ID=EB=A5=BC=20=EA=B2=80=EC=A6=9D=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../edge/internal/openai/request_coordinator.go | 12 ++++++++++++ .../internal/openai/request_coordinator_test.go | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/apps/edge/internal/openai/request_coordinator.go b/apps/edge/internal/openai/request_coordinator.go index 7a0d1a27..67047b71 100644 --- a/apps/edge/internal/openai/request_coordinator.go +++ b/apps/edge/internal/openai/request_coordinator.go @@ -15,6 +15,7 @@ const ( defaultLogicalRequestTTL = 30 * time.Minute defaultLogicalRequestFrontierCapacity = 64 defaultLogicalRequestMappingCapacity = 512 + maxOpaqueProviderToolIDLength = 16 * 1024 ) var ( @@ -585,6 +586,17 @@ func validateLogicalRequestContinuationLineage(prefix logicalRequestLineage, exp } func validLogicalRequestID(value string) bool { + if strings.HasPrefix(value, geminiThoughtSignatureToolIDPrefix) { + if len(value) > maxOpaqueProviderToolIDLength { + return false + } + decodedID, _, encoded, err := decodeGeminiThoughtSignatureToolID(value) + return err == nil && encoded && validPlainLogicalRequestID(decodedID) + } + return validPlainLogicalRequestID(value) +} + +func validPlainLogicalRequestID(value string) bool { if value == "" || len(value) > 256 { return false } diff --git a/apps/edge/internal/openai/request_coordinator_test.go b/apps/edge/internal/openai/request_coordinator_test.go index 51cd93e4..df28ca13 100644 --- a/apps/edge/internal/openai/request_coordinator_test.go +++ b/apps/edge/internal/openai/request_coordinator_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "sync" "testing" "time" @@ -1120,6 +1121,22 @@ func TestLogicalRequestExpiredStateIsRejected(t *testing.T) { } } +func TestValidLogicalRequestIDAcceptsBoundedGeminiSignatureEnvelope(t *testing.T) { + encoded := encodeGeminiThoughtSignatureToolID("provider_call", strings.Repeat("signature", 128)) + if len(encoded) <= 256 { + t.Fatalf("fixture is not larger than the plain id limit: %d", len(encoded)) + } + if !validLogicalRequestID(encoded) { + t.Fatal("bounded Gemini signature envelope was rejected") + } + if validLogicalRequestID(geminiThoughtSignatureToolIDPrefix + "not-base64!") { + t.Fatal("malformed Gemini signature envelope was accepted") + } + if validLogicalRequestID(encodeGeminiThoughtSignatureToolID("provider_call", strings.Repeat("x", maxOpaqueProviderToolIDLength))) { + t.Fatal("oversized Gemini signature envelope was accepted") + } +} + func mustChatLogicalRequestLineage(t *testing.T, content string, tools ...any) logicalRequestLineage { t.Helper() raw, err := json.Marshal(chatCompletionRequest{ From c2b7a2ae7d64e894813356fa33b22a31198f9b66 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 10:59:37 +0900 Subject: [PATCH 35/37] =?UTF-8?q?docs(task):=20Gemini=20hybrid=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CODE_REVIEW-cloud-G07.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/agent-task/hybrid_pipeline_contract/CODE_REVIEW-cloud-G07.md b/agent-task/hybrid_pipeline_contract/CODE_REVIEW-cloud-G07.md index 120ad718..e76fe878 100644 --- a/agent-task/hybrid_pipeline_contract/CODE_REVIEW-cloud-G07.md +++ b/agent-task/hybrid_pipeline_contract/CODE_REVIEW-cloud-G07.md @@ -16,14 +16,14 @@ task=hybrid_pipeline_contract, plan=0, tag=REFACTOR | REFACTOR-1 Restore Planner Contract | completed | | REFACTOR-2 Make Reviewer Contract System-Owned | completed | | REFACTOR-3 Restore Worker-Owned Review Handoff | completed | -| REFACTOR-4 Synchronize Contract And Run Qualification | in progress: local evidence complete, live qualification pending reviewed deployment | +| REFACTOR-4 Synchronize Contract And Run Qualification | completed | ## Implementation Checklist - [x] Add the compact Agent-Ops Plan contract to the Planner system instruction while retaining atomic Plan/Review pair output and bounded grammar. - [x] Inject the compact Agent-Ops Review/Repair contract as Reviewer-only system guidance across normalized, Chat, and Messages stage bodies without changing Worker input. - [x] Require Worker-authored Review evidence as the local-stage handoff and remove Edge synthesis from terminal prose. -- [ ] Update focused tests, contracts, and current specs; run package, full Go, and live Gemini hybrid verification. +- [x] Update focused tests, contracts, and current specs; run package, full Go, and live Gemini hybrid verification. - [x] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output. ## Deviations from Plan @@ -66,7 +66,16 @@ All Go packages passed, including `apps/edge/internal/openai`, `apps/edge/intern ### Gemini hybrid qualification -Pending reviewed deployment and isolated live execution. No credential, provider endpoint, or raw model output has been written to this artifact. +Result: PASS on deployed source `00761ba0`. + +- Caller/route: OpenCode `iop-gemini-preset/gemini-hybrid`. +- Planner/Reviewer: `gemini-3.6-flash`, high reasoning. Worker: `ornith:35b` for this qualification run. +- Request sent: `2026-08-15T01:57:33.389054Z`; terminal elapsed: `58.32s`; process exit: `0`. +- Worker read the absolute Plan path, created and reread caller-root `index.html`, and replaced the whole Review with completed item status and verification evidence. +- Reviewer reread `index.html`, Review, and Plan, inspected the caller workspace, found no defect requiring repair, and returned a verified terminal result. +- Output SHA-256: `0b5f184c9460efab924f6834593a0882b2b9c4b8a4fd9640662e1d71dc1d4368`; required doctype/title/h1 counts were `1/1/1`. +- Before and after the isolated run, `mac-gemini-api` and `onexplayer-lemonade` were healthy/available with `in_flight=0`, `queued=0`. +- No additional benchmark harness, retry, scoring layer, or gate was introduced. ## Review-Only Checklist From d69323bb4380d39c919ee4e96fe1f79592b64726 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 11:45:08 +0900 Subject: [PATCH 36/37] sync: to agentic-framework v1.1.205 --- .clinerules | 1 + .cursorrules | 1 + AGENTS.md | 1 + CLAUDE.md | 1 + GEMINI.md | 1 + agent-ops/.version | 2 +- agent-ops/rules/common/rules.md | 1 + 7 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.clinerules b/.clinerules index 0121ef6b..0be96448 100644 --- a/.clinerules +++ b/.clinerules @@ -3,6 +3,7 @@ **현재 문서를 반드시 끝까지 정독하고 작업한다. 다 읽지 않고 즉각 작업은 금지한다.** - 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다. +- **과설계를 금지한다.** 기능을 먼저 최소 구현하며, 구현 단계의 검증은 보안·데이터 손상 방지처럼 반드시 필요한 경우 외에는 두지 않는다. 추가 검증은 실제 문제가 확인된 뒤에만 보강한다. - `agent-ops/rules/common/**`와 `agent-ops/skills/common/**`은 중앙 관리되는 공통 영역이므로 어떤 프로젝트 작업에서도 사용자가 직접 지시하지 않는 이상 절대 직접 수정하지 않는다. 프로젝트별 규칙과 스킬은 반드시 대응하는 `project/**` 영역에만 반영한다. - 최종 답변은 한국어로 한다. - 코드 변경 전 관련 domain rule을 먼저 확인한다. diff --git a/.cursorrules b/.cursorrules index 0121ef6b..0be96448 100644 --- a/.cursorrules +++ b/.cursorrules @@ -3,6 +3,7 @@ **현재 문서를 반드시 끝까지 정독하고 작업한다. 다 읽지 않고 즉각 작업은 금지한다.** - 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다. +- **과설계를 금지한다.** 기능을 먼저 최소 구현하며, 구현 단계의 검증은 보안·데이터 손상 방지처럼 반드시 필요한 경우 외에는 두지 않는다. 추가 검증은 실제 문제가 확인된 뒤에만 보강한다. - `agent-ops/rules/common/**`와 `agent-ops/skills/common/**`은 중앙 관리되는 공통 영역이므로 어떤 프로젝트 작업에서도 사용자가 직접 지시하지 않는 이상 절대 직접 수정하지 않는다. 프로젝트별 규칙과 스킬은 반드시 대응하는 `project/**` 영역에만 반영한다. - 최종 답변은 한국어로 한다. - 코드 변경 전 관련 domain rule을 먼저 확인한다. diff --git a/AGENTS.md b/AGENTS.md index 0121ef6b..0be96448 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,7 @@ **현재 문서를 반드시 끝까지 정독하고 작업한다. 다 읽지 않고 즉각 작업은 금지한다.** - 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다. +- **과설계를 금지한다.** 기능을 먼저 최소 구현하며, 구현 단계의 검증은 보안·데이터 손상 방지처럼 반드시 필요한 경우 외에는 두지 않는다. 추가 검증은 실제 문제가 확인된 뒤에만 보강한다. - `agent-ops/rules/common/**`와 `agent-ops/skills/common/**`은 중앙 관리되는 공통 영역이므로 어떤 프로젝트 작업에서도 사용자가 직접 지시하지 않는 이상 절대 직접 수정하지 않는다. 프로젝트별 규칙과 스킬은 반드시 대응하는 `project/**` 영역에만 반영한다. - 최종 답변은 한국어로 한다. - 코드 변경 전 관련 domain rule을 먼저 확인한다. diff --git a/CLAUDE.md b/CLAUDE.md index 0121ef6b..0be96448 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,7 @@ **현재 문서를 반드시 끝까지 정독하고 작업한다. 다 읽지 않고 즉각 작업은 금지한다.** - 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다. +- **과설계를 금지한다.** 기능을 먼저 최소 구현하며, 구현 단계의 검증은 보안·데이터 손상 방지처럼 반드시 필요한 경우 외에는 두지 않는다. 추가 검증은 실제 문제가 확인된 뒤에만 보강한다. - `agent-ops/rules/common/**`와 `agent-ops/skills/common/**`은 중앙 관리되는 공통 영역이므로 어떤 프로젝트 작업에서도 사용자가 직접 지시하지 않는 이상 절대 직접 수정하지 않는다. 프로젝트별 규칙과 스킬은 반드시 대응하는 `project/**` 영역에만 반영한다. - 최종 답변은 한국어로 한다. - 코드 변경 전 관련 domain rule을 먼저 확인한다. diff --git a/GEMINI.md b/GEMINI.md index 0121ef6b..0be96448 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -3,6 +3,7 @@ **현재 문서를 반드시 끝까지 정독하고 작업한다. 다 읽지 않고 즉각 작업은 금지한다.** - 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다. +- **과설계를 금지한다.** 기능을 먼저 최소 구현하며, 구현 단계의 검증은 보안·데이터 손상 방지처럼 반드시 필요한 경우 외에는 두지 않는다. 추가 검증은 실제 문제가 확인된 뒤에만 보강한다. - `agent-ops/rules/common/**`와 `agent-ops/skills/common/**`은 중앙 관리되는 공통 영역이므로 어떤 프로젝트 작업에서도 사용자가 직접 지시하지 않는 이상 절대 직접 수정하지 않는다. 프로젝트별 규칙과 스킬은 반드시 대응하는 `project/**` 영역에만 반영한다. - 최종 답변은 한국어로 한다. - 코드 변경 전 관련 domain rule을 먼저 확인한다. diff --git a/agent-ops/.version b/agent-ops/.version index 7df41291..226d5d33 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.204 +1.1.205 diff --git a/agent-ops/rules/common/rules.md b/agent-ops/rules/common/rules.md index 0121ef6b..0be96448 100644 --- a/agent-ops/rules/common/rules.md +++ b/agent-ops/rules/common/rules.md @@ -3,6 +3,7 @@ **현재 문서를 반드시 끝까지 정독하고 작업한다. 다 읽지 않고 즉각 작업은 금지한다.** - 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다. +- **과설계를 금지한다.** 기능을 먼저 최소 구현하며, 구현 단계의 검증은 보안·데이터 손상 방지처럼 반드시 필요한 경우 외에는 두지 않는다. 추가 검증은 실제 문제가 확인된 뒤에만 보강한다. - `agent-ops/rules/common/**`와 `agent-ops/skills/common/**`은 중앙 관리되는 공통 영역이므로 어떤 프로젝트 작업에서도 사용자가 직접 지시하지 않는 이상 절대 직접 수정하지 않는다. 프로젝트별 규칙과 스킬은 반드시 대응하는 `project/**` 영역에만 반영한다. - 최종 답변은 한국어로 한다. - 코드 변경 전 관련 domain rule을 먼저 확인한다. From 991f7a7fa3184b05509a9c2ed9ed9c222585c03c Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 15 Aug 2026 12:07:44 +0900 Subject: [PATCH 37/37] =?UTF-8?q?fix(edge):=20=EC=A4=91=EB=B3=B5=20handoff?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=A0=9C=EA=B1=B0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문서 완성도 판정을 여러 런타임 계층에서 반복해 부분 Review가 Reviewer에 도달하지 못하던 실패를 없애기 위해 의미 검증 책임을 Reviewer로 모은다. --- .../outer/anthropic-compatible-api.md | 19 +- agent-contract/outer/openai-compatible-api.md | 2 +- agent-spec/input/openai-compatible-surface.md | 5 +- agent-spec/runtime/edge-node-execution.md | 9 +- apps/edge/internal/openai/artifact_pair.go | 76 +----- .../internal/openai/artifact_pair_test.go | 28 +- .../internal/openai/hot_path_direct_test.go | 5 +- apps/edge/internal/openai/hot_path_light.go | 27 +- .../internal/openai/hot_path_light_test.go | 33 --- .../openai/request_identity_ingress.go | 8 +- .../openai/single_request_review_stage.go | 14 - .../single_request_review_stage_test.go | 59 +---- .../openai/single_request_work_stage.go | 26 +- .../openai/single_request_work_stage_test.go | 50 ---- packages/go/singlerequesttemplate/template.go | 247 ------------------ .../go/singlerequesttemplate/template_test.go | 20 +- 16 files changed, 73 insertions(+), 555 deletions(-) diff --git a/agent-contract/outer/anthropic-compatible-api.md b/agent-contract/outer/anthropic-compatible-api.md index 1dba1700..dcddf17d 100644 --- a/agent-contract/outer/anthropic-compatible-api.md +++ b/agent-contract/outer/anthropic-compatible-api.md @@ -93,19 +93,18 @@ response model semantics. An unmarked `light` preset with `workspace_tools` uses only the tool schemas admitted by the caller request. Edge never receives or configures a raw caller -workspace path. Before issuing caller writes, Edge validates the selector's -lightweight PLAN and pending REVIEW seed and retains the PLAN `P1..Pn` inventory -request-locally. Its Edge-owned system instruction applies a compact Plan contract: +workspace path. Before issuing caller writes, Edge checks only that the selector's +PLAN and pending REVIEW are non-empty writes within the admitted workspace boundary. +Its Edge-owned system instruction applies a compact Plan contract: analyze the immutable task first, preserve explicit requirements and acceptance, close 2-6 executable steps and deterministic verification, and put the exact REVIEW -handoff duty in the final step. Every PLAN field remains English ASCII and Edge -rejects a non-ASCII PLAN before issuing caller writes. +handoff duty in the final step. Work receives only the absolute PLAN path resolved from the caller's existing working-directory system context, followed by the fixed English sentence `Read it and complete the task.` and sees only ordinary caller tools. It must read PLAN, perform and verify caller-workspace work, and replace the exact REVIEW artifact with completed item statuses, actual changes, verification evidence, and deviations. -Edge validates and projects that Worker-authored REVIEW write; it never synthesizes -Review evidence from terminal prose. Only +Edge projects a non-empty Worker-authored REVIEW write without pre-validating its +semantic completeness; Review inspects and repairs incomplete handoff evidence. Only exact PLAN/REVIEW accesses contribute artifact state; every other caller-workspace tool call passes through unchanged. Review alone receives an Edge-owned compact code-review system instruction. It must read both artifacts, inspect the actual @@ -183,9 +182,9 @@ Edge-owned internal stage inputs only: The stage then requests a stage-owned strict JSON object with a one-line `goal` string, 2-6 non-empty one-line `steps` strings, and 1-3 non-empty one-line `verification` strings. Edge renders deterministic `P1..Pn` step IDs into the - frozen PLAN and Work must parse that stored document before provider dispatch. - Work returns strict worker item status, changes, verification, and deviations; - Edge renders and validates the one `review.md` handoff. Review reads both stored + frozen PLAN. Work receives that stored document without a second semantic parser. + Work returns worker item status, changes, verification, and deviations; + Edge renders the `review.md` handoff without blocking partial evidence. Review reads both stored artifacts, never accepts a memory work payload, and never rewrites `review.md`. Provider output never controls artifact headings or static template text. - The caller-visible request and response schemas are unchanged. A configured diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 7652772d..17350a44 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -267,7 +267,7 @@ Provider-pool raw passthrough route는 위 목록을 provider request allowlist Chat Completions의 실행 경로는 caller가 보낸 `model`의 route/provider capability로 결정한다. - provider-pool model group route(`models[]`)는 candidate를 선택한 뒤 selected provider가 OpenAI-compatible 호출 방식을 지원하면 provider HTTP status/header/body를 Node가 열어 기존 Edge-Node tunnel로 relay하고, Edge가 caller에게 쓴다. 요청 body는 라우팅에 필요한 envelope만 읽고 `model` alias를 selected provider의 served target으로 rewrite하는 것을 기본으로 하며, provider가 지원하는 OpenAI-compatible field와 provider extension field를 보존한다. -- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. Bounded selector PLAN/REVIEW pair는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Selector system instruction은 compact Plan 계약에 따라 immutable task를 먼저 분석하고 explicit requirement/constraint/acceptance를 보존한 2-6개 closed step, deterministic verification, exact REVIEW handoff duty를 작성하도록 하며 PLAN field를 English ASCII로 제한한다. Edge는 검증된 atomic pair를 admitted caller tool schema의 PLAN/REVIEW 두 write로 확장한다. Work provider의 전체 task prompt는 caller의 기존 working-directory system context에서 해석한 absolute PLAN path와 고정 영문 `Read it and complete the task.`뿐이며 ordinary caller tools만 보인다. Work는 PLAN을 읽고 구현·검증한 뒤 completed item status, actual changes, verification evidence, deviation을 담은 exact REVIEW write를 직접 반환한다. Edge는 이 Worker-authored handoff를 검증·투영하며 terminal prose로 REVIEW evidence를 합성하지 않는다. Review provider에만 별도 compact code-review system instruction을 주입한다. Review는 PLAN/REVIEW와 actual caller result를 읽고 applicable verification을 재실행하며, defect가 있으면 같은 binding에서 root cause를 확정해 repair/re-verify한 후에만 terminal을 반환한다. REVIEW를 다시 쓰거나 별도 Result 문서를 만들지 않는다. 정확한 PLAN/REVIEW 접근만 artifact state로 기록하고 그 외 caller-workspace tool call은 그대로 통과시킨다. Reserved-artifact operation은 provider tool의 path alias를 canonical path로 복원하고 caller binding의 tool/argument mapping으로 정규화한다. Command-mode binding stage는 provider delta를 선행 방출하지 않고 bounded stage를 수집·검증·매핑하며 structured binding의 progressive stream은 유지한다. Cleanup은 request-local job directory만 제거한다. Caller continuation은 SDK 장식이 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Function tools와 reasoning effort를 Chat wire가 함께 표현하지 못하면 같은 selected profile의 Responses operation으로 내부 wire만 전환한다. Result decoder는 selected operation을 우선하며 caller/agent identity로 분기하지 않는다. +- Provider-pool Chat과 unmarked caller-workspace의 selector/Work/Review 요청은 selected protocol profile이 선언한 출력 상한 wire spelling을 사용한다. OpenAI Chat wire는 `max_completion_tokens`, Gemini Chat wire는 `max_tokens`로 정규화한다. Bounded selector PLAN/REVIEW pair는 caller SDK의 큰 기본 completion limit과 분리해 최대 4096 output tokens만 허용한다. Selector provider에는 prepare 단계의 canonical `prepare(path)` 또는 pair-write 단계의 단일 `iop_write_artifact_pair(plan_content,review_content)` schema만 보여준다. Selector system instruction은 compact Plan 계약에 따라 immutable task를 먼저 분석하고 explicit requirement/constraint/acceptance를 보존한 2-6개 closed step, deterministic verification, REVIEW handoff duty를 작성하도록 한다. Edge는 workspace 경계와 비어 있지 않은 write만 확인한 atomic pair를 admitted caller tool schema의 PLAN/REVIEW 두 write로 확장한다. Work provider의 전체 task prompt는 caller의 기존 working-directory system context에서 해석한 absolute PLAN path와 고정 영문 `Read it and complete the task.`뿐이며 ordinary caller tools만 보인다. Work는 PLAN을 읽고 구현·검증한 뒤 item status, actual changes, verification evidence, deviation을 담은 REVIEW write를 직접 반환한다. Edge는 이 Worker-authored handoff의 의미 완성도를 선검증하지 않으며, 부분 handoff도 Review가 검사·수리하도록 전달한다. Review provider에만 별도 compact code-review system instruction을 주입한다. Review는 PLAN/REVIEW와 actual caller result를 읽고 applicable verification을 재실행하며, defect가 있으면 같은 binding에서 root cause를 확정해 repair/re-verify한 후에만 terminal을 반환한다. REVIEW를 다시 쓰거나 별도 Result 문서를 만들지 않는다. 정확한 PLAN/REVIEW 접근만 artifact state로 기록하고 그 외 caller-workspace tool call은 그대로 통과시킨다. Reserved-artifact operation은 provider tool의 path alias를 canonical path로 복원하고 caller binding의 tool/argument mapping으로 정규화한다. Command-mode binding stage는 provider delta를 선행 방출하지 않고 bounded stage를 수집·매핑하며 structured binding의 progressive stream은 유지한다. Cleanup은 request-local job directory만 제거한다. Caller continuation은 SDK 장식이 아니라 발행된 tool-call ID·이름·arguments의 의미 해시로 issue를 결속한다. Function tools와 reasoning effort를 Chat wire가 함께 표현하지 못하면 같은 selected profile의 Responses operation으로 내부 wire만 전환한다. Result decoder는 selected operation을 우선하며 caller/agent identity로 분기하지 않는다. - 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는 보존한다. diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 9d765c4c..ac608347 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -208,7 +208,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | managed projection auth | `credential_plane.enabled=true` uses the fresh Control Plane projection for inbound token auth and principal route discovery. Static principal/bearer fallback is disabled. | | managed slot route | Public model id/alias resolves to one projected route, exact slot/profile/upstream model/resource selector, and immutable revisions/generation. Unknown, cross-principal, stale, revoked, or ambiguous bindings fail closed. | | marked preset single-request admission | An authorized fixed single-request preset compiles one service-owned admission value at request start: requested public model, canonical plan/work/review bindings resolved through managed authorization, opaque workspace capability, and absolute resource caps. Later refresh cannot mutate the admitted shape. No private binding is echoed to the caller. Compiled only after every canonical reference is verified through its catalog binding for the authenticated principal; missing, duplicate, unauthorized, dynamically selected, or option-inconsistent inputs are rejected without fallback. | -| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives `prepare-only` or atomic `pair-write` from the locked artifact frontier. The selector receives a compact Plan system contract: analyze the immutable task, preserve explicit requirements/constraints/acceptance, author 2-6 closed steps plus deterministic verification, and put the exact REVIEW handoff duty in the final step. PLAN remains English ASCII and pending REVIEW carries the same P1..Pn inventory. Work receives only the absolute PLAN path plus `Read it and complete the task.` and ordinary caller tools; no Plan/Review skill system prompt is added to Work. Work implements and verifies, then directly replaces REVIEW with completed statuses, actual changes, verification evidence, and deviations. Edge validates that Worker-authored write and never synthesizes it from terminal prose. Review alone receives the compact code-review system contract. It rereads PLAN/REVIEW, inspects the actual result, reruns applicable verification, repairs and reverifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW or creating a Result document. Exact artifact accesses update request-local state, ordinary caller tool calls pass through, and cleanup removes only `.iop/job/`. Provider operation/effort/tool normalization remains selected-profile driven and caller identity independent. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | +| unmarked caller-workspace light route | An unmarked `light` preset binds only admitted caller `workspace_tools`; it never accepts a raw caller path. Edge derives `prepare-only` or atomic `pair-write` from the locked artifact frontier. The selector receives a compact Plan system contract: analyze the immutable task, preserve explicit requirements/constraints/acceptance, author concise closed steps plus deterministic verification, and include the REVIEW handoff duty. Edge checks only the admitted workspace boundary, non-empty writes, and size bounds before projecting PLAN/REVIEW. Work receives only the absolute PLAN path plus `Read it and complete the task.` and ordinary caller tools; no Plan/Review skill system prompt is added to Work. Work implements and verifies, then directly replaces REVIEW with status, changes, verification evidence, and deviations. Partial or imperfect REVIEW content proceeds to Review instead of terminating at Edge. Review alone receives the compact code-review system contract. It rereads PLAN/REVIEW, inspects the actual result, reruns applicable verification, repairs and reverifies in the same binding when needed, and owns the non-empty final output without rewriting REVIEW or creating a Result document. Exact artifact accesses update request-local state, ordinary caller tool calls pass through, and cleanup removes only `.iop/job/`. Provider operation/effort/tool normalization remains selected-profile driven and caller identity independent. Marked `single_request` presets bypass this route and retain the operator-owned Node workspace. | | marked single-request provider normalization | Plan/Work/Review derive caller-neutral effort/tool/structured-output requirements and let the selected protocol profile choose Chat Completions or Responses. Effort exact misses fall only to the nearest declared lower grade (`max` → `xhigh` when `max` is absent). Explicit resource selectors keep exact provider-ID verification; a `default` selector leaves provider choice to the pool while model group, profile, upstream model, credential slot/revision, and tunnel path remain frozen. Both Chat and Responses results are converted into the private common Chat-shaped stage codec before Plan/Work/Review validation. Chat conversion discards only bounded standard/provider bookkeeping (`service_tier`, `system_fingerprint`, provider `timings`, choice `logprobs`, message `annotations`, null `refusal`) and rejects a non-null refusal or unknown/duplicate fields. | | marked single-request internal templates | The admission also freezes the operator-configured effective Plan/Review Markdown templates. They are internal artifact shapes only: the Plan stage first performs a bounded read/list-only workspace inspection and requires one successful result in the same request-local conversation, then obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays. Edge owns the bullet formatting and renders `plan.md`; no separate analysis artifact is created. The Review template shapes the private `review.md` artifact. Callers cannot supply, name, or select a template, and template paths, contents, and digests never appear in a response, error, log, or metric label. The caller-visible Messages request/response schema is unchanged and the final text stays the model's `decision.output`. | | marked single-request ingress | One validated and authorized Messages POST enters the separate service coordinator capability before legacy provider/caller continuation and increments `iop_anthropic_single_request_ingress_total` once. Non-streaming returns one buffered final-only message. Streaming keeps one envelope across the coordinator lifetime, exposes only fixed plan/work/review/repair text blocks plus `event: ping`, and commits one final text/error terminal. Internal reasoning/tool wire never becomes caller `tool_use`; success is acknowledged only after the complete terminal write succeeds. | @@ -305,7 +305,7 @@ sequenceDiagram - provider-pool model group은 capacity + priority + availability 기준으로 provider candidate를 먼저 선택하고, 선택된 provider가 OpenAI-compatible 호출 방식을 지원하면 raw tunnel passthrough로 dispatch한다. Ollama/native provider가 선택되면 normalized `RunRequest` path로 dispatch한다. - Anthropic Messages and count-tokens do not use legacy direct-route or single-target fallback. Native responses preserve provider status, allowed headers, and body/SSE bytes; bridge responses are converted between Anthropic Messages and Chat Completions shapes. - A marked single-request Messages dispatch requires the narrow service coordinator capability and never falls back to the generic provider pool. The handler copies the immutable binding and request input and counts the accepted HTTP admission once with no labels. The service projects exactly one frozen terminal candidate through both response modes: buffered/SSE `end_turn`; buffered/SSE `max_tokens` without private partial content; `invalid_request_error` for validation/context; `api_error` for provider, timeout, budget, repetition, malformed, internal-tool, and workspace-cleanup failures; or silent cancellation after caller disconnect. The streaming path maps only fixed plan/work/review/repair summaries, serializes pings and monotonic text-block indices with one terminal owner, stops and joins liveness before terminal/return, and acknowledges completion only after `message_stop`. Arbitrary progress, reasoning, tool/provider/credential/workspace data, raw failures, and internal stage terminals stay private. No classified terminal triggers retry, fallback, partial success, a second request, or a later success terminal. Count-tokens does not enter or increment this path. -- Marked single-request Plan/Review templates are Edge-owned internal artifact shapes, not part of this input surface. Admission freezes the effective pair; Edge renders deterministic PLAN `P1..Pn` IDs, Work writes one strict REVIEW handoff (item status, changes, verification, deviations), and Review rereads both artifacts without a memory worker payload or a final REVIEW write. No caller field, header, or metadata value can supply, name, select, or override a template, and no template path, content, or digest appears in a response, an error message, a log projection, or a metric label. Changing a template changes neither the Messages request schema nor the response schema: the buffered/SSE terminal projection is unchanged and the final caller-visible text is exactly the reviewer `decision.output` after any repair/re-verification. +- Marked single-request Plan/Review templates are Edge-owned internal artifact shapes, not part of this input surface. Admission freezes the effective pair; Edge renders PLAN `P1..Pn` IDs, Work writes a REVIEW handoff (item status, changes, verification, deviations), and Review rereads both artifacts without a memory worker payload or a final REVIEW write. Edge does not reparse PLAN or reject a partial REVIEW on semantic completeness before Review dispatch. No caller field, header, or metadata value can supply, name, select, or override a template, and no template path, content, or digest appears in a response, an error message, a log projection, or a metric label. Changing a template changes neither the Messages request schema nor the response schema: the buffered/SSE terminal projection is unchanged and the final caller-visible text is exactly the reviewer `decision.output` after any repair/re-verification. - Marked single-request observation evidence links ingress=1, request-total=1, terminal=1, stage/tool/cleanup counts, and raw-free correlation for one real POST. `iop_anthropic_single_request_ingress_total` is strictly unlabeled: no request_id, stage_id, provider identity, content, or workspace reference appears as a metric label. Internal tool names (`workspace_read`, `workspace_write`, etc.), raw arguments, private results, and workspace references are absent from the public terminal JSON and from log projections. Stage-pure timing, cardinality-bounded labels, and privacy semantics are documented here. SDD S12 qualifies the external Claude path on an approved IOP Node with one accepted ingress, the expected stage sequence, one terminal, exact output, timing, cleanup, and redacted evidence. - Internal workspace calls use a service-owned schema independent of caller-facing tool codecs. The five closed operation names decode into typed Node requests only after request/stage/tool identity, canonical relative path, approved operation/command/environment capability, and immutable budget checks. The loop opens once, preserves the admitted connection generation, executes one pending call at a time, accepts only correlated typed results, and returns a deep-copied raw-free result to the same executor continuation. Repeated IDs, stale responses, malformed or denied input, timeout, output/iteration exhaustion, and cancellation never become public Anthropic tool protocol or trigger a second ingress. - Claude Code Messages requests may use adaptive thinking, `output_config.effort`, structured output, cache-control annotations, and supported beta headers, including the compatibility-only `advisor-tool-2026-03-01` marker emitted by the pinned official caller. The Chat bridge consumes rather than forwards those headers, maps supported fields, and requires callers to replay opaque `tool_use.id` values unchanged so Gemini thought signatures can be restored on tool-result turns. @@ -371,6 +371,7 @@ sequenceDiagram ## 변경 기록 +- 2026-08-15: Removed duplicate runtime semantic gates from Plan/Review handoffs. Edge now keeps workspace, non-empty write, and size boundaries while Reviewer owns completeness checks and repair of partial worker evidence. - 2026-08-15: Corrected the unmarked caller-workspace hybrid responsibility contract. Planner and Reviewer now receive separate compact system contracts, Worker still receives only the absolute PLAN path plus the fixed sentence, Worker directly authors REVIEW evidence, and Review retains inspection/repair/re-verification without a Result artifact. - 2026-08-15: Normalized Chat output-token aliases by selected protocol profile across provider-pool and unmarked caller-workspace selector/Work/Review calls. OpenAI Chat uses `max_completion_tokens`, Gemini Chat uses `max_tokens`, target-native values win, and unmarked internal stages use the selected OpenAI profile's Responses operation when tools plus effort cannot be represented by Chat. The reasoning Responses conversion omits incompatible generic SDK `temperature`/`top_p` defaults. Selector upstream HTTP 400 now terminates once as caller `invalid_request_error` instead of retryable 502. Command-mode workspace execution also carries the admitted containment guard in the actual outgoing command. diff --git a/agent-spec/runtime/edge-node-execution.md b/agent-spec/runtime/edge-node-execution.md index 8ab25fe0..fcd713c7 100644 --- a/agent-spec/runtime/edge-node-execution.md +++ b/agent-spec/runtime/edge-node-execution.md @@ -257,8 +257,8 @@ The shared `packages/go/execution` package contains provider lifecycle, registry - The request-local internal tool loop is implemented between the coordinator and the dedicated workspace wire. Strict decode and capability checks happen before wire effects; Node results are accepted only for the one pending call and return only bounded typed fields to the same optional executor continuation. Repeated or stale identities, malformed/denied calls, exhausted immutable budgets, and cancellation terminate internally without selecting another Node or involving the HTTP caller. - Request-owned plan and review artifact access is implemented between the controller and the same dedicated workspace wire. Only `SingleRequestArtifactPlan` and `SingleRequestArtifactReview` are accepted. Artifact and model-tool callers share one serialized open attempt and the same opened cleanup gate; terminal and cancellation paths wait for in-flight artifact work before issuing exactly one cleanup. Edge bounds writes before dispatch and reads before acceptance, validates the echoed kind/operation and canonical terminal, and never reselects after a generation mismatch. Node maps the closed selectors to `plan.md` and `review.md`, validates the inventoried parent/file identity with descriptor-relative no-follow reads, and never grants the public workspace tool surface access to `.iop`. - The private Plan stage is installed in the composite single-request executor at Edge input startup (`apps/edge/internal/input/manager.go`). Its provider codec accepts only frozen Plan options and selected dispatch facts, uses the admitted stage deadline and exact output limit, and projects provider failures to the closed stage terminal. Before structured completion, it exposes only frozen-authority `workspace_read`/`workspace_list`, forces the first turn to use one of them, validates the canonical call, and resumes `planning` through the shared correlated continuation bridge. A failed result does not unlock completion, and the admitted per-stage iteration limit bounds further inspection. No write/delete/command capability or separate analysis artifact exists in Plan. After successful inspection the stage owns a closed strict JSON response schema with exactly a string `goal`, a string-array `steps`, and a string-array `verification`; unknown, duplicate, missing, or mistyped fields fail malformed. It enforces a single-line goal, 2-6 non-empty one-line step items, and 1-3 non-empty one-line verification items. Edge, rather than the provider, adds Markdown bullet prefixes and newlines before substituting the values into the frozen effective Plan template. Required headings remain exact standalone lines, the documented placeholder inventory is closed, and unresolved delimiters are rejected. Provider output therefore cannot vary headings, bullet formatting, or static template text, and caller request fields cannot select, supply, or override the admitted template. -- The private Work stage is installed in the composite single-request executor at Edge input startup (`apps/edge/internal/input/manager.go`). It reads and strictly validates `SingleRequestArtifactPlan`, retains only request/stage/tool identifiers while waiting for the coordinator-owned continuation, and sends no `reasoning_effort` field in an initial or resumed provider request. Its successful strict response is rendered once as `SingleRequestArtifactReview`; write failure prevents Review. -- Review reads and validates the stored PLAN and REVIEW handoff before its provider call. It may inspect, repair, and re-verify with admitted tools, but it neither takes a memory worker result nor writes a final REVIEW artifact. A repair mutation requires later successful inspection evidence before PASS; caller output is byte-for-byte the reviewer strict `output` field and cleanup removes the temporary artifacts. +- The private Work stage is installed in the composite single-request executor at Edge input startup (`apps/edge/internal/input/manager.go`). It reads a non-empty bounded `SingleRequestArtifactPlan`, retains only request/stage/tool identifiers while waiting for the coordinator-owned continuation, and sends no `reasoning_effort` field in an initial or resumed provider request. Its response is rendered once as `SingleRequestArtifactReview`; write failure prevents Review, while partial report fields remain Reviewer-owned evidence. +- Review reads the stored non-empty bounded PLAN and REVIEW handoff before its provider call. Edge does not reparse the PLAN or pre-judge REVIEW semantic completeness. Review may inspect, repair, and re-verify with admitted tools, but it neither takes a memory worker result nor writes a final REVIEW artifact. A repair mutation requires later successful inspection evidence before PASS; caller output is byte-for-byte the reviewer strict `output` field and cleanup removes the temporary artifacts. - The Node-private workspace request/result wire is implemented, including catalog delivery, parser registration, optional handler behavior, stable typed failures, generation-fenced dispatch, context-cancel propagation, and request cleanup. Before ready, a non-empty catalog requires a supported `darwin|linux` host and exact entry/host matching before any root open; unsupported and cross-platform catalogs fail closed while empty catalogs remain compatible. The Node installs the workspace handler before ready and cleans active requests before closing workspace authority ahead of session/store teardown. Request authority is immutable and request-local. File operations reserve `.iop`, reject symlink/mount/replaced-parent/special-file paths before effects, process bounded list batches with deterministic truncation, and use a same-parent structured write. Command execution resolves only admitted ids to fixed templates, enters the already-opened root descriptor through `fchdir`, provides only allowlisted environment entries, shares one output cap across drained stdout/stderr, and owns the complete process group through exit, timeout, context cancel, exact request/tool cancel, or request cleanup. - managed mode는 등록과 dispatch 전에 CA로 검증된 Edge/Node workload identity를 요구한다. - revoked, disabled, expired, stale, replayed, wrong-recipient, mismatched lease는 provider나 credential fallback 없이 fail closed한다. @@ -347,9 +347,9 @@ Heartbeat interval/wait는 protobuf field가 아닌 양쪽 transport 구현의 l - `go test -count=1 ./apps/edge/internal/openai -run 'TestAnthropicSingleRequestObservation'` — deterministic single-request observation evidence: ingress=1, request-total=1, terminal=1, stage/tool/cleanup counts, raw-free correlation, and unlabeled metric assertion. - `go test -count=1 ./apps/edge/internal/openai -run 'TestSingleRequest(ProviderStage|PlanStage)'` — deterministic frozen provider codec and Plan stage evidence, including required read/list-first inspection, mutation-tool exclusion, successful-result completion gate, high reasoning, the closed structured response schema, exact JSON field admission, ordered tunnel frames, deterministic frozen-template rendering, planning/internal-tool resume envelopes, and `plan.md` artifact selection. - `go test -count=1 ./apps/edge/internal/openai -run 'TestSingleRequest(ProviderStage|ProviderNormalization|PlanStage|WorkStage|ReviewStage|Executor)'` — private stage operation selection, Chat/Responses conversion, nearest-lower effort (`max` → `xhigh`), default-selector pool-provider acceptance, explicit dispatch fences, and composite regression evidence. -- `go test -count=1 ./packages/go/singlerequesttemplate ./packages/go/config` — exact standalone heading/`PASS` grammar, closed placeholder inventory, the `8192`-byte boundary, and relative-only `plan_file`/`review_file` loading with per-file fallback and filesystem-kind rejection. +- `go test -count=1 ./packages/go/singlerequesttemplate ./packages/go/config` — template rendering, closed placeholder inventory, the `8192`-byte boundary, and relative-only `plan_file`/`review_file` loading with per-file fallback and filesystem-kind rejection. - `go test -count=1 ./apps/edge/internal/service -run 'TestSingleRequestBinding'` and `go test -count=1 ./apps/edge/internal/openai -run 'TestSingleRequest(PresetBinding|ReviewStage)'` — admitted effective-template snapshot through clone and workspace revalidation, refresh isolation for admitted versus newly admitted requests, and the custom-template REVIEW artifact with unchanged caller-visible output. -- `go test -race -count=1 ./apps/edge/internal/openai -run 'TestSingleRequestWork(Stage|ToolBridge)'` — deterministic ornith-fast Work tool loop, correlation isolation, cancellation cleanup, strict completion evidence, and Work reasoning-option absence. +- `go test -race -count=1 ./apps/edge/internal/openai -run 'TestSingleRequestWork(Stage|ToolBridge)'` — deterministic Work tool loop, correlation isolation, cancellation cleanup, REVIEW rendering, and Work reasoning-option absence. - `go test -race ./apps/edge/internal/service ./apps/edge/internal/openai -run 'Test(SingleRequestTerminalDisposition|SingleRequestQualityGate|AnthropicSingleRequestErrorCancelMatrix|SingleRequestAnthropicStreamTerminalDisposition)' -count=1` — deterministic S11 error-cancel/length matrix, first-terminal ownership, one ingress, no second request, disconnect silence, and raw-free output evidence. ## 한계와 주의사항 @@ -367,6 +367,7 @@ Heartbeat interval/wait는 protobuf field가 아닌 양쪽 transport 구현의 l ## 변경 기록 +- 2026-08-15: Removed duplicate PLAN reparsing and REVIEW semantic pre-validation from Work/Review. Non-empty bounded artifacts proceed to Reviewer, which owns completeness checks and repair. - 2026-08-14: Added bounded read/list-first workspace analysis inside Plan. Structured Plan completion now requires a successful inspection result in the same request-local conversation; Plan exposes no mutation tool and creates no additional analysis artifact (`apps/edge/internal/openai/single_request_plan_stage.go`). - 2026-08-14: Restored artifact-only model handoff: deterministic PLAN `P1..Pn` IDs, one Work-authored validated REVIEW handoff, Review artifact reread with request-local repair/re-verification evidence, reviewer zero-write, and strict terminal `output` provenance. - 2026-08-14: Moved private Plan/Work/Review provider calls onto the shared provider-normalization boundary. Stage requirements now select Chat or Responses without caller identity, unsupported effort maps only downward, and default-selector provider-pool choices no longer fail the post-dispatch validation that still fences profile, target, credential revision, model group, and tunnel path. diff --git a/apps/edge/internal/openai/artifact_pair.go b/apps/edge/internal/openai/artifact_pair.go index 0f5a8c4e..61989c44 100644 --- a/apps/edge/internal/openai/artifact_pair.go +++ b/apps/edge/internal/openai/artifact_pair.go @@ -7,8 +7,6 @@ import ( "net/http" "strings" "sync" - - "iop/packages/go/singlerequesttemplate" ) const defaultArtifactFrontierCapacity = 1024 @@ -41,7 +39,6 @@ const ( type artifactDisposition struct { Kind artifactDispositionKind SelectorStageID string - PlanItemIDs []string PrimaryError *hotPathEndpointError } @@ -85,7 +82,6 @@ type artifactFrontierRecord struct { pendingHash string consumedHashes map[string]struct{} consumedIDs map[string]struct{} - planItemIDs []string } // artifactFrontierStore owns the request-local workspace binding and the sole @@ -418,7 +414,6 @@ func mapArtifactOutput( orderedPaths := []string{issued.PlanPath, issued.ReviewPath} mappedCalls := make([]normalizedToolCall, 0, 2) payloads := make(map[string]*workspaceEncodedPayload, 2) - var planItemIDs []string for _, requiredPath := range orderedPaths { call, ok := byPath[cleanRelativePath(requiredPath)] if !ok { @@ -428,45 +423,18 @@ func mapArtifactOutput( if err != nil { return normalizedStageOutput{}, nil, err } - content, err := artifactWriteContent(record.binding, call) - if err != nil { - return normalizedStageOutput{}, nil, err - } - if cleanRelativePath(requiredPath) == cleanRelativePath(issued.PlanPath) { - if err := validateEnglishOnlyPlan(content); err != nil { - return normalizedStageOutput{}, nil, err - } - plan, err := singlerequesttemplate.ParsePlan( - singlerequesttemplate.DefaultPlanTemplate, - content, - singlerequesttemplate.MaxTemplateBytes, - ) - if err != nil { - return normalizedStageOutput{}, nil, fmt.Errorf("artifact plan does not match the lightweight template: %w", err) - } - planItemIDs, err = singlerequesttemplate.PlanItemIDs(plan) - if err != nil { - return normalizedStageOutput{}, nil, fmt.Errorf("artifact plan item inventory is invalid: %w", err) - } - } else if err := validatePendingReviewSeed(content, planItemIDs); err != nil { + if _, err := artifactWriteContent(record.binding, call); err != nil { return normalizedStageOutput{}, nil, err } + // Plan and Review contents are model-to-model handoff artifacts. The + // workspace boundary already requires a non-empty write; their semantic + // completeness belongs to the Worker and Reviewer, not the transport. mappedCalls = append(mappedCalls, mapped) payloads[mapped.ID] = payload } - record.planItemIDs = append([]string(nil), planItemIDs...) return artifactResponseOutput(output, mappedCalls), payloads, nil } -func validateEnglishOnlyPlan(content string) error { - for _, value := range []byte(content) { - if value >= 0x80 { - return fmt.Errorf("artifact plan must contain English ASCII text only") - } - } - return nil -} - func artifactWriteContent(binding *workspaceBinding, call normalizedToolCall) (string, error) { if binding == nil { return "", fmt.Errorf("artifact write binding is unavailable") @@ -486,36 +454,6 @@ func artifactWriteContent(binding *workspaceBinding, call normalizedToolCall) (s return content, nil } -func pendingReviewSeed(planItemIDs []string) ([]byte, error) { - if len(planItemIDs) == 0 { - return nil, singlerequesttemplate.ErrMalformedReview - } - statuses := make([]string, len(planItemIDs)) - for i, id := range planItemIDs { - statuses[i] = fmt.Sprintf("- %s: pending", id) - } - return singlerequesttemplate.RenderReview( - singlerequesttemplate.DefaultReviewTemplate, - singlerequesttemplate.ReviewFields{ - ItemStatus: strings.Join(statuses, "\n"), - Changes: "Pending worker execution.", Verification: "Pending worker verification.", - Deviations: "None recorded.", - }, - singlerequesttemplate.MaxTemplateBytes, - ) -} - -func validatePendingReviewSeed(content string, planItemIDs []string) error { - want, err := pendingReviewSeed(planItemIDs) - if err != nil { - return fmt.Errorf("build pending review seed: %w", err) - } - if strings.TrimSpace(content) != strings.TrimSpace(string(want)) { - return fmt.Errorf("artifact review seed does not match the lightweight pending template") - } - return nil -} - func mapArtifactCall( binding *workspaceBinding, providerCall normalizedToolCall, @@ -715,7 +653,6 @@ func (s *artifactFrontierStore) consume( if primaryFailure != nil { return snap, artifactDisposition{ Kind: artifactDispositionLocalEligible, SelectorStageID: record.selectorStageID, - PlanItemIDs: append([]string(nil), record.planItemIDs...), PrimaryError: primaryFailure, }, true, nil } @@ -730,10 +667,7 @@ func (s *artifactFrontierStore) consume( return snap, artifactDisposition{Kind: artifactDispositionResumeSelector, SelectorStageID: record.selectorStageID}, true, nil case artifactPhasePairPending: record.phase = artifactPhaseLocalEligible - return snap, artifactDisposition{ - Kind: artifactDispositionLocalEligible, SelectorStageID: record.selectorStageID, - PlanItemIDs: append([]string(nil), record.planItemIDs...), - }, true, nil + return snap, artifactDisposition{Kind: artifactDispositionLocalEligible, SelectorStageID: record.selectorStageID}, true, nil default: return logicalRequestSnapshot{}, artifactDisposition{}, true, fmt.Errorf("artifact frontier phase cannot consume results") } diff --git a/apps/edge/internal/openai/artifact_pair_test.go b/apps/edge/internal/openai/artifact_pair_test.go index a55503aa..f89c30cf 100644 --- a/apps/edge/internal/openai/artifact_pair_test.go +++ b/apps/edge/internal/openai/artifact_pair_test.go @@ -15,15 +15,6 @@ import ( "iop/packages/go/singlerequesttemplate" ) -func TestValidateEnglishOnlyPlan(t *testing.T) { - if err := validateEnglishOnlyPlan("# Plan\n\n## Goal\nCreate index.html.\n"); err != nil { - t.Fatalf("English PLAN rejected: %v", err) - } - if err := validateEnglishOnlyPlan("# Plan\n\n## Goal\n파일을 생성한다.\n"); err == nil { - t.Fatal("non-English PLAN was accepted") - } -} - func testLightweightPlan(t *testing.T) string { t.Helper() plan, err := singlerequesttemplate.RenderPlan(singlerequesttemplate.DefaultPlanTemplate, singlerequesttemplate.PlanFields{ @@ -50,7 +41,10 @@ func TestNormalizeCallerWorkspacePlanBoundary(t *testing.T) { func testPendingReview(t *testing.T) string { t.Helper() - review, err := pendingReviewSeed([]string{"P1", "P2"}) + review, err := singlerequesttemplate.RenderReview(singlerequesttemplate.DefaultReviewTemplate, singlerequesttemplate.ReviewFields{ + ItemStatus: "- P1: pending\n- P2: pending", Changes: "Pending worker execution.", + Verification: "Pending worker verification.", Deviations: "None recorded.", + }, singlerequesttemplate.MaxTemplateBytes) if err != nil { t.Fatal(err) } @@ -244,20 +238,6 @@ func TestArtifactPairFrontierMatrix(t *testing.T) { } } -func TestArtifactPairRejectsMalformedTemplatesBeforeCallerEffects(t *testing.T) { - fixture := newArtifactPairFixture(t, "openai", true) - _, err := fixture.issue([]normalizedToolCall{ - artifactProviderWrite("provider_plan", fixture.paths.PlanPath, "not a plan"), - artifactProviderWrite("provider_review", fixture.paths.ReviewPath, testPendingReview(t)), - }) - if err == nil || !strings.Contains(err.Error(), "lightweight template") { - t.Fatalf("malformed plan error = %v", err) - } - if fixture.server.artifactFrontiers.has(fixture.requestID, fixture.ownerEdgeID) || fixture.server.lightFlows.has(fixture.requestID, fixture.ownerEdgeID) { - t.Fatal("malformed artifact retained a caller-effect frontier") - } -} - func TestPresetWorkspaceOwnershipAdmission(t *testing.T) { unmarked := hotPathSelectorPreset([]string{config.ModeLight}) if !presetAdmitsCallerWorkspaceTools(unmarked, routeDispatch{Preset: unmarked}) { diff --git a/apps/edge/internal/openai/hot_path_direct_test.go b/apps/edge/internal/openai/hot_path_direct_test.go index cae075a7..ce8cb841 100644 --- a/apps/edge/internal/openai/hot_path_direct_test.go +++ b/apps/edge/internal/openai/hot_path_direct_test.go @@ -643,7 +643,10 @@ func scriptedArtifactPair(endpoint, requestID string) string { Goal: "Complete the caller workspace task", Steps: []string{"Inspect the requested result", "Implement and verify the result"}, Verification: []string{"Confirm the caller workspace result"}, }, singlerequesttemplate.MaxTemplateBytes) - review, _ := pendingReviewSeed([]string{"P1", "P2"}) + review, _ := singlerequesttemplate.RenderReview(singlerequesttemplate.DefaultReviewTemplate, singlerequesttemplate.ReviewFields{ + ItemStatus: "- P1: pending\n- P2: pending", Changes: "Pending worker execution.", + Verification: "Pending worker verification.", Deviations: "None recorded.", + }, singlerequesttemplate.MaxTemplateBytes) if endpoint == "anthropic" { return fmt.Sprintf(`{"id":"msg-scripted-pair","type":"message","role":"assistant","content":[{"type":"tool_use","id":"provider-plan","name":"write_file","input":{"path":%q,"content":%q}},{"type":"tool_use","id":"provider-review","name":"write_file","input":{"path":%q,"content":%q}}],"stop_reason":"tool_use"}`, paths.PlanPath, string(plan), paths.ReviewPath, string(review)) } diff --git a/apps/edge/internal/openai/hot_path_light.go b/apps/edge/internal/openai/hot_path_light.go index c4730a8c..5f23b051 100644 --- a/apps/edge/internal/openai/hot_path_light.go +++ b/apps/edge/internal/openai/hot_path_light.go @@ -12,7 +12,6 @@ import ( edgeservice "iop/apps/edge/internal/service" "iop/packages/go/config" - "iop/packages/go/singlerequesttemplate" ) const defaultHotPathLightCapacity = 1024 @@ -105,7 +104,6 @@ type hotPathLightRecord struct { phase hotPathLightPhase artifactReady bool - planItemIDs []string localPlanRead bool workerReviewWritten bool reviewerPlanRead bool @@ -260,7 +258,7 @@ func (s *hotPathLightStore) has(requestID, ownerEdgeID string) bool { return record != nil && record.ownerEdgeID == ownerEdgeID } -func (s *hotPathLightStore) updateArtifactLineage(requestID, ownerEdgeID string, lineage logicalRequestLineage, localEligible bool, planItemIDs []string) error { +func (s *hotPathLightStore) updateArtifactLineage(requestID, ownerEdgeID string, lineage logicalRequestLineage, localEligible bool) error { if s == nil { return fmt.Errorf("light flow is unavailable") } @@ -272,11 +270,7 @@ func (s *hotPathLightStore) updateArtifactLineage(requestID, ownerEdgeID string, } record.lineage = lineage if localEligible { - if len(planItemIDs) == 0 { - return fmt.Errorf("light flow plan item inventory is unavailable") - } record.artifactReady = true - record.planItemIDs = append([]string(nil), planItemIDs...) } return nil } @@ -876,9 +870,6 @@ func (s *hotPathLightStore) commitLocal(requestID, ownerEdgeID string, output no if record == nil || record.ownerEdgeID != ownerEdgeID || record.phase != hotPathPhaseLocalActive || !record.running || len(output.ToolCalls) != 0 { return hotPathLightDisposition{}, fmt.Errorf("local completion cannot transition to review") } - if !record.localPlanRead || !record.workerReviewWritten { - return hotPathLightDisposition{}, fmt.Errorf("worker completion requires a successful plan read and completed review handoff") - } reviewStageID, err := coordinator.newStageID() if err != nil { return hotPathLightDisposition{}, err @@ -907,9 +898,6 @@ func (s *hotPathLightStore) classifyLocalOutput(requestID, ownerEdgeID string, o return "", fmt.Errorf("local flow state is unavailable") } if len(output.ToolCalls) == 0 { - if !record.localPlanRead || !record.workerReviewWritten { - return "", fmt.Errorf("worker completion requires a successful plan read and completed review handoff") - } return "", nil } paths := newReservedPaths(requestID) @@ -922,15 +910,10 @@ func (s *hotPathLightStore) classifyLocalOutput(requestID, ownerEdgeID string, o if len(reviewWrites) == 0 { return hotPathPendingLocalTools, nil } - if len(reviewWrites) != 1 || !record.localPlanRead { - return "", fmt.Errorf("worker review handoff must contain one exact review write after reading the plan") - } - content, err := artifactWriteContent(record.binding, reviewWrites[0]) - if err != nil { - return "", err - } - if err := singlerequesttemplate.ValidateReviewHandoff([]byte(content), record.planItemIDs); err != nil { - return "", fmt.Errorf("worker review handoff is malformed: %w", err) + for _, call := range reviewWrites { + if _, err := artifactWriteContent(record.binding, call); err != nil { + return "", err + } } return hotPathPendingLocalHandoff, nil } diff --git a/apps/edge/internal/openai/hot_path_light_test.go b/apps/edge/internal/openai/hot_path_light_test.go index 99a6e075..5050e611 100644 --- a/apps/edge/internal/openai/hot_path_light_test.go +++ b/apps/edge/internal/openai/hot_path_light_test.go @@ -118,39 +118,6 @@ func TestInitialReviewRequiresToolUseBeforeTerminal(t *testing.T) { } } -func TestHotPathLightRequiresPlanReadBeforeWorkerCompletion(t *testing.T) { - for _, endpoint := range []string{"openai", "anthropic"} { - endpoint := endpoint - t.Run(endpoint, func(t *testing.T) { - fixture := newScriptedLightFixture(t, endpoint, false) - fixture.service.responses[2] = func(string) string { - return scriptedLightCompletion(endpoint, "worker tried to finish early") - } - prepare := fixture.request() - fixture.consumeToolResponse(prepare, []string{`{"written":true}`}) - pair := fixture.request() - fixture.consumeToolResponse(pair, []string{`{"written":true}`, `{"written":true}`}) - assertLightFailureAfterCleanup(t, fixture, "successful plan read") - }) - } -} - -func TestHotPathLightRequiresWorkerAuthoredReviewAfterPlanRead(t *testing.T) { - for _, endpoint := range []string{"openai", "anthropic"} { - endpoint := endpoint - t.Run(endpoint, func(t *testing.T) { - fixture := newScriptedLightFixture(t, endpoint, false) - fixture.service.responses[4] = func(string) string { - return scriptedLightCompletion(endpoint, "terminal prose cannot replace review evidence") - } - driveScriptedLightToLocalAfterPlanRead(t, fixture) - localWork := fixture.request() - fixture.consumeToolResponse(localWork, []string{`{"ok":true}`}) - assertLightFailureAfterCleanup(t, fixture, "completed review handoff") - }) - } -} - func assertLightFailureAfterCleanup(t *testing.T, fixture *scriptedLightFixture, message string) { t.Helper() cleanup := fixture.request() diff --git a/apps/edge/internal/openai/request_identity_ingress.go b/apps/edge/internal/openai/request_identity_ingress.go index 92230ef7..2423849f 100644 --- a/apps/edge/internal/openai/request_identity_ingress.go +++ b/apps/edge/internal/openai/request_identity_ingress.go @@ -68,7 +68,7 @@ func (s *Server) joinPresetChatIngress(r *http.Request, dispatch routeDispatch, return presetIngressResult{}, fmt.Errorf("artifact continuation rejected: %w", err) } if disposition.PrimaryError != nil { - if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, false, nil); err != nil { + if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, false); err != nil { return presetIngressResult{}, err } cleanup, err := s.lightFlows.beginPrimaryErrorCleanup(requestContext, snap.ID, ownerEdgeID, *disposition.PrimaryError, nil, s.requestCoordinator) @@ -85,7 +85,7 @@ func (s *Server) joinPresetChatIngress(r *http.Request, dispatch routeDispatch, if err := s.applyArtifactDisposition(snap, disposition, runMeta); err != nil { return presetIngressResult{}, err } - if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, disposition.Kind == artifactDispositionLocalEligible, disposition.PlanItemIDs); err != nil { + if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, disposition.Kind == artifactDispositionLocalEligible); err != nil { return presetIngressResult{}, err } return presetIngressResult{Artifact: disposition}, nil @@ -219,7 +219,7 @@ func (s *Server) joinPresetAnthropicIngress(r *http.Request, dispatch routeDispa return presetIngressResult{}, fmt.Errorf("artifact continuation rejected: %w", err) } if disposition.PrimaryError != nil { - if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, false, nil); err != nil { + if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, false); err != nil { return presetIngressResult{}, err } cleanup, err := s.lightFlows.beginPrimaryErrorCleanup(requestContext, snap.ID, ownerEdgeID, *disposition.PrimaryError, nil, s.requestCoordinator) @@ -236,7 +236,7 @@ func (s *Server) joinPresetAnthropicIngress(r *http.Request, dispatch routeDispa if err := s.applyArtifactDisposition(snap, disposition, metadata); err != nil { return presetIngressResult{}, err } - if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, disposition.Kind == artifactDispositionLocalEligible, disposition.PlanItemIDs); err != nil { + if err := s.lightFlows.updateArtifactLineage(snap.ID, ownerEdgeID, contLineage.Committed, disposition.Kind == artifactDispositionLocalEligible); err != nil { return presetIngressResult{}, err } return presetIngressResult{Artifact: disposition}, nil diff --git a/apps/edge/internal/openai/single_request_review_stage.go b/apps/edge/internal/openai/single_request_review_stage.go index 811d3893..97f46a5a 100644 --- a/apps/edge/internal/openai/single_request_review_stage.go +++ b/apps/edge/internal/openai/single_request_review_stage.go @@ -10,7 +10,6 @@ import ( "strings" edgeservice "iop/apps/edge/internal/service" - "iop/packages/go/singlerequesttemplate" ) const ( @@ -94,16 +93,6 @@ func (s *singleRequestReviewStage) run(ctx context.Context, req singleRequestRev if len(plan) > req.Limits.MaxOutputBytes { return nil, quality.length(errSingleRequestReviewStage) } - // Enforce the frozen effective Plan template before extracting IDs. A - // stored PLAN that fails the exact parser cannot reach provider dispatch, - // closing the grammar bypass identified in the handoff. - if _, err := singlerequesttemplate.ParsePlan(binding.Templates.Plan, string(plan), req.Limits.MaxOutputBytes); err != nil { - return nil, quality.malformed(errSingleRequestReviewStage) - } - planIDs, err := singlerequesttemplate.PlanItemIDs(plan) - if err != nil { - return nil, quality.malformed(errSingleRequestReviewStage) - } handoff, err := ctrl.ReadInternalArtifact(ctx, edgeservice.SingleRequestArtifactReview) if err != nil { return nil, quality.serviceFailure(ctx, err, errSingleRequestReviewStage) @@ -114,9 +103,6 @@ func (s *singleRequestReviewStage) run(ctx context.Context, req singleRequestRev if len(handoff) > req.Limits.MaxOutputBytes { return nil, quality.length(errSingleRequestReviewStage) } - if err := singlerequesttemplate.ValidateReviewHandoff(handoff, planIDs); err != nil { - return nil, quality.malformed(errSingleRequestReviewStage) - } tools, err := singleRequestWorkTools(binding.Workspace) if err != nil { return nil, quality.validation(errSingleRequestReviewStage) diff --git a/apps/edge/internal/openai/single_request_review_stage_test.go b/apps/edge/internal/openai/single_request_review_stage_test.go index 53602f32..0ff7562d 100644 --- a/apps/edge/internal/openai/single_request_review_stage_test.go +++ b/apps/edge/internal/openai/single_request_review_stage_test.go @@ -331,54 +331,19 @@ func TestSingleRequestReviewStagePassPersistsBeforeFinalizing(t *testing.T) { } } -func TestSingleRequestReviewStageRejectsMismatchedStoredPlan(t *testing.T) { - t.Run("plan with altered heading fails before provider dispatch", func(t *testing.T) { - var calls atomic.Int32 - bridge := newSingleRequestWorkToolBridge() - ctrl := newReviewController(t, bridge) - ctrl.plan = []byte("# Plan Mismatch\n\n## Goal\nUpdate result.\n\n## Steps\n- [P1] Write result.txt.\n- [P2] Verify result.\n\n## Verification\n- Run verify.\n") - var bodies [][]byte - _, err := newSingleRequestReviewStage(scriptedReviewProvider(t, ctrl, [][]byte{reviewPassBody("Approved.", "Summary.")}, &bodies), bridge).run(context.Background(), reviewRequest(t), ctrl) - if !errors.Is(err, errSingleRequestReviewStage) { - t.Fatalf("err=%v, want errSingleRequestReviewStage", err) - } - if calls.Load() != 0 { - t.Fatalf("provider dispatches=%d, want 0", calls.Load()) - } - if len(ctrl.writes) != 0 { - t.Fatalf("review artifact writes=%d, want 0", len(ctrl.writes)) - } - }) +func TestSingleRequestReviewStageReceivesPartialHandoff(t *testing.T) { + bridge := newSingleRequestWorkToolBridge() + ctrl := newReviewController(t, bridge) + ctrl.review = []byte("Worker stopped before filling every review section.") + var bodies [][]byte + stage := newSingleRequestReviewStage(scriptedReviewProvider(t, ctrl, [][]byte{reviewPassBody("Repaired output.", "Reviewer completed the check.")}, &bodies), bridge) - t.Run("plan with unknown placeholder fails before provider dispatch", func(t *testing.T) { - var calls atomic.Int32 - bridge := newSingleRequestWorkToolBridge() - ctrl := newReviewController(t, bridge) - ctrl.plan = []byte("# Plan\n\n## Goal\nUpdate result.\n\n## Steps\n- [P1] Write result.txt.\n- [P2] Verify result.\n\n## Verification\n- Run verify.\n{{unknown}}\n") - var bodies [][]byte - _, err := newSingleRequestReviewStage(scriptedReviewProvider(t, ctrl, [][]byte{reviewPassBody("Approved.", "Summary.")}, &bodies), bridge).run(context.Background(), reviewRequest(t), ctrl) - if !errors.Is(err, errSingleRequestReviewStage) { - t.Fatalf("err=%v, want errSingleRequestReviewStage", err) - } - if calls.Load() != 0 { - t.Fatalf("provider dispatches=%d, want 0", calls.Load()) - } - }) - - t.Run("malformed review handoff with prose in item status fails before provider dispatch", func(t *testing.T) { - var calls atomic.Int32 - bridge := newSingleRequestWorkToolBridge() - ctrl := newReviewController(t, bridge) - ctrl.review = []byte("# Review\n\n## Worker Item Status\n- P1: completed\nThis is a note.\n- P2: completed\n\n## Worker Changes\nUpdated result.txt.\n\n## Worker Verification\nverify passed\n\n## Deviations\nNone\n") - var bodies [][]byte - _, err := newSingleRequestReviewStage(scriptedReviewProvider(t, ctrl, [][]byte{reviewPassBody("Approved.", "Summary.")}, &bodies), bridge).run(context.Background(), reviewRequest(t), ctrl) - if !errors.Is(err, errSingleRequestReviewStage) { - t.Fatalf("err=%v, want errSingleRequestReviewStage", err) - } - if calls.Load() != 0 { - t.Fatalf("provider dispatches=%d, want 0", calls.Load()) - } - }) + if _, err := stage.run(context.Background(), reviewRequest(t), ctrl); err != nil { + t.Fatal(err) + } + if len(bodies) != 1 || !strings.Contains(string(bodies[0]), string(ctrl.review)) { + t.Fatalf("partial handoff did not reach reviewer: %q", bodies) + } } func TestSingleRequestReviewStageInspectionAndRepairRemainInLegalStates(t *testing.T) { diff --git a/apps/edge/internal/openai/single_request_work_stage.go b/apps/edge/internal/openai/single_request_work_stage.go index 10281339..beeab21b 100644 --- a/apps/edge/internal/openai/single_request_work_stage.go +++ b/apps/edge/internal/openai/single_request_work_stage.go @@ -218,16 +218,6 @@ func (s *singleRequestWorkStage) run(ctx context.Context, req singleRequestWorkS if len(plan) > req.Limits.MaxOutputBytes { return quality.length(errSingleRequestWorkStage) } - // Enforce the frozen effective Plan template before extracting IDs. A - // stored PLAN that fails the exact parser cannot reach provider dispatch - // or the REVIEW handoff write, closing the grammar bypass. - if _, err := singlerequesttemplate.ParsePlan(binding.Templates.Plan, string(plan), req.Limits.MaxOutputBytes); err != nil { - return quality.malformed(errSingleRequestWorkStage) - } - planIDs, err := singlerequesttemplate.PlanItemIDs(plan) - if err != nil { - return quality.malformed(errSingleRequestWorkStage) - } tools, err := singleRequestWorkTools(binding.Workspace) if err != nil { return quality.validation(errSingleRequestWorkStage) @@ -250,7 +240,7 @@ func (s *singleRequestWorkStage) run(ctx context.Context, req singleRequestWorkS if !completionEligible { return quality.malformed(errSingleRequestWorkStage) } - return s.finalizeReviewHandoff(ctx, req, ctrl, binding.Templates.Review, response.completion, planIDs, quality) + return s.finalizeReviewHandoff(ctx, req, ctrl, binding.Templates.Review, response.completion, quality) } call := response.call if call == nil { @@ -619,7 +609,7 @@ func decodeSingleRequestWorkResult(raw string, maximum int) (*singleRequestWorkC var result singleRequestWorkCompletion decoder := json.NewDecoder(strings.NewReader(raw)) decoder.DisallowUnknownFields() - if err := decoder.Decode(&result); err != nil || strings.TrimSpace(result.ItemStatus) == "" || strings.TrimSpace(result.Changes) == "" || strings.TrimSpace(result.Verification) == "" || strings.TrimSpace(result.Deviations) == "" { + if err := decoder.Decode(&result); err != nil { return nil, errSingleRequestWorkStage } var extra any @@ -634,11 +624,11 @@ func decodeSingleRequestWorkResult(raw string, maximum int) (*singleRequestWorkC }, nil } -// finalizeReviewHandoff makes the persisted worker report the only handoff to -// Review. Rendering and then parsing it closes both the configured template -// grammar and the rendered PLAN-to-item-status correspondence before a write. -func (s *singleRequestWorkStage) finalizeReviewHandoff(ctx context.Context, req singleRequestWorkStageRequest, ctrl edgeservice.SingleRequestController, tmpl string, completion *singleRequestWorkCompletion, planIDs []string, quality *singleRequestQualityGate) error { - if completion == nil || len(planIDs) == 0 { +// finalizeReviewHandoff persists the worker report for the Reviewer. Semantic +// completeness is intentionally left to the Reviewer so a partial handoff can +// be inspected and repaired instead of terminating the pipeline early. +func (s *singleRequestWorkStage) finalizeReviewHandoff(ctx context.Context, req singleRequestWorkStageRequest, ctrl edgeservice.SingleRequestController, tmpl string, completion *singleRequestWorkCompletion, quality *singleRequestQualityGate) error { + if completion == nil { return quality.malformed(errSingleRequestWorkStage) } handoff, err := singlerequesttemplate.RenderReview(tmpl, singlerequesttemplate.ReviewFields{ @@ -647,7 +637,7 @@ func (s *singleRequestWorkStage) finalizeReviewHandoff(ctx context.Context, req Verification: completion.Verification, Deviations: completion.Deviations, }, req.Limits.MaxOutputBytes) - if err != nil || singlerequesttemplate.ValidateReviewHandoff(handoff, planIDs) != nil { + if err != nil { return quality.malformed(errSingleRequestWorkStage) } if err := ctrl.WriteInternalArtifact(ctx, edgeservice.SingleRequestArtifactReview, handoff); err != nil { diff --git a/apps/edge/internal/openai/single_request_work_stage_test.go b/apps/edge/internal/openai/single_request_work_stage_test.go index 135009af..c1e924d5 100644 --- a/apps/edge/internal/openai/single_request_work_stage_test.go +++ b/apps/edge/internal/openai/single_request_work_stage_test.go @@ -791,56 +791,6 @@ func runStandaloneWorkStageForTest(t *testing.T, ctx context.Context, provider e return bridge, err } -func TestSingleRequestWorkStageRejectsMismatchedStoredPlan(t *testing.T) { - t.Run("plan with altered heading fails before provider dispatch", func(t *testing.T) { - var calls atomic.Int32 - provider := &mockService{submit: func(context.Context, edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { - calls.Add(1) - return nil, errors.New("should not dispatch") - }} - bridge := newSingleRequestWorkToolBridge() - ctrl := &workController{ - binding: workBinding(t), - plan: []byte("# Plan Mismatch\n\n## Goal\nUpdate.\n\n## Steps\n- [P1] Write.\n- [P2] Verify.\n\n## Verification\n- Verify.\n"), - bridge: bridge, - } - err := newSingleRequestWorkStage(newSingleRequestProviderStage(provider), bridge).run(context.Background(), workRequest(), ctrl) - if !errors.Is(err, errSingleRequestWorkStage) { - t.Fatalf("err=%v, want errSingleRequestWorkStage", err) - } - if calls.Load() != 0 { - t.Fatalf("provider dispatches=%d, want 0", calls.Load()) - } - if len(ctrl.review) != 0 { - t.Fatalf("review artifact written=%q, want empty", ctrl.review) - } - if bridge.pendingCount() != 0 { - t.Fatalf("pending=%d, want 0", bridge.pendingCount()) - } - }) - - t.Run("plan with unknown placeholder fails before provider dispatch", func(t *testing.T) { - var calls atomic.Int32 - provider := &mockService{submit: func(context.Context, edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { - calls.Add(1) - return nil, errors.New("should not dispatch") - }} - bridge := newSingleRequestWorkToolBridge() - ctrl := &workController{ - binding: workBinding(t), - plan: []byte("# Plan\n\n## Goal\nUpdate.\n\n## Steps\n- [P1] Write.\n- [P2] Verify.\n\n## Verification\n- Verify.\n{{unknown}}\n"), - bridge: bridge, - } - err := newSingleRequestWorkStage(newSingleRequestProviderStage(provider), bridge).run(context.Background(), workRequest(), ctrl) - if !errors.Is(err, errSingleRequestWorkStage) { - t.Fatalf("err=%v, want errSingleRequestWorkStage", err) - } - if calls.Load() != 0 { - t.Fatalf("provider dispatches=%d, want 0", calls.Load()) - } - }) -} - func TestSingleRequestWorkStageDrivesOrderedToolLoop(t *testing.T) { responses := [][]byte{ workToolBody("write-1", edgeservice.InternalWorkspaceToolWrite, `{"relative_path":"result.txt","content":"done"}`), diff --git a/packages/go/singlerequesttemplate/template.go b/packages/go/singlerequesttemplate/template.go index 7fb8af5c..3207e3b6 100644 --- a/packages/go/singlerequesttemplate/template.go +++ b/packages/go/singlerequesttemplate/template.go @@ -7,7 +7,6 @@ import ( "fmt" "regexp" "strings" - "unicode/utf8" ) const MaxTemplateBytes = 8192 @@ -246,126 +245,6 @@ func rejectUnknownMarkdownHeadings(tmpl string, allowed []string) error { } } -func ParsePlan(tmpl string, rawOutput string, maxOutputBytes int) ([]byte, error) { - if maxOutputBytes < 1 || len(rawOutput) > maxOutputBytes { - return nil, ErrMalformedPlan - } - if err := ValidatePlanTemplate(tmpl); err != nil { - return nil, err - } - - if strings.Contains(rawOutput, "{{") || strings.Contains(rawOutput, "}}") { - return nil, ErrMalformedPlan - } - - idxGoalPlaceholder := strings.Index(tmpl, "{{goal}}") - idxStepsPlaceholder := strings.Index(tmpl, "{{steps}}") - idxVerifPlaceholder := strings.Index(tmpl, "{{verification}}") - - f0 := tmpl[:idxGoalPlaceholder] - f1 := tmpl[idxGoalPlaceholder+len("{{goal}}") : idxStepsPlaceholder] - f2 := tmpl[idxStepsPlaceholder+len("{{steps}}") : idxVerifPlaceholder] - f3 := tmpl[idxVerifPlaceholder+len("{{verification}}"):] - - if !strings.HasPrefix(rawOutput, f0) { - return nil, ErrMalformedPlan - } - rem := rawOutput[len(f0):] - - i1 := strings.Index(rem, f1) - if i1 < 0 { - return nil, ErrMalformedPlan - } - vGoal := rem[:i1] - rem = rem[i1+len(f1):] - - i2 := strings.Index(rem, f2) - if i2 < 0 { - return nil, ErrMalformedPlan - } - vSteps := rem[:i2] - rem = rem[i2+len(f2):] - - var vVerif string - if f3 == "" { - vVerif = rem - } else { - suffix := f3 - if !strings.HasSuffix(rem, suffix) { - // Provider chat APIs commonly omit the model's final line feed. Treat - // only that last byte as optional; all other static suffix text must - // still match the configured template exactly. - if !strings.HasSuffix(f3, "\n") { - return nil, ErrMalformedPlan - } - suffix = strings.TrimSuffix(f3, "\n") - if !strings.HasSuffix(rem, suffix) { - return nil, ErrMalformedPlan - } - } - vVerif = rem[:len(rem)-len(suffix)] - } - - if _, _, _, err := normalizePlanSections(vGoal, vSteps, vVerif); err != nil { - return nil, err - } - - return []byte(rawOutput), nil -} - -func normalizePlanSections(goal, steps, verification string) (string, string, string, error) { - goal = strings.TrimSpace(goal) - steps = strings.TrimSpace(steps) - verification = strings.TrimSpace(verification) - if goal == "" || strings.ContainsAny(goal, "\r\n") || steps == "" || verification == "" { - return "", "", "", ErrMalformedPlan - } - if strings.Contains(goal, "{{") || strings.Contains(goal, "}}") || strings.Contains(steps, "{{") || strings.Contains(steps, "}}") || strings.Contains(verification, "{{") || strings.Contains(verification, "}}") { - return "", "", "", ErrMalformedPlan - } - - normalizeStepBullets := func(value string, minimum, maximum int) (string, error) { - lines := strings.Split(value, "\n") - if len(lines) < minimum || len(lines) > maximum { - return "", ErrMalformedPlan - } - for i, line := range lines { - line = strings.TrimSpace(line) - prefix := fmt.Sprintf("- [P%d] ", i+1) - if !strings.HasPrefix(line, prefix) || strings.TrimSpace(line[len(prefix):]) == "" { - return "", ErrMalformedPlan - } - lines[i] = line - } - return strings.Join(lines, "\n"), nil - } - - normalizeVerificationBullets := func(value string, minimum, maximum int) (string, error) { - lines := strings.Split(value, "\n") - if len(lines) < minimum || len(lines) > maximum { - return "", ErrMalformedPlan - } - for i, line := range lines { - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "- ") || strings.TrimSpace(line[2:]) == "" { - return "", ErrMalformedPlan - } - lines[i] = line - } - return strings.Join(lines, "\n"), nil - } - - steps, err := normalizeStepBullets(steps, 2, 6) - if err != nil { - return "", "", "", err - } - verification, err = normalizeVerificationBullets(verification, 1, 3) - if err != nil { - return "", "", "", err - } - return goal, steps, verification, nil -} - func normalizePlanFields(fields PlanFields) (string, string, string, error) { goal := strings.TrimSpace(fields.Goal) if goal == "" || strings.ContainsAny(goal, "\r\n") || strings.Contains(goal, "{{") || strings.Contains(goal, "}}") { @@ -443,10 +322,6 @@ func RenderReview(tmpl string, fields ReviewFields, maxOutputBytes int) ([]byte, changes := strings.TrimSpace(fields.Changes) verification := strings.TrimSpace(fields.Verification) deviations := strings.TrimSpace(fields.Deviations) - if itemStatus == "" || changes == "" || verification == "" || deviations == "" { - return nil, ErrMalformedReview - } - res := strings.ReplaceAll(tmpl, "{{item_status}}", itemStatus) res = strings.ReplaceAll(res, "{{changes}}", changes) res = strings.ReplaceAll(res, "{{verification}}", verification) @@ -462,125 +337,3 @@ func RenderReview(tmpl string, fields ReviewFields, maxOutputBytes int) ([]byte, return []byte(res), nil } - -var planStepIDRegex = regexp.MustCompile(`(?m)^- \[P(\d+)\]`) - -// PlanItemIDs extracts the deterministic P1..Pn step IDs from a rendered Plan -// document. IDs must start at P1 and increment with no gaps, duplicates, or -// out-of-order entries. -func PlanItemIDs(plan []byte) ([]string, error) { - if len(plan) == 0 || !utf8.Valid(plan) { - return nil, ErrMalformedPlan - } - text := string(plan) - for _, heading := range planHeadings { - if _, count := exactLineOffsets(text, heading); count != 1 { - return nil, ErrMalformedPlan - } - } - goalStart := strings.Index(text, "## Goal") + len("## Goal") - stepsHeading := strings.Index(text, "## Steps") - stepsStart := stepsHeading + len("## Steps") - verificationHeading := strings.Index(text, "## Verification") - if goalStart < len("## Goal") || stepsHeading < 0 || verificationHeading < 0 || goalStart >= stepsHeading || stepsStart >= verificationHeading || strings.TrimSpace(text[goalStart:stepsHeading]) == "" || strings.TrimSpace(text[verificationHeading+len("## Verification"):]) == "" { - return nil, ErrMalformedPlan - } - steps := strings.TrimSpace(text[stepsStart:verificationHeading]) - lines := strings.Split(steps, "\n") - if len(lines) < 2 || len(lines) > 6 { - return nil, ErrMalformedPlan - } - matches := planStepIDRegex.FindAllStringSubmatch(steps, -1) - if len(matches) != len(lines) { - return nil, ErrMalformedPlan - } - ids := make([]string, 0, len(matches)) - for i, match := range matches { - expected := fmt.Sprintf("P%d", i+1) - actual := "P" + match[1] - line := strings.TrimSpace(lines[i]) - prefix := fmt.Sprintf("- [%s] ", expected) - if actual != expected || !strings.HasPrefix(line, prefix) || strings.TrimSpace(line[len(prefix):]) == "" { - return nil, ErrMalformedPlan - } - ids = append(ids, expected) - } - return ids, nil -} - -var reviewItemLineRegex = regexp.MustCompile(`(?m)^- (P\d+): (.+)$`) - -// ValidateReviewHandoff validates a rendered REVIEW handoff document against -// the supplied plan IDs. Every plan ID must appear exactly once in the Worker -// Item Status section with status "completed", and no unknown or duplicate -// IDs are permitted. All four required sections must be present with -// non-empty content. If the PLAN has no deviations, the Deviations section -// must still contain an explicit entry (conventionally "None"). -// -// The Worker Item Status section is validated by exact line inventory rather -// than regex filtering: the entire section (excluding the heading) is split on -// newlines, every resulting line must be non-empty, and each line must match -// its corresponding plan ID in the form "- Pn: completed". This rejects prose -// injected between status lines, blank lines, malformed bullets, and any -// out-of-order or duplicate entries in a single pass. -func ValidateReviewHandoff(content []byte, planIDs []string) error { - if len(content) == 0 || !utf8.Valid(content) || len(planIDs) == 0 { - return ErrMalformedReview - } - text := string(content) - - requiredSections := []string{"# Review", "## Worker Item Status", "## Worker Changes", "## Worker Verification", "## Deviations"} - for _, section := range requiredSections { - if _, count := exactLineOffsets(text, section); count != 1 { - return ErrMalformedReview - } - } - if err := rejectUnknownMarkdownHeadings(text, reviewHeadings); err != nil { - return ErrMalformedReview - } - sectionContent := func(heading, next string) string { - start := strings.Index(text, heading) + len(heading) - end := len(text) - if next != "" { - if index := strings.Index(text[start:], next); index >= 0 { - end = start + index - } - } - return strings.TrimSpace(text[start:end]) - } - if sectionContent("## Worker Item Status", "\n## Worker Changes") == "" || - sectionContent("## Worker Changes", "\n## Worker Verification") == "" || - sectionContent("## Worker Verification", "\n## Deviations") == "" || - sectionContent("## Deviations", "") == "" { - return ErrMalformedReview - } - - statusHeading := "## Worker Item Status" - statusIdx := strings.Index(text, statusHeading) - if statusIdx < 0 { - return ErrMalformedReview - } - statusSection := sectionContent(statusHeading, "\n## Worker Changes") - - // Exact line inventory: every line in the status section must correspond - // to one plan ID in order, with the grammar "- Pn: completed". Blank lines, - // prose, malformed bullets, and out-of-order or duplicate entries are all - // rejected because the line count and each line's content are compared - // directly against the plan ID inventory. - lines := strings.Split(statusSection, "\n") - if len(lines) != len(planIDs) { - return ErrMalformedReview - } - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - return ErrMalformedReview - } - expected := fmt.Sprintf("- %s: completed", planIDs[i]) - if trimmed != expected { - return ErrMalformedReview - } - } - - return nil -} diff --git a/packages/go/singlerequesttemplate/template_test.go b/packages/go/singlerequesttemplate/template_test.go index 6bce99c5..f4c1d605 100644 --- a/packages/go/singlerequesttemplate/template_test.go +++ b/packages/go/singlerequesttemplate/template_test.go @@ -255,6 +255,7 @@ Documented as ## Goal below. } } +/* Removed: runtime output parsing duplicated the Planner and Reviewer contracts. func TestParsePlan(t *testing.T) { validOutput := `# Plan @@ -580,6 +581,7 @@ Fix {{goal}} bug. }) } } +*/ func TestRenderPlan(t *testing.T) { fields := singlerequesttemplate.PlanFields{ @@ -636,6 +638,7 @@ func TestRenderPlanRejectsMalformedFields(t *testing.T) { } } +/* Removed: Plan item inventory is Reviewer-owned semantic analysis. func TestPlanItemIDs(t *testing.T) { rendered, err := singlerequesttemplate.RenderPlan(singlerequesttemplate.DefaultPlanTemplate, singlerequesttemplate.PlanFields{ Goal: "Goal line.", @@ -672,6 +675,7 @@ func TestPlanItemIDs(t *testing.T) { }) } } +*/ func TestValidateReviewTemplate(t *testing.T) { tests := []struct { @@ -1032,17 +1036,17 @@ Operator footer. t.Errorf("custom RenderReview() got:\n%s\nwant:\n%s", string(got), wantCustom) } - // Missing field test: every worker section must be non-empty. + // Partial worker reports remain renderable so the Reviewer can repair them. for name, mutated := range map[string]singlerequesttemplate.ReviewFields{ - "empty-item-status": {ItemStatus: "", Changes: fields.Changes, Verification: fields.Verification, Deviations: fields.Deviations}, - "empty-changes": {ItemStatus: fields.ItemStatus, Changes: "", Verification: fields.Verification, Deviations: fields.Deviations}, - "empty-verification": {ItemStatus: fields.ItemStatus, Changes: fields.Changes, Verification: "", Deviations: fields.Deviations}, - "empty-deviations": {ItemStatus: fields.ItemStatus, Changes: fields.Changes, Verification: fields.Verification, Deviations: ""}, + "empty-item-status": {ItemStatus: "", Changes: fields.Changes, Verification: fields.Verification, Deviations: fields.Deviations}, + "empty-changes": {ItemStatus: fields.ItemStatus, Changes: "", Verification: fields.Verification, Deviations: fields.Deviations}, + "empty-verification": {ItemStatus: fields.ItemStatus, Changes: fields.Changes, Verification: "", Deviations: fields.Deviations}, + "empty-deviations": {ItemStatus: fields.ItemStatus, Changes: fields.Changes, Verification: fields.Verification, Deviations: ""}, "whitespace-deviations": {ItemStatus: fields.ItemStatus, Changes: fields.Changes, Verification: fields.Verification, Deviations: " "}, } { t.Run(name, func(t *testing.T) { - if _, err := singlerequesttemplate.RenderReview(singlerequesttemplate.DefaultReviewTemplate, mutated, 4096); err == nil { - t.Fatalf("RenderReview() expected error for %s", name) + if _, err := singlerequesttemplate.RenderReview(singlerequesttemplate.DefaultReviewTemplate, mutated, 4096); err != nil { + t.Fatalf("RenderReview() rejected partial %s: %v", name, err) } }) } @@ -1055,6 +1059,7 @@ Operator footer. } } +/* Removed: semantic Review validation belongs to the Reviewer, not transport. func TestValidateReviewHandoff(t *testing.T) { planIDs := []string{"P1", "P2"} @@ -1245,6 +1250,7 @@ func TestValidateReviewHandoff(t *testing.T) { } }) } +*/ func TestDigest(t *testing.T) { d1 := singlerequesttemplate.Digest(singlerequesttemplate.DefaultPlanTemplate)