@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.
Files changed (54) hide show
  1. package/README.md +30 -0
  2. package/dist/index.js +258 -23
  3. package/examples/starter-kit/agents/planner.md +4 -0
  4. package/examples/starter-kit/agents/reviewer.md +4 -0
  5. package/examples/starter-kit/agents/scout.md +4 -0
  6. package/examples/starter-kit/agents/worker.md +4 -0
  7. package/examples/starter-kit/agents/workspace-preparer.md +4 -0
  8. package/examples/starter-kit/investigate.workflow.yaml +34 -64
  9. package/examples/starter-kit/jira.workflow.yaml +75 -0
  10. package/examples/starter-kit/mr-comment.workflow.yaml +48 -115
  11. package/examples/starter-kit/mr-review.workflow.yaml +36 -93
  12. package/examples/starter-kit/settings.yaml +2 -1
  13. package/examples/starter-kit/steps/investigate/investigate.md +18 -55
  14. package/examples/starter-kit/steps/investigate/retrieve.md +15 -50
  15. package/examples/starter-kit/steps/investigate/validate.md +10 -36
  16. package/examples/starter-kit/steps/jira/create.md +25 -0
  17. package/examples/starter-kit/steps/jira/draft.md +18 -0
  18. package/examples/starter-kit/steps/jira/plan.md +30 -0
  19. package/examples/starter-kit/steps/mr-comment/checkout-source.md +7 -60
  20. package/examples/starter-kit/steps/mr-comment/fetch.md +11 -36
  21. package/examples/starter-kit/steps/mr-comment/implement.md +10 -34
  22. package/examples/starter-kit/steps/mr-comment/plan.md +47 -70
  23. package/examples/starter-kit/steps/mr-comment/publish.md +11 -32
  24. package/examples/starter-kit/steps/mr-comment/verify.md +8 -42
  25. package/examples/starter-kit/steps/mr-review/fetch.md +8 -46
  26. package/examples/starter-kit/steps/mr-review/publish-approved.md +7 -38
  27. package/examples/starter-kit/steps/mr-review/review-for-approval.md +24 -113
  28. package/examples/starter-kit/steps/mr-review/verify-published.md +9 -30
  29. package/examples/starter-kit/steps/shared/prepare-workspace.md +9 -105
  30. package/examples/starter-kit/steps/shared/publish-remote.md +8 -37
  31. package/examples/starter-kit/steps/ticket/implement.md +11 -58
  32. package/examples/starter-kit/steps/ticket/plan.md +49 -171
  33. package/examples/starter-kit/steps/ticket/verify.md +12 -98
  34. package/examples/starter-kit/steps/work/implement.md +11 -58
  35. package/examples/starter-kit/steps/work/plan.md +42 -130
  36. package/examples/starter-kit/steps/work/verify.md +11 -58
  37. package/examples/starter-kit/ticket.workflow.yaml +38 -81
  38. package/examples/starter-kit/work.workflow.yaml +34 -72
  39. package/package.json +7 -4
  40. package/schemas/workflow.schema.json +61 -0
  41. package/scripts/patch-herdr-agent-state.mjs +36 -0
  42. package/src/config/types.ts +9 -0
  43. package/src/config/validation/step.ts +117 -0
  44. package/src/harness/artifact-contract.ts +46 -0
  45. package/src/harness/delegation-recovery.ts +15 -0
  46. package/src/harness/delegation-response-actions.ts +21 -1
  47. package/src/harness/gate-submission-action.ts +28 -0
  48. package/src/harness/status-actions.ts +19 -0
  49. package/src/herdr-workflow-state.ts +65 -0
  50. package/src/integrations/subagents/child-runtime-repair.ts +23 -0
  51. package/src/integrations/subagents/child-runtime.ts +25 -0
  52. package/src/integrations/subagents/client.ts +84 -1
  53. package/src/integrations/subagents/diagnostics.ts +43 -0
  54. 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(directWorkerResponse(request, code, signal, stderr));
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
  };