@intx/hub-sessions 0.1.2 → 0.2.2

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 (87) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +84 -1
  3. package/dist/agent-repo.d.ts +89 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +185 -0
  7. package/dist/asset-service.d.ts +123 -0
  8. package/dist/asset-service.js +349 -0
  9. package/dist/available-skills-stanza.d.ts +21 -0
  10. package/dist/available-skills-stanza.js +32 -0
  11. package/dist/credential-push.d.ts +32 -0
  12. package/dist/credential-push.js +85 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +357 -0
  17. package/dist/hub-session-lookups.d.ts +17 -0
  18. package/dist/hub-session-lookups.js +204 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +122 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.js +16 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1692 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +53 -0
  30. package/dist/repo-store/subscribe-kind.js +179 -0
  31. package/dist/repo-store/types.d.ts +483 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +235 -0
  34. package/dist/session-service.js +997 -0
  35. package/dist/skill-kind.d.ts +41 -0
  36. package/dist/skill-kind.js +288 -0
  37. package/dist/substrate.d.ts +8 -0
  38. package/dist/substrate.js +21 -0
  39. package/dist/workflow-kind.d.ts +21 -0
  40. package/dist/workflow-kind.js +263 -0
  41. package/dist/workflow-run-event-log.d.ts +21 -0
  42. package/dist/workflow-run-event-log.js +51 -0
  43. package/dist/workflow-run-kind.d.ts +326 -0
  44. package/dist/workflow-run-kind.js +2646 -0
  45. package/dist/workflow-run-reader.d.ts +47 -0
  46. package/dist/workflow-run-reader.js +157 -0
  47. package/dist/ws/index.d.ts +3 -0
  48. package/dist/ws/index.js +3 -0
  49. package/dist/ws/sidecar-events.d.ts +134 -0
  50. package/dist/ws/sidecar-events.js +70 -0
  51. package/dist/ws/sidecar-handler.d.ts +184 -0
  52. package/dist/ws/sidecar-handler.js +1603 -0
  53. package/dist/ws/sidecar-token-authenticator.d.ts +15 -0
  54. package/dist/ws/sidecar-token-authenticator.js +24 -0
  55. package/package.json +34 -12
  56. package/src/agent-repo.test.ts +0 -310
  57. package/src/agent-repo.ts +0 -165
  58. package/src/agent-state-kind.test.ts +0 -247
  59. package/src/agent-state-kind.ts +0 -204
  60. package/src/asset-service.test.ts +0 -540
  61. package/src/asset-service.ts +0 -378
  62. package/src/available-skills-stanza.test.ts +0 -87
  63. package/src/available-skills-stanza.ts +0 -47
  64. package/src/credential-push.ts +0 -65
  65. package/src/event-collector-registry.test.ts +0 -73
  66. package/src/event-collector-registry.ts +0 -171
  67. package/src/event-collector.test.ts +0 -1387
  68. package/src/event-collector.ts +0 -424
  69. package/src/hub-session-lookups.ts +0 -206
  70. package/src/hub-session-orchestrator.test.ts +0 -510
  71. package/src/hub-session-orchestrator.ts +0 -213
  72. package/src/index.ts +0 -78
  73. package/src/repo-store/index.ts +0 -15
  74. package/src/repo-store/store.test.ts +0 -1169
  75. package/src/repo-store/store.ts +0 -428
  76. package/src/repo-store/types.ts +0 -253
  77. package/src/session-service.test.ts +0 -895
  78. package/src/session-service.ts +0 -464
  79. package/src/skill-kind.test.ts +0 -599
  80. package/src/skill-kind.ts +0 -350
  81. package/src/ws/index.ts +0 -18
  82. package/src/ws/sidecar-events.test.ts +0 -96
  83. package/src/ws/sidecar-events.ts +0 -231
  84. package/src/ws/sidecar-handler.test.ts +0 -2217
  85. package/src/ws/sidecar-handler.ts +0 -1574
  86. package/tsconfig.json +0 -4
  87. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Render the `<available_skills>` stanza appended to the agent's
3
+ * system prompt. Returns the empty string when `entries` is empty —
4
+ * an empty `<available_skills></available_skills>` wrapper would be
5
+ * misleading noise for agents with no skills attached.
6
+ *
7
+ * Values are XML-escaped at the boundary. The skill kind handler
8
+ * already rejects descriptions containing literal `<` or `>` so the
9
+ * `&` escape is the practical case in production; the others are
10
+ * defensive.
11
+ */
12
+ export function buildAvailableSkillsStanza(entries) {
13
+ if (entries.length === 0) {
14
+ return "";
15
+ }
16
+ const lines = ["<available_skills>"];
17
+ for (const entry of entries) {
18
+ lines.push(" <skill>");
19
+ lines.push(` <name>${escapeXml(entry.qualifiedName)}</name>`);
20
+ lines.push(` <description>${escapeXml(entry.description)}</description>`);
21
+ lines.push(` <path>${escapeXml(entry.workspacePath)}</path>`);
22
+ lines.push(" </skill>");
23
+ }
24
+ lines.push("</available_skills>");
25
+ return lines.join("\n");
26
+ }
27
+ function escapeXml(value) {
28
+ return value
29
+ .replaceAll("&", "&amp;")
30
+ .replaceAll("<", "&lt;")
31
+ .replaceAll(">", "&gt;");
32
+ }
@@ -0,0 +1,32 @@
1
+ import type { DB } from "@intx/db";
2
+ import type { SidecarRouter } from "./ws/sidecar-handler.js";
3
+ /**
4
+ * Re-resolve a single running instance's inference sources from the catalog
5
+ * (the agent's model requirements plus the invoker preferences persisted on
6
+ * the instance) and push the ordered list to its sidecar. The head of the
7
+ * catalog-priority-ordered list is the active default; the tail is the
8
+ * failover chain.
9
+ *
10
+ * No-op when the instance resolves to no launchable source — the resolver's
11
+ * own logger is the signal for why.
12
+ */
13
+ export declare function pushInstanceSourceUpdate(db: DB["db"], sidecarRouter: Pick<SidecarRouter, "sendSourcesUpdate">, instance: {
14
+ address: string;
15
+ agentId: string;
16
+ tenantId: string;
17
+ modelPreferences: unknown;
18
+ }): Promise<void>;
19
+ /**
20
+ * After a credential secret is rotated, re-resolve every running instance in
21
+ * the tenant against the catalog and push the updates. A rotated secret flows
22
+ * through because resolution dereferences the provider's credential reference
23
+ * to the current secret.
24
+ */
25
+ export declare function pushSourceUpdates(db: DB["db"], sidecarRouter: SidecarRouter, tenantId: string): Promise<void>;
26
+ /**
27
+ * After a catalog edit in a tenant, re-resolve and push to every running
28
+ * instance in that tenant AND its descendants. Descendants inherit the
29
+ * edited tenant's catalog, so a change there (a disabled provider, a new
30
+ * offering, a price update) alters their resolved sources too.
31
+ */
32
+ export declare function pushSourceUpdatesSubtree(db: DB["db"], sidecarRouter: SidecarRouter, tenantId: string): Promise<void>;
@@ -0,0 +1,85 @@
1
+ // Shared logic for re-resolving a running instance's inference sources from
2
+ // the catalog and pushing the update to its sidecar.
3
+ //
4
+ // Used after a credential secret rotation (a model provider's credential
5
+ // changes the resolved source's apiKey) and on sidecar reconnect.
6
+ import { eq, and, inArray } from "drizzle-orm";
7
+ import { getLogger } from "@intx/log";
8
+ import { agentInstance } from "@intx/db/schema";
9
+ import { resolveInstanceModelSources, getDescendantTenants } from "@intx/db";
10
+ const log = getLogger(["hub", "credentials"]);
11
+ /**
12
+ * Re-resolve a single running instance's inference sources from the catalog
13
+ * (the agent's model requirements plus the invoker preferences persisted on
14
+ * the instance) and push the ordered list to its sidecar. The head of the
15
+ * catalog-priority-ordered list is the active default; the tail is the
16
+ * failover chain.
17
+ *
18
+ * No-op when the instance resolves to no launchable source — the resolver's
19
+ * own logger is the signal for why.
20
+ */
21
+ export async function pushInstanceSourceUpdate(db, sidecarRouter, instance) {
22
+ const resolution = await resolveInstanceModelSources(db, instance.tenantId, instance);
23
+ if (!resolution.ok)
24
+ return;
25
+ const [head] = resolution.sources;
26
+ if (head === undefined)
27
+ return;
28
+ await sidecarRouter.sendSourcesUpdate(instance.address, resolution.sources, head.id);
29
+ }
30
+ /**
31
+ * Re-resolve every running instance in the given tenants against the catalog
32
+ * and push the updates to sidecars. Each instance re-resolves from its own
33
+ * tenant's context (its ancestor chain), so the rotated/edited upstream entry
34
+ * flows through. Errors are logged per-instance but do not propagate.
35
+ */
36
+ async function pushSourceUpdatesToTenants(db, sidecarRouter, tenantIds) {
37
+ if (tenantIds.length === 0)
38
+ return;
39
+ // Callers fire this without awaiting, so it must never reject: a failure to
40
+ // enumerate or push is logged and dropped, not propagated as an unhandled
41
+ // rejection. The push is best effort — the next mutation or a sidecar
42
+ // reconnect re-resolves sources.
43
+ try {
44
+ const instances = await db.query.agentInstance.findMany({
45
+ where: and(inArray(agentInstance.tenantId, tenantIds), eq(agentInstance.status, "running")),
46
+ });
47
+ if (instances.length === 0)
48
+ return;
49
+ const results = await Promise.allSettled(instances.map((instance) => pushInstanceSourceUpdate(db, sidecarRouter, instance)));
50
+ for (const result of results) {
51
+ if (result.status === "rejected") {
52
+ log.warn `Failed to push source update: ${String(result.reason)}`;
53
+ }
54
+ }
55
+ }
56
+ catch (err) {
57
+ log.warn `Failed to push source updates: ${String(err)}`;
58
+ }
59
+ }
60
+ /**
61
+ * After a credential secret is rotated, re-resolve every running instance in
62
+ * the tenant against the catalog and push the updates. A rotated secret flows
63
+ * through because resolution dereferences the provider's credential reference
64
+ * to the current secret.
65
+ */
66
+ export async function pushSourceUpdates(db, sidecarRouter, tenantId) {
67
+ await pushSourceUpdatesToTenants(db, sidecarRouter, [tenantId]);
68
+ }
69
+ /**
70
+ * After a catalog edit in a tenant, re-resolve and push to every running
71
+ * instance in that tenant AND its descendants. Descendants inherit the
72
+ * edited tenant's catalog, so a change there (a disabled provider, a new
73
+ * offering, a price update) alters their resolved sources too.
74
+ */
75
+ export async function pushSourceUpdatesSubtree(db, sidecarRouter, tenantId) {
76
+ let tenants;
77
+ try {
78
+ tenants = await getDescendantTenants(db, tenantId);
79
+ }
80
+ catch (err) {
81
+ log.warn `Failed to enumerate descendants for source push: ${String(err)}`;
82
+ return;
83
+ }
84
+ await pushSourceUpdatesToTenants(db, sidecarRouter, tenants);
85
+ }
@@ -0,0 +1,20 @@
1
+ import type { DB } from "@intx/db";
2
+ import type { InferenceEvent } from "@intx/types/runtime";
3
+ import type { SessionStatus } from "@intx/types";
4
+ import { type TurnFinalized } from "./event-collector.js";
5
+ export type EventCollectorRegistry = {
6
+ create(agentAddress: string, tenantId: string, sessionId: string, instanceId: string): void;
7
+ dispatch(agentAddress: string, event: InferenceEvent): void;
8
+ abandon(agentAddress: string): void;
9
+ has(agentAddress: string): boolean;
10
+ getStatus(agentAddress: string): SessionStatus | undefined;
11
+ getAccumulatedText(agentAddress: string): string | undefined;
12
+ getCurrentTurnId(agentAddress: string): string | null | undefined;
13
+ getLastTurnId(agentAddress: string): string | null | undefined;
14
+ };
15
+ export type EventCollectorRegistryConfig = {
16
+ db: DB["db"];
17
+ onTurnFinalized?: (agentAddress: string, turn: TurnFinalized) => void;
18
+ };
19
+ export declare function deriveStatus(event: InferenceEvent): SessionStatus | null;
20
+ export declare function createEventCollectorRegistry(config: EventCollectorRegistryConfig): EventCollectorRegistry;
@@ -0,0 +1,115 @@
1
+ // Registry of active event collectors, keyed by agent address.
2
+ //
3
+ // The hub creates a collector when an instance starts and removes it when the
4
+ // instance ends or the sidecar disconnects. The hub session orchestrator's
5
+ // `agent.event` listener looks up the collector by agent address and
6
+ // dispatches the event.
7
+ import { getLogger } from "@intx/log";
8
+ import { createEventCollector, } from "./event-collector.js";
9
+ const log = getLogger(["hub", "event-collector-registry"]);
10
+ export function deriveStatus(event) {
11
+ switch (event.type) {
12
+ case "inference.start":
13
+ return { status: "busy" };
14
+ case "connector.reply":
15
+ return { status: "idle" };
16
+ case "reactor.gate.blocked":
17
+ if (event.data.reason === "approval")
18
+ return { status: "waiting_approval" };
19
+ return null;
20
+ case "reactor.gate.cleared":
21
+ return { status: "busy" };
22
+ case "reactor.done":
23
+ return { status: "idle" };
24
+ case "reactor.error":
25
+ if (event.data.fatal)
26
+ return { status: "idle" };
27
+ return null;
28
+ default:
29
+ return null;
30
+ }
31
+ }
32
+ export function createEventCollectorRegistry(config) {
33
+ const { db, onTurnFinalized } = config;
34
+ const collectors = new Map();
35
+ const statuses = new Map();
36
+ function create(agentAddress, tenantId, sessionId, instanceId) {
37
+ if (collectors.has(agentAddress)) {
38
+ log.warn `Collector already exists for ${agentAddress}, replacing`;
39
+ abandon(agentAddress);
40
+ }
41
+ const collector = createEventCollector({
42
+ db,
43
+ sessionId,
44
+ instanceId,
45
+ tenantId,
46
+ ...(onTurnFinalized
47
+ ? {
48
+ onTurnFinalized: (turn) => onTurnFinalized(agentAddress, turn),
49
+ }
50
+ : {}),
51
+ });
52
+ collectors.set(agentAddress, collector);
53
+ statuses.set(agentAddress, { status: "idle" });
54
+ }
55
+ function removeCollector(agentAddress) {
56
+ collectors.delete(agentAddress);
57
+ statuses.delete(agentAddress);
58
+ }
59
+ function dispatch(agentAddress, event) {
60
+ const collector = collectors.get(agentAddress);
61
+ if (collector === undefined) {
62
+ return;
63
+ }
64
+ const derived = deriveStatus(event);
65
+ if (derived !== null) {
66
+ statuses.set(agentAddress, derived);
67
+ }
68
+ const isTerminal = event.type === "reactor.done" ||
69
+ (event.type === "reactor.error" && event.data.fatal);
70
+ collector
71
+ .onEvent(event)
72
+ .catch((err) => {
73
+ log.warn `Failed to persist event ${event.type} seq=${String(event.seq)} for ${agentAddress}: ${err instanceof Error ? err.message : String(err)}`;
74
+ })
75
+ .finally(() => {
76
+ if (isTerminal) {
77
+ removeCollector(agentAddress);
78
+ }
79
+ });
80
+ }
81
+ function abandon(agentAddress) {
82
+ const collector = collectors.get(agentAddress);
83
+ if (collector === undefined)
84
+ return;
85
+ collector.abandon().catch((err) => {
86
+ log.warn `Failed to abandon collector for ${agentAddress}: ${err instanceof Error ? err.message : String(err)}`;
87
+ });
88
+ removeCollector(agentAddress);
89
+ }
90
+ function has(agentAddress) {
91
+ return collectors.has(agentAddress);
92
+ }
93
+ function getStatus(agentAddress) {
94
+ return statuses.get(agentAddress);
95
+ }
96
+ function getAccumulatedText(agentAddress) {
97
+ return collectors.get(agentAddress)?.getAccumulatedText();
98
+ }
99
+ function getCurrentTurnId(agentAddress) {
100
+ return collectors.get(agentAddress)?.getCurrentTurnId();
101
+ }
102
+ function getLastTurnId(agentAddress) {
103
+ return collectors.get(agentAddress)?.getLastTurnId();
104
+ }
105
+ return {
106
+ create,
107
+ dispatch,
108
+ abandon,
109
+ has,
110
+ getStatus,
111
+ getAccumulatedText,
112
+ getCurrentTurnId,
113
+ getLastTurnId,
114
+ };
115
+ }
@@ -0,0 +1,39 @@
1
+ import type { InferenceEvent } from "@intx/types/runtime";
2
+ import { type DB } from "@intx/db";
3
+ export type TurnToolCall = {
4
+ name: string;
5
+ arguments: Record<string, unknown>;
6
+ result: string;
7
+ isError: boolean;
8
+ };
9
+ export type TurnFinalized = {
10
+ turnId: string;
11
+ status: "completed" | "failed";
12
+ text: string;
13
+ hadReply: boolean;
14
+ hadError: boolean;
15
+ errors: {
16
+ category: string;
17
+ message: string;
18
+ }[];
19
+ toolCalls: TurnToolCall[];
20
+ toolErrors: {
21
+ name: string;
22
+ content: string;
23
+ }[];
24
+ };
25
+ export type EventCollector = {
26
+ onEvent(event: InferenceEvent): Promise<void>;
27
+ abandon(): Promise<void>;
28
+ getAccumulatedText(): string;
29
+ getCurrentTurnId(): string | null;
30
+ getLastTurnId(): string | null;
31
+ };
32
+ export type EventCollectorConfig = {
33
+ db: DB["db"];
34
+ sessionId: string;
35
+ instanceId: string;
36
+ tenantId: string;
37
+ onTurnFinalized?: (turn: TurnFinalized) => void;
38
+ };
39
+ export declare function createEventCollector(config: EventCollectorConfig): EventCollector;
@@ -0,0 +1,357 @@
1
+ // Persists assistant inference turns from the InferenceEvent stream.
2
+ //
3
+ // One collector per active session. Events are written eagerly to the DB
4
+ // so data survives crashes. The collector does not block the websocket
5
+ // message loop — callers should fire-and-forget and log errors.
6
+ import { eq } from "drizzle-orm";
7
+ import { inferenceTurn, turnPart } from "@intx/db/schema";
8
+ import { getLogger } from "@intx/log";
9
+ import { parseTurnPartType } from "@intx/db";
10
+ import { generateId } from "@intx/hub-common";
11
+ const log = getLogger(["hub", "event-collector"]);
12
+ export function createEventCollector(config) {
13
+ const { db, sessionId, instanceId, tenantId, onTurnFinalized } = config;
14
+ // Current inference turn being accumulated. A new turn is created on each
15
+ // inference.start. Finalized on connector.reply, reactor.done,
16
+ // reactor.error (fatal), or abandon. Null when no turn is active.
17
+ let currentTurnId = null;
18
+ // Most recent turn ID, set in beginTurn. Unlike currentTurnId this is NOT
19
+ // cleared on finalization — the SSE replay endpoint needs the turn ID
20
+ // after the turn commits but before the collector is removed.
21
+ let lastTurnId = null;
22
+ let ordinal = 0;
23
+ // Prevents double-finalization when reactor.done and abandon() race.
24
+ let finalized = false;
25
+ // Set when inference.error fires so connector.reply knows to persist its
26
+ // content (on normal turns, inference.done already persisted the text).
27
+ let pendingError = false;
28
+ // Accumulated visible text content for the current turn. Only text blocks
29
+ // from inference.done (not thinking/reasoning) are included. Reset on each
30
+ // new turn.
31
+ let accumulatedText = "";
32
+ // In-progress text from inference.text.delta events during the current
33
+ // inference step. Reset on inference.done (when accumulatedText takes over).
34
+ let streamingText = "";
35
+ // Set when inference.error fires. Unlike pendingError (which resets on
36
+ // connector.reply), this persists until finalization so the callback can
37
+ // report whether an inference error occurred during the turn.
38
+ let turnHadError = false;
39
+ // Structured error details accumulated during the turn for inclusion in
40
+ // TurnFinalized. Reset on each new turn.
41
+ let accumulatedErrors = [];
42
+ // Maps tool call IDs to tool names for correlating tool results with their
43
+ // originating calls. Populated from inference.done tool_call blocks.
44
+ const callNames = new Map();
45
+ const callArgs = new Map();
46
+ // Tool calls accumulated for TurnFinalized.
47
+ let accumulatedToolCalls = [];
48
+ // Tool results that reported isError, accumulated for TurnFinalized.
49
+ let accumulatedToolErrors = [];
50
+ async function onEvent(event) {
51
+ switch (event.type) {
52
+ case "inference.start":
53
+ await beginTurn(event.data.model);
54
+ await insertPart("step-start", null, { model: event.data.model });
55
+ break;
56
+ case "inference.text.delta":
57
+ streamingText += event.data.token;
58
+ break;
59
+ case "inference.done":
60
+ await handleInferenceDone(event.data.turn.content);
61
+ streamingText = "";
62
+ break;
63
+ case "tool.done": {
64
+ const callId = event.data.result.callId;
65
+ const isError = event.data.result.isError ?? false;
66
+ await insertPart("tool", null, {
67
+ kind: "result",
68
+ callId,
69
+ content: event.data.result.content,
70
+ isError,
71
+ });
72
+ const name = callNames.get(callId) ?? callId;
73
+ const raw = event.data.result.content;
74
+ const content = typeof raw === "string" ? raw : JSON.stringify(raw);
75
+ accumulatedToolCalls.push({
76
+ name,
77
+ arguments: callArgs.get(callId) ?? {},
78
+ result: content,
79
+ isError,
80
+ });
81
+ if (isError) {
82
+ accumulatedToolErrors.push({ name, content });
83
+ }
84
+ break;
85
+ }
86
+ case "inference.error":
87
+ pendingError = true;
88
+ turnHadError = true;
89
+ await insertPart("error", event.data.error.message, {
90
+ category: event.data.error.category,
91
+ ...(event.data.error.statusCode !== undefined
92
+ ? { statusCode: event.data.error.statusCode }
93
+ : {}),
94
+ });
95
+ accumulatedErrors.push({
96
+ category: event.data.error.category,
97
+ message: event.data.error.message,
98
+ });
99
+ break;
100
+ case "connector.reply":
101
+ if (finalized)
102
+ break;
103
+ // Only persist reply content when it originated from an error path.
104
+ // On normal turns inference.done already persisted the text parts.
105
+ if (pendingError) {
106
+ accumulatedText += event.data.content;
107
+ await insertPart("text", event.data.content, null);
108
+ pendingError = false;
109
+ }
110
+ await finalizeTurn(turnHadError ? "failed" : "completed", true, true);
111
+ break;
112
+ case "reactor.done":
113
+ await finalizeTurn("completed", true, false);
114
+ break;
115
+ case "reactor.error":
116
+ if (event.data.fatal && !finalized) {
117
+ // The reactor failed before any inference started (e.g., context
118
+ // store load failure), but the user needs to see why their agent
119
+ // failed, and without a turn there is no container for the error.
120
+ if (currentTurnId === null) {
121
+ await beginTurn("unknown");
122
+ }
123
+ turnHadError = true;
124
+ // Push after beginTurn so the error survives the array reset.
125
+ accumulatedErrors.push({
126
+ category: "reactor_error",
127
+ message: event.data.error,
128
+ });
129
+ await insertPart("error", event.data.error, {
130
+ category: "reactor_error",
131
+ });
132
+ await finalizeTurn("failed", true, false);
133
+ }
134
+ else if (!event.data.fatal && !finalized) {
135
+ if (currentTurnId === null) {
136
+ await beginTurn("unknown");
137
+ }
138
+ turnHadError = true;
139
+ accumulatedErrors.push({
140
+ category: "reactor_error",
141
+ message: event.data.error,
142
+ });
143
+ await insertPart("error", event.data.error, {
144
+ category: "reactor_error",
145
+ });
146
+ }
147
+ break;
148
+ default:
149
+ // reactor.start, streaming deltas, usage, and other events are
150
+ // not persisted.
151
+ break;
152
+ }
153
+ }
154
+ async function beginTurn(model) {
155
+ // A previous turn is still open — this is normal in multi-step tool-use
156
+ // loops where inference.start fires again after tools return.
157
+ if (currentTurnId !== null && !finalized) {
158
+ await finalizeTurn("completed", true, false);
159
+ }
160
+ currentTurnId = generateId("inferenceTurn");
161
+ lastTurnId = currentTurnId;
162
+ ordinal = 0;
163
+ finalized = false;
164
+ pendingError = false;
165
+ accumulatedText = "";
166
+ streamingText = "";
167
+ turnHadError = false;
168
+ accumulatedErrors = [];
169
+ callNames.clear();
170
+ callArgs.clear();
171
+ accumulatedToolCalls = [];
172
+ accumulatedToolErrors = [];
173
+ await db.insert(inferenceTurn).values({
174
+ id: currentTurnId,
175
+ sessionId,
176
+ instanceId,
177
+ tenantId,
178
+ model,
179
+ status: "running",
180
+ startedAt: new Date(),
181
+ });
182
+ }
183
+ async function handleInferenceDone(content) {
184
+ for (const block of content) {
185
+ switch (block.type) {
186
+ case "text":
187
+ accumulatedText += block.text;
188
+ await insertPart("text", block.text, null);
189
+ break;
190
+ case "thinking":
191
+ await insertPart("reasoning", block.thinking, null);
192
+ break;
193
+ case "redacted_thinking":
194
+ // The opaque `data` blob is meaningless to humans and
195
+ // must be preserved verbatim for echo-back on follow-up
196
+ // turns; persisting it as a reasoning row would invite
197
+ // truncation or display. Skip and let the adapter layer
198
+ // own the round-trip.
199
+ break;
200
+ case "refusal":
201
+ // Refusal blocks carry human-readable text the model
202
+ // emitted when it declined a structured-output request.
203
+ // A dedicated `refusal` part kind keeps the signal
204
+ // distinct from ordinary `text` (the model produced
205
+ // schema-conformant content) and from `error` (the HTTP
206
+ // call failed or the protocol mismatched). Session
207
+ // readers can branch on the part type to render policy
208
+ // declines differently from regular assistant output.
209
+ await insertPart("refusal", block.reason, null);
210
+ break;
211
+ case "tool_call":
212
+ callNames.set(block.id, block.name);
213
+ callArgs.set(block.id, block.arguments);
214
+ await insertPart("tool", null, {
215
+ kind: "call",
216
+ callId: block.id,
217
+ name: block.name,
218
+ arguments: block.arguments,
219
+ });
220
+ break;
221
+ case "tool_result":
222
+ // Tool results in the content block are echoes of earlier
223
+ // tool.done events. Skip to avoid duplication.
224
+ break;
225
+ case "citation":
226
+ // Citations annotate model output by reference (URI or
227
+ // document index). Persistence semantics — whether the
228
+ // cited source warrants an audit row of its own — are not
229
+ // yet settled; skip until they are.
230
+ break;
231
+ case "code_execution_request":
232
+ case "code_execution_result":
233
+ // Server-side code execution requests and results.
234
+ // Persistence semantics — whether the code and its output
235
+ // warrant audit rows of their own, and how to relate the
236
+ // pair through the requestId back-pointer — are not yet
237
+ // settled; skip until they are.
238
+ break;
239
+ case "image":
240
+ case "audio":
241
+ case "video":
242
+ case "document": {
243
+ // All media block variants persist into the generic "file"
244
+ // part bucket. The block's own `type` distinguishes the
245
+ // semantic role; `mimeType` distinguishes the encoding; the
246
+ // MediaSource discriminant distinguishes inline vs reference.
247
+ const source = block.source;
248
+ if (source.kind === "base64") {
249
+ await insertPart("file", null, {
250
+ kind: "base64",
251
+ mimeType: source.mimeType,
252
+ dataLength: source.data.length,
253
+ });
254
+ }
255
+ else if (source.kind === "file-reference") {
256
+ await insertPart("file", null, {
257
+ kind: "file-reference",
258
+ mimeType: source.mimeType,
259
+ reference: source.reference,
260
+ });
261
+ }
262
+ else if (source.kind === "url") {
263
+ // The `url` MediaSource variant carries a self-contained
264
+ // dereferenceable HTTP(S) URL (Gemini accepts these in
265
+ // `fileData/fileUri`; other adapters route similarly).
266
+ // The session record keeps the URL on a distinct `url`
267
+ // field rather than reusing the `reference` slot that
268
+ // `file-reference` uses: a session reader that filters
269
+ // or joins on `metadata->>'reference'` should see
270
+ // provider-opaque file ids only, with public URLs
271
+ // surfaced under their own field. The discriminant
272
+ // `kind` is the structural source of truth, and the
273
+ // field-per-variant shape keeps naive substring queries
274
+ // honest.
275
+ await insertPart("file", null, {
276
+ kind: "url",
277
+ mimeType: source.mimeType,
278
+ url: source.url,
279
+ });
280
+ }
281
+ else {
282
+ source;
283
+ throw new Error(`unreachable: unknown MediaSource kind`);
284
+ }
285
+ break;
286
+ }
287
+ }
288
+ }
289
+ // Mark the end of this inference step.
290
+ await insertPart("step-finish", null, null);
291
+ }
292
+ async function finalizeTurn(status, notify, hadReply) {
293
+ if (currentTurnId === null || finalized)
294
+ return;
295
+ finalized = true;
296
+ const turnId = currentTurnId;
297
+ await db
298
+ .update(inferenceTurn)
299
+ .set({ status, endedAt: new Date() })
300
+ .where(eq(inferenceTurn.id, turnId));
301
+ if (notify && onTurnFinalized) {
302
+ onTurnFinalized({
303
+ turnId,
304
+ status,
305
+ text: accumulatedText,
306
+ hadReply,
307
+ hadError: turnHadError,
308
+ errors: [...accumulatedErrors],
309
+ toolCalls: [...accumulatedToolCalls],
310
+ toolErrors: [...accumulatedToolErrors],
311
+ });
312
+ }
313
+ currentTurnId = null;
314
+ }
315
+ async function abandon() {
316
+ if (currentTurnId === null || finalized)
317
+ return;
318
+ log.warn `Abandoning running turn ${currentTurnId} for session ${sessionId}`;
319
+ await finalizeTurn("failed", false, false);
320
+ }
321
+ async function insertPart(partType, content, metadata) {
322
+ if (currentTurnId === null) {
323
+ log.warn `Dropping ${partType} part: no active turn for session ${sessionId}`;
324
+ return;
325
+ }
326
+ const values = {
327
+ id: generateId("turnPart"),
328
+ turnId: currentTurnId,
329
+ sessionId,
330
+ type: parseTurnPartType(partType),
331
+ ordinal: ordinal++,
332
+ };
333
+ if (content !== null) {
334
+ values.content = content;
335
+ }
336
+ if (metadata !== null) {
337
+ values.metadata = metadata;
338
+ }
339
+ await db.insert(turnPart).values(values);
340
+ }
341
+ function getAccumulatedText() {
342
+ return accumulatedText + streamingText;
343
+ }
344
+ function getCurrentTurnId() {
345
+ return currentTurnId;
346
+ }
347
+ function getLastTurnId() {
348
+ return lastTurnId;
349
+ }
350
+ return {
351
+ onEvent,
352
+ abandon,
353
+ getAccumulatedText,
354
+ getCurrentTurnId,
355
+ getLastTurnId,
356
+ };
357
+ }