@metrio-ai/cli 1.4.0 → 1.5.0
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/README.md +5 -1
- package/dist/commands/sync.js +104 -19
- package/dist/commands/verify.js +66 -22
- package/dist/index.js +2 -2
- package/dist/manage/registry.js +13 -0
- package/dist/manage/run.js +19 -12
- package/dist/sync/push-agents.js +380 -0
- package/dist/sync/push.js +16 -4
- package/dist/sync/remote.js +52 -4
- package/dist/sync/render.js +119 -0
- package/dist/sync/status.js +35 -1
- package/dist/sync/workspace.js +3 -2
- package/dist/verify/agent-runs.js +23 -0
- package/dist/verify/assert.js +19 -0
- package/dist/verify/cases.js +15 -6
- package/dist/verify/execute.js +41 -3
- package/package.json +2 -3
package/dist/sync/status.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderRemoteAgents = renderRemoteAgents;
|
|
3
4
|
exports.computeEntries = computeEntries;
|
|
5
|
+
const render_1 = require("./render");
|
|
4
6
|
const workspace_1 = require("./workspace");
|
|
5
7
|
function proposedKey(r) {
|
|
6
8
|
if (r.kind === 'workflow') {
|
|
@@ -8,12 +10,18 @@ function proposedKey(r) {
|
|
|
8
10
|
return `${workspace_1.KIND_DIR.workflow}/${unsafe ? 'workflow-' + (0, workspace_1.slugify)(r.id, 'workflow') : r.id}`;
|
|
9
11
|
}
|
|
10
12
|
const slug = (0, workspace_1.slugify)(r.name, `${r.kind}-${r.id}`);
|
|
13
|
+
if (r.kind === 'agent')
|
|
14
|
+
return `${workspace_1.KIND_DIR.agent}/${slug}`;
|
|
11
15
|
return r.kind === 'skill' ? `${workspace_1.KIND_DIR.skill}/${slug}.md` : `${workspace_1.KIND_DIR.prompt}/${slug}`;
|
|
12
16
|
}
|
|
13
17
|
function withSuffix(key, id) {
|
|
14
18
|
return key.endsWith('.md') ? `${key.slice(0, -3)}-${id}.md` : `${key}-${id}`;
|
|
15
19
|
}
|
|
16
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The workspace key of each remote resource: its tracked key, else a proposed one (suffixed on
|
|
22
|
+
* collision). Agent files are then rendered with directory references to those keys.
|
|
23
|
+
*/
|
|
24
|
+
function assignKeys(state, remote, kinds) {
|
|
17
25
|
const tracked = Object.entries(state.resources).filter(([, s]) => kinds.includes(s.kind));
|
|
18
26
|
const remoteAt = new Map();
|
|
19
27
|
const taken = new Set(tracked.map(([key]) => key));
|
|
@@ -29,6 +37,32 @@ function computeEntries(root, state, remote, kinds) {
|
|
|
29
37
|
taken.add(key);
|
|
30
38
|
remoteAt.set(key, r);
|
|
31
39
|
}
|
|
40
|
+
// Agents reference prompts and agents by directory, so render them once every key is known.
|
|
41
|
+
const idToKey = (kind) => {
|
|
42
|
+
const map = new Map();
|
|
43
|
+
for (const [key, s] of Object.entries(state.resources))
|
|
44
|
+
if (s.kind === kind)
|
|
45
|
+
map.set(s.id, key);
|
|
46
|
+
for (const [key, r] of remoteAt)
|
|
47
|
+
if (r.kind === kind)
|
|
48
|
+
map.set(r.id, key);
|
|
49
|
+
return map;
|
|
50
|
+
};
|
|
51
|
+
if (remote.some((r) => r.kind === 'agent')) {
|
|
52
|
+
const refs = { prompts: idToKey('prompt'), agents: idToKey('agent') };
|
|
53
|
+
for (const r of remote)
|
|
54
|
+
if (r.kind === 'agent')
|
|
55
|
+
r.files = { 'agent.yaml': (0, render_1.renderAgent)(r.data, refs) };
|
|
56
|
+
}
|
|
57
|
+
return remoteAt;
|
|
58
|
+
}
|
|
59
|
+
/** Renders remote agent files with directory references exactly as computeEntries does for this state. */
|
|
60
|
+
function renderRemoteAgents(state, remote, kinds) {
|
|
61
|
+
assignKeys(state, remote, kinds);
|
|
62
|
+
}
|
|
63
|
+
function computeEntries(root, state, remote, kinds) {
|
|
64
|
+
const tracked = Object.entries(state.resources).filter(([, s]) => kinds.includes(s.kind));
|
|
65
|
+
const remoteAt = assignKeys(state, remote, kinds);
|
|
32
66
|
const locals = (0, workspace_1.listLocal)(root, kinds);
|
|
33
67
|
const keys = new Set([...remoteAt.keys(), ...tracked.map(([k]) => k), ...locals.map((l) => l.key)]);
|
|
34
68
|
const entries = [];
|
package/dist/sync/workspace.js
CHANGED
|
@@ -52,11 +52,12 @@ const fs = __importStar(require("fs"));
|
|
|
52
52
|
const path = __importStar(require("path"));
|
|
53
53
|
const yaml_1 = require("yaml");
|
|
54
54
|
const errors_1 = require("../errors");
|
|
55
|
-
exports.KIND_DIR = { prompt: 'prompts', skill: 'skills', workflow: 'workflows' };
|
|
55
|
+
exports.KIND_DIR = { prompt: 'prompts', skill: 'skills', workflow: 'workflows', agent: 'agents' };
|
|
56
56
|
exports.KIND_FILES = {
|
|
57
57
|
prompt: ['prompt.yaml', 'system.md'],
|
|
58
58
|
skill: ['skill.md'],
|
|
59
59
|
workflow: ['workflow.yaml', 'definition.json'],
|
|
60
|
+
agent: ['agent.yaml'],
|
|
60
61
|
};
|
|
61
62
|
const CONFIG = 'metrio.yaml';
|
|
62
63
|
/** Check a file path for symlinks; ENOENT is fine (file doesn't exist yet). */
|
|
@@ -264,7 +265,7 @@ function validateKey(key) {
|
|
|
264
265
|
if (segments.length !== 2)
|
|
265
266
|
throw new errors_1.CliError(`Unsafe resource path "${key}" in .metrio/state.json or console data`);
|
|
266
267
|
// First segment must be a resource kind directory
|
|
267
|
-
if (!
|
|
268
|
+
if (!Object.values(exports.KIND_DIR).includes(segments[0])) {
|
|
268
269
|
throw new errors_1.CliError(`Unsafe resource path "${key}" in .metrio/state.json or console data`);
|
|
269
270
|
}
|
|
270
271
|
// Check each segment
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runAgentSync = runAgentSync;
|
|
4
|
+
async function runAgentSync(o) {
|
|
5
|
+
const doFetch = o.fetchImpl ?? fetch;
|
|
6
|
+
const url = `${o.baseUrl.replace(/\/+$/, '')}/v1/agents/${o.agentId}/runs`;
|
|
7
|
+
const started = Date.now();
|
|
8
|
+
const res = await doFetch(url, {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: { 'Content-Type': 'application/json', metrioai_api_key: o.apiKey },
|
|
11
|
+
body: JSON.stringify({ messages: o.messages, ...(o.variables ? { variables: o.variables } : {}), ...(o.timezone ? { timezone: o.timezone } : {}), mode: 'sync' }),
|
|
12
|
+
});
|
|
13
|
+
const text = await res.text();
|
|
14
|
+
const latencyMs = Date.now() - started;
|
|
15
|
+
let body;
|
|
16
|
+
try {
|
|
17
|
+
body = JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
body = { error: text.slice(0, 500) };
|
|
21
|
+
}
|
|
22
|
+
return { status: res.status, body, latencyMs };
|
|
23
|
+
}
|
package/dist/verify/assert.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.observePrompt = observePrompt;
|
|
4
4
|
exports.observeWorkflow = observeWorkflow;
|
|
5
|
+
exports.observeAgent = observeAgent;
|
|
5
6
|
exports.evaluate = evaluate;
|
|
6
7
|
/**
|
|
7
8
|
* Turns a chat / workflow response into one Observation, and checks an Expect against it.
|
|
@@ -63,6 +64,22 @@ function observeWorkflow(res) {
|
|
|
63
64
|
llmCalls: res.llmCalls ?? 0,
|
|
64
65
|
};
|
|
65
66
|
}
|
|
67
|
+
/** Builds an Observation from a 2xx sync agent run body (AgentRunResponse). */
|
|
68
|
+
function observeAgent(body, latencyMs) {
|
|
69
|
+
const text = typeof body?.output === 'string' ? body.output : '';
|
|
70
|
+
const usage = body?.usage ?? {};
|
|
71
|
+
return {
|
|
72
|
+
text,
|
|
73
|
+
root: { content: text, structContent: undefined, state: {}, tags: [], json: parseJson(text) ?? null },
|
|
74
|
+
path: [],
|
|
75
|
+
terminalNodeId: null,
|
|
76
|
+
responseType: null,
|
|
77
|
+
latencyMs,
|
|
78
|
+
tokens: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0),
|
|
79
|
+
llmCalls: null,
|
|
80
|
+
stopReason: typeof body?.stop_reason === 'string' ? body.stop_reason : null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
66
83
|
function containsValue(actual, wanted) {
|
|
67
84
|
if (Array.isArray(actual))
|
|
68
85
|
return actual.some((item) => (0, util_1.isDeepStrictEqual)(item, wanted));
|
|
@@ -107,6 +124,8 @@ function evaluate(e, obs) {
|
|
|
107
124
|
fail('terminal_node', e.terminal_node, obs.terminalNodeId);
|
|
108
125
|
if (e.response_type !== undefined && obs.responseType !== e.response_type)
|
|
109
126
|
fail('response_type', e.response_type, obs.responseType);
|
|
127
|
+
if (e.stop_reason !== undefined && obs.stopReason !== e.stop_reason)
|
|
128
|
+
fail('stop_reason', e.stop_reason, obs.stopReason ?? null);
|
|
110
129
|
if (e.max_latency_ms !== undefined && obs.latencyMs > e.max_latency_ms)
|
|
111
130
|
fail('max_latency_ms', e.max_latency_ms, obs.latencyMs);
|
|
112
131
|
if (e.max_tokens !== undefined && obs.tokens > e.max_tokens)
|
package/dist/verify/cases.js
CHANGED
|
@@ -45,7 +45,9 @@ const errors_1 = require("../errors");
|
|
|
45
45
|
const shared_1 = require("../commands/shared");
|
|
46
46
|
const jsonpath_1 = require("./jsonpath");
|
|
47
47
|
const WORKFLOW_ONLY = ['path_includes', 'terminal_node', 'response_type', 'max_llm_calls'];
|
|
48
|
-
const
|
|
48
|
+
const AGENT_ONLY = ['stop_reason'];
|
|
49
|
+
const STOP_REASONS = ['final', 'terminal_tool', 'max_turns', 'interrupted', 'cancelled'];
|
|
50
|
+
const EXPECT_KEYS = ['contains', 'not_contains', 'regex', 'equals', 'json', ...WORKFLOW_ONLY, ...AGENT_ONLY, 'max_latency_ms', 'max_tokens'];
|
|
49
51
|
const MESSAGE_KEYS = ['role', 'content', 'text', 'file', 'mime', 'fileType'];
|
|
50
52
|
const ROLES = ['system', 'user', 'assistant'];
|
|
51
53
|
const FILE_TYPES = ['image', 'pdf', 'binary'];
|
|
@@ -130,8 +132,10 @@ function parseExpect(raw, kind, where) {
|
|
|
130
132
|
const at = `${where}.${key}`;
|
|
131
133
|
if (!EXPECT_KEYS.includes(key))
|
|
132
134
|
throw new Error(`${at}: unknown assertion (known: ${EXPECT_KEYS.join(', ')})`);
|
|
133
|
-
if (kind
|
|
135
|
+
if (kind !== 'workflow' && WORKFLOW_ONLY.includes(key))
|
|
134
136
|
throw new Error(`${at}: only workflows support this assertion`);
|
|
137
|
+
if (kind !== 'agent' && AGENT_ONLY.includes(key))
|
|
138
|
+
throw new Error(`${at}: only agents support this assertion`);
|
|
135
139
|
switch (key) {
|
|
136
140
|
case 'contains':
|
|
137
141
|
case 'not_contains':
|
|
@@ -157,6 +161,11 @@ function parseExpect(raw, kind, where) {
|
|
|
157
161
|
case 'json':
|
|
158
162
|
e.json = parseJsonChecks(v, at);
|
|
159
163
|
break;
|
|
164
|
+
case 'stop_reason':
|
|
165
|
+
if (typeof v !== 'string' || !STOP_REASONS.includes(v))
|
|
166
|
+
throw new Error(`${at} must be one of ${STOP_REASONS.join(', ')}`);
|
|
167
|
+
e.stop_reason = v;
|
|
168
|
+
break;
|
|
160
169
|
case 'terminal_node':
|
|
161
170
|
if (typeof v !== 'string' || v === '')
|
|
162
171
|
throw new Error(`${at} must be a node id`);
|
|
@@ -177,10 +186,10 @@ function parseVariables(v, kind, where) {
|
|
|
177
186
|
if (v === undefined)
|
|
178
187
|
return undefined;
|
|
179
188
|
const vars = mapping(v, where);
|
|
180
|
-
if (kind
|
|
189
|
+
if (kind !== 'workflow') {
|
|
181
190
|
for (const [name, value] of Object.entries(vars)) {
|
|
182
191
|
if (!['string', 'number', 'boolean'].includes(typeof value)) {
|
|
183
|
-
throw new Error(`${where}.${name} must be a string, number or boolean (prompt variables are text)`);
|
|
192
|
+
throw new Error(`${where}.${name} must be a string, number or boolean (prompt and agent variables are text)`);
|
|
184
193
|
}
|
|
185
194
|
}
|
|
186
195
|
}
|
|
@@ -257,7 +266,7 @@ function parseDoc(raw, opts) {
|
|
|
257
266
|
if (key !== 'variables' && key !== 'channel')
|
|
258
267
|
throw new Error(`defaults.${key}: unknown key (expected variables, channel)`);
|
|
259
268
|
}
|
|
260
|
-
if (opts.kind
|
|
269
|
+
if (opts.kind !== 'workflow' && defaults.channel !== undefined)
|
|
261
270
|
throw new Error('defaults.channel: only workflows take a channel');
|
|
262
271
|
const defaultVars = parseVariables(defaults.variables, opts.kind, 'defaults.variables');
|
|
263
272
|
const defaultChannel = defaults.channel === undefined ? undefined : mapping(defaults.channel, 'defaults.channel');
|
|
@@ -278,7 +287,7 @@ function parseDoc(raw, opts) {
|
|
|
278
287
|
throw new Error(`${where}.name "${c.name}" is used twice`);
|
|
279
288
|
names.add(c.name);
|
|
280
289
|
const at = `${where} (${c.name})`;
|
|
281
|
-
if (opts.kind
|
|
290
|
+
if (opts.kind !== 'workflow' && c.channel !== undefined)
|
|
282
291
|
throw new Error(`${at}.channel: only workflows take a channel`);
|
|
283
292
|
const vars = parseVariables(c.variables, opts.kind, `${at}.variables`);
|
|
284
293
|
const channel = c.channel === undefined ? undefined : mapping(c.channel, `${at}.channel`);
|
package/dist/verify/execute.js
CHANGED
|
@@ -9,10 +9,48 @@ exports.runAll = runAll;
|
|
|
9
9
|
const client_1 = require("@metrio-ai/client");
|
|
10
10
|
const shared_1 = require("../commands/shared");
|
|
11
11
|
const prompt_1 = require("../commands/prompt");
|
|
12
|
+
const agent_runs_1 = require("./agent-runs");
|
|
12
13
|
const assert_1 = require("./assert");
|
|
13
|
-
|
|
14
|
+
const scrubKey = (s, key) => (key ? s.split(key).join('***') : s);
|
|
15
|
+
async function runAgentCase(api, t, c, base, timezone) {
|
|
16
|
+
const res = await (0, agent_runs_1.runAgentSync)({
|
|
17
|
+
...api,
|
|
18
|
+
agentId: t.ref,
|
|
19
|
+
messages: c.messages,
|
|
20
|
+
variables: (0, prompt_1.mergeVariables)([], c.variables),
|
|
21
|
+
timezone,
|
|
22
|
+
});
|
|
23
|
+
const taskId = res.body?.runId !== undefined ? String(res.body.runId) : undefined;
|
|
24
|
+
const ids = taskId ? { taskId } : {};
|
|
25
|
+
if (res.status < 200 || res.status >= 300) {
|
|
26
|
+
const detail = typeof res.body?.error === 'string' ? res.body.error : JSON.stringify(res.body?.error ?? '');
|
|
27
|
+
let extra = '';
|
|
28
|
+
if (res.status >= 400 && res.status < 500 && res.body?.details !== undefined) {
|
|
29
|
+
const d = typeof res.body.details === 'string' ? res.body.details : JSON.stringify(res.body.details);
|
|
30
|
+
extra = ` (${d.length > 200 ? `${d.slice(0, 200)}...` : d})`;
|
|
31
|
+
}
|
|
32
|
+
return { ...base, status: 'error', failures: [], error: { message: scrubKey(`http=${res.status} ${detail}${extra}`.trim(), api.apiKey), status: res.status }, latencyMs: res.latencyMs, ...ids };
|
|
33
|
+
}
|
|
34
|
+
if (typeof res.body?.status !== 'string') {
|
|
35
|
+
return { ...base, status: 'error', failures: [], error: { message: scrubKey(`unexpected response: http=${res.status} without a run status`, api.apiKey) }, latencyMs: res.latencyMs, ...ids };
|
|
36
|
+
}
|
|
37
|
+
if (res.body.status === 'failed') {
|
|
38
|
+
const err = res.body.error;
|
|
39
|
+
const detail = typeof err === 'string' ? err : (err?.message ?? 'no reason given');
|
|
40
|
+
return { ...base, status: 'error', failures: [], error: { message: scrubKey(`agent run failed: ${detail}`, api.apiKey) }, latencyMs: res.latencyMs, ...ids };
|
|
41
|
+
}
|
|
42
|
+
const obs = (0, assert_1.observeAgent)(res.body, res.latencyMs);
|
|
43
|
+
const failures = (0, assert_1.evaluate)(c.expect, obs);
|
|
44
|
+
return { ...base, status: failures.length ? 'fail' : 'pass', failures, latencyMs: obs.latencyMs, tokens: obs.tokens, ...ids };
|
|
45
|
+
}
|
|
46
|
+
async function runCase(client, t, c, timezone, agentApi) {
|
|
14
47
|
const base = { resource: t.label, case: c.name };
|
|
15
48
|
try {
|
|
49
|
+
if (t.kind === 'agent') {
|
|
50
|
+
if (!agentApi)
|
|
51
|
+
throw new Error('agent targets need agentApi (base URL and API key)');
|
|
52
|
+
return await runAgentCase(agentApi, t, c, base, timezone);
|
|
53
|
+
}
|
|
16
54
|
let obs;
|
|
17
55
|
let version;
|
|
18
56
|
let taskId;
|
|
@@ -86,7 +124,7 @@ async function runCase(client, t, c, timezone) {
|
|
|
86
124
|
status: 'error',
|
|
87
125
|
failures: [],
|
|
88
126
|
error: {
|
|
89
|
-
message: err instanceof Error ? err.message : String(err),
|
|
127
|
+
message: scrubKey(err instanceof Error ? err.message : String(err), agentApi?.apiKey ?? ''),
|
|
90
128
|
status: err instanceof client_1.MetrioApiError ? err.statusCode : undefined,
|
|
91
129
|
},
|
|
92
130
|
};
|
|
@@ -99,7 +137,7 @@ async function runAll(client, targets, opts) {
|
|
|
99
137
|
return (0, shared_1.mapWithConcurrency)(jobs, opts.concurrency, async ({ t, c }) => {
|
|
100
138
|
const result = stop
|
|
101
139
|
? { resource: t.label, case: c.name, status: 'skipped', failures: [] }
|
|
102
|
-
: await runCase(client, t, c, opts.timezone);
|
|
140
|
+
: await runCase(client, t, c, opts.timezone, opts.agentApi);
|
|
103
141
|
if (opts.failFast && (result.status === 'fail' || result.status === 'error'))
|
|
104
142
|
stop = true;
|
|
105
143
|
done += 1;
|
package/package.json
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metrio-ai/cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"packageManager": "yarn@4.14.1",
|
|
3
|
+
"version": "1.5.0",
|
|
5
4
|
"description": "Metrio AI command-line interface: run prompts and workflows, manage Metrio resources",
|
|
6
5
|
"bin": {
|
|
7
6
|
"metrio": "dist/index.js"
|
|
@@ -13,7 +12,7 @@
|
|
|
13
12
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
14
13
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
15
14
|
"test": "jest",
|
|
16
|
-
"prepublishOnly": "
|
|
15
|
+
"prepublishOnly": "bun run build"
|
|
17
16
|
},
|
|
18
17
|
"engines": {
|
|
19
18
|
"node": ">=18.0.0"
|