@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.
- package/README.md +7 -1
- package/dist/commands/sync.js +104 -19
- package/dist/commands/verify.js +66 -22
- package/dist/index.js +2 -2
- package/dist/manage/output.js +3 -1
- package/dist/manage/registry.js +52 -4
- package/dist/manage/run.js +44 -19
- 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/manage/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 !==
|
|
84
|
-
throw new errors_1.CliError(`Unknown
|
|
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
|
|
133
|
-
|
|
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
|
|
136
|
-
|
|
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
|
-
|
|
152
|
-
put(
|
|
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
|
-
|
|
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
|
}
|
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
|
}
|