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
26 changes: 26 additions & 0 deletions packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,32 @@ active tool selection, compaction/tree workflows, and higher-level queue events.
`CuaAgentHarness` extends pi `AgentHarness`, installs CUA defaults, and refreshes
provider-specific runtime state when `setModel()` changes models.

## Empty Response Recovery

pi treats an assistant message with empty content and a successful `stop` reason
as a completed turn. CUA preserves that behavior by default. Applications that
want another model turn can opt into a bounded continuation:

```ts
const agent = new CuaAgent({
browser,
client,
initialState: { model: "openai:gpt-5.5" },
emptyResponseRecovery: {
followUp: "Continue working on the task.",
maxAttempts: 1,
},
});
```

Recovery uses pi's public `followUp()` queue. It preserves the empty assistant
message, appends the configured user message, and starts another model turn.
Each attempt therefore adds provider latency and a billed provider call. A
`maxAttempts` value of `1` is recommended when recovery is needed; omission or
`0` keeps stock pi completion semantics. Provider history converters and
response-ID threading may represent the preserved empty turn differently in a
later wire request.

## Core Concepts

### Class-First API
Expand Down
84 changes: 84 additions & 0 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ type CuaAgentInitialState = Omit<NonNullable<AgentOptions["initialState"]>, "mod
model: CuaRuntimeInput;
};

/** Explicit opt-in policy for continuing after a successful exact-empty assistant response. */
export interface CuaEmptyResponseRecoveryOptions {
/** User message appended to request another model turn. */
followUp: string;
/** Maximum recovery continuations per top-level prompt. */
maxAttempts: number;
}

/**
* Constructor options for {@link CuaAgent}.
*
Expand All @@ -144,6 +152,8 @@ export type CuaAgentOptions = Omit<AgentOptions, "initialState"> & {
nativeTool?: CuaNativeToolSpec;
/** Expose a tool that runs Playwright code against the browser session. */
playwright?: boolean;
/** Explicitly continue successful exact-empty responses with pi's follow-up queue. */
emptyResponseRecovery?: CuaEmptyResponseRecoveryOptions;
/** Maximum tool-result images included from message history per provider request. Defaults to 4; false disables projection. */
toolResultImageReplayLimit?: ToolResultImageReplayLimit;
/** Chain OpenAI and Tzafon requests through provider-stored response state. Defaults to true. */
Expand Down Expand Up @@ -188,6 +198,8 @@ export type CuaAgentHarnessOptions<
playwright?: boolean;
/** Optional payload hook composed after the provider-specific CUA payload hook. */
onPayload?: SimpleStreamOptions["onPayload"];
/** Explicitly continue successful exact-empty responses with pi's follow-up queue. */
emptyResponseRecovery?: CuaEmptyResponseRecoveryOptions;
/** Maximum tool-result images included from message history per provider request. Defaults to 4; false disables projection. */
toolResultImageReplayLimit?: ToolResultImageReplayLimit;
/** Chain OpenAI and Tzafon requests through provider-stored response state. Defaults to true. */
Expand Down Expand Up @@ -347,6 +359,23 @@ class CuaRuntimeController {
/** Default stream path: the shared CUA `Models` collection. */
const defaultCuaStream: StreamFn = (model, context, options) => cuaModels().streamSimple(model, context, options);

function resolveEmptyResponseRecovery(
options: CuaEmptyResponseRecoveryOptions | undefined,
): CuaEmptyResponseRecoveryOptions | undefined {
if (!options) return undefined;
if (options.followUp.trim().length === 0) {
throw new Error("emptyResponseRecovery.followUp must not be blank");
}
if (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 0) {
throw new Error("emptyResponseRecovery.maxAttempts must be a non-negative finite integer");
}
return { followUp: options.followUp, maxAttempts: options.maxAttempts };
}

function isEmptyAssistantResponse(message: AgentMessage): boolean {
return message.role === "assistant" && message.stopReason === "stop" && message.content.length === 0;
}

function resolveResponseThreading(responseThreading: boolean | undefined): boolean {
if (responseThreading !== undefined && typeof responseThreading !== "boolean") {
throw new TypeError("responseThreading must be a boolean");
Expand Down Expand Up @@ -423,6 +452,7 @@ export class CuaAgent extends Agent {
private readonly runtime: CuaRuntimeController;
private readonly ownsSystemPrompt: boolean;
private runtimeDirty = false;
private emptyResponseRecoveryAttempts = 0;
private stateProxy?: CuaAgentState;
private stateProxyTarget?: AgentState;

Expand All @@ -439,11 +469,13 @@ export class CuaAgent extends Agent {
mode,
nativeTool,
playwright,
emptyResponseRecovery,
toolResultImageReplayLimit,
responseThreading,
retry,
...agentOptions
} = options;
const recovery = resolveEmptyResponseRecovery(emptyResponseRecovery);
const imageReplayLimit = resolveToolResultImageReplayLimit(toolResultImageReplayLimit);
const useResponseThreading = resolveResponseThreading(responseThreading);
const runtime = new CuaRuntimeController({
Expand Down Expand Up @@ -489,6 +521,16 @@ export class CuaAgent extends Agent {

this.runtime = runtime;
this.ownsSystemPrompt = initialState.systemPrompt === undefined;
if (recovery && recovery.maxAttempts > 0) {
this.subscribe((event, signal) => {
if (event.type === "agent_start") {
this.emptyResponseRecoveryAttempts = 0;
return;
}
if (event.type !== "turn_end" || !isEmptyAssistantResponse(event.message)) return;
this.recoverFromEmptyResponse(recovery, signal);
});
}
/**
* pi's loop only re-reads model/tools/prompt between provider requests
* through `prepareNextTurn`. The wrapper stays pass-through (returning
Expand Down Expand Up @@ -563,6 +605,18 @@ export class CuaAgent extends Agent {
return this.runtime.mode;
}

private recoverFromEmptyResponse(recovery: CuaEmptyResponseRecoveryOptions, signal: AbortSignal): void {
if (signal.aborted || this.emptyResponseRecoveryAttempts >= recovery.maxAttempts || this.hasQueuedMessages()) {
return;
}
super.followUp({
role: "user",
content: [{ type: "text", text: recovery.followUp }],
timestamp: Date.now(),
});
this.emptyResponseRecoveryAttempts += 1;
}

private applyRuntime(model: CuaRuntimeInput): void {
this.runtime.setModel(model);
this.runtimeDirty = true;
Expand Down Expand Up @@ -590,6 +644,8 @@ export class CuaAgentHarness<
> extends AgentHarness<TSkill, TPromptTemplate, AgentTool> {
private readonly runtime: CuaRuntimeController;
private requestedActiveToolNames?: string[];
private emptyResponseRecoveryAttempts = 0;
private hasPendingActiveQueue = false;

constructor(options: CuaAgentHarnessOptions<TSkill, TPromptTemplate>) {
const {
Expand All @@ -604,11 +660,13 @@ export class CuaAgentHarness<
systemPrompt,
onPayload,
activeToolNames,
emptyResponseRecovery,
toolResultImageReplayLimit,
responseThreading,
retry,
...harnessOptions
} = options;
const recovery = resolveEmptyResponseRecovery(emptyResponseRecovery);
const imageReplayLimit = resolveToolResultImageReplayLimit(toolResultImageReplayLimit);
const useResponseThreading = resolveResponseThreading(responseThreading);
const runtime = new CuaRuntimeController({
Expand Down Expand Up @@ -639,13 +697,39 @@ export class CuaAgentHarness<

this.runtime = runtime;
this.requestedActiveToolNames = activeToolNames;
if (recovery && recovery.maxAttempts > 0) {
this.on("before_agent_start", () => {
this.emptyResponseRecoveryAttempts = 0;
this.hasPendingActiveQueue = false;
return undefined;
});
this.subscribe(async (event, signal) => {
if (event.type === "queue_update") {
this.hasPendingActiveQueue = event.steer.length > 0 || event.followUp.length > 0;
return;
}
if (event.type !== "turn_end" || !isEmptyAssistantResponse(event.message)) return;
await this.recoverFromEmptyResponse(recovery, signal);
});
}
this.on("before_provider_payload", async ({ model, payload }: { model: Model<Api>; payload: unknown }) => {
const onPayload = this.runtime.onPayload();
if (!onPayload) return { payload };
return { payload: (await onPayload(payload, model)) ?? payload };
});
}

private async recoverFromEmptyResponse(
recovery: CuaEmptyResponseRecoveryOptions,
signal?: AbortSignal,
): Promise<void> {
if (signal?.aborted || this.emptyResponseRecoveryAttempts >= recovery.maxAttempts || this.hasPendingActiveQueue) {
Comment thread
rgarcia marked this conversation as resolved.
return;
}
await super.followUp(recovery.followUp);
this.emptyResponseRecoveryAttempts += 1;
}

/**
* Mirror pi `AgentHarness.setModel()` while accepting CUA model refs.
*
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type {
CuaAgentHarnessOptions,
CuaAgentOptions,
CuaAgentState,
CuaEmptyResponseRecoveryOptions,
ToolResultImageReplayLimit,
} from "./agent";
export type { CuaRetryOptions } from "./provider-retry";
Loading
Loading