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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Run a workflow to audit every route under src/routes/ for missing auth checks.

Pi writes and starts the workflow in the background. A live panel tracks progress while you keep working, and the final result is delivered back into the conversation automatically.

Keyword triggering is on by default: use the bounded word **workflow** or **workflows** in a message to force workflow mode, or run `/workflows run <prompt>` explicitly. Identifier-like text and paths such as `myworkflow`, `workflow_name`, and `src/workflow-editor.ts` do not trigger. You can change the keyword with `/workflows-trigger set pi-workflow` or disable it with `/workflows-trigger off`.
Keyword triggering is on by default: use the bounded word **workflow** or **workflows** in a message to arm workflow mode — the assistant then handles a request by fanning it out across agents, but still answers plainly if you're only asking *about* workflows (the trigger authorizes the tool, it doesn't force it). Or run `/workflows run <prompt>` explicitly. Identifier-like text and paths such as `myworkflow`, `workflow_name`, and `src/workflow-editor.ts` do not trigger. You can change the keyword with `/workflows-trigger set pi-workflow` or disable it with `/workflows-trigger off`.

## How it works

Expand Down Expand Up @@ -114,7 +114,7 @@ Pi can manage background runs directly with the `workflow_control` tool instead
| Command | Purpose |
| --- | --- |
| `/workflows` | Open the interactive run navigator |
| `/workflows run <prompt>` | Force a workflow even when keyword triggering is off |
| `/workflows run <prompt>` | Arm workflow mode for a prompt even when keyword triggering is off |
| `/workflows status <id>` | Watch a run and print its result when complete |
| `/workflows pause\|resume\|stop\|rm <id>` | Control a run |
| `/workflows save <name>` | Save the latest script as a reusable command |
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export type {
} from "./workflow-control-tool.js";
export { createWorkflowControlTool } from "./workflow-control-tool.js";
export {
type ArmReason,
buildArmedWorkflowPrompt,
buildForcedWorkflowPrompt,
colorizeWorkflow,
endsWithTrigger,
Expand Down
9 changes: 6 additions & 3 deletions src/workflow-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,14 @@ export function registerWorkflowCommands(

const effort = opts.effort;
const extra = effort && effort.level !== "off" ? effortDirective(effort.level) : undefined;
const forced = buildForcedWorkflowPrompt(prompt, extra);
ctx.ui.notify(`Forcing workflow: ${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}`, "info");
// `/workflows run` is an explicit, maximal-intent command — use the
// forcing directive (no "if it's a question just answer" escape),
// distinct from the heuristic keyword/effort arming.
const armed = buildForcedWorkflowPrompt(prompt, extra);
ctx.ui.notify(`Running workflow: ${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}`, "info");
try {
await pi.sendMessage(
{ customType: "workflow-run", content: forced, display: true },
{ customType: "workflow-run", content: armed, display: true },
{ triggerTurn: true, deliverAs: "followUp" },
);
} catch {
Expand Down
124 changes: 107 additions & 17 deletions src/workflow-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,26 +384,104 @@ export class WorkflowEditor extends CustomEditor {
}

/**
* The directive appended to a submitted message when workflows mode is armed.
* `extraDirective` (e.g. an effort-tier nudge) is appended when present.
* Why a turn was armed. This is stated truthfully in the banner so the model
* isn't told "the trigger word you typed" on a path where no word was typed:
* - "keyword": the user typed the configured workflow trigger word.
* - "effort": standing `/effort` armed this turn (no workflow word was typed).
*/
export function buildForcedWorkflowPrompt(text: string, extraDirective?: string): string {
export type ArmReason = "keyword" | "effort";

/**
* Appended to the effort-path directive: standing `/effort` arms on every
* substantive message, so the model must be told it can decline the workflow on
* a conversational or trivial turn (mirrors "solo only on conversational turns").
*/
export const EFFORT_CONVERSATIONAL_ESCAPE =
"This turn was armed by standing effort mode, not by an explicit workflow request: if it is conversational or trivial, skip the workflow and just respond directly.";

/** The one-line, truthful "why armed" clause for each heuristic arming path. */
function armReasonClause(reason: ArmReason): string {
return reason === "keyword"
? "you typed the workflow trigger word, which counts as an explicit opt-in to multi-agent orchestration"
: "standing effort mode armed this turn (you did not explicitly ask for a workflow)";
}

/**
* The #89 reassurance shared by every arming banner: a background run ENDING the
* turn is expected, not a stall — the result auto-delivers back — so the model
* shouldn't feel it must stay and block, nor avoid the tool to stay interactive.
* Names when `background:false` is the right call (user waiting inline).
*/
const BACKGROUND_DELIVERY_REASSURANCE =
"If you do call `workflow`, it runs in the background by default: this turn will end and the result is delivered back into the conversation automatically when it finishes — that's expected, not a stall, so you do not need to stay and block. Only pass background:false if the user is waiting for the result inline in this same turn.";

/**
* The directive appended to a submitted message when workflows mode is ARMED by a
* HEURISTIC path — the keyword trigger or standing `/effort`. (The explicit
* `/workflows run` command uses {@link buildForcedWorkflowPrompt} instead.)
*
* This authorizes — it does not force. Arming is a confirmed opt-in signal that
* lifts the always-on "do not call the tool" gate for THIS message; the model
* still decides whether the message is actually a request to do work (→ call the
* `workflow` tool) or just talk about workflows (→ answer directly). The old
* "You MUST / the ONLY acceptable action / Do NOT answer directly" forcing text
* caused two bugs: it over-triggered on messages that merely mention workflows
* (#88), and — by commanding the model to emit nothing but one `workflow` call
* and not talk — it produced a bare background run that ends the turn and leaves
* the user at an idle prompt (#89).
*
* The banner therefore (1) LEADS with the decision boundary (question/trivial →
* answer directly; a real decomposable request → call `workflow`) rather than
* leading with "call the tool"; (2) states the truthful opt-in `reason` for THIS
* path (no "the word you typed" on the effort path, where none was); and (3)
* carries the #89 background/deliver-back reassurance so an ending turn reads as
* expected. The how-to mechanics are NOT here — they live in the tool's static
* `description` (see createWorkflowTool), visible whenever the model looks at the
* tool, so they aren't re-injected per armed turn (#65).
*
* `extraDirective` (e.g. an effort-tier nudge + EFFORT_CONVERSATIONAL_ESCAPE) is
* appended when present.
*/
export function buildArmedWorkflowPrompt(
text: string,
opts: { reason?: ArmReason; extraDirective?: string } = {},
): string {
const reason = opts.reason ?? "keyword";
const lines = [
text,
"",
"---",
"[workflows mode is ON for this message]",
"You MUST handle this request by calling the tool named exactly `workflow` (Pi's",
"deterministic JavaScript workflow-orchestration tool from pi-dynamic-workflows).",
"Write a workflow script that fans the task out across subagents via",
"agent()/parallel()/pipeline().",
"[workflows mode armed. Decide first: if this message is a question, a trivial task, or",
"just talk (about workflows, this repo, or the tool itself), answer it directly and stay",
"conversational — arming authorizes the tool, it does not force it. If it is a real,",
"decomposable request to do work, handle it by calling the `workflow` tool: write a script",
"that fans the task out across subagents via agent()/parallel()/pipeline().",
`Why this turn is armed: ${armReasonClause(reason)}.`,
BACKGROUND_DELIVERY_REASSURANCE + "]",
];
if (opts.extraDirective) lines.push("", opts.extraDirective);
return lines.join("\n");
}

/**
* The directive for the explicit `/workflows run <prompt>` command. Unlike the
* heuristic {@link buildArmedWorkflowPrompt}, `/workflows run` is a maximal-intent
* command — the user typed a command whose whole purpose is to execute a workflow
* now — so it does NOT get the "if it's a question, just answer" escape. It still
* avoids the old MUST/ONLY forcing language (which caused #88/#89) and still
* carries the #89 background/deliver-back reassurance so an ending turn reads as
* expected. `extraDirective` (e.g. a standing effort-tier nudge) is appended.
*/
export function buildForcedWorkflowPrompt(text: string, extraDirective?: string): string {
const lines = [
text,
"",
"The ONLY acceptable action is a `workflow` tool call. Do NOT instead:",
"- answer directly or in prose,",
"- call the `subagent` tool yourself,",
"- use any skill or command (e.g. pi-subagents, /code-review, deep-research),",
'- or interpret the word "workflow/workflows" loosely as some other parallel/audit approach.',
"Even for a small task, wrap it in a minimal `workflow` call with at least one agent().",
"---",
"[/workflows run — you ran an explicit command to execute a workflow for this request.",
"Call the `workflow` tool now: write a script that fans this task out across subagents",
"via agent()/parallel()/pipeline(). (This is a direct command, not a heuristic guess, so",
"do not answer in prose instead of running the workflow.)",
BACKGROUND_DELIVERY_REASSURANCE + "]",
];
if (extraDirective) lines.push("", extraDirective);
return lines.join("\n");
Expand Down Expand Up @@ -603,10 +681,22 @@ export function installWorkflowEditor(
pi.setActiveTools?.(current);
}
} catch {
// Tool restriction is best-effort; the directive still forces the workflow.
// Tool restriction is best-effort; the armed directive still authorizes the workflow.
}
const extra = byEffort && effort ? effortDirective(effort.level) : undefined;
return { action: "transform", text: buildForcedWorkflowPrompt(event.text, extra) } as const;
// Effort path: the trigger word was NOT typed — this arms on ANY substantive
// message while standing effort is on. So the directive must (a) state the
// truthful "effort" reason (not "the word you typed"), and (b) let the model
// skip the workflow entirely on conversational/trivial turns, not just on
// questions about workflows.
const extra =
byEffort && effort
? [effortDirective(effort.level), EFFORT_CONVERSATIONAL_ESCAPE].filter(Boolean).join(" ")
: undefined;
const reason: ArmReason = byEffort ? "effort" : "keyword";
return {
action: "transform",
text: buildArmedWorkflowPrompt(event.text, { reason, extraDirective: extra }),
} as const;
});

// Restore the user's full tool set once the forced turn completes.
Expand Down
Loading