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.
Files changed (121) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/README.md +161 -0
  4. package/package.json +76 -0
  5. package/src/agent-card.ts +39 -0
  6. package/src/agent.ts +283 -0
  7. package/src/agentmail-client.ts +138 -0
  8. package/src/augments/bash/index.ts +463 -0
  9. package/src/augments/bash/skill/SKILL.md +156 -0
  10. package/src/augments/budgets/budget-store.ts +513 -0
  11. package/src/augments/budgets/index.ts +134 -0
  12. package/src/augments/budgets/preamble.ts +93 -0
  13. package/src/augments/budgets/types.ts +89 -0
  14. package/src/augments/file-memory/index.ts +71 -0
  15. package/src/augments/filesystem/index.ts +533 -0
  16. package/src/augments/filesystem/skill/SKILL.md +142 -0
  17. package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
  18. package/src/augments/layered-memory/extractor/buffer.ts +56 -0
  19. package/src/augments/layered-memory/extractor/frequency.ts +79 -0
  20. package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
  21. package/src/augments/layered-memory/extractor/parse.ts +75 -0
  22. package/src/augments/layered-memory/extractor/prompt.md +26 -0
  23. package/src/augments/layered-memory/index.ts +757 -0
  24. package/src/augments/layered-memory/skill/SKILL.md +153 -0
  25. package/src/augments/layered-memory/storage/migrations/README.md +16 -0
  26. package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
  27. package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
  28. package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
  29. package/src/augments/layered-memory/storage/types.ts +98 -0
  30. package/src/augments/link/index.ts +489 -0
  31. package/src/augments/link/translate.ts +261 -0
  32. package/src/augments/notify/adapters/agentmail.ts +70 -0
  33. package/src/augments/notify/adapters/telegram.ts +60 -0
  34. package/src/augments/notify/adapters/webhook.ts +55 -0
  35. package/src/augments/notify/index.ts +284 -0
  36. package/src/augments/notify/skill/SKILL.md +150 -0
  37. package/src/augments/org-context/index.ts +721 -0
  38. package/src/augments/org-context/skill/SKILL.md +96 -0
  39. package/src/augments/skills/index.ts +103 -0
  40. package/src/augments/supabase-memory/index.ts +151 -0
  41. package/src/augments/telegram-transport/index.ts +312 -0
  42. package/src/augments/telegram-transport/polling.ts +55 -0
  43. package/src/augments/telegram-transport/webhook.ts +56 -0
  44. package/src/augments/turn-control/index.ts +61 -0
  45. package/src/augments/turn-control/skill/SKILL.md +155 -0
  46. package/src/augments/visitor-auth/email-validation.ts +66 -0
  47. package/src/augments/visitor-auth/index.ts +779 -0
  48. package/src/augments/visitor-auth/rate-limiter.ts +90 -0
  49. package/src/augments/visitor-auth/skill/SKILL.md +55 -0
  50. package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
  51. package/src/augments/visitor-auth/storage/types.ts +164 -0
  52. package/src/augments/visitor-auth/types.ts +123 -0
  53. package/src/augments/visitor-auth/verify-page.ts +179 -0
  54. package/src/augments/web-fetch/index.ts +331 -0
  55. package/src/augments/web-fetch/skill/SKILL.md +100 -0
  56. package/src/cli/agent-index.ts +289 -0
  57. package/src/cli/augment-catalog.ts +320 -0
  58. package/src/cli/augment-resolver.ts +597 -0
  59. package/src/cli/commands/add-skill.ts +194 -0
  60. package/src/cli/commands/add.ts +87 -0
  61. package/src/cli/commands/chat.ts +207 -0
  62. package/src/cli/commands/create.ts +462 -0
  63. package/src/cli/commands/dev.ts +139 -0
  64. package/src/cli/commands/eval.ts +180 -0
  65. package/src/cli/commands/ls.ts +66 -0
  66. package/src/cli/commands/remove.ts +95 -0
  67. package/src/cli/commands/restart.ts +40 -0
  68. package/src/cli/commands/start.ts +123 -0
  69. package/src/cli/commands/status.ts +104 -0
  70. package/src/cli/commands/stop.ts +84 -0
  71. package/src/cli/commands/visitors-revoke.ts +155 -0
  72. package/src/cli/commands/visitors.ts +101 -0
  73. package/src/cli/config-parser.ts +1034 -0
  74. package/src/cli/engine-resolver.ts +68 -0
  75. package/src/cli/index.ts +178 -0
  76. package/src/cli/model-picker.ts +89 -0
  77. package/src/cli/pid-registry.ts +146 -0
  78. package/src/cli/plist-generator.ts +117 -0
  79. package/src/cli/resolve-config.ts +56 -0
  80. package/src/cli/scaffold-skills.ts +158 -0
  81. package/src/cli/scaffold.ts +291 -0
  82. package/src/cli/skill-frontmatter.ts +51 -0
  83. package/src/cli/skill-validator.ts +151 -0
  84. package/src/cli/types.ts +228 -0
  85. package/src/cli/yaml-helpers.ts +66 -0
  86. package/src/engines/_shared/cost.ts +55 -0
  87. package/src/engines/_shared/schema-normalize.ts +75 -0
  88. package/src/engines/anthropic/pricing.ts +117 -0
  89. package/src/engines/anthropic.ts +483 -0
  90. package/src/engines/openai/pricing.ts +67 -0
  91. package/src/engines/openai.ts +446 -0
  92. package/src/engines/openrouter/pricing.ts +83 -0
  93. package/src/engines/openrouter.ts +185 -0
  94. package/src/helpers.ts +24 -0
  95. package/src/http.ts +387 -0
  96. package/src/index.ts +165 -0
  97. package/src/kernel/capability-table.ts +172 -0
  98. package/src/kernel/context-allocator.ts +161 -0
  99. package/src/kernel/history-manager.ts +198 -0
  100. package/src/kernel/lifecycle-manager.ts +106 -0
  101. package/src/kernel/output-validator.ts +35 -0
  102. package/src/kernel/preamble.ts +23 -0
  103. package/src/kernel/route-collector.ts +97 -0
  104. package/src/kernel/timeout.ts +21 -0
  105. package/src/kernel/tool-selector.ts +47 -0
  106. package/src/kernel/trace-emitter.ts +66 -0
  107. package/src/kernel/transport-queue.ts +147 -0
  108. package/src/kernel/turn-loop.ts +1148 -0
  109. package/src/memory/context-synthesis.ts +83 -0
  110. package/src/memory/memory-bus.ts +61 -0
  111. package/src/memory/registry.ts +80 -0
  112. package/src/memory/tools.ts +320 -0
  113. package/src/memory/types.ts +8 -0
  114. package/src/parts.ts +30 -0
  115. package/src/scaffold-templates/identity.md +31 -0
  116. package/src/telegram-client.ts +145 -0
  117. package/src/tokenizer.ts +14 -0
  118. package/src/transports/ag-ui-events.ts +253 -0
  119. package/src/transports/visitor-token.ts +82 -0
  120. package/src/transports/web-transport.ts +948 -0
  121. 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
+ }