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
129 changes: 129 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ const workflow = createAgentWorkflow(
tools: tools.definitions,
});

// Using single-agent (global) helper
await actions.handleModelResult(result);
actions.setLoading(false);
},
Expand Down Expand Up @@ -740,6 +741,69 @@ const workflow = createAgentWorkflow(
- **Debugging**: Add debugging information that doesn't appear in AI conversations
- **Integration**: Store data needed for external systems or APIs

### Per‑Agent Message Scoping (Multi‑Agent in One Workflow)

Keep control state global (loading, inputDisabled, statusLine, queue, taskList). Use lightweight agent handles to scope messages and transcripts per agent within a single workflow.

```ts
const planner = actions.createAgent("Planner"); // auto‑generates UUID id
console.log(planner.id); // uuid
console.log(planner.name); // "Planner"
planner.setName("PlanBot"); // updates display name (keeps same id)
planner.say("Starting plan…");

const planResult = await generateText({
model: openai("gpt-4o"),
system: "You are the planning agent.",
messages: planner.transcript(),
tools: tools.definitions,
});

// Assistant and tool messages are tagged to this agent only
await planner.handleModelResults(planResult);

// Another agent in the same workflow
const executor = actions.createAgent("Executor");
const execResult = await generateText({
model: openai("gpt-4o-mini"),
system: "You are the execution agent.",
messages: executor.transcript(),
tools: tools.definitions,
});
await executor.handleModelResults(execResult);
```

Notes:
- `actions.createAgent(name)` returns a handle with `id`, `name`, `setName`, `say`, `addMessage`, `transcript`, `handleModelResults`.
- Messages produced via these methods carry `metadata.agentId = id` and the UI prefixes messages with `[name]` (fallback to `[id]`).
- All other state (loading, inputDisabled, statusLine, queue, taskList) remains global at the workflow level.

#### Message structure and storage

- Storage model: a single workflow‑level message log (`state.messages`). There is no `agentsById` state; messages are attributed to agents via metadata and names are stored in `state.agentNames`.
- Each message can include agent attribution by id only; names are resolved at render time:

```ts
// UI-only messages or model/tool messages with optional agent attribution
type UIMessage =
| { role: "ui"; content: string; metadata?: { agentId?: string } & Record<string, unknown> }
| (ModelMessage & { metadata?: { agentId?: string } & Record<string, unknown> });

// Example stored assistant message
const m: UIMessage = {
role: "assistant",
content: [{ type: "text", text: "Plan complete." }],
metadata: { agentId: "planner" },
};
```

#### Transcript semantics

- Global transcript (`state.transcript`): excludes UI messages and excludes any message with `metadata.agentId` (i.e., sub‑agents are filtered out). Use this for simple single‑agent prompts.
- Per‑agent transcript (`agent.transcript()`): includes only non‑UI messages tagged with that agent’s `id`.
- Tool execution: assistant messages and tool results produced via `agent.handleModelResults(result)` are tagged with that agent. Tool‑synthesized user messages are routed via the normal input path.
- UI display: when `metadata.agentId` is present, the terminal UI prefixes the role header with `[name]` using `state.agentNames[agentId]` (fallback to `[id]`). You can override this by providing `displayConfig.formatRoleHeader` or a custom `agentNameResolver`.

## 📚 Examples

The SDK includes examples:
Expand Down Expand Up @@ -912,6 +976,71 @@ const workflow = createAgentWorkflow(
);
```

### Built-in Tools

These tools are available to your agent when you pass `tools.definitions` to your model call. Consider including the brief definitions below in your system prompt so the model knows how to use them.

- **`shell`**
- **What it does**: Execute a shell/CLI command (including `git`) in a given working directory.
- **Parameters**:
- `cmd: string[]` — command and args, e.g. `["git","status"]`
- `workdir: string` — working directory
- `timeout: number` — milliseconds

- **`apply_patch`**
- **What it does**: Apply file edits via a structured `apply_patch` payload for auditable changes.
- **Parameters**:
- `cmd: string[]` — invocation and payload, e.g. `["apply_patch","*** Begin Patch\\n...\\n*** End Patch"]`
- `workdir: string` — working directory
- `timeout: number` — milliseconds

- **`user_select`** (UI mode only)
- **What it does**: Prompt the user to choose from options. The UI also includes a "None of the above (enter custom option)" choice automatically.
- **Parameters**:
- `message: string` — text shown to the user
- `options: string[]` — available choices
- `defaultValue: string` — must match one of `options`; used on timeout

Note: In headless mode, only `shell` and `apply_patch` are available; `user_select` is not supported.

### Prompt guidance: include tool definitions

Add a succinct description of the tools and their parameter shapes in your system prompt so the model reliably uses them. For example:

```text
You can use the following tools. Call them with the exact parameter shapes.

- shell: run terminal commands.
Parameters: { cmd: string[]; workdir: string; timeout: number }

- apply_patch: make file edits using a single apply_patch payload wrapped in *** Begin Patch ... *** End Patch.
Parameters: { cmd: string[]; workdir: string; timeout: number }

- user_select (UI only): ask the user to pick from options; a custom option entry is available in the UI.
Parameters: { message: string; options: string[]; defaultValue: string }

Guidelines:
- Use shell for executing commands; prefer safe/read-only commands where possible.
- Use apply_patch for all file modifications; do not edit files via shell.
- Use user_select to disambiguate choices; ensure defaultValue matches one of options.
```

### Sample system prompt

```text
You are an engineering agent assisting inside a terminal UI. You can use tools.

Tools:
- shell(cmd: string[], workdir: string, timeout: number): run terminal commands.
- apply_patch(cmd: string[], workdir: string, timeout: number): apply file edits via a single *** Begin Patch ... *** End Patch block.
- user_select(message: string, options: string[], defaultValue: string): prompt the user to choose (UI only).

Rules:
- Prefer safe commands; request approval before risky actions if applicable.
- All code edits must use apply_patch with a complete patch block. Do not edit files via shell.
- Use user_select when you need the user to choose among options or confirm a decision.
```

## 📚 Type Definitions

Full TypeScript support with type definitions:
Expand Down
2 changes: 1 addition & 1 deletion examples/gpt5-structure-scan.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { run, createAgentWorkflow } from "../dist/lib.js";
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
import { run, createAgentWorkflow } from "../dist/lib.js";

// Basic GPT‑5 structure scanning workflow
export const workflow = createAgentWorkflow(
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codex-sdk",
"version": "0.2.4",
"version": "0.2.5",
"license": "Apache-2.0",
"type": "module",
"main": "./dist/lib.js",
Expand Down
2 changes: 1 addition & 1 deletion src/components/WorkflowSwitcherOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function WorkflowSwitcherOverlay({
...(currentWorkflows.length > 0
? [
{
label: `Close current (${(currentWorkflows.find((w) => w.id === activeWorkflowId)?.displayTitle) || "current"})`,
label: `Close current (${currentWorkflows.find((w) => w.id === activeWorkflowId)?.displayTitle || "current"})`,
value: "__close_current__",
isLoading: false,
},
Expand Down
2 changes: 1 addition & 1 deletion src/components/chat/hooks/use-tool-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export function useToolExecution(params: {
// Forward to the workflow input path instead of duplicating in transcript
dispatchUserMessage?.(r);
} else if (r) {
toolResponses.push(r);
toolResponses.push(r as ModelMessage);
}
}
}
Expand Down
111 changes: 109 additions & 2 deletions src/components/chat/hooks/use-workflow-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
toggleNextIncomplete,
toggleTaskAtIndex,
} from "../../../utils/workflow/tasks.js";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useRef } from "react";

export function useWorkflowActions(params: {
smartSetState: (
Expand Down Expand Up @@ -230,7 +230,14 @@ export function useWorkflowActions(params: {
return syncStateRef.current.taskList || [];
},
get transcript() {
return filterTranscript(syncStateRef.current.messages);
const all = syncStateRef.current.messages || [];
const topLevel = all.filter((m) => {
const meta = (m as UIMessage).metadata as
| { agentId?: string }
| undefined;
return m.role !== "ui" && !meta?.agentId;
});
return filterTranscript(topLevel as Array<UIMessage>);
},
get statusLine() {
return syncStateRef.current.statusLine;
Expand All @@ -245,6 +252,102 @@ export function useWorkflowActions(params: {
[syncStateRef],
);

// Agent API
const agentsRef = useRef<Map<string, string>>(new Map());

const makeAgentHandle = useCallback(
(id: string, name: string) => {
const tagUiMessage = (m: UIMessage): UIMessage => ({
...m,
metadata: { ...(m.metadata || {}), agentId: id },
});

const sayForAgent = (text: string, metadata?: MessageMetadata) => {
const message = tagUiMessage({ role: "ui", content: text, metadata });
void smartSetState((prev) => ({
...prev,
messages: [...prev.messages, message],
}));
};

const addMessageForAgent = (message: UIMessage | Array<UIMessage>) => {
const list = Array.isArray(message) ? message : [message];
const tagged = list.map(tagUiMessage);
void smartSetState((prev) => ({
...prev,
messages: [...prev.messages, ...tagged],
}));
};

const transcriptForAgent = (): Array<ModelMessage> => {
const all = syncStateRef.current.messages || [];
const mine = all.filter((m) => {
const meta = (m as UIMessage).metadata as
| { agentId?: string }
| undefined;
return m.role !== "ui" && meta?.agentId === id;
});
return filterTranscript(mine as Array<UIMessage>);
};

const handleModelResultsForAgent = async (
result: ModelResult,
opts?: { abortSignal?: AbortSignal },
): Promise<Array<UIMessage>> => {
const messages = result?.response?.messages ?? [];
addMessageForAgent(messages as unknown as Array<UIMessage>);
const toolResponses = (await handleToolCall(messages, {
...(opts || {}),
})) as Array<ModelMessage>;
addMessageForAgent(toolResponses as unknown as Array<UIMessage>);
return (toolResponses as unknown as Array<UIMessage>) || [];
};

const setName = (newName: string) => {
agentsRef.current.set(id, newName);
void smartSetState((prev) => ({
...prev,
agentNames: { ...(prev.agentNames || {}), [id]: newName },
}));
};

return {
id,
name,
say: sayForAgent,
addMessage: addMessageForAgent,
transcript: transcriptForAgent,
handleModelResults: handleModelResultsForAgent,
setName,
} as const;
},
[handleToolCall, smartSetState, syncStateRef],
);

const createAgent = useCallback(
(name: string) => {
const id = crypto.randomUUID();
agentsRef.current.set(id, name);
void smartSetState((prev) => ({
...prev,
agentNames: { ...(prev.agentNames || {}), [id]: name },
}));
return makeAgentHandle(id, name);
},
[agentsRef, makeAgentHandle, smartSetState],
);

const getAgent = useCallback(
(id: string) => {
const name = agentsRef.current.get(id);
if (!name) {
return undefined;
}
return makeAgentHandle(id, name);
},
[agentsRef, makeAgentHandle],
);

const actions = useMemo(
() => ({
say,
Expand All @@ -265,6 +368,8 @@ export function useWorkflowActions(params: {
setInputValue,
truncateFromLastMessage,
handleModelResult,
createAgent,
getAgent,
}),
[
say,
Expand All @@ -285,6 +390,8 @@ export function useWorkflowActions(params: {
setInputValue,
truncateFromLastMessage,
handleModelResult,
createAgent,
getAgent,
],
);

Expand Down
13 changes: 11 additions & 2 deletions src/components/chat/hooks/use-workflow-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
createApplyPatchTool,
createUserSelectTool,
} from "../tools/definitions.js";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, useMemo } from "react";

export function useWorkflowManager(params: {
initialApprovalPolicy: ApprovalPolicy;
Expand Down Expand Up @@ -73,6 +73,7 @@ export function useWorkflowManager(params: {
taskList: [],
statusLine: undefined,
slots: undefined,
agentNames: {},
approvalPolicy: initialApprovalPolicy,
});

Expand Down Expand Up @@ -124,10 +125,18 @@ export function useWorkflowManager(params: {
});

const workflowRef = useRef<Workflow | null>(null);
const [displayConfig, setDisplayConfig] = useState<
const [displayConfigRaw, setDisplayConfig] = useState<
Workflow["displayConfig"] | undefined
>(undefined);

const displayConfig = useMemo(() => {
const resolver = (id: string) =>
(syncRef.current.agentNames && syncRef.current.agentNames[id]) || id;
return displayConfigRaw
? { ...displayConfigRaw, agentNameResolver: resolver }
: { agentNameResolver: resolver };
}, [displayConfigRaw, syncRef]);

useEffect(() => {
// Build the workflow once (and when the factory changes). Keep the instance
// stable during transient UI state changes (e.g. confirmation overlays)
Expand Down
Loading