@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.
@@ -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
+ }
@@ -68,7 +68,14 @@ interface RunTurnInput {
68
68
  */
69
69
  memory?: MemoryStore;
70
70
  definition: { description?: string };
71
- /** When true, respond actions use 2-call streaming: Call 1 picks action+receiverId, Call 2 streams the text (ZIG-471). */
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
- const streamRespondActions = input.streamRespondActions ?? (process.env.ENABLE_LLM_STREAMING === 'true');
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
- runtimeLog.info('runTurn', `stream-text done chars=${replyText.length}`);
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
- export const PROBE_CTX: Ctx = {
4
- identity: { agentId: '', sessionId: '', laneKey: '' },
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(