@namzu/sdk 52.2.0 → 52.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 +20 -0
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +3 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +18 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/connector/web/guarded-fetch.d.ts.map +1 -1
- package/dist/connector/web/guarded-fetch.js +44 -0
- package/dist/connector/web/guarded-fetch.js.map +1 -1
- package/dist/contracts/session/api.d.ts +1 -1
- package/dist/contracts/session/api.d.ts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pals/channels/routing.d.ts +1 -1
- package/dist/pals/channels/schema.d.ts +7 -7
- package/dist/pals/communication/ingress-schema.d.ts +32 -32
- package/dist/process/kill-tree.d.ts +3 -2
- package/dist/process/kill-tree.d.ts.map +1 -1
- package/dist/process/kill-tree.js.map +1 -1
- package/dist/public-runtime.d.ts +2 -2
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +1 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts +3 -1
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +6 -1
- package/dist/public-tools.js.map +1 -1
- package/dist/runtime/jobs/awaited-jobs.d.ts +59 -2
- package/dist/runtime/jobs/awaited-jobs.d.ts.map +1 -1
- package/dist/runtime/jobs/awaited-jobs.js +108 -5
- package/dist/runtime/jobs/awaited-jobs.js.map +1 -1
- package/dist/runtime/jobs/monitor-stream.d.ts +96 -0
- package/dist/runtime/jobs/monitor-stream.d.ts.map +1 -0
- package/dist/runtime/jobs/monitor-stream.js +221 -0
- package/dist/runtime/jobs/monitor-stream.js.map +1 -0
- package/dist/runtime/jobs/registry.d.ts +175 -1
- package/dist/runtime/jobs/registry.d.ts.map +1 -1
- package/dist/runtime/jobs/registry.js +267 -19
- package/dist/runtime/jobs/registry.js.map +1 -1
- package/dist/runtime/jobs/websocket-source.d.ts +120 -0
- package/dist/runtime/jobs/websocket-source.d.ts.map +1 -0
- package/dist/runtime/jobs/websocket-source.js +379 -0
- package/dist/runtime/jobs/websocket-source.js.map +1 -0
- package/dist/runtime/query/executor.d.ts +9 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +6 -0
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts +16 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +93 -3
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/outstanding-work.d.ts +14 -0
- package/dist/runtime/query/iteration/outstanding-work.d.ts.map +1 -1
- package/dist/runtime/query/iteration/outstanding-work.js +64 -3
- package/dist/runtime/query/iteration/outstanding-work.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +9 -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 +11 -2
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/release-turn.d.ts.map +1 -1
- package/dist/runtime/query/release-turn.js +35 -0
- package/dist/runtime/query/release-turn.js.map +1 -1
- package/dist/runtime/query/steering.d.ts +35 -0
- package/dist/runtime/query/steering.d.ts.map +1 -1
- package/dist/runtime/query/steering.js +134 -0
- package/dist/runtime/query/steering.js.map +1 -1
- package/dist/runtime/query/tooling.d.ts +4 -0
- package/dist/runtime/query/tooling.d.ts.map +1 -1
- package/dist/runtime/query/tooling.js +4 -0
- package/dist/runtime/query/tooling.js.map +1 -1
- package/dist/runtime/system-events.d.ts +9 -0
- package/dist/runtime/system-events.d.ts.map +1 -1
- package/dist/runtime/system-events.js +12 -0
- package/dist/runtime/system-events.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts +3 -0
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +3 -2
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/index.d.ts +1 -0
- package/dist/tools/builtins/index.d.ts.map +1 -1
- package/dist/tools/builtins/index.js +1 -0
- package/dist/tools/builtins/index.js.map +1 -1
- package/dist/tools/builtins/job.d.ts.map +1 -1
- package/dist/tools/builtins/job.js +6 -2
- package/dist/tools/builtins/job.js.map +1 -1
- package/dist/tools/builtins/monitor.d.ts +58 -0
- package/dist/tools/builtins/monitor.d.ts.map +1 -0
- package/dist/tools/builtins/monitor.js +306 -0
- package/dist/tools/builtins/monitor.js.map +1 -0
- package/dist/tools/builtins/wait-for-job.d.ts +1 -1
- package/dist/turn/reporter.d.ts.map +1 -1
- package/dist/turn/reporter.js +8 -0
- package/dist/turn/reporter.js.map +1 -1
- package/dist/types/sandbox/index.d.ts +10 -0
- package/dist/types/sandbox/index.d.ts.map +1 -1
- package/dist/types/sandbox/index.js.map +1 -1
- package/dist/types/session/events.d.ts +30 -4
- package/dist/types/session/events.d.ts.map +1 -1
- package/dist/types/session/events.js +6 -1
- package/dist/types/session/events.js.map +1 -1
- package/dist/types/session/records.d.ts +2 -2
- package/dist/types/session/records.d.ts.map +1 -1
- package/dist/types/session/records.js +3 -2
- package/dist/types/session/records.js.map +1 -1
- package/dist/types/tool/index.d.ts +60 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/package.json +4 -2
- package/src/bridge/a2a/mapper.ts +3 -0
- package/src/bridge/sse/mapper.ts +18 -0
- package/src/connector/web/guarded-fetch.ts +41 -0
- package/src/contracts/session/api.ts +1 -0
- package/src/index.ts +23 -0
- package/src/process/kill-tree.ts +4 -2
- package/src/public-runtime.ts +4 -1
- package/src/public-tools.ts +14 -0
- package/src/runtime/jobs/awaited-jobs.ts +127 -5
- package/src/runtime/jobs/monitor-stream.ts +269 -0
- package/src/runtime/jobs/registry.ts +437 -23
- package/src/runtime/jobs/websocket-source.ts +438 -0
- package/src/runtime/query/executor.ts +15 -0
- package/src/runtime/query/index.ts +116 -3
- package/src/runtime/query/iteration/outstanding-work.ts +64 -3
- package/src/runtime/query/iteration/phases/context.ts +9 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +21 -9
- package/src/runtime/query/release-turn.ts +33 -0
- package/src/runtime/query/steering.ts +170 -0
- package/src/runtime/query/tooling.ts +8 -0
- package/src/runtime/system-events.ts +17 -0
- package/src/tools/builtins/bash.ts +3 -2
- package/src/tools/builtins/index.ts +6 -0
- package/src/tools/builtins/job.ts +11 -2
- package/src/tools/builtins/monitor.ts +376 -0
- package/src/turn/reporter.ts +8 -0
- package/src/types/sandbox/index.ts +10 -0
- package/src/types/session/events.ts +31 -4
- package/src/types/session/records.ts +3 -2
- package/src/types/tool/index.ts +51 -1
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
MONITOR_TIMEOUT_DEFAULT_MS,
|
|
5
|
+
MONITOR_TIMEOUT_MAX_MS,
|
|
6
|
+
MONITOR_TIMEOUT_MIN_MS,
|
|
7
|
+
formatMonitorDuration,
|
|
8
|
+
} from '../../runtime/jobs/monitor-stream.js'
|
|
9
|
+
import {
|
|
10
|
+
WEBSOCKET_PROTOCOLS_MAX,
|
|
11
|
+
WebSocketRefusedError,
|
|
12
|
+
type WebSocketVetOptions,
|
|
13
|
+
vetWebSocketTarget,
|
|
14
|
+
webSocketHostname,
|
|
15
|
+
webSocketProtocolsProblem,
|
|
16
|
+
webSocketSandboxRefusal,
|
|
17
|
+
webSocketUrlProblem,
|
|
18
|
+
} from '../../runtime/jobs/websocket-source.js'
|
|
19
|
+
import type { MonitorWebSocketTarget } from '../../types/tool/index.js'
|
|
20
|
+
import {
|
|
21
|
+
bashToolDialect,
|
|
22
|
+
hostCommandShell,
|
|
23
|
+
isWindowsBash,
|
|
24
|
+
rewriteWindowsNullRedirect,
|
|
25
|
+
} from '../command-shell.js'
|
|
26
|
+
import { defineTool } from '../defineTool.js'
|
|
27
|
+
import {
|
|
28
|
+
DISABLE_BACKGROUND_TASKS_ENV,
|
|
29
|
+
backgroundTasksDisabled,
|
|
30
|
+
isDangerousCommand,
|
|
31
|
+
} from './bash.js'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Watch a command's output while the model keeps working.
|
|
35
|
+
*
|
|
36
|
+
* `bash run_in_background` plus `wait_for_job` answers "tell me when this
|
|
37
|
+
* finishes", and nothing answered "tell me when something happens in it": a
|
|
38
|
+
* log line, a failed check, a changed status. The only alternative was a
|
|
39
|
+
* polling loop, which spends a full context resend per look and is silent
|
|
40
|
+
* between looks. This tool starts the command as a job the registry owns and
|
|
41
|
+
* turns each line it prints on stdout into an event the model reads on its
|
|
42
|
+
* next tool result (or wakes it, when it has stopped) — see
|
|
43
|
+
* `runtime/jobs/monitor-stream.ts` for how lines become batches and why a
|
|
44
|
+
* source that prints too much is stopped.
|
|
45
|
+
*
|
|
46
|
+
* It is a different tool from `bash`, and not a flag on it, for the same
|
|
47
|
+
* reason `wait_for_job` is: the model chooses by intent. The permission story
|
|
48
|
+
* is bash's, line for line — a monitor runs a shell command — so the rules
|
|
49
|
+
* read the command the way they read `bash`'s, and it is never read-only. It
|
|
50
|
+
* has no way out of the sandbox: a watch that outlives one approved call must
|
|
51
|
+
* not run outside the boundary that call was approved under.
|
|
52
|
+
*
|
|
53
|
+
* A source can also be a WebSocket (`ws`), for services that push their events
|
|
54
|
+
* and offer no command that prints them. That one is a different thing to
|
|
55
|
+
* approve: it is not a shell line, it opens a connection FROM THE HOST to an
|
|
56
|
+
* address the model chose, and it does so outside any sandbox. So it is read
|
|
57
|
+
* by no command rule, it is always reviewed, a sandbox that blocks the network
|
|
58
|
+
* refuses it, and the address is checked and pinned (see
|
|
59
|
+
* `runtime/jobs/websocket-source.ts`). Untrusted frames reach the model the way
|
|
60
|
+
* a command's lines do: through the monitor event path, in its untrusted frame.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
const webSocketInput = z.object({
|
|
64
|
+
url: z
|
|
65
|
+
.string()
|
|
66
|
+
.superRefine((value, ctx) => {
|
|
67
|
+
const problem = webSocketUrlProblem(value)
|
|
68
|
+
if (problem !== undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: problem })
|
|
69
|
+
})
|
|
70
|
+
.describe(
|
|
71
|
+
'The ws:// or wss:// URL to connect to. A query string is kept; credentials in it are refused.',
|
|
72
|
+
),
|
|
73
|
+
protocols: z
|
|
74
|
+
.array(z.string())
|
|
75
|
+
.max(WEBSOCKET_PROTOCOLS_MAX)
|
|
76
|
+
.superRefine((value, ctx) => {
|
|
77
|
+
const problem = webSocketProtocolsProblem(value)
|
|
78
|
+
if (problem !== undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: problem })
|
|
79
|
+
})
|
|
80
|
+
.optional()
|
|
81
|
+
.describe('Subprotocol names to offer, when the service asks for one.'),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const inputSchema = z
|
|
85
|
+
.object({
|
|
86
|
+
description: z
|
|
87
|
+
.string()
|
|
88
|
+
.min(1)
|
|
89
|
+
.max(200)
|
|
90
|
+
.describe(
|
|
91
|
+
'What you are watching, in a few specific words. It is shown with every event ("Monitor event: <description>"), so make it identify this watch among several: "errors in deploy log", not "monitor".',
|
|
92
|
+
),
|
|
93
|
+
command: z
|
|
94
|
+
.string()
|
|
95
|
+
.min(1)
|
|
96
|
+
// Same rewrite as `bash`, for the same reason: the rules read the line a
|
|
97
|
+
// shell will run.
|
|
98
|
+
.transform((command) =>
|
|
99
|
+
isWindowsBash(hostCommandShell()) ? rewriteWindowsNullRedirect(command) : command,
|
|
100
|
+
)
|
|
101
|
+
.optional()
|
|
102
|
+
.describe(
|
|
103
|
+
'The shell command to watch. Each line it prints to stdout becomes an event. It runs until it ends or the timeout; it must not need stdin. Give this or ws, not both.',
|
|
104
|
+
),
|
|
105
|
+
ws: webSocketInput
|
|
106
|
+
.optional()
|
|
107
|
+
.describe(
|
|
108
|
+
'Watch a WebSocket instead of a command: each line of each text frame the server sends becomes an event. Give this or command, not both.',
|
|
109
|
+
),
|
|
110
|
+
timeout_ms: z
|
|
111
|
+
.preprocess(
|
|
112
|
+
(v) => (typeof v === 'string' ? Number(v) : v),
|
|
113
|
+
z
|
|
114
|
+
.number()
|
|
115
|
+
.int()
|
|
116
|
+
.min(MONITOR_TIMEOUT_MIN_MS)
|
|
117
|
+
.max(MONITOR_TIMEOUT_MAX_MS)
|
|
118
|
+
.default(MONITOR_TIMEOUT_DEFAULT_MS),
|
|
119
|
+
)
|
|
120
|
+
.describe(
|
|
121
|
+
`How long to watch, in milliseconds. Default ${MONITOR_TIMEOUT_DEFAULT_MS}. The host caps it (30 minutes by default; shorter in a single-shot run), and a larger value is lowered to that cap without an error; the result states the time actually granted.`,
|
|
122
|
+
),
|
|
123
|
+
})
|
|
124
|
+
.refine((input) => (input.command === undefined) !== (input.ws === undefined), {
|
|
125
|
+
message: 'Give exactly one of command and ws.',
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
type MonitorInput = z.infer<typeof inputSchema>
|
|
129
|
+
|
|
130
|
+
export const MONITOR_TOOL_NAME = 'monitor'
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What a sandbox without `spawnDetached` is told. The wording of `bash`'s own
|
|
134
|
+
* refusal names `run_in_background` and `timeout`, neither of which is this
|
|
135
|
+
* tool's, so it is restated here; the substance is the same and so is the
|
|
136
|
+
* rule: the watch is never run on the host to get around the sandbox.
|
|
137
|
+
*/
|
|
138
|
+
export const MONITOR_CANNOT_DETACH =
|
|
139
|
+
'monitor is unavailable: this sandbox cannot start a detached process, and the watch will not be run on the host to get around it. Run the command in the foreground with bash, with a timeout.'
|
|
140
|
+
|
|
141
|
+
const MONITOR_DESCRIPTION =
|
|
142
|
+
'Starts a command and tells you about each line it prints to stdout, as it prints it, while you keep working. Lines that arrive within 200 ms come together as one event. Use it to watch a log, a test or deploy run, a status endpoint — anything where you must react to something happening INSIDE a long-running command. For ONE notification when something finishes, do not use this: run bash with run_in_background and wait_for_job. Every stage of a pipe must flush per line or events arrive late or never (grep --line-buffered, awk with fflush(), sed -u; `head` cannot). Make the filter match failure as well as success (crash, Traceback, Error, FAILED, Killed…): silence from a monitor is not success. Poll a remote API no faster than every 30 seconds. stderr is not an event; read it with job. A monitor that prints too much is stopped, so filter at the source. It expires after the timeout (default 5 minutes, at most 30) and you get one notice; start it again if you still need it. Stop one early with job {"action":"kill","id":"<id>"}. ' +
|
|
143
|
+
'To watch a service that PUSHES events (a CI or log stream, a change feed) give ws instead of command: { "url": "wss://…", "protocols": [...] }. Each line of each text frame becomes an event, binary frames are only counted, and the watch ends when the server closes the connection. Use it for a push stream, not for request and response. It connects from this machine, so private and local addresses are refused, and you are asked before it connects.'
|
|
144
|
+
|
|
145
|
+
/** What a host can tune about the WebSocket source; for tests only, see {@link WebSocketVetOptions}. */
|
|
146
|
+
export interface MonitorToolOptions {
|
|
147
|
+
readonly webSocket?: WebSocketVetOptions
|
|
148
|
+
/**
|
|
149
|
+
* How to turn web access on, in the host's own words, appended to the
|
|
150
|
+
* refusal a WebSocket call gets while it is off. The SDK cannot name a
|
|
151
|
+
* switch it does not own (a CLI config key, a desktop setting), and a
|
|
152
|
+
* refusal that only says "off" leaves the operator guessing. One short
|
|
153
|
+
* sentence; it is shown to the model and the operator as written.
|
|
154
|
+
*/
|
|
155
|
+
readonly webAccessHint?: string
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The text that says a WebSocket watch was refused because the host has web access off. */
|
|
159
|
+
export const MONITOR_WEBSOCKET_WEB_OFF =
|
|
160
|
+
"WebSocket monitors follow the host's web access setting, and web access is switched off here, so no connection was opened. Watch the service with a command (for example curl or websocat through monitor) if the operator allows it."
|
|
161
|
+
|
|
162
|
+
const MONITOR_WEBSOCKET_NOT_SUPPORTED =
|
|
163
|
+
'This host cannot start a WebSocket monitor, so no connection was opened. Watch the service with a command through monitor if the operator allows it.'
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The tool, built once with defaults for a host and again by a test that needs
|
|
167
|
+
* to reach a server on this machine. The options belong to whoever builds the
|
|
168
|
+
* tool; nothing a model sends can set them.
|
|
169
|
+
*/
|
|
170
|
+
export function createMonitorTool(options: MonitorToolOptions = {}) {
|
|
171
|
+
return defineTool({
|
|
172
|
+
name: MONITOR_TOOL_NAME,
|
|
173
|
+
description: MONITOR_DESCRIPTION,
|
|
174
|
+
inputSchema,
|
|
175
|
+
category: 'shell',
|
|
176
|
+
// Bash's, because it IS a shell command: the gate reads the line, splits
|
|
177
|
+
// chains, and applies the operator's rules to each part. A WebSocket call
|
|
178
|
+
// has no `command`, so a rule written for commands has nothing to match
|
|
179
|
+
// and cannot allow it; `requiresApproval` below is what asks.
|
|
180
|
+
commandArgument: 'command',
|
|
181
|
+
commandDialect: bashToolDialect,
|
|
182
|
+
// `network_access` too: a WebSocket call reaches the network. The gate
|
|
183
|
+
// reads a tool's `category`, not this list, to route a call, so declaring
|
|
184
|
+
// it changes no decision for a command; it says what the tool may do.
|
|
185
|
+
permissions: ['shell_execute', 'network_access'],
|
|
186
|
+
readOnly: false,
|
|
187
|
+
destructive: (input: MonitorInput) =>
|
|
188
|
+
input.command !== undefined && isDangerousCommand(input.command),
|
|
189
|
+
// A WebSocket call is the one input-aware seam the gate offers that no
|
|
190
|
+
// allow rule or mode outranks: it connects from the host, outside any
|
|
191
|
+
// sandbox, to an address the model chose. `category: 'shell'` is what the
|
|
192
|
+
// sandboxed-shell preset auto-approves, and moving the whole tool to
|
|
193
|
+
// `network` would start asking about commands that were never a question;
|
|
194
|
+
// this asks only about the connection. It is stronger than "review where
|
|
195
|
+
// the mode reviews non-read-only calls": `auto` mode and an unattended
|
|
196
|
+
// turn are asked too (or refuse when nobody can be asked), which is the
|
|
197
|
+
// point for a connection that leaves the boundary those modes rely on.
|
|
198
|
+
requiresApproval: (input: MonitorInput) => input.ws !== undefined,
|
|
199
|
+
// Starting a monitor registers a job against the owner's cap and its id
|
|
200
|
+
// is then named in later calls; two at once could race the cap, as they
|
|
201
|
+
// can for a background `bash`.
|
|
202
|
+
concurrencySafe: false,
|
|
203
|
+
|
|
204
|
+
presentCall(input) {
|
|
205
|
+
const label = typeof input.description === 'string' ? input.description : undefined
|
|
206
|
+
if (input.ws !== undefined && typeof input.ws === 'object') {
|
|
207
|
+
const ws = input.ws as { url?: unknown; protocols?: unknown }
|
|
208
|
+
if (typeof ws.url !== 'string') return undefined
|
|
209
|
+
const protocols =
|
|
210
|
+
Array.isArray(ws.protocols) && ws.protocols.length > 0
|
|
211
|
+
? ` (subprotocols: ${ws.protocols.join(', ')})`
|
|
212
|
+
: ''
|
|
213
|
+
// What will happen, said plainly, because this is what a person
|
|
214
|
+
// approves: no command line stands in for it.
|
|
215
|
+
return {
|
|
216
|
+
kind: 'generic',
|
|
217
|
+
label: `Monitor${label ? ` · ${label}` : ''} · WebSocket ${ws.url}${protocols}`,
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (typeof input.command !== 'string') return undefined
|
|
221
|
+
return {
|
|
222
|
+
kind: 'generic',
|
|
223
|
+
label: `Monitor · ${label ?? input.command}`,
|
|
224
|
+
facts: { kind: 'command', command: input.command, background: true },
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
async execute(input, context) {
|
|
229
|
+
if (input.command !== undefined && isDangerousCommand(input.command)) {
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
output: '',
|
|
233
|
+
error: `Dangerous command blocked: "${input.command}"`,
|
|
234
|
+
facts: { kind: 'failure', reason: 'blocked', subject: MONITOR_TOOL_NAME },
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (backgroundTasksDisabled()) {
|
|
238
|
+
return {
|
|
239
|
+
success: false,
|
|
240
|
+
output: '',
|
|
241
|
+
error: `Background jobs are switched off here (${DISABLE_BACKGROUND_TASKS_ENV}), and a monitor is one. Check in the foreground with bash instead.`,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// What is checked about a connection is checked before anything that
|
|
245
|
+
// speaks of commands, so a refusal names its own cause.
|
|
246
|
+
let webSocket: MonitorWebSocketTarget | undefined
|
|
247
|
+
if (input.ws !== undefined) {
|
|
248
|
+
// The operator's switch for reaching the web at all. A host that has
|
|
249
|
+
// not given this turn a web capability has not decided to let the
|
|
250
|
+
// model open connections, and a WebSocket is one.
|
|
251
|
+
if (context.web?.fetch === undefined) {
|
|
252
|
+
return {
|
|
253
|
+
success: false,
|
|
254
|
+
output: '',
|
|
255
|
+
error: options.webAccessHint
|
|
256
|
+
? `${MONITOR_WEBSOCKET_WEB_OFF} ${options.webAccessHint}`
|
|
257
|
+
: MONITOR_WEBSOCKET_WEB_OFF,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const hostname = webSocketHostname(input.ws.url)
|
|
261
|
+
const sandboxRefusal = webSocketSandboxRefusal(context.sandbox, hostname)
|
|
262
|
+
if (sandboxRefusal !== undefined) {
|
|
263
|
+
return { success: false, output: '', error: sandboxRefusal }
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const addresses = await vetWebSocketTarget(input.ws.url, options.webSocket)
|
|
267
|
+
webSocket = {
|
|
268
|
+
url: input.ws.url,
|
|
269
|
+
...(input.ws.protocols ? { protocols: input.ws.protocols } : {}),
|
|
270
|
+
addresses,
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (err instanceof WebSocketRefusedError) {
|
|
274
|
+
return { success: false, output: '', error: err.message }
|
|
275
|
+
}
|
|
276
|
+
throw err
|
|
277
|
+
}
|
|
278
|
+
} else if (context.sandbox && context.sandbox.spawnDetached === undefined) {
|
|
279
|
+
// Same rule as background bash: the executor withholds the registry
|
|
280
|
+
// from a sandbox that cannot start a detached process, and this check
|
|
281
|
+
// is for the direct caller who hands both.
|
|
282
|
+
return { success: false, output: '', error: MONITOR_CANNOT_DETACH }
|
|
283
|
+
}
|
|
284
|
+
const jobs = context.backgroundJobs
|
|
285
|
+
if (!jobs) {
|
|
286
|
+
return {
|
|
287
|
+
success: false,
|
|
288
|
+
output: '',
|
|
289
|
+
error:
|
|
290
|
+
'monitor was requested, but this host provides no background job registry. Run the command in the foreground with bash, with a timeout.',
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (webSocket !== undefined && !jobs.startWebSocketMonitor) {
|
|
294
|
+
return {
|
|
295
|
+
success: false,
|
|
296
|
+
output: '',
|
|
297
|
+
error: MONITOR_WEBSOCKET_NOT_SUPPORTED,
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (webSocket === undefined && !jobs.startMonitor) {
|
|
301
|
+
return {
|
|
302
|
+
success: false,
|
|
303
|
+
output: '',
|
|
304
|
+
error:
|
|
305
|
+
'This host cannot deliver monitor events, so no watch was started. Use bash with run_in_background and wait_for_job to be told when a command finishes.',
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const common = {
|
|
311
|
+
description: input.description,
|
|
312
|
+
timeoutMs: input.timeout_ms,
|
|
313
|
+
workingDirectory: context.workingDirectory,
|
|
314
|
+
}
|
|
315
|
+
const { job, timeoutMs } =
|
|
316
|
+
webSocket !== undefined
|
|
317
|
+
? (jobs.startWebSocketMonitor as NonNullable<typeof jobs.startWebSocketMonitor>)({
|
|
318
|
+
...common,
|
|
319
|
+
webSocket,
|
|
320
|
+
})
|
|
321
|
+
: (jobs.startMonitor as NonNullable<typeof jobs.startMonitor>)({
|
|
322
|
+
...common,
|
|
323
|
+
command: input.command as string,
|
|
324
|
+
})
|
|
325
|
+
// `startMonitor` returns before the process exists, and a spawn that
|
|
326
|
+
// fails reports a tick later; wait for that one event so "started" is
|
|
327
|
+
// never said of a watch that could not start.
|
|
328
|
+
const startup = await jobs.awaitStarted?.(job.id)
|
|
329
|
+
if (startup?.error !== undefined) {
|
|
330
|
+
return {
|
|
331
|
+
success: false,
|
|
332
|
+
output: '',
|
|
333
|
+
error: `${startup.error} (monitor ${job.id} did not run).`,
|
|
334
|
+
data: { jobId: job.id, monitor: true, startFailed: true },
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (webSocket !== undefined) {
|
|
338
|
+
return {
|
|
339
|
+
success: true,
|
|
340
|
+
output: `WebSocket monitor started (job ${job.id}, expires in ${formatMonitorDuration(timeoutMs)} unless the server closes the connection first; you get one notice at expiry — start it again if you still need the watch). Each line of each text frame the server sends reaches you as an event while you keep working; binary frames are only counted. The connection opens in the background: if it fails you are told in an event. Do not poll or sleep. Stop it with job {"action":"kill","id":"${job.id}"}. An event may arrive while you are waiting for the user — it is not their reply.`,
|
|
341
|
+
data: { jobId: job.id, monitor: true, timeoutMs, transport: 'websocket' },
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
success: true,
|
|
346
|
+
output: `Monitor started (job ${job.id}, expires in ${formatMonitorDuration(timeoutMs)} unless the command ends first; you get one notice at expiry — start it again if you still need the watch). Each line the command prints to stdout reaches you as an event while you keep working; do not poll or sleep. Stop it with job {"action":"kill","id":"${job.id}"}. An event may arrive while you are waiting for the user — it is not their reply.`,
|
|
347
|
+
data: { jobId: job.id, monitor: true, timeoutMs },
|
|
348
|
+
facts: { kind: 'command', command: input.command as string, background: true },
|
|
349
|
+
}
|
|
350
|
+
} catch (err) {
|
|
351
|
+
// The per-owner cap, most likely; naming the limit is actionable.
|
|
352
|
+
return {
|
|
353
|
+
success: false,
|
|
354
|
+
output: '',
|
|
355
|
+
error: err instanceof Error ? err.message : String(err),
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export const MonitorTool = createMonitorTool()
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The tool, for a host that opts in.
|
|
366
|
+
*
|
|
367
|
+
* Not in `getBuiltinTools()`: a watch needs a host that delivers events and
|
|
368
|
+
* wakes on them, and offering a tool that starts a job nobody will hear from
|
|
369
|
+
* is worse than not offering it. A function rather than a constant so a host
|
|
370
|
+
* can decide per turn, and so it reads like `getCodeAnalysisTools`, beside
|
|
371
|
+
* which a host mounts it deferred. Called with no options it returns the
|
|
372
|
+
* shared {@link MonitorTool}.
|
|
373
|
+
*/
|
|
374
|
+
export function getMonitorTools(options?: MonitorToolOptions): (typeof MonitorTool)[] {
|
|
375
|
+
return [options === undefined ? MonitorTool : createMonitorTool(options)]
|
|
376
|
+
}
|
package/src/turn/reporter.ts
CHANGED
|
@@ -309,6 +309,14 @@ export function createTurnReporter(parentLogger?: Logger): TurnReporter {
|
|
|
309
309
|
...(event.exitCode !== undefined ? { 'namzu.jobs.exit_code': event.exitCode } : {}),
|
|
310
310
|
})
|
|
311
311
|
break
|
|
312
|
+
case 'monitor_event':
|
|
313
|
+
log.debug('Monitor event', {
|
|
314
|
+
'namzu.jobs.id': event.jobId,
|
|
315
|
+
'namzu.monitor.event_id': event.eventId,
|
|
316
|
+
'namzu.monitor.kind': event.kind,
|
|
317
|
+
'namzu.monitor.chars': event.text.length,
|
|
318
|
+
})
|
|
319
|
+
break
|
|
312
320
|
case 'memory_consolidated':
|
|
313
321
|
log.info('Turn learnings consolidated into the memory store', {
|
|
314
322
|
'namzu.memory.id': event.memoryId,
|
|
@@ -334,6 +334,16 @@ export interface Sandbox {
|
|
|
334
334
|
readonly status: SandboxStatus
|
|
335
335
|
readonly rootDir: string
|
|
336
336
|
readonly environment: SandboxEnvironment
|
|
337
|
+
/**
|
|
338
|
+
* The network policy in force right now, when the backend can say.
|
|
339
|
+
*
|
|
340
|
+
* Optional, and absent does NOT mean open: a caller that needs to know
|
|
341
|
+
* whether a host is reachable (the host process opening a connection on a
|
|
342
|
+
* turn's behalf, outside the boundary) treats absence as "not allowed" on a
|
|
343
|
+
* tier that isolates the network. A backend that enforces
|
|
344
|
+
* {@link setNetworkPolicy} should report what it enforces here.
|
|
345
|
+
*/
|
|
346
|
+
readonly networkPolicy?: SandboxNetworkPolicy
|
|
337
347
|
exec(command: string, args?: string[], opts?: SandboxExecOptions): Promise<SandboxExecResult>
|
|
338
348
|
|
|
339
349
|
/**
|
|
@@ -612,6 +612,28 @@ type CoreSessionEvent =
|
|
|
612
612
|
status: 'exited' | 'killed'
|
|
613
613
|
exitCode?: number
|
|
614
614
|
signal?: string
|
|
615
|
+
/** Present when the job was a monitor: what it watched and, if the registry stopped it, why. */
|
|
616
|
+
monitor?: {
|
|
617
|
+
description: string
|
|
618
|
+
stopReason?: 'expired' | 'too_much_output' | 'stopped'
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* A monitor this session (or the owner it runs under) started has
|
|
623
|
+
* something to say: a batch of its stdout lines, or a notice from the
|
|
624
|
+
* monitor itself (events suppressed, stopped, expired). The model reads it
|
|
625
|
+
* on its next tool result; the host learns it from this, whether or not a
|
|
626
|
+
* turn is running. `eventId` is what a host acknowledges.
|
|
627
|
+
*/
|
|
628
|
+
| {
|
|
629
|
+
type: 'monitor_event'
|
|
630
|
+
sessionId: SessionId
|
|
631
|
+
turnId?: TurnId
|
|
632
|
+
jobId: string
|
|
633
|
+
eventId: string
|
|
634
|
+
description: string
|
|
635
|
+
kind: 'output' | 'notice'
|
|
636
|
+
text: string
|
|
615
637
|
}
|
|
616
638
|
/**
|
|
617
639
|
* What the turn learned was written to the host's memory store: its
|
|
@@ -1229,7 +1251,7 @@ export type ChildSessionLifecycleEvent =
|
|
|
1229
1251
|
| ChildSessionIdledEvent
|
|
1230
1252
|
|
|
1231
1253
|
/**
|
|
1232
|
-
* Discriminated union of every live session event:
|
|
1254
|
+
* Discriminated union of every live session event: 63 type literals.
|
|
1233
1255
|
*
|
|
1234
1256
|
* `type` is the sole discriminator for exhaustive switches; envelope fields
|
|
1235
1257
|
* are additive and never take part in discrimination.
|
|
@@ -1247,21 +1269,26 @@ export type SessionEventListener = (event: SessionEvent) => void | Promise<void>
|
|
|
1247
1269
|
/**
|
|
1248
1270
|
* Event types whose volume makes durable persistence wasteful. They reach the
|
|
1249
1271
|
* live stream and never the session log: the completed message, reasoning
|
|
1250
|
-
* block and tool input carry the same content once.
|
|
1272
|
+
* block and tool input carry the same content once. A `monitor_event` is the
|
|
1273
|
+
* same case: the text the model read is already in the transcript as the
|
|
1274
|
+
* runtime-context message or tool result that carried it, and a noisy watch
|
|
1275
|
+
* would write tens of megabytes an hour of raw process output a second time.
|
|
1276
|
+
* `background_job_exited.monitor` is the durable record of how a watch ended.
|
|
1251
1277
|
*/
|
|
1252
1278
|
export const EPHEMERAL_EVENT_TYPES: ReadonlySet<SessionEventType> = new Set<SessionEventType>([
|
|
1253
1279
|
'text_delta',
|
|
1254
1280
|
'tool_input_delta',
|
|
1255
1281
|
'reasoning_delta',
|
|
1256
1282
|
'tool_progress',
|
|
1283
|
+
'monitor_event',
|
|
1257
1284
|
])
|
|
1258
1285
|
|
|
1259
1286
|
export function isEphemeralEvent(event: { readonly type: string }): boolean {
|
|
1260
1287
|
return EPHEMERAL_EVENT_TYPES.has(event.type as SessionEventType)
|
|
1261
1288
|
}
|
|
1262
1289
|
|
|
1263
|
-
/** The event types a session log records: every literal except the ephemeral
|
|
1290
|
+
/** The event types a session log records: every literal except the ephemeral five. */
|
|
1264
1291
|
export type PersistedSessionEventType = Exclude<
|
|
1265
1292
|
SessionEventType,
|
|
1266
|
-
'text_delta' | 'tool_input_delta' | 'reasoning_delta' | 'tool_progress'
|
|
1293
|
+
'text_delta' | 'tool_input_delta' | 'reasoning_delta' | 'tool_progress' | 'monitor_event'
|
|
1267
1294
|
>
|
|
@@ -648,6 +648,7 @@ const SESSION_EVENT_TYPE_TABLE: Record<SessionEventType, true> = {
|
|
|
648
648
|
turn_resuming: true,
|
|
649
649
|
guardrail_triggered: true,
|
|
650
650
|
background_job_exited: true,
|
|
651
|
+
monitor_event: true,
|
|
651
652
|
memory_consolidated: true,
|
|
652
653
|
turn_completed: true,
|
|
653
654
|
turn_failed: true,
|
|
@@ -687,12 +688,12 @@ const SESSION_EVENT_TYPE_TABLE: Record<SessionEventType, true> = {
|
|
|
687
688
|
child_session_idled: true,
|
|
688
689
|
}
|
|
689
690
|
|
|
690
|
-
/** All
|
|
691
|
+
/** All 63 live event type literals. */
|
|
691
692
|
export const SESSION_EVENT_TYPES: readonly SessionEventType[] = Object.freeze(
|
|
692
693
|
Object.keys(SESSION_EVENT_TYPE_TABLE) as SessionEventType[],
|
|
693
694
|
)
|
|
694
695
|
|
|
695
|
-
/** The 58 event types a session log records. */
|
|
696
|
+
/** The 58 event types a session log records: all 63 but the five ephemeral ones. */
|
|
696
697
|
export const PERSISTED_SESSION_EVENT_TYPES: readonly PersistedSessionEventType[] = Object.freeze(
|
|
697
698
|
SESSION_EVENT_TYPES.filter(
|
|
698
699
|
(type) => !EPHEMERAL_EVENT_TYPES.has(type),
|
package/src/types/tool/index.ts
CHANGED
|
@@ -105,6 +105,20 @@ export interface SkillRegistryRef {
|
|
|
105
105
|
names(): readonly string[]
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Where a WebSocket monitor connects, already checked.
|
|
110
|
+
*
|
|
111
|
+
* `addresses` are what the caller verified are outside the host's own
|
|
112
|
+
* network; the connection is pinned to them and never resolves the name
|
|
113
|
+
* itself. A caller that did not check must not hand it a target: the registry
|
|
114
|
+
* trusts it, as it trusts a spawn it is given.
|
|
115
|
+
*/
|
|
116
|
+
export interface MonitorWebSocketTarget {
|
|
117
|
+
readonly url: string
|
|
118
|
+
readonly protocols?: readonly string[]
|
|
119
|
+
readonly addresses: readonly { readonly address: string; readonly family: 4 | 6 }[]
|
|
120
|
+
}
|
|
121
|
+
|
|
108
122
|
/**
|
|
109
123
|
* The slice of the background job registry a tool is given.
|
|
110
124
|
*
|
|
@@ -120,6 +134,34 @@ export interface BackgroundJobRegistryRef {
|
|
|
120
134
|
id: string
|
|
121
135
|
status: string
|
|
122
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Start a command whose stdout lines reach the model as events while it
|
|
139
|
+
* runs, and which is stopped at `timeoutMs`. The host clamps the timeout to
|
|
140
|
+
* its own ceiling and the clamped value comes back, so the caller reports
|
|
141
|
+
* the deadline that will actually fire. Optional, like `adopt`: a host that
|
|
142
|
+
* cannot deliver events must omit it, and the `monitor` tool then refuses
|
|
143
|
+
* rather than start a watch nobody will hear from. Throws when the owner is
|
|
144
|
+
* at its job cap.
|
|
145
|
+
*/
|
|
146
|
+
startMonitor?(params: {
|
|
147
|
+
command: string
|
|
148
|
+
description: string
|
|
149
|
+
timeoutMs: number
|
|
150
|
+
workingDirectory?: string
|
|
151
|
+
}): { job: { id: string; status: string }; timeoutMs: number }
|
|
152
|
+
/**
|
|
153
|
+
* Start a monitor whose source is a WebSocket connection. Separate from
|
|
154
|
+
* {@link startMonitor} so a host that implements that one for commands is
|
|
155
|
+
* not handed an input it was not written for; a host without this method
|
|
156
|
+
* refuses a `ws` call with a plain message. The target's addresses are
|
|
157
|
+
* already checked; see {@link MonitorWebSocketTarget}.
|
|
158
|
+
*/
|
|
159
|
+
startWebSocketMonitor?(params: {
|
|
160
|
+
description: string
|
|
161
|
+
timeoutMs: number
|
|
162
|
+
workingDirectory?: string
|
|
163
|
+
webSocket: MonitorWebSocketTarget
|
|
164
|
+
}): { job: { id: string; status: string }; timeoutMs: number }
|
|
123
165
|
get(id: string): {
|
|
124
166
|
id: string
|
|
125
167
|
status: string
|
|
@@ -156,7 +198,15 @@ export interface BackgroundJobRegistryRef {
|
|
|
156
198
|
error?: string
|
|
157
199
|
}
|
|
158
200
|
kill(id: string): Promise<{ id: string; status: string }>
|
|
159
|
-
list(): readonly {
|
|
201
|
+
list(): readonly {
|
|
202
|
+
id: string
|
|
203
|
+
command: string
|
|
204
|
+
status: string
|
|
205
|
+
/** `monitor` for a job the `monitor` tool started. */
|
|
206
|
+
kind?: 'monitor'
|
|
207
|
+
/** What a monitor watches. */
|
|
208
|
+
description?: string
|
|
209
|
+
}[]
|
|
160
210
|
/**
|
|
161
211
|
* Await this job's exit instead of reading it in a loop; resolves at
|
|
162
212
|
* once for a job that has already stopped. Optional, and added after
|