@ziggs-ai/agent-sdk 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/package.json +9 -4
  3. package/src/AgentHost.ts +342 -0
  4. package/src/adapters/OpenAIAdapter.ts +125 -0
  5. package/src/agent/Agent.ts +98 -0
  6. package/src/cognition/validateContext.ts +95 -0
  7. package/src/context/applyEffects.ts +80 -0
  8. package/src/context/batch.ts +17 -0
  9. package/src/context/classifyEnvelope.ts +38 -0
  10. package/src/context/routingLabels.ts +46 -0
  11. package/src/defineAgent.ts +62 -0
  12. package/src/formatters/AgreementFormatter.ts +111 -0
  13. package/src/formatters/HistoryFormatter.ts +166 -0
  14. package/src/formatters/index.ts +2 -0
  15. package/src/index.ts +86 -0
  16. package/src/ingress/normalizeIncoming.ts +119 -0
  17. package/src/memory/MemoryStore.ts +104 -0
  18. package/src/runtime/AgentMachine.ts +298 -0
  19. package/src/runtime/PromptBuilder.ts +461 -0
  20. package/src/runtime/buildOutcome.ts +488 -0
  21. package/src/runtime/defaults.ts +72 -0
  22. package/src/runtime/runTurn.ts +637 -0
  23. package/src/runtime/validateWorkflow.ts +165 -0
  24. package/src/server/ConnectionPool.ts +155 -0
  25. package/src/server/EventQueue.ts +119 -0
  26. package/src/server/OutboxBuffer.ts +90 -0
  27. package/src/server/ZiggsEffectHandler.ts +335 -0
  28. package/src/server/agreements/AgreementService.ts +111 -0
  29. package/src/server/createHealthServer.ts +8 -0
  30. package/src/server/proactive/ProactiveTrigger.ts +83 -0
  31. package/src/server/runLauncher.ts +131 -0
  32. package/src/server/tasks/TaskService.ts +111 -0
  33. package/src/server/tasks/index.ts +4 -0
  34. package/src/server/tasks/paymentTools.ts +156 -0
  35. package/src/server/tasks/protocolRunner.ts +101 -0
  36. package/src/server/tasks/protocolTools.ts +96 -0
  37. package/src/server/ziggspay/ZiggsPayClient.ts +193 -0
  38. package/src/shared/ids.ts +3 -0
  39. package/src/shared/runtimeLog.ts +72 -0
  40. package/src/shared/types.ts +31 -0
  41. package/src/tasks/protocolRegistry.ts +25 -0
  42. package/src/tasks/taskCore.ts +139 -0
  43. package/src/tools/ToolManager.ts +95 -0
  44. package/src/tools/{ToolProvider.js → ToolProvider.ts} +5 -15
  45. package/src/tools/defineTool.ts +90 -0
  46. package/src/tools/index.ts +5 -0
  47. package/src/types.ts +368 -0
  48. package/src/utils/jsonExtractor.ts +100 -0
  49. package/src/ConnectionPool.js +0 -133
  50. package/src/adapters/OpenAIAdapter.js +0 -73
  51. package/src/agent/Agent.js +0 -121
  52. package/src/agent/EventQueue.js +0 -68
  53. package/src/agent/OutboxBuffer.js +0 -62
  54. package/src/cognition/PromptBuilder.js +0 -312
  55. package/src/cognition/resolveActionTool.js +0 -12
  56. package/src/cognition/runTurn.js +0 -578
  57. package/src/context/applyEffects.js +0 -133
  58. package/src/context/batch.js +0 -25
  59. package/src/context/classifyEnvelope.js +0 -82
  60. package/src/context/routingLabels.js +0 -54
  61. package/src/createHealthServer.js +0 -28
  62. package/src/formatters/HistoryFormatter.js +0 -257
  63. package/src/formatters/TaskFormatter.js +0 -180
  64. package/src/formatters/index.js +0 -9
  65. package/src/index.js +0 -76
  66. package/src/ingress/normalizeIncoming.js +0 -70
  67. package/src/runLauncher.js +0 -159
  68. package/src/shared/ids.js +0 -7
  69. package/src/shared/types.js +0 -86
  70. package/src/tasks/TaskService.js +0 -247
  71. package/src/tasks/index.js +0 -9
  72. package/src/tasks/taskCore.js +0 -229
  73. package/src/tasks/taskProtocolRegistry.js +0 -22
  74. package/src/tasks/taskProtocolRunner.js +0 -107
  75. package/src/tasks/taskProtocolTools.js +0 -87
  76. package/src/tools/ToolManager.js +0 -79
  77. package/src/tools/defineTool.js +0 -82
  78. package/src/tools/index.js +0 -11
  79. package/src/utils/jsonExtractor.js +0 -139
  80. package/src/workflow/AgentMachine.js +0 -250
  81. package/src/workflow/WorkflowRuntime.js +0 -63
  82. package/src/workflow/dsl.js +0 -287
  83. package/src/workflow/motifs.js +0 -435
  84. package/src/ziggs/runtime.js +0 -192
  85. /package/src/adapters/{index.js → index.ts} +0 -0
package/src/index.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @ziggs-ai/agent-sdk public surface.
3
+ *
4
+ * Foundation:
5
+ * • Outcome union — discriminated, hybrid (closed core + extension)
6
+ * • Workflow shape — explicit kind: 'parked' | 'thinking'
7
+ * • Visible defaults — thinkingDefaults({ initial }) spread by author
8
+ * • Validation at load — defineAgent throws on hard problems, warns on soft
9
+ */
10
+
11
+ export { defineAgent } from './defineAgent.js';
12
+ export { AgentHost, createAgent, createAgentPool } from './AgentHost.js';
13
+ export { Agent } from './agent/Agent.js';
14
+ export type { TickInput, TickOutput, AgentOptions } from './agent/Agent.js';
15
+ export type { SessionResolver, AgentHostOptions } from './AgentHost.js';
16
+ export { createZiggsEffectHandler } from './server/ZiggsEffectHandler.js';
17
+ export type { ZiggsEffectDeps } from './server/ZiggsEffectHandler.js';
18
+ export { EventQueue } from './server/EventQueue.js';
19
+ export { OutboxBuffer } from './server/OutboxBuffer.js';
20
+ export { normalizeIncomingEvent } from './ingress/normalizeIncoming.js';
21
+
22
+ // Runtime primitives
23
+ export { AgentMachine } from './runtime/AgentMachine.js';
24
+ export { runTurn } from './runtime/runTurn.js';
25
+ export { PromptBuilder } from './runtime/PromptBuilder.js';
26
+ export { thinkingDefaults, defineThinkingState, DEFAULT_WAIT_PROMPT } from './runtime/defaults.js';
27
+ export { validateWorkflow, WorkflowValidationError } from './runtime/validateWorkflow.js';
28
+ export { outcomeFromEvent, outcomeFromActionResult, applyOutcomeToCtx } from './runtime/buildOutcome.js';
29
+
30
+ // Types
31
+ export type {
32
+ Outcome, OutcomeKind,
33
+ Ctx, Identity, HistoryEntry, ToolResultEntry,
34
+ Workflow, State, ParkedState, ThinkingState,
35
+ Action, Transition, PromptDef, ActionPrompt, Produces, ProducesFn,
36
+ AgreementRef, TaskRef, ProposalRef,
37
+ AgentConfig, DefineAgentInput, ServicesConfig,
38
+ // Effects — Agent↔Server boundary
39
+ Effect, EffectKind, EffectResult, EffectResultMap, EffectHandler,
40
+ LlmMessage, LlmToolCall, LlmToolSchema, LlmResponse,
41
+ ContextSnapshot, ReadContextOpts, ToolCallResult, RecordedEntry,
42
+ } from './types.js';
43
+
44
+ export { default } from './AgentHost.js';
45
+
46
+ // ── Supporting modules ──────────────────────────────────────────────────────
47
+
48
+ export { validateContext, ContextWarning } from './cognition/validateContext.js';
49
+ export { runLauncher } from './server/runLauncher.js';
50
+ export { ConnectionPool } from './server/ConnectionPool.js';
51
+ export { createHealthServer } from './server/createHealthServer.js';
52
+ export { TaskService } from './server/tasks/TaskService.js';
53
+ export { AgreementService } from './server/agreements/AgreementService.js';
54
+ export { ToolManager } from './tools/ToolManager.js';
55
+ export { ToolProvider } from './tools/ToolProvider.js';
56
+ export { defineTool } from './tools/defineTool.js';
57
+ export { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
58
+ export { extractJSON, safeParseJSON } from './utils/jsonExtractor.js';
59
+ export { HistoryFormatter, historyFormatter, AgreementFormatter, agreementFormatter } from './formatters/index.js';
60
+ export { ProactiveTrigger } from './server/proactive/ProactiveTrigger.js';
61
+ export { ZiggsPayClient, createZiggsPayClient } from './server/ziggspay/ZiggsPayClient.js';
62
+ export {
63
+ PROTOCOL_TOOLS, agreementProposeTool, agreementSubcontractTool,
64
+ agreementRespondTool, agreementCounterProposalTool, agreementCheckProposalTool,
65
+ taskSpawnTool, taskUpdateTool, taskUpdatePlanStepTool,
66
+ } from './server/tasks/protocolTools.js';
67
+ export { PAYMENT_TOOLS, paymentBalanceTool, paymentTransferTool, paymentHoldTool, paymentReleaseTool, paymentResolveWalletTool } from './server/tasks/paymentTools.js';
68
+ export { PROTOCOL_TOOL_NAMES, PROTOCOL_TOOL_TO_OPERATION, mapProtocolToolToOperation, isProtocolToolName } from './tasks/protocolRegistry.js';
69
+ export { dispatchProtocolOp } from './server/tasks/protocolRunner.js';
70
+ export { InMemoryStore, FileMemoryStore } from './memory/MemoryStore.js';
71
+ export type { MemoryStore } from './memory/MemoryStore.js';
72
+ export { classifyIncomingEvent } from './context/classifyEnvelope.js';
73
+ export { buildContextUpdates, CONTEXT_RESET } from './context/applyEffects.js';
74
+ export { getBatchEvents, isTaskResultRelevantToAgent } from './context/batch.js';
75
+ export { classifyWorkflowEvent, findTaskResult, unwrapBatchEvent, findIncomingTaskResult } from './context/routingLabels.js';
76
+ export {
77
+ WebSocketClient,
78
+ MessagesClient,
79
+ ArtifactsClient,
80
+ ScopeClient,
81
+ AgentSearchClient,
82
+ getBackendUrl,
83
+ getWebSocketUrl,
84
+ } from '@ziggs-ai/api-client';
85
+
86
+ export { runtimeLog, resetRuntimeLogLevelCache } from './shared/runtimeLog.js';
@@ -0,0 +1,119 @@
1
+ import { OPEN_AGREEMENT_TARGET } from '../tasks/taskCore.js';
2
+
3
+ export interface NormalizedEvent {
4
+ chatId: string | null;
5
+ event: {
6
+ senderId?: string;
7
+ senderType?: string;
8
+ receiverId?: string;
9
+ timestamp: number;
10
+ type: 'task_result' | 'message';
11
+ result?: unknown;
12
+ text?: string;
13
+ };
14
+ shouldProcess: boolean;
15
+ reason: string | null;
16
+ }
17
+
18
+ interface WireParties {
19
+ creatorId?: string;
20
+ providerId?: string;
21
+ payerId?: string;
22
+ proposedToId?: string;
23
+ }
24
+
25
+ interface WireTask {
26
+ taskId?: string;
27
+ agentId?: string;
28
+ executorId?: string;
29
+ payerId?: string;
30
+ proposedTo?: string;
31
+ createdBy?: string;
32
+ parentTaskId?: string;
33
+ state?: string;
34
+ proposal?: unknown;
35
+ agreement?: { parties?: WireParties };
36
+ }
37
+
38
+ interface WireMetadata {
39
+ chatId?: string;
40
+ chat_id?: string;
41
+ entryType?: string;
42
+ content_type?: string;
43
+ contentType?: string;
44
+ receiverId?: string;
45
+ sender?: { id?: string; type?: string };
46
+ receiver?: { id?: string };
47
+ to?: { id?: string };
48
+ task?: WireTask;
49
+ [key: string]: unknown;
50
+ }
51
+
52
+ interface NormalizeArgs {
53
+ text: string;
54
+ metadata?: WireMetadata;
55
+ ownAgentId?: string | null;
56
+ }
57
+
58
+ function normalizeChatId(metadata: WireMetadata): string | null {
59
+ return metadata.chatId || metadata.chat_id || null;
60
+ }
61
+
62
+ function normalizeReceiverId(metadata: WireMetadata, taskData: WireTask | null): string | null {
63
+ return metadata.receiver?.id || metadata.receiverId || metadata.to?.id || taskData?.executorId || taskData?.proposedTo || null;
64
+ }
65
+
66
+ function isTaskRelated(metadata: WireMetadata, taskData: WireTask | null): boolean {
67
+ if (!taskData?.taskId) return false;
68
+ const entryType = metadata.entryType || 'message';
69
+ const contentType = metadata.content_type || metadata.contentType || 'text';
70
+ return (
71
+ entryType === 'notification' || entryType === 'task_history'
72
+ || contentType === 'task' || contentType === 'task_update'
73
+ || Boolean(taskData.state || taskData.proposal || taskData.parentTaskId || taskData.executorId || taskData.agentId || taskData.payerId || taskData.createdBy)
74
+ );
75
+ }
76
+
77
+ function isRelevantForAgent(ids: (string | null | undefined)[], ownAgentId: string | null): boolean {
78
+ if (!ownAgentId) return true;
79
+ return ids.includes(ownAgentId) || ids.includes(OPEN_AGREEMENT_TARGET);
80
+ }
81
+
82
+ export function normalizeIncomingEvent({ text, metadata = {}, ownAgentId = null }: NormalizeArgs): NormalizedEvent {
83
+ const taskData = metadata.task || null;
84
+ const chatId = normalizeChatId(metadata);
85
+ const receiverId = normalizeReceiverId(metadata, taskData);
86
+ const senderType = metadata.sender?.type ? String(metadata.sender.type).toUpperCase() : undefined;
87
+
88
+ const base = {
89
+ senderId: metadata.sender?.id,
90
+ senderType,
91
+ receiverId: receiverId || undefined,
92
+ timestamp: Date.now(),
93
+ };
94
+
95
+ if (isTaskRelated(metadata, taskData)) {
96
+ const receiverFromMeta = metadata.receiver?.id || metadata.receiverId || null;
97
+ const parties = taskData!.agreement?.parties ?? {};
98
+ const candidateIds = [
99
+ parties.creatorId, parties.providerId, parties.payerId, parties.proposedToId,
100
+ taskData!.agentId, taskData!.executorId, taskData!.payerId, taskData!.proposedTo, taskData!.createdBy,
101
+ receiverFromMeta,
102
+ ].filter(Boolean);
103
+ const relevant = isRelevantForAgent(candidateIds, ownAgentId);
104
+ return {
105
+ chatId,
106
+ event: { ...base, type: 'task_result', result: taskData, text },
107
+ shouldProcess: relevant,
108
+ reason: relevant ? null : 'task_not_targeted_to_agent',
109
+ };
110
+ }
111
+
112
+ const relevant = !ownAgentId || !receiverId || receiverId === ownAgentId || receiverId === OPEN_AGREEMENT_TARGET;
113
+ return {
114
+ chatId,
115
+ event: { ...base, type: 'message', text },
116
+ shouldProcess: relevant,
117
+ reason: relevant ? null : 'message_not_targeted_to_agent',
118
+ };
119
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Agent-private long-term memory.
3
+ *
4
+ * This is the SDK's contract; the *storage* is operator-provided. The agent
5
+ * picks the key shape (often `counterparty:<id>`, sometimes `agreement:<id>:notes`,
6
+ * etc.) — the SDK does not enforce one.
7
+ *
8
+ * Reference implementations: `InMemoryStore` (default; non-durable) and
9
+ * `FileMemoryStore` (single JSON file; useful for local dev). Anything
10
+ * production-grade (sqlite, redis, postgres, …) is operator-owned.
11
+ *
12
+ * Aligns with the project's "agents are third-party" framing: an agent's
13
+ * private notes are its own concern, not the backend's.
14
+ */
15
+ export interface MemoryStore {
16
+ get(key: string): Promise<unknown | undefined>;
17
+ put(key: string, value: unknown): Promise<void>;
18
+ list(prefix: string): Promise<{ key: string; value: unknown }[]>;
19
+ delete?(key: string): Promise<void>;
20
+ }
21
+
22
+ /** Non-durable reference impl. Per-process; lost on restart. */
23
+ export class InMemoryStore implements MemoryStore {
24
+ private readonly store = new Map<string, unknown>();
25
+
26
+ async get(key: string): Promise<unknown | undefined> {
27
+ return this.store.get(key);
28
+ }
29
+
30
+ async put(key: string, value: unknown): Promise<void> {
31
+ this.store.set(key, value);
32
+ }
33
+
34
+ async list(prefix: string): Promise<{ key: string; value: unknown }[]> {
35
+ const out: { key: string; value: unknown }[] = [];
36
+ for (const [k, v] of this.store) {
37
+ if (k.startsWith(prefix)) out.push({ key: k, value: v });
38
+ }
39
+ return out;
40
+ }
41
+
42
+ async delete(key: string): Promise<void> {
43
+ this.store.delete(key);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Single-file JSON store. Reads on startup, writes on every put — fine for
49
+ * local dev and example agents; not for production (no concurrency control,
50
+ * no atomicity). Operators with serious durability needs ship their own
51
+ * `MemoryStore` (sqlite, redis, …) and pass it via `defineAgent({ memory })`.
52
+ */
53
+ export class FileMemoryStore implements MemoryStore {
54
+ private cache: Record<string, unknown> | null = null;
55
+ private loading: Promise<void> | null = null;
56
+
57
+ constructor(private readonly path: string) {}
58
+
59
+ private async _ensureLoaded(): Promise<void> {
60
+ if (this.cache) return;
61
+ if (!this.loading) {
62
+ this.loading = (async () => {
63
+ try {
64
+ const fs = await import('fs/promises');
65
+ const txt = await fs.readFile(this.path, 'utf-8');
66
+ this.cache = JSON.parse(txt) as Record<string, unknown>;
67
+ } catch {
68
+ this.cache = {};
69
+ }
70
+ })();
71
+ }
72
+ await this.loading;
73
+ }
74
+
75
+ private async _flush(): Promise<void> {
76
+ if (!this.cache) return;
77
+ const fs = await import('fs/promises');
78
+ await fs.writeFile(this.path, JSON.stringify(this.cache, null, 2), 'utf-8');
79
+ }
80
+
81
+ async get(key: string): Promise<unknown | undefined> {
82
+ await this._ensureLoaded();
83
+ return this.cache![key];
84
+ }
85
+
86
+ async put(key: string, value: unknown): Promise<void> {
87
+ await this._ensureLoaded();
88
+ this.cache![key] = value;
89
+ await this._flush();
90
+ }
91
+
92
+ async list(prefix: string): Promise<{ key: string; value: unknown }[]> {
93
+ await this._ensureLoaded();
94
+ return Object.entries(this.cache!)
95
+ .filter(([k]) => k.startsWith(prefix))
96
+ .map(([key, value]) => ({ key, value }));
97
+ }
98
+
99
+ async delete(key: string): Promise<void> {
100
+ await this._ensureLoaded();
101
+ delete this.cache![key];
102
+ await this._flush();
103
+ }
104
+ }
@@ -0,0 +1,298 @@
1
+ import type { Ctx, Outcome, Workflow, Identity, EffectHandler } from '../types.js';
2
+ import { runtimeLog } from '../shared/runtimeLog.js';
3
+ import { applyOutcomeToCtx, outcomeFromEvent, type RawEvent } from './buildOutcome.js';
4
+ import type { runTurn as RunTurnFn } from './runTurn.js';
5
+ import { PromptBuilder } from './PromptBuilder.js';
6
+ import type { MemoryStore } from '../memory/MemoryStore.js';
7
+ import type { ToolManager } from '../tools/ToolManager.js';
8
+
9
+ export interface MachineSnapshot {
10
+ value: string;
11
+ ctx: Ctx;
12
+ status: 'active' | 'stopped';
13
+ }
14
+
15
+ interface MachineInput {
16
+ identity: Identity;
17
+ /** Server-provided ctx for an existing session. If omitted, a fresh ctx is built. */
18
+ ctx?: Ctx;
19
+ /** Server-provided state name for an existing session. Defaults to workflow.initial. */
20
+ state?: string;
21
+ }
22
+
23
+ interface MachineOptions {
24
+ workflow: Workflow;
25
+ executionCore: typeof RunTurnFn;
26
+ /** Side channel for all I/O. Provided by the Server. */
27
+ eff: EffectHandler;
28
+ /** Pure tool metadata (schemas, flags). Owned by the Agent. */
29
+ toolManager: ToolManager;
30
+ promptBuilder: PromptBuilder;
31
+ /** Agent-private long-term memory. Operator-provided. */
32
+ memory?: MemoryStore;
33
+ input: MachineInput;
34
+ }
35
+
36
+ interface IncomingFrame {
37
+ event?: RawEvent;
38
+ identity?: Identity;
39
+ }
40
+
41
+ type Subscriber = (snapshot: MachineSnapshot) => void;
42
+
43
+ /**
44
+ * Per-lane interpreter. Holds (current state name, Ctx). Re-entry is the
45
+ * single primitive: every external event and every turn produces exactly
46
+ * one Outcome that re-enters the current state.
47
+ *
48
+ * Parked states stop on entry. Thinking states run an LLM turn → produce an
49
+ * outcome → re-enter. Transitions are evaluated against the new outcome
50
+ * (first matching `when` wins; no `when` is fallthrough).
51
+ *
52
+ * `_running` guards against concurrent turns on the same machine — outer
53
+ * runtime is expected to serialize via EventQueue, but we belt-and-suspenders.
54
+ */
55
+ export class AgentMachine {
56
+ workflow: Workflow;
57
+ executionCore: typeof RunTurnFn;
58
+ eff: EffectHandler;
59
+ toolManager: ToolManager;
60
+ promptBuilder: PromptBuilder;
61
+ memory?: MemoryStore;
62
+ state: string;
63
+ status: 'active' | 'stopped' = 'active';
64
+ ctx: Ctx;
65
+ _running = false;
66
+ _subscribers = new Set<Subscriber>();
67
+ _prevSnapshot: MachineSnapshot | null = null;
68
+ _pendingFrames: IncomingFrame[] = [];
69
+ _draining = false;
70
+ _llmCalls = 0;
71
+ _tokens = { total: 0, input: 0, output: 0 };
72
+
73
+ get llmCalls(): number { return this._llmCalls; }
74
+ get tokens(): { total: number; input: number; output: number } { return this._tokens; }
75
+
76
+ constructor({ workflow, executionCore, eff, toolManager, promptBuilder, memory, input }: MachineOptions) {
77
+ this.workflow = workflow;
78
+ this.executionCore = executionCore;
79
+ this.eff = eff;
80
+ this.toolManager = toolManager;
81
+ this.promptBuilder = promptBuilder;
82
+ this.memory = memory;
83
+ this.state = input.state || workflow.initial;
84
+ this.ctx = input.ctx
85
+ ? { ...input.ctx, identity: input.identity }
86
+ : {
87
+ identity: input.identity,
88
+ activeAgreementId: null,
89
+ activeTaskId: null,
90
+ pendingProposalAgreementId: null,
91
+ delegatedAgreementIds: [],
92
+ delegatedTaskIds: [],
93
+ toolResults: [],
94
+ lastOutcome: { kind: 'enter' },
95
+ };
96
+ }
97
+
98
+ getSnapshot(): MachineSnapshot {
99
+ return { value: this.state, ctx: this.ctx, status: this.status };
100
+ }
101
+
102
+ /** Parked = currently in a parked state and not executing a turn. */
103
+ get isParked(): boolean {
104
+ if (this._running) return false;
105
+ const sd = this.workflow.states[this.state];
106
+ return sd?.kind === 'parked';
107
+ }
108
+
109
+ subscribe(fn: Subscriber): { unsubscribe: () => void } {
110
+ this._subscribers.add(fn);
111
+ return { unsubscribe: () => this._subscribers.delete(fn) };
112
+ }
113
+
114
+ /**
115
+ * Process an incoming wire event: convert to outcome → apply to ctx →
116
+ * evaluate current state's transitions → re-enter or stop.
117
+ *
118
+ * Serialized via a single-slot queue: if a turn is already in flight, the
119
+ * frame is held until the running turn settles, then drained. Without this,
120
+ * an inbound event mid-turn re-entered _step with a stale state and a hot
121
+ * `_running=true`, which spawned overlapping turns and re-created the
122
+ * clarifyingDuringApproval feedback loop even after fixing the workflow
123
+ * fall-through (ZIG-181).
124
+ */
125
+ async send(frame: IncomingFrame): Promise<void> {
126
+ if (this.status !== 'active') return;
127
+ this._pendingFrames.push(frame);
128
+ if (this._draining) return;
129
+ this._draining = true;
130
+ try {
131
+ while (this._pendingFrames.length) {
132
+ const next = this._pendingFrames.shift()!;
133
+ await this._handleFrame(next);
134
+ }
135
+ } finally {
136
+ this._draining = false;
137
+ }
138
+ }
139
+
140
+ async _handleFrame(frame: IncomingFrame): Promise<void> {
141
+ if (this.status !== 'active') return;
142
+ if (frame.identity) this.ctx.identity = frame.identity;
143
+
144
+ const ev = frame.event;
145
+ this._lastIncomingEvent = ev ?? null;
146
+ runtimeLog.debug(
147
+ 'FSM',
148
+ `_handleFrame ev.type=${ev?.type} ev.senderId=${ev?.senderId} ev.receiverId=${ev?.receiverId} myId=${this.ctx.identity?.agentId}`,
149
+ );
150
+ const outcome = outcomeFromEvent(ev, this.ctx.identity.agentId);
151
+
152
+ // `outcomeFromEvent` never returns null — it falls back to `{ kind: 'enter' }`
153
+ // when all events in a batch are no-ops (resource_changed, self-echo, etc.).
154
+ // If a real raw event produced `enter`, that means the event carried no
155
+ // meaningful signal for the FSM, so skip it entirely. Without this guard
156
+ // `idle`'s unconditional `{ to: 'understanding' }` transition fires on every
157
+ // resource_changed wake-up, kicking off an LLM turn that sends a duplicate
158
+ // message, which triggers another resource_changed, and so on.
159
+ if (ev && outcome.kind === 'enter') return;
160
+
161
+ if (
162
+ outcome.kind === 'subtask-finished' &&
163
+ ev?.type === 'task_result' &&
164
+ ev?.result?.taskId &&
165
+ this.ctx.delegatedTaskIds.includes(ev.result.taskId)
166
+ ) {
167
+ // Already correctly classified — no override needed.
168
+ }
169
+
170
+ applyOutcomeToCtx(this.ctx, outcome);
171
+ await this._step(outcome);
172
+ }
173
+
174
+ async _step(outcome: Outcome, depth = 0): Promise<void> {
175
+ const fromState = this.state;
176
+ const target = this._evaluateTransitions(outcome);
177
+ const o = outcome as Record<string, unknown>;
178
+ runtimeLog.debug(
179
+ 'FSM',
180
+ `_step from=${fromState} outcome.kind=${o?.kind} senderId=${o?.senderId ?? '<none>'} myId=${this.ctx.identity?.agentId ?? '<none>'} → target=${target ?? '(stay)'} depth=${depth}`,
181
+ );
182
+ if (target && target !== this.state) {
183
+ this.state = target;
184
+ this._notify();
185
+ }
186
+
187
+ const sd = this.workflow.states[this.state];
188
+ if (!sd) return;
189
+ if (sd.kind === 'parked') {
190
+ runtimeLog.debug('FSM', `_step parked state=${this.state} — exit`);
191
+ // Notify so runtime detects park and resolves the wait promise.
192
+ this._notify();
193
+ return;
194
+ }
195
+
196
+ // Recursion / runaway-turn guard. Without this, a thinking state whose
197
+ // transitions don't match the turn outcome (e.g. `delegating` not
198
+ // catching `tool-result` from an agent_network search) re-enters the
199
+ // same state forever — the LLM keeps re-rolling search/dummy actions
200
+ // until something accidentally matches, burning tokens and emitting
201
+ // duplicate side effects (ZIG-181: ziggs ran `discoverSpecialist` six
202
+ // times before finally invoking `delegate`). 12 is well above any
203
+ // legitimate same-state turn chain we exercise in tests; if we trip
204
+ // it, treat the state as wedged and park.
205
+ if (depth >= 12) {
206
+ runtimeLog.warn(
207
+ 'AgentMachine',
208
+ `state=${this.state} runaway turn loop (depth=${depth}); parking`,
209
+ );
210
+ this._notify();
211
+ return;
212
+ }
213
+ runtimeLog.debug('FSM', `_step thinking state=${this.state} — run turn`);
214
+
215
+ // Thinking: run a turn, produce an outcome, re-step.
216
+ this._running = true;
217
+ try {
218
+ const { outcome: turnOutcome, toolResults, llmCalls, tokens } = await this.executionCore({
219
+ stateId: this.state,
220
+ statePrompt: sd.prompt,
221
+ actions: sd.actions,
222
+ incomingEvent: this.ctx.lastOutcome ? this._lastIncomingEvent : null,
223
+ ctx: this.ctx,
224
+ eff: this.eff,
225
+ toolManager: this.toolManager,
226
+ promptBuilder: this.promptBuilder,
227
+ memory: this.memory,
228
+ definition: this.workflow,
229
+ });
230
+
231
+ this.ctx.toolResults = toolResults;
232
+ this._llmCalls += llmCalls;
233
+ this._tokens.total += tokens.total;
234
+ this._tokens.input += tokens.input;
235
+ this._tokens.output += tokens.output;
236
+ applyOutcomeToCtx(this.ctx, turnOutcome);
237
+ this._running = false;
238
+ this._notify();
239
+
240
+ await this._step(turnOutcome, depth + 1);
241
+ } catch (error: unknown) {
242
+ runtimeLog.error('AgentMachine', `state=${this.state} error: ${(error as Error)?.message || error}`);
243
+ const errOutcome: Outcome = {
244
+ kind: 'error',
245
+ source: 'unknown',
246
+ cause: (error as Error)?.message || String(error),
247
+ retryable: false,
248
+ };
249
+ applyOutcomeToCtx(this.ctx, errOutcome);
250
+ this._running = false;
251
+ this._notify();
252
+ // Try to fall through using current state's transitions; if none match
253
+ // the error outcome the machine just parks (next event will re-enter).
254
+ const fallback = this._evaluateTransitions(errOutcome);
255
+ if (fallback && fallback !== this.state) {
256
+ this.state = fallback;
257
+ this._notify();
258
+ }
259
+ }
260
+ }
261
+
262
+ _evaluateTransitions(outcome: Outcome): string | null {
263
+ const sd = this.workflow.states[this.state];
264
+ if (!sd?.transitions?.length) {
265
+ runtimeLog.debug('FSM', `evaluate state=${this.state}: NO TRANSITIONS DEFINED`);
266
+ return null;
267
+ }
268
+ for (let i = 0; i < sd.transitions.length; i++) {
269
+ const rule = sd.transitions[i]!;
270
+ let matched: boolean;
271
+ let err: unknown = null;
272
+ if (!rule.when) {
273
+ matched = true;
274
+ } else {
275
+ try { matched = !!rule.when(outcome, this.ctx); }
276
+ catch (e) { matched = false; err = e; }
277
+ }
278
+ runtimeLog.debug(
279
+ 'FSM',
280
+ `evaluate state=${this.state} rule[${i}] to=${rule.to} when=${rule.when ? 'fn' : 'always'} → ${matched}${err ? ` ERR=${(err as Error).message}` : ''}`,
281
+ );
282
+ if (matched) return rule.to;
283
+ }
284
+ runtimeLog.debug('FSM', `evaluate state=${this.state}: NONE matched`);
285
+ return null;
286
+ }
287
+
288
+ _notify(): void {
289
+ const snapshot = this.getSnapshot();
290
+ for (const fn of this._subscribers) {
291
+ try { fn(snapshot); } catch {}
292
+ }
293
+ }
294
+
295
+ // Held so PromptBuilder/runTurn can render the original wire event in <event>.
296
+ // Set by the runtime layer when it calls send(); we don't classify it twice.
297
+ _lastIncomingEvent: RawEvent | null = null;
298
+ }