auggy 0.3.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 +96 -0
- package/LICENSE +201 -0
- package/README.md +161 -0
- package/package.json +76 -0
- package/src/agent-card.ts +39 -0
- package/src/agent.ts +283 -0
- package/src/agentmail-client.ts +138 -0
- package/src/augments/bash/index.ts +463 -0
- package/src/augments/bash/skill/SKILL.md +156 -0
- package/src/augments/budgets/budget-store.ts +513 -0
- package/src/augments/budgets/index.ts +134 -0
- package/src/augments/budgets/preamble.ts +93 -0
- package/src/augments/budgets/types.ts +89 -0
- package/src/augments/file-memory/index.ts +71 -0
- package/src/augments/filesystem/index.ts +533 -0
- package/src/augments/filesystem/skill/SKILL.md +142 -0
- package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
- package/src/augments/layered-memory/extractor/buffer.ts +56 -0
- package/src/augments/layered-memory/extractor/frequency.ts +79 -0
- package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
- package/src/augments/layered-memory/extractor/parse.ts +75 -0
- package/src/augments/layered-memory/extractor/prompt.md +26 -0
- package/src/augments/layered-memory/index.ts +757 -0
- package/src/augments/layered-memory/skill/SKILL.md +153 -0
- package/src/augments/layered-memory/storage/migrations/README.md +16 -0
- package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
- package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
- package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
- package/src/augments/layered-memory/storage/types.ts +98 -0
- package/src/augments/link/index.ts +489 -0
- package/src/augments/link/translate.ts +261 -0
- package/src/augments/notify/adapters/agentmail.ts +70 -0
- package/src/augments/notify/adapters/telegram.ts +60 -0
- package/src/augments/notify/adapters/webhook.ts +55 -0
- package/src/augments/notify/index.ts +284 -0
- package/src/augments/notify/skill/SKILL.md +150 -0
- package/src/augments/org-context/index.ts +721 -0
- package/src/augments/org-context/skill/SKILL.md +96 -0
- package/src/augments/skills/index.ts +103 -0
- package/src/augments/supabase-memory/index.ts +151 -0
- package/src/augments/telegram-transport/index.ts +312 -0
- package/src/augments/telegram-transport/polling.ts +55 -0
- package/src/augments/telegram-transport/webhook.ts +56 -0
- package/src/augments/turn-control/index.ts +61 -0
- package/src/augments/turn-control/skill/SKILL.md +155 -0
- package/src/augments/visitor-auth/email-validation.ts +66 -0
- package/src/augments/visitor-auth/index.ts +779 -0
- package/src/augments/visitor-auth/rate-limiter.ts +90 -0
- package/src/augments/visitor-auth/skill/SKILL.md +55 -0
- package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
- package/src/augments/visitor-auth/storage/types.ts +164 -0
- package/src/augments/visitor-auth/types.ts +123 -0
- package/src/augments/visitor-auth/verify-page.ts +179 -0
- package/src/augments/web-fetch/index.ts +331 -0
- package/src/augments/web-fetch/skill/SKILL.md +100 -0
- package/src/cli/agent-index.ts +289 -0
- package/src/cli/augment-catalog.ts +320 -0
- package/src/cli/augment-resolver.ts +597 -0
- package/src/cli/commands/add-skill.ts +194 -0
- package/src/cli/commands/add.ts +87 -0
- package/src/cli/commands/chat.ts +207 -0
- package/src/cli/commands/create.ts +462 -0
- package/src/cli/commands/dev.ts +139 -0
- package/src/cli/commands/eval.ts +180 -0
- package/src/cli/commands/ls.ts +66 -0
- package/src/cli/commands/remove.ts +95 -0
- package/src/cli/commands/restart.ts +40 -0
- package/src/cli/commands/start.ts +123 -0
- package/src/cli/commands/status.ts +104 -0
- package/src/cli/commands/stop.ts +84 -0
- package/src/cli/commands/visitors-revoke.ts +155 -0
- package/src/cli/commands/visitors.ts +101 -0
- package/src/cli/config-parser.ts +1034 -0
- package/src/cli/engine-resolver.ts +68 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/model-picker.ts +89 -0
- package/src/cli/pid-registry.ts +146 -0
- package/src/cli/plist-generator.ts +117 -0
- package/src/cli/resolve-config.ts +56 -0
- package/src/cli/scaffold-skills.ts +158 -0
- package/src/cli/scaffold.ts +291 -0
- package/src/cli/skill-frontmatter.ts +51 -0
- package/src/cli/skill-validator.ts +151 -0
- package/src/cli/types.ts +228 -0
- package/src/cli/yaml-helpers.ts +66 -0
- package/src/engines/_shared/cost.ts +55 -0
- package/src/engines/_shared/schema-normalize.ts +75 -0
- package/src/engines/anthropic/pricing.ts +117 -0
- package/src/engines/anthropic.ts +483 -0
- package/src/engines/openai/pricing.ts +67 -0
- package/src/engines/openai.ts +446 -0
- package/src/engines/openrouter/pricing.ts +83 -0
- package/src/engines/openrouter.ts +185 -0
- package/src/helpers.ts +24 -0
- package/src/http.ts +387 -0
- package/src/index.ts +165 -0
- package/src/kernel/capability-table.ts +172 -0
- package/src/kernel/context-allocator.ts +161 -0
- package/src/kernel/history-manager.ts +198 -0
- package/src/kernel/lifecycle-manager.ts +106 -0
- package/src/kernel/output-validator.ts +35 -0
- package/src/kernel/preamble.ts +23 -0
- package/src/kernel/route-collector.ts +97 -0
- package/src/kernel/timeout.ts +21 -0
- package/src/kernel/tool-selector.ts +47 -0
- package/src/kernel/trace-emitter.ts +66 -0
- package/src/kernel/transport-queue.ts +147 -0
- package/src/kernel/turn-loop.ts +1148 -0
- package/src/memory/context-synthesis.ts +83 -0
- package/src/memory/memory-bus.ts +61 -0
- package/src/memory/registry.ts +80 -0
- package/src/memory/tools.ts +320 -0
- package/src/memory/types.ts +8 -0
- package/src/parts.ts +30 -0
- package/src/scaffold-templates/identity.md +31 -0
- package/src/telegram-client.ts +145 -0
- package/src/tokenizer.ts +14 -0
- package/src/transports/ag-ui-events.ts +253 -0
- package/src/transports/visitor-token.ts +82 -0
- package/src/transports/web-transport.ts +948 -0
- package/src/types.ts +1009 -0
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Augment,
|
|
3
|
+
AgentConfig,
|
|
4
|
+
AssembledPrompt,
|
|
5
|
+
ModelClient,
|
|
6
|
+
ModelResponse,
|
|
7
|
+
TurnTrigger,
|
|
8
|
+
TurnState,
|
|
9
|
+
TurnResult,
|
|
10
|
+
ContextBlock,
|
|
11
|
+
InboundMessage,
|
|
12
|
+
Tool,
|
|
13
|
+
ToolCallRecord,
|
|
14
|
+
KernelEventHandler,
|
|
15
|
+
CostResult,
|
|
16
|
+
TurnGateProvider,
|
|
17
|
+
TurnGateTicket,
|
|
18
|
+
ToolResult,
|
|
19
|
+
Part,
|
|
20
|
+
} from "../types";
|
|
21
|
+
import type { Tokenizer } from "../tokenizer";
|
|
22
|
+
import { extractText } from "../parts";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Streaming inference helper
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run a model inference call with streaming text deltas. Emits
|
|
30
|
+
* text_message_start / text_message_delta / text_message_end KernelEvents
|
|
31
|
+
* as text arrives. If the engine doesn't call onDelta (non-streaming
|
|
32
|
+
* engines), no streaming events are emitted and the caller falls back to
|
|
33
|
+
* the classic text_message event.
|
|
34
|
+
*
|
|
35
|
+
* On error, closes any open text stream before re-throwing so the client
|
|
36
|
+
* never has an unclosed message stuck in "typing" state.
|
|
37
|
+
*/
|
|
38
|
+
async function streamingInference(
|
|
39
|
+
model: ModelClient,
|
|
40
|
+
prompt: AssembledPrompt,
|
|
41
|
+
turnId: string,
|
|
42
|
+
emitEvent: KernelEventHandler,
|
|
43
|
+
): Promise<{ response: ModelResponse; streamed: boolean; messageId: string }> {
|
|
44
|
+
const messageId = crypto.randomUUID();
|
|
45
|
+
let streamed = false;
|
|
46
|
+
|
|
47
|
+
let response: ModelResponse;
|
|
48
|
+
try {
|
|
49
|
+
response = await model.complete(prompt, {
|
|
50
|
+
onDelta: (delta) => {
|
|
51
|
+
if (delta.kind === "text_delta") {
|
|
52
|
+
if (!streamed) {
|
|
53
|
+
emitEvent({
|
|
54
|
+
kind: "text_message_start",
|
|
55
|
+
turnId,
|
|
56
|
+
messageId,
|
|
57
|
+
role: "assistant",
|
|
58
|
+
});
|
|
59
|
+
streamed = true;
|
|
60
|
+
}
|
|
61
|
+
emitEvent({
|
|
62
|
+
kind: "text_message_delta",
|
|
63
|
+
turnId,
|
|
64
|
+
messageId,
|
|
65
|
+
delta: delta.text,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (streamed) {
|
|
72
|
+
emitEvent({ kind: "text_message_end", turnId, messageId });
|
|
73
|
+
}
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (streamed) {
|
|
78
|
+
emitEvent({ kind: "text_message_end", turnId, messageId });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { response, streamed, messageId };
|
|
82
|
+
}
|
|
83
|
+
import { withTimeout } from "./timeout";
|
|
84
|
+
import { createContextAllocator } from "./context-allocator";
|
|
85
|
+
import { createCapabilityTable } from "./capability-table";
|
|
86
|
+
import { selectTools } from "./tool-selector";
|
|
87
|
+
import { createTraceEmitter } from "./trace-emitter";
|
|
88
|
+
import { buildPreamble } from "./preamble";
|
|
89
|
+
import { validateOutput } from "./output-validator";
|
|
90
|
+
import { createHistoryManager, type HistoryManager } from "./history-manager";
|
|
91
|
+
|
|
92
|
+
export interface TurnLoopOptions {
|
|
93
|
+
signal?: AbortSignal;
|
|
94
|
+
onEvent?: KernelEventHandler;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface TurnLoop {
|
|
98
|
+
executeTurn(
|
|
99
|
+
trigger: TurnTrigger,
|
|
100
|
+
threadId: string,
|
|
101
|
+
options?: TurnLoopOptions,
|
|
102
|
+
): Promise<TurnResult>;
|
|
103
|
+
getHistoryManager(threadId: string): HistoryManager;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createTurnLoop(opts: {
|
|
107
|
+
augments: Augment[];
|
|
108
|
+
model: ModelClient;
|
|
109
|
+
tokenizer: Tokenizer;
|
|
110
|
+
config: AgentConfig;
|
|
111
|
+
}): TurnLoop {
|
|
112
|
+
const { augments, model, tokenizer, config } = opts;
|
|
113
|
+
|
|
114
|
+
const capabilityTable = createCapabilityTable(augments);
|
|
115
|
+
const traceEmitter = createTraceEmitter();
|
|
116
|
+
const historyManagers = new Map<string, HistoryManager>();
|
|
117
|
+
const historyLastAccess = new Map<string, number>();
|
|
118
|
+
const MAX_HISTORY_THREADS = 500;
|
|
119
|
+
|
|
120
|
+
// Collect all tools with their owning augment
|
|
121
|
+
const toolRegistry = new Map<string, { tool: Tool; augment: string }>();
|
|
122
|
+
const allTools: Tool[] = [];
|
|
123
|
+
for (const aug of augments) {
|
|
124
|
+
for (const tool of aug.tools ?? []) {
|
|
125
|
+
toolRegistry.set(tool.name, { tool, augment: aug.name });
|
|
126
|
+
allTools.push(tool);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getOrCreateHistory(threadId: string): HistoryManager {
|
|
131
|
+
let hm = historyManagers.get(threadId);
|
|
132
|
+
if (!hm) {
|
|
133
|
+
// Evict oldest thread if at capacity
|
|
134
|
+
if (historyManagers.size >= MAX_HISTORY_THREADS) {
|
|
135
|
+
let oldestId: string | null = null;
|
|
136
|
+
let oldestTime = Infinity;
|
|
137
|
+
for (const [id, t] of historyLastAccess) {
|
|
138
|
+
if (t < oldestTime) {
|
|
139
|
+
oldestTime = t;
|
|
140
|
+
oldestId = id;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (oldestId) {
|
|
144
|
+
historyManagers.delete(oldestId);
|
|
145
|
+
historyLastAccess.delete(oldestId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
hm = createHistoryManager({ threadId });
|
|
149
|
+
historyManagers.set(threadId, hm);
|
|
150
|
+
}
|
|
151
|
+
historyLastAccess.set(threadId, Date.now());
|
|
152
|
+
return hm;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
getHistoryManager: getOrCreateHistory,
|
|
157
|
+
|
|
158
|
+
async executeTurn(
|
|
159
|
+
trigger: TurnTrigger,
|
|
160
|
+
threadId: string,
|
|
161
|
+
options?: TurnLoopOptions,
|
|
162
|
+
): Promise<TurnResult> {
|
|
163
|
+
const signal = options?.signal;
|
|
164
|
+
const emitEvent: KernelEventHandler = options?.onEvent ?? (() => {});
|
|
165
|
+
const peer = trigger.peer ?? null;
|
|
166
|
+
const turnState: TurnState = {
|
|
167
|
+
turnId: trigger.turnId,
|
|
168
|
+
threadId,
|
|
169
|
+
trigger,
|
|
170
|
+
peer,
|
|
171
|
+
toolCallsSoFar: 0,
|
|
172
|
+
turnStartedAt: Date.now(),
|
|
173
|
+
metadata: {},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const toolCallRecords: ToolCallRecord[] = [];
|
|
177
|
+
|
|
178
|
+
// Per-turn transcript parts (ADR-027). Accumulates inbound user parts
|
|
179
|
+
// + outbound assistant text as the turn progresses. The snapshot is
|
|
180
|
+
// recorded into history-manager at every terminal return path so
|
|
181
|
+
// SchedulerContext.getCompletedTranscript() finds it.
|
|
182
|
+
const transcriptParts: Part[] = [];
|
|
183
|
+
if (trigger.type === "message" && trigger.payload && "parts" in trigger.payload) {
|
|
184
|
+
const inbound = trigger.payload as InboundMessage;
|
|
185
|
+
transcriptParts.push(...inbound.parts);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const trace = traceEmitter.startTurn({
|
|
189
|
+
turnId: trigger.turnId,
|
|
190
|
+
threadId,
|
|
191
|
+
trigger: {
|
|
192
|
+
type: trigger.type,
|
|
193
|
+
sourceAugment: trigger.source,
|
|
194
|
+
peerKind: peer?.kind,
|
|
195
|
+
trustLevel: peer?.trustLevel,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ADR-027: record a per-turn snapshot before returning. Called at
|
|
200
|
+
// every terminal return path; idempotent on retry. The snapshot is
|
|
201
|
+
// what SchedulerContext.getCompletedTranscript() reads.
|
|
202
|
+
function recordTurnSnapshot() {
|
|
203
|
+
getOrCreateHistory(threadId).recordTurn({
|
|
204
|
+
turnId: trigger.turnId,
|
|
205
|
+
threadId,
|
|
206
|
+
peer,
|
|
207
|
+
parts: [...transcriptParts],
|
|
208
|
+
toolCalls: [...toolCallRecords],
|
|
209
|
+
startedAt: turnState.turnStartedAt,
|
|
210
|
+
endedAt: Date.now(),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function makeAbortResult(): TurnResult {
|
|
215
|
+
emitEvent({
|
|
216
|
+
kind: "run_error",
|
|
217
|
+
turnId: trigger.turnId,
|
|
218
|
+
message: "Turn aborted via AbortSignal",
|
|
219
|
+
source: "kernel",
|
|
220
|
+
});
|
|
221
|
+
emitEvent({
|
|
222
|
+
kind: "run_finished",
|
|
223
|
+
turnId: trigger.turnId,
|
|
224
|
+
status: "canceled",
|
|
225
|
+
});
|
|
226
|
+
traceEmitter.finalize(trace);
|
|
227
|
+
recordTurnSnapshot();
|
|
228
|
+
return {
|
|
229
|
+
turnId: trigger.turnId,
|
|
230
|
+
success: false,
|
|
231
|
+
status: "canceled",
|
|
232
|
+
errorResponse: "Turn was aborted.",
|
|
233
|
+
toolCalls: toolCallRecords,
|
|
234
|
+
trace,
|
|
235
|
+
error: { message: "Turn aborted via AbortSignal", source: "kernel" },
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Check abort before starting work
|
|
240
|
+
if (signal?.aborted) return makeAbortResult();
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Pre-dispatch: turn-gate admission via 2PC (prepare → confirm/rollback → cost commit)
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
const turnGates = augments.filter(
|
|
246
|
+
(a): a is Augment & { turnGate: TurnGateProvider } => a.turnGate !== undefined,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const tickets: TurnGateTicket[] = [];
|
|
250
|
+
|
|
251
|
+
// Phase 1: Prepare — each gate stages its writes inside its own open transaction.
|
|
252
|
+
for (const gate of turnGates) {
|
|
253
|
+
let ticket: TurnGateTicket;
|
|
254
|
+
try {
|
|
255
|
+
ticket = await gate.turnGate.prepare({
|
|
256
|
+
turnId: trigger.turnId,
|
|
257
|
+
peer: trigger.peer ?? null,
|
|
258
|
+
threadId,
|
|
259
|
+
trigger,
|
|
260
|
+
});
|
|
261
|
+
} catch (err) {
|
|
262
|
+
// prepare itself threw — treat as admission-state-failed.
|
|
263
|
+
// Roll back any tickets already prepared.
|
|
264
|
+
for (const t of tickets) {
|
|
265
|
+
try {
|
|
266
|
+
await t.rollback();
|
|
267
|
+
} catch (e) {
|
|
268
|
+
console.error(`[turn-gate ${gate.name}] rollback after prepare-throw failed:`, e);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
traceEmitter.finalize(trace);
|
|
272
|
+
recordTurnSnapshot();
|
|
273
|
+
return {
|
|
274
|
+
turnId: trigger.turnId,
|
|
275
|
+
success: false,
|
|
276
|
+
status: "rejected",
|
|
277
|
+
response: undefined,
|
|
278
|
+
toolCalls: [],
|
|
279
|
+
trace,
|
|
280
|
+
error: {
|
|
281
|
+
message: `turn-gate "${gate.name}" prepare failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
282
|
+
source: gate.name,
|
|
283
|
+
},
|
|
284
|
+
errorClass: "admission-state-failed",
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
tickets.push(ticket);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Phase 2: Decision evaluation — conjunctive. Any denial rolls back all tickets.
|
|
291
|
+
const denied = tickets.find((t) => !t.decision.allow);
|
|
292
|
+
if (denied) {
|
|
293
|
+
const denialReason = (denied.decision as { allow: false; reason: string }).reason;
|
|
294
|
+
for (const t of tickets) {
|
|
295
|
+
try {
|
|
296
|
+
await t.rollback();
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.error("[turn-gate] rollback failed:", err);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
traceEmitter.finalize(trace);
|
|
302
|
+
recordTurnSnapshot();
|
|
303
|
+
return {
|
|
304
|
+
turnId: trigger.turnId,
|
|
305
|
+
success: false,
|
|
306
|
+
status: "rejected",
|
|
307
|
+
response: undefined,
|
|
308
|
+
toolCalls: [],
|
|
309
|
+
trace,
|
|
310
|
+
error: { message: denialReason, source: "turn-gate" },
|
|
311
|
+
errorClass: "cap-denied",
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Phase 3: Confirm — fail-closed. If any confirm throws, roll back all tickets.
|
|
316
|
+
let confirmError: unknown = null;
|
|
317
|
+
let confirmErrorGateName = "turn-gate";
|
|
318
|
+
for (let ci = 0; ci < tickets.length; ci++) {
|
|
319
|
+
try {
|
|
320
|
+
await tickets[ci]!.confirm();
|
|
321
|
+
} catch (err) {
|
|
322
|
+
confirmError = err;
|
|
323
|
+
confirmErrorGateName = turnGates[ci]?.name ?? "turn-gate";
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (confirmError !== null) {
|
|
328
|
+
for (const t of tickets) {
|
|
329
|
+
try {
|
|
330
|
+
await t.rollback();
|
|
331
|
+
} catch (err) {
|
|
332
|
+
console.error("[turn-gate] rollback after confirm-throw failed:", err);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
traceEmitter.finalize(trace);
|
|
336
|
+
recordTurnSnapshot();
|
|
337
|
+
return {
|
|
338
|
+
turnId: trigger.turnId,
|
|
339
|
+
success: false,
|
|
340
|
+
status: "rejected",
|
|
341
|
+
response: undefined,
|
|
342
|
+
toolCalls: [],
|
|
343
|
+
trace,
|
|
344
|
+
error: {
|
|
345
|
+
message: `admission state could not be persisted: ${confirmError instanceof Error ? confirmError.message : String(confirmError)}`,
|
|
346
|
+
source: confirmErrorGateName,
|
|
347
|
+
},
|
|
348
|
+
errorClass: "admission-state-failed",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// All gates admitted. Fall through to turn body.
|
|
353
|
+
// Phase 5 (cost commit) runs after the engine call returns — see bottom of executeTurn.
|
|
354
|
+
|
|
355
|
+
const history = getOrCreateHistory(threadId);
|
|
356
|
+
|
|
357
|
+
// Append inbound message to history (extract text from parts)
|
|
358
|
+
if (trigger.type === "message" && trigger.payload && "parts" in trigger.payload) {
|
|
359
|
+
const inbound = trigger.payload as InboundMessage;
|
|
360
|
+
const text = extractText(inbound.parts);
|
|
361
|
+
history.append({
|
|
362
|
+
id: crypto.randomUUID(),
|
|
363
|
+
role: "user",
|
|
364
|
+
peerId: peer?.id,
|
|
365
|
+
content: text,
|
|
366
|
+
timestamp: trigger.timestamp,
|
|
367
|
+
tokenCount: tokenizer.count(text),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// onTurnStart hooks — fire before context assembly
|
|
372
|
+
for (const aug of augments) {
|
|
373
|
+
if (aug.onTurnStart) {
|
|
374
|
+
try {
|
|
375
|
+
await aug.onTurnStart(turnState);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (aug.required) {
|
|
378
|
+
emitEvent({
|
|
379
|
+
kind: "run_error",
|
|
380
|
+
turnId: trigger.turnId,
|
|
381
|
+
message: String(err),
|
|
382
|
+
source: aug.name,
|
|
383
|
+
});
|
|
384
|
+
emitEvent({
|
|
385
|
+
kind: "run_finished",
|
|
386
|
+
turnId: trigger.turnId,
|
|
387
|
+
status: "failed",
|
|
388
|
+
});
|
|
389
|
+
traceEmitter.finalize(trace);
|
|
390
|
+
recordTurnSnapshot();
|
|
391
|
+
return {
|
|
392
|
+
turnId: trigger.turnId,
|
|
393
|
+
success: false,
|
|
394
|
+
status: "failed",
|
|
395
|
+
errorResponse: "An internal error occurred during turn initialization.",
|
|
396
|
+
toolCalls: [],
|
|
397
|
+
trace,
|
|
398
|
+
error: { message: String(err), source: aug.name },
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
// Non-required: log and continue
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Emit run_started event
|
|
407
|
+
emitEvent({
|
|
408
|
+
kind: "run_started",
|
|
409
|
+
turnId: trigger.turnId,
|
|
410
|
+
threadId,
|
|
411
|
+
contextId: trigger.contextId,
|
|
412
|
+
taskId: trigger.taskId,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// ADR-027 Decision 5: internal-trigger handler dispatch.
|
|
416
|
+
// When the trigger type is "internal", walk the augment list in
|
|
417
|
+
// declaration order and offer the trigger to each augment's
|
|
418
|
+
// handleInternalTurn. The first non-null return owns the turn —
|
|
419
|
+
// its TurnResult replaces the standard inference loop's output.
|
|
420
|
+
// The handler-supplied trace.inferenceSteps[] are merged into the
|
|
421
|
+
// kernel trace so runCostCommit aggregates them and turnGate.commit
|
|
422
|
+
// observes the full priced/unpriced cost (this is the path that
|
|
423
|
+
// closes the cost-attribution gap Codex Critical-2 flagged for
|
|
424
|
+
// PR β's option-(b) inline-extraction shortcut).
|
|
425
|
+
//
|
|
426
|
+
// If no handler claims, fall through to the standard inference loop.
|
|
427
|
+
// This preserves the existing behavior where an internal trigger
|
|
428
|
+
// with no augment-side handler runs through the normal model-engine
|
|
429
|
+
// path — useful for kernel-driven internal events that need
|
|
430
|
+
// lifecycle/budgets but no augment-specific execution.
|
|
431
|
+
if (trigger.type === "internal") {
|
|
432
|
+
for (const aug of augments) {
|
|
433
|
+
if (!aug.handleInternalTurn) continue;
|
|
434
|
+
let handlerResult: TurnResult | null;
|
|
435
|
+
try {
|
|
436
|
+
handlerResult = await aug.handleInternalTurn(trigger, {
|
|
437
|
+
threadId,
|
|
438
|
+
peer,
|
|
439
|
+
});
|
|
440
|
+
} catch (err) {
|
|
441
|
+
// Handler threw — surface as a failed turn so the augment
|
|
442
|
+
// author can debug, and so cost-commit still fires.
|
|
443
|
+
//
|
|
444
|
+
// BUDGET-ACCOUNTING WARNING: when a handler throws, it has no
|
|
445
|
+
// way to merge already-incurred LLM cost into trace.inferenceSteps.
|
|
446
|
+
// runCostCommit() will fire with no inference recorded, and
|
|
447
|
+
// budgets will see this turn as zero-cost — undercounting if the
|
|
448
|
+
// handler burned LLM spend before throwing. Per ADR-027 Decision 5,
|
|
449
|
+
// the contract is: handlers MUST NOT throw with side effects.
|
|
450
|
+
// Failure modes (engine error, parse error, etc.) MUST be caught
|
|
451
|
+
// inside the handler and returned as a failed TurnResult with
|
|
452
|
+
// accumulated trace.inferenceSteps. Surface a warning so the
|
|
453
|
+
// misbehaving handler is observable to operators.
|
|
454
|
+
console.warn(
|
|
455
|
+
`[kernel] handleInternalTurn for augment "${aug.name}" threw; ` +
|
|
456
|
+
`cost may be undercounted (handler should return failed TurnResult ` +
|
|
457
|
+
`instead of throwing — see ADR-027 Decision 5).`,
|
|
458
|
+
);
|
|
459
|
+
emitEvent({
|
|
460
|
+
kind: "run_error",
|
|
461
|
+
turnId: trigger.turnId,
|
|
462
|
+
message: err instanceof Error ? err.message : String(err),
|
|
463
|
+
source: aug.name,
|
|
464
|
+
});
|
|
465
|
+
emitEvent({
|
|
466
|
+
kind: "run_finished",
|
|
467
|
+
turnId: trigger.turnId,
|
|
468
|
+
status: "failed",
|
|
469
|
+
});
|
|
470
|
+
traceEmitter.finalize(trace);
|
|
471
|
+
await runCostCommit();
|
|
472
|
+
recordTurnSnapshot();
|
|
473
|
+
return {
|
|
474
|
+
turnId: trigger.turnId,
|
|
475
|
+
success: false,
|
|
476
|
+
status: "failed",
|
|
477
|
+
toolCalls: toolCallRecords,
|
|
478
|
+
trace,
|
|
479
|
+
error: {
|
|
480
|
+
message: err instanceof Error ? err.message : String(err),
|
|
481
|
+
source: aug.name,
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
if (handlerResult === null || handlerResult === undefined) {
|
|
486
|
+
// Augment did not claim — try the next handler.
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
// Handler claimed. Merge its inference-step costs into the
|
|
490
|
+
// kernel trace so runCostCommit observes them. Other trace
|
|
491
|
+
// fields (turnId, threadId, trigger metadata, timestamps)
|
|
492
|
+
// stay kernel-authoritative; handler-supplied artifacts
|
|
493
|
+
// (response, toolCalls, status) flow through to the caller.
|
|
494
|
+
for (const step of handlerResult.trace?.inferenceSteps ?? []) {
|
|
495
|
+
traceEmitter.recordInference(trace, step);
|
|
496
|
+
}
|
|
497
|
+
// Forward kernel events for run_finished — the handler returned
|
|
498
|
+
// a complete result; the transport-side observer needs a
|
|
499
|
+
// close-of-stream event regardless of whether the handler
|
|
500
|
+
// emitted any text.
|
|
501
|
+
emitEvent({
|
|
502
|
+
kind: "run_finished",
|
|
503
|
+
turnId: trigger.turnId,
|
|
504
|
+
status: handlerResult.status,
|
|
505
|
+
});
|
|
506
|
+
traceEmitter.finalize(trace);
|
|
507
|
+
await runCostCommit();
|
|
508
|
+
// Record any handler-supplied transcript text into the snapshot
|
|
509
|
+
// so SchedulerContext.getCompletedTranscript sees it.
|
|
510
|
+
if (handlerResult.response?.parts) {
|
|
511
|
+
for (const part of handlerResult.response.parts) {
|
|
512
|
+
if (part.kind === "text") {
|
|
513
|
+
transcriptParts.push(part);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
recordTurnSnapshot();
|
|
518
|
+
return {
|
|
519
|
+
turnId: trigger.turnId,
|
|
520
|
+
success: handlerResult.success,
|
|
521
|
+
status: handlerResult.status,
|
|
522
|
+
response: handlerResult.response,
|
|
523
|
+
responses: handlerResult.responses,
|
|
524
|
+
errorResponse: handlerResult.errorResponse,
|
|
525
|
+
toolCalls: handlerResult.toolCalls ?? toolCallRecords,
|
|
526
|
+
trace,
|
|
527
|
+
error: handlerResult.error,
|
|
528
|
+
errorClass: handlerResult.errorClass,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
// No handler claimed; fall through to the standard inference loop.
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Run augment context pipeline
|
|
535
|
+
const contextBlocks: ContextBlock[] = [];
|
|
536
|
+
for (const aug of augments) {
|
|
537
|
+
if (!aug.context) continue;
|
|
538
|
+
try {
|
|
539
|
+
const timeout = aug.constraints?.contextTimeoutMs ?? 5000;
|
|
540
|
+
const priorContext = aug.receivesPriorContext ? [...contextBlocks] : undefined;
|
|
541
|
+
const result = await withTimeout(() => aug.context!(turnState, priorContext), timeout);
|
|
542
|
+
if (typeof result === "string") {
|
|
543
|
+
contextBlocks.push({
|
|
544
|
+
source: aug.name,
|
|
545
|
+
content: result,
|
|
546
|
+
placement: "preamble",
|
|
547
|
+
provenance: "augment",
|
|
548
|
+
priority: "normal",
|
|
549
|
+
eviction: "drop",
|
|
550
|
+
origin: "system",
|
|
551
|
+
});
|
|
552
|
+
} else {
|
|
553
|
+
contextBlocks.push(...result);
|
|
554
|
+
}
|
|
555
|
+
} catch (err) {
|
|
556
|
+
if (aug.required) {
|
|
557
|
+
emitEvent({
|
|
558
|
+
kind: "run_error",
|
|
559
|
+
turnId: trigger.turnId,
|
|
560
|
+
message: String(err),
|
|
561
|
+
source: aug.name,
|
|
562
|
+
});
|
|
563
|
+
emitEvent({
|
|
564
|
+
kind: "run_finished",
|
|
565
|
+
turnId: trigger.turnId,
|
|
566
|
+
status: "failed",
|
|
567
|
+
});
|
|
568
|
+
traceEmitter.finalize(trace);
|
|
569
|
+
recordTurnSnapshot();
|
|
570
|
+
return {
|
|
571
|
+
turnId: trigger.turnId,
|
|
572
|
+
success: false,
|
|
573
|
+
status: "failed",
|
|
574
|
+
errorResponse: "An internal error occurred. Please try again.",
|
|
575
|
+
toolCalls: [],
|
|
576
|
+
trace,
|
|
577
|
+
error: { message: String(err), source: aug.name },
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
// Non-required: skip and continue
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Assemble context
|
|
585
|
+
const preamble = buildPreamble({
|
|
586
|
+
sourceAugment: trigger.source,
|
|
587
|
+
peer,
|
|
588
|
+
});
|
|
589
|
+
const budgetConfig = config.contextBudget ?? {};
|
|
590
|
+
const allocator = createContextAllocator({
|
|
591
|
+
maxTokens: model.maxContextTokens,
|
|
592
|
+
historyPercent: budgetConfig.historyPercent ?? 40,
|
|
593
|
+
toolSchemaPercent: budgetConfig.toolSchemaPercent ?? 10,
|
|
594
|
+
tokenizer,
|
|
595
|
+
preamble,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
const historyBudget = Math.floor(
|
|
599
|
+
model.maxContextTokens * ((budgetConfig.historyPercent ?? 40) / 100),
|
|
600
|
+
);
|
|
601
|
+
const historyMessages = history.getHistory(historyBudget);
|
|
602
|
+
|
|
603
|
+
// Select tools
|
|
604
|
+
const toolSelection = selectTools(allTools, turnState, {
|
|
605
|
+
canExpose: (name) => capabilityTable.canExpose(name, turnState),
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const toolChoiceOpt = config.toolChoice ? { toolChoice: config.toolChoice } : undefined;
|
|
609
|
+
let currentPrompt = allocator.assemble(
|
|
610
|
+
contextBlocks,
|
|
611
|
+
historyMessages,
|
|
612
|
+
toolSelection.definitions,
|
|
613
|
+
toolChoiceOpt,
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
const preambleTokens = currentPrompt.systemBlocks.reduce((s, b) => s + tokenizer.count(b), 0);
|
|
617
|
+
const toolSchemaTokens = currentPrompt.tools.reduce(
|
|
618
|
+
(s, t) => s + tokenizer.count(JSON.stringify(t)),
|
|
619
|
+
0,
|
|
620
|
+
);
|
|
621
|
+
traceEmitter.recordContextAssembly(trace, {
|
|
622
|
+
augmentBlocks: contextBlocks.map((b) => ({
|
|
623
|
+
source: b.source,
|
|
624
|
+
tokens: b.tokenCount ?? tokenizer.count(b.content),
|
|
625
|
+
included: !currentPrompt.evictions.find((e) => e.source === b.source),
|
|
626
|
+
evicted: !!currentPrompt.evictions.find((e) => e.source === b.source),
|
|
627
|
+
})),
|
|
628
|
+
preambleTokens,
|
|
629
|
+
toolSchemaTokens,
|
|
630
|
+
historyTokens: historyMessages.reduce((s, m) => s + m.tokenCount, 0),
|
|
631
|
+
totalTokens: currentPrompt.totalTokens,
|
|
632
|
+
budgetUsed: Math.round((currentPrompt.totalTokens / model.maxContextTokens) * 100),
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
traceEmitter.recordToolSelection(trace, {
|
|
636
|
+
totalTools: allTools.length,
|
|
637
|
+
phase1Used: toolSelection.phase1Used,
|
|
638
|
+
mountedTools: toolSelection.mounted.map((t) => t.name),
|
|
639
|
+
withheldTools: toolSelection.withheld,
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// Phase 5 helper: cost commit (post-response, fail-safe).
|
|
643
|
+
// Called after each successful engine exit. Errors are logged; they
|
|
644
|
+
// do NOT fail the turn because the response already exists.
|
|
645
|
+
//
|
|
646
|
+
// Multi-iteration semantics: a single turn may invoke the model
|
|
647
|
+
// multiple times (tool-use loop). We commit the SUM of all inference
|
|
648
|
+
// steps' priced costs. If any step is unpriced, the whole turn
|
|
649
|
+
// commits as unpriced — partial-priced sums would mislead the budget
|
|
650
|
+
// store and silently suppress unpriced_turns counters.
|
|
651
|
+
async function runCostCommit(): Promise<void> {
|
|
652
|
+
const steps = trace.inferenceSteps;
|
|
653
|
+
let cost: CostResult;
|
|
654
|
+
if (steps.length === 0) {
|
|
655
|
+
cost = { priced: false, reason: "no inference recorded" };
|
|
656
|
+
} else {
|
|
657
|
+
let totalCostUsd = 0;
|
|
658
|
+
let unpricedReason: string | null = null;
|
|
659
|
+
for (const step of steps) {
|
|
660
|
+
if (step.cost.priced) {
|
|
661
|
+
totalCostUsd += step.cost.costUsd;
|
|
662
|
+
} else {
|
|
663
|
+
unpricedReason = step.cost.reason;
|
|
664
|
+
break; // any unpriced step → whole turn unpriced
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
cost =
|
|
668
|
+
unpricedReason !== null
|
|
669
|
+
? { priced: false, reason: unpricedReason }
|
|
670
|
+
: { priced: true, costUsd: totalCostUsd };
|
|
671
|
+
}
|
|
672
|
+
for (const gate of turnGates) {
|
|
673
|
+
if (!gate.turnGate.commit) continue;
|
|
674
|
+
try {
|
|
675
|
+
await gate.turnGate.commit({
|
|
676
|
+
turnId: trigger.turnId,
|
|
677
|
+
peer: trigger.peer ?? null,
|
|
678
|
+
threadId,
|
|
679
|
+
cost,
|
|
680
|
+
});
|
|
681
|
+
} catch (err) {
|
|
682
|
+
console.error(`[turn-gate ${gate.name}] commit failed:`, err);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Inference + tool execution loop
|
|
688
|
+
capabilityTable.resetTurn();
|
|
689
|
+
const consecutiveFailures = new Map<string, number>();
|
|
690
|
+
let inferenceCount = 0;
|
|
691
|
+
const maxInferenceLoops = config.maxInferenceLoops ?? 10;
|
|
692
|
+
|
|
693
|
+
while (inferenceCount < maxInferenceLoops) {
|
|
694
|
+
if (signal?.aborted) return makeAbortResult();
|
|
695
|
+
inferenceCount++;
|
|
696
|
+
const inferStart = Date.now();
|
|
697
|
+
const {
|
|
698
|
+
response,
|
|
699
|
+
streamed: streamedText,
|
|
700
|
+
messageId: streamMessageId,
|
|
701
|
+
} = await streamingInference(model, currentPrompt, trigger.turnId, emitEvent);
|
|
702
|
+
const inferDuration = Date.now() - inferStart;
|
|
703
|
+
|
|
704
|
+
const cost: CostResult =
|
|
705
|
+
response.costUsd !== undefined
|
|
706
|
+
? { priced: true, costUsd: response.costUsd }
|
|
707
|
+
: { priced: false, reason: response.unpricedReason ?? "engine returned no costUsd" };
|
|
708
|
+
|
|
709
|
+
traceEmitter.recordInference(trace, {
|
|
710
|
+
model: config.model,
|
|
711
|
+
inputTokens: response.inputTokens,
|
|
712
|
+
outputTokens: response.outputTokens,
|
|
713
|
+
durationMs: inferDuration,
|
|
714
|
+
toolCalls: [],
|
|
715
|
+
cost,
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// Always append model content to history (even on tool_use turns)
|
|
719
|
+
if (response.content) {
|
|
720
|
+
history.append({
|
|
721
|
+
id: crypto.randomUUID(),
|
|
722
|
+
role: "assistant",
|
|
723
|
+
content: response.content,
|
|
724
|
+
timestamp: Date.now(),
|
|
725
|
+
tokenCount: tokenizer.count(response.content),
|
|
726
|
+
});
|
|
727
|
+
// ADR-027 transcript capture — assistant content is part of the
|
|
728
|
+
// turn's two-way exchange and must surface to scheduleAfterTurn.
|
|
729
|
+
transcriptParts.push({ kind: "text", text: response.content });
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// No tool calls or context window exhausted — we're done.
|
|
733
|
+
// If tool calls ARE present, always execute them regardless of
|
|
734
|
+
// finishReason. Some engines return "end_turn" alongside tool
|
|
735
|
+
// calls; dropping them would silently lose the model's work.
|
|
736
|
+
if (!response.toolCalls?.length || response.finishReason === "max_tokens") {
|
|
737
|
+
// Output validation (v1: flag and trace, don't block)
|
|
738
|
+
if (response.content) {
|
|
739
|
+
const toolNames = allTools.map((t) => t.name);
|
|
740
|
+
const augmentNames = augments.map((a) => a.name);
|
|
741
|
+
const validation = validateOutput(response.content, [...toolNames, ...augmentNames]);
|
|
742
|
+
if (validation.flagged) {
|
|
743
|
+
trace.outputValidation = {
|
|
744
|
+
flagged: true,
|
|
745
|
+
reasons: validation.reasons,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Emit text_message event for the final response — only if we
|
|
751
|
+
// didn't already stream it AND there's actual content (skip empty
|
|
752
|
+
// text from pure tool_use responses).
|
|
753
|
+
if (!streamedText && response.content) {
|
|
754
|
+
emitEvent({
|
|
755
|
+
kind: "text_message",
|
|
756
|
+
turnId: trigger.turnId,
|
|
757
|
+
messageId: streamMessageId,
|
|
758
|
+
role: "assistant",
|
|
759
|
+
text: response.content,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Emit run_finished
|
|
764
|
+
emitEvent({
|
|
765
|
+
kind: "run_finished",
|
|
766
|
+
turnId: trigger.turnId,
|
|
767
|
+
status: "completed",
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
traceEmitter.finalize(trace);
|
|
771
|
+
await runCostCommit();
|
|
772
|
+
recordTurnSnapshot();
|
|
773
|
+
return {
|
|
774
|
+
turnId: trigger.turnId,
|
|
775
|
+
success: true,
|
|
776
|
+
status: "completed",
|
|
777
|
+
response: response.content
|
|
778
|
+
? { parts: [{ kind: "text", text: response.content }], contextId: trigger.contextId }
|
|
779
|
+
: undefined,
|
|
780
|
+
toolCalls: toolCallRecords,
|
|
781
|
+
trace,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Phase 1: Validate all tool calls (synchronous — fast)
|
|
786
|
+
let terminateToolLoop = false;
|
|
787
|
+
type ToolCallEntry =
|
|
788
|
+
| {
|
|
789
|
+
type: "error";
|
|
790
|
+
call: { name: string; arguments: Record<string, unknown> };
|
|
791
|
+
error: string;
|
|
792
|
+
}
|
|
793
|
+
| {
|
|
794
|
+
type: "execute";
|
|
795
|
+
call: { name: string; arguments: Record<string, unknown> };
|
|
796
|
+
reg: { tool: Tool; augment: string };
|
|
797
|
+
validatedInput: unknown;
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
const entries: ToolCallEntry[] = [];
|
|
801
|
+
|
|
802
|
+
for (const call of response.toolCalls) {
|
|
803
|
+
const check = capabilityTable.canExecute(call.name, call.arguments, turnState);
|
|
804
|
+
traceEmitter.recordCapabilityCheck(trace, {
|
|
805
|
+
tool: call.name,
|
|
806
|
+
result:
|
|
807
|
+
"allowed" in check
|
|
808
|
+
? "allowed"
|
|
809
|
+
: "needsApproval" in check
|
|
810
|
+
? "needs-approval"
|
|
811
|
+
: "denied",
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
if ("denied" in check) {
|
|
815
|
+
entries.push({ type: "error", call, error: `Error: ${check.reason}` });
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if ("needsApproval" in check) {
|
|
820
|
+
entries.push({
|
|
821
|
+
type: "error",
|
|
822
|
+
call,
|
|
823
|
+
error: "Tool requires operator approval. Skipping for now.",
|
|
824
|
+
});
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const reg = toolRegistry.get(call.name);
|
|
829
|
+
if (!reg) {
|
|
830
|
+
entries.push({ type: "error", call, error: `Error: Unknown tool "${call.name}"` });
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const validation = reg.tool.input.safeParse(call.arguments);
|
|
835
|
+
if (!validation.success) {
|
|
836
|
+
entries.push({
|
|
837
|
+
type: "error",
|
|
838
|
+
call,
|
|
839
|
+
error: `Validation error: ${JSON.stringify(validation.error)}`,
|
|
840
|
+
});
|
|
841
|
+
const prevCount = consecutiveFailures.get(call.name) ?? 0;
|
|
842
|
+
consecutiveFailures.set(call.name, prevCount + 1);
|
|
843
|
+
if ((consecutiveFailures.get(call.name) ?? 0) >= 2) {
|
|
844
|
+
entries.push({
|
|
845
|
+
type: "error",
|
|
846
|
+
call,
|
|
847
|
+
error: `Tool "${call.name}" failed validation 2 consecutive times. Stopping tool use.`,
|
|
848
|
+
});
|
|
849
|
+
terminateToolLoop = true;
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
consecutiveFailures.delete(call.name);
|
|
856
|
+
entries.push({ type: "execute", call, reg, validatedInput: validation.data });
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Phase 2: Execute validated tools in parallel (with event emission)
|
|
860
|
+
const execResults = await Promise.all(
|
|
861
|
+
entries.map(async (entry) => {
|
|
862
|
+
if (entry.type === "error") {
|
|
863
|
+
return {
|
|
864
|
+
call: entry.call,
|
|
865
|
+
output: entry.error,
|
|
866
|
+
durationMs: 0,
|
|
867
|
+
isError: true,
|
|
868
|
+
toolCallId: crypto.randomUUID(),
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
const toolCallId = `${entry.call.name}-${crypto.randomUUID()}`;
|
|
872
|
+
emitEvent({
|
|
873
|
+
kind: "tool_call_started",
|
|
874
|
+
turnId: trigger.turnId,
|
|
875
|
+
toolCallId,
|
|
876
|
+
toolName: entry.call.name,
|
|
877
|
+
augmentName: entry.reg.augment,
|
|
878
|
+
});
|
|
879
|
+
emitEvent({
|
|
880
|
+
kind: "tool_call_args",
|
|
881
|
+
turnId: trigger.turnId,
|
|
882
|
+
toolCallId,
|
|
883
|
+
args: entry.call.arguments,
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
const execStart = Date.now();
|
|
887
|
+
let output: string;
|
|
888
|
+
let isError = false;
|
|
889
|
+
let terminate: ToolResult["terminate"] | undefined;
|
|
890
|
+
try {
|
|
891
|
+
const augForTool = augments.find((a) =>
|
|
892
|
+
a.tools?.some((t) => t.name === entry.reg.tool.name),
|
|
893
|
+
);
|
|
894
|
+
const timeout = augForTool?.constraints?.toolTimeoutMs ?? 30000;
|
|
895
|
+
const toolContext = {
|
|
896
|
+
turnId: trigger.turnId,
|
|
897
|
+
peer: peer ?? null,
|
|
898
|
+
threadId,
|
|
899
|
+
};
|
|
900
|
+
const raw: string | ToolResult = await withTimeout(
|
|
901
|
+
() => entry.reg.tool.execute(entry.validatedInput, toolContext),
|
|
902
|
+
timeout,
|
|
903
|
+
);
|
|
904
|
+
if (typeof raw === "string") {
|
|
905
|
+
output = raw;
|
|
906
|
+
} else {
|
|
907
|
+
output = raw.content;
|
|
908
|
+
terminate = raw.terminate;
|
|
909
|
+
}
|
|
910
|
+
} catch (err) {
|
|
911
|
+
output = `Error: ${String(err)}`;
|
|
912
|
+
isError = true;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
emitEvent({
|
|
916
|
+
kind: "tool_call_result",
|
|
917
|
+
turnId: trigger.turnId,
|
|
918
|
+
toolCallId,
|
|
919
|
+
output,
|
|
920
|
+
isError,
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
return {
|
|
924
|
+
call: entry.call,
|
|
925
|
+
output,
|
|
926
|
+
durationMs: Date.now() - execStart,
|
|
927
|
+
isError,
|
|
928
|
+
toolCallId,
|
|
929
|
+
terminate,
|
|
930
|
+
};
|
|
931
|
+
}),
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
// Phase 3: Append all results to history in order with matching toolCallIds
|
|
935
|
+
for (const { call, output, durationMs, isError, toolCallId } of execResults) {
|
|
936
|
+
const callStr = JSON.stringify(call);
|
|
937
|
+
history.append({
|
|
938
|
+
id: crypto.randomUUID(),
|
|
939
|
+
role: "tool_use",
|
|
940
|
+
toolCallId,
|
|
941
|
+
content: callStr,
|
|
942
|
+
timestamp: Date.now(),
|
|
943
|
+
tokenCount: tokenizer.count(callStr),
|
|
944
|
+
});
|
|
945
|
+
history.append({
|
|
946
|
+
id: crypto.randomUUID(),
|
|
947
|
+
role: "tool_result",
|
|
948
|
+
toolCallId,
|
|
949
|
+
content: output,
|
|
950
|
+
timestamp: Date.now(),
|
|
951
|
+
tokenCount: tokenizer.count(output),
|
|
952
|
+
});
|
|
953
|
+
if (!isError) {
|
|
954
|
+
toolCallRecords.push({
|
|
955
|
+
name: call.name,
|
|
956
|
+
input: call.arguments,
|
|
957
|
+
output,
|
|
958
|
+
durationMs,
|
|
959
|
+
});
|
|
960
|
+
capabilityTable.recordToolCall(call.name);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Capture first non-error terminate directive from this batch.
|
|
965
|
+
// Reset per-iteration so a directive from one batch doesn't leak forward.
|
|
966
|
+
// Runtime allowlist: although the type narrows status to "input-required" |
|
|
967
|
+
// "completed", custom augments using JS or `as` casts could return any
|
|
968
|
+
// string. Reject anything outside the allowlist — kernel-controlled states
|
|
969
|
+
// (failed/canceled/rejected/auth-required) must not be augment-spoofable.
|
|
970
|
+
let pendingTerminate: ToolResult["terminate"] | undefined;
|
|
971
|
+
for (const r of execResults) {
|
|
972
|
+
if (!r.isError && r.terminate && !pendingTerminate) {
|
|
973
|
+
const s = r.terminate.status;
|
|
974
|
+
if (s === "input-required" || s === "completed") {
|
|
975
|
+
pendingTerminate = r.terminate;
|
|
976
|
+
break;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (pendingTerminate) {
|
|
982
|
+
// Emit the directive's message as a normal assistant text message so
|
|
983
|
+
// chat widgets render it in the message bubble (not just the tool-call
|
|
984
|
+
// panel) and old AG-UI consumers see something. Skip when message is
|
|
985
|
+
// empty — emitting an empty text_message produces a blank bubble.
|
|
986
|
+
if (pendingTerminate.message) {
|
|
987
|
+
emitEvent({
|
|
988
|
+
kind: "text_message",
|
|
989
|
+
turnId: trigger.turnId,
|
|
990
|
+
messageId: crypto.randomUUID(),
|
|
991
|
+
role: "assistant",
|
|
992
|
+
text: pendingTerminate.message,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
emitEvent({
|
|
996
|
+
kind: "run_finished",
|
|
997
|
+
turnId: trigger.turnId,
|
|
998
|
+
status: pendingTerminate.status,
|
|
999
|
+
...(pendingTerminate.message !== undefined && {
|
|
1000
|
+
message: pendingTerminate.message,
|
|
1001
|
+
}),
|
|
1002
|
+
});
|
|
1003
|
+
if (pendingTerminate.message) {
|
|
1004
|
+
transcriptParts.push({ kind: "text", text: pendingTerminate.message });
|
|
1005
|
+
}
|
|
1006
|
+
traceEmitter.finalize(trace);
|
|
1007
|
+
await runCostCommit();
|
|
1008
|
+
recordTurnSnapshot();
|
|
1009
|
+
return {
|
|
1010
|
+
turnId: trigger.turnId,
|
|
1011
|
+
success: true,
|
|
1012
|
+
status: pendingTerminate.status,
|
|
1013
|
+
response: pendingTerminate.message
|
|
1014
|
+
? {
|
|
1015
|
+
parts: [{ kind: "text", text: pendingTerminate.message }],
|
|
1016
|
+
contextId: trigger.contextId,
|
|
1017
|
+
}
|
|
1018
|
+
: undefined,
|
|
1019
|
+
toolCalls: toolCallRecords,
|
|
1020
|
+
trace,
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// If consecutive failures terminated tool use, let model see the error and respond
|
|
1025
|
+
if (terminateToolLoop) {
|
|
1026
|
+
const updatedHistory = history.getHistory(historyBudget);
|
|
1027
|
+
currentPrompt = allocator.assemble(
|
|
1028
|
+
contextBlocks,
|
|
1029
|
+
updatedHistory,
|
|
1030
|
+
toolSelection.definitions,
|
|
1031
|
+
toolChoiceOpt,
|
|
1032
|
+
);
|
|
1033
|
+
|
|
1034
|
+
const termInferStart = Date.now();
|
|
1035
|
+
const {
|
|
1036
|
+
response: finalResponse,
|
|
1037
|
+
streamed: termStreamed,
|
|
1038
|
+
messageId: termMessageId,
|
|
1039
|
+
} = await streamingInference(model, currentPrompt, trigger.turnId, emitEvent);
|
|
1040
|
+
const termInferDuration = Date.now() - termInferStart;
|
|
1041
|
+
|
|
1042
|
+
// Record this final inference so its cost lands in trace.inferenceSteps[]
|
|
1043
|
+
// and runCostCommit() sees it. Without this, the consecutive-failure
|
|
1044
|
+
// completion path silently drops the final API call from cost
|
|
1045
|
+
// accounting — and an unpriced final step would never trigger the
|
|
1046
|
+
// any-unpriced→whole-turn-unpriced rule.
|
|
1047
|
+
const termCost: CostResult =
|
|
1048
|
+
finalResponse.costUsd !== undefined
|
|
1049
|
+
? { priced: true, costUsd: finalResponse.costUsd }
|
|
1050
|
+
: {
|
|
1051
|
+
priced: false,
|
|
1052
|
+
reason: finalResponse.unpricedReason ?? "engine returned no costUsd",
|
|
1053
|
+
};
|
|
1054
|
+
traceEmitter.recordInference(trace, {
|
|
1055
|
+
model: config.model,
|
|
1056
|
+
inputTokens: finalResponse.inputTokens,
|
|
1057
|
+
outputTokens: finalResponse.outputTokens,
|
|
1058
|
+
durationMs: termInferDuration,
|
|
1059
|
+
toolCalls: [],
|
|
1060
|
+
cost: termCost,
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
if (finalResponse.content) {
|
|
1064
|
+
history.append({
|
|
1065
|
+
id: crypto.randomUUID(),
|
|
1066
|
+
role: "assistant",
|
|
1067
|
+
content: finalResponse.content,
|
|
1068
|
+
timestamp: Date.now(),
|
|
1069
|
+
tokenCount: tokenizer.count(finalResponse.content),
|
|
1070
|
+
});
|
|
1071
|
+
transcriptParts.push({ kind: "text", text: finalResponse.content });
|
|
1072
|
+
if (!termStreamed) {
|
|
1073
|
+
emitEvent({
|
|
1074
|
+
kind: "text_message",
|
|
1075
|
+
turnId: trigger.turnId,
|
|
1076
|
+
messageId: termMessageId,
|
|
1077
|
+
role: "assistant",
|
|
1078
|
+
text: finalResponse.content,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
emitEvent({
|
|
1083
|
+
kind: "run_finished",
|
|
1084
|
+
turnId: trigger.turnId,
|
|
1085
|
+
status: "completed",
|
|
1086
|
+
});
|
|
1087
|
+
traceEmitter.finalize(trace);
|
|
1088
|
+
await runCostCommit();
|
|
1089
|
+
recordTurnSnapshot();
|
|
1090
|
+
return {
|
|
1091
|
+
turnId: trigger.turnId,
|
|
1092
|
+
success: true,
|
|
1093
|
+
status: "completed",
|
|
1094
|
+
response: finalResponse.content
|
|
1095
|
+
? {
|
|
1096
|
+
parts: [{ kind: "text", text: finalResponse.content }],
|
|
1097
|
+
contextId: trigger.contextId,
|
|
1098
|
+
}
|
|
1099
|
+
: undefined,
|
|
1100
|
+
toolCalls: toolCallRecords,
|
|
1101
|
+
trace,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// Check abort after tool execution
|
|
1106
|
+
if (signal?.aborted) return makeAbortResult();
|
|
1107
|
+
|
|
1108
|
+
// Rebuild prompt with updated history
|
|
1109
|
+
const updatedHistory = history.getHistory(historyBudget);
|
|
1110
|
+
currentPrompt = allocator.assemble(
|
|
1111
|
+
contextBlocks,
|
|
1112
|
+
updatedHistory,
|
|
1113
|
+
toolSelection.definitions,
|
|
1114
|
+
toolChoiceOpt,
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Max inference loops reached
|
|
1119
|
+
emitEvent({
|
|
1120
|
+
kind: "text_message",
|
|
1121
|
+
turnId: trigger.turnId,
|
|
1122
|
+
messageId: crypto.randomUUID(),
|
|
1123
|
+
role: "assistant",
|
|
1124
|
+
text: "I've completed the available actions.",
|
|
1125
|
+
});
|
|
1126
|
+
emitEvent({
|
|
1127
|
+
kind: "run_finished",
|
|
1128
|
+
turnId: trigger.turnId,
|
|
1129
|
+
status: "completed",
|
|
1130
|
+
});
|
|
1131
|
+
transcriptParts.push({ kind: "text", text: "I've completed the available actions." });
|
|
1132
|
+
traceEmitter.finalize(trace);
|
|
1133
|
+
await runCostCommit();
|
|
1134
|
+
recordTurnSnapshot();
|
|
1135
|
+
return {
|
|
1136
|
+
turnId: trigger.turnId,
|
|
1137
|
+
success: true,
|
|
1138
|
+
status: "completed",
|
|
1139
|
+
response: {
|
|
1140
|
+
parts: [{ kind: "text", text: "I've completed the available actions." }],
|
|
1141
|
+
contextId: trigger.contextId,
|
|
1142
|
+
},
|
|
1143
|
+
toolCalls: toolCallRecords,
|
|
1144
|
+
trace,
|
|
1145
|
+
};
|
|
1146
|
+
},
|
|
1147
|
+
};
|
|
1148
|
+
}
|