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