@ziggs-ai/agent-sdk 0.5.0 → 0.7.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/AgentHost.d.ts +22 -1
- package/dist/AgentHost.js +61 -13
- package/dist/agent/Agent.d.ts +9 -0
- package/dist/agent/ClaudeSdkAgent.d.ts +1 -1
- package/dist/agent/ClaudeSdkAgent.js +48 -13
- package/dist/agent/claudeCognition.js +1 -1
- package/dist/agent/claudeSdkProtocol.d.ts +1 -1
- package/dist/agent/claudeSdkProtocol.js +3 -3
- package/dist/agent/claudeSdkTools.d.ts +2 -2
- package/dist/agent/claudeSdkTools.js +12 -12
- package/dist/context/routingLabels.js +1 -1
- package/dist/defineAgent.d.ts +10 -5
- package/dist/defineAgent.js +16 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/runtime/deriveRespondsToProposals.d.ts +1 -1
- package/dist/runtime/deriveRespondsToProposals.js +1 -1
- package/dist/runtime/kickoff.js +3 -3
- package/dist/runtime/runTurn.js +54 -9
- package/dist/runtime/taskIntake.d.ts +18 -0
- package/dist/runtime/taskIntake.js +113 -0
- package/dist/server/ConnectionPool.d.ts +9 -2
- package/dist/server/ConnectionPool.js +18 -2
- package/dist/server/ZiggsEffectHandler.d.ts +1 -1
- package/dist/server/ZiggsEffectHandler.js +2 -2
- package/dist/server/agreements/AgreementService.d.ts +13 -0
- package/dist/server/agreements/AgreementService.js +16 -1
- package/dist/server/inboxLoop.d.ts +31 -11
- package/dist/server/inboxLoop.js +147 -104
- package/dist/server/runLauncher.js +9 -3
- package/dist/server/staleBacklogGate.d.ts +9 -5
- package/dist/server/staleBacklogGate.js +17 -37
- package/dist/server/tools/index.d.ts +2 -2
- package/dist/server/tools/index.js +6 -4
- package/dist/server/tools/tier1/protocolRunner.d.ts +1 -1
- package/dist/server/tools/tier1/protocolRunner.js +30 -2
- package/dist/server/tools/tier1/protocolTools.d.ts +5 -4
- package/dist/server/tools/tier1/protocolTools.js +10 -9
- package/dist/server/tools/tier2/chatTools.js +1 -1
- package/dist/server/tools/tier2/connectionTools.js +2 -2
- package/dist/server/tools/tier2/linkTools.d.ts +2 -5
- package/dist/server/tools/tier2/linkTools.js +7 -6
- package/dist/server/tools/tier2/marketplaceTools.d.ts +1 -1
- package/dist/server/tools/tier2/marketplaceTools.js +9 -22
- package/dist/tasks/protocolRegistry.d.ts +5 -4
- package/dist/tasks/protocolRegistry.js +5 -4
- package/dist/tools/recordReport.d.ts +1 -1
- package/dist/types.d.ts +4 -3
- package/package.json +2 -2
package/dist/runtime/runTurn.js
CHANGED
|
@@ -105,8 +105,18 @@ export async function runTurn(input) {
|
|
|
105
105
|
// to this agent) has no world to decide over — deciding anyway means
|
|
106
106
|
// reasoning about a fabricated empty history plus whatever stale scraps
|
|
107
107
|
// other fetches returned. Skip the LLM and park. Gated on message wakes
|
|
108
|
-
// only: agreement
|
|
109
|
-
//
|
|
108
|
+
// only. ZIG-975: a bare agreement id as sessionId is a mis-keyed lane —
|
|
109
|
+
// loud-log the empty snapshot rather than staying silent.
|
|
110
|
+
if (ctxObj.chatMissing) {
|
|
111
|
+
const looksLikeBareAgreement = typeof sessionId === 'string' &&
|
|
112
|
+
sessionId.startsWith('agr_') &&
|
|
113
|
+
!sessionId.startsWith('agrn-');
|
|
114
|
+
if (looksLikeBareAgreement) {
|
|
115
|
+
runtimeLog.warn('runTurn', `chatMissing on non-chat sessionId=${sessionId} (state=${stateId}) — ` +
|
|
116
|
+
`lane was keyed by agreementId instead of the agreement's origin/notification chat; ` +
|
|
117
|
+
`wake continues with an empty snapshot`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
110
120
|
if (ctxObj.chatMissing && ctx.lastOutcome?.kind === 'message-received') {
|
|
111
121
|
runtimeLog.warn('runTurn', `chat ${sessionId} not found on backend — skipping message wake (state=${stateId})`);
|
|
112
122
|
return {
|
|
@@ -367,10 +377,18 @@ export async function runTurn(input) {
|
|
|
367
377
|
lastActionDef = actionDef;
|
|
368
378
|
}
|
|
369
379
|
catch (err) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
380
|
+
if (isReplySlotTaken(err)) {
|
|
381
|
+
runtimeLog.warn('runTurn', `reply already persisted under ${streamMessageId} (idempotency 409) — a replay or second host answered this wake first; dropping duplicate`);
|
|
382
|
+
allEmittedEvents.push({ type: 'message_duplicate_skipped', receiverId: decision.receiverId, chatId: sessionId, senderId: 'system' });
|
|
383
|
+
lastAction = chosenAction;
|
|
384
|
+
lastActionDef = actionDef;
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
// Both the streaming path AND the non-streaming fallback failed —
|
|
388
|
+
// the user got nothing. End the turn with a loud send-failed error.
|
|
389
|
+
runtimeLog.error('runTurn', `respond send failed after stream fallback: ${err.message}`);
|
|
390
|
+
turnError = { source: 'protocol', cause: 'send-failed' };
|
|
391
|
+
}
|
|
374
392
|
}
|
|
375
393
|
break;
|
|
376
394
|
}
|
|
@@ -550,7 +568,7 @@ function annotateToolCallDedup(toolCalls, toolManager, initialSawCreate = false)
|
|
|
550
568
|
sawCreate = true;
|
|
551
569
|
return { tc, skipExecute: false };
|
|
552
570
|
}
|
|
553
|
-
if (name === '
|
|
571
|
+
if (name === 'task_set_result') {
|
|
554
572
|
let taskId = null;
|
|
555
573
|
try {
|
|
556
574
|
const a = JSON.parse(tc.function.arguments || '{}');
|
|
@@ -559,7 +577,7 @@ function annotateToolCallDedup(toolCalls, toolManager, initialSawCreate = false)
|
|
|
559
577
|
catch { }
|
|
560
578
|
const key = taskId || tc.id || '_';
|
|
561
579
|
if (seenUpdateTaskIds.has(key))
|
|
562
|
-
return { tc, skipExecute: true, skipReason: 'duplicate
|
|
580
|
+
return { tc, skipExecute: true, skipReason: 'duplicate task_set_result for same taskId' };
|
|
563
581
|
seenUpdateTaskIds.add(key);
|
|
564
582
|
return { tc, skipExecute: false };
|
|
565
583
|
}
|
|
@@ -642,6 +660,20 @@ function truncateMsg(msg) {
|
|
|
642
660
|
return msg;
|
|
643
661
|
return msg.slice(0, MAX_MSG_CHARS) + `\n\n[truncated to ${MAX_MSG_CHARS} of ${msg.length} chars]`;
|
|
644
662
|
}
|
|
663
|
+
/**
|
|
664
|
+
* A send rejected with 409 hit the backend's (chatId, messageId) idempotency
|
|
665
|
+
* gate on a wake-derived id: this wake's reply already exists. Two legal ways
|
|
666
|
+
* that happens — a crash-replay re-ran the wake, or a second host raced the
|
|
667
|
+
* same at-least-once delivery — and in both the re-run LLM produced different
|
|
668
|
+
* text, so the backend rejects instead of no-op'ing (ZIG-932 treats
|
|
669
|
+
* same-key-same-content as replay, same-key-different-content as conflict).
|
|
670
|
+
* The user already has the winner's reply. Re-sending under a fresh id would
|
|
671
|
+
* double-message; failing the turn marks an answered wake as an error. Treat
|
|
672
|
+
* it as delivered-by-the-other-attempt and move on.
|
|
673
|
+
*/
|
|
674
|
+
function isReplySlotTaken(err) {
|
|
675
|
+
return err?.status === 409;
|
|
676
|
+
}
|
|
645
677
|
async function executeSendMessage(decision, eff, sessionId) {
|
|
646
678
|
// Callers (executeDecision) guarantee message + receiverId, and sessionId is
|
|
647
679
|
// always ctx.identity.sessionId. A send failure throws to the turn loop,
|
|
@@ -652,7 +684,15 @@ async function executeSendMessage(decision, eff, sessionId) {
|
|
|
652
684
|
// to the current session otherwise.
|
|
653
685
|
const targetSessionId = decision.chatId || sessionId;
|
|
654
686
|
const safeMessage = truncateMsg(decision.message);
|
|
655
|
-
|
|
687
|
+
try {
|
|
688
|
+
await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: decision.receiverId, text: safeMessage });
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
if (!isReplySlotTaken(err))
|
|
692
|
+
throw err;
|
|
693
|
+
runtimeLog.warn('runTurn', `reply already persisted for this wake (idempotency 409) — a replay or second host answered first; dropping duplicate to ${decision.receiverId}`);
|
|
694
|
+
return [{ type: 'message_duplicate_skipped', receiverId: decision.receiverId, chatId: targetSessionId, senderId: 'system' }];
|
|
695
|
+
}
|
|
656
696
|
const events = [{ type: 'message_sent', receiverId: decision.receiverId, chatId: targetSessionId, message: safeMessage, senderId: 'system' }];
|
|
657
697
|
if (targetSessionId !== sessionId)
|
|
658
698
|
events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' });
|
|
@@ -683,6 +723,11 @@ async function executeSendMessages(decision, eff, sessionId) {
|
|
|
683
723
|
await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: msg.receiverId, text: safeMessage });
|
|
684
724
|
}
|
|
685
725
|
catch (err) {
|
|
726
|
+
if (isReplySlotTaken(err)) {
|
|
727
|
+
runtimeLog.warn('runTurn', `reply already persisted for this wake (idempotency 409) — dropping duplicate to ${msg.receiverId}`);
|
|
728
|
+
events.push({ type: 'message_duplicate_skipped', receiverId: msg.receiverId, chatId: targetSessionId, senderId: 'system' });
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
686
731
|
// Scenario: backend rejected or transport dropped this one send. Record
|
|
687
732
|
// it and keep going — remaining recipients must still get their message.
|
|
688
733
|
failures.push({ receiverId: msg.receiverId, error: err.message });
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ThinkingState, Workflow } from '../types.js';
|
|
2
|
+
/** Reserved state name for the injected assigned-task-intake default. */
|
|
3
|
+
export declare const TASK_INTAKE_STATE = "workingAssignedTask";
|
|
4
|
+
export declare function buildTaskIntakeState(initial: string): ThinkingState;
|
|
5
|
+
/**
|
|
6
|
+
* Assigned-task-intake default — the task-side twin of the agreement-kickoff
|
|
7
|
+
* default (kickoff.ts). For every state whose transitions do not already match
|
|
8
|
+
* a task-assigned outcome, append a transition to the injected
|
|
9
|
+
* `workingAssignedTask` state. Appended LAST so any user transition on the
|
|
10
|
+
* same outcome always wins (first match wins in
|
|
11
|
+
* AgentMachine._evaluateTransitions). Returns a NEW workflow — input states
|
|
12
|
+
* are not mutated.
|
|
13
|
+
*
|
|
14
|
+
* Why: a standing hire receives task #2, #3, … months after approval. Without
|
|
15
|
+
* this default a parked state with no task-assigned route (e.g. a hire parked
|
|
16
|
+
* in `engaged`) swallowed the wake and the task sat active forever.
|
|
17
|
+
*/
|
|
18
|
+
export declare function injectTaskIntakeDefault(workflow: Workflow): Workflow;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { thinkingDefaults } from './defaults.js';
|
|
2
|
+
import { KICKOFF_STATE } from './kickoff.js';
|
|
3
|
+
import { PROBE_CTX } from './validateWorkflow.js';
|
|
4
|
+
/** Reserved state name for the injected assigned-task-intake default. */
|
|
5
|
+
export const TASK_INTAKE_STATE = 'workingAssignedTask';
|
|
6
|
+
// Synthetic outcome used to ask "does this state already route an assigned
|
|
7
|
+
// task?" — same probe pattern as kickoff.ts / validateWorkflow.
|
|
8
|
+
const PROBE_TASK_ASSIGNED = {
|
|
9
|
+
kind: 'task-assigned',
|
|
10
|
+
task: { taskId: '__task_intake_probe__' },
|
|
11
|
+
};
|
|
12
|
+
const TASK_INTAKE_TRANSITION = {
|
|
13
|
+
to: TASK_INTAKE_STATE,
|
|
14
|
+
when: o => o.kind === 'task-assigned',
|
|
15
|
+
};
|
|
16
|
+
// A state "handles" task-assigned if some transition would fire for it. An
|
|
17
|
+
// unconditional (when-less) transition counts — it catches every outcome, so
|
|
18
|
+
// the intake transition appended LAST would be unreachable there anyway.
|
|
19
|
+
function stateHandlesTaskAssigned(state) {
|
|
20
|
+
return state.transitions.some(t => {
|
|
21
|
+
if (!t.when)
|
|
22
|
+
return true;
|
|
23
|
+
try {
|
|
24
|
+
return !!t.when(PROBE_TASK_ASSIGNED, PROBE_CTX);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export function buildTaskIntakeState(initial) {
|
|
32
|
+
const dflt = thinkingDefaults({ initial });
|
|
33
|
+
return {
|
|
34
|
+
kind: 'thinking',
|
|
35
|
+
prompt: {
|
|
36
|
+
role: 'A task was just assigned to you under one of your active agreements, and your workflow has no explicit route for it. This injected default exists so an assigned task is NEVER silently dropped — under a standing hire, new tasks keep arriving for the lifetime of the agreement.',
|
|
37
|
+
goal: 'Resolve the assigned task this turn: deliver the result if you can produce it, otherwise fail it with a reason.',
|
|
38
|
+
constraints: 'The task is in <event> (and as activeTaskId / activeAgreementId in <machine_context>). If the deliverable can be produced in this turn (an answer, summary, plan, draft — most representation work), call task_set_result with status "completed" and the deliverable as result — the result is what the counterparty consumes, so make it the actual deliverable, not a status note. If the task genuinely needs abilities or tools you do not have, call task_set_result with status "failed" and a one-line reason — a visible failure the counterparty can re-route beats a silent drop. Never leave the task dangling or answer with a chat message alone.',
|
|
39
|
+
},
|
|
40
|
+
actions: {
|
|
41
|
+
...dflt.actions,
|
|
42
|
+
completeTask: {
|
|
43
|
+
prompt: {
|
|
44
|
+
when: 'You can produce the deliverable for the assigned task in this turn.',
|
|
45
|
+
instruction: 'Complete the assigned task, with the deliverable itself as the result.',
|
|
46
|
+
format: '{"taskId": "<assigned task id from <event>>", "status": "completed", "result": "<the full deliverable>"}',
|
|
47
|
+
},
|
|
48
|
+
tool: 'task_set_result',
|
|
49
|
+
// Function form so a failed task_set_result surfaces as an error outcome
|
|
50
|
+
// (→ dflt error transition) instead of a phantom task-closed.
|
|
51
|
+
produces: (result) => {
|
|
52
|
+
const r = result;
|
|
53
|
+
const status = r?.task?.state ?? r?.state ?? r?.status;
|
|
54
|
+
return status === 'completed' || status === 'failed' ? 'task-closed' : 'error';
|
|
55
|
+
},
|
|
56
|
+
terminatesLoop: true,
|
|
57
|
+
},
|
|
58
|
+
failTask: {
|
|
59
|
+
prompt: {
|
|
60
|
+
when: 'The task needs abilities, tools, or information you cannot obtain this turn.',
|
|
61
|
+
instruction: 'Fail the assigned task with a one-line reason so the counterparty can re-route it.',
|
|
62
|
+
format: '{"taskId": "<assigned task id from <event>>", "status": "failed", "result": "<one-line reason>"}',
|
|
63
|
+
},
|
|
64
|
+
tool: 'task_set_result',
|
|
65
|
+
produces: (result) => {
|
|
66
|
+
const r = result;
|
|
67
|
+
const status = r?.task?.state ?? r?.state ?? r?.status;
|
|
68
|
+
return status === 'completed' || status === 'failed' ? 'task-closed' : 'error';
|
|
69
|
+
},
|
|
70
|
+
terminatesLoop: true,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
transitions: [
|
|
74
|
+
{ to: initial, when: o => o.kind === 'task-closed' },
|
|
75
|
+
...dflt.transitions, // wait / message-sent / error → initial
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Assigned-task-intake default — the task-side twin of the agreement-kickoff
|
|
81
|
+
* default (kickoff.ts). For every state whose transitions do not already match
|
|
82
|
+
* a task-assigned outcome, append a transition to the injected
|
|
83
|
+
* `workingAssignedTask` state. Appended LAST so any user transition on the
|
|
84
|
+
* same outcome always wins (first match wins in
|
|
85
|
+
* AgentMachine._evaluateTransitions). Returns a NEW workflow — input states
|
|
86
|
+
* are not mutated.
|
|
87
|
+
*
|
|
88
|
+
* Why: a standing hire receives task #2, #3, … months after approval. Without
|
|
89
|
+
* this default a parked state with no task-assigned route (e.g. a hire parked
|
|
90
|
+
* in `engaged`) swallowed the wake and the task sat active forever.
|
|
91
|
+
*/
|
|
92
|
+
export function injectTaskIntakeDefault(workflow) {
|
|
93
|
+
if (workflow.states[TASK_INTAKE_STATE]) {
|
|
94
|
+
throw new Error(`[defineAgent] state name "${TASK_INTAKE_STATE}" is reserved for the injected assigned-task-intake default — rename the state`);
|
|
95
|
+
}
|
|
96
|
+
const states = {};
|
|
97
|
+
let injected = false;
|
|
98
|
+
for (const [name, state] of Object.entries(workflow.states)) {
|
|
99
|
+
// Injected defaults never feed each other: the kickoff state's own lack of
|
|
100
|
+
// a task-assigned route must not force the intake state into a workflow
|
|
101
|
+
// whose author states all handle assignment.
|
|
102
|
+
if (name === KICKOFF_STATE || stateHandlesTaskAssigned(state)) {
|
|
103
|
+
states[name] = state;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
states[name] = { ...state, transitions: [...state.transitions, TASK_INTAKE_TRANSITION] };
|
|
107
|
+
injected = true;
|
|
108
|
+
}
|
|
109
|
+
if (!injected)
|
|
110
|
+
return workflow; // every state routes task-assigned — nothing to add
|
|
111
|
+
states[TASK_INTAKE_STATE] = buildTaskIntakeState(workflow.initial);
|
|
112
|
+
return { ...workflow, states };
|
|
113
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* This is how a single Node process serves a fleet of agents without
|
|
12
12
|
* holding 1000 open sockets — typical config: 1000+ registered, ~50 live.
|
|
13
13
|
*/
|
|
14
|
-
import { type InboxOperatorAgentEntry } from '@ziggs-ai/api-client';
|
|
14
|
+
import { type InboxOperatorAgentEntry, type StartAgentOptions } from '@ziggs-ai/api-client';
|
|
15
15
|
import { AgentHost, type AgentHostOptions } from '../AgentHost.js';
|
|
16
16
|
interface ConnectionManagerMeta {
|
|
17
17
|
domain?: string;
|
|
@@ -50,7 +50,14 @@ export declare class ConnectionPool {
|
|
|
50
50
|
* `AgentHost` constructed + connected) the first time it's referenced.
|
|
51
51
|
*/
|
|
52
52
|
register(configs: AgentHostOptions[], metaArr?: ConnectionManagerMeta[]): void;
|
|
53
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Start (or return) an agent's host. `pinned` keeps it up for the process's
|
|
55
|
+
* lifetime — no idle sweep, last to be evicted; use it for hosts the operator
|
|
56
|
+
* named rather than ones work happened to reach.
|
|
57
|
+
*/
|
|
58
|
+
startAgent(agentId: string, opts?: StartAgentOptions): Promise<AgentHost>;
|
|
59
|
+
/** Agents kept up explicitly (launcher `preStart`), not by demand. */
|
|
60
|
+
listPinned(): string[];
|
|
54
61
|
sleep(agentId: string): Promise<void>;
|
|
55
62
|
disconnectAll(): Promise<void>;
|
|
56
63
|
/**
|
|
@@ -46,7 +46,14 @@ export class ConnectionPool {
|
|
|
46
46
|
const pool = this;
|
|
47
47
|
const host = new AgentHost({
|
|
48
48
|
...cfg,
|
|
49
|
+
// Inbox activity and in-flight effects both reset the idle timer:
|
|
50
|
+
// previously only pool.sendTo() touched it, so a host woken by its
|
|
51
|
+
// own inbox loop was torn down every idleTimeoutMs (default 60s)
|
|
52
|
+
// regardless of the work it was doing — losing lane ctx, Claude
|
|
53
|
+
// session ids, and armed timers each time.
|
|
54
|
+
onActivity: () => pool.manager.touch(agentId),
|
|
49
55
|
effectTap(effect, response) {
|
|
56
|
+
pool.manager.touch(agentId);
|
|
50
57
|
if (effect.kind === 'send-message') {
|
|
51
58
|
const listeners = pool.responseListeners.get(agentId);
|
|
52
59
|
if (listeners?.length)
|
|
@@ -64,8 +71,17 @@ export class ConnectionPool {
|
|
|
64
71
|
}, metaByAgentId.get(agentId));
|
|
65
72
|
}
|
|
66
73
|
}
|
|
67
|
-
|
|
68
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Start (or return) an agent's host. `pinned` keeps it up for the process's
|
|
76
|
+
* lifetime — no idle sweep, last to be evicted; use it for hosts the operator
|
|
77
|
+
* named rather than ones work happened to reach.
|
|
78
|
+
*/
|
|
79
|
+
async startAgent(agentId, opts = {}) {
|
|
80
|
+
return await this.manager.startAgent(agentId, opts);
|
|
81
|
+
}
|
|
82
|
+
/** Agents kept up explicitly (launcher `preStart`), not by demand. */
|
|
83
|
+
listPinned() {
|
|
84
|
+
return this.manager.listPinned();
|
|
69
85
|
}
|
|
70
86
|
async sleep(agentId) {
|
|
71
87
|
return this.manager.sleep(agentId);
|
|
@@ -59,7 +59,7 @@ export interface ZiggsEffectDeps {
|
|
|
59
59
|
* side effects — that is the whole point of funneling both paths here.
|
|
60
60
|
*
|
|
61
61
|
* `eff` is threaded into the tool context so eff-backed tools
|
|
62
|
-
* (`
|
|
62
|
+
* (`context_snapshot`, `chat_send`) resolve through this same path
|
|
63
63
|
* regardless of which cognition invoked them.
|
|
64
64
|
*/
|
|
65
65
|
export declare function executeToolWithSideEffects(deps: Pick<ZiggsEffectDeps, 'toolManager' | 'artifactsClient' | 'telemetryClient' | 'telemetryIngest' | 'operatorKey' | 'agentId' | 'taskService' | 'agreementService' | 'contextReadClient' | 'messageSender'>, eff: EffectHandler, effect: Extract<Effect, {
|
|
@@ -98,7 +98,7 @@ async function emitToolOperationArtifact(client, chatId, phase, payload, idempot
|
|
|
98
98
|
* side effects — that is the whole point of funneling both paths here.
|
|
99
99
|
*
|
|
100
100
|
* `eff` is threaded into the tool context so eff-backed tools
|
|
101
|
-
* (`
|
|
101
|
+
* (`context_snapshot`, `chat_send`) resolve through this same path
|
|
102
102
|
* regardless of which cognition invoked them.
|
|
103
103
|
*/
|
|
104
104
|
export async function executeToolWithSideEffects(deps, eff, effect, roster) {
|
|
@@ -214,7 +214,7 @@ export function createZiggsEffectHandler(deps) {
|
|
|
214
214
|
// Grant-fenced aggregate read: the backend composes the ContextSnapshot
|
|
215
215
|
// (GET /context/snapshot) through the same access + temporal fence as
|
|
216
216
|
// the per-type reads. Every producer — ClaudeSdkAgent.tick, runTurn,
|
|
217
|
-
// and the on-demand
|
|
217
|
+
// and the on-demand context_snapshot tool — routes through here.
|
|
218
218
|
const snap = await buildSnapshot(deps, effect.sessionId, deps.agentId, {
|
|
219
219
|
limit: effect.opts?.maxMessages,
|
|
220
220
|
});
|
|
@@ -19,6 +19,19 @@ export declare class AgreementService {
|
|
|
19
19
|
publishOffer(payload: PublishOfferPayload): Promise<Agreement>;
|
|
20
20
|
delegate(payload: DelegateAgreementData): Promise<Agreement>;
|
|
21
21
|
respond(agreementId: string, action: 'approve' | 'reject'): Promise<Agreement>;
|
|
22
|
+
/**
|
|
23
|
+
* ZIG-1021 — claim any open broadcast (quest, standing offer, link invite).
|
|
24
|
+
* Routing (offer vs agreement claim endpoint) is decided in the shared flow.
|
|
25
|
+
*/
|
|
26
|
+
claim(agreementId: string): Promise<{
|
|
27
|
+
agreement: Agreement;
|
|
28
|
+
kind: 'link' | 'offer' | 'quest';
|
|
29
|
+
}>;
|
|
30
|
+
/**
|
|
31
|
+
* ZIG-1022 — a link proposal is an agreement (engagementKind 'link') to one
|
|
32
|
+
* agent; the target owner approves. Folds the old request_link tool.
|
|
33
|
+
*/
|
|
34
|
+
requestLink(providerId: string, description?: string): Promise<Agreement>;
|
|
22
35
|
counter(agreementId: string, counter: CounterAgreementData): Promise<Agreement>;
|
|
23
36
|
getStatus(agreementId: string): Promise<unknown | null>;
|
|
24
37
|
list(filters?: ListAgreementsFilters): Promise<Agreement[]>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { proposeAgreement, proposeBroadcast as proposeBroadcastApi, delegateAgreement, respondToAgreement, counterAgreement, getAgreementStatus, listAgreements, getMyAgreements, getAgreement, publishOffer as publishOfferApi, createAgreement, revokeAgreement, fulfillAgreement, linkAgreementToChat, getChatsForAgreement, joinAgreement, linkArtifactToAgreement, getArtifactsForAgreement, linkUserToAgreement, getUsersForAgreement, } from '@ziggs-ai/api-client';
|
|
1
|
+
import { proposeAgreement, proposeBroadcast as proposeBroadcastApi, delegateAgreement, respondToAgreement, counterAgreement, claimOpenAgreement, getAgreementStatus, listAgreements, getMyAgreements, getAgreement, publishOffer as publishOfferApi, createAgreement, revokeAgreement, fulfillAgreement, linkAgreementToChat, getChatsForAgreement, joinAgreement, linkArtifactToAgreement, getArtifactsForAgreement, linkUserToAgreement, getUsersForAgreement, } from '@ziggs-ai/api-client';
|
|
2
2
|
export class AgreementService {
|
|
3
3
|
creds;
|
|
4
4
|
constructor(operatorKey, agentId) {
|
|
@@ -37,6 +37,21 @@ export class AgreementService {
|
|
|
37
37
|
respond(agreementId, action) {
|
|
38
38
|
return respondToAgreement(agreementId, action, this.creds);
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* ZIG-1021 — claim any open broadcast (quest, standing offer, link invite).
|
|
42
|
+
* Routing (offer vs agreement claim endpoint) is decided in the shared flow.
|
|
43
|
+
*/
|
|
44
|
+
claim(agreementId) {
|
|
45
|
+
return claimOpenAgreement(agreementId, this.creds);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* ZIG-1022 — a link proposal is an agreement (engagementKind 'link') to one
|
|
49
|
+
* agent; the target owner approves. Folds the old request_link tool.
|
|
50
|
+
*/
|
|
51
|
+
async requestLink(providerId, description) {
|
|
52
|
+
const { agreement } = await createAgreement({ engagementKind: 'link', providerId, ...(description ? { description } : {}) }, this.creds);
|
|
53
|
+
return agreement;
|
|
54
|
+
}
|
|
40
55
|
counter(agreementId, counter) {
|
|
41
56
|
return counterAgreement(agreementId, counter || {}, this.creds);
|
|
42
57
|
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import { type Creds } from '@ziggs-ai/api-client';
|
|
1
|
+
import { type InboxTaskRef, type Creds } from '@ziggs-ai/api-client';
|
|
2
2
|
import { SeenMessages } from './SeenMessages.js';
|
|
3
3
|
/**
|
|
4
4
|
* Per-agent inbox long-poll loop (delivery law, phase 4). This is the ONLY way
|
|
5
|
-
* a hosted agent consumes: `GET /inbox?wait=` → grant-fenced reads of
|
|
6
|
-
* → deliver into the host's lane/tick machinery →
|
|
7
|
-
* no push, no separate catch-up channel — a
|
|
8
|
-
* iteration (the
|
|
9
|
-
* re-processing a redelivered window is
|
|
10
|
-
* is the one ordering rule.
|
|
5
|
+
* a hosted agent consumes: `GET /inbox?wait=` → grant-fenced reads of what was
|
|
6
|
+
* addressed to it → deliver into the host's lane/tick machinery →
|
|
7
|
+
* `POST /inbox/ack`. No socket, no push, no separate catch-up channel — a
|
|
8
|
+
* crash-replay is just the next iteration (the watermark is server-side), and
|
|
9
|
+
* writes carry idempotency keys so re-processing a redelivered window is
|
|
10
|
+
* wasteful, never harmful. Act-before-ack is the one ordering rule.
|
|
11
|
+
*
|
|
12
|
+
* The envelope now names the chats directly, so this loop no longer expands
|
|
13
|
+
* scopes into chat ids itself — that cost an extra grant/agreement lookup per
|
|
14
|
+
* scope, per pass, purely to rediscover what the server already knew.
|
|
11
15
|
*/
|
|
12
16
|
export interface InboxLoopDeps {
|
|
13
17
|
creds: Creds;
|
|
@@ -16,13 +20,24 @@ export interface InboxLoopDeps {
|
|
|
16
20
|
seen: SeenMessages;
|
|
17
21
|
onMessage: (text: string, metadata: Record<string, unknown>) => Promise<void>;
|
|
18
22
|
onResourceEvent: (event: Record<string, unknown>) => Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* An open task assigned to this agent (ZIG-973). Delivered from the inbox's
|
|
25
|
+
* own task channel, so work reaches the agent even when no chat row exists
|
|
26
|
+
* for it (chat-less agreement, self-assigned task).
|
|
27
|
+
*/
|
|
28
|
+
onTask?: (task: InboxTaskRef) => Promise<void>;
|
|
19
29
|
label?: string;
|
|
20
30
|
}
|
|
21
31
|
export interface InboxPassResult {
|
|
22
|
-
|
|
32
|
+
/** Delivery references this envelope carried. */
|
|
33
|
+
deliveries: number;
|
|
23
34
|
messagesDelivered: number;
|
|
24
35
|
resourceEvents: number;
|
|
25
|
-
|
|
36
|
+
/** Assigned open tasks delivered this pass (ZIG-973). */
|
|
37
|
+
tasksDelivered: number;
|
|
38
|
+
/** True when the watermark advanced this pass. */
|
|
39
|
+
acked: boolean;
|
|
40
|
+
/** True when there was more mail than one envelope carries. */
|
|
26
41
|
truncated: boolean;
|
|
27
42
|
}
|
|
28
43
|
/**
|
|
@@ -42,8 +57,13 @@ export interface InboxLoopHandle {
|
|
|
42
57
|
* Run {@link pollInboxOnce} forever (until stopped). Each iteration long-polls
|
|
43
58
|
* for `waitSeconds`; a passing envelope returns instantly, an empty one holds.
|
|
44
59
|
* Errors retry with exponential backoff + jitter and never exit the loop —
|
|
45
|
-
* there is nothing to resynchronize (the
|
|
46
|
-
* just the next iteration.
|
|
60
|
+
* there is nothing to resynchronize (the watermark is server-side), so recovery
|
|
61
|
+
* is just the next iteration.
|
|
62
|
+
*
|
|
63
|
+
* The FIRST pass is an immediate read. A held poll only answers to new mail, so
|
|
64
|
+
* a host that just started (or restarted after a crash) would otherwise sit
|
|
65
|
+
* waiting while the work it was already assigned — open tasks, pending
|
|
66
|
+
* decisions — went unseen until something unrelated arrived.
|
|
47
67
|
*/
|
|
48
68
|
export declare function runInboxLoop(deps: InboxLoopDeps, opts?: {
|
|
49
69
|
waitSeconds?: number;
|