@noesis-brain/mcp-server 2.1.3 → 2.3.1
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/dist/agent/identity.d.ts +33 -0
- package/dist/agent/identity.d.ts.map +1 -0
- package/dist/agent/identity.js +61 -0
- package/dist/agent/identity.js.map +1 -0
- package/dist/agent/runner.d.ts +194 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +465 -50
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tokenState.d.ts +81 -0
- package/dist/agent/tokenState.d.ts.map +1 -0
- package/dist/agent/tokenState.js +173 -0
- package/dist/agent/tokenState.js.map +1 -0
- package/dist/agent/webGuard.d.ts +48 -0
- package/dist/agent/webGuard.d.ts.map +1 -0
- package/dist/agent/webGuard.js +173 -0
- package/dist/agent/webGuard.js.map +1 -0
- package/dist/api/NoesisClient.d.ts +13 -1
- package/dist/api/NoesisClient.d.ts.map +1 -1
- package/dist/api/NoesisClient.js +18 -3
- package/dist/api/NoesisClient.js.map +1 -1
- package/dist/tools/claudeSessions.d.ts +72 -0
- package/dist/tools/claudeSessions.d.ts.map +1 -0
- package/dist/tools/claudeSessions.js +304 -0
- package/dist/tools/claudeSessions.js.map +1 -0
- package/dist/tools/index.d.ts +1 -6
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +188 -55
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/sessionIndex.d.ts +66 -0
- package/dist/tools/sessionIndex.d.ts.map +1 -0
- package/dist/tools/sessionIndex.js +241 -0
- package/dist/tools/sessionIndex.js.map +1 -0
- package/package.json +1 -1
- package/scripts/noesis-capture-watcher.mjs +722 -107
- package/scripts/start-agent.sh +331 -0
- package/skill-templates/noesis-refine-relations.md +163 -0
package/dist/agent/runner.js
CHANGED
|
@@ -15,10 +15,25 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { readFileSync } from 'node:fs';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { checkWebFetchUrl, webFetchEnvWarnings } from './webGuard.js';
|
|
19
|
+
import { resolveIdentity } from './identity.js';
|
|
20
|
+
import { TokenGate, applyStartupCheck, checkToken, classifyRefusal } from './tokenState.js';
|
|
18
21
|
const POLL_INTERVAL_MS = 2000; // idle poll cadence
|
|
19
22
|
const CHUNK_FLUSH_MS = 500; // batch streamed deltas before POST /events
|
|
20
23
|
const KEEPALIVE_MS = 30_000; // hold the lease during quiet stretches (< server's 120s stale)
|
|
21
24
|
const MAX_CONCURRENCY = 2;
|
|
25
|
+
function parseFakeMode(raw) {
|
|
26
|
+
const v = (raw || '').trim().toLowerCase();
|
|
27
|
+
if (v === '1' || v === 'canned')
|
|
28
|
+
return 'canned';
|
|
29
|
+
if (v === 'rate_limit' || v === 'auth')
|
|
30
|
+
return v;
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
function nonNegative(raw, fallback) {
|
|
34
|
+
const n = Number(raw);
|
|
35
|
+
return raw !== undefined && raw !== '' && Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
36
|
+
}
|
|
22
37
|
export function resolveAgentConfig() {
|
|
23
38
|
const apiBaseUrl = (process.env.NOESIS_API_URL || '').replace(/\/$/, '');
|
|
24
39
|
const apiToken = process.env.NOESIS_API_TOKEN || '';
|
|
@@ -30,7 +45,16 @@ export function resolveAgentConfig() {
|
|
|
30
45
|
concurrency = 1;
|
|
31
46
|
if (concurrency > MAX_CONCURRENCY)
|
|
32
47
|
concurrency = MAX_CONCURRENCY;
|
|
33
|
-
|
|
48
|
+
const fakeMode = parseFakeMode(process.env.NOESIS_AGENT_FAKE);
|
|
49
|
+
return {
|
|
50
|
+
apiBaseUrl,
|
|
51
|
+
apiToken,
|
|
52
|
+
concurrency,
|
|
53
|
+
fake: fakeMode !== undefined,
|
|
54
|
+
fakeMode,
|
|
55
|
+
fakeResetAt: Date.now() + nonNegative(process.env.NOESIS_AGENT_FAKE_RESET_MIN, 3) * 60_000,
|
|
56
|
+
fakeWordMs: nonNegative(process.env.NOESIS_AGENT_FAKE_WORD_MS, 15),
|
|
57
|
+
};
|
|
34
58
|
}
|
|
35
59
|
/**
|
|
36
60
|
* The ONLY tools the server may pre-approve — a literal allowlist, not a namespace
|
|
@@ -47,6 +71,16 @@ const ALLOWED_TOOLS = new Set([
|
|
|
47
71
|
'mcp__noesis__get_note',
|
|
48
72
|
'mcp__noesis__list_notes',
|
|
49
73
|
]);
|
|
74
|
+
/**
|
|
75
|
+
* Claude Code built-ins a server may request: network-only, no filesystem access. This is
|
|
76
|
+
* the ONLY route by which the SDK's `tools` option is ever non-empty (see buildQueryOptions).
|
|
77
|
+
*
|
|
78
|
+
* A separate list from ALLOWED_TOOLS on purpose, and never pre-approved: every call goes
|
|
79
|
+
* through buildCanUseTool, where a WebFetch URL must pass webGuard, because this daemon
|
|
80
|
+
* runs on the user's own machine and "fetch http://192.168.1.1" would reach their LAN.
|
|
81
|
+
* The backend asks for these only for a Navi whose Web Search toggle is on.
|
|
82
|
+
*/
|
|
83
|
+
const ALLOWED_WEB_TOOLS = ['WebFetch', 'WebSearch'];
|
|
50
84
|
/** Highest turn budget a server may request; a tool loop otherwise burns the subscription. */
|
|
51
85
|
const MAX_TURNS_CEILING = 12;
|
|
52
86
|
/** Drop anything the server asked for that is not on the read-only allowlist. */
|
|
@@ -59,6 +93,12 @@ export function clampAllowedTools(raw) {
|
|
|
59
93
|
console.error(`[noesis-agent] dropped ${dropped} tool name(s) not on the read-only allowlist`);
|
|
60
94
|
return kept;
|
|
61
95
|
}
|
|
96
|
+
/** The web built-ins this job asked for, in a fixed order. Nothing else can come out. */
|
|
97
|
+
export function clampWebTools(raw) {
|
|
98
|
+
if (!Array.isArray(raw))
|
|
99
|
+
return [];
|
|
100
|
+
return ALLOWED_WEB_TOOLS.filter((t) => raw.includes(t));
|
|
101
|
+
}
|
|
62
102
|
/** Clamp the server-requested turn budget into a sane range. */
|
|
63
103
|
export function clampMaxTurns(raw) {
|
|
64
104
|
const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : 1;
|
|
@@ -102,13 +142,58 @@ export function agentVersion() {
|
|
|
102
142
|
return '0.0.0';
|
|
103
143
|
}
|
|
104
144
|
}
|
|
105
|
-
|
|
106
|
-
|
|
145
|
+
let identity = null;
|
|
146
|
+
/** This process's machine identity, resolved once (daemonId is stable; bootId is per process). */
|
|
147
|
+
function daemonIdentity() {
|
|
148
|
+
identity ??= resolveIdentity();
|
|
149
|
+
return identity;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* What every claim sends. The server keeps one health record per machine (daemonId), tells
|
|
153
|
+
* a restart from a second process by bootId, and shows label/platform in Settings. Older
|
|
154
|
+
* servers read only workerId + agentVersion and ignore the rest.
|
|
155
|
+
*/
|
|
156
|
+
export function claimBody(workerId, id) {
|
|
157
|
+
return { workerId, agentVersion: agentVersion(), daemonId: id.daemonId, bootId: id.bootId, label: id.label, platform: id.platform };
|
|
158
|
+
}
|
|
159
|
+
/** The token state every worker shares (see tokenState.ts). */
|
|
160
|
+
const tokenGate = new TokenGate();
|
|
161
|
+
/**
|
|
162
|
+
* Read a claim answer. A 401 means the server refused this daemon's API token (expired,
|
|
163
|
+
* revoked or unknown): the shared gate explains it once and the workers back off, instead
|
|
164
|
+
* of retrying silently every 2 s. Any accepted answer clears that state and feeds the
|
|
165
|
+
* token-expiry warning.
|
|
166
|
+
*/
|
|
167
|
+
export async function handleClaimResponse(res, gate) {
|
|
168
|
+
if (res.status === 401) {
|
|
169
|
+
gate.refused(classifyRefusal(await res.json().catch(() => ({}))));
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
107
172
|
if (!res.ok)
|
|
108
173
|
return null;
|
|
174
|
+
gate.accepted();
|
|
175
|
+
gate.expiryHeader(res.headers.get('x-noesis-token-expires'));
|
|
109
176
|
const data = (await res.json());
|
|
110
177
|
return data.job ?? null;
|
|
111
178
|
}
|
|
179
|
+
async function claim(cfg, workerId) {
|
|
180
|
+
const res = await api(cfg, '/api/agent/jobs/claim', claimBody(workerId, daemonIdentity()));
|
|
181
|
+
return handleClaimResponse(res, tokenGate);
|
|
182
|
+
}
|
|
183
|
+
/** Sleep, but wake at once on shutdown — the refused-token backoff is a minute long. */
|
|
184
|
+
function sleep(ms, signal) {
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
if (signal?.aborted)
|
|
187
|
+
return resolve();
|
|
188
|
+
const timer = setTimeout(done, ms);
|
|
189
|
+
function done() {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
signal?.removeEventListener('abort', done);
|
|
192
|
+
resolve();
|
|
193
|
+
}
|
|
194
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
195
|
+
});
|
|
196
|
+
}
|
|
112
197
|
/** Buffers streamed text and flushes it as lease-fenced chunk events + keepalives. */
|
|
113
198
|
class EventSink {
|
|
114
199
|
cfg;
|
|
@@ -208,23 +293,164 @@ export function buildSdkPrompt(prompt, images) {
|
|
|
208
293
|
return singleUserMessage(buildImageContent(prompt, images));
|
|
209
294
|
return prompt;
|
|
210
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* SECOND, INDEPENDENT LAYER on the tool boundary.
|
|
298
|
+
*
|
|
299
|
+
* The `tools` option below (`[]`, or at most the two web tools) is the primary control, but
|
|
300
|
+
* it works by way of the CLI's `--tools` flag, whose handling lives in a minified `cli.js`
|
|
301
|
+
* we cannot audit. This callback is a
|
|
302
|
+
* gate we own outright: the SDK routes every permission request through it
|
|
303
|
+
* (`--permission-prompt-tool stdio`), and the request carries an `agentID`, so calls made
|
|
304
|
+
* by a spawned `Task` SUBAGENT are covered too — which matters, because a subagent, not
|
|
305
|
+
* the main loop, did the file reading in the 2026-08-14 incident.
|
|
306
|
+
*
|
|
307
|
+
* Deliberately fed from ALLOWED_TOOLS / ALLOWED_WEB_TOOLS (via the per-job clamps) so the
|
|
308
|
+
* daemon has ONE definition of what may run.
|
|
309
|
+
*
|
|
310
|
+
* DO NOT DELETE THIS AS DEAD CODE. It is the ONLY daemon-owned control over MCP tools.
|
|
311
|
+
* `tools: []` governs the SDK's BUILT-IN set and nothing else; the Noesis MCP server we
|
|
312
|
+
* spawn registers ~55 tools, and their definitions are not filtered by `allowedTools`, so
|
|
313
|
+
* the model can see and attempt every one of them — including `pull_notes`, which writes
|
|
314
|
+
* to an arbitrary local path, plus `add_root`, `sync_notes`, `trash_note`, `move_note`.
|
|
315
|
+
* For those, this callback is the whole boundary.
|
|
316
|
+
*
|
|
317
|
+
* MEASURED (30 runs, 2026-08-15), correcting an earlier comment that said the opposite:
|
|
318
|
+
* the gate DOES fire — it denied `mcp__noesis__list_roots` and `mcp__noesis__list_codebases`
|
|
319
|
+
* in real traffic, and the model received `is_error` results naming the refusal. An earlier
|
|
320
|
+
* 2-job sample saw zero firings and wrongly concluded the gate was inert; a permission tool
|
|
321
|
+
* is simply not consulted for calls that are already pre-approved, and that sample happened
|
|
322
|
+
* to exercise only pre-approved tools.
|
|
323
|
+
*
|
|
324
|
+
* KNOWN GAP, stated rather than glossed: the SDK's cache-warming sidechain
|
|
325
|
+
* (`isSidechain: true`) is a real model turn that emits genuine `Bash`/`Glob` calls against
|
|
326
|
+
* the daemon's cwd, with only the RESULT stubbed. `tools: []` does not reach it and this
|
|
327
|
+
* callback is never consulted there. Nothing executes — but that is Claude Code internal
|
|
328
|
+
* behaviour we neither own nor test, which is why the daemon is rooted at a neutral cwd
|
|
329
|
+
* (`scripts/start-agent.sh`), so the warmup probes an empty directory, not a source tree.
|
|
330
|
+
*
|
|
331
|
+
* WEB TOOLS: a permitted WebFetch is still refused unless its URL passes webGuard (public
|
|
332
|
+
* http(s) host only). The job's permitted set is checked FIRST, so a job never cleared
|
|
333
|
+
* for web cannot fetch even a public page. WebSearch needs no address check: the search
|
|
334
|
+
* runs on Anthropic's side, not from this machine. One exception the gate cannot see: the
|
|
335
|
+
* CLI itself pre-approves ~85 public documentation hosts for WebFetch and never asks. Those
|
|
336
|
+
* are public by definition, and executeJob logs every tool call from the stream anyway.
|
|
337
|
+
*/
|
|
338
|
+
export function buildCanUseTool(
|
|
339
|
+
/**
|
|
340
|
+
* The tools permitted for THIS job — i.e. the output of `clampAllowedTools`, not the
|
|
341
|
+
* module-wide `ALLOWED_TOOLS`. Required, deliberately: an earlier version closed over
|
|
342
|
+
* `ALLOWED_TOOLS` and so turned a per-job clamp into a union. `clampAllowedTools`
|
|
343
|
+
* narrows to (what the server asked for ∩ ALLOWED_TOOLS); the gate then re-allowed all
|
|
344
|
+
* four regardless, so a job clamped to `get_note` could still execute `search_semantic`.
|
|
345
|
+
* The sets happen to coincide for chat today, which is exactly why it would have gone
|
|
346
|
+
* unnoticed until the first job kind with a narrower allowlist.
|
|
347
|
+
*/
|
|
348
|
+
permitted, onDecision = logToolDecision, checkUrl = checkWebFetchUrl) {
|
|
349
|
+
const permittedSet = new Set(permitted);
|
|
350
|
+
return async (toolName, input, ctx) => {
|
|
351
|
+
if (!permittedSet.has(toolName)) {
|
|
352
|
+
onDecision(toolName, false, ctx?.agentID);
|
|
353
|
+
return { behavior: 'deny', message: `Tool "${toolName}" is not permitted for a Noesis job.` };
|
|
354
|
+
}
|
|
355
|
+
const args = (input ?? {});
|
|
356
|
+
if (toolName === 'WebFetch') {
|
|
357
|
+
const verdict = await checkUrl(args.url);
|
|
358
|
+
if (!verdict.ok) {
|
|
359
|
+
onDecision(toolName, false, ctx?.agentID, `${String(args.url)} (${verdict.reason})`);
|
|
360
|
+
return {
|
|
361
|
+
behavior: 'deny',
|
|
362
|
+
message: `WebFetch refused: ${verdict.reason}. Only public internet addresses can be fetched.`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
onDecision(toolName, true, ctx?.agentID, toolCallDetail(toolName, args));
|
|
367
|
+
return { behavior: 'allow', updatedInput: args };
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/** The URL or query a web tool call is about, for the log; undefined for other tools. */
|
|
371
|
+
function toolCallDetail(toolName, args) {
|
|
372
|
+
const value = toolName === 'WebFetch' ? args.url : toolName === 'WebSearch' ? args.query : undefined;
|
|
373
|
+
return typeof value === 'string' ? value.slice(0, 300) : undefined;
|
|
374
|
+
}
|
|
375
|
+
/** Default sink: stderr, so the daemon log is the evidence for whether the gate ever fires. */
|
|
376
|
+
function logToolDecision(toolName, allowed, agentID, detail) {
|
|
377
|
+
console.error(`[noesis-agent] canUseTool ${allowed ? 'ALLOW' : 'DENY'} ${toolName}${detail ? ` ${detail}` : ''}${agentID ? ` (agent=${agentID})` : ''}`);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* The `options` object handed to sdk.query() — extracted, like buildSdkPrompt above, so
|
|
381
|
+
* the tool boundary is asserted by a test instead of resting on a comment. `tools` is the
|
|
382
|
+
* security-relevant field: see the note at its assignment. Takes the MCP-server
|
|
383
|
+
* factory as an argument so a test can supply a stub instead of a real spawn config.
|
|
384
|
+
*/
|
|
385
|
+
export function buildQueryOptions(payload, mcpServersFor) {
|
|
386
|
+
const requested = Array.isArray(payload.allowedTools) ? payload.allowedTools : [];
|
|
387
|
+
const webTools = clampWebTools(requested);
|
|
388
|
+
// Web names have their own clamp; keeping them out of this one stops it logging them as
|
|
389
|
+
// "dropped" on every web job. What it keeps is unchanged: the four read-only lookups.
|
|
390
|
+
const allowedTools = clampAllowedTools(requested.filter((t) => !webTools.includes(t)));
|
|
391
|
+
return {
|
|
392
|
+
...(payload.system ? { systemPrompt: payload.system } : {}),
|
|
393
|
+
// Suppress the SDK's built-in tool set (`--tools <list>`) down to at most the two web
|
|
394
|
+
// tools. Without the option the CLI defaults to ALL built-ins — Bash, Read, Write,
|
|
395
|
+
// Edit, Grep, WebSearch — and ALLOWED_TOOLS above does NOT stop them: `allowedTools`
|
|
396
|
+
// is a PERMISSION allowlist, so unlisted tools are un-pre-approved, not absent, and
|
|
397
|
+
// their definitions still reach the model. Worse, the read-only built-ins need no
|
|
398
|
+
// approval at all, so they actually EXECUTE headless: on 2026-08-14 an English-coach
|
|
399
|
+
// Navi grepped this daemon's cwd and returned ~100 strings from the user's source tree
|
|
400
|
+
// (jobs 527/528); only the follow-up file Write stopped, at the permission gate.
|
|
401
|
+
// So this is `[]` for every job that did not ask for web, and never more than
|
|
402
|
+
// ALLOWED_WEB_TOOLS — network-only, no filesystem access. A server-supplied payload
|
|
403
|
+
// still can never ask for a filesystem primitive. MCP tools travel a separate channel
|
|
404
|
+
// (`--mcp-config`) and are unaffected: a `use_knowledge_base` Navi still calls
|
|
405
|
+
// search_notes (verified).
|
|
406
|
+
tools: webTools,
|
|
407
|
+
// Independent second layer — see buildCanUseTool. Covers subagent (`Task`) calls too.
|
|
408
|
+
// Web tools are deliberately left out of `allowedTools` below (never pre-approved),
|
|
409
|
+
// so every WebFetch reaches this gate and its URL check.
|
|
410
|
+
canUseTool: buildCanUseTool([...allowedTools, ...webTools]),
|
|
411
|
+
allowedTools,
|
|
412
|
+
// Only spawn the Noesis MCP server when a tool actually survived clamping.
|
|
413
|
+
...(allowedTools.length > 0 ? { mcpServers: mcpServersFor() } : {}),
|
|
414
|
+
maxTurns: clampMaxTurns(payload.maxTurns),
|
|
415
|
+
includePartialMessages: true,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/** The prompt text for a job, with the fallback used when a payload carries none. */
|
|
419
|
+
export function resolveJobPrompt(payload, kindLabel) {
|
|
420
|
+
return payload.prompt ?? `(${kindLabel} job with empty prompt)`;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* The ONE place the SDK is invoked. Exported so a test can pass a fake `sdk` and assert
|
|
424
|
+
* what actually reaches `query()`.
|
|
425
|
+
*
|
|
426
|
+
* This exists because of a real gap: the suite pinned `buildQueryOptions` with a
|
|
427
|
+
* power-set mutation battery, but NOTHING pinned the call. Restoring the pre-fix inline
|
|
428
|
+
* options literal here — deleting `tools: []` and `canUseTool` outright — reverted the
|
|
429
|
+
* entire tool boundary with all 79 tests still green. The builder being correct is
|
|
430
|
+
* worthless if the caller stops using it, so both the seam below and the source-fitness
|
|
431
|
+
* test in `agentToolClamp.test.ts` guard this.
|
|
432
|
+
*/
|
|
433
|
+
export function runSdkQuery(sdk, payload, kindLabel, mcpServersFor) {
|
|
434
|
+
return sdk.query({
|
|
435
|
+
prompt: buildSdkPrompt(resolveJobPrompt(payload, kindLabel), payload.images),
|
|
436
|
+
options: buildQueryOptions(payload, mcpServersFor),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
211
439
|
/**
|
|
212
440
|
* Execute a claimed job and stream its output through the sink. Returns the final
|
|
213
441
|
* text. Real mode runs the Claude Agent SDK on the user's subscription; fake mode
|
|
214
442
|
* emits a canned response so the queue plumbing can be verified without a login.
|
|
215
443
|
*/
|
|
216
444
|
async function executeJob(cfg, job, sink) {
|
|
217
|
-
const prompt = job.payload
|
|
218
|
-
const images = job.payload.images;
|
|
445
|
+
const prompt = resolveJobPrompt(job.payload, job.kind);
|
|
219
446
|
if (cfg.fake) {
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
return canned;
|
|
447
|
+
const fake = fakeSdk(cfg.fakeMode ?? 'canned', {
|
|
448
|
+
kind: job.kind,
|
|
449
|
+
promptText: prompt,
|
|
450
|
+
resetAt: cfg.fakeResetAt ?? 0,
|
|
451
|
+
wordMs: cfg.fakeWordMs ?? 15,
|
|
452
|
+
});
|
|
453
|
+
return consumeAgentStream(runSdkQuery(fake, job.payload, job.kind, () => ({})), sink);
|
|
228
454
|
}
|
|
229
455
|
// Real path: the Agent SDK uses the ambient Claude Code subscription login.
|
|
230
456
|
// Dynamically imported via a NON-LITERAL specifier so tsc doesn't require the
|
|
@@ -235,47 +461,170 @@ async function executeJob(cfg, job, sink) {
|
|
|
235
461
|
const sdk = await import(sdkModule).catch(() => {
|
|
236
462
|
throw new Error('Install @anthropic-ai/claude-agent-sdk to run the Noesis local agent (real mode).');
|
|
237
463
|
});
|
|
464
|
+
const stream = runSdkQuery(sdk, job.payload, job.kind, () => noesisMcpServers(cfg));
|
|
465
|
+
return consumeAgentStream(stream, sink);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Appended when the SDK stops a run at maxTurns. Without it the saved reply would be only
|
|
469
|
+
* the narration that came before the tool calls ("let me check the official page…"), with
|
|
470
|
+
* nothing saying the answer was cut off.
|
|
471
|
+
*/
|
|
472
|
+
export const TURN_LIMIT_NOTE = '\n\n_(Stopped at the step limit before finishing. Ask me to continue.)_';
|
|
473
|
+
/**
|
|
474
|
+
* Turn one SDK message stream into the reply text, pushing it to `out` as it arrives.
|
|
475
|
+
* Extracted from executeJob so these rules are testable against a fake stream:
|
|
476
|
+
* - With includePartialMessages the stream carries BOTH incremental `stream_event` deltas
|
|
477
|
+
* AND a terminal `assistant` message holding the whole reply. Taking both would emit the
|
|
478
|
+
* answer twice, so deltas win and the terminal message is only a fallback for a stream
|
|
479
|
+
* that produced none (older SDK, or partials unsupported).
|
|
480
|
+
* - A multi-turn run (every web fetch or note lookup) gets a paragraph break between turns,
|
|
481
|
+
* so narration does not run into the answer. Only when the next turn actually has text,
|
|
482
|
+
* so a tool-only turn adds nothing.
|
|
483
|
+
* - Every tool call is logged, including WebFetch calls to the hosts the CLI pre-approves
|
|
484
|
+
* itself, which never reach canUseTool.
|
|
485
|
+
* - Hitting maxTurns appends TURN_LIMIT_NOTE.
|
|
486
|
+
*/
|
|
487
|
+
export async function consumeAgentStream(stream, out, logToolCall = logStreamToolCall) {
|
|
238
488
|
let finalText = '';
|
|
239
489
|
let sawDelta = false;
|
|
240
490
|
let terminalText = '';
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
491
|
+
let breakPending = false;
|
|
492
|
+
let hitTurnLimit = false;
|
|
493
|
+
// An API failure (usage limit, revoked login, …) arrives as an assistant message
|
|
494
|
+
// carrying `error`, then a result with is_error, then the CLI exits 1 and the SDK
|
|
495
|
+
// throws a bare "Claude Code process exited with code 1". Keep the failure's own text
|
|
496
|
+
// and code — the server routes on them — and never let it become the reply.
|
|
497
|
+
let apiError = null;
|
|
498
|
+
let failedResultText = null;
|
|
499
|
+
let succeeded = false;
|
|
500
|
+
const emit = (text) => {
|
|
501
|
+
out.push(text);
|
|
502
|
+
finalText += text;
|
|
503
|
+
};
|
|
504
|
+
const failure = () => {
|
|
505
|
+
if (apiError)
|
|
506
|
+
return new AgentRunError(apiError.text, apiError.code, apiError.resetsAt);
|
|
507
|
+
if (failedResultText !== null)
|
|
508
|
+
return new AgentRunError(failedResultText || 'Claude run failed', 'unknown');
|
|
509
|
+
return null;
|
|
510
|
+
};
|
|
511
|
+
try {
|
|
512
|
+
for await (const message of stream) {
|
|
513
|
+
if (out.isClosed())
|
|
514
|
+
break;
|
|
515
|
+
const err = extractApiError(message);
|
|
516
|
+
if (err) {
|
|
517
|
+
apiError = err;
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const outcome = resultOutcome(message);
|
|
521
|
+
if (outcome?.isError)
|
|
522
|
+
failedResultText = outcome.text;
|
|
523
|
+
else if (outcome)
|
|
524
|
+
succeeded = true;
|
|
525
|
+
if (isMessageStart(message)) {
|
|
526
|
+
if (finalText)
|
|
527
|
+
breakPending = true;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const delta = extractStreamDelta(message);
|
|
531
|
+
if (delta) {
|
|
532
|
+
sawDelta = true;
|
|
533
|
+
if (breakPending) {
|
|
534
|
+
breakPending = false;
|
|
535
|
+
const gap = paragraphGap(finalText);
|
|
536
|
+
if (gap)
|
|
537
|
+
emit(gap);
|
|
538
|
+
}
|
|
539
|
+
emit(delta);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
for (const call of extractToolUses(message))
|
|
543
|
+
logToolCall(call.name, call.detail);
|
|
544
|
+
if (isTurnLimitResult(message))
|
|
545
|
+
hitTurnLimit = true;
|
|
546
|
+
// Concatenate, don't overwrite: with tools the run is multi-turn, and a
|
|
547
|
+
// stream that produced no deltas would otherwise keep only the LAST turn.
|
|
548
|
+
const whole = extractTerminalText(message);
|
|
549
|
+
if (whole)
|
|
550
|
+
terminalText += (terminalText ? paragraphGap(terminalText) : '') + whole;
|
|
266
551
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
552
|
+
}
|
|
553
|
+
catch (e) {
|
|
554
|
+
throw failure() ?? e;
|
|
555
|
+
}
|
|
556
|
+
if (!succeeded) {
|
|
557
|
+
const f = failure();
|
|
558
|
+
if (f)
|
|
559
|
+
throw f;
|
|
272
560
|
}
|
|
273
561
|
if (!sawDelta && terminalText) {
|
|
274
|
-
|
|
562
|
+
out.push(terminalText);
|
|
275
563
|
finalText = terminalText;
|
|
276
564
|
}
|
|
565
|
+
if (hitTurnLimit && !out.isClosed())
|
|
566
|
+
emit(finalText ? TURN_LIMIT_NOTE : TURN_LIMIT_NOTE.trimStart());
|
|
277
567
|
return finalText;
|
|
278
568
|
}
|
|
569
|
+
/** A Claude API failure the daemon reports with its own text and SDK error code. */
|
|
570
|
+
export class AgentRunError extends Error {
|
|
571
|
+
code;
|
|
572
|
+
resetsAt;
|
|
573
|
+
constructor(message, code, resetsAt) {
|
|
574
|
+
super(message);
|
|
575
|
+
this.code = code;
|
|
576
|
+
this.resetsAt = resetsAt;
|
|
577
|
+
this.name = 'AgentRunError';
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** An assistant message the SDK marked as an API failure (`error` set), or null. */
|
|
581
|
+
function extractApiError(message) {
|
|
582
|
+
const m = message;
|
|
583
|
+
if (m?.type !== 'assistant' || typeof m.error !== 'string' || !m.error)
|
|
584
|
+
return null;
|
|
585
|
+
const text = extractTerminalText(m) || `Claude API error (${m.error})`;
|
|
586
|
+
const resetsAt = [m.resetsAt, m.message?.resetsAt, m.quotaLimits?.resetsAt, m.rateLimit?.resetsAt].find((v) => typeof v === 'number' && Number.isFinite(v) && v > 0);
|
|
587
|
+
return { code: m.error, text, ...(resetsAt ? { resetsAt } : {}) };
|
|
588
|
+
}
|
|
589
|
+
/** A terminal `result` message: did the run fail, and with what text? Null for anything else. */
|
|
590
|
+
function resultOutcome(message) {
|
|
591
|
+
const m = message;
|
|
592
|
+
if (m?.type !== 'result')
|
|
593
|
+
return null;
|
|
594
|
+
if (m.is_error !== true)
|
|
595
|
+
return { isError: false, text: '' };
|
|
596
|
+
const text = typeof m.result === 'string' ? m.result : Array.isArray(m.errors) ? m.errors.join('; ') : '';
|
|
597
|
+
return { isError: true, text };
|
|
598
|
+
}
|
|
599
|
+
/** What to insert after `text` so the next turn starts a new paragraph. */
|
|
600
|
+
function paragraphGap(text) {
|
|
601
|
+
if (text.endsWith('\n\n'))
|
|
602
|
+
return '';
|
|
603
|
+
return text.endsWith('\n') ? '\n' : '\n\n';
|
|
604
|
+
}
|
|
605
|
+
/** The start of a new model turn: one `message_start` per assistant message. */
|
|
606
|
+
function isMessageStart(message) {
|
|
607
|
+
const m = message;
|
|
608
|
+
return m?.type === 'stream_event' && m.event?.type === 'message_start';
|
|
609
|
+
}
|
|
610
|
+
/** The SDK's terminal `result` when the run was stopped by maxTurns. */
|
|
611
|
+
function isTurnLimitResult(message) {
|
|
612
|
+
const m = message;
|
|
613
|
+
return m?.type === 'result' && m.subtype === 'error_max_turns';
|
|
614
|
+
}
|
|
615
|
+
/** Tool calls in a complete `assistant` message, with the URL or query each is about. */
|
|
616
|
+
function extractToolUses(message) {
|
|
617
|
+
const m = message;
|
|
618
|
+
if (m?.type !== 'assistant' || !Array.isArray(m.message?.content))
|
|
619
|
+
return [];
|
|
620
|
+
return m.message.content
|
|
621
|
+
.filter((b) => b?.type === 'tool_use' && typeof b.name === 'string')
|
|
622
|
+
.map((b) => ({ name: b.name, detail: toolCallDetail(b.name, b.input ?? {}) }));
|
|
623
|
+
}
|
|
624
|
+
/** Default sink: stderr, next to the canUseTool lines. */
|
|
625
|
+
function logStreamToolCall(name, detail) {
|
|
626
|
+
console.error(`[noesis-agent] tool_use ${name}${detail ? ` ${detail}` : ''}`);
|
|
627
|
+
}
|
|
279
628
|
/**
|
|
280
629
|
* Text carried by one streamed partial. Verified against the SDK (0.1.x): partials
|
|
281
630
|
* arrive as `{type:'stream_event', event:{type:'content_block_delta',
|
|
@@ -310,6 +659,62 @@ function extractTerminalText(message) {
|
|
|
310
659
|
}
|
|
311
660
|
return '';
|
|
312
661
|
}
|
|
662
|
+
/** The /complete body for a failed job: the message, plus the SDK's code when known. */
|
|
663
|
+
export function completionErrorBody(lease, err) {
|
|
664
|
+
const body = { lease, error: err instanceof Error ? err.message : String(err) };
|
|
665
|
+
if (err instanceof AgentRunError) {
|
|
666
|
+
body.errorCode = err.code;
|
|
667
|
+
if (err.resetsAt)
|
|
668
|
+
body.resetsAt = err.resetsAt;
|
|
669
|
+
}
|
|
670
|
+
return body;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* A stand-in for the Agent SDK that emits the message sequences a real run does, so the
|
|
674
|
+
* fake modes exercise consumeAgentStream exactly like real traffic.
|
|
675
|
+
*/
|
|
676
|
+
export function fakeSdk(mode, opts) {
|
|
677
|
+
return { query: () => fakeStream(mode, opts) };
|
|
678
|
+
}
|
|
679
|
+
async function* fakeStream(mode, opts) {
|
|
680
|
+
const now = (opts.now ?? Date.now)();
|
|
681
|
+
if (mode !== 'canned' && now < opts.resetAt) {
|
|
682
|
+
const text = mode === 'rate_limit'
|
|
683
|
+
? `You've hit your session limit · resets ${formatResetLikeCli(opts.resetAt, opts.timeZone, now)}`
|
|
684
|
+
: 'OAuth token revoked · Please run /login';
|
|
685
|
+
yield { type: 'system', subtype: 'init' };
|
|
686
|
+
yield { type: 'assistant', message: { content: [{ type: 'text', text }] }, error: mode === 'rate_limit' ? 'rate_limit' : 'authentication_failed' };
|
|
687
|
+
yield { type: 'result', subtype: 'success', is_error: true, result: text };
|
|
688
|
+
if (opts.throwAtEnd !== false)
|
|
689
|
+
throw new Error('Claude Code process exited with code 1');
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const canned = `Noesis local agent (fake mode) processed a "${opts.kind}" job. Prompt was: ${opts.promptText.slice(0, 80)}`;
|
|
693
|
+
yield { type: 'stream_event', event: { type: 'message_start', message: {} } };
|
|
694
|
+
const words = canned.split(' ');
|
|
695
|
+
for (let i = 0; i < words.length; i++) {
|
|
696
|
+
yield { type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text: (i ? ' ' : '') + words[i] } } };
|
|
697
|
+
if (opts.wordMs > 0)
|
|
698
|
+
await new Promise((r) => setTimeout(r, opts.wordMs));
|
|
699
|
+
}
|
|
700
|
+
yield { type: 'assistant', message: { content: [{ type: 'text', text: canned }] } };
|
|
701
|
+
yield { type: 'result', subtype: 'success', is_error: false, result: canned };
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* A reset time the way the Claude CLI prints it in its limit message: "3pm (Zone)",
|
|
705
|
+
* "3:07pm (Zone)", or "Oct 12, 3pm (Zone)" when more than a day away — minutes, no seconds.
|
|
706
|
+
*/
|
|
707
|
+
export function formatResetLikeCli(atMs, timeZone, nowMs = Date.now()) {
|
|
708
|
+
const zone = timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
709
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
710
|
+
timeZone: zone, month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true,
|
|
711
|
+
}).formatToParts(new Date(atMs));
|
|
712
|
+
const get = (t) => parts.find((p) => p.type === t)?.value ?? '';
|
|
713
|
+
const minute = get('minute');
|
|
714
|
+
const time = `${get('hour')}${minute && minute !== '00' ? `:${minute}` : ''}${get('dayPeriod').toLowerCase()}`;
|
|
715
|
+
const date = atMs - nowMs > 24 * 60 * 60 * 1000 ? `${get('month')} ${get('day')}, ` : '';
|
|
716
|
+
return `${date}${time} (${zone})`;
|
|
717
|
+
}
|
|
313
718
|
async function runJob(cfg, job) {
|
|
314
719
|
const sink = new EventSink(cfg, job.jobId, job.lease);
|
|
315
720
|
sink.start();
|
|
@@ -324,12 +729,17 @@ async function runJob(cfg, job) {
|
|
|
324
729
|
await sink.finish();
|
|
325
730
|
if (sink.isClosed())
|
|
326
731
|
return;
|
|
327
|
-
await api(cfg, `/api/agent/jobs/${job.jobId}/complete`,
|
|
328
|
-
lease: job.lease,
|
|
329
|
-
error: err instanceof Error ? err.message : String(err),
|
|
330
|
-
});
|
|
732
|
+
await api(cfg, `/api/agent/jobs/${job.jobId}/complete`, completionErrorBody(job.lease, err));
|
|
331
733
|
}
|
|
332
734
|
}
|
|
735
|
+
/**
|
|
736
|
+
* The daemon's startup banner line — pulled out of runAgentDaemon() so the printed
|
|
737
|
+
* version string is directly unit-testable without starting the real (infinite) loop.
|
|
738
|
+
*/
|
|
739
|
+
export function startupBanner(cfg) {
|
|
740
|
+
const fake = cfg.fake ? `, FAKE mode${cfg.fakeMode && cfg.fakeMode !== 'canned' ? ` (${cfg.fakeMode})` : ''}` : '';
|
|
741
|
+
return `[noesis-agent] v${agentVersion()} started (concurrency=${cfg.concurrency}${fake}) → ${cfg.apiBaseUrl}`;
|
|
742
|
+
}
|
|
333
743
|
/** Run the daemon loop until aborted. */
|
|
334
744
|
export async function runAgentDaemon(cfg, signal) {
|
|
335
745
|
// Footgun guard: keep billing on the subscription, not the metered API.
|
|
@@ -337,7 +747,12 @@ export async function runAgentDaemon(cfg, signal) {
|
|
|
337
747
|
console.error('[noesis-agent] WARNING: ANTHROPIC_API_KEY is set — unsetting it so the Claude Agent SDK uses your subscription, not the metered API.');
|
|
338
748
|
delete process.env.ANTHROPIC_API_KEY;
|
|
339
749
|
}
|
|
340
|
-
|
|
750
|
+
for (const warning of webFetchEnvWarnings(process.env))
|
|
751
|
+
console.error(warning);
|
|
752
|
+
console.error(startupBanner(cfg));
|
|
753
|
+
// Confirm the API token before polling: an expired token is explained and waited out
|
|
754
|
+
// (it recovers after an Extend); a revoked or unknown one ends the process here.
|
|
755
|
+
applyStartupCheck(await checkToken(cfg.apiBaseUrl, cfg.apiToken), tokenGate);
|
|
341
756
|
const workers = Array.from({ length: cfg.concurrency }, (_, i) => workerLoop(cfg, `worker-${i + 1}`, signal));
|
|
342
757
|
await Promise.all(workers);
|
|
343
758
|
}
|
|
@@ -355,7 +770,7 @@ async function workerLoop(cfg, workerId, signal) {
|
|
|
355
770
|
await runJob(cfg, job);
|
|
356
771
|
}
|
|
357
772
|
else {
|
|
358
|
-
await
|
|
773
|
+
await sleep(tokenGate.waitMs(POLL_INTERVAL_MS), signal);
|
|
359
774
|
}
|
|
360
775
|
}
|
|
361
776
|
}
|