@ziggs-ai/agent-sdk 0.1.3 → 0.1.5

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 (98) hide show
  1. package/README.md +3 -1
  2. package/package.json +9 -4
  3. package/src/AgentHost.ts +495 -0
  4. package/src/adapters/OpenAIAdapter.ts +146 -0
  5. package/src/agent/Agent.ts +101 -0
  6. package/src/cognition/validateContext.ts +95 -0
  7. package/src/context/applyEffects.ts +80 -0
  8. package/src/context/batch.ts +17 -0
  9. package/src/context/classifyEnvelope.ts +38 -0
  10. package/src/context/routingLabels.ts +46 -0
  11. package/src/defineAgent.ts +62 -0
  12. package/src/formatters/AgreementFormatter.ts +111 -0
  13. package/src/formatters/HistoryFormatter.ts +166 -0
  14. package/src/formatters/index.ts +2 -0
  15. package/src/index.ts +105 -0
  16. package/src/ingress/normalizeIncoming.ts +162 -0
  17. package/src/memory/MemoryStore.ts +104 -0
  18. package/src/pricing/fleetDefaults.ts +218 -0
  19. package/src/pricing/fleetEvalFree.ts +24 -0
  20. package/src/pricing/fleetFreeTierA.gen.ts +12 -0
  21. package/src/pricing/fleetTierByAgentId.gen.ts +1022 -0
  22. package/src/runtime/AgentMachine.ts +364 -0
  23. package/src/runtime/PromptBuilder.ts +463 -0
  24. package/src/runtime/buildOutcome.ts +518 -0
  25. package/src/runtime/defaults.ts +75 -0
  26. package/src/runtime/runTurn.ts +691 -0
  27. package/src/runtime/validateWorkflow.ts +181 -0
  28. package/src/server/ConnectionPool.ts +155 -0
  29. package/src/server/EventQueue.ts +133 -0
  30. package/src/server/InboxCatchUp.ts +251 -0
  31. package/src/server/OutboxBuffer.ts +90 -0
  32. package/src/server/SeenMessages.ts +27 -0
  33. package/src/server/ZiggsEffectHandler.ts +409 -0
  34. package/src/server/agreements/AgreementService.ts +117 -0
  35. package/src/server/createHealthServer.ts +85 -0
  36. package/src/server/proactive/ProactiveTrigger.ts +83 -0
  37. package/src/server/runLauncher.ts +146 -0
  38. package/src/server/tasks/TaskService.ts +110 -0
  39. package/src/server/tasks/index.ts +1 -0
  40. package/src/server/telemetryIngest.ts +91 -0
  41. package/src/server/tools/index.ts +46 -0
  42. package/src/server/tools/tier1/protocolRunner.ts +133 -0
  43. package/src/server/tools/tier1/protocolTools.ts +99 -0
  44. package/src/server/tools/tier2/connectionTools.ts +75 -0
  45. package/src/server/tools/tier2/contextTools.ts +74 -0
  46. package/src/server/tools/tier2/discoveryTools.ts +34 -0
  47. package/src/server/tools/tier2/marketplaceTools.ts +25 -0
  48. package/src/server/tools/tier2/paymentTools.ts +193 -0
  49. package/src/server/ziggsconnect/ZiggsConnectClient.ts +126 -0
  50. package/src/server/ziggscontext/ZiggsContextClient.ts +137 -0
  51. package/src/server/ziggspay/ZiggsPayClient.ts +193 -0
  52. package/src/shared/ids.ts +3 -0
  53. package/src/shared/runtimeLog.ts +72 -0
  54. package/src/shared/types.ts +29 -0
  55. package/src/tasks/protocolRegistry.ts +25 -0
  56. package/src/tools/ToolManager.ts +95 -0
  57. package/src/tools/{ToolProvider.js → ToolProvider.ts} +5 -15
  58. package/src/tools/defineTool.ts +90 -0
  59. package/src/tools/index.ts +5 -0
  60. package/src/types.ts +407 -0
  61. package/src/utils/jsonExtractor.ts +100 -0
  62. package/src/ConnectionPool.js +0 -133
  63. package/src/adapters/OpenAIAdapter.js +0 -73
  64. package/src/agent/Agent.js +0 -121
  65. package/src/agent/EventQueue.js +0 -68
  66. package/src/agent/OutboxBuffer.js +0 -62
  67. package/src/cognition/PromptBuilder.js +0 -312
  68. package/src/cognition/resolveActionTool.js +0 -12
  69. package/src/cognition/runTurn.js +0 -578
  70. package/src/context/applyEffects.js +0 -133
  71. package/src/context/batch.js +0 -25
  72. package/src/context/classifyEnvelope.js +0 -82
  73. package/src/context/routingLabels.js +0 -54
  74. package/src/createHealthServer.js +0 -28
  75. package/src/formatters/HistoryFormatter.js +0 -257
  76. package/src/formatters/TaskFormatter.js +0 -180
  77. package/src/formatters/index.js +0 -9
  78. package/src/index.js +0 -76
  79. package/src/ingress/normalizeIncoming.js +0 -70
  80. package/src/runLauncher.js +0 -159
  81. package/src/shared/ids.js +0 -7
  82. package/src/shared/types.js +0 -86
  83. package/src/tasks/TaskService.js +0 -247
  84. package/src/tasks/index.js +0 -9
  85. package/src/tasks/taskCore.js +0 -229
  86. package/src/tasks/taskProtocolRegistry.js +0 -22
  87. package/src/tasks/taskProtocolRunner.js +0 -107
  88. package/src/tasks/taskProtocolTools.js +0 -87
  89. package/src/tools/ToolManager.js +0 -79
  90. package/src/tools/defineTool.js +0 -82
  91. package/src/tools/index.js +0 -11
  92. package/src/utils/jsonExtractor.js +0 -139
  93. package/src/workflow/AgentMachine.js +0 -250
  94. package/src/workflow/WorkflowRuntime.js +0 -63
  95. package/src/workflow/dsl.js +0 -287
  96. package/src/workflow/motifs.js +0 -435
  97. package/src/ziggs/runtime.js +0 -192
  98. /package/src/adapters/{index.js → index.ts} +0 -0
@@ -0,0 +1,691 @@
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
+ const actionNames = Object.keys(actions);
173
+
174
+ runtimeLog.info('runTurn', `calling LLM tools=${tools.length}`);
175
+
176
+ for (let iter = 0; iter < MAX_TOOL_LOOP_ITERS; iter++) {
177
+ const llmResponse: LlmResponse = await effWithTrace({
178
+ kind: 'llm-call',
179
+ messages,
180
+ tools: tools as unknown as LlmToolSchema[],
181
+ sessionId,
182
+ });
183
+ llmCallsTotal++;
184
+ const usage = llmResponse?.usage;
185
+ tokensTotal += (usage?.total_tokens ?? usage?.totalTokens) || 0;
186
+ tokensInput += (usage?.prompt_tokens ?? usage?.promptTokens) || 0;
187
+ tokensOutput += (usage?.completion_tokens ?? usage?.completionTokens) || 0;
188
+
189
+ if (llmResponse?.tool_calls?.length) {
190
+ // Emit a thought for every tool-call turn so the chat timeline keeps
191
+ // showing the agent's reasoning. OpenAI's tool_call responses usually
192
+ // arrive with `content: null` (the standard "function calling"
193
+ // shape), so relying on `llmResponse.content` alone silently dropped
194
+ // the thought card whenever the agent chose to act via a tool —
195
+ // i.e. essentially every first turn that produces a proposal. Fall
196
+ // back to a deterministic "Calling tool: …" line so the user still
197
+ // sees that the agent decided on something, with the args appended
198
+ // when present.
199
+ const toolCallThought = (() => {
200
+ if (typeof llmResponse.content === 'string' && llmResponse.content.trim()) {
201
+ return llmResponse.content;
202
+ }
203
+ const fn = llmResponse.tool_calls[0]?.function;
204
+ if (!fn?.name) return null;
205
+ const argsBlob = (() => {
206
+ if (!fn.arguments) return '';
207
+ try {
208
+ const parsed = JSON.parse(fn.arguments);
209
+ const flat = JSON.stringify(parsed);
210
+ return flat.length > 240 ? ` ${flat.slice(0, 237)}…` : ` ${flat}`;
211
+ } catch {
212
+ return '';
213
+ }
214
+ })();
215
+ const suffix = llmResponse.tool_calls.length > 1
216
+ ? ` (+${llmResponse.tool_calls.length - 1} more)`
217
+ : '';
218
+ return `Calling tool: ${fn.name}${argsBlob}${suffix}`;
219
+ })();
220
+ await emitThought(toolCallThought);
221
+
222
+ const tcs = llmResponse.tool_calls;
223
+ const annotated = annotateToolCallDedup(tcs, toolManager, turnHadAgreementCreation);
224
+ const dupCount = annotated.filter(a => a.skipExecute).length;
225
+ if (dupCount > 0) {
226
+ runtimeLog.warn('runTurn', `iter=${iter} skipping ${dupCount} duplicate tool call(s) in same message`);
227
+ }
228
+
229
+ messages.push({ role: 'assistant', tool_calls: tcs });
230
+
231
+ const toolCallResults: Array<{ tc: LlmToolCall; matchingAction: string | null; result: unknown; skippedDuplicate?: boolean; skipReason?: string; args?: Record<string, unknown>; actionDef?: Action | null }> = [];
232
+ for (const ann of annotated) {
233
+ const { tc, skipExecute, skipReason } = ann;
234
+ if (skipExecute) {
235
+ toolCallResults.push({ tc, matchingAction: null, result: null, skippedDuplicate: true, skipReason });
236
+ continue;
237
+ }
238
+ const toolName = tc?.function?.name;
239
+ if (!toolName) {
240
+ toolCallResults.push({ tc, matchingAction: null, result: null });
241
+ continue;
242
+ }
243
+
244
+ let matchingAction = findActionForTool(toolName, actionNames, actions);
245
+ let actionDef: Action | null = matchingAction ? actions[matchingAction] : null;
246
+
247
+ if (!matchingAction && isGenericFallbackTool(toolName, toolManager) && toolManager.getTool?.(toolName)) {
248
+ matchingAction = '_genericDiscoveryTool';
249
+ actionDef = null;
250
+ } else if (!matchingAction) {
251
+ runtimeLog.info('runTurn', `iter=${iter} tool "${toolName}" not bound to any action — skipping`);
252
+ toolCallResults.push({ tc, matchingAction: null, result: null });
253
+ continue;
254
+ }
255
+
256
+ let args: Record<string, unknown> = {};
257
+ try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
258
+ const decision = { action: matchingAction, tool: toolName, args };
259
+
260
+ const result = await executeDecision(decision, actionDef, effWithTrace, sessionId, memory);
261
+ toolCallResults.push({ tc, matchingAction, result, args, actionDef });
262
+ }
263
+
264
+ let hasTerminal = false;
265
+ for (const item of toolCallResults) {
266
+ const { tc, matchingAction, result, skippedDuplicate, skipReason, args, actionDef } = item;
267
+ if (skippedDuplicate) {
268
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify({ skipped: true, reason: skipReason || 'deduplicated' }) });
269
+ continue;
270
+ }
271
+ if (!matchingAction || !result) {
272
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: '{"error":"unknown tool or no result"}' });
273
+ continue;
274
+ }
275
+
276
+ const events = (Array.isArray(result) ? result : [result]) as EmittedEvent[];
277
+ allEmittedEvents.push(...events);
278
+ lastAction = matchingAction;
279
+ lastActionDef = actionDef as Action | null;
280
+ lastArgs = args;
281
+
282
+ const firstEvent = (events[0] || {}) as EmittedEvent;
283
+ const toolResultContent = JSON.stringify(firstEvent.result ?? firstEvent);
284
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: toolResultContent });
285
+
286
+ if (!turnHadAgreementCreation && eventsContainAgreementCreation(events)) {
287
+ turnHadAgreementCreation = true;
288
+ }
289
+ if (eventsAreTerminal(events) || actionDef?.terminatesLoop) hasTerminal = true;
290
+ }
291
+
292
+ if (hasTerminal) break;
293
+ messages.push({ role: 'user', content: 'Tools done. Return your JSON decision now (follow the decision_schema). Do not write prose.' });
294
+
295
+ } else {
296
+ // — non-tool (JSON) response path —
297
+ const decision = parseLLMDecision(llmResponse, actionNames, actions);
298
+ if (!decision) break;
299
+ const chosenAction = decision.action;
300
+ const actionDef = actions[chosenAction];
301
+
302
+ if (decision.thought) await emitThought(decision.thought);
303
+
304
+ // 2-call streaming: if this is a respond action (has receiverId, no bound tool),
305
+ // use the streaming effect to generate text and send chunks to the client.
306
+ runtimeLog.info('runTurn', `streaming check: enabled=${streamRespondActions} receiverId=${decision.receiverId ?? 'none'} tool=${actionDef?.tool ?? 'none'} action=${chosenAction}`);
307
+ if (streamRespondActions && decision.receiverId && !actionDef?.tool && chosenAction !== 'wait') {
308
+ const streamMessageId = crypto.randomUUID();
309
+ runtimeLog.info('runTurn', `stream-text start action=${chosenAction} receiverId=${decision.receiverId} msgId=${streamMessageId}`);
310
+ const streamMessages: LlmMessage[] = [
311
+ ...messages,
312
+ {
313
+ role: 'assistant' as const,
314
+ content: JSON.stringify({ thought: decision.thought, action: decision.action, receiverId: decision.receiverId }),
315
+ },
316
+ {
317
+ role: 'user' as const,
318
+ content: 'Write your reply now. Output ONLY the message text — no JSON wrapping, no preamble.',
319
+ },
320
+ ];
321
+ let streamedText = '';
322
+ try {
323
+ const streamResult = await eff({ kind: 'stream-text', sessionId, receiverId: decision.receiverId, messageId: streamMessageId, messages: streamMessages });
324
+ streamedText = streamResult.text ?? '';
325
+ runtimeLog.info('runTurn', `stream-text done chars=${streamedText.length}`);
326
+ } catch (err) {
327
+ runtimeLog.warn('runTurn', `stream-text failed: ${(err as Error).message}`);
328
+ }
329
+ if (streamedText) {
330
+ const safeText = truncateMsg(streamedText);
331
+ await eff({ kind: 'send-message', sessionId, receiverId: decision.receiverId, text: safeText, messageId: streamMessageId });
332
+ const events: EmittedEvent[] = [{ type: 'message_sent', receiverId: decision.receiverId, chatId: sessionId, message: safeText, senderId: 'system' }];
333
+ allEmittedEvents.push(...events);
334
+ lastAction = chosenAction;
335
+ lastActionDef = actionDef;
336
+ }
337
+ break;
338
+ }
339
+
340
+ const result = await executeDecision(decision, actionDef, eff, sessionId, memory);
341
+ if (result) {
342
+ const events = (Array.isArray(result) ? result : [result]) as EmittedEvent[];
343
+ allEmittedEvents.push(...events);
344
+ lastAction = chosenAction;
345
+ lastActionDef = actionDef;
346
+ lastArgs = decision.args;
347
+
348
+ if (actionDef?.tool) {
349
+ const firstEvent = (events[0] || {}) as EmittedEvent;
350
+ const toolResultContent = JSON.stringify(firstEvent.result ?? firstEvent);
351
+ messages.push({ role: 'assistant', content: llmResponse.content });
352
+ messages.push({ role: 'user', content: `Tool "${actionDef.tool}" result:\n${toolResultContent}\n\nContinue with your next action.` });
353
+ if (!turnHadAgreementCreation && eventsContainAgreementCreation(events)) {
354
+ turnHadAgreementCreation = true;
355
+ }
356
+ if (eventsAreTerminal(events)) break;
357
+ continue;
358
+ }
359
+ }
360
+ break;
361
+ }
362
+ }
363
+
364
+ await echoOptInToolSummaryIfNeeded({
365
+ allEmittedEvents, eff, sessionId,
366
+ context: ctxObj, toolManager, incomingEvent,
367
+ });
368
+
369
+ const toolResults: ToolResultEntry[] = [];
370
+ const outcome = outcomeFromActionResult(lastAction, lastActionDef, lastArgs, allEmittedEvents, toolResults);
371
+
372
+ runtimeLog.info('runTurn', `done state=${stateId} action=${lastAction} outcome=${outcome.kind} llmCalls=${llmCallsTotal}`);
373
+
374
+ return { outcome, toolResults, llmCalls: llmCallsTotal, tokens: { total: tokensTotal, input: tokensInput, output: tokensOutput } };
375
+ }
376
+
377
+ // ─────────────────────────────────────────────────────────────────────────────
378
+
379
+ function extractIncomingMessageText(event: RawEvent | null): string {
380
+ if (!event) return '';
381
+ const parts: string[] = [];
382
+ const push = (v: unknown) => {
383
+ if (typeof v === 'string' && v.trim()) parts.push(v.trim());
384
+ };
385
+ push(event.text);
386
+ push((event as Record<string, unknown>).message);
387
+ const agreement = (event as Record<string, unknown>).agreement as Record<string, unknown> | undefined;
388
+ if (agreement) {
389
+ const terms = agreement.terms as Record<string, unknown> | undefined;
390
+ push(terms?.description);
391
+ push(JSON.stringify(agreement));
392
+ }
393
+ const task = (event as Record<string, unknown>).task;
394
+ if (task) push(JSON.stringify(task));
395
+ return parts.join('\n');
396
+ }
397
+
398
+ function normalizeContextSnapshot(raw: ContextSnapshot | undefined | null): ContextSnapshot {
399
+ if (!raw || typeof raw !== 'object') {
400
+ return { history: [], agreements: [], agents: [], users: [] };
401
+ }
402
+ const out = { ...raw } as Record<string, unknown>;
403
+ for (const key of ['history', 'agreements', 'agents', 'users']) {
404
+ if (!Array.isArray(out[key])) out[key] = [];
405
+ }
406
+ return out as unknown as ContextSnapshot;
407
+ }
408
+
409
+ function eventsAreTerminal(events: EmittedEvent[]): boolean {
410
+ return events.some(e => {
411
+ if (!e) return false;
412
+ const t = e.type;
413
+ if (t === 'message_sent' || t === 'message_duplicate_skipped' || t === 'waited') return true;
414
+ // Successful protocol-level state changes (agreement creation, task
415
+ // assignment/closure) end the turn. The next turn re-enters with the
416
+ // matching outcome and the workflow takes it from there. Without this,
417
+ // the LLM is nudged with "Continue with your next action." and often
418
+ // re-issues the same agreement_propose, producing duplicate proposal
419
+ // cards within a single user turn.
420
+ if (t === 'tool_result' || t === 'task_result') {
421
+ const r = (e.result as Record<string, unknown>) || {};
422
+ if (r.success === false || r.error) return false;
423
+ // Support both nested { agreement: {...} } and flat Agreement objects
424
+ // (api-client now returns Agreement directly, not wrapped in { agreement }).
425
+ const ag = (r.agreement ?? (r.agreementId ? r : null)) as Record<string, unknown> | null | undefined;
426
+ const task = (r.task || (r.taskId ? r : null)) as Record<string, unknown> | null | undefined;
427
+ const agProposal = ag?.proposal as Record<string, unknown> | undefined;
428
+ const taskProposal = task?.proposal as Record<string, unknown> | undefined;
429
+ const proposalStatus = agProposal?.status || taskProposal?.status;
430
+ if (proposalStatus === 'pending' || proposalStatus === 'approved') return true;
431
+ const state = task?.state || r.state;
432
+ if (state === 'active' || state === 'in-progress' ||
433
+ state === 'completed' || state === 'failed' || state === 'cancelled') {
434
+ return true;
435
+ }
436
+ }
437
+ return false;
438
+ });
439
+ }
440
+
441
+ function eventsContainAgreementCreation(events: EmittedEvent[]): boolean {
442
+ return events.some(e => {
443
+ if (!e || e.type !== 'tool_result') return false;
444
+ const r = (e.result as Record<string, unknown>) || {};
445
+ if (r.success === false || r.error) return false;
446
+ // Support both nested { agreement: {...} } and flat Agreement objects.
447
+ const ag = (r.agreement ?? (r.agreementId ? r : null)) as Record<string, unknown> | undefined;
448
+ const agProposal = ag?.proposal as Record<string, unknown> | undefined;
449
+ const task = r.task as Record<string, unknown> | undefined;
450
+ const taskProposal = task?.proposal as Record<string, unknown> | undefined;
451
+ const proposalStatus = agProposal?.status || taskProposal?.status;
452
+ return proposalStatus === 'pending' || proposalStatus === 'approved';
453
+ });
454
+ }
455
+
456
+ function annotateToolCallDedup(
457
+ toolCalls: LlmToolCall[],
458
+ toolManager: ToolManager,
459
+ initialSawCreate = false,
460
+ ): Array<{ tc: LlmToolCall; skipExecute: boolean; skipReason?: string }> {
461
+ let sawCreate = initialSawCreate;
462
+ const seenUpdateTaskIds = new Set<string>();
463
+ return (toolCalls || []).map((tc: LlmToolCall) => {
464
+ const name = tc?.function?.name;
465
+ if (name && isAgreementCreationTool(name, toolManager)) {
466
+ if (sawCreate) {
467
+ return {
468
+ tc,
469
+ skipExecute: true,
470
+ skipReason: initialSawCreate
471
+ ? 'agreement creation already executed earlier in this turn'
472
+ : 'duplicate agreement creation in same assistant message',
473
+ };
474
+ }
475
+ sawCreate = true;
476
+ return { tc, skipExecute: false };
477
+ }
478
+ if (name === 'task_update') {
479
+ let taskId: string | null = null;
480
+ try { const a = JSON.parse(tc.function.arguments || '{}'); taskId = typeof a.taskId === 'string' ? a.taskId : null; } catch {}
481
+ const key = taskId || tc.id || '_';
482
+ if (seenUpdateTaskIds.has(key)) return { tc, skipExecute: true, skipReason: 'duplicate task_update for same taskId' };
483
+ seenUpdateTaskIds.add(key);
484
+ return { tc, skipExecute: false };
485
+ }
486
+ return { tc, skipExecute: false };
487
+ });
488
+ }
489
+
490
+ function parseLLMDecision(llmResponse: LlmResponse, allowedActionNames: string[], actions: Record<string, Action>): ParsedDecision | null {
491
+ if (llmResponse?.tool_calls?.length) {
492
+ const tc = llmResponse.tool_calls[0];
493
+ if (!tc?.function?.name) return null;
494
+ let args: Record<string, unknown> = {};
495
+ try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
496
+ const matchingAction = findActionForTool(tc.function.name!, allowedActionNames, actions);
497
+ if (!matchingAction) return null;
498
+ return { thought: `Using tool: ${tc.function.name}`, action: matchingAction, tool: tc.function.name, args };
499
+ }
500
+
501
+ const parsed = safeParseJSON(llmResponse?.content || '', null) as Record<string, unknown> | null;
502
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
503
+ if (!parsed['action'] || !allowedActionNames.includes(parsed['action'] as string)) return null;
504
+ return parsed as unknown as ParsedDecision;
505
+ }
506
+
507
+ function findActionForTool(toolName: string, allowedActionNames: string[], actions: Record<string, Action>): string | null {
508
+ if (!toolName) return null;
509
+ const matches = allowedActionNames.filter(n => actions[n]?.tool === toolName);
510
+ if (matches.length === 1) return matches[0];
511
+ if (matches.length > 1) {
512
+ runtimeLog.warn('runTurn', `ambiguous: ${matches.length} actions bind tool "${toolName}"`);
513
+ return null;
514
+ }
515
+ return null;
516
+ }
517
+
518
+ async function executeDecision(
519
+ decision: ParsedDecision,
520
+ actionDef: Action | null,
521
+ eff: EffectHandler,
522
+ sessionId: string,
523
+ memory?: MemoryStore,
524
+ incomingEvent: RawEvent | null = null,
525
+ ): Promise<EmittedEvent[] | EmittedEvent | null> {
526
+ const boundTool = actionDef?.tool || null;
527
+
528
+ if (boundTool) {
529
+ decision.tool = boundTool;
530
+ return await executeTool(decision, eff, sessionId, memory, incomingEvent);
531
+ }
532
+ if (decision.action === '_genericDiscoveryTool' && decision.tool) {
533
+ return await executeTool(decision, eff, sessionId, memory, incomingEvent);
534
+ }
535
+ if (decision.tool) return await executeTool(decision, eff, sessionId, memory, incomingEvent);
536
+ if (decision.messages && Array.isArray(decision.messages) && decision.messages.length > 0) {
537
+ return await executeSendMessages(decision, eff, sessionId);
538
+ }
539
+ if (decision.message && decision.receiverId) {
540
+ return await executeSendMessage(decision, eff, sessionId);
541
+ }
542
+ // Fallback: LLM may place message/receiverId inside args due to format ambiguity
543
+ // between prompt.format ("args: { receiverId, message }") and the top-level schema.
544
+ if (!decision.message && !decision.receiverId && decision.args) {
545
+ const a = decision.args as Record<string, unknown>;
546
+ if (typeof a.message === 'string' && typeof a.receiverId === 'string') {
547
+ return await executeSendMessage({ ...decision, message: a.message, receiverId: a.receiverId }, eff, sessionId);
548
+ }
549
+ }
550
+ if (decision.action === 'wait') return { type: 'waited' };
551
+ return null;
552
+ }
553
+
554
+ function truncateMsg(msg: unknown): string {
555
+ if (typeof msg !== 'string') return String(msg ?? '');
556
+ if (msg.length <= MAX_MSG_CHARS) return msg;
557
+ return msg.slice(0, MAX_MSG_CHARS) + `\n\n[truncated to ${MAX_MSG_CHARS} of ${msg.length} chars]`;
558
+ }
559
+
560
+ async function executeSendMessage(decision: ParsedDecision, eff: EffectHandler, sessionId: string | null): Promise<EmittedEvent[] | null> {
561
+ const { message, receiverId } = decision;
562
+ if (!message || !receiverId) return null;
563
+ // `decision.chatId` is what the LLM emitted in its JSON decision; honor it
564
+ // for cross-session sends (e.g. reporting to a parent task chat), fall back
565
+ // to the current session otherwise.
566
+ const targetSessionId = decision.chatId || sessionId;
567
+ const safeMessage = truncateMsg(message);
568
+ if (targetSessionId) {
569
+ await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: receiverId!, text: safeMessage });
570
+ }
571
+ const events: EmittedEvent[] = [{ type: 'message_sent', receiverId: receiverId!, chatId: targetSessionId ?? undefined, message: safeMessage, senderId: 'system' }];
572
+ if (targetSessionId !== sessionId) events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' });
573
+ return events;
574
+ }
575
+
576
+ async function executeSendMessages(decision: ParsedDecision, eff: EffectHandler, sessionId: string | null): Promise<EmittedEvent[]> {
577
+ const events: EmittedEvent[] = [];
578
+ for (const msg of decision.messages ?? []) {
579
+ if (!msg.receiverId || !msg.message) continue;
580
+ const targetSessionId = msg.chatId || sessionId;
581
+ const safeMessage = truncateMsg(msg.message);
582
+ if (targetSessionId) {
583
+ try { await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: msg.receiverId, text: safeMessage }); } catch { continue; }
584
+ }
585
+ events.push({ type: 'message_sent', receiverId: msg.receiverId, chatId: targetSessionId ?? undefined, message: safeMessage, senderId: 'system' });
586
+ if (targetSessionId !== sessionId) events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' } as unknown as EmittedEvent);
587
+ }
588
+ return events;
589
+ }
590
+
591
+ /**
592
+ * Tool execution becomes a single effect call. The Server is responsible for
593
+ * dispatching the named tool to its implementation, providing the impure
594
+ * dependencies (taskService, agreementService, agents list, etc.), and
595
+ * wrapping the call in telemetry / operation-record entries if it wants.
596
+ */
597
+ async function executeTool(
598
+ decision: ParsedDecision,
599
+ eff: EffectHandler,
600
+ sessionId: string,
601
+ memory?: MemoryStore,
602
+ incomingEvent: RawEvent | null = null,
603
+ ): Promise<EmittedEvent[] | EmittedEvent | null> {
604
+ const { tool, args } = decision;
605
+ if (!tool) return null;
606
+ try {
607
+ const r = await eff({
608
+ kind: 'tool-call',
609
+ sessionId,
610
+ name: tool,
611
+ args: args || {},
612
+ memory,
613
+ senderId: incomingEvent?.senderId != null ? String(incomingEvent.senderId) : undefined,
614
+ senderType: incomingEvent?.senderType != null ? String(incomingEvent.senderType) : undefined,
615
+ }) as ToolCallResult;
616
+ if (!r.ok) return { type: 'tool_error', tool, error: r.error || 'unknown', senderId: 'system' };
617
+ // Tools may emit terminal markers (e.g. `message_sent`, `waited`) via
618
+ // `events` — propagate so the loop can detect terminal turns.
619
+ if (r.events && (r.events as unknown[]).length > 0) return r.events as unknown as EmittedEvent[];
620
+ return { type: 'tool_result', tool, result: r.result as WireResult | undefined, senderId: 'system' };
621
+ } catch (error: unknown) {
622
+ return { type: 'tool_error', tool, error: (error as Error)?.message ?? String(error), senderId: 'system' };
623
+ }
624
+ }
625
+
626
+ function shouldEchoToolSummaryToUser(result: unknown): boolean {
627
+ if (!result || typeof result !== 'object') return false;
628
+ const r = result as Record<string, unknown>;
629
+ if (typeof r.message !== 'string' || !(r.message as string).trim()) return false;
630
+ if (r.success === false) return false;
631
+ if (r.error) return false;
632
+ return true;
633
+ }
634
+
635
+ async function echoOptInToolSummaryIfNeeded({ allEmittedEvents, eff, sessionId, context, toolManager, incomingEvent }: {
636
+ allEmittedEvents: EmittedEvent[];
637
+ eff: EffectHandler;
638
+ sessionId: string;
639
+ context: ContextSnapshot;
640
+ toolManager: ToolManager;
641
+ incomingEvent: RawEvent | null;
642
+ }): Promise<void> {
643
+ if (!eff || !sessionId || !context || !toolManager?.getTool) return;
644
+ if (allEmittedEvents.some((e) => e?.type === 'message_sent')) return;
645
+
646
+ let summary: string | null = null;
647
+ for (const ev of allEmittedEvents) {
648
+ if (ev?.type !== 'tool_result' || !ev.tool) continue;
649
+ const reg = toolManager.getTool(ev.tool);
650
+ if (!reg?.echoUserSummaryOnSuccess) continue;
651
+ if (!shouldEchoToolSummaryToUser(ev.result)) continue;
652
+ summary = (ev.result as Record<string, unknown>).message as string;
653
+ }
654
+ if (!summary) return;
655
+
656
+ const receiverId = pickReceiverForEcho(context, incomingEvent);
657
+ if (!receiverId) return;
658
+
659
+ const safe = truncateMsg(summary);
660
+ const wasTruncated = safe !== summary;
661
+ try {
662
+ await eff({ kind: 'send-message', sessionId, receiverId, text: safe });
663
+ } catch { return; }
664
+ const event: Record<string, unknown> = { type: 'message_sent', receiverId, chatId: sessionId, message: safe, senderId: 'system' };
665
+ if (wasTruncated) { event['truncated'] = true; event['originalLength'] = summary.length; }
666
+ allEmittedEvents.push(event as unknown as EmittedEvent);
667
+ }
668
+
669
+ function pickReceiverForEcho(context: ContextSnapshot, incomingEvent: RawEvent | null): string | null {
670
+ const senderId = incomingEvent?.senderId;
671
+ const senderType = String(incomingEvent?.senderType || '').toUpperCase();
672
+ if (senderId && senderType === 'USER') return String(senderId);
673
+ return pickPrimaryUserIdFromContext(context);
674
+ }
675
+
676
+ function pickPrimaryUserIdFromContext(context: ContextSnapshot): string | null {
677
+ const users = context?.users || [];
678
+ for (const u of users as Record<string, unknown>[]) {
679
+ const id = (u?.userId || u?.id) as string | undefined;
680
+ if (id && typeof id === 'string') return id;
681
+ }
682
+ const hist = context?.history || [];
683
+ for (let i = hist.length - 1; i >= 0; i--) {
684
+ const row = hist[i] as Record<string, unknown>;
685
+ const sender = row?.sender as Record<string, unknown> | undefined;
686
+ const type = String(sender?.type || row?.senderType || '').toUpperCase();
687
+ const id = (sender?.id ?? row?.senderId) as string | undefined;
688
+ if (type === 'USER' && id) return id;
689
+ }
690
+ return null;
691
+ }