runbios-mcp 0.2.19 → 0.2.20-dev.283

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/dist/server.js CHANGED
@@ -22,7 +22,7 @@ const RAW_FAILURE_TEXT_FIELDS = [
22
22
  "last_error",
23
23
  "stage_detail",
24
24
  ];
25
- import { LOOP_TOOL_PREFIX, resolveLaunchGates } from "./config.js";
25
+ import { LOOP_TOOL_PREFIX, resolveLaunchGates, withoutGatedLoopIntrospection } from "./config.js";
26
26
  import { VERSION } from "./version.js";
27
27
  export { VERSION };
28
28
  /**
@@ -607,6 +607,58 @@ export function summarizeInferenceStatus(data) {
607
607
  }
608
608
  return shaped;
609
609
  }
610
+ const TRAINING_PHASE_LABELS = {
611
+ dataset_download: "downloading datasets",
612
+ model_download: "downloading the model",
613
+ model_build: "building the model",
614
+ dataset_load: "reading the dataset",
615
+ preprocessing: "tokenizing samples",
616
+ validation_prep: "preparing validation batches",
617
+ model_load: "loading the model into GPU memory",
618
+ first_step: "first training step (compiling GPU kernels)",
619
+ training: "training",
620
+ checkpoint_upload: "saving a checkpoint",
621
+ artifact_commit: "finalizing results",
622
+ };
623
+ /**
624
+ * Summarize what a training job detail payload says about progress RIGHT NOW:
625
+ * the active step, its measured counts and rate, and the remaining time with
626
+ * what it covers. Everything is read from fields in the same response; a
627
+ * value that is absent stays null, never guessed.
628
+ */
629
+ export function summarizeTrainingProgress(data) {
630
+ const payload = asRecord(data);
631
+ if (!payload)
632
+ return data;
633
+ const phases = Array.isArray(payload.phases) ? payload.phases.map(asRecord).filter(Boolean) : [];
634
+ if (phases.length === 0)
635
+ return data;
636
+ const active = phases.find((p) => p.state === "active") ?? null;
637
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
638
+ const phaseId = active ? nonEmptyString(active.phase) : undefined;
639
+ const eta = num(payload.eta_seconds);
640
+ const scope = nonEmptyString(payload.eta_scope);
641
+ const summary = {
642
+ current_phase: phaseId ?? null,
643
+ current_phase_label: phaseId ? (TRAINING_PHASE_LABELS[phaseId] ?? phaseId.replace(/_/g, " ")) : null,
644
+ done_units: active ? num(active.done_units) : null,
645
+ total_units: active ? num(active.total_units) : null,
646
+ units: active ? nonEmptyString(active.units) ?? null : null,
647
+ done_bytes: active ? num(active.done_bytes) : null,
648
+ total_bytes: active ? num(active.total_bytes) : null,
649
+ rate_per_second: active ? num(active.rate) : null,
650
+ phase_eta_seconds: active ? num(active.eta_seconds) : null,
651
+ phases_done: phases.filter((p) => p.state === "done").map((p) => p.phase),
652
+ phases_pending: phases.filter((p) => p.state === "pending").map((p) => p.phase),
653
+ eta_seconds: eta,
654
+ eta_confidence: nonEmptyString(payload.eta_confidence) ?? null,
655
+ eta_scope: scope ?? null,
656
+ note: "Derived by the MCP server from this same response. eta_scope 'until_training' means eta_seconds covers only the "
657
+ + "steps before training; training time is measured from the run's own steps once they run. 'measured' comes from "
658
+ + "this job's live rate, 'historical' from past runs of the same model and GPU. Null means not measured.",
659
+ };
660
+ return { ...payload, progress_summary: summary };
661
+ }
610
662
  /* ─── Server setup ─── */
611
663
  export function createBiosMcpServer(client, hooks, deploymentCaps,
612
664
  // Pre-launch surface gates (src/config.ts). While a gate is on, its creation
@@ -1541,7 +1593,9 @@ create (1-5 choices), and the queue itself stays opt-in.`,
1541
1593
  /* ══════════════════════════════════════════════════════════════════════════ */
1542
1594
  server.tool("introspect_api_key", "Discover what this API key can do. Returns the key's permissions (scopes), the org and workspace it's bound to, allowed MCP tools and SDK methods, rate limits, and expiration. Call this first to understand your access level and available capabilities.", {}, async () => {
1543
1595
  const data = await client.api("/api/api-keys/introspect");
1544
- return json(data);
1596
+ // Where the loop is gated, IAM's loop tools and descriptions are removed:
1597
+ // the answer must not advertise what tools/list hides (src/config.ts).
1598
+ return json(withoutGatedLoopIntrospection(data, launchGates));
1545
1599
  });
1546
1600
  /* ══════════════════════════════════════════════════════════════════════════ */
1547
1601
  /* TOOL: get_wallet_balance */
@@ -2554,7 +2608,7 @@ create (1-5 choices), and the queue itself stays opt-in.`,
2554
2608
  /* ══════════════════════════════════════════════════════════════════════════ */
2555
2609
  /* TOOL: get_training_status */
2556
2610
  /* ══════════════════════════════════════════════════════════════════════════ */
2557
- server.tool("get_training_status", "Get detailed status for a specific training job: progress percentage, current step/total steps, current loss, learning rate, elapsed time, estimated time remaining, GPU utilization, and job configuration. Status 'securing' means a create is still booking a pod (poll until 'booked'); 'booked' means the GPU is secured and the job is starting. 'interrupted' is a self-heal rest state after a started job loses its pod — billing is already stopped and it is distinct from 'failed'; call resume_training_job to continue from the last checkpoint.", {
2611
+ server.tool("get_training_status", "Get detailed status for a specific training job: progress percentage, current step/total steps, current loss, learning rate, elapsed time, estimated time remaining, GPU utilization, and job configuration. Status 'securing' means a create is still booking a pod (poll until 'booked'); 'booked' means the GPU is secured and the job is starting. 'interrupted' is a self-heal rest state after a started job loses its pod — billing is already stopped and it is distinct from 'failed'; call resume_training_job to continue from the last checkpoint. progress_summary names the step running now (downloading, tokenizing samples, loading into GPU memory, first-step compile, training...) with its measured counts, rate and remaining time; eta_scope 'until_training' means eta_seconds covers only the steps before training, 'run' the rest of the run.", {
2558
2612
  job_id: z.string().describe("Training job ID (e.g., 'job_abc123')"),
2559
2613
  }, async ({ job_id }) => {
2560
2614
  validateId(job_id, "job_id");
@@ -2563,7 +2617,7 @@ create (1-5 choices), and the queue itself stays opt-in.`,
2563
2617
  // call to the machine, so it carries the request URL and with it the
2564
2618
  // supplier's hostname. Redact the raw-text fields only; model ids, config
2565
2619
  // and every other value pass through untouched.
2566
- return json(redactFreeTextFields(data, RAW_FAILURE_TEXT_FIELDS));
2620
+ return json(summarizeTrainingProgress(redactFreeTextFields(data, RAW_FAILURE_TEXT_FIELDS)));
2567
2621
  });
2568
2622
  /* ══════════════════════════════════════════════════════════════════════════ */
2569
2623
  /* TOOL: get_training_metrics */
@@ -2918,7 +2972,7 @@ create (1-5 choices), and the queue itself stays opt-in.`,
2918
2972
  });
2919
2973
  return json(data);
2920
2974
  });
2921
- server.tool("loop_import_rows", "Bring data the user ALREADY HAS into the loop — an export from another provider, a spreadsheet of past answers, a set of preference pairs. This does NOT create a training set: it creates conversations, in the same place captured ones live, subject to the same review, tags and pipelines. A row becomes trainable when something says it is good, never because it arrived in a file. Each row is read for what it is: a prompt/chosen/rejected triple becomes a preference pair; an answer plus a yes-or-no becomes a thumbs verdict; a question and an answer waits for review; a question with no answer waits for an answer; a paragraph of prose is refused, because it is not a conversation. A verdict that arrives with the file is kept, but recorded as having come from the user's earlier process rather than from a reviewer here. WHAT COMES BACK, and what to tell the user: imported is what this call created and trace_ids names the conversations from the file that are now in the loop; already_present is rows an earlier import of the same file already had, so they were not stored twice; by_shape counts the shapes this call CREATED, not what was merely recognised and not rows that were already here, so a file that was silently the wrong shape shows up there rather than merely importing fewer rows than expected, and reviewed and needs_review likewise count only what this call wrote; refused with refused_why is rows whose shape could not be read, which the user fixes by changing the file; not_saved with not_saved_rows is rows that were read and understood and then could not be stored, which the file cannot fix; verdicts_not_saved with verdicts_not_saved_rows is rows whose conversation stored and whose VERDICT did not, so those conversations are in the loop waiting for review rather than reviewed. A call with any not_saved or verdicts_not_saved rows fails with code IMPORT_INCOMPLETE and carries all of the same counts. HOW TO RETRY SAFELY: every response carries import_id, the token that call was filed under. Send the SAME rows again with that import_id and anything already imported comes back under already_present instead of being stored a second time, while a verdict that failed to write is attempted again. Do NOT retry without repeating import_id: a call that repeats no token is its own import and stores everything again, deliberately, because two calls carrying the same rows are as likely to be two pages of one export as one call sent twice. SENDING MORE THAN 5000 ROWS: split the file and either give every row its own row_ids entry, after which chunking and ordering stop mattering, or send one import_id with the row_offset each page starts at. Without row_ids, do not send back only the rows not_saved_rows names: a shorter list moves every row after the gap and imports it again.", {
2975
+ server.tool("loop_import_rows", "Bring data the user ALREADY HAS into the loop — an export from another provider, a spreadsheet of past answers, a set of preference pairs. This does NOT create a training set: it creates conversations, in the same place captured ones live, subject to the same review, tags and pipelines. A row becomes trainable when something says it is good, never because it arrived in a file. Each row is read for what it is: a prompt/chosen/rejected triple becomes a correction (chosen is learned from); an answer plus a yes-or-no becomes a thumbs verdict; a question and an answer waits for review; a question with no answer waits for an answer; a paragraph of prose is refused, because it is not a conversation. A verdict that arrives with the file is kept, but recorded as having come from the user's earlier process rather than from a reviewer here. WHAT COMES BACK, and what to tell the user: imported is what this call created and trace_ids names the conversations from the file that are now in the loop; already_present is rows an earlier import of the same file already had, so they were not stored twice; by_shape counts the shapes this call CREATED, not what was merely recognised and not rows that were already here, so a file that was silently the wrong shape shows up there rather than merely importing fewer rows than expected, and reviewed and needs_review likewise count only what this call wrote; refused with refused_why is rows whose shape could not be read, which the user fixes by changing the file; not_saved with not_saved_rows is rows that were read and understood and then could not be stored, which the file cannot fix; verdicts_not_saved with verdicts_not_saved_rows is rows whose conversation stored and whose VERDICT did not, so those conversations are in the loop waiting for review rather than reviewed. A call with any not_saved or verdicts_not_saved rows fails with code IMPORT_INCOMPLETE and carries all of the same counts. HOW TO RETRY SAFELY: every response carries import_id, the token that call was filed under. Send the SAME rows again with that import_id and anything already imported comes back under already_present instead of being stored a second time, while a verdict that failed to write is attempted again. Do NOT retry without repeating import_id: a call that repeats no token is its own import and stores everything again, deliberately, because two calls carrying the same rows are as likely to be two pages of one export as one call sent twice. SENDING MORE THAN 5000 ROWS: split the file and either give every row its own row_ids entry, after which chunking and ordering stop mattering, or send one import_id with the row_offset each page starts at. Without row_ids, do not send back only the rows not_saved_rows names: a shorter list moves every row after the gap and imports it again.", {
2922
2976
  source: z.string().describe("Where this came from, e.g. 'zendesk-2026' or 'gpt4-history'. Required, and becomes the source every imported conversation is filed under — do not use a generic word like 'import', because that produces a corpus nobody can slice later."),
2923
2977
  rows: z.array(z.record(z.string(), z.any())).describe("The rows themselves, already parsed. At most 5000 per call: the call is synchronous. Pass each row in whatever shape it already has rather than reshaping it — the shape is how the server decides whether it carries a verdict."),
2924
2978
  model: z.string().optional().describe("Which model produced these answers, when the rows do not say per-row"),
@@ -2934,7 +2988,7 @@ create (1-5 choices), and the queue itself stays opt-in.`,
2934
2988
  });
2935
2989
  return json(data);
2936
2990
  });
2937
- server.tool("loop_list_traces", "List recorded conversations (samples) for this workspace, newest first. To read a pipeline's data by feedback, pass pipeline with signal ('good', 'bad' or 'corrected': the verdict that decides the sample) or unsignalled true (what still needs review). A bad sample is never trained on. from/to narrow by when a conversation was recorded.", {
2991
+ server.tool("loop_list_traces", "List recorded conversations (samples) for this workspace, newest first. To read a pipeline's data by feedback, pass pipeline with signal ('good', 'bad' or 'corrected': the verdict that decides the sample) or unsignalled true (what still needs review). A bad sample is never trained on. from/to narrow by when a conversation was recorded. A conversation kept in the workspace's own storage carries payload_stored_externally; when its text could not be read just now it also carries payload_unavailable, with messages null and completion empty. That conversation is NOT empty: say its text could not be read right now, do not review it, and read it again later.", {
2938
2992
  source: z.string().optional().describe("Only conversations from this source"),
2939
2993
  model: z.string().optional().describe("Only what THIS model answered, e.g. 'kimi-k3'. The filter distillation is made of — every other filter selects by what was asked or what somebody said about the answer, never by who produced it."),
2940
2994
  origin: z.enum(["captured", "imported"]).optional().describe("captured = the workspace's own model behaviour; imported = a file somebody brought in. Pooling them makes 'train only on what we actually served' unanswerable."),
@@ -2975,28 +3029,25 @@ create (1-5 choices), and the queue itself stays opt-in.`,
2975
3029
  });
2976
3030
  return json(data);
2977
3031
  });
2978
- server.tool("loop_get_trace", "Read one recorded conversation in full, with every verdict anyone has recorded on it. Anything that looked like a credential or a personal detail was removed before it was stored, and redaction_report says what.", { trace_id: z.string().describe("The trace id returned by loop_capture_trace or loop_list_traces") }, async ({ trace_id }) => {
3032
+ server.tool("loop_get_trace", "Read one recorded conversation in full, with every verdict and tag anyone has recorded on it. Anything that looked like a credential or a personal detail was removed before it was stored, and redaction_report says what. payload_unavailable true (messages null, completion empty) means its text is kept in the workspace's own storage and could not be read just now, not that it is empty: say so, do not review it, and read it again later.", { trace_id: z.string().describe("The trace id returned by loop_capture_trace or loop_list_traces") }, async ({ trace_id }) => {
2979
3033
  const data = await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}`);
2980
3034
  return json(data);
2981
3035
  });
2982
3036
  server.tool("loop_delete_trace", "Delete one recorded conversation, with its feedback and tags, for good. It stops counting toward every pipeline that selected it. A version already trained on it keeps what it learned. Confirm with the user first: this cannot be undone.", { trace_id: z.string() }, async ({ trace_id }) => json(await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}`, { method: "DELETE" })));
2983
- server.tool("loop_add_signal", "Record a verdict on a conversation: was the answer right? Feedback is APPEND-ONLY — a second verdict does not replace the first, because two reviewers disagreeing is information worth keeping.\n\nWHICH VERDICT TO USE, because they train different things:\n• accepted (a thumbs up) — the answer was good. It becomes an example to learn from.\n• rejected (a thumbs down) — it was wrong and you have nothing better. This REMOVES the answer from training rather than teaching anything; prefer edited whenever you know the better answer.\n• edited — the model was WRONG and here is what it should have said. The most valuable single action: it produces both halves of a preference pair, so the model learns your answer and learns to avoid its own.\n• gold — the REFERENCE answer for this question, recorded whatever the model happened to say. Unlike edited it asserts nothing about the model, so if it matches what was said no preference is invented — but it still trains SFT, and it stands in as the GRPO reference when no separate ground_truth was given. It is the most reusable feedback there is.\n• scored — a number, with ground_truth when the answer is checkable.", {
3037
+ server.tool("loop_add_signal", "Record a verdict on a conversation: was the answer right? Feedback is APPEND-ONLY — a second verdict does not replace the first, because two reviewers disagreeing is information worth keeping.\n\nWHAT EACH VERDICT DOES (every pipeline fine-tunes on examples):\n• accepted (a thumbs up) — the answer was good. It becomes an example as it stands.\n• rejected (a thumbs down) — it was wrong and you have nothing better. It is LEFT OUT of training and nothing is learned from it; prefer edited whenever you know the better answer.\n• edited — the model was WRONG and here is what it should have said. The most valuable single action: your answer is trained on in place of the model's. Nothing is learned from the answer it replaced.\n• gold — the reference answer for this question, recorded whatever the model happened to say. It trains exactly like edited; it only makes no claim that the model was wrong.", {
2984
3038
  trace_id: z.string(),
2985
- verdict: z.enum(["accepted", "rejected", "edited", "scored", "gold"]).describe("accepted = thumbs up; rejected = thumbs down with nothing better to offer; edited = the model was wrong and you wrote the better answer; gold = the reference answer for this question regardless of what the model said; scored = a number or checkable fact"),
2986
- source: z.enum(["human", "verifier", "judge", "behavioural"]).optional().describe("Who judged. A human outranks a verifier, a verifier outranks a judge, a judge outranks a behavioural hint. Default human."),
3039
+ verdict: z.enum(["accepted", "rejected", "edited", "gold"]).describe("accepted = thumbs up; rejected = thumbs down with nothing better to offer; edited = the model was wrong and you wrote the better answer; gold = the reference answer for this question regardless of what the model said"),
2987
3040
  correction: z.string().optional().describe("REQUIRED when verdict is 'edited' (the answer it should have given) or 'gold' (the reference answer for this question)"),
2988
- score: z.number().optional().describe("REQUIRED when verdict is 'scored'"),
2989
- ground_truth: z.string().optional().describe("The value or fact the answer can be checked against"),
2990
3041
  reason: z.string().optional().describe("Why, in your own words"),
2991
3042
  author: z.string().optional(),
2992
3043
  labels: z.array(z.string()).optional().describe("Which pipeline this feedback feeds, named HERE rather than in a second call: a pipeline trains on every conversation carrying any of its tags, so a tag is the pipeline a conversation goes down. Sent with the verdict it is written in one transaction: either both land or neither does. A separate tagging call is the one that gets skipped, and what it leaves is a reviewed conversation in no pipeline — counted in every 'reviewed' total and selected by nothing."),
2993
- attributes: z.record(z.string(), z.string()).optional().describe("The same, by named dimension: {\"category\": \"billing\"}. Dimensions AND together when a set is built, which is what lets one corpus become a different dataset per task."),
3044
+ attributes: z.record(z.string(), z.string()).optional().describe("The same, by named dimension: {\"category\": \"billing\"}."),
2994
3045
  parent: z.string().optional().describe("The master group the bare labels belong to, so selecting the group picks them up without anybody maintaining a list. Leave it out and a label the conversation already carries keeps the group it has; send an empty string to take the labels out of their group."),
2995
- }, async ({ trace_id, verdict, source, correction, score, ground_truth, reason, author, labels, attributes, parent }) => {
3046
+ }, async ({ trace_id, verdict, correction, reason, author, labels, attributes, parent }) => {
2996
3047
  const data = await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}/signals`, {
2997
3048
  method: "POST",
2998
3049
  body: {
2999
- verdict, source: source ?? "human", correction, score, ground_truth, reason, author,
3050
+ verdict, source: "human", correction, reason, author,
3000
3051
  labels, attributes, parent,
3001
3052
  },
3002
3053
  });
@@ -3089,7 +3140,7 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3089
3140
  body,
3090
3141
  })));
3091
3142
  server.tool("loop_delete_pipeline", "Delete a pipeline for good. An attempt still running is cancelled; versions it made, their checkpoints and whatever serves the app now are untouched, and its samples and tags are kept. Recording from its own tag's source stops: an app still sending there gets captured:false. THIS CANNOT BE UNDONE and the name is spent: the response's retained_name can never be used by another pipeline in this workspace. To stop it for now, loop_update_pipeline with enabled false. Confirm with the user first.", { pipeline_id: z.string() }, async ({ pipeline_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}`, { method: "DELETE" })));
3092
- server.tool("loop_run_pipeline", "Train now: start an attempt without waiting for the schedule. SPEND CONSENT: it trains on real GPUs charged to the wallet, so say so and get a yes. It never skips the minimum: 422 NOT_ENOUGH_SAMPLES carries have and need — read both back. 409 RUN_ACTIVE: an attempt is already running. 409 VERSION_LIMIT_REACHED: raise or remove max_versions. 409 RULE_PAUSED: it is switched off or paused (paused_reason says why). 409 PIPELINE_NEEDS_SAVE: its settings changed without a pipeline save; save it with loop_update_pipeline, then train again. 409 MONTHLY_LIMIT_REACHED: the platform's runaway guard for this month; read resumes_at back. 409 SCORING_CAPPED: the comparison's key is at its monthly cap, so what it trained could not be scored; read resumes_at back. 503 AGENT_COULD_NOT_START or 409 AGENT_OFF: the key every attempt is scored with could not be renewed just then, so nothing was started; train now again in a minute (saving the pipeline does not help). Do not retry any of the others unchanged.", { pipeline_id: z.string() }, async ({ pipeline_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/run`, {
3143
+ server.tool("loop_run_pipeline", "Train now: start an attempt without waiting for the schedule. SPEND CONSENT: it trains on real GPUs charged to the wallet, so say so and get a yes. It never skips the minimum: 422 NOT_ENOUGH_SAMPLES carries have and need — read both back. 409 RUN_ACTIVE: an attempt is already running. An attempt waiting for GPUs to compare (active_run.state waiting_gpus) is the one exception: Train now books its two comparison machines again at once and answers 202; nothing is trained again. 409 VERSION_LIMIT_REACHED: raise or remove max_versions. 409 RULE_PAUSED: it is switched off or paused (paused_reason says why). 409 PIPELINE_NEEDS_SAVE: its settings changed without a pipeline save; save it with loop_update_pipeline, then train again. 409 MONTHLY_LIMIT_REACHED: the platform's runaway guard for this month; read resumes_at back. 409 SCORING_CAPPED: the comparison's key is at its monthly cap, so what it trained could not be scored; read resumes_at back (the pipeline's scoring_capped_until says the same before you try). 409 LIVE_VERSION_STOPPED: the deployment the live version answers from is stopped, paused or deleted, so an attempt could not be compared against it; the user resumes it in Deployments or rolls the version back. 503 AGENT_COULD_NOT_START or 409 AGENT_OFF: the key every attempt is scored with could not be renewed just then, so nothing was started; train now again in a minute (saving the pipeline does not help). Do not retry any of the others unchanged.", { pipeline_id: z.string() }, async ({ pipeline_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/run`, {
3093
3144
  method: "POST",
3094
3145
  body: {},
3095
3146
  })));
@@ -3108,7 +3159,7 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3108
3159
  method: "POST",
3109
3160
  body,
3110
3161
  })));
3111
- server.tool("loop_list_training_runs", "Every attempt of every pipeline, newest first: what state it is in, what it has spent, and what the comparison said. A run sitting in 'awaiting_review' is waiting for a PERSON — it holds a candidate that is being paid for by the minute, so surface those first when the user asks what needs attention.", {
3162
+ server.tool("loop_list_training_runs", "Every attempt of every pipeline, newest first: what state it is in, what it has spent, and what the comparison said. A run sitting in 'awaiting_review' is waiting for a PERSON — it holds a candidate that is being paid for by the minute, so surface those first when the user asks what needs attention. A run in 'waiting_gpus' trained and is waiting for GPUs to run both models side by side: each try can bill the machine that got a GPU for up to 20 minutes while it waits for the other, nothing bills between tries, it keeps what it trained, tries again after 30 minutes, then 1, 2 and 4 hours, and ends after four tries or 24 hours (its last_reason says when; a last_reason opening 'waiting for room in this workspace to compare' means the workspace is at its limit of running deployments, and stopping a deployment lets it run), and Train now tries again at once. billed_candidate_up_to true means billed_candidate_cents is 'up to': a comparison machine was charged in it, counted from its first boot while the platform bills from its first answer (and at the hourly cap when no price was reported), so say 'up to', never 'spent'.", {
3112
3163
  rule_id: z.string().optional().describe("Only this pipeline's attempts (its id, from loop_list_pipelines)"),
3113
3164
  state: z.string().optional().describe("Only runs in this state, e.g. 'awaiting_review'"),
3114
3165
  limit: z.number().int().min(1).max(200).optional(),
@@ -3116,11 +3167,11 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3116
3167
  }, async ({ rule_id, state, limit, offset }) => json(await client.api("/api/loop/training-runs", {
3117
3168
  params: { rule_id, state, limit: limit?.toString(), offset: offset?.toString() },
3118
3169
  })));
3119
- server.tool("loop_get_training_run", "One run in full, with the timeline of every state it passed through and why, links to the job, the candidate, the datasets and the comparison, and available_actions — which says what THIS reader may actually do next. Use available_actions rather than guessing from the state: a run can look promotable and not be, because the consent moved or the candidate is already gone.", { run_id: z.string() }, async ({ run_id }) => json(await client.api(`/api/loop/training-runs/${encodeURIComponent(run_id)}`)));
3120
- server.tool("loop_list_pipelines", "Every pipeline in the workspace: the series of versions it has produced (versions are attempts that were put live; attempts[] lists every attempt, winner or not), which one serves the app today (live_version, the same as champion_version, null when what serves is not one of its versions), how each version did against the champion of its day and on the standing benchmark, versions_made against max_versions, the run in flight and which version it will become, and status. Read-only. When the user asks 'is this getting better' or 'what is it doing', answer from this, and read status back honestly: needs_review and needs_funds are waiting on THEM (a decision, a top-up), paused names why in paused_reason or needs_consent, complete means the version limit is reached, and running is the platform working. next_reason is the pipeline's own sentence about what it is waiting for. month_spent_cents against monthly_ceiling_cents is what decides whether the next run may start; month_spent_cents is what runs started this UTC month have cost at most (a run still in progress counts at its ceilings), so say 'up to', never 'spent'. A pipeline trains only on new samples: it never retrains the conversations an attempt already had with a recipe changed. The list is a page of 100, newest first: total is how many pipelines the workspace has and truncated is true when more exist after this page. When truncated, say it is the newest of total, and pass offset (this page's offset plus its length) for the next page.", { offset: z.number().int().min(0).optional().describe("How many of the newest pipelines to skip") }, async ({ offset }) => json(await client.api("/api/loop/pipelines", {
3170
+ server.tool("loop_get_training_run", "One run in full: the base model it trained (base_model_id, model_family) and the recipe, the rows it trained on and held back, its GPU time and what it was billed (billed_candidate_up_to true means billed_candidate_cents is 'up to': a comparison machine was charged in it, counted from its first boot while the platform bills from its first answer, and at the hourly cap when no price was reported -- say 'up to', never 'spent'), the timeline of every state it passed through and why, links to the job, the candidate and the comparison, and available_actions — which says what THIS reader may actually do next. Use available_actions rather than guessing from the state: a run can look promotable and not be, because the consent moved or the candidate is already gone.", { run_id: z.string() }, async ({ run_id }) => json(await client.api(`/api/loop/training-runs/${encodeURIComponent(run_id)}`)));
3171
+ server.tool("loop_list_pipelines", "Every pipeline in the workspace: the series of versions it has produced (versions are attempts that were put live; attempts[] lists every attempt, winner or not), which one serves the app today (live_version, the same as champion_version, null when what serves is not one of its versions), how each version did against the champion of its day and on the standing benchmark, versions_made against max_versions, the run in flight and which version it will become, and status. Read-only. When the user asks 'is this getting better' or 'what is it doing', answer from this, and read status back honestly: needs_review and needs_funds are waiting on THEM (a decision, a top-up), paused names why in paused_reason (consent_invalid means its settings changed since it was last saved: saving it again starts it), complete means the version limit is reached, and running is the platform working -- including an attempt whose active_run.state is waiting_gpus: it trained, but no GPUs were free to run the new model and the one it is compared with side by side, so both machines were stopped; each try can bill the machine that got a GPU for up to 20 minutes while it waits for the other, nothing bills between tries, it keeps what it trained, tries again after 30 minutes, then 1, 2 and 4 hours, and ends after four tries or 24 hours (its last_reason says when; a last_reason opening 'waiting for room in this workspace to compare' means the workspace is at its limit of running deployments, and stopping a deployment lets it run), and Train now tries again at once. cost_up_to true on an attempt, active_run or a version means its cost_cents is 'up to', the most it can have cost: a comparison machine was charged in it, counted from its first boot while the platform bills it from its first answer (and at the hourly cap when no price was reported), so say 'up to', never 'spent'. next_reason is the pipeline's own sentence about what it is waiting for. scoring_capped_until, when set, is when the workspace's scoring key comes off its monthly cap: until then nothing trains and Train now answers SCORING_CAPPED, so say so instead of offering Train now. month_spent_cents against monthly_ceiling_cents is what decides whether the next run may start; month_spent_cents is what runs started this UTC month have cost at most (a run still in progress counts at its ceilings), so say 'up to', never 'spent'. A pipeline trains only on new samples: it never retrains the conversations an attempt already had with a recipe changed. The list is a page of 100, newest first: total is how many pipelines the workspace has and truncated is true when more exist after this page. When truncated, say it is the newest of total, and pass offset (this page's offset plus its length) for the next page.", { offset: z.number().int().min(0).optional().describe("How many of the newest pipelines to skip") }, async ({ offset }) => json(await client.api("/api/loop/pipelines", {
3121
3172
  params: { offset: offset ? offset.toString() : undefined },
3122
3173
  })));
3123
- server.tool("loop_get_pipeline", "One pipeline in full, by its id: every version with its verdict, the decision a person made, its win rate and benchmark score, what it cost and the recipe it trained on (an old version whose trigger was 'variant' retrained the same conversations with one setting changed, named in recipe_note; variants are retired), plus the run in flight, and what this month's runs have cost at most (month_spent_cents, an upper bound, not an exact spend) against the monthly limit. Read-only: promote, reject and roll back stay on the run tools, and its settings on loop_update_pipeline. minimums says how many samples it has against what the next attempt needs. Only compare benchmark scores whose benchmark_comparable is true; the others were measured on a different yardstick. versions[] are WINNERS ONLY -- attempts that were put live, numbered v1, v2 ... in the order they went live, each with its attempt, base_model_id and model_family -- and live_version is the one serving now; attempts[] is every training run the pipeline made, winner or not, with its outcome (became_version, not_better, inconclusive, stopped, failed, running, waiting_for_review) and trigger (data, schedule, manual, or challenger for the second model trained on the same data). Nothing in flight is a version yet.", { rule_id: z.string().describe("The pipeline's id, from loop_list_pipelines") }, async ({ rule_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(rule_id)}`)));
3174
+ server.tool("loop_get_pipeline", "One pipeline in full, by its id: every version with its verdict, the decision a person made, its win rate and benchmark score, what it cost and why it was made (trigger, in the same words as attempts[]), plus the run in flight, and what this month's runs have cost at most (month_spent_cents, an upper bound, not an exact spend) against the monthly limit. Read-only: promote, reject and roll back stay on the run tools, and its settings on loop_update_pipeline. minimums says how many samples it has against what the next attempt needs. scoring_capped_until, when set, is when the workspace's scoring key comes off its monthly cap: until then nothing trains and Train now answers SCORING_CAPPED with that time as resumes_at. Only compare benchmark scores whose benchmark_comparable is true; the others were measured on a different yardstick. versions[] are WINNERS ONLY -- attempts that were put live, numbered v1, v2 ... in the order they went live, each with its attempt, base_model_id and model_family -- and live_version is the one serving now; attempts[] is every training run the pipeline made, winner or not, with its outcome (became_version, not_better, inconclusive, stopped, failed, running, waiting_for_review) and trigger (data, schedule, manual, or challenger for the second model trained on the same data). Nothing in flight is a version yet. cost_up_to true on attempts[], active_run or versions[] means that cost_cents is 'up to', the most it can have cost: a comparison machine was charged in it, counted from its first boot while the platform bills from its first answer (and at the hourly cap when no price was reported), so say 'up to', never 'spent'. An active_run.state of waiting_gpus means the attempt trained and is waiting for GPUs to run the new model and the one it is compared with side by side: each try can bill the machine that got a GPU for up to 20 minutes while it waits for the other, nothing bills between tries, it keeps what it trained, tries again after 30 minutes, then 1, 2 and 4 hours, and ends after four tries or 24 hours (its last_reason says when; a last_reason opening 'waiting for room in this workspace to compare' means the workspace is at its limit of running deployments, and stopping a deployment lets it run), and Train now tries again at once.", { rule_id: z.string().describe("The pipeline's id, from loop_list_pipelines") }, async ({ rule_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(rule_id)}`)));
3124
3175
  server.tool("loop_list_pipeline_benchmarks", "The benchmarks a pipeline is measured on, in priority order (1 is the primary), then the ones it stopped using, and its promotion policy. Every attempt is replayed on each attached benchmark; the policy decides which of those measurements make it a version.", { pipeline_id: z.string() }, async ({ pipeline_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/benchmarks`)));
3125
3176
  server.tool("loop_attach_pipeline_benchmark", "Measure a pipeline on one more benchmark (from loop_list_benchmarks or loop_create_benchmark), last unless priority is given; priority 1 makes it the primary. Applies from the next attempt; older versions are not re-measured and show not_measured for it. 409 BENCHMARK_ALREADY_ATTACHED or BENCHMARK_RETIRED.", {
3126
3177
  pipeline_id: z.string(),
@@ -3166,7 +3217,7 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3166
3217
  method: "POST",
3167
3218
  body: { reason },
3168
3219
  })));
3169
- server.tool("loop_rollback_training_run", "Undo a promotion: point the serving name back at what answered before it. Available for 30 days, after which the answer is ROLLBACK_WINDOW_CLOSED and the way back is a new run. Tell the user what the name will be serving once this lands — the previous target is named on the run.", {
3220
+ server.tool("loop_rollback_training_run", "Undo a promotion: point the serving name back at what answered before it. Available for 30 days, after which the answer is ROLLBACK_WINDOW_CLOSED and the way back is a new run. 409 RUN_ACTIVE: another attempt is being compared with this version right now; roll back once that comparison finishes, or cancel the attempt first. Tell the user what the name will be serving once this lands — the previous target is named on the run.", {
3170
3221
  run_id: z.string(),
3171
3222
  reason: z.string().optional().describe("Why. It goes on the run's timeline."),
3172
3223
  }, async ({ run_id, reason }) => json(await client.api(`/api/loop/training-runs/${encodeURIComponent(run_id)}/rollback`, {