@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.
- package/dist/{chunk-NSOMFNTK.js → chunk-37VMCQCI.js} +109 -15
- package/dist/chunk-37VMCQCI.js.map +1 -0
- package/dist/{chunk-JOADNB5J.js → chunk-MKN5663J.js} +288 -40
- package/dist/chunk-MKN5663J.js.map +1 -0
- package/dist/control-server.js +2 -2
- package/dist/index.d.ts +72 -1
- package/dist/index.js +2 -2
- package/dist/server.js +1 -1
- package/package.json +2 -2
- package/dist/chunk-JOADNB5J.js.map +0 -1
- package/dist/chunk-NSOMFNTK.js.map +0 -1
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2221
|
-
|
|
2222
|
-
|
|
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
|
-
|
|
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
|
|
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 + "." +
|
|
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-
|
|
3166
|
+
//# sourceMappingURL=chunk-MKN5663J.js.map
|