Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
354 changes: 354 additions & 0 deletions ts/docs/design/workflowSystem/ir/decisions/0010-copilot-task-family.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# TaskContext schema awareness (decision 0011)

Status: **Adopted (v1).** Engine API extension; **not** an IR change.
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:

- [../../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 `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 one field to `TaskContext`:

```typescript
export interface TaskContext {
runId: string;
nodeId: string;
scopePath: string[];
signal: AbortSignal;
constraints?: TaskConstraints;
/**
* 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 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 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
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. `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
field 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 (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
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 field from the node it is dispatching, so
existing tests continue to work without per-fixture changes.
- **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.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 `outputSchema`), §5.2 (engine-side runtime output schema validation that this decision does **not** change).
8 changes: 8 additions & 0 deletions ts/examples/workflow/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,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)}`,
);
}
Comment thread
DLehenbauer marked this conversation as resolved.

process.exit(1);
}
}
Expand Down
1 change: 1 addition & 0 deletions ts/examples/workflow/engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"tsc": "tsc -b"
},
"dependencies": {
"@github/copilot-sdk": "^0.3.0",
"aiclient": "workspace:*",
"ajv": "^8.17.1",
"debug": "^4.3.4",
Expand Down
3 changes: 3 additions & 0 deletions ts/examples/workflow/engine/src/builtinTaskSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,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",
Expand All @@ -244,6 +245,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",
Expand All @@ -253,6 +255,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",
Expand Down
119 changes: 119 additions & 0 deletions ts/examples/workflow/engine/src/builtinTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import {
} from "workflow-model";
import { isGenericBuiltinSchema } from "./builtinTaskSchemas.js";
import { openai } from "aiclient";
import type { CustomAgentConfig } from "@github/copilot-sdk";
import { BUILTIN_TASK_SCHEMAS } from "./builtinTaskSchemas.js";
import { invokeCopilotAgent } from "./copilotClientHost.js";

Comment thread
DLehenbauer marked this conversation as resolved.
const SCHEMA_BY_NAME = new Map(
BUILTIN_TASK_SCHEMAS.map((s) => [s.name, s] as const),
Expand Down Expand Up @@ -341,6 +343,118 @@ export const llmGenerateJson: GenericTaskDefinition<
},
};

/**
* 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.
Comment thread
DLehenbauer marked this conversation as resolved.
* - 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;
},
unknown
> = {
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.",
},
},
},
// `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.
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<
Expand Down Expand Up @@ -698,6 +812,7 @@ export const mathMultiply: TaskDefinition<
},
};

// Not generic: integer / integer can yield non-integer (1 / 2 = 0.5).
export const mathDivide: TaskDefinition<
{ left: number; right: number },
number
Expand Down Expand Up @@ -728,6 +843,7 @@ export const mathNegate: TaskDefinition<{ value: number }, number> = {
},
};

// Not generic: output is always integer, regardless of input subtype.
export const mathFloor: TaskDefinition<{ value: number }, number> = {
...taskSchema("math.floor"),
sideEffects: false,
Expand All @@ -736,6 +852,7 @@ export const mathFloor: TaskDefinition<{ value: number }, number> = {
},
};

// Not generic: output is always integer, regardless of input subtype.
export const mathRound: TaskDefinition<{ value: number }, number> = {
...taskSchema("math.round"),
sideEffects: false,
Expand All @@ -744,6 +861,7 @@ export const mathRound: TaskDefinition<{ value: number }, number> = {
},
};

// Not generic: output is always integer, regardless of input subtype.
export const mathCeil: TaskDefinition<{ value: number }, number> = {
...taskSchema("math.ceil"),
sideEffects: false,
Expand Down Expand Up @@ -827,6 +945,7 @@ export const allBuiltinTasks: TaskDefinition[] = [
shellExec,
llmGenerate,
llmGenerateJson,
copilotInvoke,
httpGet,
fileRead,
fileWrite,
Expand Down
Loading
Loading