Skip to content

Commit ec99d66

Browse files
authored
fix(agent-core-v2): keep background questions open past turn end (#308)
## Related Issue No tracked issue — internal reconciliation pass. Stacked on #307; review that one first (and #306 under it). ## Problem A background `AskUserQuestion` (asked with `background: true`) was bound to the asking turn: the moment the agent finished its turn, the question was cancelled and the user never got to answer it. When a background question *was* answered, the agent received only the path of a saved output file, not the answer itself. ## What changed - Detached question requests no longer carry the asking turn's id, and turn-end cancellation leaves them pending; the question stays open until the user answers or dismisses it. - The answer is now delivered to the agent inline in the task notification — bounded to 16 KB and XML-escaped inside an `<answer>` block — instead of only a saved output path. Answered and dismissed outcomes get distinct notification text, and the transcript projection keeps background-question answers grouped with their turn. - `docs/reference/tools.md` documents the `background` behaviour on `AskUserQuestion`; two patch changesets included. - `dist-web` manifest restaged in the same commit (`packages/transcript` is a web build input; only `.web-bundle-manifest.json` changed). Verification on this commit: full pre-push gate (597s, all checks passed), agent-core-v2 suite green, typecheck 0, lint 0, leak-check A–G PASS. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [ ] I have linked a related issue (external PRs: the issue must have a maintainer's `/approve`). - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update.
1 parent 49548fb commit ec99d66

15 files changed

Lines changed: 363 additions & 35 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Deliver background question answers to the agent directly instead of via a saved output file.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Fix background questions being cancelled as soon as the agent finishes its turn.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"sourceHash": "0670132fd6ebf38fbe6620f3f406ab07bcd253ce8fe8c89e34558d03fd2a4d0f",
2+
"sourceHash": "60e31323838a7481743f77ddfb9d3e4ed318a1f3b46e1ebf55cb27305899b535",
33
"sourceFileCount": 493
44
}

docs/reference/tools.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,13 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill
9595

9696
**`AgentDynamicWorkflow`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the dynamic_workflow, or omit it to use `coder`. Pass `model` (available when [secondary-model routing](../configuration/config-files.md#subagent-model-pool) is enabled and a pool is configured — a `[secondary_model.models]` table or a lone `default_model`) to run item-spawned subagents on a pool alias or on the caller's own model (`"primary"`). Without it, item-spawned subagents bind the pool's `default_model`; without a configured pool, they inherit the caller's model. Resumed subagents keep their own model. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. Each subagent times out after 2 hours by default; configure the limit with [`[dynamic_workflow] timeout_ms`](../configuration/config-files.md#dynamic-workflow) in `config.toml` (`0` means no timeout) or the `PYTHINKER_CODE_AGENT_DYNAMIC_WORKFLOW_TIMEOUT_MS` environment variable. Print mode (`pythinker -p`) defaults to no timeout. A timed-out subagent is aborted and marked as failed in the aggregated report. In the TUI, foreground dynamicWorkflows show a live `Agent dynamic_workflow` progress panel above the input box. If a model response calls `AgentDynamicWorkflow`, that call must be the only tool call in the response; to run multiple dynamicWorkflows, call one `AgentDynamicWorkflow`, wait for its result, then call the next, or combine the work into one dynamic_workflow when a single template can cover it. In `manual` permission mode, `AgentDynamicWorkflow` calls outside active dynamic_workflow mode request approval unless a permission rule allows them; while dynamic_workflow mode is active, `AgentDynamicWorkflow` itself is auto-approved. Permission rules match `AgentDynamicWorkflow` by tool name only — argument patterns such as `AgentDynamicWorkflow(dynamic_workflow)` are not supported. By default the tool ramps up concurrency without an upper limit (5 subagents start immediately, then 1 more every 700 ms); set `[dynamic_workflow] max_concurrency` or `PYTHINKER_CODE_AGENT_DYNAMIC_WORKFLOW_MAX_CONCURRENCY` to a positive integer to cap how many subagents run at the same time across all execution phases. An invalid environment value makes the call fail fast.
9797

98-
**`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead.
98+
**`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately; the question stays open after the turn ends, and the answer is delivered to the Agent as a notification once the user responds. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead.
9999

100100
**`Skill`** allows the Agent to actively invoke a registered inline-type Skill. Accepts `skill` (the Skill name) and optional `args` (additional argument text). Only `type = "inline"` Skills can be called via this tool; Skills with `disableModelInvocation: true` are rejected. Maximum nesting depth is 3 levels. See [Agent Skills](../customization/skills.md) for details.
101101

102102
## Background Tasks
103103

104-
Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuestion`. When a task reaches a terminal state, its status and saved output path are automatically delivered back to the Agent; use `TaskOutput` to check progress early, or `WaitFor` to wait for a result inside the current turn.
104+
Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuestion`. When a task reaches a terminal state, its status and saved output path (or, for questions, the answer itself) are automatically delivered back to the Agent; use `TaskOutput` to check progress early, or `WaitFor` to wait for a result inside the current turn.
105105

106106
| Tool | Default Approval | Description |
107107
| --- | --- | --- |

packages/agent-core-v2/src/agent/task/taskService.ts

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
userCancellationReason,
1414
} from '#/_base/utils/abort';
1515
import { setClampedTimeout } from '#/_base/utils/timer';
16-
import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape';
16+
import { escapeXml, escapeXmlAttr, escapeXmlTags } from '#/_base/utils/xml-escape';
1717
import { IEventBus, ISessionEventBus } from '#/app/event/eventBus';
1818
import { Error2, ErrorCodes } from '#/errors';
1919
import { z } from 'zod';
@@ -166,6 +166,7 @@ const SIGTERM_GRACE_MS = 5_000;
166166
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
167167
const SESSION_CLOSED_REASON = 'Session closed';
168168
const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000;
169+
const QUESTION_ANSWER_INLINE_BYTES = 16_000;
169170
const ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT = 'background_task_status';
170171
const TASK_RESUME_TERMINATION_VARIANT = 'task_resume_termination';
171172
const ACTIVE_BACKGROUND_TASK_GUIDANCE = [
@@ -1286,10 +1287,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
12861287
try {
12871288
let output = emptyOutputSnapshot();
12881289
try {
1289-
output = await this.getOutputSnapshot(info.taskId, 0);
1290-
if (!output.fullOutputAvailable) {
1291-
output = await this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES);
1292-
}
1290+
output = await this.notificationOutputSnapshot(info);
12931291
} catch (error) {
12941292
this.log.error('task notification output read failed; delivering without output', {
12951293
taskId: info.taskId,
@@ -1301,10 +1299,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
13011299
if (this.deliveredNotificationKeys.has(key)) return undefined;
13021300
if (this.hasDeliveredNotification(key)) return undefined;
13031301
this.scheduledNotificationKeys.add(key);
1304-
const notification = buildAgentTaskNotification(
1305-
info,
1306-
agentTaskNotificationChildren(output),
1307-
);
1302+
const notification = buildAgentTaskNotification(info, output);
13081303
const content = [
13091304
{
13101305
type: 'text',
@@ -1317,6 +1312,15 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
13171312
}
13181313
}
13191314

1315+
private async notificationOutputSnapshot(info: AgentTaskInfo): Promise<AgentTaskOutputSnapshot> {
1316+
if (info.kind === 'question') {
1317+
return this.getOutputSnapshot(info.taskId, QUESTION_ANSWER_INLINE_BYTES);
1318+
}
1319+
const persisted = await this.getOutputSnapshot(info.taskId, 0);
1320+
if (persisted.fullOutputAvailable) return persisted;
1321+
return this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES);
1322+
}
1323+
13201324
private fireNotificationHook(notification: AgentTaskNotification): void {
13211325
if (!this.lifecycleActive()) return;
13221326
void this.dispatcher.dispatch(
@@ -1417,15 +1421,64 @@ function emptyOutputSnapshot(): AgentTaskOutputSnapshot {
14171421
}
14181422

14191423
function agentTaskNotificationChildren(
1420-
output: AgentTaskOutputSnapshot,
1424+
info: AgentTaskInfo,
1425+
output: AgentTaskOutputSnapshot | undefined,
14211426
): readonly string[] | undefined {
1427+
if (output === undefined) return undefined;
1428+
if (inlinesQuestionAnswer(info, output)) {
1429+
return output.preview.length === 0 ? undefined : [renderAnswerBlock(output.preview)];
1430+
}
14221431
if (output.fullOutputAvailable && output.outputPath !== undefined) {
14231432
return [renderOutputFileBlock(output.outputPath, output.outputSizeBytes)];
14241433
}
14251434
if (output.preview.length === 0) return undefined;
14261435
return [renderOutputPreviewBlock(output)];
14271436
}
14281437

1438+
function inlinesQuestionAnswer(info: AgentTaskInfo, output: AgentTaskOutputSnapshot): boolean {
1439+
return info.kind === 'question' && !output.truncated;
1440+
}
1441+
1442+
function renderAnswerBlock(answer: string): string {
1443+
return ['<answer>', escapeXmlTags(answer), '</answer>'].join('\n');
1444+
}
1445+
1446+
function questionNotificationText(
1447+
info: AgentTaskInfo,
1448+
output: AgentTaskOutputSnapshot | undefined,
1449+
): { readonly title: string; readonly body: string } | undefined {
1450+
if (info.status !== 'completed' || output === undefined || !inlinesQuestionAnswer(info, output)) {
1451+
return undefined;
1452+
}
1453+
const outcome = questionOutcome(output.preview);
1454+
if (outcome === 'answered') {
1455+
return {
1456+
title: 'Background question answered',
1457+
body: `The user answered "${info.description}".`,
1458+
};
1459+
}
1460+
if (outcome === 'dismissed') {
1461+
return {
1462+
title: 'Background question dismissed',
1463+
body: `The user dismissed "${info.description}" without answering.`,
1464+
};
1465+
}
1466+
return undefined;
1467+
}
1468+
1469+
function questionOutcome(output: string): 'answered' | 'dismissed' | undefined {
1470+
let parsed: unknown;
1471+
try {
1472+
parsed = JSON.parse(output);
1473+
} catch {
1474+
return undefined;
1475+
}
1476+
if (typeof parsed !== 'object' || parsed === null) return undefined;
1477+
const answers = (parsed as { readonly answers?: unknown }).answers;
1478+
if (typeof answers !== 'object' || answers === null || Array.isArray(answers)) return undefined;
1479+
return Object.keys(answers).length > 0 ? 'answered' : 'dismissed';
1480+
}
1481+
14291482
function renderOutputFileBlock(outputPath: string, outputSizeBytes: number): string {
14301483
return [
14311484
`<output-file path="${escapeXmlAttr(outputPath)}" bytes="${String(outputSizeBytes)}">`,
@@ -1532,19 +1585,20 @@ function buildAgentTaskNotificationBody(info: AgentTaskInfo): string {
15321585

15331586
function buildAgentTaskNotification(
15341587
info: AgentTaskInfo,
1535-
children?: readonly string[],
1588+
output?: AgentTaskOutputSnapshot,
15361589
): AgentTaskNotification {
1590+
const question = questionNotificationText(info, output);
15371591
return {
15381592
id: taskNotificationId(info.taskId, info.status),
15391593
category: 'task',
15401594
type: `task.${info.status}`,
15411595
source_kind: 'background_task',
15421596
source_id: info.taskId,
15431597
agent_id: info.kind === 'agent' ? info.agentId : undefined,
1544-
title: `Background ${info.kind} ${info.status}`,
1598+
title: question?.title ?? `Background ${info.kind} ${info.status}`,
15451599
severity: info.status === 'completed' ? 'info' : 'warning',
1546-
body: buildAgentTaskNotificationBody(info),
1547-
children,
1600+
body: question?.body ?? buildAgentTaskNotificationBody(info),
1601+
children: agentTaskNotificationChildren(info, output),
15481602
};
15491603
}
15501604

packages/agent-core-v2/src/agent/tools/ask-user-question/askUserQuestionTool.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,8 @@ export class AskUserQuestionTool implements IAskUserQuestionTool {
140140
isError: false,
141141
output:
142142
`task_id: ${taskId}\n` +
143-
`description: ${description}\n` +
144143
`status: ${status}\n` +
145-
`automatic_notification: true\n` +
146-
'next_step: Continue your current work; the answer will arrive automatically when the user responds.\n' +
147-
'next_step: Use TaskOutput with this task_id for a non-blocking status/answer snapshot.\n' +
148-
'next_step: Use TaskStop only if the question should be cancelled.\n' +
149-
'human_shell_hint: The pending question is also visible in the client UI.',
144+
'next_step: Continue your work; the answer arrives automatically in a later message. Use TaskStop only to cancel the question.',
150145
};
151146
}
152147

@@ -174,7 +169,7 @@ export class AskUserQuestionTool implements IAskUserQuestionTool {
174169
multiSelect: q.multi_select,
175170
})),
176171
},
177-
{ signal, agentId: this.scopeContext.agentId },
172+
{ signal, agentId: this.scopeContext.agentId, detached: args.background === true },
178173
);
179174

180175
const normalized = normalizeQuestionResult(result);

packages/agent-core-v2/src/agent/tools/ask-user-question/question-background-task.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export class QuestionBackgroundTask implements AgentTask {
4242
const result = await this.run(sink.signal);
4343
const output =
4444
typeof result.output === 'string' ? result.output : JSON.stringify(result.output);
45+
if (result.isError === true) {
46+
await sink.settle({ status: 'failed', stopReason: output });
47+
return;
48+
}
4549
sink.appendOutput(output);
4650
await sink.settle({ status: 'completed' });
4751
} catch (error: unknown) {

packages/agent-core-v2/src/features/interaction/interaction.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ export interface Interaction<TPayload = unknown> {
2020
readonly createdAt: number;
2121
}
2222

23+
export type InteractionCancellationReason = 'turn_ended' | 'agent_closed';
24+
25+
export interface InteractionCancellation {
26+
readonly cancelled: true;
27+
readonly reason: InteractionCancellationReason;
28+
}
29+
30+
export function isInteractionCancellation(response: unknown): response is InteractionCancellation {
31+
if (typeof response !== 'object' || response === null) return false;
32+
const value = response as { readonly cancelled?: unknown; readonly reason?: unknown };
33+
return value.cancelled === true && (value.reason === 'turn_ended' || value.reason === 'agent_closed');
34+
}
35+
2336
export interface InteractionResolution {
2437
readonly id: string;
2538
readonly response: unknown;

packages/agent-core-v2/src/session/question/question.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export interface ISessionQuestionService {
3838

3939
request(
4040
req: QuestionRequest,
41-
options?: { signal?: AbortSignal; agentId?: string },
41+
options?: { signal?: AbortSignal; agentId?: string; detached?: boolean },
4242
): Promise<QuestionResult>;
4343
enqueue(req: QuestionRequest): QuestionRequest & { readonly id: string };
4444
answer(id: string, result: QuestionResult): void;

packages/agent-core-v2/src/session/question/questionService.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { LifecycleScope } from '#/app/scopes';
44

55
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
66
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
7+
import { isInteractionCancellation } from '#/features/interaction/interaction';
78
import {
89
enqueueSessionInteraction,
910
listSessionPendingInteractions,
@@ -22,14 +23,20 @@ export class SessionQuestionService implements ISessionQuestionService {
2223

2324
constructor(@IAgentLifecycleService private readonly agents: IAgentLifecycleService) {}
2425

25-
request(req: QuestionRequest, options?: { signal?: AbortSignal; agentId?: string }): Promise<QuestionResult> {
26+
request(
27+
req: QuestionRequest,
28+
options?: { signal?: AbortSignal; agentId?: string; detached?: boolean },
29+
): Promise<QuestionResult> {
2630
const id = requestId(req);
27-
const pending = requestSessionInteraction<QuestionRequest, QuestionResult>(this.agents, {
31+
const pending = requestSessionInteraction<QuestionRequest, unknown>(this.agents, {
2832
id,
2933
kind: 'question',
3034
payload: req,
31-
origin: { turnId: req.turnId, agentId: options?.agentId },
32-
});
35+
origin: {
36+
turnId: options?.detached === true ? undefined : req.turnId,
37+
agentId: options?.agentId,
38+
},
39+
}).then((response) => (isInteractionCancellation(response) ? null : (response as QuestionResult)));
3340

3441
const signal = options?.signal;
3542
if (signal !== undefined) {

0 commit comments

Comments
 (0)