@namzu/sdk 8.0.0 → 10.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 +244 -0
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +14 -6
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/__tests__/a-policy-nobody-can-select.test.d.ts +2 -0
- package/dist/agents/__tests__/a-policy-nobody-can-select.test.d.ts.map +1 -0
- package/dist/agents/__tests__/a-policy-nobody-can-select.test.js +177 -0
- package/dist/agents/__tests__/a-policy-nobody-can-select.test.js.map +1 -0
- package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.d.ts +2 -0
- package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.d.ts.map +1 -0
- package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.js +114 -0
- package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.js.map +1 -0
- package/dist/gateway/local.d.ts.map +1 -1
- package/dist/gateway/local.js +19 -6
- package/dist/gateway/local.js.map +1 -1
- package/dist/manager/agent/__tests__/lifecycle.test.js +56 -0
- package/dist/manager/agent/__tests__/lifecycle.test.js.map +1 -1
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +62 -33
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.d.ts +2 -0
- package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.d.ts.map +1 -0
- package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.js +73 -0
- package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.js.map +1 -0
- package/dist/manager/plan/lifecycle.d.ts +48 -0
- package/dist/manager/plan/lifecycle.d.ts.map +1 -1
- package/dist/manager/plan/lifecycle.js +56 -0
- package/dist/manager/plan/lifecycle.js.map +1 -1
- package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.js +62 -0
- package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.js.map +1 -0
- package/dist/runtime/query/events.d.ts.map +1 -1
- package/dist/runtime/query/events.js +5 -0
- package/dist/runtime/query/events.js.map +1 -1
- 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/store/task/disk.d.ts.map +1 -1
- package/dist/store/task/disk.js +14 -0
- package/dist/store/task/disk.js.map +1 -1
- package/dist/store/task/memory.d.ts.map +1 -1
- package/dist/store/task/memory.js +14 -0
- package/dist/store/task/memory.js.map +1 -1
- package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.js +101 -0
- package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.js.map +1 -0
- package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.js +99 -0
- package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.js.map +1 -0
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- package/dist/tools/coordinator/agent.js +17 -23
- package/dist/tools/coordinator/agent.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +33 -3
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/coordinator/outcome.d.ts +60 -0
- package/dist/tools/coordinator/outcome.d.ts.map +1 -0
- package/dist/tools/coordinator/outcome.js +68 -0
- package/dist/tools/coordinator/outcome.js.map +1 -0
- package/dist/types/agent/gateway.d.ts +19 -2
- package/dist/types/agent/gateway.d.ts.map +1 -1
- package/dist/types/agent/supervisor.d.ts +38 -1
- package/dist/types/agent/supervisor.d.ts.map +1 -1
- package/dist/types/plan/index.d.ts +26 -0
- package/dist/types/plan/index.d.ts.map +1 -1
- package/dist/types/run/events.d.ts +17 -0
- package/dist/types/run/events.d.ts.map +1 -1
- package/dist/types/run/events.js.map +1 -1
- package/dist/types/task/index.d.ts +19 -1
- package/dist/types/task/index.d.ts.map +1 -1
- package/dist/types/task/index.js +10 -1
- package/dist/types/task/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agents/SupervisorAgent.ts +15 -9
- package/src/agents/__tests__/a-policy-nobody-can-select.test.ts +202 -0
- package/src/gateway/__tests__/a-knob-nobody-can-turn.test.ts +139 -0
- package/src/gateway/local.ts +19 -7
- package/src/manager/agent/__tests__/lifecycle.test.ts +74 -0
- package/src/manager/agent/lifecycle.ts +72 -42
- package/src/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.ts +88 -0
- package/src/manager/plan/lifecycle.ts +61 -0
- package/src/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.ts +79 -0
- package/src/runtime/query/events.ts +5 -0
- package/src/runtime/query/index.ts +0 -6
- package/src/runtime/query/iteration/phases/context.ts +0 -19
- package/src/store/task/disk.ts +15 -0
- package/src/store/task/memory.ts +15 -0
- package/src/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.ts +133 -0
- package/src/tools/coordinator/__tests__/the-plan-a-human-approves.test.ts +121 -0
- package/src/tools/coordinator/agent.ts +17 -25
- package/src/tools/coordinator/index.ts +33 -3
- package/src/tools/coordinator/outcome.ts +70 -0
- package/src/types/agent/gateway.ts +19 -2
- package/src/types/agent/supervisor.ts +40 -1
- package/src/types/plan/index.ts +29 -0
- package/src/types/run/events.ts +17 -0
- package/src/types/task/index.ts +22 -3
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Span } from '@opentelemetry/api'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import type { Agent } from '../../types/agent/core.js'
|
|
5
|
+
import type { AgentManagerContract } from '../../types/agent/manager.js'
|
|
6
|
+
import type {
|
|
7
|
+
AgentTask,
|
|
8
|
+
AgentTaskContext,
|
|
9
|
+
AgentTaskState,
|
|
10
|
+
SendMessageOptions,
|
|
11
|
+
} from '../../types/agent/task.js'
|
|
12
|
+
import type { AgentId, RunId, SessionId, TaskId, TenantId } from '../../types/ids/index.js'
|
|
13
|
+
import type { ProjectId, ThreadId } from '../../types/session/ids.js'
|
|
14
|
+
import { LocalTaskGateway } from '../local.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `CreateTaskOptions.configOverrides` was declared, typed, and never read.
|
|
18
|
+
*
|
|
19
|
+
* `createTask` built its OWN `configOverrides` object out of `parentSpan`
|
|
20
|
+
* alone, so a caller pinning a delegated run to a cheaper model, or capping
|
|
21
|
+
* its iterations, got the agent's defaults and nothing to say otherwise. The
|
|
22
|
+
* field type-checked, the call succeeded, and the run was not the run that was
|
|
23
|
+
* asked for.
|
|
24
|
+
*
|
|
25
|
+
* Reachability, not behaviour: what `configOverrides` DOES once it lands on
|
|
26
|
+
* `sendMessage` is the agent manager's business and is tested there. What was
|
|
27
|
+
* broken is the hop.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Records what the gateway actually asked the manager for. */
|
|
31
|
+
class RecordingManager implements AgentManagerContract {
|
|
32
|
+
readonly sent: SendMessageOptions[] = []
|
|
33
|
+
|
|
34
|
+
async sendMessage(options: SendMessageOptions): Promise<AgentTask> {
|
|
35
|
+
this.sent.push(options)
|
|
36
|
+
return {
|
|
37
|
+
taskId: `task_${this.sent.length}` as TaskId,
|
|
38
|
+
agentId: options.agentId,
|
|
39
|
+
agent: {} as Agent<never, never>,
|
|
40
|
+
childAbortController: new AbortController(),
|
|
41
|
+
context: {} as AgentTaskContext,
|
|
42
|
+
state: 'completed' as AgentTaskState,
|
|
43
|
+
pendingMessages: [],
|
|
44
|
+
createdAt: 1,
|
|
45
|
+
} as AgentTask
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
cancel(): void {}
|
|
49
|
+
cancelAll(): void {}
|
|
50
|
+
async continueTask(): Promise<void> {}
|
|
51
|
+
queueMessage(): void {}
|
|
52
|
+
drainMessages() {
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
async waitForCompletion(): Promise<void> {}
|
|
56
|
+
getInstance(): AgentTask | undefined {
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
listByParent(): AgentTask[] {
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
listActive(): AgentTask[] {
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
getState(): AgentTaskState | undefined {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
on(): void {}
|
|
69
|
+
off(): void {}
|
|
70
|
+
cleanup(): void {}
|
|
71
|
+
dispose(): void {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function context(): AgentTaskContext {
|
|
75
|
+
return {
|
|
76
|
+
parentRunId: 'run_parent' as RunId,
|
|
77
|
+
parentAgentId: 'supervisor',
|
|
78
|
+
parentAbortController: new AbortController(),
|
|
79
|
+
depth: 0,
|
|
80
|
+
budgetTracker: { total: 100_000, remaining: 100_000 },
|
|
81
|
+
tenantId: 'tnt_k' as TenantId,
|
|
82
|
+
threadId: 'thd_k' as ThreadId,
|
|
83
|
+
sessionId: 'ses_k' as SessionId,
|
|
84
|
+
projectId: 'prj_k' as ProjectId,
|
|
85
|
+
parentActor: { kind: 'agent', agentId: 'supervisor' as AgentId, tenantId: 'tnt_k' as TenantId },
|
|
86
|
+
} as AgentTaskContext
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('a delegated run is built with the config its caller asked for', () => {
|
|
90
|
+
it('forwards configOverrides to the spawn', async () => {
|
|
91
|
+
const manager = new RecordingManager()
|
|
92
|
+
const gateway = new LocalTaskGateway(manager, context())
|
|
93
|
+
|
|
94
|
+
await gateway.createTask({
|
|
95
|
+
agentId: 'worker',
|
|
96
|
+
prompt: 'work',
|
|
97
|
+
workingDirectory: '/tmp',
|
|
98
|
+
configOverrides: { model: 'cheap-model', maxIterations: 3 },
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(manager.sent[0]?.configOverrides).toMatchObject({
|
|
102
|
+
model: 'cheap-model',
|
|
103
|
+
maxIterations: 3,
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('keeps the dedicated parentSpan option winning when both name a span', async () => {
|
|
108
|
+
// A caller who sets both is saying the same thing twice, and the named
|
|
109
|
+
// field is the specific one for the job — so it is applied last.
|
|
110
|
+
const manager = new RecordingManager()
|
|
111
|
+
const gateway = new LocalTaskGateway(manager, context())
|
|
112
|
+
const named = { spanContext: () => ({ traceId: 'named' }) } as unknown as Span
|
|
113
|
+
const buried = { spanContext: () => ({ traceId: 'buried' }) } as unknown as Span
|
|
114
|
+
|
|
115
|
+
await gateway.createTask({
|
|
116
|
+
agentId: 'worker',
|
|
117
|
+
prompt: 'work',
|
|
118
|
+
workingDirectory: '/tmp',
|
|
119
|
+
parentSpan: named,
|
|
120
|
+
configOverrides: { parentSpan: buried, model: 'cheap-model' },
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(manager.sent[0]?.configOverrides?.parentSpan).toBe(named)
|
|
124
|
+
// ...and the rest of the caller's overrides survive alongside it.
|
|
125
|
+
expect(manager.sent[0]?.configOverrides?.model).toBe('cheap-model')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('still sends no configOverrides at all when the caller set neither', async () => {
|
|
129
|
+
// The absent case has to stay absent: an empty object here would put
|
|
130
|
+
// `configOverrides: {}` on every spawn and override nothing, which is
|
|
131
|
+
// harmless until something starts reading its presence as intent.
|
|
132
|
+
const manager = new RecordingManager()
|
|
133
|
+
const gateway = new LocalTaskGateway(manager, context())
|
|
134
|
+
|
|
135
|
+
await gateway.createTask({ agentId: 'worker', prompt: 'work', workingDirectory: '/tmp' })
|
|
136
|
+
|
|
137
|
+
expect(manager.sent[0]).not.toHaveProperty('configOverrides')
|
|
138
|
+
})
|
|
139
|
+
})
|
package/src/gateway/local.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { taskFailed } from '../tools/coordinator/outcome.js'
|
|
1
2
|
import type { AgentInput } from '../types/agent/base.js'
|
|
2
3
|
import type {
|
|
3
4
|
CreateTaskOptions,
|
|
@@ -75,10 +76,25 @@ export class LocalTaskGateway implements TaskGateway {
|
|
|
75
76
|
tenantId: this.taskContext.tenantId,
|
|
76
77
|
projectId: this.taskContext.projectId,
|
|
77
78
|
parentActor: this.taskContext.parentActor,
|
|
78
|
-
//
|
|
79
|
+
// The caller's overrides, plus the span the caller supplied so a
|
|
79
80
|
// delegated run joins the trace it belongs to instead of
|
|
80
81
|
// starting its own root.
|
|
81
|
-
|
|
82
|
+
//
|
|
83
|
+
// `options.configOverrides` used to be dropped here: this built
|
|
84
|
+
// a fresh object from `parentSpan` and never looked at the
|
|
85
|
+
// field, so a caller pinning a child to a cheaper model got the
|
|
86
|
+
// agent's default and no sign anything had been ignored. The
|
|
87
|
+
// dedicated `parentSpan` option is applied last because it is
|
|
88
|
+
// the specific field for that job — a caller who sets both is
|
|
89
|
+
// saying the same thing twice, and the named one is the answer.
|
|
90
|
+
...(options.configOverrides || options.parentSpan
|
|
91
|
+
? {
|
|
92
|
+
configOverrides: {
|
|
93
|
+
...options.configOverrides,
|
|
94
|
+
...(options.parentSpan ? { parentSpan: options.parentSpan } : {}),
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
: {}),
|
|
82
98
|
},
|
|
83
99
|
// The budget tracker is SHARED on purpose and must not be cloned.
|
|
84
100
|
// `AgentManager.spawn` debits it (`remaining -= allocatedTokens`)
|
|
@@ -146,7 +162,7 @@ export class LocalTaskGateway implements TaskGateway {
|
|
|
146
162
|
*/
|
|
147
163
|
private applySiblingPolicy(finished: TaskHandle): void {
|
|
148
164
|
if (this.siblingFailurePolicy !== 'cancel-siblings') return
|
|
149
|
-
if (!
|
|
165
|
+
if (!taskFailed(finished)) return
|
|
150
166
|
|
|
151
167
|
const cancelled: TaskId[] = []
|
|
152
168
|
for (const taskId of this.trackedTaskIds) {
|
|
@@ -251,10 +267,6 @@ export class LocalTaskGateway implements TaskGateway {
|
|
|
251
267
|
* would therefore miss the ordinary case — an agent that tried and could
|
|
252
268
|
* not — and catch only the exceptional one.
|
|
253
269
|
*/
|
|
254
|
-
function hasFailed(handle: TaskHandle): boolean {
|
|
255
|
-
return handle.state === 'failed' || handle.result?.status === 'failed'
|
|
256
|
-
}
|
|
257
|
-
|
|
258
270
|
function toHandle(task: import('../types/agent/task.js').AgentTask): TaskHandle {
|
|
259
271
|
return {
|
|
260
272
|
taskId: task.taskId,
|
|
@@ -768,3 +768,77 @@ describe('LocalTaskGateway — what a failed child means for its siblings', () =
|
|
|
768
768
|
releaseSlow()
|
|
769
769
|
})
|
|
770
770
|
})
|
|
771
|
+
|
|
772
|
+
describe('a concurrent fan-out shares one budget', () => {
|
|
773
|
+
/**
|
|
774
|
+
* Siblings launched from one assistant turn were each allocated a fraction
|
|
775
|
+
* of the SAME undebited number.
|
|
776
|
+
*
|
|
777
|
+
* The allocation is read at the top of `sendMessage`; the debit lands
|
|
778
|
+
* after `await provisionSpawn`, which is the only critical section. So N
|
|
779
|
+
* siblings all enter, all read an untouched `remaining`, and each takes
|
|
780
|
+
* its fraction of it. `create_task`'s own description instructs exactly
|
|
781
|
+
* this shape: "'fan out 8 specialists' is one assistant message with 8
|
|
782
|
+
* create_task blocks."
|
|
783
|
+
*
|
|
784
|
+
* **The children must not be allowed to finish.** A child that settles
|
|
785
|
+
* refunds its unspent budget, and the refund restores the tracker to a
|
|
786
|
+
* plausible number — so a test that measures after settle sees a healthy
|
|
787
|
+
* total and reports nothing. The over-commitment is real and transient,
|
|
788
|
+
* and transient is enough: every allocation decision taken during the
|
|
789
|
+
* window reads a tracker that is already wrong.
|
|
790
|
+
*
|
|
791
|
+
* The first version of this test did settle its children, passed, and
|
|
792
|
+
* would have certified the bug as fixed.
|
|
793
|
+
*/
|
|
794
|
+
it('never allocates more than the parent has, while the children are still running', async () => {
|
|
795
|
+
// What each child was actually HANDED. Asserting on the tracker was the
|
|
796
|
+
// first attempt and it measured the wrong thing twice over: a settled
|
|
797
|
+
// child refunds, which restores a plausible number, and the harm is not
|
|
798
|
+
// the bookkeeping anyway — it is that four children each believe they
|
|
799
|
+
// may spend half a pool that only has one half to give.
|
|
800
|
+
const allocations: number[] = []
|
|
801
|
+
let release: (() => void) | undefined
|
|
802
|
+
const held = new Promise<void>((resolve) => {
|
|
803
|
+
release = resolve
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
// The harness's own manager, because a hand-built one here silently
|
|
807
|
+
// fails to provision and the children never run — which looks exactly
|
|
808
|
+
// like a passing test.
|
|
809
|
+
const harness = await buildHarness(
|
|
810
|
+
makeAgent('child-1', async (_input, config) => {
|
|
811
|
+
allocations.push(config.tokenBudget)
|
|
812
|
+
await held
|
|
813
|
+
return successResult()
|
|
814
|
+
}),
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
// ONE tracker, shared, as a real parent's context is.
|
|
818
|
+
const shared = { total: 100_000, remaining: 100_000 }
|
|
819
|
+
const context = {
|
|
820
|
+
...buildContext(harness.parentSession.id, harness.projectId, harness.threadId),
|
|
821
|
+
budgetTracker: shared,
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
await Promise.allSettled(
|
|
825
|
+
Array.from({ length: 4 }, () =>
|
|
826
|
+
harness.manager.sendMessage(
|
|
827
|
+
buildOptions('child-1', harness.parentSession.id, harness.projectId),
|
|
828
|
+
context,
|
|
829
|
+
),
|
|
830
|
+
),
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
// Let the children record what they were handed before any settles.
|
|
834
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
835
|
+
const handedOut = allocations.reduce((a, b) => a + b, 0)
|
|
836
|
+
release?.()
|
|
837
|
+
|
|
838
|
+
expect(allocations.length, 'every sibling should have started').toBe(4)
|
|
839
|
+
expect(
|
|
840
|
+
handedOut,
|
|
841
|
+
`four siblings were handed ${allocations.join(' + ')} from a pool of ${shared.total}`,
|
|
842
|
+
).toBeLessThanOrEqual(shared.total)
|
|
843
|
+
})
|
|
844
|
+
})
|
|
@@ -127,44 +127,29 @@ export class AgentManager {
|
|
|
127
127
|
|
|
128
128
|
const childAbortController = createChildAbortController(context.parentAbortController)
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
if (allocatedTokens <= 0) {
|
|
146
|
-
throw new NamzuError({
|
|
147
|
-
code: 'invalid_config',
|
|
148
|
-
message: `Cannot spawn "${options.agentId}": the parent has ${context.budgetTracker.remaining} tokens remaining, which allocates 0 to the child — and a token budget of 0 means UNLIMITED downstream.`,
|
|
149
|
-
details: {
|
|
150
|
-
agentId: options.agentId,
|
|
151
|
-
parentRemaining: context.budgetTracker.remaining,
|
|
152
|
-
maxBudgetFraction: this.config.maxBudgetFraction,
|
|
153
|
-
},
|
|
154
|
-
})
|
|
155
|
-
}
|
|
130
|
+
// The allocation is computed INSIDE the spawn lock, not here. Reading
|
|
131
|
+
// the parent's remaining budget at this point and debiting it after
|
|
132
|
+
// `provisionSpawn` put the two halves of a read-modify-write on either
|
|
133
|
+
// side of an await — so N siblings launched from one turn all read the
|
|
134
|
+
// same undebited number and each took a fraction of it. Measured: four
|
|
135
|
+
// concurrent children were handed 50 000 + 50 000 + 50 000 + 50 000
|
|
136
|
+
// from a pool of 100 000.
|
|
137
|
+
//
|
|
138
|
+
// `create_task`'s own description instructs exactly this shape ("'fan
|
|
139
|
+
// out 8 specialists' is one assistant message with 8 create_task
|
|
140
|
+
// blocks"), so the documented usage was the reproduction.
|
|
141
|
+
//
|
|
142
|
+
// Nothing pinned it because the only concurrent test built a fresh
|
|
143
|
+
// context per call — each spawn got its own tracker, which measures
|
|
144
|
+
// width and not budget.
|
|
156
145
|
|
|
157
146
|
// Phase 6: SubSession + child Session + WorkspaceRef triple. Happens
|
|
158
147
|
// before taskId minting so a capacity failure short-circuits cleanly
|
|
159
148
|
// with no observable state change.
|
|
160
149
|
//
|
|
161
|
-
// The
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
// comment above promised there would not be.
|
|
165
|
-
const spawnRecord = await this.provisionSpawn(options, context)
|
|
166
|
-
|
|
167
|
-
context.budgetTracker.remaining -= allocatedTokens
|
|
150
|
+
// The allocation now travels with it, because the read and the debit
|
|
151
|
+
// have to be on the same side of every await to mean anything.
|
|
152
|
+
const { spawnRecord, allocatedTokens } = await this.provisionSpawn(options, context)
|
|
168
153
|
|
|
169
154
|
const taskId = generateTaskId()
|
|
170
155
|
|
|
@@ -449,7 +434,7 @@ export class AgentManager {
|
|
|
449
434
|
private async provisionSpawn(
|
|
450
435
|
options: SendMessageOptions,
|
|
451
436
|
context: AgentTaskContext,
|
|
452
|
-
): Promise<ChildSpawnRecord> {
|
|
437
|
+
): Promise<{ spawnRecord: ChildSpawnRecord; allocatedTokens: number }> {
|
|
453
438
|
const key = options.parentSessionId
|
|
454
439
|
const queued = (this.spawnLocks.get(key) ?? Promise.resolve()).then(
|
|
455
440
|
() => this.provisionSpawnUnlocked(options, context),
|
|
@@ -475,7 +460,42 @@ export class AgentManager {
|
|
|
475
460
|
private async provisionSpawnUnlocked(
|
|
476
461
|
options: SendMessageOptions,
|
|
477
462
|
context: AgentTaskContext,
|
|
478
|
-
): Promise<ChildSpawnRecord> {
|
|
463
|
+
): Promise<{ spawnRecord: ChildSpawnRecord; allocatedTokens: number }> {
|
|
464
|
+
// Read the parent's remaining budget HERE, inside the lock, so that
|
|
465
|
+
// concurrent siblings queue behind one another rather than all reading
|
|
466
|
+
// the same untouched number. The debit at the end of this method closes
|
|
467
|
+
// the pair: read and write are now on the same side of every await.
|
|
468
|
+
const maxAllocation = Math.floor(
|
|
469
|
+
context.budgetTracker.remaining * this.config.maxBudgetFraction,
|
|
470
|
+
)
|
|
471
|
+
const allocatedTokens = Math.min(
|
|
472
|
+
options.budgetAllocation?.tokenBudget ?? maxAllocation,
|
|
473
|
+
maxAllocation,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
// Budget exhaustion must not INVERT into no budget at all. Downstream,
|
|
477
|
+
// `tokenBudget: 0` means "uncapped" (`LimitChecker`: `tokenBudget > 0
|
|
478
|
+
// && total >= tokenBudget`), and `maxAllocation` floors to 0 as soon as
|
|
479
|
+
// the parent's remaining drops below `1 / maxBudgetFraction`. So the
|
|
480
|
+
// most depleted parent in the tree was the one that spawned an
|
|
481
|
+
// unlimited child. Refuse instead: a caller that wants an uncapped
|
|
482
|
+
// child can say so explicitly with its own `budgetAllocation`.
|
|
483
|
+
//
|
|
484
|
+
// Refusing before any provisioning work also preserves the property the
|
|
485
|
+
// debit's placement was chosen for: a spawn this call rejects makes no
|
|
486
|
+
// state change at all, and burns no allocation.
|
|
487
|
+
if (allocatedTokens <= 0) {
|
|
488
|
+
throw new NamzuError({
|
|
489
|
+
code: 'invalid_config',
|
|
490
|
+
message: `Cannot spawn "${options.agentId}": the parent has ${context.budgetTracker.remaining} tokens remaining, which allocates 0 to the child — and a token budget of 0 means UNLIMITED downstream.`,
|
|
491
|
+
details: {
|
|
492
|
+
agentId: options.agentId,
|
|
493
|
+
parentRemaining: context.budgetTracker.remaining,
|
|
494
|
+
maxBudgetFraction: this.config.maxBudgetFraction,
|
|
495
|
+
},
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
|
|
479
499
|
// Phase 9: deps are unconditional required. Every spawn produces a
|
|
480
500
|
// SubSession + Session + WorkspaceRef triple (Convention #0: no
|
|
481
501
|
// partial/legacy path).
|
|
@@ -620,14 +640,24 @@ export class AgentManager {
|
|
|
620
640
|
throw err
|
|
621
641
|
}
|
|
622
642
|
|
|
643
|
+
// Debited only now, with the provisioning committed. Every path that
|
|
644
|
+
// could still have thrown is behind us, so a rejected spawn leaves the
|
|
645
|
+
// parent's budget untouched — the property the debit's original
|
|
646
|
+
// placement was chosen for, kept while closing the race that placement
|
|
647
|
+
// opened.
|
|
648
|
+
context.budgetTracker.remaining -= allocatedTokens
|
|
649
|
+
|
|
623
650
|
return {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
651
|
+
spawnRecord: {
|
|
652
|
+
subSessionId: subSession.id,
|
|
653
|
+
childSessionId: childSession.id,
|
|
654
|
+
tenantId: context.tenantId,
|
|
655
|
+
parentSessionId: options.parentSessionId,
|
|
656
|
+
rootSessionId,
|
|
657
|
+
childDepth,
|
|
658
|
+
workspaceRef,
|
|
659
|
+
},
|
|
660
|
+
allocatedTokens,
|
|
631
661
|
}
|
|
632
662
|
}
|
|
633
663
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { RunId } from '../../../types/ids/index.js'
|
|
4
|
+
import { PlanManager } from '../lifecycle.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `completePlan` scored an unreported step as a failure.
|
|
8
|
+
*
|
|
9
|
+
* The test was "is every step completed or skipped", and everything else fell
|
|
10
|
+
* to the same branch — so a step still `pending` produced `failed`. Since
|
|
11
|
+
* `addStep` defaults every step to `pending`, a caller that added steps, did
|
|
12
|
+
* the work, and settled the plan without reporting each one got `failed` for a
|
|
13
|
+
* plan that had fully succeeded. That is the path of least effort, not an
|
|
14
|
+
* unusual one.
|
|
15
|
+
*
|
|
16
|
+
* A step that FAILED is an outcome. A step nobody reported on is not — it says
|
|
17
|
+
* the caller and the plan disagree about whether the work is over, and
|
|
18
|
+
* answering "failed" settles that by inventing a result.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const RUN = 'run_plan_outcome' as RunId
|
|
22
|
+
|
|
23
|
+
function planWithSteps(count: number): PlanManager {
|
|
24
|
+
const manager = new PlanManager(RUN)
|
|
25
|
+
manager.startGenerating('a plan')
|
|
26
|
+
for (let i = 0; i < count; i += 1) {
|
|
27
|
+
manager.addStep({
|
|
28
|
+
id: `step-${i + 1}`,
|
|
29
|
+
description: `step ${i + 1}`,
|
|
30
|
+
dependsOn: [],
|
|
31
|
+
order: i,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
manager.markReady()
|
|
35
|
+
return manager
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('a plan settles on what its steps actually reported', () => {
|
|
39
|
+
it('completes when every step reported success', () => {
|
|
40
|
+
const manager = planWithSteps(2)
|
|
41
|
+
for (const step of manager.active?.steps ?? []) {
|
|
42
|
+
manager.updateStepStatus(step.id, 'completed')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
expect(manager.completePlan()?.status).toBe('completed')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('counts a skipped step as settled, not as a failure', () => {
|
|
49
|
+
const manager = planWithSteps(2)
|
|
50
|
+
const steps = manager.active?.steps ?? []
|
|
51
|
+
manager.updateStepStatus(steps[0]?.id as string, 'completed')
|
|
52
|
+
manager.updateStepStatus(steps[1]?.id as string, 'skipped')
|
|
53
|
+
|
|
54
|
+
expect(manager.completePlan()?.status).toBe('completed')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('fails when a step actually failed', () => {
|
|
58
|
+
const manager = planWithSteps(2)
|
|
59
|
+
const steps = manager.active?.steps ?? []
|
|
60
|
+
manager.updateStepStatus(steps[0]?.id as string, 'completed')
|
|
61
|
+
manager.updateStepStatus(steps[1]?.id as string, 'failed')
|
|
62
|
+
|
|
63
|
+
expect(manager.completePlan()?.status).toBe('failed')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('refuses rather than scoring a step nobody reported on', () => {
|
|
67
|
+
// The defect, in the shape a caller reaches it: steps added, work done,
|
|
68
|
+
// nothing reported. Answering `failed` here is the invented result.
|
|
69
|
+
const manager = planWithSteps(2)
|
|
70
|
+
manager.updateStepStatus(manager.active?.steps[0]?.id as string, 'completed')
|
|
71
|
+
|
|
72
|
+
expect(() => manager.completePlan()).toThrow(/have not reported an outcome/)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('names the way out rather than only the refusal', () => {
|
|
76
|
+
// A caller in this position either forgot to report progress or called
|
|
77
|
+
// too early, and only they know which — so the message has to carry
|
|
78
|
+
// both moves, not just the complaint.
|
|
79
|
+
const manager = planWithSteps(1)
|
|
80
|
+
|
|
81
|
+
expect(() => manager.completePlan()).toThrow(/updateStepStatus/)
|
|
82
|
+
expect(() => manager.completePlan()).toThrow(/failPlan/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('still returns null when there is no plan at all', () => {
|
|
86
|
+
expect(new PlanManager(RUN).completePlan()).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -26,6 +26,33 @@ export type PlanEventListener = (event: PlanEvent) => void
|
|
|
26
26
|
|
|
27
27
|
export type PlanApprovalHandler = (request: PlanApprovalRequest) => Promise<PlanApprovalResponse>
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The plan a run declares, and the gate a host approves it through.
|
|
31
|
+
*
|
|
32
|
+
* **The kernel deliberately drives only part of this class.** It builds a plan
|
|
33
|
+
* (`approve_plan` calls `startGenerating` / `addStep` / `markReady`), gates it
|
|
34
|
+
* (`iteration/phases/context.ts` calls `approve` and `startExecution`),
|
|
35
|
+
* translates its events onto the run stream (`EventTranslator.wirePlanManager`),
|
|
36
|
+
* and settles it on failure (`runtime/query/result.ts` calls `failPlan`). It
|
|
37
|
+
* never reports a step outcome and never settles a plan that succeeded.
|
|
38
|
+
*
|
|
39
|
+
* That is a split, not an omission — `drainQuery` hands the manager to the host
|
|
40
|
+
* through `onContextCreated({ planManager })` BEFORE the iteration loop starts,
|
|
41
|
+
* precisely so a host can drive the half the kernel does not. So a grep for
|
|
42
|
+
* callers of `updateStepStatus` or `completePlan` inside this package finds
|
|
43
|
+
* none, and that is not evidence the methods are dead: the callers are hosts,
|
|
44
|
+
* and they are outside the repository. `PlanManager` is exported from
|
|
45
|
+
* `public-runtime.ts` for this reason.
|
|
46
|
+
*
|
|
47
|
+
* Recorded here because the absence has already been read once as a dead layer
|
|
48
|
+
* and proposed for deletion. What it would have deleted is a working
|
|
49
|
+
* human-in-the-loop approval gate.
|
|
50
|
+
*
|
|
51
|
+
* The one genuine gap in the split is tracked separately: nothing settles a
|
|
52
|
+
* plan that SUCCEEDED, so its status can reach `failed` or stay `executing`
|
|
53
|
+
* but never `completed`. Fixing that needs a decision about what a
|
|
54
|
+
* kernel-built plan's steps mean, not a guessed status — see `completePlan`.
|
|
55
|
+
*/
|
|
29
56
|
export class PlanManager {
|
|
30
57
|
private currentPlan: Plan | null = null
|
|
31
58
|
private runId: RunId
|
|
@@ -185,9 +212,43 @@ export class PlanManager {
|
|
|
185
212
|
return step
|
|
186
213
|
}
|
|
187
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Settle the plan, computing its outcome from its steps.
|
|
217
|
+
*
|
|
218
|
+
* A step that is still `pending` or `running` used to land here as
|
|
219
|
+
* **`failed`**, because the test was "is every step completed or skipped"
|
|
220
|
+
* and anything else fell to the same branch. So a caller that added steps,
|
|
221
|
+
* did the work, and settled the plan without reporting each step got
|
|
222
|
+
* `failed` for a plan that fully succeeded — and `addStep` defaults every
|
|
223
|
+
* step to `pending`, so that is the path of least effort, not an unusual
|
|
224
|
+
* one.
|
|
225
|
+
*
|
|
226
|
+
* The two cases are different facts and want different responses. A step
|
|
227
|
+
* that FAILED is an outcome: the plan failed, report it. A step nobody
|
|
228
|
+
* reported on is not an outcome at all — it says the caller and this plan
|
|
229
|
+
* disagree about whether the work is over, and answering "failed" resolves
|
|
230
|
+
* that disagreement by inventing a result.
|
|
231
|
+
*
|
|
232
|
+
* So an unfinished step is refused rather than scored. The message names
|
|
233
|
+
* the steps and the two ways out, because a caller in this position either
|
|
234
|
+
* forgot to report progress or called too early, and only they know which.
|
|
235
|
+
*/
|
|
188
236
|
completePlan(): Plan | null {
|
|
189
237
|
if (!this.currentPlan) return null
|
|
190
238
|
|
|
239
|
+
const unfinished = this.currentPlan.steps.filter(
|
|
240
|
+
(s) => s.status === 'pending' || s.status === 'running',
|
|
241
|
+
)
|
|
242
|
+
if (unfinished.length > 0) {
|
|
243
|
+
const named = unfinished.slice(0, 3).map((s) => s.description)
|
|
244
|
+
const rest = unfinished.length - named.length
|
|
245
|
+
const listed = rest > 0 ? `${named.join('; ')}, and ${rest} more` : named.join('; ')
|
|
246
|
+
const counted = `${unfinished.length} of ${this.currentPlan.steps.length} steps`
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Cannot complete plan "${this.currentPlan.title}": ${counted} have not reported an outcome (${listed}). Report each step with updateStepStatus — 'skipped' is a valid outcome — or call failPlan if the plan is being abandoned. Scoring an unreported step as a failure would report a plan that succeeded as one that did not.`,
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
191
252
|
const allDone = this.currentPlan.steps.every(
|
|
192
253
|
(s) => s.status === 'completed' || s.status === 'skipped',
|
|
193
254
|
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { InMemoryTaskStore } from '../../../store/task/memory.js'
|
|
4
|
+
import type { RunId } from '../../../types/ids/index.js'
|
|
5
|
+
import type { RunEvent } from '../../../types/run/index.js'
|
|
6
|
+
import { EventTranslator } from '../events.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The task store maintains a full dependency graph — `blocks` and `blockedBy`
|
|
10
|
+
* mirrored on both ends, written under a lock, and deadlock-avoided — and none
|
|
11
|
+
* of it reached the wire.
|
|
12
|
+
*
|
|
13
|
+
* So a host could draw a flat list of units and nothing about their order,
|
|
14
|
+
* while the model was already maintaining the order. Two optional fields is the
|
|
15
|
+
* smallest change that lets a host draw the plan the model has in mind.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const RUN = 'run_graph' as RunId
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Only what the emitter touches — and it touches more than the id.
|
|
22
|
+
*
|
|
23
|
+
* `emitEvent` appends to the run store, so a fake without one produces an
|
|
24
|
+
* unhandled rejection AFTER the assertions have passed: every test reports
|
|
25
|
+
* green and the process exits non-zero. Worth stating because that is the
|
|
26
|
+
* failure shape this session has been unpicking all day, arriving here in a
|
|
27
|
+
* test fixture.
|
|
28
|
+
*/
|
|
29
|
+
const runMgr = {
|
|
30
|
+
id: RUN,
|
|
31
|
+
getRunStore: () => ({ appendEvent: async () => undefined }),
|
|
32
|
+
} as never
|
|
33
|
+
|
|
34
|
+
async function capture(body: (store: InMemoryTaskStore) => Promise<void>): Promise<RunEvent[]> {
|
|
35
|
+
const store = new InMemoryTaskStore()
|
|
36
|
+
const emitter = new EventTranslator(runMgr)
|
|
37
|
+
const stop = emitter.wireTaskStore(store, RUN)
|
|
38
|
+
|
|
39
|
+
await body(store)
|
|
40
|
+
// The store's listeners are async; let them settle before draining.
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
42
|
+
stop()
|
|
43
|
+
|
|
44
|
+
return [...emitter.drainPending()]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Created = Extract<RunEvent, { type: 'task_created' }>
|
|
48
|
+
type Updated = Extract<RunEvent, { type: 'task_updated' }>
|
|
49
|
+
|
|
50
|
+
describe('a host can see what a unit waits on', () => {
|
|
51
|
+
it('carries the edges once a dependency exists', async () => {
|
|
52
|
+
const events = await capture(async (store) => {
|
|
53
|
+
const gather = await store.create({ runId: RUN, subject: 'gather' })
|
|
54
|
+
const summarise = await store.create({ runId: RUN, subject: 'summarise' })
|
|
55
|
+
await store.block(gather.id, summarise.id)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const withEdges = events
|
|
59
|
+
.filter((e): e is Updated => e.type === 'task_updated')
|
|
60
|
+
.find((e) => e.blockedBy !== undefined)
|
|
61
|
+
|
|
62
|
+
expect(withEdges, 'the dependency the store recorded never reached the wire').toBeDefined()
|
|
63
|
+
expect(withEdges?.blockedBy).toHaveLength(1)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('says nothing rather than empty when a unit depends on nothing', async () => {
|
|
67
|
+
// Absent and empty are different claims. A reader must be able to tell
|
|
68
|
+
// "this unit has no dependencies" from "this emitter predates the
|
|
69
|
+
// field" — an empty array asserts the first about both.
|
|
70
|
+
const events = await capture(async (store) => {
|
|
71
|
+
await store.create({ runId: RUN, subject: 'standalone' })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const created = events.find((e): e is Created => e.type === 'task_created')
|
|
75
|
+
|
|
76
|
+
expect(created).toBeDefined()
|
|
77
|
+
expect(created && 'blockedBy' in created).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
})
|