diff --git a/agent-contract/outer/anthropic-compatible-api.md b/agent-contract/outer/anthropic-compatible-api.md index be406b9e..edb10c35 100644 --- a/agent-contract/outer/anthropic-compatible-api.md +++ b/agent-contract/outer/anthropic-compatible-api.md @@ -116,7 +116,11 @@ from the operator's `execution_presets[].single_request.templates` configuration `agent-contract/inner/edge-config-runtime-refresh.md`). These templates are Edge-owned internal stage inputs only: -- The Plan stage requests a stage-owned strict JSON object with a one-line `goal` +- The Plan stage first exposes only the admitted `workspace_read` and + `workspace_list` operations and requires at least one successful inspection + result before Plan completion is eligible. The bounded tool result remains in + the request-local Plan conversation; no separate analysis artifact is created. + 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. @@ -196,9 +200,10 @@ only in its supervised child so the single observed Messages ingress is the actu Gemini Plan and Review additionally admit only the exact OpenAI-compatible thought signature shape `extra_content.google.thought_signature`, with a non-empty string and no sibling extension members. A terminal text signature is discarded. When Review -receives a workspace tool call, its tool-call signature is retained only in request-local -memory and replayed unchanged in the immediately resumed Gemini assistant tool-call -message; it is absent from Work, artifacts, caller output, logs, and durable evidence. +or Plan receives a workspace tool call, its tool-call signature is retained only in +request-local memory and replayed unchanged in the immediately resumed Gemini +assistant tool-call message; it is absent from Work, artifacts, caller output, logs, +and durable evidence. Provider/tool timeouts, exhausted stage/request budgets, first proven repeated action/result no-progress, malformed calls/results, provider context/output limits, diff --git a/agent-spec/input/openai-compatible-surface.md b/agent-spec/input/openai-compatible-surface.md index 4b4c0636..3b6f862e 100644 --- a/agent-spec/input/openai-compatible-surface.md +++ b/agent-spec/input/openai-compatible-surface.md @@ -197,7 +197,7 @@ Edge가 OpenAI-compatible HTTP 요청을 받아 내부 `adapter + target` 실행 | 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. | | 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 obtains a strict one-line `goal` plus bounded one-line `steps`/`verification` arrays, and Edge owns the bullet formatting and renders `plan.md`, while 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 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. | | marked single-request S11 terminal policy | The service freezes one closed `end_turn`, `length`, `error`, or `cancelled` disposition. `error` classes are provider, validation, timeout, budget, repetition, malformed, context, internal-tool, and workspace-cleanup. Buffered and SSE share one projection: `end_turn`; `max_tokens` with no private partial output; `400 invalid_request_error` for validation/context; `502 api_error` for other failures; and silent cancellation after caller disconnect. No terminal classification retries, falls back, opens a second request, or later writes success. | | marked single-request observation evidence | A single real POST links ingress=1, request-total=1, terminal=1, stage/tool/cleanup counts, and raw-free correlation without public tool protocol. `iop_anthropic_single_request_ingress_total` is unlabeled (no request_id, stage_id, provider identity, or content). Internal tool names, raw arguments, private results, and workspace references are absent from the public terminal and from log projections. Stage-pure timing, cardinality-bounded labels, and privacy semantics are documented here. SDD S12 qualified an external Claude task on an approved IOP Node with ingress `0→1`, Gemini → ornith-fast → Gemini, one `end_turn`, the exact expected workspace result, timing, cleanup, and redacted evidence. | @@ -357,6 +357,7 @@ sequenceDiagram ## 변경 기록 +- 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. - 2026-08-14: Synchronized marked single-request artifact-only PLAN→Work→REVIEW→Review handoff, reviewer-owned repair/re-verification, reviewer zero-write, and strict terminal output provenance. - 2026-08-14: Added operation-scoped `normalization.tool_calls` and Gemini-only Chat thought-signature round trips across standard OpenAI-compatible callers, including non-stream, SSE, and recovery-selected dispatches. Effort mapping and caller identity remain independent (`packages/go/config/protocol_profile.go`, `apps/edge/internal/openai/provider_model_rewrite.go`). - 2026-08-13: Added official agy 1.1.12 model-role `functionResponse` continuation support while retaining fail-closed rejection for mixed assistant/tool-response content (`apps/edge/internal/openai/gemini_handler.go`). diff --git a/agent-spec/runtime/edge-node-execution.md b/agent-spec/runtime/edge-node-execution.md index f1946af5..8ab25fe0 100644 --- a/agent-spec/runtime/edge-node-execution.md +++ b/agent-spec/runtime/edge-node-execution.md @@ -80,10 +80,10 @@ source_evidence: notes: Exact-generation dispatch, frozen request-authority construction, closed artifact response validation, bounds, and stable failure translation - type: code path: apps/edge/internal/openai/single_request_plan_stage.go - notes: Private fixed Plan stage runner, strict structured field response, deterministic frozen-template rendering, and PLAN artifact write + notes: Private Plan runner with bounded read/list-first workspace inspection, strict structured field response, deterministic frozen-template rendering, and PLAN artifact write - type: test path: apps/edge/internal/openai/single_request_plan_stage_test.go - notes: Deterministic Plan request/options/envelope/artifact evidence + notes: Deterministic read/list-first Plan analysis, request/options/envelope/artifact evidence - type: code path: packages/go/singlerequesttemplate/template.go notes: Bounded Plan/Review template grammar with exact standalone heading and PASS lines, closed placeholder inventory, deterministic Plan rendering, legacy PlanMD parsing, and Review rendering @@ -228,7 +228,7 @@ The shared `packages/go/execution` package contains provider lifecycle, registry | workspace tool executor | A validated `darwin|linux` Node catalog owns opened root and directory handles only when every entry platform matches the host exactly. Windows, unknown hosts, and cross-platform catalogs fail before root open; empty catalogs remain compatible. Go 1.24-compatible no-follow file primitives provide bounded read, bounded list, structured write, and non-recursive delete. Exact operator-owned command templates run through an inherited-root `fchdir`/`exec` shim with minimal allowlisted environment, shared stdout/stderr bounds, process-group timeout/cancel, and stable typed results. OS is runtime evidence rather than a caller-visible selector. | | internal workspace tool loop | The service decodes only `workspace_read`, `workspace_list`, `workspace_write`, `workspace_delete`, and `workspace_command`, opens the admitted workspace once, dispatches one call at a time on the frozen generation, and delivers one deep-copied typed result to the emitting executor continuation. Unique request/stage/tool correlation, per-stage iteration/output/deadline limits, request wall-clock budget, and typed cancel fail closed without external continuation or reselection. | | request-owned internal artifacts | `SingleRequestController` exposes closed plan/review read/write operations. Artifact calls and model workspace tools share one serialized lazy `WorkspaceOpen`, the exact admitted Node generation, the active stage deadline, the immutable output bound, in-flight work accounting, and one terminal cleanup. Node alone maps selectors to `plan.md` and `review.md`, and inventoried descriptor-relative reads fail closed on identity replacement. | -| Plan stage | The Plan runner validates the frozen effective template, emits the `planning` envelope, sends the immutable task through the frozen Plan binding with `reasoning_effort=high` and a stage-owned strict JSON schema for one-line `goal` plus bounded one-line `steps`/`verification` arrays, validates the fields, adds Markdown bullets, renders the template deterministically inside Edge, and writes the resulting Markdown through `SingleRequestArtifactPlan`. | +| Plan stage | The Plan runner validates the frozen effective template, emits the `planning` envelope, and sends the immutable task through the frozen Plan binding with `reasoning_effort=high`. Its first provider turn requires an admitted `workspace_read` or `workspace_list`; only a successful inspection result makes Plan completion eligible. The result stays in the request-local conversation and does not create another artifact. The runner then accepts the stage-owned strict JSON schema for one-line `goal` plus bounded one-line `steps`/`verification` arrays, validates the fields, adds Markdown bullets, renders the template deterministically inside Edge, and writes the resulting Markdown through `SingleRequestArtifactPlan`. | | single-request provider normalization | Private Plan/Work/Review calls pass caller-neutral effort/tool/structured-output requirements to the selected protocol profile. The profile chooses Chat Completions or Responses and maps unsupported effort only downward. An explicit managed selector freezes the exact provider ID; `default` freezes no provider ID and accepts the provider pool's concrete choice while retaining exact model-group/profile/target/credential/tunnel fences. Chat and Responses provider results are both reduced to one canonical private Chat-shaped envelope before strict stage decoding. No new Edge-Node field is added: the selected operation continues through the existing provider tunnel operation field. | | single-request effective templates | `execution_presets[].single_request.templates` optionally loads `plan_file`/`review_file` as bounded Markdown relative to the directory containing `edge.yaml`; absolute and empty paths, non-regular files, oversize (`>8192` bytes), non-UTF-8, and invalid grammar fail closed at load, and each file falls back to its built-in default independently. Admission freezes the effective Plan/Review pair into the binding, so a later refresh reaches only newly admitted requests. Templates select internal stage input and internal artifact shape only; caller request/response schemas are unchanged. | | Work and Review handoff | Work parses the stored PLAN with deterministic `P1..Pn` IDs, projects only admitted workspace tools, and writes exactly one validated REVIEW handoff containing item status, changes, verification, and deviations. Review rereads both artifacts before provider dispatch, has no memory work payload, performs any repair/re-verification in the request-local ledger, and writes no final REVIEW page. | @@ -256,7 +256,7 @@ The shared `packages/go/execution` package contains provider lifecycle, registry - The service freezes the first public terminal candidate. Legacy successful results normalize to `end_turn`; output limits produce `length`; caller disconnect produces silent `cancelled`; validation/context become `invalid_request_error`; other errors become `api_error`. Buffered and SSE projectors share that policy, emit at most one terminal, and never expose private partial stage content for `length`. This completes deterministic S11 `error-cancel` evidence without changing the Edge-Node protobuf wire. S12 external Claude qualification on an approved IOP Node is complete. - 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, accepts only `RESPONSE_START`, zero or more `BODY`, then `END`, and projects all provider failures to a generic internal failure. 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 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 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. @@ -345,7 +345,7 @@ Heartbeat interval/wait는 protobuf field가 아닌 양쪽 transport 구현의 l - `go test -count=1 ./apps/edge/internal/service -run '^TestProviderHealthObservability'` — deterministic Edge overlay evidence/transition with closed label values and identity exclusion; `TestProviderHealthObservabilityDoesNotExposeSentinels` covers the sentinel/prohibited-value guard. - `go test -count=1 ./apps/edge/internal/openai -run '^(TestOpenAILivenessObservationSink|TestOpenAILivenessRecoveryObservability)$'` — deterministic OpenAI recovery eligibility/results with closed label values and identifier exclusion. - `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 high reasoning, the closed structured response schema, exact JSON field admission, ordered tunnel frames, deterministic frozen-template rendering, planning envelope, and `plan.md` artifact selection. +- `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 ./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. @@ -367,6 +367,7 @@ Heartbeat interval/wait는 protobuf field가 아닌 양쪽 transport 구현의 l ## 변경 기록 +- 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. - 2026-08-14: Added common Chat result normalization for private stages so standard OpenAI bookkeeping fields are removed before strict decoding, matching the existing Responses-to-common conversion while preserving fail-closed refusal and unknown-field handling. diff --git a/apps/edge/internal/openai/single_request_executor.go b/apps/edge/internal/openai/single_request_executor.go index d045a158..e0c08ff0 100644 --- a/apps/edge/internal/openai/single_request_executor.go +++ b/apps/edge/internal/openai/single_request_executor.go @@ -27,7 +27,7 @@ func NewSingleRequestExecutor(service edgeserviceRunner) *SingleRequestExecutor return &SingleRequestExecutor{ provider: provider, bridge: bridge, - plan: newSingleRequestPlanStage(provider), + plan: newSingleRequestPlanStage(provider, bridge), work: newSingleRequestWorkStage(provider, bridge), review: newSingleRequestReviewStage(provider, bridge), } diff --git a/apps/edge/internal/openai/single_request_executor_test.go b/apps/edge/internal/openai/single_request_executor_test.go index 9c7c077e..da49d540 100644 --- a/apps/edge/internal/openai/single_request_executor_test.go +++ b/apps/edge/internal/openai/single_request_executor_test.go @@ -101,6 +101,20 @@ func executorPlanBody(goal, verification string) []byte { return successBodyWithThoughtSignature(string(b)) } +func executorPlanInspectionBody(id string) []byte { + return workToolBody(id, edgeservice.InternalWorkspaceToolRead, `{"relative_path":"README.md"}`) +} + +func executorPlanStageResponse(body, id, goal, verification string) ([]byte, bool) { + if !strings.Contains(body, "Inspect the workspace before planning") { + return nil, false + } + if strings.Contains(body, `"role":"tool"`) { + return executorPlanBody(goal, verification), true + } + return executorPlanInspectionBody(id), true +} + func executorWorkBody(completion, verification string) []byte { b, _ := json.Marshal(map[string]any{ "item_status": "- P1: completed\n- P2: completed", @@ -130,6 +144,7 @@ func TestSingleRequestExecutorInterface(t *testing.T) { func TestSingleRequestExecutorPass(t *testing.T) { responses := [][]byte{ + executorPlanInspectionBody("plan-pass-1"), executorPlanBody("Execute step 1", "Verify step 1"), workToolBody("work-pass-1", edgeservice.InternalWorkspaceToolRead, `{"relative_path":"output.txt"}`), executorWorkBody("Completed work step 1", "Verified work step 1"), @@ -181,6 +196,7 @@ func TestSingleRequestExecutorPass(t *testing.T) { func TestSingleRequestExecutorInspection(t *testing.T) { responses := [][]byte{ + executorPlanInspectionBody("plan-inspect-1"), executorPlanBody("Plan inspect", "Verify plan"), workToolBody("work-inspect-1", edgeservice.InternalWorkspaceToolRead, `{"relative_path":"output.txt"}`), executorWorkBody("Work done", "Work verified"), @@ -229,6 +245,7 @@ func TestSingleRequestExecutorInspection(t *testing.T) { func TestSingleRequestExecutorRepair(t *testing.T) { responses := [][]byte{ + executorPlanInspectionBody("plan-repair-1"), executorPlanBody("Plan repair", "Verify plan"), workToolBody("work-repair-1", edgeservice.InternalWorkspaceToolRead, `{"relative_path":"output.txt"}`), executorWorkBody("Work initial", "Work initial verify"), @@ -309,8 +326,8 @@ func TestSingleRequestExecutorConcurrentToolIsolation(t *testing.T) { bodyStr := string(reqBody) var resp []byte - if strings.Contains(bodyStr, "Create a concise plan for the task") { - resp = executorPlanBody(fmt.Sprintf("Plan for %s", reqID), fmt.Sprintf("Verify plan for %s", reqID)) + if planResp, ok := executorPlanStageResponse(bodyStr, "plan-inspect-"+reqID, fmt.Sprintf("Plan for %s", reqID), fmt.Sprintf("Verify plan for %s", reqID)); ok { + resp = planResp } else if strings.Contains(bodyStr, "Read the supplied plan") { if !strings.Contains(bodyStr, "typed-result-") { resp = workToolBody("colliding-tool-id", edgeservice.InternalWorkspaceToolRead, fmt.Sprintf(`{"relative_path":"output-%s.txt"}`, reqID)) @@ -354,6 +371,12 @@ func TestSingleRequestExecutorConcurrentToolIsolation(t *testing.T) { svc, binding, nodeHarness := newTestServiceHarness(t, executor) nodeHarness.toolResponder = func(req *iop.WorkspaceToolRequest) *iop.WorkspaceToolResponse { + if req.GetStageId() == singleRequestPlanStageID { + return &iop.WorkspaceToolResponse{ + RequestId: req.GetRequestId(), StageId: req.GetStageId(), ToolCallId: req.GetToolCallId(), + Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS, Content: []byte("plan-inspection-" + req.GetRequestId()), + } + } toolArrived <- req.GetRequestId() <-releaseToolResponses return &iop.WorkspaceToolResponse{ @@ -441,17 +464,17 @@ func TestSingleRequestExecutorConcurrentToolIsolation(t *testing.T) { } reqs := nodeHarness.toolRequestsByRequest[reqID] - if len(reqs) != 1 { - t.Errorf("request %s tool requests count = %d, want 1", reqID, len(reqs)) - } else if reqs[0].GetToolCallId() != "colliding-tool-id" { - t.Errorf("request %s tool call ID = %q, want colliding-tool-id", reqID, reqs[0].GetToolCallId()) + if len(reqs) != 2 { + t.Errorf("request %s tool requests count = %d, want 2", reqID, len(reqs)) + } else if reqs[0].GetStageId() != singleRequestPlanStageID || reqs[1].GetToolCallId() != "colliding-tool-id" { + t.Errorf("request %s tool requests = %+v, want plan inspection then colliding work call", reqID, reqs) } resps := nodeHarness.toolResponsesByRequest[reqID] - if len(resps) != 1 { - t.Errorf("request %s tool responses count = %d, want 1", reqID, len(resps)) + if len(resps) != 2 { + t.Errorf("request %s tool responses count = %d, want 2", reqID, len(resps)) } else { - gotResult := string(resps[0].GetContent()) + gotResult := string(resps[1].GetContent()) wantResult := fmt.Sprintf("typed-result-%s", reqID) if gotResult != wantResult { t.Errorf("request %s result = %q, want %q", reqID, gotResult, wantResult) @@ -621,11 +644,16 @@ func TestSingleRequestExecutorRequestBudgetOwnership(t *testing.T) { func TestSingleRequestExecutorStageFailures(t *testing.T) { t.Run("PlanFailure", func(t *testing.T) { + var callCount atomic.Int32 mockSvc := &mockService{ submit: func(_ context.Context, _ edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { + body := successBody("invalid plan json") + if callCount.Add(1) == 1 { + body = executorPlanInspectionBody("plan-failure-inspect") + } return &edgeservice.ProviderPoolDispatchResult{ Path: edgeservice.ProviderPoolPathTunnel, - Tunnel: &mockTunnel{frames: framesFor(successBody("invalid plan json"))}, + Tunnel: &mockTunnel{frames: framesFor(body)}, DispatchInfo: matchingDispatch(), }, nil }, @@ -652,6 +680,11 @@ func TestSingleRequestExecutorStageFailures(t *testing.T) { submit: func(_ context.Context, _ edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { count := callCount.Add(1) if count == 1 { + return &edgeservice.ProviderPoolDispatchResult{ + Path: edgeservice.ProviderPoolPathTunnel, Tunnel: &mockTunnel{frames: framesFor(executorPlanInspectionBody("work-failure-plan-inspect"))}, DispatchInfo: matchingDispatch(), + }, nil + } + if count == 2 { return &edgeservice.ProviderPoolDispatchResult{ Path: edgeservice.ProviderPoolPathTunnel, Tunnel: &mockTunnel{frames: framesFor(executorPlanBody("Step 1", "Verify 1"))}, @@ -687,20 +720,25 @@ func TestSingleRequestExecutorStageFailures(t *testing.T) { submit: func(_ context.Context, _ edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { count := callCount.Add(1) if count == 1 { + return &edgeservice.ProviderPoolDispatchResult{ + Path: edgeservice.ProviderPoolPathTunnel, Tunnel: &mockTunnel{frames: framesFor(executorPlanInspectionBody("review-failure-plan-inspect"))}, DispatchInfo: matchingDispatch(), + }, nil + } + if count == 2 { return &edgeservice.ProviderPoolDispatchResult{ Path: edgeservice.ProviderPoolPathTunnel, Tunnel: &mockTunnel{frames: framesFor(executorPlanBody("Step 1", "Verify 1"))}, DispatchInfo: matchingDispatch(), }, nil } - if count == 2 { + if count == 3 { return &edgeservice.ProviderPoolDispatchResult{ Path: edgeservice.ProviderPoolPathTunnel, Tunnel: &mockTunnel{frames: framesFor(workToolBody("review-failure-work-1", edgeservice.InternalWorkspaceToolRead, `{"relative_path":"output.txt"}`))}, DispatchInfo: matchingDispatch(), }, nil } - if count == 3 { + if count == 4 { return &edgeservice.ProviderPoolDispatchResult{ Path: edgeservice.ProviderPoolPathTunnel, Tunnel: &mockTunnel{frames: framesFor(executorWorkBody("Work done", "Work verified"))}, @@ -733,6 +771,7 @@ func TestSingleRequestExecutorStageFailures(t *testing.T) { func TestSingleRequestExecutorFinalOutputProvenance(t *testing.T) { responses := [][]byte{ + executorPlanInspectionBody("plan-provenance-1"), executorPlanBody("Plan step", "Plan verify"), workToolBody("work-provenance-1", edgeservice.InternalWorkspaceToolRead, `{"relative_path":"output.txt"}`), executorWorkBody("UNAPPROVED WORK CANDIDATE OUTPUT", "Work verified"), @@ -778,8 +817,8 @@ func TestSingleRequestExecutorTerminalWaiterCleanup(t *testing.T) { bodyStr := string(reqBody) var resp []byte - if strings.Contains(bodyStr, "Create a concise plan for the task") { - resp = executorPlanBody("Plan step", "Verify plan") + if planResp, ok := executorPlanStageResponse(bodyStr, "plan-clean-success", "Plan step", "Verify plan"); ok { + resp = planResp } else if strings.Contains(bodyStr, "Read the supplied plan") { if !strings.Contains(bodyStr, "colliding-tool-id") { resp = workToolBody("colliding-tool-id", edgeservice.InternalWorkspaceToolRead, `{"relative_path":"output.txt"}`) @@ -832,8 +871,8 @@ func TestSingleRequestExecutorTerminalWaiterCleanup(t *testing.T) { bodyStr := string(reqBody) var resp []byte - if strings.Contains(bodyStr, "Create a concise plan for the task") { - resp = executorPlanBody("Plan step", "Verify plan") + if planResp, ok := executorPlanStageResponse(bodyStr, "plan-clean-failure", "Plan step", "Verify plan"); ok { + resp = planResp } else if strings.Contains(bodyStr, "Read the supplied plan") { if !strings.Contains(bodyStr, "colliding-tool-id") { resp = workToolBody("colliding-tool-id", edgeservice.InternalWorkspaceToolRead, `{"relative_path":"output.txt"}`) @@ -894,8 +933,8 @@ func TestSingleRequestExecutorTerminalWaiterCleanup(t *testing.T) { } var resp []byte - if strings.Contains(bodyStr, "Create a concise plan for the task") { - resp = executorPlanBody("Plan step for "+reqID, "Verify plan") + if planResp, ok := executorPlanStageResponse(bodyStr, "plan-clean-"+reqID, "Plan step for "+reqID, "Verify plan"); ok { + resp = planResp } else if strings.Contains(bodyStr, "Read the supplied plan") { if !strings.Contains(bodyStr, "colliding-tool-id") { resp = workToolBody("colliding-tool-id", edgeservice.InternalWorkspaceToolRead, `{"relative_path":"output.txt"}`) @@ -919,7 +958,7 @@ func TestSingleRequestExecutorTerminalWaiterCleanup(t *testing.T) { var once sync.Once nodeHarness.toolResponder = func(req *iop.WorkspaceToolRequest) *iop.WorkspaceToolResponse { - if req.GetRequestId() == "req-cancel-peer" { + if req.GetRequestId() == "req-cancel-peer" && req.GetStageId() == singleRequestWorkStageID { once.Do(func() { cancelWaiterRegistered <- struct{}{} }) diff --git a/apps/edge/internal/openai/single_request_plan_stage.go b/apps/edge/internal/openai/single_request_plan_stage.go index 343a0208..7874ea9d 100644 --- a/apps/edge/internal/openai/single_request_plan_stage.go +++ b/apps/edge/internal/openai/single_request_plan_stage.go @@ -1,22 +1,32 @@ package openai import ( + "bytes" "context" "encoding/json" "errors" + "io" + "net/http" + "strings" edgeservice "iop/apps/edge/internal/service" "iop/packages/go/singlerequesttemplate" ) -const singleRequestPlanPrompt = "Create a concise plan for the task. Return exactly one JSON object with a non-empty one-line goal string, a steps array containing 2 to 6 non-empty one-line strings, and a verification array containing 1 to 3 non-empty one-line strings. Do not include Markdown bullets, headings, or template text; Edge owns all Plan document formatting." +const ( + singleRequestPlanPrompt = "Inspect the workspace before planning. Use only the supplied read-only workspace tools and base the plan on the returned evidence. After at least one successful inspection, return exactly one JSON object with a non-empty one-line goal string, a steps array containing 2 to 6 non-empty one-line strings, and a verification array containing 1 to 3 non-empty one-line strings. Do not include Markdown bullets, headings, or template text; Edge owns all Plan document formatting." + singleRequestPlanStageID = "plan" +) var errSingleRequestPlanStage = errors.New("single-request plan stage: failed") -type singleRequestPlanStage struct{ provider *singleRequestProviderStage } +type singleRequestPlanStage struct { + provider *singleRequestProviderStage + bridge *singleRequestWorkToolBridge +} -func newSingleRequestPlanStage(provider *singleRequestProviderStage) *singleRequestPlanStage { - return &singleRequestPlanStage{provider: provider} +func newSingleRequestPlanStage(provider *singleRequestProviderStage, bridge *singleRequestWorkToolBridge) *singleRequestPlanStage { + return &singleRequestPlanStage{provider: provider, bridge: bridge} } type singleRequestPlanStageRequest struct { @@ -37,6 +47,11 @@ type singleRequestPlanFields struct { Verification []string `json:"verification"` } +type singleRequestPlanProviderResponse struct { + call *singleRequestReviewProviderToolCall + plan *singleRequestPlanFields +} + func (v *singleRequestPlanFields) UnmarshalJSON(data []byte) error { if err := validateSingleRequestObjectFields(data, "goal", "steps", "verification"); err != nil { return err @@ -76,42 +91,255 @@ func singleRequestPlanResponseFormat() *singleRequestProviderResponseFormat { func (s *singleRequestPlanStage) run(ctx context.Context, req singleRequestPlanStageRequest, ctrl edgeservice.SingleRequestController) ([]byte, error) { quality := singleRequestQualityGateOrNew(req.Quality) - if s == nil || s.provider == nil || ctrl == nil || req.RequestID == "" || req.Task == "" || req.Sequence == 0 { + if s == nil || s.provider == nil || s.provider.service == nil || s.bridge == nil || ctrl == nil || req.RequestID == "" || req.Task == "" || req.Sequence == 0 || req.StageBinding.Dispatch == nil { + return nil, quality.validation(errSingleRequestPlanStage) + } + if req.StageBinding.Options["reasoning_effort"] != "high" { return nil, quality.validation(errSingleRequestPlanStage) } binding := ctrl.Binding() - if binding == nil || binding.Templates.Plan == "" { + if binding == nil || binding.Workspace == nil || binding.Workspace.NodeID == "" || binding.Templates.Plan == "" || req.NodeRef != binding.Workspace.NodeID { return nil, quality.validation(errSingleRequestPlanStage) } if err := singlerequesttemplate.ValidatePlanTemplate(binding.Templates.Plan); err != nil { return nil, quality.validation(errSingleRequestPlanStage) } - if err := ctrl.SubmitEnvelope(edgeservice.SingleRequestEnvelope{RequestID: req.RequestID, Sequence: req.Sequence, Stage: edgeservice.SingleRequestStatePlanning}); err != nil { + tools, err := singleRequestPlanTools(binding.Workspace) + if err != nil { + return nil, quality.validation(errSingleRequestPlanStage) + } + sequence := req.Sequence + if err := ctrl.SubmitEnvelope(edgeservice.SingleRequestEnvelope{RequestID: req.RequestID, Sequence: sequence, Stage: edgeservice.SingleRequestStatePlanning}); err != nil { return nil, quality.serviceFailure(ctx, err, errSingleRequestPlanStage) } - response, err := s.provider.submit(ctx, singleRequestProviderStageRequest{ - StageBinding: req.StageBinding, Limits: req.Limits, NodeRef: req.NodeRef, SessionID: req.SessionID, UsageAttribution: req.UsageAttribution, Quality: quality, - Messages: []chatMessage{ - {Role: "system", Content: singleRequestPlanPrompt}, - {Role: "user", Content: req.Task}, - }, - ResponseFormat: singleRequestPlanResponseFormat(), - }) - if err != nil { - return nil, quality.reclassify(err, errSingleRequestPlanStage) + messages := []chatMessage{ + {Role: "system", Content: singleRequestPlanPrompt}, + {Role: "user", Content: strings.TrimSpace(req.Task)}, } - var fields singleRequestPlanFields - if err := json.Unmarshal([]byte(response.Output), &fields); err != nil { - return nil, quality.malformed(errSingleRequestPlanStage) + inspectionEligible := false + toolAttempts := 0 + for { + response, err := s.submit(ctx, req, messages, tools, inspectionEligible) + if err != nil { + return nil, quality.reclassify(err, errSingleRequestPlanStage) + } + if response.plan != nil { + if !inspectionEligible { + return nil, quality.malformed(errSingleRequestPlanStage) + } + content, err := singlerequesttemplate.RenderPlan(binding.Templates.Plan, singlerequesttemplate.PlanFields{ + Goal: response.plan.Goal, Steps: response.plan.Steps, Verification: response.plan.Verification, + }, req.Limits.MaxOutputBytes) + if err != nil { + return nil, quality.malformed(errSingleRequestPlanStage) + } + if err := ctrl.WriteInternalArtifact(ctx, edgeservice.SingleRequestArtifactPlan, content); err != nil { + return nil, quality.serviceFailure(ctx, err, errSingleRequestPlanStage) + } + return content, nil + } + if response.call == nil || toolAttempts >= req.Limits.MaxToolIterations { + if response.call != nil { + return nil, quality.budget(errSingleRequestPlanStage) + } + return nil, quality.malformed(errSingleRequestPlanStage) + } + if response.call.Function.Name != edgeservice.InternalWorkspaceToolRead && response.call.Function.Name != edgeservice.InternalWorkspaceToolList { + return nil, quality.malformed(errSingleRequestPlanStage) + } + arguments, err := decodeSingleRequestWorkToolArguments(response.call.Function.Arguments) + if err != nil { + return nil, quality.malformed(errSingleRequestPlanStage) + } + arguments = normalizeSingleRequestProviderToolArguments(response.call.Function.Name, arguments) + key := singleRequestWorkToolKey{requestID: req.RequestID, stageID: singleRequestPlanStageID, toolCallID: response.call.ID} + call := &edgeservice.InternalWorkspaceToolCall{RequestID: req.RequestID, StageID: key.stageID, ToolCallID: key.toolCallID, Name: response.call.Function.Name, Arguments: arguments} + if err := edgeservice.ValidateInternalWorkspaceToolCall(call); err != nil { + return nil, quality.malformed(errSingleRequestPlanStage) + } + toolAttempts++ + resultCh, err := s.bridge.register(key) + if err != nil { + return nil, quality.internalTool(errSingleRequestPlanStage) + } + sequence++ + if err := ctrl.SubmitEnvelope(edgeservice.SingleRequestEnvelope{RequestID: req.RequestID, Sequence: sequence, Stage: edgeservice.SingleRequestStateInternalTool, SavedStage: edgeservice.SingleRequestStatePlanning, ToolCall: call}); err != nil { + s.bridge.unregister(key) + return nil, quality.serviceFailure(ctx, err, errSingleRequestPlanStage) + } + result, err := s.bridge.wait(ctx, key, resultCh) + if err != nil { + return nil, quality.serviceFailure(ctx, err, errSingleRequestPlanStage) + } + if err := quality.observeToolCycle(singleRequestPlanStageID, response.call.Function.Name, arguments, result, errSingleRequestPlanStage); err != nil { + return nil, err + } + inspectionEligible = result.Status == "success" && result.ErrorCode == "" + messages = append(messages, + chatMessage{Role: "assistant", ToolCalls: []any{response.call.asChatToolCall()}}, + chatMessage{Role: "tool", ToolCallID: response.call.ID, ToolName: response.call.Function.Name, Content: singleRequestWorkToolResultContent(result, req.Limits.MaxOutputBytes)}, + ) + sequence++ + if err := ctrl.SubmitEnvelope(edgeservice.SingleRequestEnvelope{RequestID: req.RequestID, Sequence: sequence, Stage: edgeservice.SingleRequestStatePlanning, SavedStage: edgeservice.SingleRequestStatePlanning}); err != nil { + return nil, quality.serviceFailure(ctx, err, errSingleRequestPlanStage) + } } - content, err := singlerequesttemplate.RenderPlan(binding.Templates.Plan, singlerequesttemplate.PlanFields{ - Goal: fields.Goal, Steps: fields.Steps, Verification: fields.Verification, - }, req.Limits.MaxOutputBytes) - if err != nil { - return nil, quality.malformed(errSingleRequestPlanStage) - } - if err := ctrl.WriteInternalArtifact(ctx, edgeservice.SingleRequestArtifactPlan, content); err != nil { - return nil, quality.serviceFailure(ctx, err, errSingleRequestPlanStage) - } - return content, nil +} + +func singleRequestPlanTools(workspace *edgeservice.SingleRequestWorkspaceBinding) ([]any, error) { + if workspace == nil { + return nil, errSingleRequestPlanStage + } + has := func(operation string) bool { + for _, candidate := range workspace.OperationIDs { + if candidate == operation { + return true + } + } + return false + } + pathProperty := map[string]any{"type": "string", "minLength": 1, "description": singleRequestCanonicalRelativePathDescription} + parameters := map[string]any{"type": "object", "additionalProperties": false, "required": []string{"relative_path"}, "properties": map[string]any{"relative_path": pathProperty}} + tools := make([]any, 0, 2) + if has("read") { + tools = append(tools, singleRequestWorkToolSchema(edgeservice.InternalWorkspaceToolRead, parameters)) + } + if has("list") { + tools = append(tools, singleRequestWorkToolSchema(edgeservice.InternalWorkspaceToolList, parameters)) + } + if len(tools) == 0 { + return nil, errSingleRequestPlanStage + } + return tools, nil +} + +func (s *singleRequestPlanStage) submit(ctx context.Context, req singleRequestPlanStageRequest, messages []chatMessage, tools []any, inspectionEligible bool) (*singleRequestPlanProviderResponse, error) { + quality := singleRequestQualityGateOrNew(req.Quality) + dispatch := req.StageBinding.Dispatch + stageCtx, cancel := providerStageContext(ctx, req.Limits.StageTimeoutMS) + defer cancel() + poolReq := edgeservice.ProviderPoolDispatchRequest{ + Run: edgeservice.SubmitRunRequest{NodeRef: req.NodeRef, ModelGroupKey: dispatch.ModelGroupKey, ProviderID: dispatch.ProviderID, UsageAttribution: req.UsageAttribution, SessionID: req.SessionID, TimeoutSec: dispatch.TimeoutSec, MaxQueue: dispatch.MaxQueue, QueueTimeoutMS: dispatch.QueueTimeoutMS, ProviderPool: true}, + Tunnel: edgeservice.SubmitProviderTunnelRequest{CredentialBinding: dispatch.CredentialBindingSnapshot(), NodeRef: req.NodeRef, ModelGroupKey: dispatch.ModelGroupKey, ProviderID: dispatch.ProviderID, UsageAttribution: req.UsageAttribution, Adapter: "openai_compat", Target: dispatch.UpstreamModel, SessionID: req.SessionID, Method: http.MethodPost, Path: "/v1/chat/completions", Stream: false, TimeoutSec: dispatch.TimeoutSec, MaxQueue: dispatch.MaxQueue, QueueTimeoutMS: dispatch.QueueTimeoutMS, ProviderPool: true, BuildBody: func(target string) ([]byte, error) { + return buildSingleRequestPlanBody(messages, req.StageBinding.Options, tools, target, inspectionEligible) + }}, + } + var responseFormat *singleRequestProviderResponseFormat + if inspectionEligible { + responseFormat = singleRequestPlanResponseFormat() + } + requirements := singleRequestProviderRequirements(req.StageBinding.Options, tools, responseFormat) + poolReq.AcceptCandidate = singleRequestProviderCandidatePredicate(dispatch, requirements) + poolReq.PrepareProtocolTunnel = singleRequestProviderTunnelPreparer(requirements, func(target string) ([]byte, error) { + return buildSingleRequestPlanBody(messages, req.StageBinding.Options, tools, target, inspectionEligible) + }) + result, err := s.provider.service.SubmitProviderPool(stageCtx, poolReq) + if err != nil || result == nil || result.Tunnel == nil { + return nil, quality.providerFailure(stageCtx, err, errSingleRequestPlanStage) + } + if !providerStageDispatchMatches(result, dispatch) { + return nil, quality.validation(errSingleRequestPlanStage) + } + defer result.Tunnel.Close() + body, err := collectProviderStageFrames(stageCtx, result.Tunnel.Stream().Frames, req.Limits.MaxOutputBytes) + if err != nil { + return nil, quality.providerFailure(stageCtx, err, errSingleRequestPlanStage) + } + body, err = normalizeSingleRequestProviderResponse(body, result.DispatchInfo) + if err != nil { + return nil, quality.providerFailure(stageCtx, err, errSingleRequestPlanStage) + } + response, err := decodeSingleRequestPlanProviderResponse(body, req.Limits.MaxOutputBytes) + if err != nil { + if !errors.Is(err, errProviderStageOutputLimit) && !errors.Is(err, errProviderStageContextLimit) { + return nil, quality.malformed(errSingleRequestPlanStage) + } + return nil, quality.providerFailure(stageCtx, err, errSingleRequestPlanStage) + } + return response, nil +} + +func buildSingleRequestPlanBody(messages []chatMessage, options map[string]any, tools []any, target string, inspectionEligible bool) ([]byte, error) { + if target == "" || len(messages) == 0 || len(tools) == 0 || options["reasoning_effort"] != "high" { + return nil, errSingleRequestPlanStage + } + toolChoice := "required" + if inspectionEligible { + toolChoice = "auto" + } + body := map[string]any{"model": target, "messages": messages, "tools": tools, "tool_choice": toolChoice, "parallel_tool_calls": false, "stream": false} + if inspectionEligible { + body["response_format"] = singleRequestPlanResponseFormat() + } + for key, value := range options { + folded := strings.ToLower(key) + if isSingleRequestWorkReservedOption(folded) && key != folded { + return nil, errSingleRequestPlanStage + } + if isSingleRequestWorkReservedOption(folded) { + continue + } + body[key] = value + } + body["reasoning_effort"] = "high" + return json.Marshal(body) +} + +func decodeSingleRequestPlanProviderResponse(body []byte, maximum int) (*singleRequestPlanProviderResponse, error) { + if len(body) == 0 || len(body) > maximum || validateSingleRequestJSON(body) != nil { + return nil, errSingleRequestPlanStage + } + var envelope singleRequestReviewProviderEnvelope + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&envelope); err != nil || len(envelope.Choices) != 1 { + return nil, errSingleRequestPlanStage + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return nil, errSingleRequestPlanStage + } + choice := envelope.Choices[0] + message := choice.Message + if choice.Index != 0 || message.Role != "assistant" { + return nil, errSingleRequestPlanStage + } + if choice.FinishReason == "length" { + return nil, errors.Join(errSingleRequestPlanStage, errProviderStageOutputLimit) + } + if choice.FinishReason == "context_length" || choice.FinishReason == "context_length_exceeded" { + return nil, errors.Join(errSingleRequestPlanStage, errProviderStageContextLimit) + } + if choice.FinishReason == "tool_calls" && message.Content == nil && len(message.ToolCalls) == 1 { + call := message.ToolCalls[0] + if call.ID == "" || call.Type != "function" || call.Function.Name == "" || call.Function.Arguments == "" { + return nil, errSingleRequestPlanStage + } + return &singleRequestPlanProviderResponse{call: &call}, nil + } + if choice.FinishReason == "stop" && message.Content != nil && len(message.ToolCalls) == 0 { + plan, err := decodeSingleRequestPlanFields(*message.Content, maximum) + if err != nil { + return nil, errSingleRequestPlanStage + } + return &singleRequestPlanProviderResponse{plan: plan}, nil + } + return nil, errSingleRequestPlanStage +} + +func decodeSingleRequestPlanFields(raw string, maximum int) (*singleRequestPlanFields, error) { + if len(raw) == 0 || len(raw) > maximum || validateSingleRequestJSON([]byte(raw)) != nil { + return nil, errSingleRequestPlanStage + } + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.DisallowUnknownFields() + var fields singleRequestPlanFields + if err := decoder.Decode(&fields); err != nil { + return nil, errSingleRequestPlanStage + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return nil, errSingleRequestPlanStage + } + return &fields, nil } diff --git a/apps/edge/internal/openai/single_request_plan_stage_test.go b/apps/edge/internal/openai/single_request_plan_stage_test.go index 567b069b..9504f765 100644 --- a/apps/edge/internal/openai/single_request_plan_stage_test.go +++ b/apps/edge/internal/openai/single_request_plan_stage_test.go @@ -14,6 +14,8 @@ import ( type planController struct { binding *edgeservice.SingleRequestBinding + bridge *singleRequestWorkToolBridge + toolResult edgeservice.InternalWorkspaceToolResult envelopes []edgeservice.SingleRequestEnvelope kind edgeservice.SingleRequestArtifactKind content []byte @@ -24,11 +26,14 @@ type planController struct { func (c *planController) RequestID() string { return "request-1" } func (c *planController) Binding() *edgeservice.SingleRequestBinding { - if c.binding != nil { - return c.binding + binding := c.binding + if binding == nil { + binding, _ = edgeservice.NewSingleRequestBinding("virtual-model", "ws-ref", validStageBinding(), validStageBinding(), validStageBinding(), validLimits()) } - b, _ := edgeservice.NewSingleRequestBinding("virtual-model", "ws-ref", validStageBinding(), validStageBinding(), validStageBinding(), validLimits()) - return b + if binding.Workspace == nil { + binding.Workspace = &edgeservice.SingleRequestWorkspaceBinding{Ref: "ws-ref", NodeID: "node", ConnectionGeneration: 1, OperationIDs: []string{"read", "list"}} + } + return binding } func (c *planController) Context() context.Context { return context.Background() } func (c *planController) State() edgeservice.SingleRequestState { @@ -39,7 +44,21 @@ func (c *planController) ReadInternalArtifact(context.Context, edgeservice.Singl } func (c *planController) SubmitEnvelope(e edgeservice.SingleRequestEnvelope) error { c.envelopes = append(c.envelopes, e) - return c.envelopeErr + if c.envelopeErr != nil { + return c.envelopeErr + } + if e.Stage == edgeservice.SingleRequestStateInternalTool && e.ToolCall != nil { + result := c.toolResult.Clone() + if result.Status == "" { + result.Status = "success" + result.Entries = []string{"apps", "go.mod"} + } + result.RequestID = e.ToolCall.RequestID + result.StageID = e.ToolCall.StageID + result.ToolCallID = e.ToolCall.ToolCallID + return c.bridge.ContinueInternalTool(context.Background(), result) + } + return nil } func (c *planController) WriteInternalArtifact(_ context.Context, k edgeservice.SingleRequestArtifactKind, b []byte) error { c.writeAttempts++ @@ -64,34 +83,65 @@ func validPlanStageRequest() singleRequestPlanStageRequest { } } -func TestSingleRequestPlanStageWritesArtifact(t *testing.T) { - d := matchingDispatch() +func planInspectionBody() []byte { + return []byte(`{"id":"chat-plan-inspect","object":"chat.completion","created":1,"model":"served","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call-plan-list","type":"function","function":{"name":"workspace_list","arguments":"{\"relative_path\":\".\"}"}}]},"finish_reason":"tool_calls"}]}`) +} + +func newPlanTestStage(t *testing.T, ctrl *planController, finalBody []byte, captured *[]edgeservice.ProviderPoolDispatchRequest) *singleRequestPlanStage { + t.Helper() + dispatch := matchingDispatch() + call := 0 + provider := newSingleRequestProviderStage(&mockService{submit: func(_ context.Context, req edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { + if captured != nil { + *captured = append(*captured, req) + } + call++ + body := finalBody + if call == 1 { + body = planInspectionBody() + } + return &edgeservice.ProviderPoolDispatchResult{Path: edgeservice.ProviderPoolPathTunnel, Tunnel: &mockTunnel{frames: framesFor(body)}, DispatchInfo: dispatch}, nil + }}) + bridge := newSingleRequestWorkToolBridge() + ctrl.bridge = bridge + return newSingleRequestPlanStage(provider, bridge) +} + +func TestSingleRequestPlanStageInspectsThenWritesArtifact(t *testing.T) { planJSON := `{"goal":"Inspect the target.","steps":["Step one.","Step two."],"verification":["Run focused tests."]}` planMD := "# Plan\n\n## Goal\nInspect the target.\n\n## Steps\n- [P1] Step one.\n- [P2] Step two.\n\n## Verification\n- Run focused tests.\n" - tunnel := &mockTunnel{frames: framesFor(successBodyWithThoughtSignature(planJSON))} - var captured edgeservice.ProviderPoolDispatchRequest - provider := newSingleRequestProviderStage(&mockService{submit: func(_ context.Context, r edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { - captured = r - return &edgeservice.ProviderPoolDispatchResult{Path: edgeservice.ProviderPoolPathTunnel, Tunnel: tunnel, DispatchInfo: d}, nil - }}) + var captured []edgeservice.ProviderPoolDispatchRequest ctrl := &planController{} - request := validPlanStageRequest() - content, err := newSingleRequestPlanStage(provider).run(context.Background(), request, ctrl) + stage := newPlanTestStage(t, ctrl, successBodyWithThoughtSignature(planJSON), &captured) + content, err := stage.run(context.Background(), validPlanStageRequest(), ctrl) if err != nil { t.Fatal(err) } if got := string(content); got != planMD { t.Fatalf("content=%q, want %q", got, planMD) } - if ctrl.kind != edgeservice.SingleRequestArtifactPlan || len(ctrl.envelopes) != 1 || ctrl.envelopes[0].Stage != edgeservice.SingleRequestStatePlanning { + if ctrl.kind != edgeservice.SingleRequestArtifactPlan || len(ctrl.envelopes) != 3 { t.Fatalf("controller=%+v", ctrl) } - body, _ := captured.Tunnel.BuildBody("gemini-3.6-flash") - if !containsAll(string(body), "Fix immutable task", "Return exactly one JSON object", "reasoning_effort", "high") { - t.Fatalf("body=%s", body) + if ctrl.envelopes[0].Stage != edgeservice.SingleRequestStatePlanning || ctrl.envelopes[1].Stage != edgeservice.SingleRequestStateInternalTool || ctrl.envelopes[1].SavedStage != edgeservice.SingleRequestStatePlanning || ctrl.envelopes[2].Stage != edgeservice.SingleRequestStatePlanning { + t.Fatalf("envelopes=%+v", ctrl.envelopes) + } + if len(captured) != 2 { + t.Fatalf("provider calls=%d, want 2", len(captured)) + } + first, _ := captured[0].Tunnel.BuildBody("gemini-3.6-flash") + second, _ := captured[1].Tunnel.BuildBody("gemini-3.6-flash") + if !containsAll(string(first), "Inspect the workspace before planning", "Fix immutable task", `"tool_choice":"required"`, "workspace_read", "workspace_list", `"reasoning_effort":"high"`) { + t.Fatalf("first body=%s", first) + } + if strings.Contains(string(first), "workspace_write") || strings.Contains(string(first), "workspace_command") || strings.Contains(string(first), "response_format") { + t.Fatalf("first body exposed mutation or early completion: %s", first) + } + if !containsAll(string(second), `"tool_choice":"auto"`, "response_format", "call-plan-list", "go.mod") { + t.Fatalf("second body=%s", second) } var decoded map[string]any - if err := json.Unmarshal(body, &decoded); err != nil { + if err := json.Unmarshal(second, &decoded); err != nil { t.Fatal(err) } encodedFormat, _ := json.Marshal(singleRequestPlanResponseFormat()) @@ -105,184 +155,90 @@ func TestSingleRequestPlanStageWritesArtifact(t *testing.T) { } func TestSingleRequestPlanStageCustomTemplate(t *testing.T) { - d := matchingDispatch() customTmpl := "# Plan\n\nCustom Header\n\n## Goal\n{{goal}}\n\n## Steps\n{{steps}}\n\n## Verification\n{{verification}}\n" planJSON := `{"goal":"Inspect custom target.","steps":["Custom step 1.","Custom step 2."],"verification":["Custom verify."]}` planMD := "# Plan\n\nCustom Header\n\n## Goal\nInspect custom target.\n\n## Steps\n- [P1] Custom step 1.\n- [P2] Custom step 2.\n\n## Verification\n- Custom verify.\n" - - binding, err := edgeservice.NewSingleRequestBindingWithTemplates("virtual-model", "ws-ref", validStageBinding(), validStageBinding(), validStageBinding(), validLimits(), edgeservice.SingleRequestTemplateBinding{ - Plan: customTmpl, - Review: singlerequesttemplate.DefaultReviewTemplate, - }) + binding, err := edgeservice.NewSingleRequestBindingWithTemplates("virtual-model", "ws-ref", validStageBinding(), validStageBinding(), validStageBinding(), validLimits(), edgeservice.SingleRequestTemplateBinding{Plan: customTmpl, Review: singlerequesttemplate.DefaultReviewTemplate}) if err != nil { t.Fatal(err) } - - tunnel := &mockTunnel{frames: framesFor(successBodyWithThoughtSignature(planJSON))} - provider := newSingleRequestProviderStage(&mockService{submit: func(_ context.Context, _ edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { - return &edgeservice.ProviderPoolDispatchResult{Path: edgeservice.ProviderPoolPathTunnel, Tunnel: tunnel, DispatchInfo: d}, nil - }}) ctrl := &planController{binding: binding} - content, err := newSingleRequestPlanStage(provider).run(context.Background(), validPlanStageRequest(), ctrl) + content, err := newPlanTestStage(t, ctrl, successBody(planJSON), nil).run(context.Background(), validPlanStageRequest(), ctrl) if err != nil { t.Fatal(err) } if string(content) != planMD { - t.Fatalf("content = %q, want %q", string(content), planMD) + t.Fatalf("content=%q, want %q", string(content), planMD) + } +} + +func TestSingleRequestPlanStageRejectsFailedInspection(t *testing.T) { + ctrl := &planController{toolResult: edgeservice.InternalWorkspaceToolResult{Status: "error", ErrorCode: "not_found"}} + planJSON := `{"goal":"Guess.","steps":["Step one.","Step two."],"verification":["Verify."]}` + _, err := newPlanTestStage(t, ctrl, successBody(planJSON), nil).run(context.Background(), validPlanStageRequest(), ctrl) + if !errors.Is(err, errSingleRequestPlanStage) || ctrl.writeAttempts != 0 { + t.Fatalf("err=%v writes=%d", err, ctrl.writeAttempts) } } func TestSingleRequestPlanStageFailsClosed(t *testing.T) { - malformedTests := []struct { - name string - raw string - }{ - {"empty-string", ""}, - {"not-json", "just plain text"}, - {"unknown-field", `{"goal":"Fix bug.","steps":["Step 1","Step 2"],"verification":["Verify 1"],"extra":"no"}`}, - {"duplicate-field", `{"goal":"Fix bug.","goal":"Again.","steps":["Step 1","Step 2"],"verification":["Verify 1"]}`}, - {"missing-field", `{"goal":"Fix bug.","steps":["Step 1","Step 2"]}`}, - {"wrong-field-type", `{"goal":"Fix bug.","steps":"Step 1 and Step 2","verification":["Verify 1"]}`}, - {"step-count-1-too-few", `{"goal":"Fix bug.","steps":["Step 1"],"verification":["Verify 1"]}`}, - {"step-count-7-too-many", `{"goal":"Fix bug.","steps":["S1","S2","S3","S4","S5","S6","S7"],"verification":["Verify 1"]}`}, - {"step-empty-item", `{"goal":"Fix bug.","steps":["Step 1",""],"verification":["Verify 1"]}`}, - {"step-multiline-item", `{"goal":"Fix bug.","steps":["Step 1\ncontinued","Step 2"],"verification":["Verify 1"]}`}, - {"verif-count-0-too-few", `{"goal":"Fix bug.","steps":["Step 1","Step 2"],"verification":[]}`}, - {"verif-count-4-too-many", `{"goal":"Fix bug.","steps":["Step 1","Step 2"],"verification":["V1","V2","V3","V4"]}`}, - {"verification-multiline-item", `{"goal":"Fix bug.","steps":["Step 1","Step 2"],"verification":["Verify 1\ncontinued"]}`}, - {"multiline-goal", `{"goal":"Line 1\nLine 2","steps":["Step 1","Step 2"],"verification":["Verify 1"]}`}, - {"unresolved-token", `{"goal":"Fix {{goal}} bug.","steps":["Step 1","Step 2"],"verification":["Verify 1"]}`}, + malformed := []string{ + "", "just plain text", + `{"goal":"Fix bug.","steps":["Step 1","Step 2"],"verification":["Verify 1"],"extra":"no"}`, + `{"goal":"Fix bug.","steps":["Step 1"],"verification":["Verify 1"]}`, + `{"goal":"Fix bug.","steps":["Step 1","Step 2"],"verification":[]}`, + `{"goal":"Line 1\nLine 2","steps":["Step 1","Step 2"],"verification":["Verify 1"]}`, } - - for _, tt := range malformedTests { - t.Run(tt.name, func(t *testing.T) { - d := matchingDispatch() - tunnel := &mockTunnel{frames: framesFor(successBody(tt.raw))} - provider := newSingleRequestProviderStage(&mockService{submit: func(context.Context, edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { - return &edgeservice.ProviderPoolDispatchResult{Path: edgeservice.ProviderPoolPathTunnel, Tunnel: tunnel, DispatchInfo: d}, nil - }}) + for _, raw := range malformed { + t.Run(raw, func(t *testing.T) { ctrl := &planController{} - _, err := newSingleRequestPlanStage(provider).run(context.Background(), validPlanStageRequest(), ctrl) - if !errors.Is(err, errSingleRequestPlanStage) { - t.Fatalf("expected errSingleRequestPlanStage, got %v", err) - } - if ctrl.writeAttempts != 0 || len(ctrl.content) > 0 { - t.Fatalf("artifact wrote content on failure: attempts=%d content=%s", ctrl.writeAttempts, ctrl.content) + _, err := newPlanTestStage(t, ctrl, successBody(raw), nil).run(context.Background(), validPlanStageRequest(), ctrl) + if !errors.Is(err, errSingleRequestPlanStage) || ctrl.writeAttempts != 0 { + t.Fatalf("err=%v writes=%d", err, ctrl.writeAttempts) } }) } - t.Run("provider-failure-rejects", func(t *testing.T) { + t.Run("initial-plan-without-inspection", func(t *testing.T) { + dispatch := matchingDispatch() provider := newSingleRequestProviderStage(&mockService{submit: func(context.Context, edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { - return nil, errors.New("provider failure") + return &edgeservice.ProviderPoolDispatchResult{Path: edgeservice.ProviderPoolPathTunnel, Tunnel: &mockTunnel{frames: framesFor(successBody(`{"goal":"Guess.","steps":["S1","S2"],"verification":["V1"]}`))}, DispatchInfo: dispatch}, nil }}) - ctrl := &planController{} - _, err := newSingleRequestPlanStage(provider).run(context.Background(), validPlanStageRequest(), ctrl) - if !errors.Is(err, errSingleRequestPlanStage) { - t.Fatalf("expected errSingleRequestPlanStage, got %v", err) - } - if len(ctrl.content) > 0 { - t.Fatalf("artifact wrote content on provider failure") + bridge := newSingleRequestWorkToolBridge() + ctrl := &planController{bridge: bridge} + _, err := newSingleRequestPlanStage(provider, bridge).run(context.Background(), validPlanStageRequest(), ctrl) + if !errors.Is(err, errSingleRequestPlanStage) || ctrl.writeAttempts != 0 { + t.Fatalf("err=%v writes=%d", err, ctrl.writeAttempts) } }) - t.Run("context-cancelled-rejects", func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - provider := newSingleRequestProviderStage(&mockService{submit: func(ctx context.Context, _ edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { - return nil, ctx.Err() - }}) - ctrl := &planController{} - _, err := newSingleRequestPlanStage(provider).run(ctx, validPlanStageRequest(), ctrl) - if !errors.Is(err, errSingleRequestPlanStage) { - t.Fatalf("expected errSingleRequestPlanStage, got %v", err) - } - if len(ctrl.content) > 0 { - t.Fatalf("artifact wrote content on context cancel") - } - }) - - t.Run("envelope-rejection-prevents-provider-call-and-artifact", func(t *testing.T) { + t.Run("envelope-rejection", func(t *testing.T) { providerCalled := false provider := newSingleRequestProviderStage(&mockService{submit: func(context.Context, edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { providerCalled = true return nil, nil }}) - ctrl := &planController{envelopeErr: errors.New("envelope error")} - _, err := newSingleRequestPlanStage(provider).run(context.Background(), validPlanStageRequest(), ctrl) - if !errors.Is(err, errSingleRequestPlanStage) { - t.Fatalf("expected errSingleRequestPlanStage, got %v", err) - } - if providerCalled { - t.Fatalf("provider was called after envelope rejection") - } - if len(ctrl.content) > 0 { - t.Fatalf("artifact wrote content on envelope rejection") + bridge := newSingleRequestWorkToolBridge() + ctrl := &planController{bridge: bridge, envelopeErr: errors.New("envelope error")} + _, err := newSingleRequestPlanStage(provider, bridge).run(context.Background(), validPlanStageRequest(), ctrl) + if !errors.Is(err, errSingleRequestPlanStage) || providerCalled { + t.Fatalf("err=%v providerCalled=%t", err, providerCalled) } }) - t.Run("artifact-write-failure-rejects", func(t *testing.T) { - d := matchingDispatch() - planJSON := `{"goal":"Goal text.","steps":["Step 1","Step 2"],"verification":["Verify 1"]}` - tunnel := &mockTunnel{frames: framesFor(successBody(planJSON))} - provider := newSingleRequestProviderStage(&mockService{submit: func(context.Context, edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { - return &edgeservice.ProviderPoolDispatchResult{Path: edgeservice.ProviderPoolPathTunnel, Tunnel: tunnel, DispatchInfo: d}, nil - }}) + t.Run("artifact-write-failure", func(t *testing.T) { ctrl := &planController{writeErr: errors.New("write failure")} - _, err := newSingleRequestPlanStage(provider).run(context.Background(), validPlanStageRequest(), ctrl) - if !errors.Is(err, errSingleRequestPlanStage) { - t.Fatalf("expected errSingleRequestPlanStage, got %v", err) - } - if ctrl.writeAttempts != 1 || len(ctrl.content) > 0 { - t.Fatalf("expected writeAttempts=1 and len(content)==0, got attempts=%d len=%d", ctrl.writeAttempts, len(ctrl.content)) + planJSON := `{"goal":"Goal text.","steps":["Step 1","Step 2"],"verification":["Verify 1"]}` + _, err := newPlanTestStage(t, ctrl, successBody(planJSON), nil).run(context.Background(), validPlanStageRequest(), ctrl) + if !errors.Is(err, errSingleRequestPlanStage) || ctrl.writeAttempts != 1 || len(ctrl.content) != 0 { + t.Fatalf("err=%v attempts=%d content=%q", err, ctrl.writeAttempts, ctrl.content) } }) - invalidRequests := []struct { - name string - mutate func(*singleRequestPlanStageRequest) - }{ - {"empty-request-id", func(r *singleRequestPlanStageRequest) { r.RequestID = "" }}, - {"empty-task", func(r *singleRequestPlanStageRequest) { r.Task = "" }}, - {"zero-sequence", func(r *singleRequestPlanStageRequest) { r.Sequence = 0 }}, - } - - for _, tt := range invalidRequests { - t.Run(tt.name, func(t *testing.T) { - providerCalled := false - provider := newSingleRequestProviderStage(&mockService{submit: func(context.Context, edgeservice.ProviderPoolDispatchRequest) (*edgeservice.ProviderPoolDispatchResult, error) { - providerCalled = true - return nil, nil - }}) - req := validPlanStageRequest() - tt.mutate(&req) - ctrl := &planController{} - _, err := newSingleRequestPlanStage(provider).run(context.Background(), req, ctrl) - if !errors.Is(err, errSingleRequestPlanStage) { - t.Fatalf("expected errSingleRequestPlanStage, got %v", err) - } - if providerCalled { - t.Fatalf("provider called on invalid request input") - } - if len(ctrl.envelopes) > 0 || len(ctrl.content) > 0 { - t.Fatalf("controller invoked on invalid request input") - } - }) - } - - t.Run("nil-provider-rejects", func(t *testing.T) { + t.Run("invalid-construction", func(t *testing.T) { ctrl := &planController{} - _, err := newSingleRequestPlanStage(nil).run(context.Background(), validPlanStageRequest(), ctrl) - if !errors.Is(err, errSingleRequestPlanStage) { - t.Fatalf("expected errSingleRequestPlanStage, got %v", err) - } - }) - - t.Run("nil-controller-rejects", func(t *testing.T) { - provider := newSingleRequestProviderStage(&mockService{}) - _, err := newSingleRequestPlanStage(provider).run(context.Background(), validPlanStageRequest(), nil) - if !errors.Is(err, errSingleRequestPlanStage) { - t.Fatalf("expected errSingleRequestPlanStage, got %v", err) + if _, err := newSingleRequestPlanStage(nil, nil).run(context.Background(), validPlanStageRequest(), ctrl); !errors.Is(err, errSingleRequestPlanStage) { + t.Fatalf("nil stage err=%v", err) } }) } diff --git a/apps/edge/internal/openai/single_request_quality_gate_test.go b/apps/edge/internal/openai/single_request_quality_gate_test.go index 470aa454..857d16bc 100644 --- a/apps/edge/internal/openai/single_request_quality_gate_test.go +++ b/apps/edge/internal/openai/single_request_quality_gate_test.go @@ -308,25 +308,27 @@ func TestSingleRequestQualityGateBudgetAndMalformedCallStopComposite(t *testing. { name: "iteration budget", responses: [][]byte{ + executorPlanInspectionBody("quality-budget-plan-inspect"), executorPlanBody("Read bounded inputs", "Stop at the bound"), workToolBody("budget-1", edgeservice.InternalWorkspaceToolRead, `{"relative_path":"first.txt"}`), workToolBody("budget-2", edgeservice.InternalWorkspaceToolRead, `{"relative_path":"second.txt"}`), }, maxIterations: 1, want: edgeservice.SingleRequestTerminalDisposition{Kind: edgeservice.SingleRequestTerminalError, ErrorClass: edgeservice.SingleRequestTerminalErrorBudget}, - wantProviders: 3, - wantTools: 1, + wantProviders: 4, + wantTools: 2, }, { name: "malformed workspace call", responses: [][]byte{ + executorPlanInspectionBody("quality-malformed-plan-inspect"), executorPlanBody("Reject an invalid path", "No tool effect"), workToolBody("malformed-1", edgeservice.InternalWorkspaceToolRead, `{"relative_path":"../private"}`), }, maxIterations: 4, want: edgeservice.SingleRequestTerminalDisposition{Kind: edgeservice.SingleRequestTerminalError, ErrorClass: edgeservice.SingleRequestTerminalErrorMalformed}, - wantProviders: 2, - wantTools: 0, + wantProviders: 3, + wantTools: 1, }, } @@ -368,6 +370,7 @@ func TestSingleRequestQualityGateBudgetAndMalformedCallStopComposite(t *testing. func TestSingleRequestQualityGateRepetitionStopsBeforeLaterDispatch(t *testing.T) { responses := [][]byte{ + executorPlanInspectionBody("quality-repeat-plan-inspect"), executorPlanBody("Read once", "Verify once"), workToolBody("repeat-1", edgeservice.InternalWorkspaceToolRead, `{"relative_path":"result.txt"}`), workToolBody("repeat-2", edgeservice.InternalWorkspaceToolRead, `{"relative_path":"result.txt"}`), @@ -399,7 +402,7 @@ func TestSingleRequestQualityGateRepetitionStopsBeforeLaterDispatch(t *testing.T if !errors.Is(waitErr, edgeservice.ErrSingleRequestFailed) || terminal != want { t.Fatalf("Wait=%v terminal=%+v, want repetition", waitErr, terminal) } - if providerCalls.Load() != 3 || node.toolCount.Load() != 2 || node.cleanupCount.Load() != 1 || executor.bridge.pendingCount() != 0 || terminalCount != 1 { + if providerCalls.Load() != 4 || node.toolCount.Load() != 3 || node.cleanupCount.Load() != 1 || executor.bridge.pendingCount() != 0 || terminalCount != 1 { t.Fatalf("provider=%d tool=%d cleanup=%d pending=%d terminals=%d", providerCalls.Load(), node.toolCount.Load(), node.cleanupCount.Load(), executor.bridge.pendingCount(), terminalCount) } } @@ -447,6 +450,7 @@ func TestSingleRequestQualityGateAdmitsOnlyRepairableToolResults(t *testing.T) { func TestSingleRequestQualityGateToolTimeoutStopsBeforeContinuation(t *testing.T) { responses := [][]byte{ + executorPlanInspectionBody("quality-timeout-plan-inspect"), executorPlanBody("Run command", "Verify command"), workToolBody("timeout-1", edgeservice.InternalWorkspaceToolCommand, `{"command_id":"verify"}`), } @@ -461,6 +465,12 @@ func TestSingleRequestQualityGateToolTimeoutStopsBeforeContinuation(t *testing.T executor := NewSingleRequestExecutor(mockSvc) service, binding, node := newTestServiceHarness(t, executor) node.toolResponder = func(request *iop.WorkspaceToolRequest) *iop.WorkspaceToolResponse { + if request.GetStageId() == singleRequestPlanStageID { + return &iop.WorkspaceToolResponse{ + RequestId: request.GetRequestId(), StageId: request.GetStageId(), ToolCallId: request.GetToolCallId(), + Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS, + } + } return &iop.WorkspaceToolResponse{ RequestId: request.GetRequestId(), StageId: request.GetStageId(), ToolCallId: request.GetToolCallId(), Status: iop.WorkspaceStatus_WORKSPACE_STATUS_TIMEOUT, ErrorCode: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_TIMEOUT, @@ -484,7 +494,7 @@ func TestSingleRequestQualityGateToolTimeoutStopsBeforeContinuation(t *testing.T if !errors.Is(waitErr, edgeservice.ErrSingleRequestInternalToolFailed) || terminal != want { t.Fatalf("Wait=%v terminal=%+v, want timeout", waitErr, terminal) } - if providerCalls.Load() != 2 || node.toolCount.Load() != 1 || node.cleanupCount.Load() != 1 || executor.bridge.pendingCount() != 0 || terminalCount != 1 { + if providerCalls.Load() != 3 || node.toolCount.Load() != 2 || node.cleanupCount.Load() != 1 || executor.bridge.pendingCount() != 0 || terminalCount != 1 { t.Fatalf("provider=%d tool=%d cleanup=%d pending=%d terminals=%d", providerCalls.Load(), node.toolCount.Load(), node.cleanupCount.Load(), executor.bridge.pendingCount(), terminalCount) } } diff --git a/apps/edge/internal/openai/single_request_work_stage.go b/apps/edge/internal/openai/single_request_work_stage.go index 515db359..10281339 100644 --- a/apps/edge/internal/openai/single_request_work_stage.go +++ b/apps/edge/internal/openai/single_request_work_stage.go @@ -30,9 +30,9 @@ type singleRequestWorkToolKey struct { } // singleRequestWorkToolBridge is the request-local continuation boundary for -// a Work provider call. It retains only correlation identifiers and one -// bounded result slot; prompts, arguments, paths, and provider payloads never -// enter the map. +// private Plan, Work, and Review provider calls. It retains only correlation +// identifiers and one bounded result slot; prompts, arguments, paths, and +// provider payloads never enter the map. type singleRequestWorkToolBridge struct { mu sync.Mutex pending map[singleRequestWorkToolKey]chan edgeservice.InternalWorkspaceToolResult