@tangle-network/agent-app 0.49.20 → 0.49.21

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.
@@ -1,25 +1,17 @@
1
- import type { AgentProfile } from '@tangle-network/sandbox';
2
- import { type LineAttachment } from '@tangle-network/sandbox/core';
3
- export * from './engine';
1
+ import type { AgentProfile, LineVoiceOptions } from '@tangle-network/sandbox';
2
+ import { type Line } from '@tangle-network/sandbox/core';
4
3
  /**
5
4
  * A hosted agent: people text or call a line, and each person is answered
6
5
  * from their own isolated sandbox. The developer's Tangle API key pays for
7
- * every box, model turn and reply.
6
+ * every box, model turn, reply and call.
8
7
  *
9
8
  * The platform does the work. {@link HostedAgent.attachLine} attaches the
10
- * line to Tangle Hub with one sandbox per person: Hub routes each text to the
11
- * sender's box (the named instance {@link PERSON_KEY_PREFIX} plus a hash of
12
- * their number), keeps one thread per person, handles STOP and START, counts
13
- * each person's texts per day, and sends the reply. A call through ph0ny runs
14
- * in the same box and the same thread, so text and voice share one memory.
9
+ * line to Tangle Hub with one sandbox per person: Hub routes each text and
10
+ * call to the person's box (the named instance {@link PERSON_KEY_PREFIX} plus
11
+ * a hash of their number), keeps one thread per person, handles STOP and
12
+ * START, counts each person's texts per day, and sends the reply. A call on
13
+ * the line runs in the same box and thread, so text and voice share one memory.
15
14
  */
16
- /** Durable key-value storage for voice call tokens. A Cloudflare KV namespace satisfies it. */
17
- export interface HostedAgentStore {
18
- get(key: string): Promise<string | null>;
19
- put(key: string, value: string, options?: {
20
- expirationTtl?: number;
21
- }): Promise<void>;
22
- }
23
15
  export interface HostedAgentConfig {
24
16
  /** The developer's Tangle API key. */
25
17
  apiKey: string;
@@ -37,13 +29,6 @@ export interface HostedAgentConfig {
37
29
  /** Texts Hub answers per person per UTC day. Default 20. */
38
30
  freeTurnsPerDay?: number;
39
31
  box?: Partial<BoxPolicy>;
40
- /** Voice call tokens. Required for `voiceHook` and `voiceAsk`. */
41
- store?: HostedAgentStore;
42
- /** Shared secret for the ph0ny call hook and `ask_workspace` webhook tool. */
43
- voiceSecret?: string;
44
- /** How long one `ask_workspace` call may wait before it returns a ticket. Default 8 s. */
45
- voiceBudgetMs?: number;
46
- turnWallCapMs?: number;
47
32
  sandboxUrl?: string;
48
33
  }
49
34
  export interface BoxPolicy {
@@ -85,15 +70,6 @@ export declare const CONVERSATION_TOOLS_OFF: readonly ['bash', 'glob', 'grep', '
85
70
  * (2026-09-23), within the latency of the others.
86
71
  */
87
72
  export declare const DEFAULT_HOSTED_MODEL = "openai/gpt-5.6-luna";
88
- export type AskResult = {
89
- state: 'answered';
90
- text: string;
91
- } | {
92
- state: 'declined';
93
- reply?: string;
94
- } | {
95
- state: 'pending';
96
- };
97
73
  export declare class HostedAgentError extends Error {
98
74
  readonly code: string;
99
75
  constructor(code: string, message: string);
@@ -102,20 +78,14 @@ export declare function createHostedAgent(config: HostedAgentConfig): {
102
78
  /**
103
79
  * Attach an Inkbox iMessage identity, connected to Hub under the
104
80
  * developer's account, as this agent's line: the owner and anyone who
105
- * texts it each get their own box and thread. Safe to repeat with the
106
- * same config. Remove any Hub event subscription on the connection first;
107
- * Hub refuses a line that another route would also answer.
108
- */
109
- attachLine(connectionId: string): Promise<LineAttachment>;
110
- /**
111
- * ph0ny's call hook. `admit` returns a call token bound to the caller's
112
- * number from the carrier's signed call record, never from the model.
113
- */
114
- voiceHook(request: Request): Promise<Response>;
115
- /**
116
- * ph0ny's `ask_workspace` webhook tool, configured with `forwardCallToken`.
117
- * A slow turn returns a ticket; the voice agent asks again with it.
81
+ * texts it each get their own box and thread. With `voice`, calls to the
82
+ * line reach the caller's box and thread through that ph0ny agent; Hub
83
+ * admits only members, so a caller texts once before calling. Safe to
84
+ * repeat with the same config. Remove any Hub event subscription on the
85
+ * connection first; Hub refuses a line that another route would also answer.
118
86
  */
119
- voiceAsk(request: Request): Promise<Response>;
87
+ attachLine(connectionId: string, options?: {
88
+ voice?: LineVoiceOptions;
89
+ }): Promise<Line>;
120
90
  };
121
91
  export type HostedAgent = ReturnType<typeof createHostedAgent>;
@@ -1,168 +1,5 @@
1
1
  // src/hosted-agent/index.ts
2
- import { InstanceRestartingError, lineInstanceKey, Sandbox } from "@tangle-network/sandbox/core";
3
-
4
- // src/hosted-agent/engine.ts
5
- var MAX_MESSAGE_CHARS = 8e3;
6
- var MAX_REPEATED_FAILURES = 3;
7
- var AUTHORITY_ENDED = "authority_ended";
8
- var TurnPending = class extends Error {
9
- constructor(code, transient) {
10
- super(code);
11
- this.code = code;
12
- this.transient = transient;
13
- this.name = "TurnPending";
14
- }
15
- code;
16
- transient;
17
- };
18
- function sessionInfo(session) {
19
- const s = typeof session === "object" && session !== null ? session : {};
20
- const effective = s.effectiveBackend?.model;
21
- const model = typeof effective === "string" && effective ? effective : typeof s.model === "string" && s.model ? s.model : void 0;
22
- return { ...typeof s.backend === "string" && s.backend ? { harness: s.backend } : {}, ...model ? { model } : {} };
23
- }
24
- var refusal = (reason, detail) => ({ ok: false, reason, ...detail ? { detail } : {} });
25
- var RUNNING = /* @__PURE__ */ new Set(["running", "awaiting_input", "not_admitted"]);
26
- var TRANSIENT_ERRORS = /* @__PURE__ */ new Set(["NetworkError", "TimeoutError", "QuotaError", "AbortError", "EdgeNotReadyError"]);
27
- var TRANSIENT_503 = /* @__PURE__ */ new Set(["MODEL_CREDENTIAL_SUPERSEDED", "HUB_CREDENTIAL_REFRESH_UNAVAILABLE"]);
28
- function describeFailure(error) {
29
- const e = typeof error === "object" && error !== null ? error : {};
30
- const name = typeof e.name === "string" ? e.name : typeof error;
31
- const status = typeof e.status === "number" ? e.status : void 0;
32
- const own = typeof e.code === "string" ? e.code : typeof e.reason === "string" ? e.reason : void 0;
33
- const code = [name, own, status].filter((part) => part !== void 0).join(":").replace(/[^\w.:-]/g, "_").slice(0, 100);
34
- const message = String(typeof e.message === "string" ? e.message : error).replace(/sk-tan-[\w-]+|eyJ[\w-]+\.[\w-]+\.[\w-]*|Bearer\s+\S+/g, "<redacted>").slice(0, 400);
35
- return { name, code, ...status !== void 0 ? { status } : {}, ...own !== void 0 ? { reason: own } : {}, message };
36
- }
37
- function isTransientFailure(failure) {
38
- return TRANSIENT_ERRORS.has(failure.name) || [408, 429, 502, 504].includes(failure.status ?? 0) || failure.status === 503 && (failure.reason === void 0 || TRANSIENT_503.has(failure.reason));
39
- }
40
- var budgetExhausted = (error) => error instanceof Error && error.name === "KeyBudgetExhaustedError";
41
- async function runHostedTurn(turn, ports, options) {
42
- const now = options.now ?? Date.now;
43
- const sleep2 = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
44
- const observe = ports.observe ?? {};
45
- const allowDispatch = options.allowDispatch !== false;
46
- const text = turn.text.trim();
47
- if (!text || text.length > MAX_MESSAGE_CHARS || !turn.turnId || turn.turnId.length > 200) return refusal("refused", "invalid_message");
48
- const refused = await ports.admit();
49
- if (refused) return refused;
50
- const timed = async (step, fn) => {
51
- const start = now();
52
- try {
53
- return await fn();
54
- } finally {
55
- observe.span?.(step, now() - start);
56
- }
57
- };
58
- const failed = async (step, error, boxId) => {
59
- const failure = describeFailure(error);
60
- await observe.failure?.(step, failure, boxId);
61
- const classified = ports.classify?.(error, step);
62
- if (classified) return classified;
63
- if (error instanceof TurnPending) return { ok: "pending", detail: { step, code: error.code, transient: error.transient } };
64
- if (budgetExhausted(error)) return refusal("refused", "budget_exhausted");
65
- return { ok: "pending", detail: { step, code: failure.code, transient: isTransientFailure(failure) }, cause: error };
66
- };
67
- let box;
68
- try {
69
- box = await timed("ensure", () => ports.box());
70
- } catch (error) {
71
- return failed("ensure", error);
72
- }
73
- if (ports.authorize && !await ports.authorize()) return refusal("refused", AUTHORITY_ENDED);
74
- let result;
75
- try {
76
- const prompt = await ports.prompt(box);
77
- const sessionId = ports.sessionId;
78
- if (allowDispatch) await timed("session", async () => {
79
- const session = await box.session(sessionId).status();
80
- if (session) {
81
- observe.session?.({ created: false, ...sessionInfo(session) });
82
- return;
83
- }
84
- const backend = await ports.backend();
85
- await box.createSession({ sessionId, retention: "workspace", backend });
86
- const created = observe.session && observe.readCreatedSession ? sessionInfo(await box.session(sessionId).status()) : {};
87
- observe.session?.({ created: true, ...backend.type ? { harness: backend.type } : {}, ...created });
88
- });
89
- for (; ; ) {
90
- result = await timed("drive", () => box.driveConversationTurn(prompt, {
91
- sessionId,
92
- turnId: turn.turnId,
93
- wallCapMs: options.wallCapMs,
94
- timeoutMs: options.timeoutMs ?? 8e3,
95
- allowDispatch
96
- }));
97
- observe.drive?.(result, now());
98
- const pollMs = options.pollMs ?? 2e3;
99
- if (!RUNNING.has(result.state) || options.until === void 0 || now() + pollMs > options.until) break;
100
- await sleep2(pollMs);
101
- }
102
- } catch (error) {
103
- return failed("drive", error, box.id);
104
- }
105
- switch (result.state) {
106
- case "completed": {
107
- const answer = result.text.trim();
108
- if (!answer) {
109
- await observe.failure?.("turn", describeFailure(new Error("The turn completed with no reply text")), box.id);
110
- return refusal("unavailable", "empty_reply");
111
- }
112
- await ports.answered?.(answer, box);
113
- return { ok: true, text: answer };
114
- }
115
- case "failed":
116
- await observe.failure?.("turn", describeFailure(Object.assign(new Error(result.error), { name: "TurnFailed" })), box.id);
117
- return refusal("unavailable", "agent_failed");
118
- // Interaction and plan approval are not exposed over messaging. The wall
119
- // cap cancels a turn that waits on one, which frees the session.
120
- // Sandbox 0.45 reported approval and question outcomes as `failed`.
121
- case "awaiting_plan_decision":
122
- case "blocked_on_approval":
123
- case "awaiting_question":
124
- case "awaiting_interaction":
125
- return refusal("refused", "needs_decision");
126
- case "running":
127
- case "awaiting_input":
128
- case "not_admitted":
129
- return { ok: "pending", detail: { step: "turn", code: result.state, transient: true } };
130
- }
131
- }
132
- async function settleHostedTurn(pass, options) {
133
- const now = options.now ?? Date.now;
134
- const sleep2 = options.sleep ?? ((ms, signal) => new Promise((resolve, reject) => {
135
- const timer = setTimeout(resolve, ms);
136
- signal?.addEventListener("abort", () => {
137
- clearTimeout(timer);
138
- reject(signal.reason);
139
- }, { once: true });
140
- }));
141
- let last = "", repeats = 0;
142
- for (; ; ) {
143
- if (options.signal?.aborted || now() > options.deadline) return refusal("unavailable", "pending");
144
- const outcome = await pass();
145
- if (outcome.ok !== "pending") return outcome;
146
- repeats = outcome.detail.code === last ? repeats + 1 : 1;
147
- last = outcome.detail.code;
148
- if (!outcome.detail.transient && repeats >= MAX_REPEATED_FAILURES) return refusal("unavailable", `${outcome.detail.step}:${last}`);
149
- try {
150
- await sleep2(options.pollMs ?? 1500, options.signal);
151
- } catch {
152
- return refusal("unavailable", "pending");
153
- }
154
- }
155
- }
156
- function chatOnlyBackend(backend) {
157
- const profile = backend.profile ?? {};
158
- return { ...backend, profile: {
159
- ...profile,
160
- tools: { ...profile.tools, bash: false, edit: false, write: false },
161
- permissions: { ...profile.permissions, bash: "deny", edit: "deny" }
162
- } };
163
- }
164
-
165
- // src/hosted-agent/index.ts
2
+ import { Sandbox } from "@tangle-network/sandbox/core";
166
3
  var DEFAULT_BOX_POLICY = {
167
4
  cpuCores: 2,
168
5
  memoryMB: 2048,
@@ -189,23 +26,7 @@ function conversationProfile(profile) {
189
26
  };
190
27
  }
191
28
  var PERSON = { context: "own", tools: "act" };
192
- var CALL_TTL_MS = 2 * 36e5;
193
29
  var E164 = /^\+[1-9]\d{6,14}$/;
194
- var encode = (text) => new TextEncoder().encode(text);
195
- var sha256 = async (value) => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", encode(value))), (byte) => byte.toString(16).padStart(2, "0")).join("");
196
- async function equal(given, expected) {
197
- if (!given) return false;
198
- const [a, b] = await Promise.all([sha256(given), sha256(expected)]);
199
- let diff = 0;
200
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
201
- return diff === 0;
202
- }
203
- var randomToken = () => btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
204
- var b64 = (text) => btoa(String.fromCharCode(...encode(text))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
205
- var unb64 = (text) => new TextDecoder().decode(Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)));
206
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
207
- var json = (body, status = 200) => Response.json(body, { status });
208
- var describe = (error) => (error instanceof Error ? `${error.name}: ${error.message}` : String(error)).replace(/sk-tan-[\w-]+|Bearer\s+\S+/g, "<redacted>").slice(0, 400);
209
30
  var HostedAgentError = class extends Error {
210
31
  constructor(code, message) {
211
32
  super(message);
@@ -215,10 +36,9 @@ var HostedAgentError = class extends Error {
215
36
  code;
216
37
  };
217
38
  function createHostedAgent(config) {
39
+ if (!E164.test(config.owner)) throw new HostedAgentError("owner_not_e164", "owner must be an E.164 phone number, such as +15550100001.");
218
40
  const policy = { ...DEFAULT_BOX_POLICY, ...config.box };
219
- const wallCapMs = config.turnWallCapMs ?? 12e4;
220
41
  const sandbox = new Sandbox({ apiKey: config.apiKey, baseUrl: config.sandboxUrl ?? "https://sandbox.tangle.tools", timeoutMs: 2e4 });
221
- if (!E164.test(config.owner)) throw new HostedAgentError("owner_not_e164", "owner must be an E.164 phone number, such as +15550100001.");
222
42
  const backend = { ...config.harness ? { type: config.harness } : {}, profile: conversationProfile(config.profile) };
223
43
  const create = {
224
44
  name: "hosted-person",
@@ -228,79 +48,19 @@ function createHostedAgent(config) {
228
48
  maxLifetimeSeconds: policy.maxLifetimeSeconds,
229
49
  deleteAfterStoppedSeconds: policy.deleteAfterStoppedSeconds
230
50
  };
231
- async function personBox(phone, deadline) {
232
- let box;
233
- try {
234
- ({ box } = await sandbox.instances.ensure({
235
- key: await lineInstanceKey(PERSON_KEY_PREFIX, phone),
236
- create: { ...create, secrets: [], sshEnabled: false }
237
- }));
238
- } catch (error) {
239
- if (error instanceof InstanceRestartingError) throw new TurnPending("box_restarting", true);
240
- throw error;
241
- }
242
- if (box.status === "running") return box;
243
- const timeoutMs = deadline - Date.now();
244
- if (timeoutMs < 1e3) return null;
245
- try {
246
- return await sandbox.waitForRunning(box.id, { timeoutMs });
247
- } catch (error) {
248
- if (error instanceof Error && error.name === "TimeoutError") return null;
249
- throw error;
250
- }
251
- }
252
- async function callSession(phone) {
253
- for (const line of await sandbox.lines.list()) {
254
- if (line.attachment?.instance?.keyPrefix !== PERSON_KEY_PREFIX) continue;
255
- const member = (await sandbox.lines.members(line.id).list()).find((m) => m.address === phone);
256
- const thread = member && (await sandbox.lines.threads(line.id).list()).find((t) => t.memberId === member.id);
257
- if (thread) return thread.sessionId;
258
- }
259
- return `voice-${(await sha256(phone)).slice(0, 32)}`;
260
- }
261
- async function answerCall(call, text, turnId, deadline) {
262
- const outcome = await runHostedTurn({ turnId, text }, {
263
- // Calls are admitted by `voiceHook`; Hub counts texts.
264
- admit: async () => null,
265
- async box() {
266
- const box = await personBox(call.phone, deadline);
267
- if (!box) throw new TurnPending("box_starting", true);
268
- return box;
269
- },
270
- sessionId: call.session,
271
- backend: async () => backend,
272
- prompt: async () => `[Phone call. Answer in one to three short spoken sentences.]
273
- ${text}`,
274
- classify: (error) => error instanceof HostedAgentError ? { ok: false, reason: "unavailable", detail: error.code } : void 0,
275
- observe: {
276
- failure(step, failure) {
277
- console.error(`[hosted-agent] voice turn=${turnId} step=${step} code=${failure.code} message=${JSON.stringify(failure.message)}`);
278
- }
279
- }
280
- }, { wallCapMs, timeoutMs: 8e3, until: deadline });
281
- if (outcome.ok === true) return { state: "answered", text: outcome.text };
282
- if (outcome.ok === "pending") {
283
- if (outcome.cause !== void 0) throw outcome.cause;
284
- return { state: "pending" };
285
- }
286
- const code = outcome.detail ?? outcome.reason;
287
- throw new HostedAgentError(code === "agent_failed" ? "turn_failed" : code, `The turn ended without an answer (${code}).`);
288
- }
289
- function voiceStore() {
290
- if (!config.store) throw new HostedAgentError("store_missing", "voice needs a store for call tokens");
291
- return config.store;
292
- }
293
51
  return {
294
52
  /**
295
53
  * Attach an Inkbox iMessage identity, connected to Hub under the
296
54
  * developer's account, as this agent's line: the owner and anyone who
297
- * texts it each get their own box and thread. Safe to repeat with the
298
- * same config. Remove any Hub event subscription on the connection first;
299
- * Hub refuses a line that another route would also answer.
55
+ * texts it each get their own box and thread. With `voice`, calls to the
56
+ * line reach the caller's box and thread through that ph0ny agent; Hub
57
+ * admits only members, so a caller texts once before calling. Safe to
58
+ * repeat with the same config. Remove any Hub event subscription on the
59
+ * connection first; Hub refuses a line that another route would also answer.
300
60
  */
301
- async attachLine(connectionId) {
61
+ async attachLine(connectionId, options = {}) {
302
62
  const line = await sandbox.lines.fromConnection({ connectionId, transport: "imessage", clientReference: "hosted-agent" });
303
- return sandbox.lines.attach({
63
+ await sandbox.lines.attach({
304
64
  number: line.id,
305
65
  mode: "shared",
306
66
  members: [{ address: config.owner, role: "owner" }],
@@ -311,77 +71,17 @@ ${text}`,
311
71
  instance: { keyPrefix: PERSON_KEY_PREFIX, create },
312
72
  clientReference: "hosted-agent"
313
73
  });
314
- },
315
- /**
316
- * ph0ny's call hook. `admit` returns a call token bound to the caller's
317
- * number from the carrier's signed call record, never from the model.
318
- */
319
- async voiceHook(request) {
320
- const secret = config.voiceSecret;
321
- if (!secret || !await equal(request.headers.get("authorization"), `Bearer ${secret}`)) return json({ error: "unauthorized" }, 401);
322
- const body = await request.json().catch(() => null);
323
- if (body?.event !== "admit") return json({ ok: true });
324
- const phone = body.phone?.trim() ?? "";
325
- if (!E164.test(phone)) return json({ admit: false, say: "Sorry, I can only take calls from a visible number." });
326
- const callToken = randomToken();
327
- await voiceStore().put(
328
- `vcall:${callToken}`,
329
- JSON.stringify({ phone, exp: Date.now() + CALL_TTL_MS }),
330
- { expirationTtl: CALL_TTL_MS / 1e3 }
331
- );
332
- return json({ admit: true, callToken });
333
- },
334
- /**
335
- * ph0ny's `ask_workspace` webhook tool, configured with `forwardCallToken`.
336
- * A slow turn returns a ticket; the voice agent asks again with it.
337
- */
338
- async voiceAsk(request) {
339
- const secret = config.voiceSecret;
340
- if (!secret || !await equal(request.headers.get("authorization"), `Bearer ${secret}`)) return json({ error: "unauthorized" }, 401);
341
- const token = request.headers.get("x-voice-call-token") ?? "";
342
- const store = voiceStore();
343
- const admitted = /^[A-Za-z0-9_-]{16,128}$/.test(token) ? await store.get(`vcall:${token}`) : null;
344
- if (!admitted) return json({ status: "error", error: "call_not_admitted" }, 403);
345
- const call = JSON.parse(admitted);
346
- if (call.exp < Date.now()) return json({ status: "error", error: "call_expired" }, 403);
347
- const body = await request.json().catch(() => null);
348
- const [turnId, asked] = body?.ticket ? body.ticket.split(".") : [`v-${crypto.randomUUID()}`, b64(body?.utterance ?? "")];
349
- if (!turnId || !asked) return json({ status: "error", error: "missing_utterance" }, 400);
350
- try {
351
- if (!call.session) {
352
- call.session = await callSession(call.phone);
353
- await store.put(`vcall:${token}`, JSON.stringify(call), { expirationTtl: Math.max(60, Math.ceil((call.exp - Date.now()) / 1e3)) });
354
- }
355
- const budgetMs = config.voiceBudgetMs ?? 8e3;
356
- const result = await Promise.race([
357
- answerCall(call, unb64(asked), turnId, Date.now() + budgetMs),
358
- sleep(budgetMs).then(() => ({ state: "pending" }))
359
- ]);
360
- if (result.state === "pending") return json({ status: "pending", ticket: `${turnId}.${asked}` });
361
- if (result.state === "declined") return json({ status: "complete", answer: result.reply ?? "I cannot answer that right now." });
362
- return json({ status: "complete", answer: result.text });
363
- } catch (error) {
364
- console.error(`[hosted-agent] voice turn=${turnId} ${describe(error)}`);
365
- return json({ status: "error", error: "agent_unavailable" }, 502);
366
- }
74
+ if (options.voice) await sandbox.lines.enableVoice(line.id, options.voice);
75
+ return sandbox.lines.get(line.id);
367
76
  }
368
77
  };
369
78
  }
370
79
  export {
371
- AUTHORITY_ENDED,
372
80
  CONVERSATION_TOOLS_OFF,
373
81
  DEFAULT_BOX_POLICY,
374
82
  DEFAULT_HOSTED_MODEL,
375
83
  HostedAgentError,
376
- MAX_MESSAGE_CHARS,
377
- MAX_REPEATED_FAILURES,
378
84
  PERSON_KEY_PREFIX,
379
- TurnPending,
380
- chatOnlyBackend,
381
- createHostedAgent,
382
- describeFailure,
383
- isTransientFailure,
384
- runHostedTurn,
385
- settleHostedTurn
85
+ createHostedAgent
386
86
  };
387
87
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hosted-agent/index.ts","../../src/hosted-agent/engine.ts"],"sourcesContent":["import type { AgentProfile, BackendConfig, SandboxInstance } from '@tangle-network/sandbox'\nimport { InstanceRestartingError, type LineAttachment, type LineInstanceCreate, lineInstanceKey, Sandbox } from '@tangle-network/sandbox/core'\nimport { runHostedTurn, TurnPending } from './engine'\n\nexport * from './engine'\n\n/**\n * A hosted agent: people text or call a line, and each person is answered\n * from their own isolated sandbox. The developer's Tangle API key pays for\n * every box, model turn and reply.\n *\n * The platform does the work. {@link HostedAgent.attachLine} attaches the\n * line to Tangle Hub with one sandbox per person: Hub routes each text to the\n * sender's box (the named instance {@link PERSON_KEY_PREFIX} plus a hash of\n * their number), keeps one thread per person, handles STOP and START, counts\n * each person's texts per day, and sends the reply. A call through ph0ny runs\n * in the same box and the same thread, so text and voice share one memory.\n */\n\n/** Durable key-value storage for voice call tokens. A Cloudflare KV namespace satisfies it. */\nexport interface HostedAgentStore {\n get(key: string): Promise<string | null>\n put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>\n}\n\nexport interface HostedAgentConfig {\n /** The developer's Tangle API key. */\n apiKey: string\n /**\n * The persona every person's box runs. A profile without `model.default`\n * runs {@link DEFAULT_HOSTED_MODEL}, and a profile without `tools` runs with\n * {@link CONVERSATION_TOOLS_OFF} turned off. Set `tools` to choose your own,\n * for example on a harness that cannot turn those tools off.\n */\n profile: AgentProfile\n /** Backend harness type, such as `opencode`; the runtime default when omitted. */\n harness?: string\n /** The owner's own phone (E.164), the line's first member. */\n owner: string\n /** Texts Hub answers per person per UTC day. Default 20. */\n freeTurnsPerDay?: number\n box?: Partial<BoxPolicy>\n /** Voice call tokens. Required for `voiceHook` and `voiceAsk`. */\n store?: HostedAgentStore\n /** Shared secret for the ph0ny call hook and `ask_workspace` webhook tool. */\n voiceSecret?: string\n /** How long one `ask_workspace` call may wait before it returns a ticket. Default 8 s. */\n voiceBudgetMs?: number\n turnWallCapMs?: number\n sandboxUrl?: string\n}\n\nexport interface BoxPolicy {\n cpuCores: number\n memoryMB: number\n diskGB: number\n idleTimeoutSeconds: number\n maxLifetimeSeconds: number\n deleteAfterStoppedSeconds: number\n /** Egress allow-list. The default reaches the model router only. */\n allowDomains: string[]\n}\n\n/**\n * Two cores and a 2 GB disk cost what one core and 10 GB cost: both bill the\n * platform's hourly floor. A person's box starts OpenCode on their first text\n * and after every idle stop, and that start is CPU-bound: on one core it took\n * 6.1 s after a resume and 8.4 s on a new box, on two cores 3.5 s and 3.4 s\n * (production, 2026-09-24). A disk no larger than the platform's warm seed\n * lets a new person's box be claimed from the warm pool: create took 2.1-2.7 s\n * instead of 5.3-7.0 s (2026-09-25).\n */\nexport const DEFAULT_BOX_POLICY: BoxPolicy = {\n cpuCores: 2, memoryMB: 2048, diskGB: 2,\n idleTimeoutSeconds: 600, maxLifetimeSeconds: 86_400, deleteAfterStoppedSeconds: 7 * 86_400,\n allowDomains: ['router.tangle.tools'],\n}\n\n/**\n * Each person's box is the developer's named instance with this prefix. It is\n * the key this kit used before Hub routed its texts, so every existing\n * person keeps their box.\n */\nexport const PERSON_KEY_PREFIX = 'hosted:'\n\n/**\n * Harness tools a texting or calling assistant does not use. Their\n * descriptions present every turn as coding work: a shell, file search,\n * sub-agents, to-do lists, skills and web fetch. File read, write and edit\n * stay, so a persona can keep notes such as `memory.md`.\n */\nexport const CONVERSATION_TOOLS_OFF = ['bash', 'glob', 'grep', 'task', 'todowrite', 'webfetch', 'skill'] as const\n\n/**\n * The model for a profile without `model.default`. It gave the most useful\n * on-topic replies among four Router models on the same five texts and calls\n * (2026-09-23), within the latency of the others.\n */\nexport const DEFAULT_HOSTED_MODEL = 'openai/gpt-5.6-luna'\n\n/** The profile a person's box runs: the developer's profile over the conversation defaults. */\nfunction conversationProfile(profile: AgentProfile): AgentProfile {\n return {\n ...profile,\n model: { ...profile.model, default: profile.model?.default ?? DEFAULT_HOSTED_MODEL },\n // A profile that sets `tools` owns its tool set.\n ...(profile.tools ? {} : {\n tools: Object.fromEntries(CONVERSATION_TOOLS_OFF.map(tool => [tool, false])),\n // The sandbox's preview policy grants the shell unless its permission\n // is denied, so turning the tool off alone leaves the shell in place.\n permissions: { bash: 'deny' as const, ...profile.permissions },\n }),\n }\n}\n\nexport type AskResult =\n | { state: 'answered'; text: string }\n | { state: 'declined'; reply?: string }\n | { state: 'pending' }\n\n/** Each person runs in their own box, so no member shares a disk and each may use the persona's tools. */\nconst PERSON = { context: 'own', tools: 'act' } as const\nconst CALL_TTL_MS = 2 * 3_600_000\nconst E164 = /^\\+[1-9]\\d{6,14}$/\nconst encode = (text: string) => new TextEncoder().encode(text)\nconst sha256 = async (value: string) =>\n Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', encode(value))), byte => byte.toString(16).padStart(2, '0')).join('')\n/** Compares digests, so the time taken reveals nothing about the secret. */\nasync function equal(given: string | null | undefined, expected: string): Promise<boolean> {\n if (!given) return false\n const [a, b] = await Promise.all([sha256(given), sha256(expected)])\n let diff = 0\n for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)\n return diff === 0\n}\n/** 43 url-safe characters from 32 random bytes. */\nconst randomToken = () => btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\nconst b64 = (text: string) => btoa(String.fromCharCode(...encode(text))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\nconst unb64 = (text: string) => new TextDecoder().decode(Uint8Array.from(atob(text.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)))\nconst sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))\nconst json = (body: unknown, status = 200) => Response.json(body, { status })\nconst describe = (error: unknown) => (error instanceof Error ? `${error.name}: ${error.message}` : String(error))\n .replace(/sk-tan-[\\w-]+|Bearer\\s+\\S+/g, '<redacted>').slice(0, 400)\n\nexport class HostedAgentError extends Error {\n constructor(readonly code: string, message: string) { super(message); this.name = 'HostedAgentError' }\n}\n\n/** A call admitted by `voiceHook`: the caller's number and, once found, their text thread's session. */\ninterface Call { phone: string; exp: number; session?: string }\n\nexport function createHostedAgent(config: HostedAgentConfig) {\n const policy = { ...DEFAULT_BOX_POLICY, ...config.box }\n const wallCapMs = config.turnWallCapMs ?? 120_000\n const sandbox = new Sandbox({ apiKey: config.apiKey, baseUrl: config.sandboxUrl ?? 'https://sandbox.tangle.tools', timeoutMs: 20_000 })\n if (!E164.test(config.owner)) throw new HostedAgentError('owner_not_e164', 'owner must be an E.164 phone number, such as +15550100001.')\n const backend: BackendConfig = { ...(config.harness ? { type: config.harness as BackendConfig['type'] } : {}), profile: conversationProfile(config.profile) }\n const create: LineInstanceCreate = {\n name: 'hosted-person',\n resources: { cpuCores: policy.cpuCores, memoryMB: policy.memoryMB, diskGB: policy.diskGB },\n egressPolicy: { mode: 'strict', allowDomains: policy.allowDomains, includeImplicitDomains: false },\n idleTimeoutSeconds: policy.idleTimeoutSeconds, maxLifetimeSeconds: policy.maxLifetimeSeconds,\n deleteAfterStoppedSeconds: policy.deleteAfterStoppedSeconds,\n }\n\n /** The caller's running box: the instance Hub runs their texts in. Null when the deadline passed while it starts. */\n async function personBox(phone: string, deadline: number): Promise<SandboxInstance | null> {\n let box: SandboxInstance\n try {\n ({ box } = await sandbox.instances.ensure({ key: await lineInstanceKey(PERSON_KEY_PREFIX, phone),\n create: { ...create, secrets: [], sshEnabled: false } }))\n } catch (error) {\n // The platform replaces a box that keeps failing to start; until then the turn waits.\n if (error instanceof InstanceRestartingError) throw new TurnPending('box_restarting', true)\n throw error\n }\n if (box.status === 'running') return box\n const timeoutMs = deadline - Date.now()\n if (timeoutMs < 1000) return null\n try {\n return await sandbox.waitForRunning(box.id, { timeoutMs })\n } catch (error) {\n if (error instanceof Error && error.name === 'TimeoutError') return null\n throw error\n }\n }\n\n /** The session of the caller's text thread, so a call continues the conversation; a caller who never texted gets their own. */\n async function callSession(phone: string): Promise<string> {\n for (const line of await sandbox.lines.list()) {\n if (line.attachment?.instance?.keyPrefix !== PERSON_KEY_PREFIX) continue\n const member = (await sandbox.lines.members(line.id).list()).find(m => m.address === phone)\n const thread = member && (await sandbox.lines.threads(line.id).list()).find(t => t.memberId === member.id)\n if (thread) return thread.sessionId\n }\n return `voice-${(await sha256(phone)).slice(0, 32)}`\n }\n\n /** Answer one spoken question in the caller's box until it answers or `deadline`. */\n async function answerCall(call: Call, text: string, turnId: string, deadline: number): Promise<AskResult> {\n const outcome = await runHostedTurn({ turnId, text }, {\n // Calls are admitted by `voiceHook`; Hub counts texts.\n admit: async () => null,\n async box() {\n const box = await personBox(call.phone, deadline)\n if (!box) throw new TurnPending('box_starting', true)\n return box\n },\n sessionId: call.session!,\n backend: async () => backend,\n prompt: async () => `[Phone call. Answer in one to three short spoken sentences.]\\n${text}`,\n classify: error => error instanceof HostedAgentError ? { ok: false, reason: 'unavailable', detail: error.code } : undefined,\n observe: {\n failure(step, failure) {\n console.error(`[hosted-agent] voice turn=${turnId} step=${step} code=${failure.code} message=${JSON.stringify(failure.message)}`)\n },\n },\n }, { wallCapMs, timeoutMs: 8000, until: deadline })\n if (outcome.ok === true) return { state: 'answered', text: outcome.text }\n // A thrown failure is rethrown, so the caller's retry policy decides it.\n if (outcome.ok === 'pending') {\n if (outcome.cause !== undefined) throw outcome.cause\n return { state: 'pending' }\n }\n const code = outcome.detail ?? outcome.reason\n throw new HostedAgentError(code === 'agent_failed' ? 'turn_failed' : code, `The turn ended without an answer (${code}).`)\n }\n\n function voiceStore(): HostedAgentStore {\n if (!config.store) throw new HostedAgentError('store_missing', 'voice needs a store for call tokens')\n return config.store\n }\n\n return {\n /**\n * Attach an Inkbox iMessage identity, connected to Hub under the\n * developer's account, as this agent's line: the owner and anyone who\n * texts it each get their own box and thread. Safe to repeat with the\n * same config. Remove any Hub event subscription on the connection first;\n * Hub refuses a line that another route would also answer.\n */\n async attachLine(connectionId: string): Promise<LineAttachment> {\n const line = await sandbox.lines.fromConnection({ connectionId, transport: 'imessage', clientReference: 'hosted-agent' })\n return sandbox.lines.attach({\n number: line.id,\n mode: 'shared',\n members: [{ address: config.owner, role: 'owner' }],\n unknownSenders: 'guest',\n roles: { owner: PERSON, guest: PERSON },\n respond: { kind: 'agent', backend },\n limits: { turnsPerMemberPerDay: config.freeTurnsPerDay ?? 20 },\n instance: { keyPrefix: PERSON_KEY_PREFIX, create },\n clientReference: 'hosted-agent',\n })\n },\n\n /**\n * ph0ny's call hook. `admit` returns a call token bound to the caller's\n * number from the carrier's signed call record, never from the model.\n */\n async voiceHook(request: Request): Promise<Response> {\n const secret = config.voiceSecret\n if (!secret || !await equal(request.headers.get('authorization'), `Bearer ${secret}`)) return json({ error: 'unauthorized' }, 401)\n const body = await request.json().catch(() => null) as { event?: string; phone?: string } | null\n if (body?.event !== 'admit') return json({ ok: true })\n const phone = body.phone?.trim() ?? ''\n if (!E164.test(phone)) return json({ admit: false, say: 'Sorry, I can only take calls from a visible number.' })\n // ph0ny accepts only an opaque token matching ^[A-Za-z0-9_-]{16,128}$,\n // so the caller's number stays here and the token is its random key.\n const callToken = randomToken()\n await voiceStore().put(`vcall:${callToken}`, JSON.stringify({ phone, exp: Date.now() + CALL_TTL_MS } satisfies Call),\n { expirationTtl: CALL_TTL_MS / 1000 })\n return json({ admit: true, callToken })\n },\n\n /**\n * ph0ny's `ask_workspace` webhook tool, configured with `forwardCallToken`.\n * A slow turn returns a ticket; the voice agent asks again with it.\n */\n async voiceAsk(request: Request): Promise<Response> {\n const secret = config.voiceSecret\n if (!secret || !await equal(request.headers.get('authorization'), `Bearer ${secret}`)) return json({ error: 'unauthorized' }, 401)\n const token = request.headers.get('x-voice-call-token') ?? ''\n const store = voiceStore()\n const admitted = /^[A-Za-z0-9_-]{16,128}$/.test(token) ? await store.get(`vcall:${token}`) : null\n if (!admitted) return json({ status: 'error', error: 'call_not_admitted' }, 403)\n const call = JSON.parse(admitted) as Call\n if (call.exp < Date.now()) return json({ status: 'error', error: 'call_expired' }, 403)\n const body = await request.json().catch(() => null) as { utterance?: string; ticket?: string } | null\n // A ticket carries its own turn id and question, so a retry after a\n // slow first step still settles exactly one turn.\n const [turnId, asked] = body?.ticket ? body.ticket.split('.') : [`v-${crypto.randomUUID()}`, b64(body?.utterance ?? '')]\n if (!turnId || !asked) return json({ status: 'error', error: 'missing_utterance' }, 400)\n try {\n if (!call.session) {\n call.session = await callSession(call.phone)\n await store.put(`vcall:${token}`, JSON.stringify(call), { expirationTtl: Math.max(60, Math.ceil((call.exp - Date.now()) / 1000)) })\n }\n // ph0ny drops the tool call at its own timeout, and resuming an idle\n // box can take longer than the budget. Answer 'pending' at the\n // budget regardless; every step is idempotent by turn id, so the\n // ticket call continues where this one stopped.\n const budgetMs = config.voiceBudgetMs ?? 8000\n const result = await Promise.race([\n answerCall(call, unb64(asked), turnId, Date.now() + budgetMs),\n sleep(budgetMs).then((): AskResult => ({ state: 'pending' })),\n ])\n if (result.state === 'pending') return json({ status: 'pending', ticket: `${turnId}.${asked}` })\n if (result.state === 'declined') return json({ status: 'complete', answer: result.reply ?? 'I cannot answer that right now.' })\n return json({ status: 'complete', answer: result.text })\n } catch (error) {\n console.error(`[hosted-agent] voice turn=${turnId} ${describe(error)}`)\n return json({ status: 'error', error: 'agent_unavailable' }, 502)\n }\n },\n }\n}\n\nexport type HostedAgent = ReturnType<typeof createHostedAgent>\n","import type { BackendConfig, CreateSessionOptions } from '@tangle-network/sandbox'\nimport type { ConversationTurnOptions, ConversationTurnResult, PromptInputPart } from '@tangle-network/sandbox/core'\n\n/**\n * The one turn engine for a hosted agent, whatever hosts it. This kit's\n * `createHostedAgent` runs on it with a key-value store, and Agent Builder\n * runs on it with its D1 enrollments, per-user keys, spaces and product\n * ingest. A host supplies those as ports; the engine owns the order of the\n * steps and what each outcome and failure means.\n *\n * One call is one non-blocking pass for one message:\n *\n * validate -> admit once -> the person's box -> authorize -> session -> drive -> classify\n *\n * Every step is idempotent by the turn id: the sandbox's admission receipt\n * settles a repeated drive on the same turn, so a host may repeat a pass for\n * the same message any number of times and the agent runs it once.\n */\n\nexport type TurnRefusal = 'not_enrolled' | 'execution_not_ready' | 'refused' | 'unavailable'\nexport type TurnReply = { ok: true; text: string } | { ok: false; reason: TurnRefusal; detail?: string }\nexport type TurnStep = 'ensure' | 'drive' | 'turn'\n/**\n * Where an unfinished turn stands. `code` is the same on every repeat of the\n * same cause, so a host can tell a stuck turn from a slow one. A transient\n * cause (a lost response, a timeout, a busy platform, a box still starting)\n * is expected to clear by itself; any other repeats until it is fixed.\n */\nexport interface PendingDetail { step: TurnStep; code: string; transient: boolean }\n/** `cause` is what a port or the Sandbox threw, when a throw left the turn pending. */\nexport type TurnOutcome = TurnReply | { ok: 'pending'; detail: PendingDetail; cause?: unknown }\n\nexport const MAX_MESSAGE_CHARS = 8000\n/** Identical non-transient failures in a row after which a host fails a turn. */\nexport const MAX_REPEATED_FAILURES = 3\n/** Refusal detail when the host's `authorize` check fails. */\nexport const AUTHORITY_ENDED = 'authority_ended'\n\n/** The part of a Sandbox box one turn uses. `SandboxInstance` satisfies it. */\nexport interface TurnBox {\n readonly id: string\n driveConversationTurn(message: string | PromptInputPart[], options: ConversationTurnOptions): Promise<ConversationTurnResult>\n session(id: string): { status(): Promise<unknown> }\n createSession(options: CreateSessionOptions): Promise<unknown>\n}\n\n/** A port throws this to report a turn that is not ready yet, such as a box still starting. */\nexport class TurnPending extends Error {\n constructor(readonly code: string, readonly transient: boolean) {\n super(code)\n this.name = 'TurnPending'\n }\n}\n\n/** A thrown value as a stable code plus a secret-free message for a log line. */\nexport interface TurnFailure { name: string; code: string; status?: number; reason?: string; message: string }\n\n/** What a pass reports while it runs. Every hook is optional; none changes the outcome. */\nexport interface TurnObserver {\n /** Time one step of this pass took, whether it returned or threw. */\n span?(step: 'ensure' | 'session' | 'drive', ms: number): void\n /** The session the turn runs in, as the runtime reports it, or the backend it was created with. */\n session?(info: { harness?: string; model?: string; created: boolean }): void\n /**\n * Read a session this pass created once more, so `session` reports the\n * harness and model the runtime chose for it. It costs one request, so a\n * host asks for it only when it shows them (an owner's debug line, say).\n */\n readCreatedSession?: boolean\n /** Each drive result and the time it arrived. */\n drive?(result: ConversationTurnResult, at: number): void\n /** A failure that left the turn pending or refused. */\n failure?(step: TurnStep, failure: TurnFailure, boxId?: string): void | Promise<void>\n}\n\n/** Storage and policy a host supplies for one message. */\nexport interface HostedTurnPorts<Box extends TurnBox = TurnBox> {\n /**\n * Admit this turn once, before any compute. Return null to run it, or the\n * refusal. A turn admitted by an earlier pass must return null again, so a\n * retry never consumes allowance twice.\n */\n admit(): Promise<Extract<TurnReply, { ok: false }> | null>\n /** The person's running box: created, resumed or replaced as needed. Throw to report why not. */\n box(): Promise<Box>\n /** The host's own authority (a phone link, say), read after the box is ready and right before the drive. */\n authorize?(): Promise<boolean>\n /** The session this turn runs in. A session binds its backend when created, so a new persona needs a new id. */\n sessionId: string\n /** The backend a missing session is created with. Without it the runtime answers as its generic assistant. */\n backend(): Promise<BackendConfig>\n /** The message as the agent receives it: text, or text and images. */\n prompt(box: Box): Promise<string | PromptInputPart[]>\n /** Runs once the turn has a non-empty answer, before the pass returns it. */\n answered?(answer: string, box: Box): Promise<void>\n /** Map a thrown value to an outcome. Undefined leaves it to the engine's own classification. */\n classify?(error: unknown, step: 'ensure' | 'drive'): TurnOutcome | undefined\n observe?: TurnObserver\n}\n\nexport interface HostedTurnOptions {\n /** The run's wall-clock cap; the Sandbox cancels a turn that exceeds it. */\n wallCapMs: number\n /** How long one drive request may wait on a running turn. Default 8 s. */\n timeoutMs?: number\n /** False only reads a turn admitted earlier; it never admits or starts one. */\n allowDispatch?: boolean\n /** Keep driving a running turn until this time, instead of returning pending after one drive. */\n until?: number\n /** Wait between drives while `until` allows. Default 2 s. */\n pollMs?: number\n now?: () => number\n sleep?: (ms: number) => Promise<void>\n}\n\n/** Harness and model as the runtime reports them; the model it selected after normalization wins. */\nfunction sessionInfo(session: unknown): { harness?: string; model?: string } {\n const s = (typeof session === 'object' && session !== null ? session : {}) as\n { backend?: unknown; model?: unknown; effectiveBackend?: { model?: unknown } | null }\n const effective = s.effectiveBackend?.model\n const model = typeof effective === 'string' && effective ? effective : typeof s.model === 'string' && s.model ? s.model : undefined\n return { ...(typeof s.backend === 'string' && s.backend ? { harness: s.backend } : {}), ...(model ? { model } : {}) }\n}\n\nconst refusal = (reason: TurnRefusal, detail?: string): Extract<TurnReply, { ok: false }> => ({ ok: false, reason, ...(detail ? { detail } : {}) })\nconst RUNNING = new Set(['running', 'awaiting_input', 'not_admitted'])\nconst TRANSIENT_ERRORS = new Set(['NetworkError', 'TimeoutError', 'QuotaError', 'AbortError', 'EdgeNotReadyError'])\n/**\n * The 503 codes the Sandbox SDK itself classifies as refused before dispatch\n * and safe to retry. A 503 with any other code, such as\n * HUB_CREDENTIAL_REFRESH_FAILED, has not shown it will clear by itself.\n */\nconst TRANSIENT_503 = new Set(['MODEL_CREDENTIAL_SUPERSEDED', 'HUB_CREDENTIAL_REFRESH_UNAVAILABLE'])\n\n/** The code omits the message, which can carry ids or times that differ on every repeat of one cause. */\nexport function describeFailure(error: unknown): TurnFailure {\n const e = (typeof error === 'object' && error !== null ? error : {}) as Record<string, unknown>\n const name = typeof e.name === 'string' ? e.name : typeof error\n const status = typeof e.status === 'number' ? e.status : undefined\n const own = typeof e.code === 'string' ? e.code : typeof e.reason === 'string' ? e.reason : undefined\n const code = [name, own, status].filter(part => part !== undefined).join(':').replace(/[^\\w.:-]/g, '_').slice(0, 100)\n const message = String(typeof e.message === 'string' ? e.message : error)\n .replace(/sk-tan-[\\w-]+|eyJ[\\w-]+\\.[\\w-]+\\.[\\w-]*|Bearer\\s+\\S+/g, '<redacted>').slice(0, 400)\n return { name, code, ...(status !== undefined ? { status } : {}), ...(own !== undefined ? { reason: own } : {}), message }\n}\n\nexport function isTransientFailure(failure: Pick<TurnFailure, 'name' | 'status' | 'reason'>): boolean {\n return TRANSIENT_ERRORS.has(failure.name) || [408, 429, 502, 504].includes(failure.status ?? 0) ||\n (failure.status === 503 && (failure.reason === undefined || TRANSIENT_503.has(failure.reason)))\n}\n\n/**\n * The platform refuses a funded request once the paying key's budget is\n * spent. That refusal is decided, so retrying it would loop forever.\n */\nconst budgetExhausted = (error: unknown) => error instanceof Error && error.name === 'KeyBudgetExhaustedError'\n\n/** One non-blocking pass for one message. Safe to repeat with the same turn id. */\nexport async function runHostedTurn<Box extends TurnBox>(turn: { turnId: string; text: string }, ports: HostedTurnPorts<Box>,\n options: HostedTurnOptions): Promise<TurnOutcome> {\n const now = options.now ?? Date.now\n const sleep = options.sleep ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)))\n const observe = ports.observe ?? {}\n const allowDispatch = options.allowDispatch !== false\n const text = turn.text.trim()\n if (!text || text.length > MAX_MESSAGE_CHARS || !turn.turnId || turn.turnId.length > 200) return refusal('refused', 'invalid_message')\n const refused = await ports.admit()\n if (refused) return refused\n\n const timed = async <T>(step: 'ensure' | 'session' | 'drive', fn: () => Promise<T>): Promise<T> => {\n const start = now()\n try { return await fn() } finally { observe.span?.(step, now() - start) }\n }\n const failed = async (step: 'ensure' | 'drive', error: unknown, boxId?: string): Promise<TurnOutcome> => {\n const failure = describeFailure(error)\n await observe.failure?.(step, failure, boxId)\n const classified = ports.classify?.(error, step)\n if (classified) return classified\n if (error instanceof TurnPending) return { ok: 'pending', detail: { step, code: error.code, transient: error.transient } }\n if (budgetExhausted(error)) return refusal('refused', 'budget_exhausted')\n // An admission whose response was lost is retried with the same turn id;\n // the runtime's admission receipt prevents a second execution.\n return { ok: 'pending', detail: { step, code: failure.code, transient: isTransientFailure(failure) }, cause: error }\n }\n\n let box: Box\n try {\n box = await timed('ensure', () => ports.box())\n } catch (error) {\n return failed('ensure', error)\n }\n if (ports.authorize && !await ports.authorize()) return refusal('refused', AUTHORITY_ENDED)\n\n let result: ConversationTurnResult\n try {\n const prompt = await ports.prompt(box)\n const sessionId = ports.sessionId\n // The SDK would create a missing session with no profile, and the runtime\n // would then answer as its generic default assistant.\n if (allowDispatch) await timed('session', async () => {\n const session = await box.session(sessionId).status()\n if (session) {\n observe.session?.({ created: false, ...sessionInfo(session) })\n return\n }\n const backend = await ports.backend()\n await box.createSession({ sessionId, retention: 'workspace', backend })\n // A new session names its harness and model only once the runtime has created it.\n const created = observe.session && observe.readCreatedSession ? sessionInfo(await box.session(sessionId).status()) : {}\n observe.session?.({ created: true, ...(backend.type ? { harness: backend.type } : {}), ...created })\n })\n for (;;) {\n result = await timed('drive', () => box.driveConversationTurn(prompt, { sessionId, turnId: turn.turnId,\n wallCapMs: options.wallCapMs, timeoutMs: options.timeoutMs ?? 8000, allowDispatch }))\n observe.drive?.(result, now())\n const pollMs = options.pollMs ?? 2000\n if (!RUNNING.has(result.state) || options.until === undefined || now() + pollMs > options.until) break\n await sleep(pollMs)\n }\n } catch (error) {\n return failed('drive', error, box.id)\n }\n switch (result.state) {\n case 'completed': {\n const answer = result.text.trim()\n if (!answer) {\n await observe.failure?.('turn', describeFailure(new Error('The turn completed with no reply text')), box.id)\n return refusal('unavailable', 'empty_reply')\n }\n await ports.answered?.(answer, box)\n return { ok: true, text: answer }\n }\n case 'failed':\n await observe.failure?.('turn', describeFailure(Object.assign(new Error(result.error), { name: 'TurnFailed' })), box.id)\n return refusal('unavailable', 'agent_failed')\n // Interaction and plan approval are not exposed over messaging. The wall\n // cap cancels a turn that waits on one, which frees the session.\n // Sandbox 0.45 reported approval and question outcomes as `failed`.\n case 'awaiting_plan_decision': case 'blocked_on_approval': case 'awaiting_question': case 'awaiting_interaction':\n return refusal('refused', 'needs_decision')\n case 'running': case 'awaiting_input': case 'not_admitted':\n return { ok: 'pending', detail: { step: 'turn', code: result.state, transient: true } }\n }\n}\n\n/**\n * Repeat passes until the turn settles, for a caller that holds a connection\n * open (a voice question, say). A turn the runtime never finishes must not\n * hold the caller forever, so `deadline` bounds the wait; `signal` stops the\n * wait only, and the admitted turn keeps running.\n */\nexport async function settleHostedTurn(pass: () => Promise<TurnOutcome>, options: {\n deadline: number\n pollMs?: number\n signal?: AbortSignal\n now?: () => number\n sleep?: (ms: number, signal?: AbortSignal) => Promise<void>\n}): Promise<TurnReply> {\n const now = options.now ?? Date.now\n const sleep = options.sleep ?? ((ms: number, signal?: AbortSignal) => new Promise<void>((resolve, reject) => {\n const timer = setTimeout(resolve, ms)\n signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason) }, { once: true })\n }))\n let last = '', repeats = 0\n for (;;) {\n if (options.signal?.aborted || now() > options.deadline) return refusal('unavailable', 'pending')\n const outcome = await pass()\n if (outcome.ok !== 'pending') return outcome\n repeats = outcome.detail.code === last ? repeats + 1 : 1\n last = outcome.detail.code\n if (!outcome.detail.transient && repeats >= MAX_REPEATED_FAILURES) return refusal('unavailable', `${outcome.detail.step}:${last}`)\n try { await sleep(options.pollMs ?? 1500, options.signal) } catch { return refusal('unavailable', 'pending') }\n }\n}\n\n/**\n * A space member who may chat only: no shell and no file writes in the shared\n * box. The profile's other settings are kept.\n */\nexport function chatOnlyBackend(backend: BackendConfig): BackendConfig {\n const profile = backend.profile ?? {}\n return { ...backend, profile: { ...profile,\n tools: { ...profile.tools, bash: false, edit: false, write: false },\n permissions: { ...profile.permissions, bash: 'deny', edit: 'deny' } } }\n}\n"],"mappings":"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","names":["sleep"]}
1
+ {"version":3,"sources":["../../src/hosted-agent/index.ts"],"sourcesContent":["import type { AgentProfile, BackendConfig, LineVoiceOptions } from '@tangle-network/sandbox'\nimport { type Line, type LineInstanceCreate, Sandbox } from '@tangle-network/sandbox/core'\n\n/**\n * A hosted agent: people text or call a line, and each person is answered\n * from their own isolated sandbox. The developer's Tangle API key pays for\n * every box, model turn, reply and call.\n *\n * The platform does the work. {@link HostedAgent.attachLine} attaches the\n * line to Tangle Hub with one sandbox per person: Hub routes each text and\n * call to the person's box (the named instance {@link PERSON_KEY_PREFIX} plus\n * a hash of their number), keeps one thread per person, handles STOP and\n * START, counts each person's texts per day, and sends the reply. A call on\n * the line runs in the same box and thread, so text and voice share one memory.\n */\n\nexport interface HostedAgentConfig {\n /** The developer's Tangle API key. */\n apiKey: string\n /**\n * The persona every person's box runs. A profile without `model.default`\n * runs {@link DEFAULT_HOSTED_MODEL}, and a profile without `tools` runs with\n * {@link CONVERSATION_TOOLS_OFF} turned off. Set `tools` to choose your own,\n * for example on a harness that cannot turn those tools off.\n */\n profile: AgentProfile\n /** Backend harness type, such as `opencode`; the runtime default when omitted. */\n harness?: string\n /** The owner's own phone (E.164), the line's first member. */\n owner: string\n /** Texts Hub answers per person per UTC day. Default 20. */\n freeTurnsPerDay?: number\n box?: Partial<BoxPolicy>\n sandboxUrl?: string\n}\n\nexport interface BoxPolicy {\n cpuCores: number\n memoryMB: number\n diskGB: number\n idleTimeoutSeconds: number\n maxLifetimeSeconds: number\n deleteAfterStoppedSeconds: number\n /** Egress allow-list. The default reaches the model router only. */\n allowDomains: string[]\n}\n\n/**\n * Two cores and a 2 GB disk cost what one core and 10 GB cost: both bill the\n * platform's hourly floor. A person's box starts OpenCode on their first text\n * and after every idle stop, and that start is CPU-bound: on one core it took\n * 6.1 s after a resume and 8.4 s on a new box, on two cores 3.5 s and 3.4 s\n * (production, 2026-09-24). A disk no larger than the platform's warm seed\n * lets a new person's box be claimed from the warm pool: create took 2.1-2.7 s\n * instead of 5.3-7.0 s (2026-09-25).\n */\nexport const DEFAULT_BOX_POLICY: BoxPolicy = {\n cpuCores: 2, memoryMB: 2048, diskGB: 2,\n idleTimeoutSeconds: 600, maxLifetimeSeconds: 86_400, deleteAfterStoppedSeconds: 7 * 86_400,\n allowDomains: ['router.tangle.tools'],\n}\n\n/**\n * Each person's box is the developer's named instance with this prefix. It is\n * the key this kit used before Hub routed its texts, so every existing\n * person keeps their box.\n */\nexport const PERSON_KEY_PREFIX = 'hosted:'\n\n/**\n * Harness tools a texting or calling assistant does not use. Their\n * descriptions present every turn as coding work: a shell, file search,\n * sub-agents, to-do lists, skills and web fetch. File read, write and edit\n * stay, so a persona can keep notes such as `memory.md`.\n */\nexport const CONVERSATION_TOOLS_OFF = ['bash', 'glob', 'grep', 'task', 'todowrite', 'webfetch', 'skill'] as const\n\n/**\n * The model for a profile without `model.default`. It gave the most useful\n * on-topic replies among four Router models on the same five texts and calls\n * (2026-09-23), within the latency of the others.\n */\nexport const DEFAULT_HOSTED_MODEL = 'openai/gpt-5.6-luna'\n\n/** The profile a person's box runs: the developer's profile over the conversation defaults. */\nfunction conversationProfile(profile: AgentProfile): AgentProfile {\n return {\n ...profile,\n model: { ...profile.model, default: profile.model?.default ?? DEFAULT_HOSTED_MODEL },\n // A profile that sets `tools` owns its tool set.\n ...(profile.tools ? {} : {\n tools: Object.fromEntries(CONVERSATION_TOOLS_OFF.map(tool => [tool, false])),\n // The sandbox's preview policy grants the shell unless its permission\n // is denied, so turning the tool off alone leaves the shell in place.\n permissions: { bash: 'deny' as const, ...profile.permissions },\n }),\n }\n}\n\n/** Each person runs in their own box, so no member shares a disk and each may use the persona's tools. */\nconst PERSON = { context: 'own', tools: 'act' } as const\nconst E164 = /^\\+[1-9]\\d{6,14}$/\n\nexport class HostedAgentError extends Error {\n constructor(readonly code: string, message: string) { super(message); this.name = 'HostedAgentError' }\n}\n\nexport function createHostedAgent(config: HostedAgentConfig) {\n if (!E164.test(config.owner)) throw new HostedAgentError('owner_not_e164', 'owner must be an E.164 phone number, such as +15550100001.')\n const policy = { ...DEFAULT_BOX_POLICY, ...config.box }\n const sandbox = new Sandbox({ apiKey: config.apiKey, baseUrl: config.sandboxUrl ?? 'https://sandbox.tangle.tools', timeoutMs: 20_000 })\n const backend: BackendConfig = { ...(config.harness ? { type: config.harness as BackendConfig['type'] } : {}), profile: conversationProfile(config.profile) }\n const create: LineInstanceCreate = {\n name: 'hosted-person',\n resources: { cpuCores: policy.cpuCores, memoryMB: policy.memoryMB, diskGB: policy.diskGB },\n egressPolicy: { mode: 'strict', allowDomains: policy.allowDomains, includeImplicitDomains: false },\n idleTimeoutSeconds: policy.idleTimeoutSeconds, maxLifetimeSeconds: policy.maxLifetimeSeconds,\n deleteAfterStoppedSeconds: policy.deleteAfterStoppedSeconds,\n }\n\n return {\n /**\n * Attach an Inkbox iMessage identity, connected to Hub under the\n * developer's account, as this agent's line: the owner and anyone who\n * texts it each get their own box and thread. With `voice`, calls to the\n * line reach the caller's box and thread through that ph0ny agent; Hub\n * admits only members, so a caller texts once before calling. Safe to\n * repeat with the same config. Remove any Hub event subscription on the\n * connection first; Hub refuses a line that another route would also answer.\n */\n async attachLine(connectionId: string, options: { voice?: LineVoiceOptions } = {}): Promise<Line> {\n const line = await sandbox.lines.fromConnection({ connectionId, transport: 'imessage', clientReference: 'hosted-agent' })\n await sandbox.lines.attach({\n number: line.id,\n mode: 'shared',\n members: [{ address: config.owner, role: 'owner' }],\n unknownSenders: 'guest',\n roles: { owner: PERSON, guest: PERSON },\n respond: { kind: 'agent', backend },\n limits: { turnsPerMemberPerDay: config.freeTurnsPerDay ?? 20 },\n instance: { keyPrefix: PERSON_KEY_PREFIX, create },\n clientReference: 'hosted-agent',\n })\n if (options.voice) await sandbox.lines.enableVoice(line.id, options.voice)\n return sandbox.lines.get(line.id)\n },\n }\n}\n\nexport type HostedAgent = ReturnType<typeof createHostedAgent>\n"],"mappings":";AACA,SAA6C,eAAe;AAuDrD,IAAM,qBAAgC;AAAA,EAC3C,UAAU;AAAA,EAAG,UAAU;AAAA,EAAM,QAAQ;AAAA,EACrC,oBAAoB;AAAA,EAAK,oBAAoB;AAAA,EAAQ,2BAA2B,IAAI;AAAA,EACpF,cAAc,CAAC,qBAAqB;AACtC;AAOO,IAAM,oBAAoB;AAQ1B,IAAM,yBAAyB,CAAC,QAAQ,QAAQ,QAAQ,QAAQ,aAAa,YAAY,OAAO;AAOhG,IAAM,uBAAuB;AAGpC,SAAS,oBAAoB,SAAqC;AAChE,SAAO;AAAA,IACL,GAAG;AAAA,IACH,OAAO,EAAE,GAAG,QAAQ,OAAO,SAAS,QAAQ,OAAO,WAAW,qBAAqB;AAAA;AAAA,IAEnF,GAAI,QAAQ,QAAQ,CAAC,IAAI;AAAA,MACvB,OAAO,OAAO,YAAY,uBAAuB,IAAI,UAAQ,CAAC,MAAM,KAAK,CAAC,CAAC;AAAA;AAAA;AAAA,MAG3E,aAAa,EAAE,MAAM,QAAiB,GAAG,QAAQ,YAAY;AAAA,IAC/D;AAAA,EACF;AACF;AAGA,IAAM,SAAS,EAAE,SAAS,OAAO,OAAO,MAAM;AAC9C,IAAM,OAAO;AAEN,IAAM,mBAAN,cAA+B,MAAM;AAAA,EAC1C,YAAqB,MAAc,SAAiB;AAAE,UAAM,OAAO;AAA9C;AAAiD,SAAK,OAAO;AAAA,EAAmB;AAAA,EAAhF;AACvB;AAEO,SAAS,kBAAkB,QAA2B;AAC3D,MAAI,CAAC,KAAK,KAAK,OAAO,KAAK,EAAG,OAAM,IAAI,iBAAiB,kBAAkB,4DAA4D;AACvI,QAAM,SAAS,EAAE,GAAG,oBAAoB,GAAG,OAAO,IAAI;AACtD,QAAM,UAAU,IAAI,QAAQ,EAAE,QAAQ,OAAO,QAAQ,SAAS,OAAO,cAAc,gCAAgC,WAAW,IAAO,CAAC;AACtI,QAAM,UAAyB,EAAE,GAAI,OAAO,UAAU,EAAE,MAAM,OAAO,QAAiC,IAAI,CAAC,GAAI,SAAS,oBAAoB,OAAO,OAAO,EAAE;AAC5J,QAAM,SAA6B;AAAA,IACjC,MAAM;AAAA,IACN,WAAW,EAAE,UAAU,OAAO,UAAU,UAAU,OAAO,UAAU,QAAQ,OAAO,OAAO;AAAA,IACzF,cAAc,EAAE,MAAM,UAAU,cAAc,OAAO,cAAc,wBAAwB,MAAM;AAAA,IACjG,oBAAoB,OAAO;AAAA,IAAoB,oBAAoB,OAAO;AAAA,IAC1E,2BAA2B,OAAO;AAAA,EACpC;AAEA,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAUL,MAAM,WAAW,cAAsB,UAAwC,CAAC,GAAkB;AAChG,YAAM,OAAO,MAAM,QAAQ,MAAM,eAAe,EAAE,cAAc,WAAW,YAAY,iBAAiB,eAAe,CAAC;AACxH,YAAM,QAAQ,MAAM,OAAO;AAAA,QACzB,QAAQ,KAAK;AAAA,QACb,MAAM;AAAA,QACN,SAAS,CAAC,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ,CAAC;AAAA,QAClD,gBAAgB;AAAA,QAChB,OAAO,EAAE,OAAO,QAAQ,OAAO,OAAO;AAAA,QACtC,SAAS,EAAE,MAAM,SAAS,QAAQ;AAAA,QAClC,QAAQ,EAAE,sBAAsB,OAAO,mBAAmB,GAAG;AAAA,QAC7D,UAAU,EAAE,WAAW,mBAAmB,OAAO;AAAA,QACjD,iBAAiB;AAAA,MACnB,CAAC;AACD,UAAI,QAAQ,MAAO,OAAM,QAAQ,MAAM,YAAY,KAAK,IAAI,QAAQ,KAAK;AACzE,aAAO,QAAQ,MAAM,IAAI,KAAK,EAAE;AAAA,IAClC;AAAA,EACF;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-app",
3
- "version": "0.49.20",
3
+ "version": "0.49.21",
4
4
  "packageManager": "pnpm@11.24.0",
5
5
  "description": "Build agent applications with typed chat, tools, sandboxes, integrations, billing, and evaluation.",
6
6
  "keywords": [
@@ -1,159 +0,0 @@
1
- import type { BackendConfig, CreateSessionOptions } from '@tangle-network/sandbox';
2
- import type { ConversationTurnOptions, ConversationTurnResult, PromptInputPart } from '@tangle-network/sandbox/core';
3
- /**
4
- * The one turn engine for a hosted agent, whatever hosts it. This kit's
5
- * `createHostedAgent` runs on it with a key-value store, and Agent Builder
6
- * runs on it with its D1 enrollments, per-user keys, spaces and product
7
- * ingest. A host supplies those as ports; the engine owns the order of the
8
- * steps and what each outcome and failure means.
9
- *
10
- * One call is one non-blocking pass for one message:
11
- *
12
- * validate -> admit once -> the person's box -> authorize -> session -> drive -> classify
13
- *
14
- * Every step is idempotent by the turn id: the sandbox's admission receipt
15
- * settles a repeated drive on the same turn, so a host may repeat a pass for
16
- * the same message any number of times and the agent runs it once.
17
- */
18
- export type TurnRefusal = 'not_enrolled' | 'execution_not_ready' | 'refused' | 'unavailable';
19
- export type TurnReply = {
20
- ok: true;
21
- text: string;
22
- } | {
23
- ok: false;
24
- reason: TurnRefusal;
25
- detail?: string;
26
- };
27
- export type TurnStep = 'ensure' | 'drive' | 'turn';
28
- /**
29
- * Where an unfinished turn stands. `code` is the same on every repeat of the
30
- * same cause, so a host can tell a stuck turn from a slow one. A transient
31
- * cause (a lost response, a timeout, a busy platform, a box still starting)
32
- * is expected to clear by itself; any other repeats until it is fixed.
33
- */
34
- export interface PendingDetail {
35
- step: TurnStep;
36
- code: string;
37
- transient: boolean;
38
- }
39
- /** `cause` is what a port or the Sandbox threw, when a throw left the turn pending. */
40
- export type TurnOutcome = TurnReply | {
41
- ok: 'pending';
42
- detail: PendingDetail;
43
- cause?: unknown;
44
- };
45
- export declare const MAX_MESSAGE_CHARS = 8000;
46
- /** Identical non-transient failures in a row after which a host fails a turn. */
47
- export declare const MAX_REPEATED_FAILURES = 3;
48
- /** Refusal detail when the host's `authorize` check fails. */
49
- export declare const AUTHORITY_ENDED = "authority_ended";
50
- /** The part of a Sandbox box one turn uses. `SandboxInstance` satisfies it. */
51
- export interface TurnBox {
52
- readonly id: string;
53
- driveConversationTurn(message: string | PromptInputPart[], options: ConversationTurnOptions): Promise<ConversationTurnResult>;
54
- session(id: string): {
55
- status(): Promise<unknown>;
56
- };
57
- createSession(options: CreateSessionOptions): Promise<unknown>;
58
- }
59
- /** A port throws this to report a turn that is not ready yet, such as a box still starting. */
60
- export declare class TurnPending extends Error {
61
- readonly code: string;
62
- readonly transient: boolean;
63
- constructor(code: string, transient: boolean);
64
- }
65
- /** A thrown value as a stable code plus a secret-free message for a log line. */
66
- export interface TurnFailure {
67
- name: string;
68
- code: string;
69
- status?: number;
70
- reason?: string;
71
- message: string;
72
- }
73
- /** What a pass reports while it runs. Every hook is optional; none changes the outcome. */
74
- export interface TurnObserver {
75
- /** Time one step of this pass took, whether it returned or threw. */
76
- span?(step: 'ensure' | 'session' | 'drive', ms: number): void;
77
- /** The session the turn runs in, as the runtime reports it, or the backend it was created with. */
78
- session?(info: {
79
- harness?: string;
80
- model?: string;
81
- created: boolean;
82
- }): void;
83
- /**
84
- * Read a session this pass created once more, so `session` reports the
85
- * harness and model the runtime chose for it. It costs one request, so a
86
- * host asks for it only when it shows them (an owner's debug line, say).
87
- */
88
- readCreatedSession?: boolean;
89
- /** Each drive result and the time it arrived. */
90
- drive?(result: ConversationTurnResult, at: number): void;
91
- /** A failure that left the turn pending or refused. */
92
- failure?(step: TurnStep, failure: TurnFailure, boxId?: string): void | Promise<void>;
93
- }
94
- /** Storage and policy a host supplies for one message. */
95
- export interface HostedTurnPorts<Box extends TurnBox = TurnBox> {
96
- /**
97
- * Admit this turn once, before any compute. Return null to run it, or the
98
- * refusal. A turn admitted by an earlier pass must return null again, so a
99
- * retry never consumes allowance twice.
100
- */
101
- admit(): Promise<Extract<TurnReply, {
102
- ok: false;
103
- }> | null>;
104
- /** The person's running box: created, resumed or replaced as needed. Throw to report why not. */
105
- box(): Promise<Box>;
106
- /** The host's own authority (a phone link, say), read after the box is ready and right before the drive. */
107
- authorize?(): Promise<boolean>;
108
- /** The session this turn runs in. A session binds its backend when created, so a new persona needs a new id. */
109
- sessionId: string;
110
- /** The backend a missing session is created with. Without it the runtime answers as its generic assistant. */
111
- backend(): Promise<BackendConfig>;
112
- /** The message as the agent receives it: text, or text and images. */
113
- prompt(box: Box): Promise<string | PromptInputPart[]>;
114
- /** Runs once the turn has a non-empty answer, before the pass returns it. */
115
- answered?(answer: string, box: Box): Promise<void>;
116
- /** Map a thrown value to an outcome. Undefined leaves it to the engine's own classification. */
117
- classify?(error: unknown, step: 'ensure' | 'drive'): TurnOutcome | undefined;
118
- observe?: TurnObserver;
119
- }
120
- export interface HostedTurnOptions {
121
- /** The run's wall-clock cap; the Sandbox cancels a turn that exceeds it. */
122
- wallCapMs: number;
123
- /** How long one drive request may wait on a running turn. Default 8 s. */
124
- timeoutMs?: number;
125
- /** False only reads a turn admitted earlier; it never admits or starts one. */
126
- allowDispatch?: boolean;
127
- /** Keep driving a running turn until this time, instead of returning pending after one drive. */
128
- until?: number;
129
- /** Wait between drives while `until` allows. Default 2 s. */
130
- pollMs?: number;
131
- now?: () => number;
132
- sleep?: (ms: number) => Promise<void>;
133
- }
134
- /** The code omits the message, which can carry ids or times that differ on every repeat of one cause. */
135
- export declare function describeFailure(error: unknown): TurnFailure;
136
- export declare function isTransientFailure(failure: Pick<TurnFailure, 'name' | 'status' | 'reason'>): boolean;
137
- /** One non-blocking pass for one message. Safe to repeat with the same turn id. */
138
- export declare function runHostedTurn<Box extends TurnBox>(turn: {
139
- turnId: string;
140
- text: string;
141
- }, ports: HostedTurnPorts<Box>, options: HostedTurnOptions): Promise<TurnOutcome>;
142
- /**
143
- * Repeat passes until the turn settles, for a caller that holds a connection
144
- * open (a voice question, say). A turn the runtime never finishes must not
145
- * hold the caller forever, so `deadline` bounds the wait; `signal` stops the
146
- * wait only, and the admitted turn keeps running.
147
- */
148
- export declare function settleHostedTurn(pass: () => Promise<TurnOutcome>, options: {
149
- deadline: number;
150
- pollMs?: number;
151
- signal?: AbortSignal;
152
- now?: () => number;
153
- sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
154
- }): Promise<TurnReply>;
155
- /**
156
- * A space member who may chat only: no shell and no file writes in the shared
157
- * box. The profile's other settings are kept.
158
- */
159
- export declare function chatOnlyBackend(backend: BackendConfig): BackendConfig;