@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
|
@@ -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
|
}
|
package/dist/sync/remote.js
CHANGED
|
@@ -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
|
}
|
package/dist/sync/render.js
CHANGED
|
@@ -8,6 +8,8 @@ exports.renderSkill = renderSkill;
|
|
|
8
8
|
exports.parseSkill = parseSkill;
|
|
9
9
|
exports.renderWorkflow = renderWorkflow;
|
|
10
10
|
exports.parseWorkflow = parseWorkflow;
|
|
11
|
+
exports.renderAgent = renderAgent;
|
|
12
|
+
exports.parseAgent = parseAgent;
|
|
11
13
|
const yaml_1 = require("yaml");
|
|
12
14
|
const errors_1 = require("../errors");
|
|
13
15
|
const VERSION_FIELDS = ['output_format', 'model_settings', 'model_fallbacks', 'output_schema'];
|
|
@@ -179,3 +181,120 @@ function parseWorkflow(files, where) {
|
|
|
179
181
|
}
|
|
180
182
|
return { enabled: meta.enabled === undefined ? true : meta.enabled, definition: definition };
|
|
181
183
|
}
|
|
184
|
+
const dirName = (key) => key.slice(key.indexOf('/') + 1);
|
|
185
|
+
function renderAgent(a, refs) {
|
|
186
|
+
const doc = { agent_id: Number(a.agent_id), name: a.name, description: a.description ?? null };
|
|
187
|
+
const promptKey = refs.prompts.get(String(a.entry_prompt_external_id));
|
|
188
|
+
if (promptKey)
|
|
189
|
+
doc.entry_prompt = dirName(promptKey);
|
|
190
|
+
else
|
|
191
|
+
doc.entry_prompt_id = a.entry_prompt_external_id;
|
|
192
|
+
doc.entry_version = a.entry_version_pin ?? null;
|
|
193
|
+
doc.max_turns = a.max_turns;
|
|
194
|
+
doc.status = a.status;
|
|
195
|
+
const subs = a.sub_agents.map((s) => {
|
|
196
|
+
const key = refs.agents.get(String(s.agent_id));
|
|
197
|
+
return { dir: key ? dirName(key) : undefined, entry: key ? { agent: dirName(key), version: s.version_pin } : { agent_id: s.agent_id, version: s.version_pin }, id: s.agent_id };
|
|
198
|
+
});
|
|
199
|
+
subs.sort((x, y) => x.dir !== undefined && y.dir !== undefined ? (x.dir < y.dir ? -1 : x.dir > y.dir ? 1 : 0)
|
|
200
|
+
: x.dir !== undefined ? -1 : y.dir !== undefined ? 1 : x.id - y.id);
|
|
201
|
+
doc.sub_agents = subs.map((s) => s.entry);
|
|
202
|
+
doc.tool_policies = [...a.tool_policies]
|
|
203
|
+
.sort((x, y) => (x.tool_name < y.tool_name ? -1 : x.tool_name > y.tool_name ? 1 : 0))
|
|
204
|
+
.map((p) => ({
|
|
205
|
+
tool_name: p.tool_name,
|
|
206
|
+
needs_approval: p.needs_approval,
|
|
207
|
+
...(typeof p.timeout_ms === 'number' ? { timeout_ms: p.timeout_ms } : {}),
|
|
208
|
+
}));
|
|
209
|
+
return (0, yaml_1.stringify)(doc);
|
|
210
|
+
}
|
|
211
|
+
const AGENT_KEYS = ['agent_id', 'name', 'description', 'entry_prompt', 'entry_prompt_id', 'entry_version', 'max_turns', 'status', 'sub_agents', 'tool_policies'];
|
|
212
|
+
function checkKeys(obj, allowed, where) {
|
|
213
|
+
const unknown = Object.keys(obj).filter((k) => !allowed.includes(k));
|
|
214
|
+
if (unknown.length)
|
|
215
|
+
throw new errors_1.CliError(`${where}: unknown key${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => `"${k}"`).join(', ')}`);
|
|
216
|
+
}
|
|
217
|
+
function mapping(value, where) {
|
|
218
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
219
|
+
throw new errors_1.CliError(`${where} must be a mapping`);
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
const isId = (v) => typeof v === 'number' && Number.isInteger(v) && v > 0;
|
|
223
|
+
const isText = (v) => typeof v === 'string' && v.trim() !== '';
|
|
224
|
+
/** A pin is a serial or tag; YAML may read an unquoted 3 as a number. */
|
|
225
|
+
function pin(value) {
|
|
226
|
+
if (typeof value === 'number' && Number.isInteger(value))
|
|
227
|
+
return String(value);
|
|
228
|
+
return isText(value) ? value : undefined;
|
|
229
|
+
}
|
|
230
|
+
function parseAgent(text, file) {
|
|
231
|
+
const raw = yamlObject(text, file);
|
|
232
|
+
checkKeys(raw, AGENT_KEYS, file);
|
|
233
|
+
if (!isText(raw.name))
|
|
234
|
+
throw new errors_1.CliError(`${file}: "name" is required`);
|
|
235
|
+
if (raw.agent_id !== undefined && !isId(raw.agent_id))
|
|
236
|
+
throw new errors_1.CliError(`${file}: "agent_id" must be a positive integer`);
|
|
237
|
+
if (raw.description !== undefined && raw.description !== null && typeof raw.description !== 'string') {
|
|
238
|
+
throw new errors_1.CliError(`${file}: "description" must be a string or null`);
|
|
239
|
+
}
|
|
240
|
+
if ((raw.entry_prompt === undefined) === (raw.entry_prompt_id === undefined)) {
|
|
241
|
+
throw new errors_1.CliError(`${file}: set exactly one of "entry_prompt" (directory) or "entry_prompt_id"`);
|
|
242
|
+
}
|
|
243
|
+
if (raw.entry_prompt !== undefined && !isText(raw.entry_prompt))
|
|
244
|
+
throw new errors_1.CliError(`${file}: "entry_prompt" must be a directory name`);
|
|
245
|
+
if (raw.entry_prompt_id !== undefined && !isId(raw.entry_prompt_id))
|
|
246
|
+
throw new errors_1.CliError(`${file}: "entry_prompt_id" must be a positive integer`);
|
|
247
|
+
let entryVersion = null;
|
|
248
|
+
if (raw.entry_version !== undefined && raw.entry_version !== null) {
|
|
249
|
+
const v = pin(raw.entry_version);
|
|
250
|
+
if (v === undefined)
|
|
251
|
+
throw new errors_1.CliError(`${file}: "entry_version" must be a serial, a tag, or null`);
|
|
252
|
+
entryVersion = v;
|
|
253
|
+
}
|
|
254
|
+
if (raw.max_turns !== undefined && !isId(raw.max_turns))
|
|
255
|
+
throw new errors_1.CliError(`${file}: "max_turns" must be a positive integer`);
|
|
256
|
+
if (raw.status !== undefined && !isText(raw.status))
|
|
257
|
+
throw new errors_1.CliError(`${file}: "status" must be a string`);
|
|
258
|
+
for (const field of ['sub_agents', 'tool_policies']) {
|
|
259
|
+
if (raw[field] !== undefined && !Array.isArray(raw[field]))
|
|
260
|
+
throw new errors_1.CliError(`${file}: "${field}" must be a list`);
|
|
261
|
+
}
|
|
262
|
+
const sub_agents = (raw.sub_agents ?? []).map((item, i) => {
|
|
263
|
+
const where = `${file}: sub_agents[${i}]`;
|
|
264
|
+
const s = mapping(item, where);
|
|
265
|
+
checkKeys(s, ['agent', 'agent_id', 'version'], where);
|
|
266
|
+
if ((s.agent === undefined) === (s.agent_id === undefined))
|
|
267
|
+
throw new errors_1.CliError(`${where} needs exactly one of "agent" (directory) or "agent_id"`);
|
|
268
|
+
if (s.agent !== undefined && !isText(s.agent))
|
|
269
|
+
throw new errors_1.CliError(`${where}: "agent" must be a directory name`);
|
|
270
|
+
if (s.agent_id !== undefined && !isId(s.agent_id))
|
|
271
|
+
throw new errors_1.CliError(`${where}: "agent_id" must be a positive integer`);
|
|
272
|
+
const version = pin(s.version);
|
|
273
|
+
if (version === undefined)
|
|
274
|
+
throw new errors_1.CliError(`${where}: "version" is required (a serial or tag)`);
|
|
275
|
+
return { ...(s.agent !== undefined ? { agent: s.agent } : { agent_id: s.agent_id }), version };
|
|
276
|
+
});
|
|
277
|
+
const tool_policies = (raw.tool_policies ?? []).map((item, i) => {
|
|
278
|
+
const where = `${file}: tool_policies[${i}]`;
|
|
279
|
+
const p = mapping(item, where);
|
|
280
|
+
checkKeys(p, ['tool_name', 'needs_approval', 'timeout_ms'], where);
|
|
281
|
+
if (!isText(p.tool_name))
|
|
282
|
+
throw new errors_1.CliError(`${where}: "tool_name" is required`);
|
|
283
|
+
if (typeof p.needs_approval !== 'boolean')
|
|
284
|
+
throw new errors_1.CliError(`${where}: "needs_approval" must be true or false`);
|
|
285
|
+
if (p.timeout_ms !== undefined && p.timeout_ms !== null && !isId(p.timeout_ms))
|
|
286
|
+
throw new errors_1.CliError(`${where}: "timeout_ms" must be a positive integer`);
|
|
287
|
+
return { tool_name: p.tool_name, needs_approval: p.needs_approval, ...(typeof p.timeout_ms === 'number' ? { timeout_ms: p.timeout_ms } : {}) };
|
|
288
|
+
});
|
|
289
|
+
return {
|
|
290
|
+
...(raw.agent_id !== undefined ? { agent_id: raw.agent_id } : {}),
|
|
291
|
+
name: raw.name,
|
|
292
|
+
description: raw.description ?? null,
|
|
293
|
+
...(raw.entry_prompt !== undefined ? { entry_prompt: raw.entry_prompt } : { entry_prompt_id: raw.entry_prompt_id }),
|
|
294
|
+
entry_version: entryVersion,
|
|
295
|
+
...(raw.max_turns !== undefined ? { max_turns: raw.max_turns } : {}),
|
|
296
|
+
...(raw.status !== undefined ? { status: raw.status } : {}),
|
|
297
|
+
sub_agents,
|
|
298
|
+
tool_policies,
|
|
299
|
+
};
|
|
300
|
+
}
|