@namzu/sdk 6.1.0 → 7.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 +361 -0
- package/dist/agents/ReactiveAgent.d.ts.map +1 -1
- package/dist/agents/ReactiveAgent.js +5 -0
- package/dist/agents/ReactiveAgent.js.map +1 -1
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +9 -0
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.js +31 -0
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.js.map +1 -1
- package/dist/agents/runAgent.d.ts +19 -1
- package/dist/agents/runAgent.d.ts.map +1 -1
- package/dist/agents/runAgent.js +2 -0
- package/dist/agents/runAgent.js.map +1 -1
- package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts +2 -0
- package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts.map +1 -0
- package/dist/connector/mcp/__tests__/positional-arrays.test.js +142 -0
- package/dist/connector/mcp/__tests__/positional-arrays.test.js.map +1 -0
- package/dist/connector/mcp/adapter.d.ts.map +1 -1
- package/dist/connector/mcp/adapter.js +123 -8
- package/dist/connector/mcp/adapter.js.map +1 -1
- package/dist/constants/agent/index.d.ts +5 -0
- package/dist/constants/agent/index.d.ts.map +1 -1
- package/dist/constants/agent/index.js +5 -0
- package/dist/constants/agent/index.js.map +1 -1
- package/dist/constants/plugin/index.d.ts +15 -0
- package/dist/constants/plugin/index.d.ts.map +1 -1
- package/dist/constants/plugin/index.js +15 -0
- package/dist/constants/plugin/index.js.map +1 -1
- package/dist/gateway/__tests__/completion-inbox.test.js +17 -0
- package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -1
- package/dist/gateway/completion-inbox.d.ts.map +1 -1
- package/dist/gateway/completion-inbox.js +17 -3
- package/dist/gateway/completion-inbox.js.map +1 -1
- package/dist/gateway/local.d.ts +11 -0
- package/dist/gateway/local.d.ts.map +1 -1
- package/dist/gateway/local.js +27 -1
- package/dist/gateway/local.js.map +1 -1
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +6 -0
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/provider/thinking-support.d.ts +2 -1
- package/dist/provider/thinking-support.d.ts.map +1 -1
- package/dist/provider/thinking-support.js +14 -0
- package/dist/provider/thinking-support.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +9 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/runtime/query/__tests__/context.test.js +18 -0
- package/dist/runtime/query/__tests__/context.test.js.map +1 -1
- package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js +118 -0
- package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js.map +1 -0
- package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.d.ts +2 -0
- package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.js +122 -0
- package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.js.map +1 -0
- package/dist/runtime/query/__tests__/tool-timeout.test.js +34 -0
- package/dist/runtime/query/__tests__/tool-timeout.test.js.map +1 -1
- package/dist/runtime/query/context.d.ts.map +1 -1
- package/dist/runtime/query/context.js +16 -1
- package/dist/runtime/query/context.js.map +1 -1
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +11 -1
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +5 -0
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js +43 -13
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/telemetry/__tests__/model-call-span.test.js +22 -4
- package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -1
- package/dist/telemetry/__tests__/span-closure.test.js +12 -5
- package/dist/telemetry/__tests__/span-closure.test.js.map +1 -1
- package/dist/tools/builtins/__tests__/bash.proc-test.d.ts +2 -0
- package/dist/tools/builtins/__tests__/bash.proc-test.d.ts.map +1 -0
- package/dist/tools/builtins/__tests__/bash.proc-test.js +115 -0
- package/dist/tools/builtins/__tests__/bash.proc-test.js.map +1 -0
- package/dist/tools/builtins/__tests__/bash.test.d.ts +2 -0
- package/dist/tools/builtins/__tests__/bash.test.d.ts.map +1 -0
- package/dist/tools/builtins/__tests__/bash.test.js +65 -0
- package/dist/tools/builtins/__tests__/bash.test.js.map +1 -0
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +89 -17
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/coordinator/__tests__/allow-delegation.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/allow-delegation.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/allow-delegation.test.js +94 -0
- package/dist/tools/coordinator/__tests__/allow-delegation.test.js.map +1 -0
- package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js +193 -0
- package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js.map +1 -0
- package/dist/tools/coordinator/index.d.ts +28 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +101 -62
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/coordinator/wait-with-idle-bound.d.ts +66 -0
- package/dist/tools/coordinator/wait-with-idle-bound.d.ts.map +1 -0
- package/dist/tools/coordinator/wait-with-idle-bound.js +78 -0
- package/dist/tools/coordinator/wait-with-idle-bound.js.map +1 -0
- package/dist/types/agent/base.d.ts +16 -0
- package/dist/types/agent/base.d.ts.map +1 -1
- package/dist/types/agent/gateway.d.ts +23 -0
- package/dist/types/agent/gateway.d.ts.map +1 -1
- package/dist/types/agent/lifecycle-event.d.ts +9 -1
- package/dist/types/agent/lifecycle-event.d.ts.map +1 -1
- package/dist/types/agent/supervisor.d.ts +27 -0
- package/dist/types/agent/supervisor.d.ts.map +1 -1
- package/dist/types/agent/task.d.ts +5 -0
- package/dist/types/agent/task.d.ts.map +1 -1
- package/dist/types/hitl/index.d.ts +10 -0
- package/dist/types/hitl/index.d.ts.map +1 -1
- package/dist/types/hitl/index.js.map +1 -1
- package/dist/types/probe/registry.d.ts +6 -0
- package/dist/types/probe/registry.d.ts.map +1 -1
- package/dist/types/run/config.d.ts +25 -0
- package/dist/types/run/config.d.ts.map +1 -1
- package/dist/utils/__tests__/abort-reason.test.d.ts +2 -0
- package/dist/utils/__tests__/abort-reason.test.d.ts.map +1 -0
- package/dist/utils/__tests__/abort-reason.test.js +48 -0
- package/dist/utils/__tests__/abort-reason.test.js.map +1 -0
- package/dist/utils/abort.d.ts +26 -0
- package/dist/utils/abort.d.ts.map +1 -1
- package/dist/utils/abort.js +34 -0
- package/dist/utils/abort.js.map +1 -1
- package/dist/verification/__tests__/rule-order-and-reason.test.d.ts +2 -0
- package/dist/verification/__tests__/rule-order-and-reason.test.d.ts.map +1 -0
- package/dist/verification/__tests__/rule-order-and-reason.test.js +126 -0
- package/dist/verification/__tests__/rule-order-and-reason.test.js.map +1 -0
- package/dist/verification/gate.d.ts +17 -1
- package/dist/verification/gate.d.ts.map +1 -1
- package/dist/verification/gate.js +58 -2
- package/dist/verification/gate.js.map +1 -1
- package/dist/verification/index.d.ts +1 -1
- package/dist/verification/index.d.ts.map +1 -1
- package/dist/verification/index.js +1 -1
- package/dist/verification/index.js.map +1 -1
- package/package.json +3 -2
- package/src/agents/ReactiveAgent.ts +5 -0
- package/src/agents/SupervisorAgent.ts +9 -0
- package/src/agents/__tests__/supervisor-coordinator-registration.test.ts +37 -0
- package/src/agents/runAgent.ts +22 -1
- package/src/connector/mcp/__tests__/positional-arrays.test.ts +183 -0
- package/src/connector/mcp/adapter.ts +131 -7
- package/src/constants/agent/index.ts +5 -0
- package/src/constants/plugin/index.ts +15 -0
- package/src/gateway/__tests__/completion-inbox.test.ts +23 -0
- package/src/gateway/completion-inbox.ts +17 -3
- package/src/gateway/local.ts +26 -1
- package/src/manager/agent/lifecycle.ts +6 -0
- package/src/provider/thinking-support.ts +19 -2
- package/src/public-runtime.ts +9 -0
- package/src/runtime/query/__tests__/context.test.ts +24 -0
- package/src/runtime/query/__tests__/effort-reaches-the-wire.test.ts +135 -0
- package/src/runtime/query/__tests__/run-survives-its-own-park.proc-test.ts +127 -0
- package/src/runtime/query/__tests__/tool-timeout.test.ts +38 -0
- package/src/runtime/query/context.ts +16 -1
- package/src/runtime/query/executor.ts +11 -1
- package/src/runtime/query/iteration/index.ts +5 -0
- package/src/runtime/query/iteration/phases/context.ts +41 -15
- package/src/telemetry/__tests__/model-call-span.test.ts +22 -5
- package/src/telemetry/__tests__/span-closure.test.ts +12 -5
- package/src/tools/builtins/__tests__/bash.proc-test.ts +142 -0
- package/src/tools/builtins/__tests__/bash.test.ts +79 -0
- package/src/tools/builtins/bash.ts +100 -18
- package/src/tools/coordinator/__tests__/allow-delegation.test.ts +120 -0
- package/src/tools/coordinator/__tests__/wait-with-idle-bound.test.ts +247 -0
- package/src/tools/coordinator/index.ts +112 -65
- package/src/tools/coordinator/wait-with-idle-bound.ts +142 -0
- package/src/types/agent/base.ts +17 -0
- package/src/types/agent/gateway.ts +24 -0
- package/src/types/agent/lifecycle-event.ts +7 -0
- package/src/types/agent/supervisor.ts +28 -0
- package/src/types/agent/task.ts +5 -0
- package/src/types/hitl/index.ts +10 -0
- package/src/types/probe/registry.ts +6 -0
- package/src/types/run/config.ts +26 -0
- package/src/utils/__tests__/abort-reason.test.ts +56 -0
- package/src/utils/abort.ts +34 -0
- package/src/verification/__tests__/rule-order-and-reason.test.ts +149 -0
- package/src/verification/gate.ts +61 -3
- package/src/verification/index.ts +1 -1
package/src/public-runtime.ts
CHANGED
|
@@ -369,9 +369,18 @@ export {
|
|
|
369
369
|
FileLockManager,
|
|
370
370
|
} from './bus/index.js'
|
|
371
371
|
|
|
372
|
+
// `describeRule` travels with `evaluateRule` deliberately. `evaluateRule`
|
|
373
|
+
// answers WHETHER a rule matched; on its own it leaves a caller holding a
|
|
374
|
+
// decision with no words for it, and the only way to say anything about the
|
|
375
|
+
// refusal is to switch on the rule's TYPE — which names the kind of rule and
|
|
376
|
+
// nothing about what it said. That is precisely the defect the gate itself
|
|
377
|
+
// carried until its reason stopped being `Matched rule: <type>`, and shipping
|
|
378
|
+
// the verdict without the sentence would have left the same hole one layer up
|
|
379
|
+
// for anyone driving the rules directly instead of through the gate.
|
|
372
380
|
export {
|
|
373
381
|
defaultSandboxedGateConfig,
|
|
374
382
|
defaultSandboxedShellGateConfig,
|
|
383
|
+
describeRule,
|
|
375
384
|
evaluateRule,
|
|
376
385
|
VerificationGate,
|
|
377
386
|
} from './verification/index.js'
|
|
@@ -100,4 +100,28 @@ describe('RunContextFactory.build', () => {
|
|
|
100
100
|
const runDir = builder.runDir('prj_x' as ProjectId, 'ses_y' as SessionId, 'run_z' as RunId)
|
|
101
101
|
expect(posix(runDir)).toBe('/base/.namzu/projects/prj_x/sessions/ses_y/runs/run_z')
|
|
102
102
|
})
|
|
103
|
+
|
|
104
|
+
it("carries the caller's stop reason across into the run", () => {
|
|
105
|
+
// This is the frame that was losing it. The host aborts with a
|
|
106
|
+
// sentence, the run re-aborts with nothing, and every layer below —
|
|
107
|
+
// the tool executor, the tool itself, the result the model reads —
|
|
108
|
+
// can only report that something stopped. The words do not survive
|
|
109
|
+
// the hop unless this site forwards them.
|
|
110
|
+
const host = new AbortController()
|
|
111
|
+
const ctx = RunContextFactory.build(buildConfig({ signal: host.signal }))
|
|
112
|
+
|
|
113
|
+
host.abort(new Error('nightly window closed'))
|
|
114
|
+
|
|
115
|
+
expect(ctx.abortController.signal.aborted).toBe(true)
|
|
116
|
+
expect((ctx.abortController.signal.reason as Error)?.message).toBe('nightly window closed')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('still aborts when the caller gave no reason', () => {
|
|
120
|
+
const host = new AbortController()
|
|
121
|
+
const ctx = RunContextFactory.build(buildConfig({ signal: host.signal }))
|
|
122
|
+
|
|
123
|
+
host.abort()
|
|
124
|
+
|
|
125
|
+
expect(ctx.abortController.signal.aborted).toBe(true)
|
|
126
|
+
})
|
|
103
127
|
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { MockLLMProvider } from '../../../provider/mock.js'
|
|
6
|
+
import { ToolRegistry } from '../../../registry/tool/execute.js'
|
|
7
|
+
import { createUserMessage } from '../../../types/message/index.js'
|
|
8
|
+
import type { AgentRunConfig } from '../../../types/run/index.js'
|
|
9
|
+
import { drainQuery } from '../index.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `effort` was declared on the provider params, exported, and read by a driver
|
|
13
|
+
* that wrote it straight to the wire — and nothing in the kernel ever set it.
|
|
14
|
+
* No caller could reach it, and the symptom (every request going out at the
|
|
15
|
+
* model's default) reads as "this model ignores effort" rather than "nobody
|
|
16
|
+
* plumbed it through".
|
|
17
|
+
*
|
|
18
|
+
* These drive a real run and read what the provider was actually handed, which
|
|
19
|
+
* is the only thing that distinguishes a wired field from a declared one. A
|
|
20
|
+
* test asserting the field exists on the config type would have passed against
|
|
21
|
+
* the broken version.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
let workdirs: string[] = []
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await Promise.all(workdirs.map((d) => rm(d, { recursive: true, force: true })))
|
|
28
|
+
workdirs = []
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
async function run(overrides: Partial<AgentRunConfig>, turns: unknown[]): Promise<MockLLMProvider> {
|
|
32
|
+
const provider = new MockLLMProvider({ turns: turns as never })
|
|
33
|
+
const dir = await mkdtemp(join(tmpdir(), 'namzu-effort-'))
|
|
34
|
+
workdirs.push(dir)
|
|
35
|
+
|
|
36
|
+
await drainQuery({
|
|
37
|
+
provider,
|
|
38
|
+
tools: new ToolRegistry(),
|
|
39
|
+
runConfig: {
|
|
40
|
+
model: 'mock-model',
|
|
41
|
+
timeoutMs: 30_000,
|
|
42
|
+
tokenBudget: 100_000,
|
|
43
|
+
maxIterations: 2,
|
|
44
|
+
...overrides,
|
|
45
|
+
},
|
|
46
|
+
agentId: 'agent_effort',
|
|
47
|
+
agentName: 'Effort Agent',
|
|
48
|
+
workingDirectory: dir,
|
|
49
|
+
sessionId: 'ses_effort',
|
|
50
|
+
threadId: 'thd_effort',
|
|
51
|
+
projectId: 'prj_effort',
|
|
52
|
+
tenantId: 'tnt_effort',
|
|
53
|
+
messages: [createUserMessage('go')],
|
|
54
|
+
} as never)
|
|
55
|
+
|
|
56
|
+
return provider
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('an effort level set on the run reaches the provider', () => {
|
|
60
|
+
it('arrives on the request', async () => {
|
|
61
|
+
const provider = await run({ effort: 'max' }, [{ text: 'done' }])
|
|
62
|
+
|
|
63
|
+
expect(provider.requests.length).toBeGreaterThan(0)
|
|
64
|
+
expect(provider.requests[0]?.effort).toBe('max')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('is absent when nobody asked for one', async () => {
|
|
68
|
+
// Not `undefined`-valued but genuinely absent: a present key carrying
|
|
69
|
+
// undefined is the kind of thing that survives a spread into a request
|
|
70
|
+
// body and reaches a wire that did not expect the field.
|
|
71
|
+
const provider = await run({}, [{ text: 'done' }])
|
|
72
|
+
|
|
73
|
+
expect(provider.requests[0] && 'effort' in provider.requests[0]).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('rides every turn of the run, not only the first', async () => {
|
|
77
|
+
// The value is run-level because the provider documents that changing
|
|
78
|
+
// it between requests invalidates the cached prefix. A run that
|
|
79
|
+
// forwarded it once and then stopped would pay that cost silently.
|
|
80
|
+
const provider = await run({ effort: 'low' }, [{ text: 'one' }, { text: 'two' }])
|
|
81
|
+
|
|
82
|
+
for (const request of provider.requests) {
|
|
83
|
+
expect(request.effort).toBe('low')
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('travels alongside thinking rather than inside it', async () => {
|
|
88
|
+
const provider = await run({ effort: 'high', thinking: { type: 'adaptive' } }, [
|
|
89
|
+
{ text: 'done' },
|
|
90
|
+
])
|
|
91
|
+
|
|
92
|
+
expect(provider.requests[0]?.effort).toBe('high')
|
|
93
|
+
expect(provider.requests[0]?.thinking?.type).toBe('adaptive')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('the front door forwards it too, not only the kernel', () => {
|
|
98
|
+
/**
|
|
99
|
+
* These exist because everything above passed while a real run put NOTHING
|
|
100
|
+
* on the wire.
|
|
101
|
+
*
|
|
102
|
+
* `drainQuery` takes the run config a caller hands it, so testing through
|
|
103
|
+
* it proves the loop forwards the field and nothing about whether a caller
|
|
104
|
+
* can set it. Every ergonomic entry point — this one, `ReactiveAgent`,
|
|
105
|
+
* `SupervisorAgent`, and the manager's bare-config branch — builds its
|
|
106
|
+
* `AgentRunConfig` by HAND-LISTING fields, so a field nobody remembered to
|
|
107
|
+
* add is dropped in silence, with no cast to blame and no error to see.
|
|
108
|
+
* `thinking` had been in that state since it shipped.
|
|
109
|
+
*
|
|
110
|
+
* It was found by watching an actual HTTP body, which is the only place the
|
|
111
|
+
* gap is visible. So the regression test drives the front door.
|
|
112
|
+
*/
|
|
113
|
+
it('reaches the provider through runAgent', async () => {
|
|
114
|
+
const { runAgent } = await import('../../../agents/runAgent.js')
|
|
115
|
+
const provider = new MockLLMProvider({ turns: [{ text: 'done' }] as never })
|
|
116
|
+
const dir = await mkdtemp(join(tmpdir(), 'namzu-effort-door-'))
|
|
117
|
+
workdirs.push(dir)
|
|
118
|
+
|
|
119
|
+
await runAgent({
|
|
120
|
+
provider,
|
|
121
|
+
model: 'mock-model',
|
|
122
|
+
prompt: 'go',
|
|
123
|
+
workingDirectory: dir,
|
|
124
|
+
effort: 'xhigh',
|
|
125
|
+
thinking: { type: 'adaptive' },
|
|
126
|
+
timeoutMs: 30_000,
|
|
127
|
+
tokenBudget: 100_000,
|
|
128
|
+
maxIterations: 2,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(provider.requests.length).toBeGreaterThan(0)
|
|
132
|
+
expect(provider.requests[0]?.effort, 'the front door dropped effort').toBe('xhigh')
|
|
133
|
+
expect(provider.requests[0]?.thinking?.type, 'the front door dropped thinking').toBe('adaptive')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A run has to survive its own waiting.
|
|
10
|
+
*
|
|
11
|
+
* Every HITL park went through a timer that was deliberately `unref`'d, so a
|
|
12
|
+
* pending park-recorder could never hold a process open after the run
|
|
13
|
+
* settled. The intent was right and the scope was wrong: the run AWAITS that
|
|
14
|
+
* timer, mid-turn, on every park. An unref'd timer does not keep Node's loop
|
|
15
|
+
* alive — so once the decision resolved and the run sat out the rest of the
|
|
16
|
+
* delay, the loop had nothing ref'd left in it and the process exited. Mid
|
|
17
|
+
* turn. Exit code 0. No terminal event, no error, and nothing done.
|
|
18
|
+
*
|
|
19
|
+
* The headless surfaces could not finish a turn at all: the first tool call
|
|
20
|
+
* completed and the process ended.
|
|
21
|
+
*
|
|
22
|
+
* **This test spawns a child process on purpose.** In-process it is
|
|
23
|
+
* unwritable — a test runner holds the event loop open for the whole file,
|
|
24
|
+
* which is the exact prop that hid this for as long as it was hidden. Every
|
|
25
|
+
* existing test passed throughout. If you are tempted to rewrite this as an
|
|
26
|
+
* ordinary `drainQuery` call because spawning is slow, that rewrite is the
|
|
27
|
+
* bug coming back.
|
|
28
|
+
*
|
|
29
|
+
* It loads the BUILT entry point, because that is what a consumer loads and
|
|
30
|
+
* because a child process cannot resolve TypeScript. The `test:proc` script
|
|
31
|
+
* therefore builds first — a stale `dist` reports a failure that has nothing
|
|
32
|
+
* to do with the code under test, and would report a pass just as
|
|
33
|
+
* confidently.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const workdirs: string[] = []
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
for (const dir of workdirs) rmSync(dir, { recursive: true, force: true })
|
|
39
|
+
workdirs.length = 0
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A scripted provider and a run, in a file with no test runner under it.
|
|
44
|
+
*
|
|
45
|
+
* Two turns: one tool call, then an answer. The park happens between them,
|
|
46
|
+
* which is where the process used to die.
|
|
47
|
+
*/
|
|
48
|
+
const SCRIPT = `
|
|
49
|
+
import { pathToFileURL } from 'node:url'
|
|
50
|
+
const sdk = await import(pathToFileURL(process.argv[2]).href)
|
|
51
|
+
const { ToolRegistry, drainQuery, defineTool } = sdk
|
|
52
|
+
const { z } = await import(pathToFileURL(process.argv[3]).href)
|
|
53
|
+
|
|
54
|
+
const ZERO = { promptTokens: 0, completionTokens: 0, totalTokens: 0, cachedTokens: 0, cacheWriteTokens: 0 }
|
|
55
|
+
|
|
56
|
+
let calls = 0
|
|
57
|
+
const provider = {
|
|
58
|
+
id: 'scripted',
|
|
59
|
+
name: 'Scripted',
|
|
60
|
+
async *chatStream() {
|
|
61
|
+
calls += 1
|
|
62
|
+
if (calls === 1) {
|
|
63
|
+
yield { id: 'm1', delta: { toolCalls: [{ index: 0, id: 't1', type: 'function', function: { name: 'ping', arguments: '{}' } }] } }
|
|
64
|
+
yield { id: 'm1', delta: {}, finishReason: 'tool_calls', usage: ZERO }
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
yield { id: 'm2', delta: { content: 'answered' } }
|
|
68
|
+
yield { id: 'm2', delta: {}, finishReason: 'stop', usage: ZERO }
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const tools = new ToolRegistry()
|
|
73
|
+
tools.register(defineTool({
|
|
74
|
+
name: 'ping',
|
|
75
|
+
description: 'pings',
|
|
76
|
+
inputSchema: z.object({}),
|
|
77
|
+
category: 'analysis',
|
|
78
|
+
permissions: [],
|
|
79
|
+
readOnly: true,
|
|
80
|
+
destructive: false,
|
|
81
|
+
concurrencySafe: true,
|
|
82
|
+
async execute() { return { success: true, output: 'pong' } },
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
let last = '(none)'
|
|
86
|
+
drainQuery({
|
|
87
|
+
provider,
|
|
88
|
+
tools,
|
|
89
|
+
agentId: 'a',
|
|
90
|
+
agentName: 'A',
|
|
91
|
+
messages: [{ role: 'user', content: 'go', timestamp: Date.now() }],
|
|
92
|
+
workingDirectory: process.argv[4],
|
|
93
|
+
runConfig: { model: 'm', timeoutMs: 20000, tokenBudget: 10000, maxIterations: 4, maxResponseTokens: 128 },
|
|
94
|
+
sessionId: 'ses_x', threadId: 'thd_x', projectId: 'prj_x', tenantId: 'tnt_x',
|
|
95
|
+
}, (e) => { last = e.type }).then(
|
|
96
|
+
(run) => console.log('RESULT ' + JSON.stringify({ status: run.status, stop: run.stopReason, last })),
|
|
97
|
+
(err) => console.log('THREW ' + (err?.message ?? err)),
|
|
98
|
+
)
|
|
99
|
+
process.on('exit', () => console.log('LAST ' + last))
|
|
100
|
+
`
|
|
101
|
+
|
|
102
|
+
describe('a run outlives its own HITL park', () => {
|
|
103
|
+
it('finishes in a process with nothing else holding the event loop open', () => {
|
|
104
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-park-'))
|
|
105
|
+
workdirs.push(dir)
|
|
106
|
+
const script = join(dir, 'run.mjs')
|
|
107
|
+
writeFileSync(script, SCRIPT)
|
|
108
|
+
|
|
109
|
+
// `fileURLToPath`, not `pathname` with the leading slash stripped. That
|
|
110
|
+
// stripping is right on Windows, where `pathname` is `/C:/…`, and wrong
|
|
111
|
+
// everywhere else, where it turns an absolute POSIX path into a
|
|
112
|
+
// relative one — which is how this passed locally and failed in CI with
|
|
113
|
+
// the repo root pasted in front of itself.
|
|
114
|
+
const sdkEntry = fileURLToPath(new URL('../../../../dist/index.js', import.meta.url))
|
|
115
|
+
const zodEntry = require.resolve('zod')
|
|
116
|
+
|
|
117
|
+
const out = execFileSync(process.execPath, [script, sdkEntry, zodEntry, dir], {
|
|
118
|
+
encoding: 'utf8',
|
|
119
|
+
timeout: 60_000,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// The run reached its own end rather than the process reaching it first.
|
|
123
|
+
expect(out, `the run did not complete:\n${out}`).toContain('RESULT ')
|
|
124
|
+
expect(out).toContain('"status":"completed"')
|
|
125
|
+
expect(out).toContain('"last":"run_completed"')
|
|
126
|
+
}, 90_000)
|
|
127
|
+
})
|
|
@@ -177,6 +177,44 @@ describe('ToolExecutor — per-tool deadline', () => {
|
|
|
177
177
|
expect(batch.results[0]?.output).toContain('was cancelled')
|
|
178
178
|
})
|
|
179
179
|
|
|
180
|
+
it('says WHICH stop it was, when the caller named one', async () => {
|
|
181
|
+
// The reason was on this signal all along — it is forwarded into the
|
|
182
|
+
// per-tool controller a few lines above the message — and the message
|
|
183
|
+
// threw it away. So a deadline, a budget and an operator pressing stop
|
|
184
|
+
// all reached the model as the same four words, and they want
|
|
185
|
+
// different next moves: one is worth waiting out, one is worth
|
|
186
|
+
// narrowing the input for, and one is worth stopping over.
|
|
187
|
+
const controller = new AbortController()
|
|
188
|
+
const h = harness({
|
|
189
|
+
toolTimeoutMs: 60_000,
|
|
190
|
+
abortSignal: controller.signal,
|
|
191
|
+
run: never,
|
|
192
|
+
})
|
|
193
|
+
const pending = h.exec.executeBatch(response('hang'))
|
|
194
|
+
controller.abort(new Error('deployment window closed'))
|
|
195
|
+
const batch = await pending
|
|
196
|
+
|
|
197
|
+
expect(batch.results[0]?.output).toContain('deployment window closed')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('does not invent a reason when the caller gave none', async () => {
|
|
201
|
+
// `abort()` with no argument fills `reason` with a DOMException named
|
|
202
|
+
// AbortError. Rendering it would turn an honest silence into something
|
|
203
|
+
// that reads like an explanation, which is the worse failure.
|
|
204
|
+
const controller = new AbortController()
|
|
205
|
+
const h = harness({
|
|
206
|
+
toolTimeoutMs: 60_000,
|
|
207
|
+
abortSignal: controller.signal,
|
|
208
|
+
run: never,
|
|
209
|
+
})
|
|
210
|
+
const pending = h.exec.executeBatch(response('hang'))
|
|
211
|
+
controller.abort()
|
|
212
|
+
const batch = await pending
|
|
213
|
+
|
|
214
|
+
expect(batch.results[0]?.output).toContain('was cancelled.')
|
|
215
|
+
expect(batch.results[0]?.output).not.toContain('AbortError')
|
|
216
|
+
})
|
|
217
|
+
|
|
180
218
|
it('exposes a sane default deadline', () => {
|
|
181
219
|
// Documented so a change is a deliberate decision, not a drift.
|
|
182
220
|
expect(DEFAULT_TOOL_TIMEOUT_MS).toBe(120_000)
|
|
@@ -134,7 +134,22 @@ export class RunContextFactory {
|
|
|
134
134
|
static build(config: RunContextConfig): RunContext {
|
|
135
135
|
const abortController = new AbortController()
|
|
136
136
|
if (config.signal) {
|
|
137
|
-
|
|
137
|
+
// Forward the caller's REASON, not just the fact of the abort.
|
|
138
|
+
//
|
|
139
|
+
// This used to be a bare `abort()`. Every word a host attached to
|
|
140
|
+
// its stop — a deadline name, a budget, an operator's message —
|
|
141
|
+
// died one frame above the executor, so the most a tool result
|
|
142
|
+
// could say was "was cancelled". A run that ends for a nameable
|
|
143
|
+
// reason is a run someone can debug; this is the frame where the
|
|
144
|
+
// name was being thrown away.
|
|
145
|
+
//
|
|
146
|
+
// `createChildAbortController` already does exactly this, but it
|
|
147
|
+
// takes an AbortController and what arrives here is a bare
|
|
148
|
+
// AbortSignal, so the reason is forwarded by hand rather than by
|
|
149
|
+
// reaching for a helper that does not fit.
|
|
150
|
+
config.signal.addEventListener('abort', () => abortController.abort(config.signal?.reason), {
|
|
151
|
+
once: true,
|
|
152
|
+
})
|
|
138
153
|
}
|
|
139
154
|
|
|
140
155
|
const cwd = config.workingDirectory ?? process.cwd()
|
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
ToolCallRepair,
|
|
34
34
|
ToolCallRepairReason,
|
|
35
35
|
} from '../../types/tool/repair.js'
|
|
36
|
+
import { abortReasonText } from '../../utils/abort.js'
|
|
36
37
|
import { toErrorMessage } from '../../utils/error.js'
|
|
37
38
|
import type { Logger } from '../../utils/logger.js'
|
|
38
39
|
import { compressShellOutput } from '../../utils/shell-compress.js'
|
|
@@ -859,10 +860,19 @@ export class ToolExecutor {
|
|
|
859
860
|
}
|
|
860
861
|
|
|
861
862
|
if (outcome === 'aborted') {
|
|
863
|
+
// Say WHY, when the caller said why. The reason has been
|
|
864
|
+
// available on this signal all along — it is forwarded into
|
|
865
|
+
// `controller` a few lines above — and the message threw it
|
|
866
|
+
// away, so a deadline, a budget and an operator pressing stop
|
|
867
|
+
// were all reported to the model with the same four words.
|
|
868
|
+
// Those want different next moves.
|
|
869
|
+
const why = abortReasonText(controller.signal.reason)
|
|
862
870
|
return {
|
|
863
871
|
success: false,
|
|
864
872
|
output: '',
|
|
865
|
-
error:
|
|
873
|
+
error: why
|
|
874
|
+
? `Tool "${toolName}" was cancelled: ${why}`
|
|
875
|
+
: `Tool "${toolName}" was cancelled.`,
|
|
866
876
|
}
|
|
867
877
|
}
|
|
868
878
|
|
|
@@ -317,6 +317,7 @@ export class IterationOrchestrator {
|
|
|
317
317
|
maxTokens: step.maxResponseTokens ?? runConfig.maxResponseTokens,
|
|
318
318
|
cacheControl: { type: 'auto' },
|
|
319
319
|
...(runConfig.thinking ? { thinking: runConfig.thinking } : {}),
|
|
320
|
+
...(runConfig.effort ? { effort: runConfig.effort } : {}),
|
|
320
321
|
// Thread the run abort into the model call so a Stop tears the
|
|
321
322
|
// in-flight turn down (provider passes it to fetch; the consumer
|
|
322
323
|
// also races it). Inert when never aborted.
|
|
@@ -1154,6 +1155,10 @@ export class IterationOrchestrator {
|
|
|
1154
1155
|
maxTokens: this.ctx.runConfig.maxResponseTokens,
|
|
1155
1156
|
cacheControl: { type: 'auto' },
|
|
1156
1157
|
...(this.ctx.runConfig.thinking ? { thinking: this.ctx.runConfig.thinking } : {}),
|
|
1158
|
+
// This turn is a hand-maintained duplicate of the one above, which
|
|
1159
|
+
// is exactly the shape a field goes missing from — so it is tested
|
|
1160
|
+
// separately rather than assumed to have been kept in step.
|
|
1161
|
+
...(this.ctx.runConfig.effort ? { effort: this.ctx.runConfig.effort } : {}),
|
|
1157
1162
|
// Cancellable too: a Stop during the closing summary must not
|
|
1158
1163
|
// stream to completion.
|
|
1159
1164
|
signal: this.ctx.abortController.signal,
|
|
@@ -209,16 +209,51 @@ export async function awaitDecisionDurably(
|
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
212
|
+
// The wait for "is this park slow enough to be worth writing down", and
|
|
213
|
+
// the reason it is a cancellable timer rather than a slept-through one.
|
|
214
|
+
//
|
|
215
|
+
// It used to `await sleep(delay)` where `sleep` created its timer and
|
|
216
|
+
// UNREF'D it, so a pending recorder could never hold a process open after
|
|
217
|
+
// the run settled. That is a real hazard and the intent was right, but the
|
|
218
|
+
// scope was wrong: this promise is awaited *during* the run, below, on
|
|
219
|
+
// every park. An unref'd timer does not keep Node's event loop alive — so
|
|
220
|
+
// once the decision resolved and the run sat here waiting out the rest of
|
|
221
|
+
// the delay, the loop had nothing ref'd left in it and the process exited.
|
|
222
|
+
// Mid-turn. Exit code 0. Nothing written, no error, no terminal event.
|
|
223
|
+
//
|
|
224
|
+
// That shipped, and it made the headless surfaces unable to finish a turn
|
|
225
|
+
// at all: the first tool call would complete and the process would end.
|
|
226
|
+
// Every test passed because a test runner holds the loop open for the
|
|
227
|
+
// whole file, which is exactly the kind of prop that hides this.
|
|
228
|
+
//
|
|
229
|
+
// Cancelling gets both properties. The timer is ref'd, so the run cannot
|
|
230
|
+
// be killed by its own wait; and it is cleared the moment the decision
|
|
231
|
+
// arrives, so nothing dangles past the run either.
|
|
232
|
+
let parkTimer: ReturnType<typeof setTimeout> | undefined
|
|
233
|
+
// Set SYNCHRONOUSLY when the write begins, because `recorded` only turns
|
|
234
|
+
// true after it finishes — waiting on that instead would skip a write that
|
|
235
|
+
// is still in flight and let the unpark below race it.
|
|
236
|
+
let recording = false
|
|
237
|
+
const recordIfSlow = new Promise<void>((resolve) => {
|
|
238
|
+
parkTimer = setTimeout(() => {
|
|
239
|
+
if (settled) {
|
|
240
|
+
resolve()
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
recording = true
|
|
244
|
+
record().then(resolve, resolve)
|
|
245
|
+
}, delay)
|
|
246
|
+
})
|
|
217
247
|
|
|
218
248
|
try {
|
|
219
249
|
const decision = await decisionPromise
|
|
220
250
|
settled = true
|
|
221
|
-
|
|
251
|
+
// Cancel the wait rather than sitting through it. If the timer already
|
|
252
|
+
// fired, `recordIfSlow` is the park write and is worth awaiting so the
|
|
253
|
+
// unpark below cannot race it; if it has not, there is nothing to wait
|
|
254
|
+
// for and clearing it is what lets the turn continue immediately.
|
|
255
|
+
if (parkTimer !== undefined) clearTimeout(parkTimer)
|
|
256
|
+
if (recording) await recordIfSlow
|
|
222
257
|
|
|
223
258
|
// `pause` is not an answer — it is "I am not answering now, hold
|
|
224
259
|
// this". It therefore ALWAYS gets recorded, even when it arrived too
|
|
@@ -249,15 +284,6 @@ export async function awaitDecisionDurably(
|
|
|
249
284
|
}
|
|
250
285
|
}
|
|
251
286
|
|
|
252
|
-
function sleep(ms: number): Promise<void> {
|
|
253
|
-
return new Promise((resolve) => {
|
|
254
|
-
const timer = setTimeout(resolve, ms)
|
|
255
|
-
// A pending park recorder must never be the reason a process stays
|
|
256
|
-
// alive after the run settles.
|
|
257
|
-
;(timer as { unref?: () => void }).unref?.()
|
|
258
|
-
})
|
|
259
|
-
}
|
|
260
|
-
|
|
261
287
|
/**
|
|
262
288
|
* Await a HITL `resumeHandler` decision, but RACE it against the run's abort
|
|
263
289
|
* signal. A Stop that arrives while the run is parked on a tool-review or
|
|
@@ -2,6 +2,10 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { MockLLMProvider } from '../../provider/mock.js'
|
|
6
|
+
import { ToolRegistry } from '../../registry/tool/execute.js'
|
|
7
|
+
import { drainQuery } from '../../runtime/query/index.js'
|
|
8
|
+
import { createUserMessage } from '../../types/message/index.js'
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
* There was no span around the model call at all.
|
|
@@ -72,12 +76,25 @@ afterEach(async () => {
|
|
|
72
76
|
workdirs = []
|
|
73
77
|
})
|
|
74
78
|
|
|
79
|
+
/**
|
|
80
|
+
* These four are imported at module scope on purpose, and must stay there.
|
|
81
|
+
*
|
|
82
|
+
* They used to be `await import(...)` inside this function, which meant the
|
|
83
|
+
* query runtime's module graph — 75 imports deep — was loaded on the clock of
|
|
84
|
+
* whichever test called it first. Measured: that test took 1349ms on an idle
|
|
85
|
+
* machine while its eight siblings took 13-16ms, and under CPU load it hit
|
|
86
|
+
* vitest's 5000ms default and went red. Nothing about it was racy and nothing
|
|
87
|
+
* about it was slow; a load cost was simply billed to the wrong clock.
|
|
88
|
+
*
|
|
89
|
+
* At module scope the same work happens during collection, which carries no
|
|
90
|
+
* per-test deadline, so the wall time is unchanged and the deadline is not.
|
|
91
|
+
*
|
|
92
|
+
* Safe because `vi.mock` is hoisted above every import in this file, so a
|
|
93
|
+
* static import still receives the mocked `runtime-accessors`. That is a
|
|
94
|
+
* property of the transform rather than of import order — verified by running,
|
|
95
|
+
* not assumed, since no other file in this package had done it this way.
|
|
96
|
+
*/
|
|
75
97
|
async function runOnce(turns: { text?: string }[]): Promise<void> {
|
|
76
|
-
const { MockLLMProvider } = await import('../../provider/mock.js')
|
|
77
|
-
const { ToolRegistry } = await import('../../registry/tool/execute.js')
|
|
78
|
-
const { drainQuery } = await import('../../runtime/query/index.js')
|
|
79
|
-
const { createUserMessage } = await import('../../types/message/index.js')
|
|
80
|
-
|
|
81
98
|
const dir = await mkdtemp(join(tmpdir(), 'namzu-chatspan-'))
|
|
82
99
|
workdirs.push(dir)
|
|
83
100
|
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { z } from 'zod'
|
|
3
|
+
// Imported at module scope on purpose, and must stay there.
|
|
4
|
+
//
|
|
5
|
+
// This was `await import(...)` repeated inside all five test bodies. That
|
|
6
|
+
// billed the tool-registry module graph to whichever test happened to run
|
|
7
|
+
// first, out of that test's own 5000ms deadline — and because every body
|
|
8
|
+
// re-entered the same pending import, a stall did not fail one test, it took
|
|
9
|
+
// the whole file down. At module scope the load happens during collection,
|
|
10
|
+
// which has no per-test deadline.
|
|
11
|
+
//
|
|
12
|
+
// `vi.mock` is hoisted above every import here, so the static form still
|
|
13
|
+
// receives the mocked `runtime-accessors`.
|
|
14
|
+
import { ToolRegistry } from '../../registry/tool/execute.js'
|
|
3
15
|
|
|
4
16
|
/**
|
|
5
17
|
* A span that never ends is a trace that never closes, and the export is
|
|
@@ -73,7 +85,6 @@ afterEach(() => {
|
|
|
73
85
|
|
|
74
86
|
describe('a tool span closes however the call leaves', () => {
|
|
75
87
|
it('closes on the ordinary path', async () => {
|
|
76
|
-
const { ToolRegistry } = await import('../../registry/tool/execute.js')
|
|
77
88
|
const tools = new ToolRegistry()
|
|
78
89
|
tools.register({
|
|
79
90
|
name: 'echo',
|
|
@@ -89,7 +100,6 @@ describe('a tool span closes however the call leaves', () => {
|
|
|
89
100
|
})
|
|
90
101
|
|
|
91
102
|
it('closes when the tool throws', async () => {
|
|
92
|
-
const { ToolRegistry } = await import('../../registry/tool/execute.js')
|
|
93
103
|
const tools = new ToolRegistry()
|
|
94
104
|
tools.register({
|
|
95
105
|
name: 'boom',
|
|
@@ -106,7 +116,6 @@ describe('a tool span closes however the call leaves', () => {
|
|
|
106
116
|
})
|
|
107
117
|
|
|
108
118
|
it('closes when input validation refuses the call', async () => {
|
|
109
|
-
const { ToolRegistry } = await import('../../registry/tool/execute.js')
|
|
110
119
|
const tools = new ToolRegistry()
|
|
111
120
|
tools.register({
|
|
112
121
|
name: 'strict',
|
|
@@ -121,7 +130,6 @@ describe('a tool span closes however the call leaves', () => {
|
|
|
121
130
|
})
|
|
122
131
|
|
|
123
132
|
it('closes when the tool is not active', async () => {
|
|
124
|
-
const { ToolRegistry } = await import('../../registry/tool/execute.js')
|
|
125
133
|
const tools = new ToolRegistry()
|
|
126
134
|
tools.register(
|
|
127
135
|
{
|
|
@@ -139,7 +147,6 @@ describe('a tool span closes however the call leaves', () => {
|
|
|
139
147
|
})
|
|
140
148
|
|
|
141
149
|
it('closes when the registry does not hold the name at all', async () => {
|
|
142
|
-
const { ToolRegistry } = await import('../../registry/tool/execute.js')
|
|
143
150
|
const tools = new ToolRegistry()
|
|
144
151
|
|
|
145
152
|
// `getOrThrow` sat OUTSIDE the try that owned the finally, so this
|