@stage-labs/metro 0.1.0-beta.263 → 0.1.0-beta.265
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/cli.js +2 -2
- package/dist/runtime-install.js +22 -5
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/policy.mjs +1 -1
- package/runtime/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/node_modules/@metro-labs/core/src/call.ts +81 -0
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
- package/runtime/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +35 -2
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +14 -11
- package/runtime/node_modules/@metro-labs/daemon/src/channels/pending.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-bridge.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-permission.ts +25 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/event-store.ts +9 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +15 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +20 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-schema.ts +11 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/speech-send.ts +15 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/runtime-deps.ts +23 -7
- package/runtime/node_modules/@metro-labs/daemon/src/voice/audio.ts +50 -10
- package/runtime/node_modules/@metro-labs/daemon/src/voice/brain.ts +20 -9
- package/runtime/node_modules/@metro-labs/daemon/src/voice/call.ts +125 -51
- package/runtime/node_modules/@metro-labs/daemon/src/voice/calls.ts +41 -15
- package/runtime/node_modules/@metro-labs/daemon/src/voice/peer.ts +9 -8
- package/runtime/node_modules/@metro-labs/daemon/src/voice/scribe.ts +15 -3
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared-talk.ts +85 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/shared.ts +199 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech-queue.ts +173 -0
- package/runtime/node_modules/@metro-labs/daemon/src/voice/speech.ts +40 -15
- package/runtime/node_modules/@metro-labs/daemon/src/voice/talk.ts +15 -7
- package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +32 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/app-state.ts +49 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-history.ts +31 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client-types.ts +46 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +75 -77
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-events.ts +127 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-files.ts +39 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-identity.ts +58 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-message.ts +161 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-state.ts +344 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history-types.ts +91 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/history.ts +195 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/index.ts +1 -1
- package/runtime/node_modules/@metro-labs/whatsapp/src/members.ts +94 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/messages.ts +70 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/send-expiry.ts +65 -0
- package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +4 -0
- package/runtime/patches/5bca2c07d5852d3f58453cff0448dd85da13d3665b61e3d758905b298d432d7c.patch +31 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +1 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/call.ts +81 -0
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +37 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/stations/train-boot.ts +22 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/activity.ts +14 -2
- package/runtime/sdk-runner/src/app.ts +5 -4
- package/runtime/sdk-runner/src/approvals.ts +14 -5
- package/runtime/sdk-runner/src/calls.ts +133 -0
- package/runtime/sdk-runner/src/inbox.ts +58 -26
- package/runtime/sdk-runner/src/input-queue.ts +70 -0
- package/runtime/sdk-runner/src/input-timing.ts +68 -0
- package/runtime/sdk-runner/src/link.ts +27 -4
- package/runtime/sdk-runner/src/main.ts +0 -4
- package/runtime/sdk-runner/src/rules.ts +19 -5
- package/runtime/sdk-runner/src/runner.ts +64 -88
- package/runtime/sdk-runner/src/session-store.ts +14 -1
- package/runtime/sdk-runner/src/session-watch.ts +27 -20
- package/runtime/stations.json +3 -0
- package/runtime/sdk-runner/src/speech.ts +0 -79
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { errMsg } from '../log.js';
|
|
1
|
+
import { errMsg, logFatalSync } from '../log.js';
|
|
2
2
|
import { readCalls } from '../protocol.js';
|
|
3
3
|
import { TrainError } from '../train-error.js';
|
|
4
4
|
import type { CallMsg } from './station-runtime.js';
|
|
@@ -18,12 +18,33 @@ export interface ClientTrain<A extends { id: string }, C> {
|
|
|
18
18
|
loadAccounts: () => A[];
|
|
19
19
|
createClient: (account: A) => C;
|
|
20
20
|
startInbound: (client: C) => Promise<void>;
|
|
21
|
+
disconnectClient?: (client: C) => Promise<void>;
|
|
21
22
|
makeHandleCall: (clientFor: (accountId: string) => C) => (msg: CallMsg) => Promise<void>;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function runClientTrain<A extends { id: string }, C>(train: ClientTrain<A, C>): void {
|
|
25
26
|
const clients = new Map<string, C>();
|
|
27
|
+
let stopping = false;
|
|
28
|
+
if (train.disconnectClient) {
|
|
29
|
+
const disconnect = train.disconnectClient;
|
|
30
|
+
const shutdown = (): void => {
|
|
31
|
+
if (stopping) return;
|
|
32
|
+
stopping = true;
|
|
33
|
+
process.stdin.pause();
|
|
34
|
+
Promise.allSettled([...clients.values()].map(async (client) => { await disconnect(client); })).then((results) => {
|
|
35
|
+
const errors = results.flatMap((result) => result.status === 'rejected' ? [errMsg(result.reason)] : []);
|
|
36
|
+
if (errors.length) logFatalSync({ station: train.station, errors }, 'train: shutdown failed');
|
|
37
|
+
process.exit(errors.length ? 1 : 0);
|
|
38
|
+
}).catch((err: unknown) => {
|
|
39
|
+
logFatalSync({ station: train.station, err: errMsg(err) }, 'train: shutdown failed');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
process.on('SIGTERM', shutdown);
|
|
44
|
+
process.on('SIGINT', shutdown);
|
|
45
|
+
}
|
|
26
46
|
const clientFor = (accountId: string): C => {
|
|
47
|
+
if (stopping) throw new TrainError('train_stopping', `${train.station} is shutting down`);
|
|
27
48
|
const known = clients.get(accountId);
|
|
28
49
|
if (known !== undefined) return known;
|
|
29
50
|
const account = train.accounts.get(accountId);
|
|
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
4
4
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
5
|
import { log } from '@metro-labs/core/log';
|
|
6
|
-
import { runnerFailureSummary, type RunnerActivity, type RunnerEvent, type RunnerEventKind, type RunnerFailure, type RunnerFailureCode, type RunnerPhase, type RunnerTool } from '@metro-labs/core/runner-activity';
|
|
6
|
+
import { runnerFailureSummary, type RunnerActivity, type RunnerEvent, type RunnerEventKind, type RunnerFailure, type RunnerFailureCode, type RunnerInput, type RunnerPhase, type RunnerTool } from '@metro-labs/core/runner-activity';
|
|
7
7
|
import { writeAtomic } from '@metro-labs/core/secure-fs';
|
|
8
8
|
import { ActivityTasks, activityName } from './activity-tasks.js';
|
|
9
9
|
import type { ApprovalAsk } from './approvals.js';
|
|
@@ -91,12 +91,24 @@ export class Activity {
|
|
|
91
91
|
return {
|
|
92
92
|
...s, tools: [...s.tools], activeTools: s.activeTools.map((tool) => ({ ...tool })),
|
|
93
93
|
tasks: s.tasks.map((task) => ({ ...task })), events: s.events.map((event) => ({ ...event })),
|
|
94
|
+
...(s.inputs === undefined ? {} : { inputs: s.inputs.map((input) => ({ ...input })) }),
|
|
94
95
|
activeFailure: s.activeFailure ? { ...s.activeFailure } : null, lastFailure: s.lastFailure ? { ...s.lastFailure } : null,
|
|
95
96
|
};
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
pending(count: number): void {
|
|
99
|
+
pending(count: number, oldest?: number | null): void {
|
|
99
100
|
this.state.pending = count;
|
|
101
|
+
if (oldest !== undefined) this.state.queueOldestAt = oldest;
|
|
102
|
+
this.update();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
input(input: RunnerInput): void {
|
|
106
|
+
this.state.inputs = [input, ...(this.state.inputs ?? []).filter((entry) => entry.id !== input.id)].slice(0, MAX_EVENTS);
|
|
107
|
+
this.update();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
call(state: NonNullable<RunnerActivity['callState']>): void {
|
|
111
|
+
this.state.callState = state;
|
|
100
112
|
this.update();
|
|
101
113
|
}
|
|
102
114
|
|
|
@@ -7,7 +7,6 @@ import { MetroLink } from './link.js';
|
|
|
7
7
|
import { Runner, runnerOptions } from './runner.js';
|
|
8
8
|
import { SessionStore } from './session-store.js';
|
|
9
9
|
import { claimRunner } from './singleton.js';
|
|
10
|
-
import type { SpeechSink } from './speech.js';
|
|
11
10
|
import { metroTools, type MetroTools } from './tool-proxy.js';
|
|
12
11
|
|
|
13
12
|
export interface RunningAgent {
|
|
@@ -18,7 +17,6 @@ export interface RunningAgent {
|
|
|
18
17
|
}
|
|
19
18
|
|
|
20
19
|
export interface AppHooks {
|
|
21
|
-
speech: SpeechSink;
|
|
22
20
|
lost(reason: string): void;
|
|
23
21
|
observe?: (message: SDKMessage) => void;
|
|
24
22
|
env?: NodeJS.ProcessEnv;
|
|
@@ -29,9 +27,11 @@ export interface AppHooks {
|
|
|
29
27
|
async function connected(cfg: RunnerConfig, hooks: AppHooks, release: () => Promise<void>): Promise<RunningAgent> {
|
|
30
28
|
const store = new SessionStore(cfg.statePath, cfg.claudeDir, cfg.cwd);
|
|
31
29
|
let tools: MetroTools | null = null;
|
|
32
|
-
const runner = new Runner({ store,
|
|
30
|
+
const runner = new Runner({ store, readOnly: (tool) => tools?.readOnly(tool) ?? false, ...hooks });
|
|
33
31
|
const link = await MetroLink.open(cfg.mcpUrl, cfg.key, {
|
|
34
32
|
channel: (event) => { runner.chat(event); },
|
|
33
|
+
call: (notice) => { runner.calls.notice(notice); },
|
|
34
|
+
callState: (state) => { runner.calls.reconcile(state.route); },
|
|
35
35
|
toolsChanged: () => { tools?.changed(); },
|
|
36
36
|
model: (model) => {
|
|
37
37
|
log.info({ model }, 'sdk-runner: the Model page picked a model');
|
|
@@ -54,7 +54,8 @@ async function connected(cfg: RunnerConfig, hooks: AppHooks, release: () => Prom
|
|
|
54
54
|
tools = metroTools(link);
|
|
55
55
|
const resume = store.resumable();
|
|
56
56
|
log.info({ resume, model: cfg.model, mode: cfg.permissionMode }, 'sdk-runner: starting the Agent SDK session');
|
|
57
|
-
|
|
57
|
+
const approvals = approvalsThrough(link, (id, ask) => { hooks.activity?.approval(id, ask); }, (tool, input) => runner.calls.approval(tool, input));
|
|
58
|
+
runner.start(runnerOptions(cfg, tools, approvals, resume, hooks.env));
|
|
58
59
|
return { runner, link, done: runner.run(hooks.observe), stop };
|
|
59
60
|
} catch (err) {
|
|
60
61
|
runner.close(false);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { approvalId } from '@metro-labs/core/ids';
|
|
2
|
+
import type { CallSource } from '@metro-labs/core/call';
|
|
2
3
|
import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk';
|
|
3
4
|
import { log } from '@metro-labs/core/log';
|
|
4
5
|
import type { Asker } from './link.js';
|
|
@@ -8,21 +9,29 @@ const DENIED = 'The owner did not approve this.';
|
|
|
8
9
|
export interface ApprovalAsk { tool: string; worker: string | null }
|
|
9
10
|
export type ApprovalWatch = (id: string, ask: ApprovalAsk | null) => void;
|
|
10
11
|
|
|
11
|
-
export
|
|
12
|
+
export interface ApprovalBinding { call: CallSource; signal: AbortSignal }
|
|
13
|
+
export type BindApproval = (tool: string, input: Record<string, unknown>) => ApprovalBinding | null | undefined;
|
|
14
|
+
|
|
15
|
+
export function approvalsThrough(asker: Asker, waiting: ApprovalWatch = () => undefined, bind: BindApproval = () => undefined): CanUseTool {
|
|
12
16
|
return async (tool, input, options) => {
|
|
17
|
+
const binding = bind(tool, input);
|
|
18
|
+
if (binding === null) return { behavior: 'deny', message: 'This speech target is no longer authorized for the active call.' };
|
|
19
|
+
const signal = binding === undefined ? options.signal : AbortSignal.any([options.signal, binding.signal]);
|
|
13
20
|
const id = approvalId();
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
const worker = options.agentID ?? null;
|
|
22
|
+
log.info({ tool, id, worker }, 'sdk-runner: a tool waits for the owner’s approval');
|
|
23
|
+
waiting(id, { tool, worker });
|
|
16
24
|
const behavior = await asker.ask(
|
|
17
25
|
{
|
|
18
26
|
request_id: id,
|
|
19
27
|
tool_name: tool,
|
|
20
28
|
description: options.description ?? options.title ?? options.decisionReason ?? '',
|
|
21
29
|
input_preview: JSON.stringify(input),
|
|
30
|
+
...(binding === undefined ? {} : { call: binding.call }),
|
|
22
31
|
},
|
|
23
|
-
|
|
32
|
+
signal,
|
|
24
33
|
).finally(() => { waiting(id, null); });
|
|
25
34
|
log.info({ tool, id, behavior }, 'sdk-runner: the owner answered');
|
|
26
|
-
return behavior === 'allow' ? { behavior, updatedInput: input } : { behavior, message: DENIED };
|
|
35
|
+
return behavior === 'allow' && !signal.aborted ? { behavior, updatedInput: input } : { behavior: 'deny', message: DENIED };
|
|
27
36
|
};
|
|
28
37
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { sameCall, speechTarget, type CallNotice, type CallRoute, type CallSource } from '@metro-labs/core/call';
|
|
2
|
+
import { log } from '@metro-labs/core/log';
|
|
3
|
+
import type { Activity } from './activity.js';
|
|
4
|
+
import type { ApprovalBinding } from './approvals.js';
|
|
5
|
+
import type { ChannelEvent } from './channel-text.js';
|
|
6
|
+
import type { Inbox } from './inbox.js';
|
|
7
|
+
import { callEnded, callStarted, callWords, speechFailed } from './rules.js';
|
|
8
|
+
|
|
9
|
+
const GENERATIONS_MAX = 512;
|
|
10
|
+
const SOURCES_MAX = 2_048;
|
|
11
|
+
interface ActiveCall {
|
|
12
|
+
route: CallRoute;
|
|
13
|
+
sources: Set<string>;
|
|
14
|
+
failures: Set<string>;
|
|
15
|
+
inputs: string[];
|
|
16
|
+
controller: AbortController;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const routeKey = (route: CallRoute): string => JSON.stringify([route.agentId, route.line, route.from, route.callId, route.generation]);
|
|
20
|
+
|
|
21
|
+
export class RunnerCalls {
|
|
22
|
+
private active: ActiveCall | null = null;
|
|
23
|
+
private readonly seen = new Set<string>();
|
|
24
|
+
|
|
25
|
+
constructor(private readonly inbox: Inbox, private readonly activity?: Activity) {}
|
|
26
|
+
|
|
27
|
+
get live(): boolean { return this.active !== null; }
|
|
28
|
+
|
|
29
|
+
notice(notice: CallNotice): void {
|
|
30
|
+
if (notice.type === 'started') this.start(notice);
|
|
31
|
+
else if (this.active !== null && sameCall(notice.route, this.active.route)) {
|
|
32
|
+
if (notice.type === 'ended') this.end();
|
|
33
|
+
else if (notice.type === 'heard') this.heard(notice);
|
|
34
|
+
else this.speech(notice);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
reconcile(route: CallRoute | null): void {
|
|
39
|
+
if (route !== null && this.active !== null && sameCall(route, this.active.route)) return;
|
|
40
|
+
this.end();
|
|
41
|
+
if (route !== null) this.activate(route);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
chat(event: ChannelEvent): void {
|
|
45
|
+
const active = this.active;
|
|
46
|
+
if (active === null) return;
|
|
47
|
+
const { meta } = event;
|
|
48
|
+
if (meta.line !== active.route.line || meta.from !== active.route.from || meta.call_id !== active.route.callId || meta.call_generation !== active.route.generation) return;
|
|
49
|
+
if (typeof meta.call_source_id !== 'string' || meta.call_source_id.length === 0 || meta.call_source_id.length > 2048) return;
|
|
50
|
+
if (active.sources.size < SOURCES_MAX) active.sources.add(meta.call_source_id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
approval(tool: string, input: Record<string, unknown>): ApprovalBinding | null | undefined {
|
|
54
|
+
if (tool !== 'mcp__metro__send' || input.speech === undefined) return undefined;
|
|
55
|
+
const target = speechTarget(input.speech);
|
|
56
|
+
const active = this.active;
|
|
57
|
+
if (active === null || target === null || input.line !== active.route.line || target.callId !== active.route.callId || target.generation !== active.route.generation || !active.sources.has(target.sourceId)) return null;
|
|
58
|
+
return { call: { route: active.route, sourceId: target.sourceId }, signal: active.controller.signal };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
close(): void {
|
|
62
|
+
this.active?.controller.abort();
|
|
63
|
+
this.active = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private activate(route: CallRoute): boolean {
|
|
67
|
+
const key = routeKey(route);
|
|
68
|
+
if (this.seen.has(key)) return false;
|
|
69
|
+
this.seen.add(key);
|
|
70
|
+
if (this.seen.size > GENERATIONS_MAX) {
|
|
71
|
+
const oldest = this.seen.values().next().value;
|
|
72
|
+
if (oldest !== undefined) this.seen.delete(oldest);
|
|
73
|
+
}
|
|
74
|
+
this.end();
|
|
75
|
+
this.active = { route, sources: new Set(), failures: new Set(), inputs: [], controller: new AbortController() };
|
|
76
|
+
this.activity?.call('started');
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private start(notice: Extract<CallNotice, { type: 'started' }>): void {
|
|
81
|
+
if (this.activate(notice.route)) this.push(notice, callStarted(notice));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private heard(notice: Extract<CallNotice, { type: 'heard' }>): void {
|
|
85
|
+
if (notice.text.trim() === '') return;
|
|
86
|
+
this.push(notice, callWords(notice, notice.text));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private speech(notice: Extract<CallNotice, { type: 'speech' }>): void {
|
|
90
|
+
this.activity?.call(notice.status === 'started' ? 'speaking' : notice.status);
|
|
91
|
+
const active = this.active;
|
|
92
|
+
if (notice.status !== 'failed' || active === null || !active.sources.has(notice.sourceId)) return;
|
|
93
|
+
if (active.failures.has(notice.actionId) || active.failures.size >= SOURCES_MAX) return;
|
|
94
|
+
active.failures.add(notice.actionId);
|
|
95
|
+
try {
|
|
96
|
+
this.inbox.push('note', speechFailed(active.route));
|
|
97
|
+
} catch {
|
|
98
|
+
log.warn('sdk-runner: speech failure context could not be queued');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private push(source: CallSource, text: string): void {
|
|
103
|
+
const active = this.active;
|
|
104
|
+
if (active === null || active.sources.has(source.sourceId) || active.sources.size >= SOURCES_MAX) return;
|
|
105
|
+
active.sources.add(source.sourceId);
|
|
106
|
+
this.enqueue(text);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private enqueue(text: string): void {
|
|
110
|
+
const active = this.active;
|
|
111
|
+
if (active === null) return;
|
|
112
|
+
try {
|
|
113
|
+
active.inputs.push(this.inbox.push('call', text));
|
|
114
|
+
} catch {
|
|
115
|
+
this.activity?.call('failed');
|
|
116
|
+
log.warn('sdk-runner: call input queue refused a message');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private end(): void {
|
|
121
|
+
const active = this.active;
|
|
122
|
+
if (active === null) return;
|
|
123
|
+
this.active = null;
|
|
124
|
+
active.controller.abort();
|
|
125
|
+
this.inbox.cancel(active.inputs);
|
|
126
|
+
this.activity?.call('ended');
|
|
127
|
+
try {
|
|
128
|
+
this.inbox.push('note', callEnded(active.route));
|
|
129
|
+
} catch {
|
|
130
|
+
log.warn('sdk-runner: call end context could not be queued');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
3
|
+
import type { RunnerInput } from '@metro-labs/core/runner-activity';
|
|
4
|
+
import { InputQueue } from './input-queue.js';
|
|
5
|
+
import { InputTiming, type InputWatch } from './input-timing.js';
|
|
3
6
|
|
|
4
7
|
export type Uuid = ReturnType<typeof randomUUID>;
|
|
5
|
-
export type InputKind = '
|
|
8
|
+
export type InputKind = RunnerInput['kind'];
|
|
6
9
|
export type Priority = 'now' | 'next' | 'later';
|
|
7
10
|
|
|
8
11
|
export interface Unanswered {
|
|
@@ -12,32 +15,49 @@ export interface Unanswered {
|
|
|
12
15
|
state?: 'queued' | 'started';
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
interface InboxHooks {
|
|
19
|
+
ready?(): boolean;
|
|
20
|
+
dispatch?(): void;
|
|
21
|
+
queue?(count: number, oldest: number | null): void;
|
|
22
|
+
input?: InputWatch;
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
const KINDS_MAX = 2_000;
|
|
16
26
|
const LEDGER_MAX = 100;
|
|
17
27
|
const LEDGER_AGE_MS = 6 * 60 * 60_000;
|
|
18
28
|
|
|
19
29
|
export class Inbox implements AsyncIterable<SDKUserMessage> {
|
|
20
|
-
private readonly queue
|
|
30
|
+
private readonly queue = new InputQueue();
|
|
21
31
|
private readonly kinds = new Map<string, InputKind>();
|
|
22
32
|
private readonly ledger = new Map<string, Unanswered>();
|
|
33
|
+
private readonly timing: InputTiming;
|
|
23
34
|
private wake: (() => void) | null = null;
|
|
24
35
|
private closed = false;
|
|
25
36
|
private restoring = false;
|
|
26
37
|
|
|
27
|
-
constructor(private readonly changed: (unanswered: Unanswered[]) => void = () => undefined) {
|
|
38
|
+
constructor(private readonly changed: (unanswered: Unanswered[]) => void = () => undefined, private readonly hooks: InboxHooks = {}) {
|
|
39
|
+
this.timing = new InputTiming(hooks.input ?? (() => undefined));
|
|
40
|
+
}
|
|
28
41
|
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
get pending(): number { return this.queue.size; }
|
|
43
|
+
|
|
44
|
+
compact(): Uuid {
|
|
45
|
+
const uuid = this.push('note', '/compact');
|
|
46
|
+
this.queue.prefer(uuid);
|
|
47
|
+
return uuid;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
push(kind: InputKind, text: string, _priority?: Priority, uuid: Uuid = randomUUID(), at = Date.now()): Uuid {
|
|
51
|
+
if (this.closed) throw new Error('The SDK input queue is closed.');
|
|
52
|
+
this.queue.check(kind, text);
|
|
31
53
|
if (kind === 'chat') this.remember(uuid, text, at);
|
|
32
|
-
this.
|
|
33
|
-
|
|
34
|
-
message: { role: 'user', content: text },
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
});
|
|
40
|
-
this.wake?.();
|
|
54
|
+
this.mark(uuid, kind);
|
|
55
|
+
this.queue.push(kind, {
|
|
56
|
+
type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null,
|
|
57
|
+
uuid, origin: { kind: 'channel', server: 'metro' },
|
|
58
|
+
}, text, at);
|
|
59
|
+
this.timing.accept(uuid, kind, at);
|
|
60
|
+
this.notify();
|
|
41
61
|
return uuid;
|
|
42
62
|
}
|
|
43
63
|
|
|
@@ -48,9 +68,7 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
|
|
|
48
68
|
if (oldest !== undefined) this.kinds.delete(oldest);
|
|
49
69
|
}
|
|
50
70
|
|
|
51
|
-
kindOf(uuid: string): InputKind | undefined {
|
|
52
|
-
return this.kinds.get(uuid);
|
|
53
|
-
}
|
|
71
|
+
kindOf(uuid: string): InputKind | undefined { return this.kinds.get(uuid); }
|
|
54
72
|
|
|
55
73
|
started(uuids: readonly string[]): void {
|
|
56
74
|
let changed = false;
|
|
@@ -61,12 +79,24 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
|
|
|
61
79
|
changed = true;
|
|
62
80
|
}
|
|
63
81
|
if (changed) this.changed(this.unanswered());
|
|
82
|
+
this.timing.consume(uuids);
|
|
64
83
|
}
|
|
65
84
|
|
|
85
|
+
output(): void { this.timing.output(); }
|
|
86
|
+
boundary(): void { this.timing.boundary(); }
|
|
87
|
+
|
|
66
88
|
finished(uuids = this.startedUuids()): void {
|
|
67
89
|
let changed = false;
|
|
68
90
|
for (const uuid of uuids) changed = this.ledger.delete(uuid) || changed;
|
|
69
91
|
if (changed) this.changed(this.unanswered());
|
|
92
|
+
this.timing.finish(uuids);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
cancel(uuids: readonly string[]): void {
|
|
96
|
+
const ephemeral = uuids.filter((uuid) => this.kindOf(uuid) !== 'chat');
|
|
97
|
+
this.queue.cancel(new Set(ephemeral));
|
|
98
|
+
this.timing.finish(ephemeral, true);
|
|
99
|
+
this.notify();
|
|
70
100
|
}
|
|
71
101
|
|
|
72
102
|
startedUuids(): string[] {
|
|
@@ -82,16 +112,22 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
|
|
|
82
112
|
this.wake?.();
|
|
83
113
|
}
|
|
84
114
|
|
|
115
|
+
notify(): void {
|
|
116
|
+
this.hooks.queue?.(this.queue.size, this.queue.oldest);
|
|
117
|
+
this.wake?.();
|
|
118
|
+
}
|
|
119
|
+
|
|
85
120
|
async *[Symbol.asyncIterator](): AsyncIterator<SDKUserMessage> {
|
|
86
121
|
while (!this.closed) {
|
|
87
|
-
const next = this.queue.
|
|
122
|
+
const next = (this.hooks.ready?.() ?? true) ? this.queue.take() : undefined;
|
|
88
123
|
if (next !== undefined) {
|
|
124
|
+
this.hooks.dispatch?.();
|
|
125
|
+
if (next.uuid !== undefined) this.timing.dispatch(next.uuid);
|
|
126
|
+
this.notify();
|
|
89
127
|
yield next;
|
|
90
128
|
continue;
|
|
91
129
|
}
|
|
92
|
-
await new Promise<void>((resolve) => {
|
|
93
|
-
this.wake = resolve;
|
|
94
|
-
});
|
|
130
|
+
await new Promise<void>((resolve) => { this.wake = resolve; });
|
|
95
131
|
this.wake = null;
|
|
96
132
|
}
|
|
97
133
|
}
|
|
@@ -108,12 +144,8 @@ export class Inbox implements AsyncIterable<SDKUserMessage> {
|
|
|
108
144
|
}
|
|
109
145
|
|
|
110
146
|
private remember(uuid: Uuid, text: string, at: number): void {
|
|
147
|
+
if (!this.ledger.has(uuid) && this.ledger.size >= LEDGER_MAX) throw new Error('The SDK input queue is full. Wait for queued work to finish before retrying.');
|
|
111
148
|
this.ledger.set(uuid, { text, at, uuid, state: 'queued' });
|
|
112
|
-
while (this.ledger.size > LEDGER_MAX) {
|
|
113
|
-
const oldest = [...this.ledger].find(([, entry]) => entry.state !== 'started')?.[0];
|
|
114
|
-
if (oldest === undefined) break;
|
|
115
|
-
this.ledger.delete(oldest);
|
|
116
|
-
}
|
|
117
149
|
if (!this.restoring) this.changed(this.unanswered());
|
|
118
150
|
}
|
|
119
151
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
import type { RunnerInput } from '@metro-labs/core/runner-activity';
|
|
3
|
+
|
|
4
|
+
type InputKind = RunnerInput['kind'];
|
|
5
|
+
|
|
6
|
+
const LANES: readonly InputKind[] = ['chat', 'call', 'note'];
|
|
7
|
+
const CAPS: Record<InputKind, number> = { chat: 100, call: 32, note: 16 };
|
|
8
|
+
const BYTES_MAX = 4 * 1024 * 1024;
|
|
9
|
+
const INPUT_MAX = 256 * 1024;
|
|
10
|
+
|
|
11
|
+
interface Queued {
|
|
12
|
+
kind: InputKind;
|
|
13
|
+
message: SDKUserMessage;
|
|
14
|
+
bytes: number;
|
|
15
|
+
at: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class InputQueue {
|
|
19
|
+
private readonly items: Queued[] = [];
|
|
20
|
+
private bytes = 0;
|
|
21
|
+
private lane = 0;
|
|
22
|
+
private first: string | null = null;
|
|
23
|
+
|
|
24
|
+
prefer(uuid: string): void { this.first = uuid; }
|
|
25
|
+
|
|
26
|
+
get size(): number { return this.items.length; }
|
|
27
|
+
get oldest(): number | null { return this.items.reduce<number | null>((at, item) => at === null ? item.at : Math.min(at, item.at), null); }
|
|
28
|
+
|
|
29
|
+
check(kind: InputKind, text: string): void {
|
|
30
|
+
const bytes = Buffer.byteLength(text);
|
|
31
|
+
if (bytes > INPUT_MAX || this.bytes + bytes > BYTES_MAX || this.items.filter((item) => item.kind === kind).length >= CAPS[kind]) {
|
|
32
|
+
throw new Error('The SDK input queue is full. Wait for queued work to finish before retrying.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
push(kind: InputKind, message: SDKUserMessage, text: string, at: number): void {
|
|
37
|
+
const bytes = Buffer.byteLength(text);
|
|
38
|
+
this.items.push({ kind, message, bytes, at });
|
|
39
|
+
this.bytes += bytes;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
take(): SDKUserMessage | undefined {
|
|
43
|
+
if (this.first !== null) {
|
|
44
|
+
const index = this.items.findIndex((item) => item.message.uuid === this.first);
|
|
45
|
+
this.first = null;
|
|
46
|
+
if (index >= 0) return this.remove(index)?.message;
|
|
47
|
+
}
|
|
48
|
+
for (let i = 0; i < LANES.length; i += 1) {
|
|
49
|
+
const lane = (this.lane + i) % LANES.length;
|
|
50
|
+
const index = this.items.findIndex((item) => item.kind === LANES[lane]);
|
|
51
|
+
if (index < 0) continue;
|
|
52
|
+
this.lane = (lane + 1) % LANES.length;
|
|
53
|
+
return this.remove(index)?.message;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
cancel(uuids: ReadonlySet<string>): void {
|
|
59
|
+
for (let index = this.items.length - 1; index >= 0; index -= 1) {
|
|
60
|
+
const uuid = this.items[index]?.message.uuid;
|
|
61
|
+
if (uuid !== undefined && uuids.has(uuid)) this.remove(index);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private remove(index: number): Queued | undefined {
|
|
66
|
+
const [item] = this.items.splice(index, 1);
|
|
67
|
+
if (item !== undefined) this.bytes -= item.bytes;
|
|
68
|
+
return item;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { RunnerInput } from '@metro-labs/core/runner-activity';
|
|
2
|
+
import { log } from '@metro-labs/core/log';
|
|
3
|
+
|
|
4
|
+
export type InputWatch = (input: RunnerInput) => void;
|
|
5
|
+
|
|
6
|
+
export class InputTiming {
|
|
7
|
+
private readonly inputs = new Map<string, RunnerInput>();
|
|
8
|
+
private readonly active = new Set<string>();
|
|
9
|
+
|
|
10
|
+
constructor(private readonly changed: InputWatch) {}
|
|
11
|
+
|
|
12
|
+
accept(id: string, kind: RunnerInput['kind'], at: number): void {
|
|
13
|
+
const input: RunnerInput = { id, kind, state: 'accepted', acceptedAt: at, dispatchedAt: null, consumedAt: null, firstOutputAt: null, completedAt: null };
|
|
14
|
+
this.inputs.set(id, input);
|
|
15
|
+
if (this.inputs.size > 2_000) {
|
|
16
|
+
const oldest = this.inputs.keys().next().value;
|
|
17
|
+
if (oldest !== undefined) { this.inputs.delete(oldest); this.active.delete(oldest); }
|
|
18
|
+
}
|
|
19
|
+
this.note(input);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
dispatch(id: string): void {
|
|
23
|
+
const input = this.inputs.get(id);
|
|
24
|
+
if (input === undefined) return;
|
|
25
|
+
input.dispatchedAt = Date.now();
|
|
26
|
+
this.note(input);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
consume(uuids: readonly string[]): void {
|
|
30
|
+
for (const id of uuids) {
|
|
31
|
+
const input = this.inputs.get(id);
|
|
32
|
+
if (input === undefined || input.consumedAt !== null) continue;
|
|
33
|
+
input.consumedAt = Date.now();
|
|
34
|
+
input.state = 'consumed';
|
|
35
|
+
this.active.add(id);
|
|
36
|
+
this.note(input);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
output(): void {
|
|
41
|
+
for (const id of this.active) {
|
|
42
|
+
const input = this.inputs.get(id);
|
|
43
|
+
if (input === undefined || input.firstOutputAt !== null) continue;
|
|
44
|
+
input.firstOutputAt = Date.now();
|
|
45
|
+
input.state = 'output';
|
|
46
|
+
this.note(input);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
finish(uuids: readonly string[], cancelled = false): void {
|
|
51
|
+
for (const id of uuids) {
|
|
52
|
+
const input = this.inputs.get(id);
|
|
53
|
+
if (input === undefined) continue;
|
|
54
|
+
input.completedAt = Date.now();
|
|
55
|
+
input.state = cancelled ? 'cancelled' : 'completed';
|
|
56
|
+
this.inputs.delete(id);
|
|
57
|
+
this.active.delete(id);
|
|
58
|
+
this.note(input);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
boundary(): void { this.active.clear(); }
|
|
63
|
+
|
|
64
|
+
private note(input: RunnerInput): void {
|
|
65
|
+
this.changed({ ...input });
|
|
66
|
+
log.info({ input: input.id, kind: input.kind, state: input.state, queueAgeMs: (input.consumedAt ?? Date.now()) - input.acceptedAt }, 'sdk-runner: input lifecycle');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -3,6 +3,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
|
|
|
3
3
|
import { CallToolResultSchema, type CallToolRequest, type CallToolResult, type ListToolsResult, type Notification } from '@modelcontextprotocol/sdk/types.js';
|
|
4
4
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
|
+
import { CALL_NOTICE, CALL_STATE, callNotice, callState, type CallNotice, type CallSource, type CallState } from '@metro-labs/core/call';
|
|
6
7
|
import { channelEvent, type ChannelEvent } from './channel-text.js';
|
|
7
8
|
|
|
8
9
|
const CHANNEL = 'notifications/claude/channel';
|
|
@@ -23,10 +24,13 @@ export interface PermissionAsk {
|
|
|
23
24
|
tool_name: string;
|
|
24
25
|
description: string;
|
|
25
26
|
input_preview: string;
|
|
27
|
+
call?: CallSource;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface LinkEvents {
|
|
29
31
|
channel(event: ChannelEvent): void;
|
|
32
|
+
call?(notice: CallNotice): void;
|
|
33
|
+
callState?(state: CallState): void;
|
|
30
34
|
toolsChanged(): void;
|
|
31
35
|
model(model: string | null): void;
|
|
32
36
|
lost(reason: string): void;
|
|
@@ -81,14 +85,19 @@ export class MetroLink implements Asker {
|
|
|
81
85
|
ask(ask: PermissionAsk, signal: AbortSignal): Promise<Behavior> {
|
|
82
86
|
if (signal.aborted) return Promise.resolve('deny');
|
|
83
87
|
return new Promise((resolve) => {
|
|
88
|
+
const abort = (): void => {
|
|
89
|
+
settle('deny');
|
|
90
|
+
this.client.notification({ method: 'notifications/metro/permission_cancel', params: { request_id: ask.request_id } }).catch(() => {
|
|
91
|
+
log.warn('sdk-runner: could not cancel a pending approval with metro');
|
|
92
|
+
});
|
|
93
|
+
};
|
|
84
94
|
const settle = (behavior: Behavior): void => {
|
|
85
|
-
|
|
95
|
+
signal.removeEventListener('abort', abort);
|
|
96
|
+
if (!this.waiting.delete(ask.request_id)) return;
|
|
86
97
|
resolve(behavior);
|
|
87
98
|
};
|
|
88
99
|
this.waiting.set(ask.request_id, settle);
|
|
89
|
-
signal.addEventListener('abort',
|
|
90
|
-
settle('deny');
|
|
91
|
-
}, { once: true });
|
|
100
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
92
101
|
this.client.notification({ method: ASK, params: { ...ask } }).catch((err: unknown) => {
|
|
93
102
|
log.warn({ err: errMsg(err), id: ask.request_id }, 'sdk-runner: could not hand an approval to metro');
|
|
94
103
|
settle('deny');
|
|
@@ -106,6 +115,10 @@ export class MetroLink implements Asker {
|
|
|
106
115
|
if (notification.method === CHANNEL) {
|
|
107
116
|
const event = channelEvent(params);
|
|
108
117
|
if (event !== null) this.events.channel(event);
|
|
118
|
+
} else if (notification.method === CALL_NOTICE) {
|
|
119
|
+
this.callNotified(params);
|
|
120
|
+
} else if (notification.method === CALL_STATE) {
|
|
121
|
+
this.reconciled(params);
|
|
109
122
|
} else if (notification.method === ANSWER) {
|
|
110
123
|
this.answered(params);
|
|
111
124
|
} else if (notification.method === TOOLS_CHANGED) {
|
|
@@ -115,6 +128,16 @@ export class MetroLink implements Asker {
|
|
|
115
128
|
}
|
|
116
129
|
}
|
|
117
130
|
|
|
131
|
+
private callNotified(params: Record<string, unknown>): void {
|
|
132
|
+
const notice = callNotice(params);
|
|
133
|
+
if (notice !== null) this.events.call?.(notice);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private reconciled(params: Record<string, unknown>): void {
|
|
137
|
+
const state = callState(params);
|
|
138
|
+
if (state !== null) this.events.callState?.(state);
|
|
139
|
+
}
|
|
140
|
+
|
|
118
141
|
private answered(params: Record<string, unknown>): void {
|
|
119
142
|
const id = typeof params.request_id === 'string' ? params.request_id : '';
|
|
120
143
|
this.waiting.get(id)?.(params.behavior === 'allow' ? 'allow' : 'deny');
|
|
@@ -23,10 +23,6 @@ try {
|
|
|
23
23
|
activity = new Activity(join(dirname(cfg.statePath), 'agent-status.json'));
|
|
24
24
|
agent = await startAgent(cfg, {
|
|
25
25
|
activity,
|
|
26
|
-
speech: {
|
|
27
|
-
say: (text) => { log.debug({ chars: text.length }, 'sdk-runner: speech'); },
|
|
28
|
-
done: () => { log.debug('sdk-runner: speech done'); },
|
|
29
|
-
},
|
|
30
26
|
lost: () => {
|
|
31
27
|
activity?.fail('The SDK lost its Metro connection. The session will restart.');
|
|
32
28
|
exitWith(1)();
|