@ziggs-ai/agent-sdk 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +1 -1
  2. package/package.json +9 -4
  3. package/src/AgentHost.ts +342 -0
  4. package/src/adapters/OpenAIAdapter.ts +125 -0
  5. package/src/agent/Agent.ts +98 -0
  6. package/src/cognition/validateContext.ts +95 -0
  7. package/src/context/applyEffects.ts +80 -0
  8. package/src/context/batch.ts +17 -0
  9. package/src/context/classifyEnvelope.ts +38 -0
  10. package/src/context/routingLabels.ts +46 -0
  11. package/src/defineAgent.ts +62 -0
  12. package/src/formatters/AgreementFormatter.ts +111 -0
  13. package/src/formatters/HistoryFormatter.ts +166 -0
  14. package/src/formatters/index.ts +2 -0
  15. package/src/index.ts +86 -0
  16. package/src/ingress/normalizeIncoming.ts +119 -0
  17. package/src/memory/MemoryStore.ts +104 -0
  18. package/src/runtime/AgentMachine.ts +298 -0
  19. package/src/runtime/PromptBuilder.ts +461 -0
  20. package/src/runtime/buildOutcome.ts +488 -0
  21. package/src/runtime/defaults.ts +72 -0
  22. package/src/runtime/runTurn.ts +637 -0
  23. package/src/runtime/validateWorkflow.ts +165 -0
  24. package/src/server/ConnectionPool.ts +155 -0
  25. package/src/server/EventQueue.ts +119 -0
  26. package/src/server/OutboxBuffer.ts +90 -0
  27. package/src/server/ZiggsEffectHandler.ts +335 -0
  28. package/src/server/agreements/AgreementService.ts +111 -0
  29. package/src/server/createHealthServer.ts +8 -0
  30. package/src/server/proactive/ProactiveTrigger.ts +83 -0
  31. package/src/server/runLauncher.ts +131 -0
  32. package/src/server/tasks/TaskService.ts +111 -0
  33. package/src/server/tasks/index.ts +4 -0
  34. package/src/server/tasks/paymentTools.ts +156 -0
  35. package/src/server/tasks/protocolRunner.ts +101 -0
  36. package/src/server/tasks/protocolTools.ts +96 -0
  37. package/src/server/ziggspay/ZiggsPayClient.ts +193 -0
  38. package/src/shared/ids.ts +3 -0
  39. package/src/shared/runtimeLog.ts +72 -0
  40. package/src/shared/types.ts +31 -0
  41. package/src/tasks/protocolRegistry.ts +25 -0
  42. package/src/tasks/taskCore.ts +139 -0
  43. package/src/tools/ToolManager.ts +95 -0
  44. package/src/tools/{ToolProvider.js → ToolProvider.ts} +5 -15
  45. package/src/tools/defineTool.ts +90 -0
  46. package/src/tools/index.ts +5 -0
  47. package/src/types.ts +368 -0
  48. package/src/utils/jsonExtractor.ts +100 -0
  49. package/src/ConnectionPool.js +0 -133
  50. package/src/adapters/OpenAIAdapter.js +0 -73
  51. package/src/agent/Agent.js +0 -121
  52. package/src/agent/EventQueue.js +0 -68
  53. package/src/agent/OutboxBuffer.js +0 -62
  54. package/src/cognition/PromptBuilder.js +0 -312
  55. package/src/cognition/resolveActionTool.js +0 -12
  56. package/src/cognition/runTurn.js +0 -578
  57. package/src/context/applyEffects.js +0 -133
  58. package/src/context/batch.js +0 -25
  59. package/src/context/classifyEnvelope.js +0 -82
  60. package/src/context/routingLabels.js +0 -54
  61. package/src/createHealthServer.js +0 -28
  62. package/src/formatters/HistoryFormatter.js +0 -257
  63. package/src/formatters/TaskFormatter.js +0 -180
  64. package/src/formatters/index.js +0 -9
  65. package/src/index.js +0 -76
  66. package/src/ingress/normalizeIncoming.js +0 -70
  67. package/src/runLauncher.js +0 -159
  68. package/src/shared/ids.js +0 -7
  69. package/src/shared/types.js +0 -86
  70. package/src/tasks/TaskService.js +0 -247
  71. package/src/tasks/index.js +0 -9
  72. package/src/tasks/taskCore.js +0 -229
  73. package/src/tasks/taskProtocolRegistry.js +0 -22
  74. package/src/tasks/taskProtocolRunner.js +0 -107
  75. package/src/tasks/taskProtocolTools.js +0 -87
  76. package/src/tools/ToolManager.js +0 -79
  77. package/src/tools/defineTool.js +0 -82
  78. package/src/tools/index.js +0 -11
  79. package/src/utils/jsonExtractor.js +0 -139
  80. package/src/workflow/AgentMachine.js +0 -250
  81. package/src/workflow/WorkflowRuntime.js +0 -63
  82. package/src/workflow/dsl.js +0 -287
  83. package/src/workflow/motifs.js +0 -435
  84. package/src/ziggs/runtime.js +0 -192
  85. /package/src/adapters/{index.js → index.ts} +0 -0
@@ -0,0 +1,637 @@
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
+
73
+ interface RunTurnResult {
74
+ outcome: Outcome;
75
+ toolResults: ToolResultEntry[];
76
+ llmCalls: number;
77
+ tokens: { total: number; input: number; output: number };
78
+ }
79
+
80
+ /**
81
+ * Run a single thinking-state turn.
82
+ *
83
+ * Reads context → builds prompt → tool loop with the LLM → executes one
84
+ * action → returns exactly one Outcome via outcomeFromActionResult.
85
+ *
86
+ * No direct service calls. Every impure operation goes through `eff`:
87
+ * • eff('read-context') — history/agreements/agents snapshot
88
+ * • eff('llm-call') — model invocation
89
+ * • eff('tool-call') — tool execution (Server runs the tool body)
90
+ * • eff('send-message') — outbound peer message
91
+ * • eff('record-event') — persist a thought / decision marker
92
+ *
93
+ * The Server is responsible for turning each effect into the right HTTP/WS
94
+ * call and (optionally) wrapping it in telemetry / dedup / retries.
95
+ */
96
+ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
97
+ const { stateId, statePrompt, actions, incomingEvent, ctx, eff, toolManager, promptBuilder, memory, definition } = input;
98
+ const sessionId = ctx.identity.sessionId;
99
+
100
+ const emitThought = async (text: unknown): Promise<void> => {
101
+ if (text == null) return;
102
+ const s = String(text).trim();
103
+ if (s.length === 0) return;
104
+ try {
105
+ await eff({ kind: 'record-event', sessionId, entry: { kind: 'thought', text: s } });
106
+ } catch (err) {
107
+ runtimeLog.warn('runTurn', `record-event:thought failed: ${(err as Error).message}`);
108
+ }
109
+ };
110
+
111
+ runtimeLog.info('runTurn', `enter state=${stateId}`);
112
+
113
+ // Parallel: read session context + collect tool schemas (pure).
114
+ // Filter tools to only those bound to an action in the current state — the
115
+ // runtime rejects tool calls that don't map to an allowed action (see
116
+ // findActionForTool), so don't advertise tools the LLM can't actually use.
117
+ const allTools: ToolDefinition[] = await Promise.resolve(toolManager?.getAvailableTools?.() ?? []);
118
+ const allowedToolNames = new Set(
119
+ Object.values(actions).map(a => a.tool).filter((t): t is string => !!t),
120
+ );
121
+ const toolName = (t: ToolDefinition): string | undefined => {
122
+ const fn = (t.schema as Record<string, unknown>).function as Record<string, unknown> | undefined;
123
+ return (fn?.name ?? (t.schema as Record<string, unknown>).name) as string | undefined;
124
+ };
125
+ const tools = allowedToolNames.size > 0
126
+ ? allTools.filter(t => {
127
+ const n = toolName(t);
128
+ return n != null && allowedToolNames.has(n);
129
+ })
130
+ : [];
131
+ const serverContext = await eff({ kind: 'read-context', sessionId, opts: {} });
132
+ const ctxObj = normalizeContextSnapshot(serverContext);
133
+
134
+ const probeDispatch = Object.entries(actions).find(([, a]) => a.tool === 'capability_probe_dispatch');
135
+ if (probeDispatch && incomingEvent) {
136
+ const [actionName, actionDef] = probeDispatch;
137
+ const messageText =
138
+ extractIncomingMessageText(incomingEvent, ctxObj as ContextSnapshot) ||
139
+ JSON.stringify(incomingEvent);
140
+ const toolResultsOut: ToolResultEntry[] = [];
141
+ const emitted = await executeTool(
142
+ { action: actionName, tool: 'capability_probe_dispatch', args: { messageText } },
143
+ eff,
144
+ sessionId,
145
+ memory,
146
+ );
147
+ const events = (Array.isArray(emitted) ? emitted : emitted ? [emitted] : []) as EmittedEvent[];
148
+ const outcome = outcomeFromActionResult(actionName, actionDef, {}, events, toolResultsOut);
149
+ return { outcome, toolResults: toolResultsOut, llmCalls: 0, tokens: { total: 0, input: 0, output: 0 } };
150
+ }
151
+
152
+ const prompt = promptBuilder.buildFromState({
153
+ statePrompt, actions,
154
+ serverContext: { context: ctxObj as Record<string, unknown>, tools: tools as unknown as Record<string, unknown>[] },
155
+ incomingEvent, definition,
156
+ chatId: sessionId, stateId, ctx,
157
+ });
158
+
159
+ const systemContent = tools.length > 0
160
+ ? '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.'
161
+ : 'Respond with valid JSON.';
162
+ const messages: LlmMessage[] = [
163
+ { role: 'system', content: systemContent },
164
+ { role: 'user', content: prompt },
165
+ ];
166
+
167
+ const allEmittedEvents: EmittedEvent[] = [];
168
+ let lastAction: string | null = null;
169
+ let lastActionDef: Action | null = null;
170
+ let lastArgs: unknown = null;
171
+ let llmCallsTotal = 0;
172
+ let tokensTotal = 0, tokensInput = 0, tokensOutput = 0;
173
+ // Tracks whether a successful agreement creation has already happened in
174
+ // this turn. Used by annotateToolCallDedup to reject any second creation
175
+ // attempt across iterations of the tool loop.
176
+ let turnHadAgreementCreation = false;
177
+ const actionNames = Object.keys(actions);
178
+
179
+ runtimeLog.info('runTurn', `calling LLM tools=${tools.length}`);
180
+
181
+ for (let iter = 0; iter < MAX_TOOL_LOOP_ITERS; iter++) {
182
+ const llmResponse: LlmResponse = await eff({ kind: 'llm-call', messages, tools: tools as unknown as LlmToolSchema[] });
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, eff, 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)) 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
+ const result = await executeDecision(decision, actionDef, eff, sessionId, memory);
305
+ if (result) {
306
+ const events = (Array.isArray(result) ? result : [result]) as EmittedEvent[];
307
+ allEmittedEvents.push(...events);
308
+ lastAction = chosenAction;
309
+ lastActionDef = actionDef;
310
+ lastArgs = decision.args;
311
+
312
+ if (actionDef?.tool) {
313
+ const firstEvent = (events[0] || {}) as EmittedEvent;
314
+ const toolResultContent = JSON.stringify(firstEvent.result ?? firstEvent);
315
+ messages.push({ role: 'assistant', content: llmResponse.content });
316
+ messages.push({ role: 'user', content: `Tool "${actionDef.tool}" result:\n${toolResultContent}\n\nContinue with your next action.` });
317
+ if (!turnHadAgreementCreation && eventsContainAgreementCreation(events)) {
318
+ turnHadAgreementCreation = true;
319
+ }
320
+ if (eventsAreTerminal(events)) break;
321
+ continue;
322
+ }
323
+ }
324
+ break;
325
+ }
326
+ }
327
+
328
+ await echoOptInToolSummaryIfNeeded({
329
+ allEmittedEvents, eff, sessionId,
330
+ context: ctxObj, toolManager, incomingEvent,
331
+ });
332
+
333
+ const toolResults: ToolResultEntry[] = [];
334
+ const outcome = outcomeFromActionResult(lastAction, lastActionDef, lastArgs, allEmittedEvents, toolResults);
335
+
336
+ runtimeLog.info('runTurn', `done state=${stateId} action=${lastAction} outcome=${outcome.kind} llmCalls=${llmCallsTotal}`);
337
+
338
+ return { outcome, toolResults, llmCalls: llmCallsTotal, tokens: { total: tokensTotal, input: tokensInput, output: tokensOutput } };
339
+ }
340
+
341
+ // ─────────────────────────────────────────────────────────────────────────────
342
+
343
+ function normalizeContextSnapshot(raw: ContextSnapshot | undefined | null): ContextSnapshot {
344
+ if (!raw || typeof raw !== 'object') {
345
+ return { history: [], agreements: [], agents: [], users: [] };
346
+ }
347
+ const out = { ...raw } as Record<string, unknown>;
348
+ for (const key of ['history', 'agreements', 'agents', 'users']) {
349
+ if (!Array.isArray(out[key])) out[key] = [];
350
+ }
351
+ return out as unknown as ContextSnapshot;
352
+ }
353
+
354
+ function eventsAreTerminal(events: EmittedEvent[]): boolean {
355
+ return events.some(e => {
356
+ if (!e) return false;
357
+ const t = e.type;
358
+ if (t === 'message_sent' || t === 'message_duplicate_skipped' || t === 'waited') return true;
359
+ // Successful protocol-level state changes (agreement creation, task
360
+ // assignment/closure) end the turn. The next turn re-enters with the
361
+ // matching outcome and the workflow takes it from there. Without this,
362
+ // the LLM is nudged with "Continue with your next action." and often
363
+ // re-issues the same agreement_propose, producing duplicate proposal
364
+ // cards within a single user turn.
365
+ if (t === 'tool_result' || t === 'task_result') {
366
+ const r = (e.result as Record<string, unknown>) || {};
367
+ if (r.success === false || r.error) return false;
368
+ // Support both nested { agreement: {...} } and flat Agreement objects
369
+ // (api-client now returns Agreement directly, not wrapped in { agreement }).
370
+ const ag = (r.agreement ?? (r.agreementId ? r : null)) as Record<string, unknown> | null | undefined;
371
+ const task = (r.task || (r.taskId ? r : null)) as Record<string, unknown> | null | undefined;
372
+ const agProposal = ag?.proposal as Record<string, unknown> | undefined;
373
+ const taskProposal = task?.proposal as Record<string, unknown> | undefined;
374
+ const proposalStatus = agProposal?.status || taskProposal?.status;
375
+ if (proposalStatus === 'pending' || proposalStatus === 'approved') return true;
376
+ const state = task?.state || r.state;
377
+ if (state === 'active' || state === 'in-progress' ||
378
+ state === 'completed' || state === 'failed' || state === 'cancelled') {
379
+ return true;
380
+ }
381
+ }
382
+ return false;
383
+ });
384
+ }
385
+
386
+ function eventsContainAgreementCreation(events: EmittedEvent[]): boolean {
387
+ return events.some(e => {
388
+ if (!e || e.type !== 'tool_result') return false;
389
+ const r = (e.result as Record<string, unknown>) || {};
390
+ if (r.success === false || r.error) return false;
391
+ // Support both nested { agreement: {...} } and flat Agreement objects.
392
+ const ag = (r.agreement ?? (r.agreementId ? r : null)) as Record<string, unknown> | undefined;
393
+ const agProposal = ag?.proposal as Record<string, unknown> | undefined;
394
+ const task = r.task as Record<string, unknown> | undefined;
395
+ const taskProposal = task?.proposal as Record<string, unknown> | undefined;
396
+ const proposalStatus = agProposal?.status || taskProposal?.status;
397
+ return proposalStatus === 'pending' || proposalStatus === 'approved';
398
+ });
399
+ }
400
+
401
+ function annotateToolCallDedup(
402
+ toolCalls: LlmToolCall[],
403
+ toolManager: ToolManager,
404
+ initialSawCreate = false,
405
+ ): Array<{ tc: LlmToolCall; skipExecute: boolean; skipReason?: string }> {
406
+ let sawCreate = initialSawCreate;
407
+ const seenUpdateTaskIds = new Set<string>();
408
+ return (toolCalls || []).map((tc: LlmToolCall) => {
409
+ const name = tc?.function?.name;
410
+ if (name && isAgreementCreationTool(name, toolManager)) {
411
+ if (sawCreate) {
412
+ return {
413
+ tc,
414
+ skipExecute: true,
415
+ skipReason: initialSawCreate
416
+ ? 'agreement creation already executed earlier in this turn'
417
+ : 'duplicate agreement creation in same assistant message',
418
+ };
419
+ }
420
+ sawCreate = true;
421
+ return { tc, skipExecute: false };
422
+ }
423
+ if (name === 'task_update') {
424
+ let taskId: string | null = null;
425
+ try { const a = JSON.parse(tc.function.arguments || '{}'); taskId = typeof a.taskId === 'string' ? a.taskId : null; } catch {}
426
+ const key = taskId || tc.id || '_';
427
+ if (seenUpdateTaskIds.has(key)) return { tc, skipExecute: true, skipReason: 'duplicate task_update for same taskId' };
428
+ seenUpdateTaskIds.add(key);
429
+ return { tc, skipExecute: false };
430
+ }
431
+ return { tc, skipExecute: false };
432
+ });
433
+ }
434
+
435
+ function parseLLMDecision(llmResponse: LlmResponse, allowedActionNames: string[], actions: Record<string, Action>): ParsedDecision | null {
436
+ if (llmResponse?.tool_calls?.length) {
437
+ const tc = llmResponse.tool_calls[0];
438
+ if (!tc?.function?.name) return null;
439
+ let args: Record<string, unknown> = {};
440
+ try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
441
+ const matchingAction = findActionForTool(tc.function.name!, allowedActionNames, actions);
442
+ if (!matchingAction) return null;
443
+ return { thought: `Using tool: ${tc.function.name}`, action: matchingAction, tool: tc.function.name, args };
444
+ }
445
+
446
+ const parsed = safeParseJSON(llmResponse?.content || '', null) as Record<string, unknown> | null;
447
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
448
+ if (!parsed['action'] || !allowedActionNames.includes(parsed['action'] as string)) return null;
449
+ return parsed as unknown as ParsedDecision;
450
+ }
451
+
452
+ function findActionForTool(toolName: string, allowedActionNames: string[], actions: Record<string, Action>): string | null {
453
+ if (!toolName) return null;
454
+ const matches = allowedActionNames.filter(n => actions[n]?.tool === toolName);
455
+ if (matches.length === 1) return matches[0];
456
+ if (matches.length > 1) {
457
+ runtimeLog.warn('runTurn', `ambiguous: ${matches.length} actions bind tool "${toolName}"`);
458
+ return null;
459
+ }
460
+ return null;
461
+ }
462
+
463
+ async function executeDecision(decision: ParsedDecision, actionDef: Action | null, eff: EffectHandler, sessionId: string, memory?: MemoryStore): Promise<EmittedEvent[] | EmittedEvent | null> {
464
+ const boundTool = actionDef?.tool || null;
465
+
466
+ if (boundTool) {
467
+ decision.tool = boundTool;
468
+ return await executeTool(decision, eff, sessionId, memory);
469
+ }
470
+ if (decision.action === '_genericDiscoveryTool' && decision.tool) return await executeTool(decision, eff, sessionId, memory);
471
+ if (decision.tool) return await executeTool(decision, eff, sessionId, memory);
472
+ if (decision.messages && Array.isArray(decision.messages) && decision.messages.length > 0) {
473
+ return await executeSendMessages(decision, eff, sessionId);
474
+ }
475
+ if (decision.message && decision.receiverId) {
476
+ return await executeSendMessage(decision, eff, sessionId);
477
+ }
478
+ if (decision.action === 'wait') return { type: 'waited' };
479
+ return null;
480
+ }
481
+
482
+ function truncateMsg(msg: unknown): string {
483
+ if (typeof msg !== 'string') return String(msg ?? '');
484
+ if (msg.length <= MAX_MSG_CHARS) return msg;
485
+ return msg.slice(0, MAX_MSG_CHARS) + `\n\n[truncated to ${MAX_MSG_CHARS} of ${msg.length} chars]`;
486
+ }
487
+
488
+ async function executeSendMessage(decision: ParsedDecision, eff: EffectHandler, sessionId: string | null): Promise<EmittedEvent[] | null> {
489
+ const { message, receiverId } = decision;
490
+ if (!message || !receiverId) return null;
491
+ // `decision.chatId` is what the LLM emitted in its JSON decision; honor it
492
+ // for cross-session sends (e.g. reporting to a parent task chat), fall back
493
+ // to the current session otherwise.
494
+ const targetSessionId = decision.chatId || sessionId;
495
+ const safeMessage = truncateMsg(message);
496
+ if (targetSessionId) {
497
+ await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: receiverId!, text: safeMessage });
498
+ }
499
+ const events: EmittedEvent[] = [{ type: 'message_sent', receiverId: receiverId!, chatId: targetSessionId ?? undefined, message: safeMessage, senderId: 'system' }];
500
+ if (targetSessionId !== sessionId) events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' });
501
+ return events;
502
+ }
503
+
504
+ async function executeSendMessages(decision: ParsedDecision, eff: EffectHandler, sessionId: string | null): Promise<EmittedEvent[]> {
505
+ const events: EmittedEvent[] = [];
506
+ for (const msg of decision.messages ?? []) {
507
+ if (!msg.receiverId || !msg.message) continue;
508
+ const targetSessionId = msg.chatId || sessionId;
509
+ const safeMessage = truncateMsg(msg.message);
510
+ if (targetSessionId) {
511
+ try { await eff({ kind: 'send-message', sessionId: targetSessionId, receiverId: msg.receiverId, text: safeMessage }); } catch { continue; }
512
+ }
513
+ events.push({ type: 'message_sent', receiverId: msg.receiverId, chatId: targetSessionId ?? undefined, message: safeMessage, senderId: 'system' });
514
+ if (targetSessionId !== sessionId) events.push({ type: 'report_sent', message: 'Reported to owner.', senderId: 'system' } as unknown as EmittedEvent);
515
+ }
516
+ return events;
517
+ }
518
+
519
+ /**
520
+ * Tool execution becomes a single effect call. The Server is responsible for
521
+ * dispatching the named tool to its implementation, providing the impure
522
+ * dependencies (taskService, agreementService, agents list, etc.), and
523
+ * wrapping the call in telemetry / operation-record entries if it wants.
524
+ */
525
+ async function executeTool(decision: ParsedDecision, eff: EffectHandler, sessionId: string, memory?: MemoryStore): Promise<EmittedEvent[] | EmittedEvent | null> {
526
+ const { tool, args } = decision;
527
+ if (!tool) return null;
528
+ try {
529
+ const r = await eff({ kind: 'tool-call', sessionId, name: tool, args: args || {}, memory }) as ToolCallResult;
530
+ if (!r.ok) return { type: 'tool_error', tool, error: r.error || 'unknown', senderId: 'system' };
531
+ // Tools may emit terminal markers (e.g. `message_sent`, `waited`) via
532
+ // `events` — propagate so the loop can detect terminal turns.
533
+ if (r.events && (r.events as unknown[]).length > 0) return r.events as unknown as EmittedEvent[];
534
+ return { type: 'tool_result', tool, result: r.result as WireResult | undefined, senderId: 'system' };
535
+ } catch (error: unknown) {
536
+ return { type: 'tool_error', tool, error: (error as Error)?.message ?? String(error), senderId: 'system' };
537
+ }
538
+ }
539
+
540
+ function shouldEchoToolSummaryToUser(result: unknown): boolean {
541
+ if (!result || typeof result !== 'object') return false;
542
+ const r = result as Record<string, unknown>;
543
+ if (typeof r.message !== 'string' || !(r.message as string).trim()) return false;
544
+ if (r.success === false) return false;
545
+ if (r.error) return false;
546
+ return true;
547
+ }
548
+
549
+ async function echoOptInToolSummaryIfNeeded({ allEmittedEvents, eff, sessionId, context, toolManager, incomingEvent }: {
550
+ allEmittedEvents: EmittedEvent[];
551
+ eff: EffectHandler;
552
+ sessionId: string;
553
+ context: ContextSnapshot;
554
+ toolManager: ToolManager;
555
+ incomingEvent: RawEvent | null;
556
+ }): Promise<void> {
557
+ if (!eff || !sessionId || !context || !toolManager?.getTool) return;
558
+ if (allEmittedEvents.some((e) => e?.type === 'message_sent')) return;
559
+
560
+ let summary: string | null = null;
561
+ for (const ev of allEmittedEvents) {
562
+ if (ev?.type !== 'tool_result' || !ev.tool) continue;
563
+ const reg = toolManager.getTool(ev.tool);
564
+ if (!reg?.echoUserSummaryOnSuccess) continue;
565
+ if (!shouldEchoToolSummaryToUser(ev.result)) continue;
566
+ summary = (ev.result as Record<string, unknown>).message as string;
567
+ }
568
+ if (!summary) return;
569
+
570
+ const receiverId = pickReceiverForEcho(context, incomingEvent);
571
+ if (!receiverId) return;
572
+
573
+ const safe = truncateMsg(summary);
574
+ const wasTruncated = safe !== summary;
575
+ try {
576
+ await eff({ kind: 'send-message', sessionId, receiverId, text: safe });
577
+ } catch { return; }
578
+ const event: Record<string, unknown> = { type: 'message_sent', receiverId, chatId: sessionId, message: safe, senderId: 'system' };
579
+ if (wasTruncated) { event['truncated'] = true; event['originalLength'] = summary.length; }
580
+ allEmittedEvents.push(event as unknown as EmittedEvent);
581
+ }
582
+
583
+ function pickReceiverForEcho(context: ContextSnapshot, incomingEvent: RawEvent | null): string | null {
584
+ const senderId = incomingEvent?.senderId;
585
+ const senderType = String(incomingEvent?.senderType || '').toUpperCase();
586
+ if (senderId && senderType === 'USER') return String(senderId);
587
+ return pickPrimaryUserIdFromContext(context);
588
+ }
589
+
590
+ function extractIncomingMessageText(
591
+ incomingEvent: RawEvent | null,
592
+ ctx: ContextSnapshot,
593
+ ): string {
594
+ const parts: string[] = [];
595
+ const walk = (ev: RawEvent | null | undefined): void => {
596
+ if (!ev) return;
597
+ if (ev.text) parts.push(String(ev.text));
598
+ if (typeof ev.message === 'string') parts.push(ev.message);
599
+ const result = ev.result as Record<string, unknown> | undefined;
600
+ if (result) {
601
+ if (result.description) parts.push(String(result.description));
602
+ const ag = result.agreement as Record<string, unknown> | undefined;
603
+ if (ag) {
604
+ const terms = ag.terms as Record<string, unknown> | undefined;
605
+ if (terms?.description) parts.push(String(terms.description));
606
+ if (ag.agreementId) parts.push(String(ag.agreementId));
607
+ }
608
+ if (result.agreementId) parts.push(String(result.agreementId));
609
+ }
610
+ if (Array.isArray(ev.events)) (ev.events as RawEvent[]).forEach(walk);
611
+ };
612
+ walk(incomingEvent);
613
+ const hist = ctx.history as Array<Record<string, unknown>> | undefined;
614
+ if (hist?.length) {
615
+ const last = hist[hist.length - 1];
616
+ const text = last?.text ?? last?.content;
617
+ if (text) parts.push(String(text));
618
+ }
619
+ return parts.join('\n').trim();
620
+ }
621
+
622
+ function pickPrimaryUserIdFromContext(context: ContextSnapshot): string | null {
623
+ const users = context?.users || [];
624
+ for (const u of users as Record<string, unknown>[]) {
625
+ const id = (u?.userId || u?.id) as string | undefined;
626
+ if (id && typeof id === 'string') return id;
627
+ }
628
+ const hist = context?.history || [];
629
+ for (let i = hist.length - 1; i >= 0; i--) {
630
+ const row = hist[i] as Record<string, unknown>;
631
+ const sender = row?.sender as Record<string, unknown> | undefined;
632
+ const type = String(sender?.type || row?.senderType || '').toUpperCase();
633
+ const id = (sender?.id ?? row?.senderId) as string | undefined;
634
+ if (type === 'USER' && id) return id;
635
+ }
636
+ return null;
637
+ }