agentxchain 2.21.0 → 2.23.0

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.
@@ -1,6 +1,66 @@
1
- import { getActiveTurnCount } from './governed-state.js';
1
+ import {
2
+ deriveConflictLoopRecoveryAction,
3
+ deriveDispatchRecoveryAction,
4
+ deriveEscalationRecoveryAction,
5
+ deriveHookTamperRecoveryAction,
6
+ deriveNeedsHumanRecoveryAction,
7
+ getActiveTurnCount,
8
+ } from './governed-state.js';
2
9
 
3
- export function deriveRecoveryDescriptor(state) {
10
+ function isLegacyEscalationRecoveryAction(action) {
11
+ return action === 'Resolve the escalation, then run agentxchain step --resume'
12
+ || action === 'Resolve the escalation, then run agentxchain step';
13
+ }
14
+
15
+ function isLegacyNeedsHumanRecoveryAction(action) {
16
+ return action === 'Resolve the stated issue, then run agentxchain step --resume';
17
+ }
18
+
19
+ function isLegacyHookTamperRecoveryAction(action) {
20
+ return action === 'Disable or fix the hook, verify protected files, then run agentxchain step --resume';
21
+ }
22
+
23
+ function isLegacyConflictLoopRecoveryAction(action) {
24
+ return typeof action === 'string' && action.startsWith('Serialize the conflicting work, then run agentxchain step --resume');
25
+ }
26
+
27
+ function maybeRefreshRecoveryAction(state, config, persistedRecovery, turnRetained) {
28
+ if (!config || !persistedRecovery || typeof persistedRecovery !== 'object') {
29
+ return null;
30
+ }
31
+
32
+ const typedReason = persistedRecovery.typed_reason;
33
+ const currentAction = persistedRecovery.recovery_action || null;
34
+ const turnId = state?.blocked_reason?.turn_id ?? state?.escalation?.from_turn_id ?? null;
35
+ if (typedReason === 'retries_exhausted' || ((typedReason === 'operator_escalation') && isLegacyEscalationRecoveryAction(currentAction))) {
36
+ return deriveEscalationRecoveryAction(state, config, {
37
+ turnRetained,
38
+ turnId,
39
+ });
40
+ }
41
+
42
+ if (typedReason === 'needs_human' && isLegacyNeedsHumanRecoveryAction(currentAction)) {
43
+ return deriveNeedsHumanRecoveryAction(state, config, {
44
+ turnRetained,
45
+ turnId,
46
+ });
47
+ }
48
+
49
+ if (typedReason === 'hook_tamper' && isLegacyHookTamperRecoveryAction(currentAction)) {
50
+ return deriveHookTamperRecoveryAction(state, config, {
51
+ turnRetained,
52
+ turnId,
53
+ });
54
+ }
55
+
56
+ if (typedReason === 'conflict_loop' && isLegacyConflictLoopRecoveryAction(currentAction)) {
57
+ return deriveConflictLoopRecoveryAction(turnId);
58
+ }
59
+
60
+ return null;
61
+ }
62
+
63
+ export function deriveRecoveryDescriptor(state, config = null) {
4
64
  if (!state || typeof state !== 'object') {
5
65
  return null;
6
66
  }
@@ -29,10 +89,13 @@ export function deriveRecoveryDescriptor(state) {
29
89
 
30
90
  const persistedRecovery = state.blocked_reason?.recovery;
31
91
  if (persistedRecovery && typeof persistedRecovery === 'object') {
92
+ const refreshedRecoveryAction = maybeRefreshRecoveryAction(state, config, persistedRecovery, turnRetained);
32
93
  return {
33
94
  typed_reason: persistedRecovery.typed_reason || 'unknown_block',
34
95
  owner: persistedRecovery.owner || 'human',
35
- recovery_action: persistedRecovery.recovery_action || 'Inspect state.json and resolve manually before rerunning agentxchain step',
96
+ recovery_action: refreshedRecoveryAction
97
+ || persistedRecovery.recovery_action
98
+ || 'Inspect state.json and resolve manually before rerunning agentxchain step',
36
99
  turn_retained: typeof persistedRecovery.turn_retained === 'boolean'
37
100
  ? persistedRecovery.turn_retained
38
101
  : turnRetained,
@@ -48,7 +111,10 @@ export function deriveRecoveryDescriptor(state) {
48
111
  return {
49
112
  typed_reason: 'needs_human',
50
113
  owner: 'human',
51
- recovery_action: 'Resolve the stated issue, then run agentxchain step --resume',
114
+ recovery_action: deriveNeedsHumanRecoveryAction(state, config, {
115
+ turnRetained,
116
+ turnId: state.blocked_reason?.turn_id ?? null,
117
+ }),
52
118
  turn_retained: turnRetained,
53
119
  detail: state.blocked_on.slice('human:'.length) || null,
54
120
  };
@@ -66,9 +132,10 @@ export function deriveRecoveryDescriptor(state) {
66
132
 
67
133
  if (state.blocked_on.startsWith('escalation:')) {
68
134
  const isOperatorEscalation = state.blocked_on.startsWith('escalation:operator:') || state.escalation?.source === 'operator';
69
- const recoveryAction = turnRetained
70
- ? 'Resolve the escalation, then run agentxchain step --resume'
71
- : 'Resolve the escalation, then run agentxchain step';
135
+ const recoveryAction = deriveEscalationRecoveryAction(state, config, {
136
+ turnRetained,
137
+ turnId: state.blocked_reason?.turn_id ?? state.escalation?.from_turn_id ?? null,
138
+ });
72
139
  return {
73
140
  typed_reason: isOperatorEscalation ? 'operator_escalation' : 'retries_exhausted',
74
141
  owner: 'human',
@@ -82,7 +149,10 @@ export function deriveRecoveryDescriptor(state) {
82
149
  return {
83
150
  typed_reason: 'dispatch_error',
84
151
  owner: 'human',
85
- recovery_action: 'Resolve the dispatch issue, then run agentxchain step --resume',
152
+ recovery_action: deriveDispatchRecoveryAction(state, config, {
153
+ turnRetained,
154
+ turnId: state.blocked_reason?.turn_id ?? null,
155
+ }),
86
156
  turn_retained: turnRetained,
87
157
  detail: state.blocked_on.slice('dispatch:'.length) || state.blocked_on,
88
158
  };
package/src/lib/config.js CHANGED
@@ -3,7 +3,12 @@ import { join, parse as pathParse, resolve } from 'path';
3
3
  import { safeParseJson, validateConfigSchema, validateLockSchema, validateProjectStateSchema, validateStateSchema } from './schema.js';
4
4
  import { loadNormalizedConfig } from './normalized-config.js';
5
5
  import { safeWriteJson } from './safe-write.js';
6
- import { normalizeGovernedStateShape, getActiveTurn } from './governed-state.js';
6
+ import {
7
+ normalizeGovernedStateShape,
8
+ getActiveTurn,
9
+ reconcileBudgetStatusWithConfig,
10
+ reconcileRecoveryActionsWithConfig,
11
+ } from './governed-state.js';
7
12
 
8
13
  function attachLegacyCurrentTurnAlias(state) {
9
14
  if (!state || typeof state !== 'object') {
@@ -148,7 +153,11 @@ export function loadProjectState(root, config) {
148
153
  if (config?.protocol_mode === 'governed') {
149
154
  const normalized = normalizeGovernedStateShape(stateData);
150
155
  stateData = normalized.state;
151
- if (normalized.changed) {
156
+ const reconciledBudget = reconcileBudgetStatusWithConfig(stateData, config);
157
+ stateData = reconciledBudget.state;
158
+ const reconciledRecovery = reconcileRecoveryActionsWithConfig(stateData, config);
159
+ stateData = reconciledRecovery.state;
160
+ if (normalized.changed || reconciledBudget.changed || reconciledRecovery.changed) {
152
161
  safeWriteJson(filePath, stateData);
153
162
  }
154
163
  }
@@ -14,7 +14,7 @@
14
14
  * orchestrator turn loop call it after assignGovernedTurn().
15
15
  */
16
16
 
17
- import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'fs';
18
18
  import { join } from 'path';
19
19
  import { getActiveTurn, getActiveTurns } from './governed-state.js';
20
20
  import {
@@ -31,6 +31,7 @@ import {
31
31
  const HISTORY_PATH = '.agentxchain/history.jsonl';
32
32
  const FILE_PREVIEW_MAX_FILES = 5;
33
33
  const FILE_PREVIEW_MAX_LINES = 120;
34
+ const PROPOSAL_SUMMARY_MAX_LINES = 80;
34
35
  const GATE_FILE_PREVIEW_MAX_LINES = 60;
35
36
  const DISPATCH_LOG_MAX_LINES = 50;
36
37
  const DISPATCH_LOG_MAX_LINE_BYTES = 8192;
@@ -229,6 +230,21 @@ function renderPrompt(role, roleId, turn, state, config, root) {
229
230
  lines.push('');
230
231
  lines.push('- You may propose changes as patches but cannot directly commit.');
231
232
  lines.push('- Your artifact type should be `patch`.');
233
+ if (runtimeType === 'api_proxy') {
234
+ lines.push('- **This runtime cannot write repo files directly.** When doing work, you MUST return proposed changes as structured JSON.');
235
+ lines.push('- Include a `proposed_changes` array in your turn result with each file change (omit or set to `[]` on completion-only turns):');
236
+ lines.push(' ```json');
237
+ lines.push(' "proposed_changes": [');
238
+ lines.push(' { "path": "src/lib/foo.js", "action": "create", "content": "// full file..." },');
239
+ lines.push(' { "path": "src/lib/bar.js", "action": "modify", "content": "// full new content..." },');
240
+ lines.push(' { "path": "src/old.js", "action": "delete" }');
241
+ lines.push(' ]');
242
+ lines.push(' ```');
243
+ lines.push('- Valid actions: `create` (new file), `modify` (replace content), `delete` (remove file).');
244
+ lines.push('- `content` is required for `create` and `modify` actions.');
245
+ lines.push('- The orchestrator will materialize your proposal to `.agentxchain/proposed/<turn_id>/` for review.');
246
+ lines.push('- List all proposed file paths in `files_changed`.');
247
+ }
232
248
  lines.push('');
233
249
  }
234
250
 
@@ -366,6 +382,22 @@ function renderPrompt(role, roleId, turn, state, config, root) {
366
382
  lines.push(`- **You are in the \`${currentPhase}\` phase (final phase).** When ready to ship, set \`run_completion_request: true\` and \`phase_transition_request: null\`.`);
367
383
  }
368
384
  }
385
+ // Phase-specific guidance for proposed roles
386
+ if (role.write_authority === 'proposed' && phaseNames.length > 0) {
387
+ const currentPhase = state?.phase;
388
+ const phaseIdx = currentPhase ? phaseNames.indexOf(currentPhase) : -1;
389
+ if (phaseIdx >= 0 && phaseIdx < phaseNames.length - 1) {
390
+ const nextPhase = phaseNames[phaseIdx + 1];
391
+ const currentGate = config.routing?.[currentPhase]?.exit_gate;
392
+ const gateClause = currentGate ? ` and the exit gate (\`${currentGate}\`) is satisfied` : '';
393
+ lines.push(`- **You are in the \`${currentPhase}\` phase (not final phase).** When your work is complete${gateClause}, set \`phase_transition_request: "${nextPhase}"\`.`);
394
+ } else if (phaseIdx >= 0 && phaseIdx === phaseNames.length - 1) {
395
+ lines.push(`- **You are in the \`${currentPhase}\` phase (final phase).** When ready to ship, set \`run_completion_request: true\` and \`phase_transition_request: null\`.`);
396
+ if (runtimeType === 'api_proxy') {
397
+ lines.push('- **Completion turns must be no-op:** set `proposed_changes` to `[]` or omit it, set `files_changed` to `[]`, and set `artifact.type` to `"review"`. Do NOT propose file changes on a completion turn.');
398
+ }
399
+ }
400
+ }
369
401
  // Phase-specific guidance for review_only roles (terminal phase ship readiness)
370
402
  if (role.write_authority === 'review_only' && phaseNames.length > 0) {
371
403
  const currentPhase = state?.phase;
@@ -467,6 +499,42 @@ function renderContext(state, config, root, turn, role) {
467
499
  }
468
500
  }
469
501
 
502
+ const proposalPreview = role?.write_authority === 'review_only'
503
+ ? buildProposalArtifactPreview(root, lastTurn)
504
+ : null;
505
+ if (proposalPreview) {
506
+ lines.push('### Proposed Artifact');
507
+ lines.push('');
508
+ lines.push(`- **Artifact ref:** \`${proposalPreview.artifactRef}\``);
509
+ lines.push(`- **Proposed files:** ${proposalPreview.changeCount}`);
510
+ lines.push('');
511
+ lines.push('```');
512
+ lines.push(proposalPreview.summary);
513
+ lines.push('```');
514
+ if (proposalPreview.summaryTruncated) {
515
+ lines.push('');
516
+ lines.push(`_Preview truncated after ${PROPOSAL_SUMMARY_MAX_LINES} lines._`);
517
+ }
518
+ lines.push('');
519
+
520
+ if (proposalPreview.filePreviews.length > 0) {
521
+ lines.push('### Proposed File Previews');
522
+ lines.push('');
523
+ for (const preview of proposalPreview.filePreviews) {
524
+ lines.push(`#### \`${preview.path}\` (${preview.action})`);
525
+ lines.push('');
526
+ lines.push('```');
527
+ lines.push(preview.content);
528
+ lines.push('```');
529
+ if (preview.truncated) {
530
+ lines.push('');
531
+ lines.push(`_Preview truncated after ${FILE_PREVIEW_MAX_LINES} lines._`);
532
+ }
533
+ lines.push('');
534
+ }
535
+ }
536
+ }
537
+
470
538
  // Verification evidence from the previous turn
471
539
  // Use raw verification (has commands, machine_evidence, evidence_summary)
472
540
  // and supplement with normalized_verification status when available
@@ -683,6 +751,113 @@ function buildChangedFilePreviews(root, filesChanged) {
683
751
  return previews;
684
752
  }
685
753
 
754
+ function buildProposalArtifactPreview(root, lastTurn) {
755
+ const artifactRef = lastTurn?.artifact?.ref;
756
+ if (typeof artifactRef !== 'string' || !artifactRef.startsWith('.agentxchain/proposed/')) {
757
+ return null;
758
+ }
759
+
760
+ const absProposalDir = join(root, artifactRef);
761
+ const summaryPath = join(absProposalDir, 'PROPOSAL.md');
762
+
763
+ let summary = '';
764
+ let summaryTruncated = false;
765
+
766
+ if (existsSync(summaryPath)) {
767
+ try {
768
+ const raw = readFileSync(summaryPath, 'utf8');
769
+ const lines = raw.replace(/\r\n/g, '\n').split('\n');
770
+ summaryTruncated = lines.length > PROPOSAL_SUMMARY_MAX_LINES;
771
+ summary = (summaryTruncated ? lines.slice(0, PROPOSAL_SUMMARY_MAX_LINES) : lines)
772
+ .join('\n')
773
+ .trimEnd();
774
+ } catch {
775
+ summary = '';
776
+ }
777
+ }
778
+
779
+ const changeActions = extractProposalActions(summary);
780
+ const materializedFiles = collectProposalFiles(absProposalDir);
781
+
782
+ if (!summary) {
783
+ const fallbackLines = [
784
+ `# Proposed Changes — ${lastTurn.turn_id || '(unknown)'}`,
785
+ '',
786
+ lastTurn.summary || '(no summary)',
787
+ '',
788
+ ];
789
+ for (const filePath of materializedFiles) {
790
+ fallbackLines.push(`- \`${filePath}\` — ${changeActions.get(filePath) || 'create'}`);
791
+ }
792
+ summary = fallbackLines.join('\n');
793
+ }
794
+
795
+ const filePreviews = [];
796
+ for (const relPath of materializedFiles.slice(0, FILE_PREVIEW_MAX_FILES)) {
797
+ const absPath = join(absProposalDir, relPath);
798
+
799
+ let raw;
800
+ try {
801
+ raw = readFileSync(absPath, 'utf8');
802
+ } catch {
803
+ continue;
804
+ }
805
+
806
+ const lines = raw.replace(/\r\n/g, '\n').split('\n');
807
+ const truncated = lines.length > FILE_PREVIEW_MAX_LINES;
808
+ const previewLines = truncated ? lines.slice(0, FILE_PREVIEW_MAX_LINES) : lines;
809
+ filePreviews.push({
810
+ path: relPath,
811
+ action: changeActions.get(relPath) || 'create',
812
+ content: previewLines.join('\n').trimEnd(),
813
+ truncated,
814
+ });
815
+ }
816
+
817
+ return {
818
+ artifactRef,
819
+ summary,
820
+ summaryTruncated,
821
+ filePreviews,
822
+ changeCount: changeActions.size || materializedFiles.length,
823
+ };
824
+ }
825
+
826
+ function extractProposalActions(summary) {
827
+ const actions = new Map();
828
+ if (!summary) {
829
+ return actions;
830
+ }
831
+
832
+ const matches = summary.matchAll(/^- `([^`]+)` — ([a-z]+)/gm);
833
+ for (const match of matches) {
834
+ actions.set(match[1], match[2]);
835
+ }
836
+ return actions;
837
+ }
838
+
839
+ function collectProposalFiles(absProposalDir, relPrefix = '') {
840
+ if (!existsSync(absProposalDir)) {
841
+ return [];
842
+ }
843
+
844
+ const files = [];
845
+ for (const entry of readdirSync(absProposalDir, { withFileTypes: true })) {
846
+ if (entry.name === 'PROPOSAL.md') {
847
+ continue;
848
+ }
849
+
850
+ const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
851
+ const absPath = join(absProposalDir, entry.name);
852
+ if (entry.isDirectory()) {
853
+ files.push(...collectProposalFiles(absPath, relPath));
854
+ } else if (entry.isFile()) {
855
+ files.push(relPath);
856
+ }
857
+ }
858
+ return files.sort();
859
+ }
860
+
686
861
  function buildDispatchLogExcerpt(root, turnId) {
687
862
  const logPath = join(root, getDispatchLogPath(turnId));
688
863
  if (!existsSync(logPath)) {