Skip to content
Merged
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/background-question-inline-answer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Deliver background question answers to the agent directly instead of via a saved output file.
5 changes: 5 additions & 0 deletions .changeset/background-question-survives-turn-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Fix background questions being cancelled as soon as the agent finishes its turn.
2 changes: 1 addition & 1 deletion apps/pythinker-code/dist-web/.web-bundle-manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"sourceHash": "0670132fd6ebf38fbe6620f3f406ab07bcd253ce8fe8c89e34558d03fd2a4d0f",
"sourceHash": "60e31323838a7481743f77ddfb9d3e4ed318a1f3b46e1ebf55cb27305899b535",
"sourceFileCount": 493
}
4 changes: 2 additions & 2 deletions docs/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,13 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill

**`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.

**`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.
**`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.

**`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.

## Background Tasks

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.
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.

| Tool | Default Approval | Description |
| --- | --- | --- |
Expand Down
82 changes: 68 additions & 14 deletions packages/agent-core-v2/src/agent/task/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
userCancellationReason,
} from '#/_base/utils/abort';
import { setClampedTimeout } from '#/_base/utils/timer';
import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape';
import { escapeXml, escapeXmlAttr, escapeXmlTags } from '#/_base/utils/xml-escape';
import { IEventBus, ISessionEventBus } from '#/app/event/eventBus';
import { Error2, ErrorCodes } from '#/errors';
import { z } from 'zod';
Expand Down Expand Up @@ -166,6 +166,7 @@ const SIGTERM_GRACE_MS = 5_000;
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
const SESSION_CLOSED_REASON = 'Session closed';
const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000;
const QUESTION_ANSWER_INLINE_BYTES = 16_000;
const ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT = 'background_task_status';
const TASK_RESUME_TERMINATION_VARIANT = 'task_resume_termination';
const ACTIVE_BACKGROUND_TASK_GUIDANCE = [
Expand Down Expand Up @@ -1286,10 +1287,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
try {
let output = emptyOutputSnapshot();
try {
output = await this.getOutputSnapshot(info.taskId, 0);
if (!output.fullOutputAvailable) {
output = await this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES);
}
output = await this.notificationOutputSnapshot(info);
} catch (error) {
this.log.error('task notification output read failed; delivering without output', {
taskId: info.taskId,
Expand All @@ -1301,10 +1299,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
if (this.deliveredNotificationKeys.has(key)) return undefined;
if (this.hasDeliveredNotification(key)) return undefined;
this.scheduledNotificationKeys.add(key);
const notification = buildAgentTaskNotification(
info,
agentTaskNotificationChildren(output),
);
const notification = buildAgentTaskNotification(info, output);
const content = [
{
type: 'text',
Expand All @@ -1317,6 +1312,15 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
}
}

private async notificationOutputSnapshot(info: AgentTaskInfo): Promise<AgentTaskOutputSnapshot> {
if (info.kind === 'question') {
return this.getOutputSnapshot(info.taskId, QUESTION_ANSWER_INLINE_BYTES);
}
const persisted = await this.getOutputSnapshot(info.taskId, 0);
if (persisted.fullOutputAvailable) return persisted;
return this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES);
}

private fireNotificationHook(notification: AgentTaskNotification): void {
if (!this.lifecycleActive()) return;
void this.dispatcher.dispatch(
Expand Down Expand Up @@ -1417,15 +1421,64 @@ function emptyOutputSnapshot(): AgentTaskOutputSnapshot {
}

function agentTaskNotificationChildren(
output: AgentTaskOutputSnapshot,
info: AgentTaskInfo,
output: AgentTaskOutputSnapshot | undefined,
): readonly string[] | undefined {
if (output === undefined) return undefined;
if (inlinesQuestionAnswer(info, output)) {
return output.preview.length === 0 ? undefined : [renderAnswerBlock(output.preview)];
}
if (output.fullOutputAvailable && output.outputPath !== undefined) {
return [renderOutputFileBlock(output.outputPath, output.outputSizeBytes)];
}
if (output.preview.length === 0) return undefined;
return [renderOutputPreviewBlock(output)];
}

function inlinesQuestionAnswer(info: AgentTaskInfo, output: AgentTaskOutputSnapshot): boolean {
return info.kind === 'question' && !output.truncated;
}

function renderAnswerBlock(answer: string): string {
return ['<answer>', escapeXmlTags(answer), '</answer>'].join('\n');
}

function questionNotificationText(
info: AgentTaskInfo,
output: AgentTaskOutputSnapshot | undefined,
): { readonly title: string; readonly body: string } | undefined {
if (info.status !== 'completed' || output === undefined || !inlinesQuestionAnswer(info, output)) {
return undefined;
}
const outcome = questionOutcome(output.preview);
if (outcome === 'answered') {
return {
title: 'Background question answered',
body: `The user answered "${info.description}".`,
};
}
if (outcome === 'dismissed') {
return {
title: 'Background question dismissed',
body: `The user dismissed "${info.description}" without answering.`,
};
}
return undefined;
}

function questionOutcome(output: string): 'answered' | 'dismissed' | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(output);
} catch {
return undefined;
}
if (typeof parsed !== 'object' || parsed === null) return undefined;
const answers = (parsed as { readonly answers?: unknown }).answers;
if (typeof answers !== 'object' || answers === null || Array.isArray(answers)) return undefined;
return Object.keys(answers).length > 0 ? 'answered' : 'dismissed';
}

function renderOutputFileBlock(outputPath: string, outputSizeBytes: number): string {
return [
`<output-file path="${escapeXmlAttr(outputPath)}" bytes="${String(outputSizeBytes)}">`,
Expand Down Expand Up @@ -1532,19 +1585,20 @@ function buildAgentTaskNotificationBody(info: AgentTaskInfo): string {

function buildAgentTaskNotification(
info: AgentTaskInfo,
children?: readonly string[],
output?: AgentTaskOutputSnapshot,
): AgentTaskNotification {
const question = questionNotificationText(info, output);
return {
id: taskNotificationId(info.taskId, info.status),
category: 'task',
type: `task.${info.status}`,
source_kind: 'background_task',
source_id: info.taskId,
agent_id: info.kind === 'agent' ? info.agentId : undefined,
title: `Background ${info.kind} ${info.status}`,
title: question?.title ?? `Background ${info.kind} ${info.status}`,
severity: info.status === 'completed' ? 'info' : 'warning',
body: buildAgentTaskNotificationBody(info),
children,
body: question?.body ?? buildAgentTaskNotificationBody(info),
children: agentTaskNotificationChildren(info, output),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,8 @@ export class AskUserQuestionTool implements IAskUserQuestionTool {
isError: false,
output:
`task_id: ${taskId}\n` +
`description: ${description}\n` +
`status: ${status}\n` +
`automatic_notification: true\n` +
'next_step: Continue your current work; the answer will arrive automatically when the user responds.\n' +
'next_step: Use TaskOutput with this task_id for a non-blocking status/answer snapshot.\n' +
'next_step: Use TaskStop only if the question should be cancelled.\n' +
'human_shell_hint: The pending question is also visible in the client UI.',
'next_step: Continue your work; the answer arrives automatically in a later message. Use TaskStop only to cancel the question.',
};
}

Expand Down Expand Up @@ -174,7 +169,7 @@ export class AskUserQuestionTool implements IAskUserQuestionTool {
multiSelect: q.multi_select,
})),
},
{ signal, agentId: this.scopeContext.agentId },
{ signal, agentId: this.scopeContext.agentId, detached: args.background === true },
);

const normalized = normalizeQuestionResult(result);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export class QuestionBackgroundTask implements AgentTask {
const result = await this.run(sink.signal);
const output =
typeof result.output === 'string' ? result.output : JSON.stringify(result.output);
if (result.isError === true) {
await sink.settle({ status: 'failed', stopReason: output });
return;
}
sink.appendOutput(output);
await sink.settle({ status: 'completed' });
} catch (error: unknown) {
Expand Down
13 changes: 13 additions & 0 deletions packages/agent-core-v2/src/features/interaction/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ export interface Interaction<TPayload = unknown> {
readonly createdAt: number;
}

export type InteractionCancellationReason = 'turn_ended' | 'agent_closed';

export interface InteractionCancellation {
readonly cancelled: true;
readonly reason: InteractionCancellationReason;
}

export function isInteractionCancellation(response: unknown): response is InteractionCancellation {
if (typeof response !== 'object' || response === null) return false;
const value = response as { readonly cancelled?: unknown; readonly reason?: unknown };
return value.cancelled === true && (value.reason === 'turn_ended' || value.reason === 'agent_closed');
}

export interface InteractionResolution {
readonly id: string;
readonly response: unknown;
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/session/question/question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export interface ISessionQuestionService {

request(
req: QuestionRequest,
options?: { signal?: AbortSignal; agentId?: string },
options?: { signal?: AbortSignal; agentId?: string; detached?: boolean },
): Promise<QuestionResult>;
enqueue(req: QuestionRequest): QuestionRequest & { readonly id: string };
answer(id: string, result: QuestionResult): void;
Expand Down
15 changes: 11 additions & 4 deletions packages/agent-core-v2/src/session/question/questionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { LifecycleScope } from '#/app/scopes';

import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { isInteractionCancellation } from '#/features/interaction/interaction';
import {
enqueueSessionInteraction,
listSessionPendingInteractions,
Expand All @@ -22,14 +23,20 @@ export class SessionQuestionService implements ISessionQuestionService {

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

request(req: QuestionRequest, options?: { signal?: AbortSignal; agentId?: string }): Promise<QuestionResult> {
request(
req: QuestionRequest,
options?: { signal?: AbortSignal; agentId?: string; detached?: boolean },
): Promise<QuestionResult> {
const id = requestId(req);
const pending = requestSessionInteraction<QuestionRequest, QuestionResult>(this.agents, {
const pending = requestSessionInteraction<QuestionRequest, unknown>(this.agents, {
id,
kind: 'question',
payload: req,
origin: { turnId: req.turnId, agentId: options?.agentId },
});
origin: {
turnId: options?.detached === true ? undefined : req.turnId,
agentId: options?.agentId,
},
}).then((response) => (isInteractionCancellation(response) ? null : (response as QuestionResult)));

const signal = options?.signal;
if (signal !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ describe('AskUserQuestionTool', () => {
},
],
},
{ signal, agentId: 'main' },
{ signal, agentId: 'main', detached: false },
);
expect(telemetryTrack).toHaveBeenCalledWith('question_answered', {
answered: 1,
Expand Down Expand Up @@ -376,7 +376,7 @@ describe('AskUserQuestionTool', () => {
}),
],
}),
{ signal, agentId: 'main' },
{ signal, agentId: 'main', detached: false },
);
});

Expand Down Expand Up @@ -544,9 +544,13 @@ describe('AskUserQuestionTool', () => {
});

expect(result.isError).toBe(false);
expect(result.output).toContain('task_id: q_test_task_id');
expect(result.output).toContain('automatic_notification: true');
expect(result.output).toContain('human_shell_hint: The pending question is also visible in the client UI.');
expect(result.output).toBe(
[
'task_id: q_test_task_id',
'status: running',
'next_step: Continue your work; the answer arrives automatically in a later message. Use TaskStop only to cancel the question.',
].join('\n'),
);
expect(registerTask).toHaveBeenCalledOnce();
expect(registerTask.mock.calls[0]![1]).toMatchObject({ detached: true });
expect(getTask).toHaveBeenCalledWith('q_test_task_id');
Expand All @@ -571,6 +575,54 @@ describe('AskUserQuestionTool', () => {
expect(settlements).toEqual([{ status: 'completed' }]);
});

it('detaches the background question from the asking turn', async () => {
const { tool, request, lastRegisteredTask } = makeTool();
await executeTool(tool, {
turnId: 4,
toolCallId: 'call_bg_detached',
args: { ...input(), background: true },
signal,
});

const { sink } = makeSink();
await lastRegisteredTask()!.start(sink);

expect(request).toHaveBeenCalledOnce();
expect(request.mock.calls[0]![0]).toMatchObject({ turnId: 4, toolCallId: 'call_bg_detached' });
expect(request.mock.calls[0]![1]).toMatchObject({ detached: true });

await executeTool(tool, { turnId: 4, toolCallId: 'call_fg', args: input(), signal });

expect(request).toHaveBeenCalledTimes(2);
expect(request.mock.calls[1]![1]).not.toMatchObject({ detached: true });
});

it('settles failed with the tool error when the question cannot be asked', async () => {
const { tool, lastRegisteredTask } = makeTool({
request: async () => {
throw new Error2(CoreErrors.codes.NOT_IMPLEMENTED, 'Client does not support questions');
},
});
await executeTool(tool, {
turnId: 0,
toolCallId: 'call_bg_unsupported',
args: { ...input(), background: true },
signal,
});

const { sink, outputs, settlements } = makeSink();
await lastRegisteredTask()!.start(sink);

expect(outputs).toEqual([]);
expect(settlements).toEqual([
{
status: 'failed',
stopReason:
'The connected client does not support interactive questions. Do NOT call this tool again. Ask the user directly in your text response instead.',
},
]);
});

it('settles killed when the background task is aborted', async () => {
const controller = new AbortController();
const { tool, lastRegisteredTask } = makeTool({
Expand Down
Loading
Loading