@ziggs-ai/agent-sdk 0.1.10 → 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.
Files changed (35) hide show
  1. package/package.json +6 -3
  2. package/src/AgentHost.ts +347 -28
  3. package/src/adapters/AnthropicAdapter.ts +238 -0
  4. package/src/adapters/McpAdapter.ts +391 -15
  5. package/src/adapters/OpenAIAdapter.ts +20 -8
  6. package/src/adapters/index.ts +1 -0
  7. package/src/agent/Agent.ts +13 -2
  8. package/src/agent/ClaudeSdkAgent.ts +401 -0
  9. package/src/agent/claudeCognition.ts +139 -0
  10. package/src/agent/claudeSdkProtocol.ts +27 -0
  11. package/src/agent/claudeSdkTools.ts +81 -0
  12. package/src/agent/hostedAgentConnection.ts +41 -0
  13. package/src/agent/toolManagerToSdkMcp.ts +136 -0
  14. package/src/defineAgent.ts +22 -7
  15. package/src/index.ts +14 -5
  16. package/src/pricing/fleetTierByAgentId.gen.ts +53 -1
  17. package/src/runtime/AgentMachine.ts +143 -23
  18. package/src/runtime/PromptBuilder.ts +62 -6
  19. package/src/runtime/bootstrapCtx.ts +39 -0
  20. package/src/runtime/buildOutcome.ts +51 -6
  21. package/src/runtime/defaults.ts +12 -1
  22. package/src/runtime/deriveRespondsToProposals.ts +35 -0
  23. package/src/runtime/kickoff.ts +117 -0
  24. package/src/runtime/runMechanical.ts +106 -0
  25. package/src/runtime/runTurn.ts +236 -62
  26. package/src/runtime/validateWorkflow.ts +55 -15
  27. package/src/server/ConnectionPool.ts +56 -5
  28. package/src/server/InboxCatchUp.ts +37 -0
  29. package/src/server/StateTimers.ts +85 -0
  30. package/src/server/ZiggsEffectHandler.ts +198 -180
  31. package/src/server/agreements/AgreementService.ts +3 -3
  32. package/src/server/tasks/TaskService.ts +15 -3
  33. package/src/server/tools/tier1/protocolRunner.ts +221 -3
  34. package/src/server/tools/tier1/protocolTools.ts +19 -4
  35. package/src/types.ts +105 -29
@@ -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,10 +191,27 @@ 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.
171
206
  let turnHadAgreementCreation = false;
207
+ // Hard failure at the decision/send boundary. When set, the turn's outcome
208
+ // is forced to kind 'error' regardless of partial events accumulated — the
209
+ // FSM must see the failure, not a best-effort inference over half-finished
210
+ // work. Causes are stable strings so workflow transitions can match them.
211
+ let turnError: { source: 'llm' | 'protocol'; cause: 'send-failed' | 'unparseable-decision' } | null = null;
212
+ // One corrective retry when the LLM's non-tool output cannot be turned into
213
+ // an executable decision (parse failure, or missing message/receiverId).
214
+ let repromptedForDecision = false;
172
215
  const actionNames = Object.keys(actions);
173
216
 
174
217
  runtimeLog.info('runTurn', `calling LLM tools=${tools.length}`);
@@ -257,7 +300,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
257
300
  try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
258
301
  const decision = { action: matchingAction, tool: toolName, args };
259
302
 
260
- const result = await executeDecision(decision, actionDef, effWithTrace, sessionId, memory);
303
+ const result = await executeDecision(decision, actionDef, effWithTrace, sessionId, memory, null, addToolUsage);
261
304
  toolCallResults.push({ tc, matchingAction, result, args, actionDef });
262
305
  }
263
306
 
@@ -294,8 +337,20 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
294
337
 
295
338
  } else {
296
339
  // — non-tool (JSON) response path —
297
- const decision = parseLLMDecision(llmResponse, actionNames, actions);
298
- if (!decision) break;
340
+ const parsed = parseLLMDecision(llmResponse, actionNames, actions);
341
+ if (!parsed.ok) {
342
+ if (repromptedForDecision) {
343
+ runtimeLog.error('runTurn', `unparseable decision after reprompt: ${parsed.error}`);
344
+ turnError = { source: 'llm', cause: 'unparseable-decision' };
345
+ break;
346
+ }
347
+ repromptedForDecision = true;
348
+ runtimeLog.warn('runTurn', `unparseable decision — reprompting once: ${parsed.error}`);
349
+ messages.push({ role: 'assistant', content: llmResponse.content ?? '' });
350
+ messages.push({ role: 'user', content: `Your last output was not a valid action: ${parsed.error}. Respond with exactly one action from the schema.` });
351
+ continue;
352
+ }
353
+ const decision = parsed.decision;
299
354
  const chosenAction = decision.action;
300
355
  const actionDef = actions[chosenAction];
301
356
 
@@ -303,8 +358,15 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
303
358
 
304
359
  // 2-call streaming: if this is a respond action (has receiverId, no bound tool),
305
360
  // 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') {
361
+ // Streaming is a user-delivery optimization (chunks go to a user's socket).
362
+ // For agent-to-agent replies (e.g. an orchestrator's pool dispatch captured
363
+ // via ConnectionPool.requestFrom) there is no streaming channel — the chunk
364
+ // send fails with empty content and, critically, no `send-message` effect is
365
+ // emitted, so the caller's requestFrom never resolves and times out. Skip
366
+ // streaming whenever the inbound message came from another agent.
367
+ const inboundFromAgent = String(incomingEvent?.senderType || '').toUpperCase() === 'AGENT';
368
+ runtimeLog.info('runTurn', `streaming check: enabled=${streamRespondActions} receiverId=${decision.receiverId ?? 'none'} tool=${actionDef?.tool ?? 'none'} action=${chosenAction} inboundFromAgent=${inboundFromAgent}`);
369
+ if (streamRespondActions && !inboundFromAgent && decision.receiverId && !actionDef?.tool && chosenAction !== 'wait') {
308
370
  const streamMessageId = crypto.randomUUID();
309
371
  runtimeLog.info('runTurn', `stream-text start action=${chosenAction} receiverId=${decision.receiverId} msgId=${streamMessageId}`);
310
372
  const streamMessages: LlmMessage[] = [
@@ -318,56 +380,119 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
318
380
  content: 'Write your reply now. Output ONLY the message text — no JSON wrapping, no preamble.',
319
381
  },
320
382
  ];
321
- let streamedText = '';
383
+ let replyText = '';
322
384
  try {
323
385
  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}`);
386
+ replyText = streamResult.text ?? '';
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
+ }
326
400
  } catch (err) {
327
- runtimeLog.warn('runTurn', `stream-text failed: ${(err as Error).message}`);
401
+ // Streaming transport failed before the reply text existed (call 2
402
+ // runs inside the stream-text effect). Fall back to a plain
403
+ // llm-call + send-message below so the user still gets a reply.
404
+ runtimeLog.error('runTurn', `stream-text failed: ${(err as Error).message} — falling back to non-streaming send`);
328
405
  }
329
- if (streamedText) {
330
- const safeText = truncateMsg(streamedText);
406
+ try {
407
+ if (!replyText) {
408
+ const fallbackResponse: LlmResponse = await effWithTrace({ kind: 'llm-call', messages: streamMessages, tools: [], sessionId });
409
+ llmCallsTotal++;
410
+ const fu = fallbackResponse?.usage;
411
+ tokensTotal += (fu?.total_tokens ?? fu?.totalTokens) || 0;
412
+ tokensInput += (fu?.prompt_tokens ?? fu?.promptTokens) || 0;
413
+ tokensOutput += (fu?.completion_tokens ?? fu?.completionTokens) || 0;
414
+ replyText = (fallbackResponse?.content ?? '').trim();
415
+ if (!replyText) throw new Error('fallback llm-call returned empty reply text');
416
+ }
417
+ const safeText = truncateMsg(replyText);
418
+ // Reuse streamMessageId so any partial chunks already streamed to
419
+ // the client reconcile against the final persisted message.
331
420
  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);
421
+ allEmittedEvents.push({ type: 'message_sent', receiverId: decision.receiverId, chatId: sessionId, message: safeText, senderId: 'system' });
334
422
  lastAction = chosenAction;
335
423
  lastActionDef = actionDef;
424
+ } catch (err) {
425
+ // Both the streaming path AND the non-streaming fallback failed —
426
+ // the user got nothing. End the turn with a loud send-failed error.
427
+ runtimeLog.error('runTurn', `respond send failed after stream fallback: ${(err as Error).message}`);
428
+ turnError = { source: 'protocol', cause: 'send-failed' };
336
429
  }
337
430
  break;
338
431
  }
339
432
 
340
- const result = await executeDecision(decision, actionDef, eff, sessionId, memory);
341
- if (result) {
342
- const events = (Array.isArray(result) ? result : [result]) as EmittedEvent[];
343
- allEmittedEvents.push(...events);
344
- lastAction = chosenAction;
345
- lastActionDef = actionDef;
346
- lastArgs = decision.args;
347
-
348
- if (actionDef?.tool) {
349
- const firstEvent = (events[0] || {}) as EmittedEvent;
350
- const toolResultContent = JSON.stringify(firstEvent.result ?? firstEvent);
351
- messages.push({ role: 'assistant', content: llmResponse.content });
352
- messages.push({ role: 'user', content: `Tool "${actionDef.tool}" result:\n${toolResultContent}\n\nContinue with your next action.` });
353
- if (!turnHadAgreementCreation && eventsContainAgreementCreation(events)) {
354
- turnHadAgreementCreation = true;
355
- }
356
- if (eventsAreTerminal(events)) break;
357
- continue;
433
+ let result: EmittedEvent[] | EmittedEvent | null;
434
+ try {
435
+ result = await executeDecision(decision, actionDef, eff, sessionId, memory, null, addToolUsage);
436
+ } catch (err) {
437
+ // Only outbound sends can throw out of executeDecision — executeTool
438
+ // traps its own errors into tool_error events. A throw here means the
439
+ // message never reached the backend; the turn must end loudly.
440
+ runtimeLog.error('runTurn', `action "${chosenAction}" send failed: ${(err as Error).message}`);
441
+ turnError = { source: 'protocol', cause: 'send-failed' };
442
+ break;
443
+ }
444
+ if (!result) {
445
+ // The decision parsed but could not be executed — e.g. a respond
446
+ // action missing message/receiverId. Same defect class as a parse
447
+ // failure. Nothing was executed for this decision, so a single
448
+ // reprompt is safe (no duplicate side effects).
449
+ if (repromptedForDecision) {
450
+ runtimeLog.error('runTurn', `unexecutable decision after reprompt: action "${chosenAction}"`);
451
+ turnError = { source: 'llm', cause: 'unparseable-decision' };
452
+ break;
453
+ }
454
+ repromptedForDecision = true;
455
+ runtimeLog.warn('runTurn', `unexecutable decision — reprompting once: action "${chosenAction}" missing required fields`);
456
+ messages.push({ role: 'assistant', content: llmResponse.content ?? '' });
457
+ messages.push({ role: 'user', content: `Your last output was not a valid action: action "${chosenAction}" was missing required fields (for a message this means both "receiverId" and "message"). Respond with exactly one action from the schema.` });
458
+ continue;
459
+ }
460
+ const events = (Array.isArray(result) ? result : [result]) as EmittedEvent[];
461
+ allEmittedEvents.push(...events);
462
+ lastAction = chosenAction;
463
+ lastActionDef = actionDef;
464
+ lastArgs = decision.args;
465
+
466
+ if (actionDef?.tool) {
467
+ const firstEvent = (events[0] || {}) as EmittedEvent;
468
+ const toolResultContent = JSON.stringify(firstEvent.result ?? firstEvent);
469
+ messages.push({ role: 'assistant', content: llmResponse.content });
470
+ messages.push({ role: 'user', content: `Tool "${actionDef.tool}" result:\n${toolResultContent}\n\nContinue with your next action.` });
471
+ if (!turnHadAgreementCreation && eventsContainAgreementCreation(events)) {
472
+ turnHadAgreementCreation = true;
358
473
  }
474
+ if (eventsAreTerminal(events)) break;
475
+ continue;
359
476
  }
360
477
  break;
361
478
  }
362
479
  }
363
480
 
364
- await echoOptInToolSummaryIfNeeded({
365
- allEmittedEvents, eff, sessionId,
366
- context: ctxObj, toolManager, incomingEvent,
367
- });
481
+ if (!turnError) {
482
+ await echoOptInToolSummaryIfNeeded({
483
+ allEmittedEvents, eff, sessionId,
484
+ context: ctxObj, toolManager, incomingEvent,
485
+ });
486
+ }
368
487
 
369
488
  const toolResults: ToolResultEntry[] = [];
370
- const outcome = outcomeFromActionResult(lastAction, lastActionDef, lastArgs, allEmittedEvents, toolResults);
489
+ let outcome: Outcome = outcomeFromActionResult(lastAction, lastActionDef, lastArgs, allEmittedEvents, toolResults);
490
+ if (turnError) {
491
+ // A hard failure at the decision/send boundary outranks whatever partial
492
+ // (always non-terminal — terminal protocol events break the loop before
493
+ // the JSON-decision path) events accumulated this turn.
494
+ outcome = { kind: 'error', source: turnError.source, cause: turnError.cause, retryable: false };
495
+ }
371
496
 
372
497
  runtimeLog.info('runTurn', `done state=${stateId} action=${lastAction} outcome=${outcome.kind} llmCalls=${llmCallsTotal}`);
373
498
 
@@ -492,21 +617,29 @@ function annotateToolCallDedup(
492
617
  });
493
618
  }
494
619
 
495
- function parseLLMDecision(llmResponse: LlmResponse, allowedActionNames: string[], actions: Record<string, Action>): ParsedDecision | null {
620
+ type DecisionParse =
621
+ | { ok: true; decision: ParsedDecision }
622
+ | { ok: false; error: string };
623
+
624
+ function parseLLMDecision(llmResponse: LlmResponse, allowedActionNames: string[], actions: Record<string, Action>): DecisionParse {
496
625
  if (llmResponse?.tool_calls?.length) {
497
626
  const tc = llmResponse.tool_calls[0];
498
- if (!tc?.function?.name) return null;
627
+ if (!tc?.function?.name) return { ok: false, error: 'tool call had no function name' };
499
628
  let args: Record<string, unknown> = {};
500
629
  try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
501
630
  const matchingAction = findActionForTool(tc.function.name!, allowedActionNames, actions);
502
- if (!matchingAction) return null;
503
- return { thought: `Using tool: ${tc.function.name}`, action: matchingAction, tool: tc.function.name, args };
631
+ if (!matchingAction) return { ok: false, error: `tool "${tc.function.name}" is not bound to any action in this state` };
632
+ return { ok: true, decision: { thought: `Using tool: ${tc.function.name}`, action: matchingAction, tool: tc.function.name, args } };
504
633
  }
505
634
 
506
635
  const parsed = safeParseJSON(llmResponse?.content || '', null) as Record<string, unknown> | null;
507
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
508
- if (!parsed['action'] || !allowedActionNames.includes(parsed['action'] as string)) return null;
509
- return parsed as unknown as ParsedDecision;
636
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
637
+ return { ok: false, error: 'output was not a JSON object' };
638
+ }
639
+ if (!parsed['action'] || !allowedActionNames.includes(parsed['action'] as string)) {
640
+ return { ok: false, error: `missing or unknown "action" — allowed: ${allowedActionNames.join(', ')}` };
641
+ }
642
+ return { ok: true, decision: parsed as unknown as ParsedDecision };
510
643
  }
511
644
 
512
645
  function findActionForTool(toolName: string, allowedActionNames: string[], actions: Record<string, Action>): string | null {
@@ -527,17 +660,18 @@ async function executeDecision(
527
660
  sessionId: string,
528
661
  memory?: MemoryStore,
529
662
  incomingEvent: RawEvent | null = null,
663
+ onUsage?: (usage: LlmResponse['usage']) => void,
530
664
  ): Promise<EmittedEvent[] | EmittedEvent | null> {
531
665
  const boundTool = actionDef?.tool || null;
532
666
 
533
667
  if (boundTool) {
534
668
  decision.tool = boundTool;
535
- return await executeTool(decision, eff, sessionId, memory, incomingEvent);
669
+ return await executeTool(decision, eff, sessionId, memory, incomingEvent, onUsage);
536
670
  }
537
671
  if (decision.action === '_genericDiscoveryTool' && decision.tool) {
538
- return await executeTool(decision, eff, sessionId, memory, incomingEvent);
672
+ return await executeTool(decision, eff, sessionId, memory, incomingEvent, onUsage);
539
673
  }
540
- if (decision.tool) return await executeTool(decision, eff, sessionId, memory, incomingEvent);
674
+ if (decision.tool) return await executeTool(decision, eff, sessionId, memory, incomingEvent, onUsage);
541
675
  if (decision.messages && Array.isArray(decision.messages) && decision.messages.length > 0) {
542
676
  return await executeSendMessages(decision, eff, sessionId);
543
677
  }
@@ -552,7 +686,11 @@ async function executeDecision(
552
686
  return await executeSendMessage({ ...decision, message: a.message, receiverId: a.receiverId }, eff, sessionId);
553
687
  }
554
688
  }
555
- if (decision.action === 'wait') return { type: 'waited' };
689
+ // An action with no tool and no message that is named "wait" OR statically
690
+ // declares `produces: 'wait'` (e.g. "pause"/"hold") is an explicit
691
+ // do-nothing — emit `waited` so the turn ends honestly instead of falling
692
+ // through to the unexecutable-decision reprompt.
693
+ if (decision.action === 'wait' || actionDef?.produces === 'wait') return { type: 'waited' };
556
694
  return null;
557
695
  }
558
696
 
@@ -562,34 +700,61 @@ function truncateMsg(msg: unknown): string {
562
700
  return msg.slice(0, MAX_MSG_CHARS) + `\n\n[truncated to ${MAX_MSG_CHARS} of ${msg.length} chars]`;
563
701
  }
564
702
 
565
- async function executeSendMessage(decision: ParsedDecision, eff: EffectHandler, sessionId: string | null): Promise<EmittedEvent[] | null> {
566
- const { message, receiverId } = decision;
567
- if (!message || !receiverId) return null;
703
+ async function executeSendMessage(decision: ParsedDecision, eff: EffectHandler, sessionId: string): Promise<EmittedEvent[]> {
704
+ // Callers (executeDecision) guarantee message + receiverId, and sessionId is
705
+ // always ctx.identity.sessionId. A send failure throws to the turn loop,
706
+ // which converts it into the 'send-failed' error outcome — no silent nulls,
707
+ // and message_sent is only emitted after the send effect resolved.
568
708
  // `decision.chatId` is what the LLM emitted in its JSON decision; honor it
569
709
  // for cross-session sends (e.g. reporting to a parent task chat), fall back
570
710
  // to the current session otherwise.
571
711
  const targetSessionId = decision.chatId || sessionId;
572
- const safeMessage = truncateMsg(message);
573
- if (targetSessionId) {
574
- await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: receiverId!, text: safeMessage });
575
- }
576
- const events: EmittedEvent[] = [{ type: 'message_sent', receiverId: receiverId!, chatId: targetSessionId ?? undefined, message: safeMessage, senderId: 'system' }];
712
+ const safeMessage = truncateMsg(decision.message);
713
+ await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: decision.receiverId!, text: safeMessage });
714
+ const events: EmittedEvent[] = [{ type: 'message_sent', receiverId: decision.receiverId!, chatId: targetSessionId, message: safeMessage, senderId: 'system' }];
577
715
  if (targetSessionId !== sessionId) events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' });
578
716
  return events;
579
717
  }
580
718
 
581
- async function executeSendMessages(decision: ParsedDecision, eff: EffectHandler, sessionId: string | null): Promise<EmittedEvent[]> {
719
+ /**
720
+ * Multi-message send. `message_sent` is emitted ONLY for sends that actually
721
+ * succeeded. Malformed entries (missing receiverId/message) and transport
722
+ * failures are collected: if nothing was even attempted the caller sees null
723
+ * and reprompts (LLM output defect); if every attempted send failed the whole
724
+ * action throws (→ 'send-failed' error outcome); a partial failure keeps the
725
+ * successful sends and logs loudly — reprompting after partial execution
726
+ * would risk duplicate messages.
727
+ */
728
+ async function executeSendMessages(decision: ParsedDecision, eff: EffectHandler, sessionId: string): Promise<EmittedEvent[] | null> {
582
729
  const events: EmittedEvent[] = [];
730
+ const failures: Array<{ receiverId: string; error: string }> = [];
731
+ let attempted = 0;
583
732
  for (const msg of decision.messages ?? []) {
584
- if (!msg.receiverId || !msg.message) continue;
733
+ if (!msg.receiverId || !msg.message) {
734
+ failures.push({ receiverId: msg.receiverId || '<missing>', error: 'missing receiverId or message' });
735
+ continue;
736
+ }
585
737
  const targetSessionId = msg.chatId || sessionId;
586
738
  const safeMessage = truncateMsg(msg.message);
587
- if (targetSessionId) {
588
- try { await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: msg.receiverId, text: safeMessage }); } catch { continue; }
739
+ attempted++;
740
+ try {
741
+ await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: msg.receiverId, text: safeMessage });
742
+ } catch (err) {
743
+ // Scenario: backend rejected or transport dropped this one send. Record
744
+ // it and keep going — remaining recipients must still get their message.
745
+ failures.push({ receiverId: msg.receiverId, error: (err as Error).message });
746
+ continue;
589
747
  }
590
- events.push({ type: 'message_sent', receiverId: msg.receiverId, chatId: targetSessionId ?? undefined, message: safeMessage, senderId: 'system' });
748
+ events.push({ type: 'message_sent', receiverId: msg.receiverId, chatId: targetSessionId, message: safeMessage, senderId: 'system' });
591
749
  if (targetSessionId !== sessionId) events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' } as unknown as EmittedEvent);
592
750
  }
751
+ if (attempted === 0) return null; // every entry malformed — LLM output defect, caller reprompts
752
+ if (events.length === 0) {
753
+ throw new Error(`all ${failures.length} send(s) failed: ${failures.map(f => `${f.receiverId}: ${f.error}`).join('; ')}`);
754
+ }
755
+ if (failures.length > 0) {
756
+ runtimeLog.error('runTurn', `partial send failure (${failures.length} of ${(decision.messages ?? []).length} entries): ${failures.map(f => `${f.receiverId}: ${f.error}`).join('; ')}`);
757
+ }
593
758
  return events;
594
759
  }
595
760
 
@@ -605,6 +770,7 @@ async function executeTool(
605
770
  sessionId: string,
606
771
  memory?: MemoryStore,
607
772
  incomingEvent: RawEvent | null = null,
773
+ onUsage?: (usage: LlmResponse['usage']) => void,
608
774
  ): Promise<EmittedEvent[] | EmittedEvent | null> {
609
775
  const { tool, args } = decision;
610
776
  if (!tool) return null;
@@ -617,7 +783,15 @@ async function executeTool(
617
783
  memory,
618
784
  senderId: incomingEvent?.senderId != null ? String(incomingEvent.senderId) : undefined,
619
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
+ })(),
620
793
  }) as ToolCallResult;
794
+ if (r.usage) onUsage?.(r.usage);
621
795
  if (!r.ok) return { type: 'tool_error', tool, error: r.error || 'unknown', senderId: 'system' };
622
796
  // Tools may emit terminal markers (e.g. `message_sent`, `waited`) via
623
797
  // `events` — propagate so the loop can detect terminal turns.
@@ -1,20 +1,14 @@
1
- import type { Ctx, OutcomeKind, Workflow, ThinkingState } from '../types.js';
1
+ import type { Ctx, OutcomeKind, Workflow, ThinkingState, ParkedState, MechanicalState } from '../types.js';
2
+ import { bootstrapCtx } from './bootstrapCtx.js';
2
3
 
3
- const PROBE_CTX: Ctx = {
4
- identity: { agentId: '', sessionId: '', laneKey: '' },
5
- activeAgreementId: null,
6
- activeTaskId: null,
7
- pendingProposalAgreementId: null,
8
- delegatedAgreementIds: [],
9
- delegatedTaskIds: [],
10
- toolResults: [],
11
- lastOutcome: { kind: 'enter' },
12
- };
4
+ /** Static probe ctx for evaluating transition `when` guards during validation. */
5
+ export const PROBE_CTX: Ctx = bootstrapCtx({ agentId: '', sessionId: '', laneKey: '' });
13
6
 
7
+ // 'state-timeout' is intentionally absent: it is a runtime-generated liveness
8
+ // outcome, never something an action's `produces` may declare.
14
9
  const KNOWN_OUTCOME_KINDS = new Set<OutcomeKind>([
15
10
  'enter',
16
11
  'wait',
17
- 'timeout',
18
12
  'message-sent',
19
13
  'message-received',
20
14
  'task-assigned',
@@ -72,8 +66,8 @@ export function validateWorkflow(workflow: Workflow): ValidationResult {
72
66
  errors.push(`state "${name}" is not an object`);
73
67
  continue;
74
68
  }
75
- if (state.kind !== 'parked' && state.kind !== 'thinking') {
76
- 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'`);
77
71
  continue;
78
72
  }
79
73
 
@@ -85,12 +79,56 @@ export function validateWorkflow(workflow: Workflow): ValidationResult {
85
79
  if ((state as unknown as ThinkingState).actions) {
86
80
  errors.push(`state "${name}" is parked but has actions — parked states are event-driven only`);
87
81
  }
82
+ const tm = (state as ParkedState).timeoutMs;
83
+ if (tm !== undefined && (typeof tm !== 'number' || !Number.isFinite(tm) || tm <= 0)) {
84
+ errors.push(`state "${name}" timeoutMs must be a finite number > 0 (got ${String(tm)})`);
85
+ }
86
+ if (typeof tm === 'number' && Number.isFinite(tm) && tm > 0) {
87
+ const handled = state.transitions.some(t => {
88
+ if (!t.when) return true;
89
+ try { return t.when({ kind: 'state-timeout', state: name, waitedMs: tm }, PROBE_CTX); }
90
+ catch { return false; }
91
+ });
92
+ if (!handled) {
93
+ warnings.push(`parked state "${name}" declares timeoutMs=${tm} but no transition matches { kind: 'state-timeout' } — the timeout will park loudly (runtimeLog.error) instead of routing to a recovery state.`);
94
+ }
95
+ }
88
96
  }
89
97
  if (state.kind === 'thinking') {
90
98
  if (!state.prompt) errors.push(`thinking state "${name}" requires a prompt`);
91
99
  if (!state.actions || Object.keys(state.actions).length === 0) {
92
100
  errors.push(`thinking state "${name}" requires at least one action`);
93
101
  }
102
+ if ((state as unknown as ParkedState).timeoutMs !== undefined) {
103
+ errors.push(`state "${name}" is thinking but declares timeoutMs — liveness timers apply to parked states only (thinking turns are bounded by AgentHost tickTimeoutMs)`);
104
+ }
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
+ }
94
132
  }
95
133
 
96
134
  if (!Array.isArray(state.transitions)) {
@@ -126,7 +164,9 @@ export function validateWorkflow(workflow: Workflow): ValidationResult {
126
164
  errors.push(`state "${name}" action "${actionName}" requires prompt.instruction`);
127
165
  }
128
166
  const p = action.produces;
129
- if (typeof p === 'string') {
167
+ if (p === 'state-timeout') {
168
+ errors.push(`state "${name}" action "${actionName}" produces "state-timeout" — reserved for runtime liveness timers; actions cannot produce it`);
169
+ } else if (typeof p === 'string') {
130
170
  if (!KNOWN_OUTCOME_KINDS.has(p as OutcomeKind)) {
131
171
  errors.push(
132
172
  `state "${name}" action "${actionName}" produces unknown outcome kind "${p}" — use one of: ${[...KNOWN_OUTCOME_KINDS].join(', ')}`,
@@ -47,6 +47,8 @@ export interface ControlOptions {
47
47
  export class ConnectionPool {
48
48
  private readonly manager: ConnectionManager;
49
49
  private readonly configs = new Map<string, AgentHostOptions>();
50
+ // Listeners enqueued by requestFrom — drained on outbound send-message effects.
51
+ private readonly responseListeners = new Map<string, Array<(text: string) => void>>();
50
52
 
51
53
  constructor({ maxActive = 50, idleTimeoutMs = 60_000 }: PoolOptions = {}) {
52
54
  this.manager = new ConnectionManager({ maxActive, idleTimeoutMs });
@@ -72,7 +74,19 @@ export class ConnectionPool {
72
74
  this.manager.register(
73
75
  agentId,
74
76
  async () => {
75
- const host = new AgentHost(this.configs.get(agentId)!);
77
+ const cfg = this.configs.get(agentId)!;
78
+ const originalTap = cfg.effectTap;
79
+ const pool = this;
80
+ const host = new AgentHost({
81
+ ...cfg,
82
+ effectTap(effect, response) {
83
+ if (effect.kind === 'send-message') {
84
+ const listeners = pool.responseListeners.get(agentId);
85
+ if (listeners?.length) listeners.shift()!(effect.text ?? '');
86
+ }
87
+ originalTap?.(effect, response);
88
+ },
89
+ });
76
90
  await host.connectAsync();
77
91
  runtimeLog.debug(
78
92
  'ConnectionPool',
@@ -101,14 +115,51 @@ export class ConnectionPool {
101
115
  return this.manager.sleepAll();
102
116
  }
103
117
 
118
+ /**
119
+ * Wake the named agent, deliver a message, and wait for its first outbound
120
+ * `send-message` effect — i.e. the agent's reply. Returns the reply text.
121
+ *
122
+ * The effectTap injected in `register` drains a per-agent listener queue;
123
+ * this method enqueues one resolver and awaits it. Times out if the agent
124
+ * does not reply within `timeoutMs` (default 60 s for LLM agents).
125
+ */
126
+ async requestFrom(agentId: string, text: string, metadata: Record<string, unknown> = {}, { timeoutMs = 60_000 }: SendToOptions = {}): Promise<string> {
127
+ const host = await this.wake(agentId);
128
+ return new Promise<string>((resolve, reject) => {
129
+ let settled = false;
130
+ const timer = setTimeout(() => {
131
+ if (settled) return;
132
+ settled = true;
133
+ const ls = this.responseListeners.get(agentId);
134
+ if (ls) { const i = ls.indexOf(listener); if (i !== -1) ls.splice(i, 1); }
135
+ reject(new Error(`requestFrom timeout for "${agentId}" after ${timeoutMs}ms`));
136
+ }, timeoutMs);
137
+ const listener = (responseText: string) => {
138
+ if (settled) return;
139
+ settled = true;
140
+ clearTimeout(timer);
141
+ resolve(responseText);
142
+ };
143
+ const ls = this.responseListeners.get(agentId) ?? [];
144
+ ls.push(listener);
145
+ this.responseListeners.set(agentId, ls);
146
+ host.handleMessage(text, metadata).catch((err: unknown) => {
147
+ if (settled) return;
148
+ settled = true;
149
+ clearTimeout(timer);
150
+ const ls2 = this.responseListeners.get(agentId);
151
+ if (ls2) { const i = ls2.indexOf(listener); if (i !== -1) ls2.splice(i, 1); }
152
+ reject(err);
153
+ });
154
+ });
155
+ }
156
+
104
157
  /**
105
158
  * Wake the named agent and deliver one wire frame to it. Returns once the
106
159
  * host has finished processing.
107
160
  *
108
- * Note: does not return the agent's response. Capturing responses
109
- * synchronously required internal-buffer poking the Agent/Server split
110
- * removed. For request/response semantics, run the agent under an
111
- * AgentHost with an injected EffectHandler that records sends.
161
+ * Note: does not return the agent's response. For request/response
162
+ * semantics use `requestFrom`.
112
163
  */
113
164
  async sendTo(agentId: string, text: string, metadata: Record<string, unknown> = {}, { timeoutMs = 30_000 }: SendToOptions = {}): Promise<null> {
114
165
  const host = await this.wake(agentId);