@namzu/sdk 9.0.0 → 11.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 +110 -0
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +0 -5
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/manager/plan/lifecycle.d.ts +10 -0
- package/dist/manager/plan/lifecycle.d.ts.map +1 -1
- package/dist/manager/plan/lifecycle.js +14 -0
- package/dist/manager/plan/lifecycle.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/index.d.ts +0 -1
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +0 -1
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +0 -17
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.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/package.json +1 -1
- package/src/agents/SupervisorAgent.ts +1 -8
- package/src/manager/plan/lifecycle.ts +14 -0
- package/src/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.ts +109 -0
- package/src/runtime/query/index.ts +0 -6
- package/src/runtime/query/iteration/phases/context.ts +0 -19
- 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
|
@@ -3,7 +3,6 @@ import { CompletionInbox } from '../gateway/completion-inbox.js'
|
|
|
3
3
|
import { LocalTaskGateway } from '../gateway/local.js'
|
|
4
4
|
import { ToolNameCollisionError, ToolRegistry } from '../registry/tool/execute.js'
|
|
5
5
|
import { drainQuery } from '../runtime/query/index.js'
|
|
6
|
-
import type { LaunchedTaskMeta } from '../runtime/query/iteration/phases/context.js'
|
|
7
6
|
import { PendingAnswers, QuestionParkBinding } from '../runtime/query/question-park.js'
|
|
8
7
|
import { buildCoordinatorTools } from '../tools/coordinator/index.js'
|
|
9
8
|
import type { TaskGateway, TaskHandle } from '../types/agent/gateway.js'
|
|
@@ -15,7 +14,7 @@ import type {
|
|
|
15
14
|
SupervisorAgentResult,
|
|
16
15
|
} from '../types/agent/index.js'
|
|
17
16
|
import type { AgentTaskContext } from '../types/agent/task.js'
|
|
18
|
-
import type { AgentId, RunId
|
|
17
|
+
import type { AgentId, RunId } from '../types/ids/index.js'
|
|
19
18
|
import { deriveChildState } from '../types/invocation/index.js'
|
|
20
19
|
import type { RunEventListener } from '../types/run/index.js'
|
|
21
20
|
import type { ActorRef } from '../types/session/actor.js'
|
|
@@ -171,8 +170,6 @@ export class SupervisorAgent extends AbstractAgent<SupervisorAgentConfig, Superv
|
|
|
171
170
|
throw new Error("SupervisorAgentConfig requires either 'gateway' or 'agentManager'")
|
|
172
171
|
}
|
|
173
172
|
|
|
174
|
-
const launchedTasks = new Map<TaskId, LaunchedTaskMeta>()
|
|
175
|
-
|
|
176
173
|
let planManagerRef: import('../manager/plan/lifecycle.js').PlanManager | undefined
|
|
177
174
|
|
|
178
175
|
// Created here because the TOOLS are created here: the durability
|
|
@@ -212,9 +209,6 @@ export class SupervisorAgent extends AbstractAgent<SupervisorAgentConfig, Superv
|
|
|
212
209
|
taskStore: input.taskStore,
|
|
213
210
|
runId,
|
|
214
211
|
getPlanManager: () => planManagerRef,
|
|
215
|
-
onTaskLaunched: (agentTaskId, meta) => {
|
|
216
|
-
launchedTasks.set(agentTaskId, meta)
|
|
217
|
-
},
|
|
218
212
|
// With a resume handler present the coordinator surface gains
|
|
219
213
|
// ask_user_question — the model can park the run on a question
|
|
220
214
|
// routed through the same HITL channel as plan approvals.
|
|
@@ -334,7 +328,6 @@ export class SupervisorAgent extends AbstractAgent<SupervisorAgentConfig, Superv
|
|
|
334
328
|
runtimeContext: input.runtimeContext,
|
|
335
329
|
taskGateway: gateway,
|
|
336
330
|
completionInbox,
|
|
337
|
-
launchedTasks,
|
|
338
331
|
advisory: config.advisory,
|
|
339
332
|
invocationState: childInvocationState,
|
|
340
333
|
// HITL surface: forward optional review-time hooks so hosts can
|
|
@@ -95,6 +95,20 @@ export class PlanManager {
|
|
|
95
95
|
return this.currentPlan?.status === 'pending_approval'
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Steps that have not said how they went — the reason `completePlan` may
|
|
100
|
+
* refuse, exposed so a caller can ask before it commits.
|
|
101
|
+
*
|
|
102
|
+
* The kernel settles a successful plan only when this is empty. It cannot
|
|
103
|
+
* catch the refusal instead: a throw on the success path would turn a run
|
|
104
|
+
* that worked into a run that crashed on its way out, which is a worse
|
|
105
|
+
* version of the bug the refusal exists to prevent.
|
|
106
|
+
*/
|
|
107
|
+
get unreportedSteps(): readonly PlanStep[] {
|
|
108
|
+
if (!this.currentPlan) return []
|
|
109
|
+
return this.currentPlan.steps.filter((s) => s.status === 'pending' || s.status === 'running')
|
|
110
|
+
}
|
|
111
|
+
|
|
98
112
|
startGenerating(title: string): Plan {
|
|
99
113
|
const plan: Plan = {
|
|
100
114
|
id: generatePlanId(),
|
|
@@ -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
|
+
})
|
|
@@ -420,11 +420,6 @@ export interface QueryParams {
|
|
|
420
420
|
*/
|
|
421
421
|
completionInbox?: import('../../gateway/completion-inbox.js').CompletionInbox
|
|
422
422
|
|
|
423
|
-
launchedTasks?: Map<
|
|
424
|
-
import('../../types/ids/index.js').TaskId,
|
|
425
|
-
import('./iteration/phases/context.js').LaunchedTaskMeta
|
|
426
|
-
>
|
|
427
|
-
|
|
428
423
|
onContextCreated?: (ctx: {
|
|
429
424
|
planManager: import('../../manager/plan/lifecycle.js').PlanManager
|
|
430
425
|
}) => void
|
|
@@ -854,7 +849,6 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
|
|
|
854
849
|
taskGateway: params.taskGateway,
|
|
855
850
|
completionInbox: params.completionInbox,
|
|
856
851
|
taskStore: params.taskStore,
|
|
857
|
-
launchedTasks: params.launchedTasks ?? new Map(),
|
|
858
852
|
// Run-scoped. An approval is a statement about this run's work;
|
|
859
853
|
// carrying one into a later run would be reuse nobody agreed to.
|
|
860
854
|
toolGrants: new ToolGrantSet(),
|
|
@@ -14,7 +14,6 @@ import type {
|
|
|
14
14
|
IterationCheckpoint,
|
|
15
15
|
ResumeHandler,
|
|
16
16
|
} from '../../../../types/hitl/index.js'
|
|
17
|
-
import type { TaskId } from '../../../../types/ids/index.js'
|
|
18
17
|
import type { LLMProvider } from '../../../../types/provider/index.js'
|
|
19
18
|
import type { TaskRouterConfig } from '../../../../types/router/index.js'
|
|
20
19
|
import type { ReviewAnswer } from '../../../../types/run/answer-review.js'
|
|
@@ -36,22 +35,6 @@ import type { GuardCoordinator } from '../../guard.js'
|
|
|
36
35
|
import type { SteeringChannel } from '../../steering.js'
|
|
37
36
|
import type { ToolGrantSet } from '../../tool-grants.js'
|
|
38
37
|
|
|
39
|
-
export interface LaunchedTaskMeta {
|
|
40
|
-
readonly agentId: string
|
|
41
|
-
readonly description: string
|
|
42
|
-
readonly planTaskId?: string
|
|
43
|
-
/**
|
|
44
|
-
* The `tool_use_id` of the assistant `create_task` block that
|
|
45
|
-
* spawned this background task. Required to emit the canonical
|
|
46
|
-
* `tool_result` content block when the task completes — without
|
|
47
|
-
* it we'd fall back to the legacy synthetic-user-message inject
|
|
48
|
-
* (see ses_009-task-notification-envelope). Optional because
|
|
49
|
-
* older call paths that don't thread `ToolContext.toolUseId`
|
|
50
|
-
* still publish the meta without it.
|
|
51
|
-
*/
|
|
52
|
-
readonly originalToolUseId?: string
|
|
53
|
-
}
|
|
54
|
-
|
|
55
38
|
export interface IterationContext {
|
|
56
39
|
readonly provider: LLMProvider
|
|
57
40
|
/**
|
|
@@ -115,8 +98,6 @@ export interface IterationContext {
|
|
|
115
98
|
|
|
116
99
|
readonly taskStore?: TaskStore
|
|
117
100
|
|
|
118
|
-
readonly launchedTasks: Map<TaskId, LaunchedTaskMeta>
|
|
119
|
-
|
|
120
101
|
/**
|
|
121
102
|
* Approvals a human granted earlier in this run, at a scope they chose.
|
|
122
103
|
*
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { PlanManager } from '../../../manager/plan/lifecycle.js'
|
|
4
|
+
import type { TaskGateway, TaskHandle } from '../../../types/agent/gateway.js'
|
|
5
|
+
import type { RunId, TaskId } from '../../../types/ids/index.js'
|
|
6
|
+
import type { ToolContext, ToolDefinition } from '../../../types/tool/index.js'
|
|
7
|
+
import { buildCoordinatorTools } from '../index.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A plan's steps had no relationship to the work that carried them out.
|
|
11
|
+
*
|
|
12
|
+
* `approve_plan` built steps, `create_task` launched workers, and nothing
|
|
13
|
+
* connected the two — so no step could ever be observed, `updateStepStatus` had
|
|
14
|
+
* no production caller, and a plan could reach `failed` (the error path calls
|
|
15
|
+
* `failPlan`) or sit at `executing` forever, but never `completed`.
|
|
16
|
+
*
|
|
17
|
+
* Two bindings close it, because there are two kinds of step. A DELEGATED step
|
|
18
|
+
* reports through the `create_task` that carries it out. An ORCHESTRATOR-OWNED
|
|
19
|
+
* step has no tool call to bind to at all, and reports through
|
|
20
|
+
* `update_plan_step` — without which a plan containing one could never settle
|
|
21
|
+
* however well it went.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const RUN = 'run_step_binding' as RunId
|
|
25
|
+
|
|
26
|
+
function gatewayReturning(outcome: 'ok' | 'failed'): TaskGateway {
|
|
27
|
+
const handle = (taskId: TaskId): TaskHandle => ({
|
|
28
|
+
taskId,
|
|
29
|
+
agentId: 'worker',
|
|
30
|
+
state: 'completed',
|
|
31
|
+
createdAt: 1_000,
|
|
32
|
+
completedAt: 2_000,
|
|
33
|
+
result: (outcome === 'ok'
|
|
34
|
+
? { status: 'completed', result: 'the work' }
|
|
35
|
+
: { status: 'failed', lastError: 'the worker died' }) as TaskHandle['result'],
|
|
36
|
+
})
|
|
37
|
+
return {
|
|
38
|
+
async createTask() {
|
|
39
|
+
return handle('tsk_1' as TaskId)
|
|
40
|
+
},
|
|
41
|
+
async waitForTask(id) {
|
|
42
|
+
return handle(id)
|
|
43
|
+
},
|
|
44
|
+
async continueTask() {},
|
|
45
|
+
cancelTask() {},
|
|
46
|
+
getTask(id) {
|
|
47
|
+
return handle(id)
|
|
48
|
+
},
|
|
49
|
+
listTasks() {
|
|
50
|
+
return []
|
|
51
|
+
},
|
|
52
|
+
onTaskCompleted() {
|
|
53
|
+
return () => {}
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ctx(): ToolContext {
|
|
59
|
+
return {
|
|
60
|
+
runId: RUN,
|
|
61
|
+
workingDirectory: '/tmp/test',
|
|
62
|
+
abortSignal: new AbortController().signal,
|
|
63
|
+
env: {},
|
|
64
|
+
log: () => {},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** An approved two-step plan: one delegated, one the orchestrator's own. */
|
|
69
|
+
function approvedPlan(): PlanManager {
|
|
70
|
+
const pm = new PlanManager(RUN, async () => ({ approved: true }))
|
|
71
|
+
pm.startGenerating('do the work')
|
|
72
|
+
pm.addStep({
|
|
73
|
+
id: 'step_1',
|
|
74
|
+
description: 'delegated work',
|
|
75
|
+
agentId: 'worker',
|
|
76
|
+
dependsOn: [],
|
|
77
|
+
order: 1,
|
|
78
|
+
})
|
|
79
|
+
pm.addStep({ id: 'step_2', description: 'my own work', dependsOn: [], order: 2 })
|
|
80
|
+
pm.markReady()
|
|
81
|
+
pm.approve()
|
|
82
|
+
pm.startExecution()
|
|
83
|
+
return pm
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toolsOver(pm: PlanManager, gateway: TaskGateway): (name: string) => ToolDefinition {
|
|
87
|
+
const tools = buildCoordinatorTools({
|
|
88
|
+
gateway,
|
|
89
|
+
workingDirectory: '/tmp/test',
|
|
90
|
+
allowedAgentIds: ['worker'],
|
|
91
|
+
getPlanManager: () => pm,
|
|
92
|
+
})
|
|
93
|
+
return (name: string) => {
|
|
94
|
+
const t = tools.find((tool) => tool.name === name)
|
|
95
|
+
if (!t) throw new Error(`${name} missing from coordinator builder`)
|
|
96
|
+
return t
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stepStatus = (pm: PlanManager, id: string) =>
|
|
101
|
+
pm.active?.steps.find((s) => s.id === id)?.status
|
|
102
|
+
|
|
103
|
+
describe('a delegated step reports through the launch that carries it out', () => {
|
|
104
|
+
it('completes the step when the worker succeeded', async () => {
|
|
105
|
+
const pm = approvedPlan()
|
|
106
|
+
const named = toolsOver(pm, gatewayReturning('ok'))
|
|
107
|
+
|
|
108
|
+
await named('create_task').execute(
|
|
109
|
+
{ agent_id: 'worker', prompt: 'go', description: 'do it', plan_step_id: 'step_1' },
|
|
110
|
+
ctx(),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
expect(stepStatus(pm, 'step_1')).toBe('completed')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('fails the step when the worker failed, from both authorities', async () => {
|
|
117
|
+
// The handle is `state: 'completed'` with `result.status: 'failed'` —
|
|
118
|
+
// the split that made a failed worker read as an answer. The step must
|
|
119
|
+
// follow the run status, not the gateway state.
|
|
120
|
+
const pm = approvedPlan()
|
|
121
|
+
const named = toolsOver(pm, gatewayReturning('failed'))
|
|
122
|
+
|
|
123
|
+
await named('create_task').execute(
|
|
124
|
+
{ agent_id: 'worker', prompt: 'go', description: 'do it', plan_step_id: 'step_1' },
|
|
125
|
+
ctx(),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
expect(stepStatus(pm, 'step_1')).toBe('failed')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('leaves the plan alone when the launch names no step', async () => {
|
|
132
|
+
// A launch outside the approved plan must not silently settle one.
|
|
133
|
+
const pm = approvedPlan()
|
|
134
|
+
const named = toolsOver(pm, gatewayReturning('ok'))
|
|
135
|
+
|
|
136
|
+
await named('create_task').execute(
|
|
137
|
+
{ agent_id: 'worker', prompt: 'go', description: 'unrelated work' },
|
|
138
|
+
ctx(),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
expect(stepStatus(pm, 'step_1')).toBe('pending')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('an orchestrator-owned step reports through update_plan_step', () => {
|
|
146
|
+
it('records the outcome and says what is still outstanding', async () => {
|
|
147
|
+
const pm = approvedPlan()
|
|
148
|
+
const named = toolsOver(pm, gatewayReturning('ok'))
|
|
149
|
+
|
|
150
|
+
const result = await named('update_plan_step').execute(
|
|
151
|
+
{ step_id: 'step_2', status: 'completed' },
|
|
152
|
+
ctx(),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
expect(result.success).toBe(true)
|
|
156
|
+
expect(stepStatus(pm, 'step_2')).toBe('completed')
|
|
157
|
+
// step_1 has not reported, and saying so is the point — this is what
|
|
158
|
+
// tells the model the plan cannot settle yet.
|
|
159
|
+
expect(result.output).toContain('step_1')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('treats skipped as a real outcome, not a failure', async () => {
|
|
163
|
+
const pm = approvedPlan()
|
|
164
|
+
const named = toolsOver(pm, gatewayReturning('ok'))
|
|
165
|
+
|
|
166
|
+
await named('update_plan_step').execute({ step_id: 'step_2', status: 'skipped' }, ctx())
|
|
167
|
+
|
|
168
|
+
expect(stepStatus(pm, 'step_2')).toBe('skipped')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('refuses an id the plan does not have, and names the ones it does', async () => {
|
|
172
|
+
const pm = approvedPlan()
|
|
173
|
+
const named = toolsOver(pm, gatewayReturning('ok'))
|
|
174
|
+
|
|
175
|
+
const result = await named('update_plan_step').execute(
|
|
176
|
+
{ step_id: 'step_9', status: 'completed' },
|
|
177
|
+
ctx(),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
expect(result.success).toBe(false)
|
|
181
|
+
expect(result.error).toContain('step_1')
|
|
182
|
+
expect(result.error).toContain('step_2')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe('the two bindings together let a plan settle', () => {
|
|
187
|
+
it('reaches completed once every step has reported', async () => {
|
|
188
|
+
const pm = approvedPlan()
|
|
189
|
+
const named = toolsOver(pm, gatewayReturning('ok'))
|
|
190
|
+
|
|
191
|
+
await named('create_task').execute(
|
|
192
|
+
{ agent_id: 'worker', prompt: 'go', description: 'do it', plan_step_id: 'step_1' },
|
|
193
|
+
ctx(),
|
|
194
|
+
)
|
|
195
|
+
await named('update_plan_step').execute({ step_id: 'step_2', status: 'completed' }, ctx())
|
|
196
|
+
|
|
197
|
+
expect(pm.unreportedSteps).toHaveLength(0)
|
|
198
|
+
expect(pm.completePlan()?.status).toBe('completed')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('leaves the plan unsettled while a step is still silent', async () => {
|
|
202
|
+
// The state the kernel reads before deciding whether to settle. An
|
|
203
|
+
// unreported step means the caller and the plan disagree about whether
|
|
204
|
+
// the work is over, and the run must not resolve that by guessing.
|
|
205
|
+
const pm = approvedPlan()
|
|
206
|
+
const named = toolsOver(pm, gatewayReturning('ok'))
|
|
207
|
+
|
|
208
|
+
await named('create_task').execute(
|
|
209
|
+
{ agent_id: 'worker', prompt: 'go', description: 'do it', plan_step_id: 'step_1' },
|
|
210
|
+
ctx(),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
expect(pm.unreportedSteps.map((s) => s.id)).toEqual(['step_2'])
|
|
214
|
+
})
|
|
215
|
+
})
|