@namzu/sdk 8.0.0 → 9.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 +215 -0
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +14 -1
- 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/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 +14 -1
- 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/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,133 @@
|
|
|
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 { ToolDefinition } from '../../../types/tool/index.js'
|
|
6
|
+
import { buildCoordinatorTools } from '../index.js'
|
|
7
|
+
import { failureLabel, taskSucceeded } from '../outcome.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A worker that ran and failed was reported to the model as an answer.
|
|
11
|
+
*
|
|
12
|
+
* Two layers can disagree. `finalizeChild` always calls `markCompleted`, so the
|
|
13
|
+
* gateway's `state` is `'completed'` for a child whose run returned
|
|
14
|
+
* `status: 'failed'` — and `create_task` asked only that layer. The model then
|
|
15
|
+
* read the failure text as a result, the tool result carried `isError: false`,
|
|
16
|
+
* and the plan task was written closed as though the work had been done.
|
|
17
|
+
*
|
|
18
|
+
* The correct predicate existed twenty lines away in the canonical `Agent`
|
|
19
|
+
* tool, put there because a review caught it on that site. Nothing carried the
|
|
20
|
+
* answer to the other one. So this file tests the shared predicate, and the
|
|
21
|
+
* predicate is shared so there is no longer a second place to forget.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const handle = (
|
|
25
|
+
state: TaskHandle['state'],
|
|
26
|
+
status?: string,
|
|
27
|
+
): Pick<TaskHandle, 'state' | 'result'> =>
|
|
28
|
+
({ state, result: status === undefined ? undefined : { status } }) as Pick<
|
|
29
|
+
TaskHandle,
|
|
30
|
+
'state' | 'result'
|
|
31
|
+
>
|
|
32
|
+
|
|
33
|
+
describe('success needs both authorities to agree', () => {
|
|
34
|
+
it('refuses a child the gateway called complete but whose run failed', () => {
|
|
35
|
+
// The exact shape the kernel produces: markCompleted was called, and
|
|
36
|
+
// the run underneath it did not succeed.
|
|
37
|
+
expect(taskSucceeded(handle('completed', 'failed'))).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('accepts a child both layers agree on', () => {
|
|
41
|
+
expect(taskSucceeded(handle('completed', 'completed'))).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('accepts a gateway that reports no run status at all', () => {
|
|
45
|
+
// A host gateway need not surface a run status. Treating its absence as
|
|
46
|
+
// failure would break every such gateway, so absence means "this layer
|
|
47
|
+
// has no opinion" rather than "it went wrong".
|
|
48
|
+
expect(taskSucceeded(handle('completed'))).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('refuses a child that never reached a completed state', () => {
|
|
52
|
+
expect(taskSucceeded(handle('failed', 'completed'))).toBe(false)
|
|
53
|
+
expect(taskSucceeded(handle('canceled'))).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('the failure is named by whichever layer reported it', () => {
|
|
58
|
+
it('uses the task state when the task itself did not complete', () => {
|
|
59
|
+
// "failed" would lose the distinction a reader needs: a cancelled task
|
|
60
|
+
// and a task whose run errored call for different next moves.
|
|
61
|
+
expect(failureLabel(handle('canceled', 'completed'))).toBe('canceled')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('uses the run status when the task completed but the run did not', () => {
|
|
65
|
+
expect(failureLabel(handle('completed', 'failed'))).toBe('failed')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('falls back to a plain word when neither layer said anything useful', () => {
|
|
69
|
+
expect(failureLabel(handle('completed'))).toBe('failed')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('create_task itself reaches the predicate', () => {
|
|
74
|
+
/**
|
|
75
|
+
* The unit tests above prove the predicate is right. They would all pass
|
|
76
|
+
* with `create_task` still asking only the gateway — which is exactly the
|
|
77
|
+
* state that shipped, with the correct version sitting twenty lines away in
|
|
78
|
+
* a sibling tool.
|
|
79
|
+
*
|
|
80
|
+
* So this drives the tool.
|
|
81
|
+
*/
|
|
82
|
+
function toolFor(handle: TaskHandle): ToolDefinition {
|
|
83
|
+
const gateway = {
|
|
84
|
+
createTask: async () => ({ ...handle, state: 'running' }),
|
|
85
|
+
waitForTask: async () => handle,
|
|
86
|
+
getTask: () => handle,
|
|
87
|
+
listTasks: () => [handle],
|
|
88
|
+
cancelTask: () => undefined,
|
|
89
|
+
continueTask: async () => undefined,
|
|
90
|
+
onTaskCompleted: () => () => undefined,
|
|
91
|
+
} as unknown as TaskGateway
|
|
92
|
+
|
|
93
|
+
const tools = buildCoordinatorTools({
|
|
94
|
+
gateway,
|
|
95
|
+
workingDirectory: '/tmp/test',
|
|
96
|
+
allowedAgentIds: ['reviewer'],
|
|
97
|
+
})
|
|
98
|
+
const createTask = tools.find((t) => t.name === 'create_task')
|
|
99
|
+
if (!createTask) throw new Error('create_task was not built')
|
|
100
|
+
return createTask
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const settled = (status: string): TaskHandle =>
|
|
104
|
+
({
|
|
105
|
+
taskId: 'tsk_1' as TaskId,
|
|
106
|
+
agentId: 'reviewer',
|
|
107
|
+
// The kernel's own shape: markCompleted ran regardless of the run.
|
|
108
|
+
state: 'completed',
|
|
109
|
+
createdAt: 1_000,
|
|
110
|
+
completedAt: 2_000,
|
|
111
|
+
result: { status, result: 'the worker text', lastError: 'it blew up' },
|
|
112
|
+
}) as unknown as TaskHandle
|
|
113
|
+
|
|
114
|
+
it('reports a failed run as a failure', async () => {
|
|
115
|
+
const tool = toolFor(settled('failed'))
|
|
116
|
+
const result = await tool.execute(
|
|
117
|
+
{ agent_id: 'reviewer', prompt: 'go', description: 'a task' },
|
|
118
|
+
{ toolUseId: 'call_1' } as never,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
expect(result.success, 'a failed worker was reported as an answer').toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('still reports a successful run as a success', async () => {
|
|
125
|
+
const tool = toolFor(settled('completed'))
|
|
126
|
+
const result = await tool.execute(
|
|
127
|
+
{ agent_id: 'reviewer', prompt: 'go', description: 'a task' },
|
|
128
|
+
{ toolUseId: 'call_1' } as never,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
expect(result.success).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { PlanManager } from '../../../manager/plan/lifecycle.js'
|
|
4
|
+
import type { TaskGateway } from '../../../types/agent/gateway.js'
|
|
5
|
+
import type { RunId } from '../../../types/ids/index.js'
|
|
6
|
+
import type { PlanApprovalRequest } from '../../../types/plan/index.js'
|
|
7
|
+
import type { ToolContext } from '../../../types/tool/index.js'
|
|
8
|
+
import { buildCoordinatorTools } from '../index.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The plan put in front of a human said a step delegates, never to whom.
|
|
12
|
+
*
|
|
13
|
+
* `approve_plan` asks the model for an `agent_id` per step — "which agent
|
|
14
|
+
* handles this" — and reduced the answer to a boolean: the step got
|
|
15
|
+
* `toolName: 'create_task'` when any agent was named and nothing when not. The
|
|
16
|
+
* name itself was dropped between the model saying it and the human being
|
|
17
|
+
* shown the plan.
|
|
18
|
+
*
|
|
19
|
+
* The approval is the one moment where that difference can still be acted on.
|
|
20
|
+
* Approving "delegate this step" is not the same as approving "delegate this
|
|
21
|
+
* step to the agent with shell access", and a reviewer who cannot see which
|
|
22
|
+
* agent was chosen cannot withhold approval from the wrong one.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const RUN = 'run_plan_approval' as RunId
|
|
26
|
+
|
|
27
|
+
function unusedGateway(): TaskGateway {
|
|
28
|
+
return {
|
|
29
|
+
async createTask() {
|
|
30
|
+
throw new Error('this test never launches')
|
|
31
|
+
},
|
|
32
|
+
async waitForTask() {
|
|
33
|
+
throw new Error('this test never waits')
|
|
34
|
+
},
|
|
35
|
+
async continueTask() {},
|
|
36
|
+
cancelTask() {},
|
|
37
|
+
getTask() {
|
|
38
|
+
return undefined
|
|
39
|
+
},
|
|
40
|
+
listTasks() {
|
|
41
|
+
return []
|
|
42
|
+
},
|
|
43
|
+
onTaskCompleted() {
|
|
44
|
+
return () => {}
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function testToolContext(): ToolContext {
|
|
50
|
+
return {
|
|
51
|
+
runId: RUN,
|
|
52
|
+
workingDirectory: '/tmp/test',
|
|
53
|
+
abortSignal: new AbortController().signal,
|
|
54
|
+
env: {},
|
|
55
|
+
log: () => {},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Run `approve_plan` and hand back exactly what the approver was shown. */
|
|
60
|
+
async function whatTheApproverSaw(
|
|
61
|
+
steps: Array<{ description: string; agent_id?: string }>,
|
|
62
|
+
): Promise<PlanApprovalRequest> {
|
|
63
|
+
let seen: PlanApprovalRequest | undefined
|
|
64
|
+
const pm = new PlanManager(RUN, async (request) => {
|
|
65
|
+
seen = request
|
|
66
|
+
return { approved: true }
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const tools = buildCoordinatorTools({
|
|
70
|
+
gateway: unusedGateway(),
|
|
71
|
+
workingDirectory: '/tmp/test',
|
|
72
|
+
allowedAgentIds: ['researcher', 'shell-runner'],
|
|
73
|
+
getPlanManager: () => pm,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const approvePlan = tools.find((tool) => tool.name === 'approve_plan')
|
|
77
|
+
if (!approvePlan) throw new Error('approve_plan tool missing from coordinator builder')
|
|
78
|
+
|
|
79
|
+
await approvePlan.execute(
|
|
80
|
+
{ title: 'Do the work', summary: 'A plan with delegated steps.', steps },
|
|
81
|
+
testToolContext(),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if (!seen) throw new Error('the approval handler was never called')
|
|
85
|
+
return seen
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('the plan a human approves names the agent the model chose', () => {
|
|
89
|
+
it('carries the agent per step, not just that there is one', async () => {
|
|
90
|
+
const request = await whatTheApproverSaw([
|
|
91
|
+
{ description: 'Gather the sources', agent_id: 'researcher' },
|
|
92
|
+
{ description: 'Run the migration', agent_id: 'shell-runner' },
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
expect(request.steps.map((s) => s.agentId)).toEqual(['researcher', 'shell-runner'])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('distinguishes two delegated steps that used to look identical', async () => {
|
|
99
|
+
// The defect in the shape that matters: before this, both steps below
|
|
100
|
+
// reached the approver as `toolName: 'create_task'` and nothing else, so
|
|
101
|
+
// the one with shell access was indistinguishable from the one without.
|
|
102
|
+
const request = await whatTheApproverSaw([
|
|
103
|
+
{ description: 'Read the docs', agent_id: 'researcher' },
|
|
104
|
+
{ description: 'Delete the old rows', agent_id: 'shell-runner' },
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
const [first, second] = request.steps
|
|
108
|
+
expect(first?.toolName).toBe(second?.toolName)
|
|
109
|
+
expect(first?.agentId).not.toBe(second?.agentId)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('leaves an orchestrator-owned step unattributed', async () => {
|
|
113
|
+
// Omitting `agent_id` means the orchestrator does it itself. That has to
|
|
114
|
+
// stay distinguishable from delegation, so absent stays absent rather
|
|
115
|
+
// than becoming a placeholder name.
|
|
116
|
+
const request = await whatTheApproverSaw([{ description: 'Summarize what came back' }])
|
|
117
|
+
|
|
118
|
+
expect(request.steps[0]?.agentId).toBeUndefined()
|
|
119
|
+
expect(request.steps[0]?.toolName).toBeUndefined()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -5,6 +5,7 @@ import type { TaskGateway } from '../../types/agent/gateway.js'
|
|
|
5
5
|
import type { ToolDefinition } from '../../types/tool/index.js'
|
|
6
6
|
import { defineTool } from '../defineTool.js'
|
|
7
7
|
import { wrapUntrusted } from '../untrusted-envelope.js'
|
|
8
|
+
import { failureLabel, taskSucceeded } from './outcome.js'
|
|
8
9
|
|
|
9
10
|
import type { TaskLaunchedCallback } from './index.js'
|
|
10
11
|
|
|
@@ -138,6 +139,13 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
|
|
|
138
139
|
prompt,
|
|
139
140
|
workingDirectory: cwd,
|
|
140
141
|
runtimeContext: opts.runtimeContext,
|
|
142
|
+
// Hang the child off the executing tool's span, so the
|
|
143
|
+
// delegation appears inside the turn that asked for it rather
|
|
144
|
+
// than as a disconnected root trace. `create_task` has done
|
|
145
|
+
// this all along; this tool — the kernel's other delegation
|
|
146
|
+
// surface, and the one it exports as the canonical shape —
|
|
147
|
+
// did not.
|
|
148
|
+
...(context.parentSpan ? { parentSpan: context.parentSpan } : {}),
|
|
141
149
|
})
|
|
142
150
|
|
|
143
151
|
onTaskLaunched?.(handle.taskId, {
|
|
@@ -153,26 +161,12 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
|
|
|
153
161
|
|
|
154
162
|
const completed = await gateway.waitForTask(handle.taskId)
|
|
155
163
|
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
// whatever the AgentManager set, which does not always
|
|
163
|
-
// reflect run-level failure.
|
|
164
|
-
// 2. `BaseAgentResult.status` — the run's own status. The
|
|
165
|
-
// canonical source of truth for whether the agent actually
|
|
166
|
-
// finished its work; `lastError` carries the failure
|
|
167
|
-
// message when set.
|
|
168
|
-
//
|
|
169
|
-
// Treat the subagent as successful only when BOTH agree.
|
|
170
|
-
// Reporting a failed subagent as successful would silently
|
|
171
|
-
// hand the parent garbage output and make debugging
|
|
172
|
-
// impossible, which is what review flagged on the first cut.
|
|
173
|
-
const runStatus = completed.result?.status
|
|
174
|
-
const succeeded =
|
|
175
|
-
completed.state === 'completed' && (runStatus === undefined || runStatus === 'completed')
|
|
164
|
+
// Both authorities must agree — see `taskSucceeded` for which two
|
|
165
|
+
// and why either alone is wrong. The reasoning used to live here
|
|
166
|
+
// alone, which is exactly how `create_task` came to ship without
|
|
167
|
+
// it: a review caught this site, and nothing carried the answer to
|
|
168
|
+
// the other one.
|
|
169
|
+
const succeeded = taskSucceeded(completed)
|
|
176
170
|
|
|
177
171
|
const resultText =
|
|
178
172
|
typeof completed.result?.result === 'string'
|
|
@@ -182,19 +176,17 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
|
|
|
182
176
|
: ''
|
|
183
177
|
|
|
184
178
|
if (!succeeded) {
|
|
185
|
-
const failureLabel =
|
|
186
|
-
completed.state !== 'completed' ? completed.state : (runStatus ?? 'failed')
|
|
187
179
|
const detail =
|
|
188
180
|
completed.result?.lastError ?? resultText ?? '(subagent provided no failure detail)'
|
|
189
181
|
return {
|
|
190
182
|
success: false,
|
|
191
183
|
output: '',
|
|
192
|
-
error: `Subagent ${agentId} ${failureLabel}: ${detail}`,
|
|
184
|
+
error: `Subagent ${agentId} ${failureLabel(completed)}: ${detail}`,
|
|
193
185
|
data: {
|
|
194
186
|
task_id: handle.taskId,
|
|
195
187
|
subagent_type: agentId,
|
|
196
188
|
state: completed.state,
|
|
197
|
-
status:
|
|
189
|
+
status: completed.result?.status,
|
|
198
190
|
lastError: completed.result?.lastError,
|
|
199
191
|
},
|
|
200
192
|
}
|
|
@@ -221,7 +213,7 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
|
|
|
221
213
|
subagent_type: agentId,
|
|
222
214
|
result: resultText,
|
|
223
215
|
state: completed.state,
|
|
224
|
-
status:
|
|
216
|
+
status: completed.result?.status,
|
|
225
217
|
},
|
|
226
218
|
}
|
|
227
219
|
},
|
|
@@ -10,6 +10,7 @@ import type { TaskStore } from '../../types/task/index.js'
|
|
|
10
10
|
import type { ToolDefinition } from '../../types/tool/index.js'
|
|
11
11
|
import { defineTool } from '../defineTool.js'
|
|
12
12
|
import { wrapUntrusted } from '../untrusted-envelope.js'
|
|
13
|
+
import { failureLabel, taskSucceeded } from './outcome.js'
|
|
13
14
|
import { resolvePlanDependencies } from './plan-dependencies.js'
|
|
14
15
|
import { describeWaitTimeout, waitForTaskWithBounds } from './wait-with-idle-bound.js'
|
|
15
16
|
|
|
@@ -457,6 +458,17 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
|
|
|
457
458
|
// Naming the missing piece is the only response that tells them
|
|
458
459
|
// what to change.
|
|
459
460
|
if (background && !canLaunchInBackground) {
|
|
461
|
+
// The plan task was marked in progress a few lines above, on the
|
|
462
|
+
// assumption that a worker was about to run. Nothing is running,
|
|
463
|
+
// so leaving it there would show a plan step underway with no
|
|
464
|
+
// worker behind it — indefinitely, since nothing later will
|
|
465
|
+
// close a task whose launch never happened.
|
|
466
|
+
if (resolvedPlanTaskId && taskStore) {
|
|
467
|
+
await taskStore.update(resolvedPlanTaskId as `task_${string}`, {
|
|
468
|
+
status: 'failed',
|
|
469
|
+
description: 'Failed: the launch was refused before any worker started',
|
|
470
|
+
})
|
|
471
|
+
}
|
|
460
472
|
return {
|
|
461
473
|
success: false,
|
|
462
474
|
output: '',
|
|
@@ -533,15 +545,26 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
|
|
|
533
545
|
}
|
|
534
546
|
}
|
|
535
547
|
completionInbox?.claim(handle.taskId)
|
|
536
|
-
|
|
548
|
+
// Both authorities, not just the gateway's. `finalizeChild` always
|
|
549
|
+
// calls `markCompleted`, so `state === 'completed'` holds for a
|
|
550
|
+
// child that ran and returned `status: 'failed'` — and this tool
|
|
551
|
+
// reported that child's error text to the model as its answer, with
|
|
552
|
+
// `isError: false`, while writing the plan task closed as though
|
|
553
|
+
// the work had been done.
|
|
554
|
+
const success = taskSucceeded(completed)
|
|
537
555
|
const resultText =
|
|
538
556
|
completed.result?.result ??
|
|
539
557
|
completed.result?.lastError ??
|
|
540
|
-
`Task finished with state: ${completed
|
|
558
|
+
`Task finished with state: ${failureLabel(completed)}`
|
|
541
559
|
|
|
542
560
|
if (resolvedPlanTaskId && taskStore) {
|
|
561
|
+
// The status carries the outcome now, rather than `completed`
|
|
562
|
+
// with the failure written into prose. A reader scanning
|
|
563
|
+
// statuses saw work that had been done; only a reader of every
|
|
564
|
+
// description saw otherwise — and a dependent unit had no way
|
|
565
|
+
// to tell at all.
|
|
543
566
|
await taskStore.update(resolvedPlanTaskId as `task_${string}`, {
|
|
544
|
-
status: 'completed',
|
|
567
|
+
status: success ? 'completed' : 'failed',
|
|
545
568
|
description: success ? undefined : `Failed: ${resultText.substring(0, 200)}`,
|
|
546
569
|
})
|
|
547
570
|
}
|
|
@@ -922,6 +945,13 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
|
|
|
922
945
|
id: `step_${i + 1}`,
|
|
923
946
|
description: step.description,
|
|
924
947
|
toolName: step.agent_id ? 'create_task' : undefined,
|
|
948
|
+
// WHICH agent, not just whether there is one. The schema
|
|
949
|
+
// asks the model to name an agent per step and the answer
|
|
950
|
+
// was collapsed to the boolean above, so the human
|
|
951
|
+
// approving the plan saw that a step delegates and never
|
|
952
|
+
// to whom — at the one moment the difference can still be
|
|
953
|
+
// acted on.
|
|
954
|
+
...(step.agent_id ? { agentId: step.agent_id } : {}),
|
|
925
955
|
// Was `[]` unconditionally, which dropped every ordering
|
|
926
956
|
// constraint the model was invited to express — and put an
|
|
927
957
|
// empty dependency list in front of the human approving it.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { TaskHandle } from '../../types/agent/gateway.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Did this worker actually succeed?
|
|
5
|
+
*
|
|
6
|
+
* Two layers can disagree, and asking only one of them is how a failed worker
|
|
7
|
+
* gets reported as an answer:
|
|
8
|
+
*
|
|
9
|
+
* 1. **`TaskHandle.state`** — the gateway's terminal task state. Some gateways
|
|
10
|
+
* map a failed run to `state: 'failed'`; others forward whatever the agent
|
|
11
|
+
* manager set, which does not always reflect run-level failure. The kernel's
|
|
12
|
+
* own `finalizeChild` always calls `markCompleted`, so `state` is
|
|
13
|
+
* `'completed'` for a child that ran and returned `status: 'failed'`.
|
|
14
|
+
* 2. **`BaseAgentResult.status`** — the run's own status, and the canonical
|
|
15
|
+
* answer to whether the agent finished its work. `lastError` carries the
|
|
16
|
+
* message when it did not.
|
|
17
|
+
*
|
|
18
|
+
* So success requires BOTH to agree. Reporting a failed worker as successful
|
|
19
|
+
* hands the parent garbage output as though it were a result, and makes
|
|
20
|
+
* debugging impossible — the model reads an error as an answer and builds on
|
|
21
|
+
* it.
|
|
22
|
+
*
|
|
23
|
+
* **This lives here because it was written twice and omitted once**, and the
|
|
24
|
+
* omission was in `create_task`, the primary delegation surface. The version in
|
|
25
|
+
* the canonical `Agent` tool was correct because a review caught it there; the
|
|
26
|
+
* same review never reached the other site. A predicate that is easy to get
|
|
27
|
+
* wrong, and whose wrong answer is silent, belongs in one place that every
|
|
28
|
+
* caller reaches rather than in each caller's memory.
|
|
29
|
+
*/
|
|
30
|
+
export function taskSucceeded(handle: Pick<TaskHandle, 'state' | 'result'>): boolean {
|
|
31
|
+
const runStatus = handle.result?.status
|
|
32
|
+
return handle.state === 'completed' && (runStatus === undefined || runStatus === 'completed')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Did this worker actually fail? **Not the negation of {@link taskSucceeded}.**
|
|
37
|
+
*
|
|
38
|
+
* Three answers exist, not two: succeeded, failed, and not settled yet. A task
|
|
39
|
+
* still running satisfies neither predicate, and that is the point — a caller
|
|
40
|
+
* deciding whether to tear down healthy siblings must act on a child that
|
|
41
|
+
* *failed*, never on one that merely has not succeeded yet. Writing this as
|
|
42
|
+
* `!taskSucceeded(handle)` would cancel a fan-out the moment the first child
|
|
43
|
+
* was still working.
|
|
44
|
+
*
|
|
45
|
+
* The two-authority rule applies here too, for the same reason: the kernel's
|
|
46
|
+
* `finalizeChild` always calls `markCompleted`, so a run that returned
|
|
47
|
+
* `status: 'failed'` carries `state: 'completed'`, and a check that read only
|
|
48
|
+
* the gateway state would never see it fail.
|
|
49
|
+
*
|
|
50
|
+
* Third copy of this knowledge, now in the one place `taskSucceeded` already
|
|
51
|
+
* lives — it was written independently in `LocalTaskGateway`, which got it
|
|
52
|
+
* right, but a rule that each caller has to remember is a rule one of them
|
|
53
|
+
* eventually forgets. That has already happened once with `taskSucceeded`.
|
|
54
|
+
*/
|
|
55
|
+
export function taskFailed(handle: Pick<TaskHandle, 'state' | 'result'>): boolean {
|
|
56
|
+
return handle.state === 'failed' || handle.result?.status === 'failed'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What to call the failure, in the words of whichever layer reported it.
|
|
61
|
+
*
|
|
62
|
+
* The gateway state wins when it is the one that disagrees, because a task that
|
|
63
|
+
* never reached `completed` failed in a way the run status cannot describe — it
|
|
64
|
+
* was cancelled, or it timed out, and saying "failed" for those loses the
|
|
65
|
+
* distinction a reader needs to decide what to do next.
|
|
66
|
+
*/
|
|
67
|
+
export function failureLabel(handle: Pick<TaskHandle, 'state' | 'result'>): string {
|
|
68
|
+
if (handle.state !== 'completed') return handle.state
|
|
69
|
+
return handle.result?.status ?? 'failed'
|
|
70
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TaskId } from '../ids/index.js'
|
|
2
|
-
import type { AgentRuntimeContext, BaseAgentResult } from './base.js'
|
|
2
|
+
import type { AgentRuntimeContext, BaseAgentConfig, BaseAgentResult } from './base.js'
|
|
3
3
|
import type { AgentTaskState } from './task.js'
|
|
4
4
|
|
|
5
5
|
export interface TaskHandle {
|
|
@@ -40,7 +40,24 @@ export interface CreateTaskOptions {
|
|
|
40
40
|
|
|
41
41
|
runtimeContext?: AgentRuntimeContext
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Config the spawned run should be built with, overriding what the
|
|
45
|
+
* agent's own definition supplies — the model it runs on, its iteration
|
|
46
|
+
* ceiling, its thinking or effort settings.
|
|
47
|
+
*
|
|
48
|
+
* **This was accepted and dropped.** `LocalTaskGateway.createTask` built
|
|
49
|
+
* its own `configOverrides` object out of `parentSpan` alone and never
|
|
50
|
+
* read this field, so a caller pinning a delegated run to a cheaper model
|
|
51
|
+
* got the agent's default model and no indication otherwise. It is
|
|
52
|
+
* forwarded now, with the dedicated {@link parentSpan} option winning if
|
|
53
|
+
* both name a span, since that one is the specific field for the job.
|
|
54
|
+
*
|
|
55
|
+
* Typed as `Partial<BaseAgentConfig>` rather than
|
|
56
|
+
* `Record<string, unknown>`: this lands on `SendMessageOptions`, which is
|
|
57
|
+
* already that shape, and the loose type let a misspelled key type-check
|
|
58
|
+
* and then do nothing — the same silence this field was already producing.
|
|
59
|
+
*/
|
|
60
|
+
configOverrides?: Partial<BaseAgentConfig>
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
export interface TaskGateway {
|
|
@@ -10,7 +10,7 @@ import type { ToolRegistryContract } from '../tool/index.js'
|
|
|
10
10
|
import type { VerificationGateConfig } from '../verification/index.js'
|
|
11
11
|
import type { BaseAgentConfig, BaseAgentResult } from './base.js'
|
|
12
12
|
import type { AgentFactoryOptions } from './factory.js'
|
|
13
|
-
import type { TaskGateway } from './gateway.js'
|
|
13
|
+
import type { SiblingFailurePolicy, TaskGateway } from './gateway.js'
|
|
14
14
|
import type { AgentManagerContract } from './manager.js'
|
|
15
15
|
import type { WorkingMemoryProvider } from './working-memory.js'
|
|
16
16
|
|
|
@@ -72,6 +72,45 @@ export interface SupervisorAgentConfig extends BaseAgentConfig {
|
|
|
72
72
|
*/
|
|
73
73
|
maxDepth?: number
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* How many tools may execute at once in one turn — which, for a
|
|
77
|
+
* supervisor, is how wide a fan-out actually runs.
|
|
78
|
+
*
|
|
79
|
+
* The kernel has honoured this all along and `ReactiveAgent` forwards it.
|
|
80
|
+
* It was missing here, so the agent whose entire job is delegation could
|
|
81
|
+
* not set the gate that bounds delegation, while the agent that does not
|
|
82
|
+
* delegate could. A host wanting a narrower fan-out had to reach past the
|
|
83
|
+
* supervisor to `drainQuery`.
|
|
84
|
+
*
|
|
85
|
+
* Absent leaves the kernel default. Note what it does and does not bound:
|
|
86
|
+
* it limits how many delegated children run CONCURRENTLY, not how many a
|
|
87
|
+
* turn may launch — a model that emits twenty `create_task` blocks still
|
|
88
|
+
* launches twenty, and they queue.
|
|
89
|
+
*/
|
|
90
|
+
maxToolConcurrency?: number
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What a failed child means for the siblings still running. Defaults to
|
|
94
|
+
* `'continue'`.
|
|
95
|
+
*
|
|
96
|
+
* `LocalTaskGateway` has honoured this since it was written, and the
|
|
97
|
+
* cancellation machinery behind `'cancel-siblings'` is complete — but the
|
|
98
|
+
* policy was a constructor argument on a gateway the supervisor builds
|
|
99
|
+
* itself, and the supervisor passed nothing. So every host in existence
|
|
100
|
+
* ran `'continue'`, and the only way to reach the other value was to
|
|
101
|
+
* construct the gateway by hand and hand it in. A policy nobody can select
|
|
102
|
+
* is not a policy.
|
|
103
|
+
*
|
|
104
|
+
* `'continue'` stays the default deliberately: partial results are usually
|
|
105
|
+
* worth having, and tearing down healthy siblings on any failure lets one
|
|
106
|
+
* flaky child waste four good ones. `'cancel-siblings'` is for a fan-out
|
|
107
|
+
* whose parts only mean something together — if one leg of a comparison
|
|
108
|
+
* dies, the others are spending budget on an answer nobody can use.
|
|
109
|
+
*
|
|
110
|
+
* Ignored when the host supplies its own `gateway`, which owns its policy.
|
|
111
|
+
*/
|
|
112
|
+
siblingFailurePolicy?: SiblingFailurePolicy
|
|
113
|
+
|
|
75
114
|
taskRouter?: TaskRouterConfig
|
|
76
115
|
|
|
77
116
|
factoryOptions?: AgentFactoryOptions
|
package/src/types/plan/index.ts
CHANGED
|
@@ -18,7 +18,36 @@ export interface PlanStep {
|
|
|
18
18
|
id: string
|
|
19
19
|
description: string
|
|
20
20
|
toolName?: string
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Which agent this step is to be delegated to, when it is delegated at all.
|
|
24
|
+
*
|
|
25
|
+
* `approve_plan` invites the model to name an agent per step, and that
|
|
26
|
+
* answer was reduced to a boolean: the step got `toolName: 'create_task'`
|
|
27
|
+
* if any agent was named and nothing if not. So the human approving the
|
|
28
|
+
* plan was shown THAT a step delegates and never TO WHOM — while the model
|
|
29
|
+
* had said, and the approval is the one moment where the difference can
|
|
30
|
+
* still be acted on. Approving "delegate this" is not approving "delegate
|
|
31
|
+
* this to the agent with shell access".
|
|
32
|
+
*
|
|
33
|
+
* Typed rather than folded into {@link estimatedInput}, which is `unknown`:
|
|
34
|
+
* an approval gate's whole job is being readable, and a field a host has to
|
|
35
|
+
* cast before it can render is one a host renders wrong or not at all.
|
|
36
|
+
*
|
|
37
|
+
* Absent means the step is the orchestrator's own work, which is what
|
|
38
|
+
* omitting `agent_id` in `approve_plan` says.
|
|
39
|
+
*/
|
|
40
|
+
agentId?: string
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* **No producer and no reader.** Nothing in the SDK writes this and
|
|
44
|
+
* nothing reads it; it is declared here and that is all. Noted rather
|
|
45
|
+
* than removed because it is on the published typings — see
|
|
46
|
+
* {@link agentId}, which is the field the plan approval path actually
|
|
47
|
+
* needed and did not have.
|
|
48
|
+
*/
|
|
21
49
|
estimatedInput?: unknown
|
|
50
|
+
|
|
22
51
|
dependsOn: string[]
|
|
23
52
|
status: 'pending' | 'running' | 'completed' | 'skipped' | 'failed'
|
|
24
53
|
error?: string
|
package/src/types/run/events.ts
CHANGED
|
@@ -461,6 +461,21 @@ type CoreRunEvent =
|
|
|
461
461
|
taskId: TaskId
|
|
462
462
|
subject: string
|
|
463
463
|
status: TaskStatus
|
|
464
|
+
/**
|
|
465
|
+
* What this unit waits on, and who claims it.
|
|
466
|
+
*
|
|
467
|
+
* The store maintains a full dependency graph — `blocks` and
|
|
468
|
+
* `blockedBy` are mirrored on both ends, written under a lock, and
|
|
469
|
+
* deadlock-avoided — and none of it reached the wire. So a host
|
|
470
|
+
* could show a flat list of units and nothing about their order,
|
|
471
|
+
* while the model was already maintaining the order.
|
|
472
|
+
*
|
|
473
|
+
* Absent rather than empty when the unit depends on nothing, so a
|
|
474
|
+
* reader can tell "no dependencies" from an emitter that predates
|
|
475
|
+
* these fields.
|
|
476
|
+
*/
|
|
477
|
+
blockedBy?: readonly TaskId[]
|
|
478
|
+
owner?: string
|
|
464
479
|
}
|
|
465
480
|
| {
|
|
466
481
|
type: 'task_updated'
|
|
@@ -469,6 +484,8 @@ type CoreRunEvent =
|
|
|
469
484
|
subject: string
|
|
470
485
|
status: TaskStatus
|
|
471
486
|
owner?: string
|
|
487
|
+
/** See `task_created`. Carried on updates because an edge can be added later. */
|
|
488
|
+
blockedBy?: readonly TaskId[]
|
|
472
489
|
}
|
|
473
490
|
| {
|
|
474
491
|
type: 'plugin_hook_executing'
|
package/src/types/task/index.ts
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
import type { RunId, TaskId, TenantId } from '../ids/index.js'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* `failed` exists because a unit that did not succeed had nowhere to say so.
|
|
5
|
+
*
|
|
6
|
+
* Delegation wrote a failed worker's task as `completed` with the failure
|
|
7
|
+
* encoded as prose in `description` — so a reader scanning statuses saw work
|
|
8
|
+
* that had been done, and only a reader of every description saw otherwise. A
|
|
9
|
+
* status nobody can set is a status nobody can act on: a dependent unit cannot
|
|
10
|
+
* decide whether to wait or give up, and a plan cannot report that it did not
|
|
11
|
+
* finish.
|
|
12
|
+
*/
|
|
13
|
+
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Terminal means "will not change on its own", not "succeeded".
|
|
17
|
+
*
|
|
18
|
+
* `failed` is terminal for the same reason `completed` is: nothing downstream
|
|
19
|
+
* should wait on it. That matters most to the blocker check in the task
|
|
20
|
+
* listing — a dependent unit blocked on something that failed would otherwise
|
|
21
|
+
* wait forever for a status that will never arrive.
|
|
22
|
+
*/
|
|
5
23
|
export function isTerminalTaskStatus(status: TaskStatus): boolean {
|
|
6
|
-
return status === 'completed'
|
|
24
|
+
return status === 'completed' || status === 'failed'
|
|
7
25
|
}
|
|
8
26
|
|
|
9
27
|
export function assertTaskStatus(status: TaskStatus): void {
|
|
@@ -11,6 +29,7 @@ export function assertTaskStatus(status: TaskStatus): void {
|
|
|
11
29
|
case 'pending':
|
|
12
30
|
case 'in_progress':
|
|
13
31
|
case 'completed':
|
|
32
|
+
case 'failed':
|
|
14
33
|
return
|
|
15
34
|
default: {
|
|
16
35
|
const _exhaustive: never = status
|