@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
@@ -1,870 +0,0 @@
1
- import type {
2
- Action, Ctx, Outcome, ToolResultEntry,
3
- EffectHandler, LlmMessage, LlmResponse, LlmToolSchema, LlmToolCall,
4
- ContextSnapshot, ToolCallResult,
5
- } from '../types.js';
6
- import type { MemoryStore } from '../memory/MemoryStore.js';
7
- import { outcomeFromActionResult, type RawEvent, type EmittedEvent, type WireResult } from './buildOutcome.js';
8
- import { PromptBuilder } from './PromptBuilder.js';
9
- import { runtimeLog } from '../shared/runtimeLog.js';
10
- import { safeParseJSON } from '../utils/jsonExtractor.js';
11
- import type { ToolManager } from '../tools/ToolManager.js';
12
- import type { ToolDefinition } from '../tools/defineTool.js';
13
-
14
- type WireEvent = Record<string, unknown>;
15
-
16
- interface ParsedDecision {
17
- action: string;
18
- tool?: string;
19
- thought?: string;
20
- message?: string;
21
- messages?: Array<{ receiverId: string; message: string; chatId?: string }>;
22
- receiverId?: string;
23
- chatId?: string;
24
- args?: Record<string, unknown>;
25
- }
26
-
27
- const MAX_TOOL_LOOP_ITERS = 20;
28
- const MAX_MSG_CHARS = 3500;
29
-
30
- /**
31
- * Legacy "name set" classifications. The runtime now prefers per-tool flags
32
- * (`isAgreementCreation`, `isGenericFallback`) set via `defineTool` options,
33
- * but these sets remain as a fallback so tools authored before the flag API
34
- * existed continue to work without modification.
35
- */
36
- const LEGACY_AGREEMENT_CREATION_NAMES = new Set([
37
- 'agreement_propose',
38
- 'agreement_subcontract',
39
- ]);
40
- const LEGACY_GENERIC_FALLBACK_NAMES = new Set(['agent_network', 'task_board']);
41
-
42
- function isAgreementCreationTool(name: string, toolManager: ToolManager): boolean {
43
- if (!name) return false;
44
- if (toolManager?.getTool?.(name)?.isAgreementCreation === true) return true;
45
- return LEGACY_AGREEMENT_CREATION_NAMES.has(name);
46
- }
47
- function isGenericFallbackTool(name: string, toolManager: ToolManager): boolean {
48
- if (!name) return false;
49
- if (toolManager?.getTool?.(name)?.isGenericFallback === true) return true;
50
- return LEGACY_GENERIC_FALLBACK_NAMES.has(name);
51
- }
52
-
53
- interface RunTurnInput {
54
- stateId: string;
55
- statePrompt: import('../types.js').PromptDef;
56
- actions: Record<string, Action>;
57
- incomingEvent: RawEvent | null;
58
- ctx: Ctx;
59
- /** The only side channel. Every I/O — context, LLM, tools, send, record — flows through here. */
60
- eff: EffectHandler;
61
- /** Pure metadata: tool schemas, flags. Owned by the Agent. */
62
- toolManager: ToolManager;
63
- promptBuilder: PromptBuilder;
64
- /**
65
- * Agent-private long-term memory, threaded from AgentHost. Currently
66
- * surfaced into `eff` as part of the tool-call effect payload so tools
67
- * can read/write notes; the prompt builder doesn't consume it yet.
68
- */
69
- memory?: MemoryStore;
70
- definition: { description?: string };
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
- */
79
- streamRespondActions?: boolean;
80
- }
81
-
82
- interface RunTurnResult {
83
- outcome: Outcome;
84
- toolResults: ToolResultEntry[];
85
- llmCalls: number;
86
- tokens: { total: number; input: number; output: number };
87
- }
88
-
89
- /**
90
- * Run a single thinking-state turn.
91
- *
92
- * Reads context → builds prompt → tool loop with the LLM → executes one
93
- * action → returns exactly one Outcome via outcomeFromActionResult.
94
- *
95
- * No direct service calls. Every impure operation goes through `eff`:
96
- * • eff('read-context') — history/agreements/agents snapshot
97
- * • eff('llm-call') — model invocation
98
- * • eff('tool-call') — tool execution (Server runs the tool body)
99
- * • eff('send-message') — outbound peer message
100
- * • eff('record-event') — persist a thought / decision marker
101
- *
102
- * The Server is responsible for turning each effect into the right HTTP/WS
103
- * call and (optionally) wrapping it in telemetry / dedup / retries.
104
- */
105
- export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
106
- const { stateId, statePrompt, actions, incomingEvent, ctx, eff, toolManager, promptBuilder, memory, definition } = input;
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';
111
- const sessionId = ctx.identity.sessionId;
112
- const runId = crypto.randomUUID();
113
-
114
- // Wrap eff to stamp runId + stateId onto llm-call and tool-call effects.
115
- const effWithTrace: typeof eff = (effect) => {
116
- if (effect.kind === 'llm-call' || effect.kind === 'tool-call') {
117
- return eff({ ...effect, runId, stateId } as typeof effect);
118
- }
119
- return eff(effect);
120
- };
121
-
122
- const emitThought = async (text: unknown): Promise<void> => {
123
- if (text == null) return;
124
- const s = String(text).trim();
125
- if (s.length === 0) return;
126
- try {
127
- await eff({ kind: 'record-event', sessionId, entry: { kind: 'thought', text: s } });
128
- } catch (err) {
129
- runtimeLog.warn('runTurn', `record-event:thought failed: ${(err as Error).message}`);
130
- }
131
- };
132
-
133
- runtimeLog.info('runTurn', `enter state=${stateId} runId=${runId}`);
134
-
135
- // Parallel: read session context + collect tool schemas (pure).
136
- // Filter tools to only those bound to an action in the current state — the
137
- // runtime rejects tool calls that don't map to an allowed action (see
138
- // findActionForTool), so don't advertise tools the LLM can't actually use.
139
- const allTools: ToolDefinition[] = await Promise.resolve(toolManager?.getAvailableTools?.() ?? []);
140
- const allowedToolNames = new Set(
141
- Object.values(actions).map(a => a.tool).filter((t): t is string => !!t),
142
- );
143
- const toolName = (t: ToolDefinition): string | undefined => {
144
- const fn = (t.schema as Record<string, unknown>).function as Record<string, unknown> | undefined;
145
- return (fn?.name ?? (t.schema as Record<string, unknown>).name) as string | undefined;
146
- };
147
- const tools = allowedToolNames.size > 0
148
- ? allTools.filter(t => {
149
- const n = toolName(t);
150
- return n != null && allowedToolNames.has(n);
151
- })
152
- : [];
153
- const serverContext = await effWithTrace({ kind: 'read-context', sessionId, opts: {} });
154
- const ctxObj = normalizeContextSnapshot(serverContext);
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
-
172
- const prompt = promptBuilder.buildFromState({
173
- statePrompt, actions,
174
- serverContext: { context: ctxObj as Record<string, unknown>, tools: tools as unknown as Record<string, unknown>[] },
175
- incomingEvent, definition,
176
- chatId: sessionId, stateId, ctx,
177
- omitMessageFromRespond: streamRespondActions,
178
- });
179
-
180
- const systemContent = tools.length > 0
181
- ? '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.'
182
- : 'Respond with valid JSON.';
183
- const messages: LlmMessage[] = [
184
- { role: 'system', content: systemContent },
185
- { role: 'user', content: prompt },
186
- ];
187
-
188
- const allEmittedEvents: EmittedEvent[] = [];
189
- let lastAction: string | null = null;
190
- let lastActionDef: Action | null = null;
191
- let lastArgs: unknown = null;
192
- let llmCallsTotal = 0;
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
- };
203
- // Tracks whether a successful agreement creation has already happened in
204
- // this turn. Used by annotateToolCallDedup to reject any second creation
205
- // attempt across iterations of the tool loop.
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;
215
- const actionNames = Object.keys(actions);
216
-
217
- runtimeLog.info('runTurn', `calling LLM tools=${tools.length}`);
218
-
219
- for (let iter = 0; iter < MAX_TOOL_LOOP_ITERS; iter++) {
220
- const llmResponse: LlmResponse = await effWithTrace({
221
- kind: 'llm-call',
222
- messages,
223
- tools: tools as unknown as LlmToolSchema[],
224
- sessionId,
225
- });
226
- llmCallsTotal++;
227
- const usage = llmResponse?.usage;
228
- tokensTotal += (usage?.total_tokens ?? usage?.totalTokens) || 0;
229
- tokensInput += (usage?.prompt_tokens ?? usage?.promptTokens) || 0;
230
- tokensOutput += (usage?.completion_tokens ?? usage?.completionTokens) || 0;
231
-
232
- if (llmResponse?.tool_calls?.length) {
233
- // Emit a thought for every tool-call turn so the chat timeline keeps
234
- // showing the agent's reasoning. OpenAI's tool_call responses usually
235
- // arrive with `content: null` (the standard "function calling"
236
- // shape), so relying on `llmResponse.content` alone silently dropped
237
- // the thought card whenever the agent chose to act via a tool —
238
- // i.e. essentially every first turn that produces a proposal. Fall
239
- // back to a deterministic "Calling tool: …" line so the user still
240
- // sees that the agent decided on something, with the args appended
241
- // when present.
242
- const toolCallThought = (() => {
243
- if (typeof llmResponse.content === 'string' && llmResponse.content.trim()) {
244
- return llmResponse.content;
245
- }
246
- const fn = llmResponse.tool_calls[0]?.function;
247
- if (!fn?.name) return null;
248
- const argsBlob = (() => {
249
- if (!fn.arguments) return '';
250
- try {
251
- const parsed = JSON.parse(fn.arguments);
252
- const flat = JSON.stringify(parsed);
253
- return flat.length > 240 ? ` ${flat.slice(0, 237)}…` : ` ${flat}`;
254
- } catch {
255
- return '';
256
- }
257
- })();
258
- const suffix = llmResponse.tool_calls.length > 1
259
- ? ` (+${llmResponse.tool_calls.length - 1} more)`
260
- : '';
261
- return `Calling tool: ${fn.name}${argsBlob}${suffix}`;
262
- })();
263
- await emitThought(toolCallThought);
264
-
265
- const tcs = llmResponse.tool_calls;
266
- const annotated = annotateToolCallDedup(tcs, toolManager, turnHadAgreementCreation);
267
- const dupCount = annotated.filter(a => a.skipExecute).length;
268
- if (dupCount > 0) {
269
- runtimeLog.warn('runTurn', `iter=${iter} skipping ${dupCount} duplicate tool call(s) in same message`);
270
- }
271
-
272
- messages.push({ role: 'assistant', tool_calls: tcs });
273
-
274
- const toolCallResults: Array<{ tc: LlmToolCall; matchingAction: string | null; result: unknown; skippedDuplicate?: boolean; skipReason?: string; args?: Record<string, unknown>; actionDef?: Action | null }> = [];
275
- for (const ann of annotated) {
276
- const { tc, skipExecute, skipReason } = ann;
277
- if (skipExecute) {
278
- toolCallResults.push({ tc, matchingAction: null, result: null, skippedDuplicate: true, skipReason });
279
- continue;
280
- }
281
- const toolName = tc?.function?.name;
282
- if (!toolName) {
283
- toolCallResults.push({ tc, matchingAction: null, result: null });
284
- continue;
285
- }
286
-
287
- let matchingAction = findActionForTool(toolName, actionNames, actions);
288
- let actionDef: Action | null = matchingAction ? actions[matchingAction] : null;
289
-
290
- if (!matchingAction && isGenericFallbackTool(toolName, toolManager) && toolManager.getTool?.(toolName)) {
291
- matchingAction = '_genericDiscoveryTool';
292
- actionDef = null;
293
- } else if (!matchingAction) {
294
- runtimeLog.info('runTurn', `iter=${iter} tool "${toolName}" not bound to any action — skipping`);
295
- toolCallResults.push({ tc, matchingAction: null, result: null });
296
- continue;
297
- }
298
-
299
- let args: Record<string, unknown> = {};
300
- try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
301
- const decision = { action: matchingAction, tool: toolName, args };
302
-
303
- const result = await executeDecision(decision, actionDef, effWithTrace, sessionId, memory, null, addToolUsage);
304
- toolCallResults.push({ tc, matchingAction, result, args, actionDef });
305
- }
306
-
307
- let hasTerminal = false;
308
- for (const item of toolCallResults) {
309
- const { tc, matchingAction, result, skippedDuplicate, skipReason, args, actionDef } = item;
310
- if (skippedDuplicate) {
311
- messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify({ skipped: true, reason: skipReason || 'deduplicated' }) });
312
- continue;
313
- }
314
- if (!matchingAction || !result) {
315
- messages.push({ role: 'tool', tool_call_id: tc.id, content: '{"error":"unknown tool or no result"}' });
316
- continue;
317
- }
318
-
319
- const events = (Array.isArray(result) ? result : [result]) as EmittedEvent[];
320
- allEmittedEvents.push(...events);
321
- lastAction = matchingAction;
322
- lastActionDef = actionDef as Action | null;
323
- lastArgs = args;
324
-
325
- const firstEvent = (events[0] || {}) as EmittedEvent;
326
- const toolResultContent = JSON.stringify(firstEvent.result ?? firstEvent);
327
- messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResultContent });
328
-
329
- if (!turnHadAgreementCreation && eventsContainAgreementCreation(events)) {
330
- turnHadAgreementCreation = true;
331
- }
332
- if (eventsAreTerminal(events) || actionDef?.terminatesLoop) hasTerminal = true;
333
- }
334
-
335
- if (hasTerminal) break;
336
- messages.push({ role: 'user', content: 'Tools done. Return your JSON decision now (follow the decision_schema). Do not write prose.' });
337
-
338
- } else {
339
- // — non-tool (JSON) response path —
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;
354
- const chosenAction = decision.action;
355
- const actionDef = actions[chosenAction];
356
-
357
- if (decision.thought) await emitThought(decision.thought);
358
-
359
- // 2-call streaming: if this is a respond action (has receiverId, no bound tool),
360
- // use the streaming effect to generate text and send chunks to the client.
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') {
370
- const streamMessageId = crypto.randomUUID();
371
- runtimeLog.info('runTurn', `stream-text start action=${chosenAction} receiverId=${decision.receiverId} msgId=${streamMessageId}`);
372
- const streamMessages: LlmMessage[] = [
373
- ...messages,
374
- {
375
- role: 'assistant' as const,
376
- content: JSON.stringify({ thought: decision.thought, action: decision.action, receiverId: decision.receiverId }),
377
- },
378
- {
379
- role: 'user' as const,
380
- content: 'Write your reply now. Output ONLY the message text — no JSON wrapping, no preamble.',
381
- },
382
- ];
383
- let replyText = '';
384
- try {
385
- const streamResult = await eff({ kind: 'stream-text', sessionId, receiverId: decision.receiverId, messageId: streamMessageId, messages: streamMessages });
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
- }
400
- } catch (err) {
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`);
405
- }
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.
420
- await eff({ kind: 'send-message', sessionId, receiverId: decision.receiverId, text: safeText, messageId: streamMessageId });
421
- allEmittedEvents.push({ type: 'message_sent', receiverId: decision.receiverId, chatId: sessionId, message: safeText, senderId: 'system' });
422
- lastAction = chosenAction;
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' };
429
- }
430
- break;
431
- }
432
-
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;
473
- }
474
- if (eventsAreTerminal(events)) break;
475
- continue;
476
- }
477
- break;
478
- }
479
- }
480
-
481
- if (!turnError) {
482
- await echoOptInToolSummaryIfNeeded({
483
- allEmittedEvents, eff, sessionId,
484
- context: ctxObj, toolManager, incomingEvent,
485
- });
486
- }
487
-
488
- const toolResults: ToolResultEntry[] = [];
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
- }
496
-
497
- runtimeLog.info('runTurn', `done state=${stateId} action=${lastAction} outcome=${outcome.kind} llmCalls=${llmCallsTotal}`);
498
-
499
- return { outcome, toolResults, llmCalls: llmCallsTotal, tokens: { total: tokensTotal, input: tokensInput, output: tokensOutput } };
500
- }
501
-
502
- // ─────────────────────────────────────────────────────────────────────────────
503
-
504
- function extractIncomingMessageText(event: RawEvent | null): string {
505
- if (!event) return '';
506
- const parts: string[] = [];
507
- const push = (v: unknown) => {
508
- if (typeof v === 'string' && v.trim()) parts.push(v.trim());
509
- };
510
- push(event.text);
511
- push((event as Record<string, unknown>).message);
512
- const agreement = (event as Record<string, unknown>).agreement as Record<string, unknown> | undefined;
513
- if (agreement) {
514
- const terms = agreement.terms as Record<string, unknown> | undefined;
515
- push(terms?.description);
516
- push(JSON.stringify(agreement));
517
- }
518
- const task = (event as Record<string, unknown>).task;
519
- if (task) push(JSON.stringify(task));
520
- return parts.join('\n');
521
- }
522
-
523
- function normalizeContextSnapshot(raw: ContextSnapshot | undefined | null): ContextSnapshot {
524
- if (!raw || typeof raw !== 'object') {
525
- return { history: [], agreements: [], agents: [], users: [] };
526
- }
527
- const out = { ...raw } as Record<string, unknown>;
528
- for (const key of ['history', 'agreements', 'agents', 'users']) {
529
- if (!Array.isArray(out[key])) out[key] = [];
530
- }
531
- return out as unknown as ContextSnapshot;
532
- }
533
-
534
- function eventsAreTerminal(events: EmittedEvent[]): boolean {
535
- return events.some(e => {
536
- if (!e) return false;
537
- const t = e.type;
538
- if (t === 'message_sent' || t === 'message_duplicate_skipped' || t === 'waited') return true;
539
- // Successful protocol-level state changes (agreement creation, task
540
- // assignment/closure) end the turn. The next turn re-enters with the
541
- // matching outcome and the workflow takes it from there. Without this,
542
- // the LLM is nudged with "Continue with your next action." and often
543
- // re-issues the same agreement_propose, producing duplicate proposal
544
- // cards within a single user turn.
545
- if (t === 'tool_result' || t === 'task_result') {
546
- const r = (e.result as Record<string, unknown>) || {};
547
- if (r.success === false || r.error) return false;
548
- // Support both nested { agreement: {...} } and flat Agreement objects
549
- // (api-client now returns Agreement directly, not wrapped in { agreement }).
550
- const ag = (r.agreement ?? (r.agreementId ? r : null)) as Record<string, unknown> | null | undefined;
551
- const task = (r.task || (r.taskId ? r : null)) as Record<string, unknown> | null | undefined;
552
- // Agreement's own status lives flat as `proposalStatus` (see api-client
553
- // Agreement type) — not nested under `.proposal.status`. Check both so
554
- // a successful agreement_propose is recognized as terminal instead of
555
- // being re-issued by the LLM on the next loop iteration.
556
- const agProposal = ag?.proposal as Record<string, unknown> | undefined;
557
- const taskProposal = task?.proposal as Record<string, unknown> | undefined;
558
- const proposalStatus = ag?.proposalStatus ?? agProposal?.status ?? taskProposal?.status;
559
- if (proposalStatus === 'pending' || proposalStatus === 'approved') return true;
560
- const state = task?.state || r.state;
561
- if (state === 'active' || state === 'in-progress' ||
562
- state === 'completed' || state === 'failed' || state === 'cancelled') {
563
- return true;
564
- }
565
- }
566
- return false;
567
- });
568
- }
569
-
570
- function eventsContainAgreementCreation(events: EmittedEvent[]): boolean {
571
- return events.some(e => {
572
- if (!e || e.type !== 'tool_result') return false;
573
- const r = (e.result as Record<string, unknown>) || {};
574
- if (r.success === false || r.error) return false;
575
- // Support both nested { agreement: {...} } and flat Agreement objects.
576
- const ag = (r.agreement ?? (r.agreementId ? r : null)) as Record<string, unknown> | undefined;
577
- // See eventsAreTerminal above: proposalStatus is flat on Agreement, not nested.
578
- const agProposal = ag?.proposal as Record<string, unknown> | undefined;
579
- const task = r.task as Record<string, unknown> | undefined;
580
- const taskProposal = task?.proposal as Record<string, unknown> | undefined;
581
- const proposalStatus = ag?.proposalStatus ?? agProposal?.status ?? taskProposal?.status;
582
- return proposalStatus === 'pending' || proposalStatus === 'approved';
583
- });
584
- }
585
-
586
- function annotateToolCallDedup(
587
- toolCalls: LlmToolCall[],
588
- toolManager: ToolManager,
589
- initialSawCreate = false,
590
- ): Array<{ tc: LlmToolCall; skipExecute: boolean; skipReason?: string }> {
591
- let sawCreate = initialSawCreate;
592
- const seenUpdateTaskIds = new Set<string>();
593
- return (toolCalls || []).map((tc: LlmToolCall) => {
594
- const name = tc?.function?.name;
595
- if (name && isAgreementCreationTool(name, toolManager)) {
596
- if (sawCreate) {
597
- return {
598
- tc,
599
- skipExecute: true,
600
- skipReason: initialSawCreate
601
- ? 'agreement creation already executed earlier in this turn'
602
- : 'duplicate agreement creation in same assistant message',
603
- };
604
- }
605
- sawCreate = true;
606
- return { tc, skipExecute: false };
607
- }
608
- if (name === 'task_update') {
609
- let taskId: string | null = null;
610
- try { const a = JSON.parse(tc.function.arguments || '{}'); taskId = typeof a.taskId === 'string' ? a.taskId : null; } catch {}
611
- const key = taskId || tc.id || '_';
612
- if (seenUpdateTaskIds.has(key)) return { tc, skipExecute: true, skipReason: 'duplicate task_update for same taskId' };
613
- seenUpdateTaskIds.add(key);
614
- return { tc, skipExecute: false };
615
- }
616
- return { tc, skipExecute: false };
617
- });
618
- }
619
-
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 {
625
- if (llmResponse?.tool_calls?.length) {
626
- const tc = llmResponse.tool_calls[0];
627
- if (!tc?.function?.name) return { ok: false, error: 'tool call had no function name' };
628
- let args: Record<string, unknown> = {};
629
- try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
630
- const matchingAction = findActionForTool(tc.function.name!, allowedActionNames, actions);
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 } };
633
- }
634
-
635
- const parsed = safeParseJSON(llmResponse?.content || '', null) as Record<string, unknown> | null;
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 };
643
- }
644
-
645
- function findActionForTool(toolName: string, allowedActionNames: string[], actions: Record<string, Action>): string | null {
646
- if (!toolName) return null;
647
- const matches = allowedActionNames.filter(n => actions[n]?.tool === toolName);
648
- if (matches.length === 1) return matches[0];
649
- if (matches.length > 1) {
650
- runtimeLog.warn('runTurn', `ambiguous: ${matches.length} actions bind tool "${toolName}"`);
651
- return null;
652
- }
653
- return null;
654
- }
655
-
656
- async function executeDecision(
657
- decision: ParsedDecision,
658
- actionDef: Action | null,
659
- eff: EffectHandler,
660
- sessionId: string,
661
- memory?: MemoryStore,
662
- incomingEvent: RawEvent | null = null,
663
- onUsage?: (usage: LlmResponse['usage']) => void,
664
- ): Promise<EmittedEvent[] | EmittedEvent | null> {
665
- const boundTool = actionDef?.tool || null;
666
-
667
- if (boundTool) {
668
- decision.tool = boundTool;
669
- return await executeTool(decision, eff, sessionId, memory, incomingEvent, onUsage);
670
- }
671
- if (decision.action === '_genericDiscoveryTool' && decision.tool) {
672
- return await executeTool(decision, eff, sessionId, memory, incomingEvent, onUsage);
673
- }
674
- if (decision.tool) return await executeTool(decision, eff, sessionId, memory, incomingEvent, onUsage);
675
- if (decision.messages && Array.isArray(decision.messages) && decision.messages.length > 0) {
676
- return await executeSendMessages(decision, eff, sessionId);
677
- }
678
- if (decision.message && decision.receiverId) {
679
- return await executeSendMessage(decision, eff, sessionId);
680
- }
681
- // Fallback: LLM may place message/receiverId inside args due to format ambiguity
682
- // between prompt.format ("args: { receiverId, message }") and the top-level schema.
683
- if (!decision.message && !decision.receiverId && decision.args) {
684
- const a = decision.args as Record<string, unknown>;
685
- if (typeof a.message === 'string' && typeof a.receiverId === 'string') {
686
- return await executeSendMessage({ ...decision, message: a.message, receiverId: a.receiverId }, eff, sessionId);
687
- }
688
- }
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' };
694
- return null;
695
- }
696
-
697
- function truncateMsg(msg: unknown): string {
698
- if (typeof msg !== 'string') return String(msg ?? '');
699
- if (msg.length <= MAX_MSG_CHARS) return msg;
700
- return msg.slice(0, MAX_MSG_CHARS) + `\n\n[truncated to ${MAX_MSG_CHARS} of ${msg.length} chars]`;
701
- }
702
-
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.
708
- // `decision.chatId` is what the LLM emitted in its JSON decision; honor it
709
- // for cross-session sends (e.g. reporting to a parent task chat), fall back
710
- // to the current session otherwise.
711
- const targetSessionId = decision.chatId || sessionId;
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' }];
715
- if (targetSessionId !== sessionId) events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' });
716
- return events;
717
- }
718
-
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> {
729
- const events: EmittedEvent[] = [];
730
- const failures: Array<{ receiverId: string; error: string }> = [];
731
- let attempted = 0;
732
- for (const msg of decision.messages ?? []) {
733
- if (!msg.receiverId || !msg.message) {
734
- failures.push({ receiverId: msg.receiverId || '<missing>', error: 'missing receiverId or message' });
735
- continue;
736
- }
737
- const targetSessionId = msg.chatId || sessionId;
738
- const safeMessage = truncateMsg(msg.message);
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;
747
- }
748
- events.push({ type: 'message_sent', receiverId: msg.receiverId, chatId: targetSessionId, message: safeMessage, senderId: 'system' });
749
- if (targetSessionId !== sessionId) events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' } as unknown as EmittedEvent);
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
- }
758
- return events;
759
- }
760
-
761
- /**
762
- * Tool execution becomes a single effect call. The Server is responsible for
763
- * dispatching the named tool to its implementation, providing the impure
764
- * dependencies (taskService, agreementService, agents list, etc.), and
765
- * wrapping the call in telemetry / operation-record entries if it wants.
766
- */
767
- async function executeTool(
768
- decision: ParsedDecision,
769
- eff: EffectHandler,
770
- sessionId: string,
771
- memory?: MemoryStore,
772
- incomingEvent: RawEvent | null = null,
773
- onUsage?: (usage: LlmResponse['usage']) => void,
774
- ): Promise<EmittedEvent[] | EmittedEvent | null> {
775
- const { tool, args } = decision;
776
- if (!tool) return null;
777
- try {
778
- const r = await eff({
779
- kind: 'tool-call',
780
- sessionId,
781
- name: tool,
782
- args: args || {},
783
- memory,
784
- senderId: incomingEvent?.senderId != null ? String(incomingEvent.senderId) : undefined,
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
- })(),
793
- }) as ToolCallResult;
794
- if (r.usage) onUsage?.(r.usage);
795
- if (!r.ok) return { type: 'tool_error', tool, error: r.error || 'unknown', senderId: 'system' };
796
- // Tools may emit terminal markers (e.g. `message_sent`, `waited`) via
797
- // `events` — propagate so the loop can detect terminal turns.
798
- if (r.events && (r.events as unknown[]).length > 0) return r.events as unknown as EmittedEvent[];
799
- return { type: 'tool_result', tool, result: r.result as WireResult | undefined, senderId: 'system' };
800
- } catch (error: unknown) {
801
- return { type: 'tool_error', tool, error: (error as Error)?.message ?? String(error), senderId: 'system' };
802
- }
803
- }
804
-
805
- function shouldEchoToolSummaryToUser(result: unknown): boolean {
806
- if (!result || typeof result !== 'object') return false;
807
- const r = result as Record<string, unknown>;
808
- if (typeof r.message !== 'string' || !(r.message as string).trim()) return false;
809
- if (r.success === false) return false;
810
- if (r.error) return false;
811
- return true;
812
- }
813
-
814
- async function echoOptInToolSummaryIfNeeded({ allEmittedEvents, eff, sessionId, context, toolManager, incomingEvent }: {
815
- allEmittedEvents: EmittedEvent[];
816
- eff: EffectHandler;
817
- sessionId: string;
818
- context: ContextSnapshot;
819
- toolManager: ToolManager;
820
- incomingEvent: RawEvent | null;
821
- }): Promise<void> {
822
- if (!eff || !sessionId || !context || !toolManager?.getTool) return;
823
- if (allEmittedEvents.some((e) => e?.type === 'message_sent')) return;
824
-
825
- let summary: string | null = null;
826
- for (const ev of allEmittedEvents) {
827
- if (ev?.type !== 'tool_result' || !ev.tool) continue;
828
- const reg = toolManager.getTool(ev.tool);
829
- if (!reg?.echoUserSummaryOnSuccess) continue;
830
- if (!shouldEchoToolSummaryToUser(ev.result)) continue;
831
- summary = (ev.result as Record<string, unknown>).message as string;
832
- }
833
- if (!summary) return;
834
-
835
- const receiverId = pickReceiverForEcho(context, incomingEvent);
836
- if (!receiverId) return;
837
-
838
- const safe = truncateMsg(summary);
839
- const wasTruncated = safe !== summary;
840
- try {
841
- await eff({ kind: 'send-message', sessionId, receiverId, text: safe });
842
- } catch { return; }
843
- const event: Record<string, unknown> = { type: 'message_sent', receiverId, chatId: sessionId, message: safe, senderId: 'system' };
844
- if (wasTruncated) { event['truncated'] = true; event['originalLength'] = summary.length; }
845
- allEmittedEvents.push(event as unknown as EmittedEvent);
846
- }
847
-
848
- function pickReceiverForEcho(context: ContextSnapshot, incomingEvent: RawEvent | null): string | null {
849
- const senderId = incomingEvent?.senderId;
850
- const senderType = String(incomingEvent?.senderType || '').toUpperCase();
851
- if (senderId && senderType === 'USER') return String(senderId);
852
- return pickPrimaryUserIdFromContext(context);
853
- }
854
-
855
- function pickPrimaryUserIdFromContext(context: ContextSnapshot): string | null {
856
- const users = context?.users || [];
857
- for (const u of users as Record<string, unknown>[]) {
858
- const id = (u?.userId || u?.id) as string | undefined;
859
- if (id && typeof id === 'string') return id;
860
- }
861
- const hist = context?.history || [];
862
- for (let i = hist.length - 1; i >= 0; i--) {
863
- const row = hist[i] as Record<string, unknown>;
864
- const sender = row?.sender as Record<string, unknown> | undefined;
865
- const type = String(sender?.type || row?.senderType || '').toUpperCase();
866
- const id = (sender?.id ?? row?.senderId) as string | undefined;
867
- if (type === 'USER' && id) return id;
868
- }
869
- return null;
870
- }