diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 40b4e9e02..a415abf75 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -107,6 +107,12 @@ jobs:
run: npm run build
- name: Run unit tests
run: npx vitest run tests/unit/
+ # /dg #12: high-severity dep CVEs that ship to users (not dev or
+ # examples workspace) fail the build. Scoped to non-dev deps +
+ # the root workspace only — examples are a separate workspace
+ # and don't reach end users via the published package.
+ - name: npm audit (high-severity, runtime deps only)
+ run: npm audit --workspaces=false --omit=dev --audit-level=high
# ── Java SDK Unit Tests ────────────────────────────────────────────
# Runs the Java SDK core + Spring auto-configuration unit tests in one
diff --git a/AGENTS.md b/AGENTS.md
index ba543e95c..47c314dfd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -144,6 +144,24 @@ mypy src/agentspan/agents/ --ignore-missing-imports --no-strict-optional
| `tests/integration/test_basic_execution.py` | End-to-end single agent execution |
| `tests/integration/test_multi_agent.py` | End-to-end multi-agent execution |
+### No Flaky Tests
+
+**There are NO flaky tests in this repo. Any test failure is a regression and must be fixed.**
+
+This is not negotiable and not subject to per-session interpretation:
+
+- A "flake" framing is forbidden. If a test fails once and passes on retry, that's still a regression — diagnose the underlying race, missing await, time-dependence, LLM non-determinism, or upstream-dep instability, and **fix the root cause**.
+- Never re-run CI to "make it pass" without first understanding why it failed. A re-run that turns green doesn't mean the bug went away; it means you got lucky and shipped the bug.
+- "Pre-existing flake" / "happens on main too" is not a get-out clause. If a test is flaky on main, that's a regression on main that we now own. File it, fix it, or remove the test — but don't tolerate it.
+- Re-enqueueing a failed CI job without changing code is only allowed AFTER you've identified the root cause and have a fix in flight.
+
+When a test reveals non-determinism that the test itself caused (timing-sensitive assertions, ordering assumptions), fix the **test** so it's robust. When the non-determinism is in the system under test (real race, real instability), fix the **system**. Don't add retries to mask either case.
+
+**The narrow exception — upstream LLM provider variability.** Some e2e tests validate a non-LLM property (a strategy compiles, a sub-workflow fires, a worker registers) but depend on the LLM to drive the scenario (call a tool, pick a route). When gpt-4o-mini occasionally skips a tool call or paraphrases away a number, that's external provider variability — not Agentspan's bug and not the test's bug. For these cases:
+- Strongly prefer asserting on deterministic server-side state (workflow status, task names, `outputData` shapes from `@tool` stubs that return fixed data).
+- When that's not enough, `{ retry: 2 }` is acceptable, but only with a comment explaining *which* property is the real subject of the test and *why* LLM variability is incidental. See the pattern in `test_suite20_plan_execute.test.ts`.
+- Never use retries to paper over a real race in the system or a brittle assertion in the test. The retry is a coping mechanism for upstream variability, not for our own bugs.
+
### Writing Tests
- Unit tests must run without an Agentspan server (mock all external calls)
@@ -152,6 +170,7 @@ mypy src/agentspan/agents/ --ignore-missing-imports --no-strict-optional
- Use `pytest` fixtures and parametrize where appropriate
- do NOT use mocks. Mocks considered harmful. Write tests that use the actual server
- SDK e2e tests MUST rely on the Agentspan server to ensure we are testing the actual communication
+- E2E tests that depend on an LLM's behavior (output content, tool-call timing) must assert on **deterministic** server-side state — workflow status, task names, compiled DAG structure, `outputData` shapes — never on free-form LLM text. If a test fails because the LLM didn't say the magic word, the test is wrong.
### Examples
- Every feature MUST have an example in all the supported sdks (python/ etc)
@@ -226,6 +245,7 @@ The `server/` directory contains the Agent Runtime — a Spring Boot server that
|---|---|---|
| `/api/agent/start` | POST | Compile, register, and start an agent execution |
| `/api/agent/compile` | POST | Compile agent config (inspect only) |
+| `/api/agent/inspect-plan` | POST | Compile a plan against a PLAN_EXECUTE harness config and return the resulting `WorkflowDef` + error + warnings + stats without dispatching the SUB_WORKFLOW. Body: `{agentConfig, plan}`. Same compile path PAC uses at runtime. See [docs/concepts/plan-execute.md](docs/concepts/plan-execute.md) |
| `/api/agent/list` | GET | List all registered agents (filtered by `agent_sdk` metadata) |
| `/api/agent/executions` | GET | Search agent executions (with `start`, `size`, `sort`, `freeText`, `status`, `agentName` params) |
| `/api/agent/executions/{id}` | GET | Get detailed execution status (agent name, version, status, input, output, current task) |
diff --git a/docs/concepts/plan-execute.md b/docs/concepts/plan-execute.md
new file mode 100644
index 000000000..a5448d69e
--- /dev/null
+++ b/docs/concepts/plan-execute.md
@@ -0,0 +1,481 @@
+---
+title: Plan-Execute Strategy
+description: PLAN_EXECUTE compiles LLM-generated (or static) plans into deterministic Conductor sub-workflows — the planner reasons, the executor runs.
+---
+
+# Plan-Execute Strategy
+
+`Strategy.PLAN_EXECUTE` (also called PAE; the server-side compiler is PAC, "PLAN_AND_COMPILE") splits a task into two phases:
+
+1. **Plan** — a planner agent emits a JSON DAG of operations.
+2. **Execute** — the server compiles that JSON into a Conductor sub-workflow and runs it deterministically.
+
+The LLM is only invoked where it adds value (planning, per-op content generation). Orchestration, retries, parallelism, and validation are pure Conductor primitives — no token cost, no nondeterminism.
+
+## The deterministic boundary
+
+The whole point of PAC/PAE is to draw a hard line between the **non-deterministic part** (the planner LLM reasoning about *what to do*) and the **deterministic part** (Conductor running the compiled DAG). Once the plan is compiled, the executor is replay-safe, branch-stable, and free of LLM randomness.
+
+```mermaid
+flowchart TB
+ subgraph ND["LLM (non-deterministic)"]
+ direction LR
+ Planner["planner agent emits JSON plan"]
+ end
+
+ subgraph PAC["PAC compile step (server, pure function)"]
+ direction LR
+ ExtractJSON["extract_json (static_plan → markdown_plan → planSource)"]
+ Compile["compile to WorkflowDef"]
+ ExtractJSON --> Compile
+ end
+
+ subgraph DET["Conductor sub-workflow (deterministic)"]
+ direction LR
+ Setup["SET_VARIABLE _ctx_init"]
+ Fork["FORK_JOIN (parallel steps)"]
+ Join["JOIN (aggregate)"]
+ Validate["validation + SWITCH gate"]
+ Setup --> Fork --> Join --> Validate
+ end
+
+ Prompt[["user prompt"]] --> Planner
+ Planner -- "JSON plan in ```json fence```" --> ExtractJSON
+ Compile -- "workflowDef (Conductor JSON)" --> Setup
+
+ StaticPlan[["static_plan= (skip planner)"]] -.->|"Case 0: overrides LLM"| ExtractJSON
+ Validate -- pass --> Done(["COMPLETED"])
+ Validate -- fail --> Fallback{{"fallback agent?"}}
+ Fallback -- yes --> FallbackRun["LLM-loop recovery"]
+ Fallback -- no --> Failed(["FAILED"])
+
+ classDef llm fill:#fff3e0,stroke:#e65100,stroke-width:2px;
+ classDef pure fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px;
+ classDef det fill:#e3f2fd,stroke:#0d47a1,stroke-width:2px;
+ class Planner,FallbackRun llm;
+ class ExtractJSON,Compile pure;
+ class Setup,Fork,Join,Validate det;
+```
+
+**Why this shape gives you determinism:**
+
+- **One planner call, then we're done with the LLM.** The plan is a value; everything downstream is a function of that value. Two identical plans produce two identical workflow defs and two identical executions (modulo tool side effects).
+- **`Ref("step_id")` is resolved at compile time**, not at run time — there is no runtime "interpret-the-plan" loop that could diverge. The wire form (`{"$ref": "fetch"}`) becomes a Conductor template (`${fetch.output.result}`) once, in PAC.
+- **Branching is a SWITCH, not a re-prompt.** `success_condition` is a JS expression evaluated by Conductor's JavaScript engine — same input, same branch, every time.
+- **Parallelism is FORK_JOIN, not "ask the LLM to fan out".** A 5-section parallel report has exactly 5 branches, deterministically.
+- **`plan=` (static plan) bypasses the LLM entirely.** Workflow shape and execution are now fully determined by your code. Use this for tests, replays, or any pipeline where planning lives outside the agent.
+
+## When to use it
+
+PLAN_EXECUTE wins when the work has **fixed structure but variable content**:
+
+- Generate a research report (3 sections, parallel writes, then assemble + validate)
+- Process a batch of records with conditional branches
+- Multi-stage refactor where each stage is the same shape but the inputs differ
+- Anywhere you'd otherwise hand-write 20 turns of LLM tool-calling and hope it doesn't loop
+
+If you need fully agentic exploration with no fixed shape, use `Strategy.HANDOFF` instead. If you have a fully fixed pipeline, use `Strategy.SEQUENTIAL`. PLAN_EXECUTE is the middle ground.
+
+## The shape
+
+```python
+from agentspan.agents import Strategy, Agent, plan_execute
+
+# One-call construction (recommended):
+harness = plan_execute(
+ name="report_generator",
+ tools=[create_directory, write_file, assemble_files, check_word_count],
+ planner_instructions="Plan a research report on the user's topic. Use 3 sections, then assemble.",
+ fallback_instructions="The deterministic plan failed — recover agentically.",
+)
+
+# Or assemble manually if you need every knob:
+planner = Agent(name="planner", instructions=PLANNER_INSTRUCTIONS, model=...)
+fallback = Agent(name="fb", instructions=FALLBACK_INSTRUCTIONS, tools=[...], model=...)
+harness = Agent(
+ name="report_generator",
+ strategy=Strategy.PLAN_EXECUTE,
+ planner=planner,
+ fallback=fallback,
+ tools=[...], # canonical plan-executable set; PAC validates against this
+ fallback_max_turns=5,
+)
+```
+
+The **planner**, **fallback**, and **tools** slots are the three first-class fields. `agents=[...]` is **not** valid for PLAN_EXECUTE — set the named slots.
+
+## Plan schema
+
+The server auto-appends a `## Plan schema` block to the planner's user prompt (along with `## Available tools` derived from `harness.tools`). Your `planner_instructions` only needs to cover **domain-level guidance** — what to plan, not how to format JSON.
+
+The schema PAC consumes:
+
+```json
+{
+ "steps": [
+ {
+ "id": "",
+ "depends_on": [""],
+ "parallel": false,
+ "operations": [
+ {"tool": "", "args": {}},
+ {"tool": "", "generate": {
+ "instructions": "",
+ "output_schema": "",
+ "max_tokens": 4096
+ }}
+ ]
+ }
+ ],
+ "validation": [
+ {"tool": "", "args": {...},
+ "success_condition": "$.passed === true"}
+ ],
+ "on_success": [{"tool": "", "args": {...}}],
+ "on_failure": [{"tool": "", "args": {...}}]
+}
+```
+
+**Key concepts:**
+
+- **`args` vs `generate`** — `args` runs the tool with literal values you decide at plan time. `generate` defers arg construction to a per-op LLM call at run time.
+- **`depends_on`** — cross-step concurrency. A step starts when *all* listed deps complete. Defaults to the previous step.
+- **`parallel`** — when true, the step's own `operations` run concurrently (FORK_JOIN). Without it, operations run in order within the step.
+- **`success_condition`** — JS expression evaluated against the validator's output (`$` = parsed output map). Returns truthy on pass.
+- **`on_success` / `on_failure`** — tools to run after validation. Optional.
+
+## Typed plans (no JSON soup)
+
+For static plans (or plans you build programmatically), import the typed builders:
+
+```python
+from agentspan.agents import Plan, Step, Op, Generate, Validation, Action
+
+plan = Plan(
+ steps=[
+ Step("setup", operations=[Op("create_directory", args={"path": "out"})]),
+ Step(
+ "write",
+ depends_on=["setup"],
+ parallel=True,
+ operations=[
+ Op("write_file", generate=Generate(
+ instructions="Write the introduction.",
+ output_schema='{"path": "out/intro.md", "content": "..."}',
+ )),
+ ],
+ ),
+ ],
+ validation=[
+ Validation("check_word_count", args={"path": "out/intro.md", "min_words": 200}),
+ ],
+)
+```
+
+IDE autocomplete, Pylance type-checks, no escaping nightmares.
+
+## Output → input across steps with `Ref`
+
+Wire the **whole output** of one step into the args of a later step with `Ref("step_id")`. No JSON path, no field selection, no Conductor task-ref naming to memorise.
+
+```python
+from agentspan.agents import Op, Plan, Ref, Step
+
+plan = Plan(steps=[
+ Step("fetch", operations=[Op("fetch_data", args={"url": URL})]),
+ Step(
+ "summarize",
+ depends_on=["fetch"],
+ operations=[
+ # The whole dict returned by `fetch_data` becomes the value of
+ # the `document` arg passed to `summarize`. No `.result` suffix,
+ # no JSONPath — the SDK serialises Ref(...) to {"$ref": "fetch"}
+ # and the server rewrites it to the right Conductor template
+ # against an INLINE wrapper that normalises dict vs. wrapped
+ # worker returns.
+ Op("summarize", args={"document": Ref("fetch")}),
+ ],
+ ),
+])
+```
+
+Rules:
+
+- The referenced step must be declared in this step's `depends_on` — explicit beats implicit. The server's PAC compile step rejects plans that Ref a step they don't depend on (the typed-Plan builders ship the Ref to the wire as-is; the failure surfaces at workflow start, not in your IDE).
+- The referenced step must exist in the plan.
+- Self-Refs (`Ref(stepId)` from inside `stepId`) are a compile error.
+- A step can Ref multiple upstream steps independently — `Op("report", args={"src": Ref("fetch"), "summary": Ref("summarize")})` works.
+- For a `parallel=True` step, `Ref("step_id")` resolves to the **array of branch results** (the FORK_JOIN aggregator's payload).
+- Refs work inside nested args (lists, nested dicts) — the serialiser walks the whole arg tree.
+
+See `examples/108_plan_execute_refs.py` for a three-step pipeline that pipes one step's record dict through two downstream steps without ever spelling out a JSONPath.
+
+## Static plans — skip the planner LLM
+
+Pass a `Plan` (or a raw dict in the same shape) to `runtime.run` and PAC uses it directly:
+
+```python
+result = runtime.run(harness, "anything", plan=plan, cwd=work_dir)
+```
+
+The planner LLM still runs (the workflow shape is fixed at compile time) but its output is discarded — PAC's `extract_json` reads `workflow.input.static_plan` as Case 0, which wins over planner output. Use this for:
+
+- Tests (deterministic plan, no LLM nondeterminism)
+- Replays of a previously-emitted plan
+- Pipelines where planning lives outside the agent (a separate service or a code path that builds the `Plan` object)
+
+## Tool guardrails propagate
+
+`@tool(guardrails=[...])` works inside PLAN_EXECUTE the same way it works in the LLM-loop:
+
+```python
+no_pii = RegexGuardrail(patterns=[r"\b\d{16}\b"], on_fail=OnFail.RAISE, ...)
+
+@tool(guardrails=[no_pii])
+def send_email(to: str, body: str) -> str: ...
+```
+
+PAC wraps every emitted SIMPLE for `send_email` in a guardrail SWITCH gate. The bare SIMPLE only runs from the gate's `pass` branch. If the guardrail trips:
+
+- `on_fail=raise` — TERMINATE the dynamic plan; harness's `fallback` agent recovers
+- `on_fail=retry` / `fix` / `human` — collapse to TERMINATE in plan mode; same fallback path. (See `OnFail` docstring for full semantics — there's no LLM loop in plan mode to feed retry feedback into; the fallback IS the retry loop.)
+
+The compiler emits **only the SWITCH cases that are reachable** for the configured `on_fail`. An `on_fail=raise` guardrail produces one `raise` case, not four dead branches.
+
+## Fallback — the recovery agent
+
+Configure `fallback=` on the harness for adaptive recovery when:
+
+- The planner emits a malformed plan (PAC validation fails)
+- A guardrail trips on a deterministic step
+- A plan step itself fails at run time
+
+The fallback runs as a normal LLM-loop agent with the harness's `tools`. It receives the original prompt + the failure context (planner output, error message). `fallback_max_turns` caps its turn count during recovery.
+
+Without a fallback, any failure terminates the workflow. Acceptable for fail-loud pipelines; surprising otherwise — PAC **refuses to compile** when guardrails with `on_fail=retry|fix|human` are configured but no fallback exists, forcing you to either configure a fallback or explicitly set `on_fail=raise` to acknowledge fail-closed semantics.
+
+## What PAC actually emits
+
+For a plan with N parallel steps + 1 validator, the compiled WorkflowDef looks roughly like:
+
+```
+SET_VARIABLE _ctx_init
+FORK_JOIN (per-step branches)
+ LLM_CHAT_COMPLETE (per generate op)
+ INLINE (parse LLM JSON output)
+ SWITCH (parse-error gate)
+ SIMPLE (the tool call)
+JOIN
+INLINE (aggregate parallel branch results — only if downstream reads it)
+SIMPLE (validator)
+INLINE (val_eval — emits "passed"/"failed")
+SWITCH vsw ("passed" → on_success, default → TERMINATE/on_failure)
+```
+
+Visually, for a 3-section parallel-write plan with one validator:
+
+```mermaid
+flowchart TB
+ Start([start]) --> Init["SET_VARIABLE _ctx_init"]
+ Init --> Fork{{"FORK_JOIN"}}
+
+ Fork --> S1L["LLM_CHAT_COMPLETE section_1 generate"]
+ S1L --> S1P["INLINE parse JSON"]
+ S1P --> S1S{"SWITCH parse ok?"}
+ S1S -- ok --> S1T["SIMPLE write_file"]
+ S1S -- fail --> S1F["TERMINATE"]
+
+ Fork --> S2L["LLM_CHAT_COMPLETE section_2 generate"]
+ S2L --> S2P["INLINE parse JSON"]
+ S2P --> S2S{"SWITCH parse ok?"}
+ S2S -- ok --> S2T["SIMPLE write_file"]
+ S2S -- fail --> S2F["TERMINATE"]
+
+ Fork --> S3L["LLM_CHAT_COMPLETE section_3 generate"]
+ S3L --> S3P["INLINE parse JSON"]
+ S3P --> S3S{"SWITCH parse ok?"}
+ S3S -- ok --> S3T["SIMPLE write_file"]
+ S3S -- fail --> S3F["TERMINATE"]
+
+ S1T --> Join((JOIN))
+ S2T --> Join
+ S3T --> Join
+
+ Join --> Agg["INLINE step_output_write_all (Ref normaliser)"]
+ Agg --> Val["SIMPLE check_word_count"]
+ Val --> VEval["INLINE val_eval"]
+ VEval --> VSW{"SWITCH passed?"}
+ VSW -- passed --> OK([COMPLETED])
+ VSW -- failed --> Bad([TERMINATE / on_failure])
+
+ classDef llm fill:#fff3e0,stroke:#e65100;
+ classDef pure fill:#e8f5e9,stroke:#1b5e20;
+ classDef tool fill:#e3f2fd,stroke:#0d47a1;
+ classDef gate fill:#fce4ec,stroke:#880e4f;
+ class S1L,S2L,S3L llm;
+ class S1P,S2P,S3P,Agg,VEval,Init pure;
+ class S1T,S2T,S3T,Val tool;
+ class S1S,S2S,S3S,VSW,Fork,Join gate;
+```
+
+Only the orange `LLM_CHAT_COMPLETE` nodes are non-deterministic. Everything else — parse, gate, tool call, aggregate, validate, branch — is pure Conductor and replay-safe. With a **static plan** (`plan=` argument), the planner LLM call up-front is elided too, leaving a fully deterministic pipeline.
+
+The `## Available tools` block in the planner prompt and PAC's validator share the same source: `harness.tools`. A planner can't emit a tool name that PAC will reject (and PAC will reject anything not in the harness's set — closes the hallucinated-tool-name bug).
+
+## Common patterns
+
+### Research report (LLM-driven planning)
+
+```python
+harness = plan_execute(
+ name="report",
+ tools=[create_directory, write_file, assemble_files, check_word_count],
+ planner_instructions="Plan a research report on the user's topic. Use 3 sections.",
+ fallback_instructions="Fix what the deterministic plan couldn't.",
+)
+result = runtime.run(harness, "AI agents in 2025")
+```
+
+### Static pipeline (no planner reasoning needed)
+
+```python
+harness = plan_execute(name="ingest", tools=[fetch, transform, store])
+plan = Plan(steps=[
+ Step("fetch", operations=[Op("fetch", args={"url": url})]),
+ Step("transform", depends_on=["fetch"], operations=[Op("transform", args={"path": "raw.json"})]),
+ Step("store", depends_on=["transform"], operations=[Op("store", args={"key": "result"})]),
+])
+result = runtime.run(harness, "ingest job", plan=plan)
+```
+
+### Parallel work + validation
+
+```python
+plan = Plan(
+ steps=[
+ Step("setup", operations=[Op("create_directory", args={"path": "out"})]),
+ Step("write_all", depends_on=["setup"], parallel=True, operations=[
+ Op("write_file", generate=Generate(
+ instructions=f"Write section {i}.",
+ output_schema=f'{{"path": "out/{i}.md", "content": "..."}}',
+ ))
+ for i in range(5)
+ ]),
+ Step("assemble", depends_on=["write_all"], operations=[
+ Op("assemble_files", args={"output_path": "report.md", "input_paths": "..."})
+ ]),
+ ],
+ validation=[Validation("check_word_count", args={"path": "report.md", "min_words": 1000})],
+)
+```
+
+## Planner context — ground the planner in your domain rules
+
+The planner's `instructions` are fine for "how to emit a plan." They're a poor fit for the *domain-specific rules* a real plan depends on: KYC tier thresholds, onboarding phase ordering, compliance escalation paths, region-specific exceptions. Those live in docs that change weekly — not in code that ships quarterly.
+
+`planner_context` injects those rules into the planner's user prompt at runtime, as a `## Reference Context` block. Two entry shapes:
+
+```python
+from agentspan.agents import Agent, Context, Strategy
+
+harness = Agent(
+ name="onboarding_harness",
+ strategy=Strategy.PLAN_EXECUTE,
+ tools=[validate_kyc, create_account, send_welcome_email],
+ planner=planner,
+ fallback=fallback,
+ planner_context=[
+ # 1) Inline text — short, stable, hand-edited in code.
+ "Onboarding has 3 mandatory phases in order: validate_kyc → create_account → send_welcome_email.",
+ "Tier 'enterprise' customers ADDITIONALLY require schedule_kickoff_call.",
+
+ # 2) Live doc — fetched per planner invocation, no compile-time fetch, no cache.
+ # Authorization placeholders use the same `${CRED}` shape as ToolConfig.headers.
+ Context(
+ url="https://confluence.example.com/onboarding-rules",
+ headers={"Authorization": "Bearer ${CONFLUENCE_TOKEN}"},
+ required=True, # fetch failure → workflow fails (default)
+ max_bytes=8192, # truncate at 8KB + add a [doc truncated] marker
+ ),
+ ],
+)
+```
+
+**How it compiles.** Each URL entry emits a `PLANNER_CONTEXT_FETCH` system task inside the planner-route's *live* branch (the static-plan path skips it for free). With ≥2 URLs the fetches are wrapped in a `FORK_JOIN` so they run in parallel. A small in-process TTL cache (default 60 s) plus `If-None-Match`/ETag means repeat fetches for the same doc within the TTL return the cached body without touching the wire — and 304 responses refresh the TTL without re-downloading.
+
+**Cache scope.** Cache key is `(url, sorted-headers)`, so different `Authorization` headers (different principals) never share a cache entry. Bounded LRU at ~1024 entries.
+
+**Credential placeholders.** `${CRED_NAME}` in headers gets escaped server-side to `#{CRED_NAME}` so Conductor's templater leaves it alone; the runtime credential resolver fills the value at request time — same pipeline as HTTP tool headers. Headers containing `CR`/`LF` are rejected at compile time to close the HTTP-response-splitting injection vector.
+
+**Failure handling.** `required=True` (default) hard-fails the workflow on fetch error. `required=False` substitutes a `[doc unavailable]` marker in the planner prompt so the planner runs on partial context — for "nice-to-have" docs (glossaries, FAQs).
+
+End-to-end demo: `examples/115_plan_execute_planner_context.py` (Python; mirrored to TS / Java / C#).
+
+## Inspecting compiled plans
+
+`POST /api/agent/inspect-plan` compiles a plan against a PLAN_EXECUTE harness config and returns the resulting Conductor `WorkflowDef` + error string + warnings + stats — **without dispatching the SUB_WORKFLOW**. Useful for:
+
+* IDE tooling validating that a plan compiles cleanly against a fixed agent config before deploy
+* Plan-debug REPLs visualizing the compiled DAG
+* CI checks that verify a static plan still compiles after agent-config or tool-schema changes
+
+Request shape:
+
+```json
+{
+ "agentConfig": { /* same shape as POST /agent/start */ },
+ "plan": { "steps": [ { "id": "...", "operations": [ ... ] } ] }
+}
+```
+
+Response includes the same fields PAC sets on `output` at execution time (`workflowDef`, `error`, `warnings`, `stats`) — uses the production compile path, so the inspected output is byte-equal to what a real run would produce for that plan.
+
+## Knobs reference
+
+| Field | Purpose |
+|---|---|
+| `planner=` | Required. The agent that emits the JSON plan. |
+| `fallback=` | Optional. Agentic recovery when a plan can't compile/exec. |
+| `tools=` | Required. Plan-executable tool set. PAC validates `op.tool` names against this list and propagates each tool's guardrails. |
+| `planner_context=` | Optional. List of `Context(text=…)` / `Context(url=…)` entries appended to the planner's user prompt as `## Reference Context`. URLs fetched per-planner-invocation with TTL cache + ETag revalidation. See "Planner context" above. |
+| `fallback_max_turns=` | Caps the fallback agent's turn count during recovery. |
+| `plan_source=` | Optional. Reads a fixed plan from a deterministic tool call after the planner sub-workflow runs. When the planner's text output fails extraction, this source is tried as a fallback. The newer run-time `plan=` argument (see "Static plans" below) is the simpler path for most cases. |
+
+| Run-time kwarg | Purpose |
+|---|---|
+| `plan=` | Skip the planner LLM's output; use this `Plan`/dict directly. |
+| `cwd=` | Working directory for filesystem-bound tools. |
+
+## Examples
+
+- `examples/85_plan_execute_harness.py` — research report with LLM planner + fallback recovery
+- `examples/103_plan_and_compile.py` — minimal PAC demo with `args` + `generate` ops + validation
+- `examples/104_plan_execute_guardrails.py` — guardrail propagation in plan mode
+- `examples/100_issue_fixer_agent.py` — production-shape pipeline with PLAN_EXECUTE coder + agentic fallback
+- `examples/108_plan_execute_refs.py` — cross-step output piping via `Ref("step_id")`
+- `examples/109_plan_execute_replan.py` — outer-loop replan pattern: run plan, inspect result, build the next plan with feedback baked into the per-op `generate.instructions`
+- `examples/110_plan_execute_replan_solve.py` — adaptive goal-seeking loop: K parallel proposers + deterministic verifier per iteration; the replanner threads each candidate's exact failure modes back into the next iteration's prompt and loops until any candidate clears all constraints
+- `examples/111_plan_execute_replan_binsearch.py` — many-iteration binary-search loop. The verifier holds a secret integer and each iteration's verdict reveals only one bit (too_low / too_high), so the loop *must* iterate ~log₂ N times. Use this when you want to see the plan-execute-replan cycle visibly converge over many iterations
+- `examples/112_dowhile_loop_inside_workflow.py` — the loop *inside* a single Conductor workflow via a hand-built `DO_WHILE` task. Body of the loop: planner LLM → INLINE verify → reviewer LLM → SET_VARIABLE update. One workflow ID for the whole run; iterations show up as `planner_llm__1`, `planner_llm__2`, etc. in the same workflow's task list. This is the shape of the future `Strategy.PLAN_EXECUTE_REPLAN` (recommendation #2 from the design review)
+- `examples/113_aml_sar_investigation_loop.py` — AML/SAR investigation as a DO_WHILE-inside-workflow with real PAC sub-workflows per iteration. The planner emits red-flag tool calls, the loop checks for "needs more evidence", and the cycle continues until the case is dispositioned. Mirrors finance compliance workflows
+- `examples/114_portfolio_rebalance_loop.py` — multi-constraint portfolio rebalancing with wash-sale / concentration / drift checks. Each iteration refines the trade list to satisfy more constraints; the loop terminates when all checks pass
+- `examples/115_plan_execute_planner_context.py` — customer onboarding with `planner_context` grounding the planner in tier rules. Mixed inline-text + commented Confluence-URL with `${CONFLUENCE_TOKEN}` reference for the credentialed-URL pattern. Mirrored to TS/Java/C#
+
+## Plan → execute → replan
+
+PAE itself is single-shot: plan-once, execute-once, fallback-once on hard failure. For tasks that need iterative refinement — run, check the output, decide to continue or replan, repeat — wrap the harness in your own loop. `examples/109_plan_execute_replan.py` shows the simple shape: each iteration calls `runtime.run(harness, prompt, plan=plan_N)`, the host code reads the artifacts the run produced, a decider returns `done | replan`, and a builder constructs `plan_{N+1}` with the prior iteration's measurements baked into the LLM instructions. The inner per-iteration run stays deterministic; the outer loop carries the adaptive control flow.
+
+`examples/110_plan_execute_replan_solve.py` shows the *adaptive goal-seeking* variant. Each iteration's plan emits **K parallel proposers** (generate ops in a FORK_JOIN step) feeding a deterministic verifier that produces a precise **per-candidate, per-constraint failure breakdown** (e.g. `word_count_off (got 21, expected 25)`). The outer loop reads the verdict JSON, terminates the moment any candidate clears all constraints, and otherwise threads each prior candidate's exact failures into the next iteration's `generate.instructions`. The result is a real plan → execute → replan → execute cycle that converges by *fixing what the previous attempt got wrong*, not by retrying the same prompt with a different seed. The pattern generalises to any LLM-generator + deterministic-verifier loop — swap the verifier for `run_pytest`, `check_proof`, `query_db`, etc., and the outer loop is identical.
+
+## Failure modes
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| Workflow FAILED with "uses unknown tool" in PAC error | Planner emitted a tool name not in `harness.tools` | Add the tool, or fix the planner prompt; the auto-injected `## Available tools` block already constrains the planner — check it appears in your prompt |
+| Workflow FAILED, no fallback ran | `plan_exec` SUB_WORKFLOW failure not caught | Confirm `harness.fallback` is set; failures route through `exec_route` SWITCH to fallback |
+| Compile fails with "guardrails with on_fail=retry\|fix\|human but no fallback" | PAC blocks compile to prevent the silent degrade-to-terminate footgun | Configure a fallback or set `on_fail=raise` |
+| Compile fails with "uses unsupported JSON Schema keyword '$ref' (or `oneOf`/`allOf`/`format`/etc.)" | The runtime input-schema validator implements a Draft-07 subset — keywords like `$ref`/`allOf`/`oneOf`/`format` would silently pass at runtime, producing *permissive validation*. PAC rejects at compile time instead | Restrict the tool's `inputSchema` to the supported subset (`type`, `properties`, `required`, `additionalProperties`, `enum`, `minLength`, `maxLength`, `pattern`, `minimum`, `maximum`, `items`, `minItems`, `maxItems`) or remove the misleading constraint |
+| Compile fails with "plannerContext header '...' contains CR/LF" | A `Context(url=…, headers=…)` value contained a newline — would smuggle a fake HTTP header (response-splitting vector) | Sanitize the credential value; CR/LF in HTTP header values is never legitimate |
+| `[doc unavailable]` markers in the planner's `## Reference Context` block | `Context(url=…, required=False)` doc fetch returned non-2xx | If the doc IS required, set `required=True` (default) so the workflow fails loudly instead. If it's truly optional, the marker is the intended behaviour |
+| Plan compiled but did wrong thing | Planner LLM produced a syntactically-valid but semantically-wrong plan | Improve `planner_instructions`; consider switching to `plan=` static plan for deterministic flows. For domain rules, lift them into `planner_context` so the planner re-reads them on every run instead of relying on the static `instructions` |
+| Need to see what PAC will compile a plan to without running it | Use the `POST /api/agent/inspect-plan` endpoint — same compile path PAC uses at runtime, no SUB_WORKFLOW dispatch | See "Inspecting compiled plans" above |
diff --git a/docs/sdk-design/2026-03-23-multi-language-sdk-design.md b/docs/sdk-design/2026-03-23-multi-language-sdk-design.md
index 61dd5a442..291688883 100644
--- a/docs/sdk-design/2026-03-23-multi-language-sdk-design.md
+++ b/docs/sdk-design/2026-03-23-multi-language-sdk-design.md
@@ -310,7 +310,7 @@ This is the JSON structure that every SDK must produce when serializing an Agent
{
"name": "agent_name",
"model": "provider/model_name",
- "strategy": "handoff|sequential|parallel|router|round_robin|random|swarm|manual",
+ "strategy": "handoff|sequential|parallel|router|round_robin|random|swarm|manual|plan_execute",
"maxTurns": 25,
"timeoutSeconds": 300,
"external": false,
@@ -329,7 +329,16 @@ This is the JSON structure that every SDK must produce when serializing an Agent
"allowedTransitions": { "agent_a": ["agent_b", "agent_c"] },
"introduction": "I am agent X, I specialize in...",
"metadata": { "key": "value" },
- "planner": true,
+
+ // Plan-first preamble (Google ADK feature) — Boolean.
+ "enablePlanning": true,
+
+ // PLAN_EXECUTE named slots (only with strategy=plan_execute).
+ // Both nest as full AgentConfig objects, NOT booleans. See §3.9.
+ "planner": AgentConfig,
+ "fallback": AgentConfig,
+ "fallbackMaxTurns": 5,
+
"callbacks": [ { "position": "before_agent", "taskName": "agent_name_before_agent" } ],
"includeContents": "default|none",
"thinkingConfig": { "enabled": true, "budgetTokens": 1024 },
@@ -482,6 +491,89 @@ Composable with AND/OR operators:
}
```
+### 3.9 PLAN_EXECUTE — Typed Plan Builders + `Ref`
+
+`Strategy.PLAN_EXECUTE` (also called PAC/PAE — Plan-and-Compile / Plan-and-Execute) splits a task into two phases: a **planner** agent emits a JSON DAG of operations, and the server compiles that JSON into a deterministic Conductor sub-workflow. See `docs/concepts/plan-execute.md` for the conceptual overview.
+
+Every SDK that exposes PLAN_EXECUTE **must** provide:
+
+1. A `Strategy.plan_execute` enum value.
+2. `Agent.planner` (required when strategy is `plan_execute`) and `Agent.fallback` (optional) — both nest as full `AgentConfig` objects, NOT booleans. The legacy "plan-first preamble" boolean lives at `Agent.enablePlanning` (renamed to free the `planner` JSON key for this sub-agent slot).
+3. Typed plan builders: `Plan`, `Step`, `Op`, `Generate`, `Validation`, `Action`.
+4. A `Ref(stepId)` helper for cross-step output piping.
+5. A `runtime.run(harness, prompt, plan=...)` overload that forwards the plan as `static_plan` on the start payload.
+
+#### Plan JSON shape
+
+The wire format is identical across SDKs — what every SDK's `Plan.to_dict()` (or equivalent) must produce:
+
+```json
+{
+ "steps": [
+ {
+ "id": "",
+ "depends_on": [""],
+ "parallel": false,
+ "operations": [
+ { "tool": "", "args": { } },
+ { "tool": "", "generate": {
+ "instructions": "",
+ "output_schema": "",
+ "max_tokens": 4096,
+ "context": ""
+ }}
+ ]
+ }
+ ],
+ "validation": [
+ { "tool": "", "args": {...}, "success_condition": "$.passed === true" }
+ ],
+ "on_success": [{ "tool": "", "args": {...} }],
+ "on_failure": [{ "tool": "", "args": {...} }]
+}
+```
+
+#### `Ref` — cross-step output piping
+
+`Ref("step_id")` wires the **whole output** of an upstream step into a downstream step's args. The serializer walks every plan-value tree (`Op.args`, `Generate.context`, `Validation.args`, `Action.args`) recursively and replaces nested `Ref` instances with their wire marker:
+
+```json
+{ "$ref": "step_id" }
+```
+
+The server's PAC compiler rewrites these markers to Conductor template expressions pointing at a per-step `step_output_` INLINE wrapper that normalises dict-vs-string worker returns into `.output.result`. Users get "the whole output of step X" with no JSONPath syntax.
+
+**Plan-validation rules every SDK must trigger via the server (the SDK can also pre-validate for nicer errors):**
+
+- Self-Refs (`Ref(stepId)` inside `stepId`) are a hard error.
+- A `Ref` whose target doesn't exist in the plan is a hard error.
+- A `Ref` whose target isn't in the step's `depends_on` is a hard error. Explicit deps keep the data flow visible in the plan instead of hidden behind a runtime Conductor template.
+
+#### `static_plan` — skip the planner LLM
+
+The SDK's `runtime.run(harness, prompt, plan=...)` (or equivalent) must forward the supplied plan as a new top-level field `static_plan` on `POST /api/agent/start`:
+
+```json
+{
+ "agentConfig": { ... },
+ "prompt": "...",
+ "static_plan": { "steps": [...] }
+}
+```
+
+The server's `extract_json` INLINE reads `workflow.input.static_plan` as **Case-0** (highest priority) and discards whatever the planner sub-agent emits. The planner LLM still runs (the workflow shape is fixed at compile time) but its output is ignored. Use this for tests, replays, and pipelines where planning lives outside the agent.
+
+#### Reference implementations
+
+| Language | Plan builders | Example | `Ref` impl |
+|---|---|---|---|
+| Python | `agentspan.agents.plans` (Plan/Step/Op/Generate/Validation/Action) | `sdk/python/examples/108_plan_execute_refs.py` | `Ref` dataclass + `_serialize_value` walk |
+| TypeScript | `Plan`, `Step`, `Op`, `Generate`, `Validation`, `Action` in `src/plans.ts` | `sdk/typescript/examples/108-plan-execute-refs.ts` | `Ref` class + `serializePlanValue` walk |
+| Java | `ai.agentspan.plans.*` builders | `sdk/java/examples/.../Example108PlanExecuteRefs.java` | `Ref` final class + `PlanValues.serializeValue` walk |
+| C# | `Agentspan.Plans.*` records | `sdk/csharp/examples/108_PlanExecuteRefs/` | `Ref` sealed class + `PlanValues.SerializeValue` walk |
+
+When adding a new SDK, mirror the Python file as the reference; **the wire JSON must match byte-for-byte** for round-tripping with the Python SDK and the existing server PAC compiler.
+
---
## 4. Conceptual Model — SDK Public API
diff --git a/mkdocs.yml b/mkdocs.yml
index f102c3a8e..fc2b33112 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -64,6 +64,7 @@ nav:
- Agents: concepts/agents.md
- Tools: concepts/tools.md
- Multi-Agent Strategies: concepts/multi-agent.md
+ - Plan-Execute (PAC/PAE): concepts/plan-execute.md
- Guardrails: concepts/guardrails.md
- Memory: concepts/memory.md
- Streaming: concepts/streaming.md
diff --git a/sdk/csharp/examples/108_PlanExecuteRefs/Example108PlanExecuteRefs.csproj b/sdk/csharp/examples/108_PlanExecuteRefs/Example108PlanExecuteRefs.csproj
new file mode 100644
index 000000000..aa44b46c2
--- /dev/null
+++ b/sdk/csharp/examples/108_PlanExecuteRefs/Example108PlanExecuteRefs.csproj
@@ -0,0 +1,12 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
diff --git a/sdk/csharp/examples/108_PlanExecuteRefs/Program.cs b/sdk/csharp/examples/108_PlanExecuteRefs/Program.cs
new file mode 100644
index 000000000..55cdd2fa8
--- /dev/null
+++ b/sdk/csharp/examples/108_PlanExecuteRefs/Program.cs
@@ -0,0 +1,160 @@
+// Copyright (c) 2025 Agentspan
+// Licensed under the MIT License.
+
+// 108 — Plan-Execute with cross-step output piping via `Ref`.
+//
+// The `new Ref("step_id")` helper wires the whole output of an upstream
+// step into a downstream step's args. No JSON path, no field selection,
+// no internal task-ref naming to memorise — one expression and the
+// runtime substitutes the value at execution time.
+//
+// This example runs a three-step pipeline:
+//
+// produce → enrich → report
+//
+// `produce` emits a record dict, `enrich` adds a derived field via
+// Ref("produce"), and `report` reads Ref("enrich") to format a final
+// summary. The plan is fully deterministic — no planner LLM required —
+// because we pass it directly to RunAsync.
+
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Agentspan;
+using Agentspan.Examples;
+using Agentspan.Plans;
+
+// ── Tool implementations ─────────────────────────────────
+
+internal sealed class PipelineTools
+{
+ [Tool("Return a fixed payload.")]
+ public Dictionary Produce(string record_id) => new()
+ {
+ ["record_id"] = record_id,
+ ["value"] = 42,
+ ["tags"] = new[] { "alpha", "beta" },
+ };
+
+ [Tool("Append a derived field. Reads the whole `produce` output via Ref.")]
+ public Dictionary Enrich(JsonElement record)
+ {
+ var dict = JsonSerializer.Deserialize>(record.GetRawText())!;
+ var value = ((JsonElement)dict["value"]!).GetInt32();
+ dict["value_squared"] = value * value;
+ return dict;
+ }
+
+ [Tool("Format the final report. Reads BOTH upstream steps via Refs.")]
+ public Dictionary Report(JsonElement record, JsonElement enriched)
+ {
+ var recordId = record.GetProperty("record_id").GetString();
+ var value = record.GetProperty("value").GetInt32();
+ var tags = record.GetProperty("tags").EnumerateArray()
+ .Select(e => e.GetString()!).ToList();
+ var squared = enriched.GetProperty("value_squared").GetInt32();
+ return new Dictionary
+ {
+ ["id"] = recordId,
+ ["original_value"] = value,
+ ["squared"] = squared,
+ ["tags_joined"] = string.Join(", ", tags),
+ ["summary"] = $"record={recordId} value={value} squared={squared} tags=[{string.Join(", ", tags)}]",
+ };
+ }
+}
+
+// ── Main ─────────────────────────────────────────────────
+
+var planner = new Agent("ref_demo_planner")
+{
+ Model = Settings.LlmModel,
+ Instructions = "(planner unused; static plan supplied)",
+};
+
+var harness = new Agent("ref_demo")
+{
+ Model = Settings.LlmModel,
+ Strategy = Strategy.PlanExecute,
+ Planner = planner,
+ Tools = ToolRegistry.FromInstance(new PipelineTools()),
+};
+
+// Typed plan — no JSON strings, no field selectors. Each Ref serialises
+// to {"$ref":""} which the server rewrites to the right
+// Conductor template at compile time.
+var plan = new Plan
+{
+ Steps =
+ {
+ new Step("produce")
+ {
+ Operations =
+ {
+ new Op("produce", new Dictionary { ["record_id"] = "r-001" }),
+ },
+ },
+ new Step("enrich")
+ {
+ DependsOn = { "produce" },
+ Operations =
+ {
+ new Op("enrich", new Dictionary { ["record"] = new Ref("produce") }),
+ },
+ },
+ new Step("report")
+ {
+ DependsOn = { "produce", "enrich" },
+ Operations =
+ {
+ new Op("report", new Dictionary
+ {
+ ["record"] = new Ref("produce"),
+ ["enriched"] = new Ref("enrich"),
+ }),
+ },
+ },
+ },
+};
+
+await using var runtime = new AgentRuntime();
+var result = await runtime.RunAsync(harness, "demo", plan: plan);
+
+Console.WriteLine($"status={result.Status} executionId={result.ExecutionId}");
+await ShowPipelineOutputsAsync(result.ExecutionId);
+
+// ── Trace helper — reads task outputs from the workflow API ───
+
+static async Task ShowPipelineOutputsAsync(string executionId)
+{
+ var baseUrl = (Environment.GetEnvironmentVariable("AGENTSPAN_SERVER_URL")
+ ?? "http://localhost:6767/api").Replace("/api", "");
+ using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
+
+ var parentBody = await http.GetStringAsync($"{baseUrl}/api/workflow/{executionId}?includeTasks=true");
+ var parent = JsonNode.Parse(parentBody)!.AsObject();
+ string? subId = null;
+ foreach (var t in parent["tasks"]?.AsArray() ?? new JsonArray())
+ {
+ var refName = t?["referenceTaskName"]?.GetValue() ?? "";
+ if (refName.EndsWith("_plan_exec"))
+ {
+ subId = t?["outputData"]?["subWorkflowId"]?.GetValue();
+ break;
+ }
+ }
+ if (subId is null) return;
+
+ var subBody = await http.GetStringAsync($"{baseUrl}/api/workflow/{subId}?includeTasks=true");
+ var sub = JsonNode.Parse(subBody)!.AsObject();
+
+ Console.WriteLine("\n── pipeline trace (Ref data flow) ────────────────────────");
+ foreach (var t in sub["tasks"]?.AsArray() ?? new JsonArray())
+ {
+ var name = t?["taskDefName"]?.GetValue() ?? "";
+ if (name is "produce" or "enrich" or "report")
+ {
+ Console.WriteLine($"\n{name}:");
+ Console.WriteLine(t?["outputData"]?.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+ }
+ }
+}
diff --git a/sdk/csharp/examples/115_PlanExecutePlannerContext/Example115PlanExecutePlannerContext.csproj b/sdk/csharp/examples/115_PlanExecutePlannerContext/Example115PlanExecutePlannerContext.csproj
new file mode 100644
index 000000000..aa44b46c2
--- /dev/null
+++ b/sdk/csharp/examples/115_PlanExecutePlannerContext/Example115PlanExecutePlannerContext.csproj
@@ -0,0 +1,12 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
diff --git a/sdk/csharp/examples/115_PlanExecutePlannerContext/Program.cs b/sdk/csharp/examples/115_PlanExecutePlannerContext/Program.cs
new file mode 100644
index 000000000..fa99db685
--- /dev/null
+++ b/sdk/csharp/examples/115_PlanExecutePlannerContext/Program.cs
@@ -0,0 +1,212 @@
+// Copyright (c) 2025 Agentspan
+// Licensed under the MIT License.
+
+// 115 — Plan-Execute with `PlannerContext`: customer onboarding plan.
+//
+// The PAE planner's static `Instructions` string is fine for *how* to emit
+// a plan, but it's a poor fit for the domain-specific rules a real plan
+// depends on — tier thresholds, KYC step ordering, region exceptions,
+// escalation rules. Those live in docs that change weekly, not in code.
+//
+// `PlannerContext` solves this: a list of text snippets and/or URLs
+// appended to the planner's user prompt as a `## Reference Context` block
+// on every planner invocation. URLs are fetched dynamically — no compile-
+// time fetch, no cache — so a Confluence edit lands on the next plan run
+// with zero redeploy.
+//
+// This example runs WITHOUT a real Confluence backend — the
+// `PlannerContext` is text-only by default. The `Context.FromUrl(...)`
+// example below is commented as a reference for how real installations
+// wire credentialed docs.
+//
+// Mirrors sdk/python/examples/115_plan_execute_planner_context.py,
+// sdk/typescript/examples/115-plan-execute-planner-context.ts,
+// and sdk/java/examples/.../Example115PlannerContext.java.
+
+using Agentspan;
+using Agentspan.Examples;
+using Agentspan.Plans;
+
+// ── Onboarding tools (deterministic, no external calls) ──────────────
+
+internal sealed class OnboardingTools
+{
+ [Tool("Validate a single KYC document. Phase 1 of onboarding.")]
+ public Dictionary ValidateKyc(string customer_id, string doc_type) => new()
+ {
+ ["customer_id"] = customer_id,
+ ["doc_type"] = doc_type,
+ ["status"] = "verified",
+ };
+
+ [Tool("Provision the customer's account record. Phase 2 of onboarding.")]
+ public Dictionary CreateAccount(string customer_id, string tier) => new()
+ {
+ ["customer_id"] = customer_id,
+ ["tier"] = tier,
+ ["account_id"] = $"acct_{customer_id}_{tier}",
+ ["status"] = "active",
+ };
+
+ [Tool("Send the tier-appropriate welcome email. Phase 3 of onboarding.")]
+ public Dictionary SendWelcomeEmail(string customer_id, string account_id) => new()
+ {
+ ["customer_id"] = customer_id,
+ ["account_id"] = account_id,
+ ["message_id"] = $"msg_{customer_id}",
+ ["status"] = "sent",
+ };
+
+ [Tool("Schedule the enterprise-tier kickoff call. Conditional on tier.")]
+ public Dictionary ScheduleKickoffCall(string customer_id, string account_id) => new()
+ {
+ ["customer_id"] = customer_id,
+ ["account_id"] = account_id,
+ ["calendar_invite_id"] = $"cal_{customer_id}",
+ ["status"] = "scheduled",
+ };
+}
+
+// ── Agents ──────────────────────────────────────────────────────────
+
+var planner = new Agent("onboarding_planner")
+{
+ Model = Settings.LlmModel,
+ MaxTurns = 3,
+ Instructions =
+ "You are an onboarding plan generator. Output a JSON plan that "
+ + "validates KYC, creates the account, and notifies the customer. "
+ + "Follow the rules in the Reference Context block exactly.",
+};
+
+var fallback = new Agent("onboarding_fallback")
+{
+ Model = Settings.LlmModel,
+ MaxTurns = 3,
+ Instructions =
+ "If you receive this, the plan compile failed. Run the four "
+ + "onboarding tools in their natural order: validate_kyc, "
+ + "create_account, send_welcome_email, and schedule_kickoff_call "
+ + "if the customer tier is 'enterprise'.",
+ Tools = ToolRegistry.FromInstance(new OnboardingTools()),
+};
+
+var harness = new Agent("onboarding_harness")
+{
+ Model = Settings.LlmModel,
+ Strategy = Strategy.PlanExecute,
+ Planner = planner,
+ Fallback = fallback,
+ FallbackMaxTurns = 3,
+ Tools = ToolRegistry.FromInstance(new OnboardingTools()),
+ PlannerContext = new List
+ {
+ // ── Inline rules: short, stable, hand-edited in code ──
+ Context.FromText(
+ "Onboarding has 3 mandatory phases in this exact order: "
+ + "(1) validate_kyc with doc_type='id', "
+ + "(2) create_account, "
+ + "(3) send_welcome_email."),
+ Context.FromText(
+ "Tier 'enterprise' customers ADDITIONALLY require step "
+ + "(4) schedule_kickoff_call AFTER send_welcome_email. "
+ + "Tiers 'starter' and 'pro' must NOT include this step."),
+ Context.FromText(
+ "send_welcome_email depends on create_account's output: "
+ + "use the account_id field as the account_id arg."),
+
+ // ── Live doc (commented out — uncomment if you have a real
+ // compliance/Confluence URL + token, demonstrates the
+ // URL+auth path the same way ToolConfig.Headers does):
+ // Context.FromUrl(
+ // "https://docs.example.com/onboarding-compliance.md",
+ // headers: new Dictionary
+ // {
+ // ["Authorization"] = "Bearer ${CONFLUENCE_TOKEN}",
+ // },
+ // required: true, // workflow fails if the doc can't be fetched
+ // maxBytes: 8192), // truncate giant wikis at 8KB
+ },
+};
+
+const string prompt =
+ "Onboard customer cust-001 at tier 'enterprise'. "
+ + "Use customer_id='cust-001' and tier='enterprise' for the tools.";
+
+await using var runtime = new AgentRuntime();
+var result = await runtime.RunAsync(harness, prompt);
+
+Console.WriteLine($"status: {result.Status}");
+Console.WriteLine($"output: {result.Output}");
+
+// Surface the executed plan steps so this example doubles as a proof
+// that the planner actually used the context (4 steps when
+// tier=enterprise, 3 when tier=starter/pro).
+await ShowExecutedSteps(result.ExecutionId);
+
+static async Task ShowExecutedSteps(string executionId)
+{
+ var baseUrl = (Environment.GetEnvironmentVariable("AGENTSPAN_SERVER_URL")
+ ?? "http://localhost:6767/api")
+ .TrimEnd('/')
+ .Replace("/api", "");
+ using var http = new HttpClient();
+ var parent = await http.GetFromJsonAsync(
+ $"{baseUrl}/api/workflow/{executionId}?includeTasks=true");
+
+ Console.WriteLine("\n=== Executed onboarding plan ===");
+
+ string? subId = null;
+ if (parent.TryGetProperty("tasks", out var tasks))
+ {
+ foreach (var t in tasks.EnumerateArray())
+ {
+ var refName = t.TryGetProperty("referenceTaskName", out var rn)
+ ? rn.GetString() ?? string.Empty
+ : string.Empty;
+ if (refName.EndsWith("_plan_exec"))
+ {
+ if (t.TryGetProperty("outputData", out var od)
+ && od.TryGetProperty("subWorkflowId", out var sid))
+ {
+ subId = sid.GetString();
+ }
+ break;
+ }
+ }
+ }
+
+ if (subId == null)
+ {
+ Console.WriteLine(" (no plan_exec sub-workflow — planner output was rejected)");
+ return;
+ }
+
+ var sub = await http.GetFromJsonAsync(
+ $"{baseUrl}/api/workflow/{subId}?includeTasks=true");
+ var expected = new HashSet
+ {
+ "validate_kyc", "create_account", "send_welcome_email", "schedule_kickoff_call",
+ };
+ int count = 0;
+ bool sawKickoff = false;
+ if (sub.TryGetProperty("tasks", out var subTasks))
+ {
+ foreach (var t in subTasks.EnumerateArray())
+ {
+ var name = t.TryGetProperty("taskDefName", out var n) ? n.GetString() ?? "" : "";
+ if (expected.Contains(name))
+ {
+ count++;
+ if (name == "schedule_kickoff_call") sawKickoff = true;
+ var status = t.TryGetProperty("status", out var s) ? s.GetString() : "";
+ Console.WriteLine($" {status,-10} {name}");
+ }
+ }
+ }
+ Console.WriteLine($" {count} step(s) executed");
+ if (sawKickoff)
+ {
+ Console.WriteLine(" ✓ planner picked up the 'enterprise tier needs kickoff' rule");
+ }
+}
diff --git a/sdk/csharp/examples/48_Planner/Program.cs b/sdk/csharp/examples/48_Planner/Program.cs
index 0ec30d7cc..120b37cbf 100644
--- a/sdk/csharp/examples/48_Planner/Program.cs
+++ b/sdk/csharp/examples/48_Planner/Program.cs
@@ -21,8 +21,8 @@
Instructions =
"You are a research writer. Research topics thoroughly and " +
"write structured reports with multiple sections.",
- Tools = ToolRegistry.FromInstance(new ResearchTools()),
- Planner = true,
+ Tools = ToolRegistry.FromInstance(new ResearchTools()),
+ EnablePlanning = true,
};
await using var runtime = new AgentRuntime();
diff --git a/sdk/csharp/src/Agentspan/Agent.cs b/sdk/csharp/src/Agentspan/Agent.cs
index 2da9e5d55..c15305a2a 100644
--- a/sdk/csharp/src/Agentspan/Agent.cs
+++ b/sdk/csharp/src/Agentspan/Agent.cs
@@ -3,6 +3,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
+using Agentspan.Plans;
namespace Agentspan;
@@ -10,14 +11,15 @@ namespace Agentspan;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum Strategy
{
- [JsonPropertyName("handoff")] Handoff,
- [JsonPropertyName("sequential")] Sequential,
- [JsonPropertyName("parallel")] Parallel,
- [JsonPropertyName("router")] Router,
- [JsonPropertyName("round_robin")] RoundRobin,
- [JsonPropertyName("random")] Random,
- [JsonPropertyName("swarm")] Swarm,
- [JsonPropertyName("manual")] Manual,
+ [JsonPropertyName("handoff")] Handoff,
+ [JsonPropertyName("sequential")] Sequential,
+ [JsonPropertyName("parallel")] Parallel,
+ [JsonPropertyName("router")] Router,
+ [JsonPropertyName("round_robin")] RoundRobin,
+ [JsonPropertyName("random")] Random,
+ [JsonPropertyName("swarm")] Swarm,
+ [JsonPropertyName("manual")] Manual,
+ [JsonPropertyName("plan_execute")] PlanExecute,
}
///
@@ -38,7 +40,52 @@ public sealed class Agent
public double? Temperature { get; set; }
public int? TimeoutSeconds { get; set; }
public bool External { get; set; }
- public bool Planner { get; set; }
+ ///
+ /// When true, the server augments the system prompt with a
+ /// "plan first, then execute" preamble (Google ADK feature). Unrelated
+ /// to the {@link Planner} PLAN_EXECUTE sub-agent slot below.
+ ///
+ /// Renamed from the legacy {@code Planner: bool} once that JSON key
+ /// became the PAC/PAE planner sub-agent slot.
+ ///
+ public bool EnablePlanning { get; set; }
+
+ ///
+ /// {@code Strategy.PlanExecute}: the agent that produces the JSON
+ /// plan. Required when Strategy is PlanExecute. The planner sub-agent
+ /// can itself be a multi-agent (e.g. a SEQUENTIAL of explorer +
+ /// planner). Replaces the legacy positional {@code agents[0]}.
+ ///
+ public Agent? Planner { get; set; }
+
+ ///
+ /// {@code Strategy.PlanExecute}: agentic recovery when the deterministic
+ /// plan fails to compile or execute. Optional — if absent, plan failures
+ /// TERMINATE the workflow.
+ ///
+ public Agent? Fallback { get; set; }
+
+ ///
+ /// Max LLM turns for the fallback agent in PlanExecute strategy.
+ ///
+ public int? FallbackMaxTurns { get; set; }
+
+ ///
+ /// PLAN_EXECUTE planner context: text snippets and/or URLs whose contents
+ /// are appended to the planner's user prompt as a ## Reference Context
+ /// block on every planner invocation. URLs are fetched dynamically — no
+ /// compile-time fetch, no cache — so doc edits go live without recompile.
+ ///
+ /// Build entries via /
+ /// . URL entries may carry credentialed
+ /// headers in the ${CRED_NAME} shape; the server escapes them
+ /// and the runtime credential resolver fills them in at request time —
+ /// same auth pipeline as HTTP tool headers.
+ ///
+ /// Only meaningful with Strategy.PlanExecute. The server
+ /// compiler skips emission for any other strategy.
+ ///
+ public List? PlannerContext { get; set; }
public bool LocalCodeExecution { get; set; }
public List? AllowedLanguages { get; set; }
public List? AllowedCommands { get; set; }
@@ -183,7 +230,28 @@ public sealed class AgentBuilder
public AgentBuilder WithTemperature(double temp) { _agent.Temperature = temp; return this; }
public AgentBuilder WithTimeout(int seconds) { _agent.TimeoutSeconds = seconds; return this; }
public AgentBuilder WithExternal(bool external = true) { _agent.External = external; return this; }
- public AgentBuilder WithPlanner(bool planner = true) { _agent.Planner = planner; return this; }
+ public AgentBuilder WithEnablePlanning(bool enable = true) { _agent.EnablePlanning = enable; return this; }
+ public AgentBuilder WithPlanner(Agent planner) { _agent.Planner = planner; return this; }
+ public AgentBuilder WithFallback(Agent fallback) { _agent.Fallback = fallback; return this; }
+ public AgentBuilder WithFallbackMaxTurns(int turns) { _agent.FallbackMaxTurns = turns; return this; }
+ ///
+ /// PLAN_EXECUTE planner context — text snippets and URLs appended to the
+ /// planner's user prompt at runtime. See .
+ /// Only valid with Strategy.PlanExecute; throws at serialization
+ /// time on other strategies.
+ ///
+ public AgentBuilder WithPlannerContext(params Context[] entries)
+ {
+ _agent.PlannerContext = [.. entries];
+ return this;
+ }
+ /// Shorthand: text-only planner context. Wraps each string in
+ /// .
+ public AgentBuilder WithPlannerContext(params string[] texts)
+ {
+ _agent.PlannerContext = [.. texts.Select(Context.FromText)];
+ return this;
+ }
public AgentBuilder WithIncludeContents(string mode) { _agent.IncludeContents = mode; return this; }
public AgentBuilder WithThinkingBudget(int tokens) { _agent.ThinkingBudgetTokens = tokens; return this; }
public AgentBuilder WithRequiredTools(params string[] tools) { _agent.RequiredTools = [.. tools]; return this; }
diff --git a/sdk/csharp/src/Agentspan/AgentConfigSerializer.cs b/sdk/csharp/src/Agentspan/AgentConfigSerializer.cs
index 5b05d1283..3416fb35e 100644
--- a/sdk/csharp/src/Agentspan/AgentConfigSerializer.cs
+++ b/sdk/csharp/src/Agentspan/AgentConfigSerializer.cs
@@ -65,7 +65,35 @@ internal static JsonObject SerializeAgent(Agent agent)
if (agent.IncludeContents is not null) cfg["includeContents"] = agent.IncludeContents;
if (agent.Introduction is not null) cfg["introduction"] = agent.Introduction;
if (agent.External) cfg["external"] = true;
- if (agent.Planner) cfg["planner"] = true;
+ // Legacy "plan-first preamble" flag — server expects `enablePlanning`
+ // (Boolean) since the `planner` JSON key was repurposed for the
+ // PAC/PAE sub-agent slot below.
+ if (agent.EnablePlanning) cfg["enablePlanning"] = true;
+
+ // PLAN_EXECUTE named slots: planner (required when Strategy=PlanExecute)
+ // + fallback (optional). Both serialize as nested AgentConfig objects.
+ if (agent.Planner is not null) cfg["planner"] = SerializeAgent(agent.Planner);
+ if (agent.Fallback is not null) cfg["fallback"] = SerializeAgent(agent.Fallback);
+ if (agent.FallbackMaxTurns.HasValue) cfg["fallbackMaxTurns"] = agent.FallbackMaxTurns.Value;
+
+ // Planner context (PLAN_EXECUTE strategy) — text snippets + URLs
+ // injected into the planner's prompt. Reject if set on a non-
+ // PLAN_EXECUTE strategy to match the Python/TS/Java SDK guard
+ // shape (caught at build time elsewhere; serialization is the
+ // last line of defence).
+ if (agent.PlannerContext is { Count: > 0 })
+ {
+ if (agent.Strategy != Strategy.PlanExecute)
+ {
+ throw new InvalidOperationException(
+ "PlannerContext is only valid with Strategy.PlanExecute. " +
+ $"Got Strategy={agent.Strategy}. The context block is appended " +
+ "to the planner's user prompt at runtime, which only exists in PLAN_EXECUTE.");
+ }
+ var arr = new JsonArray();
+ foreach (var entry in agent.PlannerContext) arr.Add(entry.ToJson());
+ cfg["plannerContext"] = arr;
+ }
if (agent.LocalCodeExecution || agent.CodeExecution is not null
|| agent.AllowedLanguages is not null || agent.AllowedCommands is not null)
@@ -302,6 +330,7 @@ private static JsonNode GenerateSchema(Type type)
private static string StrategyToWire(Strategy strategy) => strategy switch
{
Strategy.RoundRobin => "round_robin",
+ Strategy.PlanExecute => "plan_execute",
_ => strategy.ToString().ToLowerInvariant(),
};
diff --git a/sdk/csharp/src/Agentspan/AgentRuntime.cs b/sdk/csharp/src/Agentspan/AgentRuntime.cs
index 9336bc92f..b0c17f084 100644
--- a/sdk/csharp/src/Agentspan/AgentRuntime.cs
+++ b/sdk/csharp/src/Agentspan/AgentRuntime.cs
@@ -129,11 +129,17 @@ public AgentHandle Start(string workflowName, string prompt, string? sessionId =
// ── Async API ────────────────────────────────────────────
/// Run an agent and wait for the result.
+ ///
+ /// Optional deterministic plan for Strategy.PlanExecute harnesses.
+ /// When present, the SDK forwards it as static_plan on the start
+ /// payload; the server's PAC extract_json picks it up as Case-0
+ /// (highest priority) and discards the planner LLM's output.
+ ///
public async Task RunAsync(
Agent agent, string prompt, string? sessionId = null,
- IEnumerable? media = null, CancellationToken ct = default)
+ IEnumerable? media = null, Plans.Plan? plan = null, CancellationToken ct = default)
{
- var handle = await StartInternalAsync(agent, prompt, sessionId, media, ct);
+ var handle = await StartInternalAsync(agent, prompt, sessionId, media, plan, ct);
var result = await handle.WaitAsync(ct);
await StopWorkersAsync();
return result;
@@ -150,9 +156,9 @@ public async Task RunByNameAsync(
/// Start an agent asynchronously and return a handle for streaming / HITL.
public async Task StartAsync(
Agent agent, string prompt, string? sessionId = null,
- IEnumerable? media = null, CancellationToken ct = default)
+ IEnumerable? media = null, Plans.Plan? plan = null, CancellationToken ct = default)
{
- return await StartInternalAsync(agent, prompt, sessionId, media, ct);
+ return await StartInternalAsync(agent, prompt, sessionId, media, plan, ct);
}
/// Start a pre-deployed agent by workflow name (no agentConfig payload).
@@ -169,7 +175,7 @@ public async IAsyncEnumerable StreamAsync(
IEnumerable? media = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
- var handle = await StartInternalAsync(agent, prompt, sessionId, media, ct);
+ var handle = await StartInternalAsync(agent, prompt, sessionId, media, plan: null, ct);
await foreach (var evt in handle.StreamAsync(ct))
yield return evt;
await StopWorkersAsync();
@@ -281,7 +287,7 @@ public void Respond(string executionId, object response)
private async Task StartInternalAsync(
Agent agent, string prompt, string? sessionId,
- IEnumerable? media, CancellationToken ct)
+ IEnumerable? media, Plans.Plan? plan, CancellationToken ct)
{
// Generate a fresh per-execution domain UUID for stateful agents. The
// server uses this as taskToDomain for every worker task in the run,
@@ -298,6 +304,12 @@ private async Task StartInternalAsync(
var payload = AgentConfigSerializer.Serialize(agent, prompt, sessionId ?? "", media);
if (runId is not null) payload["runId"] = runId;
+ if (plan is not null)
+ {
+ // Server reads ${workflow.input.static_plan} as the Case-0 plan source
+ // for Strategy.PlanExecute harnesses — wins over the planner LLM's output.
+ payload["static_plan"] = plan.ToJson();
+ }
var executionId = await _http.StartAsync(payload, ct);
return new AgentHandle(executionId, _http, runId);
}
diff --git a/sdk/csharp/src/Agentspan/Plans.cs b/sdk/csharp/src/Agentspan/Plans.cs
new file mode 100644
index 000000000..3ae641f4f
--- /dev/null
+++ b/sdk/csharp/src/Agentspan/Plans.cs
@@ -0,0 +1,401 @@
+// Copyright (c) 2025 Agentspan
+// Licensed under the MIT License.
+
+using System.Text.Json.Nodes;
+
+namespace Agentspan.Plans;
+
+///
+/// Typed plan builders for Strategy.PlanExecute.
+///
+/// These records produce the JSON shape PAC (the server's
+/// PLAN_AND_COMPILE task) consumes. The wire format is identical to the
+/// Python SDK's agentspan.agents.plans dataclasses and the
+/// TypeScript SDK's Plan class — same JSON shape, same field names,
+/// same Ref marker ({"$ref": "step_id"}).
+///
+/// Example:
+///
+/// using Agentspan.Plans;
+///
+/// var plan = new Plan
+/// {
+/// Steps = [
+/// new Step("fetch") { Operations = [new Op("fetch_data", args: new() {{ "url", URL }})] },
+/// new Step("summarize")
+/// {
+/// DependsOn = ["fetch"],
+/// Operations = [new Op("summarize", args: new() {{ "document", new Ref("fetch") }})],
+/// },
+/// ],
+/// };
+/// await runtime.RunAsync(harness, prompt, plan: plan);
+///
+///
+///
+
+// ── Ref ──────────────────────────────────────────────────
+
+///
+/// A reference to a prior step's whole output. Use new Ref("step_id")
+/// anywhere a literal value would go in an 's args (or a
+/// 's Context) to wire one step's output into
+/// another step's input — no JSON path, no field selection.
+///
+/// The referenced step must be declared in this step's
+/// DependsOn and must exist in the plan; the server rejects the plan
+/// at compile time otherwise.
+///
+public sealed class Ref
+{
+ public string StepId { get; }
+
+ public Ref(string stepId)
+ {
+ if (string.IsNullOrEmpty(stepId))
+ throw new ArgumentException("Ref stepId must be a non-empty string", nameof(stepId));
+ StepId = stepId;
+ }
+
+ /// Wire format the server's PAC consumes: {"$ref": "<step_id>"}.
+ public JsonObject ToJson() => new() { ["$ref"] = StepId };
+
+ public override string ToString() => $"Ref({StepId})";
+}
+
+///
+/// A reference document made available to the PLAN_EXECUTE planner.
+///
+/// Appended to the planner's user prompt as a ## Reference Context
+/// block on every planner invocation. Use to ground the planner in
+/// domain-specific rules / processes / edge cases that a static
+/// Instructions string can't capture — onboarding playbooks,
+/// KYC rules, compliance thresholds, etc.
+///
+/// Exactly one of or must be set.
+///
+/// For URL entries, optional carry credential
+/// placeholders in the ${CRED_NAME} shape; the server escapes them
+/// to #{CRED_NAME} so Conductor's templater doesn't consume them
+/// and the runtime credential resolver fills them in at request time —
+/// same auth pipeline as HTTP tool headers.
+///
+/// Mirrors Python's Context dataclass, TypeScript's
+/// Context class, and Java's Context — same wire shape
+/// produced by .
+///
+public sealed class Context
+{
+ public string? Text { get; }
+ public string? Url { get; }
+ public IDictionary? Headers { get; }
+ public bool Required { get; }
+ public int MaxBytes { get; }
+
+ private Context(string? text, string? url, IDictionary? headers, bool required, int maxBytes)
+ {
+ Text = text;
+ Url = url;
+ Headers = headers;
+ Required = required;
+ MaxBytes = maxBytes;
+ }
+
+ /// Inline-text Context entry.
+ public static Context FromText(string text)
+ {
+ if (string.IsNullOrEmpty(text))
+ throw new ArgumentException("Context.Text must be a non-empty string", nameof(text));
+ return new Context(text, null, null, true, 16384);
+ }
+
+ /// URL Context entry. Optional headers may contain
+ /// ${CRED_NAME} placeholders that resolve against the agent's
+ /// credential store at request time. =false
+ /// substitutes a [doc unavailable] marker on fetch failure
+ /// instead of failing the workflow.
+ /// truncates large responses with a [doc truncated] marker.
+ public static Context FromUrl(
+ string url,
+ IDictionary? headers = null,
+ bool required = true,
+ int maxBytes = 16384)
+ {
+ if (string.IsNullOrEmpty(url))
+ throw new ArgumentException("Context.Url must be a non-empty string", nameof(url));
+ return new Context(null, url, headers, required, maxBytes);
+ }
+
+ ///
+ /// Wire format the server's MultiAgentCompiler consumes. Defaults are
+ /// omitted so the payload stays tight for the common text-only /
+ /// minimal-URL case.
+ ///
+ public JsonObject ToJson()
+ {
+ var obj = new JsonObject();
+ if (Text != null)
+ {
+ obj["text"] = Text;
+ }
+ if (Url != null)
+ {
+ obj["url"] = Url;
+ if (Headers != null && Headers.Count > 0)
+ {
+ var h = new JsonObject();
+ foreach (var (k, v) in Headers) h[k] = v;
+ obj["headers"] = h;
+ }
+ if (!Required)
+ {
+ obj["required"] = false;
+ }
+ if (MaxBytes != 16384)
+ {
+ obj["maxBytes"] = MaxBytes;
+ }
+ }
+ return obj;
+ }
+
+ public override string ToString() =>
+ Text != null ? $"Context(text={Text.Substring(0, Math.Min(Text.Length, 40))}…)" : $"Context(url={Url})";
+}
+
+internal static class PlanValues
+{
+ ///
+ /// Walk an arg value tree and replace nested instances
+ /// with their wire form. Lists and dicts are traversed in place.
+ ///
+ internal static JsonNode? SerializeValue(object? v)
+ {
+ if (v is null) return null;
+ if (v is Ref r) return r.ToJson();
+ if (v is JsonNode jn) return jn;
+ if (v is IDictionary dict)
+ {
+ var obj = new JsonObject();
+ foreach (var (k, sub) in dict) obj[k] = SerializeValue(sub);
+ return obj;
+ }
+ if (v is System.Collections.IEnumerable enumerable && v is not string)
+ {
+ var arr = new JsonArray();
+ foreach (var item in enumerable) arr.Add(SerializeValue(item));
+ return arr;
+ }
+ // Primitives — wrap via JsonValue
+ return JsonValue.Create(v);
+ }
+
+ internal static JsonObject SerializeArgs(IDictionary args)
+ {
+ var obj = new JsonObject();
+ foreach (var (k, v) in args) obj[k] = SerializeValue(v);
+ return obj;
+ }
+}
+
+// ── Generate ─────────────────────────────────────────────
+
+///
+/// LLM-generated arguments for a tool call inside a plan step. When an
+/// carries Generate, the server emits an LLM call
+/// at run time that produces the tool's args from these instructions.
+///
+public sealed class Generate
+{
+ public required string Instructions { get; init; }
+ public required string OutputSchema { get; init; }
+ public int? MaxTokens { get; init; }
+ ///
+ /// Optional extra text appended to the LLM's user message. Accepts a
+ /// plain string or a — when a Ref is passed, the
+ /// server substitutes the upstream step's output at run time.
+ ///
+ public object? Context { get; init; }
+
+ public JsonObject ToJson()
+ {
+ var obj = new JsonObject
+ {
+ ["instructions"] = Instructions,
+ ["output_schema"] = OutputSchema,
+ };
+ if (MaxTokens.HasValue) obj["max_tokens"] = MaxTokens.Value;
+ if (Context is not null) obj["context"] = PlanValues.SerializeValue(Context);
+ return obj;
+ }
+}
+
+// ── Op ───────────────────────────────────────────────────
+
+///
+/// A single tool invocation within a plan step. Exactly one of
+/// Args (literal call) or Generate (LLM-driven args) is
+/// set, enforced structurally by the constructor / factory.
+///
+/// Construct via new Op(tool, args) for a deterministic
+/// call, or Op.WithGenerate(tool, generate) for an LLM-driven
+/// one. There is no bare new Op(tool) — that loophole let a
+/// neither-set Op exist and only fail server-side at PAC compile.
+///
+public sealed class Op
+{
+ public string Tool { get; }
+ public Dictionary? Args { get; }
+ public Generate? Generate { get; }
+
+ /// Op with literal args — runs the tool deterministically.
+ public Op(string tool, Dictionary args)
+ {
+ if (args is null)
+ throw new ArgumentNullException(
+ nameof(args),
+ $"Op('{tool}'): exactly one of args or generate must be set");
+ Tool = tool;
+ Args = args;
+ }
+
+ private Op(string tool, Generate generate)
+ {
+ Tool = tool;
+ Generate = generate;
+ }
+
+ /// Op whose args are produced at runtime by an LLM call.
+ public static Op WithGenerate(string tool, Generate generate)
+ {
+ if (generate is null)
+ throw new ArgumentNullException(
+ nameof(generate),
+ $"Op('{tool}'): exactly one of args or generate must be set");
+ return new Op(tool, generate);
+ }
+
+ public JsonObject ToJson()
+ {
+ // Invariant: exactly one of Args / Generate set — enforced by ctors.
+ var obj = new JsonObject { ["tool"] = Tool };
+ if (Args is not null) obj["args"] = PlanValues.SerializeArgs(Args);
+ if (Generate is not null) obj["generate"] = Generate.ToJson();
+ return obj;
+ }
+}
+
+// ── Step ─────────────────────────────────────────────────
+
+///
+/// A node in the plan DAG. Steps run sequentially by default;
+/// DependsOn overrides to express cross-step concurrency.
+/// Parallel=true runs the step's own s concurrently.
+///
+public sealed class Step
+{
+ public string Id { get; }
+ public List Operations { get; init; } = [];
+ public List DependsOn { get; init; } = [];
+ public bool Parallel { get; init; }
+
+ public Step(string id) { Id = id; }
+
+ public JsonObject ToJson()
+ {
+ var obj = new JsonObject { ["id"] = Id };
+ var ops = new JsonArray();
+ foreach (var op in Operations) ops.Add(op.ToJson());
+ obj["operations"] = ops;
+ if (DependsOn.Count > 0)
+ {
+ var deps = new JsonArray();
+ foreach (var d in DependsOn) deps.Add(d);
+ obj["depends_on"] = deps;
+ }
+ if (Parallel) obj["parallel"] = true;
+ return obj;
+ }
+}
+
+// ── Validation ────────────────────────────────────────────
+
+public sealed class Validation
+{
+ public string Tool { get; }
+ public Dictionary? Args { get; init; }
+ ///
+ /// Optional JS expression evaluated against the tool's output
+ /// ($ is the parsed output map). Returns truthy on pass.
+ ///
+ public string? SuccessCondition { get; init; }
+
+ public Validation(string tool) { Tool = tool; }
+
+ public JsonObject ToJson()
+ {
+ var obj = new JsonObject { ["tool"] = Tool };
+ if (Args is not null) obj["args"] = PlanValues.SerializeArgs(Args);
+ if (SuccessCondition is not null) obj["success_condition"] = SuccessCondition;
+ return obj;
+ }
+}
+
+// ── Action (on_success / on_failure) ──────────────────────
+
+public sealed class Action
+{
+ public string Tool { get; }
+ public Dictionary? Args { get; init; }
+
+ public Action(string tool) { Tool = tool; }
+
+ public JsonObject ToJson()
+ {
+ var obj = new JsonObject { ["tool"] = Tool };
+ if (Args is not null) obj["args"] = PlanValues.SerializeArgs(Args);
+ return obj;
+ }
+}
+
+// ── Plan ─────────────────────────────────────────────────
+
+///
+/// A compiled plan ready for Strategy.PlanExecute execution.
+/// Pass to runtime.RunAsync(harness, prompt, plan: plan) to skip
+/// the planner LLM and run a fully deterministic pipeline.
+///
+public sealed class Plan
+{
+ public List Steps { get; init; } = [];
+ public List Validation { get; init; } = [];
+ public List OnSuccess { get; init; } = [];
+ public List OnFailure { get; init; } = [];
+
+ public JsonObject ToJson()
+ {
+ var obj = new JsonObject();
+ var steps = new JsonArray();
+ foreach (var s in Steps) steps.Add(s.ToJson());
+ obj["steps"] = steps;
+ if (Validation.Count > 0)
+ {
+ var arr = new JsonArray();
+ foreach (var v in Validation) arr.Add(v.ToJson());
+ obj["validation"] = arr;
+ }
+ if (OnSuccess.Count > 0)
+ {
+ var arr = new JsonArray();
+ foreach (var a in OnSuccess) arr.Add(a.ToJson());
+ obj["on_success"] = arr;
+ }
+ if (OnFailure.Count > 0)
+ {
+ var arr = new JsonArray();
+ foreach (var a in OnFailure) arr.Add(a.ToJson());
+ obj["on_failure"] = arr;
+ }
+ return obj;
+ }
+}
diff --git a/sdk/csharp/tests/AgentspanE2eTests/Plans_ContextTests.cs b/sdk/csharp/tests/AgentspanE2eTests/Plans_ContextTests.cs
new file mode 100644
index 000000000..27a87364f
--- /dev/null
+++ b/sdk/csharp/tests/AgentspanE2eTests/Plans_ContextTests.cs
@@ -0,0 +1,208 @@
+// Copyright (c) 2025 Agentspan
+// Licensed under the MIT License.
+
+// Context + planner_context tests — mirror Python's test_planner_context.py,
+// TS's planner-context.test.ts, and Java's ContextTest. The four SDKs MUST
+// emit identical wire payloads; this file pins the C# side.
+//
+// CLAUDE.md rule: no LLM for validation. Pure dataclass + serializer.
+
+using System.Collections.Generic;
+using System.Text.Json.Nodes;
+using Xunit;
+using Agentspan;
+using Agentspan.Plans;
+
+namespace Agentspan.E2eTests;
+
+public sealed class Plans_ContextTests
+{
+ // ── Context dataclass ──────────────────────────────────
+
+ [Fact]
+ public void TextShorthandConstruction()
+ {
+ var c = Context.FromText("rule one");
+ Assert.Equal("rule one", c.Text);
+ Assert.Null(c.Url);
+ }
+
+ [Fact]
+ public void UrlShorthandHasDefaults()
+ {
+ var c = Context.FromUrl("https://x/y");
+ Assert.Equal("https://x/y", c.Url);
+ Assert.Null(c.Text);
+ Assert.True(c.Required);
+ Assert.Equal(16384, c.MaxBytes);
+ }
+
+ [Fact]
+ public void ToJsonTextOnlyIsMinimal()
+ {
+ // Text-only entries serialise as a single-key object — no url,
+ // headers, required, maxBytes. Keeps the wire payload tight.
+ var json = Context.FromText("rule").ToJson();
+ Assert.Equal("rule", (string?)json["text"]);
+ Assert.False(json.ContainsKey("url"));
+ Assert.False(json.ContainsKey("headers"));
+ Assert.False(json.ContainsKey("required"));
+ Assert.False(json.ContainsKey("maxBytes"));
+ }
+
+ [Fact]
+ public void ToJsonUrlOnlyWithDefaultsIsMinimal()
+ {
+ // URL with all defaults: only url on the wire (server applies the
+ // same defaults). Mirrors Python/TS/Java behaviour.
+ var json = Context.FromUrl("https://x/").ToJson();
+ Assert.Equal("https://x/", (string?)json["url"]);
+ Assert.False(json.ContainsKey("required"));
+ Assert.False(json.ContainsKey("maxBytes"));
+ }
+
+ [Fact]
+ public void ToJsonUrlFullOptionsPreservesCredentialPlaceholder()
+ {
+ // Credential placeholder MUST pass through verbatim — the
+ // ${} -> #{} escape is the server's job. The SDK must NOT
+ // pre-escape; otherwise the credential resolver wouldn't see
+ // #{NAME} on the wire and resolution would silently no-op.
+ var c = Context.FromUrl(
+ "https://confluence.example.com/page",
+ headers: new Dictionary
+ {
+ ["Authorization"] = "Bearer ${CONFLUENCE_TOKEN}",
+ },
+ required: false,
+ maxBytes: 8192);
+ var json = c.ToJson();
+ Assert.Equal("https://confluence.example.com/page", (string?)json["url"]);
+ var headers = json["headers"]!.AsObject();
+ Assert.Equal("Bearer ${CONFLUENCE_TOKEN}", (string?)headers["Authorization"]);
+ Assert.False((bool)json["required"]!);
+ Assert.Equal(8192, (int)json["maxBytes"]!);
+ }
+
+ // ── Agent + AgentConfigSerializer wiring ───────────────
+
+ [Fact]
+ public void SerializerEmitsPlannerContextWithMixedEntries()
+ {
+ var planner = AgentBuilder.Create("planner_sub").WithModel("openai/gpt-4o-mini").Build();
+ var stub = new ToolDef
+ {
+ Name = "stub",
+ Description = "stub",
+ InputSchema = new JsonObject
+ {
+ ["type"] = "object",
+ ["properties"] = new JsonObject(),
+ },
+ };
+ var harness = AgentBuilder.Create("h")
+ .WithModel("openai/gpt-4o-mini")
+ .WithStrategy(Strategy.PlanExecute)
+ .WithPlanner(planner)
+ .WithTools(stub)
+ .WithPlannerContext(
+ Context.FromText("inline rule"),
+ Context.FromUrl(
+ "https://confluence.example.com/onboarding",
+ headers: new Dictionary
+ {
+ ["Authorization"] = "Bearer ${CONFLUENCE_TOKEN}",
+ },
+ required: false,
+ maxBytes: 8192))
+ .Build();
+
+ var cfg = SerializeAgentConfigForTest(harness);
+ var ctx = cfg["plannerContext"]!.AsArray();
+ Assert.Equal(2, ctx.Count);
+ Assert.Equal("inline rule", (string?)ctx[0]!["text"]);
+ Assert.Equal(
+ "https://confluence.example.com/onboarding",
+ (string?)ctx[1]!["url"]);
+ Assert.Equal(
+ "Bearer ${CONFLUENCE_TOKEN}",
+ (string?)ctx[1]!["headers"]!["Authorization"]);
+ Assert.False((bool)ctx[1]!["required"]!);
+ Assert.Equal(8192, (int)ctx[1]!["maxBytes"]!);
+ }
+
+ [Fact]
+ public void SerializerOmitsPlannerContextWhenUnset()
+ {
+ // Counterfactual: without PlannerContext the wire field MUST NOT
+ // appear. Pairs with the positive test — without this, the
+ // positive case could vacuously pass if the serializer always
+ // emitted the field.
+ var planner = AgentBuilder.Create("planner_sub").WithModel("openai/gpt-4o-mini").Build();
+ var stub = new ToolDef
+ {
+ Name = "stub",
+ Description = "stub",
+ InputSchema = new JsonObject
+ {
+ ["type"] = "object",
+ ["properties"] = new JsonObject(),
+ },
+ };
+ var harness = AgentBuilder.Create("h")
+ .WithModel("openai/gpt-4o-mini")
+ .WithStrategy(Strategy.PlanExecute)
+ .WithPlanner(planner)
+ .WithTools(stub)
+ .Build();
+
+ var cfg = SerializeAgentConfigForTest(harness);
+ Assert.False(cfg.ContainsKey("plannerContext"));
+ }
+
+ [Fact]
+ public void SerializerThrowsOnPlannerContextWithNonPlanExecuteStrategy()
+ {
+ // Same guard shape as Python/TS/Java — setting PlannerContext on
+ // anything other than Strategy.PlanExecute is a silent bug.
+ // Serializer is the last line of defence.
+ var sub = AgentBuilder.Create("sub").WithModel("openai/gpt-4o-mini").Build();
+ var harness = AgentBuilder.Create("h")
+ .WithModel("openai/gpt-4o-mini")
+ .WithStrategy(Strategy.Handoff)
+ .WithAgents(sub)
+ .WithPlannerContext("rule")
+ .Build();
+
+ var ex = Assert.Throws(
+ () => SerializeAgentConfigForTest(harness));
+ Assert.Contains("PlanExecute", ex.Message);
+ }
+
+ ///
+ /// AgentConfigSerializer is `internal static` (the public entry point
+ /// is Serialize which wraps the agent config in the POST /agent/start
+ /// envelope). The test assembly can't reference the type name directly
+ /// — look it up by string via Assembly.GetType, matching the pattern
+ /// OpenAIAgentTests already uses.
+ ///
+ private static JsonObject SerializeAgentConfigForTest(Agent agent)
+ {
+ var t = typeof(Agent).Assembly
+ .GetType("Agentspan.AgentConfigSerializer", throwOnError: true)!;
+ var mi = t.GetMethod(
+ "SerializeAgent",
+ System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)!;
+ try
+ {
+ return (JsonObject)mi.Invoke(null, new object[] { agent })!;
+ }
+ catch (System.Reflection.TargetInvocationException tie)
+ when (tie.InnerException is not null)
+ {
+ // Unwrap so Assert.Throws sees the
+ // real exception, not the reflection wrapper.
+ throw tie.InnerException;
+ }
+ }
+}
diff --git a/sdk/csharp/tests/AgentspanE2eTests/Plans_OpTests.cs b/sdk/csharp/tests/AgentspanE2eTests/Plans_OpTests.cs
new file mode 100644
index 000000000..5c71e7e77
--- /dev/null
+++ b/sdk/csharp/tests/AgentspanE2eTests/Plans_OpTests.cs
@@ -0,0 +1,78 @@
+// Copyright (c) 2025 Agentspan
+// Licensed under the MIT License.
+
+// Op XOR invariant tests — mirror Python plans.py + TS plans.ts.
+//
+// Op must carry exactly one of Args (deterministic literal call) or Generate
+// (LLM-driven). The C# typed builder previously placed the both-set check in
+// ToJson(), letting an invalid Op live for its entire lifetime and only fail
+// on serialization. Tighten to construction-time so invalid state is
+// unrepresentable.
+//
+// CLAUDE.md rule: no LLM for validation; write test → make it fail → confirm.
+
+using System;
+using System.Collections.Generic;
+using Xunit;
+using Agentspan.Plans;
+
+namespace Agentspan.E2eTests;
+
+public sealed class Plans_OpTests
+{
+ [Fact]
+ public void AcceptsArgsOnly()
+ {
+ var op = new Op("write_file", new Dictionary { ["path"] = "x" });
+ var json = op.ToJson();
+ Assert.Equal("write_file", (string?)json["tool"]);
+ Assert.NotNull(json["args"]);
+ }
+
+ [Fact]
+ public void AcceptsGenerateOnlyViaFactory()
+ {
+ var op = Op.WithGenerate(
+ "write_file",
+ new Generate { Instructions = "i", OutputSchema = "{\"x\":1}" });
+ var json = op.ToJson();
+ Assert.Equal("write_file", (string?)json["tool"]);
+ Assert.NotNull(json["generate"]);
+ }
+
+ [Fact]
+ public void RejectsNullArgs()
+ {
+ var ex = Assert.Throws(
+ () => new Op("write_file", (Dictionary)null!));
+ Assert.Contains("exactly one of args or generate", ex.Message);
+ }
+
+ [Fact]
+ public void RejectsNullGenerate()
+ {
+ var ex = Assert.Throws(
+ () => Op.WithGenerate("write_file", null!));
+ Assert.Contains("exactly one of args or generate", ex.Message);
+ }
+
+ [Fact]
+ public void BareConstructorWithNoFieldsIsNotPublic()
+ {
+ // The bare `new Op(tool)` constructor was the loophole that let an
+ // invalid (neither-set) Op exist. The fix: the only way to construct
+ // an Op is via `new Op(tool, args)` or `Op.WithGenerate(tool, gen)`.
+ // This test pins the API surface — if someone re-introduces a public
+ // single-arg constructor, this test breaks.
+ var ctors = typeof(Op).GetConstructors(
+ System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
+ foreach (var c in ctors)
+ {
+ var ps = c.GetParameters();
+ Assert.False(
+ ps.Length == 1 && ps[0].ParameterType == typeof(string),
+ "Op should not expose a public single-string-arg constructor — " +
+ "that loophole is what allowed a neither-set Op to exist.");
+ }
+ }
+}
diff --git a/sdk/csharp/tests/AgentspanE2eTests/Suite16_PlanExecuteRefs.cs b/sdk/csharp/tests/AgentspanE2eTests/Suite16_PlanExecuteRefs.cs
new file mode 100644
index 000000000..f7283ee75
--- /dev/null
+++ b/sdk/csharp/tests/AgentspanE2eTests/Suite16_PlanExecuteRefs.cs
@@ -0,0 +1,212 @@
+// Copyright (c) 2025 Agentspan
+// Licensed under the MIT License.
+
+// Suite 16 — Plan-Execute (PAC/PAE) with cross-step Refs.
+//
+// Deterministic tests for the typed Plan / Step / Op / Ref builders:
+// - Ref("step_id") wires the whole output of an upstream step into a
+// downstream step's args (no JSON path, no field selection).
+// - Two Refs in the same args map resolve independently.
+//
+// The planner sub-agent is built but its output is discarded by the
+// static-plan path (RunAsync(harness, prompt, plan: plan)). All
+// assertions are algorithmic — per CLAUDE.md, we never use LLM output
+// for validation.
+
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Xunit;
+using Agentspan.Examples;
+using Agentspan.Plans;
+
+namespace Agentspan.E2eTests;
+
+[Collection("E2e")]
+public sealed class Suite16_PlanExecuteRefs
+{
+ private readonly E2eFixture _fixture;
+
+ public Suite16_PlanExecuteRefs(E2eFixture fixture) => _fixture = fixture;
+
+ // ── Tool host ───────────────────────────────────────────────────────
+
+ internal sealed class S16Tools
+ {
+ [Tool("Step A — emit a known record.")]
+ public Dictionary S16Produce(string record_id) => new()
+ {
+ ["record_id"] = record_id,
+ ["value"] = 42,
+ ["tags"] = new[] { "alpha", "beta" },
+ };
+
+ [Tool("Step B — read Step A via Ref.")]
+ public Dictionary S16Enrich(JsonElement record)
+ {
+ var dict = JsonSerializer.Deserialize>(record.GetRawText())!;
+ var value = ((JsonElement)dict["value"]!).GetInt32();
+ dict["value_squared"] = value * value;
+ return dict;
+ }
+
+ [Tool("Step C — read BOTH upstream steps.")]
+ public Dictionary S16Report(JsonElement record, JsonElement enriched) => new()
+ {
+ ["id"] = record.GetProperty("record_id").GetString(),
+ ["original_value"] = record.GetProperty("value").GetInt32(),
+ ["squared"] = enriched.GetProperty("value_squared").GetInt32(),
+ ["tags_joined"] = string.Join(
+ ", ",
+ record.GetProperty("tags").EnumerateArray().Select(e => e.GetString()!)),
+ };
+ }
+
+ // ── Helpers ─────────────────────────────────────────────────────────
+
+ private Agent BuildRefsHarness()
+ {
+ var planner = new Agent("s16_refs_planner")
+ {
+ Model = Settings.LlmModel,
+ Instructions = "(planner unused; static plan supplied)",
+ };
+ return new Agent("s16_refs_harness")
+ {
+ Model = Settings.LlmModel,
+ Strategy = Strategy.PlanExecute,
+ Planner = planner,
+ Tools = ToolRegistry.FromInstance(new S16Tools()),
+ };
+ }
+
+ private async Task> FetchStepOutputsAsync(string executionId)
+ {
+ var parent = await _fixture.FetchWorkflowAsync(executionId);
+ string? subId = null;
+ foreach (var t in parent?["tasks"]?.AsArray() ?? new JsonArray())
+ {
+ var refName = t?["referenceTaskName"]?.GetValue() ?? "";
+ if (refName.EndsWith("_plan_exec"))
+ {
+ subId = t?["outputData"]?["subWorkflowId"]?.GetValue();
+ break;
+ }
+ }
+ var result = new Dictionary();
+ if (subId is null) return result;
+ var sub = await _fixture.FetchWorkflowAsync(subId);
+ foreach (var t in sub?["tasks"]?.AsArray() ?? new JsonArray())
+ {
+ var name = t?["taskDefName"]?.GetValue() ?? "";
+ // Tool names are auto-snake_cased by the SDK from method names.
+ if (name.StartsWith("s16_"))
+ {
+ result[name] = t?["outputData"];
+ }
+ }
+ return result;
+ }
+
+ // ── 16.1 Ref pipes the whole output across steps ───────────────────
+
+ [SkippableFact]
+ public async Task RefPipesWholeOutputAcrossSteps()
+ {
+ _fixture.RequireServer();
+
+ var harness = BuildRefsHarness();
+ var plan = new Plan
+ {
+ Steps =
+ {
+ new Step("a")
+ {
+ Operations = { new Op("s16_produce", new() { ["record_id"] = "r-001" }) },
+ },
+ new Step("b")
+ {
+ DependsOn = { "a" },
+ Operations = { new Op("s16_enrich", new() { ["record"] = new Ref("a") }) },
+ },
+ },
+ };
+
+ await using var runtime = new AgentRuntime();
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(300));
+ var result = await runtime.RunAsync(harness, "go", plan: plan, ct: cts.Token);
+
+ Assert.True(
+ result.IsSuccess,
+ $"workflow did not COMPLETE: status={result.Status} error={result.Error}");
+
+ var outputs = await FetchStepOutputsAsync(result.ExecutionId);
+
+ // Step A — seed dict.
+ var produce = outputs["s16_produce"]!.AsObject();
+ Assert.Equal("r-001", produce["record_id"]!.GetValue());
+ Assert.Equal(42, produce["value"]!.GetValue());
+
+ // Step B — proves Ref("a") delivered the whole upstream dict.
+ // Counterfactual: if Ref were unwired, enrich would receive the
+ // literal {"$ref":"a"} marker and value_squared would be 0.
+ var enrich = outputs["s16_enrich"]!.AsObject();
+ Assert.Equal(
+ 1764, enrich["value_squared"]!.GetValue());
+ Assert.Equal(42, enrich["value"]!.GetValue());
+ Assert.Equal("r-001", enrich["record_id"]!.GetValue());
+ }
+
+ // ── 16.2 Two Refs in the same args resolve independently ───────────
+
+ [SkippableFact]
+ public async Task TwoRefsInSameArgsResolveIndependently()
+ {
+ _fixture.RequireServer();
+
+ var harness = BuildRefsHarness();
+ var plan = new Plan
+ {
+ Steps =
+ {
+ new Step("a")
+ {
+ Operations = { new Op("s16_produce", new() { ["record_id"] = "r-001" }) },
+ },
+ new Step("b")
+ {
+ DependsOn = { "a" },
+ Operations = { new Op("s16_enrich", new() { ["record"] = new Ref("a") }) },
+ },
+ new Step("c")
+ {
+ DependsOn = { "a", "b" },
+ Operations =
+ {
+ new Op("s16_report", new()
+ {
+ ["record"] = new Ref("a"),
+ ["enriched"] = new Ref("b"),
+ }),
+ },
+ },
+ },
+ };
+
+ await using var runtime = new AgentRuntime();
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(300));
+ var result = await runtime.RunAsync(harness, "go", plan: plan, ct: cts.Token);
+
+ Assert.True(result.IsSuccess, $"status={result.Status} error={result.Error}");
+
+ var outputs = await FetchStepOutputsAsync(result.ExecutionId);
+ var report = outputs["s16_report"]!.AsObject();
+
+ // Counterfactual: if both Refs collapsed to the same upstream,
+ // squared would equal original_value (both 42). Asserting 1764 ≠
+ // 42 rules that out.
+ Assert.Equal("r-001", report["id"]!.GetValue());
+ Assert.Equal(42, report["original_value"]!.GetValue());
+ Assert.Equal(1764, report["squared"]!.GetValue());
+ Assert.Equal("alpha, beta", report["tags_joined"]!.GetValue());
+ }
+}
diff --git a/sdk/csharp/tests/AgentspanE2eTests/Suite1_BasicValidation.cs b/sdk/csharp/tests/AgentspanE2eTests/Suite1_BasicValidation.cs
index e195cfb75..cb04750e3 100644
--- a/sdk/csharp/tests/AgentspanE2eTests/Suite1_BasicValidation.cs
+++ b/sdk/csharp/tests/AgentspanE2eTests/Suite1_BasicValidation.cs
@@ -291,14 +291,15 @@ public async Task AllStrategies_SerializeToCorrectWireValues()
var cases = new (Strategy strategy, string expected)[]
{
- (Strategy.Handoff, "handoff"),
- (Strategy.Sequential, "sequential"),
- (Strategy.Parallel, "parallel"),
- (Strategy.Router, "router"),
- (Strategy.RoundRobin, "round_robin"),
- (Strategy.Random, "random"),
- (Strategy.Swarm, "swarm"),
- (Strategy.Manual, "manual"),
+ (Strategy.Handoff, "handoff"),
+ (Strategy.Sequential, "sequential"),
+ (Strategy.Parallel, "parallel"),
+ (Strategy.Router, "router"),
+ (Strategy.RoundRobin, "round_robin"),
+ (Strategy.Random, "random"),
+ (Strategy.Swarm, "swarm"),
+ (Strategy.Manual, "manual"),
+ (Strategy.PlanExecute, "plan_execute"),
};
foreach (var (strategy, expected) in cases)
@@ -316,6 +317,15 @@ public async Task AllStrategies_SerializeToCorrectWireValues()
Strategy = strategy, Router = router,
};
}
+ else if (strategy == Strategy.PlanExecute)
+ {
+ // PlanExecute uses named slots (planner=) rather than agents=[…]
+ var planner = new Agent($"s1_strat_{expected}_planner") { Model = Settings.LlmModel };
+ parent = new Agent($"s1_strat_{expected}_parent")
+ {
+ Model = Settings.LlmModel, Strategy = strategy, Planner = planner,
+ };
+ }
else
{
parent = new Agent($"s1_strat_{expected}_parent")
diff --git a/sdk/java/e2e/PlanExecuteTest.java b/sdk/java/e2e/PlanExecuteTest.java
new file mode 100644
index 000000000..774479600
--- /dev/null
+++ b/sdk/java/e2e/PlanExecuteTest.java
@@ -0,0 +1,1030 @@
+// Copyright (c) 2025 Agentspan
+// Licensed under the MIT License. See LICENSE file in the project root for details.
+
+
+import ai.agentspan.Agent;
+import ai.agentspan.AgentConfig;
+import ai.agentspan.AgentRuntime;
+import ai.agentspan.enums.AgentStatus;
+import ai.agentspan.enums.Strategy;
+import ai.agentspan.model.AgentResult;
+import ai.agentspan.model.ToolDef;
+import ai.agentspan.plans.Op;
+import ai.agentspan.plans.Plan;
+import ai.agentspan.plans.Ref;
+import ai.agentspan.plans.Step;
+import org.junit.jupiter.api.*;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Plan-Execute strategy e2e test — runs real agents with real LLM calls.
+ *
+ *
All assertions are algorithmic (file existence, word counts) — no LLM
+ * output is used for validation (CLAUDE.md rule).
+ */
+@Tag("e2e")
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class PlanExecuteTest extends BaseTest {
+
+ static final Path WORK_DIR = Path.of(System.getProperty("java.io.tmpdir"), "plan-execute-test-java");
+ static final int MIN_WORD_COUNT = 200;
+
+ static AgentRuntime runtime;
+
+ @BeforeAll
+ static void setUp() {
+ runtime = new AgentRuntime(new AgentConfig(BASE_URL, null, null, 100, 1));
+ }
+
+ @AfterAll
+ static void tearDown() {
+ if (runtime != null) runtime.close();
+ }
+
+ @BeforeEach
+ void cleanWorkDir() throws IOException {
+ if (Files.exists(WORK_DIR)) {
+ Files.walk(WORK_DIR)
+ .sorted(Comparator.reverseOrder())
+ .map(Path::toFile)
+ .forEach(File::delete);
+ }
+ Files.createDirectories(WORK_DIR);
+ }
+
+ // ── Tools ────────────────────────────────────────────────────────────
+
+ static ToolDef createDirectoryTool() {
+ Map props = new LinkedHashMap<>();
+ props.put("path", Map.of("type", "string", "description", "Directory path to create (relative to working dir)."));
+
+ Map inputSchema = new LinkedHashMap<>();
+ inputSchema.put("type", "object");
+ inputSchema.put("properties", props);
+ inputSchema.put("required", List.of("path"));
+
+ return ToolDef.builder()
+ .name("create_directory")
+ .description("Create a directory (and parents) if it doesn't exist.")
+ .inputSchema(inputSchema)
+ .toolType("worker")
+ .func(input -> {
+ String path = (String) input.get("path");
+ Path full = WORK_DIR.resolve(path);
+ try {
+ Files.createDirectories(full);
+ } catch (IOException e) {
+ return "ERROR: " + e.getMessage();
+ }
+ return "Created directory: " + full;
+ })
+ .build();
+ }
+
+ static ToolDef writeFileTool() {
+ Map props = new LinkedHashMap<>();
+ props.put("path", Map.of("type", "string", "description", "File path (relative to working dir)."));
+ props.put("content", Map.of("type", "string", "description", "Full file content to write."));
+
+ Map inputSchema = new LinkedHashMap<>();
+ inputSchema.put("type", "object");
+ inputSchema.put("properties", props);
+ inputSchema.put("required", List.of("path", "content"));
+
+ return ToolDef.builder()
+ .name("write_file")
+ .description("Write content to a file, creating parent directories if needed.")
+ .inputSchema(inputSchema)
+ .toolType("worker")
+ .func(input -> {
+ String path = (String) input.get("path");
+ String content = (String) input.get("content");
+ Path full = WORK_DIR.resolve(path);
+ try {
+ Files.createDirectories(full.getParent());
+ Files.writeString(full, content);
+ } catch (IOException e) {
+ return "ERROR: " + e.getMessage();
+ }
+ return "Wrote " + content.length() + " bytes to " + full;
+ })
+ .build();
+ }
+
+ static ToolDef readFileTool() {
+ Map props = new LinkedHashMap<>();
+ props.put("path", Map.of("type", "string", "description", "File path (relative to working dir)."));
+
+ Map inputSchema = new LinkedHashMap<>();
+ inputSchema.put("type", "object");
+ inputSchema.put("properties", props);
+ inputSchema.put("required", List.of("path"));
+
+ return ToolDef.builder()
+ .name("read_file")
+ .description("Read the contents of a file.")
+ .inputSchema(inputSchema)
+ .toolType("worker")
+ .func(input -> {
+ String path = (String) input.get("path");
+ Path full = WORK_DIR.resolve(path);
+ if (!Files.exists(full)) {
+ return "ERROR: File not found: " + full;
+ }
+ try {
+ return Files.readString(full);
+ } catch (IOException e) {
+ return "ERROR: " + e.getMessage();
+ }
+ })
+ .build();
+ }
+
+ static ToolDef assembleFilesTool() {
+ Map props = new LinkedHashMap<>();
+ props.put("output_path", Map.of("type", "string", "description", "Output file path (relative to working dir)."));
+ props.put("input_paths", Map.of("type", "string", "description", "JSON array of input file paths (relative to working dir)."));
+ props.put("separator", Map.of("type", "string", "description", "Text to insert between file contents."));
+
+ Map inputSchema = new LinkedHashMap<>();
+ inputSchema.put("type", "object");
+ inputSchema.put("properties", props);
+ inputSchema.put("required", List.of("output_path", "input_paths"));
+
+ return ToolDef.builder()
+ .name("assemble_files")
+ .description("Concatenate multiple files into one, with a separator between them.")
+ .inputSchema(inputSchema)
+ .toolType("worker")
+ .func(input -> {
+ String outputPath = (String) input.get("output_path");
+ String inputPathsJson = (String) input.get("input_paths");
+ String separator = input.get("separator") instanceof String
+ ? (String) input.get("separator") : "\n\n---\n\n";
+
+ List paths;
+ try {
+ com.fasterxml.jackson.databind.ObjectMapper mapper =
+ new com.fasterxml.jackson.databind.ObjectMapper();
+ paths = mapper.readValue(inputPathsJson,
+ mapper.getTypeFactory().constructCollectionType(List.class, String.class));
+ } catch (Exception e) {
+ return "ERROR: Failed to parse input_paths: " + e.getMessage();
+ }
+
+ StringBuilder combined = new StringBuilder();
+ for (int i = 0; i < paths.size(); i++) {
+ if (i > 0) combined.append(separator);
+ Path full = WORK_DIR.resolve(paths.get(i));
+ if (Files.exists(full)) {
+ try {
+ combined.append(Files.readString(full));
+ } catch (IOException e) {
+ combined.append("[Error reading: ").append(paths.get(i)).append("]");
+ }
+ } else {
+ combined.append("[Missing: ").append(paths.get(i)).append("]");
+ }
+ }
+
+ Path outFull = WORK_DIR.resolve(outputPath);
+ try {
+ Files.createDirectories(outFull.getParent());
+ Files.writeString(outFull, combined.toString());
+ } catch (IOException e) {
+ return "ERROR: " + e.getMessage();
+ }
+ return "Assembled " + paths.size() + " files into " + outFull
+ + " (" + combined.length() + " bytes)";
+ })
+ .build();
+ }
+
+ static ToolDef checkWordCountTool() {
+ Map props = new LinkedHashMap<>();
+ props.put("path", Map.of("type", "string", "description", "File path (relative to working dir)."));
+ props.put("min_words", Map.of("type", "integer", "description", "Minimum number of words required."));
+
+ Map inputSchema = new LinkedHashMap<>();
+ inputSchema.put("type", "object");
+ inputSchema.put("properties", props);
+ inputSchema.put("required", List.of("path", "min_words"));
+
+ return ToolDef.builder()
+ .name("check_word_count")
+ .description("Check that a file meets a minimum word count.")
+ .inputSchema(inputSchema)
+ .toolType("worker")
+ .func(input -> {
+ String path = (String) input.get("path");
+ Object minWordsRaw = input.get("min_words");
+ int minWords = minWordsRaw instanceof Number
+ ? ((Number) minWordsRaw).intValue() : 200;
+
+ Path full = WORK_DIR.resolve(path);
+ if (!Files.exists(full)) {
+ return "{\"passed\": false, \"error\": \"File not found: " + path
+ + "\", \"word_count\": 0}";
+ }
+ String content;
+ try {
+ content = Files.readString(full);
+ } catch (IOException e) {
+ return "{\"passed\": false, \"error\": \"" + e.getMessage()
+ + "\", \"word_count\": 0}";
+ }
+ int count = content.split("\\s+").length;
+ boolean passed = count >= minWords;
+ return "{\"passed\": " + passed + ", \"word_count\": " + count
+ + ", \"min_words\": " + minWords + "}";
+ })
+ .build();
+ }
+
+ // ── Agent instructions (max_tokens variant) ─────────────────────────
+
+ static final String MAX_TOKENS_PLANNER_INSTRUCTIONS = "You are a research report planner. Given a topic, plan a detailed report.\n"
+ + "\n"
+ + "Your job:\n"
+ + "1. Decide on 3 sections for the report (introduction, body, conclusion)\n"
+ + "2. For each section, write clear instructions requesting DETAILED content (250+ words each)\n"
+ + "3. Output your plan as Markdown with an embedded JSON fence\n"
+ + "\n"
+ + "IMPORTANT: Your plan MUST include a ```json fence with the structured plan.\n"
+ + "IMPORTANT: Every generate block MUST include \"max_tokens\": 8192.\n"
+ + "\n"
+ + "## Available tools:\n"
+ + "- `create_directory`: args={path}\n"
+ + "- `write_file`: generate={instructions, output_schema, max_tokens}\n"
+ + "- `assemble_files`: args={output_path, input_paths, separator}\n"
+ + "- `check_word_count`: args={path, min_words}\n"
+ + "\n"
+ + "## Plan format:\n"
+ + "\n"
+ + "```json\n"
+ + "{\n"
+ + " \"steps\": [\n"
+ + " {\n"
+ + " \"id\": \"setup\",\n"
+ + " \"parallel\": false,\n"
+ + " \"operations\": [\n"
+ + " {\"tool\": \"create_directory\", \"args\": {\"path\": \"sections\"}}\n"
+ + " ]\n"
+ + " },\n"
+ + " {\n"
+ + " \"id\": \"write_sections\",\n"
+ + " \"depends_on\": [\"setup\"],\n"
+ + " \"parallel\": true,\n"
+ + " \"operations\": [\n"
+ + " {\n"
+ + " \"tool\": \"write_file\",\n"
+ + " \"generate\": {\n"
+ + " \"instructions\": \"Write a detailed 250+ word introduction about [topic].\",\n"
+ + " \"output_schema\": \"{\\\"path\\\": \\\"sections/01_intro.md\\\", \\\"content\\\": \\\"...\\\"}\",\n"
+ + " \"max_tokens\": 8192\n"
+ + " }\n"
+ + " },\n"
+ + " {\n"
+ + " \"tool\": \"write_file\",\n"
+ + " \"generate\": {\n"
+ + " \"instructions\": \"Write a detailed 250+ word body section about [subtopic].\",\n"
+ + " \"output_schema\": \"{\\\"path\\\": \\\"sections/02_body.md\\\", \\\"content\\\": \\\"...\\\"}\",\n"
+ + " \"max_tokens\": 8192\n"
+ + " }\n"
+ + " },\n"
+ + " {\n"
+ + " \"tool\": \"write_file\",\n"
+ + " \"generate\": {\n"
+ + " \"instructions\": \"Write a detailed 250+ word conclusion about [topic].\",\n"
+ + " \"output_schema\": \"{\\\"path\\\": \\\"sections/03_conclusion.md\\\", \\\"content\\\": \\\"...\\\"}\",\n"
+ + " \"max_tokens\": 8192\n"
+ + " }\n"
+ + " }\n"
+ + " ]\n"
+ + " },\n"
+ + " {\n"
+ + " \"id\": \"assemble\",\n"
+ + " \"depends_on\": [\"write_sections\"],\n"
+ + " \"parallel\": false,\n"
+ + " \"operations\": [\n"
+ + " {\n"
+ + " \"tool\": \"assemble_files\",\n"
+ + " \"args\": {\n"
+ + " \"output_path\": \"report.md\",\n"
+ + " \"input_paths\": \"[\\\"sections/01_intro.md\\\", \\\"sections/02_body.md\\\", \\\"sections/03_conclusion.md\\\"]\",\n"
+ + " \"separator\": \"\\n\\n---\\n\\n\"\n"
+ + " }\n"
+ + " }\n"
+ + " ]\n"
+ + " }\n"
+ + " ],\n"
+ + " \"validation\": [\n"
+ + " {\"tool\": \"check_word_count\", \"args\": {\"path\": \"report.md\", \"min_words\": " + MIN_WORD_COUNT + "}}\n"
+ + " ],\n"
+ + " \"on_success\": []\n"
+ + "}\n"
+ + "```\n"
+ + "\n"
+ + "## Rules:\n"
+ + "- Section files go in sections/ directory\n"
+ + "- Each section MUST be 250+ words (detailed, thorough)\n"
+ + "- Every generate block MUST include \"max_tokens\": 8192\n"
+ + "- The assemble step must list ALL section files in order\n"
+ + "- Always validate with check_word_count (min " + MIN_WORD_COUNT + " words)\n"
+ + "- The JSON must be valid\n";
+
+ // ── Agent instructions ───────────────────────────────────────────────
+
+ static final String PLANNER_INSTRUCTIONS = "You are a research report planner. Given a topic, plan a structured report.\n"
+ + "\n"
+ + "Your job:\n"
+ + "1. Decide on 3 sections for the report (introduction, body, conclusion)\n"
+ + "2. For each section, write clear instructions on what content to include\n"
+ + "3. Output your plan as Markdown with an embedded JSON fence\n"
+ + "\n"
+ + "IMPORTANT: Your plan MUST include a ```json fence with the structured plan.\n"
+ + "\n"
+ + "## Available tools for operations:\n"
+ + "- `create_directory`: args={path} — create a directory\n"
+ + "- `write_file`: generate={instructions, output_schema} — LLM writes content\n"
+ + "- `assemble_files`: args={output_path, input_paths, separator} — concatenate files\n"
+ + "- `check_word_count`: args={path, min_words} — validate word count\n"
+ + "\n"
+ + "## Plan format:\n"
+ + "\n"
+ + "Your output MUST end with a JSON fence like this example:\n"
+ + "\n"
+ + "```json\n"
+ + "{\n"
+ + " \"steps\": [\n"
+ + " {\n"
+ + " \"id\": \"setup\",\n"
+ + " \"parallel\": false,\n"
+ + " \"operations\": [\n"
+ + " {\"tool\": \"create_directory\", \"args\": {\"path\": \"sections\"}}\n"
+ + " ]\n"
+ + " },\n"
+ + " {\n"
+ + " \"id\": \"write_sections\",\n"
+ + " \"depends_on\": [\"setup\"],\n"
+ + " \"parallel\": true,\n"
+ + " \"operations\": [\n"
+ + " {\n"
+ + " \"tool\": \"write_file\",\n"
+ + " \"generate\": {\n"
+ + " \"instructions\": \"Write a 100-word introduction about [topic].\",\n"
+ + " \"output_schema\": \"{\\\"path\\\": \\\"sections/01_intro.md\\\", \\\"content\\\": \\\"...\\\"}\"\n"
+ + " }\n"
+ + " },\n"
+ + " {\n"
+ + " \"tool\": \"write_file\",\n"
+ + " \"generate\": {\n"
+ + " \"instructions\": \"Write a 100-word section about [subtopic].\",\n"
+ + " \"output_schema\": \"{\\\"path\\\": \\\"sections/02_body.md\\\", \\\"content\\\": \\\"...\\\"}\"\n"
+ + " }\n"
+ + " }\n"
+ + " ]\n"
+ + " },\n"
+ + " {\n"
+ + " \"id\": \"assemble\",\n"
+ + " \"depends_on\": [\"write_sections\"],\n"
+ + " \"parallel\": false,\n"
+ + " \"operations\": [\n"
+ + " {\n"
+ + " \"tool\": \"assemble_files\",\n"
+ + " \"args\": {\n"
+ + " \"output_path\": \"report.md\",\n"
+ + " \"input_paths\": \"[\\\"sections/01_intro.md\\\", \\\"sections/02_body.md\\\"]\",\n"
+ + " \"separator\": \"\\n\\n---\\n\\n\"\n"
+ + " }\n"
+ + " }\n"
+ + " ]\n"
+ + " }\n"
+ + " ],\n"
+ + " \"validation\": [\n"
+ + " {\"tool\": \"check_word_count\", \"args\": {\"path\": \"report.md\", \"min_words\": " + MIN_WORD_COUNT + "}}\n"
+ + " ],\n"
+ + " \"on_success\": []\n"
+ + "}\n"
+ + "```\n"
+ + "\n"
+ + "## Rules:\n"
+ + "- Section files go in sections/ directory (01_intro.md, 02_body.md, etc.)\n"
+ + "- Each section should be 80-150 words\n"
+ + "- The assemble step must list ALL section files in order\n"
+ + "- Always validate with check_word_count (min " + MIN_WORD_COUNT + " words)\n"
+ + "- Keep it simple: 3 sections total\n"
+ + "- The JSON must be valid\n";
+
+ static final String FALLBACK_INSTRUCTIONS = "You are fixing a report that failed validation. "
+ + "The plan was already partially executed but something went wrong "
+ + "(missing sections, word count too low, etc.).\n"
+ + "\n"
+ + "Review the error output, figure out what's missing or broken, and fix it.\n"
+ + "You have access to read_file, write_file, assemble_files, and check_word_count.\n"
+ + "\n"
+ + "Working directory: " + WORK_DIR;
+
+ // ── Tests ────────────────────────────────────────────────────────────
+
+ /**
+ * Plan-Execute should generate a report that passes word count validation.
+ *
+ *
COUNTERFACTUAL: if PLAN_EXECUTE strategy enum is not recognized by the
+ * server, the workflow won't compile or execute. If tool workers don't run,
+ * no files are created and file existence assertions fail. If fallbackMaxTurns
+ * is not serialized, the server may reject the config.
+ */
+ @Test
+ @Order(1)
+ @Timeout(value = 600, unit = TimeUnit.SECONDS)
+ void testReportGeneration() {
+ List tools = List.of(
+ createDirectoryTool(),
+ writeFileTool(),
+ readFileTool(),
+ assembleFilesTool(),
+ checkWordCountTool()
+ );
+
+ Agent planner = Agent.builder()
+ .name("test_java_planner")
+ .model(MODEL)
+ .instructions(PLANNER_INSTRUCTIONS)
+ .maxTurns(3)
+ .maxTokens(4000)
+ .build();
+
+ Agent fallback = Agent.builder()
+ .name("test_java_fallback")
+ .model(MODEL)
+ .instructions(FALLBACK_INSTRUCTIONS)
+ .tools(tools)
+ .maxTurns(10)
+ .maxTokens(8000)
+ .build();
+
+ Agent harness = Agent.builder()
+ .name("test_java_report_gen")
+ .model(MODEL)
+ .tools(tools)
+ .planner(planner)
+ .fallback(fallback)
+ .strategy(Strategy.PLAN_EXECUTE)
+ .fallbackMaxTurns(5)
+ .build();
+
+ AgentResult result = runtime.run(harness,
+ "Write a short research report about: The impact of AI on software testing");
+
+ // 1. Workflow completed
+ assertEquals(AgentStatus.COMPLETED, result.getStatus(),
+ "Agent did not complete. Status: " + result.getStatus()
+ + ". Error: " + result.getError());
+
+ // 2. Report file exists
+ Path reportPath = WORK_DIR.resolve("report.md");
+ assertTrue(Files.exists(reportPath),
+ "Report file not found at " + reportPath
+ + ". COUNTERFACTUAL: if tool workers didn't execute, no files are created.");
+
+ // 3. Report has content
+ String content;
+ try {
+ content = Files.readString(reportPath);
+ } catch (IOException e) {
+ fail("Failed to read report file: " + e.getMessage());
+ return;
+ }
+ assertTrue(content.length() > 0, "Report file is empty");
+
+ int wordCount = content.split("\\s+").length;
+
+ // 4. Word count meets minimum
+ assertTrue(wordCount >= MIN_WORD_COUNT,
+ "Report has " + wordCount + " words, expected >= " + MIN_WORD_COUNT
+ + ". COUNTERFACTUAL: if plan execution skipped write steps, word count is 0.");
+
+ // 5. Section files were created (proves parallel execution happened)
+ Path sectionsDir = WORK_DIR.resolve("sections");
+ assertTrue(Files.isDirectory(sectionsDir),
+ "sections/ directory not created. "
+ + "COUNTERFACTUAL: if create_directory tool didn't run, this directory won't exist.");
+
+ File[] sectionFiles = sectionsDir.toFile().listFiles(
+ (dir, name) -> name.endsWith(".md"));
+ assertNotNull(sectionFiles, "Could not list section files");
+ assertTrue(sectionFiles.length >= 2,
+ "Expected >= 2 section files, found " + sectionFiles.length
+ + ". COUNTERFACTUAL: parallel write_file steps must each produce a file.");
+
+ // 6. Each section file has content
+ for (File sf : sectionFiles) {
+ try {
+ String sfContent = Files.readString(sf.toPath());
+ int sfWords = sfContent.split("\\s+").length;
+ assertTrue(sfWords > 10,
+ "Section " + sf.getName() + " has only " + sfWords + " words");
+ } catch (IOException e) {
+ fail("Failed to read section file " + sf.getName() + ": " + e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * Plan-Execute should honor max_tokens in generate blocks.
+ *
+ *
COUNTERFACTUAL: if gen.max_tokens is not read by the GraalJS plan compiler,
+ * the LLM_CHAT_COMPLETE task gets the hardcoded default 4096. This test instructs
+ * the planner to include max_tokens: 8192 in generate blocks and requests longer
+ * sections (250+ words each). The field must be accepted without error.
+ */
+ @Test
+ @Order(2)
+ @Timeout(value = 600, unit = TimeUnit.SECONDS)
+ void testMaxTokensInGenerate() {
+ List tools = List.of(
+ createDirectoryTool(),
+ writeFileTool(),
+ readFileTool(),
+ assembleFilesTool(),
+ checkWordCountTool()
+ );
+
+ Agent planner = Agent.builder()
+ .name("test_java_planner_maxtok")
+ .model(MODEL)
+ .instructions(MAX_TOKENS_PLANNER_INSTRUCTIONS)
+ .maxTurns(3)
+ .maxTokens(4000)
+ .build();
+
+ Agent fallback = Agent.builder()
+ .name("test_java_fallback_maxtok")
+ .model(MODEL)
+ .instructions(FALLBACK_INSTRUCTIONS)
+ .tools(tools)
+ .maxTurns(10)
+ .maxTokens(8000)
+ .build();
+
+ Agent harness = Agent.builder()
+ .name("test_java_report_gen_maxtok")
+ .model(MODEL)
+ .tools(tools)
+ .planner(planner)
+ .fallback(fallback)
+ .strategy(Strategy.PLAN_EXECUTE)
+ .fallbackMaxTurns(5)
+ .build();
+
+ AgentResult result = runtime.run(harness,
+ "Write a detailed research report about: Quantum computing applications in cryptography");
+
+ // 1. Workflow completed — proves max_tokens field didn't break compilation
+ assertEquals(AgentStatus.COMPLETED, result.getStatus(),
+ "Agent did not complete. Status: " + result.getStatus()
+ + ". Error: " + result.getError());
+
+ // 2. We used to assert ``report.md`` exists, but the planner LLM
+ // names the final output file unpredictably across runs (report.txt,
+ // research_report_*.txt, quantum_*.md, etc.) — the test was failing
+ // not because max_tokens compilation broke but because the model
+ // chose a different filename. The test's purpose is to verify the
+ // compiler accepts ``max_tokens`` in generate blocks and the
+ // resulting workflow runs end-to-end; any substantive text output
+ // (>= MIN_WORD_COUNT across all produced text/markdown files
+ // combined) satisfies that. Mirrors the TS equivalent in
+ // tests/e2e/test_suite20_plan_execute.test.ts.
+ List textFiles;
+ try (var stream = Files.walk(WORK_DIR)) {
+ textFiles = stream
+ .filter(Files::isRegularFile)
+ .filter(p -> {
+ String n = p.getFileName().toString();
+ return n.endsWith(".md") || n.endsWith(".txt");
+ })
+ .collect(java.util.stream.Collectors.toList());
+ } catch (IOException e) {
+ fail("Failed to walk WORK_DIR: " + e.getMessage());
+ return;
+ }
+
+ StringBuilder all = new StringBuilder();
+ for (Path p : textFiles) {
+ try {
+ all.append(Files.readString(p)).append("\n\n");
+ } catch (IOException e) {
+ fail("Failed to read " + p + ": " + e.getMessage());
+ return;
+ }
+ }
+ int wordCount = all.length() == 0 ? 0 : all.toString().trim().split("\\s+").length;
+ System.err.println("[testMaxTokensInGenerate] produced " + textFiles.size()
+ + " text file(s), total word count: " + wordCount
+ + ", files=" + textFiles);
+
+ // If the file-count assertion is about to fail, dump diagnostics
+ // FIRST so the failure message tells us what actually happened —
+ // not just "0 files produced." See dumpWorkflowDiagnostics for
+ // the shape: status, reasonForIncompletion, planner output,
+ // PAC's compile output (error/warnings/stats), which branch
+ // fired, and tool task outcomes. This was added because CI was
+ // failing intermittently with no actionable signal.
+ if (textFiles.size() == 0 || wordCount < MIN_WORD_COUNT) {
+ dumpWorkflowDiagnostics(result.getExecutionId(), "testMaxTokensInGenerate");
+ }
+
+ assertTrue(textFiles.size() > 0,
+ "no .md/.txt files produced in " + WORK_DIR
+ + ". COUNTERFACTUAL: if the GraalJS compiler dropped max_tokens, "
+ + "the workflow may have terminated before writing any output."
+ + " See stderr for workflow diagnostics.");
+ assertTrue(wordCount >= MIN_WORD_COUNT,
+ "Total word count " + wordCount + " < " + MIN_WORD_COUNT
+ + ". COUNTERFACTUAL: if max_tokens was ignored, LLM output is truncated short."
+ + " See stderr for workflow diagnostics.");
+ }
+
+ /**
+ * Fetch the workflow with tasks and dump a debugging summary to stderr.
+ * Used by tests whose assertions are several layers downstream from the
+ * server-side behaviour they actually validate (e.g. file existence as
+ * proxy for "planner emitted a plan that compiled and ran") — when those
+ * fail the bare message is useless. This dumps the workflow's status,
+ * each task's type/status/output, and recurses one level into
+ * SUB_WORKFLOWs (the plan_exec sub-workflow is where the action is).
+ *
+ *
Best-effort: any network or JSON failure is caught and logged
+ * rather than failing the test on top of the original failure.
+ */
+ @SuppressWarnings("unchecked")
+ private void dumpWorkflowDiagnostics(String executionId, String label) {
+ System.err.println();
+ System.err.println("════════════════════════════════════════════════════");
+ System.err.println(" [" + label + "] DIAGNOSTICS for execution " + executionId);
+ System.err.println("════════════════════════════════════════════════════");
+ try {
+ Map wf = fetchWorkflowWithTasks(executionId);
+ if (wf == null) {
+ System.err.println(" (workflow fetch failed)");
+ return;
+ }
+ System.err.println(" workflowName: " + wf.get("workflowName"));
+ System.err.println(" status: " + wf.get("status"));
+ Object reason = wf.get("reasonForIncompletion");
+ if (reason != null) {
+ System.err.println(" reasonForIncompletion: " + truncate(reason.toString(), 500));
+ }
+ Object output = wf.get("output");
+ if (output != null) {
+ System.err.println(" parent output keys: "
+ + (output instanceof Map, ?> m ? m.keySet() : output.getClass().getSimpleName()));
+ }
+ List