@namzu/sdk 4.0.0 → 5.1.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 +282 -0
- package/README.md +2 -2
- 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/__tests__/typed-error-classification.test.d.ts +2 -0
- package/dist/provider/__tests__/typed-error-classification.test.d.ts.map +1 -0
- package/dist/provider/__tests__/typed-error-classification.test.js +123 -0
- package/dist/provider/__tests__/typed-error-classification.test.js.map +1 -0
- package/dist/provider/retry.d.ts.map +1 -1
- package/dist/provider/retry.js +23 -7
- package/dist/provider/retry.js.map +1 -1
- 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-tools.d.ts +1 -0
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +4 -0
- package/dist/public-tools.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/__tests__/stream-recovery.test.js +5 -0
- package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
- 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/errors.d.ts +0 -17
- package/dist/types/provider/errors.d.ts.map +1 -1
- package/dist/types/provider/errors.js +60 -0
- package/dist/types/provider/errors.js.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/__tests__/typed-error-classification.test.ts +143 -0
- package/src/provider/retry.ts +23 -7
- package/src/provider/thinking-support.ts +37 -0
- package/src/public-runtime.ts +10 -0
- package/src/public-tools.ts +4 -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/__tests__/stream-recovery.test.ts +5 -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/errors.ts +71 -0
- package/src/types/provider/index.ts +1 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { assertThinkingUnsupported } from '../thinking-support.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `thinking` sits on `ChatCompletionParams`, so every driver accepts it. Six
|
|
7
|
+
* of the seven in this repo did not implement it, and five of those six simply
|
|
8
|
+
* dropped the field: the caller got an ordinary completion with an empty
|
|
9
|
+
* `reasoning` array, indistinguishable from a model that chose not to reason.
|
|
10
|
+
*
|
|
11
|
+
* One driver already refused instead, with the reasoning written out. The rule
|
|
12
|
+
* was decided once and applied once — so it moved here, where a new driver
|
|
13
|
+
* inherits it rather than re-deciding it.
|
|
14
|
+
*/
|
|
15
|
+
describe('a driver that cannot think says so', () => {
|
|
16
|
+
it('refuses a manual thinking request', () => {
|
|
17
|
+
expect(() =>
|
|
18
|
+
assertThinkingUnsupported('TestProvider', { thinking: { type: 'enabled' } }),
|
|
19
|
+
).toThrow(/TestProvider does not implement thinking/)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('refuses an adaptive one too', () => {
|
|
23
|
+
// Both are a request to think. Refusing one and dropping the other
|
|
24
|
+
// would leave exactly the silence this exists to remove.
|
|
25
|
+
expect(() =>
|
|
26
|
+
assertThinkingUnsupported('TestProvider', { thinking: { type: 'adaptive' } }),
|
|
27
|
+
).toThrow(/does not implement thinking/)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('names the driver, not just the problem', () => {
|
|
31
|
+
// In a multi-provider setup this is the difference between a bug
|
|
32
|
+
// report about the model and a one-line config fix.
|
|
33
|
+
expect(() =>
|
|
34
|
+
assertThinkingUnsupported('BedrockProvider', { thinking: { type: 'adaptive' } }),
|
|
35
|
+
).toThrow(/BedrockProvider/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('says what silence would have looked like', () => {
|
|
39
|
+
let message = ''
|
|
40
|
+
try {
|
|
41
|
+
assertThinkingUnsupported('TestProvider', { thinking: { type: 'enabled' } })
|
|
42
|
+
} catch (err) {
|
|
43
|
+
message = (err as Error).message
|
|
44
|
+
}
|
|
45
|
+
expect(message).toContain('empty reasoning list')
|
|
46
|
+
expect(message).toContain('Drop `thinking`')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('honours an explicit disable as a no-op', () => {
|
|
50
|
+
// A config shared across providers that says "do not think" should not
|
|
51
|
+
// fail on the ones that were never going to.
|
|
52
|
+
expect(() =>
|
|
53
|
+
assertThinkingUnsupported('TestProvider', { thinking: { type: 'disabled' } }),
|
|
54
|
+
).not.toThrow()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('does nothing when the caller said nothing', () => {
|
|
58
|
+
expect(() => assertThinkingUnsupported('TestProvider', {})).not.toThrow()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { classifyProviderError } from '../../types/provider/errors.js'
|
|
4
|
+
import type { LLMProvider, StreamChunk } from '../../types/provider/index.js'
|
|
5
|
+
import { ProviderRequestError } from '../errors.js'
|
|
6
|
+
import { withProviderRetry } from '../retry.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A driver that classified its own failure was coming out WORSE than one that
|
|
10
|
+
* did not, in two independent places, and both shipped.
|
|
11
|
+
*
|
|
12
|
+
* `classifyProviderError` never read `kind`. A `ProviderRequestError` fell
|
|
13
|
+
* through to the status heuristics, where a carefully-diagnosed
|
|
14
|
+
* `context_overflow` carrying a 400 became `invalid_request` — so the run
|
|
15
|
+
* loop's overflow branch, which tests for `context_length_exceeded`, could
|
|
16
|
+
* never fire for a first-party driver, and compaction relief was unreachable
|
|
17
|
+
* for exactly the drivers that had diagnosed the problem correctly.
|
|
18
|
+
*
|
|
19
|
+
* And `withProviderRetry` rethrew any such error before the retry loop. Its
|
|
20
|
+
* comment justified preserving the classification, which is right; the code
|
|
21
|
+
* also skipped retrying, which is a different decision that nobody made.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function providerThatFails(err: unknown): { provider: LLMProvider; calls: () => number } {
|
|
25
|
+
let calls = 0
|
|
26
|
+
const provider = {
|
|
27
|
+
id: 'test',
|
|
28
|
+
name: 'Test',
|
|
29
|
+
async *chatStream(): AsyncIterable<StreamChunk> {
|
|
30
|
+
calls++
|
|
31
|
+
throw err
|
|
32
|
+
// biome-ignore lint/correctness/useYield: it fails before producing anything
|
|
33
|
+
},
|
|
34
|
+
} as unknown as LLMProvider
|
|
35
|
+
return { provider, calls: () => calls }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const typed = (kind: string, status: number) =>
|
|
39
|
+
new ProviderRequestError({ kind, providerId: 'test', status, message: `a ${kind}` } as never)
|
|
40
|
+
|
|
41
|
+
describe('a driver that classified its own failure is believed', () => {
|
|
42
|
+
const cases: readonly [string, number, string, boolean][] = [
|
|
43
|
+
['throttle', 429, 'rate_limit', true],
|
|
44
|
+
['server', 500, 'server_error', true],
|
|
45
|
+
['network', 0, 'network', true],
|
|
46
|
+
['auth', 401, 'auth', false],
|
|
47
|
+
['bad_request', 400, 'invalid_request', false],
|
|
48
|
+
// The one that mattered most: a 400 whose kind says the prompt was too
|
|
49
|
+
// long is not a bad request, and the difference decides whether the
|
|
50
|
+
// kernel reaches for compaction.
|
|
51
|
+
['context_overflow', 400, 'context_length_exceeded', false],
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
for (const [kind, status, code, retryable] of cases) {
|
|
55
|
+
it(`maps kind "${kind}" to ${code}`, () => {
|
|
56
|
+
const classified = classifyProviderError(typed(kind, status), 'test')
|
|
57
|
+
expect(classified.code).toBe(code)
|
|
58
|
+
expect(classified.retryable).toBe(retryable)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it('keeps the driver-supplied status and provider id', () => {
|
|
63
|
+
const classified = classifyProviderError(typed('throttle', 429), 'other')
|
|
64
|
+
expect(classified.status).toBe(429)
|
|
65
|
+
expect(classified.providerId).toBe('test')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('a classified failure still goes through the retry loop', () => {
|
|
70
|
+
it('retries a typed throttle', async () => {
|
|
71
|
+
// This is the regression: a first-party driver reporting a 429 as
|
|
72
|
+
// `kind: 'throttle'` used to get exactly one attempt, while the same
|
|
73
|
+
// failure from a driver that classified nothing got the full backoff.
|
|
74
|
+
const { provider, calls } = providerThatFails(typed('throttle', 429))
|
|
75
|
+
const wrapped = withProviderRetry(provider, {
|
|
76
|
+
config: { maxRetries: 2 },
|
|
77
|
+
sleepFn: async () => {},
|
|
78
|
+
random: () => 0,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await expect(async () => {
|
|
82
|
+
for await (const _ of wrapped.chatStream({} as never)) {
|
|
83
|
+
// drain
|
|
84
|
+
}
|
|
85
|
+
}).rejects.toThrow()
|
|
86
|
+
|
|
87
|
+
expect(calls()).toBe(3)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('does not retry a typed auth failure', async () => {
|
|
91
|
+
const { provider, calls } = providerThatFails(typed('auth', 401))
|
|
92
|
+
const wrapped = withProviderRetry(provider, {
|
|
93
|
+
config: { maxRetries: 2 },
|
|
94
|
+
sleepFn: async () => {},
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
await expect(async () => {
|
|
98
|
+
for await (const _ of wrapped.chatStream({} as never)) {
|
|
99
|
+
// drain
|
|
100
|
+
}
|
|
101
|
+
}).rejects.toThrow()
|
|
102
|
+
|
|
103
|
+
expect(calls()).toBe(1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('does not retry a typed context overflow', async () => {
|
|
107
|
+
// Correctly non-retryable — an identical prompt overflows identically.
|
|
108
|
+
// The remedy is compaction, which the run loop reaches for once the
|
|
109
|
+
// code is `context_length_exceeded`.
|
|
110
|
+
const { provider, calls } = providerThatFails(typed('context_overflow', 400))
|
|
111
|
+
const wrapped = withProviderRetry(provider, {
|
|
112
|
+
config: { maxRetries: 2 },
|
|
113
|
+
sleepFn: async () => {},
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
await expect(async () => {
|
|
117
|
+
for await (const _ of wrapped.chatStream({} as never)) {
|
|
118
|
+
// drain
|
|
119
|
+
}
|
|
120
|
+
// The ORIGINAL escapes, so the boundary still sees the driver's
|
|
121
|
+
// own kind rather than a wrapper's code.
|
|
122
|
+
}).rejects.toMatchObject({ kind: 'context_overflow', status: 400 })
|
|
123
|
+
|
|
124
|
+
expect(calls()).toBe(1)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('leaves an abort alone', async () => {
|
|
128
|
+
const abort = Object.assign(new Error('aborted'), { name: 'AbortError' })
|
|
129
|
+
const { provider, calls } = providerThatFails(abort)
|
|
130
|
+
const wrapped = withProviderRetry(provider, {
|
|
131
|
+
config: { maxRetries: 2 },
|
|
132
|
+
sleepFn: async () => {},
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
await expect(async () => {
|
|
136
|
+
for await (const _ of wrapped.chatStream({} as never)) {
|
|
137
|
+
// drain
|
|
138
|
+
}
|
|
139
|
+
}).rejects.toThrow(/aborted/)
|
|
140
|
+
|
|
141
|
+
expect(calls()).toBe(1)
|
|
142
|
+
})
|
|
143
|
+
})
|
package/src/provider/retry.ts
CHANGED
|
@@ -111,12 +111,19 @@ export function withProviderRetry(
|
|
|
111
111
|
return
|
|
112
112
|
} catch (err) {
|
|
113
113
|
if (isAbortError(err) || params.signal?.aborted) throw err
|
|
114
|
-
// A driver that already classified its own failure
|
|
115
|
-
//
|
|
116
|
-
// first
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
114
|
+
// A driver that already classified its own failure keeps that
|
|
115
|
+
// classification — `classifyProviderError` reads its `kind`
|
|
116
|
+
// first and does not re-guess.
|
|
117
|
+
//
|
|
118
|
+
// This used to rethrow such an error outright. The stated reason
|
|
119
|
+
// was sound and the code did more than it said: preserving a
|
|
120
|
+
// first-hand classification is one thing, and skipping the retry
|
|
121
|
+
// loop is another. A first-party driver that correctly reported
|
|
122
|
+
// a 429 as `kind: 'throttle'` got ZERO attempts, while the same
|
|
123
|
+
// failure from a driver that classified nothing got the full
|
|
124
|
+
// backoff — so diagnosing your own error was punished. Whether
|
|
125
|
+
// to retry is now decided the same way for both, by the
|
|
126
|
+
// classification's own `retryable`.
|
|
120
127
|
const classified = classifyProviderError(err, provider.id)
|
|
121
128
|
const exhausted = attempt >= config.maxRetries
|
|
122
129
|
|
|
@@ -133,7 +140,16 @@ export function withProviderRetry(
|
|
|
133
140
|
? 'retries exhausted'
|
|
134
141
|
: 'not retryable',
|
|
135
142
|
})
|
|
136
|
-
|
|
143
|
+
// The ORIGINAL escapes when the driver classified it. Two
|
|
144
|
+
// different consumers want two different things and both are
|
|
145
|
+
// right: this loop needs a retryable verdict, which the
|
|
146
|
+
// classification supplies, and the run boundary reports
|
|
147
|
+
// `lastProviderError` as the driver's own `{kind, status,
|
|
148
|
+
// retryAfterMs}`, which only survives if the error itself
|
|
149
|
+
// does. Wrapping here would have kept the retry fix and lost
|
|
150
|
+
// the vendor's `kind` at the boundary — the existing
|
|
151
|
+
// stream-recovery test caught exactly that.
|
|
152
|
+
throw isProviderRequestError(err) ? err : classified
|
|
137
153
|
}
|
|
138
154
|
|
|
139
155
|
const serverDirected = classified.retryAfterMs
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ThinkingConfig } from '../types/provider/index.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Refuse a thinking request a driver does not implement.
|
|
5
|
+
*
|
|
6
|
+
* The failure this prevents is silence. `thinking` sits on
|
|
7
|
+
* `ChatCompletionParams`, so every driver accepts it; a driver that does not
|
|
8
|
+
* implement it drops the field and returns an ordinary completion with an
|
|
9
|
+
* empty `reasoning` array. The caller cannot tell that apart from a model that
|
|
10
|
+
* simply chose not to reason — the request looks honoured and the answer looks
|
|
11
|
+
* like an answer.
|
|
12
|
+
*
|
|
13
|
+
* Refusing names the driver instead, which is the difference between a bug
|
|
14
|
+
* report about the model and a one-line configuration fix.
|
|
15
|
+
*
|
|
16
|
+
* **Turning thinking OFF is honoured as a no-op**, because that is the state a
|
|
17
|
+
* driver without thinking is already in. A config shared across providers that
|
|
18
|
+
* says `{type: 'disabled'}` should not fail on the ones that were never going
|
|
19
|
+
* to think.
|
|
20
|
+
*
|
|
21
|
+
* This lived as a private copy inside one driver while five others dropped the
|
|
22
|
+
* field silently. It is here so a new driver inherits the rule instead of
|
|
23
|
+
* re-deciding it.
|
|
24
|
+
*
|
|
25
|
+
* @param driverName Named in the error, so the reader knows which provider in
|
|
26
|
+
* a multi-provider setup refused.
|
|
27
|
+
*/
|
|
28
|
+
export function assertThinkingUnsupported(
|
|
29
|
+
driverName: string,
|
|
30
|
+
params: { thinking?: ThinkingConfig },
|
|
31
|
+
): void {
|
|
32
|
+
const type = params.thinking?.type
|
|
33
|
+
if (type !== 'enabled' && type !== 'adaptive') return
|
|
34
|
+
throw new Error(
|
|
35
|
+
`${driverName} does not implement thinking. Silently ignoring the request would return an ordinary completion with an empty reasoning list, which reads as "the model did not reason" rather than "this driver cannot ask it to". Drop \`thinking\`, or use a driver that implements it.`,
|
|
36
|
+
)
|
|
37
|
+
}
|
package/src/public-runtime.ts
CHANGED
|
@@ -67,7 +67,14 @@ export { extractFinalResponse } from './utils/conversation.js'
|
|
|
67
67
|
// ─── router, runtime, run ────────────────────────────────────────────────
|
|
68
68
|
|
|
69
69
|
export { resolveTaskModel } from './router/task-router.js'
|
|
70
|
+
// Every driver accepts `thinking`; one that does not implement it must
|
|
71
|
+
// refuse rather than drop it. Shared so a new driver inherits the rule.
|
|
72
|
+
export { assertThinkingUnsupported } from './provider/thinking-support.js'
|
|
70
73
|
export { drainQuery, query } from './runtime/query/index.js'
|
|
74
|
+
// Mid-run guidance. A host holds the channel and the loop drains it at the
|
|
75
|
+
// tool-result boundary; see the module for why that is the only legal slot.
|
|
76
|
+
export { SteeringBinding, attachSteering, formatSteeringNote } from './runtime/query/steering.js'
|
|
77
|
+
export type { SteeringChannel } from './runtime/query/steering.js'
|
|
71
78
|
export { createMockBidiProvider, startBidiRun } from './runtime/bidi/index.js'
|
|
72
79
|
export { ContextCache } from './runtime/query/context-cache.js'
|
|
73
80
|
export {
|
|
@@ -135,6 +142,9 @@ export {
|
|
|
135
142
|
PipelineAgent,
|
|
136
143
|
ReactiveAgent,
|
|
137
144
|
RouterAgent,
|
|
145
|
+
// The short path: provider + model + prompt. Assembles the identity and
|
|
146
|
+
// budgets `drainQuery` requires and hands the generated identity back.
|
|
147
|
+
runAgent,
|
|
138
148
|
SupervisorAgent,
|
|
139
149
|
} from './agents/index.js'
|
|
140
150
|
|
package/src/public-tools.ts
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
// ─── Tool definition primitive ───────────────────────────────────────────
|
|
13
13
|
|
|
14
14
|
export { defineTool } from './tools/defineTool.js'
|
|
15
|
+
// Containment, for a host or sibling package that resolves caller-supplied
|
|
16
|
+
// paths against a root. Both were internal while three call sites outside
|
|
17
|
+
// this file needed them.
|
|
18
|
+
export { isWithin, resolveWithin, resolveWithinReal } from './tools/paths.js'
|
|
15
19
|
// A host that surfaces its own untrusted content to a model needs the same
|
|
16
20
|
// framing the kernel applies to connector prompts and delegated results.
|
|
17
21
|
export { neutralizeEnvelopeDelimiter, wrapUntrusted } from './tools/untrusted-envelope.js'
|
package/src/public-types.ts
CHANGED
|
@@ -99,7 +99,13 @@ export type { ContextCacheConfig, PromptCacheInput } from './runtime/query/conte
|
|
|
99
99
|
|
|
100
100
|
export type { LimitCheckResult, LimitCheckerState, RunReporter } from './run/index.js'
|
|
101
101
|
|
|
102
|
-
export type {
|
|
102
|
+
export type {
|
|
103
|
+
AgentIdentity,
|
|
104
|
+
DefineAgentOptions,
|
|
105
|
+
Disposable,
|
|
106
|
+
RunAgentOptions,
|
|
107
|
+
RunAgentResult,
|
|
108
|
+
} from './agents/index.js'
|
|
103
109
|
|
|
104
110
|
export type {
|
|
105
111
|
ActivityEvent,
|
|
@@ -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
|
+
})
|
|
@@ -182,6 +182,11 @@ describe('query stream recovery', () => {
|
|
|
182
182
|
const run = await drainQuery(
|
|
183
183
|
{
|
|
184
184
|
provider: new ClassifiedFailureProvider(),
|
|
185
|
+
// Retry off: this pins METADATA at the boundary, and a throttle
|
|
186
|
+
// is genuinely retryable — leaving retry on would spend the run's
|
|
187
|
+
// whole timeout backing off and settle it as a timeout instead,
|
|
188
|
+
// testing the retry policy rather than the thing named here.
|
|
189
|
+
retry: { maxRetries: 0 },
|
|
185
190
|
tools: new ToolRegistry(),
|
|
186
191
|
runConfig: {
|
|
187
192
|
model: 'mock-model',
|
|
@@ -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
|
}
|