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