@economic/agents 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/index.d.mts +93 -1
- package/dist/index.mjs +216 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -514,6 +514,39 @@ protected async getUserContext(jwtToken: string) {
|
|
|
514
514
|
}
|
|
515
515
|
```
|
|
516
516
|
|
|
517
|
+
If `getUserContext` throws, the socket closes with `1011` — or, when the thrown `Error` carries a numeric `closeCode` in the `4000`–`4999` range, with that code and an `"unauthorized"` status frame:
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
const error = new Error("No Eva access");
|
|
521
|
+
(error as Error & { closeCode: number }).closeCode = 4003;
|
|
522
|
+
throw error;
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
### Token exchange
|
|
526
|
+
|
|
527
|
+
For agents whose clients connect with a _down-scoped_ token — one only valid for the client ↔ agent hop — override the static `getTokenExchangeConfig(env)` to exchange it (RFC 8693) at the identity server for a token issued to the agent's own OAuth client. Requires `getJwtAuthConfig`.
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
export class SupportAgent extends Agent {
|
|
531
|
+
static getTokenExchangeConfig(env: Cloudflare.Env) {
|
|
532
|
+
return {
|
|
533
|
+
tokenUrl: `${env.IDENTITY_ENDPOINT}/connect/token`,
|
|
534
|
+
clientId: env.OAUTH_CLIENT_ID,
|
|
535
|
+
clientSecret: env.OAUTH_CLIENT_SECRET, // string or Secrets Store binding
|
|
536
|
+
scopes: "api.read api.write", // always sent explicitly
|
|
537
|
+
tokenHeader: "X-EconomicToken", // default
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
When configured:
|
|
544
|
+
|
|
545
|
+
- The verified connect token is exchanged on every connect; the exchanged token is what `getUserContext` receives and what tools see as `ctx.tokens[tokenHeader]` each turn. **Client-sent request-body tokens for that key are ignored** — tool credentials always derive from the verified connection identity, so clients should stop sending them.
|
|
546
|
+
- Renewal is connection-based. The client token rides in the WebSocket protocols, so presenting a renewed token always means a reconnect, which re-verifies and re-exchanges. Keep the `authToken` passed to the hooks current (refresh it before expiry) and the socket rotates silently — the `unauthorized` close (`4001`) then only happens when the session is genuinely dead (logout, revocation).
|
|
547
|
+
- The exchanged token expires on the identity server's clock (with a 30s margin). A turn arriving after expiry closes the connection `4001` to force a reconnect; a client that refreshes its token on time never hits this.
|
|
548
|
+
- Exchange failures at connect: `invalid_grant` (dead session token) → status `"unauthorized"` + close `4001`; anything else (identity server down, misconfigured client) → status `"error"` + close `1011`. Partial configuration is the app's concern — throw from `getTokenExchangeConfig` if you consider it a deployment fault, or return `undefined` to disable the exchange.
|
|
549
|
+
|
|
517
550
|
## Observability
|
|
518
551
|
|
|
519
552
|
Emitted per turn from the `ai` SDK's OpenTelemetry spans, no setup beyond bindings:
|
package/dist/index.d.mts
CHANGED
|
@@ -26,6 +26,61 @@ type SqlFn = InstanceType<typeof Agent$1>["sql"];
|
|
|
26
26
|
type AgentConnectionStatus = "connecting" | "connected" | "disconnected" | "unauthorized" | "error";
|
|
27
27
|
type AgentConnectionType = "agent" | "chat" | "assistant";
|
|
28
28
|
//#endregion
|
|
29
|
+
//#region src/server/features/auth/tokenExchange.d.ts
|
|
30
|
+
interface TokenExchangeConfig {
|
|
31
|
+
/** Identity server token endpoint, e.g. `${identityOrigin}/connect/token`. */
|
|
32
|
+
tokenUrl: string;
|
|
33
|
+
/** OAuth client the agent authenticates as. */
|
|
34
|
+
clientId: string;
|
|
35
|
+
/**
|
|
36
|
+
* Plain secret, or a binding resolved with `get()` (e.g. a Cloudflare
|
|
37
|
+
* Secrets Store binding). Resolved on every exchange so rotation is
|
|
38
|
+
* picked up without a redeploy.
|
|
39
|
+
*/
|
|
40
|
+
clientSecret: string | {
|
|
41
|
+
get(): Promise<string>;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Space-separated scopes for the exchanged token. Always sent explicitly:
|
|
45
|
+
* omitting `scope` would make the identity server issue every scope the
|
|
46
|
+
* client is allowed, the opposite of least privilege.
|
|
47
|
+
*/
|
|
48
|
+
scopes: string;
|
|
49
|
+
/**
|
|
50
|
+
* Key under the request-context `tokens` record that tools read the token
|
|
51
|
+
* from. Defaults to {@link DEFAULT_TOKEN_HEADER}.
|
|
52
|
+
*/
|
|
53
|
+
tokenHeader?: string;
|
|
54
|
+
}
|
|
55
|
+
interface ExchangedToken {
|
|
56
|
+
accessToken: string;
|
|
57
|
+
/** Unix ms; already includes {@link EXPIRY_MARGIN_MS}. */
|
|
58
|
+
expiresAt: number;
|
|
59
|
+
}
|
|
60
|
+
type TokenExchangeResult = ({
|
|
61
|
+
success: true;
|
|
62
|
+
} & ExchangedToken) | {
|
|
63
|
+
success: false;
|
|
64
|
+
status: number;
|
|
65
|
+
errorCode: string;
|
|
66
|
+
message: string;
|
|
67
|
+
/**
|
|
68
|
+
* True when the identity server rejected the subject token itself
|
|
69
|
+
* (RFC 6749 `invalid_grant`: expired or revoked). Re-authenticating is
|
|
70
|
+
* the only fix — other failures (`invalid_client`, `invalid_scope`,
|
|
71
|
+
* 5xx) are deployment or identity-server faults the user cannot fix by
|
|
72
|
+
* logging in again.
|
|
73
|
+
*/
|
|
74
|
+
subjectTokenRejected: boolean;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Exchange `subjectToken` for a token issued to the agent's own OAuth client.
|
|
78
|
+
*
|
|
79
|
+
* Expected identity-server rejections return a failure result; unexpected
|
|
80
|
+
* failures (network error, timeout, malformed success response) throw.
|
|
81
|
+
*/
|
|
82
|
+
declare function exchangeToken(subjectToken: string, config: TokenExchangeConfig): Promise<TokenExchangeResult>;
|
|
83
|
+
//#endregion
|
|
29
84
|
//#region src/server/util/tools.d.ts
|
|
30
85
|
type ToolSet = Record<string, Tool>;
|
|
31
86
|
type Tool<Context extends Record<string, unknown> = Record<string, unknown>, INPUT extends JSONValue | unknown | never = any, OUTPUT extends JSONValue | unknown | never = any> = Tool$1<INPUT, OUTPUT> & {
|
|
@@ -141,6 +196,41 @@ declare abstract class Agent<RequestContext extends Record<string, unknown> = Re
|
|
|
141
196
|
* @returns JWT auth config or undefined to skip authentication
|
|
142
197
|
*/
|
|
143
198
|
static getJwtAuthConfig?(env: Cloudflare.Env): JwtAuthConfig<Record<string, unknown>> | undefined;
|
|
199
|
+
/**
|
|
200
|
+
* Override to exchange the verified connect token (RFC 8693) for a token
|
|
201
|
+
* issued to this agent's own OAuth client. Requires {@link getJwtAuthConfig}.
|
|
202
|
+
*
|
|
203
|
+
* When configured, the exchanged token is passed to {@link getUserContext}
|
|
204
|
+
* and injected into the tool context every turn under
|
|
205
|
+
* `tokens[config.tokenHeader]` — client-sent request-body tokens for that
|
|
206
|
+
* key are ignored. Renewal is connection-based: a client presenting a new
|
|
207
|
+
* token reconnects, and the connect flow re-verifies and re-exchanges.
|
|
208
|
+
*
|
|
209
|
+
* Do not add side effects to this method - return a single config from it,
|
|
210
|
+
* or undefined to disable the exchange.
|
|
211
|
+
*
|
|
212
|
+
* @param env - The worker environment variables
|
|
213
|
+
* @returns Token exchange config or undefined to disable
|
|
214
|
+
*/
|
|
215
|
+
static getTokenExchangeConfig?(env: Cloudflare.Env): TokenExchangeConfig | undefined;
|
|
216
|
+
/**
|
|
217
|
+
* The exchanged token for this agent, refreshed on every authenticated
|
|
218
|
+
* connect. Kept in memory for the hot path and mirrored to DO storage so
|
|
219
|
+
* turns that run after hibernation (or without a connection at all) still
|
|
220
|
+
* have it. Short-lived by construction — at most the identity server's
|
|
221
|
+
* token lifetime.
|
|
222
|
+
*/
|
|
223
|
+
private _exchangedToken?;
|
|
224
|
+
private _storeExchangedToken;
|
|
225
|
+
private _getExchangedToken;
|
|
226
|
+
/**
|
|
227
|
+
* Swaps the client-sent token for the exchanged one in a copy of the turn
|
|
228
|
+
* body. No-op when token exchange is not configured. When the exchanged
|
|
229
|
+
* token is missing or expired the turn cannot run: only a reconnect can
|
|
230
|
+
* deliver a fresh subject token, so the current connection (if any) is
|
|
231
|
+
* closed to force one, and the turn fails.
|
|
232
|
+
*/
|
|
233
|
+
private _applyTokenExchange;
|
|
144
234
|
/**
|
|
145
235
|
* The user context for the session.
|
|
146
236
|
* Define getUserContext to set a user context.
|
|
@@ -286,6 +376,7 @@ type Chat = {
|
|
|
286
376
|
durable_object_name: string;
|
|
287
377
|
title?: string;
|
|
288
378
|
summary?: string;
|
|
379
|
+
title_set_by_user: number;
|
|
289
380
|
created_at: number;
|
|
290
381
|
updated_at: number;
|
|
291
382
|
positive_feedback_count: number;
|
|
@@ -302,6 +393,7 @@ declare abstract class Assistant<State = unknown> extends Agent$1<Cloudflare.Env
|
|
|
302
393
|
onConnect(connection: Connection, ctx: ConnectionContext): Promise<void>;
|
|
303
394
|
createChat(): Promise<string>;
|
|
304
395
|
deleteChat(id: string): Promise<void>;
|
|
396
|
+
renameChat(id: string, title: string): Promise<void>;
|
|
305
397
|
getChats(): Promise<Chat[]>;
|
|
306
398
|
getChatAgentClassName(): Promise<string>;
|
|
307
399
|
recordChatTurn(durableObjectName: string, messages: UIMessage[]): Promise<void>;
|
|
@@ -363,4 +455,4 @@ declare function skill<Context extends Record<string, unknown> = Record<string,
|
|
|
363
455
|
//#region src/server/index.d.ts
|
|
364
456
|
declare function getCurrentToolContext(): any;
|
|
365
457
|
//#endregion
|
|
366
|
-
export { Agent, type AgentConnectionStatus, type AgentConnectionType, type AgentEnv, Assistant, ChatAgent, type FacetStub, type LegacyChatStub, type LegacyMessageFeedback, type MigrationDeps, type Skill, type SkillSource, type Tool, type ToolContext, type ToolSet, type TurnSnapshot, getCurrentToolContext, migrateUserFromV1, routeAgentRequest, skill, tool };
|
|
458
|
+
export { Agent, type AgentConnectionStatus, type AgentConnectionType, type AgentEnv, Assistant, ChatAgent, type ExchangedToken, type FacetStub, type LegacyChatStub, type LegacyMessageFeedback, type MigrationDeps, type Skill, type SkillSource, type TokenExchangeConfig, type TokenExchangeResult, type Tool, type ToolContext, type ToolSet, type TurnSnapshot, exchangeToken, getCurrentToolContext, migrateUserFromV1, routeAgentRequest, skill, tool };
|
package/dist/index.mjs
CHANGED
|
@@ -4,6 +4,97 @@ import { Think, skills } from "@cloudflare/think";
|
|
|
4
4
|
import { Output, convertToModelMessages, generateText, jsonSchema, pruneMessages, tool as tool$1 } from "ai";
|
|
5
5
|
import { nanoid } from "nanoid";
|
|
6
6
|
import { createCompactFunction } from "agents/experimental/memory/utils";
|
|
7
|
+
//#region src/server/features/auth/tokenExchange.ts
|
|
8
|
+
/**
|
|
9
|
+
* OAuth 2.0 Token Exchange (RFC 8693) against an identity server.
|
|
10
|
+
*
|
|
11
|
+
* The client connects with a token down-scoped for the client ↔ agent hop only.
|
|
12
|
+
* When an agent defines `static getTokenExchangeConfig`, the verified connect
|
|
13
|
+
* token is exchanged — authenticating as the agent's own OAuth client — for a
|
|
14
|
+
* token carrying the scopes the agent needs against backends. Backends then see
|
|
15
|
+
* the agent's `client_id` and can attribute calls to it, and the browser never
|
|
16
|
+
* holds a backend-capable credential.
|
|
17
|
+
*
|
|
18
|
+
* Expected failures (the identity server rejecting the request) return a result
|
|
19
|
+
* object; unexpected failures (network errors, malformed responses) throw. Same
|
|
20
|
+
* contract as `verifyJwt` in ./index.ts.
|
|
21
|
+
*/
|
|
22
|
+
const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
23
|
+
const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
|
|
24
|
+
const TOKEN_EXCHANGE_TIMEOUT_MS = 3e4;
|
|
25
|
+
/** Exchanged tokens are treated as expired this long before they actually are. */
|
|
26
|
+
const EXPIRY_MARGIN_MS = 3e4;
|
|
27
|
+
/**
|
|
28
|
+
* Exchange `subjectToken` for a token issued to the agent's own OAuth client.
|
|
29
|
+
*
|
|
30
|
+
* Expected identity-server rejections return a failure result; unexpected
|
|
31
|
+
* failures (network error, timeout, malformed success response) throw.
|
|
32
|
+
*/
|
|
33
|
+
async function exchangeToken(subjectToken, config) {
|
|
34
|
+
const clientSecret = typeof config.clientSecret === "string" ? config.clientSecret : await config.clientSecret.get();
|
|
35
|
+
const body = new URLSearchParams({
|
|
36
|
+
client_id: config.clientId,
|
|
37
|
+
client_secret: clientSecret,
|
|
38
|
+
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
|
|
39
|
+
subject_token: subjectToken,
|
|
40
|
+
subject_token_type: ACCESS_TOKEN_TYPE,
|
|
41
|
+
scope: config.scopes
|
|
42
|
+
});
|
|
43
|
+
const response = await fetch(config.tokenUrl, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
46
|
+
body: body.toString(),
|
|
47
|
+
signal: AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS)
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
let errorCode = "unknown_error";
|
|
51
|
+
let description;
|
|
52
|
+
try {
|
|
53
|
+
const json = await response.json();
|
|
54
|
+
errorCode = json.error ?? errorCode;
|
|
55
|
+
description = json.error_description;
|
|
56
|
+
} catch {}
|
|
57
|
+
return {
|
|
58
|
+
success: false,
|
|
59
|
+
status: response.status,
|
|
60
|
+
errorCode,
|
|
61
|
+
message: `Token exchange failed with status ${response.status} (${errorCode})${description ? `: ${description}` : ""}`,
|
|
62
|
+
subjectTokenRejected: response.status === 400 && errorCode === "invalid_grant"
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
let json;
|
|
66
|
+
try {
|
|
67
|
+
json = await response.json();
|
|
68
|
+
} catch {
|
|
69
|
+
throw new Error("Token exchange response body was not valid JSON");
|
|
70
|
+
}
|
|
71
|
+
if (!json.access_token) throw new Error("Token exchange response contained no access_token");
|
|
72
|
+
if (typeof json.expires_in !== "number") throw new Error("Token exchange response contained no expires_in");
|
|
73
|
+
return {
|
|
74
|
+
success: true,
|
|
75
|
+
accessToken: json.access_token,
|
|
76
|
+
expiresAt: Date.now() + Math.max(0, json.expires_in * 1e3 - EXPIRY_MARGIN_MS)
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Returns a copy of the request body with the exchanged token injected under
|
|
81
|
+
* `tokens[tokenHeader]`, overriding any client-sent value for that key.
|
|
82
|
+
*
|
|
83
|
+
* Never mutates `body`: think persists the original request body
|
|
84
|
+
* (`_lastBody`) and replays it on continuation turns, so a mutation would
|
|
85
|
+
* persist the exchanged token and replay it as a subject token later.
|
|
86
|
+
*/
|
|
87
|
+
function withExchangedToken(body, tokenHeader, accessToken) {
|
|
88
|
+
const tokens = body?.tokens ?? {};
|
|
89
|
+
return {
|
|
90
|
+
...body,
|
|
91
|
+
tokens: {
|
|
92
|
+
...tokens,
|
|
93
|
+
[tokenHeader]: accessToken
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
//#endregion
|
|
7
98
|
//#region src/server/util/tools.ts
|
|
8
99
|
function tool(tool) {
|
|
9
100
|
return tool$1(tool);
|
|
@@ -148,6 +239,18 @@ function buildSourcesTransform(additional) {
|
|
|
148
239
|
}
|
|
149
240
|
//#endregion
|
|
150
241
|
//#region src/server/agents/Agent.ts
|
|
242
|
+
const EXCHANGED_TOKEN_STORAGE_KEY = "economic:exchangedToken";
|
|
243
|
+
/**
|
|
244
|
+
* Close code for a getUserContext failure. Subclasses signal an intentional
|
|
245
|
+
* auth-style rejection by throwing an Error carrying a numeric `closeCode`
|
|
246
|
+
* in the WebSocket application range (4000-4999); anything else is treated
|
|
247
|
+
* as an internal error (1011).
|
|
248
|
+
*/
|
|
249
|
+
function getConnectCloseCode(error) {
|
|
250
|
+
const closeCode = error?.closeCode;
|
|
251
|
+
if (typeof closeCode === "number" && closeCode >= 4e3 && closeCode <= 4999) return closeCode;
|
|
252
|
+
return 1011;
|
|
253
|
+
}
|
|
151
254
|
var Agent = class extends Think {
|
|
152
255
|
agentType = "agent";
|
|
153
256
|
clientIp;
|
|
@@ -207,7 +310,45 @@ var Agent = class extends Think {
|
|
|
207
310
|
return;
|
|
208
311
|
}
|
|
209
312
|
const token = extractTokenFromConnectRequest(ctx.request);
|
|
210
|
-
|
|
313
|
+
let effectiveToken = token;
|
|
314
|
+
const getTokenExchangeConfig = this.constructor.getTokenExchangeConfig;
|
|
315
|
+
const exchangeConfig = getTokenExchangeConfig?.(this.env);
|
|
316
|
+
if (token && exchangeConfig) {
|
|
317
|
+
let result;
|
|
318
|
+
try {
|
|
319
|
+
result = await exchangeToken(token, exchangeConfig);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
sendConnectionStatus(connection, "error");
|
|
322
|
+
console.error(`[Agent] Token exchange error - ${error}`);
|
|
323
|
+
connection.close(1011, "Token exchange failed");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (!result.success) {
|
|
327
|
+
console.error(`[Agent] Token exchange failed - ${result.message}`);
|
|
328
|
+
if (result.subjectTokenRejected) {
|
|
329
|
+
sendConnectionStatus(connection, "unauthorized");
|
|
330
|
+
connection.close(4001, "Unauthorized");
|
|
331
|
+
} else {
|
|
332
|
+
sendConnectionStatus(connection, "error");
|
|
333
|
+
connection.close(1011, "Token exchange failed");
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
await this._storeExchangedToken({
|
|
338
|
+
accessToken: result.accessToken,
|
|
339
|
+
expiresAt: result.expiresAt
|
|
340
|
+
});
|
|
341
|
+
effectiveToken = result.accessToken;
|
|
342
|
+
}
|
|
343
|
+
if (effectiveToken && this.getUserContext) try {
|
|
344
|
+
connection.setState({ userContext: await this.getUserContext(effectiveToken) });
|
|
345
|
+
} catch (error) {
|
|
346
|
+
const closeCode = getConnectCloseCode(error);
|
|
347
|
+
sendConnectionStatus(connection, closeCode === 1011 ? "error" : "unauthorized");
|
|
348
|
+
console.error(`[Agent] getUserContext error - ${error}`);
|
|
349
|
+
connection.close(closeCode, "Could not establish user context");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
211
352
|
}
|
|
212
353
|
}
|
|
213
354
|
sendConnectionStatus(connection, "connected");
|
|
@@ -231,7 +372,8 @@ var Agent = class extends Think {
|
|
|
231
372
|
*/
|
|
232
373
|
async beforeTurn(ctx) {
|
|
233
374
|
this.registerInstanceActivity();
|
|
234
|
-
|
|
375
|
+
const body = await this._applyTokenExchange(ctx.body);
|
|
376
|
+
this._toolContextForCurrentTurn = this.getToolContextForCurrentTurn(body);
|
|
235
377
|
this._toolsForCurrentTurn = ctx.tools;
|
|
236
378
|
const activeSkillName = this._getLastActivatedSkillName(ctx.messages);
|
|
237
379
|
const { tools, activeTools } = await this._getAuthorizedTools(ctx.tools, activeSkillName);
|
|
@@ -424,6 +566,48 @@ var Agent = class extends Think {
|
|
|
424
566
|
}
|
|
425
567
|
}
|
|
426
568
|
/**
|
|
569
|
+
* The exchanged token for this agent, refreshed on every authenticated
|
|
570
|
+
* connect. Kept in memory for the hot path and mirrored to DO storage so
|
|
571
|
+
* turns that run after hibernation (or without a connection at all) still
|
|
572
|
+
* have it. Short-lived by construction — at most the identity server's
|
|
573
|
+
* token lifetime.
|
|
574
|
+
*/
|
|
575
|
+
_exchangedToken;
|
|
576
|
+
async _storeExchangedToken(token) {
|
|
577
|
+
this._exchangedToken = token;
|
|
578
|
+
await this.ctx.storage.put(EXCHANGED_TOKEN_STORAGE_KEY, token);
|
|
579
|
+
}
|
|
580
|
+
async _getExchangedToken() {
|
|
581
|
+
if (!this._exchangedToken) this._exchangedToken = await this.ctx.storage.get(EXCHANGED_TOKEN_STORAGE_KEY);
|
|
582
|
+
if (this._exchangedToken && this._exchangedToken.expiresAt <= Date.now()) {
|
|
583
|
+
this._exchangedToken = void 0;
|
|
584
|
+
await this.ctx.storage.delete(EXCHANGED_TOKEN_STORAGE_KEY);
|
|
585
|
+
}
|
|
586
|
+
return this._exchangedToken;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Swaps the client-sent token for the exchanged one in a copy of the turn
|
|
590
|
+
* body. No-op when token exchange is not configured. When the exchanged
|
|
591
|
+
* token is missing or expired the turn cannot run: only a reconnect can
|
|
592
|
+
* deliver a fresh subject token, so the current connection (if any) is
|
|
593
|
+
* closed to force one, and the turn fails.
|
|
594
|
+
*/
|
|
595
|
+
async _applyTokenExchange(body) {
|
|
596
|
+
const getTokenExchangeConfig = this.constructor.getTokenExchangeConfig;
|
|
597
|
+
const exchangeConfig = getTokenExchangeConfig?.(this.env);
|
|
598
|
+
if (!exchangeConfig) return body;
|
|
599
|
+
const token = await this._getExchangedToken();
|
|
600
|
+
if (!token) {
|
|
601
|
+
const { connection } = getCurrentAgent();
|
|
602
|
+
if (connection) {
|
|
603
|
+
sendConnectionStatus(connection, "unauthorized");
|
|
604
|
+
connection.close(4001, "Unauthorized");
|
|
605
|
+
}
|
|
606
|
+
throw new Error("Exchanged token missing or expired - the client must reconnect");
|
|
607
|
+
}
|
|
608
|
+
return withExchangedToken(body, exchangeConfig.tokenHeader ?? "X-EconomicToken", token.accessToken);
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
427
611
|
* The user context for the session.
|
|
428
612
|
* Define getUserContext to set a user context.
|
|
429
613
|
*/
|
|
@@ -504,6 +688,7 @@ function ensureChatsTableExists(sql) {
|
|
|
504
688
|
durable_object_name TEXT NOT NULL,
|
|
505
689
|
title TEXT,
|
|
506
690
|
summary TEXT,
|
|
691
|
+
title_set_by_user INTEGER NOT NULL DEFAULT 0,
|
|
507
692
|
created_at INTEGER NOT NULL,
|
|
508
693
|
updated_at INTEGER NOT NULL,
|
|
509
694
|
positive_feedback_count INTEGER NOT NULL DEFAULT 0,
|
|
@@ -511,6 +696,7 @@ function ensureChatsTableExists(sql) {
|
|
|
511
696
|
PRIMARY KEY (durable_object_name)
|
|
512
697
|
)`;
|
|
513
698
|
ensureChatsFeedbackColumnsExist(sql);
|
|
699
|
+
ensureChatsTitleSetByUserColumnExists(sql);
|
|
514
700
|
ensureChatMessageFeedbackTableExists(sql);
|
|
515
701
|
} catch (error) {
|
|
516
702
|
console.error("[Agent] Failed to create chats table", error);
|
|
@@ -524,6 +710,11 @@ function ensureChatsFeedbackColumnsExist(sql) {
|
|
|
524
710
|
sql`ALTER TABLE chats ADD COLUMN negative_feedback_count INTEGER NOT NULL DEFAULT 0`;
|
|
525
711
|
} catch {}
|
|
526
712
|
}
|
|
713
|
+
function ensureChatsTitleSetByUserColumnExists(sql) {
|
|
714
|
+
try {
|
|
715
|
+
sql`ALTER TABLE chats ADD COLUMN title_set_by_user INTEGER NOT NULL DEFAULT 0`;
|
|
716
|
+
} catch {}
|
|
717
|
+
}
|
|
527
718
|
function ensureChatMessageFeedbackTableExists(sql) {
|
|
528
719
|
sql`CREATE TABLE IF NOT EXISTS chat_message_feedback (
|
|
529
720
|
chat_id TEXT NOT NULL,
|
|
@@ -552,6 +743,19 @@ function registerChatWithMetadata(sql, durableObjectName, metadata) {
|
|
|
552
743
|
VALUES (${durableObjectName}, ${title ?? null}, ${summary ?? null}, ${createdAt}, ${updatedAt})
|
|
553
744
|
ON CONFLICT (durable_object_name) DO NOTHING`;
|
|
554
745
|
}
|
|
746
|
+
/**
|
|
747
|
+
* Sets a chat's title on the user's behalf. Marks the title as user-set so
|
|
748
|
+
* the AI summariser stops overwriting it (it keeps updating the summary).
|
|
749
|
+
*/
|
|
750
|
+
function renameChat(sql, durableObjectName, title, dateTime = Date.now()) {
|
|
751
|
+
const trimmed = title.trim();
|
|
752
|
+
if (!trimmed) throw new Error("Chat title cannot be empty");
|
|
753
|
+
sql`UPDATE chats
|
|
754
|
+
SET title = ${trimmed},
|
|
755
|
+
title_set_by_user = 1,
|
|
756
|
+
updated_at = ${dateTime}
|
|
757
|
+
WHERE durable_object_name = ${durableObjectName}`;
|
|
758
|
+
}
|
|
555
759
|
function deleteChat(sql, durableObjectName) {
|
|
556
760
|
sql`DELETE FROM chats WHERE durable_object_name = ${durableObjectName}`;
|
|
557
761
|
sql`DELETE FROM chat_message_feedback WHERE chat_id = ${durableObjectName}`;
|
|
@@ -623,7 +827,7 @@ async function summariseChatWithAI(sql, durableObjectName, messages, model) {
|
|
|
623
827
|
return;
|
|
624
828
|
}
|
|
625
829
|
sql`UPDATE chats
|
|
626
|
-
SET title = ${title},
|
|
830
|
+
SET title = CASE WHEN title_set_by_user = 1 THEN title ELSE ${title} END,
|
|
627
831
|
summary = ${summary},
|
|
628
832
|
updated_at = ${Date.now()}
|
|
629
833
|
WHERE durable_object_name = ${durableObjectName}`;
|
|
@@ -870,6 +1074,11 @@ var Assistant = class extends Agent$1 {
|
|
|
870
1074
|
2,
|
|
871
1075
|
"deleteChat"
|
|
872
1076
|
],
|
|
1077
|
+
[
|
|
1078
|
+
callable(),
|
|
1079
|
+
2,
|
|
1080
|
+
"renameChat"
|
|
1081
|
+
],
|
|
873
1082
|
[
|
|
874
1083
|
callable(),
|
|
875
1084
|
2,
|
|
@@ -933,6 +1142,9 @@ var Assistant = class extends Agent$1 {
|
|
|
933
1142
|
await this.deleteSubAgent(this.agent, id);
|
|
934
1143
|
deleteChat(this.sql.bind(this), id);
|
|
935
1144
|
}
|
|
1145
|
+
async renameChat(id, title) {
|
|
1146
|
+
renameChat(this.sql.bind(this), id, title);
|
|
1147
|
+
}
|
|
936
1148
|
async getChats() {
|
|
937
1149
|
return getChats(this.sql.bind(this));
|
|
938
1150
|
}
|
|
@@ -1350,4 +1562,4 @@ function getCurrentToolContext() {
|
|
|
1350
1562
|
return getCurrentAgent().agent._lastBody;
|
|
1351
1563
|
}
|
|
1352
1564
|
//#endregion
|
|
1353
|
-
export { Agent, Assistant, ChatAgent, getCurrentToolContext, migrateUserFromV1, routeAgentRequest, skill, tool };
|
|
1565
|
+
export { Agent, Assistant, ChatAgent, exchangeToken, getCurrentToolContext, migrateUserFromV1, routeAgentRequest, skill, tool };
|