@stage-labs/metro 0.1.0-beta.327 → 0.1.0-beta.329
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/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +9 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/files.ts +7 -7
- package/runtime/node_modules/@metro-labs/daemon/src/claude/memory.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +6 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/tool-call.ts +73 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/relay.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/install.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/metro-user/session-scopes.ts +25 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-oauth.ts +16 -1
- package/runtime/node_modules/@metro-labs/inference/src/failures.ts +13 -0
- package/runtime/node_modules/@metro-labs/inference/src/forward.ts +24 -5
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/app.ts +1 -1
- package/runtime/sdk-runner/src/runner.ts +19 -9
- package/runtime/sdk-runner/src/session-watch.ts +2 -2
- package/runtime/sdk-runner/src/task-ledger.ts +13 -4
- package/runtime/sdk-runner/src/task-recovery.ts +3 -3
- package/runtime/sdk-runner/src/task-state.ts +4 -0
- package/skills/METRO.md +2 -2
package/package.json
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type SessionDeps,
|
|
26
26
|
} from './session.js';
|
|
27
27
|
import { receiveSessionFile, sessionFilePath } from './session-files.js';
|
|
28
|
+
import { readToolCall } from './tool-call.js';
|
|
28
29
|
import { createReadStream } from '../agent-user/agent-fs.js';
|
|
29
30
|
import { pipeline } from 'node:stream/promises';
|
|
30
31
|
import {
|
|
@@ -50,6 +51,11 @@ export interface ClaudeApiDeps extends SetupApiDeps {
|
|
|
50
51
|
skillSource?: GitHubSkills;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
function optionalProject(query: URLSearchParams): string | null {
|
|
55
|
+
const project = query.get('project');
|
|
56
|
+
return project === null || project === '' ? null : project;
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
function projectOf(query: URLSearchParams): string {
|
|
54
60
|
const project = query.get('project');
|
|
55
61
|
if (project === null || project === '') throw new ApiError('project is required', 400);
|
|
@@ -73,7 +79,9 @@ const COLLECTIONS: Record<string, Handler> = {
|
|
|
73
79
|
};
|
|
74
80
|
|
|
75
81
|
const ITEMS: Record<string, Handler> = {
|
|
76
|
-
sessions: (query, dir, id) => {
|
|
82
|
+
sessions: async (query, dir, id) => {
|
|
83
|
+
const tool = query.get('tool');
|
|
84
|
+
if (tool !== null) return { call: await readToolCall(optionalProject(query), id, tool, dir) };
|
|
77
85
|
const { offset, limit } = pageOf(query);
|
|
78
86
|
return readTranscript(projectOf(query), id, offset, limit, dir);
|
|
79
87
|
},
|
|
@@ -15,8 +15,8 @@ const MEMORY_DEPTH = 6;
|
|
|
15
15
|
const MEMORY_FILES_MAX = 5000;
|
|
16
16
|
const EDGE_BYTES = 64 * 1024;
|
|
17
17
|
const TEXT_CAP = 20_000;
|
|
18
|
-
const RESULT_CAP = 8_000;
|
|
19
|
-
const INPUT_CAP = 4_000;
|
|
18
|
+
export const RESULT_CAP = 8_000;
|
|
19
|
+
export const INPUT_CAP = 4_000;
|
|
20
20
|
const FILE_CAP = 1_000_000;
|
|
21
21
|
const MEMORY_MAX = 256 * 1024;
|
|
22
22
|
const MEMORY_MODE = 0o644;
|
|
@@ -31,7 +31,7 @@ export function claudeDir(): string {
|
|
|
31
31
|
return join(user === null ? homedir() : user.home, '.claude');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const str = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null);
|
|
34
|
+
export const str = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null);
|
|
35
35
|
|
|
36
36
|
export function safeName(value: string, re: RegExp, what: string): string {
|
|
37
37
|
if (!re.test(value) || value === '.' || value === '..')
|
|
@@ -39,13 +39,13 @@ export function safeName(value: string, re: RegExp, what: string): string {
|
|
|
39
39
|
return value;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function projectDir(project: string, dir: string): string {
|
|
42
|
+
export function projectDir(project: string, dir: string): string {
|
|
43
43
|
const path = join(dir, 'projects', safeName(project, PROJECT_RE, 'Claude project id'));
|
|
44
44
|
if (!existsSync(path)) throw new ApiError('no such Claude project', 404);
|
|
45
45
|
return path;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
function parseLines(text: string): Record<string, unknown>[] {
|
|
48
|
+
export function parseLines(text: string): Record<string, unknown>[] {
|
|
49
49
|
const out: Record<string, unknown>[] = [];
|
|
50
50
|
for (const line of text.split('\n')) {
|
|
51
51
|
if (line.trim() === '') continue;
|
|
@@ -187,10 +187,10 @@ export interface TranscriptPage {
|
|
|
187
187
|
next: number | null;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
const cap = (text: string, max: number): string =>
|
|
190
|
+
export const cap = (text: string, max: number): string =>
|
|
191
191
|
text.length > max ? `${text.slice(0, max)}\n… (${String(text.length - max)} more characters)` : text;
|
|
192
192
|
|
|
193
|
-
function resultText(content: unknown): string {
|
|
193
|
+
export function resultText(content: unknown): string {
|
|
194
194
|
if (typeof content === 'string') return content;
|
|
195
195
|
if (!Array.isArray(content)) return '';
|
|
196
196
|
return content
|
|
@@ -28,5 +28,5 @@ export function inSessionScope(command: [string, string[]], host: ScopeHost = re
|
|
|
28
28
|
const asAgent = file === 'sudo' && args[0] === '-n' && args[1] === '-u' && args[3] === '--';
|
|
29
29
|
if (!host.metro || !host.systemd || !asAgent) return command;
|
|
30
30
|
const limit = sessionMemoryLimit(host.total);
|
|
31
|
-
return helperArgv(['as-agent-scope', String(limit), String(
|
|
31
|
+
return helperArgv(['as-agent-scope', String(limit), String(limit), ...args.slice(4)]);
|
|
32
32
|
}
|
|
@@ -41,7 +41,7 @@ Keep the change to what the task asks for. A pre-existing bug, a performance pro
|
|
|
41
41
|
|
|
42
42
|
Never call AskUserQuestion or ExitPlanMode. They are blocked by policy, because nobody is watching the terminal around the clock and a blocking prompt stalls indefinitely. When you hit an ambiguity: do everything that does not depend on it, then choose the most reasonable interpretation, state the assumption explicitly in your report, and continue.
|
|
43
43
|
|
|
44
|
-
Your final message is a report to the orchestrator, not a message to a human. It
|
|
44
|
+
Your final message is a report to the orchestrator, not a message to a human. It stays in the orchestrator's context for the rest of the conversation and makes every later answer slower, so keep it short: about 150 words at most, self-contained. Lead with the outcome, then the key evidence for what you claim to have verified, such as the test result or the file you changed. Put anything longer, such as full logs, tables or detailed findings, in a file under ~/work and give its path. Report failures faithfully: if a test failed, say so with its key line of output; if you skipped a step, say that too.
|
|
45
45
|
`;
|
|
46
46
|
|
|
47
47
|
export interface SetupDeps {
|
|
@@ -129,7 +129,10 @@ export function setSystemPrompt(text: string, agents = agentsDir()): void {
|
|
|
129
129
|
writeAtomic(promptPath(agents), `${trimmed}\n`, 0o600);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
const PRIOR_WORKER: ReadonlySet<string> = new Set([
|
|
132
|
+
const PRIOR_WORKER: ReadonlySet<string> = new Set([
|
|
133
|
+
'08a0cd8710df285d6512245246bb8b658b7cfc7f3e89490514ee5376779eb2ef',
|
|
134
|
+
'9db999a7bb7cf1d6c12a679520e21c78d038999d5454d7abbc6b87a87c136c54',
|
|
135
|
+
]);
|
|
133
136
|
const PRIOR_METRO: ReadonlySet<string> = new Set([
|
|
134
137
|
'0b3d122ed95beff09d579cf912cd4238e1db524c41fce4b314de57d6ff5908ad',
|
|
135
138
|
'36f4fb57f231a119d717b5e3d6ccb654f94960fcca708683f376679aead8df25',
|
|
@@ -142,6 +145,7 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
|
|
|
142
145
|
'256c711a6175266c216e32c88b7190d0f98f5e6b702abe03220b422aba426574',
|
|
143
146
|
'9e164a956437d60d30ea1984115a17d303940d191f5efed04954f244fc89d48f',
|
|
144
147
|
'8b6d7c5a9bc8d02d5e3ae5e5931b36aba9f3d0eb7fd38b371294c2abaa0c94ee',
|
|
148
|
+
'8b8f21cb1b7608540d8b5ed9557e1644b03ce4809a7f320ba134f28129f08ddb',
|
|
145
149
|
]);
|
|
146
150
|
const PRIOR_STAGE: ReadonlySet<string> = new Set([
|
|
147
151
|
'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createReadStream, existsSync, readdirNames } from '../agent-user/agent-fs.js';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import { cap, claudeDir, INPUT_CAP, parseLines, projectDir, PROJECT_RE, RESULT_CAP, resultText, safeName, SESSION_RE, str } from './files.js';
|
|
6
|
+
|
|
7
|
+
const TOOL_USE_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
8
|
+
|
|
9
|
+
export interface ToolCall {
|
|
10
|
+
name: string;
|
|
11
|
+
input: string;
|
|
12
|
+
result: string | null;
|
|
13
|
+
isError: boolean;
|
|
14
|
+
calledAt: string | null;
|
|
15
|
+
answeredAt: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function transcriptFiles(root: string, session: string): string[] {
|
|
19
|
+
const workers = join(root, session, 'subagents');
|
|
20
|
+
const extra = existsSync(workers) ? readdirNames(workers).filter((name) => name.endsWith('.jsonl')).map((name) => join(workers, name)) : [];
|
|
21
|
+
return [join(root, `${session}.jsonl`), ...extra].filter((path) => existsSync(path));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function noteBlock(found: Partial<ToolCall>, block: unknown, toolUse: string, at: string | null): void {
|
|
25
|
+
if (!isRecord(block)) return;
|
|
26
|
+
if (block.type === 'tool_use' && block.id === toolUse) {
|
|
27
|
+
found.name = str(block.name) ?? 'tool';
|
|
28
|
+
found.input = cap(JSON.stringify(block.input ?? {}, null, 1), INPUT_CAP);
|
|
29
|
+
found.calledAt = at;
|
|
30
|
+
}
|
|
31
|
+
if (block.type === 'tool_result' && block.tool_use_id === toolUse) {
|
|
32
|
+
found.result = cap(resultText(block.content), RESULT_CAP);
|
|
33
|
+
found.isError = block.is_error === true;
|
|
34
|
+
found.answeredAt = at;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function scanForCall(path: string, toolUse: string): Promise<Partial<ToolCall>> {
|
|
39
|
+
const found: Partial<ToolCall> = {};
|
|
40
|
+
const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
|
|
41
|
+
for await (const line of lines) {
|
|
42
|
+
if (!line.includes(toolUse)) continue;
|
|
43
|
+
const parsed = parseLines(line)[0];
|
|
44
|
+
const content = parsed !== undefined && isRecord(parsed.message) ? parsed.message.content : null;
|
|
45
|
+
if (!Array.isArray(content)) continue;
|
|
46
|
+
for (const block of content) noteBlock(found, block, toolUse, str(parsed?.timestamp));
|
|
47
|
+
if (found.name !== undefined && found.result !== undefined) break;
|
|
48
|
+
}
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function projectHolding(session: string, dir: string): string | null {
|
|
53
|
+
const root = join(dir, 'projects');
|
|
54
|
+
if (!existsSync(root)) return null;
|
|
55
|
+
return readdirNames(root).find((name) => PROJECT_RE.test(name) && existsSync(join(root, name, `${session}.jsonl`))) ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function completed(name: string, found: Partial<ToolCall>): ToolCall {
|
|
59
|
+
return { name, input: found.input ?? '{}', result: found.result ?? null, isError: found.isError ?? false, calledAt: found.calledAt ?? null, answeredAt: found.answeredAt ?? null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function readToolCall(project: string | null, session: string, toolUse: string, dir = claudeDir()): Promise<ToolCall | null> {
|
|
63
|
+
const id = safeName(session, SESSION_RE, 'session id');
|
|
64
|
+
const holder = project ?? projectHolding(id, dir);
|
|
65
|
+
if (holder === null) return null;
|
|
66
|
+
const root = projectDir(holder, dir);
|
|
67
|
+
const wanted = safeName(toolUse, TOOL_USE_RE, 'tool call id');
|
|
68
|
+
for (const path of transcriptFiles(root, id)) {
|
|
69
|
+
const found = await scanForCall(path, wanted);
|
|
70
|
+
if (found.name !== undefined) return completed(found.name, found);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
@@ -87,9 +87,11 @@ async function relay(req: IncomingMessage, res: ServerResponse, path: string, de
|
|
|
87
87
|
sendError(res, 503, 'api_error', NOT_ACCEPTED);
|
|
88
88
|
return;
|
|
89
89
|
}
|
|
90
|
-
await pipeResponse(upstream, res, watch);
|
|
90
|
+
await pipeResponse(upstream, res, watch, { site: RELAY_SITE });
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
const RELAY_SITE = { connection: 'gateway.metro.box', model: 'relayed' };
|
|
94
|
+
|
|
93
95
|
function refusal(req: IncomingMessage, path: string, deps: RelayDeps): [number, string, string] | null {
|
|
94
96
|
if (cameThroughProxy(req)) return [401, 'authentication_error', 'metro gateway: the agent key works only on this machine, not through its public address'];
|
|
95
97
|
if (keyOf(req) === '' || !deps.identify(keyOf(req))) return [401, 'authentication_error', 'metro gateway: no agent key (x-metro-key); start the agent with metro agent'];
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from './helper-script.js';
|
|
16
16
|
import { AGENT_NAME } from '../agent-user/user.js';
|
|
17
17
|
import { hardenTailscale } from './tailscale-guard.js';
|
|
18
|
+
import { liftSessionSoftLimits } from './session-scopes.js';
|
|
18
19
|
|
|
19
20
|
interface InstallPaths {
|
|
20
21
|
helper: string;
|
|
@@ -73,7 +74,7 @@ if (import.meta.main) {
|
|
|
73
74
|
installRootHelper();
|
|
74
75
|
installImdsGuard();
|
|
75
76
|
installUpgradeUnit(node);
|
|
76
|
-
for (const note of hardenTailscale()) process.stderr.write(`metro: ${note}\n`);
|
|
77
|
+
for (const note of [...hardenTailscale(), ...liftSessionSoftLimits()]) process.stderr.write(`metro: ${note}\n`);
|
|
77
78
|
process.stdout.write(
|
|
78
79
|
`metro: wrote ${HELPER_PATH}, ${SUDOERS_PATH}, ${IMDS_UNIT_PATH} and ${UPGRADE_UNIT_PATH}; only root and metro reach the instance metadata service, and the root side follows every update\n`,
|
|
79
80
|
);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const SYSTEM_SLICE = '/sys/fs/cgroup/system.slice';
|
|
6
|
+
const SESSION_SCOPE = /^metro-claude-\d+\.scope$/;
|
|
7
|
+
|
|
8
|
+
type Run = (file: string, args: string[]) => boolean;
|
|
9
|
+
|
|
10
|
+
const quiet: Run = (file, args) => spawnSync(file, args, { stdio: 'ignore' }).status === 0;
|
|
11
|
+
|
|
12
|
+
const softLimit = (dir: string): string | null => {
|
|
13
|
+
const path = join(dir, 'memory.high');
|
|
14
|
+
return existsSync(path) ? readFileSync(path, 'utf8').trim() : null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function liftSessionSoftLimits(run: Run = quiet, slice: string = SYSTEM_SLICE): string[] {
|
|
18
|
+
if (!existsSync(slice)) return [];
|
|
19
|
+
const held = readdirSync(slice).filter((name) => SESSION_SCOPE.test(name) && ![null, 'max'].includes(softLimit(join(slice, name))));
|
|
20
|
+
return held.map((scope) =>
|
|
21
|
+
run('systemctl', ['set-property', '--runtime', scope, 'MemoryHigh=infinity'])
|
|
22
|
+
? `${scope}: its soft memory limit is lifted, so only the hard limit applies`
|
|
23
|
+
: `${scope}: its soft memory limit could not be lifted; it goes when the session starts again`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -27,6 +27,21 @@ function challengeOf(verifier: string): string {
|
|
|
27
27
|
return createHash('sha256').update(verifier).digest('base64url');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
const REASON_MAX = 600;
|
|
31
|
+
|
|
32
|
+
function oneLine(value: unknown): string {
|
|
33
|
+
if (typeof value !== 'string') return '';
|
|
34
|
+
const printable = Array.from(value, (char) => (char < ' ' || char === '\u007f' ? ' ' : char)).join('');
|
|
35
|
+
const text = printable.replace(/\s+/g, ' ').trim();
|
|
36
|
+
return text.length > REASON_MAX ? `${text.slice(0, REASON_MAX - 1)}…` : text;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function registrationReason(body: unknown): string {
|
|
40
|
+
if (!isRecord(body)) return '.';
|
|
41
|
+
const reason = oneLine(body.error_description) || oneLine(body.error);
|
|
42
|
+
return reason === '' ? '.' : `: ${reason}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
export async function registerClient(
|
|
31
46
|
server: OAuthServer,
|
|
32
47
|
redirectUri: string,
|
|
@@ -58,7 +73,7 @@ export async function registerClient(
|
|
|
58
73
|
}
|
|
59
74
|
const body: unknown = await res.json().catch(() => null);
|
|
60
75
|
if (!res.ok || !isRecord(body) || typeof body.client_id !== 'string')
|
|
61
|
-
throw refused(
|
|
76
|
+
throw refused(`That server refused to register Metro as a client${registrationReason(body)}`);
|
|
62
77
|
const secret = body.client_secret;
|
|
63
78
|
return {
|
|
64
79
|
clientId: body.client_id,
|
|
@@ -34,14 +34,27 @@ export function noteFailure(site: FailureSite, failure: Failure, what: string):
|
|
|
34
34
|
log.warn({ connection: site.connection, model: site.model, ...failure, message: providerText(failure.message) }, what);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
const STOP_DATA = /^\{\s*"type"\s*:\s*"message_stop"/;
|
|
38
|
+
|
|
37
39
|
export class StreamErrors {
|
|
38
40
|
private readonly parser = new SseParser();
|
|
41
|
+
private ended = false;
|
|
39
42
|
|
|
40
43
|
constructor(private readonly site: FailureSite) {}
|
|
41
44
|
|
|
45
|
+
get settled(): boolean {
|
|
46
|
+
return this.ended;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
cut(): void {
|
|
50
|
+
noteFailure(this.site, { code: 'stream_cut', message: '' }, 'gateway: the provider ended its stream before the answer was complete');
|
|
51
|
+
}
|
|
52
|
+
|
|
42
53
|
feed(text: string): void {
|
|
43
54
|
for (const raw of this.parser.push(text)) {
|
|
55
|
+
if (raw.event === 'message_stop' || STOP_DATA.test(raw.data)) this.ended = true;
|
|
44
56
|
if (raw.event !== 'error' && !ERROR_DATA.test(raw.data)) continue;
|
|
57
|
+
this.ended = true;
|
|
45
58
|
const data = parseEvent(raw)?.data ?? {};
|
|
46
59
|
const error = isRecord(data.error) ? data.error : {};
|
|
47
60
|
noteFailure(this.site, { code: stringOf(error.type) || stringOf(error.code), message: stringOf(error.message) }, 'gateway: the provider sent an error in the middle of its stream');
|
|
@@ -171,16 +171,30 @@ function openResponse(upstream: Response, res: ServerResponse, opts: PipeOptions
|
|
|
171
171
|
|
|
172
172
|
type Tap = (chunk: Uint8Array) => void;
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
interface Tapped {
|
|
175
|
+
tap: Tap | null;
|
|
176
|
+
errors: StreamErrors | null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function tapOf(opts: PipeOptions, streaming: boolean): Tapped {
|
|
175
180
|
const scanner = opts.scanner;
|
|
176
181
|
const errors = streaming && opts.site !== undefined ? new StreamErrors(opts.site) : null;
|
|
177
|
-
if (scanner === undefined && errors === null) return null;
|
|
182
|
+
if (scanner === undefined && errors === null) return { tap: null, errors };
|
|
178
183
|
const decoder = new TextDecoder();
|
|
179
|
-
|
|
184
|
+
const tap: Tap = (chunk) => {
|
|
180
185
|
const text = decoder.decode(chunk, { stream: true });
|
|
181
186
|
scanner?.feed(text);
|
|
182
187
|
errors?.feed(text);
|
|
183
188
|
};
|
|
189
|
+
return { tap, errors };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const CUT_MESSAGE = 'metro gateway: the provider ended its stream before the answer was complete; try again';
|
|
193
|
+
|
|
194
|
+
function closeCut(res: ServerResponse, errors: StreamErrors | null): void {
|
|
195
|
+
if (errors === null || errors.settled || res.writableEnded) return;
|
|
196
|
+
errors.cut();
|
|
197
|
+
res.write(errorFrame('api_error', CUT_MESSAGE));
|
|
184
198
|
}
|
|
185
199
|
|
|
186
200
|
async function pump(body: ReadableStream<Uint8Array>, res: ServerResponse, watch: Watch, tap: Tap | null): Promise<void> {
|
|
@@ -202,10 +216,15 @@ export async function pipeResponse(upstream: Response, res: ServerResponse, watc
|
|
|
202
216
|
return;
|
|
203
217
|
}
|
|
204
218
|
const ping = opts.keepalive === true && streaming ? setInterval(() => res.write(PING_FRAME), PING_MS) : null;
|
|
219
|
+
const { tap, errors } = tapOf(opts, streaming);
|
|
205
220
|
try {
|
|
206
|
-
await pump(body, res, watch,
|
|
221
|
+
await pump(body, res, watch, tap);
|
|
222
|
+
closeCut(res, errors);
|
|
207
223
|
} catch (err) {
|
|
208
|
-
if (!watch.idle())
|
|
224
|
+
if (!watch.idle()) {
|
|
225
|
+
closeCut(res, errors);
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
209
228
|
if (streaming) res.write(errorFrame('api_error', idleMessage(watch.ms)));
|
|
210
229
|
} finally {
|
|
211
230
|
if (ping !== null) clearInterval(ping);
|
package/runtime/runtime.json
CHANGED
|
@@ -63,7 +63,7 @@ async function connected(cfg: RunnerConfig, hooks: AppHooks, release: () => Prom
|
|
|
63
63
|
tools = metroTools(link);
|
|
64
64
|
const resume = store.resumable();
|
|
65
65
|
log.info({ resume, model: cfg.model, mode: cfg.permissionMode }, 'sdk-runner: starting the Agent SDK session');
|
|
66
|
-
const approvals = approvalsThrough(link, (id, ask) => { hooks.activity?.approval(id, ask); }, (tool, input) => runner.calls.approval(tool, input));
|
|
66
|
+
const approvals = approvalsThrough(link, (id, ask) => { hooks.activity?.approval(id, ask); runner.approval(id, ask); }, (tool, input) => runner.calls.approval(tool, input));
|
|
67
67
|
runner.start(runnerOptions(cfg, tools, approvals, resume, hooks.env));
|
|
68
68
|
return { runner, link, done: runner.run(hooks.observe), stop };
|
|
69
69
|
} catch (err) {
|
|
@@ -7,7 +7,6 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
7
7
|
import type { AutomationStore } from '@metro-labs/core/automation-store';
|
|
8
8
|
import type { Activity } from './activity.js';
|
|
9
9
|
import { Automation } from './automation.js';
|
|
10
|
-
import { ActivityTasks } from './activity-tasks.js';
|
|
11
10
|
import { RunnerCalls } from './calls.js';
|
|
12
11
|
import { channelText, type ChannelEvent } from './channel-text.js';
|
|
13
12
|
import type { RunnerConfig } from './config.js';
|
|
@@ -21,7 +20,8 @@ import { METRO_SERVER, type MetroTools } from './tool-proxy.js';
|
|
|
21
20
|
import { debugFailure, failureSummary, SessionError, StartupDiagnostics } from './errors.js';
|
|
22
21
|
|
|
23
22
|
const STARTUP_WAIT = 'CLAUDE_CODE_MCP_STARTUP_WAIT_MS';
|
|
24
|
-
export const COMPACT_AT =
|
|
23
|
+
export const COMPACT_AT = 80_000;
|
|
24
|
+
const URGENT_FACTOR = 1.5;
|
|
25
25
|
export const COMPACT_IDLE_MS = 60_000;
|
|
26
26
|
|
|
27
27
|
const MODEL_PINS: ReadonlySet<string> = new Set(['ANTHROPIC_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']);
|
|
@@ -97,7 +97,6 @@ export class Runner {
|
|
|
97
97
|
private readonly recovery: TaskRecovery;
|
|
98
98
|
private readonly watch: SessionWatch;
|
|
99
99
|
private readonly diagnostics = new StartupDiagnostics();
|
|
100
|
-
private readonly tasks = new ActivityTasks(() => undefined);
|
|
101
100
|
private readonly skillWork = new SkillWork();
|
|
102
101
|
private compactTimer: ReturnType<typeof setTimeout> | null = null;
|
|
103
102
|
private session: Query | null = null;
|
|
@@ -108,11 +107,12 @@ export class Runner {
|
|
|
108
107
|
private model: string | null = null;
|
|
109
108
|
private switching: Promise<void> = Promise.resolve();
|
|
110
109
|
private ended = false;
|
|
110
|
+
private readonly mainAsks = new Set<string>();
|
|
111
111
|
private readonly interrupted: number;
|
|
112
112
|
private interruptionReported = false;
|
|
113
113
|
|
|
114
114
|
constructor(private readonly parts: RunnerParts) {
|
|
115
|
-
this.watch = new SessionWatch(parts.readOnly);
|
|
115
|
+
this.watch = new SessionWatch(parts.readOnly, () => this.mainAsks.size > 0);
|
|
116
116
|
this.inbox = new Inbox((unanswered) => { this.keep(unanswered); }, {
|
|
117
117
|
ready: () => { parts.skills?.check(); return this.watch.admitting && parts.skills?.busy !== true; },
|
|
118
118
|
dispatch: (message) => { this.automation?.dispatched(message.uuid); this.watch.dispatched(message.uuid); },
|
|
@@ -167,6 +167,12 @@ export class Runner {
|
|
|
167
167
|
return this.session;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
approval(id: string, ask: { worker: string | null } | null): void {
|
|
171
|
+
if (ask === null) this.mainAsks.delete(id);
|
|
172
|
+
else if (ask.worker === null) this.mainAsks.add(id);
|
|
173
|
+
this.inbox.notify();
|
|
174
|
+
}
|
|
175
|
+
|
|
170
176
|
switchModel(model: string | null): Promise<void> {
|
|
171
177
|
this.queueModel(model);
|
|
172
178
|
return this.switching;
|
|
@@ -178,7 +184,6 @@ export class Runner {
|
|
|
178
184
|
for await (const message of this.session) {
|
|
179
185
|
if (this.ended) break;
|
|
180
186
|
this.cancelCompact();
|
|
181
|
-
if (message.type === 'system') this.tasks.observe(message);
|
|
182
187
|
const m: Record<string, unknown> = { ...message };
|
|
183
188
|
this.recovery.observe(m);
|
|
184
189
|
this.automation?.observe(m);
|
|
@@ -186,8 +191,8 @@ export class Runner {
|
|
|
186
191
|
this.observeWork(m);
|
|
187
192
|
this.note(m);
|
|
188
193
|
this.parts.activity?.observe(message);
|
|
189
|
-
this.inbox.notify();
|
|
190
194
|
this.maybeCompact();
|
|
195
|
+
this.inbox.notify();
|
|
191
196
|
observe?.(message);
|
|
192
197
|
}
|
|
193
198
|
} catch (err) {
|
|
@@ -318,14 +323,19 @@ export class Runner {
|
|
|
318
323
|
return this.ended || this.parts.skills?.busy === true || this.compactAsked !== null || !this.watch.safe || !this.watch.admitting;
|
|
319
324
|
}
|
|
320
325
|
|
|
326
|
+
private get compactUrgent(): boolean {
|
|
327
|
+
return this.watch.context >= (this.parts.compactAt ?? COMPACT_AT) * URGENT_FACTOR;
|
|
328
|
+
}
|
|
329
|
+
|
|
321
330
|
private compactReady(resuming = false): boolean {
|
|
322
|
-
const
|
|
331
|
+
const waiting = this.inbox.pending > 0 && !this.compactUrgent;
|
|
332
|
+
const busy = this.maintenanceBusy || (!resuming && (this.calls.live || waiting));
|
|
323
333
|
return compactDue({ context: this.watch.context, floor: this.compactFloor, limit: this.parts.compactAt ?? COMPACT_AT, busy });
|
|
324
334
|
}
|
|
325
335
|
|
|
326
336
|
private maybeCompact(resuming = false): void {
|
|
327
337
|
if (!this.compactReady(resuming)) return;
|
|
328
|
-
if (resuming) { this.askCompact(
|
|
338
|
+
if (resuming || this.compactUrgent) { this.askCompact(resuming); return; }
|
|
329
339
|
this.compactTimer = setTimeout(() => {
|
|
330
340
|
this.compactTimer = null;
|
|
331
341
|
if (this.compactReady()) this.askCompact(false);
|
|
@@ -335,7 +345,7 @@ export class Runner {
|
|
|
335
345
|
|
|
336
346
|
private askCompact(resuming: boolean): void {
|
|
337
347
|
this.compactAsked = this.inbox.compact();
|
|
338
|
-
log.info({ context: this.watch.context, resuming }, 'sdk-runner: compacting at a safe
|
|
348
|
+
log.info({ context: this.watch.context, resuming, urgent: this.compactUrgent, waiting: this.inbox.pending }, 'sdk-runner: compacting at a safe boundary');
|
|
339
349
|
}
|
|
340
350
|
|
|
341
351
|
private cancelCompact(): void {
|
|
@@ -35,7 +35,7 @@ export class SessionWatch {
|
|
|
35
35
|
private lastContext = 0;
|
|
36
36
|
private streamUsage: Record<string, unknown> = {};
|
|
37
37
|
|
|
38
|
-
constructor(private readonly readOnly: (tool: string) => boolean) {}
|
|
38
|
+
constructor(private readonly readOnly: (tool: string) => boolean, private readonly mainAsking: () => boolean = () => true) {}
|
|
39
39
|
|
|
40
40
|
get compacting(): boolean {
|
|
41
41
|
return this.compactingNow;
|
|
@@ -52,7 +52,7 @@ export class SessionWatch {
|
|
|
52
52
|
get safe(): boolean { return !this.busy && !this.writing && !this.compacting; }
|
|
53
53
|
|
|
54
54
|
get admitting(): boolean {
|
|
55
|
-
return this.awaitingInput === null && !this.requiresAction && !this.compacting;
|
|
55
|
+
return this.awaitingInput === null && !(this.requiresAction && this.mainAsking()) && !this.compacting;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
dispatched(uuid?: string): void {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
import { log } from '@metro-labs/core/log';
|
|
2
3
|
import { runnerTaskDescription } from '@metro-labs/core/runner-activity';
|
|
3
4
|
import { activityName } from './activity-tasks.js';
|
|
4
5
|
import type { Unanswered } from './inbox.js';
|
|
5
|
-
import { pendingNotice, TASK_LIMIT, type SavedTask, type TaskReason } from './task-state.js';
|
|
6
|
+
import { exhausted, pendingNotice, TASK_LIMIT, type SavedTask, type TaskReason } from './task-state.js';
|
|
6
7
|
|
|
7
8
|
interface Tool { name: string; parent: string | null }
|
|
8
9
|
const failed = (text: unknown): TaskReason => typeof text === 'string' && /\b429\b|rate.?limit/i.test(text.slice(0, 4_096)) ? 'rate_limit' : 'task_error';
|
|
@@ -11,6 +12,7 @@ export class TaskLedger {
|
|
|
11
12
|
private readonly rows = new Map<string, SavedTask>();
|
|
12
13
|
private readonly tools = new Map<string, Tool>();
|
|
13
14
|
private readonly errors = new Map<string, TaskReason>();
|
|
15
|
+
private warned = false;
|
|
14
16
|
|
|
15
17
|
constructor(tasks: SavedTask[], private readonly save: (tasks: SavedTask[], handled?: ReadonlySet<string>) => void) {
|
|
16
18
|
for (const task of tasks) this.rows.set(task.id, task);
|
|
@@ -102,7 +104,8 @@ export class TaskLedger {
|
|
|
102
104
|
|
|
103
105
|
private room(): boolean {
|
|
104
106
|
if (this.rows.size < TASK_LIMIT) return true;
|
|
105
|
-
const
|
|
107
|
+
const rows = this.snapshot();
|
|
108
|
+
const old = rows.find((task) => task.notice === null && task.state !== 'running') ?? rows.find(exhausted);
|
|
106
109
|
if (old === undefined) return false;
|
|
107
110
|
this.rows.delete(old.id);
|
|
108
111
|
return true;
|
|
@@ -111,12 +114,18 @@ export class TaskLedger {
|
|
|
111
114
|
private get(id: string, now: number): SavedTask {
|
|
112
115
|
const known = this.rows.get(id);
|
|
113
116
|
if (known !== undefined) return known;
|
|
114
|
-
if (!this.room()) throw new Error('The saved Agent SDK task ledger is full. Handle pending recovery notices before starting more work.');
|
|
115
117
|
const task: SavedTask = { id, toolUseId: null, owner: null, attempt: 0, state: 'interrupted', events: [], updatedAt: now, notice: null };
|
|
116
|
-
this.rows.set(id, task);
|
|
118
|
+
if (this.room()) this.rows.set(id, task);
|
|
119
|
+
else this.untracked();
|
|
117
120
|
return task;
|
|
118
121
|
}
|
|
119
122
|
|
|
123
|
+
private untracked(): void {
|
|
124
|
+
if (this.warned) return;
|
|
125
|
+
this.warned = true;
|
|
126
|
+
log.warn({ limit: TASK_LIMIT }, 'sdk-runner: the task ledger is full of running work and pending recovery notices, so new work runs without recovery tracking');
|
|
127
|
+
}
|
|
128
|
+
|
|
120
129
|
private start(id: string, rawTool: unknown, event: string | null, now: number, description?: unknown): void {
|
|
121
130
|
const task = this.get(id, now);
|
|
122
131
|
if (task.state !== 'running') task.attempt += 1;
|
|
@@ -4,9 +4,9 @@ import type { Inbox, Unanswered, Uuid } from './inbox.js';
|
|
|
4
4
|
import type { SessionStore } from './session-store.js';
|
|
5
5
|
import { completedCommand, startedCommand, uuidsOf } from './session-watch.js';
|
|
6
6
|
import { TaskLedger } from './task-ledger.js';
|
|
7
|
-
import { TASK_LIMIT, type SavedTask } from './task-state.js';
|
|
7
|
+
import { NOTICE_DELIVERIES, TASK_LIMIT, type SavedTask } from './task-state.js';
|
|
8
8
|
|
|
9
|
-
const DELAYS = [30_000, 120_000, 300_000];
|
|
9
|
+
const DELAYS = [30_000, 120_000, 300_000] as const;
|
|
10
10
|
const marker = (token: string): string => `[metro-task-handled:${token}]`;
|
|
11
11
|
interface Delivery { uuid: Uuid; tokens: string[]; consumed: boolean; completed?: boolean }
|
|
12
12
|
|
|
@@ -159,7 +159,7 @@ export class TaskRecovery {
|
|
|
159
159
|
|
|
160
160
|
private eligible(task: SavedTask): boolean {
|
|
161
161
|
const notice = task.notice;
|
|
162
|
-
if (notice === null || notice.deliveries >=
|
|
162
|
+
if (notice === null || notice.deliveries >= NOTICE_DELIVERIES) return false;
|
|
163
163
|
return ![...this.deliveries.values()].some((delivery) => delivery.completed !== true && delivery.tokens.includes(notice.token));
|
|
164
164
|
}
|
|
165
165
|
|
|
@@ -5,6 +5,7 @@ import { activityName } from './activity-tasks.js';
|
|
|
5
5
|
import type { Uuid } from './inbox.js';
|
|
6
6
|
|
|
7
7
|
export const TASK_LIMIT = 200;
|
|
8
|
+
export const NOTICE_DELIVERIES = 3;
|
|
8
9
|
const STATES = ['running', 'completed', 'failed', 'stopped', 'interrupted'] as const;
|
|
9
10
|
const REASONS = ['task_error', 'rate_limit', 'worker_restart', 'interrupted', 'provider_error'] as const;
|
|
10
11
|
export type TaskReason = (typeof REASONS)[number];
|
|
@@ -57,6 +58,9 @@ function task(raw: unknown): raw is SavedTask {
|
|
|
57
58
|
return integer(raw.attempt) && integer(raw.updatedAt) && STATES.some((state) => state === raw.state) && notice(raw.notice);
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
export const exhausted = (task: SavedTask): boolean =>
|
|
62
|
+
task.state !== 'running' && task.notice !== null && task.notice.deliveries >= NOTICE_DELIVERIES && task.notice.offered === undefined;
|
|
63
|
+
|
|
60
64
|
export const sdkTask = (task: SavedTask): boolean => !/^(?:input|turn):/.test(task.id);
|
|
61
65
|
|
|
62
66
|
export function savedTasks(raw: unknown): SavedTask[] {
|
package/skills/METRO.md
CHANGED
|
@@ -22,7 +22,7 @@ You are the main thread of a Claude Code session that is connected to chat throu
|
|
|
22
22
|
1. `tool_search` finds a tool and returns its full definition: `select:notion.search` for an exact name, words (`notion create page`) to rank, or `+linear issue` to require `linear` in the name. Each name is `<connector>.<tool>`.
|
|
23
23
|
2. `tool_call` runs it, with the name exactly as found and arguments that match its schema. After `tool_search` with `select:notion.search` returned `notion.search`: `{"calls": [{"tool": "notion.search", "arguments": {"query": "roadmap"}}]}`.
|
|
24
24
|
|
|
25
|
-
Put up to 10 independent calls in one `tool_call`; they run in parallel and come back in order, each under `[n] <tool>`.
|
|
25
|
+
Put up to 10 independent calls in one `tool_call`; they run in parallel and come back in order, each under `[n] <tool>`. Both steps run in a worker, never on this thread: `tool_search` answers carry full tool definitions, and anything on this thread stays in its context and slows every later answer. When a call answers `needs_approval`, the owner is asked in chat and on the page: tell the person what is waiting, then wait for the channel message `Approved: <tool> (<id>)` or `Denied: <tool> (<id>)`. After an approval, have a worker repeat the same call with `"approval": "<id>"` added to it.
|
|
26
26
|
|
|
27
27
|
**Finish what was asked.** You work on your own. Nobody is watching a screen, and a question sent to chat may go unanswered for hours, so asking "Shall I?" about work already requested simply stops it. Anything reversible that follows from the request, do. Stop only for a destructive action or a genuine change of scope. Before you end a turn, read your own last paragraph: if it is a plan, a question, a list of next steps or a promise ("I'll…", "Next…"), then the work is not done, so do it now, retries and missing information included. End the turn when the task is finished, or when you are truly blocked on something only the person can give you.
|
|
28
28
|
|
|
@@ -30,6 +30,6 @@ The exception is a person describing a problem, asking a question or thinking ou
|
|
|
30
30
|
|
|
31
31
|
**The whole request is the deliverable.** Do not quietly make it smaller, larger or different. Read an ambiguity the way a careful colleague would, make the ordinary judgement calls yourself, and name the assumption in your reply. If one part turns out to be blocked, finish every other part and say plainly what you left out and why: scaling the work down is the person's call, not yours. Something else you notice on the way is a suggestion at the end of the reply, not extra work to do now.
|
|
32
32
|
|
|
33
|
-
**Report like a relay.** A worker's report is the only evidence you have, so pass on what it verified and say plainly what it did not. Short messages on chat: lead with the outcome, one idea per sentence, no tool names, no file paths unless the person needs them. Send a file out with `create_upload` (the curl line it hands back runs in a worker) and then `send` with the upload id.
|
|
33
|
+
**Report like a relay.** A worker's report is the only evidence you have, so pass on what it verified and say plainly what it did not. Keep this thread small: ask workers for short reports, and give them file paths and details rather than pasting long text into their prompts. Short messages on chat: lead with the outcome, one idea per sentence, no tool names, no file paths unless the person needs them. Send a file out with `create_upload` (the curl line it hands back runs in a worker) and then `send` with the upload id.
|
|
34
34
|
|
|
35
35
|
**Keep going.** Long tasks run as background agents and loops; check in over chat when something lands rather than going quiet. The session is restarted by the daemon if it exits, so an interrupted task is resumed from chat, not from memory.
|