Skip to content

Commit db23a3c

Browse files
authored
feat: authorize keyword triggering instead of forcing it (addresses #88, #89) (#93)
* feat(workflow): authorize keyword triggering instead of forcing it Reframes keyword/effort triggering from 'force a workflow' to 'arm the tool and let the model decide', addressing #88 (over-triggering), #89 (triggered runs going idle instead of staying interactive), and cutting the always-on prompt (part of #65). - buildForcedWorkflowPrompt -> buildArmedWorkflowPrompt: the trigger now injects an authorize directive ('typing the word is explicit opt-in; if it's a request call workflow, if it's a question just answer -- the opt-in does not force a workflow') instead of 'You MUST / the ONLY acceptable action / Do NOT answer'. A lexical false-positive is no longer amplified into a forced wrong action. - #89 falls out of de-forcing: the idle was a downstream effect of the model being told to emit nothing but one bare background workflow call (which ends the turn). Handled in a normal turn, Pi stays interactive and the model can fold results inline. No change to the tool's background machinery. - Always-on prompt shrunk ~91% (6500 -> ~600 bytes): the ~20 'For workflow,' how-to lines moved off the always-on promptGuidelines into workflowHowToGuidelines(), injected into the message only on an armed turn; the always-on guideline is now a single gate line. Cuts self-priming and the always-on token cost, and stops churning the prompt-prefix cache. - byEffort path de-forced too, with an explicit conversational-turn escape. - Kept: keywordTriggerEnabled default ON (under authorize semantics the worst case is just an armed-turn directive), the #79 boundary regex, rainbow highlight, Backspace-to-disarm. README updated (force -> arm). 914 tests; always-on prompt budget ratcheted 6500 -> 650. * refactor(workflow): root-and-branch authorize fix (P1-P5) - Move the how-to mechanics from the per-armed-turn message into the workflow tool's static description, so the model has the manual on every arming path AND on off-keyword natural-language opt-ins, cacheably, not as per-turn priming (P2). Always-on promptGuidelines stays the single gate line. - buildArmedWorkflowPrompt now leads with the decision boundary, states the truthful opt-in reason per path (keyword vs standing-effort), and carries the #89 background/deliver-back reassurance so an ending turn reads as expected, not a stall (P1, P3). - /workflows run uses a distinct forcing directive (no question-escape) while avoiding the old MUST/ONLY language (P5). - Gate line gains task-shape positives, balancing the strong negative (P4). - Tests: trigger regression corpus (negatives/positives), gate-line R3, how-to now in tool description, armed-directive content, /workflows run forcing; honest prompt-budget ratchets (always-on ~766B, tool def grows to ~8.8KB as a MOVE, armed message drops ~6.8KB -> ~0.9KB; how-to trimming is #65, separate). * docs: note the off-keyword natural-language path needs the workflow tool default-active The how-to now lives in the tool description, so a bare natural-language opt-in ('fan this out') sees the mechanics only if the host keeps the workflow tool in its default active set. The arming paths add it on arm; document the dependency.
1 parent 63f4e50 commit db23a3c

11 files changed

Lines changed: 482 additions & 85 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Run a workflow to audit every route under src/routes/ for missing auth checks.
3434

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

37-
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`.
37+
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`.
3838

3939
## How it works
4040

@@ -114,7 +114,7 @@ Pi can manage background runs directly with the `workflow_control` tool instead
114114
| Command | Purpose |
115115
| --- | --- |
116116
| `/workflows` | Open the interactive run navigator |
117-
| `/workflows run <prompt>` | Force a workflow even when keyword triggering is off |
117+
| `/workflows run <prompt>` | Arm workflow mode for a prompt even when keyword triggering is off |
118118
| `/workflows status <id>` | Watch a run and print its result when complete |
119119
| `/workflows pause\|resume\|stop\|rm <id>` | Control a run |
120120
| `/workflows save <name>` | Save the latest script as a reusable command |

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ export type {
103103
} from "./workflow-control-tool.js";
104104
export { createWorkflowControlTool } from "./workflow-control-tool.js";
105105
export {
106+
type ArmReason,
107+
buildArmedWorkflowPrompt,
106108
buildForcedWorkflowPrompt,
107109
colorizeWorkflow,
108110
endsWithTrigger,

src/workflow-commands.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,14 @@ export function registerWorkflowCommands(
164164

165165
const effort = opts.effort;
166166
const extra = effort && effort.level !== "off" ? effortDirective(effort.level) : undefined;
167-
const forced = buildForcedWorkflowPrompt(prompt, extra);
168-
ctx.ui.notify(`Forcing workflow: ${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}`, "info");
167+
// `/workflows run` is an explicit, maximal-intent command — use the
168+
// forcing directive (no "if it's a question just answer" escape),
169+
// distinct from the heuristic keyword/effort arming.
170+
const armed = buildForcedWorkflowPrompt(prompt, extra);
171+
ctx.ui.notify(`Running workflow: ${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}`, "info");
169172
try {
170173
await pi.sendMessage(
171-
{ customType: "workflow-run", content: forced, display: true },
174+
{ customType: "workflow-run", content: armed, display: true },
172175
{ triggerTurn: true, deliverAs: "followUp" },
173176
);
174177
} catch {

src/workflow-editor.ts

Lines changed: 107 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -384,26 +384,104 @@ export class WorkflowEditor extends CustomEditor {
384384
}
385385

386386
/**
387-
* The directive appended to a submitted message when workflows mode is armed.
388-
* `extraDirective` (e.g. an effort-tier nudge) is appended when present.
387+
* Why a turn was armed. This is stated truthfully in the banner so the model
388+
* isn't told "the trigger word you typed" on a path where no word was typed:
389+
* - "keyword": the user typed the configured workflow trigger word.
390+
* - "effort": standing `/effort` armed this turn (no workflow word was typed).
389391
*/
390-
export function buildForcedWorkflowPrompt(text: string, extraDirective?: string): string {
392+
export type ArmReason = "keyword" | "effort";
393+
394+
/**
395+
* Appended to the effort-path directive: standing `/effort` arms on every
396+
* substantive message, so the model must be told it can decline the workflow on
397+
* a conversational or trivial turn (mirrors "solo only on conversational turns").
398+
*/
399+
export const EFFORT_CONVERSATIONAL_ESCAPE =
400+
"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.";
401+
402+
/** The one-line, truthful "why armed" clause for each heuristic arming path. */
403+
function armReasonClause(reason: ArmReason): string {
404+
return reason === "keyword"
405+
? "you typed the workflow trigger word, which counts as an explicit opt-in to multi-agent orchestration"
406+
: "standing effort mode armed this turn (you did not explicitly ask for a workflow)";
407+
}
408+
409+
/**
410+
* The #89 reassurance shared by every arming banner: a background run ENDING the
411+
* turn is expected, not a stall — the result auto-delivers back — so the model
412+
* shouldn't feel it must stay and block, nor avoid the tool to stay interactive.
413+
* Names when `background:false` is the right call (user waiting inline).
414+
*/
415+
const BACKGROUND_DELIVERY_REASSURANCE =
416+
"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.";
417+
418+
/**
419+
* The directive appended to a submitted message when workflows mode is ARMED by a
420+
* HEURISTIC path — the keyword trigger or standing `/effort`. (The explicit
421+
* `/workflows run` command uses {@link buildForcedWorkflowPrompt} instead.)
422+
*
423+
* This authorizes — it does not force. Arming is a confirmed opt-in signal that
424+
* lifts the always-on "do not call the tool" gate for THIS message; the model
425+
* still decides whether the message is actually a request to do work (→ call the
426+
* `workflow` tool) or just talk about workflows (→ answer directly). The old
427+
* "You MUST / the ONLY acceptable action / Do NOT answer directly" forcing text
428+
* caused two bugs: it over-triggered on messages that merely mention workflows
429+
* (#88), and — by commanding the model to emit nothing but one `workflow` call
430+
* and not talk — it produced a bare background run that ends the turn and leaves
431+
* the user at an idle prompt (#89).
432+
*
433+
* The banner therefore (1) LEADS with the decision boundary (question/trivial →
434+
* answer directly; a real decomposable request → call `workflow`) rather than
435+
* leading with "call the tool"; (2) states the truthful opt-in `reason` for THIS
436+
* path (no "the word you typed" on the effort path, where none was); and (3)
437+
* carries the #89 background/deliver-back reassurance so an ending turn reads as
438+
* expected. The how-to mechanics are NOT here — they live in the tool's static
439+
* `description` (see createWorkflowTool), visible whenever the model looks at the
440+
* tool, so they aren't re-injected per armed turn (#65).
441+
*
442+
* `extraDirective` (e.g. an effort-tier nudge + EFFORT_CONVERSATIONAL_ESCAPE) is
443+
* appended when present.
444+
*/
445+
export function buildArmedWorkflowPrompt(
446+
text: string,
447+
opts: { reason?: ArmReason; extraDirective?: string } = {},
448+
): string {
449+
const reason = opts.reason ?? "keyword";
391450
const lines = [
392451
text,
393452
"",
394453
"---",
395-
"[workflows mode is ON for this message]",
396-
"You MUST handle this request by calling the tool named exactly `workflow` (Pi's",
397-
"deterministic JavaScript workflow-orchestration tool from pi-dynamic-workflows).",
398-
"Write a workflow script that fans the task out across subagents via",
399-
"agent()/parallel()/pipeline().",
454+
"[workflows mode armed. Decide first: if this message is a question, a trivial task, or",
455+
"just talk (about workflows, this repo, or the tool itself), answer it directly and stay",
456+
"conversational — arming authorizes the tool, it does not force it. If it is a real,",
457+
"decomposable request to do work, handle it by calling the `workflow` tool: write a script",
458+
"that fans the task out across subagents via agent()/parallel()/pipeline().",
459+
`Why this turn is armed: ${armReasonClause(reason)}.`,
460+
BACKGROUND_DELIVERY_REASSURANCE + "]",
461+
];
462+
if (opts.extraDirective) lines.push("", opts.extraDirective);
463+
return lines.join("\n");
464+
}
465+
466+
/**
467+
* The directive for the explicit `/workflows run <prompt>` command. Unlike the
468+
* heuristic {@link buildArmedWorkflowPrompt}, `/workflows run` is a maximal-intent
469+
* command — the user typed a command whose whole purpose is to execute a workflow
470+
* now — so it does NOT get the "if it's a question, just answer" escape. It still
471+
* avoids the old MUST/ONLY forcing language (which caused #88/#89) and still
472+
* carries the #89 background/deliver-back reassurance so an ending turn reads as
473+
* expected. `extraDirective` (e.g. a standing effort-tier nudge) is appended.
474+
*/
475+
export function buildForcedWorkflowPrompt(text: string, extraDirective?: string): string {
476+
const lines = [
477+
text,
400478
"",
401-
"The ONLY acceptable action is a `workflow` tool call. Do NOT instead:",
402-
"- answer directly or in prose,",
403-
"- call the `subagent` tool yourself,",
404-
"- use any skill or command (e.g. pi-subagents, /code-review, deep-research),",
405-
'- or interpret the word "workflow/workflows" loosely as some other parallel/audit approach.',
406-
"Even for a small task, wrap it in a minimal `workflow` call with at least one agent().",
479+
"---",
480+
"[/workflows run — you ran an explicit command to execute a workflow for this request.",
481+
"Call the `workflow` tool now: write a script that fans this task out across subagents",
482+
"via agent()/parallel()/pipeline(). (This is a direct command, not a heuristic guess, so",
483+
"do not answer in prose instead of running the workflow.)",
484+
BACKGROUND_DELIVERY_REASSURANCE + "]",
407485
];
408486
if (extraDirective) lines.push("", extraDirective);
409487
return lines.join("\n");
@@ -603,10 +681,22 @@ export function installWorkflowEditor(
603681
pi.setActiveTools?.(current);
604682
}
605683
} catch {
606-
// Tool restriction is best-effort; the directive still forces the workflow.
684+
// Tool restriction is best-effort; the armed directive still authorizes the workflow.
607685
}
608-
const extra = byEffort && effort ? effortDirective(effort.level) : undefined;
609-
return { action: "transform", text: buildForcedWorkflowPrompt(event.text, extra) } as const;
686+
// Effort path: the trigger word was NOT typed — this arms on ANY substantive
687+
// message while standing effort is on. So the directive must (a) state the
688+
// truthful "effort" reason (not "the word you typed"), and (b) let the model
689+
// skip the workflow entirely on conversational/trivial turns, not just on
690+
// questions about workflows.
691+
const extra =
692+
byEffort && effort
693+
? [effortDirective(effort.level), EFFORT_CONVERSATIONAL_ESCAPE].filter(Boolean).join(" ")
694+
: undefined;
695+
const reason: ArmReason = byEffort ? "effort" : "keyword";
696+
return {
697+
action: "transform",
698+
text: buildArmedWorkflowPrompt(event.text, { reason, extraDirective: extra }),
699+
} as const;
610700
});
611701

612702
// Restore the user's full tool set once the forced turn completes.

0 commit comments

Comments
 (0)