Add streaming terminal chat UX
This commit is contained in:
@@ -31,6 +31,9 @@ Type a message, "/help" for commands, or "/exit" to quit.
|
|||||||
you>
|
you>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Interactive responses stream into the terminal as they are generated, and the
|
||||||
|
session keeps conversation history until you reset or exit.
|
||||||
|
|
||||||
You can also keep using one-shot mode when scripting:
|
You can also keep using one-shot mode when scripting:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -78,6 +81,7 @@ npm run dev -- "/tools"
|
|||||||
The core provides:
|
The core provides:
|
||||||
|
|
||||||
- `/help` — show available commands
|
- `/help` — show available commands
|
||||||
|
- `/history` — show how many conversation items are currently in memory
|
||||||
- `/reset` — clear the current conversation history
|
- `/reset` — clear the current conversation history
|
||||||
- `/exit` — leave the interactive session
|
- `/exit` — leave the interactive session
|
||||||
|
|
||||||
|
|||||||
15
src/agent.ts
15
src/agent.ts
@@ -8,6 +8,10 @@ export type AgentOptions = {
|
|||||||
events?: EventBus;
|
events?: EventBus;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RunTurnOptions = {
|
||||||
|
onTextDelta?: (delta: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export class Agent {
|
export class Agent {
|
||||||
private readonly history: AgentMessage[] = [];
|
private readonly history: AgentMessage[] = [];
|
||||||
|
|
||||||
@@ -17,7 +21,7 @@ export class Agent {
|
|||||||
private readonly options: AgentOptions
|
private readonly options: AgentOptions
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async runTurn(prompt: string): Promise<string> {
|
async runTurn(prompt: string, runOptions: RunTurnOptions = {}): Promise<string> {
|
||||||
const events = this.options.events;
|
const events = this.options.events;
|
||||||
const maxTurns = this.options.maxTurns ?? 20;
|
const maxTurns = this.options.maxTurns ?? 20;
|
||||||
|
|
||||||
@@ -26,7 +30,14 @@ export class Agent {
|
|||||||
|
|
||||||
for (let turn = 0; turn < maxTurns; turn += 1) {
|
for (let turn = 0; turn < maxTurns; turn += 1) {
|
||||||
await events?.emit("agent.turn.started", { turn: turn + 1 });
|
await events?.emit("agent.turn.started", { turn: turn + 1 });
|
||||||
const result = await this.model.respond(this.history, this.tools.list());
|
const result =
|
||||||
|
runOptions.onTextDelta && this.model.respondStream
|
||||||
|
? await this.model.respondStream(
|
||||||
|
this.history,
|
||||||
|
this.tools.list(),
|
||||||
|
runOptions.onTextDelta
|
||||||
|
)
|
||||||
|
: await this.model.respond(this.history, this.tools.list());
|
||||||
this.history.push(...(result.output as AgentMessage[]));
|
this.history.push(...(result.output as AgentMessage[]));
|
||||||
|
|
||||||
if (result.toolCalls.length === 0) {
|
if (result.toolCalls.length === 0) {
|
||||||
|
|||||||
44
src/index.ts
44
src/index.ts
@@ -72,6 +72,14 @@ await commands.register({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await commands.register({
|
||||||
|
name: "history",
|
||||||
|
description: "Show how many conversation items are in memory.",
|
||||||
|
execute() {
|
||||||
|
return `Conversation items in memory: ${currentAgent?.getHistory().length ?? 0}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await loadExtensions(resolve(cwd, "extensions"), registry, commands, events, cwd, promptFragments);
|
await loadExtensions(resolve(cwd, "extensions"), registry, commands, events, cwd, promptFragments);
|
||||||
|
|
||||||
const systemPrompt = promptFragments.join("\n\n");
|
const systemPrompt = promptFragments.join("\n\n");
|
||||||
@@ -117,15 +125,39 @@ async function handleCommand(prompt: string): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePrompt(prompt: string): Promise<void> {
|
async function handlePrompt(prompt: string, interactive = false): Promise<void> {
|
||||||
if (!prompt.trim()) return;
|
if (!prompt.trim()) return;
|
||||||
|
|
||||||
if (await handleCommand(prompt)) {
|
if (await handleCommand(prompt)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const answer = await getAgent().runTurn(prompt);
|
if (!interactive) {
|
||||||
console.log(`assistant> ${answer}`);
|
const answer = await getAgent().runTurn(prompt);
|
||||||
|
console.log(answer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wroteAssistantText = false;
|
||||||
|
let streamedText = "";
|
||||||
|
|
||||||
|
output.write("\x1b[36massistant>\x1b[0m ");
|
||||||
|
|
||||||
|
const answer = await getAgent().runTurn(prompt, {
|
||||||
|
onTextDelta(delta) {
|
||||||
|
wroteAssistantText = true;
|
||||||
|
streamedText += delta;
|
||||||
|
output.write(delta);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wroteAssistantText) {
|
||||||
|
output.write(answer);
|
||||||
|
} else if (streamedText !== answer && !answer.startsWith(streamedText)) {
|
||||||
|
output.write(answer.slice(streamedText.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
output.write("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initialPrompt) {
|
if (initialPrompt) {
|
||||||
@@ -137,15 +169,15 @@ const rl = createInterface({ input, output });
|
|||||||
|
|
||||||
console.log("Simple Agent Harness");
|
console.log("Simple Agent Harness");
|
||||||
console.log('Type a message, "/help" for commands, or "/exit" to quit.');
|
console.log('Type a message, "/help" for commands, or "/exit" to quit.');
|
||||||
output.write("you> ");
|
output.write("\x1b[32myou>\x1b[0m ");
|
||||||
|
|
||||||
for await (const prompt of rl) {
|
for await (const prompt of rl) {
|
||||||
if (prompt.trim() === "/exit") {
|
if (prompt.trim() === "/exit") {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
await handlePrompt(prompt);
|
await handlePrompt(prompt, true);
|
||||||
output.write("you> ");
|
output.write("\x1b[32myou>\x1b[0m ");
|
||||||
}
|
}
|
||||||
|
|
||||||
rl.close();
|
rl.close();
|
||||||
|
|||||||
@@ -37,13 +37,73 @@ export class OpenAIResponsesClient implements ModelClient {
|
|||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolCalls: ToolCall[] = response.output
|
const toolCalls: ToolCall[] = response.output.flatMap((item) =>
|
||||||
.filter((item): item is OpenAI.Responses.ResponseFunctionToolCall => item.type === "function_call")
|
item.type === "function_call"
|
||||||
.map((item) => ({
|
? [
|
||||||
id: item.call_id,
|
{
|
||||||
name: item.name,
|
id: item.call_id,
|
||||||
arguments: item.arguments
|
name: item.name,
|
||||||
}));
|
arguments: item.arguments
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.events?.emit("model.response", {
|
||||||
|
outputItems: response.output.length,
|
||||||
|
toolCalls: toolCalls.map((toolCall) => toolCall.name),
|
||||||
|
hasText: response.output_text.length > 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
output: response.output,
|
||||||
|
outputText: response.output_text,
|
||||||
|
toolCalls
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondStream(
|
||||||
|
input: AgentMessage[],
|
||||||
|
tools: ToolDefinition[],
|
||||||
|
onTextDelta: (delta: string) => void
|
||||||
|
): Promise<ModelTurn> {
|
||||||
|
await this.events?.emit("model.request", {
|
||||||
|
model: this.model,
|
||||||
|
messages: input.length,
|
||||||
|
tools: tools.map((tool) => tool.name)
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = this.client.responses.stream({
|
||||||
|
model: this.model,
|
||||||
|
instructions: this.instructions,
|
||||||
|
input,
|
||||||
|
tools: tools.map((tool) => ({
|
||||||
|
type: "function",
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: tool.parameters,
|
||||||
|
strict: true
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
if (event.type === "response.output_text.delta") {
|
||||||
|
onTextDelta(event.delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await stream.finalResponse();
|
||||||
|
const toolCalls: ToolCall[] = response.output.flatMap((item) =>
|
||||||
|
item.type === "function_call"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: item.call_id,
|
||||||
|
name: item.name,
|
||||||
|
arguments: item.arguments
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
await this.events?.emit("model.response", {
|
await this.events?.emit("model.response", {
|
||||||
outputItems: response.output.length,
|
outputItems: response.output.length,
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export type ModelTurn = {
|
|||||||
|
|
||||||
export interface ModelClient {
|
export interface ModelClient {
|
||||||
respond(input: AgentMessage[], tools: ToolDefinition[]): Promise<ModelTurn>;
|
respond(input: AgentMessage[], tools: ToolDefinition[]): Promise<ModelTurn>;
|
||||||
|
respondStream?(
|
||||||
|
input: AgentMessage[],
|
||||||
|
tools: ToolDefinition[],
|
||||||
|
onTextDelta: (delta: string) => void
|
||||||
|
): Promise<ModelTurn>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CommandContext = {
|
export type CommandContext = {
|
||||||
|
|||||||
Reference in New Issue
Block a user