@namzu/sdk 10.0.0 → 12.0.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.
- package/CHANGELOG.md +124 -0
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +2 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +12 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/contracts/api.d.ts +1 -1
- package/dist/contracts/api.d.ts.map +1 -1
- package/dist/manager/plan/lifecycle.d.ts +11 -1
- package/dist/manager/plan/lifecycle.d.ts.map +1 -1
- package/dist/manager/plan/lifecycle.js +20 -1
- package/dist/manager/plan/lifecycle.js.map +1 -1
- package/dist/run/reporter.d.ts.map +1 -1
- package/dist/run/reporter.js +2 -0
- package/dist/run/reporter.js.map +1 -1
- package/dist/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.js +90 -0
- package/dist/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.js.map +1 -0
- package/dist/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.js +97 -0
- package/dist/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.js.map +1 -0
- package/dist/runtime/query/events.d.ts.map +1 -1
- package/dist/runtime/query/events.js +20 -2
- package/dist/runtime/query/events.js.map +1 -1
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +14 -1
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/result.d.ts.map +1 -1
- package/dist/runtime/query/result.js +17 -1
- package/dist/runtime/query/result.js.map +1 -1
- package/dist/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.js +139 -0
- package/dist/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.js.map +1 -0
- package/dist/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.js +160 -0
- package/dist/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.js.map +1 -0
- package/dist/tools/coordinator/__tests__/approve-plan.test.js +21 -6
- package/dist/tools/coordinator/__tests__/approve-plan.test.js.map +1 -1
- package/dist/tools/coordinator/__tests__/completion-delivery.test.js +4 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -1
- package/dist/tools/coordinator/__tests__/task-list.test.js +44 -15
- package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +159 -8
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/types/plan/index.d.ts +13 -0
- package/dist/types/plan/index.d.ts.map +1 -1
- package/dist/types/run/events.d.ts +33 -0
- package/dist/types/run/events.d.ts.map +1 -1
- package/dist/types/run/events.js.map +1 -1
- package/package.json +1 -1
- package/src/bridge/a2a/mapper.ts +2 -0
- package/src/bridge/sse/mapper.ts +14 -0
- package/src/contracts/api.ts +5 -0
- package/src/manager/plan/lifecycle.ts +20 -1
- package/src/run/reporter.ts +2 -0
- package/src/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.ts +109 -0
- package/src/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.ts +122 -0
- package/src/runtime/query/events.ts +20 -2
- package/src/runtime/query/index.ts +15 -2
- package/src/runtime/query/result.ts +18 -1
- package/src/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.ts +171 -0
- package/src/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.ts +215 -0
- package/src/tools/coordinator/__tests__/approve-plan.test.ts +32 -11
- package/src/tools/coordinator/__tests__/completion-delivery.test.ts +7 -0
- package/src/tools/coordinator/__tests__/task-list.test.ts +47 -20
- package/src/tools/coordinator/index.ts +177 -8
- package/src/types/plan/index.ts +14 -0
- package/src/types/run/events.ts +24 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { PlanManager } from '../../../manager/plan/lifecycle.js'
|
|
4
|
+
import { MockLLMProvider, registerMock } from '../../../provider/index.js'
|
|
5
|
+
import { ToolRegistry } from '../../../registry/index.js'
|
|
6
|
+
import {
|
|
7
|
+
generateProjectId,
|
|
8
|
+
generateSessionId,
|
|
9
|
+
generateTenantId,
|
|
10
|
+
generateThreadId,
|
|
11
|
+
} from '../../../utils/id.js'
|
|
12
|
+
import { drainQuery } from '../index.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Nothing settled a plan that SUCCEEDED.
|
|
16
|
+
*
|
|
17
|
+
* The error path calls `failPlan`, so a run that blew up said so. The success
|
|
18
|
+
* path never touched the plan manager at all — so a plan could reach `failed`
|
|
19
|
+
* or sit at `executing` forever, but never `completed`. A host reading
|
|
20
|
+
* `plan.status` after a successful run was told the work was still going.
|
|
21
|
+
*
|
|
22
|
+
* Settlement is conditional on every step having reported, and the condition is
|
|
23
|
+
* read rather than caught: `completePlan` refuses an unreported step on
|
|
24
|
+
* purpose, and letting that throw here would turn a run that worked into a run
|
|
25
|
+
* that crashed on its way out — a worse version of the bug the refusal exists
|
|
26
|
+
* to prevent.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
registerMock()
|
|
30
|
+
|
|
31
|
+
/** Run to completion, with a plan seeded through the host's own seam. */
|
|
32
|
+
async function runWithPlan(seed: (pm: PlanManager) => void): Promise<PlanManager> {
|
|
33
|
+
let captured: PlanManager | undefined
|
|
34
|
+
|
|
35
|
+
await drainQuery({
|
|
36
|
+
provider: new MockLLMProvider({ responses: [{ content: 'done' }] } as never),
|
|
37
|
+
tools: new ToolRegistry(),
|
|
38
|
+
agentId: 'a',
|
|
39
|
+
agentName: 'A',
|
|
40
|
+
messages: [{ role: 'user', content: 'go' }],
|
|
41
|
+
workingDirectory: process.cwd(),
|
|
42
|
+
runConfig: { model: 'mock', tokenBudget: 100_000, timeoutMs: 30_000, maxIterations: 4 },
|
|
43
|
+
projectId: generateProjectId(),
|
|
44
|
+
sessionId: generateSessionId(),
|
|
45
|
+
threadId: generateThreadId(),
|
|
46
|
+
tenantId: generateTenantId(),
|
|
47
|
+
// The documented host seam: `drainQuery` hands the plan manager over
|
|
48
|
+
// BEFORE the iteration loop, which is exactly what makes a host-driven
|
|
49
|
+
// plan possible at all.
|
|
50
|
+
onContextCreated: ({ planManager }: { planManager: PlanManager }) => {
|
|
51
|
+
captured = planManager
|
|
52
|
+
seed(planManager)
|
|
53
|
+
},
|
|
54
|
+
} as never)
|
|
55
|
+
|
|
56
|
+
if (!captured) throw new Error('onContextCreated never fired')
|
|
57
|
+
return captured
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function twoStepPlan(pm: PlanManager): void {
|
|
61
|
+
pm.startGenerating('the work')
|
|
62
|
+
pm.addStep({ id: 'step_1', description: 'first', dependsOn: [], order: 1 })
|
|
63
|
+
pm.addStep({ id: 'step_2', description: 'second', dependsOn: [], order: 2 })
|
|
64
|
+
pm.markReady()
|
|
65
|
+
pm.approve()
|
|
66
|
+
pm.startExecution()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('a run that succeeded settles the plan it was executing', () => {
|
|
70
|
+
it('reports completed when every step reported', async () => {
|
|
71
|
+
const pm = await runWithPlan((p) => {
|
|
72
|
+
twoStepPlan(p)
|
|
73
|
+
p.updateStepStatus('step_1', 'completed')
|
|
74
|
+
p.updateStepStatus('step_2', 'skipped')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(pm.active?.status).toBe('completed')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('reports failed when a step actually failed', async () => {
|
|
81
|
+
const pm = await runWithPlan((p) => {
|
|
82
|
+
twoStepPlan(p)
|
|
83
|
+
p.updateStepStatus('step_1', 'completed')
|
|
84
|
+
p.updateStepStatus('step_2', 'failed')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
expect(pm.active?.status).toBe('failed')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('leaves it executing — and does not throw — when a step never reported', async () => {
|
|
91
|
+
// The honest answer. The caller and the plan disagree about whether the
|
|
92
|
+
// work is over, and the end of a successful run is not the place to
|
|
93
|
+
// resolve that by guessing. The run itself must still finish cleanly,
|
|
94
|
+
// which is the half that would break if the refusal were caught here
|
|
95
|
+
// instead of checked.
|
|
96
|
+
const pm = await runWithPlan((p) => {
|
|
97
|
+
twoStepPlan(p)
|
|
98
|
+
p.updateStepStatus('step_1', 'completed')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(pm.active?.status).toBe('executing')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('does nothing when the run had no plan at all', async () => {
|
|
105
|
+
const pm = await runWithPlan(() => {})
|
|
106
|
+
|
|
107
|
+
expect(pm.active).toBeNull()
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { PlanManager } from '../../../manager/plan/lifecycle.js'
|
|
4
|
+
import { MockLLMProvider, registerMock } from '../../../provider/index.js'
|
|
5
|
+
import { ToolRegistry } from '../../../registry/index.js'
|
|
6
|
+
import type { RunEvent } from '../../../types/run/index.js'
|
|
7
|
+
import {
|
|
8
|
+
generateProjectId,
|
|
9
|
+
generateSessionId,
|
|
10
|
+
generateTenantId,
|
|
11
|
+
generateThreadId,
|
|
12
|
+
} from '../../../utils/id.js'
|
|
13
|
+
import { drainQuery } from '../index.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The plan stream stopped one event short of the outcome.
|
|
17
|
+
*
|
|
18
|
+
* `plan_ready`, `plan_approved`, `plan_rejected` and `plan_step_updated` all
|
|
19
|
+
* reached the wire; `plan.completed` and `plan.failed` were folded into a bare
|
|
20
|
+
* `break` in the translator and emitted nothing. So a host watching the stream
|
|
21
|
+
* saw the steps report and then silence — it could learn a plan had been
|
|
22
|
+
* approved and never that it closed, which leaves a plan rendered as in-flight
|
|
23
|
+
* indefinitely.
|
|
24
|
+
*
|
|
25
|
+
* **This was found by a live end-to-end run, not by a test, and that is the
|
|
26
|
+
* point worth keeping.** The settlement tests read the outcome off
|
|
27
|
+
* `PlanManager` through `onContextCreated`, so they proved the plan settled
|
|
28
|
+
* without ever asking whether a consumer of the EVENT STREAM could see it. A
|
|
29
|
+
* verification can be entirely sound about a thing that is no longer the thing
|
|
30
|
+
* you need to know.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
registerMock()
|
|
34
|
+
|
|
35
|
+
async function runWithPlan(seed: (pm: PlanManager) => void): Promise<RunEvent[]> {
|
|
36
|
+
const events: RunEvent[] = []
|
|
37
|
+
|
|
38
|
+
await drainQuery(
|
|
39
|
+
{
|
|
40
|
+
provider: new MockLLMProvider({ responses: [{ content: 'done' }] } as never),
|
|
41
|
+
tools: new ToolRegistry(),
|
|
42
|
+
agentId: 'a',
|
|
43
|
+
agentName: 'A',
|
|
44
|
+
messages: [{ role: 'user', content: 'go' }],
|
|
45
|
+
workingDirectory: process.cwd(),
|
|
46
|
+
runConfig: { model: 'mock', tokenBudget: 100_000, timeoutMs: 30_000, maxIterations: 4 },
|
|
47
|
+
projectId: generateProjectId(),
|
|
48
|
+
sessionId: generateSessionId(),
|
|
49
|
+
threadId: generateThreadId(),
|
|
50
|
+
tenantId: generateTenantId(),
|
|
51
|
+
onContextCreated: ({ planManager }: { planManager: PlanManager }) => seed(planManager),
|
|
52
|
+
} as never,
|
|
53
|
+
(event: RunEvent) => {
|
|
54
|
+
events.push(event)
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return events
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function twoStepPlan(pm: PlanManager): void {
|
|
62
|
+
pm.startGenerating('the work')
|
|
63
|
+
pm.addStep({ id: 'step_1', description: 'first', dependsOn: [], order: 1 })
|
|
64
|
+
pm.addStep({ id: 'step_2', description: 'second', dependsOn: [], order: 2 })
|
|
65
|
+
pm.markReady()
|
|
66
|
+
pm.approve()
|
|
67
|
+
pm.startExecution()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const typesOf = (events: RunEvent[]) => events.map((e) => e.type)
|
|
71
|
+
|
|
72
|
+
describe('a settled plan says so on the run stream', () => {
|
|
73
|
+
it('emits plan_completed when the run settles a successful plan', async () => {
|
|
74
|
+
const events = await runWithPlan((p) => {
|
|
75
|
+
twoStepPlan(p)
|
|
76
|
+
p.updateStepStatus('step_1', 'completed')
|
|
77
|
+
p.updateStepStatus('step_2', 'skipped')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
expect(typesOf(events)).toContain('plan_completed')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('emits plan_failed, carrying the reason failPlan was given', async () => {
|
|
84
|
+
// `failPlan` took this argument and discarded it — the parameter was
|
|
85
|
+
// spelled `_error`. An event that says "failed" without saying why puts
|
|
86
|
+
// the reader back where the missing event did.
|
|
87
|
+
const events = await runWithPlan((p) => {
|
|
88
|
+
twoStepPlan(p)
|
|
89
|
+
p.failPlan('the provider refused the request')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const failed = events.find((e) => e.type === 'plan_failed')
|
|
93
|
+
expect(failed).toBeDefined()
|
|
94
|
+
expect((failed as { reason?: string }).reason).toBe('the provider refused the request')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('emits plan_failed when a step actually failed', async () => {
|
|
98
|
+
const events = await runWithPlan((p) => {
|
|
99
|
+
twoStepPlan(p)
|
|
100
|
+
p.updateStepStatus('step_1', 'completed')
|
|
101
|
+
p.updateStepStatus('step_2', 'failed')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
expect(typesOf(events)).toContain('plan_failed')
|
|
105
|
+
expect(typesOf(events)).not.toContain('plan_completed')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('says nothing terminal while a step has not reported', async () => {
|
|
109
|
+
// The plan is genuinely unsettled, so the silence here is correct — it
|
|
110
|
+
// is the silence AFTER settlement that was the defect.
|
|
111
|
+
const events = await runWithPlan((p) => {
|
|
112
|
+
twoStepPlan(p)
|
|
113
|
+
p.updateStepStatus('step_1', 'completed')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(typesOf(events)).not.toContain('plan_completed')
|
|
117
|
+
expect(typesOf(events)).not.toContain('plan_failed')
|
|
118
|
+
// ...and the step that DID report is still announced, so this is not a
|
|
119
|
+
// stream that has simply gone quiet.
|
|
120
|
+
expect(typesOf(events)).toContain('plan_step_updated')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -193,10 +193,28 @@ export class EventTranslator {
|
|
|
193
193
|
})
|
|
194
194
|
}
|
|
195
195
|
break
|
|
196
|
-
case 'plan.generating':
|
|
197
|
-
case 'plan.executing':
|
|
198
196
|
case 'plan.completed':
|
|
197
|
+
await this.emitEvent({
|
|
198
|
+
type: 'plan_completed',
|
|
199
|
+
runId,
|
|
200
|
+
planId: plan.id,
|
|
201
|
+
})
|
|
202
|
+
break
|
|
199
203
|
case 'plan.failed':
|
|
204
|
+
await this.emitEvent({
|
|
205
|
+
type: 'plan_failed',
|
|
206
|
+
runId,
|
|
207
|
+
planId: plan.id,
|
|
208
|
+
...(plan.failureReason ? { reason: plan.failureReason } : {}),
|
|
209
|
+
})
|
|
210
|
+
break
|
|
211
|
+
// Deliberately silent, and not for the same reason the terminal
|
|
212
|
+
// pair used to be. `plan.generating` and `plan.executing` are
|
|
213
|
+
// already bracketed by `plan_ready` and `plan_approved` — a
|
|
214
|
+
// consumer learns both facts from events it already gets, so an
|
|
215
|
+
// event here would carry nothing a reader did not have.
|
|
216
|
+
case 'plan.generating':
|
|
217
|
+
case 'plan.executing':
|
|
200
218
|
break
|
|
201
219
|
default: {
|
|
202
220
|
// `PlanEvent.type` is scoped to plan-manager events; sub-session
|
|
@@ -552,8 +552,6 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
|
|
|
552
552
|
return { approved: false, feedback: `Action: ${decision.action}` }
|
|
553
553
|
})
|
|
554
554
|
|
|
555
|
-
params.onContextCreated?.({ planManager: ctx.planManager })
|
|
556
|
-
|
|
557
555
|
const eventTranslator = new EventTranslator(ctx.runMgr)
|
|
558
556
|
eventTranslator.wireActivityStore(ctx.activityStore, ctx.runId)
|
|
559
557
|
eventTranslator.wirePlanManager(ctx.planManager, ctx.runId)
|
|
@@ -977,6 +975,21 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
|
|
|
977
975
|
try {
|
|
978
976
|
await ctx.runMgr.init()
|
|
979
977
|
|
|
978
|
+
// Handed over here, and the position is load-bearing in BOTH
|
|
979
|
+
// directions. It has to follow `wirePlanManager`, or a host that
|
|
980
|
+
// builds its plan in this callback — which is what the callback is
|
|
981
|
+
// for — does it into silence: `plan_ready`, `plan_approved` and
|
|
982
|
+
// every `plan_step_updated` are emitted with nothing subscribed,
|
|
983
|
+
// and the host then watches a stream that never mentions the plan
|
|
984
|
+
// it just created. It also has to follow `runMgr.init()`, because
|
|
985
|
+
// emitting appends to the run store and an uninitialised store
|
|
986
|
+
// throws — moving it up to the wiring alone traded a silent drop
|
|
987
|
+
// for 25 unhandled rejections.
|
|
988
|
+
//
|
|
989
|
+
// Still before the iteration loop, which is the guarantee the
|
|
990
|
+
// callback actually makes.
|
|
991
|
+
params.onContextCreated?.({ planManager: ctx.planManager })
|
|
992
|
+
|
|
980
993
|
ctx.log.info('Starting query', {
|
|
981
994
|
runId: ctx.runMgr.id,
|
|
982
995
|
agent: params.agentName,
|
|
@@ -37,12 +37,29 @@ export class ResultAssembler {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async *completeRun(rootSpan: Span): AsyncGenerator<RunEvent> {
|
|
40
|
-
const { runMgr, activityStore, log, emitEvent, drainPending } = this.config
|
|
40
|
+
const { runMgr, planManager, activityStore, log, emitEvent, drainPending } = this.config
|
|
41
41
|
|
|
42
42
|
if (runMgr.status === 'running') {
|
|
43
43
|
runMgr.markCompleted(runMgr.stopReason)
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Settle the plan, which nothing did on this path — so a plan could
|
|
47
|
+
// reach `failed` (the error path calls `failPlan`) or stay `executing`
|
|
48
|
+
// forever, but never `completed`. A host reading `plan.status` after a
|
|
49
|
+
// successful run saw "still running".
|
|
50
|
+
//
|
|
51
|
+
// Only when every step has reported, and the check is a read rather
|
|
52
|
+
// than a caught throw: `completePlan` refuses an unreported step on
|
|
53
|
+
// purpose, and turning a run that worked into a run that crashed on its
|
|
54
|
+
// way out would be a worse version of the bug the refusal prevents.
|
|
55
|
+
//
|
|
56
|
+
// A plan with steps nobody reported is LEFT `executing`, which is the
|
|
57
|
+
// honest answer — the caller and the plan disagree about whether the
|
|
58
|
+
// work is over, and this is not the place to resolve that by guessing.
|
|
59
|
+
if (planManager.isActive && planManager.unreportedSteps.length === 0) {
|
|
60
|
+
planManager.completePlan()
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
await emitEvent({
|
|
47
64
|
type: 'run_completed',
|
|
48
65
|
runId: runMgr.id,
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { TaskGateway, TaskHandle } from '../../../types/agent/gateway.js'
|
|
4
|
+
import type { TaskId } from '../../../types/ids/index.js'
|
|
5
|
+
import type { ToolContext } from '../../../types/tool/index.js'
|
|
6
|
+
import { buildCoordinatorTools } from '../index.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A supervisor could read a sibling run's worker output by listing.
|
|
10
|
+
*
|
|
11
|
+
* `SupervisorAgentConfig.gateway` exists so a host can hand the SAME gateway to
|
|
12
|
+
* several runs, which makes `listTasks()` gateway-wide by design.
|
|
13
|
+
* `agent_task_list` handed that straight to the model — including each task's
|
|
14
|
+
* `result`, the worker's actual output — and `wait_for_task` had the same reach
|
|
15
|
+
* through `getTask`.
|
|
16
|
+
*
|
|
17
|
+
* `CompletionInbox` closed exactly this on the push side, because
|
|
18
|
+
* `onTaskCompleted` is a broadcast and a shared gateway would otherwise hand
|
|
19
|
+
* each supervisor the other's completions. The pull side kept no such record
|
|
20
|
+
* and asked the gateway directly, so the same leak stayed open through a
|
|
21
|
+
* different door.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const AGENTS = ['reviewer', 'researcher']
|
|
25
|
+
|
|
26
|
+
function makeContext(): ToolContext {
|
|
27
|
+
return {
|
|
28
|
+
runId: 'run_scope' as never,
|
|
29
|
+
workingDirectory: '/tmp/test',
|
|
30
|
+
abortSignal: new AbortController().signal,
|
|
31
|
+
env: {},
|
|
32
|
+
log: () => {},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One gateway, as a host sharing it between two supervisors would have. */
|
|
37
|
+
function sharedGateway() {
|
|
38
|
+
const handles = new Map<string, TaskHandle>()
|
|
39
|
+
let seq = 0
|
|
40
|
+
|
|
41
|
+
const gateway = {
|
|
42
|
+
createTask: async (opts: { agentId: string }) => {
|
|
43
|
+
seq += 1
|
|
44
|
+
const taskId = `tsk_${seq}` as TaskId
|
|
45
|
+
const handle: TaskHandle = {
|
|
46
|
+
taskId,
|
|
47
|
+
agentId: opts.agentId,
|
|
48
|
+
state: 'completed',
|
|
49
|
+
createdAt: 1_000,
|
|
50
|
+
completedAt: 2_000,
|
|
51
|
+
result: {
|
|
52
|
+
status: 'completed',
|
|
53
|
+
result: `output of ${opts.agentId} on ${taskId}`,
|
|
54
|
+
} as TaskHandle['result'],
|
|
55
|
+
}
|
|
56
|
+
handles.set(taskId, handle)
|
|
57
|
+
return handle
|
|
58
|
+
},
|
|
59
|
+
waitForTask: async (taskId: TaskId) => handles.get(taskId) as TaskHandle,
|
|
60
|
+
getTask: (taskId: TaskId) => handles.get(taskId),
|
|
61
|
+
listTasks: () => [...handles.values()],
|
|
62
|
+
cancelTask: () => undefined,
|
|
63
|
+
continueTask: async () => undefined,
|
|
64
|
+
onTaskCompleted: () => () => {},
|
|
65
|
+
} as unknown as TaskGateway
|
|
66
|
+
|
|
67
|
+
return gateway
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A run's own coordinator surface over a gateway it may be sharing. */
|
|
71
|
+
function runOver(gateway: TaskGateway) {
|
|
72
|
+
const tools = buildCoordinatorTools({
|
|
73
|
+
gateway,
|
|
74
|
+
workingDirectory: '/tmp/test',
|
|
75
|
+
allowedAgentIds: AGENTS,
|
|
76
|
+
})
|
|
77
|
+
const named = (name: string) => {
|
|
78
|
+
const t = tools.find((tool) => tool.name === name)
|
|
79
|
+
if (!t) throw new Error(`${name} missing from coordinator builder`)
|
|
80
|
+
return t
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
launch: (agentId: string) =>
|
|
84
|
+
named('create_task').execute(
|
|
85
|
+
{ agent_id: agentId, prompt: 'work', description: `${agentId} work` },
|
|
86
|
+
makeContext(),
|
|
87
|
+
),
|
|
88
|
+
list: () => named('agent_task_list').execute({}, makeContext()),
|
|
89
|
+
waitFor: (taskId: string) => named('wait_for_task').execute({ task_id: taskId }, makeContext()),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('one run cannot read another run through the listing', () => {
|
|
94
|
+
it('lists only the tasks this run launched', async () => {
|
|
95
|
+
const gateway = sharedGateway()
|
|
96
|
+
const first = runOver(gateway)
|
|
97
|
+
const second = runOver(gateway)
|
|
98
|
+
|
|
99
|
+
await first.launch('reviewer')
|
|
100
|
+
await second.launch('researcher')
|
|
101
|
+
|
|
102
|
+
const listed = await second.list()
|
|
103
|
+
|
|
104
|
+
// Its own, yes.
|
|
105
|
+
expect(listed.output).toContain('tsk_2')
|
|
106
|
+
// The sibling's task, and — the part that matters — the sibling's
|
|
107
|
+
// worker output, which the listing renders inline.
|
|
108
|
+
expect(listed.output).not.toContain('tsk_1')
|
|
109
|
+
expect(listed.output).not.toContain('output of reviewer')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('counts only its own in the summary', async () => {
|
|
113
|
+
// The summary is what a supervisor reads to decide "done vs not done".
|
|
114
|
+
// A total that includes a sibling's tasks is a wrong answer to that
|
|
115
|
+
// question even when no output leaks with it.
|
|
116
|
+
const gateway = sharedGateway()
|
|
117
|
+
const first = runOver(gateway)
|
|
118
|
+
const second = runOver(gateway)
|
|
119
|
+
|
|
120
|
+
await first.launch('reviewer')
|
|
121
|
+
await first.launch('reviewer')
|
|
122
|
+
await second.launch('researcher')
|
|
123
|
+
|
|
124
|
+
const listed = await second.list()
|
|
125
|
+
const data = listed.data as { summary: { total: number }; items: unknown[] }
|
|
126
|
+
|
|
127
|
+
expect(data.summary.total).toBe(1)
|
|
128
|
+
expect(data.items).toHaveLength(1)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('refuses to wait on a task another run launched', async () => {
|
|
132
|
+
const gateway = sharedGateway()
|
|
133
|
+
const first = runOver(gateway)
|
|
134
|
+
const second = runOver(gateway)
|
|
135
|
+
|
|
136
|
+
await first.launch('reviewer')
|
|
137
|
+
|
|
138
|
+
const waited = await second.waitFor('tsk_1')
|
|
139
|
+
|
|
140
|
+
expect(waited.success).toBe(false)
|
|
141
|
+
expect(waited.output).not.toContain('output of reviewer')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('says the same thing about a task that never existed', async () => {
|
|
145
|
+
// The refusal must not distinguish "belongs to someone else" from
|
|
146
|
+
// "never existed". Confirming a real id to a run that should not know
|
|
147
|
+
// it is the leak in miniature.
|
|
148
|
+
const gateway = sharedGateway()
|
|
149
|
+
const first = runOver(gateway)
|
|
150
|
+
const second = runOver(gateway)
|
|
151
|
+
|
|
152
|
+
await first.launch('reviewer')
|
|
153
|
+
|
|
154
|
+
const sibling = await second.waitFor('tsk_1')
|
|
155
|
+
const fictional = await second.waitFor('tsk_9999')
|
|
156
|
+
|
|
157
|
+
expect(sibling.output).toBe(fictional.output.replace('tsk_9999', 'tsk_1'))
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('still lets a run wait on its own task', async () => {
|
|
161
|
+
// The scope has to be a filter, not a wall — a run that launched a task
|
|
162
|
+
// must still be able to read it back, or the fix breaks delegation.
|
|
163
|
+
const gateway = sharedGateway()
|
|
164
|
+
const only = runOver(gateway)
|
|
165
|
+
|
|
166
|
+
await only.launch('reviewer')
|
|
167
|
+
const waited = await only.waitFor('tsk_1')
|
|
168
|
+
|
|
169
|
+
expect(waited.success).toBe(true)
|
|
170
|
+
})
|
|
171
|
+
})
|