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
40 changes: 40 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,23 @@ RAG tasks: `ChunkVectorUpsertTask` (`knowledgeBase` + `chunks` + `vector`, optio
`method: "similarity" | "hybrid"`), `HierarchyJoinTask`, `RerankerTask`,
`QueryExpanderTask`, `TextChunkerTask`, `HierarchicalChunkerTask`.

`AgentTask` is the turn loop: one `ToolCallingTask` per round, then the tools the model asked
for, then their results back to it, until the model answers or `maxRounds` runs out.
`messages` goes in and comes back out, so the host owns the conversation. **Every `tool_use`
is answered** — an unknown tool, arguments failing the tool's schema, a throw, a person
declining — because dropping the call orphans it and the provider rejects the next round. A
tool is backed by a registered task (looked up by `taskType`, else `name`) or by a
`ToolDefinition.execute` function, which is handed a `ToolExecuteContext` — the tool-use id
its answer belongs to, and the run's signal — and may throw a `ToolCallError` to report a
failure in its own words rather than wrapped. The turn also emits a `snapshot` of `messages`
after every message it records, so a host can draw a tool card from the moment the model asks
for it; a `snapshot` rather than an object-delta because an array delta is folded as an upsert
list and successive whole-list snapshots would append into a transcript several times its
length. A tool reaching beyond `INFERENCE_ENTITLEMENTS` is put to
`IHumanConnector` as a `confirm` first: `requiresApproval` overrides that per tool,
`approval: "never"` turns it off for a headless run, and with no connector registered such a
call is refused rather than run.

Conversation helpers for a host keeping its own message list, none of which run inside a
task: `normalizeHistoryForModel` / `trimHistoryForModel` (`ChatHistory.ts`), and
`collectToolUseIds` / `uniquifyToolCallIds` / `repairDuplicateToolCallIds` (`ToolCallIds.ts`).
Expand Down Expand Up @@ -402,6 +419,29 @@ terminal runs. Three load-bearing properties:
`timeline`, `markdown`, `empty` and `error`; a status widget contributes meters **or** text
lines, since most of what an operator checks has no denominator to draw a bar against.

`workglow agent chat` is the terminal consumer of `AgentTask`: a line-based REPL that
streams the model's reply straight to stdout — so the transcript stays in scrollback, which
the Ink run UI's clear-on-complete would erase — prints one row per tool call off the task's
own progress messages, and carries `messages` from one turn's output into the next's input.
`--tools` takes task type names and **has no default**: what an agent may call decides what it
can reach. Approvals go through `PromptHumanConnector`, the connector for anything prompting
BETWEEN runs rather than during one: `InkHumanConnector` needs a mounted
`HumanInteractionHost` and throws without one, while this draws its own prompt and gives the
screen back. It has no `followUp` — a modal prompt settles the question it asked — and
decides form-vs-approval through the same `humanPromptModel` the Ink panel and the console
read.

**The same command serves the web console**, because a chat needs no channel the console did
not already have: each turn runs through `withCli` so its rows and text report up the event
stream, and the next message is asked for as an ordinary `elicit` whose field carries
`format: "chat-message"` — the marker the console keys on to draw a composer instead of a
one-line field, and to fold the answer into a transcript instead of a form somebody once
filled in. A reported session does NOT install `PromptHumanConnector`: the channel installs
its own connector, and overriding it would point a console session's approvals at an Ink
prompt on a process whose stdout is a pipe. `chatTranscript` (`web/client/state.ts`) zips
what the console sent against the `AgentTask` rows it saw, one turn per message, so the
conversation is derived rather than a second copy of the run.

`workglow mcp serve` is the second server the CLI hosts: the registered tasks offered to
MCP clients as tools, one per task type, named for the registered type itself (`task list`
prints the same types with the `Task` suffix trimmed) and carrying the
Expand Down
47 changes: 47 additions & 0 deletions examples/cli/src/agent/chatCommands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

/**
* What a typed line means before the model sees it.
*
* Only lines that are exactly a known command are one: a message beginning
* "/exit is the command that..." is a message, and a model that never receives
* it because of a prefix match is a chat that silently eats input.
*/
export type ChatLineIntent =
| { readonly kind: "blank" }
| { readonly kind: "quit" }
| { readonly kind: "reset" }
| { readonly kind: "help" }
| { readonly kind: "unknown-command"; readonly typed: string }
| { readonly kind: "message"; readonly text: string };

const COMMANDS: ReadonlyMap<string, ChatLineIntent["kind"]> = new Map([
["/exit", "quit"],
["/quit", "quit"],
["/reset", "reset"],
["/help", "help"],
]);

export function classifyChatLine(line: string): ChatLineIntent {
const text = line.trim();
if (text.length === 0) return { kind: "blank" };
if (!text.startsWith("/")) return { kind: "message", text };
const known = COMMANDS.get(text.toLowerCase());
if (known === "quit") return { kind: "quit" };
if (known === "reset") return { kind: "reset" };
if (known === "help") return { kind: "help" };
// A lone word starting with "/" is a mistyped command far more often than a
// message; anything longer is prose that happens to open with a slash.
if (/^\/\S*$/.test(text)) return { kind: "unknown-command", typed: text };
return { kind: "message", text };
}

export const CHAT_HELP_LINES: readonly string[] = [
"/exit, /quit end the session",
"/reset forget the conversation so far",
"/help this list",
];
47 changes: 47 additions & 0 deletions examples/cli/src/agent/chatTranscript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Where a chat turn is written, and the one thing that is easy to get wrong
* about it: a model's text arrives as deltas that rarely end on a line break,
* and everything else the turn prints — a tool line, an approval, the next
* prompt — has to start at column 0 or it lands in the middle of a sentence.
*
* Kept apart from the loop so the rule can be read, and tested, without a
* terminal.
*/
export interface ChatTranscript {
/** Model text, exactly as it arrived. */
delta(text: string): void;
/** A line about the run rather than from the model. */
note(line: string): void;
/** Close the turn, leaving the cursor at column 0. */
endTurn(): void;
}

export function createChatTranscript(write: (text: string) => void): ChatTranscript {
let atLineStart = true;
const breakLine = (): void => {
if (!atLineStart) {
write("\n");
atLineStart = true;
}
};
return {
delta(text) {
if (text.length === 0) return;
write(text);
atLineStart = text.endsWith("\n");
},
note(line) {
breakLine();
write(`${line}\n`);
},
endTurn() {
breakLine();
},
};
}
238 changes: 238 additions & 0 deletions examples/cli/src/agent/runAgentChat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

import type { AgentApprovalMode, AgentTaskOutput, ChatMessage, ToolDefinition } from "@workglow/ai";
import { AgentTask } from "@workglow/ai";
import type { StreamEvent } from "@workglow/task-graph";
import {
globalServiceRegistry,
HUMAN_CONNECTOR,
resolveHumanConnector,
ServiceRegistry,
uuid4,
} from "@workglow/util";
import type { DataPortSchema } from "@workglow/util/schema";
import { ensureRunReporting } from "../run-events/runReporting";
import { withCli } from "../run-interactive";
import { createInterface } from "node:readline/promises";
import { formatError } from "../util";
import { PromptHumanConnector } from "../ui/PromptHumanConnector";
import { CHAT_HELP_LINES, classifyChatLine } from "./chatCommands";
import { createChatTranscript } from "./chatTranscript";

/**
* The session's two ends, injectable so the loop can be driven without a
* terminal. Defaults are readline and stdout.
*/
export interface AgentChatIo {
/** One line from the person, or `undefined` at end of input. */
readonly ask: (prompt: string) => Promise<string | undefined>;
readonly write: (text: string) => void;
}

export interface AgentChatOptions {
readonly model: string;
readonly tools: readonly ToolDefinition[];
readonly systemPrompt: string | undefined;
readonly maxRounds: number | undefined;
readonly approval: AgentApprovalMode;
}

/**
* One question, on a readline interface that exists only for the length of it.
*
* A long-lived interface keeps listeners on stdin, and the approval prompts
* this session raises render their own Ink app over the same terminal — two
* readers of one stdin is a session that drops keystrokes into whichever
* happens to be listening. Creating and closing per question means nothing but
* the prompt of the moment ever holds it.
*/
async function askLine(prompt: string): Promise<string | undefined> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
return await rl.question(prompt);
} catch {
// The interface closes on EOF (Ctrl-D), which rejects the pending question.
return undefined;
} finally {
rl.close();
}
}

/**
* The schema of the one thing this loop asks a person for.
*
* `format` is the marker a renderer keys on: the console draws a chat composer
* for it rather than the one-line text field every other string port gets, and
* folds the answer into the transcript instead of showing it as a form it once
* filled in.
*/
export const CHAT_MESSAGE_SCHEMA: DataPortSchema = {
type: "object",
properties: {
message: { type: "string", title: "Message", format: "chat-message" },
},
required: ["message"],
additionalProperties: false,
};

/**
* A child of the host's registry carrying the connector this session prompts
* through — but ONLY when the session owns a terminal.
*
* A run reporting to a parent process already has a connector wired to that
* channel, installed with the channel itself. Overriding it here would point a
* console session's approvals at an Ink prompt nobody can see, on a process
* whose stdout is a pipe.
*/
export function chatRegistry(parent: ServiceRegistry, reported: boolean): ServiceRegistry {
if (reported) return parent;
const registry = new ServiceRegistry(parent.container.createChildContainer());
registry.registerInstance(HUMAN_CONNECTOR, new PromptHumanConnector());
return registry;
}

/**
* The next message, asked through whoever is listening.
*
* A reported run has no terminal to read a line from — its stdin is not a
* person — so the question goes up the same channel every other question does
* and the answer comes back down it. Declining or dismissing ends the session,
* which is what closing the composer means.
*/
export function askThroughConnector(
registry: ServiceRegistry,
signal: AbortSignal
): () => Promise<string | undefined> {
return async (): Promise<string | undefined> => {
const response = await resolveHumanConnector({ registry }).send(
{
requestId: uuid4(),
targetHumanId: "default",
kind: "elicit",
message: "Your message",
contentSchema: CHAT_MESSAGE_SCHEMA,
contentData: undefined,
expectsResponse: true,
mode: "single",
metadata: undefined,
},
signal
);
if (response.action !== "accept") return undefined;
const message = response.content?.message;
return typeof message === "string" ? message : undefined;
};
}

/**
* The chat loop: read a line, run one {@link AgentTask} turn, carry the
* conversation forward.
*
* `messages` is this loop's only state — the task takes the history in and
* hands it back with the turn appended, so nothing here has to know what a
* tool result or a tool-call id looks like.
*/
export async function runAgentChat(options: AgentChatOptions, io?: AgentChatIo): Promise<void> {
const reported = ensureRunReporting() !== undefined;
const registry = chatRegistry(globalServiceRegistry, reported);
const sessionAbort = new AbortController();
const ask = io?.ask ?? (reported ? askThroughConnector(registry, sessionAbort.signal) : askLine);
const transcript = createChatTranscript(io?.write ?? ((text) => void process.stdout.write(text)));
let messages: ChatMessage[] = [];

transcript.note(
options.tools.length === 0
? "No tools — this agent can talk, and nothing else. Pass --tools to give it some."
: `Tools: ${options.tools.map((tool) => tool.name).join(", ")}`
);
transcript.note("/help for commands, /exit to leave.");

for (;;) {
transcript.endTurn();
const line = await ask("\n› ");
if (line === undefined) return;
const intent = classifyChatLine(line);
if (intent.kind === "quit") return;
if (intent.kind === "blank") continue;
if (intent.kind === "reset") {
messages = [];
transcript.note("Conversation cleared.");
continue;
}
if (intent.kind === "help") {
for (const help of CHAT_HELP_LINES) transcript.note(help);
continue;
}
if (intent.kind === "unknown-command") {
transcript.note(`Unknown command ${intent.typed}. /help for the list.`);
continue;
}
messages = await runTurn(intent.text, messages, options, registry, transcript);
}
}

async function runTurn(
text: string,
messages: ChatMessage[],
options: AgentChatOptions,
registry: ServiceRegistry,
transcript: ReturnType<typeof createChatTranscript>
): Promise<ChatMessage[]> {
const task = new AgentTask();
const controller = new AbortController();
const onInterrupt = (): void => controller.abort();
process.on("SIGINT", onInterrupt);

let phase: string | undefined;
const offStream = task.subscribe("stream_chunk", (event: StreamEvent) => {
if (event.type === "text-delta") transcript.delta(event.textDelta);
});
const offProgress = task.subscribe("progress", (_progress, message) => {
// Only the tool lines are worth a row: "Thinking" is what the blank space
// between the prompt and the first token already says.
if (!message || message === phase) return;
phase = message;
if (message.startsWith("Running ")) transcript.note(` · ${message.slice("Running ".length)}`);
});

try {
// Through `withCli` rather than `task.run` so a session the console
// started reports its rows and its text up the event channel like every
// other command. `interactive: false` keeps the Ink run UI out of it: on a
// terminal that UI clears its frame when the run completes, which is the
// transcript this session is writing.
const output = (await withCli(task, { interactive: false, suppressResultOutput: true }).run(
{
model: options.model,
prompt: text,
messages,
tools: [...options.tools],
systemPrompt: options.systemPrompt,
maxRounds: options.maxRounds,
approval: options.approval,
},
{ registry, signal: controller.signal }
)) as AgentTaskOutput;
if (output.stopReason === "max-rounds") {
transcript.note(` · stopped after ${output.rounds} rounds without an answer`);
}
return output.messages;
} catch (error) {
if (controller.signal.aborted) {
// The turn is gone but the conversation is not: an interrupted turn wrote
// nothing to `messages`, so the next one continues from where it was.
transcript.note(" · interrupted");
return messages;
}
transcript.note(` · ${formatError(error)}`);
return messages;
} finally {
process.off("SIGINT", onInterrupt);
offStream();
offProgress();
}
}
Loading
Loading