@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.
- package/package.json +6 -3
- package/src/AgentHost.ts +347 -28
- package/src/adapters/AnthropicAdapter.ts +238 -0
- package/src/adapters/McpAdapter.ts +391 -15
- package/src/adapters/OpenAIAdapter.ts +20 -8
- package/src/adapters/index.ts +1 -0
- package/src/agent/Agent.ts +13 -2
- package/src/agent/ClaudeSdkAgent.ts +401 -0
- package/src/agent/claudeCognition.ts +139 -0
- package/src/agent/claudeSdkProtocol.ts +27 -0
- package/src/agent/claudeSdkTools.ts +81 -0
- package/src/agent/hostedAgentConnection.ts +41 -0
- package/src/agent/toolManagerToSdkMcp.ts +136 -0
- package/src/defineAgent.ts +22 -7
- package/src/index.ts +14 -5
- package/src/pricing/fleetTierByAgentId.gen.ts +53 -1
- package/src/runtime/AgentMachine.ts +143 -23
- package/src/runtime/PromptBuilder.ts +62 -6
- package/src/runtime/bootstrapCtx.ts +39 -0
- package/src/runtime/buildOutcome.ts +51 -6
- package/src/runtime/defaults.ts +12 -1
- package/src/runtime/deriveRespondsToProposals.ts +35 -0
- package/src/runtime/kickoff.ts +117 -0
- package/src/runtime/runMechanical.ts +106 -0
- package/src/runtime/runTurn.ts +236 -62
- package/src/runtime/validateWorkflow.ts +55 -15
- package/src/server/ConnectionPool.ts +56 -5
- package/src/server/InboxCatchUp.ts +37 -0
- package/src/server/StateTimers.ts +85 -0
- package/src/server/ZiggsEffectHandler.ts +198 -180
- package/src/server/agreements/AgreementService.ts +3 -3
- package/src/server/tasks/TaskService.ts +15 -3
- package/src/server/tools/tier1/protocolRunner.ts +221 -3
- package/src/server/tools/tier1/protocolTools.ts +19 -4
- package/src/types.ts +105 -29
package/src/runtime/runTurn.ts
CHANGED
|
@@ -68,7 +68,14 @@ interface RunTurnInput {
|
|
|
68
68
|
*/
|
|
69
69
|
memory?: MemoryStore;
|
|
70
70
|
definition: { description?: string };
|
|
71
|
-
/**
|
|
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
|
-
|
|
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
|
|
298
|
-
if (!
|
|
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
|
-
|
|
307
|
-
|
|
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
|
|
383
|
+
let replyText = '';
|
|
322
384
|
try {
|
|
323
385
|
const streamResult = await eff({ kind: 'stream-text', sessionId, receiverId: decision.receiverId, messageId: streamMessageId, messages: streamMessages });
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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))
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
588
|
-
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
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 (
|
|
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
|
|
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);
|