@wichayutdew/pi-workflows 2.6.0 → 2.7.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/README.md +30 -0
- package/dist/index.js +258 -23
- package/examples/starter-kit/agents/planner.md +4 -0
- package/examples/starter-kit/agents/reviewer.md +4 -0
- package/examples/starter-kit/agents/scout.md +4 -0
- package/examples/starter-kit/agents/worker.md +4 -0
- package/examples/starter-kit/agents/workspace-preparer.md +4 -0
- package/examples/starter-kit/investigate.workflow.yaml +34 -64
- package/examples/starter-kit/jira.workflow.yaml +75 -0
- package/examples/starter-kit/mr-comment.workflow.yaml +48 -115
- package/examples/starter-kit/mr-review.workflow.yaml +36 -93
- package/examples/starter-kit/settings.yaml +2 -1
- package/examples/starter-kit/steps/investigate/investigate.md +18 -55
- package/examples/starter-kit/steps/investigate/retrieve.md +15 -50
- package/examples/starter-kit/steps/investigate/validate.md +10 -36
- package/examples/starter-kit/steps/jira/create.md +25 -0
- package/examples/starter-kit/steps/jira/draft.md +18 -0
- package/examples/starter-kit/steps/jira/plan.md +30 -0
- package/examples/starter-kit/steps/mr-comment/checkout-source.md +7 -60
- package/examples/starter-kit/steps/mr-comment/fetch.md +11 -36
- package/examples/starter-kit/steps/mr-comment/implement.md +10 -34
- package/examples/starter-kit/steps/mr-comment/plan.md +47 -70
- package/examples/starter-kit/steps/mr-comment/publish.md +11 -32
- package/examples/starter-kit/steps/mr-comment/verify.md +8 -42
- package/examples/starter-kit/steps/mr-review/fetch.md +8 -46
- package/examples/starter-kit/steps/mr-review/publish-approved.md +7 -38
- package/examples/starter-kit/steps/mr-review/review-for-approval.md +24 -113
- package/examples/starter-kit/steps/mr-review/verify-published.md +9 -30
- package/examples/starter-kit/steps/shared/prepare-workspace.md +9 -105
- package/examples/starter-kit/steps/shared/publish-remote.md +8 -37
- package/examples/starter-kit/steps/ticket/implement.md +11 -58
- package/examples/starter-kit/steps/ticket/plan.md +49 -171
- package/examples/starter-kit/steps/ticket/verify.md +12 -98
- package/examples/starter-kit/steps/work/implement.md +11 -58
- package/examples/starter-kit/steps/work/plan.md +42 -130
- package/examples/starter-kit/steps/work/verify.md +11 -58
- package/examples/starter-kit/ticket.workflow.yaml +38 -81
- package/examples/starter-kit/work.workflow.yaml +34 -72
- package/package.json +7 -4
- package/schemas/workflow.schema.json +61 -0
- package/scripts/patch-herdr-agent-state.mjs +36 -0
- package/src/config/types.ts +9 -0
- package/src/config/validation/step.ts +117 -0
- package/src/harness/artifact-contract.ts +46 -0
- package/src/harness/delegation-recovery.ts +15 -0
- package/src/harness/delegation-response-actions.ts +21 -1
- package/src/harness/gate-submission-action.ts +28 -0
- package/src/harness/status-actions.ts +19 -0
- package/src/herdr-workflow-state.ts +65 -0
- package/src/integrations/subagents/child-runtime-repair.ts +23 -0
- package/src/integrations/subagents/child-runtime.ts +25 -0
- package/src/integrations/subagents/client.ts +84 -1
- package/src/integrations/subagents/diagnostics.ts +43 -0
- package/src/integrations/subagents/protocol-events.ts +3 -0
|
@@ -2,6 +2,7 @@ import { isAbsolute, win32 } from 'node:path';
|
|
|
2
2
|
import {
|
|
3
3
|
MAX_WORKSPACE_ALLOWED_ROOTS,
|
|
4
4
|
MAX_WORKSPACE_PATH_CHARS,
|
|
5
|
+
type ArtifactContract,
|
|
5
6
|
type PromptSpec,
|
|
6
7
|
type StepWorkspaceBinding,
|
|
7
8
|
type WorkflowGate,
|
|
@@ -68,6 +69,106 @@ function parseTransitions(
|
|
|
68
69
|
return transitions;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
function parseArtifactContract(
|
|
73
|
+
value: unknown,
|
|
74
|
+
path: string,
|
|
75
|
+
errors: ValidationErrors,
|
|
76
|
+
): ArtifactContract | undefined {
|
|
77
|
+
if (value === undefined) return undefined;
|
|
78
|
+
if (!isJsonObject(value)) {
|
|
79
|
+
errors.push(`${path}: expected an object`);
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
rejectUnknownKeys(
|
|
83
|
+
value,
|
|
84
|
+
[
|
|
85
|
+
'maxChars',
|
|
86
|
+
'requiredSubstrings',
|
|
87
|
+
'forbiddenSubstrings',
|
|
88
|
+
'equalOccurrenceGroups',
|
|
89
|
+
'onValidationFailure',
|
|
90
|
+
],
|
|
91
|
+
path,
|
|
92
|
+
errors,
|
|
93
|
+
);
|
|
94
|
+
if (value.maxChars === undefined) {
|
|
95
|
+
errors.push(`${path}.maxChars: expected an integer from 1 to 200000`);
|
|
96
|
+
}
|
|
97
|
+
const maxChars = readInteger(
|
|
98
|
+
value.maxChars,
|
|
99
|
+
200_000,
|
|
100
|
+
`${path}.maxChars`,
|
|
101
|
+
errors,
|
|
102
|
+
{ min: 1, max: 200_000 },
|
|
103
|
+
);
|
|
104
|
+
const parseSubstrings = (field: string): Array<string> => {
|
|
105
|
+
const values = readStringList(
|
|
106
|
+
value[field],
|
|
107
|
+
`${path}.${field}`,
|
|
108
|
+
errors,
|
|
109
|
+
/.+/,
|
|
110
|
+
);
|
|
111
|
+
if (values.length > 32) {
|
|
112
|
+
errors.push(`${path}.${field}: at most 32 values are allowed`);
|
|
113
|
+
}
|
|
114
|
+
values.forEach((substring, index) => {
|
|
115
|
+
if (substring.length > 1_024) {
|
|
116
|
+
errors.push(`${path}.${field}[${index}]: exceeds 1024 characters`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return values;
|
|
120
|
+
};
|
|
121
|
+
const equalOccurrenceGroups = (() => {
|
|
122
|
+
if (value.equalOccurrenceGroups === undefined) return [];
|
|
123
|
+
if (!Array.isArray(value.equalOccurrenceGroups)) {
|
|
124
|
+
errors.push(`${path}.equalOccurrenceGroups: expected an array`);
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
if (value.equalOccurrenceGroups.length > 32) {
|
|
128
|
+
errors.push(
|
|
129
|
+
`${path}.equalOccurrenceGroups: at most 32 groups are allowed`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return value.equalOccurrenceGroups.reduce<Array<Array<string>>>(
|
|
133
|
+
(groups, group, index) => {
|
|
134
|
+
const groupPath = `${path}.equalOccurrenceGroups[${index}]`;
|
|
135
|
+
const values = readStringList(group, groupPath, errors, /.+/);
|
|
136
|
+
if (values.length < 2) {
|
|
137
|
+
errors.push(`${groupPath}: at least two values are required`);
|
|
138
|
+
}
|
|
139
|
+
if (values.length > 32) {
|
|
140
|
+
errors.push(`${groupPath}: at most 32 values are allowed`);
|
|
141
|
+
}
|
|
142
|
+
values.forEach((substring, valueIndex) => {
|
|
143
|
+
if (substring.length > 1_024) {
|
|
144
|
+
errors.push(`${groupPath}[${valueIndex}]: exceeds 1024 characters`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
return [...groups, values];
|
|
148
|
+
},
|
|
149
|
+
[],
|
|
150
|
+
);
|
|
151
|
+
})();
|
|
152
|
+
const onValidationFailure =
|
|
153
|
+
value.onValidationFailure === undefined
|
|
154
|
+
? undefined
|
|
155
|
+
: readString(
|
|
156
|
+
value.onValidationFailure,
|
|
157
|
+
`${path}.onValidationFailure`,
|
|
158
|
+
errors,
|
|
159
|
+
);
|
|
160
|
+
if (onValidationFailure !== undefined && onValidationFailure !== 'retry') {
|
|
161
|
+
errors.push(`${path}.onValidationFailure: expected retry`);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
maxChars,
|
|
165
|
+
requiredSubstrings: parseSubstrings('requiredSubstrings'),
|
|
166
|
+
forbiddenSubstrings: parseSubstrings('forbiddenSubstrings'),
|
|
167
|
+
equalOccurrenceGroups,
|
|
168
|
+
...(onValidationFailure === 'retry' ? { onValidationFailure } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
71
172
|
function parseGate(
|
|
72
173
|
value: unknown,
|
|
73
174
|
path: string,
|
|
@@ -86,6 +187,7 @@ function parseGate(
|
|
|
86
187
|
'approvedOutcome',
|
|
87
188
|
'rejectedOutcome',
|
|
88
189
|
'timeoutMs',
|
|
190
|
+
'artifactContract',
|
|
89
191
|
],
|
|
90
192
|
path,
|
|
91
193
|
errors,
|
|
@@ -120,6 +222,11 @@ function parseGate(
|
|
|
120
222
|
errors,
|
|
121
223
|
{ pattern: OUTCOME_PATTERN },
|
|
122
224
|
);
|
|
225
|
+
const artifactContract = parseArtifactContract(
|
|
226
|
+
value.artifactContract,
|
|
227
|
+
`${path}.artifactContract`,
|
|
228
|
+
errors,
|
|
229
|
+
);
|
|
123
230
|
if (provider === 'prompt' && value.timeoutMs !== undefined) {
|
|
124
231
|
errors.push(`${path}.timeoutMs: only valid with provider "plannotator"`);
|
|
125
232
|
}
|
|
@@ -136,12 +243,14 @@ function parseGate(
|
|
|
136
243
|
submitOutcome,
|
|
137
244
|
approvedOutcome,
|
|
138
245
|
rejectedOutcome,
|
|
246
|
+
...(artifactContract ? { artifactContract } : {}),
|
|
139
247
|
}
|
|
140
248
|
: {
|
|
141
249
|
provider,
|
|
142
250
|
submitOutcome,
|
|
143
251
|
approvedOutcome,
|
|
144
252
|
rejectedOutcome,
|
|
253
|
+
...(artifactContract ? { artifactContract } : {}),
|
|
145
254
|
timeoutMs: readInteger(
|
|
146
255
|
value.timeoutMs,
|
|
147
256
|
30_000,
|
|
@@ -312,6 +421,14 @@ export function parseWorkflowStep(
|
|
|
312
421
|
`${path}.transitions: submitOutcome is handled by the gate and must not be a transition`,
|
|
313
422
|
);
|
|
314
423
|
}
|
|
424
|
+
if (
|
|
425
|
+
gate.artifactContract?.onValidationFailure === 'retry' &&
|
|
426
|
+
!Object.hasOwn(transitions, 'retry')
|
|
427
|
+
) {
|
|
428
|
+
errors.push(
|
|
429
|
+
`${path}.transitions: artifact-contract retry requires a "retry" transition`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
315
432
|
}
|
|
316
433
|
if (workspace) {
|
|
317
434
|
workspace.bindOn.forEach((outcome) => {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ArtifactContract } from '../config/types.ts';
|
|
2
|
+
|
|
3
|
+
function countOccurrences(value: string, substring: string): number {
|
|
4
|
+
let count = 0;
|
|
5
|
+
let offset = 0;
|
|
6
|
+
while (true) {
|
|
7
|
+
const match = value.indexOf(substring, offset);
|
|
8
|
+
if (match === -1) return count;
|
|
9
|
+
count += 1;
|
|
10
|
+
offset = match + substring.length;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function validateArtifactContract(
|
|
15
|
+
artifact: string,
|
|
16
|
+
contract: ArtifactContract | undefined,
|
|
17
|
+
): string | undefined {
|
|
18
|
+
if (!contract) return undefined;
|
|
19
|
+
if (artifact.length > contract.maxChars) {
|
|
20
|
+
return `gate artifact exceeds ${contract.maxChars} characters`;
|
|
21
|
+
}
|
|
22
|
+
const required = contract.requiredSubstrings.find(
|
|
23
|
+
(substring) => !artifact.includes(substring),
|
|
24
|
+
);
|
|
25
|
+
if (required) {
|
|
26
|
+
return `gate artifact is missing required text: ${JSON.stringify(required)}`;
|
|
27
|
+
}
|
|
28
|
+
const forbidden = contract.forbiddenSubstrings.find((substring) =>
|
|
29
|
+
artifact.includes(substring),
|
|
30
|
+
);
|
|
31
|
+
if (forbidden) {
|
|
32
|
+
return `gate artifact contains forbidden text: ${JSON.stringify(forbidden)}`;
|
|
33
|
+
}
|
|
34
|
+
for (const group of contract.equalOccurrenceGroups) {
|
|
35
|
+
const counts = group.map((substring) =>
|
|
36
|
+
countOccurrences(artifact, substring),
|
|
37
|
+
);
|
|
38
|
+
if (counts.some((count) => count === 0)) {
|
|
39
|
+
return `gate artifact is missing required repeated text: ${JSON.stringify(group)}`;
|
|
40
|
+
}
|
|
41
|
+
if (!counts.every((count) => count === counts[0])) {
|
|
42
|
+
return `gate artifact has unequal repeated text counts: ${JSON.stringify(group)}`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
classifyRecoverySafety,
|
|
3
|
+
type DelegationDiagnostic,
|
|
4
|
+
} from '../integrations/subagents/diagnostics.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Allows one fresh retry only after the same-child repair settled with complete
|
|
8
|
+
* read-only evidence. The caller owns preserving run identity and cleanup.
|
|
9
|
+
*/
|
|
10
|
+
export const shouldRetryMissingCompletion = (
|
|
11
|
+
diagnostic: DelegationDiagnostic | undefined,
|
|
12
|
+
subagentAttemptCount: number,
|
|
13
|
+
): boolean =>
|
|
14
|
+
subagentAttemptCount === 1 &&
|
|
15
|
+
classifyRecoverySafety(diagnostic) === 'read-only';
|
|
@@ -9,6 +9,7 @@ import type { WorkflowStepResult } from '../runtime/step-result.ts';
|
|
|
9
9
|
import type { HarnessActionContext as FullHarnessActionContext } from './action-context.ts';
|
|
10
10
|
import type { ActiveDelegation } from './types.ts';
|
|
11
11
|
import { resolveStepEffects } from './step-effects.ts';
|
|
12
|
+
import { shouldRetryMissingCompletion } from './delegation-recovery.ts';
|
|
12
13
|
|
|
13
14
|
type HarnessActionContext = Pick<
|
|
14
15
|
FullHarnessActionContext,
|
|
@@ -19,6 +20,7 @@ type HarnessActionContext = Pick<
|
|
|
19
20
|
| 'finishDelegation'
|
|
20
21
|
| 'isSessionActive'
|
|
21
22
|
| 'latestContext'
|
|
23
|
+
| 'launchCurrentStep'
|
|
22
24
|
| 'mutationQueue'
|
|
23
25
|
| 'pauseForDelegationFailure'
|
|
24
26
|
| 'releaseMainAfterCancellation'
|
|
@@ -174,8 +176,26 @@ async function finishDelegation(
|
|
|
174
176
|
serializedResult = await this.dependencies.readDelegatedResult(active);
|
|
175
177
|
} catch (error) {
|
|
176
178
|
if (hasErrorCode(error, 'ENOENT')) {
|
|
179
|
+
const subagentAttemptCount =
|
|
180
|
+
this.run.currentStepAttempts?.filter(
|
|
181
|
+
(attempt) => attempt.kind === 'subagent',
|
|
182
|
+
).length ?? 0;
|
|
183
|
+
if (
|
|
184
|
+
shouldRetryMissingCompletion(
|
|
185
|
+
response.diagnostic,
|
|
186
|
+
subagentAttemptCount,
|
|
187
|
+
)
|
|
188
|
+
) {
|
|
189
|
+
cleanupAttempted = true;
|
|
190
|
+
await this.cleanupDelegation(active);
|
|
191
|
+
this.launchCurrentStep(workflow);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const diagnosticState = response.diagnostic
|
|
195
|
+
? `settled=${response.diagnostic.settled}, truncated=${response.diagnostic.truncated}, calls=${response.diagnostic.calls.length}`
|
|
196
|
+
: 'unavailable';
|
|
177
197
|
throw new Error(
|
|
178
|
-
`Subagent "${active.agent}" completed without producing the required correlated structured_output result`,
|
|
198
|
+
`Subagent "${active.agent}" completed without producing the required correlated structured_output result (request ${active.requestId}; diagnostic ${diagnosticState})`,
|
|
179
199
|
{ cause: error },
|
|
180
200
|
);
|
|
181
201
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { LoadedWorkflow } from '../config/types.ts';
|
|
2
2
|
import type { WorkflowRun } from '../engine/state.ts';
|
|
3
|
+
import { validateArtifactContract } from './artifact-contract.ts';
|
|
3
4
|
import {
|
|
4
5
|
attachGateReviewId,
|
|
6
|
+
advanceRun,
|
|
5
7
|
beginGate,
|
|
6
8
|
failGate,
|
|
7
9
|
failRun,
|
|
@@ -19,6 +21,7 @@ type HarnessActionContext = Pick<
|
|
|
19
21
|
| 'pi'
|
|
20
22
|
| 'restoreBaselineTools'
|
|
21
23
|
| 'run'
|
|
24
|
+
| 'settleAfterTransition'
|
|
22
25
|
| 'sessionEpoch'
|
|
23
26
|
| 'updateStatus'
|
|
24
27
|
>;
|
|
@@ -63,6 +66,31 @@ async function submitGate(
|
|
|
63
66
|
this.dependencies.createRequestId();
|
|
64
67
|
const step = workflow.definition.steps[originalRun.currentStepId];
|
|
65
68
|
if (!step?.gate) throw new Error('Current step has no gate');
|
|
69
|
+
const contractError = validateArtifactContract(
|
|
70
|
+
artifact,
|
|
71
|
+
step.gate.artifactContract,
|
|
72
|
+
);
|
|
73
|
+
if (contractError) {
|
|
74
|
+
if (step.gate.artifactContract?.onValidationFailure !== 'retry') {
|
|
75
|
+
throw new Error(contractError);
|
|
76
|
+
}
|
|
77
|
+
const retrySummary = `Artifact contract failed: ${contractError}`;
|
|
78
|
+
this.run = advanceRun(
|
|
79
|
+
workflow,
|
|
80
|
+
originalRun,
|
|
81
|
+
'retry',
|
|
82
|
+
retrySummary,
|
|
83
|
+
this.dependencies.now(),
|
|
84
|
+
);
|
|
85
|
+
this.persist();
|
|
86
|
+
this.updateStatus();
|
|
87
|
+
this.settleAfterTransition(workflow, {
|
|
88
|
+
stepId: originalRun.currentStepId,
|
|
89
|
+
outcome: 'retry',
|
|
90
|
+
summary: retrySummary,
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
66
94
|
|
|
67
95
|
this.run = beginGate(
|
|
68
96
|
workflow,
|
|
@@ -78,6 +78,25 @@ function workflowStatusSnapshot(
|
|
|
78
78
|
|
|
79
79
|
function updateStatus(this: StatusContext): void {
|
|
80
80
|
refreshStatusWhileRunning.call(this);
|
|
81
|
+
const run = this.run;
|
|
82
|
+
this.pi.events.emit('pi-workflows:state', {
|
|
83
|
+
state:
|
|
84
|
+
run?.status === 'running'
|
|
85
|
+
? 'working'
|
|
86
|
+
: run?.status === 'awaiting-gate' || run?.status === 'paused'
|
|
87
|
+
? 'blocked'
|
|
88
|
+
: run?.status === 'completed'
|
|
89
|
+
? 'completed'
|
|
90
|
+
: 'interrupted',
|
|
91
|
+
workflowId: run?.workflowId,
|
|
92
|
+
stepId: run?.currentStepId,
|
|
93
|
+
message:
|
|
94
|
+
run?.status === 'completed'
|
|
95
|
+
? `Workflow "${run.workflowId}" completed`
|
|
96
|
+
: run?.status === 'paused'
|
|
97
|
+
? run.pauseReason
|
|
98
|
+
: undefined,
|
|
99
|
+
});
|
|
81
100
|
if (!this.latestContext) return;
|
|
82
101
|
if (this.legacyProgressWidgetContext !== this.latestContext) {
|
|
83
102
|
this.latestContext.ui.setWidget(LEGACY_PROGRESS_WIDGET_KEY, undefined);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
|
|
4
|
+
const source = 'herdr:pi-workflows';
|
|
5
|
+
|
|
6
|
+
type WorkflowState = {
|
|
7
|
+
state: 'working' | 'blocked' | 'completed' | 'interrupted';
|
|
8
|
+
workflowId?: string;
|
|
9
|
+
stepId?: string;
|
|
10
|
+
message?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default function herdrWorkflowState(pi: ExtensionAPI): void {
|
|
14
|
+
const socketPath = process.env.HERDR_SOCKET_PATH;
|
|
15
|
+
const paneId = process.env.HERDR_PANE_ID;
|
|
16
|
+
if (process.env.HERDR_ENV !== '1' || !socketPath || !paneId) return;
|
|
17
|
+
|
|
18
|
+
let seq = Date.now() * 1000;
|
|
19
|
+
let last = '';
|
|
20
|
+
pi.events.on('pi-workflows:state', (data: unknown) => {
|
|
21
|
+
if (!data || typeof data !== 'object') return;
|
|
22
|
+
const event = data as WorkflowState;
|
|
23
|
+
if (
|
|
24
|
+
!['working', 'blocked', 'completed', 'interrupted'].includes(event.state)
|
|
25
|
+
)
|
|
26
|
+
return;
|
|
27
|
+
const state =
|
|
28
|
+
event.state === 'working'
|
|
29
|
+
? 'working'
|
|
30
|
+
: event.state === 'blocked'
|
|
31
|
+
? 'blocked'
|
|
32
|
+
: 'idle';
|
|
33
|
+
const message =
|
|
34
|
+
event.message ??
|
|
35
|
+
(event.workflowId
|
|
36
|
+
? `Workflow "${event.workflowId}" ${event.state}`
|
|
37
|
+
: undefined);
|
|
38
|
+
const fingerprint = `${state}:${message ?? ''}`;
|
|
39
|
+
if (fingerprint === last) return;
|
|
40
|
+
last = fingerprint;
|
|
41
|
+
const request = {
|
|
42
|
+
id: `${source}:${Date.now()}:${++seq}`,
|
|
43
|
+
method: 'pane.report_agent',
|
|
44
|
+
params: {
|
|
45
|
+
pane_id: paneId,
|
|
46
|
+
source,
|
|
47
|
+
agent: 'pi-workflows',
|
|
48
|
+
state,
|
|
49
|
+
message,
|
|
50
|
+
seq,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const socket = net.createConnection(socketPath);
|
|
54
|
+
const timeout = setTimeout(() => socket.destroy(), 1500);
|
|
55
|
+
timeout.unref();
|
|
56
|
+
socket.on('connect', () => socket.write(`${JSON.stringify(request)}\n`));
|
|
57
|
+
socket.on('data', () => socket.destroy());
|
|
58
|
+
socket.on('close', () => {
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
});
|
|
61
|
+
socket.on('error', () => {
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ChildStepPolicy } from './child-policy-types.ts';
|
|
2
|
+
import type { SubagentChildRuntimeDependencies } from './child-runtime-types.ts';
|
|
3
|
+
|
|
4
|
+
export const COMPLETION_REPAIR_PROMPT = [
|
|
5
|
+
'The delegated step settled without its required correlated result.',
|
|
6
|
+
'Do not repeat completed work and do not execute work tools.',
|
|
7
|
+
'Call `structured_output` exactly once, alone, with one configured outcome and the required summary, artifact, and workspace fields.',
|
|
8
|
+
].join('\n');
|
|
9
|
+
|
|
10
|
+
/** Returns whether a same-child completion repair may be requested safely. */
|
|
11
|
+
export const needsCompletionRepair = ({
|
|
12
|
+
policy,
|
|
13
|
+
dependencies,
|
|
14
|
+
}: {
|
|
15
|
+
readonly policy: ChildStepPolicy;
|
|
16
|
+
readonly dependencies: SubagentChildRuntimeDependencies;
|
|
17
|
+
}): boolean => {
|
|
18
|
+
try {
|
|
19
|
+
return !dependencies.fileSystem.exists(policy.resultPath);
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
parseChildStructuredResult,
|
|
14
14
|
} from './child-runtime-completion.ts';
|
|
15
15
|
import { DEFAULT_CHILD_RUNTIME_DEPENDENCIES } from './child-runtime-dependencies.ts';
|
|
16
|
+
import {
|
|
17
|
+
COMPLETION_REPAIR_PROMPT,
|
|
18
|
+
needsCompletionRepair,
|
|
19
|
+
} from './child-runtime-repair.ts';
|
|
16
20
|
import {
|
|
17
21
|
verifyChildCapability,
|
|
18
22
|
verifyChildWorkingDirectory,
|
|
@@ -39,6 +43,7 @@ type ChildRuntimeState = {
|
|
|
39
43
|
readonly policyError: string | undefined;
|
|
40
44
|
readonly invalidCompletionCalls: ReadonlySet<string>;
|
|
41
45
|
readonly effectiveTools: ReadonlySet<string>;
|
|
46
|
+
readonly repairRequested: boolean;
|
|
42
47
|
};
|
|
43
48
|
|
|
44
49
|
const INITIAL_STATE: ChildRuntimeState = {
|
|
@@ -46,6 +51,7 @@ const INITIAL_STATE: ChildRuntimeState = {
|
|
|
46
51
|
policyError: undefined,
|
|
47
52
|
invalidCompletionCalls: new Set(),
|
|
48
53
|
effectiveTools: new Set(),
|
|
54
|
+
repairRequested: false,
|
|
49
55
|
};
|
|
50
56
|
|
|
51
57
|
const errorMessage = (error: unknown): string =>
|
|
@@ -142,6 +148,7 @@ export const registerSubagentChildRuntime = (
|
|
|
142
148
|
activePolicy: extracted.policy,
|
|
143
149
|
policyError: undefined,
|
|
144
150
|
effectiveTools,
|
|
151
|
+
repairRequested: false,
|
|
145
152
|
};
|
|
146
153
|
} catch (error) {
|
|
147
154
|
const policyError = errorMessage(error);
|
|
@@ -175,6 +182,24 @@ export const registerSubagentChildRuntime = (
|
|
|
175
182
|
state = { ...state, invalidCompletionCalls: new Set() };
|
|
176
183
|
});
|
|
177
184
|
|
|
185
|
+
pi.on('agent_settled', () => {
|
|
186
|
+
const policy = state.activePolicy;
|
|
187
|
+
if (
|
|
188
|
+
!policy ||
|
|
189
|
+
state.repairRequested ||
|
|
190
|
+
!needsCompletionRepair({ policy, dependencies })
|
|
191
|
+
) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
state = {
|
|
195
|
+
...state,
|
|
196
|
+
repairRequested: true,
|
|
197
|
+
effectiveTools: new Set([CHILD_COMPLETION_TOOL]),
|
|
198
|
+
};
|
|
199
|
+
pi.setActiveTools([CHILD_COMPLETION_TOOL]);
|
|
200
|
+
pi.sendUserMessage(COMPLETION_REPAIR_PROMPT, { deliverAs: 'followUp' });
|
|
201
|
+
});
|
|
202
|
+
|
|
178
203
|
pi.on('message_end', (event) => {
|
|
179
204
|
if (!state.activePolicy) return;
|
|
180
205
|
const invalid = invalidCompletionCallIds(
|
|
@@ -5,6 +5,10 @@ import type {
|
|
|
5
5
|
SubagentDelegationResponse,
|
|
6
6
|
SubagentDelegationUpdate,
|
|
7
7
|
} from './protocol-events.ts';
|
|
8
|
+
import type {
|
|
9
|
+
DelegationDiagnostic,
|
|
10
|
+
DelegationDiagnosticCall,
|
|
11
|
+
} from './diagnostics.ts';
|
|
8
12
|
|
|
9
13
|
export type DelegateOptions = {
|
|
10
14
|
readonly signal?: AbortSignal;
|
|
@@ -52,6 +56,7 @@ export function directWorkerResponse(
|
|
|
52
56
|
code: number | null,
|
|
53
57
|
signal: NodeJS.Signals | null,
|
|
54
58
|
stderr: string,
|
|
59
|
+
diagnostic?: DelegationDiagnostic,
|
|
55
60
|
): SubagentDelegationResponse {
|
|
56
61
|
const status = code === 0 ? 'completed' : signal ? 'cancelled' : 'failed';
|
|
57
62
|
return {
|
|
@@ -62,12 +67,15 @@ export function directWorkerResponse(
|
|
|
62
67
|
...(status !== 'completed' && stderr.trim()
|
|
63
68
|
? { error: stderr.trim().slice(-4_000) }
|
|
64
69
|
: {}),
|
|
70
|
+
...(diagnostic ? { diagnostic } : {}),
|
|
65
71
|
};
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
type WorkerJsonEvent = {
|
|
69
75
|
readonly type?: unknown;
|
|
76
|
+
readonly toolCallId?: unknown;
|
|
70
77
|
readonly toolName?: unknown;
|
|
78
|
+
readonly isError?: unknown;
|
|
71
79
|
readonly args?: unknown;
|
|
72
80
|
readonly message?: { readonly role?: unknown };
|
|
73
81
|
readonly assistantMessageEvent?: {
|
|
@@ -83,6 +91,7 @@ type WorkerProgress = {
|
|
|
83
91
|
};
|
|
84
92
|
|
|
85
93
|
const MAX_PROGRESS_DETAIL_CHARS = 480;
|
|
94
|
+
const MAX_DIAGNOSTIC_CALLS = 64;
|
|
86
95
|
const SECRET_KEY = /authorization|cookie|password|secret|token|api[-_]?key/i;
|
|
87
96
|
|
|
88
97
|
function redactProgressValue(value: unknown, key = ''): unknown {
|
|
@@ -110,6 +119,70 @@ function formatToolCall(toolName: string, args: unknown): string {
|
|
|
110
119
|
return `call ${toolName} ${rendered}`.slice(0, MAX_PROGRESS_DETAIL_CHARS);
|
|
111
120
|
}
|
|
112
121
|
|
|
122
|
+
type MutableDiagnostic = {
|
|
123
|
+
settled: boolean;
|
|
124
|
+
truncated: boolean;
|
|
125
|
+
calls: Map<string, DelegationDiagnosticCall>;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const createDiagnostic = (): MutableDiagnostic => ({
|
|
129
|
+
settled: false,
|
|
130
|
+
truncated: false,
|
|
131
|
+
calls: new Map(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const diagnosticSnapshot = (
|
|
135
|
+
diagnostic: MutableDiagnostic,
|
|
136
|
+
): DelegationDiagnostic => ({
|
|
137
|
+
settled: diagnostic.settled,
|
|
138
|
+
truncated: diagnostic.truncated,
|
|
139
|
+
calls: [...diagnostic.calls.values()],
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const recordWorkerDiagnostic = (
|
|
143
|
+
line: string,
|
|
144
|
+
diagnostic: MutableDiagnostic,
|
|
145
|
+
): void => {
|
|
146
|
+
let event: WorkerJsonEvent;
|
|
147
|
+
try {
|
|
148
|
+
const parsed: unknown = JSON.parse(line);
|
|
149
|
+
if (typeof parsed !== 'object' || parsed === null) return;
|
|
150
|
+
event = parsed;
|
|
151
|
+
} catch {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (event.type === 'agent_settled') {
|
|
155
|
+
diagnostic.settled = true;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
(event.type !== 'tool_execution_start' &&
|
|
160
|
+
event.type !== 'tool_execution_end') ||
|
|
161
|
+
typeof event.toolName !== 'string' ||
|
|
162
|
+
typeof event.toolCallId !== 'string'
|
|
163
|
+
) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!diagnostic.calls.has(event.toolCallId)) {
|
|
167
|
+
if (diagnostic.calls.size >= MAX_DIAGNOSTIC_CALLS) {
|
|
168
|
+
diagnostic.truncated = true;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
diagnostic.calls.set(event.toolCallId, {
|
|
172
|
+
id: event.toolCallId,
|
|
173
|
+
name: event.toolName,
|
|
174
|
+
state: 'started',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (event.type === 'tool_execution_end') {
|
|
178
|
+
diagnostic.calls.set(event.toolCallId, {
|
|
179
|
+
id: event.toolCallId,
|
|
180
|
+
name: event.toolName,
|
|
181
|
+
state: event.isError === false ? 'completed' : 'failed',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
113
186
|
/** Converts one Pi JSONL event into safe, operator-visible worker progress. */
|
|
114
187
|
export function workerProgressFromJsonLine(
|
|
115
188
|
line: string,
|
|
@@ -211,6 +284,7 @@ export function createSubagentDelegationClient(
|
|
|
211
284
|
let stdoutBuffer = '';
|
|
212
285
|
let toolCount = 0;
|
|
213
286
|
let responseText = '';
|
|
287
|
+
const diagnostic = createDiagnostic();
|
|
214
288
|
const stdoutDecoder = new StringDecoder('utf8');
|
|
215
289
|
const consumeWorkerLines = (): void => {
|
|
216
290
|
while (true) {
|
|
@@ -218,6 +292,7 @@ export function createSubagentDelegationClient(
|
|
|
218
292
|
if (newline === -1) return;
|
|
219
293
|
const line = stdoutBuffer.slice(0, newline);
|
|
220
294
|
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
|
295
|
+
recordWorkerDiagnostic(line, diagnostic);
|
|
221
296
|
const progress = workerProgressFromJsonLine(
|
|
222
297
|
line,
|
|
223
298
|
request.requestId,
|
|
@@ -250,7 +325,15 @@ export function createSubagentDelegationClient(
|
|
|
250
325
|
consumeWorkerLines();
|
|
251
326
|
if (active?.process === child) active = undefined;
|
|
252
327
|
options.signal?.removeEventListener('abort', abort);
|
|
253
|
-
resolve(
|
|
328
|
+
resolve(
|
|
329
|
+
directWorkerResponse(
|
|
330
|
+
request,
|
|
331
|
+
code,
|
|
332
|
+
signal,
|
|
333
|
+
stderr,
|
|
334
|
+
diagnosticSnapshot(diagnostic),
|
|
335
|
+
),
|
|
336
|
+
);
|
|
254
337
|
});
|
|
255
338
|
});
|
|
256
339
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type DiagnosticCallState = 'completed' | 'failed' | 'started';
|
|
2
|
+
|
|
3
|
+
export type DelegationDiagnosticCall = {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly state: DiagnosticCallState;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type DelegationDiagnostic = {
|
|
10
|
+
readonly settled: boolean;
|
|
11
|
+
readonly truncated: boolean;
|
|
12
|
+
readonly calls: ReadonlyArray<DelegationDiagnosticCall>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type RecoverySafety = 'read-only' | 'unsafe' | 'incomplete';
|
|
16
|
+
|
|
17
|
+
const READ_ONLY_TOOLS: ReadonlySet<string> = new Set([
|
|
18
|
+
'read',
|
|
19
|
+
'ls',
|
|
20
|
+
'grep',
|
|
21
|
+
'structured_output',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Decides whether a fresh child may safely repeat a step after same-child
|
|
26
|
+
* completion repair failed. Unknown, partial, and mutation-capable evidence
|
|
27
|
+
* always fails closed.
|
|
28
|
+
*/
|
|
29
|
+
export const classifyRecoverySafety = (
|
|
30
|
+
diagnostic: DelegationDiagnostic | undefined,
|
|
31
|
+
): RecoverySafety => {
|
|
32
|
+
if (!diagnostic || !diagnostic.settled || diagnostic.truncated) {
|
|
33
|
+
return 'incomplete';
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
diagnostic.calls.some(
|
|
37
|
+
(call) => call.state !== 'completed' || !READ_ONLY_TOOLS.has(call.name),
|
|
38
|
+
)
|
|
39
|
+
) {
|
|
40
|
+
return 'unsafe';
|
|
41
|
+
}
|
|
42
|
+
return 'read-only';
|
|
43
|
+
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DelegationDiagnostic } from './diagnostics.ts';
|
|
2
|
+
|
|
1
3
|
export const SUBAGENT_DELEGATION_PROTOCOL_VERSION = 1 as const;
|
|
2
4
|
export const SUBAGENT_DELEGATION_REQUEST_EVENT =
|
|
3
5
|
'prompt-template:subagent:request';
|
|
@@ -41,4 +43,5 @@ export type SubagentDelegationResponse = {
|
|
|
41
43
|
readonly error?: string;
|
|
42
44
|
readonly exitCode?: number;
|
|
43
45
|
readonly warnings?: ReadonlyArray<string>;
|
|
46
|
+
readonly diagnostic?: DelegationDiagnostic;
|
|
44
47
|
};
|