@frankieai/server 0.2.2 → 0.2.3

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.
@@ -482,8 +482,9 @@ async function buildApiTools(options) {
482
482
  }
483
483
  async function buildOne(source, options) {
484
484
  const doc = await loadSpec(source.spec, options.fetchImpl);
485
+ const clientMode = source.execution === "client";
485
486
  const baseUrl = (source.baseUrl ?? specBaseUrl(doc))?.replace(/\/+$/, "");
486
- if (!baseUrl) {
487
+ if (!baseUrl && !clientMode) {
487
488
  throw new OpenApiError(
488
489
  `API "${source.name}" needs a baseUrl: its spec declares no \`servers\` entry to fall back to.`
489
490
  );
@@ -508,7 +509,7 @@ async function buildOne(source, options) {
508
509
  const body = collectRequestBody(doc, operation);
509
510
  const { headers: securedHeaders, query: securityQuery } = applySecurity(doc, source, operation, schemes);
510
511
  const headers = buildForwardedHeaders({ ...source, headers: securedHeaders }, options.session);
511
- const opBaseUrl = resolveOperationBaseUrl(source, doc, item, operation, baseUrl);
512
+ const opBaseUrl = resolveOperationBaseUrl(source, doc, item, operation, baseUrl ?? "");
512
513
  tools.push({
513
514
  name,
514
515
  label: describeTool(name, {}),
@@ -517,6 +518,27 @@ async function buildOne(source, options) {
517
518
  ${retrySafetyNote(method, doc, item, operation)}`,
518
519
  parameters: Type.Unsafe(toolSchema(params, body)),
519
520
  async execute(_id, args, signal) {
521
+ if (clientMode && options.clientBridge?.available) {
522
+ const clientInput = isRecord(args) ? args : {};
523
+ const relPath = buildRelativePath(path, params, clientInput);
524
+ const clientBody = body ? clientInput["body"] : void 0;
525
+ const result = await options.clientBridge.execute(
526
+ name,
527
+ {
528
+ source: source.name,
529
+ method: method.toUpperCase(),
530
+ path: relPath,
531
+ ...clientBody !== void 0 ? { body: clientBody } : {}
532
+ },
533
+ signal ?? AbortSignal.timeout(source.timeoutMs)
534
+ );
535
+ return formatClientResult(operationId, result, source, options.session);
536
+ }
537
+ if (!opBaseUrl) {
538
+ throw new Error(
539
+ `"${operationId}" runs in the user's browser and has no server base URL, so it can't run over the hosted MCP endpoint or a headless caller. Set a baseUrl (and a user-token forwarding header) to expose it that way too. Report this to the user.`
540
+ );
541
+ }
520
542
  const input = enforceIdentityParams(params, isRecord(args) ? args : {}, options.session);
521
543
  const url = buildUrl(opBaseUrl, path, params, input, securityQuery);
522
544
  const rawBody = body ? input["body"] : void 0;
@@ -531,7 +553,7 @@ ${retrySafetyNote(method, doc, item, operation)}`,
531
553
  ...requestBody !== void 0 ? { body: JSON.stringify(requestBody) } : {},
532
554
  signal: signal ?? AbortSignal.timeout(source.timeoutMs)
533
555
  });
534
- const text = redactSecrets(truncate(await response.text().catch(() => ""), source.maxResponseChars), source, options.session);
556
+ const text = truncate(redactSecrets(await response.text().catch(() => ""), source, options.session), source.maxResponseChars);
535
557
  if (!response.ok) {
536
558
  const detail = `${operationId} failed with HTTP ${response.status}: ${truncate(text, 400)}`;
537
559
  throw new Error(
@@ -691,6 +713,42 @@ function buildUrl(baseUrl, path, params, input, extraQuery = {}) {
691
713
  }
692
714
  return url.toString();
693
715
  }
716
+ function buildRelativePath(path, params, input) {
717
+ let resolved = path;
718
+ for (const param of params) {
719
+ if (param.location !== "path") continue;
720
+ resolved = resolved.replace(`{${param.name}}`, encodeURIComponent(String(input[param.name] ?? "")));
721
+ }
722
+ const query = new URLSearchParams();
723
+ for (const param of params) {
724
+ if (param.location !== "query") continue;
725
+ const value = input[param.name];
726
+ if (value === void 0 || value === null) continue;
727
+ if (Array.isArray(value)) {
728
+ for (const entry of value) query.append(param.name, String(entry));
729
+ } else {
730
+ query.set(param.name, String(value));
731
+ }
732
+ }
733
+ const rel = resolved.startsWith("/") ? resolved.slice(1) : resolved;
734
+ const qs = query.toString();
735
+ return qs ? `${rel}?${qs}` : rel;
736
+ }
737
+ function formatClientResult(operationId, result, source, session) {
738
+ if (result.error) {
739
+ throw new Error(
740
+ `${operationId} could not be reached from the browser: ${truncate(result.error, 300)}. Report this to the user.`
741
+ );
742
+ }
743
+ const text = truncate(redactSecrets(result.body ?? "", source, session), source.maxResponseChars);
744
+ if (result.status < 200 || result.status >= 300) {
745
+ const detail = `${operationId} failed with HTTP ${result.status}: ${truncate(text, 400)}`;
746
+ throw new Error(
747
+ result.status === 401 || result.status === 403 ? `${detail} (authentication/authorization failure). Do not retry by guessing or supplying a different resource ID, header, or credential \u2014 that will not fix an authentication failure. Report the exact error to the user.` : detail
748
+ );
749
+ }
750
+ return { content: [{ type: "text", text: text || "(empty response)" }], details: null };
751
+ }
694
752
  function describeOperation(operation, method, path) {
695
753
  const summary = typeof operation["summary"] === "string" ? operation["summary"] : "";
696
754
  const description = typeof operation["description"] === "string" ? operation["description"] : "";
@@ -766,7 +824,11 @@ function asArray(value) {
766
824
  function isRecord(value) {
767
825
  return typeof value === "object" && value !== null && !Array.isArray(value);
768
826
  }
769
- var IDENTITY_FIELD_PATTERN = /^(user|owner|account|customer|author|created_?by)[_-]?id$/i;
827
+ var IDENTITY_ACTOR = "user|owner|account|customer|author|member|assignee|principal|editor|creator|created_?by|updated_?by|modified_?by";
828
+ var IDENTITY_FIELD_PATTERN = new RegExp(
829
+ `^(?:(?:${IDENTITY_ACTOR})[_-]?id|owner|author|assignee|actor|principal|creator|created_?by|on[_-]?behalf[_-]?of)$`,
830
+ "i"
831
+ );
770
832
  function enforceServerDerivedIdentity(body, session) {
771
833
  return enforceIdentityDeep(body, session);
772
834
  }
@@ -775,7 +837,7 @@ function enforceIdentityDeep(value, session) {
775
837
  if (!isRecord(value)) return value;
776
838
  const next = {};
777
839
  for (const [key, inner] of Object.entries(value)) {
778
- if (IDENTITY_FIELD_PATTERN.test(key)) {
840
+ if (IDENTITY_FIELD_PATTERN.test(key) && !isRecord(inner) && !Array.isArray(inner)) {
779
841
  if (session.user.anonymous) continue;
780
842
  next[key] = session.user.id;
781
843
  } else {
@@ -830,7 +892,7 @@ async function connectMcpTools(options) {
830
892
  if (result.server.selectedTools && !result.server.selectedTools.includes(tool.name)) continue;
831
893
  const name = `${MCP_TOOL_PREFIX}${result.server.name}__${tool.name}`;
832
894
  if (!evaluateToolPolicy2(name, options.policy).allowed) continue;
833
- tools.push(adaptTool(name, tool, result.client, options.callTimeoutMs));
895
+ tools.push(adaptTool(name, tool, result.client, result.server, options.session, options.callTimeoutMs));
834
896
  }
835
897
  } catch (error) {
836
898
  const message = error instanceof Error ? error.message : String(error);
@@ -863,7 +925,34 @@ async function connectOne(server, options) {
863
925
  }
864
926
  return client;
865
927
  }
866
- function adaptTool(name, tool, client, callTimeoutMs = 6e4) {
928
+ function mcpSecrets(server, session) {
929
+ const secrets = [];
930
+ for (const value of Object.values(server.headers ?? {})) {
931
+ if (!value) continue;
932
+ secrets.push(value);
933
+ secrets.push(Buffer.from(value, "utf8").toString("base64"));
934
+ }
935
+ if (session?.userToken) secrets.push(session.userToken);
936
+ return secrets.sort((a, b) => b.length - a.length);
937
+ }
938
+ function redactString(text, secrets) {
939
+ let out = text;
940
+ for (const value of secrets) out = out.split(value).join("[redacted]");
941
+ return out;
942
+ }
943
+ function redactDeep(value, secrets) {
944
+ if (typeof value === "string") return redactString(value, secrets);
945
+ if (Array.isArray(value)) return value.map((entry) => redactDeep(entry, secrets));
946
+ if (value && typeof value === "object") {
947
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactDeep(v, secrets)]));
948
+ }
949
+ return value;
950
+ }
951
+ function redactContent(content, secrets) {
952
+ return content.map((block) => block.type === "text" ? { ...block, text: redactString(block.text, secrets) } : block);
953
+ }
954
+ function adaptTool(name, tool, client, server, session, callTimeoutMs = 6e4) {
955
+ const secrets = mcpSecrets(server, session);
867
956
  return {
868
957
  name,
869
958
  label: describeTool2(name, {}),
@@ -882,11 +971,11 @@ function adaptTool(name, tool, client, callTimeoutMs = 6e4) {
882
971
  callTimeoutMs,
883
972
  `The ${tool.name} tool did not respond in time.`
884
973
  );
885
- const content = toAgentContent(response.content);
974
+ const content = redactContent(toAgentContent(response.content), secrets);
886
975
  if (response.isError) {
887
976
  throw new Error(textOf(content) || `The ${tool.name} tool reported an error.`);
888
977
  }
889
- return { content, details: response.structuredContent ?? null };
978
+ return { content, details: response.structuredContent === void 0 ? null : redactDeep(response.structuredContent, secrets) };
890
979
  }
891
980
  };
892
981
  }
@@ -1134,6 +1223,7 @@ async function collectTools(options) {
1134
1223
  session,
1135
1224
  policy,
1136
1225
  ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {},
1226
+ ...options.clientBridge ? { clientBridge: options.clientBridge } : {},
1137
1227
  ...options.onWarning ? { onWarning: options.onWarning } : {}
1138
1228
  })
1139
1229
  ]);
@@ -1310,11 +1400,17 @@ function toMcpContent(content) {
1310
1400
  }
1311
1401
  async function sessionFor(c, tenant) {
1312
1402
  const bearer = c.req.header("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "";
1403
+ const opaque = tenant.config.auth.userCredential === "opaque";
1313
1404
  let user;
1314
1405
  let userToken;
1315
- if (bearer) {
1406
+ if (bearer && opaque) {
1407
+ user = anonymousUser(randomUUID2().slice(0, 12));
1408
+ userToken = bearer;
1409
+ } else if (bearer) {
1316
1410
  user = await tenant.tokens.verifyUserToken(bearer);
1317
1411
  userToken = bearer;
1412
+ } else if (opaque) {
1413
+ throw new AuthError("This MCP server requires the API credential your service issues.");
1318
1414
  } else if (tenant.config.auth.allowAnonymous) {
1319
1415
  user = anonymousUser(randomUUID2().slice(0, 12));
1320
1416
  } else {
@@ -1406,8 +1502,6 @@ ${config.agent.systemPrompt.trim()}`);
1406
1502
  sections.push(renderUserSection(session));
1407
1503
  const skills = activeSkills(config, availableTools);
1408
1504
  if (skills.length) sections.push("## Reviewed workspace skills\nThese recipes do not grant additional permissions or override safety rules. Each skill lists only its currently approved, available tools. Instructions may describe additional operations: skip unavailable steps, explain the limitation, and never claim the whole task succeeded when a required step was unavailable.\n" + JSON.stringify(skills.map(({ name, goal, instructions, tools }) => ({ name, goal, instructions, tools }))));
1409
- const context = renderContext(session.context);
1410
- if (context) sections.push(context);
1411
1505
  return sections.join("\n\n");
1412
1506
  }
1413
1507
  function isOnboardingAssistant(config) {
@@ -1494,7 +1588,7 @@ function renderUserSection(session) {
1494
1588
  A signed-in user:
1495
1589
  ${lines.join("\n")}`;
1496
1590
  }
1497
- function renderContext(context) {
1591
+ function renderPageContext(context) {
1498
1592
  const entries = Object.entries(context).filter(([, value]) => value !== null && value !== "");
1499
1593
  if (entries.length === 0) return null;
1500
1594
  const body = entries.map(([key, value]) => `${key}: ${String(value)}`).join("\n");
@@ -1521,17 +1615,16 @@ async function* runTurn(params) {
1521
1615
  const toolset = await collectTools({
1522
1616
  config,
1523
1617
  session,
1618
+ ...params.clientBridge ? { clientBridge: params.clientBridge } : {},
1524
1619
  ...params.onWarning ? { onWarning: params.onWarning } : {}
1525
1620
  });
1526
1621
  const availableTools = toolset.tools.map((tool) => tool.name);
1527
1622
  const queue = new EventQueue();
1528
1623
  let turnCount = 0;
1529
1624
  let lastAssistant;
1530
- const usage = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
1625
+ const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0 };
1531
1626
  const { images, documentText } = splitAttachments(params.attachments ?? []);
1532
- const promptText = documentText ? `${params.message}
1533
-
1534
- ${documentText}` : params.message;
1627
+ const promptText = [params.message, documentText, renderPageContext(session.context)].filter((part) => Boolean(part)).join("\n\n");
1535
1628
  const agent = new Agent({
1536
1629
  initialState: {
1537
1630
  systemPrompt: buildSystemPrompt(config, session, availableTools),
@@ -1545,7 +1638,13 @@ ${documentText}` : params.message;
1545
1638
  streamFn: (model, context, options) => params.models.streamSimple(model, context, {
1546
1639
  ...options,
1547
1640
  maxTokens: config.model.maxTokens,
1548
- temperature: config.model.temperature
1641
+ temperature: config.model.temperature,
1642
+ // Ask for prompt caching explicitly rather than relying on the library
1643
+ // default (which an env var can silently turn off). With the session id
1644
+ // the agent already carries, providers that cache by replica affinity
1645
+ // route every turn of a conversation to the same warm replica, and
1646
+ // Anthropic-style providers get cache_control markers on the prefix.
1647
+ cacheRetention: "short"
1549
1648
  }),
1550
1649
  getApiKey: () => params.apiKey,
1551
1650
  sessionId: session.id,
@@ -1576,10 +1675,18 @@ ${documentText}` : params.message;
1576
1675
  usage.inputTokens += event.message.usage.input;
1577
1676
  usage.outputTokens += event.message.usage.output;
1578
1677
  usage.costUsd += event.message.usage.cost?.total ?? 0;
1678
+ usage.cacheReadTokens += event.message.usage.cacheRead ?? 0;
1679
+ usage.cacheWriteTokens += event.message.usage.cacheWrite ?? 0;
1579
1680
  }
1580
1681
  });
1581
1682
  const onAbort = () => agent.abort();
1582
1683
  params.signal.addEventListener("abort", onAbort, { once: true });
1684
+ let transcriptSaved = false;
1685
+ const saveTranscript = () => {
1686
+ if (transcriptSaved) return;
1687
+ transcriptSaved = true;
1688
+ params.onTranscript?.(stripReasoningFromMessages(agent.state.messages));
1689
+ };
1583
1690
  const run = agent.prompt(promptText, images.length ? images : void 0).then(() => void 0).catch((error) => {
1584
1691
  queue.push({
1585
1692
  type: "error",
@@ -1593,13 +1700,16 @@ ${documentText}` : params.message;
1593
1700
  yield { type: "tools_active", apis: toolset.apis };
1594
1701
  for await (const event of queue) yield event;
1595
1702
  await run;
1596
- params.onTranscript?.(stripReasoningFromMessages(agent.state.messages));
1703
+ saveTranscript();
1597
1704
  yield {
1598
1705
  type: "turn_end",
1599
1706
  stopReason: stopReasonFor(params.signal, turnCount, config, lastAssistant),
1600
1707
  ...lastAssistant ? { usage } : {}
1601
1708
  };
1602
1709
  } finally {
1710
+ if (!params.signal.aborted) agent.abort();
1711
+ await run;
1712
+ saveTranscript();
1603
1713
  params.signal.removeEventListener("abort", onAbort);
1604
1714
  unsubscribe();
1605
1715
  await toolset.close();
@@ -1810,7 +1920,7 @@ var EventQueue = class {
1810
1920
  };
1811
1921
 
1812
1922
  // src/app.ts
1813
- import { randomUUID as randomUUID3 } from "crypto";
1923
+ import { randomUUID as randomUUID4 } from "crypto";
1814
1924
  import { Hono as Hono3 } from "hono";
1815
1925
 
1816
1926
  // src/guide.ts
@@ -1904,6 +2014,91 @@ function createGuideApp() {
1904
2014
  return app;
1905
2015
  }
1906
2016
 
2017
+ // src/agent/client-tools.ts
2018
+ import { randomUUID as randomUUID3 } from "crypto";
2019
+ var ClientToolChannel = class {
2020
+ emitters = /* @__PURE__ */ new Map();
2021
+ pending = /* @__PURE__ */ new Map();
2022
+ /** Bind the SSE emitter for a session at the start of its turn. */
2023
+ bindSession(sessionId, emit) {
2024
+ this.emitters.set(sessionId, emit);
2025
+ }
2026
+ /**
2027
+ * Drop a session's emitter at turn end and fail any calls still waiting on the
2028
+ * browser — the stream that would deliver their answer is gone.
2029
+ */
2030
+ releaseSession(sessionId) {
2031
+ this.emitters.delete(sessionId);
2032
+ for (const [key, entry] of this.pending) {
2033
+ if (!key.startsWith(`${sessionId}::`)) continue;
2034
+ this.pending.delete(key);
2035
+ entry.timer && clearTimeout(entry.timer);
2036
+ entry.cleanup();
2037
+ entry.reject(new Error("The chat stream closed before the browser returned a tool result."));
2038
+ }
2039
+ }
2040
+ /** A bridge scoped to one session and turn timeout, handed to the tool builder. */
2041
+ bridge(sessionId, defaultTimeoutMs) {
2042
+ const channel = this;
2043
+ return {
2044
+ get available() {
2045
+ return channel.emitters.has(sessionId);
2046
+ },
2047
+ execute(name, request, signal) {
2048
+ return channel.request(sessionId, name, request, defaultTimeoutMs, signal);
2049
+ }
2050
+ };
2051
+ }
2052
+ request(sessionId, name, request, timeoutMs, signal) {
2053
+ const emit = this.emitters.get(sessionId);
2054
+ if (!emit) {
2055
+ return Promise.reject(
2056
+ new Error(
2057
+ `"${name}" runs in the user's browser, but this session has no live browser channel (for example the hosted MCP endpoint or a headless caller). Report this to the user.`
2058
+ )
2059
+ );
2060
+ }
2061
+ const callId = randomUUID3();
2062
+ const key = `${sessionId}::${callId}`;
2063
+ return new Promise((resolve2, reject) => {
2064
+ const onAbort = () => {
2065
+ this.pending.delete(key);
2066
+ clearTimeout(timer);
2067
+ cleanup();
2068
+ reject(new Error("The turn was aborted before the browser returned a tool result."));
2069
+ };
2070
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
2071
+ const timer = setTimeout(() => {
2072
+ this.pending.delete(key);
2073
+ cleanup();
2074
+ reject(new Error("The browser did not return a tool result in time."));
2075
+ }, timeoutMs);
2076
+ if (signal?.aborted) {
2077
+ clearTimeout(timer);
2078
+ reject(new Error("The turn was already aborted."));
2079
+ return;
2080
+ }
2081
+ signal?.addEventListener("abort", onAbort, { once: true });
2082
+ this.pending.set(key, { resolve: resolve2, reject, timer, cleanup });
2083
+ emit({ type: "tool_call_request", callId, toolUseId: callId, name, request });
2084
+ });
2085
+ }
2086
+ /**
2087
+ * Deliver a browser's outcome to the waiting call. Returns false when no call
2088
+ * is pending for this (session, callId) — a duplicate, late, or forged result.
2089
+ */
2090
+ resolve(sessionId, callId, result) {
2091
+ const key = `${sessionId}::${callId}`;
2092
+ const entry = this.pending.get(key);
2093
+ if (!entry) return false;
2094
+ this.pending.delete(key);
2095
+ clearTimeout(entry.timer);
2096
+ entry.cleanup();
2097
+ entry.resolve(result);
2098
+ return true;
2099
+ }
2100
+ };
2101
+
1907
2102
  // src/app.ts
1908
2103
  var HEARTBEAT_MS = 15e3;
1909
2104
  function createFrankieApp(options) {
@@ -1913,6 +2108,7 @@ function createFrankieApp(options) {
1913
2108
  const registry = options.resolved ? TenantRegistry.single(options.resolved) : options.tenants instanceof TenantRegistry ? options.tenants : new TenantRegistry(options.tenants);
1914
2109
  const sessions = options.sessions ?? new MemorySessionStore();
1915
2110
  const limiter = options.rateLimiter ?? new MemoryRateLimiter();
2111
+ const clientTools = new ClientToolChannel();
1916
2112
  const log = options.logger ?? ((event) => console.log(JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...event })));
1917
2113
  const app = new Hono3();
1918
2114
  app.get("/healthz", (c) => c.json({ ok: true }));
@@ -1924,6 +2120,7 @@ function createFrankieApp(options) {
1924
2120
  if (origin) setCors(c, origin);
1925
2121
  return c.body(null, 204);
1926
2122
  }
2123
+ if (origin) setCors(c, origin);
1927
2124
  const key = c.req.header("x-frankie-key") ?? "";
1928
2125
  let tenant;
1929
2126
  try {
@@ -1942,7 +2139,6 @@ function createFrankieApp(options) {
1942
2139
  if (origin && !isOriginAllowed(origin, tenant.config.auth.allowedOrigins)) {
1943
2140
  return jsonError(c, 403, "forbidden_origin", `Origin ${origin} is not allowed for this tenant.`);
1944
2141
  }
1945
- if (origin) setCors(c, origin);
1946
2142
  c.set("tenant", tenant);
1947
2143
  await next();
1948
2144
  return;
@@ -1971,7 +2167,10 @@ function createFrankieApp(options) {
1971
2167
  },
1972
2168
  modelPicker: {
1973
2169
  enabled: config.features.modelPicker.enabled,
1974
- models: config.features.modelPicker.models.map((entry) => ({ id: entry.id, label: entry.label }))
2170
+ // The workspace default is always selectable, so enabling the picker
2171
+ // shows a working selector even before any extra models are curated —
2172
+ // otherwise an enabled picker with an empty list renders nothing.
2173
+ models: publicPickerModels(config)
1975
2174
  }
1976
2175
  }
1977
2176
  };
@@ -1990,7 +2189,7 @@ function createFrankieApp(options) {
1990
2189
  if (body.userToken) {
1991
2190
  user = await tokens.verifyUserToken(body.userToken);
1992
2191
  } else if (config.auth.allowAnonymous) {
1993
- user = anonymousUser(randomUUID3().slice(0, 12));
2192
+ user = anonymousUser(randomUUID4().slice(0, 12));
1994
2193
  } else {
1995
2194
  return jsonError(c, 401, "unauthorized", "This assistant requires a signed-in user.");
1996
2195
  }
@@ -2177,19 +2376,21 @@ function createFrankieApp(options) {
2177
2376
  if (config.agent.maxBudgetUsd && session.costUsd >= config.agent.maxBudgetUsd) {
2178
2377
  return jsonError(c, 402, "budget_exceeded", "This conversation has reached its limit.");
2179
2378
  }
2180
- if (body.context) {
2181
- await sessions.update(session.id, { context: { ...session.context, ...sanitizeContext(body.context) } });
2182
- }
2183
- await sessions.update(session.id, {
2379
+ const patch = {
2184
2380
  busy: true,
2185
2381
  messageCount: session.messageCount + 1,
2382
+ ...body.context ? { context: { ...session.context, ...sanitizeContext(body.context) } } : {},
2186
2383
  ...!session.title ? { title: titleFromText(body.message) } : {}
2187
- });
2384
+ };
2385
+ await sessions.update(session.id, patch);
2386
+ const current = { ...session, ...patch };
2188
2387
  const abort = new AbortController();
2189
2388
  const timeout = setTimeout(() => abort.abort(), config.limits.turnTimeoutMs);
2190
2389
  c.req.raw.signal?.addEventListener("abort", () => abort.abort());
2191
2390
  const modelOverride = body.model && body.model !== config.model.model && config.features.modelPicker.enabled ? resolveModelChoice(models, config, body.model) ?? void 0 : void 0;
2192
- const current = await sessions.get(session.id) ?? session;
2391
+ let finalMessages;
2392
+ let turnCostUsd = 0;
2393
+ const clientBridge = clientTools.bridge(session.id, config.limits.turnTimeoutMs);
2193
2394
  const events = runTurn({
2194
2395
  config,
2195
2396
  session: current,
@@ -2197,11 +2398,12 @@ function createFrankieApp(options) {
2197
2398
  apiKey: secrets.providerApiKey,
2198
2399
  model,
2199
2400
  models,
2401
+ clientBridge,
2200
2402
  ...modelOverride ? { modelOverride } : {},
2201
2403
  ...attachments.length ? { attachments } : {},
2202
2404
  signal: abort.signal,
2203
2405
  onTranscript: (messages) => {
2204
- void sessions.update(session.id, { messages });
2406
+ finalMessages = messages;
2205
2407
  },
2206
2408
  onWarning: (event, detail) => log({ level: "warn", event, sessionId: session.id, ...detail })
2207
2409
  });
@@ -2214,14 +2416,13 @@ function createFrankieApp(options) {
2214
2416
  `));
2215
2417
  };
2216
2418
  const heartbeat = setInterval(() => send({ type: "ping" }), HEARTBEAT_MS);
2419
+ clientTools.bindSession(session.id, send);
2217
2420
  send({ type: "session", sessionId: session.id, resumed: current.messages.length > 0 });
2218
2421
  try {
2219
2422
  for await (const event of events) {
2220
- if (event.type === "turn_end" && event.usage?.costUsd !== void 0) {
2221
- const latest = await sessions.get(session.id);
2222
- await sessions.update(session.id, {
2223
- costUsd: (latest?.costUsd ?? current.costUsd) + event.usage.costUsd
2224
- });
2423
+ if (event.type === "turn_end" && event.usage) {
2424
+ if (event.usage.costUsd !== void 0) turnCostUsd += event.usage.costUsd;
2425
+ log({ level: "info", event: "turn_usage", sessionId: session.id, ...event.usage });
2225
2426
  }
2226
2427
  send(event);
2227
2428
  }
@@ -2236,14 +2437,18 @@ function createFrankieApp(options) {
2236
2437
  } finally {
2237
2438
  clearInterval(heartbeat);
2238
2439
  clearTimeout(timeout);
2239
- await sessions.update(session.id, { busy: false });
2440
+ clientTools.releaseSession(session.id);
2441
+ await sessions.update(session.id, {
2442
+ busy: false,
2443
+ ...finalMessages ? { messages: finalMessages } : {},
2444
+ ...turnCostUsd ? { costUsd: current.costUsd + turnCostUsd } : {}
2445
+ });
2240
2446
  controller.close();
2241
2447
  }
2242
2448
  },
2243
2449
  cancel() {
2244
2450
  clearTimeout(timeout);
2245
2451
  abort.abort();
2246
- void sessions.update(session.id, { busy: false });
2247
2452
  }
2248
2453
  });
2249
2454
  const origin = c.req.header("origin");
@@ -2257,13 +2462,46 @@ function createFrankieApp(options) {
2257
2462
  }
2258
2463
  });
2259
2464
  });
2465
+ app.post("/v1/chat/tool-result", async (c) => {
2466
+ const { tokens } = c.get("tenant");
2467
+ const bearer = c.req.header("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
2468
+ let claims;
2469
+ try {
2470
+ claims = await tokens.verifySessionToken(bearer);
2471
+ } catch (error) {
2472
+ return jsonError(c, 401, "unauthorized", error instanceof AuthError ? error.message : "Unauthorized.");
2473
+ }
2474
+ const body = await readJson(c);
2475
+ if (!body || typeof body.callId !== "string" || !isHostToolResult(body.result)) {
2476
+ return jsonError(c, 400, "internal", "A tool result needs a `callId` and a `result`.");
2477
+ }
2478
+ const delivered = clientTools.resolve(claims.sessionId, body.callId, body.result);
2479
+ if (!delivered) {
2480
+ return jsonError(c, 404, "session_not_found", "No tool call is awaiting this result.");
2481
+ }
2482
+ return c.json({ ok: true });
2483
+ });
2260
2484
  return app;
2261
2485
  }
2486
+ function isHostToolResult(value) {
2487
+ if (typeof value !== "object" || value === null) return false;
2488
+ const result = value;
2489
+ if (typeof result["status"] !== "number") return false;
2490
+ if (result["body"] !== void 0 && typeof result["body"] !== "string") return false;
2491
+ if (result["error"] !== void 0 && typeof result["error"] !== "string") return false;
2492
+ return true;
2493
+ }
2262
2494
  function mimeAccepted(mimeType, accept) {
2263
2495
  return accept.some(
2264
2496
  (pattern) => pattern.endsWith("/*") ? mimeType.startsWith(pattern.slice(0, -1)) : pattern === mimeType
2265
2497
  );
2266
2498
  }
2499
+ function publicPickerModels(config) {
2500
+ const curated = config.features.modelPicker.models.map((entry) => ({ id: entry.id, label: entry.label }));
2501
+ if (!config.features.modelPicker.enabled) return curated;
2502
+ if (curated.some((model) => model.id === config.model.model)) return curated;
2503
+ return [{ id: config.model.model, label: "Default" }, ...curated];
2504
+ }
2267
2505
  function base64Bytes(data) {
2268
2506
  const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
2269
2507
  return Math.floor(data.length * 3 / 4) - padding;
@@ -2354,10 +2592,14 @@ function titleFromClientMessages(messages) {
2354
2592
 
2355
2593
  // src/control/store.ts
2356
2594
  import { mkdir, readFile as readFile2, readdir, rename, rm, writeFile } from "fs/promises";
2357
- import { randomUUID as randomUUID4 } from "crypto";
2595
+ import { randomUUID as randomUUID5 } from "crypto";
2358
2596
  import { join, resolve } from "path";
2359
2597
  import { parseFrankieConfig } from "@frankieai/core";
2360
2598
  var MEMBERS_FILE = "__members.json";
2599
+ function publicVercelLink(link) {
2600
+ const { accessToken: _token, ...rest } = link;
2601
+ return rest;
2602
+ }
2361
2603
  function toPublicTenant(record) {
2362
2604
  const { config } = record;
2363
2605
  return {
@@ -2371,6 +2613,7 @@ function toPublicTenant(record) {
2371
2613
  publishableKey: config.auth.publishableKey,
2372
2614
  allowedOrigins: config.auth.allowedOrigins,
2373
2615
  allowAnonymous: config.auth.allowAnonymous,
2616
+ userCredential: config.auth.userCredential,
2374
2617
  connection: config.apis.length > 0 ? "openapi" : config.mcpServers.length > 0 ? "mcp" : "knowledge",
2375
2618
  mcpServers: config.mcpServers.map((s) => s.name),
2376
2619
  apis: config.apis.map((a) => a.name),
@@ -2378,6 +2621,7 @@ function toPublicTenant(record) {
2378
2621
  ...config.mcpServers.map((s) => ({
2379
2622
  kind: "mcp",
2380
2623
  name: s.name,
2624
+ ...s.displayName ? { displayName: s.displayName } : {},
2381
2625
  url: s.url,
2382
2626
  discoveredTools: s.discoveredTools,
2383
2627
  ...s.selectedTools ? { selectedTools: s.selectedTools } : {},
@@ -2388,13 +2632,16 @@ function toPublicTenant(record) {
2388
2632
  ...config.apis.map((a) => ({
2389
2633
  kind: "openapi",
2390
2634
  name: a.name,
2635
+ ...a.displayName ? { displayName: a.displayName } : {},
2391
2636
  spec: a.spec,
2392
2637
  ...a.baseUrl ? { baseUrl: a.baseUrl } : {},
2393
2638
  methods: a.methods,
2394
2639
  ...a.selectedTools ? { selectedTools: a.selectedTools } : {},
2395
2640
  exposeAsMcp: a.exposeAsMcp,
2641
+ execution: a.execution,
2396
2642
  capabilities: a.capabilities.map((capability) => ({ ...capability })),
2397
2643
  forwardsToken: Boolean(a.forwardUserTokenAs),
2644
+ ...a.forwardUserTokenAs ? { forwardTokenAs: a.forwardUserTokenAs } : {},
2398
2645
  securedSchemes: Object.keys(a.security ?? {}),
2399
2646
  enabled: a.enabled && config.agent.tools.allow.some((entry) => entry === `api__${a.name}` || entry.startsWith(`api__${a.name}__`)),
2400
2647
  status: a.capabilities.length ? "ready" : "needs_attention"
@@ -2402,6 +2649,7 @@ function toPublicTenant(record) {
2402
2649
  ],
2403
2650
  locked: record.locked ?? false,
2404
2651
  ...record.repositoryInstall ? { repositoryInstall: record.repositoryInstall } : {},
2652
+ ...record.vercel ? { vercel: publicVercelLink(record.vercel) } : {},
2405
2653
  theme: config.theme,
2406
2654
  features: config.features,
2407
2655
  createdAt: record.createdAt,
@@ -2436,7 +2684,7 @@ var FileTenantStore = class _FileTenantStore {
2436
2684
  async putPlatformAdmin(admin) {
2437
2685
  const path = this.adminPath(admin.ownerId);
2438
2686
  await mkdir(join(this.dir, "platform-admins"), { recursive: true });
2439
- const temporary = path + "." + randomUUID4() + ".tmp";
2687
+ const temporary = path + "." + randomUUID5() + ".tmp";
2440
2688
  await writeFile(temporary, JSON.stringify(admin), { mode: 384 });
2441
2689
  await rename(temporary, path);
2442
2690
  }
@@ -2915,4 +3163,4 @@ export {
2915
3163
  resolveStores,
2916
3164
  resolveTenantStore
2917
3165
  };
2918
- //# sourceMappingURL=chunk-JOADNB5J.js.map
3166
+ //# sourceMappingURL=chunk-MKN5663J.js.map