runbios-mcp 0.2.17 → 0.2.18-dev.271

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -22,7 +22,7 @@ const RAW_FAILURE_TEXT_FIELDS = [
22
22
  "last_error",
23
23
  "stage_detail",
24
24
  ];
25
- import { resolveLaunchGates } from "./config.js";
25
+ import { LOOP_TOOL_PREFIX, resolveLaunchGates } from "./config.js";
26
26
  import { VERSION } from "./version.js";
27
27
  export { VERSION };
28
28
  /**
@@ -55,6 +55,22 @@ function wrapToolHandler(name, handler, onToolCall) {
55
55
  return wrapped;
56
56
  }
57
57
  const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
58
+ /**
59
+ * Adds an explicit next_offset to an offset-paged list so an agent walks a large
60
+ * list (thousands of datasets or models) page by page from a known total
61
+ * instead of fetching everything or mistaking one page for all of it.
62
+ */
63
+ function withNextOffset(data, rowsKey, pageSize, skip) {
64
+ // Paging describes a list the API actually returned; an empty or malformed
65
+ // body is passed through untouched rather than decorated with invented fields.
66
+ if (!data || !Array.isArray(data[rowsKey]))
67
+ return data;
68
+ const rows = data[rowsKey].length;
69
+ const total = typeof data?.total === "number" ? data.total : null;
70
+ const next = skip + rows;
71
+ const more = total !== null ? next < total : data?.hasMore === true || data?.has_more === true || rows === pageSize;
72
+ return { ...data, limit: pageSize, offset: skip, next_offset: more && rows > 0 ? next : null };
73
+ }
58
74
  function validateId(value, label) {
59
75
  if (!SAFE_ID_RE.test(value)) {
60
76
  throw new Error(`Invalid ${label}: must contain only alphanumeric characters, hyphens, and underscores`);
@@ -715,10 +731,50 @@ launchGates = resolveLaunchGates(), allowedTools) {
715
731
  overlong_policy: z.enum(["truncate", "drop"]).optional()
716
732
  .describe("SFT only. 'truncate' trims oldest context while preserving a trainable answer; 'drop' skips overlong samples. CPT packs raw text and does not use this policy."),
717
733
  };
734
+ // The platform guide's Conscious Loop section. Where the loop is not offered
735
+ // (launchGates.loop) no loop tool is registered, so the guide says nothing about
736
+ // the loop at all: an agent that reads a tool name will try it. The alias tools
737
+ // are inference tools that stay registered either way, so they keep a line of
738
+ // their own there.
739
+ const loopGuideSection = launchGates.loop
740
+ ? `## Inference Aliases
741
+ - set_inference_alias / list_inference_aliases / delete_inference_alias - Re-pointable public handles: a name customers call that can be moved to different weights without them changing anything.`
742
+ : `## Conscious Loop MCP Tools
743
+ The loop turns what a deployed model actually did into what it learns next.
744
+ NOTHING IS RECORDED until a source is switched on — capture is a consent
745
+ decision, not a default, and these tools cannot bypass it. Creating a pipeline
746
+ is that decision for one source: the one named by the pipeline's own tag
747
+ (own_tag), which records what is sent to it stamped with that tag, until the
748
+ pipeline is deleted. Every other source is switched on with loop_set_capture.
749
+
750
+ 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.
751
+ 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.
752
+ 3. loop_list_traces / loop_get_trace / loop_delete_trace - Read what has been recorded; pipeline=<id> lists one pipeline's samples. Credentials and personal details are stripped before storage, never after.
753
+ 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.
754
+ 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.
755
+ 6. loop_stats / loop_get_metrics_history - How much is recorded and reviewed, and how the attempts and the feedback moved over time.
756
+ 7. loop_list_models - The base models a pipeline can train, and the sample floors. The only list a pipeline's model may come from.
757
+ 8. loop_create_pipeline - One use case: a name, a model (optionally a challenger model), its tags, when to train (at least N new samples, and optionally a schedule) 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 only the GPU time used is charged. Say so and wait for a yes.
758
+ 9. loop_list_pipelines / loop_get_pipeline / loop_update_pipeline / loop_delete_pipeline - Each pipeline's versions (winners only, v1, v2 ... numbers only go up), every attempt and how it ended, the live version, status and next_reason, and its settings.
759
+ 10. 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.
760
+ 11. loop_create_benchmark / loop_list_benchmarks / loop_get_benchmark / loop_list_benchmark_items / loop_get_benchmark_history / loop_retire_benchmark / loop_get_benchmark_run - Fixed sets of conversations every attempt is replayed on, so "is it getting better" has a yardstick that does not move.
761
+ 12. loop_list_pipeline_benchmarks / loop_attach_pipeline_benchmark / loop_move_pipeline_benchmark / loop_remove_pipeline_benchmark / loop_compare_versions - A pipeline's benchmarks in priority order (1 is the primary), and its versions side by side (v1 against v5).
762
+ 13. loop_list_training_runs / loop_get_training_run - Every attempt: its timeline, and available_actions — what this reader may actually do next, rather than what the state looks like it allows.
763
+ 14. loop_get_evaluation / loop_list_evaluation_items - The comparison behind a verdict: the win rate and the per-dimension means on the held-back samples, and the paired conversations themselves. Read the warnings out, not just the verdict.
764
+ 15. 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.
765
+ 16. 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.
766
+
767
+ 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 at least min_samples new samples carry its tags (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 only when it is better by the promotion policy — so each version beats 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.`;
718
768
  const server = {
719
769
  tool(name, description, paramsSchema, cb) {
720
770
  if (allowedTools && !allowedTools.has(name))
721
771
  return;
772
+ // PRE-LAUNCH GATE (launchGates.loop — src/config.ts): the Conscious Loop is
773
+ // offered on development only, so where it is gated the WHOLE family stays
774
+ // unregistered, reads included. Nothing about it is offered there, and a
775
+ // listed read would only answer 403 LOOP_COMING_SOON.
776
+ if (launchGates.loop && name.startsWith(LOOP_TOOL_PREFIX))
777
+ return;
722
778
  mcp.tool(name, description, paramsSchema, wrapToolHandler(name, cb, hooks?.onToolCall));
723
779
  registeredTools.add(name);
724
780
  },
@@ -878,36 +934,7 @@ get_inference_status instead of asserting them.
878
934
  - get_serverless_savings - Read the authenticated user and workspace's model-promotion savings
879
935
  Only the current workspace is readable with analytics:read; serverless inference scope alone is not analytics. Key creation, per-key usage, org-wide totals, and all RPM/spend mutations remain authenticated-console-only.
880
936
 
881
- ## Conscious Loop MCP Tools
882
- The loop turns what a deployed model actually did into what it learns next.
883
- NOTHING IS RECORDED until loop_set_capture turns a source on — capture is a
884
- consent decision, not a default, and these tools cannot bypass it.
885
- 1. loop_set_capture - Turn recording on or off for a source and set how long conversations are kept. Start here; every other loop tool is inert until this is done.
886
- 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. 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.
887
- 3. loop_list_traces / loop_get_trace - Read what has been recorded. Credentials and personal details are stripped before storage, never after.
888
- 4. loop_add_signal - Record whether an answer was right. Append-only. Verdict 'edited' with a correction is the most valuable feedback there is.
889
- 5. loop_add_candidate / loop_list_candidates - Submit ALTERNATIVE answers to a recorded prompt. Sampling the model N times and scoring the samples produces DPO pairs without a human writing each one; a stronger model's answers do distillation the same way.
890
- 6. loop_label_trace / loop_remove_label / loop_list_labels - Carve the corpus into slices. A bare tag with a parent makes a master group; NAMED DIMENSIONS (category, task, language, anything you like) are what let ONE captured corpus become a DIFFERENT dataset for every task, because filters on different dimensions AND together and nothing has to be re-labelled.
891
- 7. loop_create_judge / loop_list_judges - A RUBRIC a model applies, for the question no rule can answer: was this actually helpful. The rubric doubles as a GRPO reward function, which makes prompts with no checkable answer trainable.
892
- 8. loop_start_judge_run then loop_take_judge_work then loop_post_judge_verdicts - Run a rubric over a slice. By default YOU call the model: take the rendered prompt, send it, post the scores back. A judge created with auto=true is run by the platform's agent instead, on the workspace's own serverless account. loop_list_judge_run_items says what happened to each conversation in a run and why a failed one failed, which the work list cannot: it hands out only what is still pending, so a finished run answers it with nothing. loop_stop_judge_run closes a run that will NOT finish, which is what unblocks the judge — an open run blocks the next one, and closing it by posting an empty verdict list with finish set writes 'done' on a pass that covered three of forty.
893
- 9. loop_create_grader / loop_list_graders - Deterministic rules that score answers with no person reading them, weighted so the criteria that matter count more, and aimable at one label so a rule about shipping does not mark down every billing answer.
894
- 10. loop_grade_trace - Apply those rules to an answer AND its samples in one pass. This is what closes the loop automatically.
895
- 11. loop_build_dataset / loop_create_build_rule / loop_list_build_rules / loop_delete_build_rule - Curate the feedback into an sft, dpo, grpo or kto training set, once or STANDING. Always reports why rows were left out. kto takes a bare thumbs-down, which the other three cannot use at all. A build rule is the same curation left running: it fires when enough NEW reviewed work exists, and last_reason says why it did not — a rule quiet because it is waiting reads exactly like one that is broken.
896
- 12. loop_agent_status / loop_enable_agent / loop_disable_agent - The AGENT: the worker that calls a model for the workspace (automatic judges, sample answers). It spends through the workspace's OWN serverless key, so every call is billed like one of theirs; enabling it is SPEND CONSENT and you say so first. Status tells you whether an agent exists in this environment at all.
897
- 13. loop_create_sample_run / loop_list_sample_runs - Have the agent write N alternative answers to each conversation in a slice and score them with a judge. This is how DPO pairs and GRPO groups are made with no human writing each one. State the upper bound of model calls (sample × n × 2) before starting.
898
- 14. loop_list_datasets - The sets built so far, each with how many rows it kept and why the rest were dropped.
899
- 15. loop_preview_dataset / loop_register_dataset_for_training - Read the actual rows before anyone trains on them; each carries the address of the conversation it came from. Registering hands a built set to training as a real dataset id, keeping the link back to the conversations, verdicts and judges that produced it — the alternative is downloading the file and uploading it again as an unrelated dataset, which throws that link away. Register "train" and "holdout" separately when the job should be scored on rows it never saw. The set is then validated like any upload, so poll get_dataset_status before training on it.
900
- 16. loop_stats - How much is recorded, how much is reviewed, and what each method could use right now.
901
- 17. loop_preflight_training_rule - PRICE a standing training rule without creating anything: the pinned revision, the worst hourly price each GPU ladder can reach, how many hours each ceiling buys, the refusals, and terms_text — the sentence the member is agreeing to, with their own figures in it. Always first.
902
- 18. loop_create_training_rule - Create it. SPEND CONSENT, and recorded: accept_terms is the member's signature on that sentence. Read the estimate and terms_text back VERBATIM, wait for an explicit yes, and never state a ceiling, a price cap or an hour count preflight did not return.
903
- 19. loop_list_training_rules / loop_get_training_rule / loop_update_training_rule / loop_consent_training_rule / loop_run_training_rule / loop_delete_training_rule - Read, edit, re-consent, fire now, retire. A money-bearing edit bumps the rule's revision, drops the consent and STOPS FUTURE FIRINGS until somebody accepts the new terms.
904
- 20. loop_list_training_runs / loop_get_training_run / loop_list_pipelines / loop_get_pipeline - What each firing did, its timeline, and available_actions — which says what this reader may actually do next, rather than what the state looks like it allows. The pipeline tools read a rule as the versions it produced: which one serves, how many of max_versions are made, the run in flight, and the month's spend against its limit.
905
- 21. loop_get_evaluation / loop_list_evaluation_items / loop_get_judge_agreement - The comparison behind a verdict: the win rate and the per-dimension means, the paired conversations themselves, and how far the judge already agrees with this workspace's own reviewers. Read the warnings out, not just the verdict.
906
- 22. loop_promote_training_run / loop_reject_training_run / loop_rollback_training_run / loop_cancel_training_run - The decision. Promotion re-points the serving name at the candidate and changes what real customers get; it is undoable for 30 days.
907
- 23. loop_get_agent_settings / loop_update_agent_settings / loop_update_build_rule - The agent's default model, its system prompts and the monthly evaluation cap; and the standing curation rule a training rule draws from.
908
- 24. 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.
909
-
910
- END TO END: capture what a model was asked and answered, have people or judges say whether it was right, write ONE training rule that curates the reviewed work and trains on it, let the platform compare the result against what serves today, read the comparison, then decide — promote, reject, or leave it. Everything before the rule costs nothing; the rule is where money starts, which is why it carries a recorded 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': last_reason says why a quiet rule is quiet, because a rule waiting for rows looks identical to a broken one; and accepted_revision against revision (with paused_reason) says whether the rule still has a consent to spend under, because a money-bearing edit pauses it until somebody agrees again.`;
937
+ ${loopGuideSection}`;
911
938
  const guides = {
912
939
  overview: `# Run BiOS Fine-Tuning Platform
913
940
 
@@ -916,8 +943,8 @@ Run BiOS provides serverless inference, dedicated GPU deployments, datasets, and
916
943
  ## Choose the workflow before calling tools
917
944
  - **Need an answer now from a published serverless model?** Use serverless inference: model id + chat_with_inference. No GPU booking or deployment object; billed per token.
918
945
  - **Need dedicated capacity or a durable endpoint?** Use dedicated deployment: GPU options -> preflight -> explicit approval -> create -> wait for running -> chat -> stop/delete. Billed per second of GPU time.
919
- - **Need custom weights?** Prepare a dataset -> preflight and run training -> monitor -> select a verified checkpoint -> deploy that checkpoint through the dedicated workflow.
920
- - **Need continuous improvement from real conversations?** Use the Conscious Loop workflow; capture is opt-in and automated model calls spend through the workspace's own serverless account.
946
+ - **Need custom weights?** Prepare a dataset -> preflight and run training -> monitor -> select a verified checkpoint -> deploy that checkpoint through the dedicated workflow.${launchGates.loop ? "" : `
947
+ - **Need continuous improvement from real conversations?** Use the Conscious Loop workflow; capture is opt-in and automated model calls spend through the workspace's own serverless account.`}
921
948
 
922
949
  ## Training Workflow
923
950
  1. **Upload/import a dataset** — use upload_dataset for a local JSONL file or import_huggingface_dataset for a Hub source. Poll get_dataset_status until ready, then preview_dataset before training. A created row is not proof that processing succeeded.
@@ -935,7 +962,7 @@ Run BiOS provides serverless inference, dedicated GPU deployments, datasets, and
935
962
  ## Identity, account and scope
936
963
  - Every MCP call uses the API key's bound organization and workspace. Start with introspect_api_key and repeat the resolved identity before any paid or destructive action.
937
964
  - Never ask an agent to choose an arbitrary account id; tool responses are scoped server-side. A different workspace requires a different authorized credential.
938
- - get_wallet_balance describes the same billing owner that training, dedicated deployment, and automated Loop calls spend from.
965
+ - get_wallet_balance describes the same billing owner that training${launchGates.loop ? " and dedicated deployment spend" : ", dedicated deployment, and automated Loop calls spend"} from.
939
966
 
940
967
  ## What the PLATFORM decides for you (do not try to set these)
941
968
  Serving settings are derived from the model itself and are immutable. They are
@@ -1016,36 +1043,7 @@ get_inference_status instead of asserting them.
1016
1043
  - get_serverless_savings - Read the authenticated user and workspace's model-promotion savings
1017
1044
  Only the current workspace is readable with analytics:read; serverless inference scope alone is not analytics. Key creation, per-key usage, org-wide totals, and all RPM/spend mutations remain authenticated-console-only.
1018
1045
 
1019
- ## Conscious Loop MCP Tools
1020
- The loop turns what a deployed model actually did into what it learns next.
1021
- NOTHING IS RECORDED until loop_set_capture turns a source on — capture is a
1022
- consent decision, not a default, and these tools cannot bypass it.
1023
- 1. loop_set_capture - Turn recording on or off for a source and set how long conversations are kept. Start here; every other loop tool is inert until this is done.
1024
- 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. 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.
1025
- 3. loop_list_traces / loop_get_trace - Read what has been recorded. Credentials and personal details are stripped before storage, never after.
1026
- 4. loop_add_signal - Record whether an answer was right. Append-only. Verdict 'edited' with a correction is the most valuable feedback there is.
1027
- 5. loop_add_candidate / loop_list_candidates - Submit ALTERNATIVE answers to a recorded prompt. Sampling the model N times and scoring the samples produces DPO pairs without a human writing each one; a stronger model's answers do distillation the same way.
1028
- 6. loop_label_trace / loop_remove_label / loop_list_labels - Carve the corpus into slices. A bare tag with a parent makes a master group; NAMED DIMENSIONS (category, task, language, anything you like) are what let ONE captured corpus become a DIFFERENT dataset for every task, because filters on different dimensions AND together and nothing has to be re-labelled.
1029
- 7. loop_create_judge / loop_list_judges - A RUBRIC a model applies, for the question no rule can answer: was this actually helpful. The rubric doubles as a GRPO reward function, which makes prompts with no checkable answer trainable.
1030
- 8. loop_start_judge_run then loop_take_judge_work then loop_post_judge_verdicts - Run a rubric over a slice. By default YOU call the model: take the rendered prompt, send it, post the scores back. A judge created with auto=true is run by the platform's agent instead, on the workspace's own serverless account. loop_list_judge_run_items says what happened to each conversation in a run and why a failed one failed, which the work list cannot: it hands out only what is still pending, so a finished run answers it with nothing. loop_stop_judge_run closes a run that will NOT finish, which is what unblocks the judge — an open run blocks the next one, and closing it by posting an empty verdict list with finish set writes 'done' on a pass that covered three of forty.
1031
- 9. loop_create_grader / loop_list_graders - Deterministic rules that score answers with no person reading them, weighted so the criteria that matter count more, and aimable at one label so a rule about shipping does not mark down every billing answer.
1032
- 10. loop_grade_trace - Apply those rules to an answer AND its samples in one pass. This is what closes the loop automatically.
1033
- 11. loop_build_dataset / loop_create_build_rule / loop_list_build_rules / loop_delete_build_rule - Curate the feedback into an sft, dpo, grpo or kto training set, once or STANDING. Always reports why rows were left out. kto takes a bare thumbs-down, which the other three cannot use at all. A build rule is the same curation left running: it fires when enough NEW reviewed work exists, and last_reason says why it did not — a rule quiet because it is waiting reads exactly like one that is broken.
1034
- 12. loop_agent_status / loop_enable_agent / loop_disable_agent - The AGENT: the worker that calls a model for the workspace (automatic judges, sample answers). It spends through the workspace's OWN serverless key, so every call is billed like one of theirs; enabling it is SPEND CONSENT and you say so first. Status tells you whether an agent exists in this environment at all.
1035
- 13. loop_create_sample_run / loop_list_sample_runs - Have the agent write N alternative answers to each conversation in a slice and score them with a judge. This is how DPO pairs and GRPO groups are made with no human writing each one. State the upper bound of model calls (sample × n × 2) before starting.
1036
- 14. loop_list_datasets - The sets built so far, each with how many rows it kept and why the rest were dropped.
1037
- 15. loop_preview_dataset / loop_register_dataset_for_training - Read the actual rows before anyone trains on them; each carries the address of the conversation it came from. Registering hands a built set to training as a real dataset id, keeping the link back to the conversations, verdicts and judges that produced it — the alternative is downloading the file and uploading it again as an unrelated dataset, which throws that link away. Register "train" and "holdout" separately when the job should be scored on rows it never saw. The set is then validated like any upload, so poll get_dataset_status before training on it.
1038
- 16. loop_stats - How much is recorded, how much is reviewed, and what each method could use right now.
1039
- 17. loop_preflight_training_rule - PRICE a standing training rule without creating anything: the pinned revision, the worst hourly price each GPU ladder can reach, how many hours each ceiling buys, the refusals, and terms_text — the sentence the member is agreeing to, with their own figures in it. Always first.
1040
- 18. loop_create_training_rule - Create it. SPEND CONSENT, and recorded: accept_terms is the member's signature on that sentence. Read the estimate and terms_text back VERBATIM, wait for an explicit yes, and never state a ceiling, a price cap or an hour count preflight did not return.
1041
- 19. loop_list_training_rules / loop_get_training_rule / loop_update_training_rule / loop_consent_training_rule / loop_run_training_rule / loop_delete_training_rule - Read, edit, re-consent, fire now, retire. A money-bearing edit bumps the rule's revision, drops the consent and STOPS FUTURE FIRINGS until somebody accepts the new terms.
1042
- 20. loop_list_training_runs / loop_get_training_run / loop_list_pipelines / loop_get_pipeline - What each firing did, its timeline, and available_actions — which says what this reader may actually do next, rather than what the state looks like it allows. The pipeline tools read a rule as the versions it produced: which one serves, how many of max_versions are made, the run in flight, and the month's spend against its limit.
1043
- 21. loop_get_evaluation / loop_list_evaluation_items / loop_get_judge_agreement - The comparison behind a verdict: the win rate and the per-dimension means, the paired conversations themselves, and how far the judge already agrees with this workspace's own reviewers. Read the warnings out, not just the verdict.
1044
- 22. loop_promote_training_run / loop_reject_training_run / loop_rollback_training_run / loop_cancel_training_run - The decision. Promotion re-points the serving name at the candidate and changes what real customers get; it is undoable for 30 days.
1045
- 23. loop_get_agent_settings / loop_update_agent_settings / loop_update_build_rule - The agent's default model, its system prompts and the monthly evaluation cap; and the standing curation rule a training rule draws from.
1046
- 24. 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.
1047
-
1048
- END TO END: capture what a model was asked and answered, have people or judges say whether it was right, write ONE training rule that curates the reviewed work and trains on it, let the platform compare the result against what serves today, read the comparison, then decide — promote, reject, or leave it. Everything before the rule costs nothing; the rule is where money starts, which is why it carries a recorded 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': last_reason says why a quiet rule is quiet, because a rule waiting for rows looks identical to a broken one; and accepted_revision against revision (with paused_reason) says whether the rule still has a consent to spend under, because a money-bearing edit pauses it until somebody agrees again.`,
1046
+ ${loopGuideSection}`,
1049
1047
  quick_start: `# Quick Start Guide
1050
1048
 
1051
1049
  ## Fastest path to a fine-tuned model:
@@ -1223,7 +1221,9 @@ Serverless is the immediate, per-token inference product. It does NOT create or
1223
1221
  - Do not retry an empty completion blindly: finish_reason=length means max_tokens was exhausted, not that the endpoint lost data.
1224
1222
 
1225
1223
  ## Billing
1226
- Serverless is billed per token to the API key's bound workspace. It is distinct from dedicated GPU billing and from the Loop agent, even though the Loop agent spends through that workspace's serverless account.`,
1224
+ Serverless is billed per token to the API key's bound workspace. ${launchGates.loop
1225
+ ? "It is distinct from dedicated GPU billing."
1226
+ : "It is distinct from dedicated GPU billing and from the Loop agent, even though the Loop agent spends through that workspace's serverless account."}`,
1227
1227
  dedicated_deployment: `# Dedicated Model Deployment
1228
1228
 
1229
1229
  A dedicated deployment reserves GPU capacity and creates a durable endpoint. Use it for dedicated capacity, custom/trained checkpoints, or lifecycle control. It bills GPU time per second and is NOT required for serverless inference.
@@ -1256,9 +1256,9 @@ Capacity errors include bookable alternatives. Never silently substitute a GPU o
1256
1256
 
1257
1257
  Every MCP tool acts inside the API key's bound organization and workspace.
1258
1258
  1. Call introspect_api_key first. Read user/workspace/org, scopes, allowed tools and feature grants from the result.
1259
- 2. Call get_wallet_balance before any training, dedicated deployment or automated Loop action that spends.
1259
+ 2. Call get_wallet_balance before any training${launchGates.loop ? " or dedicated deployment" : ", dedicated deployment or automated Loop action"} that spends.
1260
1260
  3. Never invent, guess or substitute another org/workspace/account id. A different workspace requires a different authorized credential.
1261
- 4. Serverless calls, dedicated deployments, training jobs, datasets and Loop artifacts remain scoped to that resolved workspace. Empty lists may mean this workspace has no resources; they do not prove the platform has none globally.
1261
+ 4. Serverless calls, dedicated deployments, training jobs${launchGates.loop ? " and datasets" : ", datasets and Loop artifacts"} remain scoped to that resolved workspace. Empty lists may mean this workspace has no resources; they do not prove the platform has none globally.
1262
1262
  5. Repeat the resolved workspace and cost owner before asking for spend or destructive consent, without exposing the credential itself.`,
1263
1263
  end_to_end: launchGates.training || launchGates.datasets
1264
1264
  ? `# End-to-End Availability\n\nServerless inference and dedicated deployment are fully live. Start with identity_and_scope, then choose serverless_inference or dedicated_deployment. Dataset/training creation is gated in this deployment; do not invent a workaround. Existing datasets, jobs and checkpoints remain readable and manageable through their lifecycle tools.`
@@ -1270,8 +1270,7 @@ Every MCP tool acts inside the API key's bound organization and workspace.
1270
1270
  4. Consent and train: explicit spend approval -> create_training_job -> status/metrics/evals/logs -> verified checkpoint.
1271
1271
  5. Deploy checkpoint: preflight_inference(source_type=checkpoint) -> explicit deployment approval -> create_inference -> status=running.
1272
1272
  6. Infer: chat_with_inference -> metrics/notifications.
1273
- 7. Improve: optional Conscious Loop capture/review/evaluation/training rule; capture and spending are separate opt-ins.
1274
- 8. Clean up: stop/delete deployment, checkpoint or dataset only as separate explicit irreversible actions.
1273
+ ${launchGates.loop ? "" : "7. Improve: optional Conscious Loop capture, feedback and pipelines; capture and spending are separate opt-ins.\n"}${launchGates.loop ? 7 : 8}. Clean up: stop/delete deployment, checkpoint or dataset only as separate explicit irreversible actions.
1275
1274
 
1276
1275
  At every step use returned ids and available_actions. Never infer the next action from a status label alone, never report success before the authoritative terminal state, and never substitute a GPU/model/price without approval.`,
1277
1276
  inference: `# Model Inference Guide
@@ -1581,15 +1580,17 @@ create (1-5 choices), and the queue itself stays opt-in.`,
1581
1580
  limit: z.number().int().min(1).max(60).optional().describe("Page size (default 24, max 60)."),
1582
1581
  offset: z.number().int().min(0).optional().describe("Pagination offset (default 0)."),
1583
1582
  }, async ({ type, sort, limit, offset }) => {
1583
+ const pageSize = limit ?? 24;
1584
+ const skip = offset ?? 0;
1584
1585
  const data = await client.api(MODEL_CATALOG_PATH, {
1585
1586
  params: {
1586
1587
  type: type && type !== "all" ? type : undefined,
1587
1588
  sort,
1588
- limit: limit !== undefined ? String(limit) : undefined,
1589
- offset: offset !== undefined ? String(offset) : undefined,
1589
+ limit: String(pageSize),
1590
+ offset: String(skip),
1590
1591
  },
1591
1592
  });
1592
- return json(data);
1593
+ return json(withNextOffset(data, "models", pageSize, skip));
1593
1594
  });
1594
1595
  server.tool("list_inference_models", "List models this credential can invoke through /v1/models: serverless pool models with serverless scope and this workspace's dedicated deployments with deployments:read or deployments:write. An unreachable source is named in usf_unreachable_sources; never infer it has no models. list_models is the separate trainable-model registry.", {
1595
1596
  query: z.string().min(1).max(200).optional().describe("Case-insensitive match on the model id."),
@@ -2160,15 +2161,17 @@ create (1-5 choices), and the queue itself stays opt-in.`,
2160
2161
  /* ══════════════════════════════════════════════════════════════════════════ */
2161
2162
  /* TOOL: list_datasets */
2162
2163
  /* ══════════════════════════════════════════════════════════════════════════ */
2163
- server.tool("list_datasets", "Read one page of workspace datasets. Ready is the default; request processing, failed, or all explicitly. Use offset/limit for more results; list membership does not mean the dataset is compatible with every training method.", {
2164
+ server.tool("list_datasets", "Read one page of workspace datasets with the total count. Ready is the default; request processing, failed, or all explicitly. Pass next_offset as offset for the next page; never treat one page as the whole list. Use query to find a dataset by name. List membership does not mean the dataset is compatible with every training method.", {
2164
2165
  query: z.string().max(256).optional(),
2165
2166
  status: z.enum(["ready", "processing", "failed", "all"]).default("ready"),
2166
2167
  dataset_type: z.string().max(64).optional(),
2167
2168
  limit: z.number().int().min(1).max(200).optional(),
2168
2169
  offset: z.number().int().min(0).optional(),
2169
2170
  }, async ({ query, status, dataset_type, limit, offset }) => {
2170
- const data = await client.api("/api/datasets", { params: { q: query, status, dataset_type, limit: String(limit ?? 50), offset: String(offset ?? 0) } });
2171
- return json(data);
2171
+ const pageSize = limit ?? 50;
2172
+ const skip = offset ?? 0;
2173
+ const data = await client.api("/api/datasets", { params: { q: query, status, dataset_type, limit: String(pageSize), offset: String(skip) } });
2174
+ return json(withNextOffset(data, "datasets", pageSize, skip));
2172
2175
  });
2173
2176
  /* ══════════════════════════════════════════════════════════════════════════ */
2174
2177
  /* TOOL: upload_dataset */
@@ -2861,25 +2864,28 @@ create (1-5 choices), and the queue itself stays opt-in.`,
2861
2864
  /* preset on purpose: what they return is raw prompts and completions, not */
2862
2865
  /* catalog metadata, so a key holds them only by an explicit grant. */
2863
2866
  /* */
2864
- /* NOTHING IS RECORDED until loop_set_capture turns a source on. An agent */
2865
- /* calling loop_capture_trace against a source nobody enabled gets */
2866
- /* captured:false back, not an error and not a stored conversation. */
2867
+ /* NOTHING IS RECORDED until a source is switched on: by loop_set_capture, */
2868
+ /* or -- for the source named by a pipeline's own tag -- by creating the */
2869
+ /* pipeline. An agent calling loop_capture_trace against a source nobody */
2870
+ /* switched on gets captured:false back, not an error and not a stored */
2871
+ /* conversation. */
2867
2872
  /* ══════════════════════════════════════════════════════════════════════════ */
2868
- 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). Recordings are deleted automatically once the retention window passes.", {
2873
+ 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.", {
2869
2874
  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')"),
2870
2875
  enabled: z.boolean().describe("true starts recording new conversations from this source; false stops. Turning it off never deletes what is already stored."),
2871
2876
  retention_days: z.number().int().optional().describe("How many days recordings are kept before automatic deletion (1-3650, default 30)"),
2872
2877
  sample_rate: z.number().optional().describe("Record only a fraction of conversations, 0 to 1. Deterministic per conversation, so a multi-turn exchange is never split in half."),
2873
- auto_grade: z.boolean().optional().describe("Turn on continuous rule-based scoring for this source: the platform applies the workspace's graders to conversations nobody has reviewed, for free — a rule is arithmetic, not a model call. Off by default. OMIT IT to leave the current setting alone; sending false switches it off."),
2874
- }, async ({ source, enabled, retention_days, sample_rate, auto_grade }) => {
2878
+ tags: z.array(z.string()).optional().describe("Tags stamped, server side, on every conversation recorded from this source. A pipeline trains on every conversation carrying any of its tags, so this is how LIVE TRAFFIC reaches a pipeline. Omit to leave the source's tags as they are; an empty list clears them."),
2879
+ }, async ({ source, enabled, retention_days, sample_rate, tags }) => {
2875
2880
  const data = await client.api(`/api/loop/configs/${encodeURIComponent(source)}`, {
2876
2881
  method: "PUT",
2877
- body: { enabled, retention_days, sample_rate, auto_grade },
2882
+ body: { enabled, retention_days, sample_rate, tags },
2878
2883
  });
2879
2884
  return json(data);
2880
2885
  });
2881
- 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. 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; if you pass request_id, replaying it returns the same trace instead of storing a duplicate. If the source has not been enabled with loop_set_capture this returns captured:false and stores nothing.", {
2882
- source: z.string().describe("The source being recorded — must already be enabled with loop_set_capture"),
2886
+ 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")));
2887
+ 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.", {
2888
+ source: z.string().describe("The source being recorded: a pipeline's own_tag, or a source switched on with loop_set_capture"),
2883
2889
  model: z.string().describe("The model that produced the answer, e.g. 'gpt-4o' or a Run BiOS model id"),
2884
2890
  messages: z.array(z.object({
2885
2891
  role: z.string().describe("system | user | assistant | tool"),
@@ -2900,18 +2906,19 @@ create (1-5 choices), and the queue itself stays opt-in.`,
2900
2906
  conversation_id: z.string().optional().describe("Ties the turns of one conversation together"),
2901
2907
  request_id: z.string().optional().describe("Your own idempotency handle — replaying it will not create a second trace"),
2902
2908
  latency_ms: z.number().int().optional(),
2903
- }, async ({ source, model, messages, completion, tool_calls, tools, conversation_id, request_id, latency_ms }) => {
2909
+ 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."),
2910
+ }, async ({ source, model, messages, completion, tool_calls, tools, conversation_id, request_id, latency_ms, labels }) => {
2904
2911
  const data = await client.api("/api/loop/traces", {
2905
2912
  method: "POST",
2906
2913
  body: {
2907
2914
  deployment_id: source, model, messages,
2908
2915
  completion: completion ?? "",
2909
- tool_calls, tools, conversation_id, request_id, latency_ms,
2916
+ tool_calls, tools, conversation_id, request_id, latency_ms, labels,
2910
2917
  },
2911
2918
  });
2912
2919
  return json(data);
2913
2920
  });
2914
- 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, rules, judges and labels. A row becomes trainable when something says it is good, never because it arrived in a file. Each row is read for what it is: a prompt/chosen/rejected triple becomes a preference pair; an answer plus a yes-or-no becomes a thumbs verdict; a question and an answer waits for review; a question with no answer waits for an answer; a paragraph of prose is refused, because it is not a conversation. A verdict that arrives with the file is kept, but recorded as having come from the user's earlier process rather than from a reviewer here. WHAT COMES BACK, and what to tell the user: imported is what this call created and trace_ids names the conversations from the file that are now in the loop; already_present is rows an earlier import of the same file already had, so they were not stored twice; by_shape counts the shapes this call CREATED, not what was merely recognised and not rows that were already here, so a file that was silently the wrong shape shows up there rather than merely importing fewer rows than expected, and reviewed and needs_review likewise count only what this call wrote; refused with refused_why is rows whose shape could not be read, which the user fixes by changing the file; not_saved with not_saved_rows is rows that were read and understood and then could not be stored, which the file cannot fix; verdicts_not_saved with verdicts_not_saved_rows is rows whose conversation stored and whose VERDICT did not, so those conversations are in the loop waiting for review rather than reviewed. A call with any not_saved or verdicts_not_saved rows fails with code IMPORT_INCOMPLETE and carries all of the same counts. HOW TO RETRY SAFELY: every response carries import_id, the token that call was filed under. Send the SAME rows again with that import_id and anything already imported comes back under already_present instead of being stored a second time, while a verdict that failed to write is attempted again. Do NOT retry without repeating import_id: a call that repeats no token is its own import and stores everything again, deliberately, because two calls carrying the same rows are as likely to be two pages of one export as one call sent twice. SENDING MORE THAN 5000 ROWS: split the file and either give every row its own row_ids entry, after which chunking and ordering stop mattering, or send one import_id with the row_offset each page starts at. Without row_ids, do not send back only the rows not_saved_rows names: a shorter list moves every row after the gap and imports it again.", {
2921
+ server.tool("loop_import_rows", "Bring data the user ALREADY HAS into the loop — an export from another provider, a spreadsheet of past answers, a set of preference pairs. This does NOT create a training set: it creates conversations, in the same place captured ones live, subject to the same review, tags and pipelines. A row becomes trainable when something says it is good, never because it arrived in a file. Each row is read for what it is: a prompt/chosen/rejected triple becomes a preference pair; an answer plus a yes-or-no becomes a thumbs verdict; a question and an answer waits for review; a question with no answer waits for an answer; a paragraph of prose is refused, because it is not a conversation. A verdict that arrives with the file is kept, but recorded as having come from the user's earlier process rather than from a reviewer here. WHAT COMES BACK, and what to tell the user: imported is what this call created and trace_ids names the conversations from the file that are now in the loop; already_present is rows an earlier import of the same file already had, so they were not stored twice; by_shape counts the shapes this call CREATED, not what was merely recognised and not rows that were already here, so a file that was silently the wrong shape shows up there rather than merely importing fewer rows than expected, and reviewed and needs_review likewise count only what this call wrote; refused with refused_why is rows whose shape could not be read, which the user fixes by changing the file; not_saved with not_saved_rows is rows that were read and understood and then could not be stored, which the file cannot fix; verdicts_not_saved with verdicts_not_saved_rows is rows whose conversation stored and whose VERDICT did not, so those conversations are in the loop waiting for review rather than reviewed. A call with any not_saved or verdicts_not_saved rows fails with code IMPORT_INCOMPLETE and carries all of the same counts. HOW TO RETRY SAFELY: every response carries import_id, the token that call was filed under. Send the SAME rows again with that import_id and anything already imported comes back under already_present instead of being stored a second time, while a verdict that failed to write is attempted again. Do NOT retry without repeating import_id: a call that repeats no token is its own import and stores everything again, deliberately, because two calls carrying the same rows are as likely to be two pages of one export as one call sent twice. SENDING MORE THAN 5000 ROWS: split the file and either give every row its own row_ids entry, after which chunking and ordering stop mattering, or send one import_id with the row_offset each page starts at. Without row_ids, do not send back only the rows not_saved_rows names: a shorter list moves every row after the gap and imports it again.", {
2915
2922
  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."),
2916
2923
  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."),
2917
2924
  model: z.string().optional().describe("Which model produced these answers, when the rows do not say per-row"),
@@ -2927,18 +2934,23 @@ create (1-5 choices), and the queue itself stays opt-in.`,
2927
2934
  });
2928
2935
  return json(data);
2929
2936
  });
2930
- server.tool("loop_list_traces", "List recorded conversations for this workspace, newest first. Use signalled_only to see just the ones that already carry a verdict, which is what a training set can actually be built from.", {
2937
+ server.tool("loop_list_traces", "List recorded conversations (samples) for this workspace, newest first. To read a pipeline's data by feedback, pass pipeline with signal ('good', 'bad' or 'corrected': the verdict that decides the sample) or unsignalled true (what still needs review). A bad sample is never trained on. from/to narrow by when a conversation was recorded.", {
2931
2938
  source: z.string().optional().describe("Only conversations from this source"),
2932
2939
  model: z.string().optional().describe("Only what THIS model answered, e.g. 'kimi-k3'. The filter distillation is made of — every other filter selects by what was asked or what somebody said about the answer, never by who produced it."),
2933
2940
  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."),
2934
2941
  conversation_id: z.string().optional(),
2942
+ pipeline: z.string().optional().describe("Only the conversations this pipeline's tags select (its id, from loop_list_pipelines): its samples, reviewed or not."),
2935
2943
  label: z.string().optional().describe("Only conversations carrying this bare tag. A tag with children matches them too."),
2936
2944
  attributes: z.record(z.string(), z.string()).optional().describe("Only conversations matching every one of these dimensions, e.g. {\"category\":\"billing\",\"language\":\"es\"}"),
2937
2945
  unlabelled: z.boolean().optional().describe("Only the conversations nobody has described yet — the pile worth looking at before deciding what the dimensions should be"),
2938
- signalled_only: z.boolean().optional().describe("Only conversations that already have feedback on them"),
2939
- limit: z.number().int().optional().describe("Default 50, maximum 200"),
2946
+ signal: z.enum(["good", "bad", "corrected"]).optional().describe("Only conversations whose deciding feedback is this: good (accepted), bad (rejected) or corrected (an edited or gold answer)"),
2947
+ unsignalled: z.boolean().optional().describe("Only conversations with no feedback yet: the review queue"),
2948
+ signalled_only: z.boolean().optional().describe("Only conversations that have any feedback (good, bad or corrected)"),
2949
+ from: z.string().datetime({ offset: true }).optional().describe("Only conversations recorded at or after this RFC 3339 time"),
2950
+ to: z.string().datetime({ offset: true }).optional().describe("Only conversations recorded at or before this RFC 3339 time"),
2951
+ limit: z.number().int().min(1).max(200).optional().describe("Default 50, maximum 200"),
2940
2952
  offset: z.number().int().optional(),
2941
- }, async ({ source, model, origin, conversation_id, label, attributes, unlabelled, signalled_only, limit, offset }) => {
2953
+ }, async ({ source, model, origin, conversation_id, pipeline, label, attributes, unlabelled, signal, unsignalled, signalled_only, from, to, limit, offset }) => {
2942
2954
  const attrs = Object.entries(attributes ?? {})
2943
2955
  .filter(([k, v]) => k && v)
2944
2956
  .map(([k, v]) => `${k}:${v}`);
@@ -2948,10 +2960,15 @@ create (1-5 choices), and the queue itself stays opt-in.`,
2948
2960
  model,
2949
2961
  origin,
2950
2962
  conversation_id,
2963
+ pipeline,
2951
2964
  label,
2952
2965
  attr: attrs.length ? attrs : undefined,
2953
2966
  unlabelled: unlabelled ? "true" : undefined,
2967
+ signal,
2968
+ unsignalled: unsignalled ? "true" : undefined,
2954
2969
  signalled: signalled_only ? "true" : undefined,
2970
+ from,
2971
+ to,
2955
2972
  limit: limit?.toString(),
2956
2973
  offset: offset?.toString(),
2957
2974
  },
@@ -2962,6 +2979,7 @@ create (1-5 choices), and the queue itself stays opt-in.`,
2962
2979
  const data = await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}`);
2963
2980
  return json(data);
2964
2981
  });
2982
+ server.tool("loop_delete_trace", "Delete one recorded conversation, with its feedback and tags, for good. It stops counting toward every pipeline that selected it. A version already trained on it keeps what it learned. Confirm with the user first: this cannot be undone.", { trace_id: z.string() }, async ({ trace_id }) => json(await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}`, { method: "DELETE" })));
2965
2983
  server.tool("loop_add_signal", "Record a verdict on a conversation: was the answer right? Feedback is APPEND-ONLY — a second verdict does not replace the first, because two reviewers disagreeing is information worth keeping.\n\nWHICH VERDICT TO USE, because they train different things:\n• accepted (a thumbs up) — the answer was good. It becomes an example to learn from.\n• rejected (a thumbs down) — it was wrong and you have nothing better. This REMOVES the answer from training rather than teaching anything; prefer edited whenever you know the better answer.\n• edited — the model was WRONG and here is what it should have said. The most valuable single action: it produces both halves of a preference pair, so the model learns your answer and learns to avoid its own.\n• gold — the REFERENCE answer for this question, recorded whatever the model happened to say. Unlike edited it asserts nothing about the model, so if it matches what was said no preference is invented — but it still trains SFT, and it stands in as the GRPO reference when no separate ground_truth was given. It is the most reusable feedback there is.\n• scored — a number, with ground_truth when the answer is checkable.", {
2966
2984
  trace_id: z.string(),
2967
2985
  verdict: z.enum(["accepted", "rejected", "edited", "scored", "gold"]).describe("accepted = thumbs up; rejected = thumbs down with nothing better to offer; edited = the model was wrong and you wrote the better answer; gold = the reference answer for this question regardless of what the model said; scored = a number or checkable fact"),
@@ -2971,7 +2989,7 @@ create (1-5 choices), and the queue itself stays opt-in.`,
2971
2989
  ground_truth: z.string().optional().describe("The value or fact the answer can be checked against"),
2972
2990
  reason: z.string().optional().describe("Why, in your own words"),
2973
2991
  author: z.string().optional(),
2974
- labels: z.array(z.string()).optional().describe("Which training pipeline this feedback feeds, named HERE rather than in a second call. A build rule selects conversations by label and a training rule trains from that build rule, so a label 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 labelling 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."),
2992
+ 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."),
2975
2993
  attributes: z.record(z.string(), z.string()).optional().describe("The same, by named dimension: {\"category\": \"billing\"}. Dimensions AND together when a set is built, which is what lets one corpus become a different dataset per task."),
2976
2994
  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."),
2977
2995
  }, async ({ trace_id, verdict, source, correction, score, ground_truth, reason, author, labels, attributes, parent }) => {
@@ -2984,29 +3002,7 @@ create (1-5 choices), and the queue itself stays opt-in.`,
2984
3002
  });
2985
3003
  return json(data);
2986
3004
  });
2987
- server.tool("loop_add_candidate", "Submit an ALTERNATIVE answer to a prompt that was already recorded, and score it. This is how preference training scales without a person writing every answer: sample the model several times for the same prompt, score each sample, and a DPO pair falls out automatically — the best-scoring sample becomes 'chosen' and the worst becomes 'rejected'. Point a stronger model at the prompt instead and the same mechanism does distillation. SCORE EVERY CANDIDATE: an unscored alternative cannot pair, because it says nothing about which answer is preferred. A human correction recorded with loop_add_signal always outranks any score you submit here.", {
2988
- trace_id: z.string().describe("The recorded conversation this is an alternative answer to"),
2989
- completion: z.string().optional().describe("The alternative answer's text"),
2990
- tool_calls: z.array(z.object({
2991
- id: z.string().optional(),
2992
- type: z.string().optional(),
2993
- function: z.object({ name: z.string(), arguments: z.string() }),
2994
- })).optional().describe("When the alternative was itself a tool call"),
2995
- model: z.string().optional().describe("Which model produced this alternative — the teacher, when distilling"),
2996
- score: z.number().optional().describe("How good it is. Higher is better. Required for the candidate to take part in a pair."),
2997
- score_source: z.enum(["human", "verifier", "judge", "behavioural"]).optional().describe("What produced the score. Required whenever a score is given."),
2998
- reason: z.string().optional(),
2999
- }, async ({ trace_id, completion, tool_calls, model, score, score_source, reason }) => {
3000
- const data = await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}/candidates`, {
3001
- method: "POST",
3002
- body: { completion: completion ?? "", tool_calls, model, score, score_source, reason },
3003
- });
3004
- return json(data);
3005
- });
3006
- server.tool("loop_list_candidates", "Read every alternative answer recorded for one prompt, with its score and what produced that score. Use this to see whether sampling has produced a spread worth training on — if every sample scored the same, the reward function cannot separate them and no pair will be built.", { trace_id: z.string() }, async ({ trace_id }) => {
3007
- const data = await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}/candidates`);
3008
- return json(data);
3009
- });
3005
+ 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`)));
3010
3006
  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.", {
3011
3007
  trace_id: z.string(),
3012
3008
  labels: z.array(z.string()).optional().describe("Bare tags, e.g. ['refunds','escalated']. Lowercase letters, digits, dot, dash or underscore."),
@@ -3028,368 +3024,92 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3028
3024
  return json(data);
3029
3025
  });
3030
3026
  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")));
3031
- server.tool("loop_create_judge", "Write a RUBRIC a model applies to answers nobody has time to read. A grader is a rule — deterministic, cheap, and blind to anything it was not told to look for; a judge is instructions in your own words, and it is the only thing that can answer 'was this answer actually helpful'. Score several things SEPARATELY: 'helpful but wrong' is not expressible in one number, and an average that hides it is worse than no score. The rubric also becomes a REWARD FUNCTION for GRPO — a prompt with a rubric attached is trainable even though no string the answer has to equal exists, which is most real work.", {
3032
- name: z.string().describe("What this judge is for, e.g. 'Billing tone'"),
3033
- instructions: z.string().describe("What makes an answer good here, in your own words"),
3034
- dimensions: z.array(z.object({
3035
- key: z.string().describe("Lowercase name, e.g. 'accuracy'"),
3036
- description: z.string().optional().describe("What that dimension means"),
3037
- })).describe("What to score, separately. At most 12."),
3038
- label: z.string().optional().describe("Only judge this slice. A parent label gathers its children."),
3039
- deployment_id: z.string().optional().describe("Only judge conversations from this source"),
3040
- sample: z.number().optional().describe("At most this many conversations per run"),
3041
- only_unscored: z.boolean().optional().describe("Skip conversations a judge has already scored"),
3042
- model: z.string().optional().describe("Which model judges with. Recorded so two runs are comparable; REQUIRED when auto is true, because that is the model the workspace pays for."),
3043
- write_gold: z.boolean().optional().describe("Let the judge write the answer that SHOULD have been given. Off by default: training on a model-written reference is distillation, and that is a decision you make deliberately."),
3044
- auto: z.boolean().optional().describe("SPEND CONSENT. Let the platform's agent run this judge: new conversations in its slice are scored as they arrive, one model call each, billed to THIS WORKSPACE'S OWN serverless account through a managed key of theirs ('Conscious Loop' in their key list). Say this to the user before setting it. Requires model. Off (default), the user runs the model: loop_start_judge_run, loop_take_judge_work, loop_post_judge_verdicts."),
3045
- }, async ({ name, instructions, dimensions, label, deployment_id, sample, only_unscored, model, write_gold, auto }) => {
3046
- const data = await client.api("/api/loop/judges", {
3047
- method: "POST",
3048
- body: {
3049
- name, instructions, dimensions, model, write_gold, auto,
3050
- selection: { label, deployment_id, sample, only_unscored },
3051
- },
3052
- });
3053
- return json(data);
3054
- });
3055
- server.tool("loop_list_judges", "Every rubric this workspace has written, with what it scores and which slice it covers.", {}, async () => json(await client.api("/api/loop/judges")));
3056
- server.tool("loop_start_judge_run", "Open a run of one judge: select the conversations NOW and freeze the rubric onto them. The selection is fixed at this moment on purpose — a run whose selection is a live query silently grows as conversations arrive, so 'we scored the refunds slice' becomes a claim about a set that no longer exists. Follow with loop_take_judge_work.", {
3057
- judge_id: z.string(),
3058
- limit: z.number().optional().describe("Cap this pass below the judge's own sample, for a cheap look first"),
3059
- }, async ({ judge_id, limit }) => {
3060
- const data = await client.api(`/api/loop/judges/${encodeURIComponent(judge_id)}/runs`, {
3061
- method: "POST", body: limit ? { limit } : {},
3062
- });
3063
- return json(data);
3064
- });
3065
- server.tool("loop_take_judge_work", "Take the next conversations to score. Each item arrives with a 'prompt' ALREADY RENDERED — send that text to a model as-is and reply with the JSON it asks for. Do not assemble your own: two callers that build the prompt differently are not applying one rubric. YOU run the model here on purpose; the service that stores these conversations holds no credentials to any model, which is most of the reason it is safe to store them there. Nothing is marked taken, so asking again returns the same items.", { run_id: z.string(), limit: z.number().optional() }, async ({ run_id, limit }) => {
3066
- const qs = limit ? `?limit=${limit}` : "";
3067
- return json(await client.api(`/api/loop/runs/${encodeURIComponent(run_id)}/work${qs}`));
3068
- });
3069
- server.tool("loop_list_judge_run_items", "What happened to each conversation in a judge run, and why. loop_take_judge_work hands out what is still PENDING, so a finished run answers it with nothing; this answers with every conversation the run selected, its outcome (pending, scored or failed), the reason a failed one could not be scored, and when it was scored. A run that ends 'scored 0, failed 3' is otherwise a number with no detail behind it, and the reason is usually the whole answer: a model name nothing serves, a refused key, a reply that would not parse. Ask with status 'failed' when you are looking into a run that went wrong. At most 500 conversations come back at a time; when 'has_more' is true, ask again with the 'next_offset' from the reply.", {
3070
- run_id: z.string(),
3071
- status: z.enum(["pending", "scored", "failed"]).optional().describe("Only conversations with this outcome. 'failed' is the usual question."),
3072
- limit: z.number().optional().describe("How many to return, up to 500. Default 100."),
3073
- offset: z.number().optional().describe("Where to carry on from, for a run longer than one page"),
3074
- }, async ({ run_id, status, limit, offset }) => {
3075
- const qs = new URLSearchParams();
3076
- if (status)
3077
- qs.set("status", status);
3078
- if (limit != null)
3079
- qs.set("limit", String(limit));
3080
- if (offset)
3081
- qs.set("offset", String(offset));
3082
- const suffix = qs.toString() ? `?${qs.toString()}` : "";
3083
- return json(await client.api(`/api/loop/runs/${encodeURIComponent(run_id)}/items${suffix}`));
3084
- });
3085
- server.tool("loop_post_judge_verdicts", "Hand the scores back. Each verdict carries one score per dimension the rubric asked for, each between 0 and 1, plus a reason — a score nobody can argue with is one nobody can correct. Scoring something the rubric never asked for is REFUSED rather than averaged in, and reported in 'rejected'. If you could not score an item, send it with 'error' instead of dropping it: a run that quietly shrinks is one whose coverage nobody can state.", {
3086
- run_id: z.string(),
3087
- verdicts: z.array(z.object({
3088
- trace_id: z.string(),
3089
- scores: z.record(z.string(), z.number()).optional().describe("One score per dimension, 0 to 1"),
3090
- overall: z.number().optional().describe("Your own combination. Left out, the plain mean is used."),
3091
- reason: z.string().optional().describe("Why, in a sentence or two"),
3092
- gold: z.string().optional().describe("The answer that should have been given. Stored only if the judge has write_gold."),
3093
- error: z.string().optional().describe("Mark an item you could not score"),
3094
- })),
3095
- finish: z.boolean().optional().describe("Close the run in this same call, once you have nothing left to send. To close a run WITHOUT scores use loop_stop_judge_run: an empty verdict list with finish set writes 'done' on a run that never finished."),
3096
- }, async ({ run_id, verdicts, finish }) => {
3097
- const data = await client.api(`/api/loop/runs/${encodeURIComponent(run_id)}/verdicts`, {
3098
- method: "POST", body: { verdicts, finish: finish ?? false },
3099
- });
3100
- return json(data);
3101
- });
3102
- server.tool("loop_stop_judge_run", "Close a run that has not finished. This is what the refusal means when it says a judge already has a run going: an open run BLOCKS the next one, and the runs that most need closing are the ones nobody can wait out — a run parked on an empty balance or a refused key stays open until the reason is fixed or somebody stops it. Nothing is deleted: every verdict already recorded stays recorded, the counters keep saying how much of the selection was covered, and the conversations the run was holding are free for the next run. Do NOT close a run by posting an empty verdict list with finish set: that writes 'done' on it, and a run that scored 3 of 40 then reads like one that finished its work. A stopped run reads 'stopped'. A run that finished on its own answers 409 and is left alone.", { run_id: z.string() }, async ({ run_id }) => {
3103
- const data = await client.api(`/api/loop/runs/${encodeURIComponent(run_id)}/stop`, {
3104
- method: "POST", body: {},
3105
- });
3106
- return json(data);
3107
- });
3108
- server.tool("loop_agent_status", "Whether the platform's AGENT can run in this environment, whether it is running, and whether THIS workspace has turned it on. The agent is the worker that calls a model on the workspace's behalf — applying automatic judges and writing sample answers — and it spends through the workspace's OWN serverless key ('Conscious Loop' in their key list), so every call is billed exactly like a member's own. `available` is about the environment, `online` about the worker, `credential` about this workspace. Read this before promising a user that a judge will run itself.", {}, async () => json(await client.api("/api/loop/agent")));
3109
- server.tool("loop_enable_agent", "SPEND CONSENT. Turn the agent on for this workspace: mints its managed serverless key and resumes the automatic judges a previous turn-off paused (judges_resumed in the response). A judge whose model the serving gateway will not route is NOT resumed — making it automatic would buy a run that fails every conversation — so it stays paused, judges_still_paused counts those, and each carries auto_pause_reason saying so; point it at a served model and turn the agent on again. After this, automatic judges and sample runs make model calls billed to the workspace. Tell the user that before calling it. Idempotent — an already-on agent answers with its existing key's metadata (never the secret). `monthly_spend_cap_cents` caps what the key may spend on model calls per calendar month; omitted = no cap (the agent can spend up to the workspace's serverless balance), and passing it while the agent is already on moves the cap on the existing key. When the cap is reached the agent's model calls are refused until next month and its runs pause with that reason. Offer the cap when the user is unsure how much the agent should spend. A freshly minted key can be refused by the gateway for up to a minute; the agent waits that out on its own and a run's last_error says the key is still propagating.", {
3110
- monthly_spend_cap_cents: z.number().int().min(1).optional().describe("Monthly spend cap for the agent's key, in cents (e.g. 5000 = $50). Omit for no cap."),
3111
- }, async ({ monthly_spend_cap_cents }) => json(await client.api("/api/loop/agent", {
3112
- method: "POST",
3113
- body: monthly_spend_cap_cents != null ? { monthly_spend_cap_cents } : {},
3114
- })));
3115
- server.tool("loop_disable_agent", "Turn the agent off for this workspace: revokes its key and PAUSES every automatic judge in the workspace (auto=false, auto_paused=true; the response says how many as judges_paused). loop_enable_agent restores exactly those judges (judges_resumed). Open runs stop where they are and continue if it is turned back on. Nothing already scored or written is removed.", {}, async () => json(await client.api("/api/loop/agent", { method: "DELETE" })));
3116
- server.tool("loop_create_sample_run", "SPEND CONSENT. Ask the agent to write `n` ALTERNATIVE answers to each conversation in a slice with `model`, score each with `judge_id`, and store them as candidates. This is how preference pairs (DPO) and reward-scored groups (GRPO) are made WITHOUT a person writing each one: the curation pass pairs the best sample against the worst wherever the gap is real. Choose a stronger model than the one being trained and it is distillation. COST: up to n calls to write plus n to judge, per conversation, at the workspace's serverless rate — state the upper bound (sample × n × 2) to the user before starting. `sample` caps at 200 conversations and `n` at 8. Opening a run turns the agent on if it is off.", {
3117
- model: z.string().describe("The serverless model that writes the alternatives"),
3118
- n: z.number().int().min(1).max(8).optional().describe("Alternatives per conversation, 1-8. Default 4."),
3119
- temperature: z.number().min(0).max(2).optional().describe("Default 0.8. 0 makes every sample the same answer, which defeats the point."),
3120
- max_tokens: z.number().int().min(16).max(8192).optional().describe("Default 1024"),
3121
- judge_id: z.string().optional().describe("Score each sample with this judge as it is written. Omit to store them unscored; scoring rules still apply."),
3122
- label: z.string().optional().describe("Only this slice. A parent label gathers its children."),
3123
- deployment_id: z.string().optional().describe("Only conversations from this source"),
3124
- sample: z.number().int().min(1).max(200).optional().describe("At most this many conversations. Default and maximum 200."),
3125
- only_unsampled: z.boolean().optional().describe("Skip conversations that already have alternatives (default true)"),
3126
- }, async ({ model, n, temperature, max_tokens, judge_id, label, deployment_id, sample, only_unsampled }) => {
3127
- const data = await client.api("/api/loop/sample-runs", {
3128
- method: "POST",
3129
- body: {
3130
- model, n, temperature, max_tokens, judge_id,
3131
- selection: { label, deployment_id, sample, only_unsampled: only_unsampled ?? true },
3132
- },
3133
- });
3134
- return json(data);
3135
- });
3136
- server.tool("loop_list_sample_runs", "Every sample run in this workspace, newest first: selected, done, failed, alternatives written, and last_error when the agent had to pause one (a refused key, an empty balance, an unknown model). A run that stops moving says why here.", {}, async () => json(await client.api("/api/loop/sample-runs")));
3137
- server.tool("loop_create_grader", "Write a rule that scores answers automatically, with no person reading them. Human review is the most trustworthy feedback and the least available; a grader is written once and applied to every answer afterwards. Every kind is DETERMINISTIC — no model call — which is why a verifier's score outranks a judge's when they disagree: it cannot be flattered and it cannot drift between runs. 'weight' is the multiplier: a criterion that matters twice as much gets twice the weight, and the combined score is the weighted mean over the rules that actually applied. CAUTION WHEN WRITING A SET: a rule made only of 'forbidden' phrases is satisfied VACUOUSLY by an answer that says nothing, so pair it with a required phrase or you are rewarding silence. 'matches_gold' is the one kind with no expected value of its own: it compares each answer to the gold answer recorded on that SAME conversation (or the human correction when there is no gold), scores sampled alternatives against the same gold, and is NOT APPLIED to a conversation carrying neither, so one rule checks every labelled question without marking down the unlabelled ones.", {
3138
- name: z.string().describe("What this rule checks, in your words"),
3139
- kind: z.enum(["matches_gold", "exact_match", "contains", "regex", "json_valid", "numeric", "tool_called"]),
3140
- expected: z.string().optional().describe("For exact_match and numeric: the value to compare against"),
3141
- required: z.array(z.string()).optional().describe("For contains: every phrase that must appear"),
3142
- forbidden: z.array(z.string()).optional().describe("For contains: phrases that must not appear"),
3143
- pattern: z.string().optional().describe("For regex: the pattern the answer must match"),
3144
- keys: z.array(z.string()).optional().describe("For json_valid: keys the parsed answer must carry"),
3145
- tolerance: z.number().optional().describe("For numeric: how far from expected still counts. For matches_gold: how far from the gold answer still counts when both are bare numbers"),
3146
- function_name: z.string().optional().describe("For tool_called: the function that must have been invoked"),
3147
- case_sensitive: z.boolean().optional().describe("Turn off the default case and whitespace normalisation"),
3148
- label: z.string().optional().describe("Aim the rule at one slice. A master group covers its children. Outside that slice the rule is NOT APPLIED rather than failed, so it stays out of the score entirely. Without this, a rule like 'must name the order number' marks down every billing answer for not mentioning a shipment."),
3149
- weight: z.number().optional().describe("The multiplier, greater than zero. Default 1."),
3150
- source: z.string().optional().describe("Limit the rule to one capture source. Omit to apply it everywhere."),
3151
- }, async (input) => {
3152
- const data = await client.api("/api/loop/graders", {
3153
- method: "POST",
3154
- body: {
3155
- name: input.name, kind: input.kind, weight: input.weight,
3156
- deployment_id: input.source, label: input.label,
3157
- config: {
3158
- expected: input.expected, required: input.required, forbidden: input.forbidden,
3159
- pattern: input.pattern, keys: input.keys, tolerance: input.tolerance,
3160
- function: input.function_name, case_sensitive: input.case_sensitive,
3161
- },
3162
- },
3163
- });
3164
- return json(data);
3165
- });
3166
- server.tool("loop_list_graders", "Every scoring rule this workspace has written, with its weight and whether it is enabled.", {}, async () => json(await client.api("/api/loop/graders")));
3167
- server.tool("loop_grade_trace", "Apply this workspace's rules to one recorded answer AND to every alternative sampled for it, in one pass. That pairing is the point: scoring only the original gives a verdict, while scoring the samples too gives a preference pair — so sampling the model and then calling this produces DPO training data with nobody reading anything. A run where NO rule could apply writes nothing at all: no verdict, no scores. Recording a zero that no rule produced would poison the training data with a judgement nobody reached, so you get applied:0 back instead. Read the per-rule 'detail' to see which criterion an answer failed; a single combined number tells you nothing about what to fix.", { trace_id: z.string() }, async ({ trace_id }) => {
3168
- const data = await client.api(`/api/loop/traces/${encodeURIComponent(trace_id)}/grade`, {
3169
- method: "POST", body: {},
3170
- });
3171
- return json(data);
3172
- });
3173
- server.tool("loop_build_dataset", "Turn the recorded feedback into a training set. The three methods need genuinely different feedback, so a set built for one CANNOT be reshaped into another: 'sft' uses answers marked right and answers that were rewritten; 'dpo' needs answers that were REWRITTEN, so a better and a worse version of the same reply exist; 'grpo' needs answers with a checkable ground truth OR a judge rubric; 'kto' needs only a yes or a no on an answer, INCLUDING a bare thumbs-down that trains nothing under the other three — which is why it usually has the most rows. The result always reports rejected_counts — why rows were left out — so a small set can be explained rather than guessed at. holdout_percent holds back the most RECENT work rather than a random slice, so an evaluation measures whether the model generalised instead of memorising the same week.", {
3174
- name: z.string().describe("A name you will recognise later"),
3175
- method: z.enum(["sft", "dpo", "grpo", "kto"]),
3176
- attributes: z.record(z.string(), z.string()).optional().describe("Narrow to named dimensions, ANDed together: {\"category\":\"billing\",\"language\":\"es\"}. This is how ONE captured corpus becomes a DIFFERENT dataset for every task — nothing is re-labelled to do it."),
3177
- unlabelled: z.boolean().optional().describe("Only the conversations nobody has described yet"),
3178
- source: z.string().optional().describe("Only conversations from this source"),
3179
- label: z.string().optional().describe("Only conversations carrying this label. A label with children selects them too, so 'billing' picks up 'refunds'."),
3180
- from: z.string().optional().describe("RFC3339 timestamp, earliest conversation to consider"),
3181
- to: z.string().optional().describe("RFC3339 timestamp, latest conversation to consider"),
3182
- sample: z.number().int().optional().describe("Take this many of what the filters matched. Which ones is arbitrary but repeatable: the same conversations always give the same slice. Applied before the holdout split, so the held-back slice is still the newest of what was chosen."),
3183
- holdout_percent: z.number().int().optional().describe("0-50. Holds back the newest slice for evaluation."),
3184
- }, async ({ name, method, source, label, attributes, unlabelled, from, to, sample, holdout_percent }) => {
3185
- const data = await client.api("/api/loop/datasets", {
3186
- method: "POST",
3187
- body: {
3188
- name, method, deployment_id: source, label, attributes, unlabelled,
3189
- from, to, sample, holdout_percent,
3190
- },
3191
- });
3192
- return json(data);
3193
- });
3194
- server.tool("loop_create_build_rule", "Stand up a STANDING instruction to build a training set whenever enough new reviewed work exists — the automatic version of loop_build_dataset. The manual path asks somebody to notice that enough conversations have been reviewed, remember which filters describe the slice they care about, and press build, every time; nobody does that six weeks in, which is how a workspace accumulates reviewed feedback that never becomes a dataset. The `spec` is the same selection loop_build_dataset takes and is replayed verbatim, so an automatic set is identical to a hand-made one. Tell the user what `min_new_rows` means when you set it: it counts rows reviewed SINCE THE LAST BUILD, not the whole corpus — counting the total would fire the rule forever, because a total that has crossed a threshold stays across it.", {
3195
- name: z.string().describe("What to call the series of sets this rule produces, e.g. 'nightly support sft'. Each build is named after it plus a timestamp. Must be unique in the workspace: two rules writing the same name make the series unreadable."),
3196
- method: z.enum(["sft", "dpo", "grpo", "kto"]).describe("Which training method the sets are curated for"),
3197
- min_new_rows: z.number().int().min(100).optional().describe("Rows reviewed since the last build before this fires again. Minimum 100, default 100. A set built from fewer is too small to learn from and buries the useful ones."),
3198
- enabled: z.boolean().optional().describe("Default true. false stores the rule without running it."),
3199
- spec: z.record(z.string(), z.any()).optional().describe("The selection, same shape as loop_build_dataset: deployment_id, label, attributes, model, origin, holdout_percent, max_items, sample. Omit for the whole corpus."),
3200
- }, async ({ name, method, min_new_rows, enabled, spec }) => {
3201
- const data = await client.api("/api/loop/build-rules", {
3202
- method: "POST",
3203
- body: { name, method, min_new_rows, enabled, spec },
3204
- });
3205
- return json(data);
3206
- });
3207
- server.tool("loop_list_build_rules", "The standing build rules, with what each one last did and why. READ last_reason BACK TO THE USER when they ask why no dataset appeared: a rule that is quiet because it is waiting looks exactly like one that is quiet because it is broken, and that field is the only thing that tells them apart — 'waiting: 3 reviewed since the last build, needs 100'.", {}, async () => json(await client.api("/api/loop/build-rules")));
3208
- server.tool("loop_delete_build_rule", "Stop a standing build rule. The training sets it already produced are untouched — this stops future builds, it does not undo past ones.", { rule_id: z.string().describe("The rule's id, from loop_list_build_rules") }, async ({ rule_id }) => json(await client.api(`/api/loop/build-rules/${encodeURIComponent(rule_id)}`, { method: "DELETE" })));
3209
- server.tool("loop_list_datasets", "List the training sets built in this workspace, each with how many rows it kept, how many conversations were considered, and why the rest were left out.", {
3210
- method: z.enum(["sft", "dpo", "grpo", "kto"]).optional(),
3211
- limit: z.number().int().optional(),
3212
- }, async ({ method, limit }) => {
3213
- const data = await client.api("/api/loop/datasets", {
3214
- params: { method, limit: limit?.toString() },
3215
- });
3216
- return json(data);
3217
- });
3218
- // PRE-LAUNCH GATE (launchGates.datasets — src/config.ts): this REGISTERS a
3219
- // dataset, so it follows the dataset gate rather than the loop ones. The
3220
- // service enforces the same gate server-side via _dataset_creation_gate
3221
- // (403 DATASETS_COMING_SOON) as the authority.
3222
- if (!launchGates.datasets) {
3223
- server.tool("loop_register_dataset_for_training", "Turn a curated Conscious Loop set into a dataset a training job can use, keeping the link back to the conversations, verdicts and judges that produced it. Without this the only way to train on a loop set is to download the JSONL and upload it again as an unrelated dataset, which throws that link away. Returns a dataset id for create_training_job; the set then goes through the same validation every uploaded dataset gets, so poll get_dataset_status until it is ready before training. Registering the same set and split twice is refused rather than billed twice, and an empty set is refused rather than registered as something that would fail inside the trainer.", {
3224
- loop_dataset_id: z.string().describe("The Conscious Loop set to register (from loop_list_datasets)"),
3225
- name: z.string().optional().describe("Display name; defaults to loop-<first 8 of the loop id>"),
3226
- split: z.enum(["all", "train", "holdout"]).optional()
3227
- .describe("Which rows to take. Defaults to all. The loop already split the set by TIME, so re-splitting it throws that decision away; register train and holdout separately to score a training job against the loop's own held-back rows."),
3228
- }, async ({ loop_dataset_id, name, split }) => {
3229
- const data = await client.api("/api/datasets/register-loop", {
3230
- method: "POST",
3231
- body: {
3232
- loop_dataset_id,
3233
- workspace_id: await client.resolvedWorkspaceId(),
3234
- split: split ?? "all",
3235
- ...(name !== undefined && { name }),
3236
- },
3237
- });
3238
- return json(data);
3239
- });
3240
- }
3241
- server.tool("loop_preview_dataset", "Read the actual training rows in a set before anyone spends money training on it. Each row carries the address of the conversation it was built from, so a suspicious row can be traced back to what produced it.", {
3242
- dataset_id: z.string(),
3243
- limit: z.number().int().optional().describe("Default 50, maximum 200"),
3244
- }, async ({ dataset_id, limit }) => {
3245
- const data = await client.api(`/api/loop/datasets/${encodeURIComponent(dataset_id)}/items`, {
3246
- params: { limit: limit?.toString() },
3247
- });
3248
- return json(data);
3249
- });
3250
- server.tool("loop_stats", "How much has been recorded in this workspace, how much of it has been reviewed, and how many rows each training method could use right now. The 'ready' figures are upper bounds: repeated questions are folded into one row when a set is built, so the finished count can be lower.", {}, async () => {
3027
+ server.tool("loop_get_trace_pipelines", "Which pipelines one conversation feeds: every live pipeline whose tags select 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. 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`)));
3028
+ 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.", {}, async () => {
3251
3029
  const data = await client.api("/api/loop/stats");
3252
3030
  return json(data);
3253
3031
  });
3032
+ 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 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.", {
3033
+ rule_id: z.string().optional().describe("One pipeline's id, from loop_list_pipelines. Omit for the whole workspace."),
3034
+ limit: z.number().int().min(1).max(500).optional().describe("How many attempts at most: 200 when omitted, at most 500"),
3035
+ days: z.number().int().min(1).max(365).optional().describe("Days of feedback counts: 90 when omitted, at most 365"),
3036
+ }, async ({ rule_id, limit, days }) => json(await client.api("/api/loop/metrics/history", {
3037
+ params: { rule_id, limit: limit?.toString(), days: days?.toString() },
3038
+ })));
3254
3039
  /* ══════════════════════════════════════════════════════════════════════════ */
3255
- /* TOOLS: Conscious Loop — automatic training */
3040
+ /* TOOLS: Conscious Loop — pipelines */
3256
3041
  /* */
3257
- /* A TRAINING RULE is a standing instruction to spend the workspace's money: */
3258
- /* curate what has been reviewed, train an adapter on it, boot a candidate, */
3259
- /* compare it against what serves today, and either hand back a verdict or */
3260
- /* promote the winner. It is the only loop surface that spends without a */
3261
- /* person in the room, which is why creating one carries a recorded consent */
3262
- /* and why loop_create_training_rule refuses to be called blind. */
3042
+ /* A PIPELINE is one use case: a name, a model, the tags whose samples it */
3043
+ /* trains on, when to train (at least N new samples, and optionally a */
3044
+ /* schedule) and how a new version is promoted. The platform decides the */
3045
+ /* rest -- the GPUs, LoRA, the held-back set, the comparison -- and SAVING A */
3046
+ /* PIPELINE IS THE AUTHORIZATION TO SPEND: whenever its data has grown */
3047
+ /* enough it trains an attempt on the workspace's wallet with nobody in the */
3048
+ /* room. Only the GPU time an attempt actually uses is charged. */
3263
3049
  /* */
3264
- /* The wire shapes here are the frozen contract in */
3265
- /* docs/loop-training-contracts.md. Money is whole cents in a field named */
3266
- /* _cents; on a request an ABSENT key leaves that setting alone and a */
3267
- /* PRESENT null clears it, so omit what you are not changing rather than */
3268
- /* echoing the current value back. */
3269
- /* ══════════════════════════════════════════════════════════════════════════ */
3270
- /** One rung of a ranked GPU ladder. The provider is the neutral public brand. */
3271
- const trainingGPURungSchema = z.object({
3272
- gpu_type: z.string().describe("e.g. 'H100_80GB'"),
3273
- gpu_count: z.number().int().min(1),
3274
- provider: z.string().describe("The public provider brand, e.g. 'bios-cloud'"),
3275
- region: z.string().describe("e.g. 'global'"),
3276
- tier: z.string().describe("e.g. 'secure'"),
3277
- });
3278
- /**
3279
- * The create body, minus the yes. Preflight takes exactly this, create adds
3280
- * accept_terms, and update adds expected_revision — which is what the contract
3281
- * says, so the three tools share one shape rather than three copies that drift.
3282
- */
3283
- const trainingRuleInputShape = {
3284
- workspace_id: z.string().optional().describe("The workspace the rule belongs to. Omit to use the key's own workspace."),
3285
- name: z.string().optional().describe("What to call this rule, e.g. 'Support tuning'. Unique in the workspace."),
3286
- build_rule_id: z.string().optional().describe("Curate from an EXISTING standing build rule (loop_list_build_rules). Give this or `build`, never both."),
3287
- build: z.object({
3288
- method: z.string().describe("The training method the curated set is built for, e.g. 'sft'"),
3289
- spec: z.record(z.string(), z.any()).describe("The same selection loop_build_dataset takes: deployment_id, label, attributes, model, origin, holdout_percent, max_items, sample"),
3290
- }).optional().describe("Create a new standing build rule for this training rule to own. Give this or `build_rule_id`, never both."),
3291
- trigger: z.object({
3292
- cadence: z.enum(["none", "daily", "weekly"]).optional().describe("'none' means the rule only ever fires when a person asks it to"),
3293
- cadence_hour_utc: z.number().int().min(0).max(23).optional().describe("The UTC hour the rule is due. The platform adds its own per-rule jitter so ten thousand rules are not all due at once."),
3294
- cadence_weekday: z.number().int().min(0).max(6).nullable().optional().describe("0 is Sunday. null DROPS the weekday, which is what a weekly rule moved to daily needs."),
3295
- combinator: z.enum(["and", "or"]).optional().describe("'and' fires only when the schedule is due AND enough new rows exist; 'or' fires on either"),
3296
- min_new_rows: z.number().int().min(100).nullable().optional().describe("Rows reviewed SINCE THE LAST FIRING, not the whole corpus. At least 100. null removes the row floor, leaving the schedule as the only trigger."),
3297
- max_versions: z.number().int().min(1).max(10).optional().describe("How many versions this pipeline may make, 1 to 10; five if absent on a create, unchanged if absent on an update. A version is a trained model whose comparison scored conversations, or one a member put live. At the limit the pipeline stops and a manual run is refused with VERSION_LIMIT_REACHED until the limit is raised. Each version is a paid run, so the limit is also a bound on how many the rule will pay for."),
3298
- explore_recipes: z.boolean().optional().describe("MONEY-BEARING. true lets the pipeline, once it has made a version and nothing reviewed since adds anything new to train on (nothing was reviewed, or the reviews give it no row the last version's set lacked, such as thumbs-down with no correction), train the SAME base model on the SAME conversations with ONE training setting changed (the learning rate, the epochs, the LoRA rank) and compare it like any other version; it only takes over if it beats the model serving the app. Every such variant is a FULL PAID RUN inside the rule's per-run ceilings and its monthly limit, so changing it in EITHER direction changes the terms: turning it on or off pauses the rule, and it makes no version of any kind until somebody accepts the terms again. Turning it off to save money stops the pipeline too. Say that before you set it. Absent on a create means off; absent on an update leaves it alone."),
3299
- }).optional(),
3300
- serving: z.object({
3301
- kind: z.enum(["serverless_slug", "deployment"]).describe("What serves this model to customers today"),
3302
- name: z.string().describe("The public handle traffic arrives on. A promotion re-points THIS name at the winner, so it is also the alias name."),
3303
- deployment_id: z.string().nullable().describe("The deployment behind the name when kind is 'deployment'. Pass null for a serverless slug — the key is part of the shape, so send it explicitly rather than leaving the platform to guess."),
3304
- }).optional(),
3305
- training: z.object({
3306
- model_id: z.string().optional().describe("The base model, from the Run BiOS catalog (search_models)"),
3307
- model_revision: z.string().optional().describe("Pinned once at save so a rule cannot silently start training a different set of weights. Preflight returns the revision it pinned; pass it back rather than inventing one."),
3308
- training_method: z.enum(["sft", "rlhf"]).optional().describe("'rlhf' is refused until the platform enables it; preflight says so rather than the rule failing at fire time"),
3309
- rlhf_type: z.enum(["dpo", "kto"]).nullable().optional().describe("null clears it, which is what moving a rule back to sft means"),
3310
- // lora or qlora only: loop-service refuses `full` on preflight, create AND
3311
- // update for a standing rule, so advertising it here would hand an agent a
3312
- // legal-looking choice that is guaranteed to come back a 400 — on the tool
3313
- // its own description marks "Always first", with no estimate and no terms
3314
- // text to show the person whose wallet pays. One-off full fine-tunes are
3315
- // unaffected; they are a different tool.
3316
- train_type: z.enum(["lora", "qlora"]).optional(),
3317
- config: z.record(z.string(), z.any()).optional().describe("Hyperparameters, the same shape create_training_job takes"),
3318
- train_gpu_priorities: z.array(trainingGPURungSchema).optional().describe("The training ladder, best rung first. The run takes the first rung in stock under the price cap."),
3319
- train_max_price_hour_cents: z.number().int().min(1).optional().describe("The most this rule will pay per hour to TRAIN, in cents"),
3320
- }).optional(),
3321
- deploy: z.object({
3322
- deploy_gpu_priorities: z.array(trainingGPURungSchema).optional().describe("The candidate's serving ladder, best rung first"),
3323
- deploy_max_price_hour_cents: z.number().int().min(1).optional().describe("The most this rule will pay per hour to SERVE the candidate while it is compared, in cents"),
3324
- context_length: z.number().int().min(1).optional(),
3325
- quant: z.string().optional(),
3326
- serving_config: z.record(z.string(), z.any()).optional(),
3327
- hf_integration_id: z.string().optional(),
3328
- }).optional(),
3329
- money: z.object({
3330
- training_ceiling_cents: z.number().int().min(1).optional().describe("The hard stop on one run's training spend, in cents"),
3331
- candidate_ceiling_cents: z.number().int().min(1).optional().describe("The hard stop on the candidate's serving spend for one run, in cents"),
3332
- eval_ceiling_cents: z.number().int().min(1).optional().describe("The hard stop on the comparison's model calls for one run, in cents"),
3333
- monthly_ceiling_cents: z.number().int().min(1).nullable().optional().describe("The monthly limit. A run starts only if the most this rule's runs this UTC calendar month can have cost (month_spent_cents, an upper bound, not an exact spend), plus the most one more run can cost, fits under it. null REMOVES the monthly cap; absent leaves it exactly where it stands."),
3334
- eval_max_rows: z.number().int().min(20).max(500).optional().describe("How many held-out conversations the comparison scores, 20 to 500 and never below evaluation.min_holdout_rows. 100 if absent on a create."),
3335
- }).optional(),
3336
- evaluation: z.object({
3337
- judge_id: z.string().nullable().optional().describe("The rubric that scores both answers (loop_list_judges). null removes the judge from this rule."),
3338
- judge_model: z.string().nullable().optional().describe("Override the judge's own advisory model. null drops the override."),
3339
- graders_scope: z.enum(["source", "all", "none"]).optional().describe("Which deterministic graders also run: the source's own, every grader in the workspace, or none"),
3340
- min_holdout_rows: z.number().int().min(20).optional().describe("The fewest held-back conversations that may decide a comparison. At least 20, and 20 if absent on a create. Below this the run refuses to fire rather than reporting a verdict from a handful of rows"),
3341
- eval_max_tokens: z.number().int().min(1).optional(),
3050
+ /* The wire shapes are the frozen contract in docs/loop-training-contracts.md.*/
3051
+ /* On an edit an ABSENT key leaves that setting alone and a PRESENT null */
3052
+ /* clears the ones that can be cleared (max_versions, challenger_model, */
3053
+ /* train_when.schedule), so omit what you are not changing. */
3054
+ /* ══════════════════════════════════════════════════════════════════════════ */
3055
+ const pipelineModelSchema = z.object({
3056
+ id: z.string().describe("A model id from loop_list_models. Any other model is refused MODEL_NOT_TRAINABLE."),
3057
+ });
3058
+ /** The pipeline create and edit body: every field the service accepts, and no other. */
3059
+ const pipelineInputShape = {
3060
+ workspace_id: z.string().optional().describe("The workspace the pipeline belongs to. Omit to use the key's own workspace."),
3061
+ 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."),
3062
+ use_case: z.string().optional().describe("One sentence on what it is for"),
3063
+ model: pipelineModelSchema.optional().describe("The base model to improve"),
3064
+ 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. null removes it."),
3065
+ 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."),
3066
+ train_when: z.object({
3067
+ min_samples: z.number().int().min(1).optional().describe("Train when at least this many NEW samples exist. The default and the floor are loop_list_models' floors.new_samples; below it is refused MIN_SAMPLES_TOO_LOW. The first version also needs floors.first_version_samples."),
3068
+ schedule: z.object({
3069
+ every: z.enum(["day", "week"]),
3070
+ weekday: z.number().int().min(0).max(6).optional().describe("0 is Sunday. Required for 'week'; leave it out for 'day'."),
3071
+ hour_utc: z.number().int().min(0).max(23).describe("The UTC hour"),
3072
+ }).nullable().optional().describe("Also wait for this time. Training starts only when BOTH the minimum and the schedule are met. null trains as soon as the minimum is met."),
3342
3073
  }).optional(),
3343
3074
  promotion: z.object({
3344
- auto_promote: z.boolean().optional().describe("true re-points the serving name at a winning candidate WITHOUT anyone reading the report. Say what that means before you set it."),
3345
- promote_margin: z.number().optional().describe("How much better the candidate must score, as a fraction"),
3346
- promote_min_win_rate: z.number().min(0).max(1).optional().describe("The share of paired conversations the candidate must win"),
3347
- min_judge_agreement: z.number().min(0).max(1).optional().describe("How far the judge must already agree with this workspace's own reviewers before its verdict counts"),
3348
- review_window_hours: z.number().int().min(1).optional().describe("How long a candidate waits for a person before the run expires and the candidate is retired"),
3349
- keep_candidate_warm_minutes: z.number().int().min(0).optional().describe("Minutes the candidate keeps serving after a decision. Every minute is paid for."),
3075
+ 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."),
3076
+ k: z.number().int().min(1).optional().describe("For k_of_n only: how many measurements must improve"),
3350
3077
  }).optional(),
3351
- enabled: z.boolean().optional().describe("false stores the rule without letting it fire"),
3078
+ 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."),
3079
+ enabled: z.boolean().optional().describe("false saves it switched off"),
3352
3080
  };
3353
- const acceptTermsSchema = z.object({
3354
- terms_version: z.string().describe("Exactly the terms_version loop_preflight_training_rule returned. Do not type a version you have not read back to the user."),
3355
- });
3356
- server.tool("loop_preflight_training_rule", "Price a training rule WITHOUT creating anything. This is the first call, always: it pins the model revision, runs the training and deployment preflights, works out the worst hourly price each ladder can reach and how many hours each ceiling buys, lists any API key that cannot read deployments (such a key would create a rule whose promotions it cannot see), and returns terms_text — the sentence the member is agreeing to, with their real figures in it. Nothing is written and no money moves. Read worst_hourly_training_cents, worst_hourly_candidate_cents, eval_calls_max, the refusals and the warnings back to the user in their own words, then read terms_text back VERBATIM. NEVER state a ceiling, a price cap or an hour count this call did not return. If valid is false and refusals is EMPTY, look at unreachable: it names the peers the platform could not reach, and on such a pass both worst_hourly_* are 0 and so are both max_*_hours beside them. model_revision is empty unless training-service actually pinned a commit, so an empty one means nothing was pinned — but it does not on its own say whether the pass was refused or degraded; unreachable does. Tell the user which service is not answering and that waiting is the fix; do NOT read those zeroed figures out as if they were prices, and do not ask them to accept terms_text built from them. A refusal whose code is TRAINING_NOT_ENABLED is neither a mistake in the rule nor an outage: training is in beta and the user's organisation is not enabled for it yet. Say exactly that, tell them the platform team is who enables it, and do not ask them to accept terms_text or to change the rule to get round it.", trainingRuleInputShape, async (input) => json(await client.api("/api/loop/training-rules/preflight", { method: "POST", body: input })));
3357
- server.tool("loop_create_training_rule", "Create the standing instruction to train, compare and possibly promote — the one loop surface that spends the workspace's money with nobody in the room. SPEND CONSENT, and it is RECORDED: accept_terms is the member's signature on the sentence preflight rendered, kept as an append-only consent row. CALL loop_preflight_training_rule FIRST. Read its estimate and its terms_text back to the user, word for word, and WAIT FOR AN EXPLICIT YES before you send accept_terms. Do not send accept_terms in the same turn you were asked to set the rule up, do not treat 'go ahead' from before the estimate as the yes, and NEVER INVENT a ceiling, a price cap, an hour count or a terms_version — every one of those figures comes from preflight or from the user, never from you. A rule created without a ceiling the user chose is a standing charge they did not agree to. A 409 TRAINING_NOT_ENABLED means training is in beta and the user's organisation is not enabled yet: nothing was saved, nothing in the request can change that, so tell them to ask the platform team to enable training and do NOT retry.", {
3358
- ...trainingRuleInputShape,
3359
- accept_terms: acceptTermsSchema.describe("The member's yes. Send it ONLY after reading preflight's terms_text back and hearing an explicit yes. Absent is a refusal, not a default."),
3360
- }, async (input) => json(await client.api("/api/loop/training-rules", { method: "POST", body: input })));
3361
- server.tool("loop_list_training_rules", "The workspace's training rules, with what each last did and why. READ last_reason BACK TO THE USER when they ask why nothing trained: a rule quiet because it is waiting for rows reads exactly like one that is broken, and that field is the only thing that tells them apart. paused_reason says when a rule has stopped itself — a money-bearing edit that has not been consented to again, a ceiling that no longer buys an hour, a peer that could not be reached at save time, or training_not_enabled: training is in beta and the organisation is not enabled for it yet, so the platform team has to enable it and then a save of the rule starts it again (it never restarts by itself).", {
3362
- enabled: z.boolean().optional().describe("Only enabled rules, or only paused ones"),
3363
- limit: z.number().int().min(1).max(200).optional(),
3364
- offset: z.number().int().min(0).optional(),
3365
- }, async ({ enabled, limit, offset }) => json(await client.api("/api/loop/training-rules", {
3366
- params: { enabled: enabled?.toString(), limit: limit?.toString(), offset: offset?.toString() },
3367
- })));
3368
- server.tool("loop_get_training_rule", "One training rule in full: the consent it stands on, its recent runs, what its runs this UTC calendar month have cost at most, and how far its judge agrees with this workspace's own reviewers. Report month_spent_cents against monthly_ceiling_cents when the user asks what this is costing them, as 'up to' that amount: it is an upper bound (a run still in progress counts at its ceilings, and a comparison machine at the most it could have billed), never an exact spend, and say so plainly if accepted_revision is behind revision — the rule is paused until somebody consents again.", { rule_id: z.string().describe("The rule's id, from loop_list_training_rules") }, async ({ rule_id }) => json(await client.api(`/api/loop/training-rules/${encodeURIComponent(rule_id)}`)));
3369
- server.tool("loop_update_training_rule", "Edit or pause a training rule. Send ONLY what is changing: an absent key leaves that setting alone and a present null clears the ones that can be cleared (monthly_ceiling_cents, cadence_weekday, min_new_rows, rlhf_type, judge_id, judge_model). A MONEY-BEARING CHANGE — any ceiling, either price cap, the model, the method, the ladders or changing trigger.explore_recipes in EITHER direction — bumps the rule's revision, drops the consent and STOPS FUTURE FIRINGS until somebody accepts the new terms. Turning recipe variants OFF does this too: asked to turn them off to save money, tell the user first that the pipeline then makes no version at all, variant or not, until the terms are accepted again; the response says so as consent_required, and the honest next step is loop_preflight_training_rule again, not loop_consent_training_rule on a sentence nobody re-read. Pass expected_revision to be refused rather than overwrite an edit somebody else made. Switching a rule on, into preference training, or with accept_terms re-runs the preflight and can be refused 409 TRAINING_NOT_ENABLED exactly like a create.", {
3370
- rule_id: z.string().describe("The rule's id, from loop_list_training_rules"),
3371
- ...trainingRuleInputShape,
3372
- expected_revision: z.number().int().min(0).optional().describe("The revision you read. A stale value is refused with REVISION_MISMATCH instead of overwriting somebody else's edit."),
3373
- accept_terms: acceptTermsSchema.optional().describe("Consent to the new terms in the same call. Same rule as create: only after the user has read them back and said yes."),
3374
- }, async ({ rule_id, ...body }) => json(await client.api(`/api/loop/training-rules/${encodeURIComponent(rule_id)}`, {
3081
+ 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: first_version_samples (what a new pipeline's first attempt needs) and new_samples (the default and smallest train_when.min_samples). Offer the user a choice from this list; never type a model id from memory.", {}, async () => json(await client.api("/api/loop/models")));
3082
+ server.tool("loop_create_pipeline", "Create a pipeline: one use case, trained again whenever enough new samples carrying its tags exist. 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. Only the GPU time actually used is charged. Say that in plain words and WAIT FOR AN EXPLICIT YES before calling. Take model.id from loop_list_models, never from memory. Nothing is saved when it is refused. 409 PIPELINE_NAME_TAKEN or OWN_TAG_TAKEN: choose another name. 422 MIN_SAMPLES_TOO_LOW, 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: training is in beta and the organisation is not enabled yet — tell the user the platform team enables it, and do not retry. 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 })));
3083
+ server.tool("loop_update_pipeline", "Edit a pipeline: its name, use case, model, challenger, tags, when to train, promotion policy, version limit, or switch it off and on. Send ONLY what changes: an absent key leaves it alone, and null clears max_versions (no limit), challenger_model (no challenger) or train_when.schedule (train as soon as the minimum is met). Saving is the authorization, as on a create: a change that makes attempts cost more (a bigger model, switching it back on) is SPEND CONSENT, so say so first. Pass expected_revision (the pipeline's revision as you read it) to be refused REVISION_MISMATCH rather than overwrite someone else's 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.", {
3084
+ pipeline_id: z.string().describe("The pipeline's id (rule_id), from loop_list_pipelines"),
3085
+ ...pipelineInputShape,
3086
+ expected_revision: z.number().int().min(0).optional().describe("The revision you read"),
3087
+ }, async ({ pipeline_id, ...body }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}`, {
3375
3088
  method: "PUT",
3376
3089
  body,
3377
3090
  })));
3378
- server.tool("loop_consent_training_rule", "Accept the rule's CURRENT terms, which is what restarts a rule paused by a money-bearing edit. SPEND CONSENT: this is the member's signature, so read the terms back first — call loop_preflight_training_rule with the rule as it now stands and read its terms_text verbatim — and send this only after an explicit yes. The revision you send must be the revision they were shown; a stale one is refused rather than silently accepted.", {
3379
- rule_id: z.string(),
3380
- terms_version: z.string().describe("The version the user was shown, from preflight"),
3381
- revision: z.number().int().min(0).describe("The rule revision the user was shown"),
3382
- }, async ({ rule_id, terms_version, revision }) => json(await client.api(`/api/loop/training-rules/${encodeURIComponent(rule_id)}/consent`, {
3091
+ server.tool("loop_delete_pipeline", "Delete a pipeline for good. An attempt still running is cancelled; versions it made, their checkpoints and whatever serves the app now are untouched, and its samples and tags are kept. Recording from its own tag's source stops: an app still sending there gets captured:false. THIS CANNOT BE UNDONE and the name is spent: the response's retained_name can never be used by another pipeline in this workspace. To stop it for now, loop_update_pipeline with enabled false. Confirm with the user first.", { pipeline_id: z.string() }, async ({ pipeline_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}`, { method: "DELETE" })));
3092
+ server.tool("loop_run_pipeline", "Train now: start an attempt without waiting for the schedule. SPEND CONSENT: it trains on real GPUs charged to the wallet, so say so and get a yes. It never skips the minimum: 422 NOT_ENOUGH_SAMPLES carries have and need — read both back. 409 RUN_ACTIVE: an attempt is already running. 409 VERSION_LIMIT_REACHED: raise or remove max_versions. 409 RULE_PAUSED: it is switched off or paused (paused_reason says why). 409 PIPELINE_NEEDS_SAVE: its settings changed without a pipeline save; save it with loop_update_pipeline, then train again. 409 MONTHLY_LIMIT_REACHED: the platform's runaway guard for this month; read resumes_at back. 409 SCORING_CAPPED: the comparison's key is at its monthly cap, so what it trained could not be scored; read resumes_at back. 503 AGENT_COULD_NOT_START or 409 AGENT_OFF: the key every attempt is scored with could not be renewed just then, so nothing was started; train now again in a minute (saving the pipeline does not help). Do not retry any of the others unchanged.", { pipeline_id: z.string() }, async ({ pipeline_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/run`, {
3383
3093
  method: "POST",
3384
- body: { terms_version, revision },
3094
+ body: {},
3385
3095
  })));
3386
- server.tool("loop_run_training_rule", "Fire a rule NOW, without waiting for its cadence or its row floor. SPEND CONSENT: this starts a real training job and boots a real candidate against the rule's ceilings, so say what it will cost before you call it. The floors that protect the result still apply — too few curated or held-out rows answers NOT_ENOUGH_ROWS with the counts, an unconsented rule answers CONSENT_REQUIRED, a switched-off or paused rule answers RULE_PAUSED, a rule already mid-run answers RUN_ACTIVE rather than starting a second one, a pipeline that has made max_versions versions answers VERSION_LIMIT_REACHED, a run whose worst case would take this month past the rule's monthly limit answers MONTHLY_LIMIT_REACHED with month_spent_cents (what this month's runs have cost at most, not an exact spend), monthly_ceiling_cents, run_max_cents and resumes_at — read those figures back rather than retrying — and a workspace whose Conscious Loop agent is not turned on answers AGENT_OFF, because every comparison runs under the agent's key and a model trained without one could not be compared: tell the user to turn the agent on in Agent settings, and do not retry until they have.", { rule_id: z.string() }, async ({ rule_id }) => json(await client.api(`/api/loop/training-rules/${encodeURIComponent(rule_id)}/run`, {
3096
+ 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.", {
3097
+ pipeline_id: z.string(),
3098
+ rows: z.array(z.record(z.string(), z.any())).describe("The rows, already parsed, at most 5000, each in the shape it already has"),
3099
+ default_feedback: z.enum(["good"]).optional().describe("'good' makes every answered row without its own verdict a good example"),
3100
+ source: z.string().optional().describe("Where the rows came from. Defaults to the pipeline's own tag."),
3101
+ model: z.string().optional(),
3102
+ labels: z.array(z.string()).optional().describe("More tags for every row, beside the pipeline's own"),
3103
+ attributes: z.record(z.string(), z.string()).optional(),
3104
+ import_id: z.string().optional().describe("Repeat a previous call's import_id to RETRY it without storing rows twice"),
3105
+ row_offset: z.number().int().min(0).optional(),
3106
+ row_ids: z.array(z.string()).optional(),
3107
+ }, async ({ pipeline_id, ...body }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/import`, {
3387
3108
  method: "POST",
3388
- body: {},
3109
+ body,
3389
3110
  })));
3390
- server.tool("loop_delete_training_rule", "Stop a training rule for good. An active run is cancelled; runs that already finished, the checkpoints they produced and anything already promoted are untouched: this stops future firings, it does not undo past ones. The response names the run it cancelled, if any, and retained_name, the name this rule keeps. THIS CANNOT BE UNDONE and the name is spent. The deleted rule goes on holding its name while every list stops showing it, so no later rule in the workspace can be called that, and creating one under the same name is refused. To stop a rule firing for now, set enabled false with loop_update_training_rule instead. Deleting is also the only way to return the rule's curation spec to the build sweeper; switching the rule off does not.", { rule_id: z.string() }, async ({ rule_id }) => json(await client.api(`/api/loop/training-rules/${encodeURIComponent(rule_id)}`, { method: "DELETE" })));
3391
- server.tool("loop_list_training_runs", "Every firing of every training rule, newest first: what state it is in, what it has spent, and what the comparison said. A run sitting in 'awaiting_review' is waiting for a PERSON — it holds a candidate that is being paid for by the minute, so surface those first when the user asks what needs attention.", {
3392
- rule_id: z.string().optional().describe("Only this rule's runs"),
3111
+ server.tool("loop_list_training_runs", "Every attempt of every pipeline, newest first: what state it is in, what it has spent, and what the comparison said. A run sitting in 'awaiting_review' is waiting for a PERSON — it holds a candidate that is being paid for by the minute, so surface those first when the user asks what needs attention.", {
3112
+ rule_id: z.string().optional().describe("Only this pipeline's attempts (its id, from loop_list_pipelines)"),
3393
3113
  state: z.string().optional().describe("Only runs in this state, e.g. 'awaiting_review'"),
3394
3114
  limit: z.number().int().min(1).max(200).optional(),
3395
3115
  offset: z.number().int().min(0).optional(),
@@ -3397,11 +3117,43 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3397
3117
  params: { rule_id, state, limit: limit?.toString(), offset: offset?.toString() },
3398
3118
  })));
3399
3119
  server.tool("loop_get_training_run", "One run in full, with the timeline of every state it passed through and why, links to the job, the candidate, the datasets and the comparison, and available_actions — which says what THIS reader may actually do next. Use available_actions rather than guessing from the state: a run can look promotable and not be, because the consent moved or the candidate is already gone.", { run_id: z.string() }, async ({ run_id }) => json(await client.api(`/api/loop/training-runs/${encodeURIComponent(run_id)}`)));
3400
- server.tool("loop_list_pipelines", "Every training rule in the workspace seen as a PIPELINE: the series of versions it has produced, which one serves the app today (champion_version, null when what serves is not one of its versions), how each version did against the champion of its day and on the standing benchmark, versions_made against max_versions, the run in flight and which version it will become, and status. Read-only. When the user asks 'is this getting better' or 'what is it doing', answer from this, and read status back honestly: needs_review and needs_funds are waiting on THEM (a decision, a top-up), paused names why in paused_reason or needs_consent, complete means the version limit is reached, and running is the platform working. next_reason is the rule's own sentence about what it is waiting for. month_spent_cents against monthly_ceiling_cents is what decides whether the next run may start; month_spent_cents is what runs started this UTC month have cost at most (a run still in progress counts at its ceilings), so say 'up to', never 'spent'. recipe_exploration says whether a recipe variant can run next and, when not, why: off, waiting_for_v1 (no version to vary yet), available (recipe_variants_left untried), no_slots (untried variants left, recipe_variants_left above 0, but no version slot free; raise max_versions, or at 10 start a new pipeline, and one can run — but no_slots with recipe_variants_left 0 means all_tried, so never suggest raising the limit for it), all_tried (every variant that fits has been tried; raising max_versions does not start one, new reviews bring the next version) or none_fit (no variant fits the rule's settings). Read that, never recipe_variants_left alone: 0 does not mean every variant was tried. The list is the newest 100: total is how many pipelines the workspace has and truncated is true when more exist than were returned. When truncated, say it is the newest of total, and page through loop_list_training_rules for the rest.", {}, async () => json(await client.api("/api/loop/pipelines")));
3401
- server.tool("loop_get_pipeline", "One pipeline in full, by its training rule's id: every version with its verdict, the decision a person made, its win rate and benchmark score, what it cost and the recipe it trained on (a version whose trigger is 'variant' retrained the same conversations with one setting changed, named in recipe_note), plus the run in flight, explore_recipes, recipe_variants_left and recipe_exploration (why a variant can or cannot run next; read it rather than the count), and what this month's runs have cost at most (month_spent_cents, an upper bound, not an exact spend) against the monthly limit. Read-only: promote, reject and roll back stay on the run tools, and the version limit and explore_recipes stay on loop_update_training_rule. Only compare benchmark scores whose benchmark_comparable is true; the others were measured on a different yardstick. A run with competed false is not a version yet, and says so.", { rule_id: z.string().describe("The training rule's id, from loop_list_pipelines or loop_list_training_rules") }, async ({ rule_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(rule_id)}`)));
3120
+ server.tool("loop_list_pipelines", "Every pipeline in the workspace: the series of versions it has produced (versions are attempts that were put live; attempts[] lists every attempt, winner or not), which one serves the app today (live_version, the same as champion_version, null when what serves is not one of its versions), how each version did against the champion of its day and on the standing benchmark, versions_made against max_versions, the run in flight and which version it will become, and status. Read-only. When the user asks 'is this getting better' or 'what is it doing', answer from this, and read status back honestly: needs_review and needs_funds are waiting on THEM (a decision, a top-up), paused names why in paused_reason or needs_consent, complete means the version limit is reached, and running is the platform working. next_reason is the pipeline's own sentence about what it is waiting for. month_spent_cents against monthly_ceiling_cents is what decides whether the next run may start; month_spent_cents is what runs started this UTC month have cost at most (a run still in progress counts at its ceilings), so say 'up to', never 'spent'. A pipeline trains only on new samples: it never retrains the conversations an attempt already had with a recipe changed. The list is a page of 100, newest first: total is how many pipelines the workspace has and truncated is true when more exist after this page. When truncated, say it is the newest of total, and pass offset (this page's offset plus its length) for the next page.", { offset: z.number().int().min(0).optional().describe("How many of the newest pipelines to skip") }, async ({ offset }) => json(await client.api("/api/loop/pipelines", {
3121
+ params: { offset: offset ? offset.toString() : undefined },
3122
+ })));
3123
+ server.tool("loop_get_pipeline", "One pipeline in full, by its id: every version with its verdict, the decision a person made, its win rate and benchmark score, what it cost and the recipe it trained on (an old version whose trigger was 'variant' retrained the same conversations with one setting changed, named in recipe_note; variants are retired), plus the run in flight, and what this month's runs have cost at most (month_spent_cents, an upper bound, not an exact spend) against the monthly limit. Read-only: promote, reject and roll back stay on the run tools, and its settings on loop_update_pipeline. minimums says how many samples it has against what the next attempt needs. Only compare benchmark scores whose benchmark_comparable is true; the others were measured on a different yardstick. versions[] are WINNERS ONLY -- attempts that were put live, numbered v1, v2 ... in the order they went live, each with its attempt, base_model_id and model_family -- and live_version is the one serving now; attempts[] is every training run the pipeline made, winner or not, with its outcome (became_version, not_better, inconclusive, stopped, failed, running, waiting_for_review) and trigger (data, schedule, manual, or challenger for the second model trained on the same data). Nothing in flight is a version yet.", { rule_id: z.string().describe("The pipeline's id, from loop_list_pipelines") }, async ({ rule_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(rule_id)}`)));
3124
+ server.tool("loop_list_pipeline_benchmarks", "The benchmarks a pipeline is measured on, in priority order (1 is the primary), then the ones it stopped using, and its promotion policy. Every attempt is replayed on each attached benchmark; the policy decides which of those measurements make it a version.", { pipeline_id: z.string() }, async ({ pipeline_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/benchmarks`)));
3125
+ server.tool("loop_attach_pipeline_benchmark", "Measure a pipeline on one more benchmark (from loop_list_benchmarks or loop_create_benchmark), last unless priority is given; priority 1 makes it the primary. Applies from the next attempt; older versions are not re-measured and show not_measured for it. 409 BENCHMARK_ALREADY_ATTACHED or BENCHMARK_RETIRED.", {
3126
+ pipeline_id: z.string(),
3127
+ benchmark_id: z.string(),
3128
+ priority: z.number().int().min(1).optional(),
3129
+ }, async ({ pipeline_id, benchmark_id, priority }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/benchmarks`, {
3130
+ method: "POST",
3131
+ body: { benchmark_id, priority },
3132
+ })));
3133
+ server.tool("loop_move_pipeline_benchmark", "Change a benchmark's priority on a pipeline; 1 makes it the primary. 404 BENCHMARK_NOT_ATTACHED when it is not on the list.", {
3134
+ pipeline_id: z.string(),
3135
+ benchmark_id: z.string(),
3136
+ priority: z.number().int().min(1),
3137
+ }, async ({ pipeline_id, benchmark_id, priority }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/benchmarks/${encodeURIComponent(benchmark_id)}`, {
3138
+ method: "PUT",
3139
+ body: { priority },
3140
+ })));
3141
+ server.tool("loop_remove_pipeline_benchmark", "Stop measuring a pipeline on one benchmark. Every score it already produced is kept; when it was the primary, the next one takes its place.", {
3142
+ pipeline_id: z.string(),
3143
+ benchmark_id: z.string(),
3144
+ }, async ({ pipeline_id, benchmark_id }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/benchmarks/${encodeURIComponent(benchmark_id)}`, {
3145
+ method: "DELETE",
3146
+ })));
3147
+ 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, 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. Compare only scores marked comparable.", {
3148
+ pipeline_id: z.string(),
3149
+ a: z.number().int().min(1).optional().describe("A version number"),
3150
+ b: z.number().int().min(1).optional().describe("Another version number"),
3151
+ }, async ({ pipeline_id, a, b }) => json(await client.api(`/api/loop/pipelines/${encodeURIComponent(pipeline_id)}/versions/compare`, {
3152
+ params: { a: a?.toString(), b: b?.toString() },
3153
+ })));
3402
3154
  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.", {
3403
3155
  run_id: z.string(),
3404
- expected_revision: z.number().int().min(0).optional().describe("The rule revision the decision was made against. Stale answers CONSENT_CHANGED instead of promoting under terms nobody agreed to."),
3156
+ 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."),
3405
3157
  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."),
3406
3158
  }, async ({ run_id, expected_revision, force }) => json(await client.api(`/api/loop/training-runs/${encodeURIComponent(run_id)}/promote`, {
3407
3159
  method: "POST",
@@ -3428,38 +3180,55 @@ create (1-5 choices), and the queue itself stays opt-in.`,
3428
3180
  method: "POST",
3429
3181
  body: { reason },
3430
3182
  })));
3431
- server.tool("loop_get_evaluation", "The comparison report behind a verdict: how many pairs were scored, the candidate's win rate, the mean scores per rubric dimension and per grader, 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)}`)));
3432
- server.tool("loop_list_evaluation_items", "The paired conversations behind the numbers: the same prompt, what the model serving today answered, what the candidate answered, and what the judge and the graders made of each. This is how a person checks a verdict instead of trusting it. Filter to winner='incumbent' to read the cases the candidate LOST, which is the honest thing to show somebody before they promote.", {
3183
+ server.tool("loop_get_evaluation", "The comparison report behind a verdict: how many pairs were scored, the candidate's win rate, 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)}`)));
3184
+ server.tool("loop_list_evaluation_items", "The paired conversations behind the numbers: the same prompt, what the version serving today answered, what the attempt answered, and what the judge made of each. 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.", {
3433
3185
  evaluation_id: z.string(),
3434
- winner: z.enum(["candidate", "incumbent", "tie"]).optional().describe("Only pairs with this outcome"),
3435
- limit: z.number().int().min(1).max(100).optional().describe("Maximum 100"),
3186
+ 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"),
3187
+ limit: z.number().int().min(1).max(200).optional().describe("50 when omitted, at most 200"),
3436
3188
  offset: z.number().int().min(0).optional(),
3437
3189
  }, async ({ evaluation_id, winner, limit, offset }) => json(await client.api(`/api/loop/evaluations/${encodeURIComponent(evaluation_id)}/items`, {
3438
3190
  params: { winner, limit: limit?.toString(), offset: offset?.toString() },
3439
3191
  })));
3440
- server.tool("loop_get_judge_agreement", "How often this judge reached the same verdict as the workspace's OWN reviewers, over a window, overall and per rubric dimension. It is the answer to 'why should I believe the comparison'. When enough_pairs is false the figure is not yet evidence of anything and must be reported that way rather than as a low score.", {
3441
- judge_id: z.string(),
3442
- from: z.string().optional().describe("Start of the window, RFC 3339"),
3443
- to: z.string().optional().describe("End of the window, RFC 3339"),
3444
- }, async ({ judge_id, from, to }) => json(await client.api(`/api/loop/judges/${encodeURIComponent(judge_id)}/agreement`, {
3445
- params: { from, to },
3192
+ 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 and the same judge, replayed on every attempt of every pipeline it is attached to, so 'is it getting better' has a yardstick that does not move. It is refused rather than trimmed when a conversation has nothing to ask a model. Attach it with loop_attach_pipeline_benchmark.", {
3193
+ name: z.string(),
3194
+ source: z.object({
3195
+ kind: z.enum(["traces"]).describe("Always 'traces': a benchmark pins the conversations named in trace_ids"),
3196
+ trace_ids: z.array(z.string()).min(1).describe("The conversations to pin, 20 to 200 (from loop_list_traces, or the trace_ids loop_import_rows returns for a file)"),
3197
+ }),
3198
+ }, async ({ name, source }) => json(await client.api("/api/loop/benchmarks", {
3199
+ method: "POST",
3200
+ body: { name, source },
3446
3201
  })));
3447
- server.tool("loop_get_agent_settings", "The workspace's agent settings: which model it uses by default, the system prompts it judges and samples with, and the monthly cap on what evaluation may spend. Read eval_monthly_cap_cents back when the user asks why a comparison stopped early.", {}, async () => json(await client.api("/api/loop/agent/settings")));
3448
- server.tool("loop_update_agent_settings", "Change the agent's default model, its judge or sampler system prompt, or the monthly cap on evaluation spend. Send only what is changing: an absent key leaves that setting alone and a PRESENT NULL returns it to the platform default. Raising eval_monthly_cap_cents raises what the workspace can be charged, so treat it as SPEND CONSENT and say the new figure back before you send it.", {
3449
- default_model: z.string().nullable().optional().describe("null returns to the platform default"),
3450
- judge_system_prompt: z.string().nullable().optional().describe("null returns to the platform default"),
3451
- sampler_system_prompt: z.string().nullable().optional().describe("null returns to the platform default"),
3452
- eval_monthly_cap_cents: z.number().int().min(0).nullable().optional().describe("Whole cents. null returns to the platform default."),
3453
- }, async (input) => json(await client.api("/api/loop/agent/settings", { method: "PUT", body: input })));
3454
- server.tool("loop_update_build_rule", "Edit or pause a standing build rule: its row floor, its selection, or whether it runs at all. Send only what is changing. A build rule OWNED BY AN ENABLED TRAINING RULE refuses a change of spec.deployment_id with a 409, because that would silently retrain a live model on a different slice of conversations than the one its consent was priced against.", {
3455
- rule_id: z.string().describe("The build rule's id, from loop_list_build_rules"),
3456
- enabled: z.boolean().optional().describe("false stores the rule without running it"),
3457
- min_new_rows: z.number().int().min(100).optional().describe("Rows reviewed since the last build before it fires again. Minimum 100."),
3458
- spec: z.record(z.string(), z.any()).optional().describe("The selection, same shape as loop_build_dataset"),
3459
- }, async ({ rule_id, enabled, min_new_rows, spec }) => json(await client.api(`/api/loop/build-rules/${encodeURIComponent(rule_id)}`, {
3460
- method: "PUT",
3461
- body: { enabled, min_new_rows, spec },
3202
+ server.tool("loop_list_benchmarks", "The workspace's benchmarks, newest first. status 'active' hides the retired ones.", {
3203
+ status: z.enum(["active", "retired"]).optional(),
3204
+ limit: z.number().int().min(1).max(200).optional(),
3205
+ offset: z.number().int().min(0).optional(),
3206
+ }, async ({ status, limit, offset }) => json(await client.api("/api/loop/benchmarks", {
3207
+ params: { status, limit: limit?.toString(), offset: offset?.toString() },
3208
+ })));
3209
+ server.tool("loop_get_benchmark", "One benchmark: its frozen judge and scoring rule, and the fingerprint of its set.", { benchmark_id: z.string() }, async ({ benchmark_id }) => json(await client.api(`/api/loop/benchmarks/${encodeURIComponent(benchmark_id)}`)));
3210
+ server.tool("loop_list_benchmark_items", "The conversations a benchmark pinned. A conversation deleted since keeps its pinned copy; its source link reads null.", {
3211
+ benchmark_id: z.string(),
3212
+ limit: z.number().int().min(1).max(200).optional().describe("50 when omitted, at most 200"),
3213
+ offset: z.number().int().min(0).optional(),
3214
+ }, async ({ benchmark_id, limit, offset }) => json(await client.api(`/api/loop/benchmarks/${encodeURIComponent(benchmark_id)}/items`, {
3215
+ params: { limit: limit?.toString(), offset: offset?.toString() },
3216
+ })));
3217
+ 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.", {
3218
+ benchmark_id: z.string(),
3219
+ limit: z.number().int().min(1).max(200).optional(),
3220
+ offset: z.number().int().min(0).optional(),
3221
+ }, async ({ benchmark_id, limit, offset }) => json(await client.api(`/api/loop/benchmarks/${encodeURIComponent(benchmark_id)}/history`, {
3222
+ params: { limit: limit?.toString(), offset: offset?.toString() },
3223
+ })));
3224
+ 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.", {
3225
+ benchmark_id: z.string(),
3226
+ reason: z.string().optional(),
3227
+ }, async ({ benchmark_id, reason }) => json(await client.api(`/api/loop/benchmarks/${encodeURIComponent(benchmark_id)}/retire`, {
3228
+ method: "POST",
3229
+ body: { reason },
3462
3230
  })));
3231
+ server.tool("loop_get_benchmark_run", "One replay in full: both scores, the difference, the per-dimension breakdown and what it cost. Read status first — a replay that did not score every pinned conversation publishes no score.", { benchmark_run_id: z.string() }, async ({ benchmark_run_id }) => json(await client.api(`/api/loop/benchmark-runs/${encodeURIComponent(benchmark_run_id)}`)));
3463
3232
  server.tool("set_inference_alias", "Point a re-pointable public handle at a deployment. An alias is a NAME customers call that can be moved to different weights without them changing anything — it is what a promotion writes, and what lets a retrained model take over traffic with no client edit. Setting one CHANGES WHAT REAL TRAFFIC REACHES, so confirm with the user first. A name that belongs to a live deployment is refused (ALIAS_NAME_IS_A_DEPLOYMENT) unless the alias is being given that deployment's own name, and a target that is not serving is refused (TARGET_NOT_SERVABLE) rather than pointing the handle at something that cannot answer.", {
3464
3233
  name: z.string().describe("The public handle, e.g. 'support-bot'"),
3465
3234
  target_inference_id: z.string().describe("The deployment the name should reach, from list_inferences"),