@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.
@@ -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
- function computeEntries(root, state, remote, kinds) {
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 = [];
@@ -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 (!['prompts', 'skills', 'workflows'].includes(segments[0])) {
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
+ }
@@ -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)
@@ -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 EXPECT_KEYS = ['contains', 'not_contains', 'regex', 'equals', 'json', ...WORKFLOW_ONLY, 'max_latency_ms', 'max_tokens'];
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 === 'prompt' && WORKFLOW_ONLY.includes(key))
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 === 'prompt') {
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 === 'prompt' && defaults.channel !== undefined)
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 === 'prompt' && c.channel !== undefined)
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`);
@@ -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
- async function runCase(client, t, c, timezone) {
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.0",
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": "yarn build"
15
+ "prepublishOnly": "bun run build"
17
16
  },
18
17
  "engines": {
19
18
  "node": ">=18.0.0"