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