From 18bd86807a769fa06d62e67a9579e9b16d80c7e6 Mon Sep 17 00:00:00 2001 From: Daniel Lehenbauer Date: Wed, 20 May 2026 13:17:33 -0700 Subject: [PATCH 1/8] feat(workflow): add Copilot SDK task family and outputSchema to workflow engine Introduces a new `copilot.invoke` builtin task that drives schema-guided Copilot agent turns via a new `CopilotClientHost` abstraction backed by `@github/copilot-sdk` 0.3.0. TaskContext is extended with an `outputSchema` field so tasks can use structured output contracts to shape their computation. --- .../ir/decisions/0001-bound-outputs.md | 6 +- .../ir/decisions/0003-task-schema-source.md | 7 + .../ir/decisions/0010-copilot-task-family.md | 354 +++++++++ .../0011-task-context-schema-awareness.md | 143 ++++ ts/examples/workflow/cli/src/cli.ts | 8 + ts/examples/workflow/engine/package.json | 1 + .../workflow/engine/src/builtinTasks.ts | 118 +++ .../workflow/engine/src/copilotClientHost.ts | 423 ++++++++++ ts/examples/workflow/engine/src/events.ts | 6 +- ts/examples/workflow/engine/src/index.ts | 12 + ts/examples/workflow/engine/src/runner.ts | 36 +- .../engine/test/copilotInvoke.spec.ts | 632 +++++++++++++++ .../workflow/engine/test/engine.spec.ts | 3 +- .../workflow/model/src/taskDefinition.ts | 10 +- .../workflows/d10-conventional-commit.json | 749 ++++++++++++++++++ 15 files changed, 2499 insertions(+), 9 deletions(-) create mode 100644 ts/docs/design/workflowSystem/ir/decisions/0010-copilot-task-family.md create mode 100644 ts/docs/design/workflowSystem/ir/decisions/0011-task-context-schema-awareness.md create mode 100644 ts/examples/workflow/engine/src/copilotClientHost.ts create mode 100644 ts/examples/workflow/engine/test/copilotInvoke.spec.ts create mode 100644 ts/examples/workflow/workflows/d10-conventional-commit.json diff --git a/ts/docs/design/workflowSystem/ir/decisions/0001-bound-outputs.md b/ts/docs/design/workflowSystem/ir/decisions/0001-bound-outputs.md index c4c312c5a..adcf3ddee 100644 --- a/ts/docs/design/workflowSystem/ir/decisions/0001-bound-outputs.md +++ b/ts/docs/design/workflowSystem/ir/decisions/0001-bound-outputs.md @@ -7,7 +7,11 @@ future extensions. Related: [0002-cfg-ddg-separation.md](0002-cfg-ddg-separation.md) (C2, C3, C4, C5), [../future/block-scope.md](../future/block-scope.md) (blocks remain post-v1 for the multi-statement try and the regional -grouping cases that bound outputs alone don't address). +grouping cases that bound outputs alone don't address), +[0010-copilot-task-family.md](0010-copilot-task-family.md) (the +deferred `copilot.session.fork` pattern carries session/event IDs +across nodes through the `bind`/`$from "scope"` mechanism this +decision establishes). ## 1. The proposal diff --git a/ts/docs/design/workflowSystem/ir/decisions/0003-task-schema-source.md b/ts/docs/design/workflowSystem/ir/decisions/0003-task-schema-source.md index 3c3c6aa2b..eca1ed56f 100644 --- a/ts/docs/design/workflowSystem/ir/decisions/0003-task-schema-source.md +++ b/ts/docs/design/workflowSystem/ir/decisions/0003-task-schema-source.md @@ -388,3 +388,10 @@ of it. reduces duplication within a single IR; orthogonal to this decision. - ir-v0.1.md §8.1 notes (post-v1) - the DSL layer that would handle the authoring-friction side of this trade-off. +- [0010-copilot-task-family.md](0010-copilot-task-family.md) §4 - + schema-guided design for `copilot.invoke` relies on the Option 1' + drift check to reject non-object IR `outputSchema`s at IR + validation time. +- [0011-task-context-schema-awareness.md](0011-task-context-schema-awareness.md) + exposes the IR-declared schemas this decision makes authoritative + to task implementers via `TaskContext`. diff --git a/ts/docs/design/workflowSystem/ir/decisions/0010-copilot-task-family.md b/ts/docs/design/workflowSystem/ir/decisions/0010-copilot-task-family.md new file mode 100644 index 000000000..ce05e691e --- /dev/null +++ b/ts/docs/design/workflowSystem/ir/decisions/0010-copilot-task-family.md @@ -0,0 +1,354 @@ +# Copilot SDK task family (decision 0010) + +Status: **Adopted (v1).** Design-complete for the full family; v1 ships +only `copilot.invoke` (the rest is documented and approved but +deferred to a later rev). The IR schema does **not** change — every +member of the family is just a registered task. + +Related: + +- [../../principles/design-principles.md](../../principles/design-principles.md) — P1-P5 and the "fewest concepts / behavioral variance" discipline. +- [0001-bound-outputs.md](0001-bound-outputs.md) — `bind`/`$from` mechanism the deferred fork pattern relies on. +- [0003-task-schema-source.md](0003-task-schema-source.md) — Option 1' drift check: the rule that rejects non-object IR `outputSchema`s for `copilot.invoke`. +- [0011-task-context-schema-awareness.md](0011-task-context-schema-awareness.md) — engine extension that exposes a node's declared schemas to the task implementer (used by `copilot.invoke` to drive its schema-guided turn loop). + +## 1. Problem + +The workflow engine ships an `llm.generate` builtin task that calls a +chat model via the in-repo `aiclient` package. We want sibling tasks +that drive **agentic** turns through the GitHub Copilot CLI via +[`@github/copilot-sdk`](https://github.com/github/copilot-sdk). + +The SDK is qualitatively different from `aiclient`: + +- **Stateful.** A long-lived `CopilotClient` (which spawns a CLI + subprocess over JSON-RPC) hosts one or more `CopilotSession`s. +- **Agentic.** The model calls tools (read/write file, shell, web, + MCP servers, custom JS handlers) inside the session's tool-use + loop. +- **Permissioned.** Every tool call is gated through an + `onPermissionRequest` callback the host must supply. +- **Sub-agent capable.** Custom agents declared at session creation + (`customAgents: [{name, prompt, tools, ...}]`) are auto-delegated + to by the runtime. +- **State-rich.** `sessions.fork`, `session.history.compact`, and + `session.history.truncate` (all marked `@experimental` on the SDK's + RPC surface) provide non-destructive forks from a given event ID, + forced compaction, and destructive rollback respectively. +- **Event-streaming.** `assistant.message_delta`, `tool.execution_*`, + `subagent.*`, `session.idle`, `session.compaction_*`, and more. + +Mapping all of this naively into the IR would create several new +IR-level concepts (sessions as IR-visible resources, sub-agent +topology as IR structure, tool surfaces as wired data flow). The IR +discipline (§principles preamble: "fewest concepts / behavioral +variance"; "principles govern the boundary, not the interior") says +we should NOT pull those into the IR unless they earn it. + +## 2. Key insight: the family fits with zero new IR concepts + +The Copilot SDK's session/fork/compact APIs operate on two opaque +identifiers: **session ID** (string) and **event ID** (string). +These are pure data. They flow through the IR's existing reference +mechanism — `bind` (decision 0001) and `$from: "scope"` — +without requiring any new IR concept: + +``` +research: copilot.session.send → bind: "research" = { sessionId, lastEventId, text } + ┌─────────────────────────────────────────┐ + ▼ ▼ +forkA: copilot.session.fork forkB: copilot.session.fork + { sessionId: $from research.sessionId, { sessionId: $from research.sessionId, + toEventId: $from research.lastEventId } toEventId: $from research.lastEventId } + bind: "branchA" = { sessionId } bind: "branchB" = { sessionId } + │ │ + ▼ ▼ +sumA: copilot.session.send (parallel) sumB: copilot.session.send (parallel) +``` + +Principle scorecard: + +- **P2** ("All data flow is traceable through the IR alone" — _for any + task input, can I trace it back to its origin by reading the IR?_): + every consumer reads who forked from what. +- **P3** ("IR structure corresponds to computational structure" — _does + the IR reveal the pattern, or must you analyze the graph to discover + it?_): a fork in the conversation is a fork in the IR. +- **P5** ("A reader of the IR can predict engine behavior" — _would a + reader be surprised by the behavior?_): which nodes share session + state is visible from the IR. +- **Fewest concepts.** Zero new IR concepts. Sessions and event IDs are + opaque values; the existing reference machinery moves them. Each + task is just typed-in, typed-out (P4 boundary). + +## 3. The full task family + +Each entry has a one-line "earns its place by exposing a distinct SDK +behavior the existing tasks cannot reproduce" justification. + +| Task | Earns its place by | Implementation phase | +| ------------------------- | ------------------------------------------------------------------------------------------------ | -------------------- | +| `copilot.invoke` | Convenience: create+send+close in one call; no session-ID plumbing; matches `llm.generate` shape | **v1 (now)** | +| `copilot.session.create` | Produce a session ID; configure model/agents/tools once | Deferred | +| `copilot.session.send` | Send a turn; return `{sessionId, lastEventId, text}` so downstream can fork or rewind | Deferred | +| `copilot.session.fork` | Non-destructive branch from a remembered event ID — the parallel-continuation primitive | Deferred | +| `copilot.session.compact` | Force compaction; expose `tokensRemoved` / `contextWindow` for IR-visible decisions | Deferred | +| `copilot.session.close` | Release in-memory resources (data preserved on disk for resume) | Deferred | + +`copilot.session.truncate` (destructive in-place rollback) is +**rejected** from the family — see §6 alternative D. + +## 4. Schema-guided design (applies to every member of the family that + +returns a value) + +`copilot.invoke` (and the deferred `copilot.session.send`) is "JSON in, +JSON out": the registered output schema is `{ "type": "object" }`, and +the _actual_ per-call output shape is whatever the IR node declares as +its `outputSchema`. Decision 0003 (Option 1') already makes the IR +node's `outputSchema` authoritative; this decision adds the rule that +the task **uses** that schema to drive the agent's response, not just +have it validated post-hoc. + +The pattern is TypeChat-shaped: + +1. **The task reads `ctx.outputSchema`** — exposed by decision 0011 + (engine API extension; not an IR change). +2. **It registers a synthetic `submit_response` tool** whose + parameters JSON Schema _is_ the node's `outputSchema`. The system + prompt nudges the agent to call it exactly once when finished. +3. **It runs the agent** via `session.sendAndWait`. +4. **It captures the validated tool arguments** from the + `submit_response` handler. The SDK validates the tool args against + the schema before our handler runs, so most of the repair work is + free. +5. **On failure** — either the agent called `submit_response` with + schema-invalid arguments (the SDK's tool-validation message + becomes the next-turn instruction), or the session reached `idle` + without `submit_response` being called (the task sends a follow-up + nudge) — **it retries** within a bounded budget. + +Default repair budget: **3 attempts** (initial + 2 repairs). Override +via optional `repairBudget?: integer` input; budget ≥ 1; task-internal +cap at 10 to prevent runaway loops. On budget exhaustion, +`copilot.invoke` returns +`{ kind: "fail", error: { message, data: { lastResponse, ajvErrors, attempts } } }` +so the workflow's existing `onError` mechanism can react. + +Non-object IR `outputSchema`s are **rejected at IR validation time** +by decision 0003's drift check (Option 1') — `copilot.invoke`'s +registered output is `{"type":"object"}`, and any narrower IR-side +declaration must be a subtype of that. Free-text returns require +explicit wrapping (e.g. +`{type:"object", required:["text"], properties:{text:{type:"string"}}}`). +Primitive returns are not supported in v1; if a real workflow needs +them, the answer is a separate task (e.g. `copilot.invokeText`), not +blurring this one. + +The IR author's optional `systemMessage` input is **appended** to the +SDK's system prompt scaffolding (mode `append`), never replaces it. + +## 5. v1 task: `copilot.invoke` + +### 5.1 Input schema + +| Field | Type | Required | Notes | +| ----------------- | ------------------------------------------------------------ | -------- | ----------------------------------------------------------------- | -------- | --- | -------------------------- | +| `prompt` | string | yes | The user-turn message | +| `model` | string | no | e.g. `"gpt-5"`. Defaults to engine config / SDK default | +| `systemMessage` | string | no | Appended to SDK system prompt scaffolding (mode `append`) | +| `customAgents` | array of `{name, displayName?, description, prompt, tools?}` | no | Pure-data sub-agent definitions | +| `allowedTools` | string[] | no | Allow-list of CLI built-in tool names (`view`, `edit`, `bash`, …). The engine always merges the synthetic `submit_response` tool into the SDK's `availableTools` allow-list so an empty `allowedTools: []` (deny all built-ins) still leaves the termination contract intact. | +| `attachments` | array of `{path}` | no | Paths validated against the `validateFilePath` allowed roots | +| `timeoutMs` | integer | no | Hard cap on session run time | +| `reasoningEffort` | `"low" | "medium" | "high" | "xhigh"` | no | For models that support it | +| `repairBudget` | integer | no | Schema-repair attempts; default 3, range 1–10 | + +### 5.2 Output schema (registered) + +`{ "type": "object" }`. Per-call shape comes from the IR node's +declared `outputSchema` per §4. + +### 5.3 Side-effects and permissions + +`sideEffects: true`. The engine's existing per-task policy +(`allow|prompt|deny`) gates the entire invocation as today. **Inside** +the session, the SDK's `onPermissionRequest` is wired to `approveAll` +in v1 — the agent may freely call any tool the SDK exposes +(read/write file, shell, web, MCP, custom). This is deliberately +temporary; see §7. + +### 5.4 Authentication + +Environment variables only — `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / +`GITHUB_TOKEN` — falling back to the `copilot` CLI's stored OAuth +login. No `gitHubToken` or `provider` field appears in the IR. Same +posture as `llm.generate`. Per-task BYOK is purely additive and can +be added later without revisiting this record. + +## 6. Deferred tasks (design captured; not in v1 implementation) + +All `sideEffects: true`. All schema-guided per §4 (where applicable). + +- **`copilot.session.create`** + - In: same as `copilot.invoke` minus `prompt`/`repairBudget`. + - Out: `{ sessionId: string }`. +- **`copilot.session.send`** + - In: `{ sessionId, prompt, attachments?, timeoutMs?, repairBudget? }`. + - Out: `{ sessionId, lastEventId, ... }` where `` + is whatever the IR node declares; `sessionId` and `lastEventId` + are added by the task on top of the IR-declared object so + downstream nodes can `bind` and chain. (The IR author declares + these fields on their node's `outputSchema`.) +- **`copilot.session.fork`** + - In: `{ sessionId, toEventId? }`. + - Out: `{ sessionId }` (the new fork's ID). +- **`copilot.session.compact`** + - In: `{ sessionId }`. + - Out: `{ sessionId, tokensRemoved, messagesRemoved, contextTokens, contextLimit }`. +- **`copilot.session.close`** + - In: `{ sessionId, deletePersistent?: boolean }`. + - Out: `{}`. (`deletePersistent: true` calls the SDK's + `deleteSession` instead of `disconnect`.) + +## 7. Permission posture and the longer-term direction + +v1's `approveAll` posture is **deliberately temporary**. The durable +answer is a **capability-based security model** in which: (a) each +task declares the capabilities it needs (file-write, shell, network, +outbound-domain), (b) a workflow declares the capability budget it +grants, and (c) the engine enforces the intersection at task +boundaries. This aligns with the existing aspiration in +[../../principles/design-principles.md](../../principles/design-principles.md): + +> "The design should remain open to expanding what tasks declare +> about themselves … capability and side-effect declarations." + +That work is engine/IR-wide, not Copilot-specific, so it gets its own +decision record when it begins. The Copilot family will be one of its +first consumers — its `onPermissionRequest` is the natural enforcement +point. + +## 8. Engine-side concerns (not IR concepts) + +### 8.1 SDK client lifecycle + +The `@github/copilot-sdk` `CopilotClient` spawns a CLI subprocess and +holds a JSON-RPC connection. Spawning per task call is too expensive. +v1 holds a **lazy module-singleton** in +`engine/src/copilotClientHost.ts`, lazily started on first call to any +`copilot.*` task and disposed at engine shutdown. The SDK import +itself is dynamic (`import("@github/copilot-sdk")`) so consumers who +never invoke any `copilot.*` task don't pay the bundled-CLI install +cost on first use. + +### 8.2 Session-leak safety net + +`copilot.invoke` creates an internal session per call and disposes it +in a `finally`. The deferred `copilot.session.*` family creates +session IDs that cross node boundaries; without a safety net, long +workflows leak. Recommended approach for the deferred tasks (already +plumbed into `copilotClientHost.ts` for v1 so it's ready): + +- **Explicit close is the contract.** Authors `bind` a session ID + and pair it with a `copilot.session.close` consumer. +- **Best-effort safety net.** The host module maintains a per-run + set of Copilot session IDs created by `copilot.session.create` / + `copilot.session.fork`, and disconnects any not closed when the + run ends (success or failure). + +This is **not** a new IR concept — it's an engine concern analogous +to `AbortSignal` cleanup. + +### 8.3 Concurrency + +The SDK explicitly states "no built-in session locking; concurrent +access to the same session is undefined." When fork lands, the engine +MUST ensure two `copilot.session.send` nodes never share the same +session ID. Forking creates a new ID, so the IR-correct pattern (fork +before parallel send) makes this fall out automatically. A future +validation warning could flag two sends in concurrent regions +referencing the same `sessionId`. + +### 8.4 Experimental SDK surface + +`sessions.fork`, `session.history.compact`, and +`session.history.truncate` are marked `@experimental` on the SDK's +RPC layer. **`copilot.invoke` does NOT use any experimental RPCs** — +it only uses stable `createSession` / `sendAndWait` / `disconnect` +plus the stable `defineTool` mechanism for `submit_response`. When the +deferred `copilot.session.*` family lands, the experimental calls +will be isolated in `copilotClientHost.ts` so the surface area is one +well-named adapter. + +## 9. Alternatives considered + +### A. Single-shot only, opaque session per call (no session.\* family) + +Reject. Forecloses the fork/rewind/parallel-continuation patterns the +SDK specifically supports, with no IR-side justification. Leaves +genuine capability on the floor. + +### B. IR-visible "resource handle" type for sessions + +Reject. Session IDs are already opaque strings; introducing a new IR +concept (handle/resource value with engine-managed lifetime) earns no +behavioral variance the existing reference mechanism cannot already +express. Cleanup is an engine concern (§8.2), not an IR concern. The +"fewest concepts" discipline rejects new concepts that only relabel +existing mechanisms. + +### C. Non-IR-visible "session context" carried in `TaskContext` + +Reject. Violates P2 (data flow happens outside the IR — readers +can't see which nodes share a session) and P5 (reader can't predict +which sessions are shared without consulting engine internals). The +fact that session IDs cross node boundaries via `bind`/`$from` is +exactly what makes the family principle-aligned. + +### D. Include `copilot.session.truncate` in the family + +Reject. Destructive in-place mutation of a session referenced by other +nodes violates P5 ("would a reader be surprised by the behavior, +including by what the engine keeps alive?"). `fork` covers the same +use cases non-destructively; truncate's only edge over fork is "saves +the cost of duplicating the session prefix," which is not a workflow +author concern. + +### E. Free-text output (fixed `{text}` shape) for `copilot.invoke` + +Reject. Schema-guided structured output is the headline value of +running a tool-using agent inside a workflow — downstream nodes can +reference structured fields via `$from … path: […]` with full P1 +type-checking. `llm.generate` already exists for free-text use; that +is the right destination for callers who want a string. + +### F. Schema-guidance via system-prompt-only or wrapping primitives + +Reject system-prompt-only: brittle parsing of the agent's last message +(must strip code fences, narrative text), no SDK-side validation, +hits the repair loop more often. Reject primitive wrapping: creates +two ways to express one thing in the IR (`{type:"string"}` vs +`{type:"object", properties:{value:{type:"string"}}}`) which would +behave differently — P5 violation. The chosen design uses the SDK's +typed-tool surface (`defineTool`) as a clean termination contract. + +## 10. Risks and gotchas + +- **Bundled CLI install size.** `@github/copilot-sdk` bundles the + Copilot CLI binary. Mitigated by dynamic import in + `copilotClientHost.ts`. +- **CI cannot make real Copilot calls.** Tests mock the client + factory. Per repo policy, `pnpm run test:live` is not run. +- **Experimental RPCs in deferred tasks.** When the + `copilot.session.*` family lands, the experimental surface is + isolated in `copilotClientHost.ts` so a future SDK churn affects + one adapter. + +## 11. Cross-references + +- [../../principles/design-principles.md](../../principles/design-principles.md) — P1-P5 and the "fewest concepts" discipline. +- [0001-bound-outputs.md](0001-bound-outputs.md) — `bind`/`$from` mechanism the deferred fork pattern relies on. +- [0003-task-schema-source.md](0003-task-schema-source.md) — Option 1' drift check that rejects non-object IR `outputSchema`s for `copilot.invoke`. +- [0011-task-context-schema-awareness.md](0011-task-context-schema-awareness.md) — engine extension exposing the node's declared schemas to the task implementer; what makes §4 possible. +- [../ir-v1.md](../ir-v1.md) §3.5 (task node), §5.2 (runtime output schema validation). diff --git a/ts/docs/design/workflowSystem/ir/decisions/0011-task-context-schema-awareness.md b/ts/docs/design/workflowSystem/ir/decisions/0011-task-context-schema-awareness.md new file mode 100644 index 000000000..8e48c019a --- /dev/null +++ b/ts/docs/design/workflowSystem/ir/decisions/0011-task-context-schema-awareness.md @@ -0,0 +1,143 @@ +# TaskContext schema awareness (decision 0011) + +Status: **Adopted (v1).** Engine API extension; **not** an IR change. +Adds `inputSchema` and `outputSchema` to the `TaskContext` value the +engine passes to `task.execute`. The schemas are populated from the +dispatching node's IR-declared schemas — i.e. existing IR data, made +visible to the task implementer. + +Related: + +- [../../principles/design-principles.md](../../principles/design-principles.md) — P4 ("each part can be understood / validated / tested without the whole, given only its declared boundary contract"). +- [0003-task-schema-source.md](0003-task-schema-source.md) — establishes that the IR node's `inputSchema`/`outputSchema` are authoritative (Option 1'). +- [0010-copilot-task-family.md](0010-copilot-task-family.md) — the first consumer; `copilot.invoke` reads `ctx.outputSchema` to drive its schema-guided turn loop. + +## 1. Problem + +Decision 0003 establishes that for every task node the IR's declared +`inputSchema`/`outputSchema` is authoritative — it either restates the +registered task's contract verbatim or narrows it. The engine already +validates a task's return value against the IR-declared `outputSchema` +at runtime (`ir-v1.md` §5.2). + +But today, the task implementation cannot **read** its own node's +declared schemas. `TaskContext` carries `runId`, `nodeId`, `scopePath`, +`signal`, `constraints` — not `inputSchema`/`outputSchema`. So a task +that wants to _use_ the schema as part of its computation (for +example, instructing an LLM agent to produce a value of a specific +shape, or driving a schema-aware transform) has no first-class access +to it. + +## 2. Decision + +Add two fields to `TaskContext`: + +```typescript +export interface TaskContext { + runId: string; + nodeId: string; + scopePath: string[]; + signal: AbortSignal; + constraints?: TaskConstraints; + /** + * The dispatching node's declared input schema, per IR §3.5. + * Authoritative for this call: equal to or a narrowing of the + * registered task's inputSchema (decision 0003 Option 1'). + */ + inputSchema: JSONSchema; + /** + * The dispatching node's declared output schema, per IR §3.5. + * Authoritative for this call: equal to or a narrowing of the + * registered task's outputSchema (decision 0003 Option 1'). + * The engine validates the task's return value against this + * schema after execution (IR §5.2); tasks may also use it to + * shape their computation (e.g. schema-guided LLM responses). + */ + outputSchema: JSONSchema; +} +``` + +The engine's runner populates these fields from the dispatching +`WorkflowNode`'s `inputSchema`/`outputSchema` before invoking +`task.execute`. + +## 3. Why this earns its place + +This is a near-zero-cost extension that exposes existing IR data to +the task implementer. It satisfies: + +- **P4 (boundary contract).** The IR-declared schemas _are_ the + task's boundary contract for this call. P4's one-line test — + _"Can I validate/test this part using only what its boundary + declares?"_ — is more directly satisfied when the task itself can + see its boundary, not merely have it enforced from outside. +- **Decision 0003 alignment.** 0003 made the IR's schemas + authoritative. Making them visible to the task is the natural + consequence: if the IR is the source of truth, the task should be + able to consult that source. +- **Generality.** The change is not Copilot-specific. Any future + schema-aware task benefits without re-litigating: a structured- + response variant of `llm.generate`, a `json.transform` task that + reshapes input to the declared output, an MCP bridge that maps + the node's schema onto the upstream protocol, etc. + +## 4. Why this is NOT an IR change + +No IR field is added or removed. `inputSchema`/`outputSchema` already +exist on every task node (`ir-v1.md` §3.5). This decision changes +only: + +- `workflow-model/src/taskDefinition.ts` — the `TaskContext` interface. +- `workflow-engine/src/runner.ts` — the runner populates the new + fields when constructing the per-call `TaskContext`. + +`ir-v1.md` does not need editing. No validator rule changes. No +existing IR document semantics change. + +## 5. Alternatives considered + +### A. Pass the schema in via a side-channel (e.g., a per-runId map) + +Reject. Hides the contract from the task's documented interface; +implementers have to know the side-channel exists. The whole point of +`TaskContext` is to be the documented per-call contract handed to +tasks. + +### B. Have schema-aware tasks accept a `responseSchema` field on input + +Reject. Creates duplicate declarations of the same shape (the IR +node's `outputSchema` and the task's input `responseSchema`) which +must agree by convention but the engine can't enforce in a way that's +visible at one read site. P5 ("would a reader be surprised?") — yes, +because they'd have to know the redundancy is required. + +### C. Defer until the next schema-aware task earns the change + +Reject. The cost of the change is essentially zero (two fields on a +context object, one population site in the runner). Doing it now +means decision 0010 (Copilot task family) lands cleanly and any +future schema-aware task gets the same affordance for free. Doing +it later means doing the migration of test fixtures and the runner +twice. + +## 6. Implementation notes + +- **No test-fixture cascade.** A scan of `engine/test/` and + `model/test/` confirms no test directly constructs a `TaskContext`; + all task execution flows through `WorkflowEngine.run`. The runner + populates the new fields from the node it is dispatching, so + existing tests continue to work without per-fixture changes. +- **Schemas are JSON values, not Ajv validators.** The runner does + NOT pre-compile a per-task `submit_response` validator or otherwise + cache schemas keyed by node — each task that wants to validate + against the schema brings its own validator (e.g. Ajv instance). + Keeping `TaskContext.{inputSchema,outputSchema}` as plain + `JSONSchema` mirrors how `TaskDefinition.inputSchema` / + `TaskDefinition.outputSchema` are typed today. + +## 7. Cross-references + +- [../../principles/design-principles.md](../../principles/design-principles.md) — P4. +- [0003-task-schema-source.md](0003-task-schema-source.md) — what made the IR's schemas the authoritative source this decision exposes. +- [0010-copilot-task-family.md](0010-copilot-task-family.md) — first consumer. +- [../ir-v1.md](../ir-v1.md) §3.5 (task node `inputSchema`/`outputSchema`), §5.2 (engine-side runtime output schema validation that this decision does **not** change). diff --git a/ts/examples/workflow/cli/src/cli.ts b/ts/examples/workflow/cli/src/cli.ts index fc947ddd0..1a78e05a1 100644 --- a/ts/examples/workflow/cli/src/cli.ts +++ b/ts/examples/workflow/cli/src/cli.ts @@ -174,6 +174,14 @@ async function cmdRun( console.error( `${prefix} Workflow failed${location}: ${result.error?.message ?? "unknown error"}`, ); + + // Log any structured context attached to the error. + if (result.error?.data !== undefined) { + console.error( + `${prefix} error data: ${JSON.stringify(result.error.data, null, 2)}`, + ); + } + process.exit(1); } } diff --git a/ts/examples/workflow/engine/package.json b/ts/examples/workflow/engine/package.json index 430d2fa1e..fb4e4f5fe 100644 --- a/ts/examples/workflow/engine/package.json +++ b/ts/examples/workflow/engine/package.json @@ -28,6 +28,7 @@ "tsc": "tsc -b" }, "dependencies": { + "@github/copilot-sdk": "^0.3.0", "aiclient": "workspace:*", "ajv": "^8.17.1", "debug": "^4.3.4", diff --git a/ts/examples/workflow/engine/src/builtinTasks.ts b/ts/examples/workflow/engine/src/builtinTasks.ts index fc8446549..c334c7647 100644 --- a/ts/examples/workflow/engine/src/builtinTasks.ts +++ b/ts/examples/workflow/engine/src/builtinTasks.ts @@ -17,7 +17,9 @@ import { dirname, resolve, relative, isAbsolute } from "node:path"; import { homedir, tmpdir } from "node:os"; import { JSONSchema, TaskDefinition } from "workflow-model"; import { openai } from "aiclient"; +import type { CustomAgentConfig } from "@github/copilot-sdk"; import { BUILTIN_TASK_SCHEMAS } from "./builtinTaskSchemas.js"; +import { invokeCopilotAgent } from "./copilotClientHost.js"; const SCHEMA_BY_NAME = new Map( BUILTIN_TASK_SCHEMAS.map((s) => [s.name, s] as const), @@ -295,6 +297,121 @@ export const llmGenerateJson: TaskDefinition< }, }; +/** + * This task runs a Copilot agent turn against a fresh session, with the agent's + * response shaped by the IR node's declared `outputSchema`. + * + * Key contracts: + * - Registered output schema: `{type: "object"}`. The actual per-call + * output shape is whatever the IR node declares. + * - Authentication: env vars (`COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / + * `GITHUB_TOKEN`) or the logged-in `copilot` CLI user. No IR knob. + * - Permission posture: Tools restricted to set listed in `allowedTools`. + * Requests to `allowedTools` are `approveAll` for v1. Capability-based + * security model is the longer-term follow-up (decision 0010 §7). + */ +export const copilotInvoke: TaskDefinition< + { + prompt: string; + model?: string; + systemMessage?: string; + customAgents?: CustomAgentConfig[]; + allowedTools?: string[]; + attachments?: Array<{ path: string }>; + timeoutMs?: number; + reasoningEffort?: "low" | "medium" | "high" | "xhigh"; + repairBudget?: number; + }, + Record +> = { + name: "copilot.invoke", + sideEffects: true, + inputSchema: { + type: "object", + required: ["prompt"], + properties: { + prompt: { type: "string" }, + model: { type: "string" }, + systemMessage: { type: "string" }, + customAgents: { type: "array" }, + allowedTools: { type: "array", items: { type: "string" } }, + attachments: { + type: "array", + items: { + type: "object", + required: ["path"], + properties: { path: { type: "string" } }, + }, + }, + timeoutMs: { type: "integer" }, + reasoningEffort: { + type: "string", + enum: ["low", "medium", "high", "xhigh"], + }, + repairBudget: { + type: "integer", + minimum: 1, + maximum: 10, + description: "Schema-repair attempts; default 3.", + }, + }, + }, + outputSchema: { + // `copilot.invoke`'s schema-guided turn loop registers a synthetic + // `submit_response` tool whose JSON Schema parameters block must be an + // object (LLM tool-call APIs require object parameters). + type: "object", + }, + async execute(input, ctx) { + // Validate any attachment paths against the same allowed roots + // file.read / file.write enforce. + if (input.attachments) { + for (const a of input.attachments) { + try { + a.path = validateFilePath(a.path); + } catch (err) { + return { + kind: "fail", + error: { + message: `copilot.invoke attachment ${a.path} rejected: ${err instanceof Error ? err.message : String(err)}`, + }, + }; + } + } + } + + const result = await invokeCopilotAgent({ + prompt: input.prompt, + outputSchema: ctx.outputSchema, + ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.systemMessage !== undefined + ? { systemMessageAppend: input.systemMessage } + : {}), + ...(input.customAgents !== undefined + ? { customAgents: input.customAgents } + : {}), + ...(input.allowedTools !== undefined + ? { availableTools: input.allowedTools } + : {}), + ...(input.attachments !== undefined + ? { attachments: input.attachments } + : {}), + ...(input.timeoutMs !== undefined + ? { timeoutMs: input.timeoutMs } + : {}), + ...(input.reasoningEffort !== undefined + ? { reasoningEffort: input.reasoningEffort } + : {}), + ...(input.repairBudget !== undefined + ? { repairBudget: input.repairBudget } + : {}), + signal: ctx.signal, + }); + + return result; + }, +}; + // ---- Utility tasks ---- export const textTemplate: TaskDefinition< @@ -779,6 +896,7 @@ export const allBuiltinTasks: TaskDefinition[] = [ shellExec, llmGenerate, llmGenerateJson, + copilotInvoke, httpGet, fileRead, fileWrite, diff --git a/ts/examples/workflow/engine/src/copilotClientHost.ts b/ts/examples/workflow/engine/src/copilotClientHost.ts new file mode 100644 index 000000000..10664fe84 --- /dev/null +++ b/ts/examples/workflow/engine/src/copilotClientHost.ts @@ -0,0 +1,423 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Copilot SDK client host (decision 0010). + * + * Owns the `@github/copilot-sdk` `CopilotClient` lifecycle and provides + * the schema-guided turn driver that `copilot.invoke` builds on. + * + * Design notes: + * - SDK *types* are imported statically via `import type` so we + * never duplicate the SDK's surface to avoid drift. `import type` + * is erased at emit and does not trigger module loading. + * - SDK *runtime* values (`defineTool`, `approveAll`, `CopilotClient`) + * are loaded via dynamic `import("@github/copilot-sdk")` so + * consumers who never invoke any `copilot.*` task don't pay the + * Copilot CLI runtime install cost. + * - The SDK client is a lazy module-singleton: started on first call, + * disposed on engine shutdown. + * - `invokeCopilotAgent(...)` implements the §4 turn loop from + * decision 0010: register `submit_response` whose parameters are + * the node's declared `outputSchema`, run the agent in an + * ephemeral session, capture the validated arguments, and repair on + * failure within a bounded budget. + * - For tests, `setCopilotClientFactory` swaps in a mock client. + */ + +import AjvModule from "ajv"; +import Debug from "debug"; +import type { JSONSchema } from "workflow-model"; +import type { + CopilotClient, + CopilotSession, + CustomAgentConfig, + MessageOptions, + SessionConfig, +} from "@github/copilot-sdk"; + +const debug = Debug("typeagent:workflow:copilot"); + +/** Maximum length, in characters, that we log via debug(). */ +const DEBUG_TRUNCATE_LEN = 800; + +function truncateForDebug(s: string): string { + if (s.length <= DEBUG_TRUNCATE_LEN) return s; + return `${s.slice(0, DEBUG_TRUNCATE_LEN)}…[truncated ${s.length - DEBUG_TRUNCATE_LEN} chars]`; +} + +/** + * Extract assistant text for debug logging. + * + * Today the SDK type for `sendAndWait` is `AssistantMessageEvent | + * undefined`, and `AssistantMessageEvent.data.content` is a string. + * We still keep a narrow runtime guard because this path is diagnostics + * only: if the SDK/event payload drifts at runtime, we prefer to log a + * coarse marker instead of throwing from debug plumbing. + */ +function extractAssistantText(reply: unknown): string | undefined { + if (reply === undefined || reply === null) return undefined; + const data = (reply as { data?: unknown }).data; + if (typeof data !== "object" || data === null) { + return "[assistant message: unexpected data payload]"; + } + const content = (data as { content?: unknown }).content; + if (typeof content === "string") return content; + if (content === undefined || content === null) return undefined; + return "[assistant message: non-string content]"; +} + +const AjvConstructor = (AjvModule as any).default ?? AjvModule; + +// ---- Public types: structural views over the SDK types ---- +// +// These are derived from the SDK classes via `Pick` so the SDK is the +// single source of truth for method signatures. The narrow surface +// (only the members we actually call) keeps the test mock surface +// small while still failing the build if the SDK changes a signature +// out from under us. + +export type MinimalCopilotSession = Pick< + CopilotSession, + "sessionId" | "sendAndWait" | "disconnect" +>; + +export interface MinimalCopilotClient { + start: CopilotClient["start"]; + stop: CopilotClient["stop"]; + createSession(config: SessionConfig): Promise; +} + +/** + * Factory returning a started client. The default implementation + * dynamically imports `@github/copilot-sdk`. Tests inject a mock via + * `setCopilotClientFactory`. + */ +export type CopilotClientFactory = () => Promise; + +// ---- Lazy singleton + factory swap ---- + +let factory: CopilotClientFactory = defaultFactory; +let clientPromise: Promise | undefined; + +async function defaultFactory(): Promise { + // Dynamic import keeps the Copilot CLI runtime bundle out of the + // workflow-engine critical path until any copilot.* task actually runs. + const sdk = (await import("@github/copilot-sdk")) as any; + const client = new sdk.CopilotClient(); + await client.start(); + return client as MinimalCopilotClient; +} + +/** + * Swap the SDK client factory. Intended for tests; production code + * should leave the default in place. + */ +export function setCopilotClientFactory(fn: CopilotClientFactory): void { + factory = fn; + clientPromise = undefined; +} + +/** Reset the singleton (tests). */ +export function resetCopilotClientFactory(): void { + factory = defaultFactory; + clientPromise = undefined; +} + +/** Lazily start the singleton client. */ +export async function getCopilotClient(): Promise { + if (!clientPromise) { + clientPromise = factory(); + } + return clientPromise; +} + +/** + * Stop the lazy singleton SDK client, if started. Idempotent. + */ +export async function shutdownCopilotHost(): Promise { + if (!clientPromise) return; + const client = await clientPromise.catch(() => undefined); + clientPromise = undefined; + if (!client) return; + try { + await client.stop(); + } catch (err) { + debug("Error stopping Copilot client: %O", err); + } +} + +// ---- Schema-guided turn driver (decision 0010 §4) ---- + +const ajv = new AjvConstructor({ strict: false }); + +/** Result of an ephemeral copilot agent invocation (decision 0010 §4). */ +export type InvokeCopilotAgentResult = + | { kind: "ok"; output: Record } + | { + kind: "fail"; + error: { message: string; data?: Record }; + }; + +export interface InvokeCopilotAgentOptions { + /** User-turn prompt. */ + prompt: string; + /** The node's IR-declared outputSchema; must be an object schema. */ + outputSchema: JSONSchema; + /** Optional model name. */ + model?: string; + /** Author-supplied addition to the SDK system prompt (mode "append"). */ + systemMessageAppend?: string; + /** Custom sub-agent definitions. */ + customAgents?: CustomAgentConfig[]; + /** Allow-list of Copilot CLI runtime built-in tool names. */ + availableTools?: string[]; + /** File attachments (already path-validated by caller). */ + attachments?: Array<{ path: string }>; + /** + * Hard cap on session run time. Forwarded as the `timeout` second + * positional argument to `CopilotSession.sendAndWait`. + */ + timeoutMs?: number; + /** + * For models that support reasoning effort. Typed via the SDK so a + * change in supported levels is a compile error here, not a silent + * runtime mismatch. The IR-declared input schema for `copilot.invoke` + * narrows this to a fixed enum (decision 0010 §5). + */ + reasoningEffort?: SessionConfig["reasoningEffort"]; + /** Schema-repair attempts (default 3, range 1-10). */ + repairBudget?: number; + /** Engine cooperative-cancellation signal. */ + signal: AbortSignal; +} + +/** System-prompt scaffolding for the submit_response convention. */ +function buildSystemMessageContent( + outputSchema: JSONSchema, + authorAppend?: string, +): string { + const schemaText = JSON.stringify(outputSchema, null, 2); + const base = [ + "You are an AI agent driven by an automated workflow engine. Only your `submit_response` tool call is read; assistant text is ignored by the engine but allowed for reasoning.", + "", + "Call `submit_response` exactly once when done. Its `arguments` MUST match this JSON Schema:", + "", + "```json", + schemaText, + "```", + "", + "If rejected, the next user message contains the validator errors — fix the arguments and call `submit_response` again. Repair attempts are bounded.", + "", + "You may call other available tools before submitting.", + ].join("\n"); + return authorAppend ? `${base}\n\n${authorAppend}` : base; +} + +/** + * Implementation of the `copilot.invoke` builtin task: runs one or + * more agent turns against a fresh session, using a `submit_response` + * custom tool whose parameters JSON Schema is the IR node's + * `outputSchema`. Repairs (re-prompts) on validation failure or + * no-call-on-idle, up to `repairBudget` total attempts. + * + * The session is created and disposed inside this call (ephemeral). + */ +export async function invokeCopilotAgent( + options: InvokeCopilotAgentOptions, +): Promise { + options.signal.throwIfAborted(); + + // Validate budget bounds. + const budget = options.repairBudget ?? 3; + if (!Number.isInteger(budget) || budget < 1 || budget > 10) { + return { + kind: "fail", + error: { + message: `repairBudget must be an integer in [1, 10]; got ${budget}`, + }, + }; + } + + // outputSchema MUST be an object schema. Decision 0003's drift check would + // reject non-object schemas at IR validation time (since copilot.invoke's + // registered outputSchema is `{type: "object"}`); this is a + // defense-in-depth guard. + const otype = (options.outputSchema as Record).type; + if (otype !== "object") { + return { + kind: "fail", + error: { + message: `copilot.invoke output schema must have type "object"; got ${JSON.stringify(otype)}. See decision 0010 §4.`, + }, + }; + } + + // Compile the validator for the per-call output schema. + let validate; + try { + validate = ajv.compile(options.outputSchema as object); + } catch (e) { + const m = e instanceof Error ? e.message : String(e); + return { + kind: "fail", + error: { message: `Invalid outputSchema: ${m}` }, + }; + } + + // Mutable capture cell shared with the submit_response handler. + const captured: { value?: Record; lastErrors?: string } = + {}; + + const sdk = (await import("@github/copilot-sdk")) as any; + const client = await getCopilotClient(); + + // Build the synthetic submit_response tool. The SDK accepts a raw JSON + // Schema object as `parameters`. We also Ajv-validate inside the handler so + // we never accept malformed args even if SDK-side validation is permissive. + const submitTool = sdk.defineTool("submit_response", { + description: + "Submit your final structured answer. Call this exactly once when you have your final answer.", + parameters: options.outputSchema as Record, + skipPermission: true, + handler: async (args: unknown) => { + if ( + typeof args !== "object" || + args === null || + Array.isArray(args) || + !validate(args) + ) { + const errs = validate?.errors + ? ajv.errorsText(validate.errors) + : "arguments must be a JSON object matching the schema"; + captured.lastErrors = errs; + return `\`submit_response\` rejected: ${errs}. Please call \`submit_response\` again with corrected arguments.`; + } + captured.value = args as Record; + delete captured.lastErrors; + return "Response recorded."; + }, + }); + + const sessionConfig: SessionConfig = { + onPermissionRequest: sdk.approveAll, + tools: [submitTool], + systemMessage: { + mode: "append", + content: buildSystemMessageContent( + options.outputSchema, + options.systemMessageAppend, + ), + }, + }; + if (options.model !== undefined) sessionConfig.model = options.model; + if (options.reasoningEffort !== undefined) + sessionConfig.reasoningEffort = options.reasoningEffort; + if (options.customAgents !== undefined) + sessionConfig.customAgents = options.customAgents; + if (options.availableTools !== undefined) { + // Always add our synthetic `submit_response` tool to the user's + // allow-list so the model can complete the turn contract. + const merged = new Set(options.availableTools); + merged.add("submit_response"); + sessionConfig.availableTools = [...merged]; + } + + let session: MinimalCopilotSession | undefined; + const onAbort = () => { + // Best-effort: disconnect immediately on cancellation. + session?.disconnect().catch(() => undefined); + }; + options.signal.addEventListener("abort", onAbort, { once: true }); + + try { + session = await client.createSession(sessionConfig); + + const attachments: MessageOptions["attachments"] | undefined = + options.attachments?.map((a) => ({ + type: "file" as const, + path: a.path, + })); + + let attempt = 0; + let prompt = options.prompt; + let lastAssistantText: string | undefined; + while (attempt < budget) { + attempt++; + options.signal.throwIfAborted(); + debug( + "copilot.invoke attempt %d/%d (sessionId=%s) prompt=%s", + attempt, + budget, + session.sessionId, + truncateForDebug(prompt), + ); + + const sendOpts: MessageOptions = { prompt }; + if (attachments) sendOpts.attachments = attachments; + // `timeout` is the second positional arg to sendAndWait, NOT a + // field on MessageOptions. + const reply = await session.sendAndWait( + sendOpts, + options.timeoutMs, + ); + lastAssistantText = extractAssistantText(reply); + if (lastAssistantText !== undefined) { + debug( + "copilot.invoke attempt %d assistant text: %s", + attempt, + truncateForDebug(lastAssistantText), + ); + } + + if (captured.value !== undefined) { + return { kind: "ok", output: captured.value }; + } + + // No successful capture this turn. Build a repair prompt for the + // next attempt (if budget allows). + const reason = captured.lastErrors + ? `Your previous \`submit_response\` call was rejected: ${captured.lastErrors}.` + : `You did not call \`submit_response\`. You MUST call \`submit_response\` with arguments matching the required schema.`; + debug( + "copilot.invoke attempt %d rejected: %s", + attempt, + captured.lastErrors ?? "no submit_response call", + ); + prompt = `${reason} Please call \`submit_response\` now with corrected arguments matching the required schema.`; + } + + return { + kind: "fail", + error: { + message: `copilot.invoke exhausted repair budget (${budget}) without a valid submit_response call.`, + data: { + attempts: attempt, + lastErrors: captured.lastErrors ?? null, + lastAssistantText: lastAssistantText ?? null, + }, + }, + }; + } catch (err) { + if (options.signal.aborted || (err as Error)?.name === "AbortError") { + return { + kind: "fail", + error: { message: "copilot.invoke aborted" }, + }; + } + const m = err instanceof Error ? err.message : String(err); + return { + kind: "fail", + error: { message: `copilot.invoke error: ${m}` }, + }; + } finally { + options.signal.removeEventListener("abort", onAbort); + if (session) { + try { + await session.disconnect(); + } catch (e) { + debug("session.disconnect() failed: %O", e); + } + } + } +} diff --git a/ts/examples/workflow/engine/src/events.ts b/ts/examples/workflow/engine/src/events.ts index dececa844..c003d8e00 100644 --- a/ts/examples/workflow/engine/src/events.ts +++ b/ts/examples/workflow/engine/src/events.ts @@ -64,7 +64,11 @@ export type WorkflowEvent = | { type: "runFailed"; runId: string; - error: { message: string; data?: unknown }; + error: { + message: string; + nodeId?: string | undefined; + data?: unknown; + }; timestamp: number; } | { diff --git a/ts/examples/workflow/engine/src/index.ts b/ts/examples/workflow/engine/src/index.ts index e9f198519..af7cc3604 100644 --- a/ts/examples/workflow/engine/src/index.ts +++ b/ts/examples/workflow/engine/src/index.ts @@ -15,6 +15,7 @@ export { boolToLabel, shellExec, llmGenerate, + copilotInvoke, httpGet, fileRead, fileWrite, @@ -41,3 +42,14 @@ export { standardLibraryTasks, allBuiltinTasks, } from "./builtinTasks.js"; + +// TODO: The @github/copilot-sdk dependency hints that copilotInvoke may be +// a good first candidate for an external task. +export { + setCopilotClientFactory, + resetCopilotClientFactory, + shutdownCopilotHost, + type CopilotClientFactory, + type MinimalCopilotClient, + type MinimalCopilotSession, +} from "./copilotClientHost.js"; diff --git a/ts/examples/workflow/engine/src/runner.ts b/ts/examples/workflow/engine/src/runner.ts index 48520b97a..f463df7b2 100644 --- a/ts/examples/workflow/engine/src/runner.ts +++ b/ts/examples/workflow/engine/src/runner.ts @@ -308,7 +308,15 @@ export interface RunResult { runId: string; success: boolean; output?: unknown; - error?: { message: string; nodeId?: string | undefined }; + error?: { + message: string; + nodeId?: string | undefined; + /** + * Structured error context attached by the failing task. Opaque to the + * engine; callers may log or serialize it for diagnostics. + */ + data?: unknown; + }; } // ---- Engine ---- @@ -535,17 +543,33 @@ export class WorkflowEngine { return { runId, success: true, output }; } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const nodeId = err instanceof TaskFailure ? err.nodeId : undefined; + const isTaskFailure = err instanceof TaskFailure; + const errorPayload: { + message: string; + nodeId?: string | undefined; + data?: unknown; + } = { + message: err instanceof Error ? err.message : String(err), + ...(isTaskFailure + ? { nodeId: err.nodeId } + : {}), + ...(isTaskFailure && err.taskError.data !== undefined + ? { data: err.taskError.data } + : {}), + }; this.emit({ type: "runFailed", runId, - error: { message }, + error: errorPayload, timestamp: Date.now(), }); - return { runId, success: false, error: { message, nodeId } }; + return { + runId, + success: false, + error: errorPayload, + }; } } @@ -869,6 +893,8 @@ export class WorkflowEngine { scopePath: [...scopePath], signal: taskSignal, ...(constraints ? { constraints } : {}), + // Expose the dispatching node's declared output schema + // to schema-guided tasks like copilot.invoke. ...(node.outputSchema ? { outputSchema: node.outputSchema } : {}), diff --git a/ts/examples/workflow/engine/test/copilotInvoke.spec.ts b/ts/examples/workflow/engine/test/copilotInvoke.spec.ts new file mode 100644 index 000000000..f19ec5941 --- /dev/null +++ b/ts/examples/workflow/engine/test/copilotInvoke.spec.ts @@ -0,0 +1,632 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Unit tests for copilot.invoke (decision 0010 §5). + * + * These tests use a mock CopilotClient injected via + * setCopilotClientFactory; the real @github/copilot-sdk is never + * loaded. Per repo policy, integration tests against the live SDK + * are out of scope (test:live). + */ + +import { + TaskRegistry, + WorkflowEngine, + copilotInvoke, + setCopilotClientFactory, + resetCopilotClientFactory, + type MinimalCopilotClient, + type MinimalCopilotSession, +} from "../src/index.js"; +import { TaskPolicy, WorkflowIR } from "workflow-model"; +import type { MessageOptions, SessionConfig, Tool } from "@github/copilot-sdk"; + +// Allow-all policy for tests. +const allowAllPolicy: TaskPolicy = new Proxy({} as TaskPolicy, { + get: () => "allow" as const, +}); + +// ---- Mock client ---- + +type SubmitResponseHandler = (args: unknown) => Promise; + +function makeMockClient(script: AgentScript[]): { + client: MinimalCopilotClient; + sessions: MockSession[]; + stopCount: { value: number }; +} { + let scriptIdx = 0; + const sessions: MockSession[] = []; + const stopCount = { value: 0 }; + + const client: MinimalCopilotClient = { + async start() {}, + async stop() { + stopCount.value++; + return []; + }, + async createSession(config: SessionConfig) { + const submitTool = (config.tools ?? []).find( + (t: Tool) => t.name === "submit_response", + ); + if (!submitTool) { + throw new Error( + "mock createSession: expected a submit_response tool to be registered", + ); + } + // SDK ToolHandler is `(args, invocation)`; our submit_response + // handler ignores `invocation`, so it's safe to call with one arg. + const handler = submitTool.handler as SubmitResponseHandler; + const session = new MockSession(config, handler, () => { + if (scriptIdx >= script.length) { + throw new Error( + `mock client ran out of scripted turns (asked for #${scriptIdx + 1}, have ${script.length})`, + ); + } + return script[scriptIdx++]!; + }); + sessions.push(session); + return session; + }, + }; + + return { client, sessions, stopCount }; +} + +interface AgentScript { + /** + * Function that, given the prompt and the submit_response tool + * handler, simulates the agent's actions for one sendAndWait + * call. Returns when the "session" goes idle. May call the + * tool handler 0 or more times. + */ + onSend: ( + prompt: string, + callSubmit: SubmitResponseHandler, + ) => Promise; +} + +class MockSession implements MinimalCopilotSession { + public sessionId: string; + public sentPrompts: string[] = []; + public sentTimeouts: Array = []; + public disconnected = false; + constructor( + public config: SessionConfig, + private submitHandler: SubmitResponseHandler, + private nextScript: () => AgentScript, + ) { + this.sessionId = `mock-${Math.random().toString(36).slice(2, 8)}`; + } + async sendAndWait(opts: MessageOptions, timeout?: number) { + this.sentPrompts.push(opts.prompt); + this.sentTimeouts.push(timeout); + const script = this.nextScript(); + await script.onSend(opts.prompt, this.submitHandler); + return undefined; + } + async disconnect() { + this.disconnected = true; + } +} + +// ---- Tests ---- + +describe("copilot.invoke (decision 0010)", () => { + afterEach(() => { + resetCopilotClientFactory(); + }); + + function makeEngine() { + const reg = new TaskRegistry(); + reg.register(copilotInvoke); + return new WorkflowEngine(reg); + } + + function makeIR(opts: { + outputSchema: Record; + inputs: Record; + }): WorkflowIR { + return { + kind: "workflow", + name: "copilotTest", + version: "1", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + nodes: { + step: { + kind: "task", + task: "copilot.invoke", + inputSchema: copilotInvoke.inputSchema, + outputSchema: opts.outputSchema, + inputs: opts.inputs as any, + bind: "result", + }, + }, + entry: "step", + output: { $from: "scope", name: "result" } as any, + }; + } + + it("happy path: agent calls submit_response with valid args", async () => { + const { client } = makeMockClient([ + { + async onSend(_prompt, callSubmit) { + await callSubmit({ summary: "hi", count: 3 }); + }, + }, + ]); + setCopilotClientFactory(async () => client); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + required: ["summary", "count"], + properties: { + summary: { type: "string" }, + count: { type: "integer" }, + }, + }, + inputs: { prompt: "do the thing" }, + }); + + const result = await eng.run(ir, { policy: allowAllPolicy }); + expect(result.success).toBe(true); + expect(result.output).toEqual({ summary: "hi", count: 3 }); + }); + + it("repair loop: invalid args first, valid on second turn", async () => { + const { client, sessions } = makeMockClient([ + { + async onSend(_p, callSubmit) { + // Wrong shape — missing required `count`. + await callSubmit({ summary: "hi" }); + }, + }, + { + async onSend(_p, callSubmit) { + await callSubmit({ summary: "hi", count: 7 }); + }, + }, + ]); + setCopilotClientFactory(async () => client); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + required: ["summary", "count"], + properties: { + summary: { type: "string" }, + count: { type: "integer" }, + }, + }, + inputs: { prompt: "do the thing" }, + }); + + const result = await eng.run(ir, { policy: allowAllPolicy }); + expect(result.success).toBe(true); + expect(result.output).toEqual({ summary: "hi", count: 7 }); + // Two sendAndWait turns were used. + expect(sessions[0]!.sentPrompts.length).toBe(2); + // Second prompt is a repair nudge. + expect(sessions[0]!.sentPrompts[1]).toContain("submit_response"); + }); + + it("repair loop: idle without calling submit_response is repaired", async () => { + const { client, sessions } = makeMockClient([ + { + // Agent says nothing useful and goes idle. + async onSend() {}, + }, + { + async onSend(_p, callSubmit) { + await callSubmit({ summary: "answer", count: 1 }); + }, + }, + ]); + setCopilotClientFactory(async () => client); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + required: ["summary", "count"], + properties: { + summary: { type: "string" }, + count: { type: "integer" }, + }, + }, + inputs: { prompt: "..." }, + }); + + const result = await eng.run(ir, { policy: allowAllPolicy }); + expect(result.success).toBe(true); + expect(result.output).toEqual({ summary: "answer", count: 1 }); + expect(sessions[0]!.sentPrompts[1]).toContain( + "did not call `submit_response`", + ); + }); + + it("budget exhaustion fails with diagnostic data", async () => { + // 3 attempts, all invalid; default budget is 3. + const { client } = makeMockClient([ + { + async onSend(_p, callSubmit) { + await callSubmit({ wrong: "shape" }); + }, + }, + { + async onSend(_p, callSubmit) { + await callSubmit({ wrong: "shape" }); + }, + }, + { + async onSend(_p, callSubmit) { + await callSubmit({ wrong: "shape" }); + }, + }, + ]); + setCopilotClientFactory(async () => client); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + required: ["summary", "count"], + properties: { + summary: { type: "string" }, + count: { type: "integer" }, + }, + }, + inputs: { prompt: "..." }, + }); + + const result = await eng.run(ir, { policy: allowAllPolicy }); + expect(result.success).toBe(false); + expect(result.error?.message).toMatch(/exhausted repair budget/); + // error.data must surface the per-attempt diagnostics so the + // CLI can print them. See copilotClientHost.ts (the failure + // payload) and runner.ts RunResult (which now propagates `data`). + const data = result.error?.data as + | { + attempts?: number; + lastErrors?: string | null; + lastAssistantText?: string | null; + } + | undefined; + expect(data).toBeDefined(); + expect(data?.attempts).toBe(3); + expect(typeof data?.lastErrors).toBe("string"); + expect(data?.lastErrors).toMatch(/required|summary|count/); + }); + + it("respects custom repairBudget input", async () => { + const { client, sessions } = makeMockClient([ + { + async onSend(_p, callSubmit) { + await callSubmit({ wrong: true }); + }, + }, + ]); + setCopilotClientFactory(async () => client); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + required: ["summary"], + properties: { summary: { type: "string" } }, + }, + inputs: { prompt: "...", repairBudget: 1 }, + }); + + const result = await eng.run(ir, { policy: allowAllPolicy }); + expect(result.success).toBe(false); + expect(sessions[0]!.sentPrompts.length).toBe(1); + }); + + it("rejects non-object IR outputSchema (defense-in-depth)", async () => { + // Invalid outputSchema for copilot.invoke — should fail before + // we even call the SDK. The factory should not be invoked. + let factoryCalled = false; + setCopilotClientFactory(async () => { + factoryCalled = true; + throw new Error("factory should not be called"); + }); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { type: "string" }, + inputs: { prompt: "give me a string" }, + }); + + const result = await eng.run(ir, { policy: allowAllPolicy }); + expect(result.success).toBe(false); + expect(factoryCalled).toBe(false); + // Either the engine's IR-level drift check fires, or our + // defense-in-depth check inside invokeCopilotAgent fires. + // Either way we want a clear non-object schema message. + expect(result.error?.message.toLowerCase()).toMatch( + /object|outputschema/, + ); + }); + + it("validates repairBudget bounds", async () => { + const { client } = makeMockClient([]); + setCopilotClientFactory(async () => client); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + properties: { x: { type: "string" } }, + }, + inputs: { prompt: "...", repairBudget: 99 }, + }); + + const result = await eng.run(ir, { policy: allowAllPolicy }); + // The IR validates repairBudget (max 10) at IR validation time + // because the input schema declares minimum/maximum. + expect(result.success).toBe(false); + }); + + it("passes model, customAgents, availableTools through to SDK", async () => { + const { client, sessions } = makeMockClient([ + { + async onSend(_p, callSubmit) { + await callSubmit({ ok: true }); + }, + }, + ]); + setCopilotClientFactory(async () => client); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + required: ["ok"], + properties: { ok: { type: "boolean" } }, + }, + inputs: { + prompt: "...", + model: "gpt-5", + customAgents: [ + { name: "researcher", description: "x", prompt: "y" }, + ], + allowedTools: ["view", "grep"], + reasoningEffort: "high", + systemMessage: "extra rules go here", + }, + }); + + const result = await eng.run(ir, { policy: allowAllPolicy }); + expect(result.success).toBe(true); + const cfg = sessions[0]!.config; + expect(cfg.model).toBe("gpt-5"); + expect(cfg.customAgents).toEqual([ + { name: "researcher", description: "x", prompt: "y" }, + ]); + // The host always merges `submit_response` into the SDK + // `availableTools` allow-list so the synthetic termination + // tool stays exposed alongside whatever built-ins the IR + // permits. See copilotClientHost.ts. + expect(cfg.availableTools).toEqual( + expect.arrayContaining(["view", "grep", "submit_response"]), + ); + expect(cfg.availableTools).toHaveLength(3); + expect(cfg.reasoningEffort).toBe("high"); + // The SDK system-message scaffolding uses mode "append" and + // includes the schema text plus the author's addendum. + expect(cfg.systemMessage?.mode).toBe("append"); + expect(cfg.systemMessage?.content).toContain("submit_response"); + expect(cfg.systemMessage?.content).toContain("extra rules go here"); + }); + + it("keeps submit_response available even when allowedTools is empty", async () => { + // Regression: an `allowedTools: []` IR input (deny all CLI + // built-ins) was previously forwarded as `availableTools: []`, + // which the SDK reads as "no tools at all" — including the + // synthetic `submit_response` — making it impossible for the + // model to terminate the turn-loop. The host must always merge + // `submit_response` into the allow-list. + const { client, sessions } = makeMockClient([ + { + async onSend(_p, callSubmit) { + await callSubmit({ ok: true }); + }, + }, + ]); + setCopilotClientFactory(async () => client); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + required: ["ok"], + properties: { ok: { type: "boolean" } }, + }, + inputs: { + prompt: "...", + allowedTools: [], + }, + }); + + const result = await eng.run(ir, { policy: allowAllPolicy }); + expect(result.success).toBe(true); + expect(sessions[0]!.config.availableTools).toEqual(["submit_response"]); + }); + + it("disposes the session after a successful call", async () => { + const { client, sessions } = makeMockClient([ + { + async onSend(_p, callSubmit) { + await callSubmit({ x: "y" }); + }, + }, + ]); + setCopilotClientFactory(async () => client); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + required: ["x"], + properties: { x: { type: "string" } }, + }, + inputs: { prompt: "..." }, + }); + + await eng.run(ir, { policy: allowAllPolicy }); + expect(sessions[0]!.disconnected).toBe(true); + }); + + it("disposes the session even on failure", async () => { + const { client, sessions } = makeMockClient([ + { + async onSend() { + throw new Error("boom"); + }, + }, + ]); + setCopilotClientFactory(async () => client); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + required: ["x"], + properties: { x: { type: "string" } }, + }, + inputs: { prompt: "..." }, + }); + + const result = await eng.run(ir, { policy: allowAllPolicy }); + expect(result.success).toBe(false); + expect(sessions[0]!.disconnected).toBe(true); + }); + + it("rejects attachment paths outside the allowed roots", async () => { + let factoryCalled = false; + setCopilotClientFactory(async () => { + factoryCalled = true; + throw new Error("factory should not be called"); + }); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + required: ["x"], + properties: { x: { type: "string" } }, + }, + inputs: { + prompt: "...", + attachments: [{ path: "/etc/passwd" }], + }, + }); + + const result = await eng.run(ir, { policy: allowAllPolicy }); + expect(result.success).toBe(false); + expect(result.error?.message).toMatch(/attachment.*rejected/); + expect(factoryCalled).toBe(false); + }); + + it("respects ctx.signal cancellation", async () => { + const ctrl = new AbortController(); + + // Script that "hangs" until aborted, then resolves. + const { client, sessions } = makeMockClient([ + { + async onSend() { + return new Promise((resolve) => { + ctrl.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + }, + }, + ]); + setCopilotClientFactory(async () => client); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + required: ["x"], + properties: { x: { type: "string" } }, + }, + inputs: { prompt: "..." }, + }); + + const promise = eng.run(ir, { + policy: allowAllPolicy, + signal: ctrl.signal, + }); + // Abort mid-flight. + setTimeout(() => ctrl.abort(), 30); + + const result = await promise; + expect(result.success).toBe(false); + // Either via the engine's "Run cancelled" or copilot's abort path. + expect(result.error?.message.toLowerCase()).toMatch(/cancel|abort/); + // The session should still have been disconnected. + expect(sessions[0]!.disconnected).toBe(true); + }); + + it("forwards timeoutMs as sendAndWait's second positional arg", async () => { + // The SDK's sendAndWait signature is `(options, timeout?)` — not + // a `timeout` field on `options`. This guards against drifting + // back to the buggy options-bag form (which the SDK silently + // ignores). + const { client, sessions } = makeMockClient([ + { + async onSend(_p, callSubmit) { + await callSubmit({ x: "ok" }); + }, + }, + ]); + setCopilotClientFactory(async () => client); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + required: ["x"], + properties: { x: { type: "string" } }, + }, + inputs: { prompt: "...", timeoutMs: 12345 }, + }); + + const result = await eng.run(ir, { policy: allowAllPolicy }); + expect(result.success).toBe(true); + expect(sessions[0]!.sentTimeouts).toEqual([12345]); + }); + + it("omits timeout when timeoutMs is not provided", async () => { + const { client, sessions } = makeMockClient([ + { + async onSend(_p, callSubmit) { + await callSubmit({ x: "ok" }); + }, + }, + ]); + setCopilotClientFactory(async () => client); + + const eng = makeEngine(); + const ir = makeIR({ + outputSchema: { + type: "object", + required: ["x"], + properties: { x: { type: "string" } }, + }, + inputs: { prompt: "..." }, + }); + + const result = await eng.run(ir, { policy: allowAllPolicy }); + expect(result.success).toBe(true); + expect(sessions[0]!.sentTimeouts).toEqual([undefined]); + }); +}); diff --git a/ts/examples/workflow/engine/test/engine.spec.ts b/ts/examples/workflow/engine/test/engine.spec.ts index 864507ac3..2225a0196 100644 --- a/ts/examples/workflow/engine/test/engine.spec.ts +++ b/ts/examples/workflow/engine/test/engine.spec.ts @@ -4895,7 +4895,8 @@ describe("WorkflowEngine (IR v1)", () => { nodeId: "test", scopePath: [], signal: new AbortController().signal, - }, + outputSchema: { type: "object" }, + } as any, ); expect(result.kind).toBe("fail"); if (result.kind === "fail") { diff --git a/ts/examples/workflow/model/src/taskDefinition.ts b/ts/examples/workflow/model/src/taskDefinition.ts index 02a912e21..44404f9bd 100644 --- a/ts/examples/workflow/model/src/taskDefinition.ts +++ b/ts/examples/workflow/model/src/taskDefinition.ts @@ -35,7 +35,15 @@ export interface TaskContext { */ constraints?: TaskConstraints; - /** The node's declared output schema, if any. */ + /** + * The dispatching node's declared output schema, if any. Tasks may use it + * to shape their computation (e.g. schema-guided LLM responses, per + * copilot.invoke). + * + * NOTE: The engine always validates the task's return value against the + * output schema after execution. Tasks normally do not need to do this, + * unless the task uses the results internally. + */ outputSchema?: JSONSchema; } diff --git a/ts/examples/workflow/workflows/d10-conventional-commit.json b/ts/examples/workflow/workflows/d10-conventional-commit.json new file mode 100644 index 000000000..9d43e40f1 --- /dev/null +++ b/ts/examples/workflow/workflows/d10-conventional-commit.json @@ -0,0 +1,749 @@ +{ + "kind": "workflow", + "name": "d10-conventional-commit", + "version": "1", + "description": "Generate a Conventional Commits message from staged git changes. Plumbing (file enumeration, per-file diff, templating, joining, looping) is fully deterministic; copilot.invoke is used twice with schema-guided structured output (decisions 0010/0011): once per file to categorize the change, then once at the end to synthesize the final message.", + "inputSchema": { + "type": "object", + "required": ["repoPath"], + "properties": { + "repoPath": { + "type": "string", + "description": "Absolute path to the git repo with staged changes." + } + } + }, + "outputSchema": { + "type": "object", + "required": ["message", "type", "scope", "subject", "body", "fileBullets"], + "properties": { + "message": { + "type": "string", + "description": "Final conventional commit message ready to pass to git commit -m." + }, + "type": { "type": "string" }, + "scope": { "type": "string" }, + "subject": { "type": "string" }, + "body": { "type": "string" }, + "fileBullets": { "type": "string" } + } + }, + "constants": { + "categorizePromptTemplate": { + "schema": { "type": "string" }, + "value": "You are categorizing one file change for a Conventional Commits message.\n\nFile: {{file}}\n\nGit diff (staged):\n{{diff}}\n\nCall the `submit_response` tool with:\n- `type`: one of feat, fix, refactor, perf, docs, test, style, chore, build, ci. Pick the type that best describes the dominant change in this file.\n- `scope`: a short lower-case scope (1-2 words, e.g. a package or module name). Use \"\" if no obvious scope.\n- `summary`: a single concise imperative-mood phrase (\u2264 80 chars) describing what changed in this file. No trailing period." + }, + "synthesizePromptTemplate": { + "schema": { "type": "string" }, + "value": "You are composing a single Conventional Commits message that covers all of the following per-file changes.\n\n{{bullets}}\n\nCall the `submit_response` tool with:\n- `type`: the dominant Conventional Commits type across the files (feat, fix, refactor, perf, docs, test, style, chore, build, ci). When mixed, prefer feat > fix > refactor > perf > test > docs > build > ci > style > chore.\n- `scope`: a short lower-case scope shared by the changes, or \"\" if there is no single coherent scope.\n- `subject`: an imperative-mood subject line \u2264 72 chars, no trailing period, summarizing the overall intent of the commit (NOT a list of files).\n- `body`: a 1-3 paragraph body explaining what changed and why, not how. Use plain prose, no markdown headings, no bullet lists." + }, + "subjectWithScopeTemplate": { + "schema": { "type": "string" }, + "value": "{{type}}({{scope}}): {{subject}}" + }, + "subjectNoScopeTemplate": { + "schema": { "type": "string" }, + "value": "{{type}}: {{subject}}" + }, + "finalMessageTemplate": { + "schema": { "type": "string" }, + "value": "{{header}}\n\n{{body}}\n\n# Files\n{{bullets}}\n" + } + }, + "nodes": { + "getFileList": { + "kind": "task", + "task": "shell.exec", + "inputSchema": { + "type": "object", + "required": ["command"], + "properties": { + "command": { "type": "string" }, + "args": { "type": "array", "items": { "type": "string" } }, + "cwd": { "type": "string" } + } + }, + "outputSchema": { + "type": "object", + "required": ["stdout", "stderr", "exitCode"], + "properties": { + "stdout": { "type": "string" }, + "stderr": { "type": "string" }, + "exitCode": { "type": "integer" } + } + }, + "inputs": { + "command": "git", + "args": ["diff", "--staged", "--name-only"], + "cwd": { "$from": "input", "name": "repoPath" } + }, + "next": "splitFiles", + "bind": "fileListResult" + }, + "splitFiles": { + "kind": "task", + "task": "string.split", + "inputSchema": { + "type": "object", + "required": ["text", "delimiter"], + "properties": { + "text": { "type": "string" }, + "delimiter": { "type": "string" } + } + }, + "outputSchema": { + "type": "object", + "required": ["list"], + "properties": { + "list": { "type": "array", "items": { "type": "string" } } + } + }, + "inputs": { + "text": { + "$from": "scope", + "name": "fileListResult", + "path": ["stdout"] + }, + "delimiter": "\n" + }, + "next": "fileLoop", + "bind": "files" + }, + "fileLoop": { + "kind": "loop", + "inputs": { + "files": { "$from": "scope", "name": "files", "path": ["list"] }, + "repoPath": { "$from": "input", "name": "repoPath" }, + "categorizePromptTemplate": { + "$from": "constant", + "name": "categorizePromptTemplate" + } + }, + "inputSchema": { + "type": "object", + "required": ["files", "repoPath", "categorizePromptTemplate"], + "properties": { + "files": { "type": "array", "items": { "type": "string" } }, + "repoPath": { "type": "string" }, + "categorizePromptTemplate": { "type": "string" } + } + }, + "state": { + "i": { "schema": { "type": "integer" }, "initial": 0 }, + "bullets": { + "schema": { "type": "array", "items": { "type": "string" } }, + "initial": [] + } + }, + "body": { + "entry": "pickFile", + "nodes": { + "pickFile": { + "kind": "task", + "task": "list.elementAt", + "inputSchema": { + "type": "object", + "required": ["list", "index"], + "properties": { + "list": { "type": "array" }, + "index": { "type": "integer" } + } + }, + "outputSchema": { + "type": "object", + "required": ["element"], + "properties": { "element": { "type": "string" } } + }, + "inputs": { + "list": { "$from": "input", "name": "files" }, + "index": { "$from": "state", "name": "i" } + }, + "next": "getFileDiff", + "bind": "picked" + }, + "getFileDiff": { + "kind": "task", + "task": "shell.exec", + "inputSchema": { + "type": "object", + "required": ["command"], + "properties": { + "command": { "type": "string" }, + "args": { + "type": "array", + "items": { "type": "string" } + }, + "cwd": { "type": "string" } + } + }, + "outputSchema": { + "type": "object", + "required": ["stdout", "stderr", "exitCode"], + "properties": { + "stdout": { "type": "string" }, + "stderr": { "type": "string" }, + "exitCode": { "type": "integer" } + } + }, + "inputs": { + "command": "git", + "args": [ + "diff", + "--staged", + "--", + { + "$from": "scope", + "name": "picked", + "path": ["element"] + } + ], + "cwd": { "$from": "input", "name": "repoPath" } + }, + "next": "buildCategorizePrompt", + "bind": "fileDiff" + }, + "buildCategorizePrompt": { + "kind": "task", + "task": "text.template", + "inputSchema": { + "type": "object", + "required": ["template", "vars"], + "properties": { + "template": { "type": "string" }, + "vars": { "type": "object" } + } + }, + "outputSchema": { + "type": "object", + "required": ["text"], + "properties": { "text": { "type": "string" } } + }, + "inputs": { + "template": { + "$from": "input", + "name": "categorizePromptTemplate" + }, + "vars": { + "file": { + "$from": "scope", + "name": "picked", + "path": ["element"] + }, + "diff": { + "$from": "scope", + "name": "fileDiff", + "path": ["stdout"] + } + } + }, + "next": "categorize", + "bind": "categorizePrompt" + }, + "categorize": { + "kind": "task", + "task": "copilot.invoke", + "inputSchema": { + "type": "object", + "required": ["prompt"], + "properties": { + "prompt": { "type": "string" }, + "model": { "type": "string" }, + "allowedTools": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "outputSchema": { + "type": "object", + "required": ["type", "scope", "summary"], + "properties": { + "type": { + "type": "string", + "enum": [ + "feat", + "fix", + "refactor", + "perf", + "docs", + "test", + "style", + "chore", + "build", + "ci" + ], + "description": "Conventional Commits type for this file." + }, + "scope": { + "type": "string", + "description": "Short lower-case scope, or empty string." + }, + "summary": { + "type": "string", + "description": "Imperative-mood phrase \u2264 80 chars describing the file change." + } + } + }, + "inputs": { + "prompt": { + "$from": "scope", + "name": "categorizePrompt", + "path": ["text"] + }, + "model": "claude-sonnet-4.6", + "allowedTools": [] + }, + "next": "formatBullet", + "bind": "categorization" + }, + "formatBullet": { + "kind": "task", + "task": "text.template", + "inputSchema": { + "type": "object", + "required": ["template", "vars"], + "properties": { + "template": { "type": "string" }, + "vars": { "type": "object" } + } + }, + "outputSchema": { + "type": "object", + "required": ["text"], + "properties": { "text": { "type": "string" } } + }, + "inputs": { + "template": "- {{type}} ({{scope}}) {{file}}: {{summary}}", + "vars": { + "type": { + "$from": "scope", + "name": "categorization", + "path": ["type"] + }, + "scope": { + "$from": "scope", + "name": "categorization", + "path": ["scope"] + }, + "file": { + "$from": "scope", + "name": "picked", + "path": ["element"] + }, + "summary": { + "$from": "scope", + "name": "categorization", + "path": ["summary"] + } + } + }, + "next": "appendBullet", + "bind": "bullet" + }, + "appendBullet": { + "kind": "task", + "task": "list.append", + "inputSchema": { + "type": "object", + "required": ["list", "item"], + "properties": { + "list": { "type": "array" }, + "item": {} + } + }, + "outputSchema": { + "type": "object", + "required": ["list"], + "properties": { + "list": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "inputs": { + "list": { "$from": "state", "name": "bullets" }, + "item": { + "$from": "scope", + "name": "bullet", + "path": ["text"] + } + }, + "next": "stepIndex", + "bind": "appended" + }, + "stepIndex": { + "kind": "task", + "task": "int.add", + "inputSchema": { + "type": "object", + "required": ["a", "b"], + "properties": { + "a": { "type": "integer" }, + "b": { "type": "integer" } + } + }, + "outputSchema": { + "type": "object", + "required": ["result"], + "properties": { "result": { "type": "integer" } } + }, + "inputs": { + "a": { "$from": "state", "name": "i" }, + "b": 1 + }, + "next": "computeLength", + "bind": "stepped" + }, + "computeLength": { + "kind": "task", + "task": "list.length", + "inputSchema": { + "type": "object", + "required": ["list"], + "properties": { "list": { "type": "array" } } + }, + "outputSchema": { + "type": "object", + "required": ["length"], + "properties": { "length": { "type": "integer" } } + }, + "inputs": { + "list": { "$from": "input", "name": "files" } + }, + "next": "compareIndex", + "bind": "fileCount" + }, + "compareIndex": { + "kind": "task", + "task": "int.lessThan", + "inputSchema": { + "type": "object", + "required": ["a", "b"], + "properties": { + "a": { "type": "integer" }, + "b": { "type": "integer" } + } + }, + "outputSchema": { + "type": "object", + "required": ["result"], + "properties": { "result": { "type": "boolean" } } + }, + "inputs": { + "a": { + "$from": "scope", + "name": "stepped", + "path": ["result"] + }, + "b": { + "$from": "scope", + "name": "fileCount", + "path": ["length"] + } + }, + "next": "checkDone", + "bind": "hasMore" + }, + "checkDone": { + "kind": "branch", + "selector": { + "$from": "scope", + "name": "hasMore", + "path": ["result"] + }, + "selectorSchema": { "type": "boolean" }, + "cases": { + "true": "@iterate", + "false": "@exit" + }, + "default": "@exit" + } + } + }, + "iterateState": { + "i": { + "$from": "scope", + "name": "stepped", + "path": ["result"] + }, + "bullets": { + "$from": "scope", + "name": "appended", + "path": ["list"] + } + }, + "output": { + "$from": "scope", + "name": "appended", + "path": ["list"] + }, + "outputSchema": { + "type": "array", + "items": { "type": "string" } + }, + "maxIterations": 200, + "next": "joinBullets", + "bind": "fileBullets" + }, + "joinBullets": { + "kind": "task", + "task": "string.join", + "inputSchema": { + "type": "object", + "required": ["list", "delimiter"], + "properties": { + "list": { + "type": "array", + "items": { "type": "string" } + }, + "delimiter": { "type": "string" } + } + }, + "outputSchema": { + "type": "object", + "required": ["text"], + "properties": { "text": { "type": "string" } } + }, + "inputs": { + "list": { "$from": "scope", "name": "fileBullets" }, + "delimiter": "\n" + }, + "next": "buildSynthesisPrompt", + "bind": "bulletsText" + }, + "buildSynthesisPrompt": { + "kind": "task", + "task": "text.template", + "inputSchema": { + "type": "object", + "required": ["template", "vars"], + "properties": { + "template": { "type": "string" }, + "vars": { "type": "object" } + } + }, + "outputSchema": { + "type": "object", + "required": ["text"], + "properties": { "text": { "type": "string" } } + }, + "inputs": { + "template": { + "$from": "constant", + "name": "synthesizePromptTemplate" + }, + "vars": { + "bullets": { + "$from": "scope", + "name": "bulletsText", + "path": ["text"] + } + } + }, + "next": "synthesize", + "bind": "synthesisPrompt" + }, + "synthesize": { + "kind": "task", + "task": "copilot.invoke", + "inputSchema": { + "type": "object", + "required": ["prompt"], + "properties": { + "prompt": { "type": "string" }, + "model": { "type": "string" }, + "allowedTools": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "outputSchema": { + "type": "object", + "required": ["type", "scope", "subject", "body"], + "properties": { + "type": { + "type": "string", + "enum": [ + "feat", + "fix", + "refactor", + "perf", + "docs", + "test", + "style", + "chore", + "build", + "ci" + ], + "description": "Dominant Conventional Commits type across the staged files." + }, + "scope": { + "type": "string", + "description": "Shared scope, or empty string." + }, + "subject": { + "type": "string", + "description": "Imperative-mood subject line, \u2264 72 chars, no trailing period." + }, + "body": { + "type": "string", + "description": "1-3 paragraph plain-prose body explaining what changed and why." + } + } + }, + "inputs": { + "prompt": { + "$from": "scope", + "name": "synthesisPrompt", + "path": ["text"] + }, + "model": "claude-sonnet-4.6", + "allowedTools": [] + }, + "next": "buildSubject", + "bind": "synthesis" + }, + "buildSubject": { + "kind": "branch", + "selector": { "$from": "scope", "name": "synthesis", "path": ["scope"] }, + "selectorSchema": { "type": "string" }, + "cases": { "": "buildSubjectNoScope" }, + "default": "buildSubjectWithScope" + }, + "buildSubjectWithScope": { + "kind": "task", + "task": "text.template", + "inputSchema": { + "type": "object", + "required": ["template", "vars"], + "properties": { + "template": { "type": "string" }, + "vars": { "type": "object" } + } + }, + "outputSchema": { + "type": "object", + "required": ["text"], + "properties": { "text": { "type": "string" } } + }, + "inputs": { + "template": { + "$from": "constant", + "name": "subjectWithScopeTemplate" + }, + "vars": { + "type": { + "$from": "scope", + "name": "synthesis", + "path": ["type"] + }, + "scope": { + "$from": "scope", + "name": "synthesis", + "path": ["scope"] + }, + "subject": { + "$from": "scope", + "name": "synthesis", + "path": ["subject"] + } + } + }, + "next": "formatFinal", + "bind": "header" + }, + "buildSubjectNoScope": { + "kind": "task", + "task": "text.template", + "inputSchema": { + "type": "object", + "required": ["template", "vars"], + "properties": { + "template": { "type": "string" }, + "vars": { "type": "object" } + } + }, + "outputSchema": { + "type": "object", + "required": ["text"], + "properties": { "text": { "type": "string" } } + }, + "inputs": { + "template": { + "$from": "constant", + "name": "subjectNoScopeTemplate" + }, + "vars": { + "type": { + "$from": "scope", + "name": "synthesis", + "path": ["type"] + }, + "subject": { + "$from": "scope", + "name": "synthesis", + "path": ["subject"] + } + } + }, + "next": "formatFinal", + "bind": "header" + }, + "formatFinal": { + "kind": "task", + "task": "text.template", + "inputSchema": { + "type": "object", + "required": ["template", "vars"], + "properties": { + "template": { "type": "string" }, + "vars": { "type": "object" } + } + }, + "outputSchema": { + "type": "object", + "required": ["text"], + "properties": { "text": { "type": "string" } } + }, + "inputs": { + "template": { + "$from": "constant", + "name": "finalMessageTemplate" + }, + "vars": { + "header": { "$from": "scope", "name": "header", "path": ["text"] }, + "body": { + "$from": "scope", + "name": "synthesis", + "path": ["body"] + }, + "bullets": { + "$from": "scope", + "name": "bulletsText", + "path": ["text"] + } + } + }, + "bind": "finalMessage" + } + }, + "entry": "getFileList", + "output": { + "message": { + "$from": "scope", + "name": "finalMessage", + "path": ["text"] + }, + "type": { "$from": "scope", "name": "synthesis", "path": ["type"] }, + "scope": { "$from": "scope", "name": "synthesis", "path": ["scope"] }, + "subject": { "$from": "scope", "name": "synthesis", "path": ["subject"] }, + "body": { "$from": "scope", "name": "synthesis", "path": ["body"] }, + "fileBullets": { + "$from": "scope", + "name": "bulletsText", + "path": ["text"] + } + } +} From 2db10387bc82870dd9a4e625d7228bf9f22f3e44 Mon Sep 17 00:00:00 2001 From: Daniel Lehenbauer Date: Wed, 27 May 2026 09:08:22 -0700 Subject: [PATCH 2/8] feat(workflow): fixes for merge into main --- .../workflow/engine/src/builtinTasks.ts | 11 +- .../workflow/engine/src/copilotClientHost.ts | 70 +- ts/examples/workflow/engine/src/runner.ts | 11 +- .../engine/test/copilotInvoke.spec.ts | 81 +- .../workflow/model/src/taskDefinition.ts | 17 +- .../workflows/d10-conventional-commit.json | 749 ----------- .../workflows/ir/d10-conventional-commit.json | 1102 +++++++++++++++++ ts/pnpm-lock.yaml | 120 +- 8 files changed, 1335 insertions(+), 826 deletions(-) delete mode 100644 ts/examples/workflow/workflows/d10-conventional-commit.json create mode 100644 ts/examples/workflow/workflows/ir/d10-conventional-commit.json diff --git a/ts/examples/workflow/engine/src/builtinTasks.ts b/ts/examples/workflow/engine/src/builtinTasks.ts index c334c7647..f869c48e1 100644 --- a/ts/examples/workflow/engine/src/builtinTasks.ts +++ b/ts/examples/workflow/engine/src/builtinTasks.ts @@ -322,7 +322,7 @@ export const copilotInvoke: TaskDefinition< reasoningEffort?: "low" | "medium" | "high" | "xhigh"; repairBudget?: number; }, - Record + unknown > = { name: "copilot.invoke", sideEffects: true, @@ -356,12 +356,9 @@ export const copilotInvoke: TaskDefinition< }, }, }, - outputSchema: { - // `copilot.invoke`'s schema-guided turn loop registers a synthetic - // `submit_response` tool whose JSON Schema parameters block must be an - // object (LLM tool-call APIs require object parameters). - type: "object", - }, + // `copilot.invoke`'s actual per-call output shape is whatever the + // dispatching node declares (`{}` is JSONSchema for `any`). + outputSchema: {}, async execute(input, ctx) { // Validate any attachment paths against the same allowed roots // file.read / file.write enforce. diff --git a/ts/examples/workflow/engine/src/copilotClientHost.ts b/ts/examples/workflow/engine/src/copilotClientHost.ts index 10664fe84..b72afc67b 100644 --- a/ts/examples/workflow/engine/src/copilotClientHost.ts +++ b/ts/examples/workflow/engine/src/copilotClientHost.ts @@ -153,7 +153,7 @@ const ajv = new AjvConstructor({ strict: false }); /** Result of an ephemeral copilot agent invocation (decision 0010 §4). */ export type InvokeCopilotAgentResult = - | { kind: "ok"; output: Record } + | { kind: "ok"; output: unknown } | { kind: "fail"; error: { message: string; data?: Record }; @@ -162,7 +162,7 @@ export type InvokeCopilotAgentResult = export interface InvokeCopilotAgentOptions { /** User-turn prompt. */ prompt: string; - /** The node's IR-declared outputSchema; must be an object schema. */ + /** The node's IR-declared outputSchema. */ outputSchema: JSONSchema; /** Optional model name. */ model?: string; @@ -194,10 +194,10 @@ export interface InvokeCopilotAgentOptions { /** System-prompt scaffolding for the submit_response convention. */ function buildSystemMessageContent( - outputSchema: JSONSchema, + submitParamsSchema: Record, authorAppend?: string, ): string { - const schemaText = JSON.stringify(outputSchema, null, 2); + const schemaText = JSON.stringify(submitParamsSchema, null, 2); const base = [ "You are an AI agent driven by an automated workflow engine. Only your `submit_response` tool call is read; assistant text is ignored by the engine but allowed for reasoning.", "", @@ -239,24 +239,30 @@ export async function invokeCopilotAgent( }; } - // outputSchema MUST be an object schema. Decision 0003's drift check would - // reject non-object schemas at IR validation time (since copilot.invoke's - // registered outputSchema is `{type: "object"}`); this is a - // defense-in-depth guard. - const otype = (options.outputSchema as Record).type; - if (otype !== "object") { - return { - kind: "fail", - error: { - message: `copilot.invoke output schema must have type "object"; got ${JSON.stringify(otype)}. See decision 0010 §4.`, - }, - }; - } - - // Compile the validator for the per-call output schema. + // LLM tool-calls MUST be a JSON-Schema object (not a bare string, etc.) + // Adapt scalar schemas like `{type: "string"}` by wrapping them in + // `{type:"object", properties: {value: }, required:["value"]}`. + // The captured value is unwrapped to the bare scalar before returning. + const userSchema = options.outputSchema as Record; + const userType = userSchema.type; + const isObjectShape = + userType === undefined || + userType === "object" || + (Array.isArray(userType) && (userType as unknown[]).includes("object")); + const submitSchema: Record = isObjectShape + ? userSchema + : { + type: "object", + properties: { value: userSchema }, + required: ["value"], + additionalProperties: false, + }; + + // Compile the validator for the (possibly wrapped) submit_response + // parameters schema. let validate; try { - validate = ajv.compile(options.outputSchema as object); + validate = ajv.compile(submitSchema); } catch (e) { const m = e instanceof Error ? e.message : String(e); return { @@ -265,9 +271,14 @@ export async function invokeCopilotAgent( }; } - // Mutable capture cell shared with the submit_response handler. - const captured: { value?: Record; lastErrors?: string } = - {}; + // Mutable capture cell shared with the submit_response handler. `value` + // holds the unwrapped node-output value (an object when the node's + // outputSchema is object-shaped, or the bare scalar otherwise). + const captured: { + value?: unknown; + hasValue?: boolean; + lastErrors?: string; + } = {}; const sdk = (await import("@github/copilot-sdk")) as any; const client = await getCopilotClient(); @@ -277,8 +288,8 @@ export async function invokeCopilotAgent( // we never accept malformed args even if SDK-side validation is permissive. const submitTool = sdk.defineTool("submit_response", { description: - "Submit your final structured answer. Call this exactly once when you have your final answer.", - parameters: options.outputSchema as Record, + "Submit your final answer. Call this exactly once when you have your final answer.", + parameters: submitSchema, skipPermission: true, handler: async (args: unknown) => { if ( @@ -293,7 +304,10 @@ export async function invokeCopilotAgent( captured.lastErrors = errs; return `\`submit_response\` rejected: ${errs}. Please call \`submit_response\` again with corrected arguments.`; } - captured.value = args as Record; + captured.value = isObjectShape + ? (args as Record) + : (args as Record).value; + captured.hasValue = true; delete captured.lastErrors; return "Response recorded."; }, @@ -305,7 +319,7 @@ export async function invokeCopilotAgent( systemMessage: { mode: "append", content: buildSystemMessageContent( - options.outputSchema, + submitSchema, options.systemMessageAppend, ), }, @@ -370,7 +384,7 @@ export async function invokeCopilotAgent( ); } - if (captured.value !== undefined) { + if (captured.hasValue) { return { kind: "ok", output: captured.value }; } diff --git a/ts/examples/workflow/engine/src/runner.ts b/ts/examples/workflow/engine/src/runner.ts index f463df7b2..463eb273a 100644 --- a/ts/examples/workflow/engine/src/runner.ts +++ b/ts/examples/workflow/engine/src/runner.ts @@ -550,9 +550,7 @@ export class WorkflowEngine { data?: unknown; } = { message: err instanceof Error ? err.message : String(err), - ...(isTaskFailure - ? { nodeId: err.nodeId } - : {}), + ...(isTaskFailure ? { nodeId: err.nodeId } : {}), ...(isTaskFailure && err.taskError.data !== undefined ? { data: err.taskError.data } : {}), @@ -893,11 +891,8 @@ export class WorkflowEngine { scopePath: [...scopePath], signal: taskSignal, ...(constraints ? { constraints } : {}), - // Expose the dispatching node's declared output schema - // to schema-guided tasks like copilot.invoke. - ...(node.outputSchema - ? { outputSchema: node.outputSchema } - : {}), + // The dispatching node's declared output schema. + outputSchema: node.outputSchema, }; let result: TaskResult; diff --git a/ts/examples/workflow/engine/test/copilotInvoke.spec.ts b/ts/examples/workflow/engine/test/copilotInvoke.spec.ts index f19ec5941..8775212bb 100644 --- a/ts/examples/workflow/engine/test/copilotInvoke.spec.ts +++ b/ts/examples/workflow/engine/test/copilotInvoke.spec.ts @@ -130,22 +130,26 @@ describe("copilot.invoke (decision 0010)", () => { }): WorkflowIR { return { kind: "workflow", - name: "copilotTest", version: "1", - inputSchema: { type: "object" }, - outputSchema: { type: "object" }, - nodes: { - step: { - kind: "task", - task: "copilot.invoke", - inputSchema: copilotInvoke.inputSchema, + entry: "copilotTest", + workflows: { + copilotTest: { + inputSchema: { type: "object" }, outputSchema: opts.outputSchema, - inputs: opts.inputs as any, - bind: "result", + entry: "step", + nodes: { + step: { + kind: "task", + task: "copilot.invoke", + inputSchema: copilotInvoke.inputSchema, + outputSchema: opts.outputSchema, + inputs: opts.inputs as any, + bind: "result", + }, + }, + output: { $from: "scope", name: "result" } as any, }, }, - entry: "step", - output: { $from: "scope", name: "result" } as any, }; } @@ -328,14 +332,31 @@ describe("copilot.invoke (decision 0010)", () => { expect(sessions[0]!.sentPrompts.length).toBe(1); }); - it("rejects non-object IR outputSchema (defense-in-depth)", async () => { - // Invalid outputSchema for copilot.invoke — should fail before - // we even call the SDK. The factory should not be invoked. - let factoryCalled = false; - setCopilotClientFactory(async () => { - factoryCalled = true; - throw new Error("factory should not be called"); - }); + it("string outputSchema: wraps submit_response and unwraps the bare value", async () => { + let observedParams: unknown; + const { client } = makeMockClient([ + { + async onSend(_prompt, callSubmit) { + // Free-text mode: node declared `outputSchema: { type: "string" }`, + // so submit_response is wrapped as `{ value: }`. + await callSubmit({ value: "hello world" }); + }, + }, + ]); + // Spy on the synthetic tool's parameters via createSession. + const wrappedClient: MinimalCopilotClient = { + ...client, + async createSession(config) { + const submitTool = (config.tools ?? []).find( + (t: Tool) => t.name === "submit_response", + ); + observedParams = ( + submitTool as { parameters?: unknown } | undefined + )?.parameters; + return client.createSession(config); + }, + }; + setCopilotClientFactory(async () => wrappedClient); const eng = makeEngine(); const ir = makeIR({ @@ -344,14 +365,18 @@ describe("copilot.invoke (decision 0010)", () => { }); const result = await eng.run(ir, { policy: allowAllPolicy }); - expect(result.success).toBe(false); - expect(factoryCalled).toBe(false); - // Either the engine's IR-level drift check fires, or our - // defense-in-depth check inside invokeCopilotAgent fires. - // Either way we want a clear non-object schema message. - expect(result.error?.message.toLowerCase()).toMatch( - /object|outputschema/, - ); + expect(result.success).toBe(true); + // Output is unwrapped to the bare string, not the `{value: ...}` + // envelope. + expect(result.output).toBe("hello world"); + // submit_response's params were wrapped so the LLM tool-call + // constraint (object-typed params) is satisfied. + expect(observedParams).toEqual({ + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + additionalProperties: false, + }); }); it("validates repairBudget bounds", async () => { diff --git a/ts/examples/workflow/model/src/taskDefinition.ts b/ts/examples/workflow/model/src/taskDefinition.ts index 44404f9bd..a1b20df2d 100644 --- a/ts/examples/workflow/model/src/taskDefinition.ts +++ b/ts/examples/workflow/model/src/taskDefinition.ts @@ -36,15 +36,28 @@ export interface TaskContext { constraints?: TaskConstraints; /** - * The dispatching node's declared output schema, if any. Tasks may use it + * The dispatching node's declared output schema. Tasks may use it * to shape their computation (e.g. schema-guided LLM responses, per * copilot.invoke). * + * Always present: TaskNode.outputSchema is required by the IR contract + * (`model/src/ir.ts`) and the static validator rejects task nodes that + * omit it, so the runner can — and does — pass it unconditionally. + * + * The schema is a JSON Schema 7 value. A typical schema-guided task + * dispatches on its shape: + * - `{ type: "object", properties: { ... } }` — produce a structured + * JSON object matching the declared properties. + * - `{ type: "string" }` — produce free text; the returned value is a + * plain string. + * - `{}` (the top schema) — produce anything; the task is free to + * return any JSON value. + * * NOTE: The engine always validates the task's return value against the * output schema after execution. Tasks normally do not need to do this, * unless the task uses the results internally. */ - outputSchema?: JSONSchema; + outputSchema: JSONSchema; } /** diff --git a/ts/examples/workflow/workflows/d10-conventional-commit.json b/ts/examples/workflow/workflows/d10-conventional-commit.json deleted file mode 100644 index 9d43e40f1..000000000 --- a/ts/examples/workflow/workflows/d10-conventional-commit.json +++ /dev/null @@ -1,749 +0,0 @@ -{ - "kind": "workflow", - "name": "d10-conventional-commit", - "version": "1", - "description": "Generate a Conventional Commits message from staged git changes. Plumbing (file enumeration, per-file diff, templating, joining, looping) is fully deterministic; copilot.invoke is used twice with schema-guided structured output (decisions 0010/0011): once per file to categorize the change, then once at the end to synthesize the final message.", - "inputSchema": { - "type": "object", - "required": ["repoPath"], - "properties": { - "repoPath": { - "type": "string", - "description": "Absolute path to the git repo with staged changes." - } - } - }, - "outputSchema": { - "type": "object", - "required": ["message", "type", "scope", "subject", "body", "fileBullets"], - "properties": { - "message": { - "type": "string", - "description": "Final conventional commit message ready to pass to git commit -m." - }, - "type": { "type": "string" }, - "scope": { "type": "string" }, - "subject": { "type": "string" }, - "body": { "type": "string" }, - "fileBullets": { "type": "string" } - } - }, - "constants": { - "categorizePromptTemplate": { - "schema": { "type": "string" }, - "value": "You are categorizing one file change for a Conventional Commits message.\n\nFile: {{file}}\n\nGit diff (staged):\n{{diff}}\n\nCall the `submit_response` tool with:\n- `type`: one of feat, fix, refactor, perf, docs, test, style, chore, build, ci. Pick the type that best describes the dominant change in this file.\n- `scope`: a short lower-case scope (1-2 words, e.g. a package or module name). Use \"\" if no obvious scope.\n- `summary`: a single concise imperative-mood phrase (\u2264 80 chars) describing what changed in this file. No trailing period." - }, - "synthesizePromptTemplate": { - "schema": { "type": "string" }, - "value": "You are composing a single Conventional Commits message that covers all of the following per-file changes.\n\n{{bullets}}\n\nCall the `submit_response` tool with:\n- `type`: the dominant Conventional Commits type across the files (feat, fix, refactor, perf, docs, test, style, chore, build, ci). When mixed, prefer feat > fix > refactor > perf > test > docs > build > ci > style > chore.\n- `scope`: a short lower-case scope shared by the changes, or \"\" if there is no single coherent scope.\n- `subject`: an imperative-mood subject line \u2264 72 chars, no trailing period, summarizing the overall intent of the commit (NOT a list of files).\n- `body`: a 1-3 paragraph body explaining what changed and why, not how. Use plain prose, no markdown headings, no bullet lists." - }, - "subjectWithScopeTemplate": { - "schema": { "type": "string" }, - "value": "{{type}}({{scope}}): {{subject}}" - }, - "subjectNoScopeTemplate": { - "schema": { "type": "string" }, - "value": "{{type}}: {{subject}}" - }, - "finalMessageTemplate": { - "schema": { "type": "string" }, - "value": "{{header}}\n\n{{body}}\n\n# Files\n{{bullets}}\n" - } - }, - "nodes": { - "getFileList": { - "kind": "task", - "task": "shell.exec", - "inputSchema": { - "type": "object", - "required": ["command"], - "properties": { - "command": { "type": "string" }, - "args": { "type": "array", "items": { "type": "string" } }, - "cwd": { "type": "string" } - } - }, - "outputSchema": { - "type": "object", - "required": ["stdout", "stderr", "exitCode"], - "properties": { - "stdout": { "type": "string" }, - "stderr": { "type": "string" }, - "exitCode": { "type": "integer" } - } - }, - "inputs": { - "command": "git", - "args": ["diff", "--staged", "--name-only"], - "cwd": { "$from": "input", "name": "repoPath" } - }, - "next": "splitFiles", - "bind": "fileListResult" - }, - "splitFiles": { - "kind": "task", - "task": "string.split", - "inputSchema": { - "type": "object", - "required": ["text", "delimiter"], - "properties": { - "text": { "type": "string" }, - "delimiter": { "type": "string" } - } - }, - "outputSchema": { - "type": "object", - "required": ["list"], - "properties": { - "list": { "type": "array", "items": { "type": "string" } } - } - }, - "inputs": { - "text": { - "$from": "scope", - "name": "fileListResult", - "path": ["stdout"] - }, - "delimiter": "\n" - }, - "next": "fileLoop", - "bind": "files" - }, - "fileLoop": { - "kind": "loop", - "inputs": { - "files": { "$from": "scope", "name": "files", "path": ["list"] }, - "repoPath": { "$from": "input", "name": "repoPath" }, - "categorizePromptTemplate": { - "$from": "constant", - "name": "categorizePromptTemplate" - } - }, - "inputSchema": { - "type": "object", - "required": ["files", "repoPath", "categorizePromptTemplate"], - "properties": { - "files": { "type": "array", "items": { "type": "string" } }, - "repoPath": { "type": "string" }, - "categorizePromptTemplate": { "type": "string" } - } - }, - "state": { - "i": { "schema": { "type": "integer" }, "initial": 0 }, - "bullets": { - "schema": { "type": "array", "items": { "type": "string" } }, - "initial": [] - } - }, - "body": { - "entry": "pickFile", - "nodes": { - "pickFile": { - "kind": "task", - "task": "list.elementAt", - "inputSchema": { - "type": "object", - "required": ["list", "index"], - "properties": { - "list": { "type": "array" }, - "index": { "type": "integer" } - } - }, - "outputSchema": { - "type": "object", - "required": ["element"], - "properties": { "element": { "type": "string" } } - }, - "inputs": { - "list": { "$from": "input", "name": "files" }, - "index": { "$from": "state", "name": "i" } - }, - "next": "getFileDiff", - "bind": "picked" - }, - "getFileDiff": { - "kind": "task", - "task": "shell.exec", - "inputSchema": { - "type": "object", - "required": ["command"], - "properties": { - "command": { "type": "string" }, - "args": { - "type": "array", - "items": { "type": "string" } - }, - "cwd": { "type": "string" } - } - }, - "outputSchema": { - "type": "object", - "required": ["stdout", "stderr", "exitCode"], - "properties": { - "stdout": { "type": "string" }, - "stderr": { "type": "string" }, - "exitCode": { "type": "integer" } - } - }, - "inputs": { - "command": "git", - "args": [ - "diff", - "--staged", - "--", - { - "$from": "scope", - "name": "picked", - "path": ["element"] - } - ], - "cwd": { "$from": "input", "name": "repoPath" } - }, - "next": "buildCategorizePrompt", - "bind": "fileDiff" - }, - "buildCategorizePrompt": { - "kind": "task", - "task": "text.template", - "inputSchema": { - "type": "object", - "required": ["template", "vars"], - "properties": { - "template": { "type": "string" }, - "vars": { "type": "object" } - } - }, - "outputSchema": { - "type": "object", - "required": ["text"], - "properties": { "text": { "type": "string" } } - }, - "inputs": { - "template": { - "$from": "input", - "name": "categorizePromptTemplate" - }, - "vars": { - "file": { - "$from": "scope", - "name": "picked", - "path": ["element"] - }, - "diff": { - "$from": "scope", - "name": "fileDiff", - "path": ["stdout"] - } - } - }, - "next": "categorize", - "bind": "categorizePrompt" - }, - "categorize": { - "kind": "task", - "task": "copilot.invoke", - "inputSchema": { - "type": "object", - "required": ["prompt"], - "properties": { - "prompt": { "type": "string" }, - "model": { "type": "string" }, - "allowedTools": { - "type": "array", - "items": { "type": "string" } - } - } - }, - "outputSchema": { - "type": "object", - "required": ["type", "scope", "summary"], - "properties": { - "type": { - "type": "string", - "enum": [ - "feat", - "fix", - "refactor", - "perf", - "docs", - "test", - "style", - "chore", - "build", - "ci" - ], - "description": "Conventional Commits type for this file." - }, - "scope": { - "type": "string", - "description": "Short lower-case scope, or empty string." - }, - "summary": { - "type": "string", - "description": "Imperative-mood phrase \u2264 80 chars describing the file change." - } - } - }, - "inputs": { - "prompt": { - "$from": "scope", - "name": "categorizePrompt", - "path": ["text"] - }, - "model": "claude-sonnet-4.6", - "allowedTools": [] - }, - "next": "formatBullet", - "bind": "categorization" - }, - "formatBullet": { - "kind": "task", - "task": "text.template", - "inputSchema": { - "type": "object", - "required": ["template", "vars"], - "properties": { - "template": { "type": "string" }, - "vars": { "type": "object" } - } - }, - "outputSchema": { - "type": "object", - "required": ["text"], - "properties": { "text": { "type": "string" } } - }, - "inputs": { - "template": "- {{type}} ({{scope}}) {{file}}: {{summary}}", - "vars": { - "type": { - "$from": "scope", - "name": "categorization", - "path": ["type"] - }, - "scope": { - "$from": "scope", - "name": "categorization", - "path": ["scope"] - }, - "file": { - "$from": "scope", - "name": "picked", - "path": ["element"] - }, - "summary": { - "$from": "scope", - "name": "categorization", - "path": ["summary"] - } - } - }, - "next": "appendBullet", - "bind": "bullet" - }, - "appendBullet": { - "kind": "task", - "task": "list.append", - "inputSchema": { - "type": "object", - "required": ["list", "item"], - "properties": { - "list": { "type": "array" }, - "item": {} - } - }, - "outputSchema": { - "type": "object", - "required": ["list"], - "properties": { - "list": { - "type": "array", - "items": { "type": "string" } - } - } - }, - "inputs": { - "list": { "$from": "state", "name": "bullets" }, - "item": { - "$from": "scope", - "name": "bullet", - "path": ["text"] - } - }, - "next": "stepIndex", - "bind": "appended" - }, - "stepIndex": { - "kind": "task", - "task": "int.add", - "inputSchema": { - "type": "object", - "required": ["a", "b"], - "properties": { - "a": { "type": "integer" }, - "b": { "type": "integer" } - } - }, - "outputSchema": { - "type": "object", - "required": ["result"], - "properties": { "result": { "type": "integer" } } - }, - "inputs": { - "a": { "$from": "state", "name": "i" }, - "b": 1 - }, - "next": "computeLength", - "bind": "stepped" - }, - "computeLength": { - "kind": "task", - "task": "list.length", - "inputSchema": { - "type": "object", - "required": ["list"], - "properties": { "list": { "type": "array" } } - }, - "outputSchema": { - "type": "object", - "required": ["length"], - "properties": { "length": { "type": "integer" } } - }, - "inputs": { - "list": { "$from": "input", "name": "files" } - }, - "next": "compareIndex", - "bind": "fileCount" - }, - "compareIndex": { - "kind": "task", - "task": "int.lessThan", - "inputSchema": { - "type": "object", - "required": ["a", "b"], - "properties": { - "a": { "type": "integer" }, - "b": { "type": "integer" } - } - }, - "outputSchema": { - "type": "object", - "required": ["result"], - "properties": { "result": { "type": "boolean" } } - }, - "inputs": { - "a": { - "$from": "scope", - "name": "stepped", - "path": ["result"] - }, - "b": { - "$from": "scope", - "name": "fileCount", - "path": ["length"] - } - }, - "next": "checkDone", - "bind": "hasMore" - }, - "checkDone": { - "kind": "branch", - "selector": { - "$from": "scope", - "name": "hasMore", - "path": ["result"] - }, - "selectorSchema": { "type": "boolean" }, - "cases": { - "true": "@iterate", - "false": "@exit" - }, - "default": "@exit" - } - } - }, - "iterateState": { - "i": { - "$from": "scope", - "name": "stepped", - "path": ["result"] - }, - "bullets": { - "$from": "scope", - "name": "appended", - "path": ["list"] - } - }, - "output": { - "$from": "scope", - "name": "appended", - "path": ["list"] - }, - "outputSchema": { - "type": "array", - "items": { "type": "string" } - }, - "maxIterations": 200, - "next": "joinBullets", - "bind": "fileBullets" - }, - "joinBullets": { - "kind": "task", - "task": "string.join", - "inputSchema": { - "type": "object", - "required": ["list", "delimiter"], - "properties": { - "list": { - "type": "array", - "items": { "type": "string" } - }, - "delimiter": { "type": "string" } - } - }, - "outputSchema": { - "type": "object", - "required": ["text"], - "properties": { "text": { "type": "string" } } - }, - "inputs": { - "list": { "$from": "scope", "name": "fileBullets" }, - "delimiter": "\n" - }, - "next": "buildSynthesisPrompt", - "bind": "bulletsText" - }, - "buildSynthesisPrompt": { - "kind": "task", - "task": "text.template", - "inputSchema": { - "type": "object", - "required": ["template", "vars"], - "properties": { - "template": { "type": "string" }, - "vars": { "type": "object" } - } - }, - "outputSchema": { - "type": "object", - "required": ["text"], - "properties": { "text": { "type": "string" } } - }, - "inputs": { - "template": { - "$from": "constant", - "name": "synthesizePromptTemplate" - }, - "vars": { - "bullets": { - "$from": "scope", - "name": "bulletsText", - "path": ["text"] - } - } - }, - "next": "synthesize", - "bind": "synthesisPrompt" - }, - "synthesize": { - "kind": "task", - "task": "copilot.invoke", - "inputSchema": { - "type": "object", - "required": ["prompt"], - "properties": { - "prompt": { "type": "string" }, - "model": { "type": "string" }, - "allowedTools": { - "type": "array", - "items": { "type": "string" } - } - } - }, - "outputSchema": { - "type": "object", - "required": ["type", "scope", "subject", "body"], - "properties": { - "type": { - "type": "string", - "enum": [ - "feat", - "fix", - "refactor", - "perf", - "docs", - "test", - "style", - "chore", - "build", - "ci" - ], - "description": "Dominant Conventional Commits type across the staged files." - }, - "scope": { - "type": "string", - "description": "Shared scope, or empty string." - }, - "subject": { - "type": "string", - "description": "Imperative-mood subject line, \u2264 72 chars, no trailing period." - }, - "body": { - "type": "string", - "description": "1-3 paragraph plain-prose body explaining what changed and why." - } - } - }, - "inputs": { - "prompt": { - "$from": "scope", - "name": "synthesisPrompt", - "path": ["text"] - }, - "model": "claude-sonnet-4.6", - "allowedTools": [] - }, - "next": "buildSubject", - "bind": "synthesis" - }, - "buildSubject": { - "kind": "branch", - "selector": { "$from": "scope", "name": "synthesis", "path": ["scope"] }, - "selectorSchema": { "type": "string" }, - "cases": { "": "buildSubjectNoScope" }, - "default": "buildSubjectWithScope" - }, - "buildSubjectWithScope": { - "kind": "task", - "task": "text.template", - "inputSchema": { - "type": "object", - "required": ["template", "vars"], - "properties": { - "template": { "type": "string" }, - "vars": { "type": "object" } - } - }, - "outputSchema": { - "type": "object", - "required": ["text"], - "properties": { "text": { "type": "string" } } - }, - "inputs": { - "template": { - "$from": "constant", - "name": "subjectWithScopeTemplate" - }, - "vars": { - "type": { - "$from": "scope", - "name": "synthesis", - "path": ["type"] - }, - "scope": { - "$from": "scope", - "name": "synthesis", - "path": ["scope"] - }, - "subject": { - "$from": "scope", - "name": "synthesis", - "path": ["subject"] - } - } - }, - "next": "formatFinal", - "bind": "header" - }, - "buildSubjectNoScope": { - "kind": "task", - "task": "text.template", - "inputSchema": { - "type": "object", - "required": ["template", "vars"], - "properties": { - "template": { "type": "string" }, - "vars": { "type": "object" } - } - }, - "outputSchema": { - "type": "object", - "required": ["text"], - "properties": { "text": { "type": "string" } } - }, - "inputs": { - "template": { - "$from": "constant", - "name": "subjectNoScopeTemplate" - }, - "vars": { - "type": { - "$from": "scope", - "name": "synthesis", - "path": ["type"] - }, - "subject": { - "$from": "scope", - "name": "synthesis", - "path": ["subject"] - } - } - }, - "next": "formatFinal", - "bind": "header" - }, - "formatFinal": { - "kind": "task", - "task": "text.template", - "inputSchema": { - "type": "object", - "required": ["template", "vars"], - "properties": { - "template": { "type": "string" }, - "vars": { "type": "object" } - } - }, - "outputSchema": { - "type": "object", - "required": ["text"], - "properties": { "text": { "type": "string" } } - }, - "inputs": { - "template": { - "$from": "constant", - "name": "finalMessageTemplate" - }, - "vars": { - "header": { "$from": "scope", "name": "header", "path": ["text"] }, - "body": { - "$from": "scope", - "name": "synthesis", - "path": ["body"] - }, - "bullets": { - "$from": "scope", - "name": "bulletsText", - "path": ["text"] - } - } - }, - "bind": "finalMessage" - } - }, - "entry": "getFileList", - "output": { - "message": { - "$from": "scope", - "name": "finalMessage", - "path": ["text"] - }, - "type": { "$from": "scope", "name": "synthesis", "path": ["type"] }, - "scope": { "$from": "scope", "name": "synthesis", "path": ["scope"] }, - "subject": { "$from": "scope", "name": "synthesis", "path": ["subject"] }, - "body": { "$from": "scope", "name": "synthesis", "path": ["body"] }, - "fileBullets": { - "$from": "scope", - "name": "bulletsText", - "path": ["text"] - } - } -} diff --git a/ts/examples/workflow/workflows/ir/d10-conventional-commit.json b/ts/examples/workflow/workflows/ir/d10-conventional-commit.json new file mode 100644 index 000000000..e4b4e7279 --- /dev/null +++ b/ts/examples/workflow/workflows/ir/d10-conventional-commit.json @@ -0,0 +1,1102 @@ +{ + "kind": "workflow", + "version": "1", + "description": "Generate a Conventional Commits message from staged git changes. Plumbing (file enumeration, per-file diff, templating, joining, looping) is fully deterministic; copilot.invoke is used twice with schema-guided structured output (decisions 0010/0011): once per file to categorize the change, then once at the end to synthesize the final message.", + "constants": { + "categorizePromptTemplate": { + "schema": { + "type": "string" + }, + "value": "You are categorizing one file change for a Conventional Commits message.\n\nFile: {{file}}\n\nGit diff (staged):\n{{diff}}\n\nCall the `submit_response` tool with:\n- `type`: one of feat, fix, refactor, perf, docs, test, style, chore, build, ci. Pick the type that best describes the dominant change in this file.\n- `scope`: a short lower-case scope (1-2 words, e.g. a package or module name). Use \"\" if no obvious scope.\n- `summary`: a single concise imperative-mood phrase (≤ 80 chars) describing what changed in this file. No trailing period." + }, + "synthesizePromptTemplate": { + "schema": { + "type": "string" + }, + "value": "You are composing a single Conventional Commits message that covers all of the following per-file changes.\n\n{{bullets}}\n\nCall the `submit_response` tool with:\n- `type`: the dominant Conventional Commits type across the files (feat, fix, refactor, perf, docs, test, style, chore, build, ci). When mixed, prefer feat > fix > refactor > perf > test > docs > build > ci > style > chore.\n- `scope`: a short lower-case scope shared by the changes, or \"\" if there is no single coherent scope.\n- `subject`: an imperative-mood subject line ≤ 72 chars, no trailing period, summarizing the overall intent of the commit (NOT a list of files).\n- `body`: a 1-3 paragraph body explaining what changed and why, not how. Use plain prose, no markdown headings, no bullet lists." + }, + "subjectWithScopeTemplate": { + "schema": { + "type": "string" + }, + "value": "{{type}}({{scope}}): {{subject}}" + }, + "subjectNoScopeTemplate": { + "schema": { + "type": "string" + }, + "value": "{{type}}: {{subject}}" + }, + "finalMessageTemplate": { + "schema": { + "type": "string" + }, + "value": "{{header}}\n\n{{body}}\n\n# Files\n{{bullets}}\n" + } + }, + "entry": "d10-conventional-commit", + "workflows": { + "d10-conventional-commit": { + "inputSchema": { + "type": "object", + "required": [ + "repoPath" + ], + "properties": { + "repoPath": { + "type": "string", + "description": "Absolute path to the git repo with staged changes." + } + } + }, + "outputSchema": { + "type": "object", + "required": [ + "message", + "type", + "scope", + "subject", + "body", + "fileBullets" + ], + "properties": { + "message": { + "type": "string", + "description": "Final conventional commit message ready to pass to git commit -m." + }, + "type": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "body": { + "type": "string" + }, + "fileBullets": { + "type": "string" + } + } + }, + "entry": "getFileList", + "nodes": { + "getFileList": { + "kind": "task", + "task": "shell.exec", + "inputSchema": { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + } + } + }, + "outputSchema": { + "type": "object", + "required": [ + "stdout", + "stderr", + "exitCode" + ], + "properties": { + "stdout": { + "type": "string" + }, + "stderr": { + "type": "string" + }, + "exitCode": { + "type": "integer" + } + } + }, + "inputs": { + "command": "git", + "args": [ + "diff", + "--staged", + "--name-only" + ], + "cwd": { + "$from": "input", + "name": "repoPath" + } + }, + "next": "splitFiles", + "bind": "fileListResult" + }, + "splitFiles": { + "kind": "task", + "task": "string.split", + "inputSchema": { + "type": "object", + "required": [ + "text", + "delimiter" + ], + "properties": { + "text": { + "type": "string" + }, + "delimiter": { + "type": "string" + } + } + }, + "outputSchema": { + "type": "array", + "items": { + "type": "string" + } + }, + "inputs": { + "text": { + "$from": "scope", + "name": "fileListResult", + "path": [ + "stdout" + ] + }, + "delimiter": "\n" + }, + "next": "fileLoop", + "bind": "files" + }, + "fileLoop": { + "kind": "loop", + "inputs": { + "files": { + "$from": "scope", + "name": "files" + }, + "repoPath": { + "$from": "input", + "name": "repoPath" + }, + "categorizePromptTemplate": { + "$from": "constant", + "name": "categorizePromptTemplate" + } + }, + "state": { + "i": { + "schema": { + "type": "integer" + }, + "initial": 0 + }, + "bullets": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "initial": [] + } + }, + "body": { + "entry": "pickFile", + "nodes": { + "pickFile": { + "kind": "task", + "task": "list.elementAt", + "inputSchema": { + "type": "object", + "required": [ + "list", + "index" + ], + "properties": { + "list": { + "type": "array" + }, + "index": { + "type": "integer" + } + } + }, + "outputSchema": { + "type": "string" + }, + "inputs": { + "list": { + "$from": "input", + "name": "files" + }, + "index": { + "$from": "state", + "name": "i" + } + }, + "next": "getFileDiff", + "bind": "picked" + }, + "getFileDiff": { + "kind": "task", + "task": "shell.exec", + "inputSchema": { + "type": "object", + "required": [ + "command" + ], + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + } + } + }, + "outputSchema": { + "type": "object", + "required": [ + "stdout", + "stderr", + "exitCode" + ], + "properties": { + "stdout": { + "type": "string" + }, + "stderr": { + "type": "string" + }, + "exitCode": { + "type": "integer" + } + } + }, + "inputs": { + "command": "git", + "args": [ + "diff", + "--staged", + "--", + { + "$from": "scope", + "name": "picked" + } + ], + "cwd": { + "$from": "input", + "name": "repoPath" + } + }, + "next": "buildCategorizePrompt", + "bind": "fileDiff" + }, + "buildCategorizePrompt": { + "kind": "task", + "task": "text.template", + "inputSchema": { + "type": "object", + "required": [ + "template", + "vars" + ], + "properties": { + "template": { + "type": "string" + }, + "vars": { + "type": "object" + } + } + }, + "outputSchema": { + "type": "string" + }, + "inputs": { + "template": { + "$from": "input", + "name": "categorizePromptTemplate" + }, + "vars": { + "file": { + "$from": "scope", + "name": "picked" + }, + "diff": { + "$from": "scope", + "name": "fileDiff", + "path": [ + "stdout" + ] + } + } + }, + "next": "categorize", + "bind": "categorizePrompt" + }, + "categorize": { + "kind": "task", + "task": "copilot.invoke", + "inputSchema": { + "type": "object", + "required": [ + "prompt" + ], + "properties": { + "prompt": { + "type": "string" + }, + "model": { + "type": "string" + }, + "allowedTools": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "outputSchema": { + "type": "object", + "required": [ + "type", + "scope", + "summary" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "feat", + "fix", + "refactor", + "perf", + "docs", + "test", + "style", + "chore", + "build", + "ci" + ], + "description": "Conventional Commits type for this file." + }, + "scope": { + "type": "string", + "description": "Short lower-case scope, or empty string." + }, + "summary": { + "type": "string", + "description": "Imperative-mood phrase ≤ 80 chars describing the file change." + } + } + }, + "inputs": { + "prompt": { + "$from": "scope", + "name": "categorizePrompt" + }, + "model": "claude-sonnet-4.6", + "allowedTools": [] + }, + "next": "formatBullet", + "bind": "categorization" + }, + "formatBullet": { + "kind": "task", + "task": "text.template", + "inputSchema": { + "type": "object", + "required": [ + "template", + "vars" + ], + "properties": { + "template": { + "type": "string" + }, + "vars": { + "type": "object" + } + } + }, + "outputSchema": { + "type": "string" + }, + "inputs": { + "template": "- {{type}} ({{scope}}) {{file}}: {{summary}}", + "vars": { + "type": { + "$from": "scope", + "name": "categorization", + "path": [ + "type" + ] + }, + "scope": { + "$from": "scope", + "name": "categorization", + "path": [ + "scope" + ] + }, + "file": { + "$from": "scope", + "name": "picked" + }, + "summary": { + "$from": "scope", + "name": "categorization", + "path": [ + "summary" + ] + } + } + }, + "next": "appendBullet", + "bind": "bullet" + }, + "appendBullet": { + "kind": "task", + "task": "list.append", + "inputSchema": { + "type": "object", + "required": [ + "list", + "item" + ], + "properties": { + "list": { + "type": "array" + }, + "item": {} + } + }, + "outputSchema": { + "type": "array", + "items": { + "type": "string" + } + }, + "inputs": { + "list": { + "$from": "state", + "name": "bullets" + }, + "item": { + "$from": "scope", + "name": "bullet" + } + }, + "next": "stepIndex", + "bind": "appended" + }, + "stepIndex": { + "kind": "task", + "task": "math.add", + "inputSchema": { + "type": "object", + "required": [ + "left", + "right" + ], + "properties": { + "left": { + "type": "number" + }, + "right": { + "type": "number" + } + } + }, + "outputSchema": { + "type": "number" + }, + "inputs": { + "left": { + "$from": "state", + "name": "i" + }, + "right": 1 + }, + "next": "computeLength", + "bind": "stepped" + }, + "computeLength": { + "kind": "task", + "task": "list.length", + "inputSchema": { + "type": "object", + "required": [ + "list" + ], + "properties": { + "list": { + "type": "array" + } + } + }, + "outputSchema": { + "type": "integer" + }, + "inputs": { + "list": { + "$from": "input", + "name": "files" + } + }, + "next": "compareIndex", + "bind": "fileCount" + }, + "compareIndex": { + "kind": "task", + "task": "compare.lessThan", + "inputSchema": { + "type": "object", + "required": [ + "left", + "right" + ], + "properties": { + "left": { + "type": "number" + }, + "right": { + "type": "number" + } + } + }, + "outputSchema": { + "type": "boolean" + }, + "inputs": { + "left": { + "$from": "scope", + "name": "stepped" + }, + "right": { + "$from": "scope", + "name": "fileCount" + } + }, + "bind": "hasMore" + } + }, + "inputSchema": { + "type": "object", + "required": [ + "files", + "repoPath", + "categorizePromptTemplate" + ], + "properties": { + "files": { + "type": "array", + "items": { + "type": "string" + } + }, + "repoPath": { + "type": "string" + }, + "categorizePromptTemplate": { + "type": "string" + } + } + }, + "output": { + "$from": "scope", + "name": "appended" + }, + "outputSchema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "continueWhen": { + "$from": "scope", + "name": "hasMore" + }, + "iterateState": { + "i": { + "$from": "scope", + "name": "stepped" + }, + "bullets": { + "$from": "scope", + "name": "appended" + } + }, + "maxIterations": 200, + "next": "joinBullets", + "bind": "fileBullets" + }, + "joinBullets": { + "kind": "task", + "task": "string.join", + "inputSchema": { + "type": "object", + "required": [ + "list", + "delimiter" + ], + "properties": { + "list": { + "type": "array", + "items": { + "type": "string" + } + }, + "delimiter": { + "type": "string" + } + } + }, + "outputSchema": { + "type": "string" + }, + "inputs": { + "list": { + "$from": "scope", + "name": "fileBullets" + }, + "delimiter": "\n" + }, + "next": "buildSynthesisPrompt", + "bind": "bulletsText" + }, + "buildSynthesisPrompt": { + "kind": "task", + "task": "text.template", + "inputSchema": { + "type": "object", + "required": [ + "template", + "vars" + ], + "properties": { + "template": { + "type": "string" + }, + "vars": { + "type": "object" + } + } + }, + "outputSchema": { + "type": "string" + }, + "inputs": { + "template": { + "$from": "constant", + "name": "synthesizePromptTemplate" + }, + "vars": { + "bullets": { + "$from": "scope", + "name": "bulletsText" + } + } + }, + "next": "synthesize", + "bind": "synthesisPrompt" + }, + "synthesize": { + "kind": "task", + "task": "copilot.invoke", + "inputSchema": { + "type": "object", + "required": [ + "prompt" + ], + "properties": { + "prompt": { + "type": "string" + }, + "model": { + "type": "string" + }, + "allowedTools": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "outputSchema": { + "type": "object", + "required": [ + "type", + "scope", + "subject", + "body" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "feat", + "fix", + "refactor", + "perf", + "docs", + "test", + "style", + "chore", + "build", + "ci" + ], + "description": "Dominant Conventional Commits type across the staged files." + }, + "scope": { + "type": "string", + "description": "Shared scope, or empty string." + }, + "subject": { + "type": "string", + "description": "Imperative-mood subject line, ≤ 72 chars, no trailing period." + }, + "body": { + "type": "string", + "description": "1-3 paragraph plain-prose body explaining what changed and why." + } + } + }, + "inputs": { + "prompt": { + "$from": "scope", + "name": "synthesisPrompt" + }, + "model": "claude-sonnet-4.6", + "allowedTools": [] + }, + "next": "buildSubject", + "bind": "synthesis" + }, + "buildSubject": { + "kind": "branch", + "selector": { + "$from": "scope", + "name": "synthesis", + "path": [ + "scope" + ] + }, + "selectorSchema": { + "type": "string" + }, + "cases": { + "": { + "inputs": { + "type": { + "$from": "scope", + "name": "synthesis", + "path": [ + "type" + ] + }, + "subject": { + "$from": "scope", + "name": "synthesis", + "path": [ + "subject" + ] + }, + "tmpl": { + "$from": "constant", + "name": "subjectNoScopeTemplate" + } + }, + "scope": { + "inputSchema": { + "type": "object", + "required": [ + "type", + "subject", + "tmpl" + ], + "properties": { + "tmpl": { + "type": "string" + }, + "type": { + "type": "string" + }, + "subject": { + "type": "string" + } + } + }, + "entry": "build", + "nodes": { + "build": { + "kind": "task", + "task": "text.template", + "inputSchema": { + "type": "object", + "required": [ + "template", + "vars" + ], + "properties": { + "template": { + "type": "string" + }, + "vars": { + "type": "object" + } + } + }, + "outputSchema": { + "type": "string" + }, + "inputs": { + "template": { + "$from": "input", + "name": "tmpl" + }, + "vars": { + "type": { + "$from": "input", + "name": "type" + }, + "subject": { + "$from": "input", + "name": "subject" + } + } + }, + "bind": "header" + } + }, + "output": { + "$from": "scope", + "name": "header" + }, + "outputSchema": { + "type": "string" + } + } + } + }, + "default": { + "inputs": { + "type": { + "$from": "scope", + "name": "synthesis", + "path": [ + "type" + ] + }, + "subject": { + "$from": "scope", + "name": "synthesis", + "path": [ + "subject" + ] + }, + "tmpl": { + "$from": "constant", + "name": "subjectWithScopeTemplate" + }, + "scope": { + "$from": "scope", + "name": "synthesis", + "path": [ + "scope" + ] + } + }, + "scope": { + "inputSchema": { + "type": "object", + "required": [ + "type", + "scope", + "subject", + "tmpl" + ], + "properties": { + "tmpl": { + "type": "string" + }, + "type": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "scope": { + "type": "string" + } + } + }, + "entry": "build", + "nodes": { + "build": { + "kind": "task", + "task": "text.template", + "inputSchema": { + "type": "object", + "required": [ + "template", + "vars" + ], + "properties": { + "template": { + "type": "string" + }, + "vars": { + "type": "object" + } + } + }, + "outputSchema": { + "type": "string" + }, + "inputs": { + "template": { + "$from": "input", + "name": "tmpl" + }, + "vars": { + "type": { + "$from": "input", + "name": "type" + }, + "subject": { + "$from": "input", + "name": "subject" + }, + "scope": { + "$from": "input", + "name": "scope" + } + } + }, + "bind": "header" + } + }, + "output": { + "$from": "scope", + "name": "header" + }, + "outputSchema": { + "type": "string" + } + } + }, + "outputSchema": { + "type": "string" + }, + "bind": "header", + "next": "formatFinal" + }, + "formatFinal": { + "kind": "task", + "task": "text.template", + "inputSchema": { + "type": "object", + "required": [ + "template", + "vars" + ], + "properties": { + "template": { + "type": "string" + }, + "vars": { + "type": "object" + } + } + }, + "outputSchema": { + "type": "string" + }, + "inputs": { + "template": { + "$from": "constant", + "name": "finalMessageTemplate" + }, + "vars": { + "header": { + "$from": "scope", + "name": "header" + }, + "body": { + "$from": "scope", + "name": "synthesis", + "path": [ + "body" + ] + }, + "bullets": { + "$from": "scope", + "name": "bulletsText" + } + } + }, + "bind": "finalMessage" + } + }, + "output": { + "message": { + "$from": "scope", + "name": "finalMessage" + }, + "type": { + "$from": "scope", + "name": "synthesis", + "path": [ + "type" + ] + }, + "scope": { + "$from": "scope", + "name": "synthesis", + "path": [ + "scope" + ] + }, + "subject": { + "$from": "scope", + "name": "synthesis", + "path": [ + "subject" + ] + }, + "body": { + "$from": "scope", + "name": "synthesis", + "path": [ + "body" + ] + }, + "fileBullets": { + "$from": "scope", + "name": "bulletsText" + } + } + } + } +} diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index e05fae31e..daf3534a0 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -155,7 +155,7 @@ importers: version: 8.18.1 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)) + version: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5)) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -986,7 +986,7 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.9.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@5.4.5)) + version: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -1055,6 +1055,9 @@ importers: examples/workflow/engine: dependencies: + '@github/copilot-sdk': + specifier: ^0.3.0 + version: 0.3.0 aiclient: specifier: workspace:* version: link:../../../packages/aiclient @@ -7349,44 +7352,104 @@ packages: os: [darwin] hasBin: true + '@github/copilot-darwin-arm64@1.0.54': + resolution: {integrity: sha512-ZRiKkxCvDccdGSNB/gmge4UkqMsWWZNIOr0pcim4/x2YUdHbh9cex9RZRjEMXijtUkBTzW5DP/cACuoAqTCyEg==} + cpu: [arm64] + os: [darwin] + hasBin: true + '@github/copilot-darwin-x64@1.0.17': resolution: {integrity: sha512-yqRS0/8kYTGl4VvfJ/QOtHTeYF+DnAWNUReZgt2U0AEP3zgj4z4hxSH7D2PsO/488L4KsBmmcnJr13HmBGiT/w==} cpu: [x64] os: [darwin] hasBin: true + '@github/copilot-darwin-x64@1.0.54': + resolution: {integrity: sha512-DGqs8x5r4y+SebMco890lNsPrqe6L4v2hCmV1IQ1pvYPvD1o1NMVSZPAQhkdvUeR5bqusOg8+0ugIZOQGTFpFQ==} + cpu: [x64] + os: [darwin] + hasBin: true + '@github/copilot-linux-arm64@1.0.17': resolution: {integrity: sha512-TOK0ma0A24zmQJslkGxUk+KnMFpiqquWEXB5sIv/5Ci45Qi7s0BRWTnqtiJ8Vahwb/wkja6KarHkLA27+ETGUA==} cpu: [arm64] os: [linux] hasBin: true + '@github/copilot-linux-arm64@1.0.54': + resolution: {integrity: sha512-waVKu6RuG8YBvCoGrOgtsOxmnfLaUywvbqZXRgvMya1m4akRkMi5r9B2UDr3+egjChp+FIUJVbGIoXN6ZST0rQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + hasBin: true + '@github/copilot-linux-x64@1.0.17': resolution: {integrity: sha512-4Yum3uaAuTM/SiNtzchsO/G/144Bi/Z4FEcearW6WsGDvS6cRwSJeudOM0y4aoy4BHcv8+yw7YuXH5BHC3SAiA==} cpu: [x64] os: [linux] hasBin: true + '@github/copilot-linux-x64@1.0.54': + resolution: {integrity: sha512-u/ltZa+HDIuhMivkIwkkuylRdEMk5Lp0XjE9w/OityW+BPKjZ+VKAmJ1/1Xm/uUx1IUlZaE3TJnka52wVNOD0A==} + cpu: [x64] + os: [linux] + libc: [glibc] + hasBin: true + + '@github/copilot-linuxmusl-arm64@1.0.54': + resolution: {integrity: sha512-21LLjoQnD57Y1fvO56G1FGVbkt/ffZNDpHqVe2NW7C4r78Gn0hOTqwp+xWRUMpdmxrGZyKeFjX8jK6qox2uF5w==} + cpu: [arm64] + os: [linux] + libc: [musl] + hasBin: true + + '@github/copilot-linuxmusl-x64@1.0.54': + resolution: {integrity: sha512-sbeATKa9vaIetsY1vhQJO0PN/5FgoK48wkGBWCy4BpO8ER/kGYczT22qv6n96gBYrVmC2IZuTFTM4GFpC3bbBw==} + cpu: [x64] + os: [linux] + libc: [musl] + hasBin: true + '@github/copilot-sdk@0.2.0': resolution: {integrity: sha512-fCEpD9W9xqcaCAJmatyNQ1PkET9P9liK2P4Vk0raDFoMXcvpIdqewa5JQeKtWCBUsN/HCz7ExkkFP8peQuo+DA==} engines: {node: '>=20.0.0'} + '@github/copilot-sdk@0.3.0': + resolution: {integrity: sha512-SUo35k56pzzgYgwmDPHcu7kZxPrzXbH66IWXaEf6pmb94DlA709F82HrrDeja087TL4djJ9OuvRFWWOKCosAsg==} + engines: {node: '>=20.0.0'} + '@github/copilot-win32-arm64@1.0.17': resolution: {integrity: sha512-I1ferbfQ0aS149WyEUw6XS1sFixwTUUm13BPBQ3yMzD8G2SaoxTsdYdlhZpkVfkfh/rUYyvMKKi9VNxoVYOlDA==} cpu: [arm64] os: [win32] hasBin: true + '@github/copilot-win32-arm64@1.0.54': + resolution: {integrity: sha512-muOX8qrJSi56BWQejkH0TgXpZYRO8Y9k1qIfMuRojZyLyATn1P4lIKb67ZqDCXJLkcPfVJ5eJYsSAeGwU3Qpww==} + cpu: [arm64] + os: [win32] + hasBin: true + '@github/copilot-win32-x64@1.0.17': resolution: {integrity: sha512-kjiOxY9ibS+rPp9XFpPdfdYzluEL3SHN8R5/fnA7RO+kZEJ4FDKWJjAiec3tgVkEHQT3UwNuVa/u3TdfYNF15w==} cpu: [x64] os: [win32] hasBin: true + '@github/copilot-win32-x64@1.0.54': + resolution: {integrity: sha512-BheXmqrYFmfRXA0iveKkjKks/2wgK5glrEOARomzy3JCbvVMSPIE8YeK+3YysiOh2SUkWjahwJc09cxaBq4+qQ==} + cpu: [x64] + os: [win32] + hasBin: true + '@github/copilot@1.0.17': resolution: {integrity: sha512-RTJ+kEKOdidjuOs8ozsoBdz+94g7tFJIEu5kz1P2iwJhsL+iIA5rtn9/jXOF0hAI3CLSXKZoSd66cqHrn4rb1A==} hasBin: true + '@github/copilot@1.0.54': + resolution: {integrity: sha512-gxiWEQFWxJ3J2Rh67CxKEfER/zayB1z2qaSBUz3RZ0u1iDNJdGPry/1vOQ72X/yHmpGNm+9egucN5VMzyedsIg==} + hasBin: true + '@grpc/grpc-js@1.13.4': resolution: {integrity: sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==} engines: {node: '>=12.10.0'} @@ -11286,6 +11349,10 @@ packages: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -16622,7 +16689,7 @@ snapshots: '@anthropic-ai/claude-agent-sdk@0.2.105': dependencies: - '@anthropic-ai/sdk': 0.81.0(zod@3.25.76) + '@anthropic-ai/sdk': 0.81.0(zod@4.1.13) '@modelcontextprotocol/sdk': 1.29.0 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 @@ -18707,27 +18774,57 @@ snapshots: '@github/copilot-darwin-arm64@1.0.17': optional: true + '@github/copilot-darwin-arm64@1.0.54': + optional: true + '@github/copilot-darwin-x64@1.0.17': optional: true + '@github/copilot-darwin-x64@1.0.54': + optional: true + '@github/copilot-linux-arm64@1.0.17': optional: true + '@github/copilot-linux-arm64@1.0.54': + optional: true + '@github/copilot-linux-x64@1.0.17': optional: true + '@github/copilot-linux-x64@1.0.54': + optional: true + + '@github/copilot-linuxmusl-arm64@1.0.54': + optional: true + + '@github/copilot-linuxmusl-x64@1.0.54': + optional: true + '@github/copilot-sdk@0.2.0': dependencies: '@github/copilot': 1.0.17 vscode-jsonrpc: 8.2.1 zod: 4.3.6 + '@github/copilot-sdk@0.3.0': + dependencies: + '@github/copilot': 1.0.54 + vscode-jsonrpc: 8.2.1 + zod: 4.3.6 + '@github/copilot-win32-arm64@1.0.17': optional: true + '@github/copilot-win32-arm64@1.0.54': + optional: true + '@github/copilot-win32-x64@1.0.17': optional: true + '@github/copilot-win32-x64@1.0.54': + optional: true + '@github/copilot@1.0.17': optionalDependencies: '@github/copilot-darwin-arm64': 1.0.17 @@ -18737,6 +18834,19 @@ snapshots: '@github/copilot-win32-arm64': 1.0.17 '@github/copilot-win32-x64': 1.0.17 + '@github/copilot@1.0.54': + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + '@github/copilot-darwin-arm64': 1.0.54 + '@github/copilot-darwin-x64': 1.0.54 + '@github/copilot-linux-arm64': 1.0.54 + '@github/copilot-linux-x64': 1.0.54 + '@github/copilot-linuxmusl-arm64': 1.0.54 + '@github/copilot-linuxmusl-x64': 1.0.54 + '@github/copilot-win32-arm64': 1.0.54 + '@github/copilot-win32-x64': 1.0.54 + '@grpc/grpc-js@1.13.4': dependencies: '@grpc/proto-loader': 0.7.15 @@ -19963,7 +20073,7 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod-to-json-schema: 3.25.1(zod@3.25.76) + zod-to-json-schema: 3.25.1(zod@4.1.13) transitivePeerDependencies: - supports-color @@ -23909,6 +24019,8 @@ snapshots: detect-libc@2.0.3: {} + detect-libc@2.1.2: {} + detect-newline@3.1.0: {} detect-newline@4.0.1: {} From 149dc55d99d4ab2ad5e841d10f194fcd71fd0c72 Mon Sep 17 00:00:00 2001 From: Daniel Lehenbauer Date: Thu, 28 May 2026 15:42:45 -0700 Subject: [PATCH 3/8] docs(workflow): drop inputSchema from TaskContext decision 0011 Update decision 0011 to reflect that only outputSchema was added to TaskContext; inputSchema was removed because no consumer needed it. Addresses PR #2402 review feedback. --- .../0011-task-context-schema-awareness.md | 68 +++++++++---------- ts/pnpm-lock.yaml | 13 ++++ 2 files changed, 46 insertions(+), 35 deletions(-) diff --git a/ts/docs/design/workflowSystem/ir/decisions/0011-task-context-schema-awareness.md b/ts/docs/design/workflowSystem/ir/decisions/0011-task-context-schema-awareness.md index 8e48c019a..605e8b217 100644 --- a/ts/docs/design/workflowSystem/ir/decisions/0011-task-context-schema-awareness.md +++ b/ts/docs/design/workflowSystem/ir/decisions/0011-task-context-schema-awareness.md @@ -1,10 +1,10 @@ # TaskContext schema awareness (decision 0011) Status: **Adopted (v1).** Engine API extension; **not** an IR change. -Adds `inputSchema` and `outputSchema` to the `TaskContext` value the -engine passes to `task.execute`. The schemas are populated from the -dispatching node's IR-declared schemas — i.e. existing IR data, made -visible to the task implementer. +Adds `outputSchema` to the `TaskContext` value the engine passes to +`task.execute`. The schema is populated from the dispatching node's +IR-declared `outputSchema` — i.e. existing IR data, made visible to +the task implementer. Related: @@ -21,16 +21,16 @@ validates a task's return value against the IR-declared `outputSchema` at runtime (`ir-v1.md` §5.2). But today, the task implementation cannot **read** its own node's -declared schemas. `TaskContext` carries `runId`, `nodeId`, `scopePath`, -`signal`, `constraints` — not `inputSchema`/`outputSchema`. So a task -that wants to _use_ the schema as part of its computation (for +declared `outputSchema`. `TaskContext` carries `runId`, `nodeId`, +`scopePath`, `signal`, `constraints` — but not the output schema. So +a task that wants to _use_ the schema as part of its computation (for example, instructing an LLM agent to produce a value of a specific shape, or driving a schema-aware transform) has no first-class access to it. ## 2. Decision -Add two fields to `TaskContext`: +Add one field to `TaskContext`: ```typescript export interface TaskContext { @@ -39,12 +39,6 @@ export interface TaskContext { scopePath: string[]; signal: AbortSignal; constraints?: TaskConstraints; - /** - * The dispatching node's declared input schema, per IR §3.5. - * Authoritative for this call: equal to or a narrowing of the - * registered task's inputSchema (decision 0003 Option 1'). - */ - inputSchema: JSONSchema; /** * The dispatching node's declared output schema, per IR §3.5. * Authoritative for this call: equal to or a narrowing of the @@ -57,22 +51,28 @@ export interface TaskContext { } ``` -The engine's runner populates these fields from the dispatching -`WorkflowNode`'s `inputSchema`/`outputSchema` before invoking -`task.execute`. +The engine's runner populates this field from the dispatching +`WorkflowNode`'s `outputSchema` before invoking `task.execute`. + +`outputSchema` is declared as required (not optional) on `TaskContext`: +`TaskNode.outputSchema` is required by the IR contract +(`model/src/ir.ts`) and the static validator rejects task nodes that +omit it, so the runner can — and does — pass it unconditionally. ## 3. Why this earns its place This is a near-zero-cost extension that exposes existing IR data to the task implementer. It satisfies: -- **P4 (boundary contract).** The IR-declared schemas _are_ the - task's boundary contract for this call. P4's one-line test — - _"Can I validate/test this part using only what its boundary - declares?"_ — is more directly satisfied when the task itself can - see its boundary, not merely have it enforced from outside. -- **Decision 0003 alignment.** 0003 made the IR's schemas - authoritative. Making them visible to the task is the natural +- **P4 (boundary contract).** The IR-declared output schema _is_ half + of the task's boundary contract for this call (the other half — the + input — is already supplied as the `execute` argument). P4's + one-line test — _"Can I validate/test this part using only what its + boundary declares?"_ — is more directly satisfied when the task + itself can see its declared output shape, not merely have it + enforced from outside. +- **Decision 0003 alignment.** 0003 made the IR's output schema + authoritative. Making it visible to the task is the natural consequence: if the IR is the source of truth, the task should be able to consult that source. - **Generality.** The change is not Copilot-specific. Any future @@ -83,13 +83,12 @@ the task implementer. It satisfies: ## 4. Why this is NOT an IR change -No IR field is added or removed. `inputSchema`/`outputSchema` already -exist on every task node (`ir-v1.md` §3.5). This decision changes -only: +No IR field is added or removed. `outputSchema` already exists on +every task node (`ir-v1.md` §3.5). This decision changes only: - `workflow-model/src/taskDefinition.ts` — the `TaskContext` interface. - `workflow-engine/src/runner.ts` — the runner populates the new - fields when constructing the per-call `TaskContext`. + field when constructing the per-call `TaskContext`. `ir-v1.md` does not need editing. No validator rule changes. No existing IR document semantics change. @@ -113,7 +112,7 @@ because they'd have to know the redundancy is required. ### C. Defer until the next schema-aware task earns the change -Reject. The cost of the change is essentially zero (two fields on a +Reject. The cost of the change is essentially zero (one field on a context object, one population site in the runner). Doing it now means decision 0010 (Copilot task family) lands cleanly and any future schema-aware task gets the same affordance for free. Doing @@ -125,19 +124,18 @@ twice. - **No test-fixture cascade.** A scan of `engine/test/` and `model/test/` confirms no test directly constructs a `TaskContext`; all task execution flows through `WorkflowEngine.run`. The runner - populates the new fields from the node it is dispatching, so + populates the new field from the node it is dispatching, so existing tests continue to work without per-fixture changes. -- **Schemas are JSON values, not Ajv validators.** The runner does +- **Schema is a JSON value, not an Ajv validator.** The runner does NOT pre-compile a per-task `submit_response` validator or otherwise cache schemas keyed by node — each task that wants to validate against the schema brings its own validator (e.g. Ajv instance). - Keeping `TaskContext.{inputSchema,outputSchema}` as plain - `JSONSchema` mirrors how `TaskDefinition.inputSchema` / - `TaskDefinition.outputSchema` are typed today. + Keeping `TaskContext.outputSchema` as a plain `JSONSchema` mirrors + how `TaskDefinition.outputSchema` is typed today. ## 7. Cross-references - [../../principles/design-principles.md](../../principles/design-principles.md) — P4. - [0003-task-schema-source.md](0003-task-schema-source.md) — what made the IR's schemas the authoritative source this decision exposes. - [0010-copilot-task-family.md](0010-copilot-task-family.md) — first consumer. -- [../ir-v1.md](../ir-v1.md) §3.5 (task node `inputSchema`/`outputSchema`), §5.2 (engine-side runtime output schema validation that this decision does **not** change). +- [../ir-v1.md](../ir-v1.md) §3.5 (task node `outputSchema`), §5.2 (engine-side runtime output schema validation that this decision does **not** change). diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index d50d7e407..aca62cbc1 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -1055,6 +1055,9 @@ importers: examples/workflow/engine: dependencies: + '@github/copilot-sdk': + specifier: ^0.3.0 + version: 0.3.0 aiclient: specifier: workspace:* version: link:../../../packages/aiclient @@ -7237,6 +7240,10 @@ packages: resolution: {integrity: sha512-fCEpD9W9xqcaCAJmatyNQ1PkET9P9liK2P4Vk0raDFoMXcvpIdqewa5JQeKtWCBUsN/HCz7ExkkFP8peQuo+DA==} engines: {node: '>=20.0.0'} + '@github/copilot-sdk@0.3.0': + resolution: {integrity: sha512-SUo35k56pzzgYgwmDPHcu7kZxPrzXbH66IWXaEf6pmb94DlA709F82HrrDeja087TL4djJ9OuvRFWWOKCosAsg==} + engines: {node: '>=20.0.0'} + '@github/copilot-win32-arm64@1.0.54': resolution: {integrity: sha512-muOX8qrJSi56BWQejkH0TgXpZYRO8Y9k1qIfMuRojZyLyATn1P4lIKb67ZqDCXJLkcPfVJ5eJYsSAeGwU3Qpww==} cpu: [arm64] @@ -18533,6 +18540,12 @@ snapshots: vscode-jsonrpc: 8.2.1 zod: 4.3.6 + '@github/copilot-sdk@0.3.0': + dependencies: + '@github/copilot': 1.0.54 + vscode-jsonrpc: 8.2.1 + zod: 4.3.6 + '@github/copilot-win32-arm64@1.0.54': optional: true From 88e231e4f6d3bf178a411a6f0d42d8f6ccf9522a Mon Sep 17 00:00:00 2001 From: Daniel Lehenbauer Date: Fri, 29 May 2026 16:26:23 -0700 Subject: [PATCH 4/8] refactor(workflow-engine): tighten builtin task type annotations Declare concrete builtin tasks with ConcreteTaskDefinition<...> and generic builtin tasks with GenericTaskDefinition<...> instead of the looser TaskDefinition<...> union. Annotations now match what each task actually is (concrete or generic), per the schemas in builtinTaskSchemas.ts. Removes the need for downstream consumers (e.g. tests) to type-assert when reading .inputSchema/.outputSchema. --- .../workflow/engine/src/builtinTasks.ts | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/ts/examples/workflow/engine/src/builtinTasks.ts b/ts/examples/workflow/engine/src/builtinTasks.ts index 919208f72..dcb252896 100644 --- a/ts/examples/workflow/engine/src/builtinTasks.ts +++ b/ts/examples/workflow/engine/src/builtinTasks.ts @@ -127,7 +127,7 @@ function sealObjects(schema: JSONSchema): JSONSchema { return copy; } -export const listLength: TaskDefinition<{ list: unknown[] }, number> = { +export const listLength: ConcreteTaskDefinition<{ list: unknown[] }, number> = { ...taskSchema("list.length"), sideEffects: false, async execute(input) { @@ -135,7 +135,7 @@ export const listLength: TaskDefinition<{ list: unknown[] }, number> = { }, }; -export const listElementAt: TaskDefinition< +export const listElementAt: GenericTaskDefinition< { list: unknown[]; index: number }, unknown > = { @@ -154,7 +154,7 @@ export const listElementAt: TaskDefinition< }, }; -export const listAppend: TaskDefinition< +export const listAppend: ConcreteTaskDefinition< { list: unknown[]; item: unknown }, unknown[] > = { @@ -165,7 +165,7 @@ export const listAppend: TaskDefinition< }, }; -export const boolToLabel: TaskDefinition< +export const boolToLabel: ConcreteTaskDefinition< { value: boolean; ifTrue: string; ifFalse: string }, string > = { @@ -181,7 +181,7 @@ export const boolToLabel: TaskDefinition< // ---- IO tasks ---- -export const shellExec: TaskDefinition< +export const shellExec: ConcreteTaskDefinition< { command: string; args?: string[]; @@ -256,7 +256,7 @@ export const shellExec: TaskDefinition< }, }; -export const llmGenerate: TaskDefinition< +export const llmGenerate: ConcreteTaskDefinition< { prompt: string; endpoint?: string }, string > = { @@ -356,7 +356,7 @@ export const llmGenerateJson: GenericTaskDefinition< * Requests to `allowedTools` are `approveAll` for v1. Capability-based * security model is the longer-term follow-up (decision 0010 §7). */ -export const copilotInvoke: TaskDefinition< +export const copilotInvoke: ConcreteTaskDefinition< { prompt: string; model?: string; @@ -457,7 +457,7 @@ export const copilotInvoke: TaskDefinition< // ---- Utility tasks ---- -export const textTemplate: TaskDefinition< +export const textTemplate: ConcreteTaskDefinition< { template: string; vars: Record }, string > = { @@ -472,7 +472,7 @@ export const textTemplate: TaskDefinition< }, }; -export const stringJoin: TaskDefinition< +export const stringJoin: ConcreteTaskDefinition< { list: string[]; delimiter: string }, string > = { @@ -483,7 +483,7 @@ export const stringJoin: TaskDefinition< }, }; -export const stringSplit: TaskDefinition< +export const stringSplit: ConcreteTaskDefinition< { text: string; delimiter: string; keepEmpty?: boolean }, string[] > = { @@ -498,7 +498,7 @@ export const stringSplit: TaskDefinition< }, }; -export const httpGet: TaskDefinition< +export const httpGet: ConcreteTaskDefinition< { url: string; headers?: Record; @@ -645,7 +645,7 @@ function validateFilePath(filePath: string): string { const DEFAULT_MAX_FILE_READ_BYTES = 10 * 1024 * 1024; // 10 MB -export const fileRead: TaskDefinition< +export const fileRead: ConcreteTaskDefinition< { path: string; maxBytes?: number }, string > = { @@ -676,7 +676,7 @@ export const fileRead: TaskDefinition< }, }; -export const fileWrite: TaskDefinition< +export const fileWrite: ConcreteTaskDefinition< { path: string; content: string }, string > = { @@ -701,7 +701,7 @@ export const fileWrite: TaskDefinition< // ---- compare tasks ---- -export const compareEquals: TaskDefinition< +export const compareEquals: ConcreteTaskDefinition< { left: unknown; right: unknown }, boolean > = { @@ -712,7 +712,7 @@ export const compareEquals: TaskDefinition< }, }; -export const compareNotEquals: TaskDefinition< +export const compareNotEquals: ConcreteTaskDefinition< { left: unknown; right: unknown }, boolean > = { @@ -723,7 +723,7 @@ export const compareNotEquals: TaskDefinition< }, }; -export const compareGreaterThan: TaskDefinition< +export const compareGreaterThan: ConcreteTaskDefinition< { left: number; right: number }, boolean > = { @@ -734,7 +734,7 @@ export const compareGreaterThan: TaskDefinition< }, }; -export const compareLessThan: TaskDefinition< +export const compareLessThan: ConcreteTaskDefinition< { left: number; right: number }, boolean > = { @@ -745,7 +745,7 @@ export const compareLessThan: TaskDefinition< }, }; -export const compareGreaterOrEqual: TaskDefinition< +export const compareGreaterOrEqual: ConcreteTaskDefinition< { left: number; right: number }, boolean > = { @@ -756,7 +756,7 @@ export const compareGreaterOrEqual: TaskDefinition< }, }; -export const compareLessOrEqual: TaskDefinition< +export const compareLessOrEqual: ConcreteTaskDefinition< { left: number; right: number }, boolean > = { @@ -769,7 +769,7 @@ export const compareLessOrEqual: TaskDefinition< // ---- bool tasks ---- -export const boolNot: TaskDefinition<{ value: boolean }, boolean> = { +export const boolNot: ConcreteTaskDefinition<{ value: boolean }, boolean> = { ...taskSchema("bool.not"), sideEffects: false, async execute(input) { @@ -790,7 +790,7 @@ export const mathAdd: GenericTaskDefinition< }, }; -export const mathSubtract: TaskDefinition< +export const mathSubtract: GenericTaskDefinition< { left: number; right: number }, number > = { @@ -801,7 +801,7 @@ export const mathSubtract: TaskDefinition< }, }; -export const mathMultiply: TaskDefinition< +export const mathMultiply: GenericTaskDefinition< { left: number; right: number }, number > = { @@ -812,7 +812,7 @@ export const mathMultiply: TaskDefinition< }, }; -export const mathDivide: TaskDefinition< +export const mathDivide: ConcreteTaskDefinition< { left: number; right: number }, number > = { @@ -823,7 +823,7 @@ export const mathDivide: TaskDefinition< }, }; -export const mathModulo: TaskDefinition< +export const mathModulo: GenericTaskDefinition< { left: number; right: number }, number > = { @@ -834,7 +834,7 @@ export const mathModulo: TaskDefinition< }, }; -export const mathNegate: TaskDefinition<{ value: number }, number> = { +export const mathNegate: GenericTaskDefinition<{ value: number }, number> = { ...genericTaskSchema("math.negate"), sideEffects: false, async execute(input) { @@ -842,7 +842,7 @@ export const mathNegate: TaskDefinition<{ value: number }, number> = { }, }; -export const mathFloor: TaskDefinition<{ value: number }, number> = { +export const mathFloor: ConcreteTaskDefinition<{ value: number }, number> = { ...taskSchema("math.floor"), sideEffects: false, async execute(input) { @@ -850,7 +850,7 @@ export const mathFloor: TaskDefinition<{ value: number }, number> = { }, }; -export const mathRound: TaskDefinition<{ value: number }, number> = { +export const mathRound: ConcreteTaskDefinition<{ value: number }, number> = { ...taskSchema("math.round"), sideEffects: false, async execute(input) { @@ -858,7 +858,7 @@ export const mathRound: TaskDefinition<{ value: number }, number> = { }, }; -export const mathCeil: TaskDefinition<{ value: number }, number> = { +export const mathCeil: ConcreteTaskDefinition<{ value: number }, number> = { ...taskSchema("math.ceil"), sideEffects: false, async execute(input) { @@ -868,7 +868,7 @@ export const mathCeil: TaskDefinition<{ value: number }, number> = { // ---- noop (merge/join point for branches) ---- -export const noop: TaskDefinition< +export const noop: ConcreteTaskDefinition< Record, Record > = { @@ -881,7 +881,7 @@ export const noop: TaskDefinition< // ---- identity (pass-through for literal values in branches) ---- -export const identity: TaskDefinition<{ value: unknown }, unknown> = { +export const identity: ConcreteTaskDefinition<{ value: unknown }, unknown> = { ...taskSchema("identity"), sideEffects: false, async execute(input) { @@ -891,7 +891,7 @@ export const identity: TaskDefinition<{ value: unknown }, unknown> = { // ---- error tasks ---- -export const errorFail: TaskDefinition<{ message: unknown }, never> = { +export const errorFail: ConcreteTaskDefinition<{ message: unknown }, never> = { ...taskSchema("error.fail"), sideEffects: false, async execute(input) { From cacd4bc6307983b58005d693a1fdca978d3969a8 Mon Sep 17 00:00:00 2001 From: Daniel Lehenbauer Date: Fri, 29 May 2026 16:33:04 -0700 Subject: [PATCH 5/8] docs(workflow-engine): note why math.divide and floor/round/ceil are concrete Add per-task one-line comments in builtinTasks.ts and builtinTaskSchemas.ts explaining why these are NOT generic over N like their arithmetic siblings (divide: int/int can yield non-int; floor/round/ceil: output is always integer regardless of input). --- ts/examples/workflow/engine/src/builtinTaskSchemas.ts | 7 ++++--- ts/examples/workflow/engine/src/builtinTasks.ts | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/ts/examples/workflow/engine/src/builtinTaskSchemas.ts b/ts/examples/workflow/engine/src/builtinTaskSchemas.ts index c63287624..aff9caba4 100644 --- a/ts/examples/workflow/engine/src/builtinTaskSchemas.ts +++ b/ts/examples/workflow/engine/src/builtinTaskSchemas.ts @@ -200,9 +200,7 @@ export const BUILTIN_TASK_SCHEMAS: readonly BuiltinTaskSchema[] = [ outputSchema: { $typeParam: "N" }, }, { - // Division is intentionally NOT generic: integer / integer - // can produce a non-integer (e.g. 1 / 2 = 0.5), so the - // output cannot be narrowed to integer from integer operands. + // Not generic: integer / integer can yield non-integer (1 / 2 = 0.5). name: "math.divide", inputSchema: { type: "object", @@ -235,6 +233,7 @@ export const BUILTIN_TASK_SCHEMAS: readonly BuiltinTaskSchema[] = [ outputSchema: { $typeParam: "N" }, }, { + // Not generic: output is always integer, regardless of input subtype. name: "math.floor", inputSchema: { type: "object", @@ -244,6 +243,7 @@ export const BUILTIN_TASK_SCHEMAS: readonly BuiltinTaskSchema[] = [ outputSchema: { type: "integer" }, }, { + // Not generic: output is always integer, regardless of input subtype. name: "math.round", inputSchema: { type: "object", @@ -253,6 +253,7 @@ export const BUILTIN_TASK_SCHEMAS: readonly BuiltinTaskSchema[] = [ outputSchema: { type: "integer" }, }, { + // Not generic: output is always integer, regardless of input subtype. name: "math.ceil", inputSchema: { type: "object", diff --git a/ts/examples/workflow/engine/src/builtinTasks.ts b/ts/examples/workflow/engine/src/builtinTasks.ts index dcb252896..f6cad334b 100644 --- a/ts/examples/workflow/engine/src/builtinTasks.ts +++ b/ts/examples/workflow/engine/src/builtinTasks.ts @@ -812,6 +812,7 @@ export const mathMultiply: GenericTaskDefinition< }, }; +// Not generic: integer / integer can yield non-integer (1 / 2 = 0.5). export const mathDivide: ConcreteTaskDefinition< { left: number; right: number }, number @@ -842,6 +843,7 @@ export const mathNegate: GenericTaskDefinition<{ value: number }, number> = { }, }; +// Not generic: output is always integer, regardless of input subtype. export const mathFloor: ConcreteTaskDefinition<{ value: number }, number> = { ...taskSchema("math.floor"), sideEffects: false, @@ -850,6 +852,7 @@ export const mathFloor: ConcreteTaskDefinition<{ value: number }, number> = { }, }; +// Not generic: output is always integer, regardless of input subtype. export const mathRound: ConcreteTaskDefinition<{ value: number }, number> = { ...taskSchema("math.round"), sideEffects: false, @@ -858,6 +861,7 @@ export const mathRound: ConcreteTaskDefinition<{ value: number }, number> = { }, }; +// Not generic: output is always integer, regardless of input subtype. export const mathCeil: ConcreteTaskDefinition<{ value: number }, number> = { ...taskSchema("math.ceil"), sideEffects: false, From 4f9a4ae376fff7e32b0e900cd84a514dab65fc8f Mon Sep 17 00:00:00 2001 From: Daniel Lehenbauer Date: Mon, 1 Jun 2026 09:32:53 -0700 Subject: [PATCH 6/8] Fix prettier formatting in d10-conventional-commit.json Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/ir/d10-conventional-commit.json | 198 ++++-------------- 1 file changed, 42 insertions(+), 156 deletions(-) diff --git a/ts/examples/workflow/workflows/ir/d10-conventional-commit.json b/ts/examples/workflow/workflows/ir/d10-conventional-commit.json index e4b4e7279..9f888522e 100644 --- a/ts/examples/workflow/workflows/ir/d10-conventional-commit.json +++ b/ts/examples/workflow/workflows/ir/d10-conventional-commit.json @@ -39,9 +39,7 @@ "d10-conventional-commit": { "inputSchema": { "type": "object", - "required": [ - "repoPath" - ], + "required": ["repoPath"], "properties": { "repoPath": { "type": "string", @@ -88,9 +86,7 @@ "task": "shell.exec", "inputSchema": { "type": "object", - "required": [ - "command" - ], + "required": ["command"], "properties": { "command": { "type": "string" @@ -108,11 +104,7 @@ }, "outputSchema": { "type": "object", - "required": [ - "stdout", - "stderr", - "exitCode" - ], + "required": ["stdout", "stderr", "exitCode"], "properties": { "stdout": { "type": "string" @@ -127,11 +119,7 @@ }, "inputs": { "command": "git", - "args": [ - "diff", - "--staged", - "--name-only" - ], + "args": ["diff", "--staged", "--name-only"], "cwd": { "$from": "input", "name": "repoPath" @@ -145,10 +133,7 @@ "task": "string.split", "inputSchema": { "type": "object", - "required": [ - "text", - "delimiter" - ], + "required": ["text", "delimiter"], "properties": { "text": { "type": "string" @@ -168,9 +153,7 @@ "text": { "$from": "scope", "name": "fileListResult", - "path": [ - "stdout" - ] + "path": ["stdout"] }, "delimiter": "\n" }, @@ -218,10 +201,7 @@ "task": "list.elementAt", "inputSchema": { "type": "object", - "required": [ - "list", - "index" - ], + "required": ["list", "index"], "properties": { "list": { "type": "array" @@ -252,9 +232,7 @@ "task": "shell.exec", "inputSchema": { "type": "object", - "required": [ - "command" - ], + "required": ["command"], "properties": { "command": { "type": "string" @@ -272,11 +250,7 @@ }, "outputSchema": { "type": "object", - "required": [ - "stdout", - "stderr", - "exitCode" - ], + "required": ["stdout", "stderr", "exitCode"], "properties": { "stdout": { "type": "string" @@ -313,10 +287,7 @@ "task": "text.template", "inputSchema": { "type": "object", - "required": [ - "template", - "vars" - ], + "required": ["template", "vars"], "properties": { "template": { "type": "string" @@ -342,9 +313,7 @@ "diff": { "$from": "scope", "name": "fileDiff", - "path": [ - "stdout" - ] + "path": ["stdout"] } } }, @@ -356,9 +325,7 @@ "task": "copilot.invoke", "inputSchema": { "type": "object", - "required": [ - "prompt" - ], + "required": ["prompt"], "properties": { "prompt": { "type": "string" @@ -376,11 +343,7 @@ }, "outputSchema": { "type": "object", - "required": [ - "type", - "scope", - "summary" - ], + "required": ["type", "scope", "summary"], "properties": { "type": { "type": "string", @@ -424,10 +387,7 @@ "task": "text.template", "inputSchema": { "type": "object", - "required": [ - "template", - "vars" - ], + "required": ["template", "vars"], "properties": { "template": { "type": "string" @@ -446,16 +406,12 @@ "type": { "$from": "scope", "name": "categorization", - "path": [ - "type" - ] + "path": ["type"] }, "scope": { "$from": "scope", "name": "categorization", - "path": [ - "scope" - ] + "path": ["scope"] }, "file": { "$from": "scope", @@ -464,9 +420,7 @@ "summary": { "$from": "scope", "name": "categorization", - "path": [ - "summary" - ] + "path": ["summary"] } } }, @@ -478,10 +432,7 @@ "task": "list.append", "inputSchema": { "type": "object", - "required": [ - "list", - "item" - ], + "required": ["list", "item"], "properties": { "list": { "type": "array" @@ -513,10 +464,7 @@ "task": "math.add", "inputSchema": { "type": "object", - "required": [ - "left", - "right" - ], + "required": ["left", "right"], "properties": { "left": { "type": "number" @@ -544,9 +492,7 @@ "task": "list.length", "inputSchema": { "type": "object", - "required": [ - "list" - ], + "required": ["list"], "properties": { "list": { "type": "array" @@ -570,10 +516,7 @@ "task": "compare.lessThan", "inputSchema": { "type": "object", - "required": [ - "left", - "right" - ], + "required": ["left", "right"], "properties": { "left": { "type": "number" @@ -601,11 +544,7 @@ }, "inputSchema": { "type": "object", - "required": [ - "files", - "repoPath", - "categorizePromptTemplate" - ], + "required": ["files", "repoPath", "categorizePromptTemplate"], "properties": { "files": { "type": "array", @@ -655,10 +594,7 @@ "task": "string.join", "inputSchema": { "type": "object", - "required": [ - "list", - "delimiter" - ], + "required": ["list", "delimiter"], "properties": { "list": { "type": "array", @@ -689,10 +625,7 @@ "task": "text.template", "inputSchema": { "type": "object", - "required": [ - "template", - "vars" - ], + "required": ["template", "vars"], "properties": { "template": { "type": "string" @@ -725,9 +658,7 @@ "task": "copilot.invoke", "inputSchema": { "type": "object", - "required": [ - "prompt" - ], + "required": ["prompt"], "properties": { "prompt": { "type": "string" @@ -745,12 +676,7 @@ }, "outputSchema": { "type": "object", - "required": [ - "type", - "scope", - "subject", - "body" - ], + "required": ["type", "scope", "subject", "body"], "properties": { "type": { "type": "string", @@ -798,9 +724,7 @@ "selector": { "$from": "scope", "name": "synthesis", - "path": [ - "scope" - ] + "path": ["scope"] }, "selectorSchema": { "type": "string" @@ -811,16 +735,12 @@ "type": { "$from": "scope", "name": "synthesis", - "path": [ - "type" - ] + "path": ["type"] }, "subject": { "$from": "scope", "name": "synthesis", - "path": [ - "subject" - ] + "path": ["subject"] }, "tmpl": { "$from": "constant", @@ -830,11 +750,7 @@ "scope": { "inputSchema": { "type": "object", - "required": [ - "type", - "subject", - "tmpl" - ], + "required": ["type", "subject", "tmpl"], "properties": { "tmpl": { "type": "string" @@ -854,10 +770,7 @@ "task": "text.template", "inputSchema": { "type": "object", - "required": [ - "template", - "vars" - ], + "required": ["template", "vars"], "properties": { "template": { "type": "string" @@ -904,16 +817,12 @@ "type": { "$from": "scope", "name": "synthesis", - "path": [ - "type" - ] + "path": ["type"] }, "subject": { "$from": "scope", "name": "synthesis", - "path": [ - "subject" - ] + "path": ["subject"] }, "tmpl": { "$from": "constant", @@ -922,20 +831,13 @@ "scope": { "$from": "scope", "name": "synthesis", - "path": [ - "scope" - ] + "path": ["scope"] } }, "scope": { "inputSchema": { "type": "object", - "required": [ - "type", - "scope", - "subject", - "tmpl" - ], + "required": ["type", "scope", "subject", "tmpl"], "properties": { "tmpl": { "type": "string" @@ -958,10 +860,7 @@ "task": "text.template", "inputSchema": { "type": "object", - "required": [ - "template", - "vars" - ], + "required": ["template", "vars"], "properties": { "template": { "type": "string" @@ -1017,10 +916,7 @@ "task": "text.template", "inputSchema": { "type": "object", - "required": [ - "template", - "vars" - ], + "required": ["template", "vars"], "properties": { "template": { "type": "string" @@ -1046,9 +942,7 @@ "body": { "$from": "scope", "name": "synthesis", - "path": [ - "body" - ] + "path": ["body"] }, "bullets": { "$from": "scope", @@ -1067,30 +961,22 @@ "type": { "$from": "scope", "name": "synthesis", - "path": [ - "type" - ] + "path": ["type"] }, "scope": { "$from": "scope", "name": "synthesis", - "path": [ - "scope" - ] + "path": ["scope"] }, "subject": { "$from": "scope", "name": "synthesis", - "path": [ - "subject" - ] + "path": ["subject"] }, "body": { "$from": "scope", "name": "synthesis", - "path": [ - "body" - ] + "path": ["body"] }, "fileBullets": { "$from": "scope", From 63254420c9417a14fd022b5cbe34fbfbbf56464b Mon Sep 17 00:00:00 2001 From: Daniel Lehenbauer Date: Mon, 1 Jun 2026 17:00:10 -0700 Subject: [PATCH 7/8] Revert tighter builtin task type annotations; cast where consumed Revert 88e231e4f. The TaskDefinition<...> union (concrete | generic) is sufficient for builtin task declarations. Only one downstream consumer (copilotInvoke.spec.ts) needed to read .inputSchema directly, so add a narrow ConcreteTaskDefinition cast there instead of widening the public declaration types of every builtin. Keeps the explanatory comments added in cacd4bc63 about which builtins are intentionally concrete despite their arithmetic siblings being generic (math.divide, math.floor/round/ceil). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflow/engine/src/builtinTasks.ts | 62 +++++++++---------- .../engine/test/copilotInvoke.spec.ts | 6 +- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/ts/examples/workflow/engine/src/builtinTasks.ts b/ts/examples/workflow/engine/src/builtinTasks.ts index f6cad334b..ea06ec706 100644 --- a/ts/examples/workflow/engine/src/builtinTasks.ts +++ b/ts/examples/workflow/engine/src/builtinTasks.ts @@ -127,7 +127,7 @@ function sealObjects(schema: JSONSchema): JSONSchema { return copy; } -export const listLength: ConcreteTaskDefinition<{ list: unknown[] }, number> = { +export const listLength: TaskDefinition<{ list: unknown[] }, number> = { ...taskSchema("list.length"), sideEffects: false, async execute(input) { @@ -135,7 +135,7 @@ export const listLength: ConcreteTaskDefinition<{ list: unknown[] }, number> = { }, }; -export const listElementAt: GenericTaskDefinition< +export const listElementAt: TaskDefinition< { list: unknown[]; index: number }, unknown > = { @@ -154,7 +154,7 @@ export const listElementAt: GenericTaskDefinition< }, }; -export const listAppend: ConcreteTaskDefinition< +export const listAppend: TaskDefinition< { list: unknown[]; item: unknown }, unknown[] > = { @@ -165,7 +165,7 @@ export const listAppend: ConcreteTaskDefinition< }, }; -export const boolToLabel: ConcreteTaskDefinition< +export const boolToLabel: TaskDefinition< { value: boolean; ifTrue: string; ifFalse: string }, string > = { @@ -181,7 +181,7 @@ export const boolToLabel: ConcreteTaskDefinition< // ---- IO tasks ---- -export const shellExec: ConcreteTaskDefinition< +export const shellExec: TaskDefinition< { command: string; args?: string[]; @@ -256,7 +256,7 @@ export const shellExec: ConcreteTaskDefinition< }, }; -export const llmGenerate: ConcreteTaskDefinition< +export const llmGenerate: TaskDefinition< { prompt: string; endpoint?: string }, string > = { @@ -356,7 +356,7 @@ export const llmGenerateJson: GenericTaskDefinition< * Requests to `allowedTools` are `approveAll` for v1. Capability-based * security model is the longer-term follow-up (decision 0010 §7). */ -export const copilotInvoke: ConcreteTaskDefinition< +export const copilotInvoke: TaskDefinition< { prompt: string; model?: string; @@ -457,7 +457,7 @@ export const copilotInvoke: ConcreteTaskDefinition< // ---- Utility tasks ---- -export const textTemplate: ConcreteTaskDefinition< +export const textTemplate: TaskDefinition< { template: string; vars: Record }, string > = { @@ -472,7 +472,7 @@ export const textTemplate: ConcreteTaskDefinition< }, }; -export const stringJoin: ConcreteTaskDefinition< +export const stringJoin: TaskDefinition< { list: string[]; delimiter: string }, string > = { @@ -483,7 +483,7 @@ export const stringJoin: ConcreteTaskDefinition< }, }; -export const stringSplit: ConcreteTaskDefinition< +export const stringSplit: TaskDefinition< { text: string; delimiter: string; keepEmpty?: boolean }, string[] > = { @@ -498,7 +498,7 @@ export const stringSplit: ConcreteTaskDefinition< }, }; -export const httpGet: ConcreteTaskDefinition< +export const httpGet: TaskDefinition< { url: string; headers?: Record; @@ -645,7 +645,7 @@ function validateFilePath(filePath: string): string { const DEFAULT_MAX_FILE_READ_BYTES = 10 * 1024 * 1024; // 10 MB -export const fileRead: ConcreteTaskDefinition< +export const fileRead: TaskDefinition< { path: string; maxBytes?: number }, string > = { @@ -676,7 +676,7 @@ export const fileRead: ConcreteTaskDefinition< }, }; -export const fileWrite: ConcreteTaskDefinition< +export const fileWrite: TaskDefinition< { path: string; content: string }, string > = { @@ -701,7 +701,7 @@ export const fileWrite: ConcreteTaskDefinition< // ---- compare tasks ---- -export const compareEquals: ConcreteTaskDefinition< +export const compareEquals: TaskDefinition< { left: unknown; right: unknown }, boolean > = { @@ -712,7 +712,7 @@ export const compareEquals: ConcreteTaskDefinition< }, }; -export const compareNotEquals: ConcreteTaskDefinition< +export const compareNotEquals: TaskDefinition< { left: unknown; right: unknown }, boolean > = { @@ -723,7 +723,7 @@ export const compareNotEquals: ConcreteTaskDefinition< }, }; -export const compareGreaterThan: ConcreteTaskDefinition< +export const compareGreaterThan: TaskDefinition< { left: number; right: number }, boolean > = { @@ -734,7 +734,7 @@ export const compareGreaterThan: ConcreteTaskDefinition< }, }; -export const compareLessThan: ConcreteTaskDefinition< +export const compareLessThan: TaskDefinition< { left: number; right: number }, boolean > = { @@ -745,7 +745,7 @@ export const compareLessThan: ConcreteTaskDefinition< }, }; -export const compareGreaterOrEqual: ConcreteTaskDefinition< +export const compareGreaterOrEqual: TaskDefinition< { left: number; right: number }, boolean > = { @@ -756,7 +756,7 @@ export const compareGreaterOrEqual: ConcreteTaskDefinition< }, }; -export const compareLessOrEqual: ConcreteTaskDefinition< +export const compareLessOrEqual: TaskDefinition< { left: number; right: number }, boolean > = { @@ -769,7 +769,7 @@ export const compareLessOrEqual: ConcreteTaskDefinition< // ---- bool tasks ---- -export const boolNot: ConcreteTaskDefinition<{ value: boolean }, boolean> = { +export const boolNot: TaskDefinition<{ value: boolean }, boolean> = { ...taskSchema("bool.not"), sideEffects: false, async execute(input) { @@ -790,7 +790,7 @@ export const mathAdd: GenericTaskDefinition< }, }; -export const mathSubtract: GenericTaskDefinition< +export const mathSubtract: TaskDefinition< { left: number; right: number }, number > = { @@ -801,7 +801,7 @@ export const mathSubtract: GenericTaskDefinition< }, }; -export const mathMultiply: GenericTaskDefinition< +export const mathMultiply: TaskDefinition< { left: number; right: number }, number > = { @@ -813,7 +813,7 @@ export const mathMultiply: GenericTaskDefinition< }; // Not generic: integer / integer can yield non-integer (1 / 2 = 0.5). -export const mathDivide: ConcreteTaskDefinition< +export const mathDivide: TaskDefinition< { left: number; right: number }, number > = { @@ -824,7 +824,7 @@ export const mathDivide: ConcreteTaskDefinition< }, }; -export const mathModulo: GenericTaskDefinition< +export const mathModulo: TaskDefinition< { left: number; right: number }, number > = { @@ -835,7 +835,7 @@ export const mathModulo: GenericTaskDefinition< }, }; -export const mathNegate: GenericTaskDefinition<{ value: number }, number> = { +export const mathNegate: TaskDefinition<{ value: number }, number> = { ...genericTaskSchema("math.negate"), sideEffects: false, async execute(input) { @@ -844,7 +844,7 @@ export const mathNegate: GenericTaskDefinition<{ value: number }, number> = { }; // Not generic: output is always integer, regardless of input subtype. -export const mathFloor: ConcreteTaskDefinition<{ value: number }, number> = { +export const mathFloor: TaskDefinition<{ value: number }, number> = { ...taskSchema("math.floor"), sideEffects: false, async execute(input) { @@ -853,7 +853,7 @@ export const mathFloor: ConcreteTaskDefinition<{ value: number }, number> = { }; // Not generic: output is always integer, regardless of input subtype. -export const mathRound: ConcreteTaskDefinition<{ value: number }, number> = { +export const mathRound: TaskDefinition<{ value: number }, number> = { ...taskSchema("math.round"), sideEffects: false, async execute(input) { @@ -862,7 +862,7 @@ export const mathRound: ConcreteTaskDefinition<{ value: number }, number> = { }; // Not generic: output is always integer, regardless of input subtype. -export const mathCeil: ConcreteTaskDefinition<{ value: number }, number> = { +export const mathCeil: TaskDefinition<{ value: number }, number> = { ...taskSchema("math.ceil"), sideEffects: false, async execute(input) { @@ -872,7 +872,7 @@ export const mathCeil: ConcreteTaskDefinition<{ value: number }, number> = { // ---- noop (merge/join point for branches) ---- -export const noop: ConcreteTaskDefinition< +export const noop: TaskDefinition< Record, Record > = { @@ -885,7 +885,7 @@ export const noop: ConcreteTaskDefinition< // ---- identity (pass-through for literal values in branches) ---- -export const identity: ConcreteTaskDefinition<{ value: unknown }, unknown> = { +export const identity: TaskDefinition<{ value: unknown }, unknown> = { ...taskSchema("identity"), sideEffects: false, async execute(input) { @@ -895,7 +895,7 @@ export const identity: ConcreteTaskDefinition<{ value: unknown }, unknown> = { // ---- error tasks ---- -export const errorFail: ConcreteTaskDefinition<{ message: unknown }, never> = { +export const errorFail: TaskDefinition<{ message: unknown }, never> = { ...taskSchema("error.fail"), sideEffects: false, async execute(input) { diff --git a/ts/examples/workflow/engine/test/copilotInvoke.spec.ts b/ts/examples/workflow/engine/test/copilotInvoke.spec.ts index 8775212bb..db4c708ac 100644 --- a/ts/examples/workflow/engine/test/copilotInvoke.spec.ts +++ b/ts/examples/workflow/engine/test/copilotInvoke.spec.ts @@ -19,7 +19,7 @@ import { type MinimalCopilotClient, type MinimalCopilotSession, } from "../src/index.js"; -import { TaskPolicy, WorkflowIR } from "workflow-model"; +import { TaskPolicy, WorkflowIR, ConcreteTaskDefinition } from "workflow-model"; import type { MessageOptions, SessionConfig, Tool } from "@github/copilot-sdk"; // Allow-all policy for tests. @@ -141,7 +141,9 @@ describe("copilot.invoke (decision 0010)", () => { step: { kind: "task", task: "copilot.invoke", - inputSchema: copilotInvoke.inputSchema, + inputSchema: ( + copilotInvoke as ConcreteTaskDefinition + ).inputSchema, outputSchema: opts.outputSchema, inputs: opts.inputs as any, bind: "result", From af9f672bb69de6497d3f83aa2076b29d4c0c29c9 Mon Sep 17 00:00:00 2001 From: Daniel Lehenbauer Date: Tue, 2 Jun 2026 19:35:07 +0000 Subject: [PATCH 8/8] docs(workflow-engine): preserve original math.divide comment Restore the pre-existing 3-line explanation above math.divide in builtinTaskSchemas.ts that was inadvertently truncated in cacd4bc6. The newly added per-task comments on math.floor/round/ceil and on mathDivide in builtinTasks.ts are kept as-is. --- ts/examples/workflow/engine/src/builtinTaskSchemas.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ts/examples/workflow/engine/src/builtinTaskSchemas.ts b/ts/examples/workflow/engine/src/builtinTaskSchemas.ts index aff9caba4..33887ff7f 100644 --- a/ts/examples/workflow/engine/src/builtinTaskSchemas.ts +++ b/ts/examples/workflow/engine/src/builtinTaskSchemas.ts @@ -200,7 +200,9 @@ export const BUILTIN_TASK_SCHEMAS: readonly BuiltinTaskSchema[] = [ outputSchema: { $typeParam: "N" }, }, { - // Not generic: integer / integer can yield non-integer (1 / 2 = 0.5). + // Division is intentionally NOT generic: integer / integer + // can produce a non-integer (e.g. 1 / 2 = 0.5), so the + // output cannot be narrowed to integer from integer operands. name: "math.divide", inputSchema: { type: "object",