-
Notifications
You must be signed in to change notification settings - Fork 9
feat(agent): persist unresolved manual tool calls as awaiting_client_tools #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -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) { | ||
| 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 | ||
|
|
@@ -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 | ||
|
|
@@ -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; | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding ▶ Prompt for agents: Grep consumers for exhaustive switches over |
||
| ) { | ||
| return true; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
persistClientToolsPauseearly-returns without persisting when!this.stateAccessor, so a manual-tool run with no state accessor now silently ends with status left as-is (notcomplete, since the oldmarkStateComplete()path was replaced) and no pendings surfaced. That's arguably better than the old falsecomplete, but confirm the no-accessor manual path is acceptable — the caller can still readgetPendingToolCalls()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.