@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.
- package/LICENSE +176 -0
- package/README.md +84 -1
- package/dist/agent-repo.d.ts +89 -0
- package/dist/agent-repo.js +109 -0
- package/dist/agent-state-kind.d.ts +12 -0
- package/dist/agent-state-kind.js +185 -0
- package/dist/asset-service.d.ts +123 -0
- package/dist/asset-service.js +349 -0
- package/dist/available-skills-stanza.d.ts +21 -0
- package/dist/available-skills-stanza.js +32 -0
- package/dist/credential-push.d.ts +32 -0
- package/dist/credential-push.js +85 -0
- package/dist/event-collector-registry.d.ts +20 -0
- package/dist/event-collector-registry.js +115 -0
- package/dist/event-collector.d.ts +39 -0
- package/dist/event-collector.js +357 -0
- package/dist/hub-session-lookups.d.ts +17 -0
- package/dist/hub-session-lookups.js +204 -0
- package/dist/hub-session-orchestrator.d.ts +25 -0
- package/dist/hub-session-orchestrator.js +122 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -0
- package/dist/package-registry-kind.d.ts +70 -0
- package/dist/package-registry-kind.js +260 -0
- package/dist/repo-store/index.d.ts +4 -0
- package/dist/repo-store/index.js +3 -0
- package/dist/repo-store/store.d.ts +41 -0
- package/dist/repo-store/store.js +1692 -0
- package/dist/repo-store/subscribe-kind.d.ts +53 -0
- package/dist/repo-store/subscribe-kind.js +179 -0
- package/dist/repo-store/types.d.ts +483 -0
- package/dist/repo-store/types.js +42 -0
- package/dist/session-service.d.ts +235 -0
- package/dist/session-service.js +997 -0
- package/dist/skill-kind.d.ts +41 -0
- package/dist/skill-kind.js +288 -0
- package/dist/substrate.d.ts +8 -0
- package/dist/substrate.js +21 -0
- package/dist/workflow-kind.d.ts +21 -0
- package/dist/workflow-kind.js +263 -0
- package/dist/workflow-run-event-log.d.ts +21 -0
- package/dist/workflow-run-event-log.js +51 -0
- package/dist/workflow-run-kind.d.ts +326 -0
- package/dist/workflow-run-kind.js +2646 -0
- package/dist/workflow-run-reader.d.ts +47 -0
- package/dist/workflow-run-reader.js +157 -0
- package/dist/ws/index.d.ts +3 -0
- package/dist/ws/index.js +3 -0
- package/dist/ws/sidecar-events.d.ts +134 -0
- package/dist/ws/sidecar-events.js +70 -0
- package/dist/ws/sidecar-handler.d.ts +184 -0
- package/dist/ws/sidecar-handler.js +1603 -0
- package/dist/ws/sidecar-token-authenticator.d.ts +15 -0
- package/dist/ws/sidecar-token-authenticator.js +24 -0
- package/package.json +34 -12
- package/src/agent-repo.test.ts +0 -310
- package/src/agent-repo.ts +0 -165
- package/src/agent-state-kind.test.ts +0 -247
- package/src/agent-state-kind.ts +0 -204
- package/src/asset-service.test.ts +0 -540
- package/src/asset-service.ts +0 -378
- package/src/available-skills-stanza.test.ts +0 -87
- package/src/available-skills-stanza.ts +0 -47
- package/src/credential-push.ts +0 -65
- package/src/event-collector-registry.test.ts +0 -73
- package/src/event-collector-registry.ts +0 -171
- package/src/event-collector.test.ts +0 -1387
- package/src/event-collector.ts +0 -424
- package/src/hub-session-lookups.ts +0 -206
- package/src/hub-session-orchestrator.test.ts +0 -510
- package/src/hub-session-orchestrator.ts +0 -213
- package/src/index.ts +0 -78
- package/src/repo-store/index.ts +0 -15
- package/src/repo-store/store.test.ts +0 -1169
- package/src/repo-store/store.ts +0 -428
- package/src/repo-store/types.ts +0 -253
- package/src/session-service.test.ts +0 -895
- package/src/session-service.ts +0 -464
- package/src/skill-kind.test.ts +0 -599
- package/src/skill-kind.ts +0 -350
- package/src/ws/index.ts +0 -18
- package/src/ws/sidecar-events.test.ts +0 -96
- package/src/ws/sidecar-events.ts +0 -231
- package/src/ws/sidecar-handler.test.ts +0 -2217
- package/src/ws/sidecar-handler.ts +0 -1574
- package/tsconfig.json +0 -4
- 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("&", "&")
|
|
30
|
+
.replaceAll("<", "<")
|
|
31
|
+
.replaceAll(">", ">");
|
|
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
|
+
}
|