@noesis-brain/mcp-server 2.1.5 → 2.5.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/README.md +10 -1
- 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 +103 -6
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +375 -64
- 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 +19 -1
- package/dist/api/NoesisClient.d.ts.map +1 -1
- package/dist/api/NoesisClient.js +55 -3
- package/dist/api/NoesisClient.js.map +1 -1
- package/dist/cli/setup.js +1 -1
- package/dist/cli/setup.js.map +1 -1
- package/dist/machineLabel.d.ts +16 -0
- package/dist/machineLabel.d.ts.map +1 -0
- package/dist/machineLabel.js +43 -0
- package/dist/machineLabel.js.map +1 -0
- package/dist/tools/backlog.d.ts +21 -0
- package/dist/tools/backlog.d.ts.map +1 -1
- package/dist/tools/backlog.js +123 -1
- package/dist/tools/backlog.js.map +1 -1
- package/dist/tools/claudeSessions.d.ts +83 -0
- package/dist/tools/claudeSessions.d.ts.map +1 -0
- package/dist/tools/claudeSessions.js +330 -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 +166 -37
- 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 +51 -12
- package/skill-templates/noesis-refine-relations.md +163 -0
- package/skill-templates/noesis-ticket.md +79 -0
- package/templates/claude-md-block.md +7 -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;
|
|
@@ -211,14 +296,16 @@ export function buildSdkPrompt(prompt, images) {
|
|
|
211
296
|
/**
|
|
212
297
|
* SECOND, INDEPENDENT LAYER on the tool boundary.
|
|
213
298
|
*
|
|
214
|
-
* `tools
|
|
215
|
-
* flag, whose handling lives in a minified `cli.js`
|
|
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
|
|
216
302
|
* gate we own outright: the SDK routes every permission request through it
|
|
217
303
|
* (`--permission-prompt-tool stdio`), and the request carries an `agentID`, so calls made
|
|
218
304
|
* by a spawned `Task` SUBAGENT are covered too — which matters, because a subagent, not
|
|
219
305
|
* the main loop, did the file reading in the 2026-08-14 incident.
|
|
220
306
|
*
|
|
221
|
-
* Deliberately
|
|
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.
|
|
222
309
|
*
|
|
223
310
|
* DO NOT DELETE THIS AS DEAD CODE. It is the ONLY daemon-owned control over MCP tools.
|
|
224
311
|
* `tools: []` governs the SDK's BUILT-IN set and nothing else; the Noesis MCP server we
|
|
@@ -240,6 +327,13 @@ export function buildSdkPrompt(prompt, images) {
|
|
|
240
327
|
* callback is never consulted there. Nothing executes — but that is Claude Code internal
|
|
241
328
|
* behaviour we neither own nor test, which is why the daemon is rooted at a neutral cwd
|
|
242
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.
|
|
243
337
|
*/
|
|
244
338
|
export function buildCanUseTool(
|
|
245
339
|
/**
|
|
@@ -251,44 +345,69 @@ export function buildCanUseTool(
|
|
|
251
345
|
* The sets happen to coincide for chat today, which is exactly why it would have gone
|
|
252
346
|
* unnoticed until the first job kind with a narrower allowlist.
|
|
253
347
|
*/
|
|
254
|
-
permitted, onDecision = logToolDecision) {
|
|
348
|
+
permitted, onDecision = logToolDecision, checkUrl = checkWebFetchUrl) {
|
|
255
349
|
const permittedSet = new Set(permitted);
|
|
256
350
|
return async (toolName, input, ctx) => {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 };
|
|
262
368
|
};
|
|
263
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
|
+
}
|
|
264
375
|
/** Default sink: stderr, so the daemon log is the evidence for whether the gate ever fires. */
|
|
265
|
-
function logToolDecision(toolName, allowed, agentID) {
|
|
266
|
-
console.error(`[noesis-agent] canUseTool ${allowed ? 'ALLOW' : 'DENY'} ${toolName}${agentID ? ` (agent=${agentID})` : ''}`);
|
|
376
|
+
function logToolDecision(toolName, allowed, agentID, detail) {
|
|
377
|
+
console.error(`[noesis-agent] canUseTool ${allowed ? 'ALLOW' : 'DENY'} ${toolName}${detail ? ` ${detail}` : ''}${agentID ? ` (agent=${agentID})` : ''}`);
|
|
267
378
|
}
|
|
268
379
|
/**
|
|
269
380
|
* The `options` object handed to sdk.query() — extracted, like buildSdkPrompt above, so
|
|
270
|
-
* the tool boundary is asserted by a test instead of resting on a comment. `tools
|
|
271
|
-
*
|
|
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
|
|
272
383
|
* factory as an argument so a test can supply a stub instead of a real spawn config.
|
|
273
384
|
*/
|
|
274
385
|
export function buildQueryOptions(payload, mcpServersFor) {
|
|
275
|
-
const
|
|
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)));
|
|
276
391
|
return {
|
|
277
392
|
...(payload.system ? { systemPrompt: payload.system } : {}),
|
|
278
|
-
// Suppress the SDK's built-in tool set
|
|
279
|
-
// defaults to ALL built-ins — Bash, Read, Write,
|
|
280
|
-
// ALLOWED_TOOLS above does NOT stop them: `allowedTools`
|
|
281
|
-
// so unlisted tools are un-pre-approved, not absent, and
|
|
282
|
-
// reach the model. Worse, the read-only built-ins need no
|
|
283
|
-
// actually EXECUTE headless: on 2026-08-14 an English-coach
|
|
284
|
-
// daemon's cwd and returned ~100 strings from the user's source tree
|
|
285
|
-
// only the follow-up file Write stopped, at the permission gate.
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
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,
|
|
290
407
|
// Independent second layer — see buildCanUseTool. Covers subagent (`Task`) calls too.
|
|
291
|
-
|
|
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]),
|
|
292
411
|
allowedTools,
|
|
293
412
|
// Only spawn the Noesis MCP server when a tool actually survived clamping.
|
|
294
413
|
...(allowedTools.length > 0 ? { mcpServers: mcpServersFor() } : {}),
|
|
@@ -325,14 +444,13 @@ export function runSdkQuery(sdk, payload, kindLabel, mcpServersFor) {
|
|
|
325
444
|
async function executeJob(cfg, job, sink) {
|
|
326
445
|
const prompt = resolveJobPrompt(job.payload, job.kind);
|
|
327
446
|
if (cfg.fake) {
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
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);
|
|
336
454
|
}
|
|
337
455
|
// Real path: the Agent SDK uses the ambient Claude Code subscription login.
|
|
338
456
|
// Dynamically imported via a NON-LITERAL specifier so tsc doesn't require the
|
|
@@ -343,36 +461,170 @@ async function executeJob(cfg, job, sink) {
|
|
|
343
461
|
const sdk = await import(sdkModule).catch(() => {
|
|
344
462
|
throw new Error('Install @anthropic-ai/claude-agent-sdk to run the Noesis local agent (real mode).');
|
|
345
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) {
|
|
346
488
|
let finalText = '';
|
|
347
489
|
let sawDelta = false;
|
|
348
490
|
let terminalText = '';
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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;
|
|
363
551
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
552
|
+
}
|
|
553
|
+
catch (e) {
|
|
554
|
+
throw failure() ?? e;
|
|
555
|
+
}
|
|
556
|
+
if (!succeeded) {
|
|
557
|
+
const f = failure();
|
|
558
|
+
if (f)
|
|
559
|
+
throw f;
|
|
369
560
|
}
|
|
370
561
|
if (!sawDelta && terminalText) {
|
|
371
|
-
|
|
562
|
+
out.push(terminalText);
|
|
372
563
|
finalText = terminalText;
|
|
373
564
|
}
|
|
565
|
+
if (hitTurnLimit && !out.isClosed())
|
|
566
|
+
emit(finalText ? TURN_LIMIT_NOTE : TURN_LIMIT_NOTE.trimStart());
|
|
374
567
|
return finalText;
|
|
375
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
|
+
}
|
|
376
628
|
/**
|
|
377
629
|
* Text carried by one streamed partial. Verified against the SDK (0.1.x): partials
|
|
378
630
|
* arrive as `{type:'stream_event', event:{type:'content_block_delta',
|
|
@@ -407,6 +659,62 @@ function extractTerminalText(message) {
|
|
|
407
659
|
}
|
|
408
660
|
return '';
|
|
409
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
|
+
}
|
|
410
718
|
async function runJob(cfg, job) {
|
|
411
719
|
const sink = new EventSink(cfg, job.jobId, job.lease);
|
|
412
720
|
sink.start();
|
|
@@ -421,10 +729,7 @@ async function runJob(cfg, job) {
|
|
|
421
729
|
await sink.finish();
|
|
422
730
|
if (sink.isClosed())
|
|
423
731
|
return;
|
|
424
|
-
await api(cfg, `/api/agent/jobs/${job.jobId}/complete`,
|
|
425
|
-
lease: job.lease,
|
|
426
|
-
error: err instanceof Error ? err.message : String(err),
|
|
427
|
-
});
|
|
732
|
+
await api(cfg, `/api/agent/jobs/${job.jobId}/complete`, completionErrorBody(job.lease, err));
|
|
428
733
|
}
|
|
429
734
|
}
|
|
430
735
|
/**
|
|
@@ -432,7 +737,8 @@ async function runJob(cfg, job) {
|
|
|
432
737
|
* version string is directly unit-testable without starting the real (infinite) loop.
|
|
433
738
|
*/
|
|
434
739
|
export function startupBanner(cfg) {
|
|
435
|
-
|
|
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}`;
|
|
436
742
|
}
|
|
437
743
|
/** Run the daemon loop until aborted. */
|
|
438
744
|
export async function runAgentDaemon(cfg, signal) {
|
|
@@ -441,7 +747,12 @@ export async function runAgentDaemon(cfg, signal) {
|
|
|
441
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.');
|
|
442
748
|
delete process.env.ANTHROPIC_API_KEY;
|
|
443
749
|
}
|
|
750
|
+
for (const warning of webFetchEnvWarnings(process.env))
|
|
751
|
+
console.error(warning);
|
|
444
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);
|
|
445
756
|
const workers = Array.from({ length: cfg.concurrency }, (_, i) => workerLoop(cfg, `worker-${i + 1}`, signal));
|
|
446
757
|
await Promise.all(workers);
|
|
447
758
|
}
|
|
@@ -459,7 +770,7 @@ async function workerLoop(cfg, workerId, signal) {
|
|
|
459
770
|
await runJob(cfg, job);
|
|
460
771
|
}
|
|
461
772
|
else {
|
|
462
|
-
await
|
|
773
|
+
await sleep(tokenGate.waitMs(POLL_INTERVAL_MS), signal);
|
|
463
774
|
}
|
|
464
775
|
}
|
|
465
776
|
}
|