@ziggs-ai/agent-sdk 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/dist/AgentHost.d.ts +108 -6
- package/dist/AgentHost.js +290 -18
- 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/claudeSdkProtocol.d.ts +1 -1
- package/dist/agent/claudeSdkProtocol.js +3 -2
- package/dist/agent/claudeSdkTools.js +71 -16
- package/dist/defineAgent.js +31 -2
- package/dist/index.d.ts +7 -3
- package/dist/index.js +11 -1
- package/dist/pricing/fleetTierByAgentId.gen.d.ts +2 -2
- package/dist/pricing/fleetTierByAgentId.gen.js +9 -2
- package/dist/runtime/PromptBuilder.js +13 -1
- package/dist/server/ConnectionPool.d.ts +19 -0
- package/dist/server/ConnectionPool.js +30 -0
- package/dist/server/OutboxBuffer.d.ts +1 -1
- package/dist/server/ZiggsEffectHandler.d.ts +1 -1
- package/dist/server/ZiggsEffectHandler.js +16 -3
- package/dist/server/agreements/AgreementService.d.ts +8 -3
- package/dist/server/agreements/AgreementService.js +10 -5
- package/dist/server/inboxLoop.d.ts +6 -6
- package/dist/server/inboxLoop.js +39 -21
- package/dist/server/polls/PollRunner.d.ts +36 -1
- package/dist/server/polls/PollRunner.js +42 -2
- package/dist/server/requestMatch.d.ts +5 -0
- package/dist/server/{questMatch.js → requestMatch.js} +2 -2
- package/dist/server/runLauncher.js +24 -3
- package/dist/server/tasks/TaskService.d.ts +5 -5
- package/dist/server/tools/index.d.ts +1 -1
- package/dist/server/tools/index.js +1 -1
- package/dist/server/tools/tier1/protocolRunner.js +2 -2
- package/dist/server/tools/tier1/protocolTools.js +7 -7
- package/dist/server/tools/tier2/marketplaceTools.js +2 -2
- package/dist/server/tools/tier2/mcpConnectionTools.d.ts +33 -0
- package/dist/server/tools/tier2/mcpConnectionTools.js +139 -7
- package/dist/server/tools/tier2/paymentTools.d.ts +0 -8
- package/dist/server/tools/tier2/paymentTools.js +8 -12
- 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 +34 -5
- package/package.json +2 -2
- package/dist/server/questMatch.d.ts +0 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- `AgentHostOptions.questMatchTags` is now `requestMatchTags`. The option is an
|
|
8
|
+
allow-list field, so an agent still passing the old name has it silently
|
|
9
|
+
dropped rather than rejected — rename the call site.
|
|
10
|
+
- The `quest` vocabulary is renamed to `request` throughout: the inbox delivery
|
|
11
|
+
kind an agent reads, the `ClaimedKind` a claim returns, and the marketplace
|
|
12
|
+
listing fields. Re-exported api-client types move with it.
|
|
13
|
+
|
|
3
14
|
## 0.10.0 — unreleased
|
|
4
15
|
|
|
5
16
|
### Removed (breaking)
|
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ The public **`Brain`** interface is `tick(TickInput): Promise<TickOutput>`. Buil
|
|
|
91
91
|
2. Pass **`openaiKey`**, **`operatorKey`** (Ziggs operator token), **`agentId`**, etc., and **`createAgent(config)`** (returns an **`AgentHost`**).
|
|
92
92
|
3. **`connect()`** to go live; the SDK long-polls the HTTP inbox and routes deliveries into **`handleMessage`**.
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
Worked examples and the full build path: https://ziggsai.com/docs
|
|
95
95
|
|
|
96
96
|
Point the shared HTTP client at a non-default backend (self-hosted / local) before constructing hosts — no direct `@ziggs-ai/api-client` dependency needed:
|
|
97
97
|
|
package/dist/AgentHost.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AgentConfig, Ctx, Workflow } from './types.js';
|
|
2
|
-
import { Agent } from './agent/Agent.js';
|
|
2
|
+
import { Agent, type TickOutput } from './agent/Agent.js';
|
|
3
3
|
import { ClaudeSdkAgent, type CognitionKind } from './agent/ClaudeSdkAgent.js';
|
|
4
4
|
import { type BrainSelection } from './brain/index.js';
|
|
5
5
|
export type { CognitionKind };
|
|
@@ -23,8 +23,9 @@ export interface SessionStore {
|
|
|
23
23
|
}): Promise<void>;
|
|
24
24
|
}
|
|
25
25
|
import { ToolManager } from './tools/ToolManager.js';
|
|
26
|
-
import type
|
|
26
|
+
import { type ToolDefinition } from './tools/defineTool.js';
|
|
27
27
|
import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
|
|
28
|
+
import { type AgentNeed } from './tools/needs.js';
|
|
28
29
|
/**
|
|
29
30
|
* The single fleet-wide default model — the one place the fallback lives.
|
|
30
31
|
* Change this (or set `LLM_MODEL`) to move the whole fleet; nothing hardcodes a
|
|
@@ -76,12 +77,18 @@ export interface AgentHostOptions {
|
|
|
76
77
|
systemPrompt?: string;
|
|
77
78
|
specialization?: string | null;
|
|
78
79
|
/**
|
|
79
|
-
* strings compared exactly against a
|
|
80
|
+
* strings compared exactly against a request's `match` field.
|
|
80
81
|
* Miss → ack with zero LLM. Match → normal wake. `specialization` is also
|
|
81
82
|
* checked when set.
|
|
82
83
|
*/
|
|
83
|
-
|
|
84
|
+
requestMatchTags?: string[];
|
|
84
85
|
tools?: ToolDefinition[];
|
|
86
|
+
/**
|
|
87
|
+
* Access this agent cannot work without. Enforced at task start:
|
|
88
|
+
* a task arriving with no live grant behind a declared need is failed with the
|
|
89
|
+
* missing access named, before the tick spends anything.
|
|
90
|
+
*/
|
|
91
|
+
needs?: AgentNeed[];
|
|
85
92
|
taskTools?: 'all' | 'none' | string[];
|
|
86
93
|
/**
|
|
87
94
|
* Brain selection — preferred over legacy `cognition`.
|
|
@@ -142,6 +149,17 @@ export interface AgentHostOptions {
|
|
|
142
149
|
cognition: string;
|
|
143
150
|
metrics: import('./agent/Agent.js').TickOutput['metrics'];
|
|
144
151
|
}) => void;
|
|
152
|
+
/**
|
|
153
|
+
* Called with everything a wake consists of, immediately before the brain
|
|
154
|
+
* runs. Pair it with {@link AgentHost.replayWake} to re-run that exact wake
|
|
155
|
+
* later against edited agent source — the difference between iterating on a
|
|
156
|
+
* prompt in seconds and standing a whole world back up to see one turn.
|
|
157
|
+
*
|
|
158
|
+
* Emitted for every wake, including ones the brain then declines to spend an
|
|
159
|
+
* LLM call on, because "why did nothing happen?" is the question a recording
|
|
160
|
+
* most often has to answer.
|
|
161
|
+
*/
|
|
162
|
+
wakeTap?: (record: WakeRecord) => void;
|
|
145
163
|
/** Production monitoring ingest. Default on; set false to disable. */
|
|
146
164
|
telemetryIngest?: boolean;
|
|
147
165
|
/**
|
|
@@ -150,6 +168,28 @@ export interface AgentHostOptions {
|
|
|
150
168
|
*/
|
|
151
169
|
inboxCatchUp?: boolean;
|
|
152
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* One wake, as the brain received it.
|
|
173
|
+
*
|
|
174
|
+
* These four fields ARE the tick's input (`agent.tick({ event, ctx, state,
|
|
175
|
+
* identity })`) — everything else a tick touches is rebuilt from the agent's own
|
|
176
|
+
* config. So a record is replayable on its own, and a replay that behaves
|
|
177
|
+
* differently differs because the agent changed, not because the input drifted.
|
|
178
|
+
*
|
|
179
|
+
* Plain JSON on purpose: a record is meant to survive being written to a file
|
|
180
|
+
* and read back by another process.
|
|
181
|
+
*/
|
|
182
|
+
export interface WakeRecord {
|
|
183
|
+
agentId: string;
|
|
184
|
+
sessionId: string;
|
|
185
|
+
laneKey: string;
|
|
186
|
+
/** The event frame that woke the agent. */
|
|
187
|
+
event: unknown;
|
|
188
|
+
/** FSM context carried into the tick — undefined on a cold lane. */
|
|
189
|
+
ctx?: unknown;
|
|
190
|
+
/** FSM state the lane was parked in — undefined on a cold lane. */
|
|
191
|
+
state?: string;
|
|
192
|
+
}
|
|
153
193
|
/**
|
|
154
194
|
* ToolManager is a Map keyed by tool name, so the last registration silently
|
|
155
195
|
* wins. An agent's own tools register last — which means an `extraTools` entry
|
|
@@ -189,6 +229,16 @@ export declare class AgentHost {
|
|
|
189
229
|
/** The per-agent inbox long-poll loop — this agent's sole wake source. */
|
|
190
230
|
private inboxLoop;
|
|
191
231
|
private inboxLoopEnabled;
|
|
232
|
+
/** Short-lived grant-provider cache for the declared-needs gate. */
|
|
233
|
+
/**
|
|
234
|
+
* Lanes where an unmet-need refusal has already been allowed its one turn to
|
|
235
|
+
* ask for the access (see `_refuseIfNeedsUnmet`). Per lane, not per agent: two
|
|
236
|
+
* hirers each deserve the ask, and neither deserves it twice.
|
|
237
|
+
*/
|
|
238
|
+
private askedForAccessLanes;
|
|
239
|
+
/** Tasks left active by the asking turn, failed right after it (per lane). */
|
|
240
|
+
private unmetTasksPendingAsk;
|
|
241
|
+
private grantProviderCache;
|
|
192
242
|
constructor(options?: AgentHostOptions);
|
|
193
243
|
private _validate;
|
|
194
244
|
private _setup;
|
|
@@ -245,16 +295,68 @@ export declare class AgentHost {
|
|
|
245
295
|
* still gets its turn, because that is an agent with something left to say.
|
|
246
296
|
*/
|
|
247
297
|
private isSettledTaskEcho;
|
|
298
|
+
/** Providers of this agent's live connection grants. Its own seam, so the gate below is testable without a backend. */
|
|
299
|
+
private _listGrantProviders;
|
|
300
|
+
/**
|
|
301
|
+
* Short TTL on purpose: the gate runs on every wake, and a claim produces
|
|
302
|
+
* several in seconds — but hire comes before grant, so a grant arriving
|
|
303
|
+
* mid-engagement has to take effect without a restart.
|
|
304
|
+
*/
|
|
305
|
+
private _grantProvidersCached;
|
|
306
|
+
/**
|
|
307
|
+
* Refuse work this agent has no way to do, before it costs a turn.
|
|
308
|
+
* Returns true when work was refused and the tick must not run.
|
|
309
|
+
*
|
|
310
|
+
* Enforced at wake for an agent holding assigned work, not at hire: hire comes
|
|
311
|
+
* first and the grant second, so a hire-time gate would refuse every honest
|
|
312
|
+
* one. Not limited to the task frame either — a store claim wakes the agent on
|
|
313
|
+
* the agreement first, and a task-only gate let the model take that turn and
|
|
314
|
+
* reason its own way to "I have no grant" (measured on linear-janitor: 8 calls,
|
|
315
|
+
* 114,025 tokens, for what one HTTP call answers).
|
|
316
|
+
*
|
|
317
|
+
* It fails the task rather than skipping quietly, which would leave it active
|
|
318
|
+
* with nothing working it — the same invisible stall from the buyer's side.
|
|
319
|
+
*/
|
|
320
|
+
private _refuseIfNeedsUnmet;
|
|
321
|
+
/**
|
|
322
|
+
* Fail whatever the asking turn left active.
|
|
323
|
+
*
|
|
324
|
+
* The one turn granted above runs with the task still active so the agent can
|
|
325
|
+
* ask; the agent's own instructions then fail it. This is the backstop for when
|
|
326
|
+
* they do not: an active task nobody is working is the invisible stall the gate
|
|
327
|
+
* exists to prevent, and it must not be the price of asking.
|
|
328
|
+
*/
|
|
329
|
+
private settleUnmetAfterAsk;
|
|
330
|
+
/**
|
|
331
|
+
* Whether this agent holds the tool that turns "I cannot" into "here is what I
|
|
332
|
+
* need". Without it the extra turn buys nothing, so it is not spent.
|
|
333
|
+
*/
|
|
334
|
+
private canAskForAccess;
|
|
248
335
|
private _processEvent;
|
|
336
|
+
/**
|
|
337
|
+
* Re-run one recorded wake against THIS host's agent, and nothing else.
|
|
338
|
+
*
|
|
339
|
+
* The dev loop for a prompt: record a wake off a live run, edit the agent, and
|
|
340
|
+
* see what it does differently in seconds instead of standing a world back up
|
|
341
|
+
* for one turn. The brain, tools and effect handler are the real ones, so the
|
|
342
|
+
* turn is real — its tool calls hit the backend this host is pointed at, which
|
|
343
|
+
* is why it belongs against a disposable world and not a shared one.
|
|
344
|
+
*
|
|
345
|
+
* Deliberately NOT routed through `_processEvent`: everything that method does
|
|
346
|
+
* around the tick is lane bookkeeping — session state, liveness timers, session
|
|
347
|
+
* persistence, ack decisions, failure re-arm — and a replay has no lane to keep.
|
|
348
|
+
* Borrowing that path would make a dev tool able to disturb a live one.
|
|
349
|
+
*/
|
|
350
|
+
replayWake(record: WakeRecord): Promise<TickOutput>;
|
|
249
351
|
/** Arms the lane's liveness timer iff stateName is a parked state declaring timeoutMs. */
|
|
250
352
|
private _armIfTimedParked;
|
|
251
353
|
private _resolveOwnAgentId;
|
|
252
354
|
connectAsync(_timeout?: number): Promise<this>;
|
|
253
355
|
/**
|
|
254
|
-
* plain string compare against {@link AgentHostOptions.
|
|
356
|
+
* plain string compare against {@link AgentHostOptions.requestMatchTags}
|
|
255
357
|
* and `specialization`. No match → return (caller acks). Match → normal wake.
|
|
256
358
|
*/
|
|
257
|
-
private
|
|
359
|
+
private triageRequestDelivery;
|
|
258
360
|
private _bootstrapAssignedActiveTasks;
|
|
259
361
|
/** replay task-assigned for an existing active task (demo recovery). */
|
|
260
362
|
wakeTaskAssignment(taskId: string): Promise<void>;
|
package/dist/AgentHost.js
CHANGED
|
@@ -12,15 +12,17 @@ import { normalizeIncomingEvent } from './ingress/normalizeIncoming.js';
|
|
|
12
12
|
import { PromptBuilder } from './runtime/PromptBuilder.js';
|
|
13
13
|
import { InMemoryStore } from './memory/MemoryStore.js';
|
|
14
14
|
import { ToolManager } from './tools/ToolManager.js';
|
|
15
|
+
import { toolName } from './tools/defineTool.js';
|
|
15
16
|
import { PROTOCOL_TOOLS } from './server/tools/tier1/protocolTools.js';
|
|
16
17
|
import { marketplaceViewTool } from './server/tools/tier2/marketplaceTools.js';
|
|
17
18
|
import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
|
|
18
19
|
import { AnthropicAdapter } from './adapters/AnthropicAdapter.js';
|
|
19
|
-
import { ArtifactsClient, ContextReadClient, TelemetryClient, sendChatMessage, } from '@ziggs-ai/api-client';
|
|
20
|
+
import { ArtifactsClient, ConnectionsClient, ContextReadClient, TelemetryClient, sendChatMessage, } from '@ziggs-ai/api-client';
|
|
21
|
+
import { normalizeNeeds, unmetNeeds, unmetNeedsMessage, } from './tools/needs.js';
|
|
20
22
|
import { TaskService } from './server/tasks/TaskService.js';
|
|
21
23
|
import { AgreementService } from './server/agreements/AgreementService.js';
|
|
22
24
|
import { runInboxLoop } from './server/inboxLoop.js';
|
|
23
|
-
import {
|
|
25
|
+
import { requestExactMatch } from './server/requestMatch.js';
|
|
24
26
|
import { SeenMessages } from './server/SeenMessages.js';
|
|
25
27
|
import { runtimeLog } from './shared/runtimeLog.js';
|
|
26
28
|
/**
|
|
@@ -177,6 +179,16 @@ export class AgentHost {
|
|
|
177
179
|
/** The per-agent inbox long-poll loop — this agent's sole wake source. */
|
|
178
180
|
inboxLoop = null;
|
|
179
181
|
inboxLoopEnabled;
|
|
182
|
+
/** Short-lived grant-provider cache for the declared-needs gate. */
|
|
183
|
+
/**
|
|
184
|
+
* Lanes where an unmet-need refusal has already been allowed its one turn to
|
|
185
|
+
* ask for the access (see `_refuseIfNeedsUnmet`). Per lane, not per agent: two
|
|
186
|
+
* hirers each deserve the ask, and neither deserves it twice.
|
|
187
|
+
*/
|
|
188
|
+
askedForAccessLanes = new Set();
|
|
189
|
+
/** Tasks left active by the asking turn, failed right after it (per lane). */
|
|
190
|
+
unmetTasksPendingAsk = new Map();
|
|
191
|
+
grantProviderCache = null;
|
|
180
192
|
constructor(options = {}) {
|
|
181
193
|
const resolved = applyBrainToHostOptions(options);
|
|
182
194
|
this.options = {
|
|
@@ -190,6 +202,11 @@ export class AgentHost {
|
|
|
190
202
|
systemPrompt: resolved.systemPrompt,
|
|
191
203
|
specialization: resolved.specialization ?? null,
|
|
192
204
|
tools: resolved.tools || [],
|
|
205
|
+
// Copied deliberately: this object is an allow-list, so a field left out
|
|
206
|
+
// here is silently dropped between the manifest and the runtime. That is
|
|
207
|
+
// exactly how `respondsToProposals` reached the store as null for 133
|
|
208
|
+
// agents — the declaration existed and nothing carried it.
|
|
209
|
+
needs: resolved.needs,
|
|
193
210
|
taskTools: resolved.taskTools,
|
|
194
211
|
states: resolved.states,
|
|
195
212
|
initial: resolved.initial,
|
|
@@ -200,6 +217,7 @@ export class AgentHost {
|
|
|
200
217
|
effectTap: resolved.effectTap,
|
|
201
218
|
transitionTap: resolved.transitionTap,
|
|
202
219
|
metricsTap: resolved.metricsTap,
|
|
220
|
+
wakeTap: resolved.wakeTap,
|
|
203
221
|
inboxCatchUp: resolved.inboxCatchUp,
|
|
204
222
|
brain: resolved.brain,
|
|
205
223
|
cognition: resolved.cognition ?? 'fsm',
|
|
@@ -209,9 +227,9 @@ export class AgentHost {
|
|
|
209
227
|
this.inboxLoopEnabled = options.inboxCatchUp !== false;
|
|
210
228
|
this.sessionResolver = options.sessionResolver || DEFAULT_SESSION_RESOLVER;
|
|
211
229
|
// A Claude-brain wake legitimately runs long (multi-turn query, prose
|
|
212
|
-
// deliverables)
|
|
213
|
-
// which then
|
|
214
|
-
//
|
|
230
|
+
// deliverables). At 120s the watchdog cuts real spec-writing wakes
|
|
231
|
+
// mid-flight, which then redeliver and re-run. FSM turns stay on the tight
|
|
232
|
+
// default.
|
|
215
233
|
this.tickTimeoutMs =
|
|
216
234
|
options.tickTimeoutMs ??
|
|
217
235
|
(isClaudeCognition(options.cognition)
|
|
@@ -257,6 +275,9 @@ export class AgentHost {
|
|
|
257
275
|
};
|
|
258
276
|
const messageSender = (message, receiverId, sessionId, messageId) => sendChatMessage({
|
|
259
277
|
chatId: sessionId,
|
|
278
|
+
// Omitted on purpose when the caller has no valid address for THIS
|
|
279
|
+
// room: the backend infers the recipient from the room's own
|
|
280
|
+
// participants.
|
|
260
281
|
receiverId,
|
|
261
282
|
text: message,
|
|
262
283
|
// messageId is the backend's (chatId, messageId) dedup key. Callers
|
|
@@ -518,6 +539,151 @@ export class AgentHost {
|
|
|
518
539
|
const reported = result.result;
|
|
519
540
|
return reported !== undefined && reported !== null && reported !== '';
|
|
520
541
|
}
|
|
542
|
+
/** Providers of this agent's live connection grants. Its own seam, so the gate below is testable without a backend. */
|
|
543
|
+
async _listGrantProviders(operatorKey, agentId) {
|
|
544
|
+
const groups = await new ConnectionsClient(operatorKey, agentId).listForHolder();
|
|
545
|
+
return groups.filter((g) => (g.grants ?? []).length > 0).map((g) => g.provider);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Short TTL on purpose: the gate runs on every wake, and a claim produces
|
|
549
|
+
* several in seconds — but hire comes before grant, so a grant arriving
|
|
550
|
+
* mid-engagement has to take effect without a restart.
|
|
551
|
+
*/
|
|
552
|
+
async _grantProvidersCached(operatorKey, agentId) {
|
|
553
|
+
const now = Date.now();
|
|
554
|
+
if (this.grantProviderCache && now - this.grantProviderCache.at < 30_000) {
|
|
555
|
+
return this.grantProviderCache.providers;
|
|
556
|
+
}
|
|
557
|
+
const providers = await this._listGrantProviders(operatorKey, agentId);
|
|
558
|
+
this.grantProviderCache = { at: now, providers };
|
|
559
|
+
return providers;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Refuse work this agent has no way to do, before it costs a turn.
|
|
563
|
+
* Returns true when work was refused and the tick must not run.
|
|
564
|
+
*
|
|
565
|
+
* Enforced at wake for an agent holding assigned work, not at hire: hire comes
|
|
566
|
+
* first and the grant second, so a hire-time gate would refuse every honest
|
|
567
|
+
* one. Not limited to the task frame either — a store claim wakes the agent on
|
|
568
|
+
* the agreement first, and a task-only gate let the model take that turn and
|
|
569
|
+
* reason its own way to "I have no grant" (measured on linear-janitor: 8 calls,
|
|
570
|
+
* 114,025 tokens, for what one HTTP call answers).
|
|
571
|
+
*
|
|
572
|
+
* It fails the task rather than skipping quietly, which would leave it active
|
|
573
|
+
* with nothing working it — the same invisible stall from the buyer's side.
|
|
574
|
+
*/
|
|
575
|
+
async _refuseIfNeedsUnmet(_event, _laneKey) {
|
|
576
|
+
const needs = normalizeNeeds(this.options.needs);
|
|
577
|
+
if (!needs.length)
|
|
578
|
+
return false;
|
|
579
|
+
const operatorKey = this.options.operatorKey;
|
|
580
|
+
const agentId = this.options.agentId;
|
|
581
|
+
if (!operatorKey || !agentId || !this.taskService)
|
|
582
|
+
return false;
|
|
583
|
+
let providers;
|
|
584
|
+
try {
|
|
585
|
+
providers = await this._grantProvidersCached(operatorKey, agentId);
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
// A grants read that failed is not proof the grant is missing, and failing
|
|
589
|
+
// the task on our own outage would be worse than letting the agent try.
|
|
590
|
+
runtimeLog.warn(this.options.name || 'agent', `needs check skipped: ${err?.message ?? err}`);
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
const unmet = unmetNeeds(needs, providers);
|
|
594
|
+
if (!unmet.length)
|
|
595
|
+
return false;
|
|
596
|
+
// Only work already on this agent's plate is refused. A wake with no assigned
|
|
597
|
+
// task is someone talking to it — an agent that cannot reach Linear can still
|
|
598
|
+
// say so in a sentence, and muting it would be a worse silence.
|
|
599
|
+
let assigned;
|
|
600
|
+
try {
|
|
601
|
+
assigned = await this.taskService.getActiveTasksForAgent(agentId);
|
|
602
|
+
}
|
|
603
|
+
catch (err) {
|
|
604
|
+
runtimeLog.warn(this.options.name || 'agent', `needs check could not read assigned work: ${err?.message ?? err}`);
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
const mine = assigned.filter((t) => t.taskId && (!t.assigneeId || t.assigneeId === agentId));
|
|
608
|
+
if (!mine.length)
|
|
609
|
+
return false;
|
|
610
|
+
const message = unmetNeedsMessage(unmet);
|
|
611
|
+
/**
|
|
612
|
+
* Ask BEFORE refusing, once per lane, when this agent can ask at all.
|
|
613
|
+
*
|
|
614
|
+
* Order is the whole fix. Failing the task first and then letting the wake
|
|
615
|
+
* run does nothing: the brain declines a wake whose task already reported a
|
|
616
|
+
* result, so the agent never speaks and the buyer is left with "ask the
|
|
617
|
+
* connection owner to issue a grant" for a connection only they can make —
|
|
618
|
+
* and no place in the web app to make it, because the grant dialog lists the
|
|
619
|
+
* workspace's own agents and a fleet-hosted one is not among them.
|
|
620
|
+
*
|
|
621
|
+
* So the first unmet wake on a lane runs with the task still active: the
|
|
622
|
+
* agent's own instructions send the consent card and fail the task. Whatever
|
|
623
|
+
* it leaves active is failed right after the tick ({@link settleUnmetAfterAsk}),
|
|
624
|
+
* so nothing hangs, and every later wake on the lane is refused up front as
|
|
625
|
+
* before — a buyer who ignores the card is not billed for reminders.
|
|
626
|
+
*/
|
|
627
|
+
if (this.canAskForAccess() && !this.askedForAccessLanes.has(_laneKey)) {
|
|
628
|
+
this.askedForAccessLanes.add(_laneKey);
|
|
629
|
+
this.unmetTasksPendingAsk.set(_laneKey, mine.map((t) => String(t.taskId)));
|
|
630
|
+
runtimeLog.info(this.options.name || 'agent', `no access for ${unmet.map((n) => n.connection).join('/')} on ${_laneKey} — letting this wake run once so it can ask, then failing whatever it leaves`);
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
for (const task of mine) {
|
|
634
|
+
const taskId = String(task.taskId);
|
|
635
|
+
runtimeLog.warn(this.options.name || 'agent', `refusing task ${taskId} — ${message}`);
|
|
636
|
+
try {
|
|
637
|
+
await this.taskService.updateState(taskId, 'failed', {
|
|
638
|
+
summary: message,
|
|
639
|
+
status: 'failed',
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
catch (err) {
|
|
643
|
+
runtimeLog.warn(this.options.name || 'agent', `could not fail task ${taskId} after refusing it: ${err?.message ?? err}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Fail whatever the asking turn left active.
|
|
650
|
+
*
|
|
651
|
+
* The one turn granted above runs with the task still active so the agent can
|
|
652
|
+
* ask; the agent's own instructions then fail it. This is the backstop for when
|
|
653
|
+
* they do not: an active task nobody is working is the invisible stall the gate
|
|
654
|
+
* exists to prevent, and it must not be the price of asking.
|
|
655
|
+
*/
|
|
656
|
+
async settleUnmetAfterAsk(laneKey) {
|
|
657
|
+
const pending = this.unmetTasksPendingAsk.get(laneKey);
|
|
658
|
+
if (!pending?.length)
|
|
659
|
+
return;
|
|
660
|
+
this.unmetTasksPendingAsk.delete(laneKey);
|
|
661
|
+
if (!this.taskService)
|
|
662
|
+
return;
|
|
663
|
+
for (const taskId of pending) {
|
|
664
|
+
try {
|
|
665
|
+
const active = await this.taskService.getActiveTasksForAgent(this.options.agentId);
|
|
666
|
+
if (!active.some((t) => String(t.taskId) === taskId))
|
|
667
|
+
continue;
|
|
668
|
+
runtimeLog.warn(this.options.name || 'agent', `failing task ${taskId} — it asked for the missing access but left the work open`);
|
|
669
|
+
await this.taskService.updateState(taskId, 'failed', {
|
|
670
|
+
summary: unmetNeedsMessage(normalizeNeeds(this.options.needs)),
|
|
671
|
+
status: 'failed',
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
catch (err) {
|
|
675
|
+
runtimeLog.warn(this.options.name || 'agent', `could not settle task ${taskId} after the access ask: ${err?.message ?? err}`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Whether this agent holds the tool that turns "I cannot" into "here is what I
|
|
681
|
+
* need". Without it the extra turn buys nothing, so it is not spent.
|
|
682
|
+
*/
|
|
683
|
+
canAskForAccess() {
|
|
684
|
+
const tools = this.options.tools ?? this.options.services?.tools ?? [];
|
|
685
|
+
return tools.some((t) => toolName(t) === 'connection_request');
|
|
686
|
+
}
|
|
521
687
|
async _processEvent(event, laneKey) {
|
|
522
688
|
const session = this.sessions.get(laneKey);
|
|
523
689
|
if (!session?.sessionId)
|
|
@@ -530,6 +696,10 @@ export class AgentHost {
|
|
|
530
696
|
runtimeLog.info(this.options.name || 'agent', `skipping wake on ${session.sessionId} — its task already reported a result`);
|
|
531
697
|
return;
|
|
532
698
|
}
|
|
699
|
+
// Before the tick spends anything: work this agent declared it cannot do
|
|
700
|
+
// without access it does not hold.
|
|
701
|
+
if (await this._refuseIfNeedsUnmet(event, laneKey))
|
|
702
|
+
return;
|
|
533
703
|
// Stale timer frames (superseded epoch) are dropped BEFORE touching the
|
|
534
704
|
// live timer — a pure-stale frame must not reset a freshly armed countdown.
|
|
535
705
|
const frame = this.stateTimers.stripStale(event, laneKey);
|
|
@@ -557,6 +727,24 @@ export class AgentHost {
|
|
|
557
727
|
sessionId: session.sessionId,
|
|
558
728
|
laneKey,
|
|
559
729
|
};
|
|
730
|
+
// Recorded here rather than after the tick: this is the last point where the
|
|
731
|
+
// input is still exactly what the brain will see, and a wake that goes on to
|
|
732
|
+
// throw is the one most worth replaying.
|
|
733
|
+
if (this.options.wakeTap) {
|
|
734
|
+
try {
|
|
735
|
+
this.options.wakeTap({
|
|
736
|
+
agentId: identity.agentId,
|
|
737
|
+
sessionId: identity.sessionId,
|
|
738
|
+
laneKey,
|
|
739
|
+
event: frame,
|
|
740
|
+
ctx: session.ctx,
|
|
741
|
+
state: session.state,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
runtimeLog.warn('AgentHost', `wakeTap failed: ${err?.message || err}`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
560
748
|
// The watchdog timer must be cleared once the tick settles — leaving it
|
|
561
749
|
// armed pinned one live 2-minute timer per tick (and kept short-lived
|
|
562
750
|
// processes/tests alive until it fired).
|
|
@@ -615,6 +803,22 @@ export class AgentHost {
|
|
|
615
803
|
// not re-arm, or the lane would runtimeLog.error every timeoutMs forever.
|
|
616
804
|
if (!result.stateTimeoutUnhandled)
|
|
617
805
|
this._armIfTimedParked(laneKey, result.state);
|
|
806
|
+
/**
|
|
807
|
+
* Only a wake that actually reached the brain counts as the asking turn.
|
|
808
|
+
*
|
|
809
|
+
* A notice-only wake (`type: 'notification'`) and a rate-gated one skip the
|
|
810
|
+
* LLM entirely, so the agent had no chance to ask — spending the lane's one
|
|
811
|
+
* turn on it, and failing the task straight after, killed the work before
|
|
812
|
+
* the wake that would have done it ever arrived. Hand the turn back instead
|
|
813
|
+
* and let the next real wake use it.
|
|
814
|
+
*/
|
|
815
|
+
if ((result.metrics?.llmCalls ?? 0) > 0) {
|
|
816
|
+
await this.settleUnmetAfterAsk(laneKey);
|
|
817
|
+
}
|
|
818
|
+
else if (this.unmetTasksPendingAsk.has(laneKey)) {
|
|
819
|
+
this.unmetTasksPendingAsk.delete(laneKey);
|
|
820
|
+
this.askedForAccessLanes.delete(laneKey);
|
|
821
|
+
}
|
|
618
822
|
}
|
|
619
823
|
catch (err) {
|
|
620
824
|
runtimeLog.warn('AgentHost', `tick failed laneKey=${laneKey}: ${err?.message || err}`);
|
|
@@ -642,6 +846,74 @@ export class AgentHost {
|
|
|
642
846
|
}
|
|
643
847
|
finally {
|
|
644
848
|
clearTimeout(tickWatchdog);
|
|
849
|
+
// Also on the failure path: a tick that threw still must not leave the
|
|
850
|
+
// work it was refused for sitting active.
|
|
851
|
+
await this.settleUnmetAfterAsk(laneKey).catch(() => { });
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Re-run one recorded wake against THIS host's agent, and nothing else.
|
|
856
|
+
*
|
|
857
|
+
* The dev loop for a prompt: record a wake off a live run, edit the agent, and
|
|
858
|
+
* see what it does differently in seconds instead of standing a world back up
|
|
859
|
+
* for one turn. The brain, tools and effect handler are the real ones, so the
|
|
860
|
+
* turn is real — its tool calls hit the backend this host is pointed at, which
|
|
861
|
+
* is why it belongs against a disposable world and not a shared one.
|
|
862
|
+
*
|
|
863
|
+
* Deliberately NOT routed through `_processEvent`: everything that method does
|
|
864
|
+
* around the tick is lane bookkeeping — session state, liveness timers, session
|
|
865
|
+
* persistence, ack decisions, failure re-arm — and a replay has no lane to keep.
|
|
866
|
+
* Borrowing that path would make a dev tool able to disturb a live one.
|
|
867
|
+
*/
|
|
868
|
+
async replayWake(record) {
|
|
869
|
+
if (!record?.sessionId)
|
|
870
|
+
throw new Error('replayWake: record.sessionId is required');
|
|
871
|
+
const identity = {
|
|
872
|
+
agentId: record.agentId || this.options.agentId,
|
|
873
|
+
sessionId: record.sessionId,
|
|
874
|
+
laneKey: record.laneKey || record.sessionId,
|
|
875
|
+
};
|
|
876
|
+
// Same watchdog as a live wake, for the same reason: an unaborted Claude
|
|
877
|
+
// query outlives the promise that abandoned it and keeps spending.
|
|
878
|
+
const abort = new AbortController();
|
|
879
|
+
let watchdog;
|
|
880
|
+
try {
|
|
881
|
+
const result = await Promise.race([
|
|
882
|
+
this.agent.tick({
|
|
883
|
+
event: record.event,
|
|
884
|
+
ctx: record.ctx,
|
|
885
|
+
state: record.state,
|
|
886
|
+
identity,
|
|
887
|
+
eff: this.eff,
|
|
888
|
+
memory: this.memory,
|
|
889
|
+
transitionTap: this.options.transitionTap,
|
|
890
|
+
signal: abort.signal,
|
|
891
|
+
}),
|
|
892
|
+
new Promise((_, reject) => {
|
|
893
|
+
watchdog = setTimeout(() => {
|
|
894
|
+
abort.abort(new Error('tick timeout'));
|
|
895
|
+
reject(new Error('tick timeout'));
|
|
896
|
+
}, this.tickTimeoutMs);
|
|
897
|
+
}),
|
|
898
|
+
]);
|
|
899
|
+
if (this.options.metricsTap && result.metrics) {
|
|
900
|
+
try {
|
|
901
|
+
this.options.metricsTap({
|
|
902
|
+
agentId: identity.agentId,
|
|
903
|
+
sessionId: identity.sessionId,
|
|
904
|
+
model: this.options.model ?? 'unknown',
|
|
905
|
+
cognition: this.options.cognition ?? 'fsm',
|
|
906
|
+
metrics: result.metrics,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
runtimeLog.warn('AgentHost', `metricsTap failed: ${err?.message || err}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return result;
|
|
914
|
+
}
|
|
915
|
+
finally {
|
|
916
|
+
clearTimeout(watchdog);
|
|
645
917
|
}
|
|
646
918
|
}
|
|
647
919
|
/** Arms the lane's liveness timer iff stateName is a parked state declaring timeoutMs. */
|
|
@@ -694,32 +966,32 @@ export class AgentHost {
|
|
|
694
966
|
}
|
|
695
967
|
},
|
|
696
968
|
// exact-match triage in code — miss acks with 0 tokens.
|
|
697
|
-
|
|
698
|
-
await this.
|
|
969
|
+
onRequest: async (request) => {
|
|
970
|
+
await this.triageRequestDelivery(request);
|
|
699
971
|
},
|
|
700
972
|
});
|
|
701
973
|
}
|
|
702
974
|
return this;
|
|
703
975
|
}
|
|
704
976
|
/**
|
|
705
|
-
* plain string compare against {@link AgentHostOptions.
|
|
977
|
+
* plain string compare against {@link AgentHostOptions.requestMatchTags}
|
|
706
978
|
* and `specialization`. No match → return (caller acks). Match → normal wake.
|
|
707
979
|
*/
|
|
708
|
-
async
|
|
980
|
+
async triageRequestDelivery(request) {
|
|
709
981
|
const tags = [
|
|
710
|
-
...(this.options.
|
|
982
|
+
...(this.options.requestMatchTags ?? []),
|
|
711
983
|
...(this.options.specialization ? [this.options.specialization] : []),
|
|
712
984
|
];
|
|
713
|
-
if (!
|
|
714
|
-
runtimeLog.debug(this.options.name || 'agent', `
|
|
985
|
+
if (!requestExactMatch(request.match, tags)) {
|
|
986
|
+
runtimeLog.debug(this.options.name || 'agent', `request ${request.agreementId}: no tag match for ${JSON.stringify(request.match)} — ack without wake`);
|
|
715
987
|
return;
|
|
716
988
|
}
|
|
717
|
-
const needle =
|
|
718
|
-
const title =
|
|
719
|
-
await this.handleMessage(`New marketplace
|
|
720
|
-
messageId: `
|
|
721
|
-
chatId: `marketplace:${
|
|
722
|
-
agreementId:
|
|
989
|
+
const needle = request.match.trim();
|
|
990
|
+
const title = request.title?.trim() || needle;
|
|
991
|
+
await this.handleMessage(`New marketplace request matching your tags (${needle}): ${title}. Claim with agreement_claim if you will take it.`, {
|
|
992
|
+
messageId: `request-wake:${request.agreementId}:${request.ts}`,
|
|
993
|
+
chatId: `marketplace:${request.agreementId}`,
|
|
994
|
+
agreementId: request.agreementId,
|
|
723
995
|
});
|
|
724
996
|
}
|
|
725
997
|
async _bootstrapAssignedActiveTasks() {
|
|
@@ -56,12 +56,11 @@ export function resolveZiggsGatewayTarget(config, defaults = {}) {
|
|
|
56
56
|
if (!agentId) {
|
|
57
57
|
throw new Error('ziggs MCP transport requires an agentId to impersonate (X-Agent-Id)');
|
|
58
58
|
}
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// backend at all, and the agent reported the server as unreachable.
|
|
59
|
+
// Explicit overrides still win, but the LAST resort is `getBackendUrl()` —
|
|
60
|
+
// the resolution every other SDK call uses — not `localhost:3000`. A deployment
|
|
61
|
+
// that sets none of BACKEND_URL / ZIGGS_BACKEND_URL would otherwise have every
|
|
62
|
+
// gateway call silently point at localhost inside its own container: the
|
|
63
|
+
// requests never reach the backend, and the agent reports it as unreachable.
|
|
65
64
|
const baseUrl = (config.baseUrl ||
|
|
66
65
|
process.env.ZIGGS_BACKEND_URL ||
|
|
67
66
|
process.env.BACKEND_URL ||
|
|
@@ -85,12 +84,11 @@ export function resolvePerUserGatewayTarget(config, agentId, userId, onBehalfOfA
|
|
|
85
84
|
if (!operatorKey) {
|
|
86
85
|
throw new Error('ziggs-per-user MCP transport requires an operatorKey (or ZIGGS_OPERATOR_KEY)');
|
|
87
86
|
}
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
// backend at all, and the agent reported the server as unreachable.
|
|
87
|
+
// Explicit overrides still win, but the LAST resort is `getBackendUrl()` —
|
|
88
|
+
// the resolution every other SDK call uses — not `localhost:3000`. A deployment
|
|
89
|
+
// that sets none of BACKEND_URL / ZIGGS_BACKEND_URL would otherwise have every
|
|
90
|
+
// gateway call silently point at localhost inside its own container: the
|
|
91
|
+
// requests never reach the backend, and the agent reports it as unreachable.
|
|
94
92
|
const baseUrl = (config.baseUrl ||
|
|
95
93
|
process.env.ZIGGS_BACKEND_URL ||
|
|
96
94
|
process.env.BACKEND_URL ||
|
|
@@ -52,6 +52,15 @@ export interface ClaudeSdkAgentOptions {
|
|
|
52
52
|
* that is all the CLI gives us — no code, no typed error — so keep it narrow
|
|
53
53
|
* enough that a different failure never claims the retry.
|
|
54
54
|
*/
|
|
55
|
+
/**
|
|
56
|
+
* "I ran out of turns", as Claude Code reports it.
|
|
57
|
+
*
|
|
58
|
+
* Turn exhaustion is not a crash: the wake did real work and stopped at a cap
|
|
59
|
+
* we set. It arrives as a thrown error result with no code and no type, so the
|
|
60
|
+
* message is all there is to match on — kept narrow so no other failure claims
|
|
61
|
+
* the handling.
|
|
62
|
+
*/
|
|
63
|
+
export declare function isMaxTurnsError(err: unknown): boolean;
|
|
55
64
|
export declare function isMissingSessionError(err: unknown): boolean;
|
|
56
65
|
/**
|
|
57
66
|
* A Ziggs agent whose brain is the Claude Agent SDK. Same host, same `eff`
|