@tangle-network/agent-runtime 0.105.0 → 0.106.1
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/README.md +2 -2
- package/dist/activation-CRjVfOiy.js +203 -0
- package/dist/activation-CRjVfOiy.js.map +1 -0
- package/dist/activation-DyWB0K6E.d.ts +98 -0
- package/dist/agent.d.ts +216 -234
- package/dist/agent.js +615 -547
- package/dist/agent.js.map +1 -1
- package/dist/analyst-loop-DGdtVENg.js +517 -0
- package/dist/analyst-loop-DGdtVENg.js.map +1 -0
- package/dist/analyst-loop.d.ts +10 -43
- package/dist/analyst-loop.js +2 -11
- package/dist/backends-CiOCyRHb.js +743 -0
- package/dist/backends-CiOCyRHb.js.map +1 -0
- package/dist/candidate-execution/index.d.ts +4 -192
- package/dist/candidate-execution/index.js +5 -66
- package/dist/candidate-execution-DqMbv1rU.js +488 -0
- package/dist/candidate-execution-DqMbv1rU.js.map +1 -0
- package/dist/coder-yhVWbdWc.js +40 -0
- package/dist/coder-yhVWbdWc.js.map +1 -0
- package/dist/conversation-BpLQZGPH.js +1265 -0
- package/dist/conversation-BpLQZGPH.js.map +1 -0
- package/dist/conversation.d.ts +2 -804
- package/dist/conversation.js +2 -55
- package/dist/environment-provider-CfLUmKmf.js +1168 -0
- package/dist/environment-provider-CfLUmKmf.js.map +1 -0
- package/dist/environment-provider-IUGU3epE.d.ts +964 -0
- package/dist/environment-provider.d.ts +2 -78
- package/dist/environment-provider.js +2 -18
- package/dist/errors-DEAvWQPy.js +111 -0
- package/dist/errors-DEAvWQPy.js.map +1 -0
- package/dist/improvement-adapter-Cr5TBSji.d.ts +166 -0
- package/dist/improvement-cycle-C095kS_g.js +2624 -0
- package/dist/improvement-cycle-C095kS_g.js.map +1 -0
- package/dist/index-BGdxAwfO.d.ts +838 -0
- package/dist/index-C-FYUuFG.d.ts +193 -0
- package/dist/index-CbNM40xG.d.ts +140 -0
- package/dist/index-DruSKs3Z.d.ts +7224 -0
- package/dist/index-Efjb3nrQ.d.ts +671 -0
- package/dist/index.d.ts +14 -1058
- package/dist/index.js +2106 -2082
- package/dist/index.js.map +1 -1
- package/dist/intelligence.d.ts +768 -889
- package/dist/intelligence.js +1466 -1367
- package/dist/intelligence.js.map +1 -1
- package/dist/{kb-gate-CwHO0vz6.d.ts → kb-gate-C8z2juK8.d.ts} +33 -31
- package/dist/kb-gate-DpaSwXVx.js +80 -0
- package/dist/kb-gate-DpaSwXVx.js.map +1 -0
- package/dist/knowledge-Ku-mPP4f.js +433 -0
- package/dist/knowledge-Ku-mPP4f.js.map +1 -0
- package/dist/knowledge.d.ts +2 -150
- package/dist/knowledge.js +2 -40
- package/dist/local-harness-Dh8PJ0ot.d.ts +455 -0
- package/dist/loop-runner-bin-BXsFEUcf.d.ts +162 -0
- package/dist/loop-runner-bin-CRagyuNb.js +268 -0
- package/dist/loop-runner-bin-CRagyuNb.js.map +1 -0
- package/dist/loop-runner-bin.d.ts +2 -15
- package/dist/loop-runner-bin.js +2 -24
- package/dist/loops.d.ts +5 -4164
- package/dist/loops.js +8 -419
- package/dist/mcp/bin.d.ts +1 -1
- package/dist/mcp/bin.js +116 -146
- package/dist/mcp/bin.js.map +1 -1
- package/dist/mcp/index.d.ts +295 -436
- package/dist/mcp/index.js +1242 -1177
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/memory-bin.d.ts +1 -1
- package/dist/mcp/memory-bin.js +37 -16
- package/dist/mcp/memory-bin.js.map +1 -1
- package/dist/memory-server-DL6cE2Ag.js +234 -0
- package/dist/memory-server-DL6cE2Ag.js.map +1 -0
- package/dist/model-resolution-Btd9iIKV.js +98 -0
- package/dist/model-resolution-Btd9iIKV.js.map +1 -0
- package/dist/openai-tools-B68JaOCx.d.ts +24 -0
- package/dist/openai-tools-CKLy1C7M.js +44 -0
- package/dist/openai-tools-CKLy1C7M.js.map +1 -0
- package/dist/otel-export-D34IQW1C.js +843 -0
- package/dist/otel-export-D34IQW1C.js.map +1 -0
- package/dist/platform.d.ts +188 -185
- package/dist/platform.js +185 -218
- package/dist/platform.js.map +1 -1
- package/dist/prepare-B53sPV7M.js +4292 -0
- package/dist/prepare-B53sPV7M.js.map +1 -0
- package/dist/primeintellect/index.d.ts +149 -145
- package/dist/primeintellect/index.js +626 -1003
- package/dist/primeintellect/index.js.map +1 -1
- package/dist/profiles.d.ts +296 -490
- package/dist/profiles.js +722 -749
- package/dist/profiles.js.map +1 -1
- package/dist/protected-model-port-BLPH5VVv.js +722 -0
- package/dist/protected-model-port-BLPH5VVv.js.map +1 -0
- package/dist/protected-model-port-BP6Z4eau.d.ts +283 -0
- package/dist/protected-redaction--F3v1oo8.js +200 -0
- package/dist/protected-redaction--F3v1oo8.js.map +1 -0
- package/dist/redact-D-u-rrcn.js +82 -0
- package/dist/redact-D-u-rrcn.js.map +1 -0
- package/dist/redact-kbIOjfjM.d.ts +452 -0
- package/dist/researcher-CoVqNhfI.js +433 -0
- package/dist/researcher-CoVqNhfI.js.map +1 -0
- package/dist/runtime-5zM5XxLr.js +5408 -0
- package/dist/runtime-5zM5XxLr.js.map +1 -0
- package/dist/sandbox-events-DeI5xX8P.js +316 -0
- package/dist/sandbox-events-DeI5xX8P.js.map +1 -0
- package/dist/spawn-journal-CwPvKUTa.js +456 -0
- package/dist/spawn-journal-CwPvKUTa.js.map +1 -0
- package/dist/structural-rollout-IXUEplky.js +1445 -0
- package/dist/structural-rollout-IXUEplky.js.map +1 -0
- package/dist/substrate-B0TYNrXn.js +27 -0
- package/dist/substrate-B0TYNrXn.js.map +1 -0
- package/dist/{substrate-Dyiqmd3o.d.ts → substrate-BcnuSHXm.d.ts} +57 -54
- package/dist/supervise-BiRutHS9.js +7321 -0
- package/dist/supervise-BiRutHS9.js.map +1 -0
- package/dist/supervisor-DTKhF-RV.js +3826 -0
- package/dist/supervisor-DTKhF-RV.js.map +1 -0
- package/dist/testing.d.ts +4 -3
- package/dist/testing.js +2478 -2637
- package/dist/testing.js.map +1 -1
- package/dist/tool-server-RcWgLIsL.js +141 -0
- package/dist/tool-server-RcWgLIsL.js.map +1 -0
- package/dist/trace-propagation-CJJC7SVB.js +57 -0
- package/dist/trace-propagation-CJJC7SVB.js.map +1 -0
- package/dist/types-BaIpb0XT.d.ts +492 -0
- package/dist/types-BevOjfTY.d.ts +765 -0
- package/dist/types-C9j4qg6l.d.ts +500 -0
- package/dist/types-Cja5OTGh.d.ts +190 -0
- package/dist/util-MVgdwuIS.js +145 -0
- package/dist/util-MVgdwuIS.js.map +1 -0
- package/dist/workspace-archive-BosQiotA.js +575 -0
- package/dist/workspace-archive-BosQiotA.js.map +1 -0
- package/package.json +44 -56
- package/skills/build-with-agent-runtime/SKILL.md +1 -0
- package/dist/activation-Cm-9MMX1.d.ts +0 -69
- package/dist/analyst-loop.js.map +0 -1
- package/dist/backends-BO4Jqoe2.d.ts +0 -134
- package/dist/candidate-execution/index.js.map +0 -1
- package/dist/chunk-2KGAN2HM.js +0 -1256
- package/dist/chunk-2KGAN2HM.js.map +0 -1
- package/dist/chunk-2VZYRB55.js +0 -834
- package/dist/chunk-2VZYRB55.js.map +0 -1
- package/dist/chunk-3MDZX7YU.js +0 -125
- package/dist/chunk-3MDZX7YU.js.map +0 -1
- package/dist/chunk-5IBQAPVB.js +0 -29
- package/dist/chunk-5IBQAPVB.js.map +0 -1
- package/dist/chunk-5QOB7H74.js +0 -387
- package/dist/chunk-5QOB7H74.js.map +0 -1
- package/dist/chunk-C5RTIBNZ.js +0 -48
- package/dist/chunk-C5RTIBNZ.js.map +0 -1
- package/dist/chunk-CTRA64LY.js +0 -680
- package/dist/chunk-CTRA64LY.js.map +0 -1
- package/dist/chunk-DPEUKJRO.js +0 -27
- package/dist/chunk-DPEUKJRO.js.map +0 -1
- package/dist/chunk-FD2MBMOH.js +0 -494
- package/dist/chunk-FD2MBMOH.js.map +0 -1
- package/dist/chunk-H5QPIZNX.js +0 -907
- package/dist/chunk-H5QPIZNX.js.map +0 -1
- package/dist/chunk-HLKC4UYB.js +0 -3514
- package/dist/chunk-HLKC4UYB.js.map +0 -1
- package/dist/chunk-HNP72PNU.js +0 -192
- package/dist/chunk-HNP72PNU.js.map +0 -1
- package/dist/chunk-ISPWRSEC.js +0 -408
- package/dist/chunk-ISPWRSEC.js.map +0 -1
- package/dist/chunk-KRBFHMV6.js +0 -1228
- package/dist/chunk-KRBFHMV6.js.map +0 -1
- package/dist/chunk-M22PL4GH.js +0 -171
- package/dist/chunk-M22PL4GH.js.map +0 -1
- package/dist/chunk-NURIQBJQ.js +0 -72
- package/dist/chunk-NURIQBJQ.js.map +0 -1
- package/dist/chunk-OPVWXJ2H.js +0 -75
- package/dist/chunk-OPVWXJ2H.js.map +0 -1
- package/dist/chunk-PZZKQVQV.js +0 -52
- package/dist/chunk-PZZKQVQV.js.map +0 -1
- package/dist/chunk-Q2JSAVQ3.js +0 -671
- package/dist/chunk-Q2JSAVQ3.js.map +0 -1
- package/dist/chunk-RDOAVVHY.js +0 -699
- package/dist/chunk-RDOAVVHY.js.map +0 -1
- package/dist/chunk-SGKPNBXE.js +0 -60
- package/dist/chunk-SGKPNBXE.js.map +0 -1
- package/dist/chunk-SMQXZGLZ.js +0 -351
- package/dist/chunk-SMQXZGLZ.js.map +0 -1
- package/dist/chunk-UHEZW5BU.js +0 -2918
- package/dist/chunk-UHEZW5BU.js.map +0 -1
- package/dist/chunk-UPWGXKXB.js +0 -254
- package/dist/chunk-UPWGXKXB.js.map +0 -1
- package/dist/chunk-VISA6CI3.js +0 -200
- package/dist/chunk-VISA6CI3.js.map +0 -1
- package/dist/chunk-WMTCUOQL.js +0 -474
- package/dist/chunk-WMTCUOQL.js.map +0 -1
- package/dist/chunk-WRTOVNN4.js +0 -4728
- package/dist/chunk-WRTOVNN4.js.map +0 -1
- package/dist/chunk-WSTRQZYQ.js +0 -5576
- package/dist/chunk-WSTRQZYQ.js.map +0 -1
- package/dist/chunk-XBG2W2VW.js +0 -6434
- package/dist/chunk-XBG2W2VW.js.map +0 -1
- package/dist/chunk-YEJR7IXO.js +0 -68
- package/dist/chunk-YEJR7IXO.js.map +0 -1
- package/dist/chunk-YJZA2BIK.js +0 -1338
- package/dist/chunk-YJZA2BIK.js.map +0 -1
- package/dist/completion-gate-BMy5LGoP.d.ts +0 -256
- package/dist/conversation.js.map +0 -1
- package/dist/coordination-BZZSVYpZ.d.ts +0 -2570
- package/dist/environment-provider.js.map +0 -1
- package/dist/improvement-adapter-HAZz-7vK.d.ts +0 -203
- package/dist/knowledge.js.map +0 -1
- package/dist/local-harness-BDNcl6jI.d.ts +0 -175
- package/dist/loop-runner-bin-68aoM9-7.d.ts +0 -199
- package/dist/loop-runner-bin.js.map +0 -1
- package/dist/loops.js.map +0 -1
- package/dist/openai-tools-fnj6SRVg.d.ts +0 -46
- package/dist/protected-model-port-B4t-OQjL.d.ts +0 -271
- package/dist/redact-BuFjxBUJ.d.ts +0 -481
- package/dist/sanitize-DEbPNtyI.d.ts +0 -328
- package/dist/structural-rollout-DEh6CEsa.d.ts +0 -446
- package/dist/supervise-4_48wwvy.d.ts +0 -379
- package/dist/types-BCoemcXU.d.ts +0 -802
- package/dist/types-BwoZWq-i.d.ts +0 -510
- package/dist/types-Cbp72qyu.d.ts +0 -491
- package/dist/types-CmYCMbFT.d.ts +0 -203
- package/dist/types-CvNwMzQt.d.ts +0 -957
- package/dist/worktree-fanout-DxhAWr5Z.d.ts +0 -999
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
import { r as BackendTransportError, u as ValidationError } from "./errors-DEAvWQPy.js";
|
|
2
|
+
import { a as InMemoryRuntimeSessionStore, c as startOrResumeRuntimeSession, o as newRuntimeSession, s as nowIso, t as createIterableBackend } from "./backends-CiOCyRHb.js";
|
|
3
|
+
//#region src/conversation/call-policy.ts
|
|
4
|
+
/** Thrown when the circuit breaker is open for a participant and no retry is allowed yet. */
|
|
5
|
+
var CircuitOpenError = class extends Error {
|
|
6
|
+
constructor(participant, retryAfterMs) {
|
|
7
|
+
super(`circuit open for participant '${participant}'; ${retryAfterMs}ms remaining before retry allowed`);
|
|
8
|
+
this.name = "CircuitOpenError";
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
/** Thrown when a backend call exceeds its per-attempt deadline. */
|
|
12
|
+
var DeadlineExceededError = class extends Error {
|
|
13
|
+
constructor(deadlineMs) {
|
|
14
|
+
super(`backend call exceeded per-attempt deadline of ${deadlineMs}ms`);
|
|
15
|
+
this.name = "DeadlineExceededError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Default retryable classification — network/timeout class errors. Errors
|
|
20
|
+
* a model deliberately throws (validation, refusal, 4xx) are not retried;
|
|
21
|
+
* those represent real outcomes, not transient infrastructure faults.
|
|
22
|
+
*/
|
|
23
|
+
const defaultIsRetryable = (err) => {
|
|
24
|
+
if (err instanceof DeadlineExceededError) return true;
|
|
25
|
+
if (err instanceof Error) {
|
|
26
|
+
const name = err.name;
|
|
27
|
+
const message = err.message.toLowerCase();
|
|
28
|
+
if (name === "AbortError" || name === "TimeoutError") return true;
|
|
29
|
+
if (message.includes("econnreset") || message.includes("etimedout") || message.includes("econnrefused") || message.includes("socket hang up") || message.includes("network") || message.includes("fetch failed")) return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
};
|
|
33
|
+
/** Live circuit-breaker state — one instance per (participant, conversation run). */
|
|
34
|
+
var CircuitBreakerState = class {
|
|
35
|
+
config;
|
|
36
|
+
consecutiveFailures = 0;
|
|
37
|
+
openedAt;
|
|
38
|
+
constructor(config) {
|
|
39
|
+
this.config = config;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Check whether the next call is allowed. Throws `CircuitOpenError` when
|
|
43
|
+
* the breaker is open and the cooldown hasn't elapsed.
|
|
44
|
+
*/
|
|
45
|
+
preflight(participant, now = Date.now()) {
|
|
46
|
+
if (!this.config || this.openedAt === void 0) return;
|
|
47
|
+
const remaining = this.config.cooldownMs - (now - this.openedAt);
|
|
48
|
+
if (remaining > 0) throw new CircuitOpenError(participant, remaining);
|
|
49
|
+
this.openedAt = void 0;
|
|
50
|
+
this.consecutiveFailures = 0;
|
|
51
|
+
}
|
|
52
|
+
recordSuccess() {
|
|
53
|
+
this.consecutiveFailures = 0;
|
|
54
|
+
this.openedAt = void 0;
|
|
55
|
+
}
|
|
56
|
+
recordFailure(now = Date.now()) {
|
|
57
|
+
if (!this.config) return;
|
|
58
|
+
this.consecutiveFailures += 1;
|
|
59
|
+
if (this.consecutiveFailures >= this.config.failuresToOpen) this.openedAt = now;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Build a per-attempt AbortSignal linked to the parent signal AND fired when
|
|
64
|
+
* the deadline elapses. The returned `dispose()` MUST be called in a
|
|
65
|
+
* `finally` (clears the timer, detaches the listener) so we don't leak.
|
|
66
|
+
*
|
|
67
|
+
* When the deadline fires, the signal's `reason` is a `DeadlineExceededError`
|
|
68
|
+
* — callers can detect timeout-vs-cancel by reading `signal.reason` after
|
|
69
|
+
* the underlying operation throws.
|
|
70
|
+
*/
|
|
71
|
+
function makePerAttemptSignal(parentSignal, deadlineMs) {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
let deadlineError;
|
|
74
|
+
const cleanups = [];
|
|
75
|
+
if (parentSignal) if (parentSignal.aborted) controller.abort(parentSignal.reason);
|
|
76
|
+
else {
|
|
77
|
+
const onAbort = () => controller.abort(parentSignal.reason);
|
|
78
|
+
parentSignal.addEventListener("abort", onAbort, { once: true });
|
|
79
|
+
cleanups.push(() => parentSignal.removeEventListener("abort", onAbort));
|
|
80
|
+
}
|
|
81
|
+
if (deadlineMs !== void 0) {
|
|
82
|
+
const ms = deadlineMs;
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
deadlineError = new DeadlineExceededError(ms);
|
|
85
|
+
controller.abort(deadlineError);
|
|
86
|
+
}, ms);
|
|
87
|
+
cleanups.push(() => clearTimeout(timer));
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
dispose() {
|
|
92
|
+
for (const c of cleanups) c();
|
|
93
|
+
},
|
|
94
|
+
getDeadlineError() {
|
|
95
|
+
return deadlineError;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Compute the delay before the next attempt. Default: 250ms exponential with jitter. */
|
|
100
|
+
function computeBackoff(spec, attempt) {
|
|
101
|
+
if (spec === void 0) {
|
|
102
|
+
const base = 250;
|
|
103
|
+
const jitter = Math.floor(Math.random() * base);
|
|
104
|
+
return base * 2 ** (attempt - 1) + jitter;
|
|
105
|
+
}
|
|
106
|
+
if (typeof spec === "function") return Math.max(0, spec(attempt));
|
|
107
|
+
return Math.max(0, spec);
|
|
108
|
+
}
|
|
109
|
+
/** Resolve after `ms` milliseconds — used for retry backoff in conversation call policy. */
|
|
110
|
+
function sleep(ms) {
|
|
111
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
//#region src/conversation/headers.ts
|
|
115
|
+
/**
|
|
116
|
+
*
|
|
117
|
+
* Cross-gateway forwarding headers — the wire-level contract that makes
|
|
118
|
+
* agent-to-agent communication composable across organizational boundaries.
|
|
119
|
+
* Every header here is read on inbound and re-emitted on outbound, so a chain
|
|
120
|
+
* `caller → A's gateway → A's runtime → B's gateway → B's runtime` ends with
|
|
121
|
+
* B billing the original user, the depth counter monotonically incremented,
|
|
122
|
+
* and the run/turn correlation IDs preserved end-to-end.
|
|
123
|
+
*
|
|
124
|
+
* The actual depth refusal (HTTP 413 at MAX_DEPTH) is enforced by
|
|
125
|
+
* `agent-gateway`'s middleware; this module owns the names + the propagation
|
|
126
|
+
* rules so both sides agree.
|
|
127
|
+
*
|
|
128
|
+
* Full protocol: `docs/agent-bus-protocol.md`.
|
|
129
|
+
*
|
|
130
|
+
* @stable
|
|
131
|
+
*/
|
|
132
|
+
/** Standard names — lowercased so Headers maps interop on every runtime. */
|
|
133
|
+
const FORWARD_HEADERS = {
|
|
134
|
+
/** Forwarded original-user identity (`Bearer sk-tan-<user>`); downstream gateways bill against this. */
|
|
135
|
+
authorization: "x-tangle-forwarded-authorization",
|
|
136
|
+
/** Monotonically incremented on every gateway hop. Refused at MAX_DEPTH. */
|
|
137
|
+
depth: "x-tangle-forwarded-depth",
|
|
138
|
+
/** Top-level conversation run identifier, propagated through every nested call. */
|
|
139
|
+
runId: "x-tangle-runid",
|
|
140
|
+
/** This call's turn within the run; deterministic + stable across retries. */
|
|
141
|
+
turnId: "x-tangle-turnid",
|
|
142
|
+
/** When the call is *inside* another turn (recursion), the parent turn's id. */
|
|
143
|
+
parentTurnId: "x-tangle-parent-turnid",
|
|
144
|
+
/** Logical conversation peer label at the sending side, for trace stitching. */
|
|
145
|
+
speaker: "x-tangle-speaker"
|
|
146
|
+
};
|
|
147
|
+
/** Hard cap on chained gateway hops; refused beyond this. Default keeps recursion bounded. */
|
|
148
|
+
const DEFAULT_MAX_DEPTH = 4;
|
|
149
|
+
/**
|
|
150
|
+
* Lowercase a header lookup so we read the same key regardless of source
|
|
151
|
+
* casing (Hono, fetch's `Headers`, raw Node IncomingMessage, …).
|
|
152
|
+
*/
|
|
153
|
+
function lc(name) {
|
|
154
|
+
return name.toLowerCase();
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Read the depth counter off an inbound request. Missing → 0 (caller is the
|
|
158
|
+
* origin). Non-integer → throws — silent coercion would let a bad caller
|
|
159
|
+
* reset depth and bypass the limit.
|
|
160
|
+
*/
|
|
161
|
+
function readDepth(headers) {
|
|
162
|
+
const raw = pickHeader(headers, FORWARD_HEADERS.depth);
|
|
163
|
+
if (raw === void 0 || raw === "") return 0;
|
|
164
|
+
const n = Number(raw);
|
|
165
|
+
if (!Number.isInteger(n) || n < 0) throw new Error(`invalid ${FORWARD_HEADERS.depth} header value '${raw}' — must be a non-negative integer`);
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Refuse further forwarding when the inbound depth has reached the limit.
|
|
170
|
+
* Callers (the gateway middleware) translate the boolean to an HTTP 413.
|
|
171
|
+
*/
|
|
172
|
+
function isDepthExceeded(inboundDepth, max = 4) {
|
|
173
|
+
return inboundDepth >= max;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Build the headers to emit on an outbound participant call, given the
|
|
177
|
+
* conversation's propagation context. Depth is incremented from the inbound
|
|
178
|
+
* value; runId / turnId / speaker stamp the current hop; the user's
|
|
179
|
+
* `Authorization` is preserved verbatim so the downstream gateway bills the
|
|
180
|
+
* right wallet.
|
|
181
|
+
*/
|
|
182
|
+
function buildForwardHeaders(input) {
|
|
183
|
+
const out = {
|
|
184
|
+
[FORWARD_HEADERS.depth]: String(input.inboundDepth + 1),
|
|
185
|
+
[FORWARD_HEADERS.runId]: input.runId,
|
|
186
|
+
[FORWARD_HEADERS.turnId]: input.turnId,
|
|
187
|
+
[FORWARD_HEADERS.speaker]: input.speaker
|
|
188
|
+
};
|
|
189
|
+
if (input.forwardedAuthorization !== void 0) out[FORWARD_HEADERS.authorization] = input.forwardedAuthorization;
|
|
190
|
+
if (input.parentTurnId !== void 0) out[FORWARD_HEADERS.parentTurnId] = input.parentTurnId;
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
function pickHeader(headers, name) {
|
|
194
|
+
const target = lc(name);
|
|
195
|
+
for (const key of Object.keys(headers)) if (lc(key) === target) {
|
|
196
|
+
const value = headers[key];
|
|
197
|
+
if (Array.isArray(value)) return value[0];
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/conversation/turn-id.ts
|
|
203
|
+
/**
|
|
204
|
+
*
|
|
205
|
+
* Deterministic turn identifier. Stable across retries of the same logical
|
|
206
|
+
* turn so backends (and any caching gateway in between) can dedupe on it.
|
|
207
|
+
* A retry triggered by a network blip or deadline timeout MUST produce the
|
|
208
|
+
* same `turn_id`; only the underlying attempt count differs.
|
|
209
|
+
*
|
|
210
|
+
* Shape: `${runId}.t${index}.${speakerSlug}` — readable in logs, sortable by
|
|
211
|
+
* turn index, attributable to a speaker. Slugify keeps the speaker portion
|
|
212
|
+
* URL-safe so it can ride in HTTP headers without escaping.
|
|
213
|
+
*
|
|
214
|
+
* @stable
|
|
215
|
+
*/
|
|
216
|
+
function turnId(runId, index, speaker) {
|
|
217
|
+
return `${runId}.t${index}.${slugifySpeaker(speaker)}`;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Reduce a speaker name to ASCII alphanumerics + dashes. Preserves enough
|
|
221
|
+
* substance to read in a log line; collisions between speakers within a
|
|
222
|
+
* single Conversation are prevented by `defineConversation`'s
|
|
223
|
+
* unique-name check, so the slug only needs to be deterministic, not unique.
|
|
224
|
+
*/
|
|
225
|
+
function slugifySpeaker(speaker) {
|
|
226
|
+
return speaker.normalize("NFKD").replace(/[^\w-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "anon";
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/conversation/run-conversation.ts
|
|
230
|
+
/** Run a conversation to completion and return its terminal result. */
|
|
231
|
+
async function runConversation(conversation, options) {
|
|
232
|
+
let result;
|
|
233
|
+
for await (const event of runConversationStream(conversation, options)) {
|
|
234
|
+
if (options.onEvent) await options.onEvent(event);
|
|
235
|
+
if (event.type === "conversation_end") result = event.result;
|
|
236
|
+
}
|
|
237
|
+
if (!result) throw new BackendTransportError("conversation", "conversation stream ended without a conversation_end event");
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
/** Streaming conversation orchestrator: drives N participants in turn through their own backends, enforcing `maxTurns` / `maxCreditsCents` / `haltOn`, yielding per-event stream markers. */
|
|
241
|
+
async function* runConversationStream(conversation, options) {
|
|
242
|
+
const runId = options.runId ?? `conv_${crypto.randomUUID()}`;
|
|
243
|
+
const inboundDepth = options.inboundDepth ?? 0;
|
|
244
|
+
const forwardedAuthorization = (options.propagatedHeaders ?? {})[FORWARD_HEADERS.authorization];
|
|
245
|
+
const breakers = /* @__PURE__ */ new Map();
|
|
246
|
+
for (const participant of conversation.participants) {
|
|
247
|
+
const cfg = participant.callPolicy?.circuitBreaker ?? conversation.policy.defaultCallPolicy?.circuitBreaker;
|
|
248
|
+
breakers.set(participant.name, new CircuitBreakerState(cfg));
|
|
249
|
+
}
|
|
250
|
+
let transcript = [];
|
|
251
|
+
let spentCreditsCents = 0;
|
|
252
|
+
let startedAt = nowIso();
|
|
253
|
+
let resumed = false;
|
|
254
|
+
const sessionStore = options.sessionStore ?? new InMemoryRuntimeSessionStore();
|
|
255
|
+
if (options.journal) {
|
|
256
|
+
const prior = await options.journal.loadRun(runId);
|
|
257
|
+
if (prior) {
|
|
258
|
+
if (prior.halted) {
|
|
259
|
+
const replayResult = {
|
|
260
|
+
runId,
|
|
261
|
+
transcript: prior.turns,
|
|
262
|
+
turns: prior.turns.length,
|
|
263
|
+
spentCreditsCents: prior.turns.reduce((sum, t) => sum + centsFromUsd(t.usage?.costUsd ?? 0), 0),
|
|
264
|
+
halted: prior.halted,
|
|
265
|
+
durationMs: 0,
|
|
266
|
+
startedAt: prior.startedAt,
|
|
267
|
+
endedAt: prior.endedAt ?? prior.startedAt
|
|
268
|
+
};
|
|
269
|
+
yield {
|
|
270
|
+
type: "conversation_resumed",
|
|
271
|
+
runId,
|
|
272
|
+
participants: conversation.participants.map((p) => p.name),
|
|
273
|
+
transcript: prior.turns,
|
|
274
|
+
timestamp: nowIso()
|
|
275
|
+
};
|
|
276
|
+
yield {
|
|
277
|
+
type: "conversation_end",
|
|
278
|
+
runId,
|
|
279
|
+
result: replayResult,
|
|
280
|
+
timestamp: nowIso()
|
|
281
|
+
};
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
transcript = [...prior.turns];
|
|
285
|
+
spentCreditsCents = transcript.reduce((sum, t) => sum + centsFromUsd(t.usage?.costUsd ?? 0), 0);
|
|
286
|
+
startedAt = prior.startedAt;
|
|
287
|
+
resumed = true;
|
|
288
|
+
} else await options.journal.beginRun(runId, startedAt);
|
|
289
|
+
}
|
|
290
|
+
const startedAtMs = Date.now();
|
|
291
|
+
const participantSessions = sessionIdsFrom(transcript);
|
|
292
|
+
if (resumed) yield {
|
|
293
|
+
type: "conversation_resumed",
|
|
294
|
+
runId,
|
|
295
|
+
participants: conversation.participants.map((p) => p.name),
|
|
296
|
+
transcript: [...transcript],
|
|
297
|
+
timestamp: nowIso()
|
|
298
|
+
};
|
|
299
|
+
else yield {
|
|
300
|
+
type: "conversation_start",
|
|
301
|
+
runId,
|
|
302
|
+
participants: conversation.participants.map((p) => p.name),
|
|
303
|
+
seed: options.seed,
|
|
304
|
+
timestamp: startedAt
|
|
305
|
+
};
|
|
306
|
+
let currentInput = transcript.length === 0 ? options.seed : transcript[transcript.length - 1]?.text ?? options.seed;
|
|
307
|
+
let halt;
|
|
308
|
+
const initialOffset = transcript.length;
|
|
309
|
+
for (let turnIndex = initialOffset; turnIndex < conversation.policy.maxTurns; turnIndex++) {
|
|
310
|
+
if (options.signal?.aborted) {
|
|
311
|
+
halt = { kind: "abort" };
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
if (conversation.policy.maxCreditsCents !== void 0 && spentCreditsCents >= conversation.policy.maxCreditsCents) {
|
|
315
|
+
halt = {
|
|
316
|
+
kind: "max_credits",
|
|
317
|
+
spentCents: spentCreditsCents,
|
|
318
|
+
capCents: conversation.policy.maxCreditsCents
|
|
319
|
+
};
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
const speakerIdx = selectSpeaker(conversation.policy.turnOrder, conversation.participants.length, {
|
|
323
|
+
transcript,
|
|
324
|
+
turnIndex,
|
|
325
|
+
spentCreditsCents
|
|
326
|
+
});
|
|
327
|
+
const speaker = conversation.participants[speakerIdx];
|
|
328
|
+
if (!speaker) throw new BackendTransportError("conversation", `turnOrder selector returned out-of-range index ${speakerIdx} for ${conversation.participants.length} participants`);
|
|
329
|
+
const tid = turnId(runId, turnIndex, speaker.name);
|
|
330
|
+
const callPolicy = speaker.callPolicy ?? conversation.policy.defaultCallPolicy;
|
|
331
|
+
const breaker = breakers.get(speaker.name);
|
|
332
|
+
if (!breaker) throw new BackendTransportError("conversation", `internal: no circuit-breaker state registered for participant '${speaker.name}'`);
|
|
333
|
+
const isRetryable = callPolicy?.isRetryable ?? defaultIsRetryable;
|
|
334
|
+
const totalAttempts = 1 + (callPolicy?.maxRetries ?? 0);
|
|
335
|
+
yield {
|
|
336
|
+
type: "turn_start",
|
|
337
|
+
runId,
|
|
338
|
+
index: turnIndex,
|
|
339
|
+
speaker: speaker.name,
|
|
340
|
+
turnId: tid,
|
|
341
|
+
attempt: 1,
|
|
342
|
+
timestamp: nowIso()
|
|
343
|
+
};
|
|
344
|
+
let aggregator;
|
|
345
|
+
let attemptCount = 0;
|
|
346
|
+
let lastError;
|
|
347
|
+
let breakerOpenFailure;
|
|
348
|
+
for (let attempt = 1; attempt <= totalAttempts; attempt++) {
|
|
349
|
+
attemptCount = attempt;
|
|
350
|
+
try {
|
|
351
|
+
breaker.preflight(speaker.name);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
breakerOpenFailure = err;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
if (attempt > 1) yield {
|
|
357
|
+
type: "turn_retry",
|
|
358
|
+
runId,
|
|
359
|
+
index: turnIndex,
|
|
360
|
+
speaker: speaker.name,
|
|
361
|
+
turnId: tid,
|
|
362
|
+
attempt,
|
|
363
|
+
reason: lastError instanceof Error ? lastError.message : String(lastError),
|
|
364
|
+
timestamp: nowIso()
|
|
365
|
+
};
|
|
366
|
+
const perAttempt = makePerAttemptSignal(options.signal, callPolicy?.perAttemptDeadlineMs);
|
|
367
|
+
const localAgg = new TurnAggregator({
|
|
368
|
+
index: turnIndex,
|
|
369
|
+
speaker: speaker.name,
|
|
370
|
+
startedAt: nowIso()
|
|
371
|
+
});
|
|
372
|
+
try {
|
|
373
|
+
for await (const delta of driveSingleAttempt({
|
|
374
|
+
speaker,
|
|
375
|
+
participants: conversation.participants,
|
|
376
|
+
seed: options.seed,
|
|
377
|
+
input: currentInput,
|
|
378
|
+
turnIndex,
|
|
379
|
+
runId,
|
|
380
|
+
turnId: tid,
|
|
381
|
+
transcript,
|
|
382
|
+
participantSessions,
|
|
383
|
+
sessionStore,
|
|
384
|
+
signal: perAttempt.signal,
|
|
385
|
+
aggregator: localAgg,
|
|
386
|
+
propagatedHeaders: buildForwardHeaders({
|
|
387
|
+
inboundDepth,
|
|
388
|
+
forwardedAuthorization: resolveAuthForwarding(speaker, {
|
|
389
|
+
transcript,
|
|
390
|
+
turnIndex,
|
|
391
|
+
spentCreditsCents
|
|
392
|
+
}) ? forwardedAuthorization : void 0,
|
|
393
|
+
runId,
|
|
394
|
+
turnId: tid,
|
|
395
|
+
parentTurnId: options.parentTurnId,
|
|
396
|
+
speaker: speaker.name
|
|
397
|
+
})
|
|
398
|
+
})) yield {
|
|
399
|
+
type: "turn_text_delta",
|
|
400
|
+
runId,
|
|
401
|
+
index: turnIndex,
|
|
402
|
+
speaker: speaker.name,
|
|
403
|
+
turnId: tid,
|
|
404
|
+
text: delta.text,
|
|
405
|
+
timestamp: delta.timestamp
|
|
406
|
+
};
|
|
407
|
+
perAttempt.dispose();
|
|
408
|
+
breaker.recordSuccess();
|
|
409
|
+
aggregator = localAgg;
|
|
410
|
+
break;
|
|
411
|
+
} catch (err) {
|
|
412
|
+
perAttempt.dispose();
|
|
413
|
+
breaker.recordFailure();
|
|
414
|
+
lastError = perAttempt.getDeadlineError() ?? err;
|
|
415
|
+
if (attempt >= totalAttempts || !isRetryable(lastError)) break;
|
|
416
|
+
await sleep(computeBackoff(callPolicy?.retryBackoffMs, attempt));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (!aggregator) {
|
|
420
|
+
const failure = breakerOpenFailure ?? lastError;
|
|
421
|
+
const message = failure instanceof Error ? failure.message : String(failure);
|
|
422
|
+
halt = {
|
|
423
|
+
kind: "participant_error",
|
|
424
|
+
participant: speaker.name,
|
|
425
|
+
message
|
|
426
|
+
};
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
const turn = aggregator.toTurn({
|
|
430
|
+
turnId: tid,
|
|
431
|
+
attempts: attemptCount
|
|
432
|
+
});
|
|
433
|
+
if (turn.sessionId) bindParticipantSession(participantSessions, turn.speaker, turn.sessionId);
|
|
434
|
+
transcript.push(turn);
|
|
435
|
+
spentCreditsCents += centsFromUsd(turn.usage?.costUsd ?? 0);
|
|
436
|
+
if (options.journal) await options.journal.appendTurn(runId, turn);
|
|
437
|
+
yield {
|
|
438
|
+
type: "turn_end",
|
|
439
|
+
runId,
|
|
440
|
+
turn,
|
|
441
|
+
timestamp: nowIso()
|
|
442
|
+
};
|
|
443
|
+
if (conversation.policy.haltOn) {
|
|
444
|
+
const haltCtx = {
|
|
445
|
+
transcript,
|
|
446
|
+
lastTurn: turn,
|
|
447
|
+
turnIndex,
|
|
448
|
+
spentCreditsCents
|
|
449
|
+
};
|
|
450
|
+
const decision = await conversation.policy.haltOn(haltCtx);
|
|
451
|
+
if (decision === true) {
|
|
452
|
+
halt = {
|
|
453
|
+
kind: "predicate",
|
|
454
|
+
reason: "predicate_true"
|
|
455
|
+
};
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
if (typeof decision === "object" && decision !== null && decision.halted) {
|
|
459
|
+
halt = {
|
|
460
|
+
kind: "predicate",
|
|
461
|
+
reason: decision.reason
|
|
462
|
+
};
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
currentInput = turn.text;
|
|
467
|
+
}
|
|
468
|
+
if (!halt) halt = {
|
|
469
|
+
kind: "max_turns",
|
|
470
|
+
turns: transcript.length
|
|
471
|
+
};
|
|
472
|
+
const endedAt = nowIso();
|
|
473
|
+
const result = {
|
|
474
|
+
runId,
|
|
475
|
+
transcript,
|
|
476
|
+
turns: transcript.length,
|
|
477
|
+
spentCreditsCents,
|
|
478
|
+
halted: halt,
|
|
479
|
+
durationMs: Date.now() - startedAtMs,
|
|
480
|
+
startedAt,
|
|
481
|
+
endedAt
|
|
482
|
+
};
|
|
483
|
+
if (options.journal) await options.journal.recordHalt(runId, halt, endedAt);
|
|
484
|
+
yield {
|
|
485
|
+
type: "conversation_end",
|
|
486
|
+
runId,
|
|
487
|
+
result,
|
|
488
|
+
timestamp: endedAt
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
async function* driveSingleAttempt(args) {
|
|
492
|
+
const task = {
|
|
493
|
+
id: args.turnId,
|
|
494
|
+
intent: args.input,
|
|
495
|
+
metadata: {
|
|
496
|
+
runId: args.runId,
|
|
497
|
+
turnId: args.turnId,
|
|
498
|
+
turnIndex: args.turnIndex,
|
|
499
|
+
speaker: args.speaker.name,
|
|
500
|
+
participants: args.participants.map((p) => p.name)
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
const knowledge = passingReadiness(task.id);
|
|
504
|
+
const startCtx = {
|
|
505
|
+
task,
|
|
506
|
+
knowledge,
|
|
507
|
+
signal: args.signal,
|
|
508
|
+
runId: args.runId,
|
|
509
|
+
turnId: args.turnId,
|
|
510
|
+
propagatedHeaders: args.propagatedHeaders
|
|
511
|
+
};
|
|
512
|
+
const previousSessionId = args.participantSessions.get(args.speaker.name);
|
|
513
|
+
const opened = await startOrResumeRuntimeSession({
|
|
514
|
+
backend: args.speaker.backend,
|
|
515
|
+
input: () => fullConversationInput(task, args.speaker.name, args.seed, args.transcript),
|
|
516
|
+
continuationInput: () => continuedConversationInput(task, args.speaker.name, args.transcript),
|
|
517
|
+
context: startCtx,
|
|
518
|
+
store: args.sessionStore,
|
|
519
|
+
sessionId: previousSessionId ?? participantSessionId(args.runId, args.speaker.name, args.participants),
|
|
520
|
+
resume: previousSessionId !== void 0 && args.speaker.backend.resume !== void 0,
|
|
521
|
+
validateSession: (session) => assertParticipantSessionAvailable(args.participantSessions, args.speaker.name, session.id)
|
|
522
|
+
});
|
|
523
|
+
const session = opened.session;
|
|
524
|
+
args.aggregator.recordSession(session.id);
|
|
525
|
+
const streamCtx = {
|
|
526
|
+
task,
|
|
527
|
+
knowledge,
|
|
528
|
+
session,
|
|
529
|
+
signal: args.signal,
|
|
530
|
+
runId: args.runId,
|
|
531
|
+
turnId: args.turnId,
|
|
532
|
+
propagatedHeaders: args.propagatedHeaders
|
|
533
|
+
};
|
|
534
|
+
for await (const event of args.speaker.backend.stream(opened.input, streamCtx)) {
|
|
535
|
+
if (args.signal.aborted) {
|
|
536
|
+
const reason = args.signal.reason;
|
|
537
|
+
throw reason instanceof Error ? reason : /* @__PURE__ */ new Error("aborted");
|
|
538
|
+
}
|
|
539
|
+
if (event.type === "text_delta") {
|
|
540
|
+
args.aggregator.appendText(event.text);
|
|
541
|
+
yield {
|
|
542
|
+
text: event.text,
|
|
543
|
+
timestamp: event.timestamp
|
|
544
|
+
};
|
|
545
|
+
} else if (event.type === "llm_call") args.aggregator.recordUsage(event);
|
|
546
|
+
else if (event.type === "backend_error") throw new BackendTransportError(args.speaker.backend.kind, event.message, {
|
|
547
|
+
status: event.error?.status,
|
|
548
|
+
body: event.error?.body
|
|
549
|
+
});
|
|
550
|
+
else if (event.type === "final") {
|
|
551
|
+
if (event.status !== "completed") throw new BackendTransportError(args.speaker.backend.kind, event.error?.message ?? event.reason, {
|
|
552
|
+
status: event.error?.status,
|
|
553
|
+
body: event.error?.body
|
|
554
|
+
});
|
|
555
|
+
args.aggregator.adoptFinalText(event.text);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (!args.aggregator.hasText()) throw new BackendTransportError(args.speaker.backend.kind, `backend '${args.speaker.backend.kind}' completed without text`);
|
|
559
|
+
}
|
|
560
|
+
var TurnAggregator = class {
|
|
561
|
+
base;
|
|
562
|
+
text = "";
|
|
563
|
+
adoptedFinal = false;
|
|
564
|
+
sessionId;
|
|
565
|
+
usage;
|
|
566
|
+
constructor(base) {
|
|
567
|
+
this.base = base;
|
|
568
|
+
}
|
|
569
|
+
appendText(text) {
|
|
570
|
+
if (this.adoptedFinal) return;
|
|
571
|
+
this.text += text;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Use the backend's `final.text` only when no streamed deltas were observed.
|
|
575
|
+
* Some backends emit deltas AND a final summary; treating both as content
|
|
576
|
+
* would double-count.
|
|
577
|
+
*/
|
|
578
|
+
adoptFinalText(text) {
|
|
579
|
+
if (!text) return;
|
|
580
|
+
if (this.text.length > 0) return;
|
|
581
|
+
this.text = text;
|
|
582
|
+
this.adoptedFinal = true;
|
|
583
|
+
}
|
|
584
|
+
hasText() {
|
|
585
|
+
return this.text.trim().length > 0;
|
|
586
|
+
}
|
|
587
|
+
recordSession(sessionId) {
|
|
588
|
+
this.sessionId = sessionId;
|
|
589
|
+
}
|
|
590
|
+
recordUsage(event) {
|
|
591
|
+
const u = this.usage ?? {};
|
|
592
|
+
if (event.tokensIn !== void 0) u.tokensIn = (u.tokensIn ?? 0) + event.tokensIn;
|
|
593
|
+
if (event.tokensOut !== void 0) u.tokensOut = (u.tokensOut ?? 0) + event.tokensOut;
|
|
594
|
+
if (event.costUsd !== void 0) u.costUsd = (u.costUsd ?? 0) + event.costUsd;
|
|
595
|
+
if (event.latencyMs !== void 0) u.latencyMs = event.latencyMs;
|
|
596
|
+
if (event.model !== void 0) u.model = event.model;
|
|
597
|
+
this.usage = u;
|
|
598
|
+
}
|
|
599
|
+
toTurn(meta) {
|
|
600
|
+
return {
|
|
601
|
+
index: this.base.index,
|
|
602
|
+
speaker: this.base.speaker,
|
|
603
|
+
turnId: meta.turnId,
|
|
604
|
+
...this.sessionId ? { sessionId: this.sessionId } : {},
|
|
605
|
+
text: this.text.trim(),
|
|
606
|
+
usage: this.usage,
|
|
607
|
+
attempts: meta.attempts,
|
|
608
|
+
startedAt: this.base.startedAt,
|
|
609
|
+
endedAt: nowIso()
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
/**
|
|
614
|
+
* Build the participant's POV of the transcript so an OpenAI-compatible
|
|
615
|
+
* backend sees its own turns as `assistant` and everyone else's as `user`,
|
|
616
|
+
* with explicit speaker tags so 3+ party conversations stay disambiguated.
|
|
617
|
+
*/
|
|
618
|
+
function buildMessagesFor(speakerName, seed, transcript) {
|
|
619
|
+
const messages = seed ? [{
|
|
620
|
+
role: "user",
|
|
621
|
+
content: seed
|
|
622
|
+
}] : [];
|
|
623
|
+
for (const turn of transcript) if (turn.speaker === speakerName) messages.push({
|
|
624
|
+
role: "assistant",
|
|
625
|
+
content: turn.text
|
|
626
|
+
});
|
|
627
|
+
else messages.push({
|
|
628
|
+
role: "user",
|
|
629
|
+
content: `[${turn.speaker}] ${turn.text}`
|
|
630
|
+
});
|
|
631
|
+
return messages;
|
|
632
|
+
}
|
|
633
|
+
function fullConversationInput(task, speakerName, seed, transcript) {
|
|
634
|
+
return {
|
|
635
|
+
task,
|
|
636
|
+
message: [seed, ...transcript.map((turn) => `[${turn.speaker}] ${turn.text}`)].filter((part) => part.length > 0).join("\n\n"),
|
|
637
|
+
messages: buildMessagesFor(speakerName, seed, transcript)
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
function continuedConversationInput(task, speakerName, transcript) {
|
|
641
|
+
let lastOwnTurn = -1;
|
|
642
|
+
for (let index = transcript.length - 1; index >= 0; index -= 1) if (transcript[index]?.speaker === speakerName) {
|
|
643
|
+
lastOwnTurn = index;
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
const unseen = transcript.slice(lastOwnTurn + 1);
|
|
647
|
+
const message = unseen.length > 0 ? unseen.map((turn) => `[${turn.speaker}] ${turn.text}`).join("\n\n") : "Continue.";
|
|
648
|
+
return {
|
|
649
|
+
task,
|
|
650
|
+
message,
|
|
651
|
+
messages: [{
|
|
652
|
+
role: "user",
|
|
653
|
+
content: message
|
|
654
|
+
}]
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
function sessionIdsFrom(transcript) {
|
|
658
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
659
|
+
for (const turn of transcript) if (turn.sessionId) bindParticipantSession(sessions, turn.speaker, turn.sessionId);
|
|
660
|
+
return sessions;
|
|
661
|
+
}
|
|
662
|
+
function bindParticipantSession(sessions, speakerName, sessionId) {
|
|
663
|
+
assertParticipantSessionAvailable(sessions, speakerName, sessionId);
|
|
664
|
+
sessions.set(speakerName, sessionId);
|
|
665
|
+
}
|
|
666
|
+
function assertParticipantSessionAvailable(sessions, speakerName, sessionId) {
|
|
667
|
+
for (const [boundSpeaker, boundSessionId] of sessions) if (boundSpeaker !== speakerName && boundSessionId === sessionId) throw new BackendTransportError("conversation", `session '${sessionId}' is already bound to participant '${boundSpeaker}'`);
|
|
668
|
+
}
|
|
669
|
+
function participantSessionId(runId, speakerName, participants) {
|
|
670
|
+
const participantIndex = participants.findIndex((participant) => participant.name === speakerName);
|
|
671
|
+
if (participantIndex < 0) throw new BackendTransportError("conversation", `participant '${speakerName}' is not registered in this conversation`);
|
|
672
|
+
return `${runId}.participant.${participantIndex}.${slugifySpeaker(speakerName)}`;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* True when this participant should forward the caller's
|
|
676
|
+
* `X-Tangle-Forwarded-Authorization` on outbound calls (the "pass-through"
|
|
677
|
+
* commercial mode). False when it elects to pay for its own outbound calls
|
|
678
|
+
* (the "reseller" / "bundle" mode). See ConversationParticipant.authSource.
|
|
679
|
+
*/
|
|
680
|
+
function resolveAuthForwarding(participant, state) {
|
|
681
|
+
return (typeof participant.authSource === "function" ? participant.authSource(state) : participant.authSource ?? "forward-user") === "forward-user";
|
|
682
|
+
}
|
|
683
|
+
function selectSpeaker(order, participantCount, state) {
|
|
684
|
+
const resolved = order ?? (participantCount === 2 ? "alternate" : "round-robin");
|
|
685
|
+
if (resolved === "alternate" || resolved === "round-robin") return state.turnIndex % participantCount;
|
|
686
|
+
if (typeof resolved === "function") {
|
|
687
|
+
const idx = resolved(state);
|
|
688
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= participantCount) throw new BackendTransportError("conversation", `turnOrder function returned invalid index ${String(idx)} for ${participantCount} participants`);
|
|
689
|
+
return idx;
|
|
690
|
+
}
|
|
691
|
+
throw new BackendTransportError("conversation", `unknown turnOrder: ${String(resolved)}`);
|
|
692
|
+
}
|
|
693
|
+
function centsFromUsd(usd) {
|
|
694
|
+
return Math.round(usd * 100);
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Synthesize a knowledge-readiness report that *passes* every gate, used to
|
|
698
|
+
* satisfy `AgentBackendContext.knowledge` per turn. Conversations don't apply
|
|
699
|
+
* task-level readiness gating per-turn — that's a `runAgentTask` concern.
|
|
700
|
+
*/
|
|
701
|
+
function passingReadiness(taskId) {
|
|
702
|
+
return {
|
|
703
|
+
taskId,
|
|
704
|
+
readinessScore: 1,
|
|
705
|
+
blockingMissingRequirements: [],
|
|
706
|
+
nonBlockingGaps: [],
|
|
707
|
+
recommendedAction: "run_agent",
|
|
708
|
+
bundle: {
|
|
709
|
+
taskId,
|
|
710
|
+
requirements: [],
|
|
711
|
+
evidenceIds: [],
|
|
712
|
+
claimIds: [],
|
|
713
|
+
wikiPageIds: [],
|
|
714
|
+
userAnswers: {},
|
|
715
|
+
missing: [],
|
|
716
|
+
readinessScore: 1
|
|
717
|
+
},
|
|
718
|
+
severity: "info",
|
|
719
|
+
reason: "conversation-mode: readiness gating not applied per-turn"
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
//#endregion
|
|
723
|
+
//#region src/conversation/conversation-backend.ts
|
|
724
|
+
/**
|
|
725
|
+
*
|
|
726
|
+
* Wrap a `Conversation` so it satisfies `AgentExecutionBackend`. The result is
|
|
727
|
+
* an addressable "single agent" whose internal behavior is an N-party
|
|
728
|
+
* orchestrated conversation — the recursion primitive that lets a swarm be a
|
|
729
|
+
* participant inside another swarm, or be published behind a single
|
|
730
|
+
* agent-gateway endpoint.
|
|
731
|
+
*
|
|
732
|
+
* Stream events from inner participants are NOT forwarded verbatim. Outer
|
|
733
|
+
* callers see one `text_delta` per inner turn (the turn's full text), tagged
|
|
734
|
+
* with `[speaker] ` prefix so the outer transcript stays attributable. The
|
|
735
|
+
* conversation's `conversation_end` halt reason rides on a `final` event.
|
|
736
|
+
*
|
|
737
|
+
* @stable
|
|
738
|
+
*/
|
|
739
|
+
/** Adapt a multi-participant conversation into the standard execution backend contract. */
|
|
740
|
+
function createConversationBackend(options) {
|
|
741
|
+
const kind = options.kind ?? "conversation";
|
|
742
|
+
return {
|
|
743
|
+
kind,
|
|
744
|
+
start(_input, context) {
|
|
745
|
+
return newRuntimeSession(kind, context.requestedSessionId, { participants: options.conversation.participants.map((p) => p.name) });
|
|
746
|
+
},
|
|
747
|
+
async *stream(input, context) {
|
|
748
|
+
const seed = input.message ?? input.messages?.at(-1)?.content ?? context.task.intent;
|
|
749
|
+
const task = context.task;
|
|
750
|
+
const session = context.session;
|
|
751
|
+
yield {
|
|
752
|
+
type: "backend_start",
|
|
753
|
+
task,
|
|
754
|
+
session,
|
|
755
|
+
backend: kind,
|
|
756
|
+
timestamp: nowIso()
|
|
757
|
+
};
|
|
758
|
+
let finalText = "";
|
|
759
|
+
let totalCostUsd = 0;
|
|
760
|
+
let totalTokensIn = 0;
|
|
761
|
+
let totalTokensOut = 0;
|
|
762
|
+
const inboundDepth = parseInboundDepth(context.propagatedHeaders);
|
|
763
|
+
for await (const event of runConversationStream(options.conversation, {
|
|
764
|
+
seed,
|
|
765
|
+
signal: context.signal,
|
|
766
|
+
runId: context.runId,
|
|
767
|
+
propagatedHeaders: context.propagatedHeaders,
|
|
768
|
+
inboundDepth,
|
|
769
|
+
parentTurnId: context.turnId
|
|
770
|
+
})) if (event.type === "turn_end") {
|
|
771
|
+
const tagged = `[${event.turn.speaker}] ${event.turn.text}\n`;
|
|
772
|
+
finalText += tagged;
|
|
773
|
+
yield {
|
|
774
|
+
type: "text_delta",
|
|
775
|
+
task,
|
|
776
|
+
session,
|
|
777
|
+
text: tagged,
|
|
778
|
+
timestamp: event.timestamp
|
|
779
|
+
};
|
|
780
|
+
if (event.turn.usage) {
|
|
781
|
+
const u = event.turn.usage;
|
|
782
|
+
if (u.costUsd !== void 0) totalCostUsd += u.costUsd;
|
|
783
|
+
if (u.tokensIn !== void 0) totalTokensIn += u.tokensIn;
|
|
784
|
+
if (u.tokensOut !== void 0) totalTokensOut += u.tokensOut;
|
|
785
|
+
yield {
|
|
786
|
+
type: "llm_call",
|
|
787
|
+
task,
|
|
788
|
+
session,
|
|
789
|
+
model: u.model ?? `${kind}/${event.turn.speaker}`,
|
|
790
|
+
tokensIn: u.tokensIn,
|
|
791
|
+
tokensOut: u.tokensOut,
|
|
792
|
+
costUsd: u.costUsd,
|
|
793
|
+
latencyMs: u.latencyMs,
|
|
794
|
+
timestamp: event.timestamp
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
} else if (event.type === "conversation_end") {
|
|
798
|
+
const halt = event.result.halted;
|
|
799
|
+
yield {
|
|
800
|
+
type: "final",
|
|
801
|
+
task,
|
|
802
|
+
session,
|
|
803
|
+
status: halt.kind === "participant_error" ? "failed" : "completed",
|
|
804
|
+
reason: describeHalt(halt),
|
|
805
|
+
text: finalText.trim(),
|
|
806
|
+
metadata: {
|
|
807
|
+
conversationRunId: event.result.runId,
|
|
808
|
+
turns: event.result.turns,
|
|
809
|
+
spentCreditsCents: event.result.spentCreditsCents,
|
|
810
|
+
halted: halt,
|
|
811
|
+
durationMs: event.result.durationMs,
|
|
812
|
+
tokensIn: totalTokensIn,
|
|
813
|
+
tokensOut: totalTokensOut,
|
|
814
|
+
costUsd: totalCostUsd
|
|
815
|
+
},
|
|
816
|
+
timestamp: event.timestamp
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
yield {
|
|
820
|
+
type: "backend_end",
|
|
821
|
+
task,
|
|
822
|
+
session,
|
|
823
|
+
backend: kind,
|
|
824
|
+
timestamp: nowIso()
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
function parseInboundDepth(headers) {
|
|
830
|
+
if (!headers) return 0;
|
|
831
|
+
const raw = headers[FORWARD_HEADERS.depth];
|
|
832
|
+
if (raw === void 0) return 0;
|
|
833
|
+
try {
|
|
834
|
+
return readDepth({ [FORWARD_HEADERS.depth]: raw });
|
|
835
|
+
} catch {
|
|
836
|
+
return 0;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function describeHalt(halt) {
|
|
840
|
+
switch (halt.kind) {
|
|
841
|
+
case "max_turns": return `max_turns (${halt.turns})`;
|
|
842
|
+
case "max_credits": return `max_credits (${halt.spentCents}/${halt.capCents}¢)`;
|
|
843
|
+
case "predicate": return `predicate: ${halt.reason}`;
|
|
844
|
+
case "abort": return "abort";
|
|
845
|
+
case "participant_error": return `participant_error[${halt.participant}]: ${halt.message}`;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
//#endregion
|
|
849
|
+
//#region src/conversation/define-conversation.ts
|
|
850
|
+
/**
|
|
851
|
+
*
|
|
852
|
+
* Declarative constructor for a multi-agent `Conversation`. Validates inputs
|
|
853
|
+
* fail-loud at definition time (duplicate participant names, alternate order
|
|
854
|
+
* with ≠2 participants, non-positive `maxTurns`) so misconfiguration is caught
|
|
855
|
+
* before `runConversation` is called and not buried inside a streaming run.
|
|
856
|
+
*
|
|
857
|
+
* @stable
|
|
858
|
+
*/
|
|
859
|
+
/** Validate and define a conversation before execution. */
|
|
860
|
+
function defineConversation(input) {
|
|
861
|
+
if (input.participants.length < 2) throw new ValidationError(`Conversation requires at least 2 participants; received ${input.participants.length}.`);
|
|
862
|
+
const seen = /* @__PURE__ */ new Set();
|
|
863
|
+
for (const p of input.participants) {
|
|
864
|
+
if (!p.name || p.name.trim() === "") throw new ValidationError("Conversation participant.name must be a non-empty string.");
|
|
865
|
+
if (seen.has(p.name)) throw new ValidationError(`Conversation participant names must be unique within a Conversation; '${p.name}' appears more than once.`);
|
|
866
|
+
seen.add(p.name);
|
|
867
|
+
if (!p.backend || typeof p.backend.stream !== "function") throw new ValidationError(`Conversation participant '${p.name}' is missing a backend with a stream() method.`);
|
|
868
|
+
}
|
|
869
|
+
const policy = normalizePolicy(input.policy, input.participants.length);
|
|
870
|
+
return {
|
|
871
|
+
participants: input.participants,
|
|
872
|
+
policy
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
function normalizePolicy(policy, participantCount) {
|
|
876
|
+
if (!Number.isInteger(policy.maxTurns) || policy.maxTurns < 1) throw new ValidationError(`ConversationPolicy.maxTurns must be a positive integer; received ${String(policy.maxTurns)}.`);
|
|
877
|
+
if (policy.maxCreditsCents !== void 0 && (!Number.isFinite(policy.maxCreditsCents) || policy.maxCreditsCents < 0)) throw new ValidationError(`ConversationPolicy.maxCreditsCents must be a non-negative finite number when set; received ${String(policy.maxCreditsCents)}.`);
|
|
878
|
+
const turnOrder = policy.turnOrder ?? (participantCount === 2 ? "alternate" : "round-robin");
|
|
879
|
+
if (turnOrder === "alternate" && participantCount !== 2) throw new ValidationError(`ConversationPolicy.turnOrder 'alternate' requires exactly 2 participants; received ${participantCount}. Use 'round-robin' or a custom selector for N-party conversations.`);
|
|
880
|
+
return {
|
|
881
|
+
...policy,
|
|
882
|
+
turnOrder
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/conversation/journal.ts
|
|
887
|
+
/** In-memory `ConversationJournal` — suitable for testing and single-process runs. */
|
|
888
|
+
var InMemoryConversationJournal = class {
|
|
889
|
+
entries = /* @__PURE__ */ new Map();
|
|
890
|
+
async loadRun(runId) {
|
|
891
|
+
const entry = this.entries.get(runId);
|
|
892
|
+
if (!entry) return void 0;
|
|
893
|
+
return {
|
|
894
|
+
runId: entry.runId,
|
|
895
|
+
startedAt: entry.startedAt,
|
|
896
|
+
halted: entry.halted,
|
|
897
|
+
endedAt: entry.endedAt,
|
|
898
|
+
turns: [...entry.turns]
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
async beginRun(runId, startedAt) {
|
|
902
|
+
const existing = this.entries.get(runId);
|
|
903
|
+
if (existing) {
|
|
904
|
+
if (existing.startedAt !== startedAt) throw new Error(`runId '${runId}' already exists with startedAt=${existing.startedAt}; refusing to overwrite with ${startedAt}`);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
this.entries.set(runId, {
|
|
908
|
+
runId,
|
|
909
|
+
startedAt,
|
|
910
|
+
turns: []
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
async appendTurn(runId, turn) {
|
|
914
|
+
const entry = this.entries.get(runId);
|
|
915
|
+
if (!entry) throw new Error(`appendTurn called for unknown runId '${runId}'; call beginRun first or use the runner which handles it`);
|
|
916
|
+
if (entry.halted) throw new Error(`cannot append turn to halted run '${runId}' (halt reason: ${JSON.stringify(entry.halted)})`);
|
|
917
|
+
entry.turns.push(turn);
|
|
918
|
+
}
|
|
919
|
+
async recordHalt(runId, halt, endedAt) {
|
|
920
|
+
const entry = this.entries.get(runId);
|
|
921
|
+
if (!entry) throw new Error(`recordHalt called for unknown runId '${runId}'`);
|
|
922
|
+
entry.halted = halt;
|
|
923
|
+
entry.endedAt = endedAt;
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
/**
|
|
927
|
+
* JSONL on disk. One line per record; first line is the `begin`, subsequent
|
|
928
|
+
* lines are `turn` records, terminal line is `halt`. Replays the whole file
|
|
929
|
+
* on `loadRun` — cheap for the conversation sizes this is designed for
|
|
930
|
+
* (thousands of turns, not millions). For huge runs, plug in a real DB
|
|
931
|
+
* adapter; the interface is small.
|
|
932
|
+
*
|
|
933
|
+
* Each `appendTurn` / `recordHalt` calls `fsync` after the write so a
|
|
934
|
+
* process crash between writes never loses an acknowledged turn.
|
|
935
|
+
*/
|
|
936
|
+
var FileConversationJournal = class {
|
|
937
|
+
path;
|
|
938
|
+
constructor(path) {
|
|
939
|
+
this.path = path;
|
|
940
|
+
}
|
|
941
|
+
async loadRun(runId) {
|
|
942
|
+
const fs = await import("node:fs/promises");
|
|
943
|
+
let text;
|
|
944
|
+
try {
|
|
945
|
+
text = await fs.readFile(this.path, "utf8");
|
|
946
|
+
} catch (err) {
|
|
947
|
+
if (isNoEntError(err)) return void 0;
|
|
948
|
+
throw err;
|
|
949
|
+
}
|
|
950
|
+
const lines = text.split("\n").filter((line) => line.length > 0);
|
|
951
|
+
let entry;
|
|
952
|
+
for (const line of lines) {
|
|
953
|
+
const record = JSON.parse(line);
|
|
954
|
+
if (record.runId !== runId) continue;
|
|
955
|
+
if (record.kind === "begin") entry = {
|
|
956
|
+
runId,
|
|
957
|
+
startedAt: record.startedAt,
|
|
958
|
+
turns: []
|
|
959
|
+
};
|
|
960
|
+
else if (record.kind === "turn") {
|
|
961
|
+
if (!entry) throw new Error(`journal corrupted: turn record for runId '${runId}' precedes its begin record`);
|
|
962
|
+
entry.turns.push(record.turn);
|
|
963
|
+
} else if (record.kind === "halt") {
|
|
964
|
+
if (!entry) throw new Error(`journal corrupted: halt record for runId '${runId}' precedes its begin record`);
|
|
965
|
+
entry.halted = record.halted;
|
|
966
|
+
entry.endedAt = record.endedAt;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return entry;
|
|
970
|
+
}
|
|
971
|
+
async beginRun(runId, startedAt) {
|
|
972
|
+
const existing = await this.loadRun(runId);
|
|
973
|
+
if (existing) {
|
|
974
|
+
if (existing.startedAt !== startedAt) throw new Error(`runId '${runId}' already exists in ${this.path} with startedAt=${existing.startedAt}; refusing to overwrite with ${startedAt}`);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
await this.appendRecord({
|
|
978
|
+
kind: "begin",
|
|
979
|
+
runId,
|
|
980
|
+
startedAt
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
async appendTurn(runId, turn) {
|
|
984
|
+
await this.appendRecord({
|
|
985
|
+
kind: "turn",
|
|
986
|
+
runId,
|
|
987
|
+
turn
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
async recordHalt(runId, halt, endedAt) {
|
|
991
|
+
await this.appendRecord({
|
|
992
|
+
kind: "halt",
|
|
993
|
+
runId,
|
|
994
|
+
halted: halt,
|
|
995
|
+
endedAt
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
async appendRecord(record) {
|
|
999
|
+
const fs = await import("node:fs/promises");
|
|
1000
|
+
const path = await import("node:path");
|
|
1001
|
+
await fs.mkdir(path.dirname(this.path), { recursive: true });
|
|
1002
|
+
const fh = await fs.open(this.path, "a");
|
|
1003
|
+
try {
|
|
1004
|
+
await fh.write(`${JSON.stringify(record)}\n`);
|
|
1005
|
+
await fh.sync();
|
|
1006
|
+
} finally {
|
|
1007
|
+
await fh.close();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
function isNoEntError(err) {
|
|
1012
|
+
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
1013
|
+
}
|
|
1014
|
+
//#endregion
|
|
1015
|
+
//#region src/conversation/journal-sql.ts
|
|
1016
|
+
/**
|
|
1017
|
+
* Adapt a Cloudflare D1 binding to the SqlAdapter shape. Lives here so D1
|
|
1018
|
+
* consumers don't have to write the wrapper themselves; the runtime never
|
|
1019
|
+
* imports `@cloudflare/workers-types` directly (peer-style typing).
|
|
1020
|
+
*/
|
|
1021
|
+
function d1ToSqlAdapter(db) {
|
|
1022
|
+
return {
|
|
1023
|
+
async exec(sql, params = []) {
|
|
1024
|
+
const stmt = db.prepare(sql);
|
|
1025
|
+
const meta = (await (params.length > 0 ? stmt.bind(...params) : stmt).run()).meta;
|
|
1026
|
+
return { rowsAffected: meta?.rows_written ?? meta?.changes ?? 0 };
|
|
1027
|
+
},
|
|
1028
|
+
async query(sql, params = []) {
|
|
1029
|
+
const stmt = db.prepare(sql);
|
|
1030
|
+
return (await (params.length > 0 ? stmt.bind(...params) : stmt).all()).results ?? [];
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
const RUNS_TABLE_DDL = (table) => `
|
|
1035
|
+
CREATE TABLE IF NOT EXISTS ${table}_runs (
|
|
1036
|
+
run_id TEXT PRIMARY KEY,
|
|
1037
|
+
started_at TEXT NOT NULL,
|
|
1038
|
+
halted_kind TEXT,
|
|
1039
|
+
halted_payload TEXT,
|
|
1040
|
+
ended_at TEXT
|
|
1041
|
+
)
|
|
1042
|
+
`;
|
|
1043
|
+
const TURNS_TABLE_DDL = (table) => `
|
|
1044
|
+
CREATE TABLE IF NOT EXISTS ${table}_turns (
|
|
1045
|
+
run_id TEXT NOT NULL,
|
|
1046
|
+
turn_index INTEGER NOT NULL,
|
|
1047
|
+
payload TEXT NOT NULL,
|
|
1048
|
+
PRIMARY KEY (run_id, turn_index)
|
|
1049
|
+
)
|
|
1050
|
+
`;
|
|
1051
|
+
const TURNS_INDEX_DDL = (table) => `
|
|
1052
|
+
CREATE INDEX IF NOT EXISTS idx_${table}_turns_run ON ${table}_turns (run_id, turn_index)
|
|
1053
|
+
`;
|
|
1054
|
+
/**
|
|
1055
|
+
* SQL-backed ConversationJournal. Two tables — runs (one row per runId, holds
|
|
1056
|
+
* start/halt timestamps + halt reason) and turns (one row per committed turn,
|
|
1057
|
+
* payload is the ConversationTurn JSON). Replays the turns table on
|
|
1058
|
+
* `loadRun` and writes append-only per `appendTurn`.
|
|
1059
|
+
*/
|
|
1060
|
+
var SqlConversationJournal = class {
|
|
1061
|
+
db;
|
|
1062
|
+
table;
|
|
1063
|
+
/**
|
|
1064
|
+
* @param db SQL adapter (D1, postgres, sqlite, libSQL — all work)
|
|
1065
|
+
* @param table Table-name prefix; the journal creates `${table}_runs` and
|
|
1066
|
+
* `${table}_turns`. Lets multiple journals share a database
|
|
1067
|
+
* without colliding (e.g. one per product surface).
|
|
1068
|
+
*/
|
|
1069
|
+
constructor(db, table = "agent_runtime_journal") {
|
|
1070
|
+
this.db = db;
|
|
1071
|
+
this.table = table;
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Create the journal's tables if absent. Idempotent. Call once at deploy
|
|
1075
|
+
* (or at app boot) — running on every request is harmless but adds latency.
|
|
1076
|
+
*/
|
|
1077
|
+
async migrate() {
|
|
1078
|
+
await this.db.exec(RUNS_TABLE_DDL(this.table));
|
|
1079
|
+
await this.db.exec(TURNS_TABLE_DDL(this.table));
|
|
1080
|
+
await this.db.exec(TURNS_INDEX_DDL(this.table));
|
|
1081
|
+
}
|
|
1082
|
+
async loadRun(runId) {
|
|
1083
|
+
const row = (await this.db.query(`SELECT run_id, started_at, halted_kind, halted_payload, ended_at FROM ${this.table}_runs WHERE run_id = ?`, [runId]))[0];
|
|
1084
|
+
if (!row) return void 0;
|
|
1085
|
+
const turns = await this.db.query(`SELECT payload, turn_index FROM ${this.table}_turns WHERE run_id = ? ORDER BY turn_index ASC`, [runId]);
|
|
1086
|
+
return {
|
|
1087
|
+
runId: row.run_id,
|
|
1088
|
+
startedAt: row.started_at,
|
|
1089
|
+
halted: row.halted_payload ? JSON.parse(row.halted_payload) : void 0,
|
|
1090
|
+
endedAt: row.ended_at ?? void 0,
|
|
1091
|
+
turns: turns.map((t) => JSON.parse(t.payload))
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
async beginRun(runId, startedAt) {
|
|
1095
|
+
const existing = await this.db.query(`SELECT started_at FROM ${this.table}_runs WHERE run_id = ?`, [runId]);
|
|
1096
|
+
if (existing.length > 0) {
|
|
1097
|
+
if (existing[0]?.started_at !== startedAt) throw new Error(`runId '${runId}' already exists with startedAt=${existing[0]?.started_at}; refusing to overwrite with ${startedAt}`);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
await this.db.exec(`INSERT INTO ${this.table}_runs (run_id, started_at) VALUES (?, ?)`, [runId, startedAt]);
|
|
1101
|
+
}
|
|
1102
|
+
async appendTurn(runId, turn) {
|
|
1103
|
+
const halted = await this.db.query(`SELECT halted_kind FROM ${this.table}_runs WHERE run_id = ?`, [runId]);
|
|
1104
|
+
if (halted.length === 0) throw new Error(`appendTurn called for unknown runId '${runId}'; call beginRun first or use the runner which handles it`);
|
|
1105
|
+
if (halted[0]?.halted_kind) throw new Error(`cannot append turn to halted run '${runId}' (halt kind: ${halted[0]?.halted_kind})`);
|
|
1106
|
+
await this.db.exec(`INSERT INTO ${this.table}_turns (run_id, turn_index, payload) VALUES (?, ?, ?)`, [
|
|
1107
|
+
runId,
|
|
1108
|
+
turn.index,
|
|
1109
|
+
JSON.stringify(turn)
|
|
1110
|
+
]);
|
|
1111
|
+
}
|
|
1112
|
+
async recordHalt(runId, halt, endedAt) {
|
|
1113
|
+
if ((await this.db.exec(`UPDATE ${this.table}_runs SET halted_kind = ?, halted_payload = ?, ended_at = ? WHERE run_id = ?`, [
|
|
1114
|
+
halt.kind,
|
|
1115
|
+
JSON.stringify(halt),
|
|
1116
|
+
endedAt,
|
|
1117
|
+
runId
|
|
1118
|
+
])).rowsAffected === 0) throw new Error(`recordHalt called for unknown runId '${runId}'`);
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
//#endregion
|
|
1122
|
+
//#region src/conversation/run-persona.ts
|
|
1123
|
+
/** Prefix a backend's requests with `systemPrompt`; when `counter` is given,
|
|
1124
|
+
* accumulate its `llm_call` token/cost usage (used for the metered worker). */
|
|
1125
|
+
function withProfilePrompt(inner, systemPrompt, counter) {
|
|
1126
|
+
return {
|
|
1127
|
+
kind: inner.kind,
|
|
1128
|
+
start: inner.start ? (input, ctx) => inner.start(input, ctx) : void 0,
|
|
1129
|
+
resume: inner.resume ? (session, input, ctx) => inner.resume(session, input, ctx) : void 0,
|
|
1130
|
+
stop: inner.stop ? (session, reason) => inner.stop(session, reason) : void 0,
|
|
1131
|
+
async *stream(input, context) {
|
|
1132
|
+
const base = input.messages ?? (input.message ? [{
|
|
1133
|
+
role: "user",
|
|
1134
|
+
content: input.message
|
|
1135
|
+
}] : []);
|
|
1136
|
+
const messages = base[0]?.role === "system" ? base : [{
|
|
1137
|
+
role: "system",
|
|
1138
|
+
content: systemPrompt
|
|
1139
|
+
}, ...base];
|
|
1140
|
+
for await (const event of inner.stream({
|
|
1141
|
+
...input,
|
|
1142
|
+
messages
|
|
1143
|
+
}, context)) {
|
|
1144
|
+
if (counter && event.type === "llm_call") {
|
|
1145
|
+
counter.tokensIn += event.tokensIn ?? 0;
|
|
1146
|
+
counter.tokensOut += event.tokensOut ?? 0;
|
|
1147
|
+
counter.costUsd += event.costUsd ?? 0;
|
|
1148
|
+
}
|
|
1149
|
+
yield event;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
/** A persona participant that replays scripted user turns in order. */
|
|
1155
|
+
function scriptedPersonaBackend(turns) {
|
|
1156
|
+
let idx = 0;
|
|
1157
|
+
return createIterableBackend({
|
|
1158
|
+
kind: "persona-user",
|
|
1159
|
+
async *stream(_input, context) {
|
|
1160
|
+
const text = turns[idx];
|
|
1161
|
+
if (text === void 0) throw new Error(`persona-user: ran out of scripted turns at index ${idx} (had ${turns.length})`);
|
|
1162
|
+
idx += 1;
|
|
1163
|
+
yield {
|
|
1164
|
+
type: "text_delta",
|
|
1165
|
+
task: context.task,
|
|
1166
|
+
session: context.session,
|
|
1167
|
+
text,
|
|
1168
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Run one worker profile against one persona as a multi-round conversation.
|
|
1175
|
+
* The persona leads (participant 0): it speaks, the worker answers, repeat,
|
|
1176
|
+
* until `maxTurns`. Returns the persistent transcript + worker-only usage.
|
|
1177
|
+
*/
|
|
1178
|
+
async function runPersonaConversation(opts) {
|
|
1179
|
+
const counter = {
|
|
1180
|
+
tokensIn: 0,
|
|
1181
|
+
tokensOut: 0,
|
|
1182
|
+
costUsd: 0
|
|
1183
|
+
};
|
|
1184
|
+
const workerName = opts.workerName ?? "agent";
|
|
1185
|
+
const worker = withProfilePrompt(opts.backendFor(opts.worker, "worker"), opts.systemPromptOf(opts.worker), counter);
|
|
1186
|
+
let persona;
|
|
1187
|
+
let maxTurns;
|
|
1188
|
+
if (opts.persona.kind === "scripted") {
|
|
1189
|
+
if (opts.persona.turns.length === 0) throw new Error("runPersonaConversation: scripted persona has no turns");
|
|
1190
|
+
persona = scriptedPersonaBackend(opts.persona.turns);
|
|
1191
|
+
maxTurns = opts.maxTurns ?? 2 * opts.persona.turns.length;
|
|
1192
|
+
} else {
|
|
1193
|
+
persona = withProfilePrompt(opts.backendFor(opts.persona.profile, "persona"), opts.systemPromptOf(opts.persona.profile));
|
|
1194
|
+
if (opts.maxTurns === void 0) throw new Error("runPersonaConversation: maxTurns is required for a profile-driven persona");
|
|
1195
|
+
maxTurns = opts.maxTurns;
|
|
1196
|
+
}
|
|
1197
|
+
const result = await runConversation(defineConversation({
|
|
1198
|
+
participants: [{
|
|
1199
|
+
name: "user",
|
|
1200
|
+
backend: persona
|
|
1201
|
+
}, {
|
|
1202
|
+
name: workerName,
|
|
1203
|
+
backend: worker
|
|
1204
|
+
}],
|
|
1205
|
+
policy: {
|
|
1206
|
+
maxTurns,
|
|
1207
|
+
turnOrder: "alternate",
|
|
1208
|
+
...opts.haltOn ? { haltOn: opts.haltOn } : {}
|
|
1209
|
+
}
|
|
1210
|
+
}), {
|
|
1211
|
+
seed: opts.seed ?? "Begin.",
|
|
1212
|
+
signal: opts.signal
|
|
1213
|
+
});
|
|
1214
|
+
const costUsd = counter.costUsd > 0 ? counter.costUsd : opts.persona.kind === "scripted" ? result.spentCreditsCents / 100 : 0;
|
|
1215
|
+
return {
|
|
1216
|
+
transcript: result.transcript,
|
|
1217
|
+
turns: result.turns,
|
|
1218
|
+
halted: result.halted,
|
|
1219
|
+
costUsd,
|
|
1220
|
+
tokensIn: counter.tokensIn,
|
|
1221
|
+
tokensOut: counter.tokensOut
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Wrap {@link runPersonaConversation} as a `ProfileDispatchFn` for
|
|
1226
|
+
* `runProfileMatrix`: the profile axis is the worker-under-test, the scenario
|
|
1227
|
+
* axis is the persona, and the runner is the cell. Meters the worker through
|
|
1228
|
+
* `ctx.cost` so the matrix's backend-integrity guard sees real usage.
|
|
1229
|
+
*/
|
|
1230
|
+
function runPersonaDispatch(config) {
|
|
1231
|
+
return async (worker, scenario, ctx) => {
|
|
1232
|
+
const model = worker.model?.default ?? "unknown";
|
|
1233
|
+
const maximumCharge = typeof config.maximumCharge === "function" ? config.maximumCharge(worker, scenario) : config.maximumCharge;
|
|
1234
|
+
const paid = await ctx.cost.runPaidCall({
|
|
1235
|
+
channel: "agent",
|
|
1236
|
+
actor: "persona-conversation",
|
|
1237
|
+
model,
|
|
1238
|
+
signal: ctx.signal,
|
|
1239
|
+
...maximumCharge ? { maximumCharge } : {},
|
|
1240
|
+
execute: (executionSignal) => runPersonaConversation({
|
|
1241
|
+
worker,
|
|
1242
|
+
persona: config.personaOf(scenario),
|
|
1243
|
+
backendFor: config.backendFor,
|
|
1244
|
+
systemPromptOf: config.systemPromptOf,
|
|
1245
|
+
maxTurns: config.maxTurns?.(scenario),
|
|
1246
|
+
seed: config.seed?.(scenario),
|
|
1247
|
+
signal: executionSignal,
|
|
1248
|
+
workerName: config.workerName
|
|
1249
|
+
}),
|
|
1250
|
+
receipt: (result) => ({
|
|
1251
|
+
model,
|
|
1252
|
+
inputTokens: result.tokensIn,
|
|
1253
|
+
outputTokens: result.tokensOut,
|
|
1254
|
+
...result.costUsd > 0 ? { actualCostUsd: result.costUsd } : {}
|
|
1255
|
+
})
|
|
1256
|
+
});
|
|
1257
|
+
if (!paid.succeeded) throw paid.error;
|
|
1258
|
+
const result = paid.value;
|
|
1259
|
+
return config.artifactOf(result.transcript, scenario);
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
//#endregion
|
|
1263
|
+
export { makePerAttemptSignal as C, defaultIsRetryable as S, readDepth as _, FileConversationJournal as a, DeadlineExceededError as b, createConversationBackend as c, slugifySpeaker as d, turnId as f, isDepthExceeded as g, buildForwardHeaders as h, d1ToSqlAdapter as i, runConversation as l, FORWARD_HEADERS as m, runPersonaDispatch as n, InMemoryConversationJournal as o, DEFAULT_MAX_DEPTH as p, SqlConversationJournal as r, defineConversation as s, runPersonaConversation as t, runConversationStream as u, CircuitBreakerState as v, sleep as w, computeBackoff as x, CircuitOpenError as y };
|
|
1264
|
+
|
|
1265
|
+
//# sourceMappingURL=conversation-BpLQZGPH.js.map
|