@ziggs-ai/agent-sdk 0.1.4 → 0.1.5

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.
Files changed (47) hide show
  1. package/README.md +3 -1
  2. package/package.json +2 -2
  3. package/src/AgentHost.ts +165 -12
  4. package/src/adapters/OpenAIAdapter.ts +21 -0
  5. package/src/agent/Agent.ts +5 -2
  6. package/src/cognition/validateContext.ts +1 -1
  7. package/src/context/batch.ts +3 -3
  8. package/src/context/classifyEnvelope.ts +2 -2
  9. package/src/context/routingLabels.ts +1 -1
  10. package/src/formatters/AgreementFormatter.ts +5 -5
  11. package/src/formatters/HistoryFormatter.ts +3 -3
  12. package/src/index.ts +23 -4
  13. package/src/ingress/normalizeIncoming.ts +50 -7
  14. package/src/pricing/fleetDefaults.ts +218 -0
  15. package/src/pricing/fleetEvalFree.ts +24 -0
  16. package/src/pricing/fleetFreeTierA.gen.ts +12 -0
  17. package/src/pricing/fleetTierByAgentId.gen.ts +1022 -0
  18. package/src/runtime/AgentMachine.ts +68 -2
  19. package/src/runtime/PromptBuilder.ts +25 -23
  20. package/src/runtime/buildOutcome.ts +33 -3
  21. package/src/runtime/defaults.ts +3 -0
  22. package/src/runtime/runTurn.ts +115 -61
  23. package/src/runtime/validateWorkflow.ts +16 -0
  24. package/src/server/EventQueue.ts +14 -0
  25. package/src/server/InboxCatchUp.ts +251 -0
  26. package/src/server/SeenMessages.ts +27 -0
  27. package/src/server/ZiggsEffectHandler.ts +82 -8
  28. package/src/server/agreements/AgreementService.ts +7 -1
  29. package/src/server/createHealthServer.ts +79 -2
  30. package/src/server/runLauncher.ts +40 -25
  31. package/src/server/tasks/TaskService.ts +4 -5
  32. package/src/server/tasks/index.ts +0 -3
  33. package/src/server/telemetryIngest.ts +91 -0
  34. package/src/server/tools/index.ts +46 -0
  35. package/src/server/{tasks → tools/tier1}/protocolRunner.ts +52 -20
  36. package/src/server/{tasks → tools/tier1}/protocolTools.ts +6 -3
  37. package/src/server/tools/tier2/connectionTools.ts +75 -0
  38. package/src/server/tools/tier2/contextTools.ts +74 -0
  39. package/src/server/tools/tier2/discoveryTools.ts +34 -0
  40. package/src/server/tools/tier2/marketplaceTools.ts +25 -0
  41. package/src/server/{tasks → tools/tier2}/paymentTools.ts +74 -37
  42. package/src/server/ziggsconnect/ZiggsConnectClient.ts +126 -0
  43. package/src/server/ziggscontext/ZiggsContextClient.ts +137 -0
  44. package/src/server/ziggspay/ZiggsPayClient.ts +12 -12
  45. package/src/shared/types.ts +0 -2
  46. package/src/types.ts +47 -8
  47. package/src/tasks/taskCore.ts +0 -139
@@ -12,6 +12,12 @@ export interface MachineSnapshot {
12
12
  status: 'active' | 'stopped';
13
13
  }
14
14
 
15
+ export type TransitionEvent =
16
+ | { kind: 'transition'; fromState: string; toState: string; outcomeKind: string; depth: number }
17
+ | { kind: 'no-match'; state: string; outcomeKind: string; depth: number }
18
+ | { kind: 'depth-guard'; state: string; depth: number }
19
+ | { kind: 'error-escape'; fromState: string; toState: string };
20
+
15
21
  interface MachineInput {
16
22
  identity: Identity;
17
23
  /** Server-provided ctx for an existing session. If omitted, a fresh ctx is built. */
@@ -31,6 +37,8 @@ interface MachineOptions {
31
37
  /** Agent-private long-term memory. Operator-provided. */
32
38
  memory?: MemoryStore;
33
39
  input: MachineInput;
40
+ /** Optional tap for FSM transition events — called synchronously on each transition. */
41
+ transitionTap?: (event: TransitionEvent) => void;
34
42
  }
35
43
 
36
44
  interface IncomingFrame {
@@ -69,17 +77,20 @@ export class AgentMachine {
69
77
  _draining = false;
70
78
  _llmCalls = 0;
71
79
  _tokens = { total: 0, input: 0, output: 0 };
80
+ _lastUserSenderId: string | null = null;
81
+ _transitionTap: ((event: TransitionEvent) => void) | undefined;
72
82
 
73
83
  get llmCalls(): number { return this._llmCalls; }
74
84
  get tokens(): { total: number; input: number; output: number } { return this._tokens; }
75
85
 
76
- constructor({ workflow, executionCore, eff, toolManager, promptBuilder, memory, input }: MachineOptions) {
86
+ constructor({ workflow, executionCore, eff, toolManager, promptBuilder, memory, input, transitionTap }: MachineOptions) {
77
87
  this.workflow = workflow;
78
88
  this.executionCore = executionCore;
79
89
  this.eff = eff;
80
90
  this.toolManager = toolManager;
81
91
  this.promptBuilder = promptBuilder;
82
92
  this.memory = memory;
93
+ this._transitionTap = transitionTap;
83
94
  this.state = input.state || workflow.initial;
84
95
  this.ctx = input.ctx
85
96
  ? { ...input.ctx, identity: input.identity }
@@ -143,6 +154,9 @@ export class AgentMachine {
143
154
 
144
155
  const ev = frame.event;
145
156
  this._lastIncomingEvent = ev ?? null;
157
+ if (ev?.senderId && String(ev?.senderType ?? '').toUpperCase() === 'USER') {
158
+ this._lastUserSenderId = String(ev.senderId);
159
+ }
146
160
  runtimeLog.debug(
147
161
  'FSM',
148
162
  `_handleFrame ev.type=${ev?.type} ev.senderId=${ev?.senderId} ev.receiverId=${ev?.receiverId} myId=${this.ctx.identity?.agentId}`,
@@ -180,6 +194,7 @@ export class AgentMachine {
180
194
  `_step from=${fromState} outcome.kind=${o?.kind} senderId=${o?.senderId ?? '<none>'} myId=${this.ctx.identity?.agentId ?? '<none>'} → target=${target ?? '(stay)'} depth=${depth}`,
181
195
  );
182
196
  if (target && target !== this.state) {
197
+ this._transitionTap?.({ kind: 'transition', fromState, toState: target, outcomeKind: (outcome as Record<string, unknown>).kind as string, depth });
183
198
  this.state = target;
184
199
  this._notify();
185
200
  }
@@ -193,6 +208,26 @@ export class AgentMachine {
193
208
  return;
194
209
  }
195
210
 
211
+ // A thinking turn that yields an outcome no transition handles must not
212
+ // silently re-enter the same state and burn another LLM call (ZIG-303).
213
+ // Explicit self-loops still match (target is non-null, even if target === state).
214
+ if (
215
+ sd.kind === 'thinking' &&
216
+ depth > 0 &&
217
+ fromState === this.state &&
218
+ !target
219
+ ) {
220
+ if (this._escapeToInitialParked(outcome, fromState)) return;
221
+ runtimeLog.warn(
222
+ 'AgentMachine',
223
+ `state=${fromState} outcome.kind=${(outcome as Record<string, unknown>).kind} matched no transition; parking (depth=${depth})`,
224
+ );
225
+ this._transitionTap?.({ kind: 'no-match', state: fromState, outcomeKind: (outcome as Record<string, unknown>).kind as string, depth });
226
+ await this._sendFallbackIfUserWaiting('Something went wrong. Please try again.');
227
+ this._notify();
228
+ return;
229
+ }
230
+
196
231
  // Recursion / runaway-turn guard. Without this, a thinking state whose
197
232
  // transitions don't match the turn outcome (e.g. `delegating` not
198
233
  // catching `tool-result` from an agent_network search) re-enters the
@@ -207,6 +242,8 @@ export class AgentMachine {
207
242
  'AgentMachine',
208
243
  `state=${this.state} runaway turn loop (depth=${depth}); parking`,
209
244
  );
245
+ this._transitionTap?.({ kind: 'depth-guard', state: this.state, depth });
246
+ await this._sendFallbackIfUserWaiting('Something went wrong. Please try again.');
210
247
  this._notify();
211
248
  return;
212
249
  }
@@ -219,7 +256,7 @@ export class AgentMachine {
219
256
  stateId: this.state,
220
257
  statePrompt: sd.prompt,
221
258
  actions: sd.actions,
222
- incomingEvent: this.ctx.lastOutcome ? this._lastIncomingEvent : null,
259
+ incomingEvent: this._lastIncomingEvent,
223
260
  ctx: this.ctx,
224
261
  eff: this.eff,
225
262
  toolManager: this.toolManager,
@@ -255,10 +292,39 @@ export class AgentMachine {
255
292
  if (fallback && fallback !== this.state) {
256
293
  this.state = fallback;
257
294
  this._notify();
295
+ } else {
296
+ await this._sendFallbackIfUserWaiting('Something went wrong. Please try again.');
258
297
  }
259
298
  }
260
299
  }
261
300
 
301
+ /** Error with no author-defined escape: park at workflow.initial if it is parked. */
302
+ _escapeToInitialParked(outcome: Outcome, fromState: string): boolean {
303
+ if (outcome.kind !== 'error') return false;
304
+ const initial = this.workflow.initial;
305
+ const initialSd = this.workflow.states[initial];
306
+ if (!initialSd || initialSd.kind !== 'parked' || initial === fromState) {
307
+ return false;
308
+ }
309
+ runtimeLog.warn(
310
+ 'AgentMachine',
311
+ `state=${fromState} error outcome had no transition — escaped to parked initial "${initial}"`,
312
+ );
313
+ this._transitionTap?.({ kind: 'error-escape', fromState, toState: initial });
314
+ this.state = initial;
315
+ this._notify();
316
+ return true;
317
+ }
318
+
319
+ /** Sends a short fallback to the last known human user when the machine parks unexpectedly. Best-effort — never throws. */
320
+ async _sendFallbackIfUserWaiting(text: string): Promise<void> {
321
+ const userId = this._lastUserSenderId;
322
+ if (!userId) return;
323
+ try {
324
+ await this.eff({ kind: 'send-message', sessionId: this.ctx.identity.sessionId, receiverId: userId, text });
325
+ } catch { /* best-effort */ }
326
+ }
327
+
262
328
  _evaluateTransitions(outcome: Outcome): string | null {
263
329
  const sd = this.workflow.states[this.state];
264
330
  if (!sd?.transitions?.length) {
@@ -33,6 +33,8 @@ interface BuildArgs {
33
33
  chatId: string | null;
34
34
  stateId: string;
35
35
  ctx: Ctx;
36
+ /** When true, respond-action schemas omit the "message" field — used for 2-call streaming (ZIG-471). */
37
+ omitMessageFromRespond?: boolean;
36
38
  }
37
39
 
38
40
  interface HistoryFormatterI { format(history: unknown[] | null | undefined, agentId: string, options?: AnyObj): string; }
@@ -74,7 +76,7 @@ export class PromptBuilder {
74
76
  }
75
77
 
76
78
  buildFromState(args: BuildArgs): string {
77
- const { statePrompt, actions, serverContext, incomingEvent, definition, stateId, ctx } = args;
79
+ const { statePrompt, actions, serverContext, incomingEvent, definition, stateId, ctx, omitMessageFromRespond } = args;
78
80
  const ctxObj = serverContext?.context || {};
79
81
  validateContext(ctxObj, { logger: this.logger as { warn: (msg: string) => void } | undefined });
80
82
 
@@ -91,7 +93,7 @@ ${this._renderSelfFromState(desc, statePrompt, serverContext?.tools)}
91
93
 
92
94
  ${this._renderWorld(incomingEvent, ctxObj, agentId, senderMeta, agreements, otherAgents, users, stateId, ctx)}
93
95
 
94
- ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj)}
96
+ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessageFromRespond)}
95
97
 
96
98
  </agent>`;
97
99
  }
@@ -218,11 +220,11 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj)}
218
220
 
219
221
  if (ag?.agreementId) {
220
222
  lines.push(`Agreement: ${ag.agreementId}${creatorIsYou ? ' (yours)' : ''}`);
221
- lines.push(` Creator: ${parties.creatorId ?? 'N/A'}${creatorIsYou ? ' (You)' : ''}`);
222
- lines.push(` Provider: ${parties.providerId ?? 'N/A'}${providerIsYou ? ' (You)' : ''}`);
223
+ lines.push(` Creator: ${parties.creator ?? 'N/A'}${creatorIsYou ? ' (You)' : ''}`);
224
+ lines.push(` Provider: ${parties.provider ?? 'N/A'}${providerIsYou ? ' (You)' : ''}`);
223
225
  const proposal = ag.proposal as AnyObj | undefined;
224
226
  if (proposal?.status) {
225
- lines.push(` Proposal: ${proposal.status} (to ${parties.proposedToId}${task.proposedToIsYou ? ' - You' : ''})`);
227
+ lines.push(` Proposal: ${proposal.status} (to ${parties.proposedTo}${task.proposedToIsYou ? ' - You' : ''})`);
226
228
  }
227
229
  const money = ag.money as AnyObj | undefined;
228
230
  if (typeof money?.price === 'number' && (money.price as number) > 0) {
@@ -299,10 +301,10 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj)}
299
301
  const agId = ag?.agreementId as string | undefined;
300
302
  const parties = (ag?.parties as AnyObj) || {};
301
303
  const roles: Array<[string, string]> = [
302
- ['creatorId', 'creator'],
303
- ['providerId', 'provider'],
304
- ['payerId', 'payer'],
305
- ['proposedToId', 'proposedTo'],
304
+ ['creator', 'creator'],
305
+ ['provider', 'provider'],
306
+ ['payer', 'payer'],
307
+ ['proposedTo', 'proposedTo'],
306
308
  ];
307
309
  for (const [field, role] of roles) {
308
310
  const pid = parties[field] as string | undefined;
@@ -360,7 +362,7 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj)}
360
362
  return lines.join('\n');
361
363
  }
362
364
 
363
- _renderDecisionSchemaFromActions(actions: Record<string, Action>, senderMeta: SenderMeta, ctxObj: ServerContext): string {
365
+ _renderDecisionSchemaFromActions(actions: Record<string, Action>, senderMeta: SenderMeta, ctxObj: ServerContext, omitMessage?: boolean): string {
364
366
  const lines: string[] = ['<decision_schema>'];
365
367
  lines.push('Return ONE JSON object. ALWAYS include "thought" as the FIRST field — briefly explain your reasoning before choosing an action.');
366
368
  lines.push('Set "action" to exactly one of the action names below.');
@@ -368,7 +370,7 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj)}
368
370
 
369
371
  const replyTo = (senderMeta.defaultReplyReceiverId as string) || '<receiverId>';
370
372
  const pendingProposals = (ctxObj.agreements || []).filter(
371
- (a) => (a.proposal as Record<string, unknown> | undefined)?.status === 'pending' && (a.proposedToIsYou || (a.parties as Record<string, unknown> | undefined)?.proposedToId === 'everyone'),
373
+ (a) => (a.proposal as Record<string, unknown> | undefined)?.status === 'pending' && (a.proposedToIsYou || (a.parties as Record<string, unknown> | undefined)?.proposedTo === 'everyone'),
372
374
  );
373
375
 
374
376
  if (pendingProposals.length > 0) {
@@ -387,7 +389,7 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj)}
387
389
  lines.push(`Tool: ${boundTool}`);
388
390
  lines.push(`Response: {"thought": "...", "action": "${name}", "args": {...}}`);
389
391
  } else {
390
- lines.push(this._buildActionSchema(name, replyTo));
392
+ lines.push(this._buildActionSchema(name, replyTo, omitMessage));
391
393
  }
392
394
 
393
395
  if (actionDef.prompt?.examples?.length) {
@@ -406,22 +408,22 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj)}
406
408
  return lines.join('\n');
407
409
  }
408
410
 
409
- _buildActionSchema(name: string, replyTo: string): string {
411
+ _buildActionSchema(name: string, replyTo: string, omitMessage?: boolean): string {
410
412
  if (name === 'wait') {
411
413
  return `Response: {"thought": "...", "action": "${name}"}`;
412
414
  }
413
- // Never pre-fill a system-owned id (ziggs-system, anything ending with
414
- // `-system`, or the literal 'system'/'service' bucket) as the default
415
- // receiverId — that just nudges the LLM to address chat replies to a
416
- // non-routable destination. Fall back to an explicit placeholder so the
417
- // model has to pick a real recipient from <recipients>.
415
+ // Never pre-fill a non-routable id (the literal 'system' or 'service'
416
+ // buckets) as the default receiverId. Fall back to an explicit placeholder
417
+ // so the model picks a real recipient from <recipients>.
418
418
  const isSystemReplyTo =
419
419
  !replyTo ||
420
420
  replyTo === 'system' ||
421
421
  replyTo === 'service' ||
422
- replyTo.endsWith('-system') ||
423
422
  replyTo.startsWith('<');
424
423
  const renderedReplyTo = isSystemReplyTo ? '<receiverId from <recipients>>' : replyTo;
424
+ if (omitMessage) {
425
+ return `Response: {"thought": "...", "action": "${name}", "receiverId": "${renderedReplyTo}"}`;
426
+ }
425
427
  return `Response: {"thought": "...", "action": "${name}", "receiverId": "${renderedReplyTo}", "message": "..."}`;
426
428
  }
427
429
 
@@ -431,11 +433,11 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj)}
431
433
  const senderType = ev?.senderType ? String(ev.senderType).toUpperCase() : 'SYSTEM';
432
434
  const myId = this._getMyAgentId(ctxObj);
433
435
 
434
- const isSystemBucket = (id: string): boolean =>
435
- !id || id === 'system' || id === 'service' || id.endsWith('-system');
436
+ const isNonRoutableSenderType = (t: string): boolean =>
437
+ t === 'SYSTEM' || t === 'SERVICE';
436
438
 
437
439
  let defaultReplyReceiverId = senderId;
438
- const isNonRoutable = isSystemBucket(senderId) || senderType === 'SERVICE';
440
+ const isNonRoutable = isNonRoutableSenderType(senderType);
439
441
  if (isNonRoutable) {
440
442
  const history = ctxObj.history || [];
441
443
  for (let i = history.length - 1; i >= 0; i--) {
@@ -443,7 +445,7 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj)}
443
445
  const sender = row.sender as Record<string, unknown> | undefined;
444
446
  const id = String(sender?.id || row.senderId || '');
445
447
  const type = String(sender?.type || row.senderType || '').toUpperCase();
446
- if (id && !isSystemBucket(id) && id !== myId && type !== 'SERVICE') {
448
+ if (id && id !== myId && !isNonRoutableSenderType(type)) {
447
449
  defaultReplyReceiverId = id;
448
450
  break;
449
451
  }
@@ -83,6 +83,25 @@ export function outcomeFromEvent(
83
83
  }
84
84
 
85
85
  function singleEventToOutcome(ev: RawEvent, ownAgentId: string | null): Outcome | null {
86
+ if (ev.type === 'agreement_lifecycle') {
87
+ const operation = ev.operation as string | undefined;
88
+ const agreementId = (ev.agreementId as string | undefined) ?? '';
89
+ const action =
90
+ operation === 'proposal_rejected'
91
+ ? 'reject'
92
+ : operation === 'proposal_approved' || operation === 'proposal_approved_execute'
93
+ ? 'approve'
94
+ : null;
95
+ if (action && agreementId) {
96
+ return {
97
+ kind: 'proposal-resolved',
98
+ action,
99
+ proposal: { agreementId },
100
+ };
101
+ }
102
+ return null;
103
+ }
104
+
86
105
  if (ev.type === 'task_result') {
87
106
  const result = ev.result || {};
88
107
  if (
@@ -120,7 +139,7 @@ function singleEventToOutcome(ev: RawEvent, ownAgentId: string | null): Outcome
120
139
 
121
140
  // Direct assignment to me (provider) — task spawned, citing an active agreement.
122
141
  const isAssignedToMe =
123
- (result.providerIsYou || parties.providerId === ownAgentId) &&
142
+ (result.providerIsYou || parties.provider === ownAgentId) &&
124
143
  (state === 'active' || state === 'in-progress');
125
144
  if (isAssignedToMe) {
126
145
  return { kind: 'task-assigned', task };
@@ -193,7 +212,8 @@ function singleEventToOutcome(ev: RawEvent, ownAgentId: string | null): Outcome
193
212
  if (ownAgentId && ev.senderId && ev.senderId === ownAgentId) return null;
194
213
 
195
214
  const text = typeof ev.text === 'string' ? ev.text : undefined;
196
- return { kind: 'message-received', text, senderId: ev.senderId };
215
+ const senderType = typeof ev.senderType === 'string' ? ev.senderType : undefined;
216
+ return { kind: 'message-received', text, senderId: ev.senderId, senderType };
197
217
  }
198
218
 
199
219
  // Higher rank wins when collapsing batched events into one outcome.
@@ -466,7 +486,7 @@ export function applyOutcomeToCtx(ctx: Ctx, outcome: Outcome): void {
466
486
  // activeAgreementId so transitions gated on activeAgreementId fire.
467
487
  // We only do this when no agreement is currently active, so that
468
488
  // approvals of CHILD sub-agreements (e.g. coffee-agent approving the
469
- // Ziggs-issued subcontract) don't overwrite the root the orchestrator
489
+ // Ziggs-issued subcontract) don't overwrite the root the primary agent
470
490
  // is bound to. Sub-agreement bookkeeping lives in delegatedAgreementIds
471
491
  // (recorded on task-delegated) — not here.
472
492
  if (outcome.action === 'approve' && !ctx.activeAgreementId) {
@@ -476,10 +496,20 @@ export function applyOutcomeToCtx(ctx: Ctx, outcome: Outcome): void {
476
496
  ctx.pendingProposalAgreementId = null;
477
497
  return;
478
498
  }
499
+ case 'subtask-finished': {
500
+ const t = outcome.task;
501
+ const taskId = t?.taskId;
502
+ const agId = t?.agreementId || t?.agreement?.agreementId;
503
+ if (taskId) ctx.delegatedTaskIds = ctx.delegatedTaskIds.filter(id => id !== taskId);
504
+ if (agId) ctx.delegatedAgreementIds = ctx.delegatedAgreementIds.filter(id => id !== agId);
505
+ return;
506
+ }
479
507
  case 'task-closed': {
480
508
  ctx.activeAgreementId = null;
481
509
  ctx.activeTaskId = null;
482
510
  ctx.pendingProposalAgreementId = null;
511
+ ctx.delegatedTaskIds = [];
512
+ ctx.delegatedAgreementIds = [];
483
513
  return;
484
514
  }
485
515
  default:
@@ -40,6 +40,9 @@ export function thinkingDefaults(opts: { initial: string }): ThinkingDefaults {
40
40
  transitions: [
41
41
  { to: initial, when: o => o.kind === 'wait' },
42
42
  { to: initial, when: o => o.kind === 'message-sent' },
43
+ // Errors (empty/failed turns) fall back to the initial state instead of
44
+ // silently re-entering and recursing until the depth guard (ZIG-303).
45
+ { to: initial, when: o => o.kind === 'error' },
43
46
  ],
44
47
  };
45
48
  }
@@ -68,6 +68,8 @@ 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). */
72
+ streamRespondActions?: boolean;
71
73
  }
72
74
 
73
75
  interface RunTurnResult {
@@ -95,7 +97,17 @@ interface RunTurnResult {
95
97
  */
96
98
  export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
97
99
  const { stateId, statePrompt, actions, incomingEvent, ctx, eff, toolManager, promptBuilder, memory, definition } = input;
100
+ const streamRespondActions = input.streamRespondActions ?? (process.env.ENABLE_LLM_STREAMING === 'true');
98
101
  const sessionId = ctx.identity.sessionId;
102
+ const runId = crypto.randomUUID();
103
+
104
+ // Wrap eff to stamp runId + stateId onto llm-call and tool-call effects.
105
+ const effWithTrace: typeof eff = (effect) => {
106
+ if (effect.kind === 'llm-call' || effect.kind === 'tool-call') {
107
+ return eff({ ...effect, runId, stateId } as typeof effect);
108
+ }
109
+ return eff(effect);
110
+ };
99
111
 
100
112
  const emitThought = async (text: unknown): Promise<void> => {
101
113
  if (text == null) return;
@@ -108,7 +120,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
108
120
  }
109
121
  };
110
122
 
111
- runtimeLog.info('runTurn', `enter state=${stateId}`);
123
+ runtimeLog.info('runTurn', `enter state=${stateId} runId=${runId}`);
112
124
 
113
125
  // Parallel: read session context + collect tool schemas (pure).
114
126
  // Filter tools to only those bound to an action in the current state — the
@@ -128,32 +140,15 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
128
140
  return n != null && allowedToolNames.has(n);
129
141
  })
130
142
  : [];
131
- const serverContext = await eff({ kind: 'read-context', sessionId, opts: {} });
143
+ const serverContext = await effWithTrace({ kind: 'read-context', sessionId, opts: {} });
132
144
  const ctxObj = normalizeContextSnapshot(serverContext);
133
145
 
134
- const probeDispatch = Object.entries(actions).find(([, a]) => a.tool === 'capability_probe_dispatch');
135
- if (probeDispatch && incomingEvent) {
136
- const [actionName, actionDef] = probeDispatch;
137
- const messageText =
138
- extractIncomingMessageText(incomingEvent, ctxObj as ContextSnapshot) ||
139
- JSON.stringify(incomingEvent);
140
- const toolResultsOut: ToolResultEntry[] = [];
141
- const emitted = await executeTool(
142
- { action: actionName, tool: 'capability_probe_dispatch', args: { messageText } },
143
- eff,
144
- sessionId,
145
- memory,
146
- );
147
- const events = (Array.isArray(emitted) ? emitted : emitted ? [emitted] : []) as EmittedEvent[];
148
- const outcome = outcomeFromActionResult(actionName, actionDef, {}, events, toolResultsOut);
149
- return { outcome, toolResults: toolResultsOut, llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } };
150
- }
151
-
152
146
  const prompt = promptBuilder.buildFromState({
153
147
  statePrompt, actions,
154
148
  serverContext: { context: ctxObj as Record<string, unknown>, tools: tools as unknown as Record<string, unknown>[] },
155
149
  incomingEvent, definition,
156
150
  chatId: sessionId, stateId, ctx,
151
+ omitMessageFromRespond: streamRespondActions,
157
152
  });
158
153
 
159
154
  const systemContent = tools.length > 0
@@ -179,7 +174,12 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
179
174
  runtimeLog.info('runTurn', `calling LLM tools=${tools.length}`);
180
175
 
181
176
  for (let iter = 0; iter < MAX_TOOL_LOOP_ITERS; iter++) {
182
- const llmResponse: LlmResponse = await eff({ kind: 'llm-call', messages, tools: tools as unknown as LlmToolSchema[] });
177
+ const llmResponse: LlmResponse = await effWithTrace({
178
+ kind: 'llm-call',
179
+ messages,
180
+ tools: tools as unknown as LlmToolSchema[],
181
+ sessionId,
182
+ });
183
183
  llmCallsTotal++;
184
184
  const usage = llmResponse?.usage;
185
185
  tokensTotal += (usage?.total_tokens ?? usage?.totalTokens) || 0;
@@ -257,7 +257,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
257
257
  try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
258
258
  const decision = { action: matchingAction, tool: toolName, args };
259
259
 
260
- const result = await executeDecision(decision, actionDef, eff, sessionId, memory);
260
+ const result = await executeDecision(decision, actionDef, effWithTrace, sessionId, memory);
261
261
  toolCallResults.push({ tc, matchingAction, result, args, actionDef });
262
262
  }
263
263
 
@@ -286,7 +286,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
286
286
  if (!turnHadAgreementCreation && eventsContainAgreementCreation(events)) {
287
287
  turnHadAgreementCreation = true;
288
288
  }
289
- if (eventsAreTerminal(events)) hasTerminal = true;
289
+ if (eventsAreTerminal(events) || actionDef?.terminatesLoop) hasTerminal = true;
290
290
  }
291
291
 
292
292
  if (hasTerminal) break;
@@ -301,6 +301,42 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
301
301
 
302
302
  if (decision.thought) await emitThought(decision.thought);
303
303
 
304
+ // 2-call streaming: if this is a respond action (has receiverId, no bound tool),
305
+ // use the streaming effect to generate text and send chunks to the client.
306
+ runtimeLog.info('runTurn', `streaming check: enabled=${streamRespondActions} receiverId=${decision.receiverId ?? 'none'} tool=${actionDef?.tool ?? 'none'} action=${chosenAction}`);
307
+ if (streamRespondActions && decision.receiverId && !actionDef?.tool && chosenAction !== 'wait') {
308
+ const streamMessageId = crypto.randomUUID();
309
+ runtimeLog.info('runTurn', `stream-text start action=${chosenAction} receiverId=${decision.receiverId} msgId=${streamMessageId}`);
310
+ const streamMessages: LlmMessage[] = [
311
+ ...messages,
312
+ {
313
+ role: 'assistant' as const,
314
+ content: JSON.stringify({ thought: decision.thought, action: decision.action, receiverId: decision.receiverId }),
315
+ },
316
+ {
317
+ role: 'user' as const,
318
+ content: 'Write your reply now. Output ONLY the message text — no JSON wrapping, no preamble.',
319
+ },
320
+ ];
321
+ let streamedText = '';
322
+ try {
323
+ const streamResult = await eff({ kind: 'stream-text', sessionId, receiverId: decision.receiverId, messageId: streamMessageId, messages: streamMessages });
324
+ streamedText = streamResult.text ?? '';
325
+ runtimeLog.info('runTurn', `stream-text done chars=${streamedText.length}`);
326
+ } catch (err) {
327
+ runtimeLog.warn('runTurn', `stream-text failed: ${(err as Error).message}`);
328
+ }
329
+ if (streamedText) {
330
+ const safeText = truncateMsg(streamedText);
331
+ await eff({ kind: 'send-message', sessionId, receiverId: decision.receiverId, text: safeText, messageId: streamMessageId });
332
+ const events: EmittedEvent[] = [{ type: 'message_sent', receiverId: decision.receiverId, chatId: sessionId, message: safeText, senderId: 'system' }];
333
+ allEmittedEvents.push(...events);
334
+ lastAction = chosenAction;
335
+ lastActionDef = actionDef;
336
+ }
337
+ break;
338
+ }
339
+
304
340
  const result = await executeDecision(decision, actionDef, eff, sessionId, memory);
305
341
  if (result) {
306
342
  const events = (Array.isArray(result) ? result : [result]) as EmittedEvent[];
@@ -340,6 +376,25 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
340
376
 
341
377
  // ─────────────────────────────────────────────────────────────────────────────
342
378
 
379
+ function extractIncomingMessageText(event: RawEvent | null): string {
380
+ if (!event) return '';
381
+ const parts: string[] = [];
382
+ const push = (v: unknown) => {
383
+ if (typeof v === 'string' && v.trim()) parts.push(v.trim());
384
+ };
385
+ push(event.text);
386
+ push((event as Record<string, unknown>).message);
387
+ const agreement = (event as Record<string, unknown>).agreement as Record<string, unknown> | undefined;
388
+ if (agreement) {
389
+ const terms = agreement.terms as Record<string, unknown> | undefined;
390
+ push(terms?.description);
391
+ push(JSON.stringify(agreement));
392
+ }
393
+ const task = (event as Record<string, unknown>).task;
394
+ if (task) push(JSON.stringify(task));
395
+ return parts.join('\n');
396
+ }
397
+
343
398
  function normalizeContextSnapshot(raw: ContextSnapshot | undefined | null): ContextSnapshot {
344
399
  if (!raw || typeof raw !== 'object') {
345
400
  return { history: [], agreements: [], agents: [], users: [] };
@@ -460,21 +515,38 @@ function findActionForTool(toolName: string, allowedActionNames: string[], actio
460
515
  return null;
461
516
  }
462
517
 
463
- async function executeDecision(decision: ParsedDecision, actionDef: Action | null, eff: EffectHandler, sessionId: string, memory?: MemoryStore): Promise<EmittedEvent[] | EmittedEvent | null> {
518
+ async function executeDecision(
519
+ decision: ParsedDecision,
520
+ actionDef: Action | null,
521
+ eff: EffectHandler,
522
+ sessionId: string,
523
+ memory?: MemoryStore,
524
+ incomingEvent: RawEvent | null = null,
525
+ ): Promise<EmittedEvent[] | EmittedEvent | null> {
464
526
  const boundTool = actionDef?.tool || null;
465
527
 
466
528
  if (boundTool) {
467
529
  decision.tool = boundTool;
468
- return await executeTool(decision, eff, sessionId, memory);
530
+ return await executeTool(decision, eff, sessionId, memory, incomingEvent);
469
531
  }
470
- if (decision.action === '_genericDiscoveryTool' && decision.tool) return await executeTool(decision, eff, sessionId, memory);
471
- if (decision.tool) return await executeTool(decision, eff, sessionId, memory);
532
+ if (decision.action === '_genericDiscoveryTool' && decision.tool) {
533
+ return await executeTool(decision, eff, sessionId, memory, incomingEvent);
534
+ }
535
+ if (decision.tool) return await executeTool(decision, eff, sessionId, memory, incomingEvent);
472
536
  if (decision.messages && Array.isArray(decision.messages) && decision.messages.length > 0) {
473
537
  return await executeSendMessages(decision, eff, sessionId);
474
538
  }
475
539
  if (decision.message && decision.receiverId) {
476
540
  return await executeSendMessage(decision, eff, sessionId);
477
541
  }
542
+ // Fallback: LLM may place message/receiverId inside args due to format ambiguity
543
+ // between prompt.format ("args: { receiverId, message }") and the top-level schema.
544
+ if (!decision.message && !decision.receiverId && decision.args) {
545
+ const a = decision.args as Record<string, unknown>;
546
+ if (typeof a.message === 'string' && typeof a.receiverId === 'string') {
547
+ return await executeSendMessage({ ...decision, message: a.message, receiverId: a.receiverId }, eff, sessionId);
548
+ }
549
+ }
478
550
  if (decision.action === 'wait') return { type: 'waited' };
479
551
  return null;
480
552
  }
@@ -522,11 +594,25 @@ async function executeSendMessages(decision: ParsedDecision, eff: EffectHandler,
522
594
  * dependencies (taskService, agreementService, agents list, etc.), and
523
595
  * wrapping the call in telemetry / operation-record entries if it wants.
524
596
  */
525
- async function executeTool(decision: ParsedDecision, eff: EffectHandler, sessionId: string, memory?: MemoryStore): Promise<EmittedEvent[] | EmittedEvent | null> {
597
+ async function executeTool(
598
+ decision: ParsedDecision,
599
+ eff: EffectHandler,
600
+ sessionId: string,
601
+ memory?: MemoryStore,
602
+ incomingEvent: RawEvent | null = null,
603
+ ): Promise<EmittedEvent[] | EmittedEvent | null> {
526
604
  const { tool, args } = decision;
527
605
  if (!tool) return null;
528
606
  try {
529
- const r = await eff({ kind: 'tool-call', sessionId, name: tool, args: args || {}, memory }) as ToolCallResult;
607
+ const r = await eff({
608
+ kind: 'tool-call',
609
+ sessionId,
610
+ name: tool,
611
+ args: args || {},
612
+ memory,
613
+ senderId: incomingEvent?.senderId != null ? String(incomingEvent.senderId) : undefined,
614
+ senderType: incomingEvent?.senderType != null ? String(incomingEvent.senderType) : undefined,
615
+ }) as ToolCallResult;
530
616
  if (!r.ok) return { type: 'tool_error', tool, error: r.error || 'unknown', senderId: 'system' };
531
617
  // Tools may emit terminal markers (e.g. `message_sent`, `waited`) via
532
618
  // `events` — propagate so the loop can detect terminal turns.
@@ -587,38 +673,6 @@ function pickReceiverForEcho(context: ContextSnapshot, incomingEvent: RawEvent |
587
673
  return pickPrimaryUserIdFromContext(context);
588
674
  }
589
675
 
590
- function extractIncomingMessageText(
591
- incomingEvent: RawEvent | null,
592
- ctx: ContextSnapshot,
593
- ): string {
594
- const parts: string[] = [];
595
- const walk = (ev: RawEvent | null | undefined): void => {
596
- if (!ev) return;
597
- if (ev.text) parts.push(String(ev.text));
598
- if (typeof ev.message === 'string') parts.push(ev.message);
599
- const result = ev.result as Record<string, unknown> | undefined;
600
- if (result) {
601
- if (result.description) parts.push(String(result.description));
602
- const ag = result.agreement as Record<string, unknown> | undefined;
603
- if (ag) {
604
- const terms = ag.terms as Record<string, unknown> | undefined;
605
- if (terms?.description) parts.push(String(terms.description));
606
- if (ag.agreementId) parts.push(String(ag.agreementId));
607
- }
608
- if (result.agreementId) parts.push(String(result.agreementId));
609
- }
610
- if (Array.isArray(ev.events)) (ev.events as RawEvent[]).forEach(walk);
611
- };
612
- walk(incomingEvent);
613
- const hist = ctx.history as Array<Record<string, unknown>> | undefined;
614
- if (hist?.length) {
615
- const last = hist[hist.length - 1];
616
- const text = last?.text ?? last?.content;
617
- if (text) parts.push(String(text));
618
- }
619
- return parts.join('\n').trim();
620
- }
621
-
622
676
  function pickPrimaryUserIdFromContext(context: ContextSnapshot): string | null {
623
677
  const users = context?.users || [];
624
678
  for (const u of users as Record<string, unknown>[]) {
@@ -156,6 +156,22 @@ export function validateWorkflow(workflow: Workflow): ValidationResult {
156
156
  `thinking state "${name}" has no fallthrough for { kind: 'wait' } — agent may stall when LLM picks wait. Spread thinkingDefaults().transitions or add explicitly.`,
157
157
  );
158
158
  }
159
+ const hasErrorFallthrough = state.transitions.some(t => {
160
+ if (!t.when) return true;
161
+ try {
162
+ return t.when(
163
+ { kind: 'error', source: 'unknown', cause: 'probe', retryable: false },
164
+ PROBE_CTX,
165
+ );
166
+ } catch {
167
+ return false;
168
+ }
169
+ });
170
+ if (!hasErrorFallthrough) {
171
+ warnings.push(
172
+ `thinking state "${name}" has no fallthrough for { kind: 'error' } — empty/failed turns may silently recurse until the depth guard (ZIG-303). Add an error transition or spread thinkingDefaults().transitions.`,
173
+ );
174
+ }
159
175
  }
160
176
  }
161
177
  }