feat(plan): 워크스페이스 분석을 선행한다

근거 없는 계획 생성을 막기 위해 읽기 전용 조사가 성공한 뒤에만 Plan을 확정하도록 한다.
This commit is contained in:
toki 2026-08-14 18:19:17 +09:00
parent b1683db9c1
commit 54c0ab1316
7 changed files with 394 additions and 203 deletions

View file

@ -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,

View file

@ -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`).

View file

@ -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.

View file

@ -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),
}

View file

@ -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
}

View file

@ -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)
}
})
}

View file

@ -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