runbios-mcp 0.4.0 → 0.5.0-dev.338

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
@@ -7,7 +7,7 @@ import { buildGPUOptionsParams, buildTrainingCreateBody, TRAINING_ADAPTERS, TRAI
7
7
  import { buildDeploymentBody, buildDeploymentGPUOptionsParams, buildDeploymentPolicyBody, DEPLOYMENT_GPU_TIERS, DEPLOYMENT_SOURCE_TYPES, } from "./deployment-contract.js";
8
8
  import { buildInferenceBody } from "./inference-contract.js";
9
9
  import { redactBuildIdentity, redactBuildIdentityDeep, redactFreeTextFields, sanitizeArchitectureRegistry, stripBuildIdentity, } from "./redaction.js";
10
- import { gpuRejectionCodeFor, gpuRejectionInstruction, gpuRejectionStatusFor, isPermanentGpuCode, } from "./api-client.js";
10
+ import { gpuRejectionCodeFor, gpuRejectionInstruction, gpuRejectionStatusFor, ApiHttpError, InferenceHttpError, isPermanentGpuCode, } from "./api-client.js";
11
11
  /**
12
12
  * Job/deployment fields whose upstream value is RAW text rather than a curated
13
13
  * sentence. `stop_last_error` is the proven one: the training stop saga stores
@@ -711,11 +711,11 @@ export function loopSwitchedOffDescription(name, description) {
711
711
  const note = loopSwitchedOffNote(name);
712
712
  switch (name) {
713
713
  case "loop_list_pipelines":
714
- return `Every Conscious Loop pipeline in the workspace, with what each still runs: its live version (a deployment that keeps answering and billing until it is stopped or the pipeline is deleted) and any attempt in flight (active_run). ${note} To stop paying: loop_update_pipeline with enabled false stops new attempts, loop_cancel_training_run stops an attempt in flight (active_run.run_id), and loop_delete_pipeline stops its live version and deletes it for good.`;
714
+ return `Every Conscious Loop pipeline in the workspace, with what each still runs: its live version (a deployment that keeps answering and billing until it is stopped or the pipeline is deleted) and any training in progress (active_run). ${note} To stop paying: loop_update_pipeline with enabled false stops new trainings, loop_cancel_training_run stops a training in progress (active_run.run_id), and loop_delete_pipeline stops its live version and deletes it for good.`;
715
715
  case "loop_get_pipeline":
716
- return `One Conscious Loop pipeline by its id (rule_id, from loop_list_pipelines): its revision, whether it is switched on, its live version and any attempt in flight (active_run, whose run_id loop_cancel_training_run takes). ${note}`;
716
+ return `One Conscious Loop pipeline by its id (rule_id, from loop_list_pipelines): its revision, whether it is switched on, its live version and any training in progress (active_run, whose run_id loop_cancel_training_run takes). ${note}`;
717
717
  case "loop_update_pipeline":
718
- return `Switch a Conscious Loop pipeline off, so it starts no new attempt. An attempt already in flight goes on unless loop_cancel_training_run stops it, and its live version keeps answering (and billing) until it is stopped or loop_delete_pipeline deletes the pipeline. Pass expected_revision (the revision loop_get_pipeline shows) to be refused REVISION_MISMATCH rather than overwrite someone else's edit. ${note}`;
718
+ return `Switch a Conscious Loop pipeline off, so it starts no new training. A training already in progress goes on unless loop_cancel_training_run stops it, and its live version keeps answering (and billing) until it is stopped or loop_delete_pipeline deletes the pipeline. Pass expected_revision (the revision loop_get_pipeline shows) to be refused REVISION_MISMATCH rather than overwrite someone else's edit. ${note}`;
719
719
  default:
720
720
  return `${description} ${note}`;
721
721
  }
@@ -877,40 +877,56 @@ launchGates = resolveLaunchGates(), allowedTools) {
877
877
  ? launchGates.loopSwitchedOff === true
878
878
  ? `## Conscious Loop: switched off
879
879
  The Conscious Loop is switched off on this platform: nothing new is recorded or trained. So that nothing a member started keeps charging them, five tools still work:
880
- - loop_list_pipelines / loop_get_pipeline - Find the pipelines and see what each still runs (its live version's deployment, an attempt in flight).
880
+ - loop_list_pipelines / loop_get_pipeline - Find the pipelines and see what each still runs (its live version's deployment, a training in progress).
881
881
  - loop_update_pipeline - Switch a pipeline off: send enabled false (with its expected_revision); no other change is accepted while the loop is switched off.
882
882
  - loop_delete_pipeline - Delete a pipeline for good: its live version's deployment is stopped unless another name answers from it. Confirm with the user first.
883
- - loop_cancel_training_run - Cancel an attempt still running.
883
+ - loop_cancel_training_run - Cancel a training still running.
884
884
 
885
885
  ${aliasGuideSection}`
886
886
  : aliasGuideSection
887
887
  : `## Conscious Loop MCP Tools
888
- The loop turns what a deployed model actually did into what it learns next.
889
- NOTHING IS RECORDED until a source is switched on — capture is a consent
890
- decision, not a default, and these tools cannot bypass it. Creating a pipeline
891
- is that decision for one source: the one named by the pipeline's own tag
892
- (own_tag), which records what is sent to it stamped with that tag, until the
893
- pipeline is deleted. Every other source is switched on with loop_set_capture.
894
-
895
- 1. loop_set_capture / loop_list_capture_settings - Turn recording on or off for a source, choose how long conversations are kept, and the tags stamped on everything it records (a tag is how live traffic reaches a pipeline). A pipeline's own tag is already on; any other source records nothing until this is done.
896
- 2. loop_capture_trace / loop_import_rows - Record one exchange as it happens (what the model was asked, what it answered, any tool calls), whoever served the model — Run BiOS, another provider, or your own servers. Send it to a pipeline's own_tag as the source, add labels to feed other pipelines too, and give feedback on the trace_id it returns. loop_import_rows brings in data that already exists instead: an export, a spreadsheet of past answers, preference pairs. Imported rows arrive as conversations NEEDING REVIEW, never as finished training data.
897
- 3. loop_list_traces / loop_get_trace / loop_delete_trace - Read what has been recorded; pipeline=<id> lists one pipeline's samples, each with its state there (held back, learned, trained, waiting or left out). Where the platform scores samples (a pipeline's auto_scoring is not null), samples nobody reviewed are scored by the judge while a pipeline is short of its next attempt's samples, and those scoring 0.8 or more are trained on; judge=picked|below_bar|unscored splits them, and a person's feedback always decides. Deleting a conversation a pipeline's live version learned from rebuilds that pipeline's model from the base without it (the answer's rebuilds names each). Credentials and personal details are stripped before storage, never after.
898
- 4. loop_add_signal / loop_list_signals - Feedback: accepted (good), rejected (bad), or edited with the corrected answer. Append-only. A correction is the most valuable feedback there is.
888
+ The Conscious Loop keeps improving a model from an app's conversations and
889
+ the user's feedback. Words to use with the user: a PIPELINE is one use case;
890
+ its pipeline ID (own_tag, e.g. support-replies) is made from its name when it
891
+ is created and never changes. A SAMPLE is one saved conversation; its sample
892
+ ID comes back in the reply's X-Loop-Sample-Id header and feedback names it. A
893
+ TRAINING is one numbered try at a better model (#1, #2 ...; the API's names
894
+ say 'attempt', and its id is run_id). A
895
+ VERSION is a training that went live (v1, v2 ... with no limit), and the LIVE
896
+ VERSION is the version the app gets answers from right now: it answers to the
897
+ model name loop-<pipeline ID>, and each new version takes that name over.
898
+ Every training is tested before it can go live: the AUTOMATIC TEST (questions
899
+ from the pipeline's own samples it never trained on) and any BENCHMARKS (the
900
+ user's fixed sets of questions). The JUDGE is an AI that compares each answer
901
+ with the right answer.
902
+
903
+ WHAT IS SAVED is a consent decision, not a default, and these tools cannot
904
+ bypass it. A call that carries the pipeline ID in the X-Loop-Pipeline header
905
+ (loop_chat does) is saved to that pipeline: the header is the consent for that
906
+ one call. Creating a pipeline switches on recording for the source named by its
907
+ pipeline ID, so loop_capture_trace to it is saved too, until the pipeline is
908
+ deleted. Every other source records nothing until loop_set_capture switches it
909
+ on.
910
+
911
+ 1. loop_set_capture / loop_list_capture_settings - Turn recording on or off for a source, choose how long conversations are kept, and the tags stamped on everything it records (a tag is how live traffic reaches a pipeline). The source named by a pipeline's ID is already on; any other source records nothing until this is done.
912
+ 2. loop_chat / loop_capture_trace / loop_import_rows - Send conversations. loop_chat asks a model with the pipeline ID header and saves the conversation as a sample in one call (a real, billed model call): it returns the answer and its sample_id. Before the pipeline's first version pass model, the model the app uses now; after it, leave model out and the live version answers. loop_capture_trace records one exchange that already happened (what the model was asked, what it answered, any tool calls), whoever served the model — Run BiOS, another provider, or the user's own servers: send it with the pipeline ID (own_tag) as the source, add labels to feed other pipelines too, and give feedback on the trace_id it returns. loop_import_rows brings in data that already exists instead: a file from another provider, a spreadsheet of past answers, preference pairs. Imported conversations arrive NEEDING REVIEW, never as finished training data.
913
+ 3. loop_list_traces / loop_get_trace / loop_delete_trace - Read what has been saved; pipeline=<id> lists one pipeline's samples, each with its state there (kept aside for the automatic test, learned, trained on by a training that did not become a version, waiting or left out). Where the platform scores samples (a pipeline's auto_scoring is not null), samples nobody reviewed are scored by the judge while a pipeline is short of its next training's samples, and those scoring 0.8 or more are trained on; judge=picked|below_bar|unscored splits them, and a person's feedback always decides. Deleting a conversation a pipeline's live version learned from rebuilds that pipeline's model from the original model without it (the answer's rebuilds names each). Credentials and personal details are stripped before storage, never after.
914
+ 4. loop_add_signal / loop_list_signals - Feedback on a sample ID: good (learned as it is), bad (wrong: not learned), or corrected with the right answer (the right answer is learned instead). Feedback is added, never replaced: a second one sits beside the first, and the newest from a person decides. A correction is the most valuable feedback there is. Training that learns from rankings or rewards is not available yet, so bad with no right answer teaches nothing.
899
915
  5. loop_label_trace / loop_remove_label / loop_list_labels / loop_get_trace_pipelines - Tags. A pipeline trains on every conversation carrying any of its tags, and a conversation can carry several and feed several pipelines; loop_get_trace_pipelines says which pipelines one conversation feeds.
900
- 6. loop_list_datasets / loop_get_dataset / loop_preview_dataset / loop_create_dataset / loop_update_dataset / loop_delete_dataset - Datasets: saved sets of conditions (tags, any or all of them; feedback; where samples came from; which model answered; when they were recorded) that select samples live, so samples that match later join. A pipeline trains on its own tags OR the datasets it adds (loop_update_pipeline datasets), narrowed by its conditions. loop_preview_dataset counts what conditions select before anything is saved. Changing a dataset changes what every pipeline using it trains on from its next attempt.
901
- 7. loop_stats / loop_get_metrics_history - How much is recorded and reviewed (the workspace's totals: samples recorded lately, every sample by its feedback, and how many pipelines are training, waiting on the user, serving or paused), and how the attempts and the feedback moved over time.
902
- 8. loop_list_models - The base models a pipeline can train, and the platform's fixed sample minimums (floors). The only list a pipeline's model may come from.
903
- 9. loop_create_pipeline - One use case: a name, a model (optionally a challenger model), its tags, when to train (optionally a schedule; the sample minimums are fixed by the platform) and how a new version is promoted. It switches on the source named by its own tag, so an app can send samples at once. SPEND CONSENT: saving it authorises attempts on the workspace's wallet whenever its data has grown enough; the platform chooses the GPUs, and what is charged is the GPU time its attempts use and, where the platform scores samples, the judge's scoring of unreviewed samples, shown on the attempt it was for. Say so and wait for a yes.
904
- 10. loop_list_pipelines / loop_get_pipeline / loop_list_pipeline_attempts / loop_update_pipeline / loop_delete_pipeline - Each pipeline's versions (winners only, v1, v2 ... numbers only go up), its attempts and how each ended (the latest on the pipeline, every one a page at a time with loop_list_pipeline_attempts, each with an explanation in plain words), the live version and whether its deployment answers (live_deployment), status and next_reason, and its settings. Deleting a pipeline stops its live version's deployment (unless another pipeline or model name answers from it too) and deletes its history.
916
+ 6. loop_list_datasets / loop_get_dataset / loop_preview_dataset / loop_create_dataset / loop_update_dataset / loop_delete_dataset / loop_export_dataset - Datasets: saved sets of conditions (tags, any or all of them; feedback; where samples came from; which model answered; when they were recorded) that select samples live, so samples that match later join. A pipeline trains on its tags (its pipeline ID among them) OR the datasets it adds (loop_update_pipeline datasets), narrowed by its conditions. loop_preview_dataset counts what conditions select before anything is saved. Changing a dataset changes what every pipeline using it trains on from its next training. Each dataset says who saved it and which trainings learned from it; loop_export_dataset reads its samples back in the import's format, with their feedback.
917
+ 7. loop_stats / loop_get_metrics_history - How much is recorded and reviewed (the workspace's totals: samples recorded lately, every sample by its feedback, and how many pipelines are training, waiting on the user, serving or paused), and how the trainings and the feedback moved over time.
918
+ 8. loop_list_models - The models a pipeline can train, and the platform's fixed sample minimums (floors). The only list a pipeline's model may come from; unavailable says, in one plain sentence per model, why one is not offered.
919
+ 9. loop_create_pipeline - One use case: a name, a model (optionally a second model, a different one), its tags, when to train (optionally a schedule; the sample minimums are fixed by the platform) and how a new version goes live. It switches on the source named by its pipeline ID, so an app can send samples at once. SPEND CONSENT: saving it authorises trainings on the workspace's wallet whenever its data has grown enough; the platform chooses the GPUs, and what is charged is the GPU time its trainings use and, where the platform scores samples, the judge's scoring of unreviewed samples, shown on the training it was for. Say so and wait for a yes.
920
+ 10. loop_list_pipelines / loop_get_pipeline / loop_list_pipeline_attempts / loop_update_pipeline / loop_delete_pipeline - Each pipeline's versions (only trainings that went live: v1, v2 ...: version version numbers count up; nothing untested goes live any more, and each version's tested says whether it was), its trainings and how each ended (the latest on the pipeline, every one a page at a time with loop_list_pipeline_attempts, each with an explanation in plain words), the live version and whether its deployment answers (live_deployment), status and next_reason, and its settings. Deleting a pipeline stops its live version's deployment (unless another pipeline or model name answers from it too) and deletes its history.
905
921
  11. loop_run_pipeline / loop_import_into_pipeline - Train now (skips the schedule, never the minimum), and bring a file in as this pipeline's samples.
906
- 12. loop_create_benchmark / loop_list_benchmarks / loop_get_benchmark / loop_list_benchmark_items / loop_get_benchmark_history / loop_retire_benchmark / loop_get_benchmark_run / loop_list_benchmark_run_items - Fixed sets of conversations every attempt is replayed on, so "is it getting better" has a yardstick that does not move, and each replay's report: its scores, how many conversations each model passed (score 0.70 or more), and every conversation behind them.
907
- 13. loop_list_pipeline_benchmarks / loop_attach_pipeline_benchmark / loop_move_pipeline_benchmark / loop_remove_pipeline_benchmark / loop_compare_versions / loop_compare_attempts - A pipeline's benchmarks in priority order (1 is the primary), its versions side by side (v1 against v5), and any two attempts side by side, put live or not, conversation by conversation on the held-back samples both were scored on.
908
- 14. loop_list_training_runs / loop_get_training_run - Every attempt of every pipeline (result filters by what became of it; each row's attempt is as its pipeline's own list says it): its timeline, why it went live or did not (promotion, and verdict_checks: every bar its comparison needed, what it got and whether it was met), what each part cost, and available_actions — what this reader may actually do next, rather than what the state looks like it allows.
909
- 15. loop_get_evaluation / loop_list_evaluation_items - The comparison behind a verdict: the win rate, the pass rates (the share of conversations each model passed: score 0.70 or more) and the per-dimension means on the held-back samples, and the paired conversations themselves, with the judge's reasons. Read the warnings out, not just the verdict.
910
- 16. loop_promote_training_run / loop_reject_training_run / loop_rollback_training_run / loop_cancel_training_run - The decision. Promotion makes an attempt the live version and changes what real customers get; it is undoable for 30 days.
911
- 17. set_inference_alias / list_inference_aliases / delete_inference_alias - The re-pointable public handles a promotion writes: a name customers call that can be moved to different weights without them changing anything.
912
-
913
- END TO END: capture what a model was asked and answered (or import it), say whether each answer was good, bad or corrected, and tag it; create ONE pipeline per use case. When enough new samples carry its tags to meet the platform's fixed minimum (loop_list_models floors; the pipeline's minimums say how many are still needed) and its schedule is due, if it has one, the platform trains an attempt, compares it with the live version on held-back samples it never trains on and on the pipeline's benchmarks, and promotes it by the promotion policy: when it is better, or when a member approves it. A rebuild after a member deleted a conversation the live version learned goes live unless it is clearly worse, under every policy but manual, where it waits for a member like any attempt; so a version need not have beaten the one before it. Everything before the pipeline costs nothing; saving a pipeline is where money starts, which is why it is spend consent. Two fields carry the state a user actually asks about and BOTH must be read back in plain words rather than summarised as 'fine': status says whether the platform is working or the user has to act, and next_reason says what a quiet pipeline is waiting for, because a pipeline waiting for samples looks identical to a broken one.`;
922
+ 12. loop_create_benchmark / loop_list_benchmarks / loop_get_benchmark / loop_list_benchmark_items / loop_get_benchmark_history / loop_retire_benchmark / loop_get_benchmark_run / loop_list_benchmark_run_items / loop_benchmark_questions - Benchmarks: fixed sets of the user's questions with right answers that every training is tested on, so "is it getting better" has a yardstick that does not move, and each benchmark run's report: its scores, how many questions each model got right (score 0.70 or more), and every question behind them. loop_benchmark_questions shows each question with how a pipeline's recent trainings did on it.
923
+ 13. loop_list_pipeline_benchmarks / loop_attach_pipeline_benchmark / loop_move_pipeline_benchmark / loop_remove_pipeline_benchmark / loop_compare_versions / loop_compare_attempts - A pipeline's benchmarks in priority order (1 is the primary), its versions side by side (v1 against v5), and any two trainings side by side, live or not, question by question on the automatic-test questions both were scored on.
924
+ 14. loop_list_training_runs / loop_get_training_run / loop_get_training_curve - Every training of every pipeline (result filters by what became of it; each item's 'attempt' is the training as its pipeline's own list says it): its timeline, why it went live or did not (its going-live record, and verdict_checks: every bar its test needed, what it got and whether it was met), what each part cost, and available_actions — what this reader may actually do next, rather than what the state looks like it allows. loop_get_training_curve shows how one went while it trained, step by step, and the step it kept.
925
+ 15. loop_get_evaluation / loop_list_evaluation_items - The automatic test behind a result: how many questions each model got right (score 0.70 or more), how often the new answer scored higher, the per-dimension means, and the questions themselves, with the judge's reasons. Read the warnings out, not just the result.
926
+ 16. loop_promote_training_run / loop_reject_training_run / loop_rollback_training_run / loop_cancel_training_run / loop_retest_training_run - The decision. Putting a training live makes it the live version and changes what real customers get; going back to the version before is possible for 30 days. A training that was never tested (tested false) can never go live (NOT_TESTED); loop_retest_training_run tests it again.
927
+ 17. set_inference_alias / list_inference_aliases / delete_inference_alias - The re-pointable public handles a version going live writes: a name customers call that can be moved to different weights without them changing anything.
928
+
929
+ END TO END: create ONE pipeline per use case; send the app's conversations with its pipeline ID (loop_chat, or the header in the app's own calls; or capture/import them), and say whether each answer was good, bad or corrected. When enough new samples carry its tags to meet the platform's fixed minimum (loop_list_models floors; the pipeline's minimums say how many are still needed) and its schedule is due, if it has one, the platform starts a training, tests it against the live version (before v1: the original model) on the automatic test and on the pipeline's benchmarks, and puts it live by the pipeline's going-live choice: when it is better, or when a member approves it. A rebuild after a member deleted a conversation the live version learned goes live unless it is clearly worse, under every choice but manual, where it waits for a member like any training; so a version need not have done better than the one before it. Everything before the pipeline costs nothing but model calls; saving a pipeline is where training money starts, which is why it is spend consent. Two fields carry the state a user actually asks about and BOTH must be read back in plain words rather than summarised as 'fine': status says whether the platform is working or the user has to act, and next_reason says what a quiet pipeline is waiting for, because a pipeline waiting for samples looks identical to a broken one.`;
914
930
  const server = {
915
931
  tool(name, description, paramsSchema, cb) {
916
932
  if (allowedTools && !allowedTools.has(name))
@@ -3029,13 +3045,16 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3029
3045
  /* preset on purpose: what they return is raw prompts and completions, not */
3030
3046
  /* catalog metadata, so a key holds them only by an explicit grant. */
3031
3047
  /* */
3032
- /* NOTHING IS RECORDED until a source is switched on: by loop_set_capture, */
3033
- /* or -- for the source named by a pipeline's own tag -- by creating the */
3034
- /* pipeline. An agent calling loop_capture_trace against a source nobody */
3035
- /* switched on gets captured:false back, not an error and not a stored */
3036
- /* conversation. */
3037
- /* ══════════════════════════════════════════════════════════════════════════ */
3038
- server.tool("loop_set_capture", "Turn conversation capture on or off for one source, and choose how long recordings are kept. THIS IS THE CONSENT DECISION the rest of the Conscious Loop rests on: until it is made, nothing is recorded. The source is a deployment id for models served on Run BiOS, or any label you choose for traffic served elsewhere (another provider, your own servers, an agent framework). A pipeline's own tag is a source its create already switched on, stamping that tag; deleting the pipeline switches it off. Recordings are deleted automatically once the retention window passes.", {
3048
+ /* WHAT IS SAVED. A call carrying a pipeline ID in X-Loop-Pipeline (loop_chat,*/
3049
+ /* or the SDKs' loop option) is saved to that pipeline: the header is the */
3050
+ /* consent for that one call, whatever the recording switches say. Anything */
3051
+ /* else is recorded only from a source that is switched on: by */
3052
+ /* loop_set_capture, or -- for the source named by a pipeline's own tag -- */
3053
+ /* by creating the pipeline. An agent calling loop_capture_trace against a */
3054
+ /* source nobody switched on gets captured:false back, not an error and not */
3055
+ /* a stored conversation. */
3056
+ /* ══════════════════════════════════════════════════════════════════════════ */
3057
+ server.tool("loop_set_capture", "Turn recording on or off for one source, and choose how long recordings are kept. THIS IS THE CONSENT DECISION for everything a source sends: until it is made, nothing that source sends is recorded. The one exception is a call that carries a pipeline ID in the X-Loop-Pipeline header (loop_chat, or the SDKs' loop option): the header is the consent for that one call, and it is saved to that pipeline whatever this switch says. The source is a deployment id for models served on Run BiOS, or any label you choose for traffic served elsewhere (another provider, your own servers, an agent framework). The source named by a pipeline's ID (own_tag) is switched on when the pipeline is created, and stamps that pipeline ID on what it records; deleting the pipeline switches it off. Recordings are deleted automatically once the retention window passes.", {
3039
3058
  source: z.string().describe("The source to record: a Run BiOS deployment id, or a label you choose for traffic served elsewhere (e.g. 'my-support-agent')"),
3040
3059
  enabled: z.boolean().describe("true starts recording new conversations from this source; false stops. Turning it off never deletes what is already stored."),
3041
3060
  retention_days: z.number().int().optional().describe("How many days recordings are kept before automatic deletion (1-3650, default 30)"),
@@ -3049,7 +3068,74 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3049
3068
  return json(data);
3050
3069
  });
3051
3070
  server.tool("loop_list_capture_settings", "Which sources are recorded: for each one whether capture is on, how long recordings are kept, the sample rate and the tags stamped on what it records. Pass source to read one; a source nobody configured reads back as disabled, because 'we are not recording this' is the honest answer.", { source: z.string().optional().describe("One source to read. Omit for every configured source.") }, async ({ source }) => json(await client.api(source ? `/api/loop/configs/${encodeURIComponent(source)}` : "/api/loop/configs")));
3052
- server.tool("loop_capture_trace", "Record one exchange — what the model was asked and what it answered — so it can be reviewed and later trained on. Works whoever served the model. To feed a pipeline, pass its own_tag as the source: creating the pipeline switched that source on, and it stamps the tag on everything it records. labels add tags of your own, so the conversation feeds those pipelines too. Pass tool_calls and tools when the turn invoked a function: without them a tool-using exchange trains the model to reply in prose exactly where it should have called something. The returned trace_id is Run BiOS's own — give feedback on it with loop_add_signal; if you pass request_id, replaying it returns the same trace instead of storing a duplicate. If the source is not switched on this returns captured:false and stores nothing. Already know the verdict? Pass feedback and it is written with the exchange, so no loop_add_signal call is needed.", {
3071
+ /**
3072
+ * A pipeline ID as the platform reads it: the gateways trim and lowercase the
3073
+ * X-Loop-Pipeline header and keep only this shape, and the pipeline's own model
3074
+ * is `loop-` and the ID exactly, matched case for case. So the ID is lowercased
3075
+ * here before it names the header and the model, and anything else is refused
3076
+ * before any call: sent, it would be dropped and nothing saved.
3077
+ */
3078
+ const LOOP_PIPELINE_ID = /^[a-z0-9][a-z0-9._-]{0,62}$/;
3079
+ /** The text of a chat answer: a string content, or the text parts of a list of parts. */
3080
+ function loopChatAnswerText(message) {
3081
+ const content = message?.content;
3082
+ if (typeof content === "string")
3083
+ return content;
3084
+ if (Array.isArray(content)) {
3085
+ return content
3086
+ .map((part) => (part && typeof part === "object" && typeof part.text === "string"
3087
+ ? part.text : ""))
3088
+ .join("");
3089
+ }
3090
+ return "";
3091
+ }
3092
+ server.tool("loop_chat", "Ask a model and save the conversation as a sample of a pipeline, in one call. It calls /v1/chat/completions with the pipeline ID in the X-Loop-Pipeline header, so the conversation is saved to that pipeline even where recording is off: the header is the consent for that one call. It is a real model call, billed like any other, so say so before calling it for the user. It returns answer (the model's text; empty when it only called a tool, and tool_calls then says what it called), sample_id (the ID feedback names; null when none came back, because the platform was not recording calls just then, so nothing was saved and there is nothing to give feedback on) and the model it asked. A sample ID does not prove the pipeline ID is right: if no pipeline has that ID (misspelt, or the pipeline was deleted), the conversation is not saved under it. If recording is switched on for the model that answered, the call is still recorded the way that model records every call, and feedback on it is then accepted; when nothing records it, feedback on it is refused, first as not recorded yet and then as not found. loop_stats counts such calls under capture (reason no_pipeline); check the pipeline ID with loop_list_pipelines. model defaults to the pipeline's own model, loop-<pipeline ID>, which answers with the live version (the version the app gets answers from right now). Before the pipeline's first version nothing answers to that name: pass model, the model the app uses now; without it the call fails with a plain sentence saying the pipeline has no live version right now, and no model answered (the same after its only live version was taken back). Then send feedback on sample_id with loop_add_signal: good, bad, or corrected with the right answer. Feedback sent the instant the answer arrives can reach the platform before the conversation is saved (409 SAMPLE_NOT_YET_RECORDED); loop_add_signal then waits and sends it again by itself.", {
3093
+ pipeline: z.string().min(1).max(63).describe("The pipeline ID: its own_tag, e.g. 'support-replies' (loop_list_pipelines shows it, and so does the pipeline's Connect page). It was made from the pipeline's name when it was created and never changes. One pipeline ID, no commas: lowercase letters, digits, dot, dash or underscore (capitals are taken as lowercase, as the platform does)."),
3094
+ messages: z.array(z.record(z.string(), z.unknown())).min(1).describe("The conversation to send, in chat-completions shape: [{\"role\": \"user\", \"content\": \"...\"}]"),
3095
+ model: z.string().min(1).optional().describe("The model to ask: a serverless model id or a deployment name. Leave it out to ask the pipeline's own model, loop-<pipeline ID>, which answers with the live version. Required before the pipeline's first version: pass the model the app uses now."),
3096
+ max_tokens: z.number().int().min(1).max(CONTEXT_TOOL_MAX).optional().describe(MAX_TOKENS_POLICY_TEXT),
3097
+ temperature: z.number().min(0).max(2).optional(),
3098
+ request_id: z.string().min(1).max(255).optional(),
3099
+ }, async ({ pipeline, messages, model, max_tokens, temperature, request_id }, extra) => {
3100
+ const id = pipeline.trim().toLowerCase();
3101
+ if (!LOOP_PIPELINE_ID.test(id)) {
3102
+ throw new Error(`pipeline must be one pipeline ID, e.g. "support-replies" (got ${JSON.stringify(pipeline)})`);
3103
+ }
3104
+ const explicit = typeof model === "string" && model.trim() !== "";
3105
+ const asked = explicit ? model.trim() : `loop-${id}`;
3106
+ let result;
3107
+ try {
3108
+ result = await client.loopChatApi({ model: asked, messages, max_tokens, temperature }, id, request_id, extra.signal);
3109
+ }
3110
+ catch (error) {
3111
+ if (!explicit && error instanceof InferenceHttpError && error.status === 404) {
3112
+ throw new Error(`Pipeline "${id}" has no live version right now, so nothing answers to "${asked}". Until a version is live, `
3113
+ + "pass model: the model the app uses now (the conversation still goes to the pipeline). "
3114
+ + "If the pipeline has a live version, check the pipeline ID.");
3115
+ }
3116
+ throw error;
3117
+ }
3118
+ const { completion, sampleId } = result;
3119
+ const choices = Array.isArray(completion.choices) ? completion.choices : [];
3120
+ const first = choices[0];
3121
+ const message = first && typeof first.message === "object" && first.message !== null
3122
+ ? first.message : undefined;
3123
+ const toolCalls = Array.isArray(message?.tool_calls) && message.tool_calls.length > 0 ? message.tool_calls : undefined;
3124
+ return json({
3125
+ answer: loopChatAnswerText(message),
3126
+ sample_id: sampleId,
3127
+ model: asked,
3128
+ pipeline: id,
3129
+ ...(toolCalls ? { tool_calls: toolCalls } : {}),
3130
+ finish_reason: first?.finish_reason ?? null,
3131
+ usage: completion.usage ?? null,
3132
+ id: completion.id ?? null,
3133
+ next: sampleId
3134
+ ? "Send feedback on sample_id with loop_add_signal: good, bad, or corrected with the right answer."
3135
+ : "No sample ID came back, so this conversation was not saved as a sample and cannot take feedback.",
3136
+ });
3137
+ });
3138
+ server.tool("loop_capture_trace", "Record one exchange — what the model was asked and what it answered — so it can be reviewed and later trained on. Works whoever served the model. To feed a pipeline, pass its pipeline ID (own_tag) as the source: creating the pipeline switched that source on, and it stamps the pipeline ID on everything it records. labels add tags of your own, so the conversation feeds those pipelines too. Pass tool_calls and tools when the turn invoked a function: without them a tool-using exchange trains the model to reply in prose exactly where it should have called something. The returned trace_id is the sample ID, Run BiOS's own — give feedback on it with loop_add_signal; if you pass request_id, sending the same one again returns the same sample instead of storing a duplicate. If the source is not switched on this returns captured:false and stores nothing. Already know how the answer did? Pass feedback and it is written with the exchange, so no loop_add_signal call is needed. To ask a model and save the conversation in one call, use loop_chat instead.", {
3053
3139
  source: z.string().describe("The source being recorded: a pipeline's own_tag, or a source switched on with loop_set_capture"),
3054
3140
  model: z.string().describe("The model that produced the answer, e.g. 'gpt-4o' or a Run BiOS model id"),
3055
3141
  messages: z.array(z.object({
@@ -3069,9 +3155,9 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3069
3155
  })).optional().describe("What the answering turn invoked, if anything"),
3070
3156
  tools: z.any().optional().describe("The tool schema the model was offered. Training tool use without it teaches the model to invent function names."),
3071
3157
  conversation_id: z.string().optional().describe("Ties the turns of one conversation together"),
3072
- request_id: z.string().optional().describe("Your own idempotency handle — replaying it will not create a second trace"),
3158
+ request_id: z.string().optional().describe("Your own idempotency handle — sending the same one again will not create a second sample"),
3073
3159
  latency_ms: z.number().int().optional(),
3074
- labels: z.array(z.string()).max(32).optional().describe("Tags for this conversation on top of the ones its source stamps. A pipeline trains on every sample carrying any of its tags. At most 32 in all."),
3160
+ labels: z.array(z.string()).max(32).optional().describe("Tags for this conversation, beside the ones its source stamps. A pipeline trains on every sample carrying any of its tags. At most 32 in all."),
3075
3161
  feedback: z.object({
3076
3162
  verdict: z.enum(["good", "bad", "corrected"]),
3077
3163
  correction: z.string().optional().describe("Required with corrected, and read only then: the answer that should have been given"),
@@ -3087,13 +3173,13 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3087
3173
  });
3088
3174
  return json(data);
3089
3175
  });
3090
- 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 is rows whose shape could not be read, which the user fixes by changing the file, and refused_reasons says why, one {code, count, sentence} per reason: read the sentences back, never the codes (refused_why carries the codes alone, from an older service too); 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.", {
3176
+ 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 good or bad feedback; 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. Feedback 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 is rows whose shape could not be read, which the user fixes by changing the file, and refused_reasons says why, one {code, count, sentence} per reason: read the sentences back, never the codes (refused_why carries the codes alone, from an older service too); 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 FEEDBACK 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 feedback that failed to write is written 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.", {
3091
3177
  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."),
3092
- 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."),
3178
+ 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 feedback."),
3093
3179
  model: z.string().optional().describe("Which model produced these answers, when the rows do not say per-row"),
3094
3180
  labels: z.array(z.string()).optional().describe("Applied to every row. Importing a dump is usually the moment somebody knows what all of it is, and labels are what make a slice of it selectable later."),
3095
3181
  attributes: z.record(z.string(), z.string()).optional().describe("Key/value facts applied to every row, e.g. {\"category\": \"billing\"}"),
3096
- import_id: z.string().optional().describe("Repeat the import_id a previous call reported to make this call a RETRY of it: rows that already arrived come back under already_present rather than being stored again, and a verdict that failed to write is attempted again. Leave it out for a fresh import or for the next page of one."),
3182
+ import_id: z.string().optional().describe("Repeat the import_id a previous call reported to make this call a RETRY of it: rows that already arrived come back under already_present rather than being stored again, and feedback that failed to write is written again. Leave it out for a fresh import or for the next page of one."),
3097
3183
  row_offset: z.number().int().min(0).optional().describe("Where this page starts in the file, counting from 0. Send it when one import is split across several calls under one import_id, so two pages are not matched against each other row for row."),
3098
3184
  row_ids: z.array(z.string()).optional().describe("Your own id per row, in the same order as rows: a ticket number, a conversation id, whatever the export already carries. The strongest identity and the one to use for a file big enough to page — chunking, ordering and subsets all stop mattering. One per row or none at all, and no two rows may share one."),
3099
3185
  }, async ({ source, rows, model, labels, attributes, import_id, row_offset, row_ids }) => {
@@ -3103,16 +3189,16 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3103
3189
  });
3104
3190
  return json(data);
3105
3191
  });
3106
- 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 feedback from a PERSON that decides the sample) or unsignalled true (what no person has reviewed yet). Good, bad and corrected are only ever a person's words: the judge's score of an unreviewed sample is not feedback, so a sample it scored stays unsignalled, and judge splits that unreviewed queue into picked (scored 0.8 or more, trained on), below_bar (not trained on) and unscored; the three add up to unsignalled, and a row the judge scored carries judge {score, picked}. A bad sample is never trained on. With pipeline, each row carries state, what happens to it in that pipeline: held_back (held back to test its attempts, never trained on), learned (by version_no), trained (by attempt_no, which did not go live), waiting (for the next attempt) or left_out, with reason, the sentence to read back. dataset lists what one saved dataset selects (not with pipeline); attempt (a run id) with split train or held_back lists the samples that attempt trained on or held back. 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. q searches the text; samples kept in the workspace's own storage cannot be searched, and a search's answer says how many were left out as unsearched: say so rather than reporting the search as complete.", {
3192
+ 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 feedback from a PERSON that decides the sample) or unsignalled true (what no person has reviewed yet). Good, bad and corrected are only ever a person's words: the judge's score of an unreviewed sample is not feedback, so a sample it scored stays unsignalled, and judge splits that unreviewed queue into picked (scored 0.8 or more, trained on), below_bar (not trained on) and unscored; the three add up to unsignalled, and a sample the judge scored carries judge {score, picked}. A bad sample is never trained on. With pipeline, each sample carries state, what happens to it in that pipeline: held_back (kept aside for the automatic test, never trained on), learned (by version_no), trained (by training number attempt_no, which did not become a version), waiting (for the next training) or left_out, with reason, the sentence to read back. dataset lists what one saved dataset selects (not with pipeline); the 'attempt' filter (a training's run id) with split train or held_back lists the samples that training learned from or was tested 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. q searches the text; samples kept in the workspace's own storage cannot be searched, and a search's answer says how many were left out as unsearched: say so rather than reporting the search as complete.", {
3107
3193
  q: z.string().min(2).max(200).optional().describe("Text search, 2 to 200 characters: any case, matched as written in every message, the answer and its tool calls. Combines with every other filter."),
3108
3194
  source: z.string().optional().describe("Only conversations from this source"),
3109
3195
  model: z.string().optional().describe("Only what THIS model answered, e.g. 'moonshotai/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."),
3110
3196
  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."),
3111
3197
  conversation_id: z.string().optional(),
3112
- pipeline: z.string().optional().describe("Only the conversations this pipeline trains on (its id, from loop_list_pipelines): its tags or its datasets, narrowed by its conditions, reviewed or not. Each row then carries state."),
3198
+ pipeline: z.string().optional().describe("Only the conversations this pipeline trains on (its id, from loop_list_pipelines): its tags or its datasets, narrowed by its conditions, reviewed or not. Each sample then carries state."),
3113
3199
  dataset: z.string().optional().describe("Only the conversations one saved dataset selects (its id, from loop_list_datasets): the same samples its samples count. Not with pipeline."),
3114
- attempt: z.string().optional().describe("Only the samples of one attempt's set (a run id, from loop_list_pipeline_attempts); pass split too."),
3115
- split: z.enum(["train", "held_back"]).optional().describe("With attempt: train (what it trained on) or held_back (what it was tested on)."),
3200
+ attempt: z.string().optional().describe("Only the samples of one training (its run id, from loop_list_pipeline_attempts); pass split too."),
3201
+ split: z.enum(["train", "held_back"]).optional().describe("With a training's run id: train (what it learned from) or held_back (its automatic test: what it was tested on)."),
3116
3202
  label: z.string().optional().describe("Only conversations carrying this bare tag. A tag with children matches them too."),
3117
3203
  attributes: z.record(z.string(), z.string()).optional().describe("Only conversations matching every one of these dimensions, e.g. {\"category\":\"billing\",\"language\":\"es\"}"),
3118
3204
  unlabelled: z.boolean().optional().describe("Only the conversations nobody has described yet — the pile worth looking at before deciding what the dimensions should be"),
@@ -3154,31 +3240,66 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3154
3240
  });
3155
3241
  return json(data);
3156
3242
  });
3157
- server.tool("loop_get_trace", "Read one recorded conversation in full, with every verdict and tag anyone has recorded on it. When the judge scored it (it scores only samples nobody reviewed), judge says its score and whether it was picked (0.8 or more: trained on), and signals holds the judge's score (source judge, verdict scored) with picked, per-aspect metadata.scores and its reason (reason_unavailable true: the reason could not be read from storage just now). It is not feedback: say 'the judge scored it', never that it was marked good, and a person's feedback decides over 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 }) => {
3243
+ server.tool("loop_get_trace", "Read one recorded conversation in full, with all the feedback and every tag anyone has recorded on it. When the judge scored it (it scores only samples nobody reviewed), judge says its score and whether it was picked (0.8 or more: trained on), and 'signals' holds the judge's score (source judge, with 'verdict' set to 'scored') with picked, per-aspect metadata.scores and its reason (reason_unavailable true: the reason could not be read from storage just now). It is not feedback: say 'the judge scored it', never that it was marked good, and a person's feedback decides over 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 sample ID: trace_id from loop_capture_trace or loop_list_traces, or sample_id from loop_chat") }, async ({ trace_id }) => {
3158
3244
  const data = await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}`);
3159
3245
  return json(data);
3160
3246
  });
3161
- 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. Where the live version of a pipeline learned from it, that pipeline rebuilds its model from the base on every remaining sample, without it: the answer's rebuilds lists each such pipeline ({pipeline_id, pipeline_name}; empty when none) -- read them back, because a rebuild is a new attempt paid from the wallet. A switched-off pipeline is listed too: its rebuild starts when it is switched back on. Until the rebuild goes live the live version still holds what it learned from it; once it does, no version that learned it can be rolled back to, and their stored weights are deleted. Any other version already trained on it keeps what it learned, and a conversation that expires under the retention setting rebuilds nothing. 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" })));
3162
- 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.\n\nA 409 SAMPLE_NOT_YET_RECORDED means the trace_id is an X-Loop-Sample-Id issued moments ago whose sample is not stored yet, and nothing was recorded: wait the Retry-After seconds (2) and send the same feedback again.", {
3247
+ 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. Where the live version of a pipeline learned from it, that pipeline rebuilds its model from the original model on every remaining sample, without it: the answer's rebuilds lists each such pipeline ({pipeline_id, pipeline_name}; empty when none) -- read them back, because a rebuild is a new training paid from the wallet. A switched-off pipeline is listed too: its rebuild starts when it is switched back on. Until the rebuild goes live the live version still holds what it learned from it; once it does, no version that learned it can be rolled back to, and their stored weights are deleted. Any other version already trained on it keeps what it learned, and a conversation that expires under the retention setting rebuilds nothing. 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" })));
3248
+ /**
3249
+ * Feedback that names a sample the gateway issued moments ago can arrive before
3250
+ * the sample is saved, and is refused 409 SAMPLE_NOT_YET_RECORDED with nothing
3251
+ * stored. The SDKs' signal() sends it again; so does loop_add_signal, the same
3252
+ * way: wait Retry-After (2 seconds when none is given, at most 30) and try
3253
+ * again, at most this many tries in all. An assistant has no way to wait on
3254
+ * its own, so without this the 409 would end the feedback.
3255
+ */
3256
+ const LOOP_FEEDBACK_TRIES = 6;
3257
+ const LOOP_FEEDBACK_RETRY_DEFAULT_SECONDS = 2;
3258
+ const LOOP_FEEDBACK_RETRY_MAX_SECONDS = 30;
3259
+ async function sendLoopFeedback(send, signal) {
3260
+ for (let tries = 1;; tries++) {
3261
+ try {
3262
+ return await send();
3263
+ }
3264
+ catch (error) {
3265
+ const onItsWay = error instanceof ApiHttpError && error.status === 409 && error.code === "SAMPLE_NOT_YET_RECORDED";
3266
+ if (!onItsWay || tries >= LOOP_FEEDBACK_TRIES || signal?.aborted)
3267
+ throw error;
3268
+ const seconds = Math.min(error.retryAfterSeconds ?? LOOP_FEEDBACK_RETRY_DEFAULT_SECONDS, LOOP_FEEDBACK_RETRY_MAX_SECONDS);
3269
+ await new Promise((resolve) => {
3270
+ const timer = setTimeout(done, seconds * 1000);
3271
+ function done() {
3272
+ clearTimeout(timer);
3273
+ signal?.removeEventListener("abort", done);
3274
+ resolve();
3275
+ }
3276
+ signal?.addEventListener("abort", done, { once: true });
3277
+ });
3278
+ if (signal?.aborted)
3279
+ throw error;
3280
+ }
3281
+ }
3282
+ }
3283
+ server.tool("loop_add_signal", "Say how an answer did: Good, Wrong, or Wrong with the right answer. trace_id is the sample ID (loop_chat's sample_id, the X-Loop-Sample-Id an app got back, or a trace_id from loop_capture_trace or loop_list_traces). Feedback is added, never replaced: a second one sits beside the first, because two reviewers disagreeing is worth knowing, and the newest from a person decides.\n\nWHAT EACH ONE DOES (every version learns from examples):\n• good (stored as accepted) — the answer was right. It is learned as it is.\n• bad (stored as rejected) — it was wrong and you have nothing better. It is LEFT OUT of training and nothing is learned from it; prefer corrected whenever you know the right answer.\n• corrected (stored as edited) — the model was WRONG and here is what it should have said, in correction. The most valuable single action: your right answer is learned instead of the model's. Nothing is learned from the answer it replaced.\n• gold — the right answer for this question, recorded whatever the model happened to say. It trains exactly like corrected; it only makes no claim that the model was wrong.\nTraining that learns from rankings or rewards is not available yet, so bad with no right answer teaches nothing; a correction does.\n\nFeedback sent the instant an answer arrives can reach the platform before the conversation is saved (409 SAMPLE_NOT_YET_RECORDED, nothing recorded). This tool then waits (the Retry-After seconds, 2 when none is given, at most 30) and sends the same feedback again by itself, at most 6 tries in all. If it is still refused, the sample ID names no saved conversation yet: saving is behind, or the call named a pipeline ID no pipeline has and nothing else recorded it. Say so, and check the pipeline ID.", {
3163
3284
  trace_id: z.string(),
3164
- verdict: z.enum(["accepted", "rejected", "edited", "gold", "good", "bad", "corrected"]).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. good, bad and corrected (the words loop_capture_trace's feedback takes) are accepted, rejected and edited, and are stored as those"),
3165
- correction: z.string().optional().describe("REQUIRED when verdict is 'edited' or 'corrected' (the answer it should have given) or 'gold' (the reference answer for this question)"),
3285
+ verdict: z.enum(["good", "bad", "corrected", "gold", "accepted", "rejected", "edited"]).describe("good = the answer was right; bad = it was wrong, with nothing better to offer; corrected = it was wrong and correction is the right answer; gold = the right answer for this question regardless of what the model said. accepted, rejected and edited are the stored names of good, bad and corrected, and are taken too"),
3286
+ correction: z.string().optional().describe("REQUIRED with 'corrected' or 'edited' (the answer it should have given) and with 'gold' (the right answer for this question)"),
3166
3287
  reason: z.string().optional().describe("Why, in your own words"),
3167
3288
  author: z.string().optional(),
3168
- 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."),
3289
+ 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 feedback 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."),
3169
3290
  attributes: z.record(z.string(), z.string()).optional().describe("The same, by named dimension: {\"category\": \"billing\"}."),
3170
3291
  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."),
3171
- }, async ({ trace_id, verdict, correction, reason, author, labels, attributes, parent }) => {
3172
- const data = await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}/signals`, {
3292
+ }, async ({ trace_id, verdict, correction, reason, author, labels, attributes, parent }, extra) => {
3293
+ const data = await sendLoopFeedback(() => client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}/signals`, {
3173
3294
  method: "POST",
3174
3295
  body: {
3175
3296
  verdict, source: "human", correction, reason, author,
3176
3297
  labels, attributes, parent,
3177
3298
  },
3178
- });
3299
+ }), extra?.signal);
3179
3300
  return json(data);
3180
3301
  });
3181
- server.tool("loop_list_signals", "Every verdict recorded on one conversation, oldest first. Feedback is append-only, so two reviewers who disagreed are both here.", { trace_id: z.string() }, async ({ trace_id }) => json(await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}/signals`)));
3302
+ server.tool("loop_list_signals", "All the feedback recorded on one conversation, oldest first. Feedback is added, never replaced, so two reviewers who disagreed are both here.", { trace_id: z.string() }, async ({ trace_id }) => json(await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}/signals`)));
3182
3303
  server.tool("loop_label_trace", "Label a recorded conversation so it can be selected later. An average over everything is the least useful thing to train on — a model weak at refunds is fixed with refund examples, and you can only select those if the conversation was labelled when it arrived. A label may name a PARENT, which makes a master group: labelling something 'refunds' with parent 'billing' makes it selectable as either, and selecting 'billing' later gathers every child without anybody maintaining a list of them. Labels are lowercased and trimmed, so 'Refunds' and 'refunds' are one label.", {
3183
3304
  trace_id: z.string(),
3184
3305
  labels: z.array(z.string()).optional().describe("Bare tags, e.g. ['refunds','escalated']. Lowercase letters, digits, dot, dash or underscore."),
@@ -3200,14 +3321,14 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3200
3321
  return json(data);
3201
3322
  });
3202
3323
  server.tool("loop_list_labels", "Every label in this workspace with how many conversations carry it. Use it to see where the corpus actually is before choosing a slice to train on — and to discover the master groups, since a parent may have no conversations labelled with it directly. Two fields answer the group question and they answer different ones: 'parent' is the group ALL of a label's conversations are in, and is null the moment they disagree; 'parents' is every group ANY of them are in, each with how many of this label's conversations are in it. LIST THE MASTER GROUPS FROM 'parents' — a label with 12 conversations, 5 of them under support, reports parent null and parents [{support, 5}], so reading only 'parent' finds no group for it. Never state that a label is in a group on the strength of 'parents' alone; say how many of it are.", {}, async () => json(await client.api("/api/loop/labels")));
3203
- server.tool("loop_get_trace_pipelines", "Which pipelines one conversation feeds: every live pipeline whose tags or datasets select it (and whose conditions keep it), and whether each is switched on. Being listed means the pipeline would select it, not that it will be trained on — the build still drops duplicates and answers it cannot use, and holds some back to judge by. Each pipeline's state says what happens to it there: held_back (held back to test its attempts, never trained on), learned (version_no learned it), trained (attempt_no trained on it and did not go live), waiting (for the next attempt) or left_out (not trained on as it is), and reason is the sentence to read back (for left_out, why). An empty list is a real answer: the conversation counts toward 'reviewed' and trains nothing, so add a pipeline's tag to it (loop_label_trace) if it should.", { trace_id: z.string() }, async ({ trace_id }) => json(await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}/pipelines`)));
3204
- server.tool("loop_stats", "How much has been recorded in this workspace, how much of it has feedback, and how many samples a pipeline could train on right now (ready.sft: the ones marked good or corrected). ready.sft is an upper bound: duplicates, the held-back set and conversations too long to train on come out of it when an attempt trains. capture lists the calls sent with X-Loop-Pipeline in the last capture.days days that were not recorded under a tag, one row per tag (a call naming two tags is counted under each, so never add rows up as calls): reason no_pipeline (no pipeline has that tag: misspelt, never made, or deleted), too_large or incomplete (the answer could not be kept); empty when every call was recorded. The workspace's totals: samples_recent is how many samples were recorded in the last 48 hours; feedback counts every sample by the feedback a person gave it (good, bad, corrected, unreviewed -- they add up to traces); pipelines is how many pipelines there are and how many are training, needs_you (waiting on the user), serving or paused.", {}, async () => {
3324
+ server.tool("loop_get_trace_pipelines", "Which pipelines one conversation feeds: every live pipeline whose tags or datasets select it (and whose conditions keep it), and whether each is switched on. Being listed means the pipeline would select it, not that it will be trained on — training still drops duplicates and answers it cannot use, and keeps some aside for the automatic test. Each pipeline's state says what happens to it there: held_back (kept aside for the automatic test, never trained on), learned (version_no learned it), trained (training attempt_no learned from it and did not become a version), waiting (for the next training) or left_out (not trained on as it is), and reason is the sentence to read back (for left_out, why). An empty list is a real answer: the conversation counts toward 'reviewed' and trains nothing, so add a pipeline's tag to it (loop_label_trace) if it should.", { trace_id: z.string() }, async ({ trace_id }) => json(await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}/pipelines`)));
3325
+ server.tool("loop_stats", "How much has been recorded in this workspace, how much of it has feedback, and how many samples a pipeline could train on right now (ready.sft: the ones marked good or corrected). ready.sft is an upper bound: duplicates, the samples kept aside for the automatic test and conversations too long to train on come out of it when a training starts. capture lists the calls sent with X-Loop-Pipeline in the last capture.days days that were not recorded under a name the header gave, one entry per name (a call naming two is counted under each, so never add entries up as calls): reason no_pipeline (no pipeline has that pipeline ID: misspelt, never made, or deleted), too_large or incomplete (the answer could not be kept); empty when every call was recorded. The workspace's totals: samples_recent is how many samples were recorded in the last 48 hours; feedback counts every sample by the feedback a person gave it (good, bad, corrected, unreviewed -- they add up to the 'traces' total, every sample); pipelines is how many pipelines there are and how many are training, needs_you (waiting on the user), serving or paused.", {}, async () => {
3205
3326
  const data = await client.api("/api/loop/stats");
3206
3327
  return json(data);
3207
3328
  });
3208
- server.tool("loop_get_metrics_history", "How the loop moved over time: every attempt as a point, oldest first (its attempt_no, the version_no it became — null when it did not become one —, its win rate, cost and the recipe it trained on, and the bars its comparison was judged against: mean_change_needed is the average score change it had to clear, promote_margin the most that bar asks, and the bar is lower near the top: a quarter of the room left) and each day's feedback counted by verdict. truncated says older attempts exist beyond the limit. Pass rule_id (a pipeline's id) for one pipeline's attempts; the feedback counts are always the workspace's. samples is the samples recorded each day over days, by their feedback today (good, bad, corrected, unreviewed): with rule_id, that pipeline's.", {
3329
+ server.tool("loop_get_metrics_history", "How the loop moved over time: every training as a point, oldest first (its training number attempt_no, the version_no it became — null when it did not become one —, its win_rate (of the test questions where its answer and the live version's scored differently, the share where its answer scored higher), cost -- the money columns as recorded, and cost_cents with cost_up_to, the training priced as the pipeline's list of trainings prices it, so the points add up to the pipeline's totals.spent_cents; cost_up_to true means say 'at most' --, the recipe it trained with, and the bars its test was judged against: mean_change_needed is the average score change it had to clear, promote_margin the most that bar asks, and the bar is lower near the top: a quarter of the room left) and each day's feedback counted by kind. truncated says older trainings exist beyond the limit. Pass rule_id (a pipeline's id) for one pipeline's trainings; the feedback counts are always the workspace's. samples is the samples recorded each day over days, by their feedback today (good, bad, corrected, unreviewed): with rule_id, that pipeline's.", {
3209
3330
  rule_id: z.string().optional().describe("One pipeline's id, from loop_list_pipelines. Omit for the whole workspace."),
3210
- limit: z.number().int().min(1).max(500).optional().describe("How many attempts at most: 200 when omitted, at most 500"),
3331
+ limit: z.number().int().min(1).max(500).optional().describe("How many trainings at most: 200 when omitted, at most 500"),
3211
3332
  days: z.number().int().min(1).max(365).optional().describe("Days of feedback counts: 90 when omitted, at most 365"),
3212
3333
  }, async ({ rule_id, limit, days }) => json(await client.api("/api/loop/metrics/history", {
3213
3334
  params: { rule_id, limit: limit?.toString(), days: days?.toString() },
@@ -3217,12 +3338,12 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3217
3338
  /* */
3218
3339
  /* A PIPELINE is one use case: a name, a model, the tags whose samples it */
3219
3340
  /* trains on, when to train (at least N new samples, and optionally a */
3220
- /* schedule), the share of its samples held back to judge each version, and */
3221
- /* how a new version is promoted. The platform decides the rest -- the GPUs, */
3222
- /* LoRA, the held-back floor, the comparison -- and SAVING A */
3341
+ /* schedule), the share of its samples kept aside for the automatic test, */
3342
+ /* and how a new version goes live. The platform decides the rest -- the */
3343
+ /* GPUs, how it trains, the test's floor, the test itself -- and SAVING A */
3223
3344
  /* PIPELINE IS THE AUTHORIZATION TO SPEND: whenever its data has grown */
3224
- /* enough it trains an attempt on the workspace's wallet with nobody in the */
3225
- /* room. What is charged is the GPU time the attempts use and, where the */
3345
+ /* enough it runs a training on the workspace's wallet with nobody in the */
3346
+ /* room. What is charged is the GPU time the trainings use and, where the */
3226
3347
  /* platform scores samples, the judge's scoring of unreviewed samples. */
3227
3348
  /* */
3228
3349
  /* The wire shapes are the frozen contract in docs/loop-training-contracts.md.*/
@@ -3232,16 +3353,16 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3232
3353
  /* are not changing. */
3233
3354
  /* ══════════════════════════════════════════════════════════════════════════ */
3234
3355
  const pipelineModelSchema = z.object({
3235
- id: z.string().describe("A model id from loop_list_models. On a create, any other model is refused MODEL_NOT_TRAINABLE. On an edit only a changed id is checked against the list: an id equal to the one the pipeline has there now (ignoring case) is kept, and whether it can still be trained is checked when an attempt starts."),
3356
+ id: z.string().describe("A model id from loop_list_models. On a create, any other model is refused MODEL_NOT_TRAINABLE. On an edit only a changed id is checked against the list: an id equal to the one the pipeline has there now (ignoring case) is kept, and whether it can still be trained is checked when a training starts."),
3236
3357
  });
3237
3358
  /** The pipeline create and edit body: every field the service accepts, and no other. */
3238
3359
  const pipelineInputShape = {
3239
3360
  workspace_id: z.string().optional().describe("The workspace the pipeline belongs to. Omit to use the key's own workspace."),
3240
- name: z.string().optional().describe("What the pipeline is called, e.g. 'Support replies'. Its own tag is made from it and does not change afterwards."),
3361
+ name: z.string().optional().describe("What the pipeline is called, e.g. 'Support replies'. Its pipeline ID (own_tag) is made from it and never changes."),
3241
3362
  use_case: z.string().optional().describe("One sentence on what it is for"),
3242
- model: pipelineModelSchema.optional().describe("The base model to improve"),
3243
- challenger_model: pipelineModelSchema.nullable().optional().describe("Optional second model: after each attempt it is trained on the same data, and it too must beat the live version to become one. When it goes live it becomes the pipeline's model and the current model becomes the second model. null removes it; an attempt of it already running is not stopped."),
3244
- tags: z.array(z.string()).optional().describe("More tags whose samples this pipeline trains on, beside its own. On an edit the list replaces the previous one; the own tag stays."),
3363
+ model: pipelineModelSchema.optional().describe("The main model: the one the pipeline improves"),
3364
+ challenger_model: pipelineModelSchema.nullable().optional().describe("Optional second model, a different model from the main one (the same model twice is refused: each model keeps building on its own versions). After each training of the main model on new samples it is trained on the same samples -- building on its own latest version, or from its original model on all samples while it has none -- and tested the same way, against the version live at the time; each training that does better goes live in turn (version numbers count up), so both models' trainings can become versions, and a second model that goes live becomes the pipeline's main model while the current main one becomes the second. null removes it; a training of it already running is not stopped."),
3365
+ tags: z.array(z.string()).optional().describe("More tags whose samples this pipeline trains on, beside its own. On an edit the list replaces the previous one; the pipeline ID stays."),
3245
3366
  // STRICT: the sample minimums are fixed by the platform, and the service
3246
3367
  // refuses a train_when.min_samples by name. zod would otherwise strip the
3247
3368
  // key in silence, the save would succeed, and the agent would tell the user
@@ -3254,16 +3375,16 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3254
3375
  }).nullable().optional().describe("Also wait for this time. Training starts only when BOTH the platform's fixed sample minimum and the schedule are met. null trains as soon as the minimum is met."),
3255
3376
  }).strict().optional().describe("When to train, beyond the platform's fixed sample minimum: only a schedule. There is no min_samples: the minimums are fixed (loop_list_models floors) and cannot be set."),
3256
3377
  promotion: z.object({
3257
- policy: z.enum(["holdout", "all", "primary", "k_of_n", "weighted", "manual"]).describe("holdout (default): better on the held-back samples. all / primary / k_of_n / weighted: by the pipeline's benchmarks. manual: never promoted without a person, a rebuild after a deleted conversation included. Under every other policy a rebuild after a deleted conversation goes live unless it is clearly worse than the live version."),
3378
+ policy: z.enum(["holdout", "all", "primary", "k_of_n", "weighted", "manual"]).describe("How a new version goes live. 'holdout' (default): better on the automatic test (questions from the pipeline's own samples it never trained on). all / primary / k_of_n / weighted: by the pipeline's benchmarks. manual: never put live without a person, a rebuild after a deleted conversation included. Under every other policy a rebuild after a deleted conversation goes live unless it is clearly worse than the live version."),
3258
3379
  k: z.number().int().min(1).optional().describe("For k_of_n only: how many measurements must improve"),
3259
3380
  }).optional(),
3260
3381
  // STRICT, and never null: the share is the member's, the floor of 100 and
3261
3382
  // the count are the platform's, and the service refuses anything else by
3262
3383
  // name. zod would otherwise strip holdout.count in silence.
3263
3384
  holdout: z.object({
3264
- percent: z.number().int().min(5).max(20).describe("The share of the pipeline's samples held back to judge each attempt and never trained on: a whole number from 5 to 20 (5 is the default). At least 100 are held back whatever it says."),
3265
- }).strict().optional().describe("The held-back share (loop_list_models floors.held_back says the range). It does not change what the pipeline may spend and nothing is asked again: a new share is a new revision, saved at once. But a larger share means more held-back samples are scored on each attempt (at most floors.held_back.scored_rows, the same ones every time), and scoring is paid from the wallet; a share that holds back more than that only keeps samples out of training. Lowering it keeps every sample already held back; raising it holds back only new samples, and new samples fill the held-back set before they count toward the next attempt, so after a raise the next attempt waits for them (the pipeline's minimums say how many). Leave it out to keep the pipeline's (or the default on a create)."),
3266
- max_versions: z.number().int().min(1).max(100).nullable().optional().describe("How many versions it may make, 1 to 100; null (the default) is no limit. A version is an attempt that became the live model; attempts that lost are not counted."),
3385
+ percent: z.number().int().min(5).max(20).describe("The share of the pipeline's samples kept aside for the automatic test of each training and never trained on: a whole number from 5 to 20 (5 is the default). At least 100 are kept aside whatever it says."),
3386
+ }).strict().optional().describe("The automatic test's share (loop_list_models floors.held_back says the range). It does not change what the pipeline may spend and nothing is asked again: a new share is a new revision, saved at once. But a larger share means more of the automatic test's samples are scored for each training (at most floors.held_back.scored_rows, the ones kept aside longest first, so later trainings are mostly asked the same questions), and scoring is paid from the wallet; a share that keeps aside more than that only keeps samples out of training. Lowering it keeps every sample already kept aside; raising it keeps aside only new samples, and new samples fill the automatic test before they count toward the next training, so after a raise the next training waits for them (the pipeline's minimums say how many). Leave it out to keep the pipeline's (or the default on a create)."),
3387
+ max_versions: z.number().int().min(1).max(100).nullable().optional().describe("How many versions it may make, 1 to 100; null (the default) is no limit. A version is a training that went live; trainings that did not become a version are not counted."),
3267
3388
  enabled: z.boolean().optional().describe("false saves it switched off"),
3268
3389
  // STRICT, and three-state twice over: training null puts every setting back
3269
3390
  // to automatic, and inside it each key is absent (keep), null (automatic) or
@@ -3274,9 +3395,9 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3274
3395
  learning_rate: z.number().min(1e-6).max(1e-2).nullable().optional().describe("1e-6 to 1e-2. null: automatic (training_defaults.learning_rate)."),
3275
3396
  lora_rank: z.union([z.literal(4), z.literal(8), z.literal(16), z.literal(32), z.literal(64)]).nullable().optional().describe("4, 8, 16, 32 or 64. null: automatic (training_defaults.lora_rank)."),
3276
3397
  lora_alpha: z.number().int().min(1).max(512).nullable().optional().describe("1 to 512. null: automatic (training_defaults.lora_alpha)."),
3277
- replay_percent: z.number().int().min(0).max(50).nullable().optional().describe("0 to 50: the share of already-learned samples an attempt replays when it builds on the live version, so the new version does not forget them. null: automatic (50)."),
3278
- }).strict().nullable().optional().describe("The pipeline's training settings (its training on loop_get_pipeline, each null while automatic; training_defaults says what automatic means for its model). Send only the keys that change: an absent key keeps the pipeline's, null puts that one back to automatic, a value sets it; training null puts every one back to automatic. Leave training out to keep them all. A change applies from the next attempt, and earlier attempts keep the settings they trained with (their recipe; custom true when one applied). Out of range is 422 TRAINING_SETTING_OUT_OF_RANGE naming the field and its range. Automatic is recommended: say so, and change a setting only when the user asks for it."),
3279
- datasets: z.array(z.string()).max(32).optional().describe("Saved datasets (ids from loop_list_datasets) whose samples it also trains on, beside its tags; at most 32. On an edit the list replaces the previous one and [] takes every one off. A change applies from the next attempt: nothing is trained again and versions already made keep what they learned."),
3398
+ replay_percent: z.number().int().min(0).max(50).nullable().optional().describe("0 to 50: when a training builds on the live version, the share of what it trains on that is samples the versions before already learned, gone over again so the new version does not forget them. null: automatic (50)."),
3399
+ }).strict().nullable().optional().describe("The pipeline's training settings (its training on loop_get_pipeline, each null while automatic; training_defaults says what automatic means for its model). Send only the keys that change: an absent key keeps the pipeline's, null puts that one back to automatic, a value sets it; training null puts every one back to automatic. Leave training out to keep them all. A change applies from the next training, and earlier trainings keep the settings they trained with (their recipe; custom true when one applied). Out of range is 422 TRAINING_SETTING_OUT_OF_RANGE naming the field and its range. Automatic is recommended: say so, and change a setting only when the user asks for it."),
3400
+ datasets: z.array(z.string()).max(32).optional().describe("Saved datasets (ids from loop_list_datasets) whose samples it also trains on, beside its tags; at most 32. On an edit the list replaces the previous one and [] takes every one off. A change applies from the next training: nothing is trained again and versions already made keep what they learned."),
3280
3401
  // STRICT, and three-state: absent keeps the conditions, null clears them,
3281
3402
  // an object replaces them whole. Tags are refused inside it by the service
3282
3403
  // (a pipeline's tags are its tags field), so they are not offered.
@@ -3286,15 +3407,15 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3286
3407
  model: z.string().min(1).max(200).nullable().optional().describe("Only samples this model answered, exactly."),
3287
3408
  from: z.string().datetime({ offset: true }).nullable().optional().describe("Only samples recorded at or after this RFC 3339 time."),
3288
3409
  to: z.string().datetime({ offset: true }).nullable().optional().describe("Only samples recorded at or before this RFC 3339 time."),
3289
- }).strict().nullable().optional().describe("What narrows everything the pipeline trains on (its tags and its datasets alike). Absent keeps them, null clears them, an object replaces them. A change applies from the next attempt; to make the live version forget samples, delete them (loop_delete_trace)."),
3410
+ }).strict().nullable().optional().describe("What narrows everything the pipeline trains on (its tags and its datasets alike). Absent keeps them, null clears them, an object replaces them. A change applies from the next training; to make the live version forget samples, delete them (loop_delete_trace)."),
3290
3411
  // STRICT: the switch is the only key the service takes under it.
3291
3412
  auto_scoring: z.object({
3292
- enabled: z.boolean().describe("true (the default): where the platform scores samples (the pipeline's auto_scoring is not null), the judge scores samples nobody reviewed while the pipeline is short of the samples its next attempt needs, and the ones scoring 0.8 or more are trained on. false trains only on samples a person reviewed."),
3293
- }).strict().optional().describe("The judge's scoring of unreviewed samples, on by default. It is not a training change and nothing is asked again; what it cost shows on the attempt it was for (sample_scoring_cents, inside cost_cents)."),
3413
+ enabled: z.boolean().describe("true (the default): where the platform scores samples (the pipeline's auto_scoring is not null), the judge scores samples nobody reviewed while the pipeline is short of the samples its next training needs, and the ones scoring 0.8 or more are trained on. false trains only on samples a person reviewed."),
3414
+ }).strict().optional().describe("The judge's scoring of unreviewed samples, on by default. It is not a training change and nothing is asked again; what it cost shows on the training it was for (sample_scoring_cents, inside cost_cents)."),
3294
3415
  };
3295
- server.tool("loop_list_models", "The base models a pipeline can train — the ONLY models loop_create_pipeline accepts — recommended first, each with its name, author, family and size, and floors, the platform's fixed sample minimums (read-only; no pipeline sets them): first_version_samples (what a new pipeline's first attempt needs at the default held-back share, the held-back samples included), new_samples (each later attempt waits for new_samples new samples, 1,000, counted since the last trained attempt, however many samples that attempt trained on; where a pipeline builds on its live version -- its chain is set -- that attempt trains a new adapter on top of the live version, on what it has not learned yet plus a replay of what it already learned (about half of what it trains on, picked at random, so it is not forgotten), and a winning version is merged in and served as one model), and held_back (at least min_rows samples are held back, or the share a pipeline chooses from default_percent to max_percent if that is more; each attempt is judged on at most scored_rows of them, so a share that holds back more only keeps samples out of training). Offer the user a choice from this list; never type a model id from memory. unavailable lists the other models the platform knows, each with reason_code (not_trainable_sft_lora, not_servable_as_adapter, too_large_for_available_gpus, not_verified, registry_missing) and reason, one plain sentence: when the user asks for a model that is not in models, read its reason back rather than guessing why. 503 MODELS_UNAVAILABLE: the list could not be read just then; try again in a minute.", {}, async () => json(await client.api("/api/loop/models")));
3296
- server.tool("loop_create_pipeline", "Create a pipeline: one use case, trained again whenever enough new samples carrying its tags exist (1,000 new ones for each attempt after the first; where the pipeline builds on its live version, each attempt trains a new adapter on top of it, on what it has not learned plus a random replay of what it learned, about half of what it trains on, and a winner is merged in). SPEND CONSENT: saving it authorises the platform to train attempts on the workspace's wallet with nobody present — whenever train_when is met it trains on GPUs the platform chooses, compares the attempt with the live version, and promotes it by the promotion policy. What is charged is the GPU time its attempts actually use and, where the platform scores samples, the judge's scoring of unreviewed samples, shown on the attempt it was for. Say both in plain words and WAIT FOR AN EXPLICIT YES before calling. Take model.id from loop_list_models, never from memory. Leave training out (automatic settings, recommended) unless the user asks for a setting. datasets (from loop_list_datasets) adds their samples to what its tags select and conditions narrows both; leave both out unless the user asks. The judge's scoring of unreviewed samples is on unless auto_scoring.enabled is false. Where the platform scores samples (the pipeline's auto_scoring is not null), while the pipeline is short of the samples its next attempt needs, the judge scores samples nobody reviewed and those scoring 0.8 or more are trained on. Nothing is saved when it is refused. 409 PIPELINE_NAME_TAKEN or OWN_TAG_TAKEN: choose another name. 422 NO_GPU_FITS, MODEL_NOT_TRAINABLE or PROMOTION_*: the message names the fix. 422 NO_JUDGE_MODEL: no listed model can score the attempts; tell the user rather than retrying. 422 MODEL_REVISION_UNAVAILABLE: the model could not be pinned because the training service did not answer; try again shortly. 422 METHOD_NOT_ENABLED or CEILING_TOO_LOW: the platform cannot train this model now and nothing in the request changes that; tell the user. 409 TRAINING_NOT_ENABLED: the platform's training service would not take the pipeline while the platform's services were being updated; nothing was saved and nothing in the request is wrong, so try again in a few minutes. 503 MODELS_UNAVAILABLE, GPU_OPTIONS_UNAVAILABLE or AGENT_COULD_NOT_START: a service the save needs did not answer just then; try again in a minute.", pipelineInputShape, async (input) => json(await client.api("/api/loop/pipelines", { method: "POST", body: input })));
3297
- server.tool("loop_update_pipeline", "Edit a pipeline: its name, use case, model, challenger, tags, datasets, conditions, the judge's scoring of unreviewed samples (auto_scoring), when to train, held-back share, promotion policy, version limit, or switch it off and on. Send ONLY what changes: datasets [] takes every dataset off, conditions null clears them, and a change to either applies from the next attempt (nothing is trained again; versions already made keep what they learned); an absent key leaves it alone, and null clears max_versions (no limit), challenger_model (no challenger), train_when.schedule (train as soon as the minimum is met), training (every training setting back to automatic) or one key inside training (that setting back to automatic). A training change applies from the next attempt; 422 TRAINING_SETTING_OUT_OF_RANGE names the field and its range. Saving is the authorization, as on a create: a change that makes attempts cost more (a bigger model, switching it back on, turning auto_scoring back on) is SPEND CONSENT, so say so first. A larger held-back share is not SPEND CONSENT (nothing is asked again and the limits do not move), and what it changes depends on how many are held back: while fewer than floors.held_back.scored_rows (1,500) are, each attempt can score more of them, paid from the wallet; once that many are, a larger share scores nothing more and costs nothing more, and only keeps more new samples out of training, for good; and a share that still comes to the floor of floors.held_back.min_rows (100) changes nothing. Say whichever applies, never that a raise always costs more. Pass expected_revision (the pipeline's revision as you read it) to be refused REVISION_MISMATCH rather than overwrite someone else's edit; REVISION_MISMATCH also comes back, sent or not, when the pipeline changed while the edit was being saved (a version went live, which can change its models): nothing was saved, so read it again and redo the edit. A model change while an attempt is running is refused RUN_ACTIVE. 409 NOT_A_SIMPLE_PIPELINE: this pipeline was created before pipelines had tags, a use case or a challenger, so those three cannot be edited; everything else can. Every other refusal is loop_create_pipeline's, with the same meaning.", {
3416
+ server.tool("loop_list_models", "The models a pipeline can train — the ONLY models loop_create_pipeline accepts — recommended first, each with its name, author, family and size, and floors, the platform's fixed sample minimums (read-only; no pipeline sets them): first_version_samples (what a new pipeline's first training needs at the default automatic-test share, the automatic test's samples included), new_samples (each later training waits for new_samples new samples, 1,000, counted since the last training, however many samples that training learned from; where a pipeline builds on its live version -- its chain is set -- that training builds on the live version and learns what it has not learned yet, and also goes over samples it already learned so it does not forget them (about half of what it trains on, picked at random); a version made that way is served as one model), and held_back (the automatic test: at least min_rows samples are kept aside, or the share a pipeline chooses from default_percent to max_percent if that is more; each training is tested on at most scored_rows of them, so a share that keeps aside more only keeps samples out of training). Offer the user a choice from this list; never type a model id from memory. unavailable lists the other models the platform knows, each with reason_code (not_trainable_sft_lora, not_servable_as_adapter, too_large_for_available_gpus, not_verified, registry_missing) and reason, one plain sentence: when the user asks for a model that is not in models, read its reason back rather than guessing why. 503 MODELS_UNAVAILABLE: the list could not be read just then; try again in a minute.", {}, async () => json(await client.api("/api/loop/models")));
3417
+ server.tool("loop_create_pipeline", "Create a pipeline: one use case, trained again whenever enough new samples carrying its tags exist (1,000 new ones for each training after the first; where the pipeline builds on its live version, each training builds on it and learns what it has not learned yet, and also goes over samples picked at random from what it already learned, about half of what it trains on, so it does not forget them). SPEND CONSENT: saving it authorises the platform to start trainings on the workspace's wallet with nobody present — whenever train_when is met it trains on GPUs the platform chooses, tests the training against the live version, and puts it live by the pipeline's going-live choice (the 'promotion' field). What is charged is the GPU time its trainings actually use and, where the platform scores samples, the judge's scoring of unreviewed samples, shown on the training it was for. Say both in plain words and WAIT FOR AN EXPLICIT YES before calling. Take model.id from loop_list_models, never from memory. Leave training out (automatic settings, recommended) unless the user asks for a setting. datasets (from loop_list_datasets) adds their samples to what its tags select and conditions narrows both; leave both out unless the user asks. The judge's scoring of unreviewed samples is on unless auto_scoring.enabled is false. Where the platform scores samples (the pipeline's auto_scoring is not null), while the pipeline is short of the samples its next training needs, the judge scores samples nobody reviewed and those scoring 0.8 or more are trained on. Nothing is saved when it is refused. 409 PIPELINE_NAME_TAKEN or OWN_TAG_TAKEN: choose another name. 422 NO_GPU_FITS, MODEL_NOT_TRAINABLE or PROMOTION_*: the message names the fix. 422 NO_JUDGE_MODEL: no listed model can score the trainings' tests; tell the user rather than retrying. 422 MODEL_REVISION_UNAVAILABLE: the model could not be pinned because the training service did not answer; try again shortly. 422 METHOD_NOT_ENABLED or CEILING_TOO_LOW: the platform cannot train this model now and nothing in the request changes that; tell the user. 409 TRAINING_NOT_ENABLED: the platform's training service would not take the pipeline while the platform's services were being updated; nothing was saved and nothing in the request is wrong, so try again in a few minutes. 503 MODELS_UNAVAILABLE, GPU_OPTIONS_UNAVAILABLE or AGENT_COULD_NOT_START: a service the save needs did not answer just then; try again in a minute.", pipelineInputShape, async (input) => json(await client.api("/api/loop/pipelines", { method: "POST", body: input })));
3418
+ server.tool("loop_update_pipeline", "Edit a pipeline: its name, use case, model, second model, tags, datasets, conditions, the judge's scoring of unreviewed samples (auto_scoring), when to train, the automatic test's share, how a new version goes live, version limit, or switch it off and on. Send ONLY what changes: datasets [] takes every dataset off, conditions null clears them, and a change to either applies from the next training (nothing is trained again; versions already made keep what they learned); an absent key leaves it alone, and null clears max_versions (no limit), challenger_model (no second model), train_when.schedule (train as soon as the minimum is met), training (every training setting back to automatic) or one key inside training (that setting back to automatic). A change to the training settings applies from the next training; 422 TRAINING_SETTING_OUT_OF_RANGE names the field and its range. Saving is the authorization, as on a create: a change that makes trainings cost more (a bigger model, switching it back on, turning auto_scoring back on) is SPEND CONSENT, so say so first. A larger automatic-test share is not SPEND CONSENT (nothing is asked again and the limits do not move), and what it changes depends on how many are kept aside: while fewer than floors.held_back.scored_rows (1,500) are, each training can score more of them, paid from the wallet; once that many are, a larger share scores nothing more and costs nothing more, and only keeps more new samples out of training, for good; and a share that still comes to the floor of floors.held_back.min_rows (100) changes nothing. Say whichever applies, never that a raise always costs more. Pass expected_revision (the pipeline's revision as you read it) to be refused REVISION_MISMATCH rather than overwrite someone else's edit; REVISION_MISMATCH also comes back, sent or not, when the pipeline changed while the edit was being saved (a version went live, which can change its models): nothing was saved, so read it again and redo the edit. A model change while a training is running is refused RUN_ACTIVE. 409 NOT_A_SIMPLE_PIPELINE: this pipeline was created before pipelines had tags, a use case or a second model, so those three cannot be edited; everything else can. Every other refusal is loop_create_pipeline's, with the same meaning.", {
3298
3419
  pipeline_id: z.string().describe("The pipeline's id (rule_id), from loop_list_pipelines"),
3299
3420
  ...pipelineInputShape,
3300
3421
  expected_revision: z.number().int().min(0).optional().describe("The revision you read"),
@@ -3302,16 +3423,16 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3302
3423
  method: "PUT",
3303
3424
  body,
3304
3425
  })));
3305
- server.tool("loop_delete_pipeline", "Delete a pipeline for good. An attempt still running is cancelled; the deployment its live version answers from is stopped and deleted, so the live version stops answering and is no longer billed, unless another pipeline or model name of yours answers from it too. If one does, that deployment keeps running and billed until you stop it on Deployments, and its trained checkpoint is kept (and billed as storage) until that deployment is deleted. Its history is deleted in the background after the call returns: the checkpoints its attempts trained, the sets they trained on, and every conversation of their comparison and benchmark reports. 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, and say that the live version stops answering and the history is deleted.", { pipeline_id: z.string() }, async ({ pipeline_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}`, { method: "DELETE" })));
3306
- 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. With a rebuild on its way (chain.rebuild: a member deleted a conversation the live version learned from), Train now starts the rebuild: a retrain from the base on every remaining sample, with no wait for new ones, at the version limit too (each rebuild that goes live is a version, so the pipeline can end past its limit). 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, unless message says no month fits (then read message back: a smaller model, or a save). 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`, {
3426
+ server.tool("loop_delete_pipeline", "Delete a pipeline for good. A training still running is cancelled; the deployment its live version answers from is stopped and deleted, so the live version stops answering and is no longer billed, unless another pipeline or model name of yours answers from it too. If one does, that deployment keeps running and billed until you stop it on Deployments, and its saved model copy is kept (and billed as storage) until that deployment is deleted. Its history is deleted in the background after the call returns: the saved copies its trainings made, the sets they trained on, and every conversation of their test and benchmark reports. Its samples and tags are kept. Recording from the source named by its pipeline ID 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, and say that the live version stops answering and the history is deleted.", { pipeline_id: z.string() }, async ({ pipeline_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}`, { method: "DELETE" })));
3427
+ server.tool("loop_run_pipeline", "Train now: start a training 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: a training is already running. A training waiting for GPUs to be tested (active_run.state waiting_gpus) is the one exception: Train now books its two test machines again at once and answers 202; nothing is trained again. With a rebuild on its way (chain.rebuild: a member deleted a conversation the live version learned from), Train now starts the rebuild: a retrain from the original model on every remaining sample, with no wait for new ones, at the version limit too (each rebuild that goes live is a version, so the pipeline can end past its limit). 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, unless message says no month fits (then read message back: a smaller model, or a save). 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 a training could not be tested 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 training's test 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`, {
3307
3428
  method: "POST",
3308
3429
  body: {},
3309
3430
  })));
3310
- server.tool("loop_import_into_pipeline", "loop_import_rows with every row given this pipeline's own tag, so the file becomes this pipeline's samples. source defaults to that tag. default_feedback 'good' records every answered row that brought no verdict of its own as a good example to learn from — use it only when the user says the file's answers are good; without it such rows wait for review. The result, the retry token (import_id) and the 5000-row limit are loop_import_rows' own.", {
3431
+ server.tool("loop_import_into_pipeline", "loop_import_rows with every row given this pipeline's ID (own_tag), so the file becomes this pipeline's samples. source defaults to the pipeline ID. default_feedback 'good' records every answered row that brought no feedback of its own as a good example to learn from — use it only when the user says the file's answers are good; without it such rows wait for review. The result, the retry token (import_id) and the 5000-row limit are loop_import_rows' own.", {
3311
3432
  pipeline_id: z.string(),
3312
3433
  rows: z.array(z.record(z.string(), z.any())).describe("The rows, already parsed, at most 5000, each in the shape it already has"),
3313
- default_feedback: z.enum(["good"]).optional().describe("'good' makes every answered row without its own verdict a good example"),
3314
- source: z.string().optional().describe("Where the rows came from. Defaults to the pipeline's own tag."),
3434
+ default_feedback: z.enum(["good"]).optional().describe("'good' makes every answered row without its own feedback a good example"),
3435
+ source: z.string().optional().describe("Where the rows came from. Defaults to the pipeline ID."),
3315
3436
  model: z.string().optional(),
3316
3437
  labels: z.array(z.string()).optional().describe("More tags for every row, beside the pipeline's own"),
3317
3438
  attributes: z.record(z.string(), z.string()).optional(),
@@ -3350,6 +3471,21 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3350
3471
  params: { limit: limit?.toString(), offset: offset ? offset.toString() : undefined },
3351
3472
  })));
3352
3473
  server.tool("loop_get_dataset", "One saved dataset: its conditions, its revision, how many samples it selects now by feedback (samples is the same number loop_list_traces with dataset totals), in_benchmarks, and the pipelines that train on it (used_by).", { dataset_id: z.string().describe("The dataset's id, from loop_list_datasets") }, async ({ dataset_id }) => json(await client.api(`/api/loop/datasets/${encodeURIComponent(dataset_id)}`)));
3474
+ server.tool("loop_export_dataset", "A dataset's samples as lines in the import's chat format, oldest first: each with sample_id, messages (the conversation, the model's answer last) -- or prompt, the question, for a sample nobody answered -- and the feedback a person gave in the import's words -- label true (good), label false (wrong), correction (wrong, with the right answer), ground_truth (the right answer given as the gold answer, or written on a question nobody answered). No feedback writes none of them. The lines can be sent back with loop_import_rows as they are. At most limit lines (100 when absent, at most 1000); for the whole file use the console's Download or the SDK's exportDataset. stored_elsewhere is how many of the dataset's samples have no conversation kept with them on the platform (kept in the workspace's own storage, or none at all), the most that can be left out: any of them that could not be read just now are left out of the lines, so when it is above 0 say that some samples may be missing and why; at 0 none can be. It writes nothing.", {
3475
+ dataset_id: z.string().describe("The dataset's id, from loop_list_datasets"),
3476
+ limit: z.number().int().min(1).max(1000).optional().describe("How many samples, 100 when absent, at most 1000"),
3477
+ }, async ({ dataset_id, limit }) => {
3478
+ const { text, headers } = await client.apiText(`/api/loop/datasets/${encodeURIComponent(dataset_id)}/export`, {
3479
+ params: { format: "jsonl", limit: String(limit ?? 100) },
3480
+ });
3481
+ const lines = text.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
3482
+ const elsewhere = headers.get("X-Loop-Stored-Elsewhere");
3483
+ return json({
3484
+ lines,
3485
+ returned: lines.length,
3486
+ stored_elsewhere: elsewhere != null && /^\d+$/.test(elsewhere) ? Number(elsewhere) : null,
3487
+ });
3488
+ });
3353
3489
  server.tool("loop_preview_dataset", "Count what conditions would select, before saving anything: samples by feedback, in_benchmarks, and the 20 models that answered most of them. datasets adds saved datasets' samples, as a pipeline adds them, so a pipeline's tags, datasets and conditions can be counted in one call. The counts are before duplicates and over-long conversations are set aside: say so, and use the pipeline's own minimums for exact training numbers. It writes nothing.", {
3354
3490
  ...datasetConditionsShape,
3355
3491
  datasets: z.array(z.string()).optional().describe("Saved dataset ids whose samples are added to what the conditions select"),
@@ -3358,7 +3494,7 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3358
3494
  name: z.string().min(1).max(80).describe("What the dataset is called, 1 to 80 characters, unique in the workspace"),
3359
3495
  ...datasetConditionsShape,
3360
3496
  }, async (body) => json(await client.api("/api/loop/datasets", { method: "POST", body })));
3361
- server.tool("loop_update_dataset", "Change a saved dataset: send ONLY what changes (null clears origin, model, from or to; [] clears tags or feedback). Every pipeline in its used_by trains on the new selection from its next attempt: samples that join count as new samples and can bring a pipeline to its next attempt, which is paid from the wallet, so widening a dataset a pipeline uses is SPEND CONSENT for that pipeline: say which pipelines it changes first and wait for a yes. Nothing is trained again and versions already made keep what they learned. Pass expected_revision to be refused REVISION_MISMATCH rather than overwrite someone else's edit.", {
3497
+ server.tool("loop_update_dataset", "Change a saved dataset: send ONLY what changes (null clears origin, model, from or to; [] clears tags or feedback). Every pipeline in its used_by trains on the new selection from its next training: samples that join count as new samples and can bring a pipeline to its next training, which is paid from the wallet, so widening a dataset a pipeline uses is SPEND CONSENT for that pipeline: say which pipelines it changes first and wait for a yes. Nothing is trained again and versions already made keep what they learned. Pass expected_revision to be refused REVISION_MISMATCH rather than overwrite someone else's edit.", {
3362
3498
  dataset_id: z.string().describe("The dataset's id, from loop_list_datasets"),
3363
3499
  name: z.string().min(1).max(80).optional(),
3364
3500
  ...datasetConditionsShape,
@@ -3367,23 +3503,24 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3367
3503
  method: "PUT",
3368
3504
  body,
3369
3505
  })));
3370
- server.tool("loop_delete_dataset", "Delete a saved dataset for good. Its samples stay. Every pipeline that trained on it (removed_from) stops taking its samples from its next attempt; versions already made keep what they learned. Confirm with the user first, naming the pipelines in its used_by.", { dataset_id: z.string().describe("The dataset's id, from loop_list_datasets") }, async ({ dataset_id }) => json(await client.api(`/api/loop/datasets/${encodeURIComponent(dataset_id)}`, { method: "DELETE" })));
3371
- 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. result filters by what became of an attempt, in loop_list_pipeline_attempts' words, and each row carries attempt: the attempt as its pipeline's own list says it (outcome, cost, pass rates and explanation, the plain words to read back; null for a run that is not a numbered attempt). 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'. It is false again once billing has settled every such machine: the run's timeline then has one line per machine saying what billing charged for the time it served and what it had been counted at (and why, when billing's figure is the higher one), so read that line back when a cost changed. The new model's machine of a run that went live (a version) or that a person took over is never settled, so that run stays 'up to'. comparison_returned_cents above 0 means a comparison machine never started (never reached running) and was not charged -- the platform bills it nothing, and the figures beside it are already net of it -- so say 'Comparison machine: not charged (it never started)' -- or, while a comparison figure above 0 remains beside it, that the one that never started was not charged and the one that ran was -- never that it was charged.", {
3372
- rule_id: z.string().optional().describe("Only this pipeline's attempts (its id, from loop_list_pipelines)"),
3373
- state: z.string().optional().describe("Only runs in this state, e.g. 'awaiting_review'"),
3506
+ server.tool("loop_delete_dataset", "Delete a saved dataset for good. Its samples stay. Every pipeline that trained on it (removed_from) stops taking its samples from its next training; versions already made keep what they learned. Confirm with the user first, naming the pipelines in its used_by.", { dataset_id: z.string().describe("The dataset's id, from loop_list_datasets") }, async ({ dataset_id }) => json(await client.api(`/api/loop/datasets/${encodeURIComponent(dataset_id)}`, { method: "DELETE" })));
3507
+ server.tool("loop_get_training_curve", "How one training went while it trained, step by step: train_loss is its mistakes on the samples it trained on and check_loss its mistakes on the practice questions it set aside and did not train on (lower is better), at most 200 points, every practice check among them. kept_step is the step whose model it kept: say 'it kept the model from step N, where it did best on the practice questions', or, when keeps_last is true, 'it kept its last step: for this model practice mistakes can rise while it is still learning facts, so the test results are what count'. trained_steps and planned_steps say how far it got. When points is empty, read reason back as written. 503 TRAINING_CURVE_UNAVAILABLE: it could not be read just now; try again in a minute. It writes nothing.", { run_id: z.string().describe("The training's id, from loop_list_pipeline_attempts or loop_list_training_runs") }, async ({ run_id }) => json(await client.api(`/api/loop/training-runs/${encodeURIComponent(run_id)}/training-curve`)));
3508
+ server.tool("loop_list_training_runs", "Every training of every pipeline, newest first: what state it is in, what it has spent, and what its test said. result filters by what became of a training, in loop_list_pipeline_attempts' words, and each item carries 'attempt': the training as its pipeline's own list says it (outcome, cost, pass rates and explanation, the plain words to read back; null for one that is not a numbered training). A training waiting in 'awaiting_review' is waiting for a PERSON — it holds a newly trained model whose machine is being paid for by the minute, so surface those first when the user asks what needs attention. A training in 'waiting_gpus' trained and is waiting for GPUs to serve the new model and what it is tested against (the live version, or before v1 the original model) side by side for its test: 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 start), and Train now tries again at once. billed_candidate_up_to true means billed_candidate_cents is a ceiling: a test 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 'at most', never 'spent'. It is false again once billing has settled every such machine: the training's timeline then has one line per machine saying what billing charged for the time it served and what it had been counted at (and why, when billing's figure is the higher one), so read that line back when a cost changed. The new model's machine of a training that went live (a version) or that a person took over is never settled, so that training stays 'at most'. comparison_returned_cents above 0 means a test machine never started (never reached running) and was not charged -- the platform bills it nothing, and the figures beside it are already net of it -- so say 'Test machine: not charged (it never started)' -- or, while a comparison figure above 0 remains beside it, that the one that never started was not charged and the one that ran was -- never that it was charged.", {
3509
+ rule_id: z.string().optional().describe("Only this pipeline's trainings (its id, from loop_list_pipelines)"),
3510
+ state: z.string().optional().describe("Only trainings in this state, e.g. 'awaiting_review'"),
3374
3511
  result: z.enum(["became_version", "not_better", "inconclusive", "not_tested", "failed", "stopped", "running", "waiting_for_review"]).optional()
3375
- .describe("Only the attempts that ended this way, as loop_list_pipeline_attempts filters them; running includes one waiting for a decision"),
3512
+ .describe("Only the trainings that ended this way, as loop_list_pipeline_attempts filters them; running includes one waiting for a decision"),
3376
3513
  limit: z.number().int().min(1).max(200).optional(),
3377
3514
  offset: z.number().int().min(0).optional(),
3378
3515
  }, async ({ rule_id, state, result, limit, offset }) => json(await client.api("/api/loop/training-runs", {
3379
3516
  params: { rule_id, state, result, limit: limit?.toString(), offset: offset?.toString() },
3380
3517
  })));
3381
- server.tool("loop_get_training_run", "One run in full: the base model it trained (base_model_id, model_family) and the recipe, how the platform trained it (training_summary, the service's own sentence -- the steps it took, the passes planned over its samples and the checkpoint it kept; read it back as written, and trained_steps is the steps the trainer counted, never an estimate; both null until its job ended, and on an attempt from before), the rows it trained on and held back (lineage says what it was built on: trained_from versions -- a new adapter on top of built_on's versions merged into the base -- or base, with from_base_reason; and train_rows = new_samples + carried_samples, from attempts that did not go live, + replayed_samples, drawn from what those versions learned (the counts are null on a run from before the chain, or of a pipeline that does not build on its live version: say only train_rows then); error_code CHAIN_MOVED means it was built on a version that was rolled back while it trained, so it was set aside with no comparison paid and its samples go to the next attempt, NO_MERGED_EXPORT that its training finished without the merged model it is served as, TRAINING_SERVICE_BEHIND that the platform's training service was being updated when it asked for its training, so it stopped before any training started with nothing charged and the next attempt asks again (STEP_STALLED:TRAINING_SERVICE_BEHIND: the same after six hours of waiting for that update; the pipeline tries again on its own), and ENV_GATED:SWITCHED_OFF that the Conscious Loop was switched off on the platform before its training started, so it stopped there with no GPU time charged), 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'; it is false again once billing has settled every such machine, and the timeline then says, one line per machine, what billing charged for the time it served and what it had been counted at -- read that line back when a cost changed; the new model's machine of a run that went live (a version) or that a person took over is never settled, so that run stays 'up to'; comparison_returned_cents above 0 means a comparison machine never started (never reached running) and was not charged -- the platform bills it nothing, and the figures beside it are already net of it -- so say 'Comparison machine: not charged (it never started)' -- or, while a comparison figure above 0 remains beside it, that the one that never started was not charged and the one that ran was -- never that it was charged), the timeline of every state it passed through and why, links to the job, the candidate and the comparison, benchmark_runs (every benchmark it was replayed on; each run_id is the replay's id for loop_get_benchmark_run and loop_list_benchmark_run_items), promotion (outcome as the pipeline's attempts list says it; decision, where rejected means a member chose not to publish it even when the verdict says better, auto_rejected that the pipeline's going-live choice turned it down on its own, and expired that nobody decided before the review deadline -- an expired attempt whose verdict is better was better, never say the pipeline refused it; and the going-live choice's reason and every measurement it read, each better, not_better, worse or not_measured with its reason -- null and empty when nothing was recorded), verdict_checks (every bar the held-back comparison had to clear -- conversations compared, failures, a rubric set, mean change, win rate, and the chance a model no better would win this often -- each with needed, got and met; null until the comparison is finished, when no thresholds were recorded, and for a report decided under earlier bars that these would not explain: when the user asks why an attempt did not go live, read the bars that are not met back in these words), verdict_checks_withheld (decided_under_earlier_rule when a finished report's bars are withheld because an earlier version of the platform's bars decided it, null otherwise: then say the comparison was decided under an earlier version of the platform's bars, so its bars are not listed -- its verdict stands as recorded; never say they were not recorded, and never work them out from today's bars), cost (training_cents, comparison_cents, scoring_cents, benchmark_cents, sample_scoring_cents -- what the judge's scoring of unreviewed samples cost before the attempt -- and total_cents, the attempt's cost_cents on the pipeline; up_to true means comparison_cents and the total are 'up to', counted from the comparison machines' first boot, and false once billing has settled every one of them (the timeline says what each was charged); comparison_returned_cents is what was taken off comparison_cents for a machine that never started: say it was not charged, and that comparison_cents above 0 is the one that ran), explanation (what became of it in plain words, as on the pipeline's attempts: read it back rather than the codes), run.trained_on (what it trained on as it was then: the pipeline's tags, its conditions and each dataset at its revision; null when its set is gone), traces_filter_attempt (true: loop_list_traces with attempt and split lists its samples), 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)}`)));
3382
- 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, each with its outcome, trigger and decision -- rejected means a member chose not to publish it, where outcome says stopped, and expired that nobody decided before the review deadline; auto_rejected that the pipeline's going-live choice turned it down on its own; an expired attempt whose verdict is better was better: never say the pipeline refused it -- and train_rows and holdout_rows, the samples it trained on and held back; each attempt is judged on at most scored_rows (1,500) of the held-back samples, the same ones every attempt), which one serves the app today (live_version, null when what serves is not one of its versions) and whether its deployment still answers (live_deployment.status: serving answers; starting is on its way up; stopped, paused_for_funds or deleted answers nothing and is not billed; paused_by_platform was paused because billing could not be reached and answers nothing; failed answers nothing; live_deployment null means no check is recorded yet or what serves is not a deployment -- never say a live version with any status but serving is answering the app), 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': 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'. It is false again once billing has settled every such machine (the attempt's timeline says what billing charged each one for the time it served). A version's own new-model machine is the one that went live and is never settled, so a version whose machine was charged stays 'up to'. On an attempt or active_run, comparison_returned_cents above 0 means a comparison machine never started (never reached running) and was not charged -- the platform bills it nothing, and the figures beside it are already net of it -- so say 'Comparison machine: not charged (it never started)' -- or, while a comparison figure above 0 remains beside it, that the one that never started was not charged and the one that ran was -- never that it was charged. 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. A monthly safety limit, set by the platform from the model's prices, can hold a pipeline until the 1st of next month (UTC): next_reason then says so, and Train now answers 409 MONTHLY_LIMIT_REACHED with resumes_at. It has no field and no figure to read back. 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 (100 unless limit says, at most 100), newest first: total is how many pipelines match 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. q finds pipelines by name, tag or model; status keeps those training, waiting on the user (needs_you), serving or paused. Each pipeline's attempts[] here is its latest 5 and its versions[] the latest few and the live one (attempts_total and versions_total say how many there are); loop_get_pipeline has every version, and loop_list_pipeline_attempts reads every attempt. Each attempt's explanation is its outcome in plain words (headline, detail, next): read those back rather than the codes. Each pipeline carries samples_total and samples_recent (recorded in the last 48 hours), and minimums with have and short, as loop_get_pipeline does.", {
3518
+ server.tool("loop_get_training_run", "One training in full: whether it was tested (tested: its comparison measured something -- a training never tested can never go live, and available_actions.retest says loop_retest_training_run can test it again), the model it trained (base_model_id, model_family) and the recipe, how the platform trained it (training_summary, the service's own sentence -- the steps it took, the passes planned over its samples and the step it kept; read it back as written, and trained_steps is the steps the trainer counted, never an estimate; both null until its training ended, and on a training from before), the samples it learned from and the ones kept aside for its automatic test ('lineage' says what it was built on: trained_from versions -- it builds on built_on's versions -- or base, from the original model, with from_base_reason; and train_rows = new_samples + carried_samples, from trainings that did not become a version, + replayed_samples, samples those versions already learned, gone over again so they are not forgotten (the counts are null on a training from before trainings built on earlier versions, or of a pipeline that does not build on its live version: say only train_rows then); say them as 'learned N new samples', 'N from training #5, which did not become a version' and 'went over N samples it had learned before'; error_code CHAIN_MOVED means it was built on a version that was rolled back while it trained, so it was set aside with no test paid and its samples go to the next training, NO_MERGED_EXPORT that its training finished without the single model it is served as, TRAINING_SERVICE_BEHIND that the platform's training service was being updated when it asked for its training, so it stopped before any training started with nothing charged and the next training asks again (STEP_STALLED:TRAINING_SERVICE_BEHIND: the same after six hours of waiting for that update; the pipeline tries again on its own), STEP_STALLED:SERVING_NOT_CERTIFIED that the platform had not yet checked it can test new versions of this model on its current serving engine, so it waited six hours before its training and stood down with nothing charged and the pipeline not paused (its next training waits the same way until that check is done; CHECKPOINT_NOT_DEPLOYABLE:SERVING_NOT_CERTIFIED is the same found at its comparison, after it trained), and ENV_GATED:SWITCHED_OFF that the Conscious Loop was switched off on the platform before its training started, so it stopped there with no GPU time charged), its GPU time and what it was billed (billed_candidate_up_to true means billed_candidate_cents is a ceiling: a test 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 'at most', never 'spent'; it is false again once billing has settled every such machine, and the timeline then says, one line per machine, what billing charged for the time it served and what it had been counted at -- read that line back when a cost changed; the new model's machine of a training that went live (a version) or that a person took over is never settled, so that training stays 'at most'; comparison_returned_cents above 0 means a test machine never started (never reached running) and was not charged -- the platform bills it nothing, and the figures beside it are already net of it -- so say 'Test machine: not charged (it never started)' -- or, while a comparison figure above 0 remains beside it, that the one that never started was not charged and the one that ran was -- never that it was charged), the timeline of every state it passed through and why, links to the training itself, the newly trained model and its test, benchmark_runs (every benchmark it was tested on; each run_id is that benchmark run's id for loop_get_benchmark_run and loop_list_benchmark_run_items), 'promotion' (outcome as the pipeline's trainings list says it; decision, where rejected means a member chose not to publish it even when the result says better, auto_rejected that the pipeline's going-live choice turned it down on its own, and expired that nobody decided before the review deadline -- an expired training whose result is better was better, never say the pipeline refused it; and the going-live choice's reason and every measurement it read, each better, not_better, worse or not_measured with its reason -- null and empty when nothing was recorded), verdict_checks (every bar the automatic test had to clear -- questions compared, failures, a rubric set, mean change, how often the new answer scored higher, and the chance a model no better would score higher this often -- each with needed, got and met; null until the test is finished, when no thresholds were recorded, and for a report decided under earlier bars that these would not explain: when the user asks why a training did not go live, read the bars that are not met back in these words), verdict_checks_withheld (decided_under_earlier_rule when a finished report's bars are withheld because an earlier version of the platform's bars decided it, null otherwise: then say the test was decided under an earlier version of the platform's bars, so its bars are not listed -- its result stands as recorded; never say they were not recorded, and never work them out from today's bars), cost (training_cents, comparison_cents, scoring_cents, benchmark_cents, sample_scoring_cents -- what the judge's scoring of unreviewed samples cost before the training -- and total_cents, the training's cost_cents on the pipeline; up_to true means comparison_cents and the total are 'at most' figures, counted from the test machines' first boot, and false once billing has settled every one of them (the timeline says what each was charged); comparison_returned_cents is what was taken off comparison_cents for a machine that never started: say it was not charged, and that comparison_cents above 0 is the one that ran), explanation (what became of it in plain words, as on the pipeline's trainings list: read it back rather than the codes), model_role, builds_on_version, compared_with_version, compared_with and missed_bar (as loop_list_pipeline_attempts says them), run.trained_on (what it trained on as it was then: the pipeline's tags, its conditions and each dataset at its revision; null when its set is gone), traces_filter_attempt (true: loop_list_traces with 'attempt' and 'split' lists its samples), and available_actions — which says what THIS reader may actually do next. Use available_actions rather than guessing from the state: a training can look ready to go live and not be, because the consent moved or the newly trained model is already gone.", { run_id: z.string() }, async ({ run_id }) => json(await client.api(`/api/loop/training-runs/${encodeURIComponent(run_id)}`)));
3519
+ server.tool("loop_list_pipelines", "Every pipeline in the workspace: the versions it has made (a version is a training that went live; attempts[] lists every training, whether it became a version or not, each with its outcome, trigger and decision -- rejected means a member chose not to publish it, where outcome says stopped, and expired that nobody decided before the review deadline; auto_rejected that the pipeline's going-live choice turned it down on its own; an expired training whose result is better was better: never say the pipeline refused it -- and train_rows and holdout_rows, the samples it learned from and the ones kept aside for its automatic test; each training is tested on at most scored_rows (1,500) of the automatic test's samples, the ones kept aside longest first; a sample once kept aside stays kept aside, so later trainings are mostly asked the same questions, plus newer ones while fewer than 1,500 are kept aside), which one serves the app today -- the live version, the version the app gets answers from right now (live_version, null when what serves is not one of its versions) and whether its deployment still answers (live_deployment.status: serving answers; starting is on its way up; stopped, paused_for_funds or deleted answers nothing and is not billed; paused_by_platform was paused because billing could not be reached and answers nothing; failed answers nothing; live_deployment null means no check is recorded yet or what serves is not a deployment -- never say a live version with any status but serving is answering the app), how each version did against what it was tested against (the live version of its day, or before v1 the original model) and on the standing benchmark, versions_made against max_versions, the training in progress 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 a training whose active_run.state is waiting_gpus: it trained, but no GPUs were free to serve the new model and the one it is tested against 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 start), and Train now tries again at once. cost_up_to true on a training, active_run or a version means its cost_cents is a ceiling: a test 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 'at most', never 'spent'. It is false again once billing has settled every such machine (the training's timeline says what billing charged each one for the time it served). A version's own new-model machine is the one that went live and is never settled, so a version whose machine was charged stays 'at most'. On a training or active_run, comparison_returned_cents above 0 means a test machine never started (never reached running) and was not charged -- the platform bills it nothing, and the figures beside it are already net of it -- so say 'Test machine: not charged (it never started)' -- or, while a comparison figure above 0 remains beside it, that the one that never started was not charged and the one that ran was -- never that it was charged. 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. A monthly safety limit, set by the platform from the model's prices, can hold a pipeline until the 1st of next month (UTC): next_reason then says so, and Train now answers 409 MONTHLY_LIMIT_REACHED with resumes_at. It has no field and no figure to read back. A pipeline trains only on new samples: it never retrains the conversations a training already had with a recipe changed. The list is a page (100 unless limit says, at most 100), newest first: total is how many pipelines match 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. q finds pipelines by name, pipeline ID or model; status keeps those training, waiting on the user (needs_you), serving or paused. Each pipeline's attempts[] here is its latest 5 trainings and its versions[] the latest few and the live one (attempts_total and versions_total say how many there are); totals is every training it made, as on loop_get_pipeline; loop_get_pipeline has every version, and loop_list_pipeline_attempts reads every training. Each training's explanation is its outcome in plain words (headline, detail, next): read those back rather than the codes. Each pipeline carries samples_total and samples_recent (recorded in the last 48 hours), and minimums with have and short, as loop_get_pipeline does. second_model is as loop_get_pipeline describes it, except that here it can also be null when where the second model stands could not be read just then: when challenger_model still names one, say its state could not be read, never that the pipeline has no second model.", {
3383
3520
  limit: z.number().int().min(1).max(100).optional().describe("How many pipelines, 100 when absent, at most 100"),
3384
3521
  offset: z.number().int().min(0).optional().describe("How many of the newest pipelines to skip"),
3385
- q: z.string().max(200).optional().describe("A case-insensitive substring of the name, the tag or the model, at most 200 characters"),
3386
- status: z.enum(["training", "needs_you", "serving", "paused"]).optional().describe("training: an attempt is running. needs_you: waiting on the user (a review or funds). serving: its live version answers. paused: switched off or paused."),
3522
+ q: z.string().max(200).optional().describe("A case-insensitive substring of the name, the pipeline ID or the model, at most 200 characters"),
3523
+ status: z.enum(["training", "needs_you", "serving", "paused"]).optional().describe("training: a training is running. needs_you: waiting on the user (a review or funds). serving: its live version answers. paused: switched off or paused."),
3387
3524
  }, async ({ limit, offset, q, status }) => json(await client.api("/api/loop/pipelines", {
3388
3525
  params: {
3389
3526
  limit: limit?.toString(),
@@ -3392,12 +3529,12 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3392
3529
  status,
3393
3530
  },
3394
3531
  })));
3395
- server.tool("loop_list_pipeline_attempts", "A pipeline's attempts, newest first, a page at a time (50 unless limit says, at most 200): every training run it made, winner or not, each with the fields attempts[] has on loop_get_pipeline -- its outcome, decision, lineage, the samples it trained on and held back, its held-back pass rates (the share each side passed, score 0.70 or more), what it cost (cost_up_to true means 'up to', never 'spent', and it is false again once billing has settled every comparison machine charged in it, whose timeline says what each was charged; comparison_returned_cents above 0 means a comparison machine never started and was not charged: say 'Comparison machine: not charged (it never started)', or, while the cost still counts a comparison machine that ran, that the one that never started was not charged) -- and explanation, its outcome in plain words: headline, detail and next (what happens now; null when there is nothing to do). Read the explanation back rather than the codes. total is how many match the filters; pass offset for the next page. result keeps the attempts that became_version, were not_better, inconclusive (tested, could not decide), not_tested (ended before it was tested), failed, stopped, or are running (waiting_for_review included) or waiting_for_review; model keeps the pipeline's own model's attempts (main) or its second model's (second). Read-only.", {
3532
+ server.tool("loop_list_pipeline_attempts", "A pipeline's trainings (in the API's names, 'attempt'), newest first, a page at a time (50 unless limit says, at most 200): every training it ran, whether it became a version or not, each with the fields attempts[] has on loop_get_pipeline -- its outcome, decision, 'lineage', the samples it learned from and the ones kept aside for its automatic test, its automatic-test pass rates (the share each side passed, score 0.70 or more), what it cost (cost_up_to true means the cost is 'at most', never 'spent', and it is false again once billing has settled every test machine charged in it, whose timeline says what each was charged; comparison_returned_cents above 0 means a test machine never started and was not charged: say 'Test machine: not charged (it never started)', or, while the cost still counts a test machine that ran, that the one that never started was not charged) -- and explanation, its outcome in plain words: headline, detail and next (what happens now; null when there is nothing to do). Read the explanation back rather than the codes. Each also says model_role (main, or second for the pipeline's second model), builds_on_version (the version it built on; null when it trained from the original model), compared_with_version and compared_with (what its automatic test asked as the opponent: version with that version's number, original for the original model while no version was live, null when it was not tested or nothing recorded which -- say 'v4', never 'the model it was compared with'), and missed_bar {key, what, needed, got} for not_better and 'inconclusive': the first bar its test missed, with what in plain words -- read what back when the user asks why a training that got more right was still not better (null when the pipeline's Going live setting decided). total is how many match the filters; pass offset for the next page. result keeps the trainings that became_version, were not_better, 'inconclusive' (tested, could not decide), not_tested (ended before it was tested), failed, stopped, or are running (waiting_for_review included) or waiting_for_review; model keeps the main model's trainings (main) or the second model's (second). Read-only.", {
3396
3533
  pipeline_id: z.string().describe("The pipeline's id (rule_id), from loop_list_pipelines"),
3397
- limit: z.number().int().min(1).max(200).optional().describe("How many attempts, 50 when absent, at most 200"),
3398
- offset: z.number().int().min(0).optional().describe("How many of the newest attempts to skip"),
3399
- result: z.enum(["became_version", "not_better", "inconclusive", "not_tested", "failed", "stopped", "running", "waiting_for_review"]).optional().describe("Only the attempts that ended this way; running includes one waiting for a decision"),
3400
- model: z.enum(["main", "second"]).optional().describe("main: the pipeline's own model's attempts. second: its second model's."),
3534
+ limit: z.number().int().min(1).max(200).optional().describe("How many trainings, 50 when absent, at most 200"),
3535
+ offset: z.number().int().min(0).optional().describe("How many of the newest trainings to skip"),
3536
+ result: z.enum(["became_version", "not_better", "inconclusive", "not_tested", "failed", "stopped", "running", "waiting_for_review"]).optional().describe("Only the trainings that ended this way; running includes one waiting for a decision"),
3537
+ model: z.enum(["main", "second"]).optional().describe("main: the main model's trainings. second: the second model's."),
3401
3538
  }, async ({ pipeline_id, limit, offset, result, model }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/attempts`, {
3402
3539
  params: {
3403
3540
  limit: limit?.toString(),
@@ -3406,9 +3543,9 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3406
3543
  model,
3407
3544
  },
3408
3545
  })));
3409
- server.tool("loop_get_pipeline", "One pipeline in full, by its id: what it trains on (tags, the datasets it adds, and the conditions that narrow both), samples_total (samples recorded so far, the same samples loop_list_traces with pipeline lists) and samples_recent (in the last 48 hours), auto_scoring (the judge's scoring of its unreviewed samples, null where the platform does not score them: read its message back as written; picked, below_bar and unscored add up to its unreviewed samples), minimums.picked_by_judge (how many of the samples it has the judge picked), every version with its verdict, the decision a person made, its win rate, its pass rates on the held-back samples (candidate_pass_rate and incumbent_pass_rate: the share each side passed, score 0.70 or more) and benchmark score, what it cost and why it was made (trigger, in the same words as attempts[]), plus the run in flight. When the monthly safety limit holds it, next_reason says so; the limit has no field. 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 the app's serving name reaches now, with live_deployment.status saying whether its deployment answers (serving answers; starting is on its way up; stopped, paused_for_funds or deleted answers nothing and is not billed; paused_by_platform was paused because billing could not be reached and answers nothing; failed answers nothing; null when no check is recorded yet or what serves is not a deployment; live_deployment.deployment_id is the deployment to open); attempts[] is the pipeline's latest 20 training runs, winner or not (attempts_total says how many it made, and loop_list_pipeline_attempts pages through every one), each with explanation (its outcome in plain words: headline, detail, next -- read it back rather than the codes) and its outcome (became_version, not_better, inconclusive -- its comparison ran and could not decide, or compared nothing; its explanation says which and why --, not_tested -- it ended before it was tested --, stopped, failed, running, waiting_for_review), trigger (data, schedule, manual, challenger for the second model -- trained from its own base on all samples, never on this pipeline's versions -- or rebuild, a retrain from the base after a member deleted a conversation the live version learned from), decision (what was decided about it once compared, and by whom: promoted, auto_promoted, rejected, auto_rejected, expired or rolled_back; null while nothing has been -- rejected means a member chose not to publish it, where outcome says stopped, so say a member declined it rather than that it stopped; promoted that a member put it live and auto_promoted that the pipeline's going-live choice did; auto_rejected that the pipeline's going-live choice turned it down on its own; expired that nobody decided before the review deadline, so it was retired undecided -- an expired attempt whose verdict is better reads stopped and was better: never say the pipeline refused it), and train_rows and holdout_rows (the samples its set trained on and held back; null until its set was built). lineage on each version, attempt and the run in flight says what it was built on: trained_from versions (a new adapter trained on top of the live version: the pinned base with built_on's versions merged in, oldest first) or base (with from_base_reason: first_version, challenger, rebuild -- a deleted conversation when lineage.rebuild is true, or, when it is false, a live version already built on 64 versions, the most one attempt can build on -- model_cannot_build_on or chain_link_unreadable), and what it trained on -- train_rows = new_samples + carried_samples (samples of the attempts in carried_from_attempts, which did not go live, trained again) + replayed_samples (about half: as many as new_samples + carried_samples, or all of learned_before when that is fewer, drawn at random from what the versions it builds on already learned, so they are not forgotten); learned_before is what those versions had learned and was not trained again, except the replayed ones; served_as is adapter or merged (one model); learned_deleted_conversation true means it, or a version it was built on (merged into it), learned a conversation that was deleted, so it can no longer be rolled back to. The counts are null on an attempt from before the chain, or of a pipeline that does not build on its live version: say only train_rows then. Say it as 'built on v1 + v2, trained on 4,000 samples: 1,000 new, 1,000 from attempt 3 and 2,000 of what v1 + v2 learned, replayed' -- name the replayed part whenever replayed_samples is above 0, so the parts add up to train_rows. chain, when set, is the live version as the versions merged into it (versions, live one last), learned_samples and not_learned_samples (trained by attempts that did not go live, so trained again next), and rebuild when a deleted conversation the live version learned has a retrain from the base on its way; chain null means the pipeline does not build on its live version and every attempt trains from the base on all samples. Nothing in flight is a version yet. holdout is the pipeline's held-back share: percent (5 to 20), min_rows (at least that many samples are held back whatever the share), rows (how many it holds back today) and held_before (how many of rows an earlier run of the pipeline held back: they stay held back whatever became of that run and whatever the share says, so a lower share releases none of them; before the first version the first attempt needs held_before plus 1,000 to train on when that is more than the share asks for). Each attempt is judged on at most scored_rows (1,500) of the held-back samples, the same ones every attempt, so rows above that only keeps samples out of training. storage is what the pipeline's history takes up in storage (bytes, objects, where: platform, your_storage or both, and billed): every conversation, answer and scoring reason of its reports, and the training rows its attempts are built from, is kept there and never in the database; it counts toward the workspace's storage quota and is deleted with the pipeline. Only the part in the platform's storage is ever billed, as Conscious Loop storage at the storage rate with no daily minimum (each day's cost is added up and charged once it reaches a whole cent), and only when billed is true; the part in the workspace's own storage never is -- with billed false, say it is not billed. Deleting the pipeline deletes its history and stops that charge. Where the pipeline builds on its live version, the merged models its versions answer from (the live one's, and each one a rollback can still put back; an attempt that did not go live keeps its merged model, billed, until it is cleaned up within a day) are billed as model storage, like any trained model, and each version's own adapter is billed as checkpoint storage, like any training checkpoint; its reports, benchmark results and training rows are billed only as its storage says (billed). An active_run whose last_reason says it is waiting for storage is holding answers already paid for until storage can keep them; they are kept then without asking the models again. cost_up_to true on attempts[], active_run or versions[] means that cost_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'. It is false again once billing has settled every such machine (the attempt's timeline says what billing charged each one for the time it served). A version's own new-model machine is the one that went live and is never settled, so a version whose machine was charged stays 'up to'. On attempts[] and active_run, comparison_returned_cents above 0 means a comparison machine never started (never reached running) and was not charged -- the platform bills it nothing, and the figures beside it are already net of it -- so say 'Comparison machine: not charged (it never started)' -- or, while a comparison figure above 0 remains beside it, that the one that never started was not charged and the one that ran was -- never that it was charged. 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. training is the pipeline's training settings (epochs, learning_rate, lora_rank, lora_alpha, replay_percent), each null while automatic, and training_defaults what automatic means for its model (learning_rate, lora_rank, lora_alpha, replay_percent, and min_epochs to max_epochs); say 'automatic' for a null, never a guessed number. capture lists the calls sent with X-Loop-Pipeline in the last capture.days days that were not recorded, one row per tag (a call naming two tags is counted under each, so never add rows up as calls): under this pipeline's tags too_large or incomplete (the answer could not be kept), and under any tag no pipeline had no_pipeline (misspelt, or a deleted pipeline's); empty when every call was recorded. On attempts[], versions[] and active_run, sample_scoring_cents is what the judge's scoring of unreviewed samples cost before that attempt (already inside cost_cents, never on top), sample_scored and sample_picked what it scored and picked, and train_rows_by_judge how many of train_rows the judge picked rather than a person; each attempt's trained_on is what it trained on as it was then (its tags, conditions and each dataset at its revision).", { 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)}`)));
3410
- 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. Each says how it scores an answer (check: judge, the judge reading its rubric, or exact, the answer must match its right answer) and what its pinned set takes up in storage (storage: bytes, objects, where, billed), so no benchmark needs reading one by one to say either. A benchmark's pinned set is never billed: it is kept until the workspace is deleted, whatever is done with the benchmark, so billed is always false on it -- say it is not billed.", { pipeline_id: z.string() }, async ({ pipeline_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/benchmarks`)));
3411
- 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.", {
3546
+ server.tool("loop_get_pipeline", "One pipeline in full, by its id. Words to use with the user: a training is one numbered try at a better model (#1, #2 ...; in the API's names, 'attempt', and its id is run_id); a version is a training that went live (v1, v2 ... with no limit); the live version is the version the app gets answers from right now; the automatic test is questions from the pipeline's own samples that the model never trained on. What it trains on (tags, the datasets it adds, and the conditions that narrow both), samples_total (samples recorded so far, the same samples loop_list_traces with pipeline lists) and samples_recent (in the last 48 hours), auto_scoring (the judge's scoring of its unreviewed samples, null where the platform does not score them: read its message back as written; picked, below_bar and unscored add up to its unreviewed samples), minimums.picked_by_judge (how many of the samples it has the judge picked), every version with its result, the decision a person made, its win_rate (of the test questions where the two answers scored differently, the share where the new one scored higher), its pass rates on the automatic test (candidate_pass_rate for the new model and incumbent_pass_rate for what it was tested against: the live version then, or the original model before v1; the share each side passed, score 0.70 or more -- say it as 'right on 97% of its test (v4 got 85% on the same questions)', or before v1 'right on 97% of its test (the original model got 85% on the same questions)') and benchmark score, what it cost and why it was made (trigger, in the same words as attempts[]), plus the training in progress. When the monthly safety limit holds it, next_reason says so; the limit has no field. Read-only: putting a training live, rejecting it and going back to an earlier version stay on the training tools, and its settings on loop_update_pipeline. minimums says how many samples it has against what the next training 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[] holds ONLY the trainings that went live, numbered v1, v2 ... in the order they went live, each with its 'attempt' (training number), base_model_id and model_family -- and live_version is the one the app's model name reaches now, with live_deployment.status saying whether its deployment answers (serving answers; starting is on its way up; stopped, paused_for_funds or deleted answers nothing and is not billed; paused_by_platform was paused because billing could not be reached and answers nothing; failed answers nothing; null when no check is recorded yet or what serves is not a deployment; live_deployment.deployment_id is the deployment to open); attempts[] is the pipeline's latest 20 trainings, whether they became a version or not (attempts_total says how many it ran, and loop_list_pipeline_attempts pages through every one; each also carries model_role, builds_on_version, compared_with_version, compared_with and missed_bar, as loop_list_pipeline_attempts says them), totals is every training it made in one object -- trainings, trainings_finished, trainings_untested (trained but never measured), trainings_by_result, trainings_by_model {main, second}, versions, spent_cents (every training's cost_cents added up, plus scoring_not_yet_in_a_training_cents -- what scoring unreviewed samples cost that no training has taken yet: training and testing, not the live version's serving), spent_up_to (true when any of them is an upper bound), spent_so_far (true while the figure can still change -- a training running or waiting for a member, billing settling one, or scoring running) -- say the two together: neither '$X', only spent_up_to 'at most $X', only spent_so_far '$X so far' (never 'at most' for a figure still growing), both 'at most $X so far' -- spent_parts {training_cents, test_machine_cents, grading_cents, benchmark_cents, sample_scoring_cents} adding up to it, gpu_seconds, first_training_at and last_training_at -- so when the user asks how many trainings, how many versions or what the pipeline has cost in all, read totals rather than adding up attempts[], each with explanation (its outcome in plain words: headline, detail, next -- read it back rather than the codes) and its outcome (became_version, not_better, 'inconclusive' -- its test ran and could not decide, or compared nothing; its explanation says which and why --, not_tested -- it ended before it was tested --, stopped, failed, running, waiting_for_review), trigger (data, schedule, manual, 'challenger' for the second model -- each model keeps its own line of versions: the second model builds on its own latest version, never on the main model's, and trains from its original model on all samples while it has none; model_role says which model a training trained (main or second) -- or rebuild, a retrain from the original model after a member deleted a conversation the live version learned from), decision (what was decided about it once tested, and by whom: 'promoted', auto_promoted, rejected, auto_rejected, expired or rolled_back; null while nothing has been -- rejected means a member chose not to publish it, where outcome says stopped, so say a member declined it rather than that it stopped; 'promoted' that a member put it live and auto_promoted that the pipeline's going-live choice did; auto_rejected that the pipeline's going-live choice turned it down on its own; expired that nobody decided before the review deadline, so it was retired undecided -- an expired training whose result is better reads stopped and was better: never say the pipeline refused it), and train_rows and holdout_rows (the samples it learned from and the ones kept aside for its automatic test; null until its set was built). 'lineage' on each version, training and the training in progress says what it was built on: trained_from versions (it builds on the live version: the versions in built_on, oldest first, served together as one model) or base (from the original model, with from_base_reason: first_version, 'challenger' (the second model's first training), rebuild -- a deleted conversation when lineage.rebuild is true, or, when it is false, a model's own latest version already built on 64 versions, the most one training can build on, so that model's line starts again -- model_cannot_build_on, chain_link_unreadable, or line_learned_deleted: a version of that model's own line learned a sample a member deleted, so its line starts again without it), and what it trained on -- train_rows = new_samples + carried_samples (samples of the trainings in carried_from_attempts, which did not become a version, learned again) + replayed_samples (about half: as many as new_samples + carried_samples, or all of learned_before when that is fewer, picked at random from what the versions it builds on already learned and gone over again, so they are not forgotten); learned_before is what those versions had learned and was not trained again, except the ones gone over again; served_as says how it is served -- an expert detail, never needed in an answer; learned_deleted_conversation true means it, or a version it was built on, learned a conversation that was deleted, so it can no longer be gone back to. The counts are null on a training from before trainings built on earlier versions, or of a pipeline that does not build on its live version: say only train_rows then. Say it in these words: 'Builds on v4. Learned 3,000 new samples: 2,000 that arrived since v4 and 1,000 from training #3, which did not become a version. It also went over 3,000 samples it had learned before, so it does not forget them.' -- name the part it went over again whenever replayed_samples is above 0, so the parts add up to train_rows; never say 'replayed', 'on top of' or 'v1 + v2'. second_model, when the pipeline names one, is where the second model stands: state (waiting -- it trains next, or after the main model's next training; training; tested; skipped -- it did not train on the main model's newest samples and will not, a skip is never a training and costs nothing; held -- a money limit, the scoring key, a live version that cannot answer, a rollback under way or a paused pipeline holds it; none -- the pipeline trains neither model now), for_training (the training the state is about), last_training, own_latest_version (the version it builds on next; null: it trains from its original model) and reason, its state in one plain sentence to read back as written; a second-model training that ends untested -- one that trained but could not be tested included -- is trained once more on the same samples (not one a member stopped, one refused for its model, or one stopped by its own spending limit). chain, when set, is the live version and the versions it is built from (versions, live one last), learned_samples and not_learned_samples (learned by trainings that did not become a version, so learned again next), and rebuild when a deleted conversation the live version learned has a retrain from the original model on its way; chain null means the pipeline does not build on its live version and every training starts from the original model on all samples. Nothing in progress is a version yet. 'holdout' is the automatic test's share: percent (5 to 20), min_rows (at least that many samples are kept aside whatever the share), 'rows' (how many it keeps aside today) and held_before (how many of those an earlier training of the pipeline kept aside: they stay kept aside whatever became of that training and whatever the share says, so a lower share releases none of them; before the first version the first training needs held_before plus 1,000 to train on when that is more than the share asks for). Each training is tested on at most scored_rows (1,500) of the automatic test's samples, the ones kept aside longest first (a sample once kept aside stays kept aside, so later trainings are mostly asked the same questions, plus newer ones while fewer than 1,500 are kept aside), so keeping more aside than that only keeps samples out of training. storage is what the pipeline's history takes up in storage (bytes, objects, where: platform, your_storage or both, and billed): every conversation, answer and scoring reason of its reports, and the training data its trainings are built from, is kept there and never in the database; it counts toward the workspace's storage quota and is deleted with the pipeline. Only the part in the platform's storage is ever billed, as Conscious Loop storage at the storage rate with no daily minimum (each day's cost is added up and charged once it reaches a whole cent), and only when billed is true; the part in the workspace's own storage never is -- with billed false, say it is not billed. Deleting the pipeline deletes its history and stops that charge. Where the pipeline builds on its live version, the saved model copies its versions answer from (the live one's, and each one going back can still put back; a training that did not become a version keeps its copy, billed, until it is cleaned up within a day) are billed as model storage, like any trained model, and each version's own saved training state is billed as storage, like any training's; its reports, benchmark results and training data are billed only as its storage says (billed). An active_run whose last_reason says it is waiting for storage is holding answers already paid for until storage can keep them; they are kept then without asking the models again. cost_up_to true on attempts[], active_run or versions[] means that cost_cents is a ceiling: a test 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 'at most', never 'spent'. It is false again once billing has settled every such machine (the training's timeline says what billing charged each one for the time it served). A version's own new-model machine is the one that went live and is never settled, so a version whose machine was charged stays 'at most'. On attempts[] and active_run, comparison_returned_cents above 0 means a test machine never started (never reached running) and was not charged -- the platform bills it nothing, and the figures beside it are already net of it -- so say 'Test machine: not charged (it never started)' -- or, while a comparison figure above 0 remains beside it, that the one that never started was not charged and the one that ran was -- never that it was charged. An active_run.state of waiting_gpus means the training finished and is waiting for GPUs to serve the new model and the one it is tested against 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 start), and Train now tries again at once. training is the pipeline's training settings ('epochs', learning_rate, lora_rank, lora_alpha, replay_percent), each null while automatic, and training_defaults what automatic means for its model (learning_rate, lora_rank, lora_alpha, replay_percent, and min_epochs to max_epochs); say 'automatic' for a null, never a guessed number. capture lists the calls sent with X-Loop-Pipeline in the last capture.days days that were not recorded, one entry per name the header gave (a call naming two is counted under each, so never add entries up as calls): under this pipeline's own names too_large or incomplete (the answer could not be kept), and under any name no pipeline had no_pipeline (misspelt, or a deleted pipeline's); empty when every call was recorded. On attempts[], versions[] and active_run, sample_scoring_cents is what the judge's scoring of unreviewed samples cost before that training (already inside cost_cents, never added to it), sample_scored and sample_picked what it scored and picked, and train_rows_by_judge how many of train_rows the judge picked rather than a person; each training's trained_on is what it trained on as it was then (its tags, conditions and each dataset at its revision).", { 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)}`)));
3547
+ server.tool("loop_list_pipeline_benchmarks", "The benchmarks a pipeline is measured on (each a fixed set of the user's own questions), in priority order (1 is the primary), then the ones it stopped using, and its going-live choice (the 'promotion' field). Every training is tested on each attached benchmark; the going-live choice decides which of those measurements make it a version. Each says how it scores an answer (check: judge, the judge reading its rubric, or exact, the answer must match its right answer) and what its pinned set takes up in storage (storage: bytes, objects, where, billed), so no benchmark needs reading one by one to say either. A benchmark's pinned set is never billed: it is kept until the workspace is deleted, whatever is done with the benchmark, so billed is always false on it -- say it is not billed.", { pipeline_id: z.string() }, async ({ pipeline_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/benchmarks`)));
3548
+ 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 training; older versions are not measured again and show not_measured for it. 409 BENCHMARK_ALREADY_ATTACHED or BENCHMARK_RETIRED.", {
3412
3549
  pipeline_id: z.string(),
3413
3550
  benchmark_id: z.string(),
3414
3551
  priority: z.number().int().min(1).optional(),
@@ -3430,69 +3567,73 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3430
3567
  }, async ({ pipeline_id, benchmark_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/benchmarks/${encodeURIComponent(benchmark_id)}`, {
3431
3568
  method: "DELETE",
3432
3569
  })));
3433
- server.tool("loop_compare_versions", "A pipeline's versions side by side: each version's head-to-head result and its score on every benchmark, each with the share of conversations each side passed (incumbent_pass_rate, candidate_pass_rate; score 0.70 or more), with comparable flags. Pass a and b (VERSION numbers, e.g. 1 and 5) for those two and a difference per benchmark; a benchmark attached after a version is not_measured for it, never a zero. Each benchmark says how it scores an answer (check: judge or exact). Compare only scores marked comparable.", {
3570
+ server.tool("loop_compare_versions", "A pipeline's versions side by side: each version's result against what it was tested against (the live version then, or the original model before v1) and its score on every benchmark, each with the share of questions each side passed (candidate_pass_rate for the version, incumbent_pass_rate for what it was tested against; score 0.70 or more), with comparable flags. Pass a and b (VERSION numbers, e.g. 1 and 5) for those two and a difference per benchmark; a benchmark attached after a version is not_measured for it, never a zero. Each benchmark says how it scores an answer (check: judge or exact). Compare only scores marked comparable. Each version also says promoted_at, is_live, rolled_back, model_id and model_role (main or second), cost_cents, cost_up_to and gpu_seconds (its training's, as its entry in the list of trainings says them), train_rows, learned_new (the samples it learned that the versions it builds on had not: new plus carried from trainings that did not become a version; null on an older training), compared_with_version and compared_with (what its automatic test asked as the opponent), and holdout.common_pass_rate with holdout.common_rows: how much of common_set -- the samples kept aside for the automatic test before the first tested version (common_set.from), each once, read from stored answers graded against the same right answer -- it got right. common_rows is how many of the set that version's test graded: a sample deleted or past its retention before a later version's test was never asked by it, so say each version's own common_rows unless every version's equals common_set.rows. Use common_pass_rate for 'how has it grown from v1 to now'; each version's own candidate_pass_rate was measured on its own test and is not one line across versions. Each version says whether it was tested (tested): nothing untested goes live any more, and false marks an older version that went live before that held.", {
3434
3571
  pipeline_id: z.string(),
3435
3572
  a: z.number().int().min(1).optional().describe("A version number"),
3436
3573
  b: z.number().int().min(1).optional().describe("Another version number"),
3437
3574
  }, async ({ pipeline_id, a, b }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/versions/compare`, {
3438
3575
  params: { a: a?.toString(), b: b?.toString() },
3439
3576
  })));
3440
- server.tool("loop_compare_attempts", "Any two attempts of a pipeline side by side, put live or not -- the one to use for 'why was attempt 8 not better than 7' or a challenger against the attempt whose samples it trained on. a and b are RUN ids (attempts[].run_id from loop_get_pipeline), two different attempts of this pipeline. Each side has its outcome, decision, its lineage (what it was built on and trained on: 'attempt 3 built on v1, trained on 2,000' beside 'attempt 4 built on v1 + v2, trained on 1,000' is often the difference that matters) and held-back numbers against what it was compared with on its day, with the share each side passed (score 0.70 or more); never subtract two attempts' win rates, their opponents differ. benchmarks lists every benchmark both replayed, with delta (b minus a) only where comparable, why otherwise. shared_sample counts the held-back conversations both were scored on and how many b scored higher, lower or the same on (by the tie band: 0.02 or more above counts as higher; less than 0.02 apart is about the same): these are two absolute scores, not a reading of one answer against the other. shared_sample.a and .b are each attempt's mean and pass rate over those same conversations -- the like-for-like figures, which may be subtracted; each side's holdout numbers are over its own held-back set, which grows between attempts, so do not subtract those. When shared_sample.comparable is false the two were scored differently (another judge, holdout.judge_model, or another rubric) or how one was scored was not recorded (an older attempt that froze no rubric is never taken to match): say why in its words, and never read a direction off them. A conversation whose right answer changed between the two attempts has direction null and says why (answer_changed counts them); so does one where it is not known whether both were scored against the same right answer, because one was compared before that was recorded (answer_unknown counts them; never read those as unchanged or as changed). items is one page of them, numbers and ids only: read an answer with loop_list_evaluation_items on that side's holdout.evaluation_id. filter narrows items and total to one direction, and is refused when the two are not comparable.", {
3577
+ server.tool("loop_compare_attempts", "Any two trainings of a pipeline side by side, whether they went live or not -- the one to use for 'why was training #8 not better than #7' or the second model's training against the main model's training whose samples it learned from. a and b are run ids (attempts[].run_id from loop_get_pipeline), two different trainings of this pipeline. Each side has its outcome, decision, its 'lineage' (what it was built on and learned from: 'training #3 builds on v1 and learned 2,000 samples' beside 'training #4 builds on v2 and learned 1,000' is often the difference that matters) and automatic-test numbers against what it was tested against on its day (the live version then, or the original model before v1), with the share each side passed (score 0.70 or more); never subtract two trainings' win_rate figures, the versions they were tested against differ. benchmarks lists every benchmark both were tested on, with delta (b minus a) only where comparable, why otherwise. shared_sample counts the automatic-test questions both were scored on and how many b scored higher, lower or the same on (by the tie band: 0.02 or more above counts as higher; less than 0.02 apart is about the same): these are two absolute scores, not a reading of one answer against the other. shared_sample.a and .b are each training's mean and pass rate over those same questions -- the like-for-like figures, which may be subtracted; each side's 'holdout' numbers are over its own automatic test, which grows between trainings, so do not subtract those. When shared_sample.comparable is false the two were scored differently (another judge model, holdout.judge_model, or another rubric) or how one was scored was not recorded (an older training that froze no rubric is never taken to match): say why in its words, and never read a direction off them. A question whose right answer changed between the two trainings has direction null and says why (answer_changed counts them); so does one where it is not known whether both were scored against the same right answer, because one was compared before that was recorded (answer_unknown counts them; never read those as unchanged or as changed). items is one page of them, numbers and ids only: read an answer with loop_list_evaluation_items on that side's holdout.evaluation_id. filter narrows items and total to one direction, and is refused when the two are not comparable.", {
3441
3578
  pipeline_id: z.string(),
3442
- a: z.string().describe("The first attempt's run id"),
3443
- b: z.string().describe("The second attempt's run id; directions say b against a"),
3579
+ a: z.string().describe("The first training's run id"),
3580
+ b: z.string().describe("The second training's run id; directions say b against a"),
3444
3581
  filter: z.enum(["higher", "lower", "same"]).optional().describe("Only the conversations where b scored higher, lower, or the same"),
3445
3582
  limit: z.number().int().min(1).max(200).optional().describe("Conversations per page, 50 when absent"),
3446
3583
  offset: z.number().int().min(0).optional(),
3447
3584
  }, async ({ pipeline_id, a, b, filter, limit, offset }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/attempts/compare`, {
3448
3585
  params: { a, b, filter, limit: limit?.toString(), offset: offset ? offset.toString() : undefined },
3449
3586
  })));
3450
- server.tool("loop_promote_training_run", "Re-point the serving name at the candidate: from this moment the workspace's traffic is answered by the newly trained model. Read the comparison back to the user first (loop_get_evaluation) and get an explicit yes — this changes what real customers get, and it is only undoable for 30 days with loop_rollback_training_run. An INCONCLUSIVE comparison is refused unless force is set, and forcing one means promoting a model nothing showed to be better; say that out loud rather than setting force to get past the error.", {
3587
+ server.tool("loop_promote_training_run", "Put a training live: point the pipeline's model name at the newly trained model, which becomes the next version and the live version -- from this moment the workspace's traffic is answered by it. Read its test back to the user first (loop_get_evaluation) and get an explicit yes — this changes what real customers get, and it can only be undone for 30 days, with loop_rollback_training_run. A test that COULD NOT DECIDE ('inconclusive') is refused unless force is set, and forcing one means putting live a model nothing showed to be better; say that out loud rather than setting force to get past the error. A training that was never tested (tested false on loop_get_training_run: its test measured nothing) is refused NOT_TESTED, forced or not -- nothing untested goes live; offer loop_retest_training_run instead.", {
3451
3588
  run_id: z.string(),
3452
- expected_revision: z.number().int().min(0).optional().describe("The pipeline revision the decision was made against. Stale answers CONSENT_CHANGED instead of promoting under settings that have since changed."),
3453
- force: z.boolean().optional().describe("Promote an inconclusive comparison anyway. Only ever after the user has been told the comparison did not show the candidate was better."),
3589
+ expected_revision: z.number().int().min(0).optional().describe("The pipeline revision the decision was made against. Stale answers CONSENT_CHANGED instead of putting it live under settings that have since changed."),
3590
+ force: z.boolean().optional().describe("Put it live even though its test could not decide. Only ever after the user has been told the test did not show the new model was better."),
3454
3591
  }, async ({ run_id, expected_revision, force }) => json(await client.api(`/api/loop/training-runs/${encodeURIComponent(run_id)}/promote`, {
3455
3592
  method: "POST",
3456
3593
  body: { expected_revision, force },
3457
3594
  })));
3458
- server.tool("loop_reject_training_run", "Retire the candidate and leave the serving name exactly where it is. This is the cheap decision: the candidate stops being paid for. The checkpoint the run produced is kept, so a rejection is not a deletion.", {
3595
+ server.tool("loop_reject_training_run", "Turn a training down: retire the newly trained model and leave the live version exactly where it is. This is the cheap decision: the new model's machine stops being paid for. The trained model's saved copy is kept, so a rejection is not a deletion.", {
3459
3596
  run_id: z.string(),
3460
- reason: z.string().optional().describe("Why, in the user's own words. It goes on the run's timeline, which is what somebody reads six weeks later."),
3597
+ reason: z.string().optional().describe("Why, in the user's own words. It goes on the training's timeline, which is what somebody reads six weeks later."),
3461
3598
  }, async ({ run_id, reason }) => json(await client.api(`/api/loop/training-runs/${encodeURIComponent(run_id)}/reject`, {
3462
3599
  method: "POST",
3463
3600
  body: { reason },
3464
3601
  })));
3465
- 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, or is being built on top of it (its lineage.built_on names this version); roll back once it finishes, or cancel that attempt with loop_cancel_training_run first. 409 ROLLBACK_DELETED_DATA: the version it would put back learned from a conversation a member deleted since, so it never answers again; a rebuild without it replaces it. Where the pipeline builds on its live version, the next attempt builds on the version put back and trains again on what the rolled-back one learned -- when the version put back is on the pipeline's model now; one on the model before a second model took over cannot be built on, so the next attempt trains the pipeline's model from its base on every sample. Tell the user what the name will be serving once this lands — the previous target is named on the run.", {
3602
+ server.tool("loop_rollback_training_run", "Go back to the version before: point the pipeline's model name back at what answered before this version went live. Available for 30 days, after which the answer is ROLLBACK_WINDOW_CLOSED and the way back is a new training. 409 RUN_ACTIVE: another training is being tested against this version right now, or builds on it (its lineage.built_on names this version); go back once it finishes, or cancel that training with loop_cancel_training_run first. 409 ROLLBACK_DELETED_DATA: the version it would put back learned from a conversation a member deleted since, so it never answers again; a rebuild without it replaces it. Where the pipeline builds on its live version, the next training builds on the version put back and learns again what the version it replaced learned -- when the version put back is on the pipeline's model now; one on the model before a second model took over cannot be built on, so the next training trains the pipeline's model from its original on every sample. Tell the user what the name will be serving once this lands — the previous target is named on the training.", {
3466
3603
  run_id: z.string(),
3467
- reason: z.string().optional().describe("Why. It goes on the run's timeline."),
3604
+ reason: z.string().optional().describe("Why. It goes on the training's timeline."),
3468
3605
  }, async ({ run_id, reason }) => json(await client.api(`/api/loop/training-runs/${encodeURIComponent(run_id)}/rollback`, {
3469
3606
  method: "POST",
3470
3607
  body: { reason },
3471
3608
  })));
3472
- server.tool("loop_cancel_training_run", "Stop a run that is still moving. The training job is stopped, the candidate is torn down and nothing further is spent on this firing. What has already been spent is already spent — cancelling is not a refund, and saying so is more honest than letting the user discover it on the invoice.", {
3609
+ server.tool("loop_cancel_training_run", "Stop a training that is still running. The training is stopped, the newly trained model is torn down and nothing further is spent on this training. What has already been spent is already spent — cancelling is not a refund, and saying so is more honest than letting the user discover it on the invoice.", {
3473
3610
  run_id: z.string(),
3474
- reason: z.string().optional().describe("Why. It goes on the run's timeline."),
3611
+ reason: z.string().optional().describe("Why. It goes on the training's timeline."),
3475
3612
  }, async ({ run_id, reason }) => json(await client.api(`/api/loop/training-runs/${encodeURIComponent(run_id)}/cancel`, {
3476
3613
  method: "POST",
3477
3614
  body: { reason },
3478
3615
  })));
3479
- server.tool("loop_get_evaluation", "The comparison report behind a verdict: how many pairs were scored, the candidate's win rate, how many conversations each side passed (score 0.70 or more: candidate_passed, incumbent_passed, and the pass rates over rows_scored, all four null until the report is finished), the mean scores per rubric dimension, the warnings, and the margins the verdict was measured against. Read the WARNINGS out too, not just the verdict — 'better' from a judge that rarely agrees with this workspace's reviewers, or from a handful of rows, is a number with a caveat attached, and the caveat is in that list.", { evaluation_id: z.string().describe("From the run's links, or loop_get_training_run") }, async ({ evaluation_id }) => json(await client.api(`/api/loop/evaluations/${encodeURIComponent(evaluation_id)}`)));
3480
- server.tool("loop_list_evaluation_items", "The paired conversations behind the numbers: the same prompt, the reference a person gave for it (answer, kind and the tool_calls it makes; null when none), what the version serving today answered, what the attempt answered, each with the tool_calls it made, what the judge made of each (judge.overall, judge.dimensions and judge.reason, its reason in its own words) and whether each passed (score 0.70 or more), and pairwise: the direct comparison asked in both orders, each with its reason, where a win counts only when both orders agree. This is how a person checks a verdict instead of trusting it. Filter to winner='serving' to read the pairs the attempt LOST (the items themselves say winner 'incumbent'), which is the honest thing to show somebody before they promote.", {
3616
+ server.tool("loop_retest_training_run", "Test again: a training that finished training but was never tested -- no machine was free to test it, or its test could not score a single question (tested false, available_actions.retest true on loop_get_training_run) -- has its test booked again, on the same training. It is tested against what is live now, on the questions kept aside for its automatic test and with its benchmark list, and spends what is left of the amount set aside for testing it, never a second amount. 202 with the training back in checkpoint_ready; asking again while that test is under way changes nothing. 409 CANNOT_TEST_AGAIN says in one plain sentence why it cannot be (already tested, went live, still training, its trained model deleted, the money for testing it spent, ...): read it back as written. Train now's own refusals apply too: PIPELINE_NEEDS_SAVE, RULE_PAUSED, RUN_ACTIVE, MONTHLY_LIMIT_REACHED and SCORING_CAPPED (both with resumes_at), AGENT_OFF. Testing costs money: say so before asking.", { run_id: z.string() }, async ({ run_id }) => json(await client.api(`/api/loop/training-runs/${encodeURIComponent(run_id)}/retest`, {
3617
+ method: "POST",
3618
+ body: {},
3619
+ })));
3620
+ server.tool("loop_get_evaluation", "The automatic test behind a training's result: how many questions were scored, win_rate (of the questions where the two answers scored differently, the share where the new model's scored higher), how many questions each side got right (score 0.70 or more: candidate_passed for the new model, incumbent_passed for what it was tested against: the live version then, or the original model before v1; and the pass rates over rows_scored, all four null until the report is finished -- say it as 'right on 31 of 40; v4 got 28 of 40 on the same questions', or before v1 'right on 31 of 40; the original model got 28 of 40 on the same questions'), the mean scores per rubric dimension, the warnings, and the margins the result was measured against. Read the WARNINGS out too, not just the result — 'better' from a judge that rarely agrees with this workspace's reviewers, or from a handful of questions, is a number with a caveat attached, and the caveat is in that list.", { evaluation_id: z.string().describe("From the training's links, or loop_get_training_run") }, async ({ evaluation_id }) => json(await client.api(`/api/loop/evaluations/${encodeURIComponent(evaluation_id)}`)));
3621
+ server.tool("loop_list_evaluation_items", "The automatic test question by question: the same prompt, the right answer a person gave for it (reference: answer, kind and the tool_calls it makes; null when none), what the live version answered, what the new model answered, each with the tool_calls it made, what the judge made of each (judge.overall, judge.dimensions and judge.reason, its reason in its own words) and whether each passed (score 0.70 or more), and pairwise: the two answers compared directly, asked in both orders, each with its reason, where one side counts as better only when both orders agree. This is how a person checks a result instead of trusting it. Filter 'winner' to 'serving' to read the questions the new model did WORSE on (the items themselves then say 'incumbent', the live version), which is the honest thing to show somebody before they put it live.", {
3481
3622
  evaluation_id: z.string(),
3482
- winner: z.enum(["candidate", "serving", "tie"]).optional().describe("Only pairs with this outcome: candidate (the attempt won), serving (the version serving today won) or tie"),
3483
- candidate_passed: z.boolean().optional().describe("true: only pairs where the attempt's answer passed (score 0.70 or more); false: only pairs where it was scored below 0.70. With incumbent_passed it narrows to one cell: candidate_passed false and incumbent_passed true are the regressions. A pair that could not be scored matches neither"),
3484
- incumbent_passed: z.boolean().optional().describe("true: only pairs where the serving version's answer passed (score 0.70 or more); false: only pairs where it was scored below 0.70"),
3623
+ winner: z.enum(["candidate", "serving", "tie"]).optional().describe("Only questions with this outcome: 'candidate' (the new model did better), 'serving' (the live version did better) or 'tie'"),
3624
+ candidate_passed: z.boolean().optional().describe("true: only questions where the new model's answer passed (score 0.70 or more); false: only questions where it was scored below 0.70. With incumbent_passed it narrows to one cell: candidate_passed false and incumbent_passed true are the ones that got worse. A question that could not be scored matches neither"),
3625
+ incumbent_passed: z.boolean().optional().describe("true: only questions where the live version's answer passed (score 0.70 or more); false: only questions where it was scored below 0.70"),
3485
3626
  orders: z.enum(["agreed", "disagreed"]).optional().describe("Only pairs whose two side-by-side orders picked the same side (agreed), or did not"),
3486
3627
  has_reference: z.boolean().optional().describe("Only pairs with (true) or without (false) a right answer"),
3487
3628
  status: z.enum(["scored", "failed"]).optional(),
3488
- sort: z.enum(["ordinal", "score_change"]).optional().describe("score_change: the attempt's score less the other's, largest first"),
3489
- q: z.string().min(2).max(200).optional().describe("Search the conversation (not the system prompt every row shares), the right answer, both answers and every reason (case-insensitive); searched in the answer says how many pairs were read. A search can be refused 429 RATE_LIMITED while this workspace already has a search running or while the service is running as many searches as it can at once, so a 429 does not say which: never tell the user they already had one running; try again after the seconds its message gives"),
3629
+ sort: z.enum(["ordinal", "score_change"]).optional().describe("score_change: the new model's score less the live version's, largest first"),
3630
+ q: z.string().min(2).max(200).optional().describe("Search the conversation (not the system prompt every question shares), the right answer, both answers and every reason (case-insensitive); searched in the answer says how many pairs were read. A search can be refused 429 RATE_LIMITED while this workspace already has a search running or while the service is running as many searches as it can at once, so a 429 does not say which: never tell the user they already had one running; try again after the seconds its message gives"),
3490
3631
  limit: z.number().int().min(1).max(200).optional().describe("50 when omitted, at most 200"),
3491
3632
  offset: z.number().int().min(0).optional(),
3492
3633
  }, async ({ evaluation_id, winner, candidate_passed, incumbent_passed, orders, has_reference, status, sort, q, limit, offset }) => json(await client.api(`/api/loop/evaluations/${encodeURIComponent(evaluation_id)}/items`, {
3493
3634
  params: { winner, candidate_passed: candidate_passed?.toString(), incumbent_passed: incumbent_passed?.toString(), orders, has_reference: has_reference?.toString(), status, sort, q, limit: limit?.toString(), offset: offset?.toString() },
3494
3635
  })));
3495
- server.tool("loop_create_benchmark", "Pin a fixed set of 20 to 200 conversations (source.kind 'traces' with trace_ids) as a benchmark: the same questions, each with the right answer a person gave it, scored the same way on every attempt of every pipeline it is attached to, so 'is it getting better' has a yardstick that does not move. check says how, and is frozen: 'judge' (the default: the judge reads both answers against the right answer) or 'exact' (the answer or the tool call must match the right answer, and no model is asked -- for tool calls, numbers and labels; a written answer rarely matches word for word; a value hidden when the conversation was captured, such as an email or a long number kept as [redacted:email], cannot be checked, so an answer that matches everywhere else still scores 0 and says so). An exact benchmark needs a right answer on every conversation: 422 BENCHMARK_ROW_HAS_NO_ANSWER names the ones without and nothing is saved -- mark their answers good or correct them first, leave them out, or use 'judge'. It is refused rather than trimmed when a conversation has nothing to ask a model, or is too large to keep in storage: 422 BENCHMARK_ROW_UNUSABLE names it, and nothing is saved -- leave it out. 409 CONFLICT when the conversations could not be stored just then: nothing was saved; try again in a minute. Attach it with loop_attach_pipeline_benchmark.", {
3636
+ server.tool("loop_create_benchmark", "Pin a fixed set of 20 to 200 conversations (source.kind 'traces' with trace_ids) as a benchmark: the same questions, each with the right answer a person gave it, scored the same way on every training of every pipeline it is attached to, so 'is it getting better' has a yardstick that does not move. check says how, and is frozen: 'judge' (the default: the judge reads both answers against the right answer) or 'exact' (the answer or the tool call must match the right answer, and no model is asked -- for tool calls, numbers and labels; a written answer rarely matches word for word; a value hidden when the conversation was captured, such as an email or a long number kept as [redacted:email], cannot be checked, so an answer that matches everywhere else still scores 0 and says so). An exact benchmark needs a right answer on every conversation: 422 BENCHMARK_ROW_HAS_NO_ANSWER names the ones without and nothing is saved -- mark their answers good or correct them first, leave them out, or use 'judge'. It is refused rather than trimmed when a conversation has nothing to ask a model, or is too large to keep in storage: 422 BENCHMARK_ROW_UNUSABLE names it, and nothing is saved -- leave it out. 409 CONFLICT when the conversations could not be stored just then: nothing was saved; try again in a minute. Attach it with loop_attach_pipeline_benchmark.", {
3496
3637
  name: z.string(),
3497
3638
  source: z.object({
3498
3639
  kind: z.enum(["traces"]).describe("Always 'traces': a benchmark pins the conversations named in trace_ids"),
@@ -3518,31 +3659,40 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3518
3659
  }, async ({ benchmark_id, limit, offset }) => json(await client.api(`/api/loop/benchmarks/${encodeURIComponent(benchmark_id)}/items`, {
3519
3660
  params: { limit: limit?.toString(), offset: offset?.toString() },
3520
3661
  })));
3521
- server.tool("loop_get_benchmark_history", "Every score a benchmark has produced, newest first: the trend line. Each point names the attempt_no of its pipeline it measured and the version that attempt became (null when it did not become one); name points by those, never by run_seq, which counts every run in the workspace. A replay that could not score the whole set has null scores and a status_reason — report the reason, never a number for it. pipeline keeps only the points of one pipeline's attempts, so the trend reads by attempt number; without it the points of every pipeline using the benchmark are mixed.", {
3662
+ server.tool("loop_get_benchmark_history", "Every score a benchmark has produced, newest first: the trend line. Each point names the training number (attempt_no) of its pipeline it measured and the version that training became (null when it did not become one); name points by those ('training #6, v5'), never by run_seq, which counts every benchmark run in the workspace. A benchmark run that could not score the whole set has null scores and a status_reason — report the reason, never a number for it. pipeline keeps only the points of one pipeline's trainings, so the trend reads by training number; without it the points of every pipeline using the benchmark are mixed.", {
3522
3663
  benchmark_id: z.string(),
3523
- pipeline: z.string().optional().describe("Only this pipeline's attempts (its id, from loop_list_pipelines)"),
3664
+ pipeline: z.string().optional().describe("Only this pipeline's trainings (its id, from loop_list_pipelines)"),
3524
3665
  limit: z.number().int().min(1).max(200).optional(),
3525
3666
  offset: z.number().int().min(0).optional(),
3526
3667
  }, async ({ benchmark_id, pipeline, limit, offset }) => json(await client.api(`/api/loop/benchmarks/${encodeURIComponent(benchmark_id)}/history`, {
3527
3668
  params: { pipeline: pipeline || undefined, limit: limit?.toString(), offset: offset?.toString() },
3528
3669
  })));
3529
- server.tool("loop_retire_benchmark", "Stop replaying a benchmark and detach it from every pipeline. Nothing it measured is removed, and its name is free for a successor. It cannot be un-retired; confirm first.", {
3670
+ server.tool("loop_retire_benchmark", "Stop testing on a benchmark and detach it from every pipeline. Nothing it measured is removed, and its name is free for a successor. It cannot be un-retired; confirm first.", {
3530
3671
  benchmark_id: z.string(),
3531
3672
  reason: z.string().optional(),
3532
3673
  }, async ({ benchmark_id, reason }) => json(await client.api(`/api/loop/benchmarks/${encodeURIComponent(benchmark_id)}/retire`, {
3533
3674
  method: "POST",
3534
3675
  body: { reason },
3535
3676
  })));
3536
- server.tool("loop_get_benchmark_run", "One replay in full: both scores, the difference, the per-dimension breakdown, how many conversations each side passed (score 0.70 or more) and the pass rates, the conversations the attempt won, lost and tied (wins, losses, ties), learned_by_version (how many of these conversations the version under test learned -- trained on itself or learned by a version it was built on; when it is above 0, say \"N of these conversations were learned by this version\", since its score on them is partly recall; null for a version the merge chain did not build: say nothing then), and what it cost. Read status first — a replay that did not score every pinned conversation publishes no score, and its pass counts, rates and wins are null too. loop_list_benchmark_run_items reads every conversation behind it.", { benchmark_run_id: z.string() }, async ({ benchmark_run_id }) => json(await client.api(`/api/loop/benchmark-runs/${encodeURIComponent(benchmark_run_id)}`)));
3537
- server.tool("loop_list_benchmark_run_items", "The conversations behind one replay, in the benchmark's order: the pinned prompt and its reference (answer, kind and the tool_calls the right answer makes; null when none), what the version serving today answered, what the attempt answered, each with the tool_calls it made, what the judge made of each (judge.overall, judge.dimensions and judge.reason; on an exact benchmark overall and dimensions.match are 1 or 0 and the reason says what matched or what differed) and whether each passed (score 0.70 or more), and which side won it (winner compares the two scores by the tie band promotion uses: 0.02 or more above counts as higher, less than 0.02 apart is a tie, and it is null until both are scored). Filter to winner='serving' to read the conversations the attempt LOST (the items themselves say winner 'incumbent'). A replay that stopped before it began has no conversations: read its status_reason back instead.", {
3538
- benchmark_run_id: z.string().describe("The replay's id: from loop_get_benchmark_history, loop_compare_versions, or a run's benchmark_runs"),
3539
- winner: z.enum(["candidate", "serving", "tie"]).optional().describe("Only conversations with this outcome: candidate (the attempt won), serving (the version serving today won) or tie"),
3540
- candidate_passed: z.boolean().optional().describe("true: only conversations where the attempt's answer passed (score 0.70 or more); false: only ones where it was scored below 0.70. With incumbent_passed it narrows to one cell: candidate_passed false and incumbent_passed true are the regressions. One that could not be scored matches neither"),
3541
- incumbent_passed: z.boolean().optional().describe("true: only conversations where the serving version's answer passed (score 0.70 or more); false: only ones where it was scored below 0.70"),
3677
+ server.tool("loop_get_benchmark_run", "One benchmark run in full (one training tested on one benchmark): both scores, the difference, the per-dimension breakdown, how many questions each side passed (score 0.70 or more) and the pass rates, the questions the new model did better, worse and the same on (wins, losses, ties), learned_by_version (how many of these conversations the version under test learned -- trained on itself or learned by a version it was built on; when it is above 0, say \"N of these conversations were learned by this version\", since its score on them is partly recall; null for a version from before versions built on each other: say nothing then), and what it cost. Read status first — a benchmark run that did not score every pinned question publishes no score, and its pass counts, rates and wins are null too. loop_list_benchmark_run_items reads every conversation behind it.", { benchmark_run_id: z.string() }, async ({ benchmark_run_id }) => json(await client.api(`/api/loop/benchmark-runs/${encodeURIComponent(benchmark_run_id)}`)));
3678
+ server.tool("loop_benchmark_questions", "Every question of a benchmark with how one pipeline's recent trainings did on it. trainings lists them oldest first ('attempt' is the training's number, version the version it became or null, status its benchmark result's status: anything but done scored only some questions), and each question's results follow that order with passed (right: a score of 0.70 or more) and score, both null where that training did not score the question. Use it to find the questions a new version got wrong that the one before got right, and say them by question, not by id. It writes nothing.", {
3679
+ benchmark_id: z.string().describe("The benchmark's id, from loop_list_benchmarks or loop_list_pipeline_benchmarks"),
3680
+ pipeline: z.string().describe("The pipeline whose trainings to show (its id, from loop_list_pipelines): training numbers are per pipeline"),
3681
+ trainings: z.number().int().min(1).max(20).optional().describe("How many of its recent trainings, 5 when absent, at most 20"),
3682
+ limit: z.number().int().min(1).max(200).optional().describe("Questions per page, 50 when absent, at most 200"),
3683
+ offset: z.number().int().min(0).optional(),
3684
+ }, async ({ benchmark_id, pipeline, trainings, limit, offset }) => json(await client.api(`/api/loop/benchmarks/${encodeURIComponent(benchmark_id)}/questions`, {
3685
+ params: { pipeline, trainings: trainings?.toString(), limit: limit?.toString(), offset: offset?.toString() },
3686
+ })));
3687
+ server.tool("loop_list_benchmark_run_items", "One benchmark run question by question, in the benchmark's order: the pinned prompt and its right answer (reference: answer, kind and the tool_calls the right answer makes; null when none), what the live version answered, what the new model answered, each with the tool_calls it made, what the judge made of each (judge.overall, judge.dimensions and judge.reason; on an exact benchmark overall and dimensions.match are 1 or 0 and the reason says what matched or what differed) and whether each passed (score 0.70 or more), and which side did better ('winner' compares the two scores by the tie band going live uses: 0.02 or more above counts as higher, less than 0.02 apart is a tie, and it is null until both are scored). Filter 'winner' to 'serving' to read the questions the new model did WORSE on (the items themselves then say 'incumbent', the live version). A benchmark run that stopped before it began has no questions: read its status_reason back instead.", {
3688
+ benchmark_run_id: z.string().describe("The benchmark run's id: from loop_get_benchmark_history, loop_compare_versions, or a training's benchmark_runs"),
3689
+ winner: z.enum(["candidate", "serving", "tie"]).optional().describe("Only questions with this outcome: 'candidate' (the new model did better), 'serving' (the live version did better) or 'tie'"),
3690
+ candidate_passed: z.boolean().optional().describe("true: only questions where the new model's answer passed (score 0.70 or more); false: only ones where it was scored below 0.70. With incumbent_passed it narrows to one cell: candidate_passed false and incumbent_passed true are the ones that got worse. One that could not be scored matches neither"),
3691
+ incumbent_passed: z.boolean().optional().describe("true: only questions where the live version's answer passed (score 0.70 or more); false: only ones where it was scored below 0.70"),
3542
3692
  has_reference: z.boolean().optional().describe("Only conversations with (true) or without (false) a right answer"),
3543
3693
  status: z.enum(["scored", "failed"]).optional(),
3544
- sort: z.enum(["ordinal", "score_change"]).optional().describe("score_change: the attempt's score less the other's, largest first"),
3545
- q: z.string().min(2).max(200).optional().describe("Search the conversation (not the system prompt every row shares), the right answer, both answers and every reason (case-insensitive); searched in the answer says how many were read. A search can be refused 429 RATE_LIMITED while this workspace already has a search running or while the service is running as many searches as it can at once, so a 429 does not say which: never tell the user they already had one running; try again after the seconds its message gives"),
3694
+ sort: z.enum(["ordinal", "score_change"]).optional().describe("score_change: the new model's score less the live version's, largest first"),
3695
+ q: z.string().min(2).max(200).optional().describe("Search the conversation (not the system prompt every question shares), the right answer, both answers and every reason (case-insensitive); searched in the answer says how many were read. A search can be refused 429 RATE_LIMITED while this workspace already has a search running or while the service is running as many searches as it can at once, so a 429 does not say which: never tell the user they already had one running; try again after the seconds its message gives"),
3546
3696
  limit: z.number().int().min(1).max(200).optional().describe("50 when omitted, at most 200"),
3547
3697
  offset: z.number().int().min(0).optional(),
3548
3698
  }, async ({ benchmark_run_id, winner, candidate_passed, incumbent_passed, has_reference, status, sort, q, limit, offset }) => json(await client.api(`/api/loop/benchmark-runs/${encodeURIComponent(benchmark_run_id)}/items`, {