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
11 changes: 11 additions & 0 deletions .changeset/fix-manual-tool-pending-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@openrouter/agent": minor
---

Persist unresolved manual tool calls (`execute: false` / no execute fn) to `ConversationState.pendingToolCalls` when the loop stops, and set status to the new value `'awaiting_client_tools'`.

Previously, HITL pauses (`onToolCalled → null`) correctly populated `pendingToolCalls` with status `'awaiting_hitl'`, but bare manual tools only `break`'d the loop — `getPendingToolCalls()` returned `[]` and status was left `in_progress`/`complete`. Cold-start consumers could not recover the unresolved calls from serialized state.

- New `ConversationStatus` value: `'awaiting_client_tools'` (additive; does not replace `'awaiting_hitl'`).
- Mixed auto+manual rounds still execute/persist regular tool outputs, then pause with only the unresolved manual calls in `pendingToolCalls`.
- A successful resume with new input from `'awaiting_client_tools'` clears the stale pendings and continues as a normal turn. Failed resume requests leave the paused state intact. Manual tools are not approved/rejected via call IDs (unlike HITL/`awaiting_approval`).
110 changes: 91 additions & 19 deletions packages/agent/src/lib/model-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export class ModelResult<
private resolvedRequest: models.ResponsesRequest | null = null;
// Fresh user items to persist atomically with the assistant response
private pendingFreshItems: models.BaseInputsUnion[] | undefined;
private resumingFromClientTools = false;

// State management for multi-turn conversations
private stateAccessor: StateAccessor<TTools> | null = null;
Expand Down Expand Up @@ -518,10 +519,23 @@ export class ModelResult<
this.pendingFreshItems = undefined;
}

await this.saveStateSafely({
messages: appendToMessages(messages, outputItems as models.BaseInputsUnion[]),
previousResponseId: response.id,
});
const stateUpdates: Partial<Omit<ConversationState<TTools>, 'id' | 'createdAt' | 'updatedAt'>> =
{
messages: appendToMessages(messages, outputItems as models.BaseInputsUnion[]),
previousResponseId: response.id,
};
if (this.resumingFromClientTools) {
this.currentState = {
...this.currentState,
};
this.clearOptionalStateProperties([
'pendingToolCalls',
]);
this.resumingFromClientTools = false;
stateUpdates.status = 'in_progress';
}

await this.saveStateSafely(stateUpdates);
}

/**
Expand Down Expand Up @@ -836,6 +850,43 @@ export class ModelResult<
await this.saveStateSafely(stateUpdates);
}

/**
* Persist state when the loop stops due to unresolved manual (client-executed)
* tool calls — tools with neither `execute` nor `onToolCalled`.
*
* Mirrors `persistHitlPause` so callers can read the unresolved calls via
* `getPendingToolCalls()` / `getState()` after the loop ends. Uses the
* distinct status `awaiting_client_tools` so consumers can discriminate
* manual pauses from HITL pauses (`awaiting_hitl`).
*
* Resume behavior: `awaiting_client_tools` is intentionally NOT treated as a
* resumable status for `processApprovalDecisions` — manual tools are not
* approved/rejected via call IDs; the caller executes them externally and
* typically supplies `function_call_output` items as new input on the next
* `callModel`. The paused status and calls remain durable until that request
* produces a response; they are then cleared atomically with the response.
*
* @param currentResponse - The response that produced the unresolved calls
* @param unresolvedCalls - Manual (or otherwise non-auto-resolvable) tool calls
*/
private async persistClientToolsPause(
currentResponse: models.OpenResponsesResult,
unresolvedCalls: ParsedToolCall<Tool>[],
): Promise<void> {
this.finalResponse = currentResponse;

if (!this.stateAccessor || unresolvedCalls.length === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

persistClientToolsPause early-returns without persisting when !this.stateAccessor, so a manual-tool run with no state accessor now silently ends with status left as-is (not complete, since the old markStateComplete() path was replaced) and no pendings surfaced. That's arguably better than the old false complete, but confirm the no-accessor manual path is acceptable — the caller can still read getPendingToolCalls() off the in-memory result, but nothing is durable. Worth a one-line test or doc note on the no-accessor behavior.

▶ Prompt for agents: Add a test for the manual-tool stop path with no StateAccessor to pin the intended behavior (result exposes pending calls in-memory; nothing persisted), or document that manual tools require a StateAccessor to be recoverable.

return;
}

const stateUpdates: Partial<Omit<ConversationState<TTools>, 'id' | 'createdAt' | 'updatedAt'>> =
{
pendingToolCalls: unresolvedCalls as ParsedToolCall<TTools[number]>[],
status: 'awaiting_client_tools',
};
await this.saveStateSafely(stateUpdates);
}

/**
* Compute the `output` payload sent to the model for a successfully
* settled tool execution. Routes through `toModelOutput` when the tool
Expand Down Expand Up @@ -1503,14 +1554,20 @@ export class ModelResult<
]);
await this.saveStateSafely();
}

// Keep manual calls durable until the resumed request produces a
// response. Clearing before the API call would lose the only copy if
// that request failed.
this.resumingFromClientTools = loadedState.status === 'awaiting_client_tools';
} else {
this.currentState = createInitialState<TTools>();
}

// Update status to in_progress
await this.saveStateSafely({
status: 'in_progress',
});
if (!this.resumingFromClientTools) {
await this.saveStateSafely({
status: 'in_progress',
});
}
}

// Resolve async functions before initial request
Expand Down Expand Up @@ -1907,9 +1964,11 @@ export class ModelResult<
return; // Paused for approval
}

// All tool calls are manual (no execute / no onToolCalled) or otherwise
// non-auto-resolvable — stop and surface them as pending client tools
// instead of marking the conversation complete with empty pendings.
if (!this.hasExecutableToolCalls(toolCalls)) {
this.finalResponse = currentResponse;
await this.markStateComplete();
await this.persistClientToolsPause(currentResponse, toolCalls);
return;
}

Expand Down Expand Up @@ -1939,8 +1998,12 @@ export class ModelResult<
return;
}

// All-manual (or otherwise non-auto-resolvable) mid-loop round: stop
// and persist the unresolved calls the same way the first-round guard
// does above, so getPendingToolCalls() surfaces them after loop end.
if (!this.hasExecutableToolCalls(currentToolCalls)) {
break;
await this.persistClientToolsPause(currentResponse, currentToolCalls);
return;
}

// Build turn context
Expand Down Expand Up @@ -2016,9 +2079,13 @@ export class ModelResult<
// `hasExecutableToolCalls` guards. Also covers calls to tool names
// not present in `options.tools` at all.
const resolvedCallIds = new Set(toolResults.map((r) => r.callId));
const hasUnresolvedToolCalls = currentToolCalls.some((tc) => !resolvedCallIds.has(tc.id));
if (hasUnresolvedToolCalls) {
break;
const unresolvedToolCalls = currentToolCalls.filter((tc) => !resolvedCallIds.has(tc.id));
if (unresolvedToolCalls.length > 0) {
// Mixed auto + manual (or unknown-name) round — regular tool outputs
// were already persisted via saveToolResultsToState above; surface
// the unresolved calls on pendingToolCalls and stop.
await this.persistClientToolsPause(currentResponse, unresolvedToolCalls);
return;
}

// Apply nextTurnParams
Expand Down Expand Up @@ -2731,17 +2798,22 @@ export class ModelResult<
// =========================================================================

/**
* Check if the conversation requires human input to continue.
* Check if the conversation requires human/client input to continue.
* Returns true when the conversation is paused waiting for caller-supplied
* decisions — either approval/rejection (`awaiting_approval`) or HITL tool
* resume (`awaiting_hitl`). Also returns true whenever `pendingToolCalls`
* is populated regardless of status.
* decisions — approval/rejection (`awaiting_approval`), HITL tool resume
* (`awaiting_hitl`), or client-executed manual tools (`awaiting_client_tools`).
* Also returns true whenever `pendingToolCalls` is populated regardless of
* status.
*/
async requiresApproval(): Promise<boolean> {
await this.initStream();

const status = this.currentState?.status;
if (status === 'awaiting_approval' || status === 'awaiting_hitl') {
if (
status === 'awaiting_approval' ||
status === 'awaiting_hitl' ||
status === 'awaiting_client_tools'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding awaiting_client_tools to the public ConversationStatus union is a breaking-ish surface change for any consumer doing an exhaustive switch on status (the repo's own style mandates default: value satisfies never). Please confirm no in-repo or known downstream consumer switches exhaustively on ConversationStatus without a default, and that the changeset being minor (not major) is the intended call for adding an enum member.

▶ Prompt for agents: Grep consumers for exhaustive switches over ConversationStatus and confirm they either have a default arm or are updated to handle awaiting_client_tools. Confirm the minor changeset bump is correct for adding a public status value.

) {
return true;
}

Expand Down
5 changes: 5 additions & 0 deletions packages/agent/src/lib/tool-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1020,12 +1020,17 @@ export interface PartialResponse<TTools extends readonly Tool[] = readonly Tool[
* - `awaiting_hitl`: one or more HITL tools returned `null` from `onToolCalled`,
* pausing execution so the caller can supply outputs for the paused calls
* before resuming
* - `awaiting_client_tools`: one or more manual (`execute: false` / no execute
* fn) tool calls are unresolved; the loop stopped so the caller can execute
* them client-side and continue. Distinct from `awaiting_hitl` — HITL tools
* have an `onToolCalled` hook; manual tools do not.
*/
export type ConversationStatus =
| 'complete'
| 'interrupted'
| 'awaiting_approval'
| 'awaiting_hitl'
| 'awaiting_client_tools'
| 'in_progress';

/**
Expand Down
Loading
Loading