Skip to content
Merged
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
10 changes: 5 additions & 5 deletions .agents/skills/workflow-creator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Load the topic-specific reference files from `references/` based on priority. **
| **1** | `getting-started.md` | **Always** — quick-start examples for all 3 SDKs, SDK exports, `SessionContext` reference |
| **1** | `failure-modes.md` | **Always for multi-session workflows** — 15 catalogued failures (silent + loud) with wrong-vs-right patterns and a pre-ship design checklist |
| **1** | `workflow-inputs.md` | **Always when declaring structured inputs or documenting how a workflow is invoked** — `WorkflowInput` schema, field-type selection, picker + CLI flag semantics, builtin-protection rules, invocation cheat sheet |
| **2** | `agent-sessions.md` | When writing SDK calls — `s.session.query()` (Claude), `s.session.sendAndWait()` (Copilot), `s.client.session.prompt()` (OpenCode); includes critical pitfalls on timeouts and session lifecycle |
| **2** | `agent-sessions.md` | When writing SDK calls — `s.session.query()` (Claude), `s.session.send()` (Copilot), `s.client.session.prompt()` (OpenCode); includes critical pitfalls on session lifecycle and when to use `sendAndWait` with explicit timeouts |
| **2** | `control-flow.md` | When using loops, conditionals, parallel execution, or review/fix patterns |
| **2** | `state-and-data-flow.md` | When passing data between sessions — `s.save()`, `s.transcript()`, `s.getMessages()`, file persistence, transcript compression |
| **3** | `computation-and-validation.md` | When adding deterministic computation, response parsing, validation, quality gates, or file I/O |
Expand Down Expand Up @@ -160,7 +160,7 @@ Hard constraints enforced by the builder, loader, and runtime:
4. **Unique session names** — every `ctx.stage()` call must use a unique `name` across the workflow run.
5. **Completed-only reads** — `transcript()` and `getMessages()` only access sessions whose callback has returned and saves have flushed. Attempting to read a still-running session throws.
6. **Graph topology is auto-inferred** — the runtime derives parent-child edges from `await`/`Promise.all` patterns. Sequential `await` creates a chain; `Promise.all([...])` branches from the same parent; a stage after `Promise.all` receives all parallel stages as parents. See `references/control-flow.md` for full details.
7. **Do not manually create clients or sessions** — the runtime auto-creates `s.client` and `s.session` from `clientOpts` and `sessionOpts`. Use `s.session.query()`, `s.session.sendAndWait()`, and `s.client.session.prompt()` instead.
7. **Do not manually create clients or sessions** — the runtime auto-creates `s.client` and `s.session` from `clientOpts` and `sessionOpts`. Use `s.session.query()`, `s.session.send()`, and `s.client.session.prompt()` instead.

## Concept-to-Code Mapping

Expand Down Expand Up @@ -220,7 +220,7 @@ Pass a type parameter to `defineWorkflow<"agent">()` to narrow all context types
| Agent | Type Parameter | Primary Session API |
|-------|---------------|---------------------|
| Claude | `defineWorkflow<"claude">` | `s.session.query(prompt)` — sends prompt to the Claude TUI pane |
| Copilot | `defineWorkflow<"copilot">` | `s.session.sendAndWait({ prompt }, TIMEOUT_MS)` — **explicit timeout required** (default 60s throws) |
| Copilot | `defineWorkflow<"copilot">` | `s.session.send({ prompt })` — fire-and-forget; use `sendAndWait({ prompt }, timeoutMs)` only when the user explicitly requests timeout-based waiting |
| OpenCode | `defineWorkflow<"opencode">` | `s.client.session.prompt({ sessionID: s.session.id, parts: [...] })` |

The runtime manages client/session lifecycle automatically. For native SDK types and advanced APIs, import directly from the provider packages (`@github/copilot-sdk`, `@anthropic-ai/claude-agent-sdk`, `@opencode-ai/sdk/v2`).
Expand All @@ -246,9 +246,9 @@ Per-SDK cheat sheet:

| Concern | Claude | Copilot | OpenCode |
|---------|--------|---------|----------|
| Send prompt | `s.session.query(prompt)` | `s.session.sendAndWait({ prompt }, TIMEOUT)` | `s.client.session.prompt({ sessionID: s.session.id, parts: [{ type: "text", text: prompt }] })` |
| Send prompt | `s.session.query(prompt)` | `s.session.send({ prompt })` | `s.client.session.prompt({ sessionID: s.session.id, parts: [{ type: "text", text: prompt }] })` |
| Save output | `s.save(s.sessionId)` | `s.save(await s.session.getMessages())` | `s.save(result.data!)` |
| Timeout | Per-query defaults via sessionOpts | **Mandatory** 2nd arg (default 60s throws!) | N/A |
| Timeout | Per-query defaults via sessionOpts | N/A (`send` has no timeout; `sendAndWait` accepts optional timeout, default 60s) | N/A |
| Context model | Tmux pane (accumulates across turns) | Fresh per `ctx.stage()` | Fresh per `ctx.stage()` |
| Extract text | `result.output` (string) | `getAssistantText(messages)` (see `failure-modes.md` F1) | `extractResponseText(result.data!.parts)` (see `failure-modes.md` F3) |

Expand Down
87 changes: 32 additions & 55 deletions .agents/skills/workflow-creator/references/agent-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,6 @@ Copilot uses a client-server architecture. The runtime auto-creates a `CopilotCl
```ts
import { defineWorkflow } from "@bastani/atomic/workflows";

// Always pass an explicit timeout to sendAndWait — see the pitfall note below.
const SEND_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes

export default defineWorkflow<"copilot">({ name: "implement" })
.run(async (ctx) => {
await ctx.stage(
Expand All @@ -223,7 +220,7 @@ export default defineWorkflow<"copilot">({ name: "implement" })
// s.client — CopilotClient (already started by runtime)
// s.session — CopilotSession (already created, foreground session set)

await s.session.sendAndWait({ prompt: (s.inputs.prompt ?? "") }, SEND_TIMEOUT_MS);
await s.session.send({ prompt: (s.inputs.prompt ?? "") });

s.save(await s.session.getMessages());
},
Expand All @@ -232,47 +229,32 @@ export default defineWorkflow<"copilot">({ name: "implement" })
.compile();
```

### Critical pitfall: `sendAndWait` has a 60-second default timeout

`session.sendAndWait(options, timeout?)` accepts an optional second `timeout`
parameter that **defaults to 60000 ms**. When the timeout elapses it **throws**
`Timeout after 60000ms waiting for session.idle` — it does NOT abort the
in-flight agent, and it does NOT silently return. The throw propagates out of
the session callback, so:

1. The current stage fails.
2. Every subsequent session step never executes (e.g. a `planner → orchestrator → reviewer` pipeline stops dead after the planner).
3. The agent may still be churning in the background.
### `send` vs `sendAndWait`: choosing the right method

This is deadly for real work — planner, reviewer, and orchestrator sub-agents
routinely need more than 60 seconds of wall-clock time. Source:
`@github/copilot-sdk/dist/session.js` — the `sendAndWait` implementation races
the idle promise against `setTimeout(..., timeout ?? 6e4)`.

**Always pass an explicit, generous timeout** when calling `sendAndWait` inside
a workflow. Define it as a named constant so it's obvious and tunable:
**Default to `send`** for all Copilot workflow stages. `send` dispatches the
prompt and returns the `messageId` immediately — no timeout to guess, no
constants to tune, no risk of aborting a long-running agent mid-work.

```ts
// Buggy — silently inherits the 60s default and crashes long stages
await session.sendAndWait({ prompt });

// Correct — explicit 30-minute budget
const SEND_TIMEOUT_MS = 30 * 60 * 1000;
await session.sendAndWait({ prompt }, SEND_TIMEOUT_MS);
// Default pattern — clean, no timeout guessing
await s.session.send({ prompt });
```

Pick a timeout that fits the expected work:
**Use `sendAndWait` only when the user explicitly requests timeout-based
waiting.** `sendAndWait` blocks until the session emits `session.idle` or the
timeout fires. If you must use it, ask the user for a reasonable timeout. If
they aren't sure, default to **5 minutes** (300 000 ms).

| Session type | Suggested timeout |
|---|---|
| Short, bounded prompts (summaries, classification) | 5-10 minutes |
| Sub-agents doing file-wide analysis (planner, reviewer) | 30 minutes |
| Long implementation or multi-file refactors | 60 minutes |
```ts
// Only when the user explicitly wants timeout-gated waiting
const SEND_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes default; ask the user
await s.session.sendAndWait({ prompt }, SEND_TIMEOUT_MS);
```

The timeout controls how long the SDK waits for `session.idle`; it does not
cap the agent itself. Err on the generous side — a truly hung session will
still surface as a clear error message rather than silently breaking
downstream stages.
**Why not `sendAndWait` by default?** The 60-second default timeout is almost
never correct for real agent work — planners, reviewers, and orchestrators
routinely exceed it. Guessing large timeouts (30-60 min) adds messy constants
and still risks aborting work prematurely. `send` avoids the problem entirely.

### Critical pitfall: session lifecycle controls what context is available

Expand All @@ -293,7 +275,7 @@ state determines what context the model sees on its next turn:
| State | How you get there | Context available | Action needed |
|---|---|---|---|
| **Fresh** | `client.createSession(...)` | **None** — empty conversation | You MUST inject everything the agent needs in the first prompt |
| **Continued** | Same session, additional `sendAndWait` calls | All prior turns in this session | Nothing — but watch total token usage |
| **Continued** | Same session, additional `send` calls | All prior turns in this session | Nothing — but watch total token usage |
| **Resumed** | `client.resumeSession(sessionId)` | All persisted turns from the prior session of the SAME agent | Nothing — full history is reattached |
| **Closed** | `session.disconnect()` or `client.stop()` | **Gone** from the live client; persisted on disk if the host enables it | Either resume by ID (same agent) or start fresh and re-inject context |

Expand Down Expand Up @@ -328,7 +310,7 @@ Simplest and most common fix:
```ts
async function runAgent(agent: string, prompt: string): Promise<string> {
const session = await client.createSession({ agent, onPermissionRequest: approveAll });
await session.sendAndWait({ prompt }, SEND_TIMEOUT_MS);
await session.send({ prompt });
const messages = await session.getMessages();
await session.disconnect();
return getAssistantText(messages); // concatenate every top-level turn — see failure-modes.md §F1
Expand All @@ -354,8 +336,8 @@ history across related steps.

```ts
// Same stage, multi-turn — full history stays attached
await s.session.sendAndWait({ prompt: "Plan the implementation." }, SEND_TIMEOUT_MS);
await s.session.sendAndWait({ prompt: "Follow up on the plan above." }, SEND_TIMEOUT_MS);
await s.session.send({ prompt: "Plan the implementation." });
await s.session.send({ prompt: "Follow up on the plan above." });
```

If you deliberately drop down to provider-specific resume/fork APIs, keep them
Expand Down Expand Up @@ -407,20 +389,17 @@ them first.**

### Multi-turn conversations

Send multiple prompts to the same session. Remember: every `sendAndWait` call
needs its own explicit timeout.
Send multiple prompts to the same session:

```ts
const SEND_TIMEOUT_MS = 30 * 60 * 1000;

.run(async (ctx) => {
await ctx.stage({ name: "implement" }, {}, {}, async (s) => {
// Turn 1
await s.session.sendAndWait({ prompt: "Plan the implementation." }, SEND_TIMEOUT_MS);
await s.session.send({ prompt: "Plan the implementation." });
// Turn 2
await s.session.sendAndWait({ prompt: "Now implement the plan." }, SEND_TIMEOUT_MS);
await s.session.send({ prompt: "Now implement the plan." });
// Turn 3
await s.session.sendAndWait({ prompt: "Run the tests." }, SEND_TIMEOUT_MS);
await s.session.send({ prompt: "Run the tests." });

s.save(await s.session.getMessages());
});
Expand All @@ -446,7 +425,7 @@ await ctx.stage(
},
}, // sessionOpts
async (s) => {
await s.session.sendAndWait({ prompt: (s.inputs.prompt ?? "") }, SEND_TIMEOUT_MS);
await s.session.send({ prompt: (s.inputs.prompt ?? "") });
s.save(await s.session.getMessages());
},
);
Expand All @@ -473,7 +452,7 @@ await ctx.stage(
{},
{ tools: [myTool] },
async (s) => {
await s.session.sendAndWait({ prompt: (s.inputs.prompt ?? "") }, SEND_TIMEOUT_MS);
await s.session.send({ prompt: (s.inputs.prompt ?? "") });
s.save(await s.session.getMessages());
},
);
Expand Down Expand Up @@ -524,15 +503,13 @@ s.session.on("assistant.reasoning_delta", (event) => {
Pass the `agent` parameter in `sessionOpts` (3rd arg to `ctx.stage()`) to bind the session to a named sub-agent:

```ts
const SEND_TIMEOUT_MS = 30 * 60 * 1000; // planner can take a while

.run(async (ctx) => {
await ctx.stage(
{ name: "plan" },
{},
{ agent: "planner" }, // sessionOpts — binds the session to the "planner" agent
async (s) => {
await s.session.sendAndWait({ prompt: (s.inputs.prompt ?? "") }, SEND_TIMEOUT_MS);
await s.session.send({ prompt: (s.inputs.prompt ?? "") });
s.save(await s.session.getMessages());
},
);
Expand Down Expand Up @@ -590,7 +567,7 @@ substitute the OpenCode API equivalents:
| Concept | Copilot API | OpenCode API |
|---|---|---|
| Fresh session (auto-created) | `s.session` (runtime creates via `createSession`) | `s.session` (runtime creates via `session.create`) |
| Send a turn | `s.session.sendAndWait({ prompt }, timeout)` | `s.client.session.prompt({ sessionID: s.session.id, parts })` |
| Send a turn | `s.session.send({ prompt })` | `s.client.session.prompt({ sessionID: s.session.id, parts })` |
| Close / disconnect | Auto-handled by runtime | session lifecycle managed via server; no explicit disconnect in typical flow |
| Continue prior conversation | `s.client.resumeSession(sessionId)` (provider API; advanced) | Reuse the same `sessionID` with `s.client.session.prompt()` inside the same logical conversation. `ctx.stage()` itself still creates a fresh session every time |
| Extract final text | `getAssistantText(messages)` (see `failure-modes.md` §F1) | `extractResponseText(result.data!.parts)` |
Expand Down
22 changes: 6 additions & 16 deletions .agents/skills/workflow-creator/references/control-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,25 +166,16 @@ The inter-session pattern is the right fit here: every review and every fix beco

### Same pattern with Copilot

Loops amplify the `sendAndWait` 60-second timeout pitfall — any iteration
whose agent response takes longer than the default throws and kills the
entire surrounding session. Always pass an explicit timeout. See the
"Critical pitfall" section in `agent-sessions.md`.

```ts
// Explicit per-call timeout — see agent-sessions.md pitfall note.
const SEND_TIMEOUT_MS = 30 * 60 * 1000;

.run(async (ctx) => {
const MAX_CYCLES = 10;
let consecutiveClean = 0;

for (let cycle = 1; cycle <= MAX_CYCLES; cycle++) {
const review = await ctx.stage({ name: `review-${cycle}` }, {}, {}, async (s) => {
await s.session.sendAndWait(
{ prompt: buildReviewPrompt((ctx.inputs.prompt ?? "")) },
SEND_TIMEOUT_MS,
);
await s.session.send({
prompt: buildReviewPrompt((ctx.inputs.prompt ?? "")),
});
const reviewRaw = getAssistantText(await s.session.getMessages()); // see failure-modes.md §F1

s.save(await s.session.getMessages());
Expand All @@ -206,10 +197,9 @@ const SEND_TIMEOUT_MS = 30 * 60 * 1000;
: buildFixSpecFromRawReview(reviewRaw, (ctx.inputs.prompt ?? ""));

await ctx.stage({ name: `fix-${cycle}` }, {}, {}, async (s) => {
await s.session.sendAndWait(
{ prompt: fixPrompt || "Fix remaining issues." },
SEND_TIMEOUT_MS,
);
await s.session.send({
prompt: fixPrompt || "Fix remaining issues.",
});

s.save(await s.session.getMessages());
});
Expand Down
20 changes: 12 additions & 8 deletions .agents/skills/workflow-creator/references/failure-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Silent failures are catalogued first below. Loud failures are grouped at the end
| [F7](#f7-continued-sessions-accumulate-state-across-loop-iterations) | Continued sessions accumulate state across loop iterations (lost-in-middle) | all | silent |
| [F8](#f8-fenced-block-parsers-break-when-the-model-adds-prose) | Fenced-block parsers break when the model adds prose before/after | all | silent |
| [F9](#f9-ssave-receives-the-wrong-shape) | `s.save()` receives the wrong shape for the SDK | all | silent |
| [F10](#f10-copilot-sendandwait-default-60s-timeout-throws) | Copilot: `sendAndWait` default 60s timeout throws | Copilot | loud |
| [F10](#f10-copilot-sendandwait-default-60s-timeout-throws) | Copilot: `sendAndWait` default 60s timeout throws (use `send` by default) | Copilot | loud |
| [F11](#f11-manual-claude-session-initialization-resolved-by-runtime) | ~~Manual Claude session initialization~~ (resolved by runtime) | Claude | N/A |
| [F12](#f12-resume-session-tries-to-swap-agents) | Resume session tries to swap agents | Copilot, OpenCode | loud |
| [F13](#f13-parallel-siblings-read-each-others-transcripts) | Parallel siblings read each other's transcripts | all | loud |
Expand Down Expand Up @@ -457,7 +457,7 @@ expects, and the runtime doesn't type-check the argument beyond "anything".
// Claude — saves the wrong thing
s.save(result.output);

// Copilot — saves an empty array if called before sendAndWait
// Copilot — saves an empty array if called before send
s.save(await s.session.getMessages());
// Or saves one message object instead of the array
s.save((await s.session.getMessages()).at(-1));
Expand All @@ -484,13 +484,17 @@ log the length. A 0-length or JSON-that-isn't-prose signature = F9.
subsequent `ctx.stage()` call never executes — the throw propagates out of
`run()` and halts the workflow.

**Full write-up.** `agent-sessions.md` §"Critical pitfall: `sendAndWait` has
a 60-second default timeout".

**Fix.** Always pass an explicit timeout as the second argument.
**Fix.** Use `send` instead of `sendAndWait` — it has no timeout and avoids
the problem entirely. Only use `sendAndWait` when the user explicitly
requests timeout-based waiting, and always pass an explicit timeout (default
to 5 minutes if the user is unsure).

```ts
const SEND_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
// Default pattern — no timeout, no risk
await s.session.send({ prompt });

// Only when the user explicitly wants timeout-gated waiting
const SEND_TIMEOUT_MS = 5 * 60 * 1000; // ask the user for a value
await s.session.sendAndWait({ prompt }, SEND_TIMEOUT_MS);
```

Expand Down Expand Up @@ -662,7 +666,7 @@ accessing `.result` without awaiting, the type will be `Promise`, not `T`.

Before shipping a multi-session workflow, walk the list:

- [ ] Every `s.session.sendAndWait` call passes an explicit timeout (F10)
- [ ] Copilot stages use `s.session.send` by default; `sendAndWait` only with an explicit user-requested timeout (F10)
- [ ] Every fresh-session handoff forwards context explicitly (F5)
- [ ] Every prompt whose output feeds a downstream stage explicitly requests trailing commentary (F6)
- [ ] Response-text extraction uses the per-SDK correct pattern (F1-F4)
Expand Down
Loading
Loading