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

The saved-workflow write helper now takes the working directory and resolves the repository root itself, saved workflows can carry a size guideline, and the workflow size guideline resolver is exported.
5 changes: 5 additions & 0 deletions .changeset/workflow-save-scope-and-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pythoughts/pythinker-code": minor
---

`/workflow save` accepts `--personal` to save into the home skills directory, resolves the repository root when saving from a subdirectory so the saved skill is discoverable, and persists the workflow size guideline into the saved skill.
29 changes: 21 additions & 8 deletions apps/pythinker-code/src/tui/commands/dynamic-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
savedWorkflowSkillName,
writeSavedWorkflowSkill,
type PermissionMode,
type SavedWorkflowScope,
} from '@pythoughts/pythinker-code-sdk';

import { getDataDir } from '#/utils/paths';
Expand All @@ -16,7 +17,7 @@ import {
import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui';
import { formatErrorMessage } from '../utils/event-payload';
import type { SlashCommandHost } from './dispatch';
import { isDynamicWorkflowDisabled } from './workflow-availability';
import { currentWorkflowSizeGuideline, isDynamicWorkflowDisabled } from './workflow-availability';

export async function handleDynamicWorkflowCommand(host: SlashCommandHost, args: string): Promise<void> {
if (isDynamicWorkflowDisabled()) {
Expand Down Expand Up @@ -121,18 +122,29 @@ function withWorkerModelInstruction(prompt: string, model: string | undefined):
}

/**
* `/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.
* `/workflow save <name> [--personal]` 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. Project scope is the default; `--personal` keeps the skill in
* the user's home skills directory instead of the repository.
*
* 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>');
const tokens = (match[1] ?? '').split(/\s+/u).filter((token) => token.length > 0);
// A name may contain spaces, so the flag is only recognised at either end.
// Anywhere else — or twice — it is a typo rather than part of the name, and
// folding it in would silently save under a different name and scope.
const personalFirst = tokens[0] === '--personal';
const personalLast = !personalFirst && tokens.at(-1) === '--personal';
if (personalFirst) tokens.shift();
else if (personalLast) tokens.pop();
const scope: SavedWorkflowScope = personalFirst || personalLast ? 'personal' : 'project';
const name = tokens.join(' ');
if (name.length === 0 || tokens.includes('--personal')) {
host.showError('Usage: /workflow save <name> [--personal]');
return true;
}

Expand All @@ -150,8 +162,8 @@ async function handleSaveSubcommand(host: SlashCommandHost, input: string): Prom

try {
const dir = await writeSavedWorkflowSkill({
scope: 'project',
projectRoot: host.state.appState.workDir,
scope,
workDir: host.state.appState.workDir,
brandHomeDir: getDataDir(),
workflow: {
name,
Expand All @@ -161,6 +173,7 @@ async function handleSaveSubcommand(host: SlashCommandHost, input: string): Prom
model: stringArg(args, 'model'),
effort: stringArg(args, 'effort'),
outputSchema: recordArg(args, 'output_schema'),
sizeGuideline: currentWorkflowSizeGuideline(),
},
});
// The skill registry is built once when the session opens, so the file just
Expand Down
20 changes: 20 additions & 0 deletions apps/pythinker-code/src/tui/commands/workflow-availability.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
resolveWorkflowSizeGuideline,
type WorkflowSizeGuideline,
} from '@pythoughts/pythinker-code-sdk';

const DISABLE_WORKFLOWS_ENV = 'PYTHINKER_CODE_DISABLE_WORKFLOWS';
const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off']);
Expand All @@ -23,3 +28,18 @@ export function setDynamicWorkflowDisabled(configValue: boolean | undefined, env
export function isDynamicWorkflowDisabled(): boolean {
return disabled;
}

let sizeGuideline: WorkflowSizeGuideline | undefined;

/** Cache the resolved guideline. Call once at startup with the value from `harness.getConfig()`. */
export function setWorkflowSizeGuideline(
configValue: WorkflowSizeGuideline | undefined,
env = process.env,
): void {
sizeGuideline = resolveWorkflowSizeGuideline({ workflowSizeGuideline: configValue }, env);
}

/** The guideline in force for this session, for surfaces that persist it (e.g. `/workflow save`). */
export function currentWorkflowSizeGuideline(): WorkflowSizeGuideline | undefined {
return sizeGuideline;
}
Original file line number Diff line number Diff line change
Expand Up @@ -773,7 +773,7 @@
}

/** Item list from the completed tool-call `items` argument. */
export function dynamicWorkflowItemsFromArgs(args: Record<string, unknown>): string[] {
function dynamicWorkflowItemsFromArgs(args: Record<string, unknown>): string[] {
const items = args['items'];
if (!Array.isArray(items)) return [];
// Blank entries are dropped by the engine before any agent is launched, so
Expand Down Expand Up @@ -833,11 +833,6 @@
return items;
}

/** Count of `items` parsed so far from streaming arguments. */
export function dynamicWorkflowPartialItemsCountFromArguments(argumentsText: string): number {
return dynamicWorkflowPartialItemsFromArguments(argumentsText).length;
}

/** Description from the completed tool-call `description` argument. */
export function dynamicWorkflowDescriptionFromArgs(args: Record<string, unknown>): string {
const description = args['description'];
Expand All @@ -846,7 +841,7 @@

/** Best-effort `description` read from a partially streamed JSON arguments string. */
export function dynamicWorkflowPartialDescriptionFromArguments(argumentsText: string): string {
const match = /"description"\s*:\s*"/.exec(argumentsText);

Check warning on line 844 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (match === null) return '';
return parsePartialJsonString(argumentsText, match.index + match[0].length).value;
}
Expand Down Expand Up @@ -895,7 +890,7 @@
}

function dynamicWorkflowPartialResumeItemsFromArguments(argumentsText: string): string[] {
const match = /"resume_agent_ids"\s*:\s*\{/.exec(argumentsText);

Check warning on line 893 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (match === null) return [];
return Array.from(
{ length: countPartialJsonObjectEntries(argumentsText, match.index + match[0].length) },
Expand All @@ -909,7 +904,7 @@
}

function dynamicWorkflowPartialPromptTemplateFromArguments(argumentsText: string): string {
const match = /"prompt_template"\s*:\s*"/.exec(argumentsText);

Check warning on line 907 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (match === null) return '';
return parsePartialJsonString(argumentsText, match.index + match[0].length).value;
}
Expand All @@ -921,7 +916,7 @@
// Indexes are validated and deduplicated: an explicit index is honored only
// once and within range; a duplicated one is dropped, not remapped.
const usedIndexes = new Set<number>();
const tagPattern = /<subagent\b([^>]*)>/g;

Check warning on line 919 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
let match: RegExpExecArray | null;
while (
statuses.length < MAX_DYNAMIC_WORKFLOW_MEMBERS &&
Expand Down Expand Up @@ -977,7 +972,7 @@

function dynamicWorkflowResultEnvelope(output: string): string | undefined {
let candidate = output.trim();
const prefix = /^dynamic_workflow:\s*(?:(?:completed|failed|cancelled|aborted)\s*)?/i.exec(candidate);

Check warning on line 975 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (prefix !== null) candidate = candidate.slice(prefix[0].length).trimStart();
const opening = /^<dynamic_workflow_result\b[^>]*>/.exec(candidate);
if (opening === null) return undefined;
Expand Down
5 changes: 4 additions & 1 deletion apps/pythinker-code/src/tui/pythinker-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
import {
isDynamicWorkflowDisabled,
setDynamicWorkflowDisabled,
setWorkflowSizeGuideline,
} from './commands/workflow-availability';
import * as slashCommands from './commands/dispatch';
import { BannerComponent } from './components/chrome/banner';
Expand Down Expand Up @@ -746,7 +747,9 @@ export class PythinkerTUI {

private async init(): Promise<boolean> {
setExperimentalFeatures(await this.harness.getExperimentalFeatures());
setDynamicWorkflowDisabled((await this.harness.getConfig()).disableWorkflows);
const pythinkerConfig = await this.harness.getConfig();
setDynamicWorkflowDisabled(pythinkerConfig.disableWorkflows);
setWorkflowSizeGuideline(pythinkerConfig.workflowSizeGuideline);
await this.authFlow.refreshAvailableModels();
void this.refreshProviderModelsInBackground();

Expand Down
57 changes: 55 additions & 2 deletions apps/pythinker-code/test/tui/commands/dynamic-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';

import { handleDynamicWorkflowCommand } from '#/tui/commands/index';
import type { SlashCommandHost } from '#/tui/commands/dispatch';
import { setDynamicWorkflowDisabled } from '#/tui/commands/workflow-availability';
import { setDynamicWorkflowDisabled, setWorkflowSizeGuideline } from '#/tui/commands/workflow-availability';
import { currentTheme } from '#/tui/theme';

const ENTER = '\r';
Expand Down Expand Up @@ -497,6 +497,59 @@ describe('/workflow save', () => {

await handleDynamicWorkflowCommand(host, 'save');

expect(host.showError).toHaveBeenCalledWith('Usage: /workflow save <name>');
expect(host.showError).toHaveBeenCalledWith('Usage: /workflow save <name> [--personal]');
});

it('asks for a name when given only the --personal flag', async () => {
const { host } = makeHost({ permissionMode: 'auto' });

await handleDynamicWorkflowCommand(host, 'save --personal');

expect(host.showError).toHaveBeenCalledWith('Usage: /workflow save <name> [--personal]');
});

it('saves --personal into the data dir and records the size guideline', async () => {
const home = await fs.mkdtemp(join(tmpdir(), 'workflow-home-'));
vi.stubEnv('PYTHINKER_CODE_HOME', home);
// Explicit empty env: the default is process.env, where an exported
// PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE would override 'small' and fail
// this test for reasons unrelated to the change under test.
setWorkflowSizeGuideline('small', {});
try {
const { host, session } = makeHost({
permissionMode: 'auto',
lastDynamicWorkflowArgs: { description: 'Audit routes for missing auth' },
});

await handleDynamicWorkflowCommand(host, 'save --personal Audit Routes');

const saved = await fs.readFile(join(home, 'skills', 'audit-routes', 'SKILL.md'), 'utf8');
expect(saved).toContain('name: "audit-routes"');
expect(saved).toContain('size-guideline: "small"');
// The body line is what shapes the re-run; the frontmatter alone is inert.
expect(saved).toContain('at most about 5 subagents');
expect(session.reloadSkills).toHaveBeenCalledOnce();
expect(host.showError).not.toHaveBeenCalled();
} finally {
vi.unstubAllEnvs();
// The module-level cache cannot return to unset; the resolved default
// ('medium') matches what TUI startup would have cached in production.
setWorkflowSizeGuideline(undefined, {});
await fs.rm(home, { recursive: true, force: true });
}
});

it('rejects --personal when it is repeated or not at either end', async () => {
for (const input of [
'save Audit --personal Routes',
'save --personal Audit --personal',
'save --personal --personal',
]) {
const { host } = makeHost({ permissionMode: 'auto' });

await handleDynamicWorkflowCommand(host, input);

expect(host.showError).toHaveBeenCalledWith('Usage: /workflow save <name> [--personal]');
}
});
});
2 changes: 2 additions & 0 deletions docs/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ Switches that control the behavior of subsystems such as telemetry, background t
| `PYTHINKER_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) |
| `PYTHINKER_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins` | URL or local path |
| `PYTHINKER_CODE_DISABLE_WORKFLOWS` | Disable Dynamic Workflow: the `DynamicWorkflow` tool is not registered and `/workflow` is hidden; takes higher priority than `config.toml` | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE` | Override the advisory Dynamic Workflow size guideline injected into the tool guidance; takes higher priority than `config.toml` | `small`, `medium`, `large`, `unrestricted` |
| `PYTHINKER_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process; `micro_compaction` is already enabled by default | `1`, `true`, `yes`, `on` |
| `PYTHINKER_CODE_EXPERIMENTAL_MICRO_COMPACTION` | Override [`[experimental].micro_compaction`](./config-files.md#experimental) for this process | Truthy or falsy |
| `PYTHINKER_SHELL_PATH` | Override the Git Bash path on Windows (used when auto-detection fails) | Absolute path |
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 @@ -52,6 +52,7 @@ Some commands are only available in the idle state. Executing these commands whi
| `/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 |
| `/workflow save <name> [--personal]` | — | Save the last Dynamic Workflow that ran in this session as a skill, immediately invocable under its generated skill name — `Audit Routes` becomes `/audit-routes`. Saves into the project (`<repo root>/.pythinker-code/skills/`) by default; `--personal` saves into your home skills directory instead. | No |
| `/goal [...]` | — | Start or manage an autonomous goal | See below |

::: info
Expand Down
33 changes: 30 additions & 3 deletions packages/agent-core/src/agent/dynamic-workflow/save-as-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { constants, promises as fs } from 'node:fs';

import path from 'pathe';

import type { WorkflowSizeGuideline } from '../../config';
import { resolveSafePath } from '../../services/fs/fsPathSafety';
import { findProjectRoot } from '../../skill/scanner';
import { normalizeSkillName } from '../../skill/types';
import { workflowSizeGuidelineTarget } from './size-guideline';

/**
* A saved workflow's name becomes both a directory name and a slash command,
Expand Down Expand Up @@ -40,6 +43,8 @@ export interface SavedWorkflow {
readonly model?: string;
readonly effort?: string;
readonly outputSchema?: Record<string, unknown>;
/** Size guideline in force when the workflow ran, so a re-run keeps the same fan-out expectation. */
readonly sizeGuideline?: WorkflowSizeGuideline;
}

/**
Expand Down Expand Up @@ -100,7 +105,22 @@ export function renderSavedWorkflowSkill(workflow: SavedWorkflow): string {
if (workflow.effort !== undefined) {
lines.push(`effort: ${quoteYamlScalar(workflow.effort)}`);
}
if (workflow.sizeGuideline !== undefined) {
lines.push(`size-guideline: ${quoteYamlScalar(workflow.sizeGuideline)}`);
}
lines.push('---', '', `# ${workflow.description}`);
// The body is what the model reads on invocation, so the guideline has to be
// stated there to shape the re-run; the frontmatter alone is inert metadata.
const sizeTarget =
workflow.sizeGuideline === undefined
? undefined
: workflowSizeGuidelineTarget(workflow.sizeGuideline);
if (sizeTarget !== undefined) {
lines.push(
'',
`Size guideline: aim for at most about ${String(sizeTarget)} subagents in this workflow, preferring fewer, larger items over many tiny ones.`,
);
}
if (workflow.promptTemplate !== undefined) {
const fence = renderFence(workflow.promptTemplate);
lines.push('', '## Prompt template', '', fence, workflow.promptTemplate, fence);
Expand All @@ -125,6 +145,12 @@ export function renderSavedWorkflowSkill(workflow: SavedWorkflow): string {
* workflow can also keep one. The name is validated before any directory is
* created, so a rejected name leaves nothing behind.
*
* Project scope resolves the closest `.git` ancestor of `workDir` — the same
* rule the skill scanner uses to pick its project root — so a save made from a
* repository subdirectory lands where the scanner will look for it. Without
* that, the saved skill is invisible until the session is reopened at the
* repository root.
*
* A validated name is not enough on its own. Agents work in repositories they
* did not write, and a checked-out tree can already contain
* `.pythinker-code/skills/<name>/SKILL.md` as a symlink pointing anywhere on
Expand All @@ -136,15 +162,16 @@ export function renderSavedWorkflowSkill(workflow: SavedWorkflow): string {
export async function writeSavedWorkflowSkill(input: {
readonly scope: SavedWorkflowScope;
readonly workflow: SavedWorkflow;
readonly projectRoot: string;
readonly workDir: string;
readonly brandHomeDir: string;
}): Promise<string> {
const name = savedWorkflowSkillName(input.workflow.name);
const root = input.scope === 'project' ? input.projectRoot : input.brandHomeDir;
const projectRoot = input.scope === 'project' ? await findProjectRoot(input.workDir) : input.workDir;
const root = input.scope === 'project' ? projectRoot : input.brandHomeDir;
const dir = savedWorkflowSkillDir({
scope: input.scope,
name: input.workflow.name,
projectRoot: input.projectRoot,
projectRoot,
brandHomeDir: input.brandHomeDir,
});
const content = renderSavedWorkflowSkill({ ...input.workflow, name });
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export {
writeSavedWorkflowSkill,
} from './dynamic-workflow/save-as-skill';
export type { SavedWorkflow, SavedWorkflowScope } from './dynamic-workflow/save-as-skill';
export { resolveWorkflowSizeGuideline } from './dynamic-workflow/size-guideline';
export type { BuiltinTool, ToolInfo, ToolSource, UserToolRegistration } from './tool';
export * from './goal';

Expand Down
9 changes: 8 additions & 1 deletion packages/agent-core/src/skill/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,14 @@ async function defaultIsFile(p: string): Promise<boolean> {
}
}

async function findProjectRoot(workDir: string): Promise<string> {
/**
* Closest `.git` ancestor of `workDir`, or `workDir` itself when none exists.
*
* Exported because it defines where project-scoped artifacts live: anything
* that writes into `<projectRoot>/.pythinker-code` (e.g. saved workflows) must
* resolve the root the same way this scanner will later scan it.
*/
export async function findProjectRoot(workDir: string): Promise<string> {
const start = path.resolve(workDir);
let current = start;
while (true) {
Expand Down
Loading
Loading