@ziggs-ai/agent-sdk 0.11.0 → 0.11.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/README.md +1 -1
- package/dist/AgentHost.d.ts +81 -1
- package/dist/AgentHost.js +184 -4
- package/dist/adapters/McpAdapter.js +10 -12
- package/dist/agent/ClaudeSdkAgent.d.ts +9 -0
- package/dist/agent/ClaudeSdkAgent.js +127 -26
- package/dist/agent/claudeSdkTools.js +25 -6
- package/dist/defineAgent.js +31 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -0
- package/dist/pricing/fleetTierByAgentId.gen.d.ts +2 -2
- package/dist/pricing/fleetTierByAgentId.gen.js +9 -2
- package/dist/server/ZiggsEffectHandler.js +10 -1
- package/dist/server/inboxLoop.js +8 -3
- package/dist/server/polls/PollRunner.d.ts +36 -1
- package/dist/server/polls/PollRunner.js +42 -2
- package/dist/server/tasks/TaskService.d.ts +5 -5
- package/dist/server/tools/tier2/mcpConnectionTools.js +1 -1
- package/dist/tools/ToolManager.d.ts +1 -1
- package/dist/tools/ToolManager.js +2 -1
- package/dist/tools/defineTool.d.ts +7 -0
- package/dist/tools/defineTool.js +12 -0
- package/dist/tools/needs.d.ts +54 -0
- package/dist/tools/needs.js +84 -0
- package/dist/types.d.ts +11 -1
- package/package.json +2 -2
|
@@ -35,26 +35,30 @@ export function isNoticeOnlyWake(event, ownAgentId) {
|
|
|
35
35
|
}
|
|
36
36
|
const DEFAULT_STATE = 'ready';
|
|
37
37
|
/**
|
|
38
|
-
* Per-
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
|
|
38
|
+
* Per-wake turn cap — a runaway backstop, NOT a budget.
|
|
39
|
+
*
|
|
40
|
+
* Cost is bounded by the priced spend guard below, so this only has to sit above
|
|
41
|
+
* the longest honest job, with room for a retry inside it. A job that gathers
|
|
42
|
+
* before it works — reading the engagement, listing grants, discovering a
|
|
43
|
+
* connection's tools, pulling the data — spends around ten turns before it
|
|
44
|
+
* starts on the work it was hired for, then needs several more to draft, record
|
|
45
|
+
* the artifact and close the task. Set below that, the cap ends wakes with the
|
|
46
|
+
* gathering done and nothing filed.
|
|
47
|
+
*/
|
|
48
|
+
const DEFAULT_MAX_TURNS = Number(process.env.CLAUDE_SDK_MAX_TURNS ?? 30);
|
|
45
49
|
/**
|
|
46
50
|
* Hard ceiling on billable input tokens (incl. cache read/write) a single wake
|
|
47
|
-
* may spend before we abort the query. Defense-in-depth on the
|
|
51
|
+
* may spend before we abort the query. Defense-in-depth on the host's LLM key
|
|
48
52
|
* against a runaway loop; set high enough that normal tasks never trip it. The
|
|
49
53
|
* task ends unfinished (and logs loudly) rather than burning unbounded. */
|
|
50
54
|
const MAX_TASK_INPUT_TOKENS = Number(process.env.CLAUDE_SDK_MAX_INPUT_TOKENS ?? 500_000);
|
|
51
55
|
/**
|
|
52
56
|
* Per-lane wake-rate circuit breaker. The per-wake caps above bound one wake;
|
|
53
|
-
* nothing
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
57
|
+
* nothing bounds the NUMBER of wakes. An echo path — anything that turns an
|
|
58
|
+
* agent's own write back into a delivery addressed to it — drives one wake per
|
|
59
|
+
* wake, forever. Targeting never routes an agent its own writes, so this is a
|
|
60
|
+
* backstop: a lane waking faster than real traffic plausibly drives it skips the
|
|
61
|
+
* LLM (and logs loudly) instead of draining the key. */
|
|
58
62
|
const WAKE_WINDOW_MS = 10 * 60_000;
|
|
59
63
|
const MAX_WAKES_PER_WINDOW = Number(process.env.CLAUDE_SDK_MAX_WAKES_PER_WINDOW ?? 40);
|
|
60
64
|
/**
|
|
@@ -77,6 +81,19 @@ const MAX_SESSION_RESUME_INPUT_TOKENS = Number(process.env.CLAUDE_SDK_MAX_SESSIO
|
|
|
77
81
|
* that is all the CLI gives us — no code, no typed error — so keep it narrow
|
|
78
82
|
* enough that a different failure never claims the retry.
|
|
79
83
|
*/
|
|
84
|
+
/**
|
|
85
|
+
* "I ran out of turns", as Claude Code reports it.
|
|
86
|
+
*
|
|
87
|
+
* Turn exhaustion is not a crash: the wake did real work and stopped at a cap
|
|
88
|
+
* we set. It arrives as a thrown error result with no code and no type, so the
|
|
89
|
+
* message is all there is to match on — kept narrow so no other failure claims
|
|
90
|
+
* the handling.
|
|
91
|
+
*/
|
|
92
|
+
export function isMaxTurnsError(err) {
|
|
93
|
+
const message = err?.message;
|
|
94
|
+
return (typeof message === 'string' &&
|
|
95
|
+
/reached maximum number of turns/i.test(message));
|
|
96
|
+
}
|
|
80
97
|
export function isMissingSessionError(err) {
|
|
81
98
|
const message = err?.message;
|
|
82
99
|
return (typeof message === 'string' &&
|
|
@@ -90,6 +107,32 @@ function billableInput(usage) {
|
|
|
90
107
|
(usage.cache_read_input_tokens ?? 0) +
|
|
91
108
|
(usage.cache_creation_input_tokens ?? 0));
|
|
92
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* The same tokens, priced.
|
|
112
|
+
*
|
|
113
|
+
* A cache READ bills at ~0.1x base input and a cache WRITE at ~1.25x. The spend
|
|
114
|
+
* guard below exists to stop a runaway from draining the key, so it has to count
|
|
115
|
+
* what the key is actually charged — and counting a cache read as a full input
|
|
116
|
+
* token makes every multi-turn wake look like a runaway. The transcript replays
|
|
117
|
+
* on every turn, so a 40k context over ten turns reads as 400k of "spend" while
|
|
118
|
+
* costing about a tenth of that: unpriced, the guard fires on ordinary work long
|
|
119
|
+
* before any runaway would, and it fires mid-job — right after the gathering
|
|
120
|
+
* calls and before the deliverable, which is the whole of "the wake ends before
|
|
121
|
+
* it files anything".
|
|
122
|
+
*
|
|
123
|
+
* Weights, not prices: the ratio between the three input kinds is stable across
|
|
124
|
+
* Claude models even as the per-million rates change, so this stays right
|
|
125
|
+
* without tracking a price table.
|
|
126
|
+
*/
|
|
127
|
+
const CACHE_READ_WEIGHT = 0.1;
|
|
128
|
+
const CACHE_WRITE_WEIGHT = 1.25;
|
|
129
|
+
function costWeightedInput(usage) {
|
|
130
|
+
if (!usage)
|
|
131
|
+
return 0;
|
|
132
|
+
return ((usage.input_tokens ?? 0) +
|
|
133
|
+
(usage.cache_read_input_tokens ?? 0) * CACHE_READ_WEIGHT +
|
|
134
|
+
(usage.cache_creation_input_tokens ?? 0) * CACHE_WRITE_WEIGHT);
|
|
135
|
+
}
|
|
93
136
|
/**
|
|
94
137
|
* Per-API-call usage. Only assistant turns carry it — the result message's
|
|
95
138
|
* usage is the CUMULATIVE total of the whole query, so adding it on top of
|
|
@@ -106,8 +149,8 @@ function extractMetrics(message) {
|
|
|
106
149
|
return null;
|
|
107
150
|
const usage = message.usage;
|
|
108
151
|
// Billable input includes cache reads/writes — that is what the Anthropic
|
|
109
|
-
// console counts and what the
|
|
110
|
-
//
|
|
152
|
+
// console counts and what the key is charged for. Reporting only the uncached
|
|
153
|
+
// slice understates real usage by more than an order of magnitude.
|
|
111
154
|
const input = billableInput(usage);
|
|
112
155
|
const output = usage?.output_tokens ?? 0;
|
|
113
156
|
// Cache read/write are already folded into `input` above; surfaced separately
|
|
@@ -351,7 +394,16 @@ export class ClaudeSdkAgent {
|
|
|
351
394
|
});
|
|
352
395
|
let metrics = { llmCalls: 0, tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 } };
|
|
353
396
|
let sessionId = nextCtx.claudeSessionId;
|
|
397
|
+
// Cost-weighted, cumulative: what this wake has charged the key so far.
|
|
354
398
|
let spentInput = 0;
|
|
399
|
+
// The transcript's SIZE, which is a different question from spend and needs
|
|
400
|
+
// a different measure: the largest single turn's input, unweighted. On the
|
|
401
|
+
// cumulative sum a lane with a modest transcript crosses the resume ceiling
|
|
402
|
+
// simply by taking enough turns, and forgets its session after every wake —
|
|
403
|
+
// so each wake re-gathers from zero and stalls at the same step.
|
|
404
|
+
let peakTurnInput = 0;
|
|
405
|
+
// Why the wake stopped, when it stopped on a guard rather than on its own.
|
|
406
|
+
let abortedBy = null;
|
|
355
407
|
// stateless-per-wake never resumes — each wake is a fresh
|
|
356
408
|
// transcript, so per-wake input stays flat instead of replaying the whole
|
|
357
409
|
// lane history every turn.
|
|
@@ -408,11 +460,22 @@ export class ClaudeSdkAgent {
|
|
|
408
460
|
runtimeLog.warn('ClaudeSdkAgent', `query ended without success subtype=${message.subtype} session=${identity.sessionId}`);
|
|
409
461
|
}
|
|
410
462
|
// Runaway guard: abort before an unbounded loop drains the
|
|
411
|
-
//
|
|
463
|
+
// key. Breaking the for-await calls the generator's return(),
|
|
412
464
|
// which terminates the underlying query.
|
|
413
|
-
|
|
465
|
+
const turnUsage = usageOf(message);
|
|
466
|
+
spentInput += costWeightedInput(turnUsage);
|
|
467
|
+
peakTurnInput = Math.max(peakTurnInput, billableInput(turnUsage));
|
|
468
|
+
// A wake that produced no per-turn usage still reports a cumulative
|
|
469
|
+
// total on the result message. Divided by the turns it covers, that is
|
|
470
|
+
// the average context per turn — a coarser read of the same transcript,
|
|
471
|
+
// used only when the precise one is unavailable.
|
|
472
|
+
if (message.type === 'result') {
|
|
473
|
+
const turns = Math.max(1, message.num_turns ?? 1);
|
|
474
|
+
peakTurnInput = Math.max(peakTurnInput, billableInput(message.usage) / turns);
|
|
475
|
+
}
|
|
414
476
|
if (spentInput > MAX_TASK_INPUT_TOKENS) {
|
|
415
|
-
|
|
477
|
+
abortedBy = 'input-budget';
|
|
478
|
+
runtimeLog.warn('ClaudeSdkAgent', `input-token budget exceeded (~${Math.round(spentInput)} cost-weighted > ${MAX_TASK_INPUT_TOKENS}) session=${identity.sessionId} agent=${this.agentId} — aborting query to cap spend`);
|
|
416
479
|
break;
|
|
417
480
|
}
|
|
418
481
|
}
|
|
@@ -421,10 +484,34 @@ export class ClaudeSdkAgent {
|
|
|
421
484
|
signal?.removeEventListener('abort', onAbort);
|
|
422
485
|
}
|
|
423
486
|
};
|
|
487
|
+
/**
|
|
488
|
+
* Say, on the agent's own reasoning channel, that this wake stopped before
|
|
489
|
+
* it finished. Without the note a wake cut off by a guard is
|
|
490
|
+
* indistinguishable from one that delivered: the task stays `active` with
|
|
491
|
+
* `processing: false`, no artifact, no result, and nothing anywhere says
|
|
492
|
+
* why. The lane's history carries the reason instead, so a wedged-task
|
|
493
|
+
* detector has something to read.
|
|
494
|
+
*/
|
|
495
|
+
const noteWakeCutShort = async (reason) => {
|
|
496
|
+
const text = `Wake stopped early: ${reason}. Work in progress was NOT filed — no ` +
|
|
497
|
+
`artifact was recorded and no task was closed by this wake.`;
|
|
498
|
+
await eff({
|
|
499
|
+
kind: 'record-event',
|
|
500
|
+
sessionId: identity.sessionId,
|
|
501
|
+
entry: { kind: 'thought', text },
|
|
502
|
+
idempotencyKey: writeKeys.artifactKey({ kind: 'thought', text }),
|
|
503
|
+
}).catch((err) => runtimeLog.warn('ClaudeSdkAgent', `record abort note failed session=${identity.sessionId}: ${err.message}`));
|
|
504
|
+
};
|
|
424
505
|
try {
|
|
425
506
|
await consumeQuery(resumeId);
|
|
426
507
|
}
|
|
427
508
|
catch (err) {
|
|
509
|
+
// Turn exhaustion arrives as a thrown error result, not a break, so it
|
|
510
|
+
// needs its own note before the throw propagates.
|
|
511
|
+
if (isMaxTurnsError(err)) {
|
|
512
|
+
abortedBy = 'max-turns';
|
|
513
|
+
await noteWakeCutShort(`the turn cap (${this.maxTurns}) was reached`);
|
|
514
|
+
}
|
|
428
515
|
// the resumed transcript is gone. Claude Code stores transcripts
|
|
429
516
|
// on the container's own disk while the session id outlives it — persisted
|
|
430
517
|
// per lane in the session store with a 24h TTL — so a redeployed agents
|
|
@@ -445,6 +532,12 @@ export class ClaudeSdkAgent {
|
|
|
445
532
|
`session=${identity.sessionId} agent=${this.agentId} — retrying once on a fresh session`);
|
|
446
533
|
nextCtx.claudeSessionId = undefined;
|
|
447
534
|
sessionId = undefined;
|
|
535
|
+
// The first attempt failed before any turn ran, so it spent nothing and
|
|
536
|
+
// left no transcript. Carrying its counters into the retry would halve the
|
|
537
|
+
// budget the real attempt gets to work with.
|
|
538
|
+
spentInput = 0;
|
|
539
|
+
peakTurnInput = 0;
|
|
540
|
+
abortedBy = null;
|
|
448
541
|
try {
|
|
449
542
|
await consumeQuery(undefined);
|
|
450
543
|
}
|
|
@@ -460,17 +553,25 @@ export class ClaudeSdkAgent {
|
|
|
460
553
|
nextCtx.claudeSessionId = undefined;
|
|
461
554
|
else if (sessionId)
|
|
462
555
|
nextCtx.claudeSessionId = sessionId;
|
|
463
|
-
// bound resumed-transcript growth.
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
556
|
+
// bound resumed-transcript growth. Past the ceiling, forget the session so
|
|
557
|
+
// the next wake starts fresh rather than paying an ever-larger replay.
|
|
558
|
+
//
|
|
559
|
+
// Measured as the largest SINGLE turn's input — that is the transcript,
|
|
560
|
+
// since every turn re-reads the whole thing. Summing the wake's turns
|
|
561
|
+
// instead measures how much WORK a wake did, not how much it carries: a lane
|
|
562
|
+
// whose transcript never leaves ~40k crosses the ceiling on turn five and
|
|
563
|
+
// drops its session on every wake. Resuming then never happens, so each wake
|
|
564
|
+
// re-reads the world from scratch and runs out of budget at the same step,
|
|
565
|
+
// and a standing job cannot advance.
|
|
566
|
+
if (nextCtx.claudeSessionId && peakTurnInput > MAX_SESSION_RESUME_INPUT_TOKENS) {
|
|
567
|
+
runtimeLog.info('ClaudeSdkAgent', `resetting Claude session (transcript ~${peakTurnInput} > ${MAX_SESSION_RESUME_INPUT_TOKENS}) ` +
|
|
471
568
|
`session=${identity.sessionId} agent=${this.agentId} — next wake starts fresh`);
|
|
472
569
|
nextCtx.claudeSessionId = undefined;
|
|
473
570
|
}
|
|
571
|
+
if (abortedBy === 'input-budget') {
|
|
572
|
+
await noteWakeCutShort(`the per-wake spend guard cut this run off (~${Math.round(spentInput)} ` +
|
|
573
|
+
`cost-weighted input tokens > ${MAX_TASK_INPUT_TOKENS})`);
|
|
574
|
+
}
|
|
474
575
|
return {
|
|
475
576
|
ctx: nextCtx,
|
|
476
577
|
state: state ?? DEFAULT_STATE,
|
|
@@ -25,16 +25,33 @@ function firstFilled(...candidates) {
|
|
|
25
25
|
}
|
|
26
26
|
return undefined;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Accept the agreement id an agent was woken on where a chat id is wanted.
|
|
30
|
+
*
|
|
31
|
+
* An agent woken on an agreement reaches for its context with the only id that
|
|
32
|
+
* wake gave it — the agreement's. That is not a chat id, so the snapshot came
|
|
33
|
+
* back empty: no history, no agreements, the agent itself as the only participant,
|
|
34
|
+
* and a bare `chatMissing`. Observed on the FIRST wake of every two-agent run,
|
|
35
|
+
* and once it sent the provider off to chat instead of to its own task.
|
|
36
|
+
*
|
|
37
|
+
* An agreement's implicit notification chat is `agrn-<agreementId>` — the lane
|
|
38
|
+
* the backend itself routes agreement traffic through — so the id the agent has
|
|
39
|
+
* does name a readable room, one prefix away. Mapping it is the difference
|
|
40
|
+
* between a wasted turn and an answer.
|
|
41
|
+
*/
|
|
42
|
+
function asChatLane(id) {
|
|
43
|
+
return id?.startsWith('agr_') ? `agrn-${id}` : id;
|
|
44
|
+
}
|
|
28
45
|
// Named distinctly from ziggs-mcp's per-type `ziggs_context_read`: this is the
|
|
29
46
|
// one-shot aggregate snapshot (grant-fenced server-side), not a cursored read.
|
|
30
47
|
export const contextSnapshotTool = defineTool('context_snapshot', {
|
|
31
|
-
chatId: { type: 'string', description: 'Omit this to snapshot the chat you were woken in — that is the usual case, and you do not need to know its id. Only pass a chatId to snapshot a DIFFERENT chat.' },
|
|
48
|
+
chatId: { type: 'string', description: 'Omit this to snapshot the chat you were woken in — that is the usual case, and you do not need to know its id. Only pass a chatId to snapshot a DIFFERENT chat. An agreement id is accepted too, and reads that agreement\'s lane.' },
|
|
32
49
|
maxMessages: { type: 'number', description: `Message history cap (default ${DEFAULT_SNAPSHOT_MAX_MESSAGES}). Pass a larger value when you need deeper history.` },
|
|
33
50
|
}, async (args, ctx) => {
|
|
34
51
|
const c = ctx;
|
|
35
52
|
if (!c.eff)
|
|
36
53
|
throw new Error('context_snapshot requires eff in tool context');
|
|
37
|
-
const sessionId = firstFilled(args.chatId, c.chatId, c.sessionId);
|
|
54
|
+
const sessionId = asChatLane(firstFilled(args.chatId, c.chatId, c.sessionId));
|
|
38
55
|
if (!sessionId) {
|
|
39
56
|
throw new Error('context_snapshot: this wake carries no chat to snapshot. Call it with no arguments ' +
|
|
40
57
|
'to read the chat you were woken in; if this wake is on an agreement lane with no ' +
|
|
@@ -57,7 +74,9 @@ export const chatSendTool = defineTool('chat_send', {
|
|
|
57
74
|
const c = ctx;
|
|
58
75
|
if (!c.eff)
|
|
59
76
|
throw new Error('chat_send requires eff in tool context');
|
|
60
|
-
|
|
77
|
+
// Same agreement-id-where-a-chat-id-goes slip as context_snapshot, and the
|
|
78
|
+
// same mapping: an agreement's traffic lane is `agrn-<agreementId>`.
|
|
79
|
+
const sessionId = asChatLane(firstFilled(args.chatId, c.chatId, c.sessionId));
|
|
61
80
|
if (!sessionId) {
|
|
62
81
|
throw new Error('chat_send: this wake carries no chat to send in. Call it with no chatId to reply in ' +
|
|
63
82
|
'the chat you were woken in, or pass the chatId of the chat you mean.');
|
|
@@ -92,9 +111,9 @@ export const chatSendTool = defineTool('chat_send', {
|
|
|
92
111
|
// and a per-turn inbox poll fed the tool-operation echo loop.
|
|
93
112
|
//
|
|
94
113
|
// grant_list IS here: the hosted agent must always be able to answer
|
|
95
|
-
// "what grants of mine do you hold?" across every rail
|
|
96
|
-
//
|
|
97
|
-
//
|
|
114
|
+
// "what grants of mine do you hold?" across every rail. Without the tool an
|
|
115
|
+
// agent tells its principal the lookup does not exist. It reads
|
|
116
|
+
// operatorKey/agentId straight off the tool context.
|
|
98
117
|
//
|
|
99
118
|
// task_list IS here: MCP delegates have ziggs_task_list; the hosted
|
|
100
119
|
// Claude surface previously had no way to enumerate its own open tasks (reads
|
package/dist/defineAgent.js
CHANGED
|
@@ -3,6 +3,8 @@ import { injectKickoffDefault } from './runtime/kickoff.js';
|
|
|
3
3
|
import { injectTaskIntakeDefault } from './runtime/taskIntake.js';
|
|
4
4
|
import { runtimeLog } from './shared/runtimeLog.js';
|
|
5
5
|
import { DEFAULT_MODEL } from './AgentHost.js';
|
|
6
|
+
import { normalizeNeeds, toolsForNeeds } from './tools/needs.js';
|
|
7
|
+
import { toolName } from './tools/defineTool.js';
|
|
6
8
|
/**
|
|
7
9
|
* Validates the workflow synchronously, resolves merged tools and model, and
|
|
8
10
|
* returns a frozen AgentConfig. Never mutates the input. `wait` defaults remain
|
|
@@ -44,10 +46,34 @@ export function defineAgent(input) {
|
|
|
44
46
|
const { warnings } = validateWorkflow(workflow);
|
|
45
47
|
for (const w of warnings)
|
|
46
48
|
runtimeLog.warn('defineAgent', w);
|
|
47
|
-
|
|
49
|
+
// Tools the DECLARATION produces, not the author. An agent that says
|
|
50
|
+
// it needs a connection gets the tools to see and use one, so the prompt and
|
|
51
|
+
// the toolset cannot describe different worlds. Author tools come last and win
|
|
52
|
+
// on name, so hand-listing a derived tool is a no-op rather than a duplicate.
|
|
53
|
+
const needs = normalizeNeeds(input.needs);
|
|
54
|
+
const tools = [];
|
|
55
|
+
const positionByName = new Map();
|
|
56
|
+
for (const tool of [
|
|
57
|
+
...toolsForNeeds(needs),
|
|
48
58
|
...(Array.isArray(input.tools) ? input.tools : []),
|
|
49
59
|
...(Array.isArray(input.services?.tools) ? input.services.tools : []),
|
|
50
|
-
]
|
|
60
|
+
]) {
|
|
61
|
+
const name = toolName(tool);
|
|
62
|
+
// A nameless entry cannot be deduped — keep it and let ToolManager reject
|
|
63
|
+
// it the way it always has, rather than silently dropping it here.
|
|
64
|
+
if (!name) {
|
|
65
|
+
tools.push(tool);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const at = positionByName.get(name);
|
|
69
|
+
if (at === undefined) {
|
|
70
|
+
positionByName.set(name, tools.length);
|
|
71
|
+
tools.push(tool);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
tools[at] = tool;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
51
77
|
// Model resolution, most-specific-first: a per-agent pin, then the
|
|
52
78
|
// fleet-wide `LLM_MODEL` lever, then the single `DEFAULT_MODEL` fallback (the
|
|
53
79
|
// ONE place the default lives — see AgentHost). The id also picks the provider
|
|
@@ -76,6 +102,9 @@ export function defineAgent(input) {
|
|
|
76
102
|
...(input.services ? { services: input.services } : {}),
|
|
77
103
|
...(taskTools !== undefined ? { taskTools } : {}),
|
|
78
104
|
...rest,
|
|
105
|
+
// After `rest`, so the config carries the NORMALIZED needs — AgentHost
|
|
106
|
+
// enforces these at task start and should not have to re-parse the raw field.
|
|
107
|
+
...(needs.length ? { needs } : {}),
|
|
79
108
|
};
|
|
80
109
|
return Object.freeze(config);
|
|
81
110
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type { CognitionKind } from './agent/ClaudeSdkAgent.js';
|
|
|
20
20
|
export { ziggsBrain, claudeBrain, isBrainSelection, isClaudeCognition } from './brain/index.js';
|
|
21
21
|
export type { Brain, BrainSelection, ZiggsBrainConfig, ClaudeBrainConfig, } from './brain/index.js';
|
|
22
22
|
export type { TickInput, TickOutput, AgentOptions } from './agent/Agent.js';
|
|
23
|
-
export type { SessionResolver, AgentHostOptions } from './AgentHost.js';
|
|
23
|
+
export type { SessionResolver, AgentHostOptions, WakeRecord } from './AgentHost.js';
|
|
24
24
|
export { createZiggsEffectHandler } from './server/ZiggsEffectHandler.js';
|
|
25
25
|
export type { ZiggsEffectDeps } from './server/ZiggsEffectHandler.js';
|
|
26
26
|
export { EventQueue } from './server/EventQueue.js';
|
|
@@ -52,6 +52,8 @@ export { ToolProvider } from './tools/ToolProvider.js';
|
|
|
52
52
|
export { defineTool } from './tools/defineTool.js';
|
|
53
53
|
export { recordReport } from './tools/recordReport.js';
|
|
54
54
|
export type { RecordReportInput, RecordReportResult, TaskReport } from './tools/recordReport.js';
|
|
55
|
+
export { MCP_PROVIDER, describeNeed, normalizeNeeds, toolsForNeed, toolsForNeeds, unmetNeeds, unmetNeedsMessage, } from './tools/needs.js';
|
|
56
|
+
export type { AgentNeed, ConnectionNeed } from './tools/needs.js';
|
|
55
57
|
export { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
|
|
56
58
|
export { extractJSON, safeParseJSON } from './utils/jsonExtractor.js';
|
|
57
59
|
export { HistoryFormatter, historyFormatter, AgreementFormatter, agreementFormatter } from './formatters/index.js';
|
package/dist/index.js
CHANGED
|
@@ -48,6 +48,9 @@ export { ToolManager } from './tools/ToolManager.js';
|
|
|
48
48
|
export { ToolProvider } from './tools/ToolProvider.js';
|
|
49
49
|
export { defineTool } from './tools/defineTool.js';
|
|
50
50
|
export { recordReport } from './tools/recordReport.js';
|
|
51
|
+
// An agent declares the access it cannot work without, and the declaration
|
|
52
|
+
// produces the tools for it.
|
|
53
|
+
export { MCP_PROVIDER, describeNeed, normalizeNeeds, toolsForNeed, toolsForNeeds, unmetNeeds, unmetNeedsMessage, } from './tools/needs.js';
|
|
51
54
|
export { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
|
|
52
55
|
export { extractJSON, safeParseJSON } from './utils/jsonExtractor.js';
|
|
53
56
|
export { HistoryFormatter, historyFormatter, AgreementFormatter, agreementFormatter } from './formatters/index.js';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AUTO-GENERATED by scripts/build-fleet-tier-map.mjs — do not edit by hand.
|
|
3
|
-
*
|
|
3
|
+
* Local default pricing; the server resolves an agent's own pricing tier.
|
|
4
4
|
*/
|
|
5
5
|
export type FleetPricingTier = 'A' | 'B' | 'C' | 'D';
|
|
6
6
|
export declare const FLEET_TIER_BY_AGENT_ID: Record<string, FleetPricingTier>;
|
|
7
|
-
export declare const FLEET_TIER_MAP_SIZE =
|
|
7
|
+
export declare const FLEET_TIER_MAP_SIZE = 772;
|
|
@@ -11,11 +11,18 @@ export const FLEET_TIER_BY_AGENT_ID = {
|
|
|
11
11
|
"shopper-agent": "D",
|
|
12
12
|
"hire-agent": "B",
|
|
13
13
|
"ziggs-docs-agent": "B",
|
|
14
|
+
"agent-definer": "B",
|
|
15
|
+
"linear-janitor": "B",
|
|
16
|
+
"shipped-digest": "B",
|
|
17
|
+
"docs-reviewer": "B",
|
|
18
|
+
"email-agent": "B",
|
|
14
19
|
"relay-coordinator": "D",
|
|
20
|
+
"retry-coordinator": "D",
|
|
21
|
+
"scatter-coordinator": "D",
|
|
22
|
+
"race-coordinator": "D",
|
|
15
23
|
"relay-intake-worker": "D",
|
|
16
24
|
"relay-work-worker": "D",
|
|
17
25
|
"relay-deliver-worker": "D",
|
|
18
|
-
"retry-coordinator": "D",
|
|
19
26
|
"retry-fail-worker": "D",
|
|
20
27
|
"retry-ok-worker": "D",
|
|
21
28
|
"weather-forecaster": "A",
|
|
@@ -765,4 +772,4 @@ export const FLEET_TIER_BY_AGENT_ID = {
|
|
|
765
772
|
"polar-researcher": "B",
|
|
766
773
|
"death-studies-scholar": "B"
|
|
767
774
|
};
|
|
768
|
-
export const FLEET_TIER_MAP_SIZE =
|
|
775
|
+
export const FLEET_TIER_MAP_SIZE = 772;
|
|
@@ -331,7 +331,16 @@ export function createZiggsEffectHandler(deps) {
|
|
|
331
331
|
};
|
|
332
332
|
const handler = async (effect) => {
|
|
333
333
|
const response = await dispatch(effect);
|
|
334
|
-
|
|
334
|
+
// A tap observes; it must never decide the effect's fate. This call was bare,
|
|
335
|
+
// so a tap that threw took the effect down with it — and taps stringify
|
|
336
|
+
// arbitrary tool results, which throws on a circular value or a BigInt. The
|
|
337
|
+
// other two taps (metrics, wake) are already guarded; this is the last one.
|
|
338
|
+
try {
|
|
339
|
+
deps.effectTap?.(effect, response);
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
runtimeLog.warn('ZiggsEffectHandler', `effectTap failed: ${err?.message || err}`);
|
|
343
|
+
}
|
|
335
344
|
return response;
|
|
336
345
|
};
|
|
337
346
|
return handler;
|
package/dist/server/inboxLoop.js
CHANGED
|
@@ -216,10 +216,10 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
|
|
|
216
216
|
// this window has failed too many times in a row and is now poison, in which
|
|
217
217
|
// case it is dropped loudly rather than wedging the agent forever.
|
|
218
218
|
let acked = false;
|
|
219
|
-
//
|
|
219
|
+
// A capped envelope has unlisted older deliveries behind ackTo;
|
|
220
220
|
// acking would refuse or bury them. Hold until a later uncapped pass.
|
|
221
221
|
if (envelope.deliveriesCapped && envelope.ackTo) {
|
|
222
|
-
warn(label, `holding the watermark at ${envelope.ackTo} — deliveriesCapped; ack would not cover the unlisted tail
|
|
222
|
+
warn(label, `holding the watermark at ${envelope.ackTo} — deliveriesCapped; ack would not cover the unlisted tail`);
|
|
223
223
|
}
|
|
224
224
|
else if (envelope.ackTo && (failures === 0 || opts.forceAck)) {
|
|
225
225
|
if (failures > 0) {
|
|
@@ -251,7 +251,12 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
|
|
|
251
251
|
failures,
|
|
252
252
|
};
|
|
253
253
|
}
|
|
254
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Default long-poll hold for hosted agents. Matches the server's own ceiling,
|
|
256
|
+
* and assumes a proxy idle timeout comfortably above it rather than the 25s
|
|
257
|
+
* some defaults ship with. Lower it if your deployment sits behind a shorter one.
|
|
258
|
+
*/
|
|
259
|
+
const DEFAULT_WAIT_SECONDS = 110;
|
|
255
260
|
const ERROR_BACKOFF_BASE_MS = 1_000;
|
|
256
261
|
const ERROR_BACKOFF_MAX_MS = 30_000;
|
|
257
262
|
/**
|
|
@@ -41,6 +41,26 @@ export interface PollRunnerOptions {
|
|
|
41
41
|
* through every single time.
|
|
42
42
|
*/
|
|
43
43
|
wakeTimeoutMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Most actionable wakes one binding may cause per {@link wakeWindowMs}.
|
|
46
|
+
* Default 20.
|
|
47
|
+
*
|
|
48
|
+
* A poll wakes its agent whenever the source says `actionable`, and each wake
|
|
49
|
+
* is a real model run. Nothing else bounds that: the agent polls the outside
|
|
50
|
+
* world and triggers itself, so the backend never sees it and the agreement
|
|
51
|
+
* wake ceiling never gets a chance to fire. A mailbox that stops being
|
|
52
|
+
* archived keeps answering "yes, there is unhandled mail" forever, at one wake
|
|
53
|
+
* per interval, indefinitely.
|
|
54
|
+
*
|
|
55
|
+
* 20 an hour is far above any healthy agent and roughly 20 minutes of a
|
|
56
|
+
* runaway at the 60s default interval.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately a runner option and NOT read from the PollKind or the binding:
|
|
59
|
+
* a bound that the bounded thing can raise is not a bound.
|
|
60
|
+
*/
|
|
61
|
+
maxWakesPerWindow?: number;
|
|
62
|
+
/** Window the wake cap is measured over. Default 1 hour. */
|
|
63
|
+
wakeWindowMs?: number;
|
|
44
64
|
label?: string;
|
|
45
65
|
}
|
|
46
66
|
export declare class PollRunner {
|
|
@@ -49,9 +69,11 @@ export declare class PollRunner {
|
|
|
49
69
|
private readonly label;
|
|
50
70
|
private readonly maxBackoffMs;
|
|
51
71
|
private readonly wakeTimeoutMs;
|
|
72
|
+
private readonly maxWakesPerWindow;
|
|
73
|
+
private readonly wakeWindowMs;
|
|
52
74
|
private readonly loops;
|
|
53
75
|
private running;
|
|
54
|
-
constructor({ pool, registry, agents, operatorKey, defaultIntervalMs, maxBackoffMs, wakeTimeoutMs, label, }: PollRunnerOptions);
|
|
76
|
+
constructor({ pool, registry, agents, operatorKey, defaultIntervalMs, maxBackoffMs, wakeTimeoutMs, maxWakesPerWindow, wakeWindowMs, label, }: PollRunnerOptions);
|
|
55
77
|
get size(): number;
|
|
56
78
|
start(): void;
|
|
57
79
|
stop(): void;
|
|
@@ -59,4 +81,17 @@ export declare class PollRunner {
|
|
|
59
81
|
/** Interval while healthy; doubling up to the ceiling while failing. */
|
|
60
82
|
private nextDelay;
|
|
61
83
|
private tick;
|
|
84
|
+
/**
|
|
85
|
+
* Take one wake from this binding's budget, or refuse.
|
|
86
|
+
*
|
|
87
|
+
* Refusing is announced once per window, at error level, and resuming is
|
|
88
|
+
* announced too. A poll that simply goes quiet is worse than one that wakes
|
|
89
|
+
* too much: from the outside it is indistinguishable from a mailbox that is
|
|
90
|
+
* genuinely empty, so nobody looks.
|
|
91
|
+
*
|
|
92
|
+
* The source keeps being polled while capped. Stopping the poll as well would
|
|
93
|
+
* mean the first tick after the window rolls is the one that discovers the
|
|
94
|
+
* backlog, and a source that recovered would go unnoticed for a whole window.
|
|
95
|
+
*/
|
|
96
|
+
private claimWake;
|
|
62
97
|
}
|
|
@@ -26,14 +26,18 @@ export class PollRunner {
|
|
|
26
26
|
label;
|
|
27
27
|
maxBackoffMs;
|
|
28
28
|
wakeTimeoutMs;
|
|
29
|
+
maxWakesPerWindow;
|
|
30
|
+
wakeWindowMs;
|
|
29
31
|
loops = [];
|
|
30
32
|
running = false;
|
|
31
|
-
constructor({ pool, registry, agents, operatorKey, defaultIntervalMs = 60_000, maxBackoffMs = 15 * 60_000, wakeTimeoutMs = Number(process.env.CLAUDE_SDK_TICK_TIMEOUT_MS ?? 300_000), label = 'PollRunner', }) {
|
|
33
|
+
constructor({ pool, registry, agents, operatorKey, defaultIntervalMs = 60_000, maxBackoffMs = 15 * 60_000, wakeTimeoutMs = Number(process.env.CLAUDE_SDK_TICK_TIMEOUT_MS ?? 300_000), maxWakesPerWindow = 20, wakeWindowMs = 60 * 60_000, label = 'PollRunner', }) {
|
|
32
34
|
this.pool = pool;
|
|
33
35
|
this.operatorKey = operatorKey;
|
|
34
36
|
this.label = label;
|
|
35
37
|
this.maxBackoffMs = maxBackoffMs;
|
|
36
38
|
this.wakeTimeoutMs = wakeTimeoutMs;
|
|
39
|
+
this.maxWakesPerWindow = maxWakesPerWindow;
|
|
40
|
+
this.wakeWindowMs = wakeWindowMs;
|
|
37
41
|
for (const config of agents) {
|
|
38
42
|
const agentId = config['agentId'];
|
|
39
43
|
if (typeof agentId !== 'string' || !agentId)
|
|
@@ -61,6 +65,9 @@ export class PollRunner {
|
|
|
61
65
|
chatId: binding.chatId,
|
|
62
66
|
timer: null,
|
|
63
67
|
failures: 0,
|
|
68
|
+
wakes: 0,
|
|
69
|
+
windowStartedAt: Date.now(),
|
|
70
|
+
capped: false,
|
|
64
71
|
});
|
|
65
72
|
}
|
|
66
73
|
}
|
|
@@ -115,7 +122,7 @@ export class PollRunner {
|
|
|
115
122
|
this.schedule(loop);
|
|
116
123
|
return;
|
|
117
124
|
}
|
|
118
|
-
if (result?.actionable) {
|
|
125
|
+
if (result?.actionable && this.claimWake(loop)) {
|
|
119
126
|
const hint = result.hint ?? `New work from ${loop.kind.id}. Check it.`;
|
|
120
127
|
try {
|
|
121
128
|
// Awaited on purpose — see the header. The next tick waits for the
|
|
@@ -135,4 +142,37 @@ export class PollRunner {
|
|
|
135
142
|
}
|
|
136
143
|
this.schedule(loop);
|
|
137
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Take one wake from this binding's budget, or refuse.
|
|
147
|
+
*
|
|
148
|
+
* Refusing is announced once per window, at error level, and resuming is
|
|
149
|
+
* announced too. A poll that simply goes quiet is worse than one that wakes
|
|
150
|
+
* too much: from the outside it is indistinguishable from a mailbox that is
|
|
151
|
+
* genuinely empty, so nobody looks.
|
|
152
|
+
*
|
|
153
|
+
* The source keeps being polled while capped. Stopping the poll as well would
|
|
154
|
+
* mean the first tick after the window rolls is the one that discovers the
|
|
155
|
+
* backlog, and a source that recovered would go unnoticed for a whole window.
|
|
156
|
+
*/
|
|
157
|
+
claimWake(loop) {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
if (now - loop.windowStartedAt >= this.wakeWindowMs) {
|
|
160
|
+
if (loop.capped) {
|
|
161
|
+
runtimeLog.info(this.label, `${loop.agentId}:${loop.kind.id} wake budget reset — waking again (cap ${this.maxWakesPerWindow}/${this.wakeWindowMs}ms)`);
|
|
162
|
+
}
|
|
163
|
+
loop.windowStartedAt = now;
|
|
164
|
+
loop.wakes = 0;
|
|
165
|
+
loop.capped = false;
|
|
166
|
+
}
|
|
167
|
+
if (loop.wakes >= this.maxWakesPerWindow) {
|
|
168
|
+
if (!loop.capped) {
|
|
169
|
+
loop.capped = true;
|
|
170
|
+
runtimeLog.error(this.label, `${loop.agentId}:${loop.kind.id} hit the wake cap (${this.maxWakesPerWindow} in ${this.wakeWindowMs}ms) and will NOT be woken again until the window rolls. ` +
|
|
171
|
+
`The source keeps reporting actionable, so either it is genuinely that busy or something downstream stopped clearing it.`);
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
loop.wakes += 1;
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
138
178
|
}
|
|
@@ -8,10 +8,10 @@ export declare class TaskService {
|
|
|
8
8
|
getActiveTasksForChat(chatId: string): Promise<Task[]>;
|
|
9
9
|
getSubtasks(parentTaskId: string): Promise<Task[]>;
|
|
10
10
|
spawnUnderAgreement(agreementId: string, taskData: Omit<CreateTaskData, 'agreementId'>): Promise<Task>;
|
|
11
|
-
updateState(taskId: string, state: TaskState, result?: unknown, idempotencyKey?: string): Promise<
|
|
12
|
-
updatePlanStep(taskId: string, stepId: string, status: string, result?: unknown): Promise<
|
|
13
|
-
cancel(taskId: string): Promise<
|
|
14
|
-
report(taskId: string, message?: string): Promise<
|
|
15
|
-
setSatisfaction(taskId: string, satisfaction: 'positive' | 'negative'): Promise<
|
|
11
|
+
updateState(taskId: string, state: TaskState, result?: unknown, idempotencyKey?: string): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
|
|
12
|
+
updatePlanStep(taskId: string, stepId: string, status: string, result?: unknown): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
|
|
13
|
+
cancel(taskId: string): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
|
|
14
|
+
report(taskId: string, message?: string): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
|
|
15
|
+
setSatisfaction(taskId: string, satisfaction: 'positive' | 'negative'): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
|
|
16
16
|
count(filters?: CountTasksFilters): Promise<number>;
|
|
17
17
|
}
|
|
@@ -4,7 +4,7 @@ import { ConnectionsClient } from '@ziggs-ai/api-client';
|
|
|
4
4
|
import { defineTool } from '../../../tools/defineTool.js';
|
|
5
5
|
import { resolveZiggsGatewayTarget } from '../../../adapters/McpAdapter.js';
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* Calling a tool on a connected MCP server, one shot.
|
|
8
8
|
*
|
|
9
9
|
* `connection_proxy` cannot do this. That path resolves a NAMED connector from
|
|
10
10
|
* `connection.provider` and checks the action against its fixed action list; a
|