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/dynamic-workflow-subagent-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pythoughts/pythinker-code": minor
---

Let a Dynamic Workflow run its subagents on a different model than the agent orchestrating them. `DynamicWorkflow` accepts `model` and `effort` for every subagent in the call, and `/workflow model <alias>` sets that model for the session so an expensive orchestrator can hand mechanical work to a cheaper or faster one.
47 changes: 46 additions & 1 deletion apps/pythinker-code/src/tui/commands/dynamic-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export async function handleDynamicWorkflowCommand(host: SlashCommandHost, args:
}

const prompt = args.trim();
if (handleModelSubcommand(host, prompt)) return;

const mode = dynamicWorkflowModeSubcommand(prompt);
if (mode !== undefined) {
await applyDynamicWorkflowMode(host, mode, `/workflow ${prompt}`);
Expand Down Expand Up @@ -93,7 +95,50 @@ async function startDynamicWorkflowTask(host: SlashCommandHost, prompt: string):
return;
}
renderDynamicWorkflowModeMarker(host, 'active');
host.sendNormalUserInput(prompt);
host.sendNormalUserInput(withWorkerModelInstruction(prompt, host.state.appState.dynamicWorkflowModel));
}

/**
* `/workflow model <alias>` is a preference, not a hard override: it reaches the
* subagents as an instruction to set DynamicWorkflow's `model` field, so the
* agent can still pick something else when the task plainly calls for it.
*/
function withWorkerModelInstruction(prompt: string, model: string | undefined): string {
return model === undefined
? prompt
: `${prompt}\n\nUse model "${model}" for the DynamicWorkflow subagents in this task.`;
}

/** Returns true when the input was a `model` subcommand and has been handled. */
function handleModelSubcommand(host: SlashCommandHost, input: string): boolean {
const match = /^model(?:\s+(.*))?$/iu.exec(input);
if (match === null) return false;

const value = match[1]?.trim() ?? '';
const current = host.state.appState.dynamicWorkflowModel;
if (value.length === 0) {
host.showStatus(
current === undefined
? 'Dynamic Workflow subagents use this session model. Set another with /workflow model <alias>.'
: `Dynamic Workflow subagents use ${current}. Clear it with /workflow model off.`,
);
return true;
}
if (value.toLowerCase() === 'off' || value.toLowerCase() === 'clear') {
host.setAppState({ dynamicWorkflowModel: undefined });
host.showStatus('Dynamic Workflow subagents now use this session model.');
return true;
}
// An alias the engine cannot resolve falls back to the session model at spawn
// time, so accepting one here would report a routing that never happens.
const configured = host.state.appState.availableModels;
if (Object.keys(configured).length > 0 && !Object.hasOwn(configured, value)) {
host.showError(`Unknown model: ${value}. Run /model to see the configured aliases.`);
return true;
}
host.setAppState({ dynamicWorkflowModel: value });
host.showStatus(`Dynamic Workflow subagents will use ${value}.`);
return true;
}

async function applyDynamicWorkflowMode(
Expand Down
3 changes: 2 additions & 1 deletion apps/pythinker-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const GOAL_NEXT_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
const DYNAMIC_WORKFLOW_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
{ value: 'on', description: 'Turn Dynamic Workflow mode on' },
{ value: 'off', description: 'Turn Dynamic Workflow mode off' },
{ value: 'model', description: 'Set the model Dynamic Workflow subagents run on' },
];

const FAST_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
Expand Down Expand Up @@ -154,7 +155,7 @@ export const BUILTIN_SLASH_COMMANDS = [
{
name: 'workflow',
aliases: [],
description: 'Toggle Dynamic Workflow or run a task in parallel',
description: 'Toggle Dynamic Workflow, set its subagent model, or run a task in parallel',
priority: 100,
completeArgs: dynamicWorkflowArgumentCompletions,
availability: 'idle-only',
Expand Down
3 changes: 3 additions & 0 deletions apps/pythinker-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export interface AppState {
permissionMode: PermissionMode;
planMode: boolean;
dynamicWorkflowMode: boolean;
/** Model alias `/workflow` asks Dynamic Workflow subagents to run on, so workers
* can use a cheaper or faster model than the agent orchestrating them. */
dynamicWorkflowModel?: string;
/** Whether provider-native Fast mode is requested for this session. */
fastMode?: boolean;
/** Whether the current model/provider accepts provider-native Fast mode. */
Expand Down
55 changes: 55 additions & 0 deletions apps/pythinker-code/test/tui/commands/dynamic-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function makeHost(
hasSession?: boolean;
permissionMode?: 'manual' | 'auto' | 'yolo';
dynamicWorkflowMode?: boolean;
availableModels?: Record<string, unknown>;
} = {},
) {
const session = {
Expand All @@ -35,6 +36,9 @@ function makeHost(
model: overrides.model ?? 'pythinker-model',
permissionMode: overrides.permissionMode ?? 'auto',
dynamicWorkflowMode: overrides.dynamicWorkflowMode ?? false,
availableModels: overrides.availableModels ?? {
'deepseek-v4': { provider: 'deepseek', model: 'deepseek-v4' },
},
},
theme: currentTheme,
transcriptContainer: { addChild: vi.fn() },
Expand Down Expand Up @@ -336,4 +340,55 @@ describe('handleDynamicWorkflowCommand', () => {
expect(markerAddChild(host)).not.toHaveBeenCalled();
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
});

it('sets, reports, and clears the Dynamic Workflow subagent model', async () => {
const { host, session } = makeHost({ permissionMode: 'auto' });

await handleDynamicWorkflowCommand(host, 'model');
expect(host.showStatus).toHaveBeenLastCalledWith(
expect.stringContaining('use this session model'),
);

await handleDynamicWorkflowCommand(host, 'model deepseek-v4');
expect(host.showStatus).toHaveBeenLastCalledWith('Dynamic Workflow subagents will use deepseek-v4.');
expect(host.state.appState.dynamicWorkflowModel).toBe('deepseek-v4');

await handleDynamicWorkflowCommand(host, 'model');
expect(host.showStatus).toHaveBeenLastCalledWith(
expect.stringContaining('subagents use deepseek-v4'),
);

await handleDynamicWorkflowCommand(host, 'model off');
expect(host.showStatus).toHaveBeenLastCalledWith(
'Dynamic Workflow subagents now use this session model.',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(host.state.appState.dynamicWorkflowModel).toBeUndefined();

// A model subcommand must never be mistaken for a task prompt.
expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled();
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
});

it('rejects a model alias that is not configured', async () => {
const { host } = makeHost({ permissionMode: 'auto' });

await handleDynamicWorkflowCommand(host, 'model not-a-real-alias');

expect(host.showError).toHaveBeenCalledWith(
expect.stringContaining('Unknown model: not-a-real-alias'),
);
expect(host.state.appState.dynamicWorkflowModel).toBeUndefined();
expect(host.sendNormalUserInput).not.toHaveBeenCalled();
});

it('asks the task to route subagents to the configured model', async () => {
const { host } = makeHost({ permissionMode: 'auto' });

await handleDynamicWorkflowCommand(host, 'model deepseek-v4');
await handleDynamicWorkflowCommand(host, 'Audit every route for missing auth');

expect(host.sendNormalUserInput).toHaveBeenCalledWith(
'Audit every route for missing auth\n\nUse model "deepseek-v4" for the DynamicWorkflow subagents in this task.',
);
});
});
3 changes: 2 additions & 1 deletion apps/pythinker-code/test/tui/commands/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,9 @@ describe('built-in slash command registry', () => {
return items === null ? null : items.map((item) => item.value);
};

expect(values('')).toEqual(['on', 'off']);
expect(values('')).toEqual(['on', 'off', 'model']);
expect(values('O')).toEqual(['on', 'off']);
expect(values('mod')).toEqual(['model']);
expect(dynamicWorkflowArgumentCompletions('of')).toEqual([
{ value: 'off', label: 'off', description: 'Turn Dynamic Workflow mode off' },
]);
Expand Down
1 change: 1 addition & 0 deletions docs/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Some commands are only available in the idle state. Executing these commands whi
| `/fast [on\|off\|status]` | — | Toggle provider-native Fast mode for the current session, or show its status. Without arguments, flips the current state | Status only |
| `/workflow [on\|off]` | — | Toggle Dynamic Workflow mode without sending a prompt. Without arguments, flips the current state; explicitly passing `on`/`off` forces the setting. | No |
| `/workflow <task>` | — | Turn Dynamic Workflow mode on, then send `<task>` as a normal prompt. If the turn completes normally, Dynamic Workflow mode turns off automatically. In `manual` permission mode, Pythinker Code asks whether to switch to `auto` or `yolo` before starting. | No |
| `/workflow model [alias\|off]` | — | Ask Dynamic Workflow subagents to run on `alias` instead of the session model, so workers can use a cheaper or faster model than the agent orchestrating them. Without arguments, shows the current setting; `off` clears it. Lasts for the session. | No |
| `/goal [...]` | — | Start or manage an autonomous goal | See below |

::: info
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill

**`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), and `run_in_background` (defaults to false). Agent tasks have a fixed 30-minute timeout. In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details.

**`DynamicWorkflow`** launches several independent subagents in parallel, resumes existing subagents through `resume_agent_ids`, or combines both in one call. It always requires `description`, a short summary of the whole workflow. Each entry in `items` launches one new subagent: without `prompt_template`, every entry is a complete prompt on its own; with `prompt_template`, the template must contain the `{{item}}` placeholder and each entry replaces it. Item prompts must be distinct — duplicates are rejected. Pass `subagent_type` to choose the profile used by every spawned subagent, or omit it to use `coder`. 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 of them to finish, and returns an aggregated report. Workflow subagents have no automatic timeout; they run until completion, failure, or user cancellation. If a model response calls `DynamicWorkflow`, that call must be the only tool call in the response; to run several workflows, call one `DynamicWorkflow`, wait for its result, then call the next, or combine the work into a single workflow. In `manual` permission mode, `DynamicWorkflow` calls outside active Dynamic Workflow mode request approval unless a permission rule allows them; while Dynamic Workflow mode is active, `DynamicWorkflow` itself is auto-approved. Permission rules match `DynamicWorkflow` by tool name only — argument patterns such as `DynamicWorkflow(workflow)` are not supported.
**`DynamicWorkflow`** launches several independent subagents in parallel, resumes existing subagents through `resume_agent_ids`, or combines both in one call. It always requires `description`, a short summary of the whole workflow. Each entry in `items` launches one new subagent: without `prompt_template`, every entry is a complete prompt on its own; with `prompt_template`, the template must contain the `{{item}}` placeholder and each entry replaces it. Item prompts must be distinct — duplicates are rejected. Pass `subagent_type` to choose the profile used by every spawned subagent, or omit it to use `coder`. Pass `model` and `effort` to run this workflow's subagents on a different model than the agent orchestrating them — a cheaper or faster model for mechanical work, for example; both apply to every subagent in the call, and omitting them falls back to the subagent profile's own settings and then to the calling agent's. A `model` the provider cannot resolve falls back to the calling agent's model rather than failing the run. 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 of them to finish, and returns an aggregated report. Workflow subagents have no automatic timeout; they run until completion, failure, or user cancellation. If a model response calls `DynamicWorkflow`, that call must be the only tool call in the response; to run several workflows, call one `DynamicWorkflow`, wait for its result, then call the next, or combine the work into a single workflow. In `manual` permission mode, `DynamicWorkflow` calls outside active Dynamic Workflow mode request approval unless a permission rule allows them; while Dynamic Workflow mode is active, `DynamicWorkflow` itself is auto-approved. Permission rules match `DynamicWorkflow` by tool name only — argument patterns such as `DynamicWorkflow(workflow)` are not supported.

In the TUI, a foreground workflow shows a live framed mission-control panel with a coral title. The panel lists one row per subagent with a compact progress cube, state, task, current work, and elapsed time, followed by a recent-activity log. Each cube advances only through observed execution milestones such as startup, model output, tool use, and finalization; it does not predict time remaining. The summary reports only factual completion, failure, and cancellation counts plus elapsed time, without an estimated aggregate percentage or progress bar. In a narrow terminal the per-agent cubes are dropped before subagent identity or state; when vertical space runs out, rows are clipped in workflow-index order and the remainder is summarized as `+ N more agents`.

Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core/src/session/subagent-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ type BaseQueuedSubagentTask<T> = {
readonly runInBackground: boolean;
readonly timeout?: number;
readonly signal?: AbortSignal;
readonly modelAlias?: string;
readonly thinkingLevel?: string;
};

export type SpawnQueuedSubagentTask<T = unknown> = BaseQueuedSubagentTask<T> & {
Expand Down Expand Up @@ -286,6 +288,8 @@ export class SubagentBatch<T> {
dynamicWorkflowIndex: task.dynamicWorkflowIndex,
dynamicWorkflowItem: task.dynamicWorkflowItem,
runInBackground: task.runInBackground,
modelAlias: task.modelAlias,
thinkingLevel: task.thinkingLevel,
signal: attempt.controller.signal,
onReady: () => {
this.markAttemptReady(attempt);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Use DynamicWorkflow when several independent subagents should run in parallel. W

Use `resume_agent_ids` to continue subagents that already exist from earlier work, such as ones that failed: map each agent id to the prompt for that resumed subagent (usually `continue` if no extra information is needed). You may combine `resume_agent_ids` with `items` in the same call to resume existing subagents and launch new ones. Do not duplicate resumed work in `items`.

Use `model` and `effort` to run this workflow's subagents on a different model than the one orchestrating them, such as a cheaper or faster model for mechanical work while the orchestration stays on the current model. Both apply to every subagent in the call. Omitting either falls back to the subagent type's own setting, and then to your current setting. A `model` that is not a configured alias also falls back to your current model rather than failing the workflow.

Use enough subagents to keep the work focused and parallel. DynamicWorkflow supports up to 128 subagents, and launches are queued automatically, so it is safe to split large tasks into many clear, independent items. Workflow subagents have no automatic timeout; they run until completion, failure, or user cancellation.

If `DynamicWorkflow` is called, that call must be the only tool call in the response.
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ export const DynamicWorkflowToolInputSchema = z
.describe(
'Map of existing subagent agent_id to the prompt used to resume that subagent. These resumed subagents are launched before new item-based subagents.',
),
model: z
.string()
.trim()
.min(1)
.optional()
.describe(
'Model alias for every subagent in this workflow, so the orchestrator can run on one model while the workers run on a cheaper or faster one. Defaults to the subagent type profile model, then this agent model.',
),
effort: z
.string()
.trim()
.min(1)
.optional()
.describe(
'Reasoning effort for every subagent in this workflow. Defaults to the subagent type profile effort, then this agent effort.',
),
})
.strict();

Expand Down Expand Up @@ -145,6 +161,11 @@ export class DynamicWorkflowTool implements BuiltinTool<DynamicWorkflowToolInput
dynamicWorkflowIndex: spec.index,
runInBackground: false,
dynamicWorkflowItem: spec.item,
// Undefined falls through to the profile, then the parent agent, in
// SessionSubagentHost — so a workflow can run its workers on a
// different model (and provider) than the orchestrating agent.
modelAlias: normalizeOptionalString(args.model),
thinkingLevel: normalizeOptionalString(args.effort),
signal,
};
if (spec.kind === 'resume') {
Expand Down
Loading
Loading