@wichayutdew/pi-workflows 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +752 -0
- package/agents/step.md +17 -0
- package/dist/index.js +4576 -0
- package/examples/mr-comments.workflow.yaml +115 -0
- package/examples/prompts/mr-comments/implement.md +8 -0
- package/examples/prompts/mr-comments/inspect.md +5 -0
- package/examples/prompts/mr-comments/plan.md +13 -0
- package/examples/prompts/mr-comments/verify.md +7 -0
- package/examples/settings.yaml +19 -0
- package/package.json +81 -0
- package/schemas/settings.schema.json +22 -0
- package/schemas/workflow.schema.json +585 -0
- package/src/command-names.ts +46 -0
- package/src/commands.ts +80 -0
- package/src/config/ceiling.ts +153 -0
- package/src/config/command-conflicts.ts +31 -0
- package/src/config/load.ts +327 -0
- package/src/config/types.ts +187 -0
- package/src/config/validate.ts +1145 -0
- package/src/digest.ts +23 -0
- package/src/engine/checkpoint.ts +30 -0
- package/src/engine/resume.ts +44 -0
- package/src/engine/state.ts +186 -0
- package/src/engine/transitions.ts +426 -0
- package/src/harness.ts +1676 -0
- package/src/index.ts +15 -0
- package/src/integrations/plannotator.ts +235 -0
- package/src/integrations/prompt-gate.ts +54 -0
- package/src/integrations/subagents/child-runtime.ts +306 -0
- package/src/integrations/subagents/client.ts +239 -0
- package/src/integrations/subagents/protocol.ts +304 -0
- package/src/policy/approved-commands.ts +225 -0
- package/src/policy/bash.ts +355 -0
- package/src/policy/completion-batch.ts +36 -0
- package/src/policy/immutable-input.ts +18 -0
- package/src/policy/tools.ts +150 -0
- package/src/preflight.ts +76 -0
- package/src/prompt.ts +146 -0
- package/src/runtime/completion-tool.ts +22 -0
- package/src/runtime/main-step-runtime.ts +227 -0
- package/src/runtime/serial-task-queue.ts +17 -0
- package/src/runtime/step-result.ts +85 -0
- package/src/workflow-list.ts +25 -0
- package/src/workflow-status.ts +611 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { WorkflowStep } from '../config/types.ts';
|
|
2
|
+
import { authorizeBash } from './bash.ts';
|
|
3
|
+
|
|
4
|
+
export interface ToolSourceInfo {
|
|
5
|
+
source?: string;
|
|
6
|
+
path?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ToolInventoryItem {
|
|
10
|
+
name: string;
|
|
11
|
+
sourceInfo?: ToolSourceInfo;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ToolAuthorization {
|
|
15
|
+
allowed: boolean;
|
|
16
|
+
reason?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function reject(reason: string): ToolAuthorization {
|
|
20
|
+
return { allowed: false, reason };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sourceText(tool: ToolInventoryItem): string {
|
|
24
|
+
return `${tool.sourceInfo?.source ?? ''}\n${tool.sourceInfo?.path ?? ''}`.toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isMcpAdapterTool(tool: ToolInventoryItem): boolean {
|
|
28
|
+
return sourceText(tool).includes('pi-mcp-adapter');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function matchesExtensionSelector(
|
|
32
|
+
tool: ToolInventoryItem,
|
|
33
|
+
selector: string,
|
|
34
|
+
): boolean {
|
|
35
|
+
const source = tool.sourceInfo?.source;
|
|
36
|
+
if (source === 'builtin' || source === 'sdk') return false;
|
|
37
|
+
return sourceText(tool).includes(selector.toLowerCase());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveActiveTools(
|
|
41
|
+
inventory: readonly ToolInventoryItem[],
|
|
42
|
+
step: WorkflowStep,
|
|
43
|
+
completionToolName: string,
|
|
44
|
+
): string[] {
|
|
45
|
+
const exact = new Set(step.permissions.tools);
|
|
46
|
+
const selected = inventory
|
|
47
|
+
.filter(
|
|
48
|
+
(tool) =>
|
|
49
|
+
tool.name === completionToolName ||
|
|
50
|
+
exact.has(tool.name) ||
|
|
51
|
+
(tool.name === 'mcp' && step.permissions.mcp.length > 0) ||
|
|
52
|
+
(!isMcpAdapterTool(tool) &&
|
|
53
|
+
step.permissions.extensions.some((selector) =>
|
|
54
|
+
matchesExtensionSelector(tool, selector),
|
|
55
|
+
)),
|
|
56
|
+
)
|
|
57
|
+
.map((tool) => tool.name);
|
|
58
|
+
return [...new Set(selected)];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function selectorAllows(
|
|
62
|
+
selectors: readonly string[],
|
|
63
|
+
server: string,
|
|
64
|
+
tool: string,
|
|
65
|
+
): boolean {
|
|
66
|
+
return selectors.some((selector) => {
|
|
67
|
+
const separator = selector.indexOf('/');
|
|
68
|
+
if (separator === -1) return selector === server;
|
|
69
|
+
return (
|
|
70
|
+
selector.slice(0, separator) === server &&
|
|
71
|
+
selector.slice(separator + 1) === tool
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function authorizeMcpProxy(
|
|
77
|
+
input: Record<string, unknown>,
|
|
78
|
+
selectors: readonly string[],
|
|
79
|
+
): ToolAuthorization {
|
|
80
|
+
if (selectors.length === 0) {
|
|
81
|
+
return reject('MCP access is disabled for this workflow step');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const unsupportedModes = [
|
|
85
|
+
'action',
|
|
86
|
+
'connect',
|
|
87
|
+
'describe',
|
|
88
|
+
'search',
|
|
89
|
+
'regex',
|
|
90
|
+
'includeSchemas',
|
|
91
|
+
].filter((field) => input[field] !== undefined);
|
|
92
|
+
if (unsupportedModes.length > 0) {
|
|
93
|
+
return reject(
|
|
94
|
+
`MCP proxy mode "${unsupportedModes[0]}" is disabled; use an explicit server and tool`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (typeof input.server !== 'string' || !input.server.trim()) {
|
|
98
|
+
return reject('MCP proxy calls must name an explicit server');
|
|
99
|
+
}
|
|
100
|
+
if (typeof input.tool !== 'string' || !input.tool.trim()) {
|
|
101
|
+
return reject('MCP proxy calls must name an explicit tool');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const server = input.server.trim();
|
|
105
|
+
const tool = input.tool.trim();
|
|
106
|
+
if (!selectorAllows(selectors, server, tool)) {
|
|
107
|
+
return reject(
|
|
108
|
+
`MCP tool "${server}/${tool}" is not allowed for this workflow step`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return { allowed: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function authorizeToolCall(
|
|
115
|
+
toolName: string,
|
|
116
|
+
input: Record<string, unknown>,
|
|
117
|
+
step: WorkflowStep,
|
|
118
|
+
inventory: readonly ToolInventoryItem[],
|
|
119
|
+
approvedBashCommands: readonly string[] = [],
|
|
120
|
+
): ToolAuthorization {
|
|
121
|
+
const tool = inventory.find((candidate) => candidate.name === toolName);
|
|
122
|
+
const allowedByName = step.permissions.tools.includes(toolName);
|
|
123
|
+
const allowedByExtension =
|
|
124
|
+
tool !== undefined &&
|
|
125
|
+
!isMcpAdapterTool(tool) &&
|
|
126
|
+
step.permissions.extensions.some((selector) =>
|
|
127
|
+
matchesExtensionSelector(tool, selector),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (toolName === 'mcp') {
|
|
131
|
+
return authorizeMcpProxy(input, step.permissions.mcp);
|
|
132
|
+
}
|
|
133
|
+
if (!allowedByName && !allowedByExtension) {
|
|
134
|
+
return reject(`tool "${toolName}" is not allowed for this workflow step`);
|
|
135
|
+
}
|
|
136
|
+
if (toolName === 'bash') {
|
|
137
|
+
const command = input.command;
|
|
138
|
+
if (typeof command !== 'string')
|
|
139
|
+
return reject('Bash call is missing command text');
|
|
140
|
+
const result = authorizeBash(
|
|
141
|
+
command,
|
|
142
|
+
step.permissions.bash,
|
|
143
|
+
approvedBashCommands,
|
|
144
|
+
);
|
|
145
|
+
return result.allowed
|
|
146
|
+
? { allowed: true }
|
|
147
|
+
: reject(result.reason ?? 'Bash command is not allowed');
|
|
148
|
+
}
|
|
149
|
+
return { allowed: true };
|
|
150
|
+
}
|
package/src/preflight.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { WorkflowStep } from './config/types.ts';
|
|
2
|
+
|
|
3
|
+
interface SourceInfoLike {
|
|
4
|
+
source?: string;
|
|
5
|
+
path?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface NamedResource {
|
|
9
|
+
name: string;
|
|
10
|
+
sourceInfo?: SourceInfoLike;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PreflightInventory {
|
|
14
|
+
tools: readonly NamedResource[];
|
|
15
|
+
commands: readonly NamedResource[];
|
|
16
|
+
skills: ReadonlySet<string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sourceMatches(resource: NamedResource, selector: string): boolean {
|
|
20
|
+
const source = `${resource.sourceInfo?.source ?? ''}\n${resource.sourceInfo?.path ?? ''}`;
|
|
21
|
+
return source.toLowerCase().includes(selector.toLowerCase());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function preflightStep(
|
|
25
|
+
step: WorkflowStep,
|
|
26
|
+
inventory: PreflightInventory,
|
|
27
|
+
): string[] {
|
|
28
|
+
const errors: string[] = [];
|
|
29
|
+
const toolNames = new Set(inventory.tools.map((tool) => tool.name));
|
|
30
|
+
const subagentTool = inventory.tools.find(
|
|
31
|
+
(tool) => tool.name === 'subagent' && sourceMatches(tool, 'pi-subagents'),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (step.subagent && !subagentTool) {
|
|
35
|
+
errors.push(
|
|
36
|
+
'pi-subagents is required, but its "subagent" tool is not installed or detectable',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const tool of step.requires.tools) {
|
|
41
|
+
if (!toolNames.has(tool)) {
|
|
42
|
+
errors.push(`required tool "${tool}" is not installed`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (step.permissions.mcp.length > 0 && !toolNames.has('mcp')) {
|
|
46
|
+
errors.push(
|
|
47
|
+
'MCP selectors are configured, but the "mcp" proxy tool is not installed',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const extensionResources = [...inventory.tools, ...inventory.commands];
|
|
52
|
+
if (
|
|
53
|
+
step.gate?.provider === 'plannotator' &&
|
|
54
|
+
!step.requires.extensions.includes('plannotator') &&
|
|
55
|
+
!extensionResources.some((resource) =>
|
|
56
|
+
sourceMatches(resource, 'plannotator'),
|
|
57
|
+
)
|
|
58
|
+
) {
|
|
59
|
+
errors.push(
|
|
60
|
+
'Plannotator is required by this gate, but its extension is not installed or detectable',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
for (const extension of step.requires.extensions) {
|
|
64
|
+
if (
|
|
65
|
+
!extensionResources.some((resource) => sourceMatches(resource, extension))
|
|
66
|
+
) {
|
|
67
|
+
errors.push(`required extension "${extension}" is not detectable`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const skill of step.requires.skills) {
|
|
71
|
+
if (!inventory.skills.has(skill)) {
|
|
72
|
+
errors.push(`required skill "${skill}" is not loaded`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return errors;
|
|
76
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { LoadedWorkflow, WorkflowStep } from './config/types.ts';
|
|
2
|
+
import type { WorkflowRun } from './engine/state.ts';
|
|
3
|
+
import { allowedOutcomes } from './engine/transitions.ts';
|
|
4
|
+
|
|
5
|
+
interface TemplateValues {
|
|
6
|
+
[key: string]: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function formatList(values: readonly string[]): string {
|
|
10
|
+
return values.length > 0 ? values.join(', ') : '(none)';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function currentStepHandoff(run: WorkflowRun): string {
|
|
14
|
+
const incoming = run.stepHandoff ?? '';
|
|
15
|
+
if (!incoming || incoming === run.lastSummary) return run.lastSummary;
|
|
16
|
+
if (!run.lastSummary) return incoming;
|
|
17
|
+
return [
|
|
18
|
+
'Incoming approved or previous-step handoff:',
|
|
19
|
+
incoming,
|
|
20
|
+
'',
|
|
21
|
+
'Latest paused attempt:',
|
|
22
|
+
run.lastSummary,
|
|
23
|
+
].join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function renderTemplate(
|
|
27
|
+
template: string,
|
|
28
|
+
values: TemplateValues,
|
|
29
|
+
): string {
|
|
30
|
+
return template.replace(/\{\{([^{}]+)\}\}/g, (_match, rawName: string) => {
|
|
31
|
+
const name = rawName.trim();
|
|
32
|
+
return values[name] ?? '';
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function templateValues(
|
|
37
|
+
workflow: LoadedWorkflow,
|
|
38
|
+
run: WorkflowRun,
|
|
39
|
+
step: WorkflowStep,
|
|
40
|
+
): TemplateValues {
|
|
41
|
+
return {
|
|
42
|
+
'workflow.input': run.input,
|
|
43
|
+
'workflow.id': workflow.definition.id,
|
|
44
|
+
'run.id': run.runId,
|
|
45
|
+
'step.id': run.currentStepId,
|
|
46
|
+
'step.title': step.title,
|
|
47
|
+
'last.summary': currentStepHandoff(run),
|
|
48
|
+
'gate.feedback': run.gateFeedback,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildStepTask(
|
|
53
|
+
workflow: LoadedWorkflow,
|
|
54
|
+
run: WorkflowRun,
|
|
55
|
+
execution: 'delegated' | 'main',
|
|
56
|
+
policyEnvelope?: string,
|
|
57
|
+
): string {
|
|
58
|
+
const step = workflow.definition.steps[run.currentStepId];
|
|
59
|
+
if (!step) throw new Error(`unknown workflow step "${run.currentStepId}"`);
|
|
60
|
+
const prompt = renderTemplate(
|
|
61
|
+
workflow.prompts[run.currentStepId] ?? '',
|
|
62
|
+
templateValues(workflow, run, step),
|
|
63
|
+
);
|
|
64
|
+
const outcomes = allowedOutcomes(workflow, run);
|
|
65
|
+
const allowedOutcomeSet = new Set(outcomes);
|
|
66
|
+
const transitionLines = Object.entries(step.transitions)
|
|
67
|
+
.filter(([outcome]) => allowedOutcomeSet.has(outcome))
|
|
68
|
+
.map(([outcome, target]) => `- ${outcome}: ${target}`)
|
|
69
|
+
.join('\n');
|
|
70
|
+
const gateLine = step.gate
|
|
71
|
+
? `- ${step.gate.submitOutcome}: submit the artifact to ${step.gate.provider}; include the full artifact argument`
|
|
72
|
+
: '';
|
|
73
|
+
const delegated = execution === 'delegated';
|
|
74
|
+
|
|
75
|
+
return [
|
|
76
|
+
...(policyEnvelope ? [policyEnvelope, ''] : []),
|
|
77
|
+
`# ${delegated ? 'Delegated' : 'Main-agent'} declarative workflow step`,
|
|
78
|
+
'',
|
|
79
|
+
`Workflow: ${workflow.definition.id}`,
|
|
80
|
+
`Run: ${run.runId}`,
|
|
81
|
+
`Step: ${run.currentStepId} (${step.title})`,
|
|
82
|
+
'',
|
|
83
|
+
'## Step instructions',
|
|
84
|
+
'',
|
|
85
|
+
prompt,
|
|
86
|
+
'',
|
|
87
|
+
`## Enforced ${delegated ? 'child' : 'step'} resources`,
|
|
88
|
+
'',
|
|
89
|
+
`Pi tools: ${formatList(step.permissions.tools)}`,
|
|
90
|
+
`MCP selectors: ${formatList(step.permissions.mcp)}`,
|
|
91
|
+
`Extension selectors: ${formatList(step.permissions.extensions)}`,
|
|
92
|
+
`Skills: ${formatList(step.permissions.skills)}`,
|
|
93
|
+
`Bash policy: ${step.permissions.bash.mode}`,
|
|
94
|
+
'',
|
|
95
|
+
`Use only the listed skills for this step. Tool calls are enforced ${delegated ? 'inside this child process' : 'by the workflow harness'}.`,
|
|
96
|
+
'',
|
|
97
|
+
'## Completion contract',
|
|
98
|
+
'',
|
|
99
|
+
`Call \`workflow_complete_step\` exactly once, after all work for this ${delegated ? 'delegated' : 'main-agent'} step is complete.`,
|
|
100
|
+
`Valid outcomes: ${outcomes.join(', ')}`,
|
|
101
|
+
transitionLines,
|
|
102
|
+
gateLine,
|
|
103
|
+
'',
|
|
104
|
+
'Put a concise handoff in `summary`. Do not call the completion tool alongside other tool calls. If the workflow definition or environment is wrong, use an outcome that transitions to `$pause`.',
|
|
105
|
+
].join('\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildDelegatedStepTask(
|
|
109
|
+
workflow: LoadedWorkflow,
|
|
110
|
+
run: WorkflowRun,
|
|
111
|
+
policyEnvelope: string,
|
|
112
|
+
): string {
|
|
113
|
+
return buildStepTask(workflow, run, 'delegated', policyEnvelope);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildMainStepTask(
|
|
117
|
+
workflow: LoadedWorkflow,
|
|
118
|
+
run: WorkflowRun,
|
|
119
|
+
): string {
|
|
120
|
+
return buildStepTask(workflow, run, 'main');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildMainWorkflowNotice(
|
|
124
|
+
workflow: LoadedWorkflow,
|
|
125
|
+
run: WorkflowRun,
|
|
126
|
+
): string {
|
|
127
|
+
const step = workflow.definition.steps[run.currentStepId];
|
|
128
|
+
if (!step) throw new Error(`unknown workflow step "${run.currentStepId}"`);
|
|
129
|
+
if (!step.subagent) {
|
|
130
|
+
return [
|
|
131
|
+
'# Active main-agent workflow',
|
|
132
|
+
'',
|
|
133
|
+
`Workflow "${workflow.definition.id}" is running step "${run.currentStepId}" (${step.title}) in this session.`,
|
|
134
|
+
'Perform only the active workflow step with its allowed resources.',
|
|
135
|
+
'Call `workflow_complete_step` exactly once when finished.',
|
|
136
|
+
'Use `/workflow-pause` to halt and repair the workflow before resuming.',
|
|
137
|
+
].join('\n');
|
|
138
|
+
}
|
|
139
|
+
return [
|
|
140
|
+
'# Active subagent workflow',
|
|
141
|
+
'',
|
|
142
|
+
`Workflow "${workflow.definition.id}" is running step "${run.currentStepId}" (${step.title}) in a separate pi-subagents child process.`,
|
|
143
|
+
'Do not perform the workflow step in this main session.',
|
|
144
|
+
'Use `/workflow-status` to inspect it or `/workflow-pause` to cancel the child and repair the workflow before resuming.',
|
|
145
|
+
].join('\n');
|
|
146
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
|
|
3
|
+
export const WORKFLOW_COMPLETION_TOOL = 'workflow_complete_step';
|
|
4
|
+
|
|
5
|
+
export const WORKFLOW_COMPLETION_PARAMETERS = Type.Object(
|
|
6
|
+
{
|
|
7
|
+
outcome: Type.String({
|
|
8
|
+
description: 'One exact outcome allowed by the active workflow step',
|
|
9
|
+
}),
|
|
10
|
+
summary: Type.String({
|
|
11
|
+
description: 'Concise checkpoint and handoff for the next workflow step',
|
|
12
|
+
maxLength: 50_000,
|
|
13
|
+
}),
|
|
14
|
+
artifact: Type.Optional(
|
|
15
|
+
Type.String({
|
|
16
|
+
description: 'Full artifact required when submitting to a review gate',
|
|
17
|
+
maxLength: 200_000,
|
|
18
|
+
}),
|
|
19
|
+
),
|
|
20
|
+
},
|
|
21
|
+
{ additionalProperties: false },
|
|
22
|
+
);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import type { WorkflowStep } from '../config/types.ts';
|
|
6
|
+
import { invalidCompletionCallIds } from '../policy/completion-batch.ts';
|
|
7
|
+
import { freezeToolInput } from '../policy/immutable-input.ts';
|
|
8
|
+
import { authorizeToolCall, resolveActiveTools } from '../policy/tools.ts';
|
|
9
|
+
import {
|
|
10
|
+
WORKFLOW_COMPLETION_PARAMETERS,
|
|
11
|
+
WORKFLOW_COMPLETION_TOOL,
|
|
12
|
+
} from './completion-tool.ts';
|
|
13
|
+
import {
|
|
14
|
+
parseWorkflowStepResult,
|
|
15
|
+
type StepResultPolicy,
|
|
16
|
+
type WorkflowStepResult,
|
|
17
|
+
} from './step-result.ts';
|
|
18
|
+
|
|
19
|
+
export interface MainStepExecution extends StepResultPolicy {
|
|
20
|
+
workflowId: string;
|
|
21
|
+
runId: string;
|
|
22
|
+
stepId: string;
|
|
23
|
+
stepDigest: string;
|
|
24
|
+
step: WorkflowStep;
|
|
25
|
+
approvedBashCommands: string[];
|
|
26
|
+
onSettled(
|
|
27
|
+
result: WorkflowStepResult | undefined,
|
|
28
|
+
context: ExtensionContext,
|
|
29
|
+
): Promise<void> | void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class MainStepRuntime {
|
|
33
|
+
private active: MainStepExecution | undefined;
|
|
34
|
+
private pendingResult: WorkflowStepResult | undefined;
|
|
35
|
+
private invalidCompletionCalls = new Set<string>();
|
|
36
|
+
private suspended = false;
|
|
37
|
+
|
|
38
|
+
constructor(private readonly pi: ExtensionAPI) {
|
|
39
|
+
this.registerLifecycle();
|
|
40
|
+
this.registerPolicy();
|
|
41
|
+
this.registerCompletionTool();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get activeStepId(): string | undefined {
|
|
45
|
+
return this.active?.stepId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
activate(execution: MainStepExecution): void {
|
|
49
|
+
if (this.active) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`main workflow step "${this.active.stepId}" is still active`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
this.suspended = false;
|
|
55
|
+
this.active = execution;
|
|
56
|
+
this.pendingResult = undefined;
|
|
57
|
+
this.invalidCompletionCalls.clear();
|
|
58
|
+
this.pi.setActiveTools(
|
|
59
|
+
resolveActiveTools(
|
|
60
|
+
this.pi.getAllTools(),
|
|
61
|
+
execution.step,
|
|
62
|
+
WORKFLOW_COMPLETION_TOOL,
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
deactivate(): boolean {
|
|
68
|
+
const wasActive = this.active !== undefined;
|
|
69
|
+
this.active = undefined;
|
|
70
|
+
this.pendingResult = undefined;
|
|
71
|
+
this.invalidCompletionCalls.clear();
|
|
72
|
+
return wasActive;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
suspend(): boolean {
|
|
76
|
+
const wasActive = this.deactivate();
|
|
77
|
+
if (wasActive) {
|
|
78
|
+
this.suspended = true;
|
|
79
|
+
this.pi.setActiveTools([]);
|
|
80
|
+
}
|
|
81
|
+
return wasActive;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
release(): void {
|
|
85
|
+
this.suspended = false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private registerLifecycle(): void {
|
|
89
|
+
const reset = (): void => {
|
|
90
|
+
this.suspended = false;
|
|
91
|
+
this.deactivate();
|
|
92
|
+
this.pi.setActiveTools(
|
|
93
|
+
this.pi
|
|
94
|
+
.getActiveTools()
|
|
95
|
+
.filter((tool) => tool !== WORKFLOW_COMPLETION_TOOL),
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
this.pi.on('session_start', reset);
|
|
99
|
+
this.pi.on('session_tree', reset);
|
|
100
|
+
this.pi.on('session_shutdown', () => {
|
|
101
|
+
this.suspended = false;
|
|
102
|
+
this.deactivate();
|
|
103
|
+
});
|
|
104
|
+
this.pi.on('agent_settled', (_event, context) => {
|
|
105
|
+
const active = this.active;
|
|
106
|
+
if (!active) return;
|
|
107
|
+
const result = this.pendingResult;
|
|
108
|
+
this.active = undefined;
|
|
109
|
+
this.pendingResult = undefined;
|
|
110
|
+
this.invalidCompletionCalls.clear();
|
|
111
|
+
return active.onSettled(result, context);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private registerPolicy(): void {
|
|
116
|
+
this.pi.on('turn_start', () => {
|
|
117
|
+
this.invalidCompletionCalls.clear();
|
|
118
|
+
});
|
|
119
|
+
this.pi.on('message_end', (event) => {
|
|
120
|
+
if (!this.active) return;
|
|
121
|
+
const invalid = invalidCompletionCallIds(
|
|
122
|
+
event.message,
|
|
123
|
+
WORKFLOW_COMPLETION_TOOL,
|
|
124
|
+
);
|
|
125
|
+
if (
|
|
126
|
+
invalid.size > 0 ||
|
|
127
|
+
(event.message as { role?: unknown }).role === 'assistant'
|
|
128
|
+
) {
|
|
129
|
+
this.invalidCompletionCalls = invalid;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
this.pi.on('tool_call', (event) => {
|
|
133
|
+
if (this.suspended) {
|
|
134
|
+
return {
|
|
135
|
+
block: true,
|
|
136
|
+
reason: 'Main-agent workflow step is suspended',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (!this.active) {
|
|
140
|
+
if (event.toolName === WORKFLOW_COMPLETION_TOOL) {
|
|
141
|
+
return {
|
|
142
|
+
block: true,
|
|
143
|
+
reason: 'No main-agent workflow step is active',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (this.invalidCompletionCalls.has(event.toolCallId)) {
|
|
149
|
+
return {
|
|
150
|
+
block: true,
|
|
151
|
+
reason: `${WORKFLOW_COMPLETION_TOOL} must be the only tool call in its message`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (event.toolName === WORKFLOW_COMPLETION_TOOL) {
|
|
155
|
+
freezeToolInput(event.input);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const authorization = authorizeToolCall(
|
|
160
|
+
event.toolName,
|
|
161
|
+
event.input as unknown as Record<string, unknown>,
|
|
162
|
+
this.active.step,
|
|
163
|
+
this.pi.getAllTools(),
|
|
164
|
+
this.active.approvedBashCommands,
|
|
165
|
+
);
|
|
166
|
+
if (!authorization.allowed) {
|
|
167
|
+
return {
|
|
168
|
+
block: true,
|
|
169
|
+
reason:
|
|
170
|
+
authorization.reason ?? 'Tool blocked by main workflow policy',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
freezeToolInput(event.input);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private registerCompletionTool(): void {
|
|
178
|
+
this.pi.registerTool({
|
|
179
|
+
name: WORKFLOW_COMPLETION_TOOL,
|
|
180
|
+
label: 'Complete Workflow Step',
|
|
181
|
+
description: 'Return one validated result from an active workflow step',
|
|
182
|
+
promptSnippet: 'Complete the active workflow step',
|
|
183
|
+
promptGuidelines: [
|
|
184
|
+
'Call workflow_complete_step alone after all active workflow-step work is complete.',
|
|
185
|
+
],
|
|
186
|
+
parameters: WORKFLOW_COMPLETION_PARAMETERS,
|
|
187
|
+
executionMode: 'sequential',
|
|
188
|
+
execute: async (_toolCallId, params) => {
|
|
189
|
+
if (!this.active) {
|
|
190
|
+
throw new Error('No main-agent workflow step is active');
|
|
191
|
+
}
|
|
192
|
+
if (this.pendingResult) {
|
|
193
|
+
throw new Error('Main-agent workflow step already produced a result');
|
|
194
|
+
}
|
|
195
|
+
const result = parseWorkflowStepResult(
|
|
196
|
+
{
|
|
197
|
+
version: 1,
|
|
198
|
+
policyDigest: this.active.policyDigest,
|
|
199
|
+
outcome: params.outcome,
|
|
200
|
+
summary: params.summary,
|
|
201
|
+
...(params.artifact !== undefined
|
|
202
|
+
? { artifact: params.artifact }
|
|
203
|
+
: {}),
|
|
204
|
+
},
|
|
205
|
+
this.active,
|
|
206
|
+
);
|
|
207
|
+
this.pendingResult = result;
|
|
208
|
+
this.pi.setActiveTools([]);
|
|
209
|
+
return {
|
|
210
|
+
content: [
|
|
211
|
+
{
|
|
212
|
+
type: 'text' as const,
|
|
213
|
+
text: `Captured workflow step outcome "${result.outcome}".`,
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
details: {
|
|
217
|
+
workflowId: this.active.workflowId,
|
|
218
|
+
runId: this.active.runId,
|
|
219
|
+
stepId: this.active.stepId,
|
|
220
|
+
outcome: result.outcome,
|
|
221
|
+
},
|
|
222
|
+
terminate: true,
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi may dispatch extension commands while another command is awaiting I/O.
|
|
3
|
+
* Serialize state-changing commands while allowing the queue to recover after
|
|
4
|
+
* an individual command rejects.
|
|
5
|
+
*/
|
|
6
|
+
export class SerialTaskQueue {
|
|
7
|
+
private tail: Promise<void> = Promise.resolve();
|
|
8
|
+
|
|
9
|
+
run<T>(task: () => Promise<T>): Promise<T> {
|
|
10
|
+
const result = this.tail.then(task, task);
|
|
11
|
+
this.tail = result.then(
|
|
12
|
+
() => undefined,
|
|
13
|
+
() => undefined,
|
|
14
|
+
);
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
}
|