@tangle-network/agent-app 0.49.9 → 0.49.11
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/dist/chat-routes/index.js +1 -1
- package/dist/{chunk-7S24DNVP.js → chunk-J42REVQJ.js} +1 -1
- package/dist/chunk-J42REVQJ.js.map +1 -0
- package/dist/hosted-agent/engine.d.ts +159 -0
- package/dist/hosted-agent/index.d.ts +1 -0
- package/dist/hosted-agent/index.js +233 -38
- package/dist/hosted-agent/index.js.map +1 -1
- package/dist/preset-cloudflare/index.js +10 -1
- package/dist/preset-cloudflare/index.js.map +1 -1
- package/dist/sandbox/index.js +1 -1
- package/package.json +11 -11
- package/dist/chunk-7S24DNVP.js.map +0 -1
|
@@ -0,0 +1,159 @@
|
|
|
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;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type HubProviderEvent } from '@tangle-network/hub-sdk';
|
|
2
2
|
import type { AgentProfile } from '@tangle-network/sandbox';
|
|
3
|
+
export * from './engine';
|
|
3
4
|
/**
|
|
4
5
|
* A hosted agent: a person texts or calls a line, and the agent answers from
|
|
5
6
|
* that person's own isolated sandbox. The developer's Tangle API key pays for
|
|
@@ -2,6 +2,169 @@
|
|
|
2
2
|
import { authenticateHubEventRequest, HubClient } from "@tangle-network/hub-sdk";
|
|
3
3
|
import { buildMessagingReply, normalizeConversationEvent } from "@tangle-network/agent-integrations/conversation-events";
|
|
4
4
|
import { Sandbox, SandboxError } from "@tangle-network/sandbox/core";
|
|
5
|
+
|
|
6
|
+
// src/hosted-agent/engine.ts
|
|
7
|
+
var MAX_MESSAGE_CHARS = 8e3;
|
|
8
|
+
var MAX_REPEATED_FAILURES = 3;
|
|
9
|
+
var AUTHORITY_ENDED = "authority_ended";
|
|
10
|
+
var TurnPending = class extends Error {
|
|
11
|
+
constructor(code, transient) {
|
|
12
|
+
super(code);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.transient = transient;
|
|
15
|
+
this.name = "TurnPending";
|
|
16
|
+
}
|
|
17
|
+
code;
|
|
18
|
+
transient;
|
|
19
|
+
};
|
|
20
|
+
function sessionInfo(session) {
|
|
21
|
+
const s = typeof session === "object" && session !== null ? session : {};
|
|
22
|
+
const effective = s.effectiveBackend?.model;
|
|
23
|
+
const model = typeof effective === "string" && effective ? effective : typeof s.model === "string" && s.model ? s.model : void 0;
|
|
24
|
+
return { ...typeof s.backend === "string" && s.backend ? { harness: s.backend } : {}, ...model ? { model } : {} };
|
|
25
|
+
}
|
|
26
|
+
var refusal = (reason, detail) => ({ ok: false, reason, ...detail ? { detail } : {} });
|
|
27
|
+
var RUNNING = /* @__PURE__ */ new Set(["running", "awaiting_input", "not_admitted"]);
|
|
28
|
+
var TRANSIENT_ERRORS = /* @__PURE__ */ new Set(["NetworkError", "TimeoutError", "QuotaError", "AbortError", "EdgeNotReadyError"]);
|
|
29
|
+
var TRANSIENT_503 = /* @__PURE__ */ new Set(["MODEL_CREDENTIAL_SUPERSEDED", "HUB_CREDENTIAL_REFRESH_UNAVAILABLE"]);
|
|
30
|
+
function describeFailure(error) {
|
|
31
|
+
const e = typeof error === "object" && error !== null ? error : {};
|
|
32
|
+
const name = typeof e.name === "string" ? e.name : typeof error;
|
|
33
|
+
const status = typeof e.status === "number" ? e.status : void 0;
|
|
34
|
+
const own = typeof e.code === "string" ? e.code : typeof e.reason === "string" ? e.reason : void 0;
|
|
35
|
+
const code = [name, own, status].filter((part) => part !== void 0).join(":").replace(/[^\w.:-]/g, "_").slice(0, 100);
|
|
36
|
+
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);
|
|
37
|
+
return { name, code, ...status !== void 0 ? { status } : {}, ...own !== void 0 ? { reason: own } : {}, message };
|
|
38
|
+
}
|
|
39
|
+
function isTransientFailure(failure) {
|
|
40
|
+
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));
|
|
41
|
+
}
|
|
42
|
+
var budgetExhausted = (error) => error instanceof Error && error.name === "KeyBudgetExhaustedError";
|
|
43
|
+
async function runHostedTurn(turn, ports, options) {
|
|
44
|
+
const now = options.now ?? Date.now;
|
|
45
|
+
const sleep2 = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
46
|
+
const observe = ports.observe ?? {};
|
|
47
|
+
const allowDispatch = options.allowDispatch !== false;
|
|
48
|
+
const text = turn.text.trim();
|
|
49
|
+
if (!text || text.length > MAX_MESSAGE_CHARS || !turn.turnId || turn.turnId.length > 200) return refusal("refused", "invalid_message");
|
|
50
|
+
const refused = await ports.admit();
|
|
51
|
+
if (refused) return refused;
|
|
52
|
+
const timed = async (step, fn) => {
|
|
53
|
+
const start = now();
|
|
54
|
+
try {
|
|
55
|
+
return await fn();
|
|
56
|
+
} finally {
|
|
57
|
+
observe.span?.(step, now() - start);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const failed = async (step, error, boxId) => {
|
|
61
|
+
const failure = describeFailure(error);
|
|
62
|
+
await observe.failure?.(step, failure, boxId);
|
|
63
|
+
const classified = ports.classify?.(error, step);
|
|
64
|
+
if (classified) return classified;
|
|
65
|
+
if (error instanceof TurnPending) return { ok: "pending", detail: { step, code: error.code, transient: error.transient } };
|
|
66
|
+
if (budgetExhausted(error)) return refusal("refused", "budget_exhausted");
|
|
67
|
+
return { ok: "pending", detail: { step, code: failure.code, transient: isTransientFailure(failure) }, cause: error };
|
|
68
|
+
};
|
|
69
|
+
let box;
|
|
70
|
+
try {
|
|
71
|
+
box = await timed("ensure", () => ports.box());
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return failed("ensure", error);
|
|
74
|
+
}
|
|
75
|
+
if (ports.authorize && !await ports.authorize()) return refusal("refused", AUTHORITY_ENDED);
|
|
76
|
+
let result;
|
|
77
|
+
try {
|
|
78
|
+
const prompt = await ports.prompt(box);
|
|
79
|
+
const sessionId = ports.sessionId;
|
|
80
|
+
if (allowDispatch) await timed("session", async () => {
|
|
81
|
+
const session = await box.session(sessionId).status();
|
|
82
|
+
if (session) {
|
|
83
|
+
observe.session?.({ created: false, ...sessionInfo(session) });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const backend = await ports.backend();
|
|
87
|
+
await box.createSession({ sessionId, retention: "workspace", backend });
|
|
88
|
+
const created = observe.session && observe.readCreatedSession ? sessionInfo(await box.session(sessionId).status()) : {};
|
|
89
|
+
observe.session?.({ created: true, ...backend.type ? { harness: backend.type } : {}, ...created });
|
|
90
|
+
});
|
|
91
|
+
for (; ; ) {
|
|
92
|
+
result = await timed("drive", () => box.driveConversationTurn(prompt, {
|
|
93
|
+
sessionId,
|
|
94
|
+
turnId: turn.turnId,
|
|
95
|
+
wallCapMs: options.wallCapMs,
|
|
96
|
+
timeoutMs: options.timeoutMs ?? 8e3,
|
|
97
|
+
allowDispatch
|
|
98
|
+
}));
|
|
99
|
+
observe.drive?.(result, now());
|
|
100
|
+
const pollMs = options.pollMs ?? 2e3;
|
|
101
|
+
if (!RUNNING.has(result.state) || options.until === void 0 || now() + pollMs > options.until) break;
|
|
102
|
+
await sleep2(pollMs);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return failed("drive", error, box.id);
|
|
106
|
+
}
|
|
107
|
+
switch (result.state) {
|
|
108
|
+
case "completed": {
|
|
109
|
+
const answer = result.text.trim();
|
|
110
|
+
if (!answer) {
|
|
111
|
+
await observe.failure?.("turn", describeFailure(new Error("The turn completed with no reply text")), box.id);
|
|
112
|
+
return refusal("unavailable", "empty_reply");
|
|
113
|
+
}
|
|
114
|
+
await ports.answered?.(answer, box);
|
|
115
|
+
return { ok: true, text: answer };
|
|
116
|
+
}
|
|
117
|
+
case "failed":
|
|
118
|
+
await observe.failure?.("turn", describeFailure(Object.assign(new Error(result.error), { name: "TurnFailed" })), box.id);
|
|
119
|
+
return refusal("unavailable", "agent_failed");
|
|
120
|
+
// Interaction and plan approval are not exposed over messaging. The wall
|
|
121
|
+
// cap cancels a turn that waits on one, which frees the session.
|
|
122
|
+
// Sandbox 0.45 reported approval and question outcomes as `failed`.
|
|
123
|
+
case "awaiting_plan_decision":
|
|
124
|
+
case "blocked_on_approval":
|
|
125
|
+
case "awaiting_question":
|
|
126
|
+
case "awaiting_interaction":
|
|
127
|
+
return refusal("refused", "needs_decision");
|
|
128
|
+
case "running":
|
|
129
|
+
case "awaiting_input":
|
|
130
|
+
case "not_admitted":
|
|
131
|
+
return { ok: "pending", detail: { step: "turn", code: result.state, transient: true } };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function settleHostedTurn(pass, options) {
|
|
135
|
+
const now = options.now ?? Date.now;
|
|
136
|
+
const sleep2 = options.sleep ?? ((ms, signal) => new Promise((resolve, reject) => {
|
|
137
|
+
const timer = setTimeout(resolve, ms);
|
|
138
|
+
signal?.addEventListener("abort", () => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
reject(signal.reason);
|
|
141
|
+
}, { once: true });
|
|
142
|
+
}));
|
|
143
|
+
let last = "", repeats = 0;
|
|
144
|
+
for (; ; ) {
|
|
145
|
+
if (options.signal?.aborted || now() > options.deadline) return refusal("unavailable", "pending");
|
|
146
|
+
const outcome = await pass();
|
|
147
|
+
if (outcome.ok !== "pending") return outcome;
|
|
148
|
+
repeats = outcome.detail.code === last ? repeats + 1 : 1;
|
|
149
|
+
last = outcome.detail.code;
|
|
150
|
+
if (!outcome.detail.transient && repeats >= MAX_REPEATED_FAILURES) return refusal("unavailable", `${outcome.detail.step}:${last}`);
|
|
151
|
+
try {
|
|
152
|
+
await sleep2(options.pollMs ?? 1500, options.signal);
|
|
153
|
+
} catch {
|
|
154
|
+
return refusal("unavailable", "pending");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function chatOnlyBackend(backend) {
|
|
159
|
+
const profile = backend.profile ?? {};
|
|
160
|
+
return { ...backend, profile: {
|
|
161
|
+
...profile,
|
|
162
|
+
tools: { ...profile.tools, bash: false, edit: false, write: false },
|
|
163
|
+
permissions: { ...profile.permissions, bash: "deny", edit: "deny" }
|
|
164
|
+
} };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/hosted-agent/index.ts
|
|
5
168
|
var DEFAULT_BOX_POLICY = {
|
|
6
169
|
cpuCores: 1,
|
|
7
170
|
memoryMB: 2048,
|
|
@@ -187,54 +350,77 @@ function createHostedAgent(config) {
|
|
|
187
350
|
if (!text || text.length > 8e3 || !/^[\w-]{1,120}$/.test(message.turnId)) return { state: "declined" };
|
|
188
351
|
const user = (await sha256(message.userId)).slice(0, 32);
|
|
189
352
|
const admittedKey = `turn:${user}:${message.turnId}`;
|
|
190
|
-
if (!await store.get(admittedKey)) {
|
|
191
|
-
const decision = await admit(message, user);
|
|
192
|
-
if (decision === "ignore") return { state: "declined" };
|
|
193
|
-
if (decision !== "answer") return { state: "declined", reply: decision.reply };
|
|
194
|
-
await store.put(admittedKey, "1", { expirationTtl: TWO_DAYS });
|
|
195
|
-
}
|
|
196
|
-
const trace = options.trace, traceKey = `trace:${user}:${message.turnId}`;
|
|
197
|
-
const box = await ensureBox(user, options.deadline, trace);
|
|
198
|
-
if (trace) {
|
|
199
|
-
const first = await store.get(traceKey);
|
|
200
|
-
if (first) trace.box = first;
|
|
201
|
-
else if (trace.box) await store.put(traceKey, trace.box, { expirationTtl: TWO_DAYS });
|
|
202
|
-
}
|
|
203
|
-
if (!box) return { state: "pending" };
|
|
204
353
|
const sessionId = `hosted-${user}-${await profileTag}`;
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
354
|
+
const trace = options.trace, traceKey = `trace:${user}:${message.turnId}`;
|
|
355
|
+
const conversationKey = `convo:${user}`;
|
|
356
|
+
let declined;
|
|
357
|
+
let conversation, at = "";
|
|
358
|
+
const outcome = await runHostedTurn({ turnId: message.turnId, text }, {
|
|
359
|
+
async admit() {
|
|
360
|
+
if (await store.get(admittedKey)) return null;
|
|
361
|
+
const decision = await admit(message, user);
|
|
362
|
+
if (decision === "answer") {
|
|
363
|
+
await store.put(admittedKey, "1", { expirationTtl: TWO_DAYS });
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
declined = decision === "ignore" ? { state: "declined" } : { state: "declined", reply: decision.reply };
|
|
367
|
+
return { ok: false, reason: "refused", detail: "declined" };
|
|
368
|
+
},
|
|
369
|
+
async box() {
|
|
370
|
+
const box = await ensureBox(user, options.deadline, trace);
|
|
371
|
+
if (trace) {
|
|
372
|
+
const first = await store.get(traceKey);
|
|
373
|
+
if (first) trace.box = first;
|
|
374
|
+
else if (trace.box) await store.put(traceKey, trace.box, { expirationTtl: TWO_DAYS });
|
|
375
|
+
}
|
|
376
|
+
if (!box) throw new TurnPending("box_starting", true);
|
|
377
|
+
return box;
|
|
378
|
+
},
|
|
379
|
+
sessionId,
|
|
380
|
+
backend: async () => backend,
|
|
381
|
+
async prompt(box) {
|
|
382
|
+
const asked = message.channel === "voice" ? `[Phone call. Answer in one to three short spoken sentences.]
|
|
212
383
|
${text}` : text;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
384
|
+
at = `${box.id}/${sessionId}`;
|
|
385
|
+
const stored = await store.get(conversationKey);
|
|
386
|
+
conversation = stored ? JSON.parse(stored) : { at, turns: [] };
|
|
387
|
+
const carried = conversation.at === at ? "" : conversation.turns.map(([, them, you]) => `Them: ${them}
|
|
217
388
|
You: ${you}`).join("\n");
|
|
218
|
-
|
|
389
|
+
return carried ? `${CARRY_HEAD}
|
|
219
390
|
${carried}
|
|
220
391
|
${CARRY_NOW}
|
|
221
392
|
${asked}` : asked;
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const answer = result.text.trim();
|
|
226
|
-
if (!answer) throw new HostedAgentError("empty_reply", "The turn completed with no reply text.");
|
|
227
|
-
if (trace) noteResult(trace, result.result, result.usage);
|
|
228
|
-
const turns = conversation.turns.filter(([id]) => id !== message.turnId);
|
|
393
|
+
},
|
|
394
|
+
async answered(answer) {
|
|
395
|
+
const turns = (conversation?.turns ?? []).filter(([id]) => id !== message.turnId);
|
|
229
396
|
turns.push([message.turnId, message.channel === "voice" ? `(on a call) ${text}` : text, answer]);
|
|
230
397
|
while (turns.length > 1 && JSON.stringify(turns).length > CARRY_CHARS) turns.shift();
|
|
231
398
|
await store.put(conversationKey, JSON.stringify({ at, turns }));
|
|
232
|
-
|
|
399
|
+
},
|
|
400
|
+
classify: (error) => error instanceof HostedAgentError ? { ok: false, reason: "unavailable", detail: error.code } : void 0,
|
|
401
|
+
observe: {
|
|
402
|
+
readCreatedSession: Boolean(trace),
|
|
403
|
+
session(info) {
|
|
404
|
+
if (!trace) return;
|
|
405
|
+
trace.harness = info.harness ?? config.harness;
|
|
406
|
+
trace.model = info.model;
|
|
407
|
+
},
|
|
408
|
+
drive(result) {
|
|
409
|
+
if (trace && result.state === "completed") noteResult(trace, result.result, result.usage);
|
|
410
|
+
},
|
|
411
|
+
failure(step, failure) {
|
|
412
|
+
console.error(`[hosted-agent] turn=${message.turnId} step=${step} code=${failure.code} message=${JSON.stringify(failure.message)}`);
|
|
413
|
+
}
|
|
233
414
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
415
|
+
}, { wallCapMs, timeoutMs: 8e3, until: options.deadline });
|
|
416
|
+
if (declined) return declined;
|
|
417
|
+
if (outcome.ok === true) return { state: "answered", text: outcome.text };
|
|
418
|
+
if (outcome.ok === "pending") {
|
|
419
|
+
if (outcome.cause !== void 0) throw outcome.cause;
|
|
420
|
+
return { state: "pending" };
|
|
237
421
|
}
|
|
422
|
+
const code = outcome.detail ?? outcome.reason;
|
|
423
|
+
throw new HostedAgentError(code === "agent_failed" ? "turn_failed" : code, `The turn ended without an answer (${code}).`);
|
|
238
424
|
}
|
|
239
425
|
return {
|
|
240
426
|
ask,
|
|
@@ -359,14 +545,23 @@ ${debugFooter(config.profile.name ?? "agent", turnId, trace, totalMs)}`;
|
|
|
359
545
|
};
|
|
360
546
|
}
|
|
361
547
|
export {
|
|
548
|
+
AUTHORITY_ENDED,
|
|
362
549
|
CONVERSATION_TOOLS_OFF,
|
|
363
550
|
DEFAULT_BOX_POLICY,
|
|
364
551
|
DEFAULT_HOSTED_MODEL,
|
|
365
552
|
HostedAgentError,
|
|
553
|
+
MAX_MESSAGE_CHARS,
|
|
554
|
+
MAX_REPEATED_FAILURES,
|
|
366
555
|
NOTICE,
|
|
556
|
+
TurnPending,
|
|
557
|
+
chatOnlyBackend,
|
|
367
558
|
createHostedAgent,
|
|
368
559
|
debugCommand,
|
|
369
560
|
debugFooter,
|
|
370
|
-
|
|
561
|
+
describeFailure,
|
|
562
|
+
isTransientFailure,
|
|
563
|
+
normalizeAddress,
|
|
564
|
+
runHostedTurn,
|
|
565
|
+
settleHostedTurn
|
|
371
566
|
};
|
|
372
567
|
//# sourceMappingURL=index.js.map
|