@ziggs-ai/agent-sdk 0.22.0 → 0.24.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/dist/Agent.d.ts +8 -2
- package/dist/Agent.js +232 -53
- package/dist/brain/ClaudeBrain.d.ts +1 -8
- package/dist/brain/ClaudeBrain.js +13 -42
- package/dist/brain/ZiggsBrain.d.ts +1 -1
- package/dist/brain/ZiggsBrain.js +9 -2
- package/dist/brain/claudeCognition.js +34 -9
- package/dist/brain/claudeSdkProtocol.d.ts +1 -1
- package/dist/brain/claudeSdkProtocol.js +5 -2
- package/dist/brain/claudeSdkTools.d.ts +3 -0
- package/dist/brain/claudeSdkTools.js +10 -1
- package/dist/brain/index.d.ts +9 -1
- package/dist/brain/index.js +5 -1
- package/dist/brain/toolManagerToSdkMcp.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/ingress/normalizeIncoming.d.ts +1 -0
- package/dist/ingress/normalizeIncoming.js +2 -1
- package/dist/platform/ZiggsEffectHandler.d.ts +1 -1
- package/dist/platform/ZiggsEffectHandler.js +20 -10
- package/dist/platform/inboxLoop.d.ts +4 -3
- package/dist/platform/inboxLoop.js +5 -6
- package/dist/platform/tasks/TaskService.d.ts +16 -1
- package/dist/platform/tasks/TaskService.js +22 -3
- package/dist/platform/tools/tier1/protocolRunner.js +11 -2
- package/dist/platform/tools/tier1/protocolTools.js +20 -5
- package/dist/probeChat.d.ts +12 -0
- package/dist/probeChat.js +14 -0
- package/dist/runtime/PromptBuilder.js +4 -1
- package/dist/runtime/agreementContext.d.ts +5 -0
- package/dist/runtime/agreementContext.js +75 -0
- package/dist/runtime/mandateDeclaration.d.ts +1 -1
- package/dist/runtime/mandateDeclaration.js +4 -4
- package/dist/runtime/runTurn.js +3 -2
- package/dist/runtime/wakeEffects.d.ts +5 -0
- package/dist/runtime/wakeEffects.js +14 -1
- package/dist/tools/capabilityAdapter.js +1 -0
- package/dist/types.d.ts +12 -0
- package/package.json +2 -2
package/dist/Agent.d.ts
CHANGED
|
@@ -230,9 +230,13 @@ export declare class Agent {
|
|
|
230
230
|
options: Required<Pick<AgentOptions, 'agentId' | 'operatorKey' | 'openaiKey'>> & AgentOptions;
|
|
231
231
|
toolManager: ToolManager;
|
|
232
232
|
llm?: OpenAIAdapter | LlmAdapter;
|
|
233
|
-
brain: ZiggsBrain | ClaudeBrain
|
|
233
|
+
brain: ZiggsBrain | ClaudeBrain | {
|
|
234
|
+
workflow: ZiggsBrain['workflow'];
|
|
235
|
+
tick: import('./brain/index.js').HandlerBrainConfig['tick'];
|
|
236
|
+
};
|
|
234
237
|
private eff;
|
|
235
238
|
private eventQueue;
|
|
239
|
+
private activeTaskRuns;
|
|
236
240
|
private sessions;
|
|
237
241
|
private sessionResolver;
|
|
238
242
|
private tickTimeoutMs;
|
|
@@ -265,6 +269,9 @@ export declare class Agent {
|
|
|
265
269
|
constructor(options?: AgentOptions);
|
|
266
270
|
private _validate;
|
|
267
271
|
private _setup;
|
|
272
|
+
/** Runtime control is a task fact, never model-interpreted message text. */
|
|
273
|
+
private taskExecutionControl;
|
|
274
|
+
private acknowledgeExecutionControl;
|
|
268
275
|
/**
|
|
269
276
|
* Handler for a resource change (artifact created, task state transition,
|
|
270
277
|
* agreement updated) surfaced by the inbox loop. The agent decides which
|
|
@@ -305,7 +312,6 @@ export declare class Agent {
|
|
|
305
312
|
* wake keys on the live room / hire.
|
|
306
313
|
*/
|
|
307
314
|
private dropStaleCancelledAgreementSession;
|
|
308
|
-
private formedByForHireActivation;
|
|
309
315
|
private laneAgreementIsTerminal;
|
|
310
316
|
/**
|
|
311
317
|
* Whether the host answers this wake without spending a model turn.
|
package/dist/Agent.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { withAgreementContext } from './runtime/agreementContext.js';
|
|
1
2
|
import { agreementLaneId, agreementIdFromLane } from '@ziggs-ai/api-client';
|
|
2
3
|
import { createDeclaredPollRunner } from './platform/polls/declaredPollRunner.js';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
|
-
import { WakeEffects } from './runtime/wakeEffects.js';
|
|
6
|
+
import { contextScopesFromWake, WakeEffects, workScopeFromWake } from './runtime/wakeEffects.js';
|
|
6
7
|
export { workScopeFromWake } from './runtime/wakeEffects.js';
|
|
7
8
|
import { ZiggsBrain } from './brain/ZiggsBrain.js';
|
|
8
9
|
import { ClaudeBrain } from './brain/ClaudeBrain.js';
|
|
@@ -17,6 +18,7 @@ import { WAKE_TOOLS } from './platform/tools/tier1/wakeTools.js';
|
|
|
17
18
|
import { createZiggsEffectHandler } from './platform/ZiggsEffectHandler.js';
|
|
18
19
|
import { normalizeIncomingEvent } from './ingress/normalizeIncoming.js';
|
|
19
20
|
import { PromptBuilder } from './runtime/PromptBuilder.js';
|
|
21
|
+
import { removeTaskBook } from './runtime/taskBookkeeping.js';
|
|
20
22
|
import { InMemoryStore, hireIdsFromEvent, memorySectionForWake, partitionMemoryStore, uniqueHireIds, } from './memory/MemoryStore.js';
|
|
21
23
|
import { ToolManager } from './tools/ToolManager.js';
|
|
22
24
|
import { toolName } from './tools/defineTool.js';
|
|
@@ -133,7 +135,7 @@ function createBackendServices(options) {
|
|
|
133
135
|
// the provider client throws at construction on a missing key, so a claude-sdk
|
|
134
136
|
// agent left on the default (OpenAI) model with no openaiKey would fail to
|
|
135
137
|
// start over an adapter it never uses. Skip it.
|
|
136
|
-
const llm = isClaudeCognition(options.cognition)
|
|
138
|
+
const llm = options.brain?.kind === 'handler' || isClaudeCognition(options.cognition)
|
|
137
139
|
? undefined
|
|
138
140
|
: isLlmAdapter(options.services?.llm)
|
|
139
141
|
? options.services.llm
|
|
@@ -179,6 +181,8 @@ export class Agent {
|
|
|
179
181
|
brain;
|
|
180
182
|
eff;
|
|
181
183
|
eventQueue;
|
|
184
|
+
// In-process ticks only; durable intent remains on the existing task row.
|
|
185
|
+
activeTaskRuns = new Map();
|
|
182
186
|
sessions = new Map();
|
|
183
187
|
sessionResolver;
|
|
184
188
|
tickTimeoutMs;
|
|
@@ -269,7 +273,10 @@ export class Agent {
|
|
|
269
273
|
this._setup();
|
|
270
274
|
}
|
|
271
275
|
_validate() {
|
|
272
|
-
if (
|
|
276
|
+
if (this.options.brain?.kind === 'handler') {
|
|
277
|
+
// Deterministic handlers use no model adapter or credentials.
|
|
278
|
+
}
|
|
279
|
+
else if (isClaudeCognition(this.options.cognition)) {
|
|
273
280
|
if (!this.options.anthropicKey) {
|
|
274
281
|
throw new Error('anthropicKey is required for the Claude brain — pass claudeBrain({ anthropicKey }) or set ANTHROPIC_API_KEY');
|
|
275
282
|
}
|
|
@@ -301,7 +308,7 @@ export class Agent {
|
|
|
301
308
|
operatorKey: this.options.operatorKey,
|
|
302
309
|
agentId: this.options.agentId,
|
|
303
310
|
};
|
|
304
|
-
const messageSender = async (message, receiverId, sessionId, messageId) => {
|
|
311
|
+
const messageSender = async (message, receiverId, sessionId, messageId, conversationAgreementId) => {
|
|
305
312
|
const sentId = messageId ?? `sdk_${randomUUID()}`;
|
|
306
313
|
const result = await sendChatMessage({
|
|
307
314
|
chatId: sessionId,
|
|
@@ -314,6 +321,7 @@ export class Agent {
|
|
|
314
321
|
// pass a wake-derived id for replay-stability; fall back to a fresh id
|
|
315
322
|
// only when absent (matches the old WS path letting the server assign).
|
|
316
323
|
messageId: sentId,
|
|
324
|
+
conversationAgreementId,
|
|
317
325
|
}, creds);
|
|
318
326
|
if (!result.success)
|
|
319
327
|
throw new Error('sendChatMessage: the server did not accept this message — nothing was delivered and nobody was woken. Retry with the same messageId, which the backend dedups on.');
|
|
@@ -340,7 +348,7 @@ export class Agent {
|
|
|
340
348
|
// The Claude brain has no FSM — it drives query() per wake and parks. It
|
|
341
349
|
// still plugs into the agent's lane/timer machinery via a trivial parked
|
|
342
350
|
// workflow, synthesized here so callers don't hand-write a dummy one.
|
|
343
|
-
if (isClaudeCognition(this.options.cognition)) {
|
|
351
|
+
if (this.options.brain?.kind === 'handler' || isClaudeCognition(this.options.cognition)) {
|
|
344
352
|
workflow = {
|
|
345
353
|
id: this.options.agentId,
|
|
346
354
|
description: this.options.description,
|
|
@@ -378,29 +386,31 @@ export class Agent {
|
|
|
378
386
|
armWakeAt: (sessionId, at, reason) => this.stateTimers.armWakeAt(laneKeyFor(this.options.agentId, sessionId), at, reason, this.wakeEffects.getStore()?.scope),
|
|
379
387
|
},
|
|
380
388
|
});
|
|
381
|
-
this.brain =
|
|
382
|
-
?
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
389
|
+
this.brain = this.options.brain?.kind === 'handler'
|
|
390
|
+
? { workflow, tick: this.options.brain.config.tick }
|
|
391
|
+
: isClaudeCognition(this.options.cognition)
|
|
392
|
+
? new ClaudeBrain({
|
|
393
|
+
workflow,
|
|
394
|
+
toolManager: services.toolManager,
|
|
395
|
+
description: this.options.description,
|
|
396
|
+
systemPrompt: this.options.systemPrompt,
|
|
397
|
+
specialization: this.options.specialization,
|
|
398
|
+
model: resolveClaudeModel(this.options.model, this.options.services?.llm?.model),
|
|
399
|
+
anthropicKey: this.options.anthropicKey,
|
|
400
|
+
operatorKey: this.options.operatorKey,
|
|
401
|
+
agentId: this.options.agentId,
|
|
402
|
+
// No taskService/agreementService: Claude-brain tools execute through
|
|
403
|
+
// eff({ kind: 'tool-call' }) → the shared executor, which sources those
|
|
404
|
+
// services from the effect handler's deps like the FSM path.
|
|
405
|
+
maxTurns: this.options.claudeMaxTurns,
|
|
406
|
+
statelessPerWake: this.options.claudeStatelessPerWake,
|
|
407
|
+
actsOnHireActivation: this.options.actsOnHireActivation === true,
|
|
408
|
+
})
|
|
409
|
+
: new ZiggsBrain({
|
|
410
|
+
workflow,
|
|
411
|
+
toolManager: services.toolManager,
|
|
412
|
+
promptBuilder: services.promptBuilder,
|
|
413
|
+
});
|
|
404
414
|
this.eventQueue = new EventQueue((event, laneKey) => this._processEvent(event, laneKey));
|
|
405
415
|
// A timer frame is not a delivery: there is no watermark waiting on it and
|
|
406
416
|
// nothing to redeliver, so its failure is logged and dropped here rather
|
|
@@ -411,6 +421,49 @@ export class Agent {
|
|
|
411
421
|
});
|
|
412
422
|
});
|
|
413
423
|
}
|
|
424
|
+
/** Runtime control is a task fact, never model-interpreted message text. */
|
|
425
|
+
async taskExecutionControl(taskId) {
|
|
426
|
+
if (!this.taskService)
|
|
427
|
+
return null;
|
|
428
|
+
let task;
|
|
429
|
+
try {
|
|
430
|
+
task = await this.taskService.getTask(taskId);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
if ([403, 404].includes(error.status ?? 0))
|
|
434
|
+
return { blocked: true };
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
// A coordinator sees its children's task events too; those do not pause it.
|
|
438
|
+
if (task.executorIsYou !== true && task.assigneeId !== this.options.agentId)
|
|
439
|
+
return null;
|
|
440
|
+
return { blocked: task.state === 'cancelled' || (task.state === 'active' && task.control?.action === 'pause'), closed: task.state === 'cancelled', control: task.control };
|
|
441
|
+
}
|
|
442
|
+
async acknowledgeExecutionControl(taskId, control) {
|
|
443
|
+
if (control?.status !== 'requested')
|
|
444
|
+
return;
|
|
445
|
+
try {
|
|
446
|
+
if (control.action !== 'resume') {
|
|
447
|
+
// Custom session resolvers may run the same task in different lanes.
|
|
448
|
+
// Acknowledgement covers all of this runtime's task executions.
|
|
449
|
+
const active = [...(this.activeTaskRuns.get(taskId) ?? [])];
|
|
450
|
+
for (const run of active) {
|
|
451
|
+
run.control = control;
|
|
452
|
+
run.abort.abort(Object.assign(new Error('Task execution held'), { code: 'TASK_CONTROL', taskId, control }));
|
|
453
|
+
}
|
|
454
|
+
// The finishing run acknowledges after its actual operations settle.
|
|
455
|
+
// A tool that ignores cancellation must not hold up this inbox lane.
|
|
456
|
+
if (active.length)
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
await this.taskService?.acknowledgeControl(taskId, control.requestId);
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
// A newer user request wins. Its durable inbox event will be handled next.
|
|
463
|
+
if (error.status !== 409)
|
|
464
|
+
runtimeLog.warn('Agent', `control acknowledgement failed for ${taskId}: ${error.message}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
414
467
|
/**
|
|
415
468
|
* Handler for a resource change (artifact created, task state transition,
|
|
416
469
|
* agreement updated) surfaced by the inbox loop. The agent decides which
|
|
@@ -612,24 +665,6 @@ export class Agent {
|
|
|
612
665
|
// Fail open: a lookup error must not drop a live hire's session.
|
|
613
666
|
}
|
|
614
667
|
}
|
|
615
|
-
async formedByForHireActivation(event) {
|
|
616
|
-
const fromWake = formedByFromWake(event);
|
|
617
|
-
if (fromWake)
|
|
618
|
-
return fromWake;
|
|
619
|
-
const agreementId = agreementIdFromWake(event);
|
|
620
|
-
if (!agreementId || !this.agreementService)
|
|
621
|
-
return undefined;
|
|
622
|
-
try {
|
|
623
|
-
const agreement = await this.agreementService.get(agreementId);
|
|
624
|
-
const formedBy = agreement?.formedBy;
|
|
625
|
-
if (formedBy === 'agent' || formedBy === 'principal')
|
|
626
|
-
return formedBy;
|
|
627
|
-
}
|
|
628
|
-
catch {
|
|
629
|
-
// Fail open: a lookup error must not drop a person's store-claim wake.
|
|
630
|
-
}
|
|
631
|
-
return undefined;
|
|
632
|
-
}
|
|
633
668
|
async laneAgreementIsTerminal(sessionId) {
|
|
634
669
|
const agreementId = agreementIdFromLane(sessionId);
|
|
635
670
|
if (!agreementId || !this.agreementService)
|
|
@@ -666,7 +701,13 @@ export class Agent {
|
|
|
666
701
|
};
|
|
667
702
|
}
|
|
668
703
|
if (isHireActivationNotice(event)) {
|
|
669
|
-
const
|
|
704
|
+
const id = agreementIdFromWake(event);
|
|
705
|
+
const agreement = id && this.options.actsOnHireActivation !== true
|
|
706
|
+
? await this.agreementService.get(id).catch(() => null)
|
|
707
|
+
: null;
|
|
708
|
+
if (agreement?.terms?.interactionMode === 'tasks')
|
|
709
|
+
return { reason: 'task_only_activation', because: 'task-only service waits for assigned work' };
|
|
710
|
+
const formedBy = formedByFromWake(event) ?? agreement?.formedBy;
|
|
670
711
|
if (shouldSkipAgentFormedHireActivation({
|
|
671
712
|
event,
|
|
672
713
|
formedBy,
|
|
@@ -894,9 +935,10 @@ export class Agent {
|
|
|
894
935
|
return;
|
|
895
936
|
// Stale timer frames (superseded epoch) are dropped BEFORE touching the
|
|
896
937
|
// live timer — a pure-stale frame must not reset a freshly armed countdown.
|
|
897
|
-
|
|
938
|
+
let frame = this.stateTimers.stripStale(event, laneKey);
|
|
898
939
|
if (frame === null)
|
|
899
940
|
return;
|
|
941
|
+
frame = await withAgreementContext(frame, id => this.agreementService.get(id));
|
|
900
942
|
// Traffic resets inactivity timers, but must not cancel an appointment
|
|
901
943
|
// the agent explicitly requested. Only its own fresh timer frame consumes
|
|
902
944
|
// that wake; a notice-only tick cannot ask the model to restore it.
|
|
@@ -921,7 +963,48 @@ export class Agent {
|
|
|
921
963
|
sessionId: session.sessionId,
|
|
922
964
|
laneKey,
|
|
923
965
|
};
|
|
966
|
+
// Cache only within this wake. Long turns refresh at action boundaries at
|
|
967
|
+
// most once per second, without a read per tool or a fallible final read.
|
|
968
|
+
const controls = new Map();
|
|
969
|
+
const readControl = async (taskId) => {
|
|
970
|
+
const cached = controls.get(taskId);
|
|
971
|
+
if (cached && Date.now() - cached.at < 1000)
|
|
972
|
+
return cached.value;
|
|
973
|
+
const value = await this.taskExecutionControl(taskId);
|
|
974
|
+
controls.set(taskId, { at: Date.now(), value });
|
|
975
|
+
return value;
|
|
976
|
+
};
|
|
977
|
+
const frames = frame?.type === 'batch'
|
|
978
|
+
? frame.events : [frame];
|
|
979
|
+
const runnable = [];
|
|
980
|
+
for (const item of frames) {
|
|
981
|
+
const taskId = workScopeFromWake(item, session.sessionId).taskId;
|
|
982
|
+
const execution = taskId ? await readControl(taskId) : null;
|
|
983
|
+
if (taskId && execution?.blocked) {
|
|
984
|
+
// The brain never sees this frame, so close the books here the way a
|
|
985
|
+
// task-closed outcome would; a paused task is still open work.
|
|
986
|
+
if (session.ctx && execution.closed)
|
|
987
|
+
removeTaskBook(session.ctx, taskId);
|
|
988
|
+
if (session.ctx?.activeTaskId === taskId)
|
|
989
|
+
session.ctx = { ...session.ctx, activeTaskId: null };
|
|
990
|
+
if (this.sessionStore)
|
|
991
|
+
await this.sessionStore.save(laneKey, { ctx: session.ctx, state: session.state });
|
|
992
|
+
await this.acknowledgeExecutionControl(taskId, execution.control);
|
|
993
|
+
}
|
|
994
|
+
else {
|
|
995
|
+
if (taskId && execution?.control?.action === 'resume')
|
|
996
|
+
await this.acknowledgeExecutionControl(taskId, execution.control);
|
|
997
|
+
runnable.push(item);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (!runnable.length) {
|
|
1001
|
+
this.recordWakeOutcome(session, { kind: 'no-op', reason: 'task_control' });
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (runnable.length !== frames.length)
|
|
1005
|
+
frame = runnable.length === 1 ? runnable[0] : { type: 'batch', events: runnable };
|
|
924
1006
|
const memory = this.memoryForWake(session, frame);
|
|
1007
|
+
const wakeTaskId = workScopeFromWake(frame, session.sessionId).taskId;
|
|
925
1008
|
// Recorded here rather than after the tick: this is the last point where the
|
|
926
1009
|
// input is still exactly what the brain will see, and a wake that goes on to
|
|
927
1010
|
// throw is the one most worth replaying.
|
|
@@ -950,16 +1033,81 @@ export class Agent {
|
|
|
950
1033
|
// event (duplicate sends, runaway per-wake input).
|
|
951
1034
|
const tickAbort = new AbortController();
|
|
952
1035
|
const marks = WakeEffects.forWake(frame, session.sessionId);
|
|
1036
|
+
let heldTask;
|
|
1037
|
+
const pendingEffects = new Set();
|
|
1038
|
+
let savedSession;
|
|
1039
|
+
let tickWork;
|
|
1040
|
+
const taskRuns = new Map();
|
|
1041
|
+
const registerTaskRun = (taskId) => {
|
|
1042
|
+
if (taskRuns.has(taskId))
|
|
1043
|
+
return;
|
|
1044
|
+
const run = { abort: tickAbort };
|
|
1045
|
+
const active = this.activeTaskRuns.get(taskId) ?? new Set();
|
|
1046
|
+
active.add(run);
|
|
1047
|
+
this.activeTaskRuns.set(taskId, active);
|
|
1048
|
+
taskRuns.set(taskId, { run, release: () => {
|
|
1049
|
+
active.delete(run);
|
|
1050
|
+
if (!active.size)
|
|
1051
|
+
this.activeTaskRuns.delete(taskId);
|
|
1052
|
+
} });
|
|
1053
|
+
};
|
|
1054
|
+
const releaseTaskRuns = () => {
|
|
1055
|
+
for (const { release } of taskRuns.values())
|
|
1056
|
+
release();
|
|
1057
|
+
taskRuns.clear();
|
|
1058
|
+
};
|
|
1059
|
+
const checkpoint = async (effectTaskId) => {
|
|
1060
|
+
if (tickAbort.signal.aborted) {
|
|
1061
|
+
if (tickAbort.signal.reason?.code === 'TASK_CONTROL')
|
|
1062
|
+
heldTask = tickAbort.signal.reason;
|
|
1063
|
+
throw tickAbort.signal.reason;
|
|
1064
|
+
}
|
|
1065
|
+
if (heldTask)
|
|
1066
|
+
throw new Error('Task execution held');
|
|
1067
|
+
const taskId = effectTaskId ?? marks.scope.taskId ?? wakeTaskId;
|
|
1068
|
+
if (!taskId)
|
|
1069
|
+
return;
|
|
1070
|
+
registerTaskRun(taskId);
|
|
1071
|
+
const execution = await readControl(taskId);
|
|
1072
|
+
if (heldTask)
|
|
1073
|
+
throw new Error('Task execution held');
|
|
1074
|
+
if (execution?.blocked) {
|
|
1075
|
+
heldTask = { taskId, control: execution.control };
|
|
1076
|
+
taskRuns.get(taskId).run.control = execution.control;
|
|
1077
|
+
tickAbort.abort(Object.assign(new Error('Task execution held'), { code: 'TASK_CONTROL', taskId, control: execution.control }));
|
|
1078
|
+
throw new Error('Task execution held');
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
953
1081
|
const eff = async (effect) => {
|
|
954
|
-
|
|
1082
|
+
// Record-only telemetry can finish after interruption. Every action and
|
|
1083
|
+
// model call checks the current task before starting another operation.
|
|
1084
|
+
if (effect.kind !== 'record-event') {
|
|
1085
|
+
const args = effect.args;
|
|
1086
|
+
const taskId = effect.kind === 'tool-call' && typeof args?.taskId === 'string' ? args.taskId : undefined;
|
|
1087
|
+
await checkpoint(taskId);
|
|
1088
|
+
}
|
|
1089
|
+
const agreementId = marks.scope.agreementId;
|
|
1090
|
+
const sameRoom = 'sessionId' in effect && effect.sessionId === session.sessionId;
|
|
1091
|
+
const scopedEffect = sameRoom && effect.kind === 'read-context'
|
|
1092
|
+
? { ...effect, opts: { ...effect.opts, workScopes: contextScopesFromWake(frame, session.sessionId) } }
|
|
1093
|
+
: sameRoom && agreementId && effect.kind === 'send-message'
|
|
1094
|
+
? { ...effect, conversationAgreementId: effect.conversationAgreementId ?? agreementId }
|
|
1095
|
+
: sameRoom && agreementId && (effect.kind === 'tool-call' || effect.kind === 'record-event')
|
|
1096
|
+
? { ...effect, serviceAgreementId: effect.serviceAgreementId ?? agreementId }
|
|
1097
|
+
: effect;
|
|
1098
|
+
const operation = this.eff(scopedEffect);
|
|
1099
|
+
pendingEffects.add(operation);
|
|
1100
|
+
const response = await operation.finally(() => { pendingEffects.delete(operation); });
|
|
955
1101
|
marks.observe(effect, response);
|
|
956
1102
|
for (const key of marks.sentMessageKeys)
|
|
957
1103
|
this.outboundMessages.mark(key);
|
|
958
1104
|
return response;
|
|
959
1105
|
};
|
|
960
1106
|
try {
|
|
1107
|
+
await checkpoint();
|
|
1108
|
+
tickWork = this.wakeEffects.run(marks, () => this.brain.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff, memory, transitionTap: this.options.transitionTap, signal: tickAbort.signal }));
|
|
961
1109
|
const result = await Promise.race([
|
|
962
|
-
|
|
1110
|
+
tickWork,
|
|
963
1111
|
new Promise((_, reject) => {
|
|
964
1112
|
tickWatchdog = setTimeout(() => {
|
|
965
1113
|
tickAbort.abort(new Error('tick timeout'));
|
|
@@ -1006,12 +1154,19 @@ export class Agent {
|
|
|
1006
1154
|
}
|
|
1007
1155
|
}
|
|
1008
1156
|
if (this.sessionStore) {
|
|
1009
|
-
this.sessionStore.save(laneKey, { ctx: session.ctx, state: session.state })
|
|
1157
|
+
savedSession = this.sessionStore.save(laneKey, { ctx: session.ctx, state: session.state });
|
|
1158
|
+
void savedSession.catch((err) => {
|
|
1010
1159
|
runtimeLog.warn('Agent', `sessionStore.save failed laneKey=${laneKey}: ${err?.message || err}`);
|
|
1011
1160
|
});
|
|
1012
1161
|
}
|
|
1013
1162
|
// Arm only when the timeout was handled: an unhandled state-timeout must
|
|
1014
1163
|
// not re-arm, or the lane would runtimeLog.error every timeoutMs forever.
|
|
1164
|
+
if (tickAbort.signal.reason?.code === 'TASK_CONTROL')
|
|
1165
|
+
heldTask = tickAbort.signal.reason;
|
|
1166
|
+
if (heldTask) {
|
|
1167
|
+
this.recordWakeOutcome(session, { kind: 'no-op', reason: 'task_control' });
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1015
1170
|
if (!result.stateTimeoutUnhandled)
|
|
1016
1171
|
this._armIfTimedParked(laneKey, result.state);
|
|
1017
1172
|
/**
|
|
@@ -1040,6 +1195,12 @@ export class Agent {
|
|
|
1040
1195
|
}
|
|
1041
1196
|
}
|
|
1042
1197
|
catch (err) {
|
|
1198
|
+
if (tickAbort.signal.reason?.code === 'TASK_CONTROL')
|
|
1199
|
+
heldTask = tickAbort.signal.reason;
|
|
1200
|
+
if (heldTask) {
|
|
1201
|
+
this.recordWakeOutcome(session, { kind: 'no-op', reason: 'task_control' });
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1043
1204
|
this.recordWakeOutcome(session, {
|
|
1044
1205
|
kind: 'no-op',
|
|
1045
1206
|
reason: 'tick_failed',
|
|
@@ -1069,9 +1230,27 @@ export class Agent {
|
|
|
1069
1230
|
}
|
|
1070
1231
|
finally {
|
|
1071
1232
|
clearTimeout(tickWatchdog);
|
|
1233
|
+
// Do not extend the tick watchdog with unbounded cleanup. Keep the
|
|
1234
|
+
// run registered until its actual work settles; only then can we attest.
|
|
1235
|
+
const finish = async () => {
|
|
1236
|
+
await Promise.allSettled([tickWork, ...pendingEffects]);
|
|
1237
|
+
try {
|
|
1238
|
+
await savedSession;
|
|
1239
|
+
}
|
|
1240
|
+
catch {
|
|
1241
|
+
releaseTaskRuns();
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
const controlsToAck = [...taskRuns].map(([taskId, { run }]) => ({ taskId, control: run.control }));
|
|
1245
|
+
releaseTaskRuns();
|
|
1246
|
+
for (const { taskId, control } of controlsToAck)
|
|
1247
|
+
await this.acknowledgeExecutionControl(taskId, control);
|
|
1248
|
+
};
|
|
1249
|
+
void finish().catch(error => runtimeLog.warn('Agent', `task control cleanup failed: ${String(error)}`));
|
|
1072
1250
|
// Also on the failure path: a tick that threw still must not leave the
|
|
1073
1251
|
// work it was refused for sitting active.
|
|
1074
|
-
|
|
1252
|
+
if (!heldTask)
|
|
1253
|
+
await this.settleUnmetAfterAsk(laneKey).catch(() => { });
|
|
1075
1254
|
}
|
|
1076
1255
|
}
|
|
1077
1256
|
/**
|
|
@@ -1250,7 +1429,7 @@ export class Agent {
|
|
|
1250
1429
|
const full = (await this.taskService.getTask(taskId));
|
|
1251
1430
|
const state = String(full.state ?? '');
|
|
1252
1431
|
if (state !== 'active' && state !== 'in-progress') {
|
|
1253
|
-
|
|
1432
|
+
return;
|
|
1254
1433
|
}
|
|
1255
1434
|
const sessionId = laneSessionIdForTask(full);
|
|
1256
1435
|
const laneKey = laneKeyFor(agentId, sessionId);
|
|
@@ -4,6 +4,7 @@ import type { Workflow } from '../types.js';
|
|
|
4
4
|
import type { ToolManager } from '../tools/ToolManager.js';
|
|
5
5
|
import { type RawEvent } from '../runtime/buildOutcome.js';
|
|
6
6
|
import type { TickInput, TickOutput } from './ZiggsBrain.js';
|
|
7
|
+
export { isPublishProbeChat, isCapabilityProbeSession } from '../probeChat.js';
|
|
7
8
|
/**
|
|
8
9
|
* status notices must not spend a Claude turn answering
|
|
9
10
|
* nobody. Real work wakes arrive as chat `message`, synthetic `task_result`
|
|
@@ -22,12 +23,6 @@ import type { TickInput, TickOutput } from './ZiggsBrain.js';
|
|
|
22
23
|
* message still lands in chat history and arrives as context on the next
|
|
23
24
|
* real wake — skipped here is the LLM turn, not the information.
|
|
24
25
|
*/
|
|
25
|
-
/**
|
|
26
|
-
* Store publish chats the agent in a room named `probe_<agentId>_<ts>`.
|
|
27
|
-
* That check is "are you connected and can you reply", not a hired job —
|
|
28
|
-
* spending a model turn on it is how a long skill prompt 504s the gate.
|
|
29
|
-
*/
|
|
30
|
-
export declare function isCapabilityProbeSession(sessionId: string | undefined | null): boolean;
|
|
31
26
|
export declare function isNoticeOnlyWake(event: RawEvent | undefined, ownAgentId?: string | null): boolean;
|
|
32
27
|
export type CognitionKind = 'fsm' | 'claude-sdk';
|
|
33
28
|
export interface ClaudeBrainOptions {
|
|
@@ -88,8 +83,6 @@ export declare class ClaudeBrain implements Brain {
|
|
|
88
83
|
workflow: Workflow;
|
|
89
84
|
toolManager: ToolManager;
|
|
90
85
|
private rolePrompt;
|
|
91
|
-
/** Store one-liner — used when the publish probe asks who we are. */
|
|
92
|
-
private listingBlurb;
|
|
93
86
|
private specialization;
|
|
94
87
|
private model;
|
|
95
88
|
private anthropicKey;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getAgreement } from '@ziggs-ai/api-client';
|
|
2
|
+
import { permitsAutomaticMandate } from '../runtime/agreementContext.js';
|
|
2
3
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
3
4
|
import { isMessageAddressedElsewhere, outcomeFromEvent } from '../runtime/buildOutcome.js';
|
|
4
5
|
import { getBatchEvents } from '../context/batch.js';
|
|
@@ -12,6 +13,8 @@ import { runtimeLog } from '../shared/runtimeLog.js';
|
|
|
12
13
|
import { resolveOnBehalfAgreementId, } from './onBehalfAgreement.js';
|
|
13
14
|
import { filterStandingOfferNotices, isReadableAgreementNoticeLane } from './standingOfferNotice.js';
|
|
14
15
|
import { formedByFromWake, shouldSkipAgentFormedHireActivation, } from './hireActivationNotice.js';
|
|
16
|
+
import { isPublishProbeChat } from '../probeChat.js';
|
|
17
|
+
export { isPublishProbeChat, isCapabilityProbeSession } from '../probeChat.js';
|
|
15
18
|
/**
|
|
16
19
|
* status notices must not spend a Claude turn answering
|
|
17
20
|
* nobody. Real work wakes arrive as chat `message`, synthetic `task_result`
|
|
@@ -30,14 +33,6 @@ import { formedByFromWake, shouldSkipAgentFormedHireActivation, } from './hireAc
|
|
|
30
33
|
* message still lands in chat history and arrives as context on the next
|
|
31
34
|
* real wake — skipped here is the LLM turn, not the information.
|
|
32
35
|
*/
|
|
33
|
-
/**
|
|
34
|
-
* Store publish chats the agent in a room named `probe_<agentId>_<ts>`.
|
|
35
|
-
* That check is "are you connected and can you reply", not a hired job —
|
|
36
|
-
* spending a model turn on it is how a long skill prompt 504s the gate.
|
|
37
|
-
*/
|
|
38
|
-
export function isCapabilityProbeSession(sessionId) {
|
|
39
|
-
return typeof sessionId === 'string' && sessionId.startsWith('probe_');
|
|
40
|
-
}
|
|
41
36
|
export function isNoticeOnlyWake(event, ownAgentId) {
|
|
42
37
|
const events = getBatchEvents(event);
|
|
43
38
|
if (events.length === 0)
|
|
@@ -186,8 +181,6 @@ export class ClaudeBrain {
|
|
|
186
181
|
workflow;
|
|
187
182
|
toolManager;
|
|
188
183
|
rolePrompt;
|
|
189
|
-
/** Store one-liner — used when the publish probe asks who we are. */
|
|
190
|
-
listingBlurb;
|
|
191
184
|
specialization;
|
|
192
185
|
model;
|
|
193
186
|
anthropicKey;
|
|
@@ -220,7 +213,6 @@ export class ClaudeBrain {
|
|
|
220
213
|
// written before the split still carry the prompt in `description`, so fall
|
|
221
214
|
// back to it rather than silently dropping their role.
|
|
222
215
|
this.rolePrompt = opts.systemPrompt || opts.description;
|
|
223
|
-
this.listingBlurb = opts.description;
|
|
224
216
|
this.specialization = opts.specialization ?? null;
|
|
225
217
|
this.model = opts.model;
|
|
226
218
|
this.anthropicKey = opts.anthropicKey;
|
|
@@ -279,36 +271,6 @@ export class ClaudeBrain {
|
|
|
279
271
|
metrics: { llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } },
|
|
280
272
|
};
|
|
281
273
|
}
|
|
282
|
-
// Store publish must hear a reply even when leftover mail already tripped
|
|
283
|
-
// the wake-rate breaker. The breaker used to sit above this and park the
|
|
284
|
-
// probe with no send-message — Register then died on ALB 504.
|
|
285
|
-
if (nextCtx.lastOutcome?.kind === 'message-received' &&
|
|
286
|
-
isCapabilityProbeSession(identity.sessionId)) {
|
|
287
|
-
const text = `I received your message. ${this.listingBlurb.trim().split(/\r?\n/)[0]?.trim() || 'I am online.'}`;
|
|
288
|
-
const writeKeys = createDeliveryWriteKeys(identity.laneKey, rawEvent);
|
|
289
|
-
const senderId = rawEvent?.senderId != null && String(rawEvent.senderId).trim() !== ''
|
|
290
|
-
? String(rawEvent.senderId)
|
|
291
|
-
: undefined;
|
|
292
|
-
await eff({
|
|
293
|
-
kind: 'send-message',
|
|
294
|
-
sessionId: identity.sessionId,
|
|
295
|
-
...(senderId ? { receiverId: senderId } : {}),
|
|
296
|
-
text,
|
|
297
|
-
messageId: writeKeys.messageKey({
|
|
298
|
-
text,
|
|
299
|
-
sessionId: identity.sessionId,
|
|
300
|
-
receiverId: senderId,
|
|
301
|
-
}),
|
|
302
|
-
});
|
|
303
|
-
runtimeLog.info('ClaudeBrain', `capability-probe reply — skipping LLM session=${identity.sessionId} agent=${this.agentId}`);
|
|
304
|
-
return {
|
|
305
|
-
ctx: nextCtx,
|
|
306
|
-
state: state ?? DEFAULT_STATE,
|
|
307
|
-
parked: true,
|
|
308
|
-
stateTimeoutUnhandled: false,
|
|
309
|
-
metrics: { llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } },
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
274
|
const listingCheck = await filterStandingOfferNotices(rawEvent, this.agentId, readAgreement);
|
|
313
275
|
if (listingCheck.ignoredAgreementIds.length > 0) {
|
|
314
276
|
rawEvent = listingCheck.event;
|
|
@@ -328,7 +290,11 @@ export class ClaudeBrain {
|
|
|
328
290
|
}
|
|
329
291
|
const now = Date.now();
|
|
330
292
|
const recentDeliveries = (nextCtx.recentDeliveries ?? []).filter((t) => now - t < WAKE_WINDOW_MS);
|
|
331
|
-
|
|
293
|
+
// Store publish still has to hear a model reply when leftover mail already
|
|
294
|
+
// tripped the breaker. The prefix only skips the park — it does not write
|
|
295
|
+
// a canned answer.
|
|
296
|
+
if (recentDeliveries.length >= MAX_WAKES_PER_WINDOW &&
|
|
297
|
+
!isPublishProbeChat(identity.sessionId)) {
|
|
332
298
|
runtimeLog.warn('ClaudeBrain', `wake-rate breaker tripped: ${recentDeliveries.length} wakes in ${WAKE_WINDOW_MS / 60_000}min ` +
|
|
333
299
|
`session=${identity.sessionId} agent=${this.agentId} — skipping LLM for this wake`);
|
|
334
300
|
nextCtx.recentDeliveries = recentDeliveries;
|
|
@@ -409,6 +375,7 @@ export class ClaudeBrain {
|
|
|
409
375
|
// not just task_result events — see resolveOnBehalfAgreementId.
|
|
410
376
|
// Skip payer-only subs named on the worker result; bind to the
|
|
411
377
|
// provider hire (see onBehalfAgreement.ts).
|
|
378
|
+
allowAutomaticMandate: permitsAutomaticMandate(rawEvent),
|
|
412
379
|
onBehalfOfAgreementId: resolveOnBehalfAgreementId(rawEvent, Array.isArray(contextSnap?.agreements)
|
|
413
380
|
? contextSnap.agreements
|
|
414
381
|
: null, this.agentId, identity.sessionId),
|
|
@@ -602,6 +569,10 @@ export class ClaudeBrain {
|
|
|
602
569
|
await consumeQuery(resumeId);
|
|
603
570
|
}
|
|
604
571
|
catch (err) {
|
|
572
|
+
if (signal?.aborted && signal.reason?.code === 'TASK_CONTROL') {
|
|
573
|
+
nextCtx.claudeSessionId = this.statelessPerWake ? undefined : sessionId;
|
|
574
|
+
return { ctx: nextCtx, state: state ?? DEFAULT_STATE, parked: true, stateTimeoutUnhandled: false, metrics };
|
|
575
|
+
}
|
|
605
576
|
// Turn exhaustion arrives as a thrown error result, not a break, so it
|
|
606
577
|
// needs its own note before the throw propagates.
|
|
607
578
|
if (isMaxTurnsError(err)) {
|
|
@@ -88,5 +88,5 @@ export declare class ZiggsBrain implements Brain {
|
|
|
88
88
|
toolManager: ToolManager;
|
|
89
89
|
promptBuilder: PromptBuilder;
|
|
90
90
|
constructor({ workflow, toolManager, promptBuilder }: ZiggsBrainOptions);
|
|
91
|
-
tick({ event, ctx, state, identity, eff, memory, transitionTap }: TickInput): Promise<TickOutput>;
|
|
91
|
+
tick({ event, ctx, state, identity, eff, memory, transitionTap, signal }: TickInput): Promise<TickOutput>;
|
|
92
92
|
}
|
package/dist/brain/ZiggsBrain.js
CHANGED
|
@@ -31,7 +31,7 @@ export class ZiggsBrain {
|
|
|
31
31
|
this.toolManager = toolManager;
|
|
32
32
|
this.promptBuilder = promptBuilder || new PromptBuilder();
|
|
33
33
|
}
|
|
34
|
-
async tick({ event, ctx, state, identity, eff, memory, transitionTap }) {
|
|
34
|
+
async tick({ event, ctx, state, identity, eff, memory, transitionTap, signal }) {
|
|
35
35
|
if (!identity?.sessionId)
|
|
36
36
|
throw new Error('ZiggsBrain.tick: identity.sessionId is required');
|
|
37
37
|
if (!eff)
|
|
@@ -51,7 +51,14 @@ export class ZiggsBrain {
|
|
|
51
51
|
});
|
|
52
52
|
// `send` awaits the recursive _step until the machine settles at a
|
|
53
53
|
// parked state (or stops). When it returns, we have the final state.
|
|
54
|
-
|
|
54
|
+
try {
|
|
55
|
+
await machine.send({ event: event, identity });
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (!signal?.aborted || signal.reason?.code !== 'TASK_CONTROL')
|
|
59
|
+
throw error;
|
|
60
|
+
// Preserve completed FSM progress so Resume does not restart the task.
|
|
61
|
+
}
|
|
55
62
|
return {
|
|
56
63
|
ctx: machine.ctx,
|
|
57
64
|
state: machine.state,
|