agent-relay 2.3.11 → 2.3.13
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/install.sh +32 -0
- package/package.json +21 -21
- package/packages/acp-bridge/package.json +2 -2
- package/packages/bridge/package.json +7 -7
- package/packages/broker-sdk/README.md +32 -0
- package/packages/broker-sdk/dist/__tests__/unit.test.js +70 -2
- package/packages/broker-sdk/dist/__tests__/unit.test.js.map +1 -1
- package/packages/broker-sdk/dist/client.d.ts +2 -0
- package/packages/broker-sdk/dist/client.d.ts.map +1 -1
- package/packages/broker-sdk/dist/client.js +10 -0
- package/packages/broker-sdk/dist/client.js.map +1 -1
- package/packages/broker-sdk/dist/protocol.d.ts +4 -0
- package/packages/broker-sdk/dist/protocol.d.ts.map +1 -1
- package/packages/broker-sdk/dist/relay.d.ts +10 -0
- package/packages/broker-sdk/dist/relay.d.ts.map +1 -1
- package/packages/broker-sdk/dist/relay.js +53 -0
- package/packages/broker-sdk/dist/relay.js.map +1 -1
- package/packages/broker-sdk/dist/relaycast.d.ts +10 -0
- package/packages/broker-sdk/dist/relaycast.d.ts.map +1 -1
- package/packages/broker-sdk/dist/relaycast.js +40 -0
- package/packages/broker-sdk/dist/relaycast.js.map +1 -1
- package/packages/broker-sdk/dist/workflows/coordinator.d.ts +1 -0
- package/packages/broker-sdk/dist/workflows/coordinator.d.ts.map +1 -1
- package/packages/broker-sdk/dist/workflows/coordinator.js +239 -7
- package/packages/broker-sdk/dist/workflows/coordinator.js.map +1 -1
- package/packages/broker-sdk/dist/workflows/index.d.ts +1 -0
- package/packages/broker-sdk/dist/workflows/index.d.ts.map +1 -1
- package/packages/broker-sdk/dist/workflows/index.js +1 -0
- package/packages/broker-sdk/dist/workflows/index.js.map +1 -1
- package/packages/broker-sdk/dist/workflows/run.d.ts +3 -1
- package/packages/broker-sdk/dist/workflows/run.d.ts.map +1 -1
- package/packages/broker-sdk/dist/workflows/run.js +4 -0
- package/packages/broker-sdk/dist/workflows/run.js.map +1 -1
- package/packages/broker-sdk/dist/workflows/runner.d.ts +9 -0
- package/packages/broker-sdk/dist/workflows/runner.d.ts.map +1 -1
- package/packages/broker-sdk/dist/workflows/runner.js +203 -14
- package/packages/broker-sdk/dist/workflows/runner.js.map +1 -1
- package/packages/broker-sdk/dist/workflows/trajectory.d.ts +80 -0
- package/packages/broker-sdk/dist/workflows/trajectory.d.ts.map +1 -0
- package/packages/broker-sdk/dist/workflows/trajectory.js +362 -0
- package/packages/broker-sdk/dist/workflows/trajectory.js.map +1 -0
- package/packages/broker-sdk/dist/workflows/types.d.ts +15 -1
- package/packages/broker-sdk/dist/workflows/types.d.ts.map +1 -1
- package/packages/broker-sdk/package.json +2 -2
- package/packages/broker-sdk/src/__tests__/swarm-coordinator.test.ts +356 -0
- package/packages/broker-sdk/src/__tests__/unit.test.ts +92 -1
- package/packages/broker-sdk/src/__tests__/workflow-trajectory.test.ts +408 -0
- package/packages/broker-sdk/src/client.ts +15 -0
- package/packages/broker-sdk/src/protocol.ts +5 -0
- package/packages/broker-sdk/src/relay.ts +59 -0
- package/packages/broker-sdk/src/relaycast.ts +42 -0
- package/packages/broker-sdk/src/workflows/README.md +64 -0
- package/packages/broker-sdk/src/workflows/coordinator.ts +246 -8
- package/packages/broker-sdk/src/workflows/index.ts +1 -0
- package/packages/broker-sdk/src/workflows/run.ts +9 -1
- package/packages/broker-sdk/src/workflows/runner.ts +249 -14
- package/packages/broker-sdk/src/workflows/schema.json +13 -1
- package/packages/broker-sdk/src/workflows/trajectory.ts +507 -0
- package/packages/broker-sdk/src/workflows/types.ts +31 -1
- package/packages/broker-sdk/tsconfig.json +1 -0
- package/packages/broker-sdk/vitest.config.ts +9 -0
- package/packages/config/package.json +2 -2
- package/packages/continuity/package.json +2 -2
- package/packages/daemon/package.json +12 -12
- package/packages/hooks/package.json +4 -4
- package/packages/mcp/package.json +5 -5
- package/packages/memory/package.json +2 -2
- package/packages/policy/package.json +2 -2
- package/packages/protocol/package.json +1 -1
- package/packages/resiliency/package.json +1 -1
- package/packages/sdk/package.json +3 -3
- package/packages/sdk-py/src/agent_relay/builder.py +4 -0
- package/packages/sdk-py/src/agent_relay/types.py +15 -0
- package/packages/spawner/package.json +1 -1
- package/packages/state/package.json +1 -1
- package/packages/storage/package.json +2 -2
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +3 -3
- package/packages/wrapper/package.json +5 -5
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkflowTrajectory unit tests.
|
|
3
|
+
*
|
|
4
|
+
* Tests trajectory recording, chapter management, reflections, decisions,
|
|
5
|
+
* confidence computation, and the disabled/enabled toggle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, rmSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { WorkflowTrajectory, type StepOutcome } from '../workflows/trajectory.js';
|
|
13
|
+
|
|
14
|
+
// ── Test helpers ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
let tmpDir: string;
|
|
17
|
+
|
|
18
|
+
function makeTmpDir(): string {
|
|
19
|
+
const dir = path.join(os.tmpdir(), `wf-traj-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
20
|
+
mkdirSync(dir, { recursive: true });
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readTrajectoryFile(dir: string): any {
|
|
25
|
+
const activeDir = path.join(dir, '.trajectories', 'active');
|
|
26
|
+
if (!existsSync(activeDir)) return null;
|
|
27
|
+
|
|
28
|
+
const files = readdirSync(activeDir);
|
|
29
|
+
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
|
|
30
|
+
if (jsonFiles.length === 0) return null;
|
|
31
|
+
|
|
32
|
+
return JSON.parse(readFileSync(path.join(activeDir, jsonFiles[0]), 'utf-8'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readCompletedTrajectoryFile(dir: string): any {
|
|
36
|
+
const completedDir = path.join(dir, '.trajectories', 'completed');
|
|
37
|
+
if (!existsSync(completedDir)) return null;
|
|
38
|
+
|
|
39
|
+
const files = readdirSync(completedDir);
|
|
40
|
+
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
|
|
41
|
+
if (jsonFiles.length === 0) return null;
|
|
42
|
+
|
|
43
|
+
return JSON.parse(readFileSync(path.join(completedDir, jsonFiles[0]), 'utf-8'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
describe('WorkflowTrajectory', () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
tmpDir = makeTmpDir();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
try {
|
|
55
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
56
|
+
} catch {
|
|
57
|
+
// cleanup best-effort
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── Disabled mode ──────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
describe('disabled', () => {
|
|
64
|
+
it('should not create files when trajectories is false', async () => {
|
|
65
|
+
const traj = new WorkflowTrajectory(false, 'run-1', tmpDir);
|
|
66
|
+
await traj.start('test-workflow', 3);
|
|
67
|
+
|
|
68
|
+
expect(traj.isEnabled()).toBe(false);
|
|
69
|
+
expect(traj.getTrajectoryId()).toBeNull();
|
|
70
|
+
expect(existsSync(path.join(tmpDir, '.trajectories'))).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should not create files when enabled is false', async () => {
|
|
74
|
+
const traj = new WorkflowTrajectory({ enabled: false }, 'run-1', tmpDir);
|
|
75
|
+
await traj.start('test-workflow', 3);
|
|
76
|
+
|
|
77
|
+
expect(traj.isEnabled()).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should be enabled by default', () => {
|
|
81
|
+
const traj = new WorkflowTrajectory(undefined, 'run-1', tmpDir);
|
|
82
|
+
expect(traj.isEnabled()).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
describe('lifecycle', () => {
|
|
89
|
+
it('should create a trajectory file on start', async () => {
|
|
90
|
+
const traj = new WorkflowTrajectory({}, 'run-abc', tmpDir);
|
|
91
|
+
await traj.start('my-workflow', 5);
|
|
92
|
+
|
|
93
|
+
expect(traj.getTrajectoryId()).toBeTruthy();
|
|
94
|
+
expect(traj.getTrajectoryId()!.startsWith('traj_')).toBe(true);
|
|
95
|
+
|
|
96
|
+
const data = readTrajectoryFile(tmpDir);
|
|
97
|
+
expect(data).toBeTruthy();
|
|
98
|
+
expect(data.status).toBe('active');
|
|
99
|
+
expect(data.task.title).toContain('my-workflow');
|
|
100
|
+
expect(data.agents).toHaveLength(1);
|
|
101
|
+
expect(data.agents[0].name).toBe('orchestrator');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should create Planning chapter on start', async () => {
|
|
105
|
+
const traj = new WorkflowTrajectory({}, 'run-abc', tmpDir);
|
|
106
|
+
await traj.start('my-workflow', 3, '3 parallel tracks, 2 barriers');
|
|
107
|
+
|
|
108
|
+
const data = readTrajectoryFile(tmpDir);
|
|
109
|
+
expect(data.chapters).toHaveLength(1);
|
|
110
|
+
expect(data.chapters[0].title).toBe('Planning');
|
|
111
|
+
expect(data.chapters[0].events.length).toBeGreaterThanOrEqual(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should complete trajectory and move to completed dir', async () => {
|
|
115
|
+
const traj = new WorkflowTrajectory({}, 'run-abc', tmpDir);
|
|
116
|
+
await traj.start('my-workflow', 2);
|
|
117
|
+
await traj.complete('All done', 0.95);
|
|
118
|
+
|
|
119
|
+
const active = readTrajectoryFile(tmpDir);
|
|
120
|
+
expect(active).toBeNull(); // Moved out of active
|
|
121
|
+
|
|
122
|
+
const completed = readCompletedTrajectoryFile(tmpDir);
|
|
123
|
+
expect(completed).toBeTruthy();
|
|
124
|
+
expect(completed.status).toBe('completed');
|
|
125
|
+
expect(completed.retrospective.summary).toBe('All done');
|
|
126
|
+
expect(completed.retrospective.confidence).toBe(0.95);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should abandon trajectory and move to completed dir', async () => {
|
|
130
|
+
const traj = new WorkflowTrajectory({}, 'run-abc', tmpDir);
|
|
131
|
+
await traj.start('my-workflow', 2);
|
|
132
|
+
await traj.abandon('Something went wrong');
|
|
133
|
+
|
|
134
|
+
const completed = readCompletedTrajectoryFile(tmpDir);
|
|
135
|
+
expect(completed).toBeTruthy();
|
|
136
|
+
expect(completed.status).toBe('abandoned');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── Step events ────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe('step events', () => {
|
|
143
|
+
it('should record step started', async () => {
|
|
144
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
145
|
+
await traj.start('wf', 2);
|
|
146
|
+
await traj.stepStarted(
|
|
147
|
+
{ name: 'build', agent: 'builder', task: 'Build it' },
|
|
148
|
+
'builder-agent',
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const data = readTrajectoryFile(tmpDir);
|
|
152
|
+
expect(data.agents).toHaveLength(2); // orchestrator + builder-agent
|
|
153
|
+
const events = data.chapters.flatMap((c: any) => c.events);
|
|
154
|
+
expect(events.some((e: any) => e.content.includes('build'))).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should record step completed', async () => {
|
|
158
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
159
|
+
await traj.start('wf', 1);
|
|
160
|
+
await traj.stepCompleted(
|
|
161
|
+
{ name: 'test', agent: 'tester', task: 'Run tests' },
|
|
162
|
+
'All tests passing',
|
|
163
|
+
1,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const data = readTrajectoryFile(tmpDir);
|
|
167
|
+
const events = data.chapters.flatMap((c: any) => c.events);
|
|
168
|
+
expect(events.some((e: any) => e.type === 'finding')).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should record step failed', async () => {
|
|
172
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
173
|
+
await traj.start('wf', 1);
|
|
174
|
+
await traj.stepFailed(
|
|
175
|
+
{ name: 'deploy', agent: 'deployer', task: 'Deploy' },
|
|
176
|
+
'Connection refused',
|
|
177
|
+
1,
|
|
178
|
+
3,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const data = readTrajectoryFile(tmpDir);
|
|
182
|
+
const events = data.chapters.flatMap((c: any) => c.events);
|
|
183
|
+
expect(events.some((e: any) => e.type === 'error')).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should record step skipped', async () => {
|
|
187
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
188
|
+
await traj.start('wf', 2);
|
|
189
|
+
await traj.stepSkipped(
|
|
190
|
+
{ name: 'integration', agent: 'tester', task: 'Test' },
|
|
191
|
+
'Upstream failed',
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const data = readTrajectoryFile(tmpDir);
|
|
195
|
+
const events = data.chapters.flatMap((c: any) => c.events);
|
|
196
|
+
expect(events.some((e: any) => e.content.includes('Skipped'))).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ── Chapters ───────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
describe('chapters', () => {
|
|
203
|
+
it('should create track chapters', async () => {
|
|
204
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
205
|
+
await traj.start('wf', 3);
|
|
206
|
+
await traj.beginTrack('backend');
|
|
207
|
+
|
|
208
|
+
const data = readTrajectoryFile(tmpDir);
|
|
209
|
+
expect(data.chapters.length).toBeGreaterThanOrEqual(2);
|
|
210
|
+
expect(data.chapters.some((c: any) => c.title === 'Execution: backend')).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should create convergence chapters', async () => {
|
|
214
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
215
|
+
await traj.start('wf', 3);
|
|
216
|
+
await traj.beginConvergence('all-tracks-done');
|
|
217
|
+
|
|
218
|
+
const data = readTrajectoryFile(tmpDir);
|
|
219
|
+
expect(data.chapters.some((c: any) => c.title === 'Convergence: all-tracks-done')).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should close previous chapter when opening new one', async () => {
|
|
223
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
224
|
+
await traj.start('wf', 3);
|
|
225
|
+
await traj.beginTrack('track-a');
|
|
226
|
+
await traj.beginTrack('track-b');
|
|
227
|
+
|
|
228
|
+
const data = readTrajectoryFile(tmpDir);
|
|
229
|
+
// Planning chapter should have endedAt
|
|
230
|
+
expect(data.chapters[0].endedAt).toBeTruthy();
|
|
231
|
+
// First track chapter should have endedAt
|
|
232
|
+
expect(data.chapters[1].endedAt).toBeTruthy();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ── Reflections ────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
describe('reflections', () => {
|
|
239
|
+
it('should record reflect events', async () => {
|
|
240
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
241
|
+
await traj.start('wf', 2);
|
|
242
|
+
await traj.reflect('All parallel tracks complete', 0.85, ['step-a: completed', 'step-b: completed']);
|
|
243
|
+
|
|
244
|
+
const data = readTrajectoryFile(tmpDir);
|
|
245
|
+
const events = data.chapters.flatMap((c: any) => c.events);
|
|
246
|
+
const reflection = events.find((e: any) => e.type === 'reflection');
|
|
247
|
+
expect(reflection).toBeTruthy();
|
|
248
|
+
expect(reflection.significance).toBe('high');
|
|
249
|
+
expect(reflection.raw.confidence).toBe(0.85);
|
|
250
|
+
expect(reflection.raw.focalPoints).toHaveLength(2);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should synthesize and reflect at convergence', async () => {
|
|
254
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
255
|
+
await traj.start('wf', 3);
|
|
256
|
+
|
|
257
|
+
const outcomes: StepOutcome[] = [
|
|
258
|
+
{ name: 'step-a', agent: 'a', status: 'completed', attempts: 1 },
|
|
259
|
+
{ name: 'step-b', agent: 'b', status: 'completed', attempts: 2 },
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
await traj.synthesizeAndReflect('backend-ready', outcomes, ['step-c']);
|
|
263
|
+
|
|
264
|
+
const data = readTrajectoryFile(tmpDir);
|
|
265
|
+
// Should have a convergence chapter
|
|
266
|
+
expect(data.chapters.some((c: any) => c.title.includes('Convergence'))).toBe(true);
|
|
267
|
+
const events = data.chapters.flatMap((c: any) => c.events);
|
|
268
|
+
const reflection = events.find((e: any) => e.type === 'reflection');
|
|
269
|
+
expect(reflection).toBeTruthy();
|
|
270
|
+
expect(reflection.content).toContain('backend-ready');
|
|
271
|
+
expect(reflection.content).toContain('step-b'); // retried
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ── Decisions ──────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
describe('decisions', () => {
|
|
278
|
+
it('should record decisions', async () => {
|
|
279
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
280
|
+
await traj.start('wf', 1);
|
|
281
|
+
await traj.decide('How to handle failure', 'retry', 'Transient error detected');
|
|
282
|
+
|
|
283
|
+
const data = readTrajectoryFile(tmpDir);
|
|
284
|
+
const events = data.chapters.flatMap((c: any) => c.events);
|
|
285
|
+
const decision = events.find((e: any) => e.type === 'decision');
|
|
286
|
+
expect(decision).toBeTruthy();
|
|
287
|
+
expect(decision.raw.chosen).toBe('retry');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should skip decisions when autoDecisions is false', async () => {
|
|
291
|
+
const traj = new WorkflowTrajectory({ autoDecisions: false }, 'run-1', tmpDir);
|
|
292
|
+
await traj.start('wf', 1);
|
|
293
|
+
await traj.decide('How to handle failure', 'retry', 'Transient error');
|
|
294
|
+
|
|
295
|
+
const data = readTrajectoryFile(tmpDir);
|
|
296
|
+
const events = data.chapters.flatMap((c: any) => c.events);
|
|
297
|
+
expect(events.filter((e: any) => e.type === 'decision')).toHaveLength(0);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ── Confidence computation ─────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
describe('computeConfidence', () => {
|
|
304
|
+
it('should return 1.0 for all first-attempt verified completions', () => {
|
|
305
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
306
|
+
const outcomes: StepOutcome[] = [
|
|
307
|
+
{ name: 'a', agent: 'a', status: 'completed', attempts: 1, verificationPassed: true },
|
|
308
|
+
{ name: 'b', agent: 'b', status: 'completed', attempts: 1, verificationPassed: true },
|
|
309
|
+
];
|
|
310
|
+
expect(traj.computeConfidence(outcomes)).toBe(1.0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should return lower confidence for retried steps', () => {
|
|
314
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
315
|
+
const outcomes: StepOutcome[] = [
|
|
316
|
+
{ name: 'a', agent: 'a', status: 'completed', attempts: 1, verificationPassed: true },
|
|
317
|
+
{ name: 'b', agent: 'b', status: 'completed', attempts: 3, verificationPassed: true },
|
|
318
|
+
];
|
|
319
|
+
const confidence = traj.computeConfidence(outcomes);
|
|
320
|
+
expect(confidence).toBeLessThan(1.0);
|
|
321
|
+
expect(confidence).toBeGreaterThan(0.5);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should return lower confidence for failed steps', () => {
|
|
325
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
326
|
+
const outcomes: StepOutcome[] = [
|
|
327
|
+
{ name: 'a', agent: 'a', status: 'completed', attempts: 1 },
|
|
328
|
+
{ name: 'b', agent: 'b', status: 'failed', attempts: 3 },
|
|
329
|
+
];
|
|
330
|
+
const confidence = traj.computeConfidence(outcomes);
|
|
331
|
+
expect(confidence).toBeLessThan(0.5);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should return 0.7 for empty outcomes', () => {
|
|
335
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
336
|
+
expect(traj.computeConfidence([])).toBe(0.7);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ── Synthesis helpers ──────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
describe('buildSynthesis', () => {
|
|
343
|
+
it('should produce meaningful synthesis text', () => {
|
|
344
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
345
|
+
const outcomes: StepOutcome[] = [
|
|
346
|
+
{ name: 'step-a', agent: 'a', status: 'completed', attempts: 1 },
|
|
347
|
+
{ name: 'step-b', agent: 'b', status: 'completed', attempts: 2 },
|
|
348
|
+
{ name: 'step-c', agent: 'c', status: 'failed', attempts: 3, error: 'timeout' },
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
const synthesis = traj.buildSynthesis('barrier-1', outcomes, ['step-d']);
|
|
352
|
+
expect(synthesis).toContain('barrier-1');
|
|
353
|
+
expect(synthesis).toContain('2/3 steps completed');
|
|
354
|
+
expect(synthesis).toContain('step-c'); // failed
|
|
355
|
+
expect(synthesis).toContain('step-b'); // retried
|
|
356
|
+
expect(synthesis).toContain('step-d'); // unblocked
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should note all-first-attempt when no retries', () => {
|
|
360
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
361
|
+
const outcomes: StepOutcome[] = [
|
|
362
|
+
{ name: 'a', agent: 'a', status: 'completed', attempts: 1 },
|
|
363
|
+
{ name: 'b', agent: 'b', status: 'completed', attempts: 1 },
|
|
364
|
+
];
|
|
365
|
+
|
|
366
|
+
const synthesis = traj.buildSynthesis('done', outcomes);
|
|
367
|
+
expect(synthesis).toContain('All steps completed on first attempt');
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe('buildRunSummary', () => {
|
|
372
|
+
it('should produce run summary with stats', () => {
|
|
373
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
374
|
+
const outcomes: StepOutcome[] = [
|
|
375
|
+
{ name: 'a', agent: 'a', status: 'completed', attempts: 1 },
|
|
376
|
+
{ name: 'b', agent: 'b', status: 'completed', attempts: 2 },
|
|
377
|
+
{ name: 'c', agent: 'c', status: 'failed', attempts: 3 },
|
|
378
|
+
{ name: 'd', agent: 'd', status: 'skipped', attempts: 1 },
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
const summary = traj.buildRunSummary(outcomes);
|
|
382
|
+
expect(summary).toContain('2/4 steps passed');
|
|
383
|
+
expect(summary).toContain('1 failed');
|
|
384
|
+
expect(summary).toContain('1 skipped');
|
|
385
|
+
expect(summary).toContain('retries');
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// ── Non-blocking behavior ──────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
describe('non-blocking', () => {
|
|
392
|
+
it('should not throw on flush errors', async () => {
|
|
393
|
+
// Use a path that will fail (read-only or invalid)
|
|
394
|
+
const traj = new WorkflowTrajectory({}, 'run-1', '/dev/null/impossible-path');
|
|
395
|
+
// Should not throw
|
|
396
|
+
await expect(traj.start('wf', 1)).resolves.not.toThrow();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('should handle all methods gracefully when not started', async () => {
|
|
400
|
+
const traj = new WorkflowTrajectory({}, 'run-1', tmpDir);
|
|
401
|
+
// Don't call start — all methods should be no-ops
|
|
402
|
+
await expect(traj.stepStarted({ name: 'x', agent: 'a', task: 't' }, 'a')).resolves.not.toThrow();
|
|
403
|
+
await expect(traj.reflect('test', 0.5)).resolves.not.toThrow();
|
|
404
|
+
await expect(traj.decide('q', 'c', 'r')).resolves.not.toThrow();
|
|
405
|
+
await expect(traj.complete('done', 0.9)).resolves.not.toThrow();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
});
|
|
@@ -35,6 +35,8 @@ export interface SpawnPtyInput {
|
|
|
35
35
|
args?: string[];
|
|
36
36
|
channels?: string[];
|
|
37
37
|
task?: string;
|
|
38
|
+
/** Silence duration in seconds before emitting agent_idle (0 = disabled, default: 30). */
|
|
39
|
+
idleThresholdSecs?: number;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export interface SpawnHeadlessClaudeInput {
|
|
@@ -170,6 +172,7 @@ export class AgentRelayClient {
|
|
|
170
172
|
const result = await this.requestOk<{ name: string; runtime: AgentRuntime }>("spawn_agent", {
|
|
171
173
|
agent,
|
|
172
174
|
...(input.task != null ? { initial_task: input.task } : {}),
|
|
175
|
+
...(input.idleThresholdSecs != null ? { idle_threshold_secs: input.idleThresholdSecs } : {}),
|
|
173
176
|
});
|
|
174
177
|
return result;
|
|
175
178
|
}
|
|
@@ -486,10 +489,22 @@ function isExplicitPath(binaryPath: string): boolean {
|
|
|
486
489
|
|
|
487
490
|
function resolveDefaultBinaryPath(): string {
|
|
488
491
|
const exe = process.platform === "win32" ? "agent-relay.exe" : "agent-relay";
|
|
492
|
+
const brokerExe = process.platform === "win32" ? "agent-relay-broker.exe" : "agent-relay-broker";
|
|
493
|
+
|
|
494
|
+
// 1. Check for bundled broker binary in SDK package (npm install)
|
|
489
495
|
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
490
496
|
const bundled = path.resolve(moduleDir, "..", "bin", exe);
|
|
491
497
|
if (fs.existsSync(bundled)) {
|
|
492
498
|
return bundled;
|
|
493
499
|
}
|
|
500
|
+
|
|
501
|
+
// 2. Check for standalone broker binary in ~/.agent-relay/bin/ (install.sh)
|
|
502
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
|
503
|
+
const standaloneBroker = path.join(homeDir, ".agent-relay", "bin", brokerExe);
|
|
504
|
+
if (fs.existsSync(standaloneBroker)) {
|
|
505
|
+
return standaloneBroker;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 3. Fall back to agent-relay on PATH (may be Node CLI — will fail for broker ops)
|
|
494
509
|
return "agent-relay";
|
|
495
510
|
}
|
|
@@ -76,6 +76,10 @@ export interface Agent {
|
|
|
76
76
|
* @param timeoutMs — optional timeout in ms. Resolves with `"timeout"` if exceeded,
|
|
77
77
|
* `"exited"` if the agent exited naturally, or `"released"` if released externally. */
|
|
78
78
|
waitForExit(timeoutMs?: number): Promise<"exited" | "timeout" | "released">;
|
|
79
|
+
/** Wait for the agent to go idle (no PTY output for the configured threshold).
|
|
80
|
+
* @param timeoutMs — optional timeout in ms. Resolves with `"idle"` when first idle event fires,
|
|
81
|
+
* `"timeout"` if timeoutMs elapses first, or `"exited"` if the agent exits. */
|
|
82
|
+
waitForIdle(timeoutMs?: number): Promise<"idle" | "timeout" | "exited">;
|
|
79
83
|
sendMessage(input: {
|
|
80
84
|
to: string;
|
|
81
85
|
text: string;
|
|
@@ -128,6 +132,7 @@ export class AgentRelay {
|
|
|
128
132
|
onAgentReady: EventHook<Agent> = null;
|
|
129
133
|
onWorkerOutput: EventHook<{ name: string; stream: string; chunk: string }> = null;
|
|
130
134
|
onDeliveryUpdate: EventHook<BrokerEvent> = null;
|
|
135
|
+
onAgentIdle: EventHook<{ name: string; idleSecs: number }> = null;
|
|
131
136
|
|
|
132
137
|
// Shorthand spawners
|
|
133
138
|
readonly codex: AgentSpawner;
|
|
@@ -145,6 +150,11 @@ export class AgentRelay {
|
|
|
145
150
|
{ resolve: (reason: "exited" | "released") => void; token: number }
|
|
146
151
|
>();
|
|
147
152
|
private exitResolverSeq = 0;
|
|
153
|
+
private readonly idleResolvers = new Map<
|
|
154
|
+
string,
|
|
155
|
+
{ resolve: (reason: "idle" | "timeout" | "exited") => void; token: number }
|
|
156
|
+
>();
|
|
157
|
+
private idleResolverSeq = 0;
|
|
148
158
|
private readonly relaycastByName = new Map<string, RelaycastApi>();
|
|
149
159
|
|
|
150
160
|
constructor(options: AgentRelayOptions = {}) {
|
|
@@ -176,6 +186,7 @@ export class AgentRelay {
|
|
|
176
186
|
args: input.args,
|
|
177
187
|
channels,
|
|
178
188
|
task: input.task,
|
|
189
|
+
idleThresholdSecs: input.idleThresholdSecs,
|
|
179
190
|
});
|
|
180
191
|
const agent = this.makeAgent(result.name, result.runtime, channels);
|
|
181
192
|
this.knownAgents.set(agent.name, agent);
|
|
@@ -326,6 +337,10 @@ export class AgentRelay {
|
|
|
326
337
|
entry.resolve("released");
|
|
327
338
|
}
|
|
328
339
|
this.exitResolvers.clear();
|
|
340
|
+
for (const entry of this.idleResolvers.values()) {
|
|
341
|
+
entry.resolve("exited");
|
|
342
|
+
}
|
|
343
|
+
this.idleResolvers.clear();
|
|
329
344
|
}
|
|
330
345
|
|
|
331
346
|
// ── Private helpers ─────────────────────────────────────────────────────
|
|
@@ -403,6 +418,8 @@ export class AgentRelay {
|
|
|
403
418
|
this.knownAgents.delete(event.name);
|
|
404
419
|
this.exitResolvers.get(event.name)?.resolve("released");
|
|
405
420
|
this.exitResolvers.delete(event.name);
|
|
421
|
+
this.idleResolvers.get(event.name)?.resolve("exited");
|
|
422
|
+
this.idleResolvers.delete(event.name);
|
|
406
423
|
break;
|
|
407
424
|
}
|
|
408
425
|
case "agent_exited": {
|
|
@@ -416,6 +433,8 @@ export class AgentRelay {
|
|
|
416
433
|
this.knownAgents.delete(event.name);
|
|
417
434
|
this.exitResolvers.get(event.name)?.resolve("exited");
|
|
418
435
|
this.exitResolvers.delete(event.name);
|
|
436
|
+
this.idleResolvers.get(event.name)?.resolve("exited");
|
|
437
|
+
this.idleResolvers.delete(event.name);
|
|
419
438
|
break;
|
|
420
439
|
}
|
|
421
440
|
case "worker_ready": {
|
|
@@ -435,6 +454,16 @@ export class AgentRelay {
|
|
|
435
454
|
});
|
|
436
455
|
break;
|
|
437
456
|
}
|
|
457
|
+
case "agent_idle": {
|
|
458
|
+
this.onAgentIdle?.({
|
|
459
|
+
name: event.name,
|
|
460
|
+
idleSecs: event.idle_secs,
|
|
461
|
+
});
|
|
462
|
+
// Resolve idle waiters
|
|
463
|
+
this.idleResolvers.get(event.name)?.resolve("idle");
|
|
464
|
+
this.idleResolvers.delete(event.name);
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
438
467
|
}
|
|
439
468
|
if (event.kind.startsWith("delivery_")) {
|
|
440
469
|
this.onDeliveryUpdate?.(event);
|
|
@@ -491,6 +520,36 @@ export class AgentRelay {
|
|
|
491
520
|
}
|
|
492
521
|
});
|
|
493
522
|
},
|
|
523
|
+
waitForIdle(timeoutMs?: number) {
|
|
524
|
+
return new Promise<"idle" | "timeout" | "exited">((resolve) => {
|
|
525
|
+
if (!relay.knownAgents.has(name)) {
|
|
526
|
+
resolve("exited");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (timeoutMs === 0) {
|
|
530
|
+
resolve("timeout");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
534
|
+
const token = ++relay.idleResolverSeq;
|
|
535
|
+
relay.idleResolvers.set(name, {
|
|
536
|
+
resolve(reason) {
|
|
537
|
+
if (timer) clearTimeout(timer);
|
|
538
|
+
resolve(reason);
|
|
539
|
+
},
|
|
540
|
+
token,
|
|
541
|
+
});
|
|
542
|
+
if (timeoutMs !== undefined) {
|
|
543
|
+
timer = setTimeout(() => {
|
|
544
|
+
const current = relay.idleResolvers.get(name);
|
|
545
|
+
if (current?.token === token) {
|
|
546
|
+
relay.idleResolvers.delete(name);
|
|
547
|
+
}
|
|
548
|
+
resolve("timeout");
|
|
549
|
+
}, timeoutMs);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
},
|
|
494
553
|
async sendMessage(input) {
|
|
495
554
|
const client = await relay.ensureStarted();
|
|
496
555
|
let result: Awaited<ReturnType<typeof client.sendMessage>>;
|
|
@@ -126,6 +126,48 @@ export class RelaycastApi {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/** Create a channel. No-op if it already exists. */
|
|
130
|
+
async createChannel(name: string, topic?: string): Promise<void> {
|
|
131
|
+
const agent = await this.ensure();
|
|
132
|
+
try {
|
|
133
|
+
await agent.channels.create({ name, ...(topic ? { topic } : {}) });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// Ignore "already exists" errors
|
|
136
|
+
if (err instanceof RelayError && err.code === "channel_already_exists") {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Join a channel. Idempotent. */
|
|
144
|
+
async joinChannel(name: string): Promise<void> {
|
|
145
|
+
const agent = await this.ensure();
|
|
146
|
+
await agent.channels.join(name);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Invite another agent to a channel. */
|
|
150
|
+
async inviteToChannel(channel: string, agentName: string): Promise<void> {
|
|
151
|
+
const agent = await this.ensure();
|
|
152
|
+
await agent.channels.invite(channel, agentName);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Register an external agent in the workspace (e.g., a spawned workflow agent).
|
|
156
|
+
* Uses the workspace API key to register, not an agent token.
|
|
157
|
+
* No-op if the agent already exists. */
|
|
158
|
+
async registerExternalAgent(name: string, persona?: string): Promise<void> {
|
|
159
|
+
const apiKey = await this.resolveApiKey();
|
|
160
|
+
const relay = new RelayCast({ apiKey, baseUrl: this.baseUrl });
|
|
161
|
+
try {
|
|
162
|
+
await relay.agents.register({ name, type: "agent", ...(persona ? { persona } : {}) });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err instanceof RelayError && err.code === "agent_already_exists") {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
129
171
|
/** Fetch message history from a channel. */
|
|
130
172
|
async getMessages(
|
|
131
173
|
channel: string,
|