trodo-cli 0.3.0 → 0.3.1

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.
@@ -135,4 +135,147 @@ async function steps([ref], flags) {
135
135
  print({ eval: e.name, steps: e.steps, ...out }, flags);
136
136
  }
137
137
 
138
- module.exports = { list: listEvals, get: getEval, verdicts, verdict, mark, queue, review, grade, steps, daysOf };
138
+ /** A route's condition as words: `verdict = frustrated`, `score < 0.5`. */
139
+ function condText(w) {
140
+ if (!w || typeof w !== 'object') return String(w);
141
+ if (Array.isArray(w.all)) return w.all.map(condText).join(' and ');
142
+ if (Array.isArray(w.any)) return w.any.map(condText).join(' or ');
143
+ const ops = { eq: '=', ne: '!=', neq: '!=', lt: '<', lte: '<=', gt: '>', gte: '>=', in: 'in', contains: 'contains' };
144
+ const v = Array.isArray(w.value) ? w.value.join(', ') : w.value;
145
+ return `${w.field} ${ops[w.op] || w.op} ${v}`;
146
+ }
147
+
148
+ const asObj = (v) => { if (v && typeof v === 'object') return v; try { return JSON.parse(v); } catch { return null; } };
149
+
150
+ /** What one step does, from its config, in the terms a person writing a filter needs. */
151
+ function stepWork(n) {
152
+ const c = n.config || {};
153
+ if (n.instrument === 'python') return { runs: 'python', code: c.source };
154
+ if (n.instrument === 'decision_model') {
155
+ const qs = asObj(c.questions) || {};
156
+ return {
157
+ runs: 'decision model (Jev)',
158
+ reads: c.state,
159
+ questions: Object.entries(qs).map(([field, q]) => ({
160
+ field,
161
+ asks: q.instructions,
162
+ type: q.type,
163
+ ...(Array.isArray(q.criteria) ? { levels: q.criteria.map((label, i) => ({ value: i, means: label })) } : {}),
164
+ ...(Array.isArray(q.options) ? { options: q.options } : {}),
165
+ ...(q.type === 'score' && Array.isArray(q.criteria) ? { note: `A weighted score from 0 to ${q.criteria.length - 1}: each level times its probability (1.8 is mostly "${q.criteria[q.criteria.length - 1]}"). Filter with a threshold, e.g. ${field}<${(q.criteria.length - 1) - 0.5}.` } : {}),
166
+ })),
167
+ may_abstain: String(c.allow_abstain) === 'true' || c.allow_abstain === true,
168
+ };
169
+ }
170
+ if (n.instrument === 'llm_judge') {
171
+ const prompt = asObj(c.prompt) || c.prompt;
172
+ return { runs: `LLM judge (${c.model || 'model'})`, prompt };
173
+ }
174
+ return { runs: n.instrument || n.type, config: c };
175
+ }
176
+
177
+ /**
178
+ * trodo evals explain EVAL [--table]
179
+ * The eval as a coding agent needs it to write filters: what it checks, each
180
+ * step in order (what it runs, what it asks or computes, the answers it gives
181
+ * and their options, where each answer leads), the outcomes with pass / fail
182
+ * and reason codes, 30-day numbers, and ready filters.
183
+ */
184
+ async function explain([ref], flags) {
185
+ const api = client(config.resolve(flags));
186
+ const e = await resolveEval(api, ref);
187
+ const [def, stats] = await Promise.all([
188
+ api.get(`/eval3/definitions/${e.id}`),
189
+ api.get(`/eval3/definitions/${e.id}/stats`).catch(() => null),
190
+ ]);
191
+ const L = def.lineage || {};
192
+ const g = (def.current && def.current.graph) || {};
193
+ const nodes = g.nodes || {};
194
+ const nameOf = (id) => (nodes[id] && nodes[id].name) || id;
195
+ // Steps in the order a unit walks them, from the entry.
196
+ const order = [];
197
+ const seen = new Set();
198
+ const queue = [g.entry].filter(Boolean);
199
+ while (queue.length) {
200
+ const id = queue.shift();
201
+ if (seen.has(id) || !nodes[id]) continue;
202
+ seen.add(id);
203
+ order.push(id);
204
+ for (const edge of nodes[id].edges || []) queue.push(edge.to || edge.default);
205
+ }
206
+ for (const id of Object.keys(nodes)) if (!seen.has(id)) order.push(id);
207
+ const outcomes = order.filter((id) => nodes[id].type === 'verdict').map((id) => {
208
+ const c = nodes[id].config || {};
209
+ const sf = asObj(c.score_from);
210
+ return { id, name: nodes[id].name, passed: String(c.passed) === 'true' || c.passed === true, reason_code: c.reason_code || null, ...(sf ? { score_from: `${nameOf(sf.node_id)}.${sf.field}` } : {}) };
211
+ });
212
+ const steps = order.filter((id) => nodes[id].type !== 'verdict').map((id) => {
213
+ const n = nodes[id];
214
+ return {
215
+ id,
216
+ name: n.name,
217
+ kind: n.type,
218
+ ...stepWork(n),
219
+ answers: Object.entries(n.output || {}).map(([field, f]) => ({ field, type: f.type, ...(f.values ? { options: f.values } : {}), ...(f.min != null ? { min: f.min, max: f.max } : {}) })),
220
+ next: (n.edges || []).map((edge) => (edge.default ? { otherwise: nameOf(edge.default) } : { when: condText(edge.when), to: nameOf(edge.to) })),
221
+ };
222
+ });
223
+ const t = (stats && stats.totals) || {};
224
+ const q = (s) => (/\s/.test(s) ? `"${s}"` : s);
225
+ const firstEnum = steps.flatMap((s) => s.answers.filter((a) => a.options).map((a) => `${s.name}.${a.field}=${a.options[0]}`))[0];
226
+ // A number worth a threshold: a judgment's weighted score, else a score on a small scale (not a count's open bound).
227
+ const scored = steps.flatMap((s) => (s.questions || []).filter((qq) => qq.levels).map((qq) => `${s.name}.${qq.field}<${qq.levels.length - 1.5}`));
228
+ const small = steps.flatMap((s) => s.answers.filter((a) => a.type === 'number' && a.max != null && Number(a.max) <= 10).map((a) => `${s.name}.${a.field}<${(Number(a.max) + Number(a.min || 0)) / 2}`));
229
+ const firstNum = scored[0] || small[0];
230
+ const codes = outcomes.filter((o) => o.reason_code).map((o) => o.reason_code);
231
+ const out = {
232
+ id: e.id,
233
+ name: e.name,
234
+ checks: L.description || null,
235
+ target: L.target,
236
+ agent: L.agent_name || null,
237
+ enabled: L.enabled,
238
+ production_version: L.production_version,
239
+ sample_pct: L.sample_pct != null ? Number(L.sample_pct) : null,
240
+ last_30_days: t.runs != null ? {
241
+ verdicts: t.runs, passed: t.passed, failed: t.failed, errored: t.errored,
242
+ pass_rate: t.runs ? Number((t.passed / Math.max(1, t.passed + t.failed)).toFixed(3)) : null,
243
+ avg_score: t.avg_score != null ? Number(Number(t.avg_score).toFixed(3)) : null,
244
+ } : null,
245
+ steps,
246
+ outcomes,
247
+ reason_codes: [...new Set(codes)],
248
+ filters: [
249
+ `--eval ${q(`${e.name}:failed`)}`,
250
+ codes[0] ? `--eval ${q(`${e.name}:reason=${codes[0]}`)}` : null,
251
+ firstEnum ? `--eval ${q(`${e.name}:${firstEnum}`)}` : null,
252
+ firstNum ? `--eval ${q(`${e.name}:${firstNum}`)}` : null,
253
+ `--eval ${q(`${e.name}:marked-wrong`)}`,
254
+ ].filter(Boolean),
255
+ };
256
+ if (!flags.table) { print(out, flags); return; }
257
+ const lines = [];
258
+ lines.push(`${out.name} (${out.target}${out.agent ? `, agent ${out.agent}` : ''}, v${out.production_version}${out.enabled ? '' : ', off'})`);
259
+ if (out.checks) lines.push(` ${out.checks}`);
260
+ if (out.last_30_days) lines.push(` Last 30 days: ${out.last_30_days.verdicts} verdicts, ${out.last_30_days.passed} passed, ${out.last_30_days.failed} failed, pass rate ${(out.last_30_days.pass_rate * 100).toFixed(1)}%, average score ${out.last_30_days.avg_score}`);
261
+ lines.push('', 'Steps');
262
+ steps.forEach((s, i) => {
263
+ lines.push(` ${i + 1}. ${s.name} [${s.id}] — ${s.runs}`);
264
+ for (const qq of s.questions || []) {
265
+ lines.push(` asks ${qq.field}: ${qq.asks || ''}`);
266
+ if (qq.levels) lines.push(` levels: ${qq.levels.map((l) => `${l.value} = ${l.means}`).join('; ')}`);
267
+ if (qq.options) lines.push(` options: ${qq.options.join(', ')}`);
268
+ if (qq.note) lines.push(` ${qq.note}`);
269
+ }
270
+ if (s.prompt) lines.push(` prompt: ${String(typeof s.prompt === 'string' ? s.prompt : JSON.stringify(s.prompt)).replace(/\s+/g, ' ').slice(0, 220)}…`);
271
+ if (s.code) lines.push(` code: ${String(s.code).split('\n').length} lines of Python (--json shows it)`);
272
+ lines.push(` answers: ${s.answers.map((a) => `${a.field} (${a.options ? a.options.join('|') : a.type}${a.min != null ? ` ${a.min}..${a.max}` : ''})`).join(', ')}`);
273
+ for (const nx of s.next) lines.push(` ${nx.when ? `if ${nx.when} → ${nx.to}` : `then → ${nx.otherwise}`}`);
274
+ });
275
+ lines.push('', 'Outcomes');
276
+ for (const o of outcomes) lines.push(` ${o.passed ? 'pass' : 'FAIL'} ${o.name}${o.reason_code ? ` (${o.reason_code})` : ''}`);
277
+ lines.push('', 'Filters', ...out.filters.map((f) => ` ${f}`));
278
+ process.stdout.write(`${lines.join('\n')}\n`);
279
+ }
280
+
281
+ module.exports = { list: listEvals, get: getEval, explain, verdicts, verdict, mark, queue, review, grade, steps, daysOf, condText };
package/lib/http.js CHANGED
@@ -20,14 +20,32 @@ function client({ url, key, fetchImpl = globalThis.fetch }) {
20
20
  const base = `${url}/api/v1`;
21
21
  const headers = { Authorization: `Bearer ${key}`, 'User-Agent': `trodo-cli/${require('../package.json').version}`, Accept: 'application/json' };
22
22
 
23
+ // Reads are retried on a dropped connection or a gateway hiccup (502/503/504):
24
+ // a script pulling thousands of rows should not die on one blip. A write is
25
+ // never retried, so it can never happen twice.
26
+ const READ_POSTS = /^\/traces\/(count|stats|export)$/;
27
+ const isRead = (method, p) => method === 'GET' || (method === 'POST' && READ_POSTS.test(p));
28
+ const RETRY_STATUS = new Set([502, 503, 504]);
29
+ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
30
+
23
31
  async function request(method, p, { query, body } = {}) {
24
32
  const qs = query ? `?${new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => [k, String(v)]))}` : '';
25
- const res = await fetchImpl(`${base}${p}${qs}`, {
26
- method,
27
- headers: body !== undefined ? { ...headers, 'Content-Type': 'application/json' } : headers,
28
- body: body !== undefined ? JSON.stringify(body) : undefined,
29
- });
30
- return res;
33
+ const attempts = isRead(method, p) ? 4 : 1;
34
+ for (let i = 1; ; i += 1) {
35
+ try {
36
+ // eslint-disable-next-line no-await-in-loop
37
+ const res = await fetchImpl(`${base}${p}${qs}`, {
38
+ method,
39
+ headers: body !== undefined ? { ...headers, 'Content-Type': 'application/json' } : headers,
40
+ body: body !== undefined ? JSON.stringify(body) : undefined,
41
+ });
42
+ if (i < attempts && RETRY_STATUS.has(res.status)) { await pause(500 * 2 ** (i - 1)); continue; } // eslint-disable-line no-await-in-loop
43
+ return res;
44
+ } catch (e) {
45
+ if (i >= attempts) throw Object.assign(new Error(`Could not reach ${url} (${e.cause && e.cause.code ? e.cause.code : e.message}).`), { exitCode: 1 });
46
+ await pause(500 * 2 ** (i - 1)); // eslint-disable-line no-await-in-loop
47
+ }
48
+ }
31
49
  }
32
50
 
33
51
  async function json(method, p, opts) {
package/lib/main.js CHANGED
@@ -35,6 +35,7 @@ Traces
35
35
 
36
36
  Evals
37
37
  trodo evals list | get EVAL names, steps (STEP.FIELD), 30-day stats
38
+ trodo evals explain EVAL [--table] what it checks, each step, answers and options, outcomes, filters
38
39
  trodo evals steps EVAL [--since 30d] [--table] the paths verdicts took through the steps, with outcomes
39
40
  trodo evals verdicts EVAL [--failed|--passed] [--since 7d] [--version N]
40
41
  trodo evals verdicts --run ID | --span ID | --conversation ID
@@ -94,7 +95,7 @@ const COMMANDS = {
94
95
  };
95
96
  const ALIASES = { threads: 'conversations', thread: 'conversations', run: 'runs', span: 'spans', dataset: 'datasets', prompt: 'prompts', issue: 'issues', agent: 'agents', eval: 'evals', trace: 'traces' };
96
97
  const GROUPS = {
97
- evals: ['list', 'get', 'steps', 'verdicts', 'verdict', 'mark', 'queue', 'review', 'grade'],
98
+ evals: ['list', 'get', 'explain', 'steps', 'verdicts', 'verdict', 'mark', 'queue', 'review', 'grade'],
98
99
  traces: ['count', 'stats', 'export', 'agents', 'fields', 'values'],
99
100
  runs: ['list', 'search', 'get', 'tree', 'feedback'],
100
101
  spans: ['list', 'search', 'get'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trodo-cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Trodo from the terminal: traces, evals, datasets, prompts, issues and agents for people, scripts and coding agents",
5
5
  "bin": {
6
6
  "trodo": "bin/trodo.js"
@@ -32,6 +32,18 @@ Everything is per team. `trodo teams` lists the teams this terminal is signed in
32
32
  to, `trodo switch TEAM` changes the current one, `--team TEAM` picks one for a
33
33
  single command. Check `trodo whoami` before writing anything.
34
34
 
35
+ ## Understand the evals first
36
+
37
+ ```bash
38
+ trodo evals list --table
39
+ trodo evals explain "EVAL" --table # what it checks, each step, answer options, outcomes, working filters
40
+ ```
41
+
42
+ Read `explain` before writing an `--eval` filter. A decision-model question with
43
+ levels answers a weighted score (1.8 is mostly the top level): use a threshold
44
+ such as `STEP.field<1.5`, never `<2`. A filter matches a unit when any of its
45
+ verdicts matches, so a unit re-scored later can appear under both failed and passed.
46
+
35
47
  ## Find what went wrong
36
48
 
37
49
  ```bash
@@ -96,6 +108,11 @@ trodo evals queue --table ; trodo evals grade ITEM_ID CHOICE
96
108
 
97
109
  ## Datasets and training data
98
110
 
111
+ Do the thinking in your own script: export with the filter, process the JSONL
112
+ locally (each line has input, output, and every verdict with each step's
113
+ answer), then write rows back with `datasets create NAME -f rows.jsonl` or
114
+ `datasets update`. Nothing of yours runs on Trodo.
115
+
99
116
  ```bash
100
117
  trodo traces export --kind spans --eval any:passed --eval any:not-failed --format openai -o sft.jsonl
101
118
  trodo traces export --kind runs --since 7d --include-trace --dir ./runs # one JSON file per run