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
5 changes: 5 additions & 0 deletions .changeset/scope-ask-question-to-its-chat.md
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.
131 changes: 131 additions & 0 deletions packages/core/src/agent/production-agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9591,6 +9591,137 @@ describe("runAgentLoop", () => {
});
});

// ─── endsTurn (actions that hand control back to the user) ───────────────────

describe("runAgentLoop endsTurn", () => {
/**
* Emits `ask-question` plus a second tool call in ONE assistant message, then
* a plain text completion on every later stream. The extra call reproduces the
* reported "it keeps asking questions": a second `ask-question` overwrites the
* first card before anyone can answer it.
*/
const yieldEngine = (): {
engine: AgentEngine;
streamCalls: () => number;
} => {
let streamCalls = 0;
const engine: AgentEngine = {
name: "test",
label: "Test",
defaultModel: "test-model",
supportedModels: ["test-model"],
capabilities: {
thinking: false,
promptCaching: false,
vision: false,
computerUse: false,
parallelToolCalls: true,
},
async *stream(): AsyncIterable<EngineEvent> {
streamCalls += 1;
if (streamCalls === 1) {
yield {
type: "assistant-content",
parts: [
{
type: "tool-call" as const,
id: "ask-1",
name: "ask-question",
input: { question: "Which range?" },
},
{
type: "tool-call" as const,
id: "ask-2",
name: "ask-question",
input: { question: "Which grain?" },
},
],
};
yield { type: "stop", reason: "tool_use" };
return;
}
yield { type: "text-delta", text: "kept working" };
yield {
type: "assistant-content",
parts: [{ type: "text" as const, text: "kept working" }],
};
yield { type: "stop", reason: "end_turn" };
},
};
return { engine, streamCalls: () => streamCalls };
};

it("stops the turn after the action runs and skips later calls in the same message", async () => {
const { engine, streamCalls } = yieldEngine();
const run = vi.fn(async () => "asked");
const events: any[] = [];
const outcomes: AgentLoopOutcome[] = [];

await runAgentLoop({
engine,
model: "test-model",
systemPrompt: "system",
tools: [],
messages: [{ role: "user", content: [{ type: "text", text: "go" }] }],
actions: {
"ask-question": {
...actionEntry({ readOnly: false }),
endsTurn: true,
run,
},
},
send: (event) => events.push(event),
onOutcome: (outcome) => outcomes.push(outcome),
signal: new AbortController().signal,
});

// The first question ran; the second never did.
expect(run).toHaveBeenCalledOnce();
expect(events).toContainEqual(
expect.objectContaining({
type: "tool_done",
id: "ask-2",
result: expect.stringContaining("Not executed"),
}),
);
// The model was never asked for another step.
expect(streamCalls()).toBe(1);
expect(events.some((event) => event.type === "done")).toBe(false);
expect(events.some((event) => event.text === "kept working")).toBe(false);
expect(outcomes).toEqual([
{
state: "input_required",
code: "awaiting_user_input",
message: "Waiting for your answer before continuing.",
},
]);
});

it("leaves a turn running when the action is not marked endsTurn", async () => {
const { engine, streamCalls } = yieldEngine();
const run = vi.fn(async () => "asked");
const outcomes: AgentLoopOutcome[] = [];

await runAgentLoop({
engine,
model: "test-model",
systemPrompt: "system",
tools: [],
messages: [{ role: "user", content: [{ type: "text", text: "go" }] }],
actions: {
"ask-question": { ...actionEntry({ readOnly: false }), run },
},
send: () => {},
onOutcome: (outcome) => outcomes.push(outcome),
signal: new AbortController().signal,
});

expect(run).toHaveBeenCalledTimes(2);
expect(streamCalls()).toBe(2);
expect(outcomes).toEqual([{ state: "completed" }]);
});
});

// ─── isContextTooLongError ────────────────────────────────────────────────────

describe("isContextTooLongError", () => {
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/agent/production-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -6074,6 +6086,13 @@ export async function runAgentLoop(opts: {
...(actionEntry.chatUI ? { chatUI: actionEntry.chatUI } : {}),
});
recordToolResult(result, isError);
if (!isError && actionEntry.endsTurn === true) {

Copy link
Copy Markdown
Contributor

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-question can therefore continue into another model iteration and issue another question. Centralize the successful endsTurn transition or invoke it in each successful replay/recovery path, with a resume regression test.

Additional Info
New finding from 1 of 3 incremental review agents; replay/recovery paths were traced in production-agent.ts.

Fix in Builder

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Do not yield when ask-question delivery fails validation

ask-question returns several validation failures as strings (for example, invalid JSON in options) rather than throwing, so isError remains false and this branch reports input_required even though no question card was persisted. Use a typed action error or an explicit successful-delivery signal so malformed calls can be corrected instead of leaving the user waiting for a nonexistent card.

Additional Info
New finding from 1 of 3 incremental review agents; validation-return behavior is in context-tools.ts.

Fix in Builder

turnYieldedToUser = true;
requestedActionStop ??= {
message: "Waiting for your answer before continuing.",
errorCode: "awaiting-user-input",
};
}
if (!isError) {
if (cacheKey) {
readOnlyToolResultCache.set(cacheKey, result);
Expand Down Expand Up @@ -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({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Do not emit real tool starts for skipped calls

The skip path emits a synthetic tool_start for a call that never ran. Slack’s input-request extraction can select the last ask-question tool start, so when a duplicate question is skipped it may deliver the skipped question text instead of the executed question that actually paused the run. Omit the synthetic start or make extraction select the successful call’s id, and add a duplicate-question Slack regression test.

Additional Info
New finding from 1 of 3 incremental review agents; cross-checked against the skipped-call event construction and Slack extraction behavior.

Fix in Builder

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Treat endsTurn actions as parallel-batch boundaries

turnYieldedToUser is checked only while building batches. If an endsTurn action is also marked read-only or parallel-safe, it can be grouped with later compatible calls and Promise.all starts them before the yielding action completes, violating the contract that remaining calls must not execute. Make endsTurn actions a batch boundary or reject incompatible parallel flags, and add a regression test.

Additional Info
New finding from 1 of 3 incremental review agents; the generic exported ActionEntry contract permits these combinations.

Fix in Builder

}
const batchKind = getParallelBatchKind(toolCall);
if (batchKind) {
if (parallelBatchKind && parallelBatchKind !== batchKind) {
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/client/AssistantChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5352,7 +5352,8 @@ const AssistantChatInner = forwardRef<
// GuidedQuestionPayload to application_state under "guided-questions". The
// hook polls that key, and on submit/skip composes the answer as a normal
// user turn (via the shared sendToAgentChat) and clears the persisted key so
// the question does not reappear.
// the question does not reappear. The key is per browser tab, so `threadId`
// is what keeps a pending question in the chat that asked it.
const {
questions: guidedQuestions,
title: guidedQuestionsTitle,
Expand All @@ -5365,6 +5366,7 @@ const AssistantChatInner = forwardRef<
stateKey: "guided-questions",
queryKey: ["guided-questions"],
...(browserTabId ? { browserTabId } : {}),
...(threadId ? { threadId } : {}),
});
const hasComposerAccessoryAboveStack = Boolean(
composerError ||
Expand Down
73 changes: 73 additions & 0 deletions packages/core/src/client/guided-questions.flow.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,79 @@ describe("useGuidedQuestionFlow scoped reads", () => {
expect(requestedKeys).not.toContain("guided-questions:undefined");
});

// The per-tab key is shared by every chat in that browser tab, so an
// agent-written payload names the thread that asked. Without this the same
// card followed the user into every other conversation.
it("hides a question asked in another chat", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) =>
readResponse(String(input), (key) =>
key === "guided-questions:tab123"
? JSON.stringify({ ...payload, threadId: "chat-a" })
: "",
),
),
);

const result = await renderFlow({
stateKey: "guided-questions",
queryKey: ["guided-questions"],
browserTabId: "tab123",
threadId: "chat-b",
refetchInterval: false,
});

expect(result.current().questions).toBeNull();
expect(result.current().payload).toBeNull();
});

it("renders a question in the chat that asked it", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) =>
readResponse(String(input), (key) =>
key === "guided-questions:tab123"
? JSON.stringify({ ...payload, threadId: "chat-a" })
: "",
),
),
);

const result = await renderFlow({
stateKey: "guided-questions",
queryKey: ["guided-questions"],
browserTabId: "tab123",
threadId: "chat-a",
refetchInterval: false,
});

expect(result.current().questions?.length).toBe(1);
});

it("renders a payload with no threadId in any chat", async () => {
// Client-initiated `askUserQuestion` and deterministic writes are not
// thread-bound; they must keep rendering wherever the flow is mounted.
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) =>
readResponse(String(input), (key) =>
key === "guided-questions:tab123" ? JSON.stringify(payload) : "",
),
),
);

const result = await renderFlow({
stateKey: "guided-questions",
queryKey: ["guided-questions"],
browserTabId: "tab123",
threadId: "chat-b",
refetchInterval: false,
});

expect(result.current().questions?.length).toBe(1);
});

it("does not read application state when disabled", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
Expand Down
Loading
Loading