@ziggs-ai/agent-sdk 0.1.11 → 0.1.12
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/package.json +5 -3
- package/src/AgentHost.ts +297 -28
- package/src/adapters/AnthropicAdapter.ts +42 -10
- package/src/adapters/McpAdapter.ts +350 -20
- package/src/adapters/OpenAIAdapter.ts +9 -2
- package/src/agent/Agent.ts +10 -2
- package/src/agent/ClaudeSdkAgent.ts +401 -0
- package/src/agent/claudeCognition.ts +139 -0
- package/src/agent/claudeSdkProtocol.ts +27 -0
- package/src/agent/claudeSdkTools.ts +81 -0
- package/src/agent/hostedAgentConnection.ts +41 -0
- package/src/agent/toolManagerToSdkMcp.ts +136 -0
- package/src/defineAgent.ts +9 -2
- package/src/index.ts +11 -5
- package/src/runtime/AgentMachine.ts +104 -22
- package/src/runtime/PromptBuilder.ts +42 -5
- package/src/runtime/bootstrapCtx.ts +39 -0
- package/src/runtime/defaults.ts +12 -1
- package/src/runtime/deriveRespondsToProposals.ts +35 -0
- package/src/runtime/runMechanical.ts +106 -0
- package/src/runtime/runTurn.ts +65 -8
- package/src/runtime/validateWorkflow.ts +33 -14
- package/src/server/InboxCatchUp.ts +37 -0
- package/src/server/ZiggsEffectHandler.ts +188 -179
- package/src/server/agreements/AgreementService.ts +3 -3
- package/src/server/tasks/TaskService.ts +2 -1
- package/src/server/tools/tier1/protocolRunner.ts +212 -3
- package/src/server/tools/tier1/protocolTools.ts +18 -3
- package/src/types.ts +49 -22
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Action,
|
|
3
|
+
Ctx,
|
|
4
|
+
EffectHandler,
|
|
5
|
+
MechanicalState,
|
|
6
|
+
Outcome,
|
|
7
|
+
ToolResultEntry,
|
|
8
|
+
} from '../types.js';
|
|
9
|
+
import type { MemoryStore } from '../memory/MemoryStore.js';
|
|
10
|
+
import { outcomeFromActionResult, type EmittedEvent, type RawEvent, type WireResult } from './buildOutcome.js';
|
|
11
|
+
import { runtimeLog } from '../shared/runtimeLog.js';
|
|
12
|
+
import type { ToolCallResult } from '../types.js';
|
|
13
|
+
|
|
14
|
+
export interface RunMechanicalResult {
|
|
15
|
+
outcome: Outcome;
|
|
16
|
+
toolResults: ToolResultEntry[];
|
|
17
|
+
llmCalls: number;
|
|
18
|
+
tokens: { total: number; input: number; output: number };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* ZIG-764: run a mechanical state — invoke one bound tool with no LLM turn.
|
|
23
|
+
* Used by dumb coordinators whose only job is a fixed tool call on task-assigned.
|
|
24
|
+
*/
|
|
25
|
+
export async function runMechanical(input: {
|
|
26
|
+
stateId: string;
|
|
27
|
+
state: MechanicalState;
|
|
28
|
+
ctx: Ctx;
|
|
29
|
+
eff: EffectHandler;
|
|
30
|
+
memory?: MemoryStore;
|
|
31
|
+
incomingEvent: RawEvent | null;
|
|
32
|
+
}): Promise<RunMechanicalResult> {
|
|
33
|
+
const { stateId, state, ctx, eff, memory, incomingEvent } = input;
|
|
34
|
+
const sessionId = ctx.identity.sessionId;
|
|
35
|
+
const runId = crypto.randomUUID();
|
|
36
|
+
|
|
37
|
+
const effWithTrace: typeof eff = (effect) => {
|
|
38
|
+
if (effect.kind === 'tool-call') {
|
|
39
|
+
return eff({ ...effect, runId, stateId } as typeof effect);
|
|
40
|
+
}
|
|
41
|
+
return eff(effect);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
runtimeLog.info('runMechanical', `enter state=${stateId} tool=${state.tool}`);
|
|
45
|
+
|
|
46
|
+
const args =
|
|
47
|
+
typeof state.buildArgs === 'function'
|
|
48
|
+
? state.buildArgs(ctx, ctx.lastOutcome)
|
|
49
|
+
: { ...(state.args ?? {}) };
|
|
50
|
+
|
|
51
|
+
const pseudoAction: Action = {
|
|
52
|
+
prompt: { instruction: '' },
|
|
53
|
+
tool: state.tool,
|
|
54
|
+
produces: state.produces,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
let emitted: EmittedEvent[] = [];
|
|
58
|
+
try {
|
|
59
|
+
const r = await effWithTrace({
|
|
60
|
+
kind: 'tool-call',
|
|
61
|
+
sessionId,
|
|
62
|
+
name: state.tool,
|
|
63
|
+
args,
|
|
64
|
+
memory,
|
|
65
|
+
senderId: incomingEvent?.senderId != null ? String(incomingEvent.senderId) : undefined,
|
|
66
|
+
senderType: incomingEvent?.senderType != null ? String(incomingEvent.senderType) : undefined,
|
|
67
|
+
}) as ToolCallResult;
|
|
68
|
+
|
|
69
|
+
if (!r.ok) {
|
|
70
|
+
emitted = [{ type: 'tool_error', tool: state.tool, error: r.error || 'unknown', senderId: 'system' }];
|
|
71
|
+
} else if (r.events && (r.events as unknown[]).length > 0) {
|
|
72
|
+
emitted = r.events as unknown as EmittedEvent[];
|
|
73
|
+
} else {
|
|
74
|
+
emitted = [{ type: 'tool_result', tool: state.tool, result: r.result as WireResult | undefined, senderId: 'system' }];
|
|
75
|
+
}
|
|
76
|
+
} catch (error: unknown) {
|
|
77
|
+
emitted = [
|
|
78
|
+
{
|
|
79
|
+
type: 'tool_error',
|
|
80
|
+
tool: state.tool,
|
|
81
|
+
error: (error as Error)?.message ?? String(error),
|
|
82
|
+
senderId: 'system',
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const toolResults: ToolResultEntry[] = [];
|
|
88
|
+
let outcome = outcomeFromActionResult('__mechanical__', pseudoAction, args, emitted, toolResults);
|
|
89
|
+
if (emitted.some((e) => e?.type === 'tool_error')) {
|
|
90
|
+
outcome = {
|
|
91
|
+
kind: 'error',
|
|
92
|
+
source: 'tool',
|
|
93
|
+
cause: emitted.find((e) => e?.type === 'tool_error')?.error ?? 'tool failed',
|
|
94
|
+
retryable: false,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
runtimeLog.info('runMechanical', `done state=${stateId} outcome=${outcome.kind} llmCalls=0`);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
outcome,
|
|
102
|
+
toolResults,
|
|
103
|
+
llmCalls: 0,
|
|
104
|
+
tokens: { total: 0, input: 0, output: 0 },
|
|
105
|
+
};
|
|
106
|
+
}
|
package/src/runtime/runTurn.ts
CHANGED
|
@@ -68,7 +68,14 @@ interface RunTurnInput {
|
|
|
68
68
|
*/
|
|
69
69
|
memory?: MemoryStore;
|
|
70
70
|
definition: { description?: string };
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Respond actions use 2-call streaming: Call 1 picks action+receiverId,
|
|
73
|
+
* Call 2 streams the text (ZIG-471). On by default (ZIG-838); pass false
|
|
74
|
+
* (or set ENABLE_LLM_STREAMING=false) to fold the reply text back into the
|
|
75
|
+
* single decision call. The env flag is a deployment kill-switch — the
|
|
76
|
+
* streaming path once broke long replies in production, so ops keeps a way
|
|
77
|
+
* to disable it without a code change.
|
|
78
|
+
*/
|
|
72
79
|
streamRespondActions?: boolean;
|
|
73
80
|
}
|
|
74
81
|
|
|
@@ -97,7 +104,10 @@ interface RunTurnResult {
|
|
|
97
104
|
*/
|
|
98
105
|
export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
|
|
99
106
|
const { stateId, statePrompt, actions, incomingEvent, ctx, eff, toolManager, promptBuilder, memory, definition } = input;
|
|
100
|
-
|
|
107
|
+
// Default on (ZIG-838); ENABLE_LLM_STREAMING=false is the deployment
|
|
108
|
+
// kill-switch. Any other value (unset, 'true', …) streams.
|
|
109
|
+
const streamRespondActions =
|
|
110
|
+
input.streamRespondActions ?? process.env.ENABLE_LLM_STREAMING !== 'false';
|
|
101
111
|
const sessionId = ctx.identity.sessionId;
|
|
102
112
|
const runId = crypto.randomUUID();
|
|
103
113
|
|
|
@@ -143,6 +153,22 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
|
|
|
143
153
|
const serverContext = await effWithTrace({ kind: 'read-context', sessionId, opts: {} });
|
|
144
154
|
const ctxObj = normalizeContextSnapshot(serverContext);
|
|
145
155
|
|
|
156
|
+
// A message wake whose chat no longer resolves (deleted, or never visible
|
|
157
|
+
// to this agent) has no world to decide over — deciding anyway means
|
|
158
|
+
// reasoning about a fabricated empty history plus whatever stale scraps
|
|
159
|
+
// other fetches returned. Skip the LLM and park. Gated on message wakes
|
|
160
|
+
// only: agreement-keyed task lanes (sessionId = agreementId) always trip
|
|
161
|
+
// chatMissing and must still run.
|
|
162
|
+
if (ctxObj.chatMissing && ctx.lastOutcome?.kind === 'message-received') {
|
|
163
|
+
runtimeLog.warn('runTurn', `chat ${sessionId} not found on backend — skipping message wake (state=${stateId})`);
|
|
164
|
+
return {
|
|
165
|
+
outcome: { kind: 'wait' },
|
|
166
|
+
toolResults: [],
|
|
167
|
+
llmCalls: 0,
|
|
168
|
+
tokens: { total: 0, input: 0, output: 0 },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
146
172
|
const prompt = promptBuilder.buildFromState({
|
|
147
173
|
statePrompt, actions,
|
|
148
174
|
serverContext: { context: ctxObj as Record<string, unknown>, tools: tools as unknown as Record<string, unknown>[] },
|
|
@@ -165,6 +191,15 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
|
|
|
165
191
|
let lastArgs: unknown = null;
|
|
166
192
|
let llmCallsTotal = 0;
|
|
167
193
|
let tokensTotal = 0, tokensInput = 0, tokensOutput = 0;
|
|
194
|
+
// Fold a tool's nested LLM usage (llmTool) into the turn's authoritative
|
|
195
|
+
// token count so tool-internal spend reaches result.metrics (ZIG-813).
|
|
196
|
+
const addToolUsage = (u: LlmResponse['usage']): void => {
|
|
197
|
+
if (!u) return;
|
|
198
|
+
llmCallsTotal++;
|
|
199
|
+
tokensTotal += (u.total_tokens ?? u.totalTokens) || 0;
|
|
200
|
+
tokensInput += (u.prompt_tokens ?? u.promptTokens) || 0;
|
|
201
|
+
tokensOutput += (u.completion_tokens ?? u.completionTokens) || 0;
|
|
202
|
+
};
|
|
168
203
|
// Tracks whether a successful agreement creation has already happened in
|
|
169
204
|
// this turn. Used by annotateToolCallDedup to reject any second creation
|
|
170
205
|
// attempt across iterations of the tool loop.
|
|
@@ -265,7 +300,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
|
|
|
265
300
|
try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
|
|
266
301
|
const decision = { action: matchingAction, tool: toolName, args };
|
|
267
302
|
|
|
268
|
-
const result = await executeDecision(decision, actionDef, effWithTrace, sessionId, memory);
|
|
303
|
+
const result = await executeDecision(decision, actionDef, effWithTrace, sessionId, memory, null, addToolUsage);
|
|
269
304
|
toolCallResults.push({ tc, matchingAction, result, args, actionDef });
|
|
270
305
|
}
|
|
271
306
|
|
|
@@ -349,7 +384,19 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
|
|
|
349
384
|
try {
|
|
350
385
|
const streamResult = await eff({ kind: 'stream-text', sessionId, receiverId: decision.receiverId, messageId: streamMessageId, messages: streamMessages });
|
|
351
386
|
replyText = streamResult.text ?? '';
|
|
352
|
-
|
|
387
|
+
// Count the streamed reply only when it actually produced the text we
|
|
388
|
+
// send (an empty resolve falls through to the llm-call fallback below,
|
|
389
|
+
// which counts itself). The streamed reply is a real billed completion
|
|
390
|
+
// (call 2 of ZIG-471 2-call streaming) — count it or the largest single
|
|
391
|
+
// output of the turn leaks from every usage/cost sink (ZIG-813).
|
|
392
|
+
if (replyText) {
|
|
393
|
+
llmCallsTotal++;
|
|
394
|
+
const su = streamResult.usage;
|
|
395
|
+
tokensTotal += (su?.total_tokens ?? su?.totalTokens) || 0;
|
|
396
|
+
tokensInput += (su?.prompt_tokens ?? su?.promptTokens) || 0;
|
|
397
|
+
tokensOutput += (su?.completion_tokens ?? su?.completionTokens) || 0;
|
|
398
|
+
runtimeLog.info('runTurn', `stream-text done chars=${replyText.length} tokens=${(su?.total_tokens ?? su?.totalTokens) || 0}`);
|
|
399
|
+
}
|
|
353
400
|
} catch (err) {
|
|
354
401
|
// Streaming transport failed before the reply text existed (call 2
|
|
355
402
|
// runs inside the stream-text effect). Fall back to a plain
|
|
@@ -385,7 +432,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
|
|
|
385
432
|
|
|
386
433
|
let result: EmittedEvent[] | EmittedEvent | null;
|
|
387
434
|
try {
|
|
388
|
-
result = await executeDecision(decision, actionDef, eff, sessionId, memory);
|
|
435
|
+
result = await executeDecision(decision, actionDef, eff, sessionId, memory, null, addToolUsage);
|
|
389
436
|
} catch (err) {
|
|
390
437
|
// Only outbound sends can throw out of executeDecision — executeTool
|
|
391
438
|
// traps its own errors into tool_error events. A throw here means the
|
|
@@ -613,17 +660,18 @@ async function executeDecision(
|
|
|
613
660
|
sessionId: string,
|
|
614
661
|
memory?: MemoryStore,
|
|
615
662
|
incomingEvent: RawEvent | null = null,
|
|
663
|
+
onUsage?: (usage: LlmResponse['usage']) => void,
|
|
616
664
|
): Promise<EmittedEvent[] | EmittedEvent | null> {
|
|
617
665
|
const boundTool = actionDef?.tool || null;
|
|
618
666
|
|
|
619
667
|
if (boundTool) {
|
|
620
668
|
decision.tool = boundTool;
|
|
621
|
-
return await executeTool(decision, eff, sessionId, memory, incomingEvent);
|
|
669
|
+
return await executeTool(decision, eff, sessionId, memory, incomingEvent, onUsage);
|
|
622
670
|
}
|
|
623
671
|
if (decision.action === '_genericDiscoveryTool' && decision.tool) {
|
|
624
|
-
return await executeTool(decision, eff, sessionId, memory, incomingEvent);
|
|
672
|
+
return await executeTool(decision, eff, sessionId, memory, incomingEvent, onUsage);
|
|
625
673
|
}
|
|
626
|
-
if (decision.tool) return await executeTool(decision, eff, sessionId, memory, incomingEvent);
|
|
674
|
+
if (decision.tool) return await executeTool(decision, eff, sessionId, memory, incomingEvent, onUsage);
|
|
627
675
|
if (decision.messages && Array.isArray(decision.messages) && decision.messages.length > 0) {
|
|
628
676
|
return await executeSendMessages(decision, eff, sessionId);
|
|
629
677
|
}
|
|
@@ -722,6 +770,7 @@ async function executeTool(
|
|
|
722
770
|
sessionId: string,
|
|
723
771
|
memory?: MemoryStore,
|
|
724
772
|
incomingEvent: RawEvent | null = null,
|
|
773
|
+
onUsage?: (usage: LlmResponse['usage']) => void,
|
|
725
774
|
): Promise<EmittedEvent[] | EmittedEvent | null> {
|
|
726
775
|
const { tool, args } = decision;
|
|
727
776
|
if (!tool) return null;
|
|
@@ -734,7 +783,15 @@ async function executeTool(
|
|
|
734
783
|
memory,
|
|
735
784
|
senderId: incomingEvent?.senderId != null ? String(incomingEvent.senderId) : undefined,
|
|
736
785
|
senderType: incomingEvent?.senderType != null ? String(incomingEvent.senderType) : undefined,
|
|
786
|
+
// ZIG-831: the subcontract agreement this wake is under (task events carry
|
|
787
|
+
// it under `result.agreementId`) so a delegated per-user MCP call can
|
|
788
|
+
// resolve the originating end-user, not the delegating agent (senderId).
|
|
789
|
+
onBehalfOfAgreementId: (() => {
|
|
790
|
+
const aid = (incomingEvent?.result as { agreementId?: unknown } | undefined)?.agreementId;
|
|
791
|
+
return typeof aid === 'string' && aid ? aid : undefined;
|
|
792
|
+
})(),
|
|
737
793
|
}) as ToolCallResult;
|
|
794
|
+
if (r.usage) onUsage?.(r.usage);
|
|
738
795
|
if (!r.ok) return { type: 'tool_error', tool, error: r.error || 'unknown', senderId: 'system' };
|
|
739
796
|
// Tools may emit terminal markers (e.g. `message_sent`, `waited`) via
|
|
740
797
|
// `events` — propagate so the loop can detect terminal turns.
|
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
import type { Ctx, OutcomeKind, Workflow, ThinkingState, ParkedState } from '../types.js';
|
|
1
|
+
import type { Ctx, OutcomeKind, Workflow, ThinkingState, ParkedState, MechanicalState } from '../types.js';
|
|
2
|
+
import { bootstrapCtx } from './bootstrapCtx.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
activeAgreementId: null,
|
|
6
|
-
activeTaskId: null,
|
|
7
|
-
pendingProposalAgreementId: null,
|
|
8
|
-
delegatedAgreementIds: [],
|
|
9
|
-
pendingSubtasks: {},
|
|
10
|
-
collectedResults: [],
|
|
11
|
-
toolResults: [],
|
|
12
|
-
lastOutcome: { kind: 'enter' },
|
|
13
|
-
};
|
|
4
|
+
/** Static probe ctx for evaluating transition `when` guards during validation. */
|
|
5
|
+
export const PROBE_CTX: Ctx = bootstrapCtx({ agentId: '', sessionId: '', laneKey: '' });
|
|
14
6
|
|
|
15
7
|
// 'state-timeout' is intentionally absent: it is a runtime-generated liveness
|
|
16
8
|
// outcome, never something an action's `produces` may declare.
|
|
@@ -74,8 +66,8 @@ export function validateWorkflow(workflow: Workflow): ValidationResult {
|
|
|
74
66
|
errors.push(`state "${name}" is not an object`);
|
|
75
67
|
continue;
|
|
76
68
|
}
|
|
77
|
-
if (state.kind !== 'parked' && state.kind !== 'thinking') {
|
|
78
|
-
errors.push(`state "${name}" must declare kind: 'parked' | 'thinking'`);
|
|
69
|
+
if (state.kind !== 'parked' && state.kind !== 'thinking' && state.kind !== 'mechanical') {
|
|
70
|
+
errors.push(`state "${name}" must declare kind: 'parked' | 'thinking' | 'mechanical'`);
|
|
79
71
|
continue;
|
|
80
72
|
}
|
|
81
73
|
|
|
@@ -111,6 +103,33 @@ export function validateWorkflow(workflow: Workflow): ValidationResult {
|
|
|
111
103
|
errors.push(`state "${name}" is thinking but declares timeoutMs — liveness timers apply to parked states only (thinking turns are bounded by AgentHost tickTimeoutMs)`);
|
|
112
104
|
}
|
|
113
105
|
}
|
|
106
|
+
if (state.kind === 'mechanical') {
|
|
107
|
+
const mech = state as MechanicalState;
|
|
108
|
+
if (!mech.tool || typeof mech.tool !== 'string') {
|
|
109
|
+
errors.push(`mechanical state "${name}" requires a tool name`);
|
|
110
|
+
}
|
|
111
|
+
if (mech.buildArgs !== undefined && typeof mech.buildArgs !== 'function') {
|
|
112
|
+
errors.push(`mechanical state "${name}" buildArgs must be a function`);
|
|
113
|
+
}
|
|
114
|
+
if ((mech as unknown as ThinkingState).prompt) {
|
|
115
|
+
errors.push(`state "${name}" is mechanical but has a prompt — use kind: 'thinking' instead`);
|
|
116
|
+
}
|
|
117
|
+
if ((mech as unknown as ThinkingState).actions) {
|
|
118
|
+
errors.push(`state "${name}" is mechanical but has actions — use kind: 'thinking' instead`);
|
|
119
|
+
}
|
|
120
|
+
const p = mech.produces;
|
|
121
|
+
if (p === 'state-timeout') {
|
|
122
|
+
errors.push(`mechanical state "${name}" produces "state-timeout" — reserved for runtime liveness timers`);
|
|
123
|
+
} else if (typeof p === 'string') {
|
|
124
|
+
if (!KNOWN_OUTCOME_KINDS.has(p as OutcomeKind)) {
|
|
125
|
+
errors.push(
|
|
126
|
+
`mechanical state "${name}" produces unknown outcome kind "${p}" — use one of: ${[...KNOWN_OUTCOME_KINDS].join(', ')}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
} else if (typeof p !== 'function') {
|
|
130
|
+
errors.push(`mechanical state "${name}" requires produces (OutcomeKind or fn)`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
114
133
|
|
|
115
134
|
if (!Array.isArray(state.transitions)) {
|
|
116
135
|
errors.push(`state "${name}" requires a transitions array`);
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
ContextDiscoveryClient,
|
|
4
4
|
InboxClient,
|
|
5
5
|
getChatsForAgreement,
|
|
6
|
+
getAgreementStatus,
|
|
6
7
|
type InboxScopeEntry,
|
|
7
8
|
type InboxAck,
|
|
8
9
|
type Creds,
|
|
@@ -40,6 +41,22 @@ function warn(label: string, msg: string): void {
|
|
|
40
41
|
runtimeLog.warn(label, msg);
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
const TERMINAL_AGREEMENT_STATUSES = new Set(['fulfilled', 'cancelled', 'refunded']);
|
|
45
|
+
|
|
46
|
+
/** ZIG-832 P4: is this agreement finished (so its backlog must not be re-run)? */
|
|
47
|
+
async function isTerminalAgreement(agreementId: string, creds: Creds): Promise<boolean> {
|
|
48
|
+
try {
|
|
49
|
+
const res = (await getAgreementStatus(agreementId, creds)) as
|
|
50
|
+
| { status?: string; agreement?: { status?: string } }
|
|
51
|
+
| undefined;
|
|
52
|
+
const status = res?.status ?? res?.agreement?.status;
|
|
53
|
+
return status != null && TERMINAL_AGREEMENT_STATUSES.has(status);
|
|
54
|
+
} catch {
|
|
55
|
+
// On lookup failure, don't skip — fall through to normal delivery.
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
/** Map Mongo message row → wire metadata for normalizeIncomingEvent. */
|
|
44
61
|
function rowToMetadata(row: WireRow, chatId: string): Record<string, unknown> {
|
|
45
62
|
const senderRaw = row.sender as { agentId?: string; id?: string; type?: string } | undefined;
|
|
@@ -182,6 +199,26 @@ export async function runInboxCatchUp(deps: InboxCatchUpDeps): Promise<InboxCatc
|
|
|
182
199
|
}
|
|
183
200
|
|
|
184
201
|
for (const scope of envelope.scopes) {
|
|
202
|
+
// ZIG-832 P4: a re-host runs catch-up from the server watermark and would
|
|
203
|
+
// re-deliver an already-finished agreement's backlog, re-running a completed
|
|
204
|
+
// pipeline and burning tokens. If this is an agreement scope that has reached
|
|
205
|
+
// a terminal status, ack it (so it drops out of future catch-ups) and skip
|
|
206
|
+
// re-delivery. Only agreement scopes — chat scopes may carry genuinely new
|
|
207
|
+
// conversation, so those are never skipped on status. (The deeper fix, acking
|
|
208
|
+
// the watermark on the live-push path so nothing re-delivers, is tracked
|
|
209
|
+
// separately.)
|
|
210
|
+
if (
|
|
211
|
+
scope.scope.kind === 'agreement' &&
|
|
212
|
+
(scope.newMessages > 0 || scope.newArtifacts > 0) &&
|
|
213
|
+
(await isTerminalAgreement(scope.scope.id, deps.creds))
|
|
214
|
+
) {
|
|
215
|
+
if (scope.latestAt) {
|
|
216
|
+
toAck.push({ kind: scope.scope.kind, id: scope.scope.id, upTo: scope.latestAt });
|
|
217
|
+
}
|
|
218
|
+
log(label, `scope agreement:${scope.scope.id} terminal — acked, not re-delivered`);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
185
222
|
const chatIds = await chatIdsForScope(scope, deps.creds, deps.baseUrl, discovery);
|
|
186
223
|
if (!chatIds.length && (scope.newMessages > 0 || scope.newArtifacts > 0)) {
|
|
187
224
|
warn(
|