@stage-labs/metro 0.1.0-beta.218 → 0.1.0-beta.219

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/claude.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { markOnboardingDone, seedChannels } from './onboarding.js';
3
3
  import { writeMcpConfig } from './mcp-config.js';
4
- import { anthropicRoute, currentRoute, permissionMode, routeModelEnv, systemPrompt } from './route.js';
4
+ import { currentRoute, permissionMode, routeModelEnv, systemPrompt } from './route.js';
5
5
  import { settingsConflicts, settingsFiles } from './claude-settings.js';
6
6
  import { localAgent } from './local.js';
7
7
  import { PROVIDER_FLAGS } from './provider-flags.js';
@@ -40,7 +40,7 @@ export function gatewayEnv(base, agentKey, port) {
40
40
  };
41
41
  }
42
42
  export const channelEnv = (env) => set(env, 'MCP_PROTOCOL_NEGOTIATION') ? env : { ...env, MCP_PROTOCOL_NEGOTIATION: 'legacy' };
43
- export const toolSearchEnv = (env, anthropic) => !anthropic || set(env, 'ENABLE_TOOL_SEARCH') ? env : { ...env, ENABLE_TOOL_SEARCH: 'true' };
43
+ export const toolSearchEnv = (env) => set(env, 'ENABLE_TOOL_SEARCH') ? env : { ...env, ENABLE_TOOL_SEARCH: 'true' };
44
44
  const OWN_CREDENTIALS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'];
45
45
  export function credentialEnv(env, agentKey, signedIn) {
46
46
  if (signedIn || OWN_CREDENTIALS.some((name) => set(env, name)))
@@ -125,7 +125,7 @@ function mcpConfigFor(key, port) {
125
125
  function gatewayLaunchEnv(key, port) {
126
126
  process.stderr.write(`metro claude: inference goes through the daemon at http://127.0.0.1:${String(port)}/gateway (the Model page decides where)\n`);
127
127
  const pointed = routeModelEnv(gatewayEnv(process.env, key, port), currentRoute());
128
- const routed = toolSearchEnv(pointed, anthropicRoute());
128
+ const routed = toolSearchEnv(pointed);
129
129
  if (routed !== pointed)
130
130
  process.stderr.write('metro claude: MCP tool search is on, so connector tools load when Claude needs them instead of all at start\n');
131
131
  const env = credentialEnv(routed, key, claudeSignedIn());
package/dist/route.js CHANGED
@@ -16,27 +16,17 @@ function routeIn(cfg) {
16
16
  const model = text(conn.model);
17
17
  return PROVIDERS.has(provider) && model !== '' ? `${provider}:${model}` : null;
18
18
  }
19
- function modelConfig(dir) {
19
+ export function currentRoute(dir = agentsDir()) {
20
20
  const path = join(dir, 'model.json');
21
21
  if (!existsSync(path))
22
22
  return null;
23
23
  try {
24
- return JSON.parse(readFileSync(path, 'utf8'));
24
+ return routeIn(JSON.parse(readFileSync(path, 'utf8')));
25
25
  }
26
26
  catch {
27
27
  return null;
28
28
  }
29
29
  }
30
- export const currentRoute = (dir = agentsDir()) => routeIn(modelConfig(dir));
31
- const isRecord = (value) => typeof value === 'object' && value !== null;
32
- export function anthropicRoute(dir = agentsDir()) {
33
- const cfg = modelConfig(dir);
34
- if (!isRecord(cfg) || !Array.isArray(cfg.connections))
35
- return true;
36
- const connections = cfg.connections.filter(isRecord);
37
- const routed = connections.find((c) => c.id === cfg.route) ?? connections[0];
38
- return routed === undefined || routed.provider === 'anthropic';
39
- }
40
30
  export function routeModelEnv(env, route) {
41
31
  if (route === null || (env.ANTHROPIC_MODEL ?? '').trim() !== '')
42
32
  return env;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.218",
3
+ "version": "0.1.0-beta.219",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.218",
3
+ "version": "0.1.0-beta.219",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -205,13 +205,15 @@ function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<
205
205
  return effort === null ? sent : withEffort(sent, effort);
206
206
  }
207
207
 
208
+ const bytesOf = (raw: Buffer, parsed: Record<string, unknown>, sent: Record<string, unknown>): Buffer => (sent === parsed ? raw : Buffer.from(JSON.stringify(sent)));
209
+
208
210
  async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
209
211
  const cfg = deps.config();
210
212
  const raw = await readBody(req);
211
- const sent = parseJson(raw);
212
- const shaped = shapedFor(req, sent);
213
- const route = resolveRoute(requestedModel(shaped), cfg) ?? passthrough(shaped);
214
- const body = fitToolSearch(req, shaped, route);
213
+ const parsed = parseJson(raw);
214
+ const sent = fitToolSearch(req, parsed);
215
+ const route = resolveRoute(requestedModel(sent), cfg) ?? passthrough(sent);
216
+ const body = shapedFor(req, sent);
215
217
  const conn = route.connection;
216
218
  log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
217
219
  if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
@@ -231,7 +233,7 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
231
233
  await toSubscription(req, res, path, body, route, deps);
232
234
  return;
233
235
  }
234
- await toAnthropic(req, res, raw, sent, body, route, deps);
236
+ await toAnthropic(req, res, bytesOf(raw, parsed, sent), sent, body, route, deps);
235
237
  }
236
238
 
237
239
  function failed(res: ServerResponse, err: unknown): void {
@@ -1,46 +1,111 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { stringOf } from '@metro-labs/http/api-http';
4
- import type { Route } from './model-config.js';
5
4
 
6
5
  type Body = Record<string, unknown>;
7
6
 
8
- const TOOL_SEARCH_BETA = /^(advanced-tool-use|tool-search-tool)-/;
9
- const BEFORE_TOOL_REFERENCE = /^claude-3|^claude-(opus|sonnet)-4(-[01])?(-\d{8})?$/;
7
+ const TOOL_SEARCH_BETA = /^(advanced-tool-use|tool-search-tool|mid-conversation-tool-changes)-/;
8
+ const PLACEHOLDER = 'DeferredToolPlaceholder';
9
+ const NOTHING_FOUND = 'No matching deferred tools found';
10
10
 
11
- export const takesToolSearch = (route: Route): boolean =>
12
- route.connection.provider === 'anthropic' && !BEFORE_TOOL_REFERENCE.test(route.model);
11
+ const blocksOf = (message: unknown): unknown[] => (isRecord(message) && Array.isArray(message.content) ? message.content : []);
13
12
 
14
- const isReference = (block: unknown): boolean => isRecord(block) && block.type === 'tool_reference';
13
+ const isResult = (block: unknown): block is Body & { content: unknown[] } => isRecord(block) && block.type === 'tool_result' && Array.isArray(block.content);
15
14
 
16
- const holdsReference = (block: unknown): boolean =>
17
- isReference(block) || (isRecord(block) && block.type === 'tool_result' && Array.isArray(block.content) && block.content.some(isReference));
15
+ const inlineDefinition = (block: unknown): Body | null =>
16
+ isRecord(block) && block.type === 'tool_addition' && isRecord(block.tool) && block.tool.type === 'tool_definition' && isRecord(block.tool.definition) ? block.tool.definition : null;
18
17
 
19
- const referencing = (message: unknown): boolean => isRecord(message) && Array.isArray(message.content) && message.content.some(holdsReference);
18
+ function loadedName(block: unknown): string | null {
19
+ if (!isRecord(block)) return null;
20
+ if (block.type === 'tool_reference') return stringOf(block.tool_name);
21
+ if (block.type !== 'tool_addition' || !isRecord(block.tool)) return null;
22
+ return stringOf(inlineDefinition(block)?.name ?? block.tool.name);
23
+ }
24
+
25
+ const removedName = (block: unknown): string | null => (isRecord(block) && block.type === 'tool_removal' && isRecord(block.tool) ? stringOf(block.tool.name) : null);
26
+
27
+ const changes = (block: unknown): boolean => loadedName(block) !== null || removedName(block) !== null;
28
+
29
+ const changing = (message: unknown): boolean => blocksOf(message).some((block) => changes(block) || (isResult(block) && block.content.some(changes)));
30
+
31
+ const functionLine = (tool: Body): string =>
32
+ `<function>${JSON.stringify({ description: stringOf(tool.description), name: tool.name, parameters: isRecord(tool.input_schema) ? tool.input_schema : {} })}</function>`;
33
+
34
+ function functionsText(blocks: unknown[], catalog: Map<string, Body>): string {
35
+ const lines = blocks.flatMap((block) => {
36
+ const tool = catalog.get(loadedName(block) ?? '');
37
+ return tool === undefined ? [] : [functionLine(tool)];
38
+ });
39
+ return lines.length === 0 ? NOTHING_FOUND : ['<functions>', ...lines, '</functions>'].join('\n');
40
+ }
41
+
42
+ const cacheOf = (blocks: unknown[]): Body => {
43
+ const marked = blocks.find((block) => isRecord(block) && block.cache_control !== undefined);
44
+ return isRecord(marked) ? { cache_control: marked.cache_control } : {};
45
+ };
20
46
 
21
- const deferred = (tool: unknown): tool is Body => isRecord(tool) && tool.defer_loading !== undefined;
47
+ function expanded(blocks: unknown[], catalog: Map<string, Body>): unknown[] {
48
+ const found = blocks.filter((block) => loadedName(block) !== null);
49
+ return blocks.flatMap((block) => {
50
+ if (block === found[0]) return [{ type: 'text', text: functionsText(found, catalog), ...cacheOf(found) }];
51
+ if (loadedName(block) !== null) return [];
52
+ const removed = removedName(block);
53
+ return removed === null ? [block] : [{ type: 'text', text: `Tool ${removed} is no longer available.`, ...cacheOf([block]) }];
54
+ });
55
+ }
56
+
57
+ const expandedBlock = (block: unknown, catalog: Map<string, Body>): unknown =>
58
+ isResult(block) && block.content.some(changes) ? { ...block, content: expanded(block.content, catalog) } : block;
59
+
60
+ function expandedMessage(message: unknown, catalog: Map<string, Body>): unknown {
61
+ if (!changing(message) || !isRecord(message)) return message;
62
+ return { ...message, content: expanded(blocksOf(message).map((block) => expandedBlock(block, catalog)), catalog) };
63
+ }
22
64
 
23
- function flatBlock(block: unknown): unknown {
24
- if (!isRecord(block)) return block;
25
- if (block.type === 'tool_reference') return { type: 'text', text: `Tool ${stringOf(block.tool_name)} is loaded: call it directly.` };
26
- if (block.type === 'tool_result' && Array.isArray(block.content)) return { ...block, content: block.content.map(flatBlock) };
27
- return block;
65
+ interface Found {
66
+ names: Set<string>;
67
+ inline: Map<string, Body>;
28
68
  }
29
69
 
30
- const flatMessage = (message: unknown): unknown =>
31
- isRecord(message) && Array.isArray(message.content) ? { ...message, content: message.content.map(flatBlock) } : message;
70
+ function foundIn(messages: unknown[]): Found {
71
+ const found: Found = { names: new Set(), inline: new Map() };
72
+ const blocks = messages.flatMap(blocksOf).flatMap((block) => (isResult(block) ? block.content : [block]));
73
+ for (const block of blocks) {
74
+ const name = loadedName(block);
75
+ const definition = inlineDefinition(block);
76
+ if (name !== null) found.names.add(name);
77
+ if (definition !== null) found.inline.set(stringOf(definition.name), definition);
78
+ const removed = removedName(block);
79
+ if (removed === null) continue;
80
+ found.names.delete(removed);
81
+ found.inline.delete(removed);
82
+ }
83
+ return found;
84
+ }
85
+
86
+ const deferred = (tool: unknown): boolean => isRecord(tool) && tool.defer_loading === true;
87
+
88
+ const loaded = (tool: unknown): unknown =>
89
+ isRecord(tool) && 'defer_loading' in tool ? Object.fromEntries(Object.entries(tool).filter(([key]) => key !== 'defer_loading')) : tool;
90
+
91
+ const deferring = (tools: unknown): boolean => Array.isArray(tools) && tools.some((tool) => isRecord(tool) && 'defer_loading' in tool);
32
92
 
33
- const loadedTool = (tool: unknown): unknown =>
34
- deferred(tool) ? Object.fromEntries(Object.entries(tool).filter(([key]) => key !== 'defer_loading')) : tool;
93
+ const nameOf = (tool: unknown): string => (isRecord(tool) ? stringOf(tool.name) : '');
35
94
 
36
- export function withoutToolSearch(body: Body): Body {
37
- const tools = Array.isArray(body.tools) ? body.tools : [];
38
- const messages = Array.isArray(body.messages) ? body.messages : [];
39
- if (!tools.some(deferred) && !messages.some(referencing)) return body;
95
+ export function resolveToolSearch(body: Body): Body {
96
+ const messages: unknown[] = Array.isArray(body.messages) ? body.messages : [];
97
+ if (!deferring(body.tools) && !messages.some(changing)) return body;
98
+ const declared: unknown[] = Array.isArray(body.tools) ? body.tools : [];
99
+ const found = foundIn(messages);
100
+ const shown = declared.filter((tool) => nameOf(tool) !== PLACEHOLDER && (!deferred(tool) || found.names.has(nameOf(tool)))).map(loaded);
101
+ const known = new Set(shown.map(nameOf));
102
+ const inline = [...found.inline.values()].filter((tool) => !known.has(nameOf(tool))).map(loaded);
103
+ const tools = [...shown, ...inline];
104
+ const catalog = new Map([...declared, ...inline].filter(isRecord).map((tool) => [nameOf(tool), tool]));
40
105
  return {
41
106
  ...body,
42
- ...(Array.isArray(body.tools) ? { tools: tools.map(loadedTool) } : {}),
43
- ...(Array.isArray(body.messages) ? { messages: messages.map(flatMessage) } : {}),
107
+ ...(Array.isArray(body.tools) || inline.length > 0 ? { tools } : {}),
108
+ ...(Array.isArray(body.messages) ? { messages: messages.map((message) => expandedMessage(message, catalog)) } : {}),
44
109
  };
45
110
  }
46
111
 
@@ -55,8 +120,7 @@ function dropToolSearchBeta(req: IncomingMessage): void {
55
120
  else delete req.headers['anthropic-beta'];
56
121
  }
57
122
 
58
- export function fitToolSearch(req: IncomingMessage, body: Body, route: Route): Body {
59
- if (takesToolSearch(route)) return body;
123
+ export function fitToolSearch(req: IncomingMessage, body: Body): Body {
60
124
  dropToolSearchBeta(req);
61
- return withoutToolSearch(body);
125
+ return resolveToolSearch(body);
62
126
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.218"
2
+ "version": "0.1.0-beta.219"
3
3
  }