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
46 changes: 46 additions & 0 deletions packages/agent/src/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,52 @@ export async function runAgentLoopContinue(
return newMessages;
}

export interface AgentStepOutcome {
messages: AgentMessage[];
/** True when the turn ran tools whose results still need another step. */
hasMoreToolCalls: boolean;
}

/**
* Run exactly one iteration of the loop against the current context, adding no
* new message. Like agentLoopContinue, the last message must convert to a `user`
* or `toolResult` message. Used to drive a turn one step at a time from outside.
*
* A step is one turn, not a whole run, so it emits no `agent_start`. The run ends
* only when the turn itself ends it, on an error, an abort, or a stop decision.
*/
export async function runAgentStep(
context: AgentContext,
config: AgentLoopConfig,
emit: AgentEventSink,
signal: AbortSignal | undefined,
streamFn: StreamFn,
): Promise<AgentStepOutcome> {
if (context.messages.length === 0) {
throw new Error("Cannot step: no messages in context");
}
if (context.messages[context.messages.length - 1].role === "assistant") {
throw new Error("Cannot step from message role: assistant");
}

const newMessages: AgentMessage[] = [];
const currentContext: AgentContext = { ...context };

const outcome = await runSingleTurn({
context: currentContext,
config,
newMessages,
pendingMessages: [],
emitTurnStart: true,
fetchNextPending: false,
signal,
emit,
streamFunction: streamFn ?? getDefaultStreamFn(),
});

return { messages: newMessages, hasMoreToolCalls: !outcome.done && outcome.hasMoreToolCalls };
}

// What the transcript says about a call when nothing can say whether it ran. The tool can have had
// its effect before the run stopped, so calling it a failure would invite a second run of something
// that already happened.
Expand Down
43 changes: 42 additions & 1 deletion packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
ThinkingBudgets,
Transport,
} from "@earendil-works/pi-ai";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
import { type AgentStepOutcome, runAgentLoop, runAgentLoopContinue, runAgentStep } from "./agent-loop.ts";
import { getDefaultStreamFn } from "./stream-fn.ts";
import type {
AfterToolCallContext,
Expand Down Expand Up @@ -387,6 +387,31 @@ export class Agent {
await this.runContinuation();
}

/**
* Advance the current transcript by exactly one step (one model call and the
* tools it requests). Adds no prompt and does not loop. The last message must
* be a user or tool-result message. `hasMoreToolCalls` tells the caller whether
* the turn still needs another step, so it does not repeat the loop's own
* termination logic.
*/
async step(): Promise<AgentStepOutcome> {
if (this.activeRun) {
throw new Error("Agent is already processing. Wait for completion before stepping.");
}

// Checked here as well as in the loop: the lifecycle below turns a thrown error into a failed
// assistant message, and a precondition failure should reach the caller instead.
const lastMessage = this._state.messages[this._state.messages.length - 1];
if (!lastMessage) {
throw new Error("No messages to step from");
}
if (lastMessage.role === "assistant") {
throw new Error("Cannot step from message role: assistant");
}

return await this.runSingleStep();
}

private normalizePromptInput(
input: string | AgentMessage | AgentMessage[],
images?: ImageContent[],
Expand Down Expand Up @@ -434,6 +459,22 @@ export class Agent {
});
}

private async runSingleStep(): Promise<AgentStepOutcome> {
// A failed run is handled inside the lifecycle, so the default stands and the
// caller stops stepping.
let outcome: AgentStepOutcome = { messages: [], hasMoreToolCalls: false };
await this.runWithLifecycle(async (signal) => {
outcome = await runAgentStep(
this.createContextSnapshot(),
this.createLoopConfig(),
(event) => this.processEvents(event),
signal,
this.streamFunction,
);
});
return outcome;
}

private createContextSnapshot(): AgentContext {
return {
systemPrompt: this._state.systemPrompt,
Expand Down
174 changes: 174 additions & 0 deletions packages/agent/test/agent-step.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import {
type AssistantMessage,
type AssistantMessageEvent,
EventStream,
type Message,
type Model,
type UserMessage,
} from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { runAgentStep } from "../src/agent-loop.ts";
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.ts";

class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
constructor() {
super(
(event) => event.type === "done" || event.type === "error",
(event) => {
if (event.type === "done") return event.message;
if (event.type === "error") return event.error;
throw new Error("Unexpected event type");
},
);
}
}

function createUsage() {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}

function createModel(): Model<"openai-responses"> {
return {
id: "mock",
name: "mock",
api: "openai-responses",
provider: "openai",
baseUrl: "https://example.invalid",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
};
}

function createAssistantMessage(
content: AssistantMessage["content"],
stopReason: AssistantMessage["stopReason"] = "stop",
): AssistantMessage {
return {
role: "assistant",
content,
api: "openai-responses",
provider: "openai",
model: "mock",
usage: createUsage(),
stopReason,
timestamp: Date.now(),
};
}

function createUserMessage(text: string): UserMessage {
return { role: "user", content: text, timestamp: Date.now() };
}

const LLM_ROLES = ["user", "assistant", "toolResult"];

function identityConverter(messages: AgentMessage[]): Message[] {
return messages.filter((m) => LLM_ROLES.includes(m.role)) as Message[];
}

describe("runAgentStep", () => {
it("runs exactly one model call and its tools, then stops", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_id, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `echoed: ${params.value}` }],
details: { value: params.value },
};
},
};

let callCount = 0;
const streamFn = () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
callCount++;
if (callCount === 1) {
stream.push({
type: "done",
reason: "toolUse",
message: createAssistantMessage(
[{ type: "toolCall", id: "t1", name: "echo", arguments: { value: "x" } }],
"toolUse",
),
});
} else {
// A whole-turn loop would take this branch; a single step must not.
stream.push({
type: "done",
reason: "stop",
message: createAssistantMessage([{ type: "text", text: "more" }]),
});
}
});
return stream;
};

const context: AgentContext = {
systemPrompt: "",
messages: [createUserMessage("go")],
tools: [tool],
};
const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter };

const events: AgentEvent[] = [];
const outcome = await runAgentStep(
context,
config,
(event) => {
events.push(event);
},
undefined,
streamFn,
);

// Exactly one model call, and the tool it requested ran once.
expect(callCount).toBe(1);
expect(executed).toEqual(["x"]);

// One turn boundary. A step is a turn, so it opens no run of its own.
expect(events.filter((e) => e.type === "turn_start").length).toBe(1);
expect(events.filter((e) => e.type === "turn_end").length).toBe(1);
expect(events.filter((e) => e.type === "agent_start").length).toBe(0);
expect(events.filter((e) => e.type === "agent_end").length).toBe(0);

// The tool result still needs an answer, so the caller has to step again.
expect(outcome.hasMoreToolCalls).toBe(true);

// The step produced the assistant message and its tool result, and nothing more.
expect(outcome.messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
});

it("throws when the last message is an assistant", async () => {
const context: AgentContext = {
systemPrompt: "",
messages: [createAssistantMessage([{ type: "text", text: "hi" }])],
tools: [],
};
await expect(
runAgentStep(
context,
{ model: createModel(), convertToLlm: identityConverter },
() => {},
undefined,
() => new MockAssistantStream(),
),
).rejects.toThrow(/Cannot step from message role: assistant/);
});
});
71 changes: 63 additions & 8 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1129,8 +1129,8 @@ export class AgentSession {
* result get one, and a trailing assistant message that holds no answer is dropped.
* Returns whether the turn still has work, so false means it already has its answer.
*
* resumeInterruptedTurn() is this plus a run to the end of the turn. A caller that drives
* the turn itself wants this one, so recovery stays one step at a time.
* resumeInterruptedTurn() is this plus a run to the end of the turn. A caller driving
* step() itself wants this one, so recovery stays one step at a time.
*/
prepareStep(): boolean {
if (this._isAgentRunActive) {
Expand Down Expand Up @@ -1197,6 +1197,32 @@ export class AgentSession {
// Other roles (bashExecution, compactionSummary, branchSummary) are persisted elsewhere.
}

/**
* Advance the current turn by exactly one step (one model call and the tools it
* requests). Unlike prompt(), it adds no message and does not loop; the caller
* drives successive steps. The last recorded message must be a user or tool-result
* message, which is what recordPrompt() and prepareStep() leave behind.
*
* `done` is false while the turn needs another step, which includes a retry or a
* compaction that post-run handling asked for. Steering and follow-up queues stay
* untouched, so the caller decides when a queued message enters the run.
*/
async step(): Promise<{ done: boolean }> {
if (this._isAgentRunActive) {
return { done: false };
}

this._isAgentRunActive = true;
try {
const outcome = await this.agent.step();
// Retries and compaction live here, so a stepped run keeps both.
const needsAnotherPass = await this._handlePostAgentRun();
return { done: !outcome.hasMoreToolCalls && !needsAnotherPass };
} finally {
await this._settleRun();
}
}

private async _handlePostAgentRun(): Promise<boolean> {
const msg = this._lastAssistantMessage;
this._lastAssistantMessage = undefined;
Expand Down Expand Up @@ -1257,6 +1283,40 @@ export class AgentSession {
* @throws Error if no model selected or no API key available (when not streaming)
*/
async prompt(text: string, options?: PromptOptions): Promise<void> {
const messages = await this._buildPromptMessages(text, options);
if (!messages) {
return;
}

options?.preflightResult?.(true);
await this._runAgentPrompt(messages);
}

/**
* Record a prompt without running it. Everything prompt() does to build the turn
* happens here (extension input, template expansion, the model and auth checks); the
* model call does not. step() picks the turn up from the transcript.
*
* For a caller that drives a turn one step at a time and checkpoints in between.
* Returns whether a prompt was recorded: an extension command handles its own text,
* and a prompt sent mid-stream is queued instead.
*/
async recordPrompt(text: string, options?: PromptOptions): Promise<boolean> {
const messages = await this._buildPromptMessages(text, options);
if (!messages) {
return false;
}

options?.preflightResult?.(true);
this._recordMessages(messages);
return true;
}

/**
* All of prompt() except the running of it. Returns the turn's messages, or undefined
* when the text needed no run at all.
*/
private async _buildPromptMessages(text: string, options?: PromptOptions): Promise<AgentMessage[] | undefined> {
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
const preflightResult = options?.preflightResult;
let messages: AgentMessage[] | undefined;
Expand Down Expand Up @@ -1401,12 +1461,7 @@ export class AgentSession {
throw error;
}

if (!messages) {
return;
}

preflightResult?.(true);
await this._runAgentPrompt(messages);
return messages;
}

/**
Expand Down
Loading