@taicho-ai/framework 0.1.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 +4 -0
- package/package.json +48 -0
- package/src/coaching/patterns.ts +103 -0
- package/src/coaching/proposal.ts +29 -0
- package/src/coaching/retrieval.ts +27 -0
- package/src/coaching/supersede.ts +14 -0
- package/src/coaching/teach.ts +74 -0
- package/src/core/agent-status.ts +79 -0
- package/src/core/auth/constants.ts +53 -0
- package/src/core/auth/login.ts +110 -0
- package/src/core/auth/pkce.ts +18 -0
- package/src/core/auth/profile.ts +38 -0
- package/src/core/auth/refresh.ts +57 -0
- package/src/core/auth/status.ts +26 -0
- package/src/core/command-guard.ts +126 -0
- package/src/core/conversation-artifacts.ts +40 -0
- package/src/core/conversation-replay.ts +160 -0
- package/src/core/costs.ts +97 -0
- package/src/core/discovery.ts +22 -0
- package/src/core/draft.ts +6 -0
- package/src/core/e2e-model.ts +315 -0
- package/src/core/embed.ts +221 -0
- package/src/core/events.ts +133 -0
- package/src/core/firecrawl.ts +25 -0
- package/src/core/headless.ts +260 -0
- package/src/core/instrument.ts +88 -0
- package/src/core/logger.ts +143 -0
- package/src/core/mcp/adapter.ts +34 -0
- package/src/core/mcp/manager.ts +145 -0
- package/src/core/mcp/oauth.ts +119 -0
- package/src/core/memory.ts +13 -0
- package/src/core/mock-model.ts +70 -0
- package/src/core/model.ts +91 -0
- package/src/core/pricing.ts +27 -0
- package/src/core/providers/openai-codex.ts +67 -0
- package/src/core/registry.ts +67 -0
- package/src/core/run.ts +909 -0
- package/src/core/schedule-cli.ts +55 -0
- package/src/core/scheduler.ts +356 -0
- package/src/core/tasks.ts +108 -0
- package/src/core/team-cli.ts +118 -0
- package/src/core/team-routing.ts +58 -0
- package/src/core/tools.ts +1104 -0
- package/src/core/turn-audit.ts +100 -0
- package/src/core/verification.ts +102 -0
- package/src/core/workflow-run.ts +119 -0
- package/src/index.ts +8 -0
- package/src/knowledge/retrieval.ts +73 -0
- package/src/knowledge/sync.ts +28 -0
- package/src/skills/retrieval.ts +22 -0
- package/src/store/annotations.ts +107 -0
- package/src/store/artifacts.ts +338 -0
- package/src/store/config.ts +187 -0
- package/src/store/conversation.ts +107 -0
- package/src/store/db.ts +34 -0
- package/src/store/files.ts +51 -0
- package/src/store/knowledge.ts +201 -0
- package/src/store/mcp-store.ts +65 -0
- package/src/store/migrate.ts +278 -0
- package/src/store/plans.ts +260 -0
- package/src/store/policy.ts +66 -0
- package/src/store/prefs.ts +64 -0
- package/src/store/roster.ts +308 -0
- package/src/store/run-transcript.ts +103 -0
- package/src/store/schedules.ts +94 -0
- package/src/store/seed-skills.ts +75 -0
- package/src/store/skills.ts +89 -0
- package/src/store/sources.ts +58 -0
- package/src/store/spend-ledger.ts +114 -0
- package/src/store/task-state.ts +267 -0
- package/src/store/teams.ts +227 -0
- package/src/store/thread.ts +72 -0
- package/src/store/trace.ts +98 -0
- package/src/store/vectors.ts +26 -0
- package/src/store/workflows.ts +144 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Connects to MCP servers and exposes their tools to agents. Dynamically mutable so the `/mcp`
|
|
2
|
+
* command works without a restart: add/remove/login/reconnect mutate the live connection map, and
|
|
3
|
+
* because toolsForAgent reads the manager at run time, changes are picked up on the next run.
|
|
4
|
+
* A server that fails to connect is recorded (status) and skipped — it never crashes the REPL. */
|
|
5
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
|
+
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
9
|
+
import type { ToolSet } from "ai";
|
|
10
|
+
import { isStdioServer, interpolateEnv, type McpServerConfig } from "../../store/config";
|
|
11
|
+
import { mcpToolToAiTool, type McpCallResult } from "./adapter";
|
|
12
|
+
import { createMcpOAuthProvider, runMcpOAuth, defaultOpenBrowser, McpAuthRequiredError } from "./oauth";
|
|
13
|
+
|
|
14
|
+
export type McpStatus = "connected" | "error" | "needs-auth";
|
|
15
|
+
export interface McpServerStatus { name: string; kind: "stdio" | "http"; status: McpStatus; toolCount: number; error?: string }
|
|
16
|
+
|
|
17
|
+
export interface McpManager {
|
|
18
|
+
/** Resolve a per-agent MCP grant ("server" or "server/tool", from an agent's `mcp:<…>` tool ref)
|
|
19
|
+
* to namespaced AI-SDK tools. This is the ONLY way MCP tools reach an agent — there is no blanket
|
|
20
|
+
* "all tools to all agents" grant (Plan 08 security hardening). */
|
|
21
|
+
toolsForRef(ref: string): ToolSet;
|
|
22
|
+
list(): McpServerStatus[];
|
|
23
|
+
addServer(name: string, spec: McpServerConfig): Promise<McpServerStatus>;
|
|
24
|
+
removeServer(name: string): Promise<boolean>;
|
|
25
|
+
login(name: string): Promise<McpServerStatus>;
|
|
26
|
+
reconnect(name: string): Promise<McpServerStatus>;
|
|
27
|
+
closeAll(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Entry { spec: McpServerConfig; kind: "stdio" | "http"; client?: Client; status: McpStatus; toolCount: number; error?: string; set: ToolSet }
|
|
31
|
+
|
|
32
|
+
const sanitize = (s: string): string => s.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
33
|
+
// Namespaced + capped at 64 chars (the model-provider tool-name limit) to avoid a call-time 400.
|
|
34
|
+
const toolKey = (server: string, tool: string): string => `${sanitize(server)}_${sanitize(tool)}`.slice(0, 64);
|
|
35
|
+
|
|
36
|
+
export interface McpManagerOptions {
|
|
37
|
+
ws: string;
|
|
38
|
+
servers: Record<string, McpServerConfig>;
|
|
39
|
+
onUrl?: (url: string) => void; // print the OAuth URL (paste fallback)
|
|
40
|
+
openBrowser?: (url: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function createMcpManager(opts: McpManagerOptions): Promise<McpManager> {
|
|
44
|
+
const entries = new Map<string, Entry>();
|
|
45
|
+
const openBrowser = opts.openBrowser ?? defaultOpenBrowser;
|
|
46
|
+
|
|
47
|
+
/** Connect one server. interactive=true permits the OAuth browser flow; false (boot) refuses it. */
|
|
48
|
+
async function connectOne(name: string, spec: McpServerConfig, interactive: boolean): Promise<Entry> {
|
|
49
|
+
const kind: Entry["kind"] = isStdioServer(spec) ? "stdio" : "http";
|
|
50
|
+
const client = new Client({ name: "taicho", version: "0.0.1" }, { capabilities: {} });
|
|
51
|
+
try {
|
|
52
|
+
if (isStdioServer(spec)) {
|
|
53
|
+
const env = Object.fromEntries(Object.entries(spec.env ?? {}).map(([k, v]) => [k, interpolateEnv(v)]));
|
|
54
|
+
await client.connect(new StdioClientTransport({ command: spec.command, args: spec.args, env: { ...getDefaultEnvironment(), ...env } }));
|
|
55
|
+
} else if (spec.auth === "oauth") {
|
|
56
|
+
// Fresh provider+transport per attempt — the OAuth reconnect after finishAuth needs a new
|
|
57
|
+
// transport (the first one is already started), so runMcpOAuth calls makeTransport twice.
|
|
58
|
+
const makeTransport = () => new StreamableHTTPClientTransport(new URL(interpolateEnv(spec.url)), {
|
|
59
|
+
authProvider: createMcpOAuthProvider({
|
|
60
|
+
ws: opts.ws,
|
|
61
|
+
serverName: name,
|
|
62
|
+
redirectToAuthorization: interactive
|
|
63
|
+
? (url) => { opts.onUrl?.(url.toString()); openBrowser(url.toString()); }
|
|
64
|
+
: () => { throw new McpAuthRequiredError(name); },
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
if (interactive) await runMcpOAuth({ makeTransport, connect: (t) => client.connect(t) });
|
|
68
|
+
else await client.connect(makeTransport());
|
|
69
|
+
} else {
|
|
70
|
+
const headers = Object.fromEntries(Object.entries(spec.headers ?? {}).map(([k, v]) => [k, interpolateEnv(v)]));
|
|
71
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(interpolateEnv(spec.url)), { requestInit: { headers } }));
|
|
72
|
+
}
|
|
73
|
+
const { tools } = await client.listTools();
|
|
74
|
+
const set: ToolSet = {};
|
|
75
|
+
for (const t of tools) {
|
|
76
|
+
set[t.name] = mcpToolToAiTool(
|
|
77
|
+
async (n, args) => (await client.callTool({ name: n, arguments: args })) as unknown as McpCallResult,
|
|
78
|
+
{ name: t.name, description: t.description, inputSchema: t.inputSchema },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return { spec, kind, client, status: "connected", toolCount: tools.length, set };
|
|
82
|
+
} catch (e) {
|
|
83
|
+
await client.close().catch(() => {});
|
|
84
|
+
const needsAuth = e instanceof McpAuthRequiredError || e instanceof UnauthorizedError;
|
|
85
|
+
return { spec, kind, status: needsAuth ? "needs-auth" : "error", toolCount: 0, error: e instanceof Error ? e.message : String(e), set: {} };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function set(name: string, spec: McpServerConfig, interactive: boolean): Promise<McpServerStatus> {
|
|
90
|
+
const prev = entries.get(name);
|
|
91
|
+
if (prev?.client) await prev.client.close().catch(() => {});
|
|
92
|
+
// Hold a transient (non-connected) slot during the async connect so a concurrent toolsForRef
|
|
93
|
+
// never hands an agent the just-closed client.
|
|
94
|
+
entries.set(name, { spec, kind: isStdioServer(spec) ? "stdio" : "http", status: "error", toolCount: 0, error: "connecting…", set: {} });
|
|
95
|
+
const e = await connectOne(name, spec, interactive);
|
|
96
|
+
entries.set(name, e);
|
|
97
|
+
return statusOf(name, e);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function statusOf(name: string, e: Entry): McpServerStatus {
|
|
101
|
+
return { name, kind: e.kind, status: e.status, toolCount: e.toolCount, error: e.error };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Connect everything concurrently at boot (non-interactive).
|
|
105
|
+
await Promise.all(Object.entries(opts.servers).map(([name, spec]) =>
|
|
106
|
+
connectOne(name, spec, false).then((e) => { entries.set(name, e); })));
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
toolsForRef(ref) {
|
|
110
|
+
const slash = ref.indexOf("/");
|
|
111
|
+
const server = slash === -1 ? ref : ref.slice(0, slash);
|
|
112
|
+
const toolName = slash === -1 ? undefined : ref.slice(slash + 1);
|
|
113
|
+
const e = entries.get(server);
|
|
114
|
+
if (!e || e.status !== "connected") return {};
|
|
115
|
+
if (toolName) return e.set[toolName] ? { [toolKey(server, toolName)]: e.set[toolName] } : {};
|
|
116
|
+
const out: ToolSet = {};
|
|
117
|
+
for (const [n, t] of Object.entries(e.set)) out[toolKey(server, n)] = t;
|
|
118
|
+
return out;
|
|
119
|
+
},
|
|
120
|
+
list() {
|
|
121
|
+
return [...entries.entries()].map(([name, e]) => statusOf(name, e));
|
|
122
|
+
},
|
|
123
|
+
addServer(name, spec) { return set(name, spec, /*interactive*/ true); },
|
|
124
|
+
login(name) {
|
|
125
|
+
const e = entries.get(name);
|
|
126
|
+
if (!e) return Promise.reject(new Error(`no MCP server "${name}"`));
|
|
127
|
+
return set(name, e.spec, /*interactive*/ true);
|
|
128
|
+
},
|
|
129
|
+
reconnect(name) {
|
|
130
|
+
const e = entries.get(name);
|
|
131
|
+
if (!e) return Promise.reject(new Error(`no MCP server "${name}"`));
|
|
132
|
+
return set(name, e.spec, /*interactive*/ false);
|
|
133
|
+
},
|
|
134
|
+
async removeServer(name) {
|
|
135
|
+
const e = entries.get(name);
|
|
136
|
+
if (!e) return false;
|
|
137
|
+
if (e.client) await e.client.close().catch(() => {});
|
|
138
|
+
entries.delete(name);
|
|
139
|
+
return true;
|
|
140
|
+
},
|
|
141
|
+
async closeAll() {
|
|
142
|
+
await Promise.all([...entries.values()].map((e) => e.client?.close().catch(() => {})));
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/** OAuth for remote MCP servers. A file-backed OAuthClientProvider (the SDK drives discovery, PKCE,
|
|
2
|
+
* and dynamic client registration) + a loopback-callback connect helper that mirrors taicho's
|
|
3
|
+
* ChatGPT OAuth (core/auth/login.ts): a Bun.serve on localhost catches the redirect code. Tokens
|
|
4
|
+
* persist per server under agents/.mcp/ (gitignored) at mode 0600 and are never logged. */
|
|
5
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
8
|
+
import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
9
|
+
import type { OAuthTokens, OAuthClientMetadata, OAuthClientInformationMixed } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
10
|
+
|
|
11
|
+
export const MCP_OAUTH_PORT = 1456; // distinct from the ChatGPT login port (1455)
|
|
12
|
+
export const MCP_OAUTH_PATH = "/oauth/callback";
|
|
13
|
+
export const mcpRedirectUrl = (port = MCP_OAUTH_PORT): string => `http://localhost:${port}${MCP_OAUTH_PATH}`;
|
|
14
|
+
|
|
15
|
+
/** Thrown (instead of opening a browser) when a connect needs interactive auth in a non-interactive
|
|
16
|
+
* context (boot). The user then runs `/mcp login <server>`. */
|
|
17
|
+
export class McpAuthRequiredError extends Error {
|
|
18
|
+
constructor(public server: string) { super(`MCP server "${server}" needs sign-in — run /mcp login ${server}`); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface OAuthState { tokens?: OAuthTokens; clientInformation?: OAuthClientInformationMixed; codeVerifier?: string }
|
|
22
|
+
|
|
23
|
+
const stateDir = (ws: string): string => join(ws, "agents", ".mcp");
|
|
24
|
+
const statePath = (ws: string, server: string): string => join(stateDir(ws), `${server}-oauth.json`);
|
|
25
|
+
|
|
26
|
+
function readState(ws: string, server: string): OAuthState {
|
|
27
|
+
const f = statePath(ws, server);
|
|
28
|
+
if (!existsSync(f)) return {};
|
|
29
|
+
try { return JSON.parse(readFileSync(f, "utf8")) as OAuthState; } catch { return {}; }
|
|
30
|
+
}
|
|
31
|
+
function writeState(ws: string, server: string, patch: Partial<OAuthState>): void {
|
|
32
|
+
mkdirSync(stateDir(ws), { recursive: true });
|
|
33
|
+
writeFileSync(statePath(ws, server), JSON.stringify({ ...readState(ws, server), ...patch }, null, 2), { mode: 0o600 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Build the provider. `redirectToAuthorization` is injected so boot can refuse (throw
|
|
37
|
+
* McpAuthRequiredError) while `/mcp login` opens the browser. Storage is shared via files, so
|
|
38
|
+
* provider instances are interchangeable across attempts. */
|
|
39
|
+
export function createMcpOAuthProvider(opts: {
|
|
40
|
+
ws: string;
|
|
41
|
+
serverName: string;
|
|
42
|
+
redirectToAuthorization: (url: URL) => void;
|
|
43
|
+
redirectPort?: number;
|
|
44
|
+
}): OAuthClientProvider {
|
|
45
|
+
const { ws, serverName } = opts;
|
|
46
|
+
return {
|
|
47
|
+
get redirectUrl() { return mcpRedirectUrl(opts.redirectPort); },
|
|
48
|
+
get clientMetadata(): OAuthClientMetadata {
|
|
49
|
+
return {
|
|
50
|
+
client_name: "taicho",
|
|
51
|
+
redirect_uris: [mcpRedirectUrl(opts.redirectPort)],
|
|
52
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
53
|
+
response_types: ["code"],
|
|
54
|
+
token_endpoint_auth_method: "none",
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
clientInformation() { return readState(ws, serverName).clientInformation; },
|
|
58
|
+
saveClientInformation(info) { writeState(ws, serverName, { clientInformation: info }); },
|
|
59
|
+
tokens() { return readState(ws, serverName).tokens; },
|
|
60
|
+
saveTokens(t) { writeState(ws, serverName, { tokens: t }); },
|
|
61
|
+
redirectToAuthorization(url) { opts.redirectToAuthorization(url); },
|
|
62
|
+
saveCodeVerifier(v) { writeState(ws, serverName, { codeVerifier: v }); },
|
|
63
|
+
codeVerifier() {
|
|
64
|
+
const v = readState(ws, serverName).codeVerifier;
|
|
65
|
+
if (!v) throw new Error("no PKCE code_verifier saved for MCP OAuth");
|
|
66
|
+
return v;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Interactive flow: start the loopback server, connect (the SDK opens the browser via
|
|
72
|
+
* redirectToAuthorization), capture the code, finishAuth, then RECONNECT WITH A FRESH TRANSPORT.
|
|
73
|
+
* The reconnect must use a new transport: the first connect started the original one, and the
|
|
74
|
+
* StreamableHTTP transport's close() aborts but never clears its started flag, so reusing it
|
|
75
|
+
* throws "already started". `makeTransport` produces a fresh transport each call; `connect`
|
|
76
|
+
* reconnects the same Client (its transport was cleared by the failed connect's close()). */
|
|
77
|
+
export async function runMcpOAuth(opts: {
|
|
78
|
+
makeTransport: () => StreamableHTTPClientTransport;
|
|
79
|
+
connect: (transport: StreamableHTTPClientTransport) => Promise<void>;
|
|
80
|
+
redirectPort?: number;
|
|
81
|
+
timeoutMs?: number;
|
|
82
|
+
}): Promise<void> {
|
|
83
|
+
const port = opts.redirectPort ?? MCP_OAUTH_PORT;
|
|
84
|
+
let resolveCode!: (c: string) => void;
|
|
85
|
+
let rejectFlow!: (e: Error) => void;
|
|
86
|
+
const codeP = new Promise<string>((res, rej) => { resolveCode = res; rejectFlow = rej; });
|
|
87
|
+
const server = Bun.serve({
|
|
88
|
+
port,
|
|
89
|
+
fetch(req) {
|
|
90
|
+
const u = new URL(req.url);
|
|
91
|
+
if (u.pathname === MCP_OAUTH_PATH) {
|
|
92
|
+
const err = u.searchParams.get("error");
|
|
93
|
+
if (err) { rejectFlow(new Error(u.searchParams.get("error_description") ?? err)); return new Response("authorization failed", { status: 400 }); }
|
|
94
|
+
const code = u.searchParams.get("code");
|
|
95
|
+
if (code) { resolveCode(code); return new Response("taicho: MCP server authorized — you can close this tab."); }
|
|
96
|
+
}
|
|
97
|
+
return new Response("not found", { status: 404 });
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const timer = setTimeout(() => rejectFlow(new Error("MCP OAuth timed out")), opts.timeoutMs ?? 120_000);
|
|
101
|
+
try {
|
|
102
|
+
const first = opts.makeTransport();
|
|
103
|
+
try { await opts.connect(first); return; } catch (e) { if (!(e instanceof UnauthorizedError)) throw e; }
|
|
104
|
+
const code = await codeP;
|
|
105
|
+
await first.finishAuth(code); // exchanges code -> tokens (persisted via the provider); HTTP only
|
|
106
|
+
await opts.connect(opts.makeTransport()); // fresh transport; tokens now on disk
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
server.stop(true);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function defaultOpenBrowser(url: string): void {
|
|
114
|
+
// `start` is a cmd.exe builtin, so on Windows it must be invoked via `cmd /c start "" <url>`.
|
|
115
|
+
const argv = process.platform === "darwin" ? ["open", url]
|
|
116
|
+
: process.platform === "win32" ? ["cmd", "/c", "start", "", url]
|
|
117
|
+
: ["xdg-open", url];
|
|
118
|
+
try { Bun.spawn(argv, { stdout: "ignore", stderr: "ignore" }); } catch { /* fall back to the printed URL */ }
|
|
119
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** A read-only digest of an agent's recent runs, injected into its prompt context so workers
|
|
2
|
+
* retain continuity across runs (no per-worker conversation thread needed for v1). */
|
|
3
|
+
import { listTraces } from "../store/trace";
|
|
4
|
+
|
|
5
|
+
export function recentRunsDigest(ws: string, agentId: string, k = 5): string | undefined {
|
|
6
|
+
const traces = listTraces(ws, agentId);
|
|
7
|
+
if (!traces.length) return undefined;
|
|
8
|
+
const recent = traces.slice(-k).reverse(); // newest first
|
|
9
|
+
const lines = recent.map(
|
|
10
|
+
(t) => `- ${t.task} → ${t.outcome}${t.artifacts.length ? ` (artifacts: ${t.artifacts.join(", ")})` : ""}`,
|
|
11
|
+
);
|
|
12
|
+
return `## Your recent runs\n${lines.join("\n")}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Test-only helper. Plan 07 unified the loop on `streamText`, so the AI SDK now drives every model
|
|
2
|
+
* via `doStream` (not `doGenerate`). Tests still script models as text / tool-call GENERATE results,
|
|
3
|
+
* so this `MockLanguageModelV3` subclass auto-derives a streaming `doStream` from a `doGenerate`
|
|
4
|
+
* script — converting each result into the equivalent LanguageModelV3 stream parts, which streamText
|
|
5
|
+
* drains back to the same aggregated fields (text, usage, toolCalls, providerMetadata). It still runs
|
|
6
|
+
* the script through the recording `doGenerate` wrapper, so `.doGenerateCalls[i].prompt` / `.length`
|
|
7
|
+
* assertions keep working unchanged. A test that passes `doStream` directly (the subscription/Codex
|
|
8
|
+
* shape) is left exactly as the real mock handles it.
|
|
9
|
+
*
|
|
10
|
+
* Usage: swap `from "ai/test"` for `from "./mock-model"` in a test's imports — nothing else changes.
|
|
11
|
+
* `mockValues` and `simulateReadableStream` are re-exported so that single path move is enough. */
|
|
12
|
+
import { MockLanguageModelV3 as RealMockLanguageModelV3, mockValues, simulateReadableStream } from "ai/test";
|
|
13
|
+
import type { LanguageModelV3GenerateResult } from "@ai-sdk/provider";
|
|
14
|
+
|
|
15
|
+
export { mockValues, simulateReadableStream };
|
|
16
|
+
|
|
17
|
+
const DEFAULT_USAGE = { inputTokens: { total: 1 }, outputTokens: { total: 1 } };
|
|
18
|
+
|
|
19
|
+
/** One generate result (content + finishReason + usage [+ providerMetadata]) → the stream parts a
|
|
20
|
+
* doStream emits. Text becomes start/delta/end; a tool call becomes a single `tool-call` part; the
|
|
21
|
+
* finish part carries usage + finishReason + any providerMetadata (e.g. OpenRouter's real cost). */
|
|
22
|
+
export function streamParts(r: LanguageModelV3GenerateResult): unknown[] {
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
const res = r as any;
|
|
25
|
+
const parts: unknown[] = [{ type: "stream-start", warnings: [] }];
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
const content: any[] = Array.isArray(res.content) ? res.content : [];
|
|
28
|
+
content.forEach((part, i) => {
|
|
29
|
+
if (part.type === "text") {
|
|
30
|
+
const id = `t${i}`;
|
|
31
|
+
parts.push({ type: "text-start", id }, { type: "text-delta", id, delta: part.text }, { type: "text-end", id });
|
|
32
|
+
} else if (part.type === "tool-call") {
|
|
33
|
+
parts.push({ type: "tool-call", toolCallId: part.toolCallId, toolName: part.toolName, input: part.input });
|
|
34
|
+
} else if (part.type === "reasoning") {
|
|
35
|
+
const id = `g${i}`;
|
|
36
|
+
parts.push({ type: "reasoning-start", id }, { type: "reasoning-delta", id, delta: part.text ?? "" }, { type: "reasoning-end", id });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
parts.push({
|
|
40
|
+
type: "finish",
|
|
41
|
+
finishReason: res.finishReason ?? { unified: "stop", raw: "stop" },
|
|
42
|
+
usage: res.usage ?? DEFAULT_USAGE,
|
|
43
|
+
providerMetadata: res.providerMetadata,
|
|
44
|
+
});
|
|
45
|
+
return parts;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type MockOpts = ConstructorParameters<typeof RealMockLanguageModelV3>[0];
|
|
49
|
+
|
|
50
|
+
export class MockLanguageModelV3 extends RealMockLanguageModelV3 {
|
|
51
|
+
constructor(opts: MockOpts = {}) {
|
|
52
|
+
const o = opts ?? {};
|
|
53
|
+
if (o.doGenerate && !o.doStream) {
|
|
54
|
+
super({ provider: o.provider, modelId: o.modelId, supportedUrls: o.supportedUrls, doGenerate: o.doGenerate });
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
56
|
+
const self = this;
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
58
|
+
this.doStream = (async (options: any) => {
|
|
59
|
+
// Run the SAME script through the recording doGenerate wrapper (records doGenerateCalls +
|
|
60
|
+
// advances mockValues), then stream the result it returns.
|
|
61
|
+
const r = (await self.doGenerate(options)) as LanguageModelV3GenerateResult;
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
return { stream: simulateReadableStream({ initialDelayInMs: 0, chunkDelayInMs: 0, chunks: streamParts(r) as any }) };
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
+
}) as any;
|
|
66
|
+
} else {
|
|
67
|
+
super(o);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** provider+model -> AI-SDK model instance. Keys are read from env by the providers. */
|
|
2
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
3
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
4
|
+
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
|
5
|
+
import type { generateText } from "ai";
|
|
6
|
+
import type { Provider, ResolvedConfig, TaichoConfig } from "../store/config";
|
|
7
|
+
import { log } from "./logger";
|
|
8
|
+
import { withRequestTimeout, DEFAULT_MODEL_REQUEST_TIMEOUT_MS } from "@taicho-ai/agent/request-timeout";
|
|
9
|
+
|
|
10
|
+
// Reuse the exact model param type generateText expects (robust across SDK versions).
|
|
11
|
+
export type Model = Parameters<typeof generateText>[0]["model"];
|
|
12
|
+
|
|
13
|
+
const warnedMismatch = new Set<string>();
|
|
14
|
+
|
|
15
|
+
/** OpenRouter has no default model; fail loudly (not at an opaque call-time 400) when none is set.
|
|
16
|
+
* The slug must be namespaced (vendor/model) — this also catches a first-party fallback model
|
|
17
|
+
* (e.g. "claude-sonnet-4-6") bleeding into a per-agent `provider: openrouter` override.
|
|
18
|
+
* Plan 12: `timeoutFetch` is the transport-deadline-wrapped fetch (a hung request errors + retries
|
|
19
|
+
* via the AI SDK's maxRetries) — applied to EVERY env-key provider, not just codex. */
|
|
20
|
+
function instantiate(provider: Provider, model: string, timeoutFetch: typeof fetch): Model {
|
|
21
|
+
if (provider === "openrouter") {
|
|
22
|
+
if (!model.includes("/")) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"OpenRouter requires an explicit namespaced model (vendor/model). Set TAICHO_MODEL or " +
|
|
25
|
+
"defaults.model in taicho.yaml, e.g. 'anthropic/claude-sonnet-4.5'. Browse slugs at " +
|
|
26
|
+
"https://openrouter.ai/models",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
// apiKey defaults to OPENROUTER_API_KEY; the HTTP-Referer/X-Title headers are OpenRouter's
|
|
30
|
+
// recommended app attribution. usage:{include:true} (per model) returns the real per-call cost.
|
|
31
|
+
const openrouter = createOpenRouter({
|
|
32
|
+
apiKey: process.env.OPENROUTER_API_KEY,
|
|
33
|
+
headers: { "HTTP-Referer": "https://taicho.ai", "X-Title": "taicho" },
|
|
34
|
+
fetch: timeoutFetch,
|
|
35
|
+
});
|
|
36
|
+
return openrouter(model, { usage: { include: true } });
|
|
37
|
+
}
|
|
38
|
+
return provider === "anthropic"
|
|
39
|
+
? createAnthropic({ fetch: timeoutFetch })(model)
|
|
40
|
+
: createOpenAI({ fetch: timeoutFetch })(model);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildModel(cfg: ResolvedConfig, timeoutMs?: number): Model {
|
|
44
|
+
return instantiate(cfg.provider, cfg.model, withRequestTimeout(fetch, timeoutMs ?? DEFAULT_MODEL_REQUEST_TIMEOUT_MS));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ResolvedModel { model: Model; modelId: string; provider: Provider; captureCost?: boolean; }
|
|
48
|
+
|
|
49
|
+
/** @param teamsOf the running agent's teams (Plan 22: many-to-many), or undefined. Injected rather than
|
|
50
|
+
* looked up so the resolver stays free of a DB import; the REPL passes a prepared registry query. */
|
|
51
|
+
export function createModelResolver(opts: {
|
|
52
|
+
config: TaichoConfig;
|
|
53
|
+
fallback: ResolvedConfig;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
teamsOf?: (agentId: string) => string[];
|
|
56
|
+
}): {
|
|
57
|
+
resolveModel: (agentId: string) => ResolvedModel;
|
|
58
|
+
} {
|
|
59
|
+
const cache = new Map<string, Model>();
|
|
60
|
+
// Plan 12: one transport-deadline fetch shared across every model this resolver builds. The deadline
|
|
61
|
+
// is config-disposed (defaults.modelRequestTimeoutMs) — model-supplied values never reach it.
|
|
62
|
+
const timeoutFetch = withRequestTimeout(fetch, opts.timeoutMs ?? DEFAULT_MODEL_REQUEST_TIMEOUT_MS);
|
|
63
|
+
const resolveModel = (agentId: string): ResolvedModel => {
|
|
64
|
+
// Plan 19/22: agent → team → defaults. The agent still wins, so a single specialist can out-run its
|
|
65
|
+
// team's model without the team having to know about it. An agent on SEVERAL teams resolves against
|
|
66
|
+
// the first (in declaration order) that actually carries a model/provider override — deterministic,
|
|
67
|
+
// and the implicit `default` team (which never has one) is skipped for free.
|
|
68
|
+
const a = opts.config.agents?.[agentId];
|
|
69
|
+
const teamId = opts.teamsOf?.(agentId)?.find((id) => opts.config.teams?.[id]);
|
|
70
|
+
const t = teamId ? opts.config.teams?.[teamId] : undefined;
|
|
71
|
+
const provider: Provider = a?.provider ?? t?.provider ?? opts.config.defaults?.provider ?? opts.fallback.provider;
|
|
72
|
+
const model = a?.model ?? t?.model ?? opts.config.defaults?.model ?? opts.fallback.model;
|
|
73
|
+
const key = `${provider}:${model}`;
|
|
74
|
+
// Heuristic diagnostic: a partial per-agent override can pair a model id with a mismatched
|
|
75
|
+
// provider (e.g. an OpenAI model under the Anthropic provider). Warn once per provider:model
|
|
76
|
+
// so a misconfiguration surfaces here instead of as a cryptic call-time API error.
|
|
77
|
+
const looksMismatched =
|
|
78
|
+
(provider === "anthropic" && !model.startsWith("claude-")) ||
|
|
79
|
+
(provider === "openai" && model.startsWith("claude-"));
|
|
80
|
+
if (looksMismatched && !warnedMismatch.has(key)) {
|
|
81
|
+
warnedMismatch.add(key);
|
|
82
|
+
log.warn(`model "${model}" looks mismatched with provider "${provider}" for agent "${agentId}" — check taicho.yaml (set both provider and model)`);
|
|
83
|
+
}
|
|
84
|
+
let inst = cache.get(key);
|
|
85
|
+
if (!inst) { inst = instantiate(provider, model, timeoutFetch); cache.set(key, inst); }
|
|
86
|
+
// OpenRouter returns the real per-call cost (usage:{include:true}); the loop reads it from
|
|
87
|
+
// providerMetadata instead of the static price table.
|
|
88
|
+
return { model: inst, modelId: model, provider, captureCost: provider === "openrouter" || undefined };
|
|
89
|
+
};
|
|
90
|
+
return { resolveModel };
|
|
91
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Per-model USD pricing for advisory cost accounting. Tokens are the hard budget; cost is
|
|
2
|
+
* secondary. Values are USD per 1,000,000 tokens. Unknown models price to 0 (never throw). */
|
|
3
|
+
import { log } from "./logger";
|
|
4
|
+
|
|
5
|
+
export interface ModelPrice { inUsdPerMTok: number; outUsdPerMTok: number; }
|
|
6
|
+
|
|
7
|
+
const TABLE: Record<string, ModelPrice> = {
|
|
8
|
+
"claude-sonnet-4-6": { inUsdPerMTok: 3, outUsdPerMTok: 15 },
|
|
9
|
+
"claude-opus-4-8": { inUsdPerMTok: 15, outUsdPerMTok: 75 },
|
|
10
|
+
"gpt-5.5": { inUsdPerMTok: 5, outUsdPerMTok: 15 },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let warned = false;
|
|
14
|
+
|
|
15
|
+
export function priceUsd(model: string, usage: { inputTokens: number; outputTokens: number }): number {
|
|
16
|
+
const p = TABLE[model];
|
|
17
|
+
if (!p) {
|
|
18
|
+
if (!warned) { warned = true; log.warn(`no price for model "${model}" — cost reported as 0`); }
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
return (usage.inputTokens / 1_000_000) * p.inUsdPerMTok + (usage.outputTokens / 1_000_000) * p.outUsdPerMTok;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Build a pricer bound to a resolved model id (the agent loop is provider-agnostic). */
|
|
25
|
+
export function pricerFor(model: string): (u: { inputTokens: number; outputTokens: number }) => number {
|
|
26
|
+
return (u) => priceUsd(model, u);
|
|
27
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Codex-backed (ChatGPT subscription) provider. Built on the AI-SDK OpenAI provider with the
|
|
2
|
+
* Codex baseURL + a custom fetch that injects the OAuth Bearer token and refreshes on 401.
|
|
3
|
+
* Tokens are NEVER logged; use redactAuthHeader for any debug output. */
|
|
4
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
5
|
+
import type { AuthProfile } from "../auth/profile";
|
|
6
|
+
import { AuthExpiredError } from "../auth/refresh";
|
|
7
|
+
import { OPENAI_CODEX_AUTH, codexHeaders } from "../auth/constants";
|
|
8
|
+
import { log } from "../logger";
|
|
9
|
+
import { withRequestTimeout, DEFAULT_MODEL_REQUEST_TIMEOUT_MS } from "@taicho-ai/agent/request-timeout";
|
|
10
|
+
|
|
11
|
+
export function redactAuthHeader(value: string | null): string {
|
|
12
|
+
if (!value) return "";
|
|
13
|
+
return value.replace(/Bearer\s+\S+/i, "Bearer ***");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface AuthFetchDeps {
|
|
17
|
+
load: () => AuthProfile | null;
|
|
18
|
+
refresh: () => Promise<AuthProfile>;
|
|
19
|
+
baseFetch?: typeof fetch;
|
|
20
|
+
/** Plan 12: per-request transport deadline (ms) for the model fetch. A genuinely hung request
|
|
21
|
+
* (open socket, zero tokens) becomes a retryable error routed through the AI SDK's maxRetries,
|
|
22
|
+
* instead of the deleted loop-level idle watchdog. Config-disposed; defaults to 120s. */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Wrap fetch to inject Codex auth headers; on 401, refresh once (single-flight upstream) + retry. */
|
|
27
|
+
export function makeAuthFetch(deps: AuthFetchDeps): typeof fetch {
|
|
28
|
+
const base = deps.baseFetch ?? fetch;
|
|
29
|
+
const send = (input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1], profile: AuthProfile) => {
|
|
30
|
+
const headers = new Headers(init?.headers);
|
|
31
|
+
for (const [k, v] of Object.entries(codexHeaders(profile.access_token, profile.account_id))) headers.set(k, v);
|
|
32
|
+
return base(input, { ...init, headers });
|
|
33
|
+
};
|
|
34
|
+
return (async (input, init) => {
|
|
35
|
+
const profile = deps.load();
|
|
36
|
+
if (!profile) throw new AuthExpiredError();
|
|
37
|
+
let res = await send(input, init, profile);
|
|
38
|
+
if (res.status === 401) {
|
|
39
|
+
const refreshed = await deps.refresh(); // throws AuthExpiredError if refresh fails
|
|
40
|
+
res = await send(input, init, refreshed);
|
|
41
|
+
}
|
|
42
|
+
// Diagnostic: on a non-2xx, record the request URL + status + body snippet so endpoint/model
|
|
43
|
+
// mismatches are debuggable. Routed through the debug-level logger (raised by --verbose or the
|
|
44
|
+
// historical TAICHO_DEBUG env; see core/logger.ts envLevel) — the logger redacts, so the
|
|
45
|
+
// Authorization header can never leak even if it reappears in a body echo.
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
48
|
+
const body = await res.clone().text().catch(() => "");
|
|
49
|
+
log.debug(`codex ${res.status} ${url} :: ${body.slice(0, 500)}`);
|
|
50
|
+
}
|
|
51
|
+
return res;
|
|
52
|
+
}) as typeof fetch;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** An AI-SDK provider whose model calls hit the Codex backend with the subscription token.
|
|
56
|
+
* apiKey is a placeholder — real auth is the Authorization header set by makeAuthFetch. */
|
|
57
|
+
export function createCodexProvider(deps: AuthFetchDeps) {
|
|
58
|
+
return createOpenAI({
|
|
59
|
+
// The ChatGPT-subscription backend serves the Responses API at <codexBaseUrl>/responses
|
|
60
|
+
// (NO /v1 — that's the api.openai.com convention). The provider appends "/responses".
|
|
61
|
+
baseURL: OPENAI_CODEX_AUTH.codexBaseUrl,
|
|
62
|
+
apiKey: "codex-oauth",
|
|
63
|
+
// Plan 12: wrap the auth-injecting fetch with the transport deadline (outside makeAuthFetch, so it
|
|
64
|
+
// bounds the whole request including a 401 refresh+retry). A hang aborts the real connection.
|
|
65
|
+
fetch: withRequestTimeout(makeAuthFetch(deps), deps.timeoutMs ?? DEFAULT_MODEL_REQUEST_TIMEOUT_MS),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { effectiveTeams, type AgentDef } from "@taicho-ai/contracts/agent";
|
|
3
|
+
import { parseTeamAcl } from "@taicho-ai/contracts/team";
|
|
4
|
+
|
|
5
|
+
/** Roster is human-controlled: creation flows through the root agent's proposal card,
|
|
6
|
+
* never autonomous. Discovery is filtered by the CALLER's visibility ACL.
|
|
7
|
+
*
|
|
8
|
+
* Plan 22: membership is many-to-many. The `registry` row keeps a denormalized PRIMARY team (the
|
|
9
|
+
* agent's first explicit team, for cheap display/model resolution), while `agent_teams` is the real
|
|
10
|
+
* membership index — one row per (agent, team), including the implicit `default` via effectiveTeams,
|
|
11
|
+
* so "who is on team X" and "which teams is agent Y on" are both one indexed query. Per-agent rows are
|
|
12
|
+
* replaced wholesale (delete-then-insert) so an edit that drops a team leaves no ghost membership. */
|
|
13
|
+
export function syncRegistry(db: Database, agents: AgentDef[]) {
|
|
14
|
+
const insR = db.query("INSERT OR REPLACE INTO registry (id, role, is_root, team) VALUES (?, ?, ?, ?)");
|
|
15
|
+
const delAT = db.query("DELETE FROM agent_teams WHERE agent_id = ?");
|
|
16
|
+
const insAT = db.query("INSERT OR IGNORE INTO agent_teams (agent_id, team_id, ord) VALUES (?, ?, ?)");
|
|
17
|
+
for (const a of agents) {
|
|
18
|
+
insR.run(a.id, a.role, a.isRoot ? 1 : 0, a.teams[0] ?? null);
|
|
19
|
+
delAT.run(a.id);
|
|
20
|
+
effectiveTeams(a).forEach((t, i) => insAT.run(a.id, t, i));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The ACL grammar (Plan 19). An entry is `"*"` (everyone), an exact agent id, or `team:<id>` — which
|
|
25
|
+
* matches every member of that team. Purely additive: no agent id may contain a colon, so an entry
|
|
26
|
+
* written before Plan 19 keeps exactly the meaning it had. Plan 22: a target now carries its full
|
|
27
|
+
* membership list (`teams`, which includes the implicit `default`), so a `team:<id>` entry matches when
|
|
28
|
+
* that id is anywhere in the list — an agent on several teams is reachable through any of them.
|
|
29
|
+
*
|
|
30
|
+
* Note this is the grammar for BOTH canSee and canDelegateTo, and that `team:<id>` in canDelegateTo
|
|
31
|
+
* grants both "address the team" and "address any of its members". A team is a legibility boundary,
|
|
32
|
+
* not a security one — encapsulation comes from what root's roster SHOWS it (teams, not members),
|
|
33
|
+
* not from what it is forbidden. Narrow the ACL by hand if you want the hard version. */
|
|
34
|
+
function aclMatches(entry: string, target: { id: string; teams?: string[] }): boolean {
|
|
35
|
+
if (entry === "*") return true;
|
|
36
|
+
if (entry === target.id) return true;
|
|
37
|
+
const team = parseTeamAcl(entry);
|
|
38
|
+
return team !== null && !!target.teams?.includes(team);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function acl(entries: string[], target: { id: string; teams?: string[] }): boolean {
|
|
42
|
+
return entries.some((e) => aclMatches(e, target));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function visibleTo(caller: AgentDef, all: AgentDef[]): { id: string; role: string }[] {
|
|
46
|
+
return all
|
|
47
|
+
.filter((a) => a.id !== caller.id)
|
|
48
|
+
.filter((a) => acl(caller.canSee, { id: a.id, teams: effectiveTeams(a) }))
|
|
49
|
+
.map((a) => ({ id: a.id, role: a.role }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Visibility from the registry INDEX (id/role/teams only) — never loads agent identities, so the
|
|
53
|
+
* per-run cost stays O(1) file reads regardless of roster size. The caller is already loaded. */
|
|
54
|
+
export function visibleToRows(
|
|
55
|
+
caller: AgentDef,
|
|
56
|
+
rows: { id: string; role: string; teams?: string[] }[],
|
|
57
|
+
): { id: string; role: string; teams?: string[] }[] {
|
|
58
|
+
return rows.filter((r) => r.id !== caller.id).filter((r) => acl(caller.canSee, r));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** May `from` delegate to this target? The target may be an agent (pass its registry row so a
|
|
62
|
+
* `team:<id>` grant can match against its membership) or a team (pass `{ id: teamId, isTeam: true }`,
|
|
63
|
+
* matched by a `team:<id>` entry or `*`). */
|
|
64
|
+
export function canDelegate(from: AgentDef, to: { id: string; teams?: string[]; isTeam?: boolean }): boolean {
|
|
65
|
+
if (to.isTeam) return from.canDelegateTo.some((e) => e === "*" || parseTeamAcl(e) === to.id);
|
|
66
|
+
return acl(from.canDelegateTo, to);
|
|
67
|
+
}
|