@ziggs-ai/agent-sdk 0.1.10 → 0.1.11

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.
@@ -169,6 +169,14 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
169
169
  // this turn. Used by annotateToolCallDedup to reject any second creation
170
170
  // attempt across iterations of the tool loop.
171
171
  let turnHadAgreementCreation = false;
172
+ // Hard failure at the decision/send boundary. When set, the turn's outcome
173
+ // is forced to kind 'error' regardless of partial events accumulated — the
174
+ // FSM must see the failure, not a best-effort inference over half-finished
175
+ // work. Causes are stable strings so workflow transitions can match them.
176
+ let turnError: { source: 'llm' | 'protocol'; cause: 'send-failed' | 'unparseable-decision' } | null = null;
177
+ // One corrective retry when the LLM's non-tool output cannot be turned into
178
+ // an executable decision (parse failure, or missing message/receiverId).
179
+ let repromptedForDecision = false;
172
180
  const actionNames = Object.keys(actions);
173
181
 
174
182
  runtimeLog.info('runTurn', `calling LLM tools=${tools.length}`);
@@ -294,8 +302,20 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
294
302
 
295
303
  } else {
296
304
  // — non-tool (JSON) response path —
297
- const decision = parseLLMDecision(llmResponse, actionNames, actions);
298
- if (!decision) break;
305
+ const parsed = parseLLMDecision(llmResponse, actionNames, actions);
306
+ if (!parsed.ok) {
307
+ if (repromptedForDecision) {
308
+ runtimeLog.error('runTurn', `unparseable decision after reprompt: ${parsed.error}`);
309
+ turnError = { source: 'llm', cause: 'unparseable-decision' };
310
+ break;
311
+ }
312
+ repromptedForDecision = true;
313
+ runtimeLog.warn('runTurn', `unparseable decision — reprompting once: ${parsed.error}`);
314
+ messages.push({ role: 'assistant', content: llmResponse.content ?? '' });
315
+ messages.push({ role: 'user', content: `Your last output was not a valid action: ${parsed.error}. Respond with exactly one action from the schema.` });
316
+ continue;
317
+ }
318
+ const decision = parsed.decision;
299
319
  const chosenAction = decision.action;
300
320
  const actionDef = actions[chosenAction];
301
321
 
@@ -303,8 +323,15 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
303
323
 
304
324
  // 2-call streaming: if this is a respond action (has receiverId, no bound tool),
305
325
  // 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') {
326
+ // Streaming is a user-delivery optimization (chunks go to a user's socket).
327
+ // For agent-to-agent replies (e.g. an orchestrator's pool dispatch captured
328
+ // via ConnectionPool.requestFrom) there is no streaming channel — the chunk
329
+ // send fails with empty content and, critically, no `send-message` effect is
330
+ // emitted, so the caller's requestFrom never resolves and times out. Skip
331
+ // streaming whenever the inbound message came from another agent.
332
+ const inboundFromAgent = String(incomingEvent?.senderType || '').toUpperCase() === 'AGENT';
333
+ runtimeLog.info('runTurn', `streaming check: enabled=${streamRespondActions} receiverId=${decision.receiverId ?? 'none'} tool=${actionDef?.tool ?? 'none'} action=${chosenAction} inboundFromAgent=${inboundFromAgent}`);
334
+ if (streamRespondActions && !inboundFromAgent && decision.receiverId && !actionDef?.tool && chosenAction !== 'wait') {
308
335
  const streamMessageId = crypto.randomUUID();
309
336
  runtimeLog.info('runTurn', `stream-text start action=${chosenAction} receiverId=${decision.receiverId} msgId=${streamMessageId}`);
310
337
  const streamMessages: LlmMessage[] = [
@@ -318,56 +345,107 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
318
345
  content: 'Write your reply now. Output ONLY the message text — no JSON wrapping, no preamble.',
319
346
  },
320
347
  ];
321
- let streamedText = '';
348
+ let replyText = '';
322
349
  try {
323
350
  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}`);
351
+ replyText = streamResult.text ?? '';
352
+ runtimeLog.info('runTurn', `stream-text done chars=${replyText.length}`);
326
353
  } catch (err) {
327
- runtimeLog.warn('runTurn', `stream-text failed: ${(err as Error).message}`);
354
+ // Streaming transport failed before the reply text existed (call 2
355
+ // runs inside the stream-text effect). Fall back to a plain
356
+ // llm-call + send-message below so the user still gets a reply.
357
+ runtimeLog.error('runTurn', `stream-text failed: ${(err as Error).message} — falling back to non-streaming send`);
328
358
  }
329
- if (streamedText) {
330
- const safeText = truncateMsg(streamedText);
359
+ try {
360
+ if (!replyText) {
361
+ const fallbackResponse: LlmResponse = await effWithTrace({ kind: 'llm-call', messages: streamMessages, tools: [], sessionId });
362
+ llmCallsTotal++;
363
+ const fu = fallbackResponse?.usage;
364
+ tokensTotal += (fu?.total_tokens ?? fu?.totalTokens) || 0;
365
+ tokensInput += (fu?.prompt_tokens ?? fu?.promptTokens) || 0;
366
+ tokensOutput += (fu?.completion_tokens ?? fu?.completionTokens) || 0;
367
+ replyText = (fallbackResponse?.content ?? '').trim();
368
+ if (!replyText) throw new Error('fallback llm-call returned empty reply text');
369
+ }
370
+ const safeText = truncateMsg(replyText);
371
+ // Reuse streamMessageId so any partial chunks already streamed to
372
+ // the client reconcile against the final persisted message.
331
373
  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);
374
+ allEmittedEvents.push({ type: 'message_sent', receiverId: decision.receiverId, chatId: sessionId, message: safeText, senderId: 'system' });
334
375
  lastAction = chosenAction;
335
376
  lastActionDef = actionDef;
377
+ } catch (err) {
378
+ // Both the streaming path AND the non-streaming fallback failed —
379
+ // the user got nothing. End the turn with a loud send-failed error.
380
+ runtimeLog.error('runTurn', `respond send failed after stream fallback: ${(err as Error).message}`);
381
+ turnError = { source: 'protocol', cause: 'send-failed' };
336
382
  }
337
383
  break;
338
384
  }
339
385
 
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;
386
+ let result: EmittedEvent[] | EmittedEvent | null;
387
+ try {
388
+ result = await executeDecision(decision, actionDef, eff, sessionId, memory);
389
+ } catch (err) {
390
+ // Only outbound sends can throw out of executeDecision — executeTool
391
+ // traps its own errors into tool_error events. A throw here means the
392
+ // message never reached the backend; the turn must end loudly.
393
+ runtimeLog.error('runTurn', `action "${chosenAction}" send failed: ${(err as Error).message}`);
394
+ turnError = { source: 'protocol', cause: 'send-failed' };
395
+ break;
396
+ }
397
+ if (!result) {
398
+ // The decision parsed but could not be executed — e.g. a respond
399
+ // action missing message/receiverId. Same defect class as a parse
400
+ // failure. Nothing was executed for this decision, so a single
401
+ // reprompt is safe (no duplicate side effects).
402
+ if (repromptedForDecision) {
403
+ runtimeLog.error('runTurn', `unexecutable decision after reprompt: action "${chosenAction}"`);
404
+ turnError = { source: 'llm', cause: 'unparseable-decision' };
405
+ break;
358
406
  }
407
+ repromptedForDecision = true;
408
+ runtimeLog.warn('runTurn', `unexecutable decision — reprompting once: action "${chosenAction}" missing required fields`);
409
+ messages.push({ role: 'assistant', content: llmResponse.content ?? '' });
410
+ 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.` });
411
+ continue;
412
+ }
413
+ const events = (Array.isArray(result) ? result : [result]) as EmittedEvent[];
414
+ allEmittedEvents.push(...events);
415
+ lastAction = chosenAction;
416
+ lastActionDef = actionDef;
417
+ lastArgs = decision.args;
418
+
419
+ if (actionDef?.tool) {
420
+ const firstEvent = (events[0] || {}) as EmittedEvent;
421
+ const toolResultContent = JSON.stringify(firstEvent.result ?? firstEvent);
422
+ messages.push({ role: 'assistant', content: llmResponse.content });
423
+ messages.push({ role: 'user', content: `Tool "${actionDef.tool}" result:\n${toolResultContent}\n\nContinue with your next action.` });
424
+ if (!turnHadAgreementCreation && eventsContainAgreementCreation(events)) {
425
+ turnHadAgreementCreation = true;
426
+ }
427
+ if (eventsAreTerminal(events)) break;
428
+ continue;
359
429
  }
360
430
  break;
361
431
  }
362
432
  }
363
433
 
364
- await echoOptInToolSummaryIfNeeded({
365
- allEmittedEvents, eff, sessionId,
366
- context: ctxObj, toolManager, incomingEvent,
367
- });
434
+ if (!turnError) {
435
+ await echoOptInToolSummaryIfNeeded({
436
+ allEmittedEvents, eff, sessionId,
437
+ context: ctxObj, toolManager, incomingEvent,
438
+ });
439
+ }
368
440
 
369
441
  const toolResults: ToolResultEntry[] = [];
370
- const outcome = outcomeFromActionResult(lastAction, lastActionDef, lastArgs, allEmittedEvents, toolResults);
442
+ let outcome: Outcome = outcomeFromActionResult(lastAction, lastActionDef, lastArgs, allEmittedEvents, toolResults);
443
+ if (turnError) {
444
+ // A hard failure at the decision/send boundary outranks whatever partial
445
+ // (always non-terminal — terminal protocol events break the loop before
446
+ // the JSON-decision path) events accumulated this turn.
447
+ outcome = { kind: 'error', source: turnError.source, cause: turnError.cause, retryable: false };
448
+ }
371
449
 
372
450
  runtimeLog.info('runTurn', `done state=${stateId} action=${lastAction} outcome=${outcome.kind} llmCalls=${llmCallsTotal}`);
373
451
 
@@ -492,21 +570,29 @@ function annotateToolCallDedup(
492
570
  });
493
571
  }
494
572
 
495
- function parseLLMDecision(llmResponse: LlmResponse, allowedActionNames: string[], actions: Record<string, Action>): ParsedDecision | null {
573
+ type DecisionParse =
574
+ | { ok: true; decision: ParsedDecision }
575
+ | { ok: false; error: string };
576
+
577
+ function parseLLMDecision(llmResponse: LlmResponse, allowedActionNames: string[], actions: Record<string, Action>): DecisionParse {
496
578
  if (llmResponse?.tool_calls?.length) {
497
579
  const tc = llmResponse.tool_calls[0];
498
- if (!tc?.function?.name) return null;
580
+ if (!tc?.function?.name) return { ok: false, error: 'tool call had no function name' };
499
581
  let args: Record<string, unknown> = {};
500
582
  try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
501
583
  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 };
584
+ if (!matchingAction) return { ok: false, error: `tool "${tc.function.name}" is not bound to any action in this state` };
585
+ return { ok: true, decision: { thought: `Using tool: ${tc.function.name}`, action: matchingAction, tool: tc.function.name, args } };
504
586
  }
505
587
 
506
588
  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;
589
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
590
+ return { ok: false, error: 'output was not a JSON object' };
591
+ }
592
+ if (!parsed['action'] || !allowedActionNames.includes(parsed['action'] as string)) {
593
+ return { ok: false, error: `missing or unknown "action" — allowed: ${allowedActionNames.join(', ')}` };
594
+ }
595
+ return { ok: true, decision: parsed as unknown as ParsedDecision };
510
596
  }
511
597
 
512
598
  function findActionForTool(toolName: string, allowedActionNames: string[], actions: Record<string, Action>): string | null {
@@ -552,7 +638,11 @@ async function executeDecision(
552
638
  return await executeSendMessage({ ...decision, message: a.message, receiverId: a.receiverId }, eff, sessionId);
553
639
  }
554
640
  }
555
- if (decision.action === 'wait') return { type: 'waited' };
641
+ // An action with no tool and no message that is named "wait" OR statically
642
+ // declares `produces: 'wait'` (e.g. "pause"/"hold") is an explicit
643
+ // do-nothing — emit `waited` so the turn ends honestly instead of falling
644
+ // through to the unexecutable-decision reprompt.
645
+ if (decision.action === 'wait' || actionDef?.produces === 'wait') return { type: 'waited' };
556
646
  return null;
557
647
  }
558
648
 
@@ -562,34 +652,61 @@ function truncateMsg(msg: unknown): string {
562
652
  return msg.slice(0, MAX_MSG_CHARS) + `\n\n[truncated to ${MAX_MSG_CHARS} of ${msg.length} chars]`;
563
653
  }
564
654
 
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;
655
+ async function executeSendMessage(decision: ParsedDecision, eff: EffectHandler, sessionId: string): Promise<EmittedEvent[]> {
656
+ // Callers (executeDecision) guarantee message + receiverId, and sessionId is
657
+ // always ctx.identity.sessionId. A send failure throws to the turn loop,
658
+ // which converts it into the 'send-failed' error outcome — no silent nulls,
659
+ // and message_sent is only emitted after the send effect resolved.
568
660
  // `decision.chatId` is what the LLM emitted in its JSON decision; honor it
569
661
  // for cross-session sends (e.g. reporting to a parent task chat), fall back
570
662
  // to the current session otherwise.
571
663
  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' }];
664
+ const safeMessage = truncateMsg(decision.message);
665
+ await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: decision.receiverId!, text: safeMessage });
666
+ const events: EmittedEvent[] = [{ type: 'message_sent', receiverId: decision.receiverId!, chatId: targetSessionId, message: safeMessage, senderId: 'system' }];
577
667
  if (targetSessionId !== sessionId) events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' });
578
668
  return events;
579
669
  }
580
670
 
581
- async function executeSendMessages(decision: ParsedDecision, eff: EffectHandler, sessionId: string | null): Promise<EmittedEvent[]> {
671
+ /**
672
+ * Multi-message send. `message_sent` is emitted ONLY for sends that actually
673
+ * succeeded. Malformed entries (missing receiverId/message) and transport
674
+ * failures are collected: if nothing was even attempted the caller sees null
675
+ * and reprompts (LLM output defect); if every attempted send failed the whole
676
+ * action throws (→ 'send-failed' error outcome); a partial failure keeps the
677
+ * successful sends and logs loudly — reprompting after partial execution
678
+ * would risk duplicate messages.
679
+ */
680
+ async function executeSendMessages(decision: ParsedDecision, eff: EffectHandler, sessionId: string): Promise<EmittedEvent[] | null> {
582
681
  const events: EmittedEvent[] = [];
682
+ const failures: Array<{ receiverId: string; error: string }> = [];
683
+ let attempted = 0;
583
684
  for (const msg of decision.messages ?? []) {
584
- if (!msg.receiverId || !msg.message) continue;
685
+ if (!msg.receiverId || !msg.message) {
686
+ failures.push({ receiverId: msg.receiverId || '<missing>', error: 'missing receiverId or message' });
687
+ continue;
688
+ }
585
689
  const targetSessionId = msg.chatId || sessionId;
586
690
  const safeMessage = truncateMsg(msg.message);
587
- if (targetSessionId) {
588
- try { await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: msg.receiverId, text: safeMessage }); } catch { continue; }
691
+ attempted++;
692
+ try {
693
+ await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: msg.receiverId, text: safeMessage });
694
+ } catch (err) {
695
+ // Scenario: backend rejected or transport dropped this one send. Record
696
+ // it and keep going — remaining recipients must still get their message.
697
+ failures.push({ receiverId: msg.receiverId, error: (err as Error).message });
698
+ continue;
589
699
  }
590
- events.push({ type: 'message_sent', receiverId: msg.receiverId, chatId: targetSessionId ?? undefined, message: safeMessage, senderId: 'system' });
700
+ events.push({ type: 'message_sent', receiverId: msg.receiverId, chatId: targetSessionId, message: safeMessage, senderId: 'system' });
591
701
  if (targetSessionId !== sessionId) events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' } as unknown as EmittedEvent);
592
702
  }
703
+ if (attempted === 0) return null; // every entry malformed — LLM output defect, caller reprompts
704
+ if (events.length === 0) {
705
+ throw new Error(`all ${failures.length} send(s) failed: ${failures.map(f => `${f.receiverId}: ${f.error}`).join('; ')}`);
706
+ }
707
+ if (failures.length > 0) {
708
+ runtimeLog.error('runTurn', `partial send failure (${failures.length} of ${(decision.messages ?? []).length} entries): ${failures.map(f => `${f.receiverId}: ${f.error}`).join('; ')}`);
709
+ }
593
710
  return events;
594
711
  }
595
712
 
@@ -1,20 +1,22 @@
1
- import type { Ctx, OutcomeKind, Workflow, ThinkingState } from '../types.js';
1
+ import type { Ctx, OutcomeKind, Workflow, ThinkingState, ParkedState } from '../types.js';
2
2
 
3
- const PROBE_CTX: Ctx = {
3
+ export const PROBE_CTX: Ctx = {
4
4
  identity: { agentId: '', sessionId: '', laneKey: '' },
5
5
  activeAgreementId: null,
6
6
  activeTaskId: null,
7
7
  pendingProposalAgreementId: null,
8
8
  delegatedAgreementIds: [],
9
- delegatedTaskIds: [],
9
+ pendingSubtasks: {},
10
+ collectedResults: [],
10
11
  toolResults: [],
11
12
  lastOutcome: { kind: 'enter' },
12
13
  };
13
14
 
15
+ // 'state-timeout' is intentionally absent: it is a runtime-generated liveness
16
+ // outcome, never something an action's `produces` may declare.
14
17
  const KNOWN_OUTCOME_KINDS = new Set<OutcomeKind>([
15
18
  'enter',
16
19
  'wait',
17
- 'timeout',
18
20
  'message-sent',
19
21
  'message-received',
20
22
  'task-assigned',
@@ -85,12 +87,29 @@ export function validateWorkflow(workflow: Workflow): ValidationResult {
85
87
  if ((state as unknown as ThinkingState).actions) {
86
88
  errors.push(`state "${name}" is parked but has actions — parked states are event-driven only`);
87
89
  }
90
+ const tm = (state as ParkedState).timeoutMs;
91
+ if (tm !== undefined && (typeof tm !== 'number' || !Number.isFinite(tm) || tm <= 0)) {
92
+ errors.push(`state "${name}" timeoutMs must be a finite number > 0 (got ${String(tm)})`);
93
+ }
94
+ if (typeof tm === 'number' && Number.isFinite(tm) && tm > 0) {
95
+ const handled = state.transitions.some(t => {
96
+ if (!t.when) return true;
97
+ try { return t.when({ kind: 'state-timeout', state: name, waitedMs: tm }, PROBE_CTX); }
98
+ catch { return false; }
99
+ });
100
+ if (!handled) {
101
+ 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.`);
102
+ }
103
+ }
88
104
  }
89
105
  if (state.kind === 'thinking') {
90
106
  if (!state.prompt) errors.push(`thinking state "${name}" requires a prompt`);
91
107
  if (!state.actions || Object.keys(state.actions).length === 0) {
92
108
  errors.push(`thinking state "${name}" requires at least one action`);
93
109
  }
110
+ if ((state as unknown as ParkedState).timeoutMs !== undefined) {
111
+ errors.push(`state "${name}" is thinking but declares timeoutMs — liveness timers apply to parked states only (thinking turns are bounded by AgentHost tickTimeoutMs)`);
112
+ }
94
113
  }
95
114
 
96
115
  if (!Array.isArray(state.transitions)) {
@@ -126,7 +145,9 @@ export function validateWorkflow(workflow: Workflow): ValidationResult {
126
145
  errors.push(`state "${name}" action "${actionName}" requires prompt.instruction`);
127
146
  }
128
147
  const p = action.produces;
129
- if (typeof p === 'string') {
148
+ if (p === 'state-timeout') {
149
+ errors.push(`state "${name}" action "${actionName}" produces "state-timeout" — reserved for runtime liveness timers; actions cannot produce it`);
150
+ } else if (typeof p === 'string') {
130
151
  if (!KNOWN_OUTCOME_KINDS.has(p as OutcomeKind)) {
131
152
  errors.push(
132
153
  `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);
@@ -0,0 +1,85 @@
1
+ import { runtimeLog } from '../shared/runtimeLog.js';
2
+
3
+ export interface StateTimeoutEvent {
4
+ type: 'state-timeout';
5
+ state: string;
6
+ waitedMs: number;
7
+ epoch: number;
8
+ }
9
+
10
+ interface ArmedTimer {
11
+ epoch: number;
12
+ state: string;
13
+ timeoutMs: number;
14
+ armedAt: number;
15
+ handle: ReturnType<typeof setTimeout>;
16
+ }
17
+
18
+ /**
19
+ * Per-lane liveness timers for parked states declaring timeoutMs. Owned by
20
+ * AgentHost. A firing timer enqueues a state-timeout frame on the SAME lane
21
+ * (caller-provided enqueue → EventQueue), so it serializes behind any real
22
+ * event and can never race one. Epochs make stale frames identifiable: a
23
+ * queued timeout frame whose epoch is not the lane's currently-armed epoch was
24
+ * superseded (a tick ran after it fired) and must be dropped.
25
+ *
26
+ * In-memory only: process restart loses all timers; each lane re-arms on its
27
+ * next real event (sessionStore persists { ctx, state } but not timers).
28
+ */
29
+ export class StateTimers {
30
+ private _timers = new Map<string, ArmedTimer>();
31
+ private _epoch = 0;
32
+ private _enqueue: (event: StateTimeoutEvent, laneKey: string) => void;
33
+
34
+ constructor(enqueue: (event: StateTimeoutEvent, laneKey: string) => void) {
35
+ this._enqueue = enqueue;
36
+ }
37
+
38
+ /** Re-arm is idempotent: any previously armed timer for the lane is cancelled first. */
39
+ arm(laneKey: string, state: string, timeoutMs: number): void {
40
+ this.clear(laneKey);
41
+ const epoch = ++this._epoch;
42
+ const handle = setTimeout(() => {
43
+ runtimeLog.debug('StateTimers', `fired laneKey=${laneKey} state=${state} waitedMs=${timeoutMs} epoch=${epoch}`);
44
+ this._enqueue({ type: 'state-timeout', state, waitedMs: timeoutMs, epoch }, laneKey);
45
+ }, timeoutMs);
46
+ (handle as unknown as { unref?: () => void }).unref?.();
47
+ this._timers.set(laneKey, { epoch, state, timeoutMs, armedAt: Date.now(), handle });
48
+ }
49
+
50
+ clear(laneKey: string): void {
51
+ const t = this._timers.get(laneKey);
52
+ if (!t) return;
53
+ clearTimeout(t.handle);
54
+ this._timers.delete(laneKey);
55
+ }
56
+
57
+ clearAll(): void {
58
+ for (const key of [...this._timers.keys()]) this.clear(key);
59
+ }
60
+
61
+ /**
62
+ * Drop stale state-timeout events from an incoming frame (single event or an
63
+ * EventQueue-coalesced { type: 'batch', events } frame). Returns null when
64
+ * nothing survives (pure stale-timeout frame — the lane's live timer, if any,
65
+ * must be left untouched by the caller in that case).
66
+ */
67
+ stripStale(event: unknown, laneKey: string): unknown | null {
68
+ const pending = this._timers.get(laneKey);
69
+ const fresh = (ev: unknown): boolean => {
70
+ const e = ev as { type?: string; epoch?: number };
71
+ if (e?.type !== 'state-timeout') return true;
72
+ const ok = pending !== undefined && e.epoch === pending.epoch;
73
+ if (!ok) runtimeLog.debug('StateTimers', `dropping stale state-timeout laneKey=${laneKey} epoch=${e.epoch} armed=${pending?.epoch ?? 'none'}`);
74
+ return ok;
75
+ };
76
+ const b = event as { type?: string; events?: unknown[] };
77
+ if (b?.type === 'batch' && Array.isArray(b.events)) {
78
+ const kept = b.events.filter(fresh);
79
+ if (kept.length === 0) return null;
80
+ if (kept.length === b.events.length) return event;
81
+ return kept.length === 1 ? kept[0] : { type: 'batch', events: kept };
82
+ }
83
+ return fresh(event) ? event : null;
84
+ }
85
+ }
@@ -280,7 +280,16 @@ export function createZiggsEffectHandler(deps: ZiggsEffectDeps): EffectHandler {
280
280
  let text = '';
281
281
  if (deps.llm.chatMessagesStream) {
282
282
  text = await deps.llm.chatMessagesStream(messages as LlmMessage[], (chunk) => {
283
- deps.chunkSender?.(sessionId, receiverId, chunk, messageId);
283
+ // Live-stream display is best-effort. The transport's send rejects
284
+ // empty/whitespace-only content (WebSocketClient: content.trim() === ''),
285
+ // and a whitespace-only chunk (e.g. a leading newline) is truthy yet
286
+ // still throws — which, uncaught, aborts the whole stream and loses the
287
+ // entire reply (e.g. a composer's final synthesis). Swallow chunk-send
288
+ // failures; `text` accumulates independently and the final send-message
289
+ // (below, in runTurn) carries the full reply regardless.
290
+ try {
291
+ if (chunk && chunk.trim()) deps.chunkSender?.(sessionId, receiverId, chunk, messageId);
292
+ } catch { /* ignore — streaming display only */ }
284
293
  });
285
294
  } else {
286
295
  const r = await deps.llm.chatMessages(messages as LlmMessage[]);
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createTask, getTask, getActiveTasksForAgent, getActiveTasksForChat, getSubtasks,
3
- updateTaskState, cancelTask,
3
+ updateTaskState, updateTaskPlanStep, cancelTask,
4
4
  reportTask, setTaskSatisfaction, countTasks,
5
5
  type Creds, type Task, type TaskState,
6
6
  } from '@ziggs-ai/api-client';
@@ -50,7 +50,7 @@ export class TaskService {
50
50
  return getSubtasks(parentTaskId, this.creds);
51
51
  }
52
52
 
53
- async spawnUnderAgreement(agreementId: string, taskData: { description: string; parentTaskId?: string; plan?: unknown; planReviewTiming?: string; requireMidWorkPlanAck?: boolean; assigneeId?: string }): Promise<Task> {
53
+ async spawnUnderAgreement(agreementId: string, taskData: { description: string; parentTaskId?: string; plan?: unknown; planReviewTiming?: string; requireMidWorkPlanAck?: boolean; assigneeId?: string; inputArtifactIds?: string[] }): Promise<Task> {
54
54
  if (!agreementId) throw new Error('TaskService.spawnUnderAgreement: agreementId is required');
55
55
  try {
56
56
  const result = await createTask({ ...taskData, agreementId } as Parameters<typeof createTask>[0], this.creds);
@@ -74,6 +74,17 @@ export class TaskService {
74
74
  }
75
75
  }
76
76
 
77
+ async updatePlanStep(taskId: string, stepId: string, status: string, result?: unknown): Promise<Task> {
78
+ try {
79
+ const updated = await updateTaskPlanStep(taskId, stepId, status, result, this.creds);
80
+ runtimeLog.info('TaskService', `Task plan step: ${taskId}/${stepId} → ${status}`);
81
+ return updated;
82
+ } catch (error) {
83
+ runtimeLog.error('TaskService', `Task plan step update failed: ${(error as Error).message}`);
84
+ throw error;
85
+ }
86
+ }
87
+
77
88
  async cancel(taskId: string): Promise<Task> {
78
89
  try {
79
90
  const updated = await cancelTask(taskId, this.creds);
@@ -32,6 +32,7 @@ interface ProtocolPayload {
32
32
  parentAgreementId?: string;
33
33
  parentTaskId?: string;
34
34
  taskId?: string;
35
+ stepId?: string;
35
36
  assigneeId?: string;
36
37
  status?: string;
37
38
  result?: unknown;
@@ -97,12 +98,20 @@ export async function dispatchProtocolOp(
97
98
  planReviewTiming: payload.planReviewTiming,
98
99
  requireMidWorkPlanAck: payload.requireMidWorkPlanAck,
99
100
  assigneeId: payload.assigneeId,
101
+ inputArtifactIds: Array.isArray(payload.inputArtifactIds)
102
+ ? (payload.inputArtifactIds as string[])
103
+ : undefined,
100
104
  });
101
105
  case 'task-update':
102
106
  if (!payload.taskId || !payload.status) return null;
103
107
  return taskService.updateState(payload.taskId, normalizeState(payload.status) as Parameters<typeof taskService.updateState>[1], payload.result);
108
+ case 'task-update-plan-step':
109
+ if (!payload.taskId) throw new Error('task-update-plan-step requires `taskId`');
110
+ if (!payload.stepId) throw new Error('task-update-plan-step requires `stepId`');
111
+ if (!payload.status) throw new Error('task-update-plan-step requires `status`');
112
+ return taskService.updatePlanStep(payload.taskId, payload.stepId, payload.status, payload.result);
104
113
  default:
105
- return null;
114
+ throw new Error(`Unknown protocol operation: ${operation}`);
106
115
  }
107
116
  }
108
117