@ziggs-ai/agent-sdk 0.1.9 → 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.
- package/package.json +3 -2
- package/src/AgentHost.ts +54 -4
- package/src/adapters/AnthropicAdapter.ts +206 -0
- package/src/adapters/McpAdapter.ts +177 -32
- package/src/adapters/OpenAIAdapter.ts +11 -6
- package/src/adapters/index.ts +1 -0
- package/src/agent/Agent.ts +3 -0
- package/src/defineAgent.ts +13 -5
- package/src/index.ts +4 -1
- package/src/pricing/fleetTierByAgentId.gen.ts +53 -1
- package/src/runtime/AgentMachine.ts +50 -12
- package/src/runtime/PromptBuilder.ts +20 -1
- package/src/runtime/buildOutcome.ts +51 -6
- package/src/runtime/kickoff.ts +117 -0
- package/src/runtime/runTurn.ts +173 -56
- package/src/runtime/validateWorkflow.ts +26 -5
- package/src/server/ConnectionPool.ts +56 -5
- package/src/server/StateTimers.ts +85 -0
- package/src/server/ZiggsEffectHandler.ts +10 -1
- package/src/server/tasks/TaskService.ts +13 -2
- package/src/server/tools/index.ts +1 -1
- package/src/server/tools/tier1/protocolRunner.ts +10 -1
- package/src/server/tools/tier1/protocolTools.ts +1 -1
- package/src/server/tools/tier2/paymentTools.ts +2 -25
- package/src/types.ts +56 -7
package/src/runtime/runTurn.ts
CHANGED
|
@@ -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
|
|
298
|
-
if (!
|
|
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
|
-
|
|
307
|
-
|
|
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
|
|
348
|
+
let replyText = '';
|
|
322
349
|
try {
|
|
323
350
|
const streamResult = await eff({ kind: 'stream-text', sessionId, receiverId: decision.receiverId, messageId: streamMessageId, messages: streamMessages });
|
|
324
|
-
|
|
325
|
-
runtimeLog.info('runTurn', `stream-text done chars=${
|
|
351
|
+
replyText = streamResult.text ?? '';
|
|
352
|
+
runtimeLog.info('runTurn', `stream-text done chars=${replyText.length}`);
|
|
326
353
|
} catch (err) {
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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))
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
588
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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.
|
|
109
|
-
*
|
|
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
|
-
|
|
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,7 +32,7 @@ export {
|
|
|
32
32
|
PAYMENT_TOOLS,
|
|
33
33
|
paymentBalanceTool, paymentTransferTool, paymentWaitForApprovalTool,
|
|
34
34
|
paymentHoldTool, paymentReleaseTool, paymentResolveWalletTool,
|
|
35
|
-
|
|
35
|
+
paymentIssueGrantTool, paymentAttenuateGrantTool, paymentRevokeGrantTool, paymentListGrantsTool,
|
|
36
36
|
} from './tier2/paymentTools.js';
|
|
37
37
|
export {
|
|
38
38
|
CONNECTION_TOOLS,
|