@namzu/sdk 1.3.0 → 1.4.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 +87 -0
- package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts +12 -0
- package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts.map +1 -0
- package/dist/compaction/__tests__/verifier-empty-reply.test.js +73 -0
- package/dist/compaction/__tests__/verifier-empty-reply.test.js.map +1 -0
- package/dist/compaction/verifier.d.ts.map +1 -1
- package/dist/compaction/verifier.js +10 -1
- package/dist/compaction/verifier.js.map +1 -1
- package/dist/config/__tests__/compaction-budget-schema.test.d.ts +18 -0
- package/dist/config/__tests__/compaction-budget-schema.test.d.ts.map +1 -0
- package/dist/config/__tests__/compaction-budget-schema.test.js +96 -0
- package/dist/config/__tests__/compaction-budget-schema.test.js.map +1 -0
- package/dist/config/runtime.d.ts.map +1 -1
- package/dist/config/runtime.js +20 -11
- package/dist/config/runtime.js.map +1 -1
- package/dist/manager/run/persistence.d.ts +2 -1
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +3 -1
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/provider/__tests__/errors.test.d.ts +2 -0
- package/dist/provider/__tests__/errors.test.d.ts.map +1 -0
- package/dist/provider/__tests__/errors.test.js +61 -0
- package/dist/provider/__tests__/errors.test.js.map +1 -0
- package/dist/provider/errors.d.ts +108 -0
- package/dist/provider/errors.d.ts.map +1 -0
- package/dist/provider/errors.js +311 -0
- package/dist/provider/errors.js.map +1 -0
- package/dist/provider/index.d.ts +2 -0
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +1 -0
- package/dist/provider/index.js.map +1 -1
- package/dist/public-runtime.d.ts +11 -11
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +11 -11
- package/dist/public-runtime.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +5 -2
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/registry/tool/execute.test.js +11 -0
- package/dist/registry/tool/execute.test.js.map +1 -1
- package/dist/runtime/query/__tests__/stream-recovery.test.js +54 -1
- package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts +32 -0
- package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js +152 -0
- package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +52 -12
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.js +11 -5
- package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
- package/dist/runtime/query/result.d.ts.map +1 -1
- package/dist/runtime/query/result.js +11 -1
- package/dist/runtime/query/result.js.map +1 -1
- package/dist/tools/builtins/__tests__/edit.test.js +54 -0
- package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
- package/dist/tools/builtins/edit.d.ts +1 -1
- package/dist/tools/builtins/edit.d.ts.map +1 -1
- package/dist/tools/builtins/edit.js +11 -13
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/tools/coordinator/__tests__/task-list.test.js +14 -0
- package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
- package/dist/tools/coordinator/agent.d.ts +6 -8
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- 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 +11 -32
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/defineTool.d.ts +1 -0
- package/dist/tools/defineTool.d.ts.map +1 -1
- package/dist/tools/defineTool.js +1 -0
- package/dist/tools/defineTool.js.map +1 -1
- package/dist/types/provider/error.d.ts +22 -0
- package/dist/types/provider/error.d.ts.map +1 -0
- package/dist/types/provider/error.js +2 -0
- package/dist/types/provider/error.js.map +1 -0
- package/dist/types/provider/index.d.ts +1 -0
- package/dist/types/provider/index.d.ts.map +1 -1
- package/dist/types/run/entity.d.ts +2 -0
- package/dist/types/run/entity.d.ts.map +1 -1
- package/dist/types/run/events.d.ts +2 -0
- package/dist/types/run/events.d.ts.map +1 -1
- package/dist/types/run/events.js.map +1 -1
- package/dist/types/tool/index.d.ts +6 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/compaction/__tests__/verifier-empty-reply.test.ts +96 -0
- package/src/compaction/verifier.ts +10 -1
- package/src/config/__tests__/compaction-budget-schema.test.ts +114 -0
- package/src/config/runtime.ts +20 -11
- package/src/manager/run/persistence.ts +3 -1
- package/src/provider/__tests__/errors.test.ts +85 -0
- package/src/provider/errors.ts +344 -0
- package/src/provider/index.ts +15 -0
- package/src/public-runtime.ts +53 -10
- package/src/registry/tool/execute.test.ts +14 -0
- package/src/registry/tool/execute.ts +5 -2
- package/src/runtime/query/__tests__/stream-recovery.test.ts +64 -1
- package/src/runtime/query/iteration/phases/compaction-safe-cut.test.ts +186 -0
- package/src/runtime/query/iteration/phases/compaction.ts +54 -12
- package/src/runtime/query/iteration/stream-turn.ts +11 -6
- package/src/runtime/query/result.ts +11 -1
- package/src/tools/builtins/__tests__/edit.test.ts +76 -0
- package/src/tools/builtins/edit.ts +12 -12
- package/src/tools/coordinator/__tests__/task-list.test.ts +16 -0
- package/src/tools/coordinator/agent.ts +6 -8
- package/src/tools/coordinator/index.ts +11 -34
- package/src/tools/defineTool.ts +2 -0
- package/src/types/provider/error.ts +29 -0
- package/src/types/provider/index.ts +5 -0
- package/src/types/run/entity.ts +2 -0
- package/src/types/run/events.ts +7 -1
- package/src/types/tool/index.ts +6 -0
|
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
5
|
import { z } from 'zod'
|
|
6
6
|
|
|
7
|
+
import { ProviderRequestError } from '../../../provider/errors.js'
|
|
7
8
|
import { ToolRegistry } from '../../../registry/tool/execute.js'
|
|
8
9
|
import type { SessionId, TenantId } from '../../../types/ids/index.js'
|
|
9
10
|
import { createUserMessage } from '../../../types/message/index.js'
|
|
@@ -72,6 +73,22 @@ class IdleDuringToolInputProvider implements LLMProvider {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
class ClassifiedFailureProvider implements LLMProvider {
|
|
77
|
+
readonly id = 'classified-failure'
|
|
78
|
+
readonly name = 'Classified Failure Provider'
|
|
79
|
+
|
|
80
|
+
async *chatStream(): AsyncIterable<StreamChunk> {
|
|
81
|
+
yield await Promise.reject(
|
|
82
|
+
new ProviderRequestError({
|
|
83
|
+
kind: 'throttle',
|
|
84
|
+
providerId: 'classified-failure',
|
|
85
|
+
status: 429,
|
|
86
|
+
retryAfterMs: 2000,
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
75
92
|
describe('query stream recovery', () => {
|
|
76
93
|
let workdirs: string[] = []
|
|
77
94
|
|
|
@@ -82,7 +99,10 @@ describe('query stream recovery', () => {
|
|
|
82
99
|
|
|
83
100
|
it('turns an idle stream with partial tool JSON into retryable tool feedback', async () => {
|
|
84
101
|
const provider = new IdleDuringToolInputProvider()
|
|
85
|
-
const actualWrite = vi.fn(async () => ({
|
|
102
|
+
const actualWrite = vi.fn(async () => ({
|
|
103
|
+
success: true,
|
|
104
|
+
output: 'should not run',
|
|
105
|
+
}))
|
|
86
106
|
const tools = new ToolRegistry()
|
|
87
107
|
tools.register({
|
|
88
108
|
name: 'write_file',
|
|
@@ -153,4 +173,47 @@ describe('query stream recovery', () => {
|
|
|
153
173
|
'extend it with edit using insertLine',
|
|
154
174
|
)
|
|
155
175
|
})
|
|
176
|
+
|
|
177
|
+
it('preserves classified provider metadata through the primary run boundary', async () => {
|
|
178
|
+
const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-provider-error-'))
|
|
179
|
+
workdirs.push(workingDirectory)
|
|
180
|
+
const events: RunEvent[] = []
|
|
181
|
+
|
|
182
|
+
const run = await drainQuery(
|
|
183
|
+
{
|
|
184
|
+
provider: new ClassifiedFailureProvider(),
|
|
185
|
+
tools: new ToolRegistry(),
|
|
186
|
+
runConfig: {
|
|
187
|
+
model: 'mock-model',
|
|
188
|
+
timeoutMs: 5_000,
|
|
189
|
+
tokenBudget: 100_000,
|
|
190
|
+
maxIterations: 1,
|
|
191
|
+
maxResponseTokens: 256,
|
|
192
|
+
},
|
|
193
|
+
agentId: 'agent_test',
|
|
194
|
+
agentName: 'Test Agent',
|
|
195
|
+
messages: [createUserMessage('fail with classified metadata')],
|
|
196
|
+
workingDirectory,
|
|
197
|
+
sessionId: 'ses_provider_error' as SessionId,
|
|
198
|
+
threadId: 'thd_provider_error' as ThreadId,
|
|
199
|
+
projectId: 'prj_provider_error' as ProjectId,
|
|
200
|
+
tenantId: 'tnt_provider_error' as TenantId,
|
|
201
|
+
},
|
|
202
|
+
(event) => {
|
|
203
|
+
events.push(event)
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
expect(run.status).toBe('failed')
|
|
208
|
+
expect(run.lastProviderError).toEqual({
|
|
209
|
+
kind: 'throttle',
|
|
210
|
+
providerId: 'classified-failure',
|
|
211
|
+
status: 429,
|
|
212
|
+
retryAfterMs: 2000,
|
|
213
|
+
})
|
|
214
|
+
expect(events.find((event) => event.type === 'run_failed')).toMatchObject({
|
|
215
|
+
type: 'run_failed',
|
|
216
|
+
providerError: run.lastProviderError,
|
|
217
|
+
})
|
|
218
|
+
})
|
|
156
219
|
})
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UNBOUNDED CUT.
|
|
3
|
+
*
|
|
4
|
+
* `runCompactionCheck` snaps the naive recent-window boundary
|
|
5
|
+
* (`messages.length - keepRecentMessages`) FORWARD via `findSafeTrimIndex` so a
|
|
6
|
+
* tool pair is never split. But `findSafeTrimIndex` only ever walks forward, and
|
|
7
|
+
* its leading-`tool`-message skip has no stop short of `messages.length` — so
|
|
8
|
+
* whenever the whole suffix from the naive index to the end is `tool` messages,
|
|
9
|
+
* the cut lands ON `messages.length`, `recentMessages` comes back EMPTY, and
|
|
10
|
+
* `[...preservedSystem, compactionMessage]` replaces the ENTIRE recent window.
|
|
11
|
+
* What is left is a transcript with no non-system message at all.
|
|
12
|
+
*
|
|
13
|
+
* The shape that produces it: ONE assistant turn that fans out
|
|
14
|
+
* `>= keepRecentMessages` parallel tool calls, measured at the START of the next
|
|
15
|
+
* iteration — which is exactly where `runCompactionCheck` runs (iteration/index.ts,
|
|
16
|
+
* after `refreshWorkingMemory`, before the model call), i.e. immediately after
|
|
17
|
+
* those results were appended.
|
|
18
|
+
*
|
|
19
|
+
* The `olderMessages.length < 1` guard at :117 cannot fire: `olderMessages` is
|
|
20
|
+
* the whole transcript in exactly this situation.
|
|
21
|
+
*
|
|
22
|
+
* Symmetrically, when the naive index lands BELOW `systemMessages.length` the
|
|
23
|
+
* cut sits inside the system prefix and the leading prompts are duplicated into
|
|
24
|
+
* `recentMessages`.
|
|
25
|
+
*
|
|
26
|
+
* The invariant these tests pin is the one a cut taken AT OR BELOW naive gives
|
|
27
|
+
* for free: a pass never removes more than the naive cut would, so at least
|
|
28
|
+
* `keepRecentMessages` original messages survive verbatim — or, when no safe cut
|
|
29
|
+
* exists at all, the pass is skipped and the transcript is untouched.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
33
|
+
|
|
34
|
+
import { WorkingStateManager } from '../../../../compaction/manager.js'
|
|
35
|
+
import { CompactionConfigSchema } from '../../../../config/runtime.js'
|
|
36
|
+
import type { RunId } from '../../../../types/ids/index.js'
|
|
37
|
+
import {
|
|
38
|
+
type Message,
|
|
39
|
+
createAssistantMessage,
|
|
40
|
+
createSystemMessage,
|
|
41
|
+
createToolMessage,
|
|
42
|
+
createUserMessage,
|
|
43
|
+
} from '../../../../types/message/index.js'
|
|
44
|
+
import type { Logger } from '../../../../utils/logger.js'
|
|
45
|
+
import { runCompactionCheck } from './compaction.js'
|
|
46
|
+
import type { IterationContext } from './context.js'
|
|
47
|
+
|
|
48
|
+
function makeLogger(): Logger {
|
|
49
|
+
const self = {
|
|
50
|
+
info: vi.fn(),
|
|
51
|
+
warn: vi.fn(),
|
|
52
|
+
error: vi.fn(),
|
|
53
|
+
debug: vi.fn(),
|
|
54
|
+
child: vi.fn(),
|
|
55
|
+
} as unknown as Logger
|
|
56
|
+
;(self as { child: (ctx: unknown) => Logger }).child = vi.fn(() => self)
|
|
57
|
+
return self
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Long enough that a handful of messages overflow a tiny budget. */
|
|
61
|
+
const FILLER = 'x'.repeat(200)
|
|
62
|
+
|
|
63
|
+
const KEEP_RECENT = 4
|
|
64
|
+
|
|
65
|
+
function makeCtx(opts: {
|
|
66
|
+
messages: Message[]
|
|
67
|
+
contextWindowTokens?: number
|
|
68
|
+
tokenBudget?: number
|
|
69
|
+
}): IterationContext {
|
|
70
|
+
const config = CompactionConfigSchema.parse({
|
|
71
|
+
strategy: 'structured',
|
|
72
|
+
llmVerification: false,
|
|
73
|
+
keepRecentMessages: KEEP_RECENT,
|
|
74
|
+
...(opts.contextWindowTokens !== undefined
|
|
75
|
+
? { contextWindowTokens: opts.contextWindowTokens }
|
|
76
|
+
: {}),
|
|
77
|
+
})
|
|
78
|
+
const manager = new WorkingStateManager(config)
|
|
79
|
+
manager.addDecision('built the report as .docx')
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
runConfig: { tokenBudget: opts.tokenBudget ?? 0 },
|
|
83
|
+
compactionConfig: config,
|
|
84
|
+
workingStateManager: manager,
|
|
85
|
+
log: makeLogger(),
|
|
86
|
+
runMgr: {
|
|
87
|
+
id: 'run_1' as RunId,
|
|
88
|
+
currentIteration: 3,
|
|
89
|
+
messages: opts.messages,
|
|
90
|
+
},
|
|
91
|
+
} as unknown as IterationContext
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function toolCall(id: string) {
|
|
95
|
+
return { id, type: 'function' as const, function: { name: 'read', arguments: '{}' } }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** How many of the ORIGINAL messages survived the pass, by identity. */
|
|
99
|
+
function survivorCount(before: readonly Message[], after: readonly Message[]): number {
|
|
100
|
+
const kept = new Set<Message>(after)
|
|
101
|
+
return before.filter((m) => kept.has(m)).length
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The exact shape the iteration loop holds when `runCompactionCheck` runs: the
|
|
106
|
+
* user's turn, one assistant that fanned out four parallel tool calls, and the
|
|
107
|
+
* four results that just landed. The next thing that happens is the model call —
|
|
108
|
+
* with whatever this function leaves behind.
|
|
109
|
+
*/
|
|
110
|
+
function buildParallelFanOutTail(): Message[] {
|
|
111
|
+
return [
|
|
112
|
+
createSystemMessage(`STATIC SYSTEM PROMPT ${FILLER}`, 'cache'),
|
|
113
|
+
createUserMessage(`please rename the heading to Q3 ${FILLER}`),
|
|
114
|
+
createAssistantMessage(`reading the sources ${FILLER}`, [
|
|
115
|
+
toolCall('a'),
|
|
116
|
+
toolCall('b'),
|
|
117
|
+
toolCall('c'),
|
|
118
|
+
toolCall('d'),
|
|
119
|
+
]),
|
|
120
|
+
createToolMessage(`result a ${FILLER}`, 'a'),
|
|
121
|
+
createToolMessage(`result b ${FILLER}`, 'b'),
|
|
122
|
+
createToolMessage(`result c ${FILLER}`, 'c'),
|
|
123
|
+
createToolMessage(`result d ${FILLER}`, 'd'),
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
describe('compaction — the unbounded cut', () => {
|
|
128
|
+
it('keeps at least keepRecentMessages messages verbatim when the tail is a parallel tool fan-out', async () => {
|
|
129
|
+
const messages = buildParallelFanOutTail()
|
|
130
|
+
const before = [...messages]
|
|
131
|
+
const ctx = makeCtx({ messages, contextWindowTokens: 100 })
|
|
132
|
+
|
|
133
|
+
await runCompactionCheck(ctx)
|
|
134
|
+
|
|
135
|
+
expect(survivorCount(before, messages)).toBeGreaterThanOrEqual(KEEP_RECENT)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('never leaves a system-only transcript (nothing for the next turn to answer)', async () => {
|
|
139
|
+
const messages = buildParallelFanOutTail()
|
|
140
|
+
const ctx = makeCtx({ messages, contextWindowTokens: 100 })
|
|
141
|
+
|
|
142
|
+
await runCompactionCheck(ctx)
|
|
143
|
+
|
|
144
|
+
const nonSystem = messages.filter((m) => m.role !== 'system')
|
|
145
|
+
expect(nonSystem.length).toBeGreaterThan(0)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('skips the pass, leaving the transcript intact, when no safe cut exists at or below naive', async () => {
|
|
149
|
+
// System prefix, then a single assistant fanning out six parallel calls.
|
|
150
|
+
// Every candidate boundary at or below naive splits that one pair-set, so
|
|
151
|
+
// there is nothing safe to cut to — skipping beats deleting the turn.
|
|
152
|
+
const ids = ['a', 'b', 'c', 'd', 'e', 'f']
|
|
153
|
+
const messages: Message[] = [
|
|
154
|
+
createSystemMessage(`STATIC SYSTEM PROMPT ${FILLER}`, 'cache'),
|
|
155
|
+
createAssistantMessage(`fanning out ${FILLER}`, ids.map(toolCall)),
|
|
156
|
+
...ids.map((id) => createToolMessage(`result ${id} ${FILLER}`, id)),
|
|
157
|
+
]
|
|
158
|
+
const before = [...messages]
|
|
159
|
+
const ctx = makeCtx({ messages, contextWindowTokens: 100 })
|
|
160
|
+
|
|
161
|
+
await runCompactionCheck(ctx)
|
|
162
|
+
|
|
163
|
+
expect(messages).toEqual(before)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('does not duplicate the leading system prompts when the naive cut lands inside them', async () => {
|
|
167
|
+
// Legacy (tokenBudget) path: five leading system messages and only three
|
|
168
|
+
// conversational ones, so naive = 8 - 4 = 4 < systemMessages.length = 5.
|
|
169
|
+
const messages: Message[] = [
|
|
170
|
+
createSystemMessage(`SYS-1 ${FILLER}`, 'cache'),
|
|
171
|
+
createSystemMessage(`SYS-2 ${FILLER}`),
|
|
172
|
+
createSystemMessage(`SYS-3 ${FILLER}`),
|
|
173
|
+
createSystemMessage(`SYS-4 ${FILLER}`),
|
|
174
|
+
createSystemMessage(`SYS-5 UNIQUE-MARKER ${FILLER}`),
|
|
175
|
+
createUserMessage(`user 0 ${FILLER}`),
|
|
176
|
+
createAssistantMessage(`assistant 0 ${FILLER}`),
|
|
177
|
+
createUserMessage(`user 1 ${FILLER}`),
|
|
178
|
+
]
|
|
179
|
+
const ctx = makeCtx({ messages, tokenBudget: 100 })
|
|
180
|
+
|
|
181
|
+
await runCompactionCheck(ctx)
|
|
182
|
+
|
|
183
|
+
const marker = messages.filter((m) => m.content?.includes('UNIQUE-MARKER'))
|
|
184
|
+
expect(marker).toHaveLength(1)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -94,18 +94,60 @@ export async function runCompactionCheck(ctx: IterationContext): Promise<void> {
|
|
|
94
94
|
}
|
|
95
95
|
if (systemMessages.length === 0) return
|
|
96
96
|
|
|
97
|
-
// Tool-pair atomicity guard
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
97
|
+
// Tool-pair atomicity guard, taken DOWNWARD.
|
|
98
|
+
//
|
|
99
|
+
// A naive cut at `length - keepRecentMessages` can land BETWEEN an
|
|
100
|
+
// assistant-with-toolCalls (dropped into `olderMessages`) and its `tool`
|
|
101
|
+
// results (kept in `recentMessages`), leaving orphaned `tool_result` blocks at
|
|
102
|
+
// the head of the recent window. The Anthropic provider then emits a
|
|
103
|
+
// `tool_result` with no matching `tool_use` and the API rejects the next turn
|
|
104
|
+
// with a 400 — so compaction, whose whole job is to keep a long run alive,
|
|
105
|
+
// instead kills it.
|
|
106
|
+
//
|
|
107
|
+
// Snapping FORWARD via `findSafeTrimIndex` fixes the orphan and introduces a
|
|
108
|
+
// worse failure. That walk skips leading `tool` messages with no stop short of
|
|
109
|
+
// `messages.length`, so whenever the entire suffix from the naive index is
|
|
110
|
+
// `tool` messages, the boundary lands ON `messages.length`: `recentMessages`
|
|
111
|
+
// comes back EMPTY and `[...preservedSystem, compactionMessage]` replaces the
|
|
112
|
+
// whole recent window. The model is then asked to answer a conversation whose
|
|
113
|
+
// last turn — including the user's own message — was deleted, and it answers a
|
|
114
|
+
// question nobody asked. The shape is routine, not exotic: one assistant turn
|
|
115
|
+
// fanning out `>= keepRecentMessages` parallel tool calls, measured at the
|
|
116
|
+
// start of the very next iteration, which is exactly where this runs. The
|
|
117
|
+
// `olderMessages` floor guard below cannot catch it either — in that shape
|
|
118
|
+
// `olderMessages` is the whole transcript.
|
|
119
|
+
//
|
|
120
|
+
// So take the LARGEST safe boundary AT OR BELOW naive instead. A cut that low
|
|
121
|
+
// removes no more than the naive cut would, so at least `keepRecentMessages`
|
|
122
|
+
// original messages always survive verbatim, and the transcript can never be
|
|
123
|
+
// reduced to system messages alone. `findSafeTrimIndex(m, k) === k` is the
|
|
124
|
+
// safety predicate — reused rather than reimplemented, so the function itself
|
|
125
|
+
// (public API, other callers) is untouched.
|
|
126
|
+
const naiveKeepStart = messages.length - config.keepRecentMessages
|
|
127
|
+
let keepStart = -1
|
|
128
|
+
for (let candidate = naiveKeepStart; candidate > systemMessages.length; candidate--) {
|
|
129
|
+
if (findSafeTrimIndex(messages, candidate) === candidate) {
|
|
130
|
+
keepStart = candidate
|
|
131
|
+
break
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// No safe boundary at or below naive. Either every candidate splits a pair-set
|
|
136
|
+
// (one assistant fanning out more calls than the recent window holds), or naive
|
|
137
|
+
// itself sits inside the leading system prefix — which would otherwise
|
|
138
|
+
// duplicate those prompts into `recentMessages`. Skipping costs one iteration's
|
|
139
|
+
// worth of context headroom; cutting anyway costs the live turn. The condition
|
|
140
|
+
// is self-clearing: the next assistant message moves naive past the tool block.
|
|
141
|
+
if (keepStart < 0) {
|
|
142
|
+
ctx.log.debug('Skipping compaction — no safe cut at or below the naive boundary', {
|
|
143
|
+
runId: ctx.runMgr.id,
|
|
144
|
+
naiveKeepStart,
|
|
145
|
+
systemMessages: systemMessages.length,
|
|
146
|
+
messageCount: messages.length,
|
|
147
|
+
})
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
109
151
|
const recentMessages = messages.slice(keepStart)
|
|
110
152
|
const olderMessages = messages.slice(systemMessages.length, keepStart)
|
|
111
153
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isProviderRequestError } from '../../../provider/errors.js'
|
|
1
2
|
import { mergeTokenUsage } from '../../../types/common/index.js'
|
|
2
3
|
import type { ToolUseId } from '../../../types/ids/index.js'
|
|
3
4
|
import type {
|
|
@@ -111,9 +112,12 @@ export async function* streamProviderTurn(
|
|
|
111
112
|
inputTruncated: boolean
|
|
112
113
|
}
|
|
113
114
|
>()
|
|
114
|
-
let streamError:
|
|
115
|
+
let streamError: Error | undefined
|
|
115
116
|
|
|
116
|
-
const stream = provider.chatStream({
|
|
117
|
+
const stream = provider.chatStream({
|
|
118
|
+
...params,
|
|
119
|
+
stream: true,
|
|
120
|
+
}) as AsyncIterable<StreamChunk>
|
|
117
121
|
|
|
118
122
|
// Drive the stream manually so each `.next()` can be RACED against the run
|
|
119
123
|
// abort: a Stop tears the in-flight model request down (the provider got
|
|
@@ -145,7 +149,7 @@ export async function* streamProviderTurn(
|
|
|
145
149
|
if (res.done) break
|
|
146
150
|
const chunk = res.value
|
|
147
151
|
if (chunk.error) {
|
|
148
|
-
streamError = chunk.error
|
|
152
|
+
streamError = new Error(`Provider stream error: ${chunk.error}`)
|
|
149
153
|
break
|
|
150
154
|
}
|
|
151
155
|
if (!id && chunk.id) id = chunk.id
|
|
@@ -252,7 +256,7 @@ export async function* streamProviderTurn(
|
|
|
252
256
|
// run as cancelled rather than recording a normal (errored) turn. Any
|
|
253
257
|
// other stream error is captured into the synthesized response as before.
|
|
254
258
|
if (signal?.aborted) throw err
|
|
255
|
-
streamError = err instanceof Error ? err
|
|
259
|
+
streamError = err instanceof Error ? err : new Error(String(err))
|
|
256
260
|
} finally {
|
|
257
261
|
if (onAbort) signal?.removeEventListener('abort', onAbort)
|
|
258
262
|
// Release the underlying connection on every exit (natural end, error,
|
|
@@ -355,7 +359,7 @@ export async function* streamProviderTurn(
|
|
|
355
359
|
log.warn('provider stream failed after tool input; surfacing tool call to executor', {
|
|
356
360
|
runId,
|
|
357
361
|
iteration,
|
|
358
|
-
error: streamError,
|
|
362
|
+
error: streamError?.message ?? 'provider stream failed',
|
|
359
363
|
toolCallCount: toolCalls.length,
|
|
360
364
|
})
|
|
361
365
|
}
|
|
@@ -378,7 +382,8 @@ export async function* streamProviderTurn(
|
|
|
378
382
|
yield* drainPending()
|
|
379
383
|
|
|
380
384
|
if (streamError && !recoveredToolInputFromStreamError) {
|
|
381
|
-
|
|
385
|
+
if (isProviderRequestError(streamError)) throw streamError
|
|
386
|
+
throw new Error(`Provider stream error: ${streamError.message}`)
|
|
382
387
|
}
|
|
383
388
|
|
|
384
389
|
const response: ChatCompletionResponse = {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Span, SpanStatusCode } from '@opentelemetry/api'
|
|
2
2
|
import type { PlanManager } from '../../manager/plan/lifecycle.js'
|
|
3
3
|
import type { RunPersistence } from '../../manager/run/persistence.js'
|
|
4
|
+
import { isProviderRequestError } from '../../provider/errors.js'
|
|
4
5
|
import type { ActivityStore } from '../../store/activity/memory.js'
|
|
5
6
|
import { GENAI, NAMZU } from '../../telemetry/attributes.js'
|
|
6
7
|
import type { Run, RunEvent } from '../../types/run/index.js'
|
|
@@ -61,7 +62,15 @@ export class ResultAssembler {
|
|
|
61
62
|
async *handleError(err: unknown, rootSpan: Span): AsyncGenerator<RunEvent> {
|
|
62
63
|
const { runMgr, planManager, log, emitEvent, drainPending } = this.config
|
|
63
64
|
const errorMessage = toErrorMessage(err)
|
|
64
|
-
|
|
65
|
+
const providerError = isProviderRequestError(err)
|
|
66
|
+
? {
|
|
67
|
+
kind: err.kind,
|
|
68
|
+
providerId: err.providerId,
|
|
69
|
+
...(err.status !== undefined ? { status: err.status } : {}),
|
|
70
|
+
...(err.retryAfterMs !== undefined ? { retryAfterMs: err.retryAfterMs } : {}),
|
|
71
|
+
}
|
|
72
|
+
: undefined
|
|
73
|
+
runMgr.markFailed(errorMessage, providerError)
|
|
65
74
|
|
|
66
75
|
if (planManager.isActive) {
|
|
67
76
|
planManager.failPlan(errorMessage)
|
|
@@ -71,6 +80,7 @@ export class ResultAssembler {
|
|
|
71
80
|
type: 'run_failed',
|
|
72
81
|
runId: runMgr.id,
|
|
73
82
|
error: errorMessage,
|
|
83
|
+
...(providerError ? { providerError } : {}),
|
|
74
84
|
})
|
|
75
85
|
yield* drainPending()
|
|
76
86
|
|
|
@@ -2,6 +2,8 @@ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { zodToJsonSchema } from 'zod-to-json-schema'
|
|
6
|
+
import { ToolRegistry } from '../../../registry/tool/execute.js'
|
|
5
7
|
import type { ToolContext } from '../../../types/tool/index.js'
|
|
6
8
|
import { EditTool } from '../edit.js'
|
|
7
9
|
|
|
@@ -54,4 +56,78 @@ describe('EditTool', () => {
|
|
|
54
56
|
expect(result.success).toBe(true)
|
|
55
57
|
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\nomega\n')
|
|
56
58
|
})
|
|
59
|
+
|
|
60
|
+
it('publishes only the insertLine values the executor can apply', () => {
|
|
61
|
+
for (const insertLine of ['## Progress', null, '', '1', -1]) {
|
|
62
|
+
const parsed = EditTool.inputSchema.safeParse({
|
|
63
|
+
path: 'doc.md',
|
|
64
|
+
insertLine,
|
|
65
|
+
newStr: 'inserted',
|
|
66
|
+
})
|
|
67
|
+
expect(parsed.success, JSON.stringify(insertLine)).toBe(false)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const insertLine of [0, 1, 'end']) {
|
|
71
|
+
const parsed = EditTool.inputSchema.safeParse({
|
|
72
|
+
path: 'doc.md',
|
|
73
|
+
insertLine,
|
|
74
|
+
newStr: 'inserted',
|
|
75
|
+
})
|
|
76
|
+
expect(parsed.success, JSON.stringify(insertLine)).toBe(true)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const json = zodToJsonSchema(EditTool.inputSchema, {
|
|
80
|
+
target: 'jsonSchema7',
|
|
81
|
+
$refStrategy: 'none',
|
|
82
|
+
}) as {
|
|
83
|
+
properties?: {
|
|
84
|
+
insertLine?: {
|
|
85
|
+
anyOf?: Record<string, unknown>[]
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
expect(json.properties?.insertLine?.anyOf).toEqual([
|
|
90
|
+
{ type: 'integer', minimum: 0 },
|
|
91
|
+
{ type: 'string', const: 'end' },
|
|
92
|
+
])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('refuses invalid insertLine values even when a caller bypasses the schema', async () => {
|
|
96
|
+
for (const insertLine of ['## Progress', null, '', '1']) {
|
|
97
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-edit-'))
|
|
98
|
+
writeFileSync(join(dir, 'doc.md'), 'alpha\nbeta\n')
|
|
99
|
+
|
|
100
|
+
const result = await EditTool.execute(
|
|
101
|
+
{
|
|
102
|
+
path: 'doc.md',
|
|
103
|
+
insertLine,
|
|
104
|
+
newStr: 'inserted',
|
|
105
|
+
replace_all: false,
|
|
106
|
+
} as never,
|
|
107
|
+
makeContext(dir),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
expect(result.success, JSON.stringify(insertLine)).toBe(false)
|
|
111
|
+
expect(result.error).toBe('insertLine must be a non-negative line number or "end".')
|
|
112
|
+
expect(readFileSync(join(dir, 'doc.md'), 'utf-8')).toBe('alpha\nbeta\n')
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('returns complete recovery shapes when path is missing and insertLine is invalid', async () => {
|
|
117
|
+
const registry = new ToolRegistry()
|
|
118
|
+
registry.register(EditTool)
|
|
119
|
+
|
|
120
|
+
const result = await registry.execute(
|
|
121
|
+
'edit',
|
|
122
|
+
{ insertLine: '## Progress' },
|
|
123
|
+
makeContext('/tmp'),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
expect(result.success).toBe(false)
|
|
127
|
+
expect(result.error).toContain('Validation failed for "edit":')
|
|
128
|
+
expect(result.error).toContain('insertLine: Invalid input')
|
|
129
|
+
expect(result.error).toContain('Required: path: string — Path to the file to edit.')
|
|
130
|
+
expect(result.error).toContain('{"path":"file.md","insertLine":"end","new_string":"text"}')
|
|
131
|
+
expect(result.error).toContain('{"path":"file.md","old_string":"old","new_string":"new"}')
|
|
132
|
+
})
|
|
57
133
|
})
|
|
@@ -29,10 +29,10 @@ const inputSchema = z
|
|
|
29
29
|
'Alias for new_string. Also used as inserted content when insertLine is provided. Self-budget this payload under 12000 characters before calling.',
|
|
30
30
|
),
|
|
31
31
|
insertLine: z
|
|
32
|
-
.union([z.
|
|
32
|
+
.union([z.number().int().min(0), z.literal('end')])
|
|
33
33
|
.optional()
|
|
34
34
|
.describe(
|
|
35
|
-
'Optional line insertion target.
|
|
35
|
+
'Optional line insertion target. Pass a JSON integer to insert after that 1-indexed line, 0 to insert before the first line, or the exact string "end" to append. Headings, anchors, numeric strings, null, and empty strings are invalid.',
|
|
36
36
|
),
|
|
37
37
|
replace_all: z
|
|
38
38
|
.boolean()
|
|
@@ -71,6 +71,8 @@ export const EditTool = defineTool({
|
|
|
71
71
|
description:
|
|
72
72
|
'Makes targeted edits to a file using exact string find-and-replace or line insertion. THIS IS THE PREFERRED WAY TO MODIFY AN EXISTING FILE — never reach for `write` to change a file that already exists, because `write` overwrites the whole body and discards earlier work on partial failure. `edit` keeps the rest of the file byte-for-byte intact and is recoverable: if a single edit fails (old_string/oldStr ambiguous, broader restructuring needed), follow up with another `edit` instead of re-emitting the entire file via `write`. The old_string/oldStr must be unique in the file unless replace_all is true. For insertions, pass insertLine plus new_string/newStr; use insertLine: "end" to extend a file at the end. Self-budget new_string/newStr under 12000 characters before emitting the tool call; use repeated bounded edits for long sections. Preserves file formatting and indentation.',
|
|
73
73
|
inputSchema,
|
|
74
|
+
validationErrorHint:
|
|
75
|
+
'Accepted shapes: {"path":"file.md","insertLine":"end","new_string":"text"} or {"path":"file.md","old_string":"old","new_string":"new"}. Always include path; insertLine accepts only a non-negative JSON integer or the exact string "end".',
|
|
74
76
|
category: 'filesystem',
|
|
75
77
|
permissions: ['file_write'],
|
|
76
78
|
readOnly: false,
|
|
@@ -166,18 +168,16 @@ function normalizeEditInput(
|
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
function normalizeInsertLine(
|
|
169
|
-
value:
|
|
171
|
+
value: unknown,
|
|
170
172
|
): { success: true; value: number | 'end' } | { success: false; error: string } {
|
|
171
|
-
if (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
173
|
+
if (value === 'end') return { success: true, value: 'end' }
|
|
174
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
|
|
175
|
+
return { success: true, value }
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
success: false,
|
|
179
|
+
error: 'insertLine must be a non-negative line number or "end".',
|
|
179
180
|
}
|
|
180
|
-
return { success: true, value }
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
function applyEdit(
|
|
@@ -179,4 +179,20 @@ describe('coordinator agent_task_list tool', () => {
|
|
|
179
179
|
expect(names).toContain('agent_task_list')
|
|
180
180
|
expect(names).not.toContain('task_list')
|
|
181
181
|
})
|
|
182
|
+
|
|
183
|
+
it('does not advertise per-task cancellation on the blocking coordinator surface', () => {
|
|
184
|
+
const coordinatorTools = buildCoordinatorTools({
|
|
185
|
+
gateway: gatewayWith([]),
|
|
186
|
+
workingDirectory: '/tmp/test',
|
|
187
|
+
allowedAgentIds: ['solution-architecture'],
|
|
188
|
+
})
|
|
189
|
+
const names = coordinatorTools.map((tool) => tool.name)
|
|
190
|
+
|
|
191
|
+
// create_task returns only after the worker is terminal, so the
|
|
192
|
+
// supervisor cannot know a live task id in a later model turn.
|
|
193
|
+
// Keeping cancel_task here only manufactured success for missing
|
|
194
|
+
// or terminal ids because every gateway cancellation is a void
|
|
195
|
+
// no-op in those states.
|
|
196
|
+
expect(names).not.toContain('cancel_task')
|
|
197
|
+
})
|
|
182
198
|
})
|
|
@@ -19,14 +19,12 @@ import type { TaskLaunchedCallback } from './index.js'
|
|
|
19
19
|
* subagent tool calls are isolated — only the summary surfaces to
|
|
20
20
|
* the parent.
|
|
21
21
|
*
|
|
22
|
-
* This is **NOT** the same shape as the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* the legacy coordinator tools only when you genuinely need
|
|
29
|
-
* fire-and-forget multi-task fan-out.
|
|
22
|
+
* This is **NOT** the same shape as the coordinator `create_task`
|
|
23
|
+
* tool that this package ships alongside it. That tool exposes
|
|
24
|
+
* Namzu's task IDs and optional planning integration, while still
|
|
25
|
+
* returning results synchronously. For free agentic alignment,
|
|
26
|
+
* prefer the canonical `Agent` tool; use the coordinator tool when
|
|
27
|
+
* the host needs task tracking.
|
|
30
28
|
*/
|
|
31
29
|
export interface AgentToolOptions {
|
|
32
30
|
gateway: TaskGateway
|