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
18 changes: 18 additions & 0 deletions .changeset/dynamic-workflow-plan-approval.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@pythoughts/pythinker-code-sdk': minor
'@pythoughts/pythinker-code': minor
---

Show the plan before a Dynamic Workflow runs, and let a good one be saved as a command

Manual mode used to approve every `DynamicWorkflow` call outright. That approval
only ever fired in manual mode — auto and yolo approve earlier in the chain — so
the one mode whose purpose is to ask was the one mode that never saw what it was
agreeing to. A `DynamicWorkflow` call in manual mode now asks, and the approval
carries the fan-out: how many subagents, the task list, the prompt template, the
worker model, and the summed size of the prompts about to be sent. "Approve for
this session" is keyed to that workflow's description rather than granting every
future `DynamicWorkflow` call.

`/workflow save <name>` writes the last run back out as a skill under
`.pythinker-code/skills/`, so a fan-out that worked can be re-run by name.
71 changes: 70 additions & 1 deletion apps/pythinker-code/src/tui/commands/dynamic-workflow.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { PermissionMode } from '@pythoughts/pythinker-code-sdk';
import {
savedWorkflowSkillName,
writeSavedWorkflowSkill,
type PermissionMode,
} from '@pythoughts/pythinker-code-sdk';

import { getDataDir } from '#/utils/paths';
import {
DynamicWorkflowStartPermissionPromptComponent,
type DynamicWorkflowStartPermissionChoice,
Expand All @@ -25,6 +30,7 @@ export async function handleDynamicWorkflowCommand(host: SlashCommandHost, args:

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

const mode = dynamicWorkflowModeSubcommand(prompt);
if (mode !== undefined) {
Expand Down Expand Up @@ -114,6 +120,69 @@ function withWorkerModelInstruction(prompt: string, model: string | undefined):
: `${prompt}\n\nUse model "${model}" for the DynamicWorkflow subagents in this task.`;
}

/**
* `/workflow save <name>` writes the last run back out as a skill, so a fan-out
* that worked can be re-run by name instead of re-described.
*
* Returns true when the input was a `save` subcommand and has been handled.
*/
async function handleSaveSubcommand(host: SlashCommandHost, input: string): Promise<boolean> {
const match = /^save(?:\s+(.*))?$/iu.exec(input);
if (match === null) return false;

const name = match[1]?.trim() ?? '';
if (name.length === 0) {
host.showError('Usage: /workflow save <name>');
return true;
}

const args = host.state.lastDynamicWorkflowArgs;
if (args === undefined) {
host.showError('No Dynamic Workflow has run in this session yet.');
return true;
}

const description = stringArg(args, 'description');
if (description === undefined) {
host.showError('The last Dynamic Workflow has no description to save.');
return true;
}

try {
const dir = await writeSavedWorkflowSkill({
scope: 'project',
projectRoot: host.state.appState.workDir,
brandHomeDir: getDataDir(),
workflow: {
name,
description,
subagentType: stringArg(args, 'subagent_type'),
promptTemplate: stringArg(args, 'prompt_template'),
model: stringArg(args, 'model'),
effort: stringArg(args, 'effort'),
outputSchema: recordArg(args, 'output_schema'),
},
});
host.refreshSlashCommandAutocomplete();
host.showStatus(`Saved /${savedWorkflowSkillName(name)} to ${dir}.`);
} catch (error) {
host.showError(`Failed to save workflow: ${formatErrorMessage(error)}`);
}
return true;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function stringArg(args: Record<string, unknown>, key: string): string | undefined {
const value = args[key];
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
}

function recordArg(args: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
const value = args[key];
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}

/** 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);
Expand Down
1 change: 1 addition & 0 deletions apps/pythinker-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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' },
{ value: 'save', description: 'Save the last Dynamic Workflow as a reusable command' },
];

const FAST_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
Expand Down
58 changes: 58 additions & 0 deletions apps/pythinker-code/src/tui/components/dialogs/approval-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* Container-based component with keyboard navigation.
*/

import { stripVTControlCharacters } from 'node:util';

import {
Container,
Input,
Expand Down Expand Up @@ -32,6 +34,7 @@ import type {
DisplayBlock,
FileContentDisplayBlock,
PendingApproval,
WorkflowPlanDisplayBlock,
} from '#/tui/reverse-rpc/types';
import { printableChar } from '#/tui/utils/printable-key';

Expand Down Expand Up @@ -167,6 +170,8 @@ function renderDisplayBlock(
}
return lines;
}
case 'workflow_plan':
return renderWorkflowPlanDisplayBlock(block, s);
case 'brief':
return block.text
? block.text.split('\n').map((line) => (line.length > 0 ? s.strong(line) : ''))
Expand All @@ -182,6 +187,57 @@ function renderDisplayBlock(
}
}

/**
* A workflow can carry up to 128 items. Listing all of them would push the
* buttons off the screen, so the panel shows enough to judge the shape of the
* fan-out and says how many it held back.
*/
const MAX_PREVIEW_ITEMS = 10;

/**
* The plan is the thing being approved, and every field in it came from the
* model. Escape sequences would let that text repaint the panel it is being
* judged in — hide a line, redraw the buttons, or reverse the reading order —
* so they are removed rather than styled. `stripVTControlCharacters` takes the
* CSI and OSC sequences; the class escape then takes the bare control and
* format characters it leaves behind, which include the bidi overrides.
*/
function sanitizePlanText(text: string): string {
return stripVTControlCharacters(text).replaceAll(/[\p{Cc}\p{Cf}]/gu, ' ');
}

function renderWorkflowPlanDisplayBlock(
block: WorkflowPlanDisplayBlock,
s: BlockStyles,
): string[] {
const plural = block.agent_count === 1 ? 'subagent' : 'subagents';
const summary = [
`${String(block.agent_count)} ${plural}`,
`~${String(block.prompt_tokens)} prompt tokens`,
];
if (block.model !== undefined && block.model.length > 0) {
summary.push(`model: ${sanitizePlanText(block.model)}`);
}
const lines = [s.strong(summary.join(' '))];

if (block.prompt_template !== undefined && block.prompt_template.length > 0) {
lines.push(
`${s.accent('prompt')} ${s.dim(truncateOneLine(sanitizePlanText(block.prompt_template), 200))}`,
);
}

for (const [index, item] of block.items.slice(0, MAX_PREVIEW_ITEMS).entries()) {
lines.push(
s.dim(`${String(index + 1).padStart(3)}. ${truncateOneLine(sanitizePlanText(item), 120)}`),
);
}
const hidden = block.items.length - MAX_PREVIEW_ITEMS;
if (hidden > 0) {
lines.push(s.dim(` +${String(hidden)} more`));
}
return lines;
}

function normalizeApprovalText(text: string): string {
return text.replaceAll('\r\n', '\n').trim();
}
Expand Down Expand Up @@ -209,6 +265,8 @@ function headerFor(toolName: string): string {
return 'Stop this task?';
case 'ExitPlanMode':
return 'Ready to build with this plan?';
case 'DynamicWorkflow':
return 'Run this Dynamic Workflow?';
default:
return `Approve ${toolName}?`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,10 @@ export class SubAgentEventHandler {
if (this.isRetiredDynamicWorkflowToolCall(toolCallId)) return;
const missionControl = this.ensureDynamicWorkflowMissionControl(toolCallId, args);
missionControl.markInputComplete();
// Captured here rather than in `ensure…`, which the delta path also calls:
// mid-stream arguments are half-parsed, and saving those would write a
// workflow missing most of its items.
this.host.state.lastDynamicWorkflowArgs = args;
this.requestRender();
}

Expand Down
16 changes: 14 additions & 2 deletions apps/pythinker-code/src/tui/reverse-rpc/approval/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,15 +300,27 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] {
scope: display.scope,
},
];
case 'agent_call':
return [
case 'agent_call': {
const blocks: DisplayBlock[] = [
{
type: 'invocation',
kind: 'agent',
name: display.agent_name ?? '',
description: display.prompt,
},
];
if (display.workflow !== undefined) {
blocks.push({
type: 'workflow_plan',
agent_count: display.workflow.agent_count,
items: [...display.workflow.items],
prompt_tokens: display.workflow.prompt_tokens,
prompt_template: display.workflow.prompt_template,
model: display.workflow.model,
});
}
return blocks;
}
case 'skill_call':
return [
{
Expand Down
14 changes: 14 additions & 0 deletions apps/pythinker-code/src/tui/reverse-rpc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ export interface InvocationDisplayBlock {
description?: string | undefined;
}

/**
* The fan-out a Dynamic Workflow is about to launch. Shown at approval time so
* the decision is made against the actual task list rather than a count.
*/
export interface WorkflowPlanDisplayBlock {
type: 'workflow_plan';
agent_count: number;
items: string[];
prompt_tokens: number;
prompt_template?: string;
model?: string;
}

export interface TodoDisplayItem {
title: string;
status: 'pending' | 'in_progress' | 'done';
Expand Down Expand Up @@ -95,6 +108,7 @@ export type DisplayBlock =
| UrlFetchDisplayBlock
| SearchDisplayBlock
| InvocationDisplayBlock
| WorkflowPlanDisplayBlock
| TodoDisplayBlock
| BackgroundTaskDisplayBlock;

Expand Down
7 changes: 7 additions & 0 deletions apps/pythinker-code/src/tui/tui-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ export interface TUIState {
externalEditorRunning: boolean;
queuedMessages: QueuedMessage[];
dynamicWorkflowModeEntry: 'manual' | 'task' | undefined;
/**
* Arguments of the most recent DynamicWorkflow tool call, so `/workflow save`
* can turn a run that just worked into a reusable command. Overwritten as the
* call streams in; the last write is the complete one.
*/
lastDynamicWorkflowArgs: Record<string, unknown> | undefined;
}

export function createTUIState(options: PythinkerTUIOptions): TUIState {
Expand Down Expand Up @@ -143,5 +149,6 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState {
externalEditorRunning: false,
queuedMessages: [],
dynamicWorkflowModeEntry: undefined,
lastDynamicWorkflowArgs: undefined,
};
}
Loading
Loading