@namzu/sdk 4.0.0 → 5.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 +180 -0
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +6 -0
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/__tests__/run-agent.test.d.ts +2 -0
- package/dist/agents/__tests__/run-agent.test.d.ts.map +1 -0
- package/dist/agents/__tests__/run-agent.test.js +143 -0
- package/dist/agents/__tests__/run-agent.test.js.map +1 -0
- package/dist/agents/index.d.ts +2 -0
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +1 -0
- package/dist/agents/index.js.map +1 -1
- package/dist/agents/runAgent.d.ts +104 -0
- package/dist/agents/runAgent.d.ts.map +1 -0
- package/dist/agents/runAgent.js +92 -0
- package/dist/agents/runAgent.js.map +1 -0
- package/dist/provider/__tests__/thinking-support.test.d.ts +2 -0
- package/dist/provider/__tests__/thinking-support.test.d.ts.map +1 -0
- package/dist/provider/__tests__/thinking-support.test.js +47 -0
- package/dist/provider/__tests__/thinking-support.test.js.map +1 -0
- package/dist/provider/thinking-support.d.ts +30 -0
- package/dist/provider/thinking-support.d.ts.map +1 -0
- package/dist/provider/thinking-support.js +32 -0
- package/dist/provider/thinking-support.js.map +1 -0
- package/dist/public-runtime.d.ts +4 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +10 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +1 -1
- package/dist/public-types.d.ts.map +1 -1
- package/dist/runtime/query/__tests__/steering-reaches-the-model.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/steering-reaches-the-model.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/steering-reaches-the-model.test.js +94 -0
- package/dist/runtime/query/__tests__/steering-reaches-the-model.test.js.map +1 -0
- package/dist/runtime/query/__tests__/steering.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/steering.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/steering.test.js +92 -0
- package/dist/runtime/query/__tests__/steering.test.js.map +1 -0
- package/dist/runtime/query/index.d.ts +10 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +1 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +8 -0
- 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/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +7 -1
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/steering.d.ts +81 -0
- package/dist/runtime/query/steering.d.ts.map +1 -0
- package/dist/runtime/query/steering.js +77 -0
- package/dist/runtime/query/steering.js.map +1 -0
- package/dist/types/agent/manager.d.ts +11 -4
- package/dist/types/agent/manager.d.ts.map +1 -1
- package/dist/types/agent/supervisor.d.ts +9 -0
- package/dist/types/agent/supervisor.d.ts.map +1 -1
- package/dist/types/common/index.d.ts +12 -0
- package/dist/types/common/index.d.ts.map +1 -1
- package/dist/types/common/index.js +7 -0
- package/dist/types/common/index.js.map +1 -1
- package/dist/types/provider/chat.d.ts +57 -5
- package/dist/types/provider/chat.d.ts.map +1 -1
- package/dist/types/provider/index.d.ts +1 -1
- package/dist/types/provider/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/agents/SupervisorAgent.ts +6 -0
- package/src/agents/__tests__/run-agent.test.ts +165 -0
- package/src/agents/index.ts +2 -0
- package/src/agents/runAgent.ts +179 -0
- package/src/provider/__tests__/thinking-support.test.ts +60 -0
- package/src/provider/thinking-support.ts +37 -0
- package/src/public-runtime.ts +10 -0
- package/src/public-types.ts +7 -1
- package/src/runtime/query/__tests__/steering-reaches-the-model.test.ts +117 -0
- package/src/runtime/query/__tests__/steering.test.ts +121 -0
- package/src/runtime/query/index.ts +12 -0
- package/src/runtime/query/iteration/phases/context.ts +9 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +7 -1
- package/src/runtime/query/steering.ts +135 -0
- package/src/types/agent/manager.ts +11 -4
- package/src/types/agent/supervisor.ts +10 -0
- package/src/types/common/index.ts +20 -0
- package/src/types/provider/chat.ts +61 -5
- package/src/types/provider/index.ts +1 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
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
|
+
import { SteeringBinding } from '../steering.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The unit tests next door prove `attachSteering` builds the right message.
|
|
17
|
+
* They would all pass with the loop never calling it — which is the exact
|
|
18
|
+
* shape of defect this repo keeps finding, and the reason this file drives a
|
|
19
|
+
* real run instead.
|
|
20
|
+
*
|
|
21
|
+
* A steer queued while a tool is running must be visible in the messages the
|
|
22
|
+
* NEXT model call receives, or the channel is another declaration nothing
|
|
23
|
+
* drives.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
registerMock()
|
|
27
|
+
|
|
28
|
+
async function runWithSteer(steerDuringTool?: string) {
|
|
29
|
+
const steering = new SteeringBinding()
|
|
30
|
+
|
|
31
|
+
const tools = new ToolRegistry()
|
|
32
|
+
tools.register({
|
|
33
|
+
name: 'inspect',
|
|
34
|
+
description: 'looks at something',
|
|
35
|
+
inputSchema: z.object({}),
|
|
36
|
+
execute: async () => {
|
|
37
|
+
// Queued from inside the tool, which is when a host would type it:
|
|
38
|
+
// the batch is in flight and there is no legal slot for a user
|
|
39
|
+
// message until it settles.
|
|
40
|
+
if (steerDuringTool) steering.steer(steerDuringTool)
|
|
41
|
+
return { success: true, output: 'inspection done' }
|
|
42
|
+
},
|
|
43
|
+
} as never)
|
|
44
|
+
|
|
45
|
+
const provider = new MockLLMProvider({
|
|
46
|
+
turns: [
|
|
47
|
+
{ toolCalls: [{ id: 'c1', name: 'inspect', rawArguments: '{}' }] },
|
|
48
|
+
{ text: 'finished' },
|
|
49
|
+
],
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
await drainQuery({
|
|
53
|
+
provider,
|
|
54
|
+
tools,
|
|
55
|
+
agentId: 'a',
|
|
56
|
+
agentName: 'A',
|
|
57
|
+
messages: [{ role: 'user', content: 'go' }],
|
|
58
|
+
workingDirectory: process.cwd(),
|
|
59
|
+
runConfig: {
|
|
60
|
+
model: 'mock-model',
|
|
61
|
+
tokenBudget: 100_000,
|
|
62
|
+
timeoutMs: 30_000,
|
|
63
|
+
maxIterations: 4,
|
|
64
|
+
},
|
|
65
|
+
projectId: generateProjectId(),
|
|
66
|
+
sessionId: generateSessionId(),
|
|
67
|
+
threadId: generateThreadId(),
|
|
68
|
+
tenantId: generateTenantId(),
|
|
69
|
+
steering,
|
|
70
|
+
} as never)
|
|
71
|
+
|
|
72
|
+
return { provider, steering }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every message body the model saw on its Nth call, flattened to text. */
|
|
76
|
+
function bodiesOn(provider: MockLLMProvider, call: number): string {
|
|
77
|
+
return (provider.requests[call]?.messages ?? [])
|
|
78
|
+
.map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)))
|
|
79
|
+
.join('\n')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('guidance queued during a tool batch reaches the model', () => {
|
|
83
|
+
it('is in the messages of the next model call', async () => {
|
|
84
|
+
const { provider } = await runWithSteer('actually, check the tests as well')
|
|
85
|
+
|
|
86
|
+
expect(provider.requests.length).toBeGreaterThan(1)
|
|
87
|
+
expect(bodiesOn(provider, 1)).toContain('actually, check the tests as well')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('rides on the tool result rather than as a separate turn', async () => {
|
|
91
|
+
const { provider } = await runWithSteer('check the tests as well')
|
|
92
|
+
|
|
93
|
+
const second = provider.requests[1]?.messages ?? []
|
|
94
|
+
const carrier = second.find((m) =>
|
|
95
|
+
typeof m.content === 'string' ? m.content.includes('check the tests as well') : false,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// The slot matters: a `tool_use` block must be answered by a
|
|
99
|
+
// `tool_result` with the same id, so a user turn wedged in here is
|
|
100
|
+
// rejected by the provider outright.
|
|
101
|
+
expect(carrier?.role).toBe('tool')
|
|
102
|
+
expect(String(carrier?.content)).toContain('inspection done')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('is delivered once and leaves the channel empty', async () => {
|
|
106
|
+
const { steering } = await runWithSteer('one time only')
|
|
107
|
+
|
|
108
|
+
expect(steering.pending).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('changes nothing when nobody steers', async () => {
|
|
112
|
+
const { provider } = await runWithSteer()
|
|
113
|
+
|
|
114
|
+
expect(bodiesOn(provider, 1)).toContain('inspection done')
|
|
115
|
+
expect(bodiesOn(provider, 1)).not.toContain('operator')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { Message } from '../../../types/message/index.js'
|
|
4
|
+
import { SteeringBinding, attachSteering, formatSteeringNote } from '../steering.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `AgentManager.queueMessage` / `drainMessages` have existed for a while and
|
|
8
|
+
* nothing in the iteration loop ever read them — the type says so outright.
|
|
9
|
+
* So a host watching a run go wrong could cancel it, throwing away every tool
|
|
10
|
+
* result already paid for, or reject through the review gate, which only
|
|
11
|
+
* works when a call happens to be pending approval and says "no" when the
|
|
12
|
+
* host meant "yes, but read this first".
|
|
13
|
+
*
|
|
14
|
+
* The delivery is the interesting part. A `tool_use` block must be answered
|
|
15
|
+
* by a `tool_result` with the same id, so there is no legal slot for a user
|
|
16
|
+
* message mid-batch; this codebase had already worked that out for denials,
|
|
17
|
+
* which carry their reason inside the `tool_result` precisely because that is
|
|
18
|
+
* where the model looks.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const toolMessage = (id: string, content: string): Message =>
|
|
22
|
+
({ role: 'tool', content, toolCallId: id, timestamp: 1 }) as unknown as Message
|
|
23
|
+
|
|
24
|
+
const assistantMessage = (content: string): Message =>
|
|
25
|
+
({ role: 'assistant', content, timestamp: 1 }) as unknown as Message
|
|
26
|
+
|
|
27
|
+
describe('steering a running turn', () => {
|
|
28
|
+
it('appends the guidance to the last tool result', () => {
|
|
29
|
+
const channel = new SteeringBinding()
|
|
30
|
+
channel.steer('check the tests too')
|
|
31
|
+
|
|
32
|
+
const out = attachSteering([toolMessage('a', 'first'), toolMessage('b', 'second')], channel)
|
|
33
|
+
|
|
34
|
+
expect(out[0]?.content).toBe('first')
|
|
35
|
+
// The LAST one: it is the final thing the model reads before deciding
|
|
36
|
+
// what to do next, where the first would be buried under every later
|
|
37
|
+
// result.
|
|
38
|
+
expect(out[1]?.content).toContain('second')
|
|
39
|
+
expect(out[1]?.content).toContain('check the tests too')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('labels the guidance as the operator speaking, not the tool', () => {
|
|
43
|
+
const channel = new SteeringBinding()
|
|
44
|
+
channel.steer('stop and ask me first')
|
|
45
|
+
|
|
46
|
+
const out = attachSteering([toolMessage('a', 'output')], channel)
|
|
47
|
+
|
|
48
|
+
// Unlabelled it would read as something `bash` said.
|
|
49
|
+
expect(out[0]?.content).toContain('[Message from the operator')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('accumulates repeated calls in order rather than replacing', () => {
|
|
53
|
+
const channel = new SteeringBinding()
|
|
54
|
+
channel.steer('first correction')
|
|
55
|
+
channel.steer('second correction')
|
|
56
|
+
|
|
57
|
+
const out = attachSteering([toolMessage('a', 'output')], channel)
|
|
58
|
+
|
|
59
|
+
const text = String(out[0]?.content)
|
|
60
|
+
expect(text.indexOf('first correction')).toBeLessThan(text.indexOf('second correction'))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('ignores empty and whitespace-only guidance', () => {
|
|
64
|
+
const channel = new SteeringBinding()
|
|
65
|
+
channel.steer(' ')
|
|
66
|
+
channel.steer('')
|
|
67
|
+
|
|
68
|
+
expect(channel.pending).toBe(false)
|
|
69
|
+
expect(attachSteering([toolMessage('a', 'output')], channel)[0]?.content).toBe('output')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('drains the channel, so guidance is delivered once', () => {
|
|
73
|
+
const channel = new SteeringBinding()
|
|
74
|
+
channel.steer('once')
|
|
75
|
+
|
|
76
|
+
attachSteering([toolMessage('a', 'output')], channel)
|
|
77
|
+
const second = attachSteering([toolMessage('b', 'later')], channel)
|
|
78
|
+
|
|
79
|
+
expect(channel.pending).toBe(false)
|
|
80
|
+
expect(second[0]?.content).toBe('later')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('keeps guidance queued when the batch has no tool result to carry it', () => {
|
|
84
|
+
const channel = new SteeringBinding()
|
|
85
|
+
channel.steer('for the next turn')
|
|
86
|
+
|
|
87
|
+
const out = attachSteering([assistantMessage('just text')], channel)
|
|
88
|
+
|
|
89
|
+
expect(out[0]?.content).toBe('just text')
|
|
90
|
+
// Not dropped. A turn that called no tools has nothing in flight, so
|
|
91
|
+
// the guidance belongs to the next one.
|
|
92
|
+
expect(channel.pending).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('leaves non-text tool content alone and re-queues rather than corrupting it', () => {
|
|
96
|
+
const channel = new SteeringBinding()
|
|
97
|
+
channel.steer('guidance')
|
|
98
|
+
const structured = {
|
|
99
|
+
role: 'tool',
|
|
100
|
+
content: [{ type: 'image', source: 'x' }],
|
|
101
|
+
toolCallId: 'a',
|
|
102
|
+
timestamp: 1,
|
|
103
|
+
} as unknown as Message
|
|
104
|
+
|
|
105
|
+
const out = attachSteering([structured], channel)
|
|
106
|
+
|
|
107
|
+
expect(out[0]?.content).toEqual([{ type: 'image', source: 'x' }])
|
|
108
|
+
expect(channel.pending).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('does nothing at all without a channel', () => {
|
|
112
|
+
const messages = [toolMessage('a', 'output')]
|
|
113
|
+
|
|
114
|
+
expect(attachSteering(messages, undefined)).toBe(messages)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('formats a note that names who is speaking', () => {
|
|
118
|
+
expect(formatSteeringNote('hello')).toContain('operator')
|
|
119
|
+
expect(formatSteeringNote('hello')).toContain('hello')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
recoverCompletedCalls,
|
|
99
99
|
unansweredToolCalls,
|
|
100
100
|
} from './resume-pending.js'
|
|
101
|
+
import type { SteeringChannel } from './steering.js'
|
|
101
102
|
import { ToolGrantSet } from './tool-grants.js'
|
|
102
103
|
import { createToolPause } from './tool-pause.js'
|
|
103
104
|
import { ToolingBootstrap } from './tooling.js'
|
|
@@ -151,6 +152,16 @@ export interface QueryParams {
|
|
|
151
152
|
*/
|
|
152
153
|
questionParks?: QuestionParkBinding
|
|
153
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Channel a host uses to hand guidance to the running turn.
|
|
157
|
+
*
|
|
158
|
+
* Optional and additive: absent leaves the loop byte-identical. Present,
|
|
159
|
+
* anything queued during a tool batch is appended to that batch's last
|
|
160
|
+
* tool result — the only slot a provider will accept text in mid-batch,
|
|
161
|
+
* and the one the model already reads for tool outcomes.
|
|
162
|
+
*/
|
|
163
|
+
steering?: SteeringChannel
|
|
164
|
+
|
|
154
165
|
/**
|
|
155
166
|
* The registry a re-entered `ask_user_question` reads its answer from.
|
|
156
167
|
*
|
|
@@ -827,6 +838,7 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
|
|
|
827
838
|
abortController: ctx.abortController,
|
|
828
839
|
log: ctx.log,
|
|
829
840
|
resumeHandler: params.resumeHandler,
|
|
841
|
+
...(params.steering ? { steering: params.steering } : {}),
|
|
830
842
|
checkpointMgr,
|
|
831
843
|
planManager: ctx.planManager,
|
|
832
844
|
taskGateway: params.taskGateway,
|
|
@@ -32,6 +32,7 @@ import type { CheckpointManager } from '../../checkpoint.js'
|
|
|
32
32
|
import type { EmitEvent } from '../../events.js'
|
|
33
33
|
import type { ToolExecutor } from '../../executor.js'
|
|
34
34
|
import type { GuardCoordinator } from '../../guard.js'
|
|
35
|
+
import type { SteeringChannel } from '../../steering.js'
|
|
35
36
|
import type { ToolGrantSet } from '../../tool-grants.js'
|
|
36
37
|
|
|
37
38
|
export interface LaunchedTaskMeta {
|
|
@@ -89,6 +90,14 @@ export interface IterationContext {
|
|
|
89
90
|
readonly abortController: AbortController
|
|
90
91
|
readonly log: Logger
|
|
91
92
|
readonly resumeHandler: ResumeHandler
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Guidance a host may hand to the turn while it runs.
|
|
96
|
+
*
|
|
97
|
+
* Absent means the loop behaves exactly as it always has — nothing is
|
|
98
|
+
* drained and no tool result is extended.
|
|
99
|
+
*/
|
|
100
|
+
readonly steering?: SteeringChannel
|
|
92
101
|
readonly checkpointMgr: CheckpointManager
|
|
93
102
|
readonly planManager: PlanManager
|
|
94
103
|
|
|
@@ -2,6 +2,7 @@ import type { ChatCompletionResponse } from '../../../../types/provider/index.js
|
|
|
2
2
|
import type { RunEvent } from '../../../../types/run/index.js'
|
|
3
3
|
import type { VerificationGate } from '../../../../verification/index.js'
|
|
4
4
|
import type { ToolCallDenials } from '../../executor.js'
|
|
5
|
+
import { attachSteering } from '../../steering.js'
|
|
5
6
|
import { type IterationContext, awaitDecisionDurably } from './context.js'
|
|
6
7
|
|
|
7
8
|
interface VerificationAwareContext extends IterationContext {
|
|
@@ -82,7 +83,12 @@ export async function* runToolReview(
|
|
|
82
83
|
const batch = await ctx.toolExecutor.executeBatch(response, denials)
|
|
83
84
|
toolMs += Date.now() - startedAt
|
|
84
85
|
executed = batch.results
|
|
85
|
-
|
|
86
|
+
// Guidance the host queued while this batch was running rides out on
|
|
87
|
+
// the last result. This is the only legal slot for it: a `tool_use`
|
|
88
|
+
// block must be answered by a `tool_result` with the same id, so a
|
|
89
|
+
// user message wedged between them is rejected by the provider. Same
|
|
90
|
+
// delivery a denial already uses, without the refusal.
|
|
91
|
+
for (const msg of attachSteering(batch.messages, ctx.steering)) {
|
|
86
92
|
ctx.runMgr.pushMessage(msg)
|
|
87
93
|
}
|
|
88
94
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Message } from '../../types/message/index.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Guidance a host hands to a turn that is already running.
|
|
5
|
+
*
|
|
6
|
+
* The gap this closes is narrow and was documented rather than fixed:
|
|
7
|
+
* `AgentManager` has had `queueMessage` / `drainMessages` for a while, and
|
|
8
|
+
* nothing in the iteration loop ever read them — the type says so in as many
|
|
9
|
+
* words. So a host watching a run go the wrong way had two options, and both
|
|
10
|
+
* are worse than they sound. Cancel and start over throws away every tool
|
|
11
|
+
* result the run had already paid for. Reject through the review gate only
|
|
12
|
+
* works if a tool call happens to be pending approval, and it says "no" when
|
|
13
|
+
* the host wanted to say "yes, but look at this first".
|
|
14
|
+
*
|
|
15
|
+
* **Why the text rides on a tool result rather than arriving as a user
|
|
16
|
+
* message.** A `tool_use` block must be answered by a `tool_result` with the
|
|
17
|
+
* same id — providers reject a user turn wedged between them — so there is no
|
|
18
|
+
* legal place to insert a message mid-batch at all. The slot that already
|
|
19
|
+
* exists is the tool result itself, and this codebase had already worked that
|
|
20
|
+
* out for a neighbouring case: a denied call carries its reason INSIDE the
|
|
21
|
+
* `tool_result`, and `executor.ts` notes that this is also what makes a
|
|
22
|
+
* rejection *steer*, because the model reads it in the slot it already
|
|
23
|
+
* attends to for tool outcomes. Steering is the same delivery with the
|
|
24
|
+
* refusal removed.
|
|
25
|
+
*
|
|
26
|
+
* **What it deliberately is not.** It does not interrupt. The batch in flight
|
|
27
|
+
* finishes, and the guidance lands where the model looks next. A host that
|
|
28
|
+
* wants the current work stopped wants `AbortSignal`, which is a different
|
|
29
|
+
* question with a different answer — and conflating the two is how "please
|
|
30
|
+
* also check the tests" ends up killing a half-written file.
|
|
31
|
+
*/
|
|
32
|
+
export interface SteeringChannel {
|
|
33
|
+
/**
|
|
34
|
+
* Queue guidance for the running turn.
|
|
35
|
+
*
|
|
36
|
+
* Repeated calls before the next drain accumulate in order rather than
|
|
37
|
+
* replacing each other: two corrections typed a second apart are two
|
|
38
|
+
* things the model should see, and keeping only the last one silently
|
|
39
|
+
* discards a host's instruction.
|
|
40
|
+
*
|
|
41
|
+
* Empty and whitespace-only text is ignored, so a stray keystroke does
|
|
42
|
+
* not append a blank line to a tool result.
|
|
43
|
+
*/
|
|
44
|
+
steer(text: string): void
|
|
45
|
+
|
|
46
|
+
/** Take everything queued, leaving the channel empty. */
|
|
47
|
+
drain(): string | undefined
|
|
48
|
+
|
|
49
|
+
/** True while guidance is queued and undelivered. */
|
|
50
|
+
readonly pending: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class SteeringBinding implements SteeringChannel {
|
|
54
|
+
private queued: string[] = []
|
|
55
|
+
|
|
56
|
+
steer(text: string): void {
|
|
57
|
+
const trimmed = text.trim()
|
|
58
|
+
if (!trimmed) return
|
|
59
|
+
this.queued.push(trimmed)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
drain(): string | undefined {
|
|
63
|
+
if (this.queued.length === 0) return undefined
|
|
64
|
+
const joined = this.queued.join('\n')
|
|
65
|
+
this.queued = []
|
|
66
|
+
return joined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get pending(): boolean {
|
|
70
|
+
return this.queued.length > 0
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The frame the guidance arrives in.
|
|
76
|
+
*
|
|
77
|
+
* Labelled because the model is being handed text from a party other than the
|
|
78
|
+
* tool whose result it is reading, in that tool's slot. Unlabelled, it reads
|
|
79
|
+
* as something the tool said — so a steer saying "stop and ask me first" would
|
|
80
|
+
* look like output from `bash`.
|
|
81
|
+
*
|
|
82
|
+
* This is NOT the untrusted-content envelope. The host operating the run is
|
|
83
|
+
* the one party whose words the agent SHOULD act on; framing them as material
|
|
84
|
+
* to be worked with rather than followed would inverting the very thing the
|
|
85
|
+
* host is trying to do. Different party, different frame, on purpose.
|
|
86
|
+
*/
|
|
87
|
+
export function formatSteeringNote(text: string): string {
|
|
88
|
+
return `\n\n[Message from the operator, received while this tool was running]\n${text}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Append the guidance to the last tool result in a settled batch.
|
|
93
|
+
*
|
|
94
|
+
* The LAST one, so it is the final thing the model reads before deciding what
|
|
95
|
+
* to do next — appending to the first would bury it under every later result.
|
|
96
|
+
*
|
|
97
|
+
* Returns the messages unchanged when there is nothing queued, and when the
|
|
98
|
+
* batch carries no tool result to attach to. The second case is not a failure
|
|
99
|
+
* to handle: a turn that called no tools has nothing in flight, so guidance
|
|
100
|
+
* belongs to the next turn and stays queued for it.
|
|
101
|
+
*/
|
|
102
|
+
export function attachSteering(
|
|
103
|
+
messages: readonly Message[],
|
|
104
|
+
channel: SteeringChannel | undefined,
|
|
105
|
+
): readonly Message[] {
|
|
106
|
+
if (!channel?.pending) return messages
|
|
107
|
+
|
|
108
|
+
let lastToolIndex = -1
|
|
109
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
110
|
+
if (messages[index]?.role === 'tool') {
|
|
111
|
+
lastToolIndex = index
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (lastToolIndex === -1) return messages
|
|
116
|
+
|
|
117
|
+
const guidance = channel.drain()
|
|
118
|
+
if (guidance === undefined) return messages
|
|
119
|
+
|
|
120
|
+
const target = messages[lastToolIndex] as Message
|
|
121
|
+
// Only text is extended. A tool that answered with structured content —
|
|
122
|
+
// an image block, say — has a shape the model reads positionally, and
|
|
123
|
+
// appending a string to it would either be dropped or corrupt the block.
|
|
124
|
+
// Such a result keeps its content and the note follows it as its own text
|
|
125
|
+
// part where the shape allows, and otherwise the guidance stays queued
|
|
126
|
+
// for the next turn rather than being forced into a slot it does not fit.
|
|
127
|
+
if (typeof target.content !== 'string') {
|
|
128
|
+
channel.steer(guidance)
|
|
129
|
+
return messages
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const next = [...messages]
|
|
133
|
+
next[lastToolIndex] = { ...target, content: target.content + formatSteeringNote(guidance) }
|
|
134
|
+
return next
|
|
135
|
+
}
|
|
@@ -30,10 +30,17 @@ export interface AgentManagerContract {
|
|
|
30
30
|
*
|
|
31
31
|
* Kept, and documented rather than deleted, because `drainMessages` is
|
|
32
32
|
* the only way a host can pick these up at all — removing it would take
|
|
33
|
-
* away the escape hatch and leave the trap.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* away the escape hatch and leave the trap.
|
|
34
|
+
*
|
|
35
|
+
* **For mid-run guidance, use `SteeringChannel` instead.** That is the
|
|
36
|
+
* delivery handshake this queue was missing: text queued on it is
|
|
37
|
+
* appended to the running batch's last `tool_result`, which is the only
|
|
38
|
+
* slot a provider accepts mid-batch, and the loop drains it. Pass one as
|
|
39
|
+
* `steering` on `drainQuery` params or on `SupervisorAgentConfig`.
|
|
40
|
+
*
|
|
41
|
+
* Two other routes also work: reject/modify feedback rides inside a tool
|
|
42
|
+
* result, and `prepareStep`'s `system` string is appended to the next
|
|
43
|
+
* model call from a hook that sees live history.
|
|
37
44
|
*/
|
|
38
45
|
continueTask(taskId: TaskId, message: string): Promise<void>
|
|
39
46
|
/** See {@link continueTask} — queued, not delivered. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CompactionConfig } from '../../config/runtime.js'
|
|
2
|
+
import type { SteeringChannel } from '../../runtime/query/steering.js'
|
|
2
3
|
import type { AdvisoryConfig } from '../advisory/index.js'
|
|
3
4
|
import type { ResumeHandler } from '../hitl/index.js'
|
|
4
5
|
import type { LLMProvider } from '../provider/index.js'
|
|
@@ -63,6 +64,15 @@ export interface SupervisorAgentConfig extends BaseAgentConfig {
|
|
|
63
64
|
*/
|
|
64
65
|
resumeHandler?: ResumeHandler
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Channel a host uses to hand guidance to the supervisor's running turn.
|
|
69
|
+
*
|
|
70
|
+
* Present here for the same reason `resumeHandler` is: a capability the
|
|
71
|
+
* kernel honours in `drainQuery` and not on the surface hosts actually
|
|
72
|
+
* construct is a capability nobody can reach.
|
|
73
|
+
*/
|
|
74
|
+
steering?: SteeringChannel
|
|
75
|
+
|
|
66
76
|
/**
|
|
67
77
|
* Optional declarative gate evaluated before tool execution. When
|
|
68
78
|
* the gate marks all calls in a batch as `allow`, they execute
|
|
@@ -10,6 +10,19 @@ export interface TokenUsage {
|
|
|
10
10
|
totalTokens: number
|
|
11
11
|
cachedTokens: number
|
|
12
12
|
cacheWriteTokens: number
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Output tokens the model spent on internal reasoning.
|
|
16
|
+
*
|
|
17
|
+
* A SUBSET of `completionTokens`, not an addition to it — reasoning is
|
|
18
|
+
* billed as output, so adding these to a total would double-count. Present
|
|
19
|
+
* so a caller can see what share of a turn went to thinking, which is the
|
|
20
|
+
* question budgeting and cost attribution actually ask.
|
|
21
|
+
*
|
|
22
|
+
* Optional because most drivers do not report it. Absent means unknown,
|
|
23
|
+
* not zero.
|
|
24
|
+
*/
|
|
25
|
+
reasoningTokens?: number
|
|
13
26
|
}
|
|
14
27
|
|
|
15
28
|
export function accumulateTokenUsage(current: TokenUsage, addition: TokenUsage): TokenUsage {
|
|
@@ -19,6 +32,13 @@ export function accumulateTokenUsage(current: TokenUsage, addition: TokenUsage):
|
|
|
19
32
|
totalTokens: current.totalTokens + addition.totalTokens,
|
|
20
33
|
cachedTokens: current.cachedTokens + addition.cachedTokens,
|
|
21
34
|
cacheWriteTokens: current.cacheWriteTokens + addition.cacheWriteTokens,
|
|
35
|
+
// Summed only when at least one side reported it. Coercing absent to
|
|
36
|
+
// zero would turn 'this driver does not tell us' into 'it spent none',
|
|
37
|
+
// and a run mixing a reporting driver with a silent one would read as
|
|
38
|
+
// though the silent turns did no thinking.
|
|
39
|
+
...(current.reasoningTokens !== undefined || addition.reasoningTokens !== undefined
|
|
40
|
+
? { reasoningTokens: (current.reasoningTokens ?? 0) + (addition.reasoningTokens ?? 0) }
|
|
41
|
+
: {}),
|
|
22
42
|
}
|
|
23
43
|
}
|
|
24
44
|
|
|
@@ -76,19 +76,75 @@ export interface ChatCompletionParams {
|
|
|
76
76
|
* Drivers that do not support it ignore the field.
|
|
77
77
|
*/
|
|
78
78
|
thinking?: ThinkingConfig
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* How much work to put into the response. See {@link ReasoningEffort}.
|
|
82
|
+
*
|
|
83
|
+
* Drivers that do not support it ignore the field.
|
|
84
|
+
*/
|
|
85
|
+
effort?: ReasoningEffort
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
export interface ThinkingConfig {
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Which thinking mode to ask for.
|
|
91
|
+
*
|
|
92
|
+
* `'adaptive'` lets the model decide whether and how deeply to think per
|
|
93
|
+
* request; depth is steered by {@link ChatCompletionParams.effort} rather
|
|
94
|
+
* than a token budget. `'enabled'` is the older manual mode, where
|
|
95
|
+
* {@link budgetTokens} fixes the depth and the model thinks on every
|
|
96
|
+
* request.
|
|
97
|
+
*
|
|
98
|
+
* **These are not interchangeable, and a driver must not guess.** Vendors
|
|
99
|
+
* reject the wrong one for a given model outright rather than degrading:
|
|
100
|
+
* newer models refuse `'enabled'`, older ones refuse `'adaptive'`, and
|
|
101
|
+
* some refuse `'disabled'` because they cannot stop thinking at all. A
|
|
102
|
+
* driver that sends a mode the model does not accept produces a failed
|
|
103
|
+
* request, not a worse answer — which is why this is a declared intent
|
|
104
|
+
* that each driver resolves against the model it is about to call.
|
|
105
|
+
*/
|
|
106
|
+
type: 'adaptive' | 'enabled' | 'disabled'
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Token allowance for the thinking pass. Manual mode only — the adaptive
|
|
110
|
+
* mode has no budget, and depth is set by `effort`.
|
|
111
|
+
*/
|
|
84
112
|
budgetTokens?: number
|
|
113
|
+
|
|
85
114
|
/**
|
|
86
|
-
* Whether the
|
|
87
|
-
*
|
|
115
|
+
* Whether the thinking text comes back or only its signature.
|
|
116
|
+
*
|
|
117
|
+
* `'omitted'` returns the blocks with an empty body and a signature, which
|
|
118
|
+
* is enough to replay them on the next turn (see `replayReasoning`) while
|
|
119
|
+
* keeping the text out of the response. `'summarized'` returns a summary
|
|
120
|
+
* of the reasoning.
|
|
121
|
+
*
|
|
122
|
+
* Worth setting explicitly. This defaults to `'omitted'` on newer models,
|
|
123
|
+
* so a caller that wants to show reasoning and does not ask for it gets
|
|
124
|
+
* thinking blocks whose text is empty and no indication why.
|
|
125
|
+
*
|
|
126
|
+
* The values were `'full' | 'summarized'` here, and `'full'` was never a
|
|
127
|
+
* value any vendor accepted — a declared option that could only ever have
|
|
128
|
+
* been rejected, next to a real one that was missing.
|
|
88
129
|
*/
|
|
89
|
-
display?: '
|
|
130
|
+
display?: 'summarized' | 'omitted'
|
|
90
131
|
}
|
|
91
132
|
|
|
133
|
+
/**
|
|
134
|
+
* How much work the model should put into a response.
|
|
135
|
+
*
|
|
136
|
+
* A sibling of {@link ChatCompletionParams.thinking}, not a field inside it,
|
|
137
|
+
* because it is not exclusively a thinking control: it shapes the whole
|
|
138
|
+
* response, and at least one manual-mode model accepts it alongside a token
|
|
139
|
+
* budget, where effort shapes the answer and the budget sets thinking depth.
|
|
140
|
+
* Nesting it under `thinking` would have made that combination unsayable.
|
|
141
|
+
*
|
|
142
|
+
* In adaptive mode it is the primary depth lever — low effort may skip
|
|
143
|
+
* thinking entirely on easy input. In manual mode `budgetTokens` sets depth
|
|
144
|
+
* and effort does not move it.
|
|
145
|
+
*/
|
|
146
|
+
export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
|
|
147
|
+
|
|
92
148
|
export interface ChatCompletionResponse {
|
|
93
149
|
id: string
|
|
94
150
|
model: string
|