-
Notifications
You must be signed in to change notification settings - Fork 425
feat: implement endsTurn behavior for ask-question action and enhance… #2774
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,5 @@ | ||
| --- | ||
| "@agent-native/core": patch | ||
| --- | ||
|
|
||
| Keep an `ask-question` card in the chat that asked it, and end the turn once it renders instead of letting the agent keep working over an unanswered question. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -733,6 +733,14 @@ export interface ActionEntry { | |
| args: any, | ||
| ctx?: import("../action.js").ActionRunContext, | ||
| ) => boolean | Promise<boolean>); | ||
| /** | ||
| * The action hands control back to the user: once it succeeds the loop stops | ||
| * the turn instead of asking the model for another step, and any remaining | ||
| * tool calls in the same assistant message do not execute. Only for actions | ||
| * whose whole purpose is to wait on a human (`ask-question`) — telling the | ||
| * model to stop in the tool result does not make it stop. | ||
| */ | ||
| endsTurn?: boolean; | ||
| /** Which framework tool group contributed this action. Set by the framework, | ||
| * never by an app: apps own their action names, and a tagged action is one | ||
| * the app can switch off wholesale through `frameworkTools`. Tagged actions | ||
|
|
@@ -5153,6 +5161,10 @@ export async function runAgentLoop(opts: { | |
|
|
||
| let requestedActionStop: { message: string; errorCode?: string } | null = | ||
| null; | ||
| // An `endsTurn` action ran and handed control to the user. Distinct from | ||
| // `requestedActionStop`, which also covers failure stops that must not | ||
| // suppress the remaining tool calls. | ||
| let turnYieldedToUser = false; | ||
|
|
||
| const noteRepeatedToolCall = (toolName: string, input: unknown) => { | ||
| const key = toolCallCacheKey(toolName, input); | ||
|
|
@@ -6074,6 +6086,13 @@ export async function runAgentLoop(opts: { | |
| ...(actionEntry.chatUI ? { chatUI: actionEntry.chatUI } : {}), | ||
| }); | ||
| recordToolResult(result, isError); | ||
| if (!isError && actionEntry.endsTurn === true) { | ||
|
Contributor
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. 🟡 Do not yield when ask-question delivery fails validation
Additional Info |
||
| turnYieldedToUser = true; | ||
| requestedActionStop ??= { | ||
| message: "Waiting for your answer before continuing.", | ||
| errorCode: "awaiting-user-input", | ||
| }; | ||
| } | ||
| if (!isError) { | ||
| if (cacheKey) { | ||
| readOnlyToolResultCache.set(cacheKey, result); | ||
|
|
@@ -6130,7 +6149,50 @@ export async function runAgentLoop(opts: { | |
| toolResultParts.push(...(await Promise.all(batch.map(runToolCall)))); | ||
| }; | ||
|
|
||
| // An `endsTurn` action already handed control to the user, so the rest of | ||
| // this assistant message belongs to a turn that is over. Report those calls | ||
| // as not executed rather than running them: a second `ask-question` would | ||
| // overwrite the first one's card before anyone could answer it. | ||
| const skipToolCallAfterYield = ( | ||
| toolCall: import("./engine/types.js").EngineToolCallPart, | ||
| ): EngineContentPart => { | ||
| const result = | ||
| `Not executed: ${toolCall.name} was called after an action that ends the turn. ` + | ||
| `The turn is paused for the user's answer — call it again on a later turn if still needed.`; | ||
| send({ | ||
|
Contributor
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. 🟡 Do not emit real tool starts for skipped callsThe skip path emits a synthetic Additional Info |
||
| type: "tool_start", | ||
| id: toolCall.id, | ||
| tool: toolCall.name, | ||
| input: toolCall.input as Record<string, string>, | ||
| }); | ||
| send({ | ||
| type: "tool_done", | ||
| id: toolCall.id, | ||
| tool: toolCall.name, | ||
| input: toolCall.input as Record<string, unknown>, | ||
| result, | ||
| completedSideEffect: false, | ||
| }); | ||
| toolResultHistory.push({ | ||
| name: toolCall.name, | ||
| content: result, | ||
| isError: false, | ||
| }); | ||
| return { | ||
| type: "tool-result" as const, | ||
| toolCallId: toolCall.id, | ||
| toolName: toolCall.name, | ||
| toolInput: JSON.stringify(toolCall.input ?? {}), | ||
| content: result, | ||
| }; | ||
| }; | ||
|
|
||
| for (const toolCall of toolCallParts) { | ||
| if (turnYieldedToUser) { | ||
| await flushParallelBatch(); | ||
| toolResultParts.push(skipToolCallAfterYield(toolCall)); | ||
| continue; | ||
|
Comment on lines
+6191
to
+6194
Contributor
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. 🟡 Treat endsTurn actions as parallel-batch boundaries
Additional Info |
||
| } | ||
| const batchKind = getParallelBatchKind(toolCall); | ||
| if (batchKind) { | ||
| if (parallelBatchKind && parallelBatchKind !== batchKind) { | ||
|
|
@@ -6256,6 +6318,12 @@ export async function runAgentLoop(opts: { | |
| code: "needs_approval", | ||
| message: terminalActionStop.message, | ||
| }); | ||
| } else if (terminalActionStop?.errorCode === "awaiting-user-input") { | ||
| reportOutcome({ | ||
| state: "input_required", | ||
| code: "awaiting_user_input", | ||
| message: terminalActionStop.message, | ||
| }); | ||
| } else if (terminalActionStop) { | ||
| reportOutcome({ | ||
| state: "failed", | ||
|
|
||
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.
🟡 Apply endsTurn when replaying completed actions
Successful journal replay and interrupted-write recovery paths return before this normal execution branch, even though they represent an action that already completed successfully. On a resumed run, a replayed
ask-questioncan therefore continue into another model iteration and issue another question. Centralize the successfulendsTurntransition or invoke it in each successful replay/recovery path, with a resume regression test.Additional Info