@ziggs-ai/agent-sdk 0.1.12 → 0.2.0

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 (218) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +41 -2
  3. package/dist/AgentHost.d.ts +201 -0
  4. package/dist/AgentHost.js +652 -0
  5. package/dist/adapters/AnthropicAdapter.d.ts +39 -0
  6. package/dist/adapters/AnthropicAdapter.js +200 -0
  7. package/dist/adapters/McpAdapter.d.ts +199 -0
  8. package/dist/adapters/McpAdapter.js +468 -0
  9. package/dist/adapters/OpenAIAdapter.d.ts +26 -0
  10. package/dist/adapters/OpenAIAdapter.js +158 -0
  11. package/dist/adapters/index.js +2 -0
  12. package/dist/agent/Agent.d.ts +78 -0
  13. package/dist/agent/Agent.js +58 -0
  14. package/dist/agent/ClaudeSdkAgent.d.ts +38 -0
  15. package/dist/agent/ClaudeSdkAgent.js +322 -0
  16. package/dist/agent/claudeCognition.d.ts +67 -0
  17. package/dist/agent/claudeCognition.js +103 -0
  18. package/dist/agent/claudeSdkProtocol.d.ts +11 -0
  19. package/dist/agent/claudeSdkTools.d.ts +19 -0
  20. package/dist/agent/claudeSdkTools.js +57 -0
  21. package/dist/agent/hostedAgentConnection.d.ts +15 -0
  22. package/dist/agent/hostedAgentConnection.js +29 -0
  23. package/dist/agent/toolManagerToSdkMcp.d.ts +12 -0
  24. package/dist/agent/toolManagerToSdkMcp.js +123 -0
  25. package/dist/brain/index.d.ts +50 -0
  26. package/dist/brain/index.js +45 -0
  27. package/dist/cognition/validateContext.d.ts +11 -0
  28. package/dist/cognition/validateContext.js +117 -0
  29. package/dist/context/applyEffects.d.ts +2 -0
  30. package/dist/context/applyEffects.js +89 -0
  31. package/dist/context/batch.d.ts +2 -0
  32. package/dist/context/batch.js +18 -0
  33. package/dist/context/classifyEnvelope.d.ts +1 -0
  34. package/dist/context/classifyEnvelope.js +58 -0
  35. package/dist/context/routingLabels.d.ts +4 -0
  36. package/dist/context/routingLabels.js +48 -0
  37. package/dist/defineAgent.d.ts +14 -0
  38. package/dist/defineAgent.js +68 -0
  39. package/dist/formatters/AgreementFormatter.d.ts +18 -0
  40. package/dist/formatters/AgreementFormatter.js +130 -0
  41. package/dist/formatters/HistoryFormatter.d.ts +26 -0
  42. package/dist/formatters/HistoryFormatter.js +173 -0
  43. package/dist/formatters/index.js +2 -0
  44. package/{src/index.ts → dist/index.d.ts} +7 -53
  45. package/dist/index.js +66 -0
  46. package/dist/ingress/normalizeIncoming.d.ts +65 -0
  47. package/dist/ingress/normalizeIncoming.js +88 -0
  48. package/dist/memory/MemoryStore.d.ts +55 -0
  49. package/dist/memory/MemoryStore.js +78 -0
  50. package/dist/pricing/fleetDefaults.d.ts +46 -0
  51. package/dist/pricing/fleetDefaults.js +168 -0
  52. package/dist/pricing/fleetEvalFree.d.ts +7 -0
  53. package/dist/pricing/fleetEvalFree.js +24 -0
  54. package/dist/pricing/fleetFreeTierA.gen.d.ts +6 -0
  55. package/{src/pricing/fleetFreeTierA.gen.ts → dist/pricing/fleetFreeTierA.gen.js} +5 -6
  56. package/dist/pricing/fleetTierByAgentId.gen.d.ts +7 -0
  57. package/dist/pricing/fleetTierByAgentId.gen.js +768 -0
  58. package/dist/runtime/AgentMachine.d.ts +143 -0
  59. package/dist/runtime/AgentMachine.js +400 -0
  60. package/dist/runtime/PromptBuilder.d.ts +96 -0
  61. package/dist/runtime/PromptBuilder.js +439 -0
  62. package/{src/runtime/bootstrapCtx.ts → dist/runtime/bootstrapCtx.d.ts} +1 -22
  63. package/dist/runtime/bootstrapCtx.js +37 -0
  64. package/dist/runtime/buildOutcome.d.ts +68 -0
  65. package/dist/runtime/buildOutcome.js +464 -0
  66. package/dist/runtime/defaults.d.ts +54 -0
  67. package/dist/runtime/defaults.js +47 -0
  68. package/dist/runtime/deriveRespondsToProposals.d.ts +9 -0
  69. package/dist/runtime/deriveRespondsToProposals.js +38 -0
  70. package/dist/runtime/kickoff.d.ts +15 -0
  71. package/dist/runtime/kickoff.js +112 -0
  72. package/dist/runtime/runMechanical.d.ts +25 -0
  73. package/dist/runtime/runMechanical.js +74 -0
  74. package/dist/runtime/runTurn.d.ts +63 -0
  75. package/dist/runtime/runTurn.js +800 -0
  76. package/dist/runtime/validateWorkflow.d.ts +17 -0
  77. package/dist/runtime/validateWorkflow.js +209 -0
  78. package/dist/server/ConnectionPool.d.ts +68 -0
  79. package/dist/server/ConnectionPool.js +166 -0
  80. package/dist/server/EventQueue.d.ts +33 -0
  81. package/dist/server/EventQueue.js +98 -0
  82. package/dist/server/InboxCatchUp.d.ts +24 -0
  83. package/dist/server/InboxCatchUp.js +213 -0
  84. package/dist/server/OutboxBuffer.d.ts +33 -0
  85. package/dist/server/OutboxBuffer.js +68 -0
  86. package/dist/server/SeenMessages.d.ts +13 -0
  87. package/dist/server/SeenMessages.js +31 -0
  88. package/dist/server/StateTimers.d.ts +34 -0
  89. package/dist/server/StateTimers.js +70 -0
  90. package/dist/server/ZiggsEffectHandler.d.ts +79 -0
  91. package/dist/server/ZiggsEffectHandler.js +319 -0
  92. package/dist/server/agreements/AgreementService.d.ts +50 -0
  93. package/dist/server/agreements/AgreementService.js +85 -0
  94. package/dist/server/createHealthServer.d.ts +7 -0
  95. package/dist/server/createHealthServer.js +75 -0
  96. package/dist/server/proactive/ProactiveTrigger.d.ts +33 -0
  97. package/dist/server/proactive/ProactiveTrigger.js +71 -0
  98. package/dist/server/runLauncher.d.ts +28 -0
  99. package/dist/server/runLauncher.js +93 -0
  100. package/dist/server/tasks/TaskService.d.ts +25 -0
  101. package/dist/server/tasks/TaskService.js +103 -0
  102. package/dist/server/tasks/index.js +1 -0
  103. package/dist/server/telemetryIngest.d.ts +24 -0
  104. package/dist/server/telemetryIngest.js +62 -0
  105. package/dist/server/tools/index.d.ts +24 -0
  106. package/{src/server/tools/index.ts → dist/server/tools/index.js} +4 -24
  107. package/dist/server/tools/tier1/protocolRunner.d.ts +39 -0
  108. package/dist/server/tools/tier1/protocolRunner.js +288 -0
  109. package/dist/server/tools/tier1/protocolTools.d.ts +12 -0
  110. package/dist/server/tools/tier1/protocolTools.js +37 -0
  111. package/dist/server/tools/tier2/connectionTools.d.ts +4 -0
  112. package/dist/server/tools/tier2/connectionTools.js +63 -0
  113. package/dist/server/tools/tier2/contextTools.d.ts +4 -0
  114. package/dist/server/tools/tier2/contextTools.js +46 -0
  115. package/dist/server/tools/tier2/discoveryTools.d.ts +4 -0
  116. package/dist/server/tools/tier2/discoveryTools.js +25 -0
  117. package/dist/server/tools/tier2/marketplaceTools.d.ts +3 -0
  118. package/dist/server/tools/tier2/marketplaceTools.js +22 -0
  119. package/dist/server/tools/tier2/paymentTools.d.ts +12 -0
  120. package/dist/server/tools/tier2/paymentTools.js +165 -0
  121. package/dist/server/ziggsconnect/ZiggsConnectClient.d.ts +35 -0
  122. package/dist/server/ziggsconnect/ZiggsConnectClient.js +95 -0
  123. package/dist/server/ziggscontext/ZiggsContextClient.d.ts +48 -0
  124. package/dist/server/ziggscontext/ZiggsContextClient.js +90 -0
  125. package/dist/server/ziggspay/ZiggsPayClient.d.ts +82 -0
  126. package/dist/server/ziggspay/ZiggsPayClient.js +188 -0
  127. package/dist/shared/ids.d.ts +1 -0
  128. package/dist/shared/ids.js +3 -0
  129. package/dist/shared/runtimeLog.d.ts +18 -0
  130. package/dist/shared/runtimeLog.js +68 -0
  131. package/dist/shared/types.d.ts +22 -0
  132. package/dist/shared/types.js +23 -0
  133. package/dist/tasks/protocolRegistry.d.ts +16 -0
  134. package/dist/tasks/protocolRegistry.js +20 -0
  135. package/dist/tools/ToolManager.d.ts +12 -0
  136. package/dist/tools/ToolManager.js +74 -0
  137. package/dist/tools/ToolProvider.d.ts +6 -0
  138. package/dist/tools/ToolProvider.js +21 -0
  139. package/dist/tools/defineTool.d.ts +27 -0
  140. package/dist/tools/defineTool.js +62 -0
  141. package/{src/tools/index.ts → dist/tools/index.d.ts} +0 -1
  142. package/dist/tools/index.js +6 -0
  143. package/dist/tools/recordReport.d.ts +41 -0
  144. package/dist/tools/recordReport.js +30 -0
  145. package/dist/types.d.ts +466 -0
  146. package/dist/types.js +7 -0
  147. package/dist/utils/jsonExtractor.d.ts +2 -0
  148. package/dist/utils/jsonExtractor.js +111 -0
  149. package/package.json +13 -5
  150. package/src/AgentHost.ts +0 -814
  151. package/src/adapters/AnthropicAdapter.ts +0 -238
  152. package/src/adapters/McpAdapter.ts +0 -678
  153. package/src/adapters/OpenAIAdapter.ts +0 -194
  154. package/src/agent/Agent.ts +0 -112
  155. package/src/agent/ClaudeSdkAgent.ts +0 -401
  156. package/src/agent/claudeCognition.ts +0 -139
  157. package/src/agent/claudeSdkTools.ts +0 -81
  158. package/src/agent/hostedAgentConnection.ts +0 -41
  159. package/src/agent/toolManagerToSdkMcp.ts +0 -136
  160. package/src/cognition/validateContext.ts +0 -95
  161. package/src/context/applyEffects.ts +0 -80
  162. package/src/context/batch.ts +0 -19
  163. package/src/context/classifyEnvelope.ts +0 -38
  164. package/src/context/routingLabels.ts +0 -46
  165. package/src/defineAgent.ts +0 -77
  166. package/src/formatters/AgreementFormatter.ts +0 -111
  167. package/src/formatters/HistoryFormatter.ts +0 -172
  168. package/src/ingress/normalizeIncoming.ts +0 -162
  169. package/src/memory/MemoryStore.ts +0 -104
  170. package/src/pricing/fleetDefaults.ts +0 -225
  171. package/src/pricing/fleetEvalFree.ts +0 -24
  172. package/src/pricing/fleetTierByAgentId.gen.ts +0 -775
  173. package/src/runtime/AgentMachine.ts +0 -484
  174. package/src/runtime/PromptBuilder.ts +0 -522
  175. package/src/runtime/buildOutcome.ts +0 -563
  176. package/src/runtime/defaults.ts +0 -86
  177. package/src/runtime/deriveRespondsToProposals.ts +0 -35
  178. package/src/runtime/kickoff.ts +0 -117
  179. package/src/runtime/runMechanical.ts +0 -106
  180. package/src/runtime/runTurn.ts +0 -870
  181. package/src/runtime/validateWorkflow.ts +0 -221
  182. package/src/server/ConnectionPool.ts +0 -206
  183. package/src/server/EventQueue.ts +0 -133
  184. package/src/server/InboxCatchUp.ts +0 -291
  185. package/src/server/OutboxBuffer.ts +0 -90
  186. package/src/server/SeenMessages.ts +0 -27
  187. package/src/server/StateTimers.ts +0 -85
  188. package/src/server/ZiggsEffectHandler.ts +0 -427
  189. package/src/server/agreements/AgreementService.ts +0 -118
  190. package/src/server/createHealthServer.ts +0 -85
  191. package/src/server/proactive/ProactiveTrigger.ts +0 -83
  192. package/src/server/runLauncher.ts +0 -146
  193. package/src/server/tasks/TaskService.ts +0 -111
  194. package/src/server/telemetryIngest.ts +0 -91
  195. package/src/server/tools/tier1/protocolRunner.ts +0 -353
  196. package/src/server/tools/tier1/protocolTools.ts +0 -114
  197. package/src/server/tools/tier2/connectionTools.ts +0 -75
  198. package/src/server/tools/tier2/contextTools.ts +0 -63
  199. package/src/server/tools/tier2/discoveryTools.ts +0 -34
  200. package/src/server/tools/tier2/marketplaceTools.ts +0 -27
  201. package/src/server/tools/tier2/paymentTools.ts +0 -170
  202. package/src/server/ziggsconnect/ZiggsConnectClient.ts +0 -126
  203. package/src/server/ziggscontext/ZiggsContextClient.ts +0 -129
  204. package/src/server/ziggspay/ZiggsPayClient.ts +0 -193
  205. package/src/shared/ids.ts +0 -3
  206. package/src/shared/runtimeLog.ts +0 -72
  207. package/src/shared/types.ts +0 -29
  208. package/src/tasks/protocolRegistry.ts +0 -25
  209. package/src/tools/ToolManager.ts +0 -95
  210. package/src/tools/ToolProvider.ts +0 -19
  211. package/src/tools/defineTool.ts +0 -90
  212. package/src/tools/recordReport.ts +0 -82
  213. package/src/types.ts +0 -483
  214. package/src/utils/jsonExtractor.ts +0 -100
  215. /package/{src/adapters/index.ts → dist/adapters/index.d.ts} +0 -0
  216. /package/{src/agent/claudeSdkProtocol.ts → dist/agent/claudeSdkProtocol.js} +0 -0
  217. /package/{src/formatters/index.ts → dist/formatters/index.d.ts} +0 -0
  218. /package/{src/server/tasks/index.ts → dist/server/tasks/index.d.ts} +0 -0
@@ -0,0 +1,800 @@
1
+ import { outcomeFromActionResult } from './buildOutcome.js';
2
+ import { runtimeLog } from '../shared/runtimeLog.js';
3
+ import { safeParseJSON } from '../utils/jsonExtractor.js';
4
+ const MAX_TOOL_LOOP_ITERS = 20;
5
+ const MAX_MSG_CHARS = 3500;
6
+ /**
7
+ * Legacy "name set" classifications. The runtime now prefers per-tool flags
8
+ * (`isAgreementCreation`, `isGenericFallback`) set via `defineTool` options,
9
+ * but these sets remain as a fallback so tools authored before the flag API
10
+ * existed continue to work without modification.
11
+ */
12
+ const LEGACY_AGREEMENT_CREATION_NAMES = new Set([
13
+ 'agreement_propose',
14
+ 'agreement_subcontract',
15
+ ]);
16
+ const LEGACY_GENERIC_FALLBACK_NAMES = new Set(['agent_network', 'task_board']);
17
+ function isAgreementCreationTool(name, toolManager) {
18
+ if (!name)
19
+ return false;
20
+ if (toolManager?.getTool?.(name)?.isAgreementCreation === true)
21
+ return true;
22
+ return LEGACY_AGREEMENT_CREATION_NAMES.has(name);
23
+ }
24
+ function isGenericFallbackTool(name, toolManager) {
25
+ if (!name)
26
+ return false;
27
+ if (toolManager?.getTool?.(name)?.isGenericFallback === true)
28
+ return true;
29
+ return LEGACY_GENERIC_FALLBACK_NAMES.has(name);
30
+ }
31
+ /**
32
+ * Run a single thinking-state turn.
33
+ *
34
+ * Reads context → builds prompt → tool loop with the LLM → executes one
35
+ * action → returns exactly one Outcome via outcomeFromActionResult.
36
+ *
37
+ * No direct service calls. Every impure operation goes through `eff`:
38
+ * • eff('read-context') — history/agreements/agents snapshot
39
+ * • eff('llm-call') — model invocation
40
+ * • eff('tool-call') — tool execution (Server runs the tool body)
41
+ * • eff('send-message') — outbound peer message
42
+ * • eff('record-event') — persist a thought / decision marker
43
+ *
44
+ * The Server is responsible for turning each effect into the right HTTP/WS
45
+ * call and (optionally) wrapping it in telemetry / dedup / retries.
46
+ */
47
+ export async function runTurn(input) {
48
+ const { stateId, statePrompt, actions, incomingEvent, ctx, eff, toolManager, promptBuilder, memory, definition } = input;
49
+ // Default on (ZIG-838); ENABLE_LLM_STREAMING=false is the deployment
50
+ // kill-switch. Any other value (unset, 'true', …) streams.
51
+ const streamRespondActions = input.streamRespondActions ?? process.env.ENABLE_LLM_STREAMING !== 'false';
52
+ const sessionId = ctx.identity.sessionId;
53
+ const runId = crypto.randomUUID();
54
+ // Wrap eff to stamp runId + stateId onto llm-call and tool-call effects.
55
+ const effWithTrace = (effect) => {
56
+ if (effect.kind === 'llm-call' || effect.kind === 'tool-call') {
57
+ return eff({ ...effect, runId, stateId });
58
+ }
59
+ return eff(effect);
60
+ };
61
+ const emitThought = async (text) => {
62
+ if (text == null)
63
+ return;
64
+ const s = String(text).trim();
65
+ if (s.length === 0)
66
+ return;
67
+ try {
68
+ await eff({ kind: 'record-event', sessionId, entry: { kind: 'thought', text: s } });
69
+ }
70
+ catch (err) {
71
+ runtimeLog.warn('runTurn', `record-event:thought failed: ${err.message}`);
72
+ }
73
+ };
74
+ runtimeLog.info('runTurn', `enter state=${stateId} runId=${runId}`);
75
+ // Parallel: read session context + collect tool schemas (pure).
76
+ // Filter tools to only those bound to an action in the current state — the
77
+ // runtime rejects tool calls that don't map to an allowed action (see
78
+ // findActionForTool), so don't advertise tools the LLM can't actually use.
79
+ const allTools = await Promise.resolve(toolManager?.getAvailableTools?.() ?? []);
80
+ const allowedToolNames = new Set(Object.values(actions).map(a => a.tool).filter((t) => !!t));
81
+ const toolName = (t) => {
82
+ const fn = t.schema.function;
83
+ return (fn?.name ?? t.schema.name);
84
+ };
85
+ const tools = allowedToolNames.size > 0
86
+ ? allTools.filter(t => {
87
+ const n = toolName(t);
88
+ return n != null && allowedToolNames.has(n);
89
+ })
90
+ : [];
91
+ const serverContext = await effWithTrace({ kind: 'read-context', sessionId, opts: {} });
92
+ const ctxObj = normalizeContextSnapshot(serverContext);
93
+ // A message wake whose chat no longer resolves (deleted, or never visible
94
+ // to this agent) has no world to decide over — deciding anyway means
95
+ // reasoning about a fabricated empty history plus whatever stale scraps
96
+ // other fetches returned. Skip the LLM and park. Gated on message wakes
97
+ // only: agreement-keyed task lanes (sessionId = agreementId) always trip
98
+ // chatMissing and must still run.
99
+ if (ctxObj.chatMissing && ctx.lastOutcome?.kind === 'message-received') {
100
+ runtimeLog.warn('runTurn', `chat ${sessionId} not found on backend — skipping message wake (state=${stateId})`);
101
+ return {
102
+ outcome: { kind: 'wait' },
103
+ toolResults: [],
104
+ llmCalls: 0,
105
+ tokens: { total: 0, input: 0, output: 0 },
106
+ };
107
+ }
108
+ const prompt = promptBuilder.buildFromState({
109
+ statePrompt, actions,
110
+ serverContext: { context: ctxObj, tools: tools },
111
+ incomingEvent, definition,
112
+ chatId: sessionId, stateId, ctx,
113
+ omitMessageFromRespond: streamRespondActions,
114
+ });
115
+ const systemContent = tools.length > 0
116
+ ? 'You are a helpful AI agent. Use the available tools to complete your tasks. When you are finished using tools, respond with a valid JSON decision object.'
117
+ : 'Respond with valid JSON.';
118
+ const messages = [
119
+ { role: 'system', content: systemContent },
120
+ { role: 'user', content: prompt },
121
+ ];
122
+ const allEmittedEvents = [];
123
+ let lastAction = null;
124
+ let lastActionDef = null;
125
+ let lastArgs = null;
126
+ let llmCallsTotal = 0;
127
+ let tokensTotal = 0, tokensInput = 0, tokensOutput = 0;
128
+ // Fold a tool's nested LLM usage (llmTool) into the turn's authoritative
129
+ // token count so tool-internal spend reaches result.metrics (ZIG-813).
130
+ const addToolUsage = (u) => {
131
+ if (!u)
132
+ return;
133
+ llmCallsTotal++;
134
+ tokensTotal += (u.total_tokens ?? u.totalTokens) || 0;
135
+ tokensInput += (u.prompt_tokens ?? u.promptTokens) || 0;
136
+ tokensOutput += (u.completion_tokens ?? u.completionTokens) || 0;
137
+ };
138
+ // Tracks whether a successful agreement creation has already happened in
139
+ // this turn. Used by annotateToolCallDedup to reject any second creation
140
+ // attempt across iterations of the tool loop.
141
+ let turnHadAgreementCreation = false;
142
+ // Hard failure at the decision/send boundary. When set, the turn's outcome
143
+ // is forced to kind 'error' regardless of partial events accumulated — the
144
+ // FSM must see the failure, not a best-effort inference over half-finished
145
+ // work. Causes are stable strings so workflow transitions can match them.
146
+ let turnError = null;
147
+ // One corrective retry when the LLM's non-tool output cannot be turned into
148
+ // an executable decision (parse failure, or missing message/receiverId).
149
+ let repromptedForDecision = false;
150
+ const actionNames = Object.keys(actions);
151
+ runtimeLog.info('runTurn', `calling LLM tools=${tools.length}`);
152
+ for (let iter = 0; iter < MAX_TOOL_LOOP_ITERS; iter++) {
153
+ const llmResponse = await effWithTrace({
154
+ kind: 'llm-call',
155
+ messages,
156
+ tools: tools,
157
+ sessionId,
158
+ });
159
+ llmCallsTotal++;
160
+ const usage = llmResponse?.usage;
161
+ tokensTotal += (usage?.total_tokens ?? usage?.totalTokens) || 0;
162
+ tokensInput += (usage?.prompt_tokens ?? usage?.promptTokens) || 0;
163
+ tokensOutput += (usage?.completion_tokens ?? usage?.completionTokens) || 0;
164
+ if (llmResponse?.tool_calls?.length) {
165
+ // Emit a thought for every tool-call turn so the chat timeline keeps
166
+ // showing the agent's reasoning. OpenAI's tool_call responses usually
167
+ // arrive with `content: null` (the standard "function calling"
168
+ // shape), so relying on `llmResponse.content` alone silently dropped
169
+ // the thought card whenever the agent chose to act via a tool —
170
+ // i.e. essentially every first turn that produces a proposal. Fall
171
+ // back to a deterministic "Calling tool: …" line so the user still
172
+ // sees that the agent decided on something, with the args appended
173
+ // when present.
174
+ const toolCallThought = (() => {
175
+ if (typeof llmResponse.content === 'string' && llmResponse.content.trim()) {
176
+ return llmResponse.content;
177
+ }
178
+ const fn = llmResponse.tool_calls[0]?.function;
179
+ if (!fn?.name)
180
+ return null;
181
+ const argsBlob = (() => {
182
+ if (!fn.arguments)
183
+ return '';
184
+ try {
185
+ const parsed = JSON.parse(fn.arguments);
186
+ const flat = JSON.stringify(parsed);
187
+ return flat.length > 240 ? ` ${flat.slice(0, 237)}…` : ` ${flat}`;
188
+ }
189
+ catch {
190
+ return '';
191
+ }
192
+ })();
193
+ const suffix = llmResponse.tool_calls.length > 1
194
+ ? ` (+${llmResponse.tool_calls.length - 1} more)`
195
+ : '';
196
+ return `Calling tool: ${fn.name}${argsBlob}${suffix}`;
197
+ })();
198
+ await emitThought(toolCallThought);
199
+ const tcs = llmResponse.tool_calls;
200
+ const annotated = annotateToolCallDedup(tcs, toolManager, turnHadAgreementCreation);
201
+ const dupCount = annotated.filter(a => a.skipExecute).length;
202
+ if (dupCount > 0) {
203
+ runtimeLog.warn('runTurn', `iter=${iter} skipping ${dupCount} duplicate tool call(s) in same message`);
204
+ }
205
+ messages.push({ role: 'assistant', tool_calls: tcs });
206
+ const toolCallResults = [];
207
+ for (const ann of annotated) {
208
+ const { tc, skipExecute, skipReason } = ann;
209
+ if (skipExecute) {
210
+ toolCallResults.push({ tc, matchingAction: null, result: null, skippedDuplicate: true, skipReason });
211
+ continue;
212
+ }
213
+ const toolName = tc?.function?.name;
214
+ if (!toolName) {
215
+ toolCallResults.push({ tc, matchingAction: null, result: null });
216
+ continue;
217
+ }
218
+ let matchingAction = findActionForTool(toolName, actionNames, actions);
219
+ let actionDef = matchingAction ? actions[matchingAction] : null;
220
+ if (!matchingAction && isGenericFallbackTool(toolName, toolManager) && toolManager.getTool?.(toolName)) {
221
+ matchingAction = '_genericDiscoveryTool';
222
+ actionDef = null;
223
+ }
224
+ else if (!matchingAction) {
225
+ runtimeLog.info('runTurn', `iter=${iter} tool "${toolName}" not bound to any action — skipping`);
226
+ toolCallResults.push({ tc, matchingAction: null, result: null });
227
+ continue;
228
+ }
229
+ let args = {};
230
+ try {
231
+ args = JSON.parse(tc.function.arguments || '{}');
232
+ }
233
+ catch { }
234
+ const decision = { action: matchingAction, tool: toolName, args };
235
+ const result = await executeDecision(decision, actionDef, effWithTrace, sessionId, memory, null, addToolUsage);
236
+ toolCallResults.push({ tc, matchingAction, result, args, actionDef });
237
+ }
238
+ let hasTerminal = false;
239
+ for (const item of toolCallResults) {
240
+ const { tc, matchingAction, result, skippedDuplicate, skipReason, args, actionDef } = item;
241
+ if (skippedDuplicate) {
242
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify({ skipped: true, reason: skipReason || 'deduplicated' }) });
243
+ continue;
244
+ }
245
+ if (!matchingAction || !result) {
246
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: '{"error":"unknown tool or no result"}' });
247
+ continue;
248
+ }
249
+ const events = (Array.isArray(result) ? result : [result]);
250
+ allEmittedEvents.push(...events);
251
+ lastAction = matchingAction;
252
+ lastActionDef = actionDef;
253
+ lastArgs = args;
254
+ const firstEvent = (events[0] || {});
255
+ const toolResultContent = JSON.stringify(firstEvent.result ?? firstEvent);
256
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResultContent });
257
+ if (!turnHadAgreementCreation && eventsContainAgreementCreation(events)) {
258
+ turnHadAgreementCreation = true;
259
+ }
260
+ if (eventsAreTerminal(events) || actionDef?.terminatesLoop)
261
+ hasTerminal = true;
262
+ }
263
+ if (hasTerminal)
264
+ break;
265
+ messages.push({ role: 'user', content: 'Tools done. Return your JSON decision now (follow the decision_schema). Do not write prose.' });
266
+ }
267
+ else {
268
+ // — non-tool (JSON) response path —
269
+ const parsed = parseLLMDecision(llmResponse, actionNames, actions);
270
+ if (!parsed.ok) {
271
+ if (repromptedForDecision) {
272
+ runtimeLog.error('runTurn', `unparseable decision after reprompt: ${parsed.error}`);
273
+ turnError = { source: 'llm', cause: 'unparseable-decision' };
274
+ break;
275
+ }
276
+ repromptedForDecision = true;
277
+ runtimeLog.warn('runTurn', `unparseable decision — reprompting once: ${parsed.error}`);
278
+ messages.push({ role: 'assistant', content: llmResponse.content ?? '' });
279
+ messages.push({ role: 'user', content: `Your last output was not a valid action: ${parsed.error}. Respond with exactly one action from the schema.` });
280
+ continue;
281
+ }
282
+ const decision = parsed.decision;
283
+ const chosenAction = decision.action;
284
+ const actionDef = actions[chosenAction];
285
+ if (decision.thought)
286
+ await emitThought(decision.thought);
287
+ // 2-call streaming: if this is a respond action (has receiverId, no bound tool),
288
+ // use the streaming effect to generate text and send chunks to the client.
289
+ // Streaming is a user-delivery optimization (chunks go to a user's socket).
290
+ // For agent-to-agent replies (e.g. an orchestrator's pool dispatch captured
291
+ // via ConnectionPool.requestFrom) there is no streaming channel — the chunk
292
+ // send fails with empty content and, critically, no `send-message` effect is
293
+ // emitted, so the caller's requestFrom never resolves and times out. Skip
294
+ // streaming whenever the inbound message came from another agent.
295
+ const inboundFromAgent = String(incomingEvent?.senderType || '').toUpperCase() === 'AGENT';
296
+ runtimeLog.info('runTurn', `streaming check: enabled=${streamRespondActions} receiverId=${decision.receiverId ?? 'none'} tool=${actionDef?.tool ?? 'none'} action=${chosenAction} inboundFromAgent=${inboundFromAgent}`);
297
+ if (streamRespondActions && !inboundFromAgent && decision.receiverId && !actionDef?.tool && chosenAction !== 'wait') {
298
+ const streamMessageId = crypto.randomUUID();
299
+ runtimeLog.info('runTurn', `stream-text start action=${chosenAction} receiverId=${decision.receiverId} msgId=${streamMessageId}`);
300
+ const streamMessages = [
301
+ ...messages,
302
+ {
303
+ role: 'assistant',
304
+ content: JSON.stringify({ thought: decision.thought, action: decision.action, receiverId: decision.receiverId }),
305
+ },
306
+ {
307
+ role: 'user',
308
+ content: 'Write your reply now. Output ONLY the message text — no JSON wrapping, no preamble.',
309
+ },
310
+ ];
311
+ let replyText = '';
312
+ try {
313
+ const streamResult = await eff({ kind: 'stream-text', sessionId, receiverId: decision.receiverId, messageId: streamMessageId, messages: streamMessages });
314
+ replyText = streamResult.text ?? '';
315
+ // Count the streamed reply only when it actually produced the text we
316
+ // send (an empty resolve falls through to the llm-call fallback below,
317
+ // which counts itself). The streamed reply is a real billed completion
318
+ // (call 2 of ZIG-471 2-call streaming) — count it or the largest single
319
+ // output of the turn leaks from every usage/cost sink (ZIG-813).
320
+ if (replyText) {
321
+ llmCallsTotal++;
322
+ const su = streamResult.usage;
323
+ tokensTotal += (su?.total_tokens ?? su?.totalTokens) || 0;
324
+ tokensInput += (su?.prompt_tokens ?? su?.promptTokens) || 0;
325
+ tokensOutput += (su?.completion_tokens ?? su?.completionTokens) || 0;
326
+ runtimeLog.info('runTurn', `stream-text done chars=${replyText.length} tokens=${(su?.total_tokens ?? su?.totalTokens) || 0}`);
327
+ }
328
+ }
329
+ catch (err) {
330
+ // Streaming transport failed before the reply text existed (call 2
331
+ // runs inside the stream-text effect). Fall back to a plain
332
+ // llm-call + send-message below so the user still gets a reply.
333
+ runtimeLog.error('runTurn', `stream-text failed: ${err.message} — falling back to non-streaming send`);
334
+ }
335
+ try {
336
+ if (!replyText) {
337
+ const fallbackResponse = await effWithTrace({ kind: 'llm-call', messages: streamMessages, tools: [], sessionId });
338
+ llmCallsTotal++;
339
+ const fu = fallbackResponse?.usage;
340
+ tokensTotal += (fu?.total_tokens ?? fu?.totalTokens) || 0;
341
+ tokensInput += (fu?.prompt_tokens ?? fu?.promptTokens) || 0;
342
+ tokensOutput += (fu?.completion_tokens ?? fu?.completionTokens) || 0;
343
+ replyText = (fallbackResponse?.content ?? '').trim();
344
+ if (!replyText)
345
+ throw new Error('fallback llm-call returned empty reply text');
346
+ }
347
+ const safeText = truncateMsg(replyText);
348
+ // Reuse streamMessageId so any partial chunks already streamed to
349
+ // the client reconcile against the final persisted message.
350
+ await eff({ kind: 'send-message', sessionId, receiverId: decision.receiverId, text: safeText, messageId: streamMessageId });
351
+ allEmittedEvents.push({ type: 'message_sent', receiverId: decision.receiverId, chatId: sessionId, message: safeText, senderId: 'system' });
352
+ lastAction = chosenAction;
353
+ lastActionDef = actionDef;
354
+ }
355
+ catch (err) {
356
+ // Both the streaming path AND the non-streaming fallback failed —
357
+ // the user got nothing. End the turn with a loud send-failed error.
358
+ runtimeLog.error('runTurn', `respond send failed after stream fallback: ${err.message}`);
359
+ turnError = { source: 'protocol', cause: 'send-failed' };
360
+ }
361
+ break;
362
+ }
363
+ let result;
364
+ try {
365
+ result = await executeDecision(decision, actionDef, eff, sessionId, memory, null, addToolUsage);
366
+ }
367
+ catch (err) {
368
+ // Only outbound sends can throw out of executeDecision — executeTool
369
+ // traps its own errors into tool_error events. A throw here means the
370
+ // message never reached the backend; the turn must end loudly.
371
+ runtimeLog.error('runTurn', `action "${chosenAction}" send failed: ${err.message}`);
372
+ turnError = { source: 'protocol', cause: 'send-failed' };
373
+ break;
374
+ }
375
+ if (!result) {
376
+ // The decision parsed but could not be executed — e.g. a respond
377
+ // action missing message/receiverId. Same defect class as a parse
378
+ // failure. Nothing was executed for this decision, so a single
379
+ // reprompt is safe (no duplicate side effects).
380
+ if (repromptedForDecision) {
381
+ runtimeLog.error('runTurn', `unexecutable decision after reprompt: action "${chosenAction}"`);
382
+ turnError = { source: 'llm', cause: 'unparseable-decision' };
383
+ break;
384
+ }
385
+ repromptedForDecision = true;
386
+ runtimeLog.warn('runTurn', `unexecutable decision — reprompting once: action "${chosenAction}" missing required fields`);
387
+ messages.push({ role: 'assistant', content: llmResponse.content ?? '' });
388
+ 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.` });
389
+ continue;
390
+ }
391
+ const events = (Array.isArray(result) ? result : [result]);
392
+ allEmittedEvents.push(...events);
393
+ lastAction = chosenAction;
394
+ lastActionDef = actionDef;
395
+ lastArgs = decision.args;
396
+ if (actionDef?.tool) {
397
+ const firstEvent = (events[0] || {});
398
+ const toolResultContent = JSON.stringify(firstEvent.result ?? firstEvent);
399
+ messages.push({ role: 'assistant', content: llmResponse.content });
400
+ messages.push({ role: 'user', content: `Tool "${actionDef.tool}" result:\n${toolResultContent}\n\nContinue with your next action.` });
401
+ if (!turnHadAgreementCreation && eventsContainAgreementCreation(events)) {
402
+ turnHadAgreementCreation = true;
403
+ }
404
+ if (eventsAreTerminal(events))
405
+ break;
406
+ continue;
407
+ }
408
+ break;
409
+ }
410
+ }
411
+ if (!turnError) {
412
+ await echoOptInToolSummaryIfNeeded({
413
+ allEmittedEvents, eff, sessionId,
414
+ context: ctxObj, toolManager, incomingEvent,
415
+ });
416
+ }
417
+ const toolResults = [];
418
+ let outcome = outcomeFromActionResult(lastAction, lastActionDef, lastArgs, allEmittedEvents, toolResults);
419
+ if (turnError) {
420
+ // A hard failure at the decision/send boundary outranks whatever partial
421
+ // (always non-terminal — terminal protocol events break the loop before
422
+ // the JSON-decision path) events accumulated this turn.
423
+ outcome = { kind: 'error', source: turnError.source, cause: turnError.cause, retryable: false };
424
+ }
425
+ runtimeLog.info('runTurn', `done state=${stateId} action=${lastAction} outcome=${outcome.kind} llmCalls=${llmCallsTotal}`);
426
+ return { outcome, toolResults, llmCalls: llmCallsTotal, tokens: { total: tokensTotal, input: tokensInput, output: tokensOutput } };
427
+ }
428
+ // ─────────────────────────────────────────────────────────────────────────────
429
+ function extractIncomingMessageText(event) {
430
+ if (!event)
431
+ return '';
432
+ const parts = [];
433
+ const push = (v) => {
434
+ if (typeof v === 'string' && v.trim())
435
+ parts.push(v.trim());
436
+ };
437
+ push(event.text);
438
+ push(event.message);
439
+ const agreement = event.agreement;
440
+ if (agreement) {
441
+ const terms = agreement.terms;
442
+ push(terms?.description);
443
+ push(JSON.stringify(agreement));
444
+ }
445
+ const task = event.task;
446
+ if (task)
447
+ push(JSON.stringify(task));
448
+ return parts.join('\n');
449
+ }
450
+ function normalizeContextSnapshot(raw) {
451
+ if (!raw || typeof raw !== 'object') {
452
+ return { history: [], agreements: [], agents: [], users: [] };
453
+ }
454
+ const out = { ...raw };
455
+ for (const key of ['history', 'agreements', 'agents', 'users']) {
456
+ if (!Array.isArray(out[key]))
457
+ out[key] = [];
458
+ }
459
+ return out;
460
+ }
461
+ function eventsAreTerminal(events) {
462
+ return events.some(e => {
463
+ if (!e)
464
+ return false;
465
+ const t = e.type;
466
+ if (t === 'message_sent' || t === 'message_duplicate_skipped' || t === 'waited')
467
+ return true;
468
+ // Successful protocol-level state changes (agreement creation, task
469
+ // assignment/closure) end the turn. The next turn re-enters with the
470
+ // matching outcome and the workflow takes it from there. Without this,
471
+ // the LLM is nudged with "Continue with your next action." and often
472
+ // re-issues the same agreement_propose, producing duplicate proposal
473
+ // cards within a single user turn.
474
+ if (t === 'tool_result' || t === 'task_result') {
475
+ const r = e.result || {};
476
+ if (r.success === false || r.error)
477
+ return false;
478
+ // Support both nested { agreement: {...} } and flat Agreement objects
479
+ // (api-client now returns Agreement directly, not wrapped in { agreement }).
480
+ const ag = (r.agreement ?? (r.agreementId ? r : null));
481
+ const task = (r.task || (r.taskId ? r : null));
482
+ // Agreement's own status lives flat as `proposalStatus` (see api-client
483
+ // Agreement type) — not nested under `.proposal.status`. Check both so
484
+ // a successful agreement_propose is recognized as terminal instead of
485
+ // being re-issued by the LLM on the next loop iteration.
486
+ const agProposal = ag?.proposal;
487
+ const taskProposal = task?.proposal;
488
+ const proposalStatus = ag?.proposalStatus ?? agProposal?.status ?? taskProposal?.status;
489
+ if (proposalStatus === 'pending' || proposalStatus === 'approved')
490
+ return true;
491
+ const state = task?.state || r.state;
492
+ if (state === 'active' || state === 'in-progress' ||
493
+ state === 'completed' || state === 'failed' || state === 'cancelled') {
494
+ return true;
495
+ }
496
+ }
497
+ return false;
498
+ });
499
+ }
500
+ function eventsContainAgreementCreation(events) {
501
+ return events.some(e => {
502
+ if (!e || e.type !== 'tool_result')
503
+ return false;
504
+ const r = e.result || {};
505
+ if (r.success === false || r.error)
506
+ return false;
507
+ // Support both nested { agreement: {...} } and flat Agreement objects.
508
+ const ag = (r.agreement ?? (r.agreementId ? r : null));
509
+ // See eventsAreTerminal above: proposalStatus is flat on Agreement, not nested.
510
+ const agProposal = ag?.proposal;
511
+ const task = r.task;
512
+ const taskProposal = task?.proposal;
513
+ const proposalStatus = ag?.proposalStatus ?? agProposal?.status ?? taskProposal?.status;
514
+ return proposalStatus === 'pending' || proposalStatus === 'approved';
515
+ });
516
+ }
517
+ function annotateToolCallDedup(toolCalls, toolManager, initialSawCreate = false) {
518
+ let sawCreate = initialSawCreate;
519
+ const seenUpdateTaskIds = new Set();
520
+ return (toolCalls || []).map((tc) => {
521
+ const name = tc?.function?.name;
522
+ if (name && isAgreementCreationTool(name, toolManager)) {
523
+ if (sawCreate) {
524
+ return {
525
+ tc,
526
+ skipExecute: true,
527
+ skipReason: initialSawCreate
528
+ ? 'agreement creation already executed earlier in this turn'
529
+ : 'duplicate agreement creation in same assistant message',
530
+ };
531
+ }
532
+ sawCreate = true;
533
+ return { tc, skipExecute: false };
534
+ }
535
+ if (name === 'task_update') {
536
+ let taskId = null;
537
+ try {
538
+ const a = JSON.parse(tc.function.arguments || '{}');
539
+ taskId = typeof a.taskId === 'string' ? a.taskId : null;
540
+ }
541
+ catch { }
542
+ const key = taskId || tc.id || '_';
543
+ if (seenUpdateTaskIds.has(key))
544
+ return { tc, skipExecute: true, skipReason: 'duplicate task_update for same taskId' };
545
+ seenUpdateTaskIds.add(key);
546
+ return { tc, skipExecute: false };
547
+ }
548
+ return { tc, skipExecute: false };
549
+ });
550
+ }
551
+ function parseLLMDecision(llmResponse, allowedActionNames, actions) {
552
+ if (llmResponse?.tool_calls?.length) {
553
+ const tc = llmResponse.tool_calls[0];
554
+ if (!tc?.function?.name)
555
+ return { ok: false, error: 'tool call had no function name' };
556
+ let args = {};
557
+ try {
558
+ args = JSON.parse(tc.function.arguments || '{}');
559
+ }
560
+ catch { }
561
+ const matchingAction = findActionForTool(tc.function.name, allowedActionNames, actions);
562
+ if (!matchingAction)
563
+ return { ok: false, error: `tool "${tc.function.name}" is not bound to any action in this state` };
564
+ return { ok: true, decision: { thought: `Using tool: ${tc.function.name}`, action: matchingAction, tool: tc.function.name, args } };
565
+ }
566
+ const parsed = safeParseJSON(llmResponse?.content || '', null);
567
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
568
+ return { ok: false, error: 'output was not a JSON object' };
569
+ }
570
+ if (!parsed['action'] || !allowedActionNames.includes(parsed['action'])) {
571
+ return { ok: false, error: `missing or unknown "action" — allowed: ${allowedActionNames.join(', ')}` };
572
+ }
573
+ return { ok: true, decision: parsed };
574
+ }
575
+ function findActionForTool(toolName, allowedActionNames, actions) {
576
+ if (!toolName)
577
+ return null;
578
+ const matches = allowedActionNames.filter(n => actions[n]?.tool === toolName);
579
+ if (matches.length === 1)
580
+ return matches[0];
581
+ if (matches.length > 1) {
582
+ runtimeLog.warn('runTurn', `ambiguous: ${matches.length} actions bind tool "${toolName}"`);
583
+ return null;
584
+ }
585
+ return null;
586
+ }
587
+ async function executeDecision(decision, actionDef, eff, sessionId, memory, incomingEvent = null, onUsage) {
588
+ const boundTool = actionDef?.tool || null;
589
+ if (boundTool) {
590
+ decision.tool = boundTool;
591
+ return await executeTool(decision, eff, sessionId, memory, incomingEvent, onUsage);
592
+ }
593
+ if (decision.action === '_genericDiscoveryTool' && decision.tool) {
594
+ return await executeTool(decision, eff, sessionId, memory, incomingEvent, onUsage);
595
+ }
596
+ if (decision.tool)
597
+ return await executeTool(decision, eff, sessionId, memory, incomingEvent, onUsage);
598
+ if (decision.messages && Array.isArray(decision.messages) && decision.messages.length > 0) {
599
+ return await executeSendMessages(decision, eff, sessionId);
600
+ }
601
+ if (decision.message && decision.receiverId) {
602
+ return await executeSendMessage(decision, eff, sessionId);
603
+ }
604
+ // Fallback: LLM may place message/receiverId inside args due to format ambiguity
605
+ // between prompt.format ("args: { receiverId, message }") and the top-level schema.
606
+ if (!decision.message && !decision.receiverId && decision.args) {
607
+ const a = decision.args;
608
+ if (typeof a.message === 'string' && typeof a.receiverId === 'string') {
609
+ return await executeSendMessage({ ...decision, message: a.message, receiverId: a.receiverId }, eff, sessionId);
610
+ }
611
+ }
612
+ // An action with no tool and no message that is named "wait" OR statically
613
+ // declares `produces: 'wait'` (e.g. "pause"/"hold") is an explicit
614
+ // do-nothing — emit `waited` so the turn ends honestly instead of falling
615
+ // through to the unexecutable-decision reprompt.
616
+ if (decision.action === 'wait' || actionDef?.produces === 'wait')
617
+ return { type: 'waited' };
618
+ return null;
619
+ }
620
+ function truncateMsg(msg) {
621
+ if (typeof msg !== 'string')
622
+ return String(msg ?? '');
623
+ if (msg.length <= MAX_MSG_CHARS)
624
+ return msg;
625
+ return msg.slice(0, MAX_MSG_CHARS) + `\n\n[truncated to ${MAX_MSG_CHARS} of ${msg.length} chars]`;
626
+ }
627
+ async function executeSendMessage(decision, eff, sessionId) {
628
+ // Callers (executeDecision) guarantee message + receiverId, and sessionId is
629
+ // always ctx.identity.sessionId. A send failure throws to the turn loop,
630
+ // which converts it into the 'send-failed' error outcome — no silent nulls,
631
+ // and message_sent is only emitted after the send effect resolved.
632
+ // `decision.chatId` is what the LLM emitted in its JSON decision; honor it
633
+ // for cross-session sends (e.g. reporting to a parent task chat), fall back
634
+ // to the current session otherwise.
635
+ const targetSessionId = decision.chatId || sessionId;
636
+ const safeMessage = truncateMsg(decision.message);
637
+ await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: decision.receiverId, text: safeMessage });
638
+ const events = [{ type: 'message_sent', receiverId: decision.receiverId, chatId: targetSessionId, message: safeMessage, senderId: 'system' }];
639
+ if (targetSessionId !== sessionId)
640
+ events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' });
641
+ return events;
642
+ }
643
+ /**
644
+ * Multi-message send. `message_sent` is emitted ONLY for sends that actually
645
+ * succeeded. Malformed entries (missing receiverId/message) and transport
646
+ * failures are collected: if nothing was even attempted the caller sees null
647
+ * and reprompts (LLM output defect); if every attempted send failed the whole
648
+ * action throws (→ 'send-failed' error outcome); a partial failure keeps the
649
+ * successful sends and logs loudly — reprompting after partial execution
650
+ * would risk duplicate messages.
651
+ */
652
+ async function executeSendMessages(decision, eff, sessionId) {
653
+ const events = [];
654
+ const failures = [];
655
+ let attempted = 0;
656
+ for (const msg of decision.messages ?? []) {
657
+ if (!msg.receiverId || !msg.message) {
658
+ failures.push({ receiverId: msg.receiverId || '<missing>', error: 'missing receiverId or message' });
659
+ continue;
660
+ }
661
+ const targetSessionId = msg.chatId || sessionId;
662
+ const safeMessage = truncateMsg(msg.message);
663
+ attempted++;
664
+ try {
665
+ await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: msg.receiverId, text: safeMessage });
666
+ }
667
+ catch (err) {
668
+ // Scenario: backend rejected or transport dropped this one send. Record
669
+ // it and keep going — remaining recipients must still get their message.
670
+ failures.push({ receiverId: msg.receiverId, error: err.message });
671
+ continue;
672
+ }
673
+ events.push({ type: 'message_sent', receiverId: msg.receiverId, chatId: targetSessionId, message: safeMessage, senderId: 'system' });
674
+ if (targetSessionId !== sessionId)
675
+ events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' });
676
+ }
677
+ if (attempted === 0)
678
+ return null; // every entry malformed — LLM output defect, caller reprompts
679
+ if (events.length === 0) {
680
+ throw new Error(`all ${failures.length} send(s) failed: ${failures.map(f => `${f.receiverId}: ${f.error}`).join('; ')}`);
681
+ }
682
+ if (failures.length > 0) {
683
+ runtimeLog.error('runTurn', `partial send failure (${failures.length} of ${(decision.messages ?? []).length} entries): ${failures.map(f => `${f.receiverId}: ${f.error}`).join('; ')}`);
684
+ }
685
+ return events;
686
+ }
687
+ /**
688
+ * Tool execution becomes a single effect call. The Server is responsible for
689
+ * dispatching the named tool to its implementation, providing the impure
690
+ * dependencies (taskService, agreementService, agents list, etc.), and
691
+ * wrapping the call in telemetry / operation-record entries if it wants.
692
+ */
693
+ async function executeTool(decision, eff, sessionId, memory, incomingEvent = null, onUsage) {
694
+ const { tool, args } = decision;
695
+ if (!tool)
696
+ return null;
697
+ try {
698
+ const r = await eff({
699
+ kind: 'tool-call',
700
+ sessionId,
701
+ name: tool,
702
+ args: args || {},
703
+ memory,
704
+ senderId: incomingEvent?.senderId != null ? String(incomingEvent.senderId) : undefined,
705
+ senderType: incomingEvent?.senderType != null ? String(incomingEvent.senderType) : undefined,
706
+ // ZIG-831: the subcontract agreement this wake is under (task events carry
707
+ // it under `result.agreementId`) so a delegated per-user MCP call can
708
+ // resolve the originating end-user, not the delegating agent (senderId).
709
+ onBehalfOfAgreementId: (() => {
710
+ const aid = incomingEvent?.result?.agreementId;
711
+ return typeof aid === 'string' && aid ? aid : undefined;
712
+ })(),
713
+ });
714
+ if (r.usage)
715
+ onUsage?.(r.usage);
716
+ if (!r.ok)
717
+ return { type: 'tool_error', tool, error: r.error || 'unknown', senderId: 'system' };
718
+ // Tools may emit terminal markers (e.g. `message_sent`, `waited`) via
719
+ // `events` — propagate so the loop can detect terminal turns.
720
+ if (r.events && r.events.length > 0)
721
+ return r.events;
722
+ return { type: 'tool_result', tool, result: r.result, senderId: 'system' };
723
+ }
724
+ catch (error) {
725
+ return { type: 'tool_error', tool, error: error?.message ?? String(error), senderId: 'system' };
726
+ }
727
+ }
728
+ function shouldEchoToolSummaryToUser(result) {
729
+ if (!result || typeof result !== 'object')
730
+ return false;
731
+ const r = result;
732
+ if (typeof r.message !== 'string' || !r.message.trim())
733
+ return false;
734
+ if (r.success === false)
735
+ return false;
736
+ if (r.error)
737
+ return false;
738
+ return true;
739
+ }
740
+ async function echoOptInToolSummaryIfNeeded({ allEmittedEvents, eff, sessionId, context, toolManager, incomingEvent }) {
741
+ if (!eff || !sessionId || !context || !toolManager?.getTool)
742
+ return;
743
+ if (allEmittedEvents.some((e) => e?.type === 'message_sent'))
744
+ return;
745
+ let summary = null;
746
+ for (const ev of allEmittedEvents) {
747
+ if (ev?.type !== 'tool_result' || !ev.tool)
748
+ continue;
749
+ const reg = toolManager.getTool(ev.tool);
750
+ if (!reg?.echoUserSummaryOnSuccess)
751
+ continue;
752
+ if (!shouldEchoToolSummaryToUser(ev.result))
753
+ continue;
754
+ summary = ev.result.message;
755
+ }
756
+ if (!summary)
757
+ return;
758
+ const receiverId = pickReceiverForEcho(context, incomingEvent);
759
+ if (!receiverId)
760
+ return;
761
+ const safe = truncateMsg(summary);
762
+ const wasTruncated = safe !== summary;
763
+ try {
764
+ await eff({ kind: 'send-message', sessionId, receiverId, text: safe });
765
+ }
766
+ catch {
767
+ return;
768
+ }
769
+ const event = { type: 'message_sent', receiverId, chatId: sessionId, message: safe, senderId: 'system' };
770
+ if (wasTruncated) {
771
+ event['truncated'] = true;
772
+ event['originalLength'] = summary.length;
773
+ }
774
+ allEmittedEvents.push(event);
775
+ }
776
+ function pickReceiverForEcho(context, incomingEvent) {
777
+ const senderId = incomingEvent?.senderId;
778
+ const senderType = String(incomingEvent?.senderType || '').toUpperCase();
779
+ if (senderId && senderType === 'USER')
780
+ return String(senderId);
781
+ return pickPrimaryUserIdFromContext(context);
782
+ }
783
+ function pickPrimaryUserIdFromContext(context) {
784
+ const users = context?.users || [];
785
+ for (const u of users) {
786
+ const id = (u?.userId || u?.id);
787
+ if (id && typeof id === 'string')
788
+ return id;
789
+ }
790
+ const hist = context?.history || [];
791
+ for (let i = hist.length - 1; i >= 0; i--) {
792
+ const row = hist[i];
793
+ const sender = row?.sender;
794
+ const type = String(sender?.type || row?.senderType || '').toUpperCase();
795
+ const id = (sender?.id ?? row?.senderId);
796
+ if (type === 'USER' && id)
797
+ return id;
798
+ }
799
+ return null;
800
+ }