@stage-labs/metro 0.1.0-beta.264 → 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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +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/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/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/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/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/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/sdk-runner/src/speech.ts +0 -79
|
@@ -6,7 +6,7 @@ import { elevenLabs, parsed, wsText } from './scribe.js';
|
|
|
6
6
|
|
|
7
7
|
const TTS_MODEL = 'eleven_flash_v2_5';
|
|
8
8
|
|
|
9
|
-
interface Voiced {
|
|
9
|
+
export interface Voiced {
|
|
10
10
|
audio(pcm48k: Int16Array, chars: number): void;
|
|
11
11
|
done(): void;
|
|
12
12
|
}
|
|
@@ -37,10 +37,12 @@ export class Utterance {
|
|
|
37
37
|
private aborted = false;
|
|
38
38
|
private voiced = false;
|
|
39
39
|
private refusal: string | null = null;
|
|
40
|
+
private finished = false;
|
|
40
41
|
|
|
41
|
-
constructor(apiKey: string, voiceId: string, failed: (reason: string) => void) {
|
|
42
|
+
constructor(apiKey: string, voiceId: string, private readonly failed: (reason: string) => void) {
|
|
42
43
|
this.ws = new WebSocket(ttsUrl(voiceId), { headers: { 'xi-api-key': apiKey } });
|
|
43
44
|
this.ws.on('open', () => {
|
|
45
|
+
if (this.aborted) return;
|
|
44
46
|
this.ws.send(JSON.stringify({ text: ' ' }));
|
|
45
47
|
for (const line of this.outbox.splice(0)) this.ws.send(line);
|
|
46
48
|
});
|
|
@@ -48,15 +50,14 @@ export class Utterance {
|
|
|
48
50
|
this.onMessage(wsText(data));
|
|
49
51
|
});
|
|
50
52
|
this.ws.on('error', (err) => {
|
|
53
|
+
if (this.aborted || this.finished) return;
|
|
51
54
|
this.refusal ??= errMsg(err);
|
|
52
55
|
log.warn({ err: errMsg(err) }, 'voice: text to speech connection error');
|
|
53
56
|
});
|
|
54
57
|
this.ws.on('close', (code, why) => {
|
|
55
58
|
this.closed = true;
|
|
56
|
-
this.listener?.done();
|
|
57
|
-
this.listener = null;
|
|
58
59
|
const reason = this.refusal ?? (code === 1000 ? null : (why.toString() || `closed (${String(code)})`));
|
|
59
|
-
|
|
60
|
+
this.complete(reason);
|
|
60
61
|
});
|
|
61
62
|
}
|
|
62
63
|
|
|
@@ -65,7 +66,7 @@ export class Utterance {
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
attach(listener: Voiced): void {
|
|
68
|
-
this.listener = listener;
|
|
69
|
+
if (!this.aborted && !this.finished) this.listener = listener;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
say(text: string): void {
|
|
@@ -77,28 +78,52 @@ export class Utterance {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
abort(): void {
|
|
81
|
+
if (this.aborted) return;
|
|
80
82
|
this.aborted = true;
|
|
81
83
|
this.listener = null;
|
|
84
|
+
this.outbox.length = 0;
|
|
82
85
|
this.ws.terminate();
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
private complete(reason: string | null): void {
|
|
89
|
+
if (this.aborted || this.finished) return;
|
|
90
|
+
this.finished = true;
|
|
91
|
+
const listener = this.listener;
|
|
92
|
+
this.listener = null;
|
|
93
|
+
this.outbox.length = 0;
|
|
94
|
+
const failure = reason ?? (listener !== null && !this.voiced ? 'no audio received' : null);
|
|
95
|
+
if (failure !== null) this.failed(failure.slice(0, MAX_REASON));
|
|
96
|
+
else listener?.done();
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
private post(msg: Record<string, unknown>): void {
|
|
100
|
+
if (this.aborted || this.closed || this.finished) return;
|
|
86
101
|
const line = JSON.stringify(msg);
|
|
87
102
|
if (this.ws.readyState === WebSocket.OPEN) this.ws.send(line);
|
|
88
103
|
else this.outbox.push(line);
|
|
89
104
|
}
|
|
90
105
|
|
|
106
|
+
private refused(msg: Record<string, unknown>, raw: string): void {
|
|
107
|
+
if (typeof msg.message === 'string' && msg.message !== '') this.refusal ??= msg.message;
|
|
108
|
+
log.warn({ msg: raw.slice(0, MAX_REASON) }, 'voice: text to speech said');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private audio(encoded: string, chars: number): void {
|
|
112
|
+
this.voiced = true;
|
|
113
|
+
const pcm = pcmOf(Buffer.from(encoded, 'base64'));
|
|
114
|
+
const up = upsample2(pcm, this.carry);
|
|
115
|
+
this.carry = pcm.at(-1) ?? this.carry;
|
|
116
|
+
this.listener?.audio(up, chars);
|
|
117
|
+
}
|
|
118
|
+
|
|
91
119
|
private onMessage(raw: string): void {
|
|
120
|
+
if (this.aborted || this.finished) return;
|
|
92
121
|
const msg = parsed(raw);
|
|
93
|
-
if (typeof msg.audio === 'string' && msg.audio !== '')
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
this.
|
|
98
|
-
this.listener?.audio(up, charsOf(msg));
|
|
99
|
-
} else if (msg.isFinal !== true) {
|
|
100
|
-
if (typeof msg.message === 'string' && msg.message !== '') this.refusal ??= msg.message;
|
|
101
|
-
log.warn({ msg: raw.slice(0, MAX_REASON) }, 'voice: text to speech said');
|
|
122
|
+
if (typeof msg.audio === 'string' && msg.audio !== '') this.audio(msg.audio, charsOf(msg));
|
|
123
|
+
else if (msg.isFinal !== true) this.refused(msg, raw);
|
|
124
|
+
if (this.refusal !== null || msg.isFinal === true) {
|
|
125
|
+
this.complete(this.refusal);
|
|
126
|
+
this.ws.close(1000);
|
|
102
127
|
}
|
|
103
128
|
}
|
|
104
129
|
}
|
|
@@ -58,7 +58,7 @@ export class Talk implements Thinking {
|
|
|
58
58
|
|
|
59
59
|
constructor(
|
|
60
60
|
private readonly cfg: VoiceConfig,
|
|
61
|
-
out:
|
|
61
|
+
out: Playback['send'],
|
|
62
62
|
private readonly ended: (reason: string) => void,
|
|
63
63
|
private readonly approvals: CallApprovals,
|
|
64
64
|
) {
|
|
@@ -72,6 +72,9 @@ export class Talk implements Thinking {
|
|
|
72
72
|
this.player.whenFirstPlayed(() => {
|
|
73
73
|
this.firstPlayed();
|
|
74
74
|
});
|
|
75
|
+
this.player.whenFailed((reason) => {
|
|
76
|
+
if (!this.finished) this.ended(`audio playback failed: ${reason}`);
|
|
77
|
+
});
|
|
75
78
|
this.brain = new Brain(brainModel(cfg), this);
|
|
76
79
|
this.scribe = new Scribe(cfg.apiKey, languageOf(cfg), {
|
|
77
80
|
partial: (text) => {
|
|
@@ -88,10 +91,11 @@ export class Talk implements Thinking {
|
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
prime(context: string): void {
|
|
91
|
-
this.brain.tell(context);
|
|
94
|
+
if (!this.finished) this.brain.tell(context);
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
connect(): void {
|
|
98
|
+
if (this.finished || this.live) return;
|
|
95
99
|
this.live = true;
|
|
96
100
|
this.spare ??= this.utterance();
|
|
97
101
|
this.player.start();
|
|
@@ -118,8 +122,8 @@ export class Talk implements Thinking {
|
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
turnStarted(): void {
|
|
121
|
-
this.muted = false;
|
|
122
125
|
if (this.finished) return;
|
|
126
|
+
this.muted = false;
|
|
123
127
|
this.reply = {
|
|
124
128
|
utterance: this.takeUtterance(),
|
|
125
129
|
said: '',
|
|
@@ -137,19 +141,20 @@ export class Talk implements Thinking {
|
|
|
137
141
|
|
|
138
142
|
text(delta: string): void {
|
|
139
143
|
const reply = this.reply;
|
|
140
|
-
if (this.muted || reply === null) return;
|
|
144
|
+
if (this.finished || this.muted || reply === null) return;
|
|
141
145
|
reply.firstText ??= performance.now();
|
|
142
146
|
this.speak(reply, reply.chunker.add(delta));
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
tool(name: string): void {
|
|
150
|
+
if (this.finished) return;
|
|
146
151
|
log.info({ tool: name }, 'voice: the agent uses a tool during the call');
|
|
147
152
|
if (this.reply !== null && !this.muted) this.speak(this.reply, this.reply.chunker.flush());
|
|
148
153
|
}
|
|
149
154
|
|
|
150
155
|
turnEnded(): void {
|
|
151
156
|
const reply = this.reply;
|
|
152
|
-
if (reply === null || this.muted) return;
|
|
157
|
+
if (this.finished || reply === null || this.muted) return;
|
|
153
158
|
this.speak(reply, reply.chunker.flush());
|
|
154
159
|
reply.utterance.end();
|
|
155
160
|
log.info(
|
|
@@ -171,6 +176,7 @@ export class Talk implements Thinking {
|
|
|
171
176
|
}
|
|
172
177
|
|
|
173
178
|
exited(reason: string): void {
|
|
179
|
+
if (this.finished) return;
|
|
174
180
|
log.warn({ reason }, 'voice: the agent session for the call ended');
|
|
175
181
|
this.ended(`agent session ${reason}`);
|
|
176
182
|
}
|
|
@@ -183,13 +189,13 @@ export class Talk implements Thinking {
|
|
|
183
189
|
ready.attach({
|
|
184
190
|
audio: (pcm, chars) => {
|
|
185
191
|
const current = reply();
|
|
186
|
-
if (current?.utterance !== ready) return;
|
|
192
|
+
if (this.finished || this.muted || current?.utterance !== ready) return;
|
|
187
193
|
this.spoke = true;
|
|
188
194
|
current.firstAudio ??= performance.now();
|
|
189
195
|
this.player.push(pcm, chars);
|
|
190
196
|
},
|
|
191
197
|
done: () => {
|
|
192
|
-
this.player.finish();
|
|
198
|
+
if (!this.finished && !this.muted && this.reply?.utterance === ready) this.player.finish();
|
|
193
199
|
},
|
|
194
200
|
});
|
|
195
201
|
return ready;
|
|
@@ -239,11 +245,13 @@ export class Talk implements Thinking {
|
|
|
239
245
|
}
|
|
240
246
|
|
|
241
247
|
private partial(text: string): void {
|
|
248
|
+
if (this.finished) return;
|
|
242
249
|
if (text !== '') this.lastHeardAt = performance.now();
|
|
243
250
|
if (this.interrupts(text)) this.cutOff();
|
|
244
251
|
}
|
|
245
252
|
|
|
246
253
|
private committed(text: string): void {
|
|
254
|
+
if (this.finished) return;
|
|
247
255
|
if (this.interrupts(text)) this.cutOff();
|
|
248
256
|
const note = this.cut;
|
|
249
257
|
const now = this.muted;
|
package/runtime/runtime.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"./events": "./src/events.ts",
|
|
10
10
|
"./is-record": "./src/is-record.ts",
|
|
11
11
|
"./runner-activity": "./src/runner-activity.ts",
|
|
12
|
+
"./call": "./src/call.ts",
|
|
12
13
|
"./secure-fs": "./src/secure-fs.ts",
|
|
13
14
|
"./version": "./src/version.ts",
|
|
14
15
|
"./train-error": "./src/train-error.ts",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { isRecord } from './is-record.js';
|
|
2
|
+
|
|
3
|
+
export const CALL_NOTICE = 'notifications/metro/call';
|
|
4
|
+
export const CALL_STATE = 'notifications/metro/call_state';
|
|
5
|
+
|
|
6
|
+
export interface CallState {
|
|
7
|
+
route: CallRoute | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CallRoute {
|
|
11
|
+
agentId: string;
|
|
12
|
+
line: string;
|
|
13
|
+
from: string;
|
|
14
|
+
callId: string;
|
|
15
|
+
generation: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SpeechTarget {
|
|
19
|
+
callId: string;
|
|
20
|
+
generation: string;
|
|
21
|
+
sourceId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CallSource {
|
|
25
|
+
route: CallRoute;
|
|
26
|
+
sourceId: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SpeechStatus = 'accepted' | 'queued' | 'started' | 'completed' | 'interrupted' | 'failed';
|
|
30
|
+
|
|
31
|
+
export type CallNotice =
|
|
32
|
+
| { type: 'started'; route: CallRoute; sourceId: string }
|
|
33
|
+
| { type: 'heard'; route: CallRoute; sourceId: string; text: string }
|
|
34
|
+
| { type: 'ended'; route: CallRoute }
|
|
35
|
+
| { type: 'speech'; route: CallRoute; sourceId: string; actionId: string; status: SpeechStatus };
|
|
36
|
+
|
|
37
|
+
const filled = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 2048;
|
|
38
|
+
|
|
39
|
+
export function callRoute(value: unknown): CallRoute | null {
|
|
40
|
+
if (!isRecord(value)) return null;
|
|
41
|
+
const { agentId, line, from, callId, generation } = value;
|
|
42
|
+
return filled(agentId) && filled(line) && filled(from) && filled(callId) && filled(generation)
|
|
43
|
+
? { agentId, line, from, callId, generation }
|
|
44
|
+
: null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function callState(value: unknown): CallState | null {
|
|
48
|
+
if (!isRecord(value)) return null;
|
|
49
|
+
if (value.route === null) return { route: null };
|
|
50
|
+
const route = callRoute(value.route);
|
|
51
|
+
return route === null ? null : { route };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function speechTarget(value: unknown): SpeechTarget | null {
|
|
55
|
+
if (!isRecord(value)) return null;
|
|
56
|
+
const { callId, generation, sourceId } = value;
|
|
57
|
+
return filled(callId) && filled(generation) && filled(sourceId) ? { callId, generation, sourceId } : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const STATUSES: readonly SpeechStatus[] = ['accepted', 'queued', 'started', 'completed', 'interrupted', 'failed'];
|
|
61
|
+
|
|
62
|
+
const speechStatus = (value: unknown): SpeechStatus | null => STATUSES.find((status) => status === value) ?? null;
|
|
63
|
+
|
|
64
|
+
function sourcedNotice(value: Record<string, unknown>, route: CallRoute, sourceId: string): CallNotice | null {
|
|
65
|
+
if (value.type === 'started') return { type: 'started', route, sourceId };
|
|
66
|
+
if (value.type === 'heard') return typeof value.text === 'string' && value.text.length <= 32_000 ? { type: 'heard', route, sourceId, text: value.text } : null;
|
|
67
|
+
if (value.type !== 'speech' || !filled(value.actionId)) return null;
|
|
68
|
+
const status = speechStatus(value.status);
|
|
69
|
+
return status === null ? null : { type: 'speech', route, sourceId, actionId: value.actionId, status };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function callNotice(value: unknown): CallNotice | null {
|
|
73
|
+
if (!isRecord(value)) return null;
|
|
74
|
+
const route = callRoute(value.route);
|
|
75
|
+
if (route === null) return null;
|
|
76
|
+
if (value.type === 'ended') return { type: 'ended', route };
|
|
77
|
+
return filled(value.sourceId) ? sourcedNotice(value, route, value.sourceId) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const sameCall = (a: CallRoute, b: CallRoute): boolean =>
|
|
81
|
+
a.agentId === b.agentId && a.line === b.line && a.from === b.from && a.callId === b.callId && a.generation === b.generation;
|
|
@@ -74,6 +74,17 @@ export interface RunnerFailure extends RunnerEvent {
|
|
|
74
74
|
code: RunnerFailureCode;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export interface RunnerInput {
|
|
78
|
+
id: string;
|
|
79
|
+
kind: 'chat' | 'call' | 'note';
|
|
80
|
+
state: 'accepted' | 'consumed' | 'output' | 'completed' | 'cancelled';
|
|
81
|
+
acceptedAt: number;
|
|
82
|
+
dispatchedAt: number | null;
|
|
83
|
+
consumedAt: number | null;
|
|
84
|
+
firstOutputAt: number | null;
|
|
85
|
+
completedAt: number | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
77
88
|
export interface RunnerActivity {
|
|
78
89
|
pid: number;
|
|
79
90
|
procStart?: string;
|
|
@@ -84,6 +95,9 @@ export interface RunnerActivity {
|
|
|
84
95
|
sessionId: string | null;
|
|
85
96
|
updatedAt: number;
|
|
86
97
|
pending: number;
|
|
98
|
+
queueOldestAt?: number | null;
|
|
99
|
+
inputs?: RunnerInput[];
|
|
100
|
+
callState?: 'started' | 'ended' | 'accepted' | 'queued' | 'speaking' | 'completed' | 'interrupted' | 'failed';
|
|
87
101
|
workers: number;
|
|
88
102
|
approvals: number;
|
|
89
103
|
tools: string[];
|
|
@@ -160,6 +174,28 @@ function failureFields(raw: Record<string, unknown>): Pick<RunnerActivity, 'last
|
|
|
160
174
|
};
|
|
161
175
|
}
|
|
162
176
|
|
|
177
|
+
function input(raw: unknown): RunnerInput | null {
|
|
178
|
+
if (!isRecord(raw) || sessionId(raw.id) === null) return null;
|
|
179
|
+
const kind = (['chat', 'call', 'note'] as const).find((value) => value === raw.kind);
|
|
180
|
+
const state = (['accepted', 'consumed', 'output', 'completed', 'cancelled'] as const).find((value) => value === raw.state);
|
|
181
|
+
if (kind === undefined || state === undefined) return null;
|
|
182
|
+
return {
|
|
183
|
+
id: String(raw.id), kind, state, acceptedAt: count(raw.acceptedAt),
|
|
184
|
+
dispatchedAt: count(raw.dispatchedAt) || null, consumedAt: count(raw.consumedAt) || null,
|
|
185
|
+
firstOutputAt: count(raw.firstOutputAt) || null, completedAt: count(raw.completedAt) || null,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function inputFields(raw: Record<string, unknown>): Pick<RunnerActivity, 'inputs' | 'queueOldestAt' | 'callState'> {
|
|
190
|
+
const states = ['started', 'ended', 'accepted', 'queued', 'speaking', 'completed', 'interrupted', 'failed'] as const;
|
|
191
|
+
const callState = states.find((state) => state === raw.callState);
|
|
192
|
+
return {
|
|
193
|
+
...(raw.inputs === undefined ? {} : { inputs: list(raw.inputs, input, 40) }),
|
|
194
|
+
...(raw.queueOldestAt === undefined ? {} : { queueOldestAt: count(raw.queueOldestAt) || null }),
|
|
195
|
+
...(callState === undefined ? {} : { callState }),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
163
199
|
export function parseRunnerActivity(raw: unknown): RunnerActivity | null {
|
|
164
200
|
if (!isRecord(raw) || raw.runner !== 'sdk' || !phase(raw.phase) || count(raw.pid) === 0 || count(raw.updatedAt) === 0) return null;
|
|
165
201
|
return {
|
|
@@ -168,6 +204,6 @@ export function parseRunnerActivity(raw: unknown): RunnerActivity | null {
|
|
|
168
204
|
pending: count(raw.pending), workers: count(raw.workers), approvals: count(raw.approvals),
|
|
169
205
|
tools: list(raw.tools, name, 20), activeTools: list(raw.activeTools, tool, 20),
|
|
170
206
|
tasks: list(raw.tasks, task, 30), events: list(raw.events, event, 40),
|
|
171
|
-
...failureFields(raw),
|
|
207
|
+
...failureFields(raw), ...inputFields(raw),
|
|
172
208
|
};
|
|
173
209
|
}
|
|
@@ -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
|
+
}
|