@metrio-ai/cli 1.3.1 → 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.
@@ -10,7 +10,7 @@ const registry_1 = require("./registry");
10
10
  const workspace_1 = require("../sync/workspace");
11
11
  const schema_1 = require("./schema");
12
12
  function parse(rest) {
13
- const parsed = { tokens: [], json: false, yes: false };
13
+ const parsed = { tokens: [], json: false, yes: false, frontmatter: false };
14
14
  const take = (i, name) => {
15
15
  const v = rest[i + 1];
16
16
  if (v === undefined)
@@ -38,6 +38,8 @@ function parse(rest) {
38
38
  parsed.json = true;
39
39
  else if (arg === '--yes')
40
40
  parsed.yes = true;
41
+ else if (arg === '--frontmatter')
42
+ parsed.frontmatter = true;
41
43
  else if (arg.startsWith('--')) {
42
44
  const eq = arg.indexOf('=');
43
45
  parsed.tokens.push(eq === -1 ? { flag: arg.slice(2) } : { flag: arg.slice(2, eq), value: arg.slice(eq + 1) });
@@ -47,13 +49,17 @@ function parse(rest) {
47
49
  }
48
50
  return parsed;
49
51
  }
50
- function fromFile(spec, file, readStdin) {
51
- const loaded = (0, input_1.loadInputFile)(file, readStdin, { frontmatter: !!spec.frontmatterKeys, stdinText: !!spec.textField });
52
+ function fromFile(spec, file, frontmatter, readStdin) {
53
+ const loaded = (0, input_1.loadInputFile)(file, readStdin, { frontmatter: !!spec.frontmatterKeys || frontmatter, stdinText: !!spec.textField });
52
54
  const cmd = `metrio ${spec.path.join(' ')}`;
53
55
  if (loaded.kind === 'text') {
54
56
  if (!spec.textField)
55
57
  throw new errors_1.CliError(`${cmd} does not take a text file; use .json or .yaml`);
56
- return { ...(loaded.frontmatter ?? {}), [spec.textField]: loaded.value };
58
+ const front = { ...(loaded.frontmatter ?? {}) };
59
+ if (frontmatter)
60
+ for (const key of registry_1.READ_ONLY_FRONTMATTER)
61
+ delete front[key];
62
+ return { ...front, [spec.textField]: loaded.value };
57
63
  }
58
64
  if (loaded.kind === 'array') {
59
65
  if (!spec.wrap)
@@ -67,21 +73,28 @@ function isCommandPrefix(argv) {
67
73
  const words = argv.filter((a) => !a.startsWith('-'));
68
74
  return registry_1.COMMANDS.some((c) => words.length < c.path.length && words.every((w, i) => c.path[i] === w));
69
75
  }
76
+ /** Positionals that may name a tracked directory instead of the numeric id. */
77
+ const DIR_REFS = {
78
+ prompt_external_id: { kind: 'prompt', noun: 'prompt' },
79
+ agent_id: { kind: 'agent', noun: 'agent' },
80
+ };
70
81
  /**
71
- * A prompt positional may name a tracked directory (prompts/<dir> or <dir>) instead of the
72
- * numeric id — only for the workspace's own project, since ids are per project.
82
+ * A prompt or agent positional may name a tracked directory (prompts/<dir> or <dir>) instead of
83
+ * the numeric id — only for the workspace's own project, since ids are per project.
73
84
  */
74
- function promptRef(raw, project) {
85
+ function dirRef(raw, project, prop) {
75
86
  if (/^\d+$/.test(raw))
76
87
  return raw;
88
+ const { kind, noun } = DIR_REFS[prop];
89
+ const prefix = workspace_1.KIND_DIR[kind];
77
90
  const root = (0, workspace_1.findRoot)();
78
91
  const dir = raw.replace(/\/+$/, '');
79
- const key = dir.startsWith('prompts/') ? dir : `prompts/${dir}`;
92
+ const key = dir.startsWith(`${prefix}/`) ? dir : `${prefix}/${dir}`;
80
93
  // A broken workspace surfaces its own (informative) CliError.
81
94
  const workspaceProject = root ? (0, workspace_1.readProject)(root) : undefined;
82
95
  const entry = root ? (0, workspace_1.readState)(root, workspaceProject).resources[key] : undefined;
83
- if (!entry || entry.kind !== 'prompt') {
84
- throw new errors_1.CliError(`Unknown prompt "${raw}": use the numeric id, or a prompts/<dir> tracked in .metrio/state.json`);
96
+ if (!entry || entry.kind !== kind) {
97
+ throw new errors_1.CliError(`Unknown ${noun} "${raw}": use the numeric id, or a ${prefix}/<dir> tracked in .metrio/state.json`);
85
98
  }
86
99
  if (project !== undefined && project !== workspaceProject) {
87
100
  throw new errors_1.CliError(`"${raw}" is a directory of project ${workspaceProject}, but this command targets project ${String(project)}`);
@@ -103,6 +116,9 @@ async function runManageCommand(argv, readStdin) {
103
116
  }
104
117
  const { spec, rest } = match;
105
118
  const opts = parse(rest);
119
+ if (opts.frontmatter && !spec.optionalFrontmatterKeys) {
120
+ throw new errors_1.CliError(`--frontmatter only applies to metrio version get | create | update, not metrio ${spec.path.join(' ')}`);
121
+ }
106
122
  const client = new client_1.ConsoleClient(await (0, client_1.resolveConsoleAuth)());
107
123
  const tool = (await client.listTools()).find((t) => t.name === spec.tool);
108
124
  if (!tool) {
@@ -123,17 +139,23 @@ async function runManageCommand(argv, readStdin) {
123
139
  sources[key] = source;
124
140
  };
125
141
  if (opts.file)
126
- for (const [k, v] of Object.entries(fromFile(spec, opts.file, readStdin)))
142
+ for (const [k, v] of Object.entries(fromFile(spec, opts.file, opts.frontmatter, readStdin)))
127
143
  put(k, v, `file ${opts.file}`);
128
144
  if (opts.project && 'project_slug' in props)
129
145
  put('project_slug', opts.project, 'project');
130
146
  for (const [k, v] of Object.entries(values))
131
147
  put(k, v, `--${(0, schema_1.kebab)(k)}`);
132
- // A prompt directory resolves against the final project_slug, so it waits for it.
133
- let promptDir;
148
+ // A version uses a tier OR a specific model, and the console lets tier_key win when both are
149
+ // sent; so a model chosen by flag must drop a tier that came from the file, or the flag is ignored.
150
+ if (('model_provider' in values || 'model_name' in values) && !('tier_key' in values) && 'tier_key' in args) {
151
+ delete args.tier_key;
152
+ delete sources.tier_key;
153
+ }
154
+ // A directory name resolves against the final project_slug, so it waits for it.
155
+ const dirRefs = [];
134
156
  expected.forEach((arg, i) => {
135
- if (arg.prop === 'prompt_external_id' && !/^\d+$/.test(positionals[i]))
136
- promptDir = { prop: arg.prop, raw: positionals[i], label: arg.label };
157
+ if (arg.prop in DIR_REFS && !/^\d+$/.test(positionals[i]))
158
+ dirRefs.push({ prop: arg.prop, raw: positionals[i], label: arg.label });
137
159
  else
138
160
  put(arg.prop, (0, schema_1.coercePositional)(positionals[i], props[arg.prop], arg.label), arg.label);
139
161
  });
@@ -148,8 +170,8 @@ async function runManageCommand(argv, readStdin) {
148
170
  throw err;
149
171
  }
150
172
  }
151
- if (promptDir) {
152
- put(promptDir.prop, (0, schema_1.coercePositional)(promptRef(promptDir.raw, args.project_slug), props[promptDir.prop], promptDir.label), promptDir.label);
173
+ for (const ref of dirRefs) {
174
+ put(ref.prop, (0, schema_1.coercePositional)(dirRef(ref.raw, args.project_slug, ref.prop), props[ref.prop], ref.label), ref.label);
153
175
  }
154
176
  if ((schema.required ?? []).includes('project_slug') && args.project_slug === undefined) {
155
177
  throw new errors_1.CliError('No project. Pass --project <slug>, set METRIO_PROJECT, or add "project: <slug>" to metrio.yaml.');
@@ -163,11 +185,14 @@ async function runManageCommand(argv, readStdin) {
163
185
  throw new errors_1.CliError('Re-run with --yes to confirm.', 2);
164
186
  }
165
187
  const data = await client.callTool(spec.tool, accepted, { retry: tool.scope === 'read' });
166
- await (0, output_1.writeOutput)(data, {
188
+ const textOut = !!opts.outFile && /\.(md|markdown|txt)$/i.test(opts.outFile);
189
+ const withFrontmatter = opts.frontmatter && textOut && isRecord(data) && spec.frontmatterView;
190
+ await (0, output_1.writeOutput)(withFrontmatter ? spec.frontmatterView(data) : data, {
167
191
  json: opts.json,
168
192
  outFile: opts.outFile,
169
193
  textField: spec.textField,
170
- frontmatterKeys: spec.frontmatterKeys,
194
+ frontmatterKeys: spec.frontmatterKeys ?? (opts.frontmatter ? spec.optionalFrontmatterKeys : undefined),
195
+ frontmatterKeepNull: withFrontmatter ? spec.frontmatterKeepNull : undefined,
171
196
  downloadField: spec.downloadField,
172
197
  });
173
198
  if (isRecord(data) && data.valid === false) {
@@ -0,0 +1,380 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.agentDirRefs = agentDirRefs;
4
+ exports.unresolvedRef = unresolvedRef;
5
+ exports.subAgentKeys = subAgentKeys;
6
+ exports.pushAgent = pushAgent;
7
+ exports.bumpUnchangedPins = bumpUnchangedPins;
8
+ exports.parentsOutOfScope = parentsOutOfScope;
9
+ exports.checkAgentRefs = checkAgentRefs;
10
+ exports.agentWriteOrder = agentWriteOrder;
11
+ exports.agentDeleteOrder = agentDeleteOrder;
12
+ const yaml_1 = require("yaml");
13
+ const errors_1 = require("../errors");
14
+ const push_1 = require("./push");
15
+ const render_1 = require("./render");
16
+ const workspace_1 = require("./workspace");
17
+ /*
18
+ * Agents (plan Decisions 3-4). agent.yaml names its entry prompt and sub-agents by workspace
19
+ * directory (resolved through state, or through what this push created: prompts and children
20
+ * are pushed first) or by numeric id. Pin bumping: a sub-agent link whose child's entry prompt
21
+ * got a new head version in this push (--activate, or a new prompt's first version) moves to
22
+ * that version's serial, written through the parent's own set_agent_tools when the parent is
23
+ * pushed anyway, else through bump_agent_pins; the parent's agent.yaml is rewritten so the next
24
+ * diff is clean. A plain push's non-head version is not live yet (push, verify, push --activate),
25
+ * so it moves no pin. Entry versions are never bumped.
26
+ */
27
+ /** The directory references of an agent file, with the field that holds each. */
28
+ function agentDirRefs(doc) {
29
+ const refs = doc.entry_prompt !== undefined ? [{ key: `prompts/${doc.entry_prompt}`, field: 'entry_prompt', dir: doc.entry_prompt }] : [];
30
+ doc.sub_agents.forEach((s, i) => {
31
+ if (s.agent !== undefined)
32
+ refs.push({ key: `agents/${s.agent}`, field: `sub_agents[${i}].agent`, dir: s.agent });
33
+ });
34
+ return refs;
35
+ }
36
+ function unresolvedRef(agentKey, ref, why = `${ref.key} is not tracked and not created by this push`) {
37
+ return new errors_1.CliError(`${agentKey}/agent.yaml: ${ref.field} "${ref.dir}" does not resolve (${why})`);
38
+ }
39
+ const parseAgentEntry = (e) => (0, render_1.parseAgent)(e.local['agent.yaml'] ?? '', `${e.key}/agent.yaml`);
40
+ /** Workspace keys of an agent's sub-agents among `entries` (local file, else the remote links), sorted. */
41
+ function subAgentKeys(e, entries) {
42
+ const keyOf = (id) => entries.find((x) => x.kind === 'agent' && (x.state?.id ?? x.remote?.id) === String(id))?.key;
43
+ let keys;
44
+ if (e.local) {
45
+ try {
46
+ keys = parseAgentEntry(e).sub_agents.map((s) => (s.agent !== undefined ? `agents/${s.agent}` : keyOf(s.agent_id)));
47
+ }
48
+ catch {
49
+ return []; // the push reports the parse error
50
+ }
51
+ }
52
+ else
53
+ keys = (e.remote?.data?.sub_agents ?? []).map((s) => keyOf(s.agent_id));
54
+ return [...new Set(keys.filter((k) => k !== undefined))].sort();
55
+ }
56
+ /** The console id of the entry prompt an agent file names, if its directory resolves. */
57
+ const entryPromptId = (sync, doc) => doc.entry_prompt !== undefined ? sync.ids.get(`prompts/${doc.entry_prompt}`) : String(doc.entry_prompt_id);
58
+ /** The local agent.yaml of a tracked agent outside this push's scope, if it has one. */
59
+ function trackedAgentFile(sync, key) {
60
+ const local = (0, workspace_1.readLocal)(sync.root, key, 'agent');
61
+ return local ? (0, render_1.parseAgent)(local['agent.yaml'] ?? '', `${key}/agent.yaml`) : undefined;
62
+ }
63
+ /** The serial this push gave the entry prompt of agent `agentId`, if it created one. */
64
+ function newEntrySerial(sync, agentId) {
65
+ const child = sync.entries.find((x) => x.kind === 'agent' && (sync.ids.get(x.key) ?? x.remote?.id) === String(agentId));
66
+ let promptId;
67
+ try {
68
+ if (child?.local)
69
+ promptId = entryPromptId(sync, parseAgentEntry(child));
70
+ else if (child?.remote)
71
+ promptId = String(child.remote.data.entry_prompt_external_id);
72
+ else if (!child) {
73
+ // A tracked child outside the scope: its local agent.yaml names the entry prompt.
74
+ const key = [...sync.ids].find(([k, id]) => k.startsWith('agents/') && id === String(agentId))?.[0];
75
+ const doc = key === undefined ? undefined : trackedAgentFile(sync, key);
76
+ promptId = doc && entryPromptId(sync, doc);
77
+ }
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ const serial = promptId === undefined ? undefined : sync.promptSerials.get(promptId);
83
+ return serial === undefined ? undefined : String(serial);
84
+ }
85
+ function resolveAgent(sync, e, doc) {
86
+ const refs = agentDirRefs(doc);
87
+ const idOf = (field) => {
88
+ const ref = refs.find((r) => r.field === field);
89
+ const id = sync.ids.get(ref.key);
90
+ if (id === undefined)
91
+ throw unresolvedRef(e.key, ref);
92
+ return Number(id);
93
+ };
94
+ const entryPrompt = doc.entry_prompt !== undefined ? idOf('entry_prompt') : doc.entry_prompt_id;
95
+ const remoteLinks = e.remote?.data?.sub_agents ?? [];
96
+ const pins = new Map();
97
+ const subAgents = doc.sub_agents.map((s, i) => {
98
+ const agentId = s.agent !== undefined ? idOf(`sub_agents[${i}].agent`) : s.agent_id;
99
+ // Only a numeric pin still equal to the console's moves: tags and pins edited by hand stay.
100
+ const untouched = /^\d+$/.test(s.version) && remoteLinks.some((l) => l.agent_id === agentId && l.version_pin === s.version);
101
+ const bumped = sync.bumpPins && untouched ? newEntrySerial(sync, agentId) : undefined;
102
+ if (bumped !== undefined && bumped !== s.version)
103
+ pins.set(i, bumped);
104
+ return { agent_id: agentId, version_pin: pins.get(i) ?? s.version };
105
+ });
106
+ return { entryPrompt, subAgents, pins };
107
+ }
108
+ const policy = (p) => ({
109
+ tool_name: p.tool_name, needs_approval: p.needs_approval, ...(typeof p.timeout_ms === 'number' ? { timeout_ms: p.timeout_ms } : {}),
110
+ });
111
+ const toolSet = (subAgents, policies) => (0, render_1.canonical)({
112
+ sub_agents: [...subAgents].sort((a, b) => a.agent_id - b.agent_id),
113
+ tool_policies: policies.map(policy).sort((a, b) => (a.tool_name < b.tool_name ? -1 : a.tool_name > b.tool_name ? 1 : 0)),
114
+ });
115
+ /** agent.yaml with agent_id written back (first key) and bumped pins; comments and layout are kept. */
116
+ function rewriteAgentYaml(text, agentId, pins) {
117
+ const doc = (0, yaml_1.parseDocument)(text);
118
+ if (agentId !== undefined)
119
+ doc.contents.items.unshift(doc.createPair('agent_id', agentId));
120
+ for (const [i, version] of pins)
121
+ doc.setIn(['sub_agents', i, 'version'], version);
122
+ return doc.toString();
123
+ }
124
+ function writeAgentFile(sync, e, text) {
125
+ const files = { 'agent.yaml': text };
126
+ if (text !== e.local['agent.yaml'])
127
+ (0, workspace_1.writeLocal)(sync.root, e.key, 'agent', files);
128
+ return files;
129
+ }
130
+ const bumpSummary = (doc, r) => [...r.pins].map(([i, v]) => `pinned ${doc.sub_agents[i].agent ?? doc.sub_agents[i].agent_id} to version ${v}`);
131
+ async function pushAgent(ctx, e) {
132
+ const sync = ctx.agents;
133
+ if (!sync)
134
+ throw new errors_1.CliError('pushing agents needs the workspace context');
135
+ const text = e.local['agent.yaml'] ?? '';
136
+ const doc = parseAgentEntry(e);
137
+ const resolved = resolveAgent(sync, e, doc);
138
+ const { entryPrompt, subAgents, pins } = resolved;
139
+ const fields = {
140
+ name: doc.name, description: doc.description, entry_prompt_external_id: entryPrompt, entry_version_pin: doc.entry_version,
141
+ max_turns: doc.max_turns, status: doc.status,
142
+ };
143
+ const remote = e.remote?.data;
144
+ if (!remote) {
145
+ const tools = subAgents.length || doc.tool_policies.length ? { sub_agents: subAgents, tool_policies: doc.tool_policies } : undefined;
146
+ const row = await (0, push_1.call)(ctx, 'create_agent', { external_id: doc.agent_id, ...fields, tools });
147
+ const created = row.agent?.agent_id;
148
+ if (typeof created !== 'number') {
149
+ throw new errors_1.CliError(`${e.key}: create_agent answered without an agent_id; check "metrio agent list" before pushing again`);
150
+ }
151
+ const id = String(created);
152
+ try {
153
+ const files = writeAgentFile(sync, e, rewriteAgentYaml(text, doc.agent_id === undefined ? created : undefined, pins));
154
+ return { id, files, summary: ['created', ...bumpSummary(doc, resolved)].join(', ') };
155
+ }
156
+ catch (error) {
157
+ throw await (0, push_1.undoCreate)(ctx, 'delete_agent', { agent_id: created }, `agent ${id}`, error);
158
+ }
159
+ }
160
+ const id = e.remote.id;
161
+ if (doc.agent_id !== undefined && String(doc.agent_id) !== id) {
162
+ throw new errors_1.CliError(`${e.key}/agent.yaml: agent_id must be ${id} (agent ids cannot change)`);
163
+ }
164
+ const before = {
165
+ name: remote.name, description: remote.description, entry_prompt_external_id: remote.entry_prompt_external_id,
166
+ entry_version_pin: remote.entry_version_pin, max_turns: remote.max_turns, status: remote.status,
167
+ };
168
+ const changed = Object.fromEntries(Object.entries(fields).filter(([k, v]) => v !== undefined && v !== before[k]));
169
+ const done = [];
170
+ if (Object.keys(changed).length) {
171
+ await (0, push_1.call)(ctx, 'update_agent', { agent_id: Number(id), ...changed });
172
+ done.push(`${Object.keys(changed).join(', ')} updated`);
173
+ }
174
+ if (toolSet(subAgents, doc.tool_policies) !== toolSet(remote.sub_agents, remote.tool_policies)) {
175
+ await (0, push_1.call)(ctx, 'set_agent_tools', { agent_id: Number(id), sub_agents: subAgents, tool_policies: doc.tool_policies });
176
+ done.push('tools replaced');
177
+ }
178
+ const files = pins.size ? writeAgentFile(sync, e, rewriteAgentYaml(text, undefined, pins)) : undefined;
179
+ return { id, ...(files ? { files } : {}), summary: [...done, ...bumpSummary(doc, resolved)].join(', ') || 'no change' };
180
+ }
181
+ /**
182
+ * Moves the sub-agent pins of agents this push did not write (an unchanged agent whose child's
183
+ * entry prompt got a new version) with bump_agent_pins, and rewrites their agent.yaml. Parents
184
+ * are sent in chunks of up to 200 pins; `onBumped` runs for each parent once its chunk applied,
185
+ * so a later failing chunk leaves the earlier bumps recorded.
186
+ */
187
+ async function bumpUnchangedPins(ctx, entries, onBumped) {
188
+ const sync = ctx.agents;
189
+ if (!sync.bumpPins || !sync.promptSerials.size)
190
+ return;
191
+ const bumps = [];
192
+ for (const e of entries) {
193
+ if (e.kind !== 'agent' || e.status !== 'unchanged' || !e.local || !e.remote)
194
+ continue;
195
+ try {
196
+ const doc = parseAgentEntry(e);
197
+ const resolved = resolveAgent(sync, e, doc);
198
+ const pins = [...resolved.pins].map(([i, version]) => ({ agent_id: Number(e.remote.id), sub_agent_id: resolved.subAgents[i].agent_id, version_pin: version }));
199
+ if (pins.length)
200
+ bumps.push({ entry: e, doc, resolved, pins });
201
+ }
202
+ catch {
203
+ continue; // an unchanged file that no longer resolves: nothing to bump
204
+ }
205
+ }
206
+ const chunks = [];
207
+ for (const b of bumps) {
208
+ const last = chunks[chunks.length - 1];
209
+ if (last && last.reduce((n, x) => n + x.pins.length, 0) + b.pins.length <= 200)
210
+ last.push(b);
211
+ else
212
+ chunks.push([b]);
213
+ }
214
+ for (const chunk of chunks) {
215
+ const pins = chunk.flatMap((b) => b.pins);
216
+ try {
217
+ // A parent with more than 200 links still needs several calls.
218
+ for (let i = 0; i < pins.length; i += 200)
219
+ await (0, push_1.call)(ctx, 'bump_agent_pins', { pins: pins.slice(i, i + 200) });
220
+ }
221
+ catch (error) {
222
+ throw new errors_1.CliError(`sub-agent pins of ${chunk.map((b) => b.entry.key).join(', ')} not bumped: ${(0, push_1.message)(error)}`);
223
+ }
224
+ for (const { entry, doc, resolved } of chunk) {
225
+ const files = writeAgentFile(sync, entry, rewriteAgentYaml(entry.local['agent.yaml'], undefined, resolved.pins));
226
+ onBumped(entry, { id: entry.remote.id, files, summary: bumpSummary(doc, resolved).join(', ') });
227
+ }
228
+ }
229
+ }
230
+ /**
231
+ * Tracked agents outside this push's scope with a numeric sub-agent pin behind the head version
232
+ * this push gave the child's entry prompt: their pins did not move.
233
+ */
234
+ function parentsOutOfScope(sync) {
235
+ if (!sync.bumpPins || !sync.promptSerials.size)
236
+ return [];
237
+ const inScope = new Set(sync.entries.map((e) => e.key));
238
+ const keys = [];
239
+ for (const key of sync.ids.keys()) {
240
+ if (!key.startsWith('agents/') || inScope.has(key))
241
+ continue;
242
+ try {
243
+ const doc = trackedAgentFile(sync, key);
244
+ if (!doc)
245
+ continue;
246
+ const stale = doc.sub_agents.some((s) => {
247
+ const id = s.agent !== undefined ? sync.ids.get(`agents/${s.agent}`) : String(s.agent_id);
248
+ const serial = id === undefined || !/^\d+$/.test(s.version) ? undefined : newEntrySerial(sync, Number(id));
249
+ return serial !== undefined && serial !== s.version;
250
+ });
251
+ if (stale)
252
+ keys.push(key);
253
+ }
254
+ catch {
255
+ continue; // unreadable files: nothing to hint
256
+ }
257
+ }
258
+ return keys.sort();
259
+ }
260
+ /**
261
+ * Every local agent in scope, written or not, must still resolve once this push is done: no
262
+ * reference (directory or numeric id) to something it deletes or that was deleted in the console,
263
+ * none to a directory it renames away, and every directory tracked or created by this push.
264
+ * Checked before any write.
265
+ */
266
+ function checkAgentRefs(agents, created, deleted, renamed, remoteDeleted, state) {
267
+ const gone = [
268
+ ...deleted.map((d) => ({ entry: d, why: `${d.key} is deleted by this push` })),
269
+ ...remoteDeleted.map((d) => ({ entry: d, why: `${d.key} was deleted in the console` })),
270
+ ];
271
+ const goneWhy = new Map([
272
+ ...gone.map((g) => [g.entry.key, g.why]),
273
+ ...renamed.map((r) => [r.from, `${r.from} is renamed to ${r.key} by this push`]),
274
+ ]);
275
+ for (const e of agents) {
276
+ let doc;
277
+ try {
278
+ doc = parseAgentEntry(e);
279
+ }
280
+ catch {
281
+ continue; // the push reports the parse error
282
+ }
283
+ const refs = agentDirRefs(doc);
284
+ const goneRef = refs.find((r) => goneWhy.has(r.key));
285
+ if (goneRef)
286
+ throw unresolvedRef(e.key, goneRef, goneWhy.get(goneRef.key));
287
+ const missing = refs.find((r) => !state.resources[r.key] && !created.has(r.key));
288
+ if (missing)
289
+ throw unresolvedRef(e.key, missing);
290
+ const idRefs = [
291
+ ...(doc.entry_prompt_id !== undefined ? [{ field: 'entry_prompt_id', kind: 'prompt', id: doc.entry_prompt_id }] : []),
292
+ ...doc.sub_agents.flatMap((s, i) => (s.agent_id !== undefined ? [{ field: `sub_agents[${i}].agent_id`, kind: 'agent', id: s.agent_id }] : [])),
293
+ ];
294
+ for (const ref of idRefs) {
295
+ const g = gone.find((x) => x.entry.kind === ref.kind && x.entry.state?.id === String(ref.id));
296
+ if (g)
297
+ throw new errors_1.CliError(`${e.key}/agent.yaml: ${ref.field} ${ref.id} does not resolve (${g.why})`);
298
+ }
299
+ }
300
+ }
301
+ /**
302
+ * The sub-agent graph over `nodes` as strongly connected components (Tarjan), each emitted after
303
+ * every component it calls: children first.
304
+ */
305
+ function agentComponents(nodes, entries) {
306
+ const keys = new Set(nodes.map((e) => e.key));
307
+ const edges = new Map(nodes.map((e) => [e.key, subAgentKeys(e, entries).filter((k) => keys.has(k))]));
308
+ const index = new Map();
309
+ const low = new Map();
310
+ const stack = [];
311
+ const onStack = new Set();
312
+ const components = [];
313
+ const connect = (v) => {
314
+ index.set(v, index.size);
315
+ low.set(v, index.get(v));
316
+ stack.push(v);
317
+ onStack.add(v);
318
+ for (const w of edges.get(v)) {
319
+ if (!index.has(w)) {
320
+ connect(w);
321
+ low.set(v, Math.min(low.get(v), low.get(w)));
322
+ }
323
+ else if (onStack.has(w))
324
+ low.set(v, Math.min(low.get(v), index.get(w)));
325
+ }
326
+ if (low.get(v) !== index.get(v))
327
+ return;
328
+ const members = [];
329
+ let w;
330
+ do {
331
+ w = stack.pop();
332
+ onStack.delete(w);
333
+ members.push(w);
334
+ } while (w !== v);
335
+ components.push({ keys: members.sort(), cyclic: members.length > 1 || edges.get(v).includes(v) });
336
+ };
337
+ for (const e of nodes)
338
+ if (!index.has(e.key))
339
+ connect(e.key);
340
+ return { components, edges };
341
+ }
342
+ /** A shortest cycle from `start` back to itself inside one component, for the error message. */
343
+ function cycleFrom(start, members, edges) {
344
+ const prev = new Map();
345
+ const queue = [start];
346
+ while (queue.length) {
347
+ const v = queue.shift();
348
+ for (const w of edges.get(v)) {
349
+ if (w === start) {
350
+ const path = [v];
351
+ for (let x = v; x !== start; x = prev.get(x))
352
+ path.unshift(prev.get(x));
353
+ return [...path, start];
354
+ }
355
+ if (members.has(w) && !prev.has(w)) {
356
+ prev.set(w, v);
357
+ queue.push(w);
358
+ }
359
+ }
360
+ }
361
+ return [start];
362
+ }
363
+ /**
364
+ * Agents being written, children first. A cycle (a component of several agents, or a self-link)
365
+ * holding an agent being written fails before any write; one only among agents this push leaves
366
+ * alone does not (the backend rejects only cycles through the agent being edited).
367
+ */
368
+ function agentWriteOrder(entries, written) {
369
+ const { components, edges } = agentComponents(entries.filter((e) => e.kind === 'agent' && e.local), entries);
370
+ for (const c of components) {
371
+ const start = c.keys.find((k) => written.has(k));
372
+ if (c.cyclic && start)
373
+ throw new errors_1.CliError(`sub-agent cycle in local agent files: ${cycleFrom(start, new Set(c.keys), edges).join(' -> ')}`);
374
+ }
375
+ return components.flatMap((c) => c.keys);
376
+ }
377
+ /** Agents being deleted, parents before children (so a parent's link never blocks its child's delete). */
378
+ function agentDeleteOrder(deleted, entries) {
379
+ return agentComponents(deleted, entries).components.flatMap((c) => c.keys).reverse();
380
+ }
package/dist/sync/push.js CHANGED
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.message = void 0;
4
+ exports.call = call;
5
+ exports.undoCreate = undoCreate;
3
6
  exports.versionContent = versionContent;
4
7
  exports.hasPendingVersion = hasPendingVersion;
5
8
  exports.pushEntry = pushEntry;
@@ -7,6 +10,7 @@ exports.deleteEntry = deleteEntry;
7
10
  const crypto_1 = require("crypto");
8
11
  const errors_1 = require("../errors");
9
12
  const remote_1 = require("./remote");
13
+ const push_agents_1 = require("./push-agents");
10
14
  const render_1 = require("./render");
11
15
  const defined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
12
16
  async function call(ctx, tool, args) {
@@ -14,14 +18,15 @@ async function call(ctx, tool, args) {
14
18
  }
15
19
  const sha = (text) => (0, crypto_1.createHash)('sha256').update(text).digest('hex');
16
20
  const message = (err) => (err instanceof Error ? err.message : String(err));
21
+ exports.message = message;
17
22
  /** Best-effort removal of a resource this push just created, so a failed push leaves no orphan. */
18
23
  async function undoCreate(ctx, tool, args, what, error) {
19
24
  try {
20
25
  await call(ctx, tool, args);
21
- return new errors_1.CliError(`${message(error)} (the half-created ${what.split(' ')[0]} was removed)`);
26
+ return new errors_1.CliError(`${(0, exports.message)(error)} (the half-created ${what.split(' ')[0]} was removed)`);
22
27
  }
23
28
  catch (cleanup) {
24
- return new errors_1.CliError(`${message(error)} (cleanup failed: ${message(cleanup)}; ${what} exists in the console)`);
29
+ return new errors_1.CliError(`${(0, exports.message)(error)} (cleanup failed: ${(0, exports.message)(cleanup)}; ${what} exists in the console)`);
25
30
  }
26
31
  }
27
32
  /*
@@ -164,6 +169,7 @@ async function pushPrompt(ctx, e) {
164
169
  });
165
170
  result.pushed_version_id = Number(v.id);
166
171
  result.pushed_serial = Number(v.serial);
172
+ result.pushed_head = head;
167
173
  result.pushed_content = content;
168
174
  result.versioned = true;
169
175
  result.summary = `version ${v.serial}${head ? ' (head)' : ''}`;
@@ -199,7 +205,7 @@ async function pushPrompt(ctx, e) {
199
205
  }
200
206
  catch (err) {
201
207
  failedIds.push(m.instance_id);
202
- failures.push(`instance ${m.instance_id}: ${message(err)}`);
208
+ failures.push(`instance ${m.instance_id}: ${(0, exports.message)(err)}`);
203
209
  }
204
210
  }
205
211
  if (failedIds.length) {
@@ -316,6 +322,8 @@ function pushEntry(ctx, e) {
316
322
  return pushPrompt(ctx, e);
317
323
  if (e.kind === 'skill')
318
324
  return pushSkill(ctx, e);
325
+ if (e.kind === 'agent')
326
+ return (0, push_agents_1.pushAgent)(ctx, e);
319
327
  return pushWorkflow(ctx, e);
320
328
  }
321
329
  async function deleteEntry(ctx, e) {
@@ -324,6 +332,10 @@ async function deleteEntry(ctx, e) {
324
332
  await call(ctx, 'delete_prompt', { prompt_external_id: Number(id) });
325
333
  else if (e.kind === 'skill')
326
334
  await call(ctx, 'delete_skill', { skill_id: Number(id) });
327
- else
335
+ else if (e.kind === 'workflow')
328
336
  await call(ctx, 'delete_workflow', { workflow_id: id });
337
+ else if (e.kind === 'agent')
338
+ await call(ctx, 'delete_agent', { agent_id: Number(id) });
339
+ else
340
+ throw new errors_1.CliError(`cannot delete unknown resource kind ${String(e.kind)}`);
329
341
  }
@@ -66,14 +66,62 @@ async function fetchWorkflows(client, project) {
66
66
  return { kind: 'workflow', id: data.workflow_id, name: data.workflow_id, files: (0, render_1.renderWorkflow)(data), data };
67
67
  });
68
68
  }
69
+ /** Agents, minus those whose entry prompt is gone (deleted prompts leave entry_prompt_external_id null). */
70
+ async function fetchAgents(client, project) {
71
+ const rows = await fetchAll(client, 'list_agents', { project_slug: project });
72
+ const fetched = await (0, shared_1.mapWithConcurrency)(rows, CONCURRENCY, async (row) => {
73
+ const res = (await read(client, 'get_agent', { project_slug: project, agent_id: row.agent_id }));
74
+ const a = (res.agent ?? res);
75
+ const tools = (a.tools ?? {});
76
+ for (const field of ['agent_id', 'max_turns']) {
77
+ if (!Number.isFinite(Number(a[field])) || a[field] === null || a[field] === undefined) {
78
+ throw new errors_1.CliError(`get_agent returned no valid ${field} for agent ${String(row.agent_id)}`);
79
+ }
80
+ }
81
+ if (a.entry_prompt_external_id === null || a.entry_prompt_external_id === undefined || !Number.isFinite(Number(a.entry_prompt_external_id))) {
82
+ return { skipped: { id: String(a.agent_id), name: String(a.name) } };
83
+ }
84
+ const data = {
85
+ agent_id: String(a.agent_id),
86
+ name: String(a.name),
87
+ description: a.description ?? null,
88
+ entry_prompt_external_id: Number(a.entry_prompt_external_id),
89
+ entry_version_pin: a.entry_version_pin ?? null,
90
+ max_turns: Number(a.max_turns),
91
+ status: String(a.status),
92
+ sub_agents: (tools.sub_agents ?? []).map((s) => ({ agent_id: Number(s.agent_id), version_pin: String(s.version_pin) })),
93
+ tool_policies: (tools.tool_policies ?? []),
94
+ };
95
+ // Directory references need the workspace's keys: computeEntries re-renders with them.
96
+ const files = { 'agent.yaml': (0, render_1.renderAgent)(data, { prompts: new Map(), agents: new Map() }) };
97
+ return { resource: { kind: 'agent', id: data.agent_id, name: data.name, files, data } };
98
+ });
99
+ return {
100
+ resources: fetched.flatMap((f) => (f.resource ? [f.resource] : [])),
101
+ skipped: fetched.flatMap((f) => (f.skipped ? [f.skipped] : [])),
102
+ };
103
+ }
69
104
  /** Rendered remote resources of the given kinds, in kind order. */
70
105
  async function fetchRemote(client, project, kinds) {
71
- const out = [];
106
+ const out = { resources: [], skippedAgents: [], agentsUnavailable: false };
72
107
  if (kinds.includes('prompt'))
73
- out.push(...(await fetchPrompts(client, project)));
108
+ out.resources.push(...(await fetchPrompts(client, project)));
74
109
  if (kinds.includes('skill'))
75
- out.push(...(await fetchSkills(client, project)));
110
+ out.resources.push(...(await fetchSkills(client, project)));
76
111
  if (kinds.includes('workflow'))
77
- out.push(...(await fetchWorkflows(client, project)));
112
+ out.resources.push(...(await fetchWorkflows(client, project)));
113
+ if (kinds.includes('agent')) {
114
+ try {
115
+ const agents = await fetchAgents(client, project);
116
+ out.resources.push(...agents.resources);
117
+ out.skippedAgents = agents.skipped;
118
+ }
119
+ catch (error) {
120
+ const unknownTool = error instanceof errors_1.ConsoleApiError && error.status === 404 && error.code === 'unknown_tool';
121
+ if (!unknownTool || kinds.length === 1)
122
+ throw error;
123
+ out.agentsUnavailable = true;
124
+ }
125
+ }
78
126
  return out;
79
127
  }