@stage-labs/metro 0.1.0-beta.311 → 0.1.0-beta.313
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/route.js +1 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +41 -22
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +13 -6
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/routeway.ts +32 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/connector-tools.ts +1 -1
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +1 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +7 -1
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/activity.ts +5 -4
- package/runtime/sdk-runner/src/task-ledger.ts +19 -4
- package/runtime/sdk-runner/src/task-state.ts +7 -1
package/dist/route.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { agentsDir } from './local.js';
|
|
4
|
-
const PROVIDERS = new Set(['bedrock', 'openrouter', 'codex', 'gemini']);
|
|
4
|
+
const PROVIDERS = new Set(['bedrock', 'openrouter', 'codex', 'gemini', 'routeway']);
|
|
5
5
|
const ANTHROPIC = 'anthropic';
|
|
6
6
|
const text = (value) => (typeof value === 'string' ? value : '');
|
|
7
7
|
function routedIn(cfg) {
|
package/package.json
CHANGED
|
@@ -43,6 +43,7 @@ import { notReady, resolveRoute, routeLabel, type Connection, type ModelSetup, t
|
|
|
43
43
|
import { codexMessages, type CodexDeps } from './codex.js';
|
|
44
44
|
import { countTokens } from './subscription.js';
|
|
45
45
|
import { OPENROUTER_BASE } from './openrouter.js';
|
|
46
|
+
import { refusedWithPage, ROUTEWAY_BASE, routewayHeaders, routewayTools } from './routeway.js';
|
|
46
47
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
47
48
|
import { forgetServed, noteServed } from './served.js';
|
|
48
49
|
import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
|
|
@@ -63,6 +64,7 @@ export interface GatewayDeps {
|
|
|
63
64
|
anthropicBase?: string;
|
|
64
65
|
bedrockBase?: string;
|
|
65
66
|
openrouterBase?: string;
|
|
67
|
+
routewayBase?: string;
|
|
66
68
|
codex?: Partial<CodexDeps>;
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -118,13 +120,21 @@ interface Payloads {
|
|
|
118
120
|
bound: boolean;
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
|
|
123
|
+
interface Shaped {
|
|
124
|
+
raw: Buffer;
|
|
125
|
+
sent: Record<string, unknown>;
|
|
126
|
+
shaped: Record<string, unknown>;
|
|
127
|
+
bind: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function anthropicPayloads({ raw, sent, shaped, bind }: Shaped, model: string): Payloads {
|
|
122
131
|
const runnable = withThinkingFor(sent, model);
|
|
123
132
|
const rewrite = runnable !== sent || (typeof sent.model === 'string' && sent.model !== model);
|
|
124
133
|
const asSent = rewrite ? Buffer.from(JSON.stringify({ ...runnable, model })) : raw;
|
|
125
|
-
const
|
|
134
|
+
const fitted = withThinkingFor(shaped, model);
|
|
135
|
+
const bound = bind ? withBlockBinding(fitted) : fitted;
|
|
126
136
|
if (bound === sent) return { metro: asSent, asSent: null, bound: false };
|
|
127
|
-
return { metro: Buffer.from(JSON.stringify(rewrite ? { ...bound, model } : bound)), asSent, bound: bound !== shaped };
|
|
137
|
+
return { metro: Buffer.from(JSON.stringify(rewrite ? { ...bound, model } : bound)), asSent, bound: bind && bound !== shaped };
|
|
128
138
|
}
|
|
129
139
|
|
|
130
140
|
type Send = (payload: Buffer, headers: Record<string, string>) => Promise<Response>;
|
|
@@ -153,32 +163,40 @@ async function refusedSubscription(conn: Connection, upstream: Response, watch:
|
|
|
153
163
|
throw new SubscriptionRefused(conn.label);
|
|
154
164
|
}
|
|
155
165
|
|
|
156
|
-
async function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
async function shapedAttempt(send: Send, base: Record<string, string>, body: Shaped, route: Route): Promise<Response> {
|
|
167
|
+
const shape = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(body, route.model), route.connection.label, route.model);
|
|
168
|
+
const first = await shape();
|
|
169
|
+
return (await refusedThinkingOff(first, body.shaped, route.model)) ? shape() : first;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sender = (url: string, watch: Watch): Send => (payload, headers) =>
|
|
173
|
+
fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
|
|
174
|
+
|
|
175
|
+
const upstreamPath = (req: IncomingMessage): string => (req.url ?? '').slice(GATEWAY_PREFIX.length);
|
|
176
|
+
|
|
177
|
+
async function toAnthropic(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps: GatewayDeps): Promise<void> {
|
|
165
178
|
const conn = route.connection;
|
|
166
|
-
const url = `${deps.anthropicBase ?? ANTHROPIC_API}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
|
|
167
179
|
const watch = watchUpstream(res);
|
|
168
|
-
const send = (
|
|
169
|
-
|
|
170
|
-
const attempt = async (base: Record<string, string>): Promise<Response> => {
|
|
171
|
-
const shape = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(raw, sent, shaped, route.model), conn.label, route.model);
|
|
172
|
-
const first = await shape();
|
|
173
|
-
return (await refusedThinkingOff(first, shaped, route.model)) ? shape() : first;
|
|
174
|
-
};
|
|
175
|
-
const upstream = await attempt(conn.apiKey !== '' ? anthropicHeaders(req, conn.apiKey) : loginHeaders(req, conn.claudeToken));
|
|
180
|
+
const send = sender(`${deps.anthropicBase ?? ANTHROPIC_API}${upstreamPath(req)}`, watch);
|
|
181
|
+
const upstream = await shapedAttempt(send, conn.apiKey !== '' ? anthropicHeaders(req, conn.apiKey) : loginHeaders(req, conn.claudeToken), body, route);
|
|
176
182
|
await refusedSubscription(conn, upstream, watch);
|
|
177
183
|
noteRefusal(conn.label, route.model, upstream);
|
|
178
184
|
noteUsageHeaders('anthropic', conn.id, upstream.headers);
|
|
179
185
|
await pipeResponse(upstream, res, watch, { ownCredential: true, scanner: new UsageScanner(conn.id) });
|
|
180
186
|
}
|
|
181
187
|
|
|
188
|
+
async function toRouteway(req: IncomingMessage, res: ServerResponse, body: Shaped, route: Route, deps: GatewayDeps): Promise<void> {
|
|
189
|
+
const conn = route.connection;
|
|
190
|
+
const watch = watchUpstream(res);
|
|
191
|
+
const send = sender(`${deps.routewayBase ?? ROUTEWAY_BASE}${upstreamPath(req)}`, watch);
|
|
192
|
+
const sent = routewayTools(body.sent);
|
|
193
|
+
const fitted: Shaped = { raw: sent === body.sent ? body.raw : Buffer.from(JSON.stringify(sent)), sent, shaped: routewayTools(body.shaped), bind: false };
|
|
194
|
+
const upstream = await shapedAttempt(send, routewayHeaders(req, conn.apiKey), fitted, route);
|
|
195
|
+
noteRefusal(conn.label, route.model, upstream);
|
|
196
|
+
await refusedWithPage(upstream, watch);
|
|
197
|
+
await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true, scanner: new UsageScanner(conn.id) });
|
|
198
|
+
}
|
|
199
|
+
|
|
182
200
|
async function toOpenRouter(req: IncomingMessage, res: ServerResponse, body: Record<string, unknown>, route: Route, deps: GatewayDeps): Promise<void> {
|
|
183
201
|
const conn = route.connection;
|
|
184
202
|
const headers: Record<string, string> = {
|
|
@@ -260,7 +278,8 @@ async function attempt(req: IncomingMessage, res: ServerResponse, ask: Request,
|
|
|
260
278
|
await toCodex(req, res, path, body, route, deps);
|
|
261
279
|
return;
|
|
262
280
|
}
|
|
263
|
-
|
|
281
|
+
const shaped: Shaped = { raw: bytesOf(ask.raw, ask.parsed, ask.sent), sent: ask.sent, shaped: body, bind: true };
|
|
282
|
+
await (conn.provider === 'routeway' ? toRouteway : toAnthropic)(req, res, shaped, route, deps);
|
|
264
283
|
}
|
|
265
284
|
|
|
266
285
|
async function heldAttempt(req: IncomingMessage, held: Held, ask: Request, route: Route, deps: GatewayDeps): Promise<Refusal | null> {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex'] as const;
|
|
1
|
+
export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'routeway'] as const;
|
|
2
2
|
export type Provider = (typeof PROVIDERS)[number];
|
|
3
3
|
export type CodexMethod = 'chatgpt' | 'code';
|
|
4
4
|
export type RouteSource = 'agent' | 'organization';
|
|
@@ -50,7 +50,7 @@ export interface Route {
|
|
|
50
50
|
|
|
51
51
|
export const CODEX_SIGN_IN = "Codex is not signed in: sign in again on the organization's Models page.";
|
|
52
52
|
|
|
53
|
-
const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex):(.+)$/;
|
|
53
|
+
const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex|routeway):(.+)$/;
|
|
54
54
|
const SMALL_RE = /haiku/i;
|
|
55
55
|
|
|
56
56
|
export const isProvider = (value: unknown): value is Provider => typeof value === 'string' && (PROVIDERS as readonly string[]).includes(value);
|
|
@@ -60,10 +60,12 @@ export const emptySetup = (): ModelSetup => ({ primary: null, fallbacks: [], con
|
|
|
60
60
|
export const connectionOf = (setup: ModelSetup, id: string): Connection | null => setup.connections.find((c) => c.id === id) ?? null;
|
|
61
61
|
|
|
62
62
|
export function primaryRoute(setup: ModelSetup): Route | null {
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
const steps = setup.primary === null ? [] : [setup.primary, ...setup.fallbacks];
|
|
64
|
+
for (const step of steps) {
|
|
65
|
+
const conn = connectionOf(setup, step.connection);
|
|
66
|
+
if (conn !== null) return { connection: conn, model: step.model === '' ? conn.model : step.model };
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
const CHECKS: Record<Provider, [(r: Route) => boolean, string][]> = {
|
|
@@ -80,6 +82,10 @@ const CHECKS: Record<Provider, [(r: Route) => boolean, string][]> = {
|
|
|
80
82
|
[(r) => r.connection.codex === null, CODEX_SIGN_IN],
|
|
81
83
|
[(r) => r.model === '', "Codex needs a model id: choose one on the organization's Models page."],
|
|
82
84
|
],
|
|
85
|
+
routeway: [
|
|
86
|
+
[(r) => r.connection.apiKey === '', "Routeway has no API key: add it on the organization's Models page."],
|
|
87
|
+
[(r) => r.model === '', "Routeway needs a model id: choose one on the organization's Models page."],
|
|
88
|
+
],
|
|
83
89
|
};
|
|
84
90
|
|
|
85
91
|
export const notReady = (route: Route): string | null => CHECKS[route.connection.provider].find(([missing]) => missing(route))?.[1] ?? null;
|
|
@@ -90,6 +96,7 @@ const DEFAULTS: Record<Provider, (requested: string, model: string) => string> =
|
|
|
90
96
|
openrouter: (requested, model) => (requested.includes('/') ? requested : model),
|
|
91
97
|
bedrock: (requested, model) => (model === '' ? requested : model),
|
|
92
98
|
codex: (requested, model) => (requested.startsWith('gpt-') ? requested : model),
|
|
99
|
+
routeway: (_requested, model) => model,
|
|
93
100
|
anthropic: (requested, model) => (model === '' || isSmallModel(requested) ? requested : model),
|
|
94
101
|
};
|
|
95
102
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { anthropicHeaders, GatewayError, type Watch } from './forward.js';
|
|
4
|
+
|
|
5
|
+
export const ROUTEWAY_BASE = 'https://api.routeway.ai';
|
|
6
|
+
const USER_AGENT = 'metro (+https://metro.box)';
|
|
7
|
+
const HANDED_OVER = new Set([402, 429]);
|
|
8
|
+
|
|
9
|
+
export function routewayHeaders(req: IncomingMessage, apiKey: string): Record<string, string> {
|
|
10
|
+
const headers = anthropicHeaders(req, apiKey);
|
|
11
|
+
headers['user-agent'] ??= USER_AGENT;
|
|
12
|
+
return headers;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function refusedWithPage(upstream: Response, watch: Watch): Promise<void> {
|
|
16
|
+
if (upstream.ok || HANDED_OVER.has(upstream.status) || !(upstream.headers.get('content-type') ?? '').includes('text/html')) return;
|
|
17
|
+
await upstream.body?.cancel();
|
|
18
|
+
watch.stop();
|
|
19
|
+
throw new GatewayError(502, 'api_error', `metro gateway: Routeway answered with a web page instead of its API (${String(upstream.status)}); try again in a moment`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function withProperties(tool: unknown): unknown {
|
|
23
|
+
if (!isRecord(tool) || !isRecord(tool.input_schema) || Object.hasOwn(tool.input_schema, 'properties')) return tool;
|
|
24
|
+
return { ...tool, input_schema: { ...tool.input_schema, properties: {} } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function routewayTools(body: Record<string, unknown>): Record<string, unknown> {
|
|
28
|
+
const listed: unknown = body.tools;
|
|
29
|
+
if (!Array.isArray(listed)) return body;
|
|
30
|
+
const tools: unknown[] = listed.map(withProperties);
|
|
31
|
+
return tools.some((tool, at) => tool !== listed[at]) ? { ...body, tools } : body;
|
|
32
|
+
}
|
|
@@ -10,7 +10,7 @@ export const CALL_TIMEOUT_MS = 120_000;
|
|
|
10
10
|
const LIST_TIMEOUT_MS = 15_000;
|
|
11
11
|
const REFRESH_MS = 5 * 60_000;
|
|
12
12
|
|
|
13
|
-
export const NOT_SET_UP = 'Connectors are not set up on this agent:
|
|
13
|
+
export const NOT_SET_UP = 'Connectors are not set up on this agent yet: it links to its organization once an admin of the organization opens its page on metro.box. Try again after that.';
|
|
14
14
|
export const UNAVAILABLE = 'mcp.metro.box is unavailable, try again.';
|
|
15
15
|
|
|
16
16
|
export interface ConnectorResult {
|
|
@@ -33,7 +33,7 @@ export function requireAdmin(session: ApiSession): void {
|
|
|
33
33
|
export function cors(req: IncomingMessage): Record<string, string> {
|
|
34
34
|
return {
|
|
35
35
|
'access-control-allow-origin': req.headers.origin ?? '*',
|
|
36
|
-
'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
36
|
+
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
37
37
|
'access-control-allow-headers': 'Authorization, Content-Type',
|
|
38
38
|
'access-control-allow-private-network': 'true',
|
|
39
39
|
'access-control-max-age': '86400',
|
|
@@ -9,6 +9,8 @@ export interface RemoteTool {
|
|
|
9
9
|
title: string;
|
|
10
10
|
description: string;
|
|
11
11
|
readOnly: boolean;
|
|
12
|
+
destructive?: boolean;
|
|
13
|
+
openWorld?: boolean;
|
|
12
14
|
inputSchema?: Record<string, unknown>;
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -36,11 +38,15 @@ async function rpc(url: URL, auth: ConnectorAuth, session: Map<string, string>,
|
|
|
36
38
|
return resultOf(text, res.headers.get('content-type') ?? '');
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
function hintsOf(annotations: Record<string, unknown>): Pick<RemoteTool, 'destructive' | 'openWorld'> {
|
|
42
|
+
return { ...(annotations.destructiveHint === true ? { destructive: true } : {}), ...(annotations.openWorldHint === true ? { openWorld: true } : {}) };
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
function toTool(raw: unknown): RemoteTool | null {
|
|
40
46
|
if (!isRecord(raw) || typeof raw.name !== 'string') return null;
|
|
41
47
|
const annotations = isRecord(raw.annotations) ? raw.annotations : {};
|
|
42
48
|
const title = typeof raw.title === 'string' && raw.title !== '' ? raw.title : raw.name;
|
|
43
|
-
return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true, ...(isRecord(raw.inputSchema) ? { inputSchema: raw.inputSchema } : {}) };
|
|
49
|
+
return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true, ...hintsOf(annotations), ...(isRecord(raw.inputSchema) ? { inputSchema: raw.inputSchema } : {}) };
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
async function endSession(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<void> {
|
package/runtime/runtime.json
CHANGED
|
@@ -7,7 +7,7 @@ import { runnerFailureSummary, type RunnerActivity, type RunnerDiagnostic, type
|
|
|
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';
|
|
10
|
-
import type
|
|
10
|
+
import { sdkTask, type SavedTask } from './task-state.js';
|
|
11
11
|
import { sessionFailure } from './errors.js';
|
|
12
12
|
export { failureSummary } from './errors.js';
|
|
13
13
|
|
|
@@ -94,10 +94,11 @@ export class Activity {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
recover(tasks: SavedTask[]): void {
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
const workers = tasks.filter(sdkTask);
|
|
98
|
+
this.tasks.restore(workers.map((task) => ({
|
|
99
|
+
id: task.id, kind: null, agent: null, status: task.state === 'interrupted' ? 'unknown' : task.state, ...(task.description === undefined ? {} : { description: task.description }),
|
|
99
100
|
background: true, startedAt: 0, updatedAt: task.updatedAt, endedAt: null, lastTool: null, toolUses: 0, durationMs: 0,
|
|
100
|
-
})),
|
|
101
|
+
})), workers.flatMap((task) => task.toolUseId === null ? [] : [[task.toolUseId, task.id]]));
|
|
101
102
|
for (const task of tasks) {
|
|
102
103
|
if (task.notice === null) continue;
|
|
103
104
|
const code = task.notice.reason === 'worker_restart' ? 'interrupted' : task.notice.reason;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
import { runnerTaskDescription } from '@metro-labs/core/runner-activity';
|
|
2
3
|
import { activityName } from './activity-tasks.js';
|
|
3
4
|
import type { Unanswered } from './inbox.js';
|
|
4
5
|
import { pendingNotice, TASK_LIMIT, type SavedTask, type TaskReason } from './task-state.js';
|
|
@@ -67,9 +68,11 @@ export class TaskLedger {
|
|
|
67
68
|
changed(handled?: ReadonlySet<string>): void { this.save(this.snapshot(), handled); }
|
|
68
69
|
|
|
69
70
|
private taskEvent(id: string, m: Record<string, unknown>, event: string | null, now: number): void {
|
|
70
|
-
|
|
71
|
+
const known = this.rows.get(id);
|
|
72
|
+
if (m.subtype === 'task_started') this.start(id, m.tool_use_id, event, now, m.description);
|
|
71
73
|
else if (m.subtype === 'task_notification') this.end(id, m, event, now);
|
|
72
|
-
else if (m.subtype === 'task_updated' && isRecord(m.patch) &&
|
|
74
|
+
else if (m.subtype === 'task_updated' && isRecord(m.patch) && known !== undefined) {
|
|
75
|
+
if (this.describe(known, m.patch.description)) this.save(this.snapshot());
|
|
73
76
|
this.end(id, { status: m.patch.status === 'killed' ? 'stopped' : m.patch.status, summary: m.patch.error }, event, now);
|
|
74
77
|
} else if (m.subtype === 'task_progress') this.progress(id, m.tool_use_id, now);
|
|
75
78
|
}
|
|
@@ -114,10 +117,11 @@ export class TaskLedger {
|
|
|
114
117
|
return task;
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
private start(id: string, rawTool: unknown, event: string | null, now: number): void {
|
|
120
|
+
private start(id: string, rawTool: unknown, event: string | null, now: number, description?: unknown): void {
|
|
118
121
|
const task = this.get(id, now);
|
|
119
122
|
if (task.state !== 'running') task.attempt += 1;
|
|
120
123
|
task.state = 'running';
|
|
124
|
+
this.describe(task, description);
|
|
121
125
|
if (event !== null) task.events = [...task.events, event].slice(-16);
|
|
122
126
|
task.updatedAt = now;
|
|
123
127
|
task.toolUseId = activityName(rawTool) ?? task.toolUseId;
|
|
@@ -165,13 +169,24 @@ export class TaskLedger {
|
|
|
165
169
|
|
|
166
170
|
private background(m: Record<string, unknown>, now: number): void {
|
|
167
171
|
if (!Array.isArray(m.tasks)) return;
|
|
172
|
+
let described = false;
|
|
168
173
|
for (const row of m.tasks) {
|
|
169
174
|
if (!isRecord(row)) continue;
|
|
170
175
|
const id = activityName(row.task_id);
|
|
171
176
|
if (id === null) continue;
|
|
177
|
+
const known = this.rows.get(id);
|
|
172
178
|
if (row.ambient === true) this.hide(id);
|
|
173
|
-
else if (
|
|
179
|
+
else if (known === undefined) this.start(id, undefined, activityName(m.uuid), now, row.description);
|
|
180
|
+
else described = this.describe(known, row.description) || described;
|
|
174
181
|
}
|
|
182
|
+
if (described) this.save(this.snapshot());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private describe(task: SavedTask, raw: unknown): boolean {
|
|
186
|
+
const description = runnerTaskDescription(raw);
|
|
187
|
+
if (description === null || description === task.description) return false;
|
|
188
|
+
task.description = description;
|
|
189
|
+
return true;
|
|
175
190
|
}
|
|
176
191
|
|
|
177
192
|
private content(m: Record<string, unknown>, now: number): void {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { runnerTaskDescription } from '@metro-labs/core/runner-activity';
|
|
3
4
|
import { activityName } from './activity-tasks.js';
|
|
4
5
|
import type { Uuid } from './inbox.js';
|
|
5
6
|
|
|
@@ -26,6 +27,7 @@ export interface SavedTask {
|
|
|
26
27
|
attempt: number;
|
|
27
28
|
handledAttempt?: number;
|
|
28
29
|
state: (typeof STATES)[number];
|
|
30
|
+
description?: string;
|
|
29
31
|
events: string[];
|
|
30
32
|
updatedAt: number;
|
|
31
33
|
notice: TaskNotice | null;
|
|
@@ -35,6 +37,7 @@ const integer = (raw: unknown): raw is number => typeof raw === 'number' && Numb
|
|
|
35
37
|
const name = (raw: unknown): raw is string | null => raw === null || activityName(raw) !== null;
|
|
36
38
|
const reason = (raw: unknown): raw is TaskReason => REASONS.some((value) => value === raw);
|
|
37
39
|
const optionalInteger = (raw: unknown): boolean => raw === undefined || integer(raw);
|
|
40
|
+
const description = (raw: unknown): boolean => raw === undefined || (typeof raw === 'string' && runnerTaskDescription(raw) === raw);
|
|
38
41
|
const optionalUuid = (raw: unknown): boolean => raw === undefined || (typeof raw === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(raw));
|
|
39
42
|
|
|
40
43
|
function notice(raw: unknown): raw is TaskNotice | null {
|
|
@@ -45,14 +48,17 @@ function notice(raw: unknown): raw is TaskNotice | null {
|
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
const events = (raw: unknown): boolean => Array.isArray(raw) && raw.length <= 16 && raw.every((event) => activityName(event) !== null);
|
|
51
|
+
const optionalFields = (raw: Record<string, unknown>): boolean => optionalInteger(raw.handledAttempt) && description(raw.description);
|
|
48
52
|
|
|
49
53
|
function task(raw: unknown): raw is SavedTask {
|
|
50
54
|
if (!isRecord(raw) || activityName(raw.id) === null) return false;
|
|
51
55
|
if (![raw.toolUseId, raw.owner, raw.parentToolUseId ?? null].every(name)) return false;
|
|
52
|
-
if (!events(raw.events) || !
|
|
56
|
+
if (!events(raw.events) || !optionalFields(raw)) return false;
|
|
53
57
|
return integer(raw.attempt) && integer(raw.updatedAt) && STATES.some((state) => state === raw.state) && notice(raw.notice);
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
export const sdkTask = (task: SavedTask): boolean => !/^(?:input|turn):/.test(task.id);
|
|
61
|
+
|
|
56
62
|
export function savedTasks(raw: unknown): SavedTask[] {
|
|
57
63
|
if (raw === undefined) return [];
|
|
58
64
|
if (!Array.isArray(raw) || raw.length > TASK_LIMIT || !raw.every(task) || new Set(raw.map((row) => row.id)).size !== raw.length)
|