Files
clawmates/crates/cm-api/src/mission_refiner.rs
T
Omar SobhandClaude Opus 5 69c294addc
deploy / test (push) Successful in 7m38s
deploy / build (push) Successful in 6m55s
fix(models): coding runs on sonnet-5, judging on opus-5, haiku only as last resort
Operator model policy: haiku ONLY for genuine yes/no questions; anything
requiring thinking is opus-5; coding is sonnet-5.

The mission AGENTS were running haiku, and nothing in the product said so.
`provider_alias_for` maps every `claude-*` binding onto the single alias
`claude_cli.default`, so a crew whose `model_binding` reads `claude-sonnet-5`
— as this deployment's does — still ran whatever that alias pointed at, which
was `model = "haiku"` in the runtime config. The binding is cosmetic; the
alias is the truth.

Measured consequence on mission 01a00bbb: the coding agents claimed six INT
items complete and had committed three, and the done_when judge caught it by
auditing git history against the claims.

Model assignments, by what the component actually does:
  evaluator (done_when judge)  haiku  -> opus-5   reads evidence, audits it
                                                  against the repo, writes
                                                  guidance. The verdict is a
                                                  boolean; the work is not —
                                                  and this is the one component
                                                  whose failure mode is passing
                                                  work that was never done.
  judge_model                  4-8    -> opus-5
  mission_refiner              4-8    -> opus-5   composition
  phase_summarizer             4-8    -> opus-5   composition
  swarm planner                4-8    -> opus-5   planning
  subscription preflight head  4-8    -> opus-5
  fallback chain head          4-6    -> sonnet-5 haiku stays BELOW it as a
                                                  last-resort link, never a peer

Every value stays env-overridable; only the shipped defaults move.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 12:36:19 -07:00

171 lines
6.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Mission refiner — take the user's freeform description on a draft
//! mission and rewrite it into a coherent, sectioned Markdown brief
//! that downstream research + coding agents can ingest cleanly.
//!
//! Asks for Claude Opus 4.8 by default, but goes through
//! `subscription::complete_with_fallback` like every other server-side model
//! call. It used to hand-roll its own HTTPS POST to the Messages API with the
//! metered key — a comment above this line still claimed prod "already carries
//! ANTHROPIC_API_KEY, so no separate env is needed", which stopped being true
//! the moment that account ran out of credit. See `subscription`, whose
//! source-walk test is what found this module.
use sqlx::PgPool;
use uuid::Uuid;
const DEFAULT_MODEL: &str = "claude-opus-5";
fn model_name() -> String {
std::env::var("CLAWMATES_REFINER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string())
}
pub struct RefineResult {
pub original: String,
pub refined: String,
}
/// Refine a description that has no mission behind it yet.
///
/// The wizard's polish button runs BEFORE the mission is created — there is no
/// row to load and no id to pass — while [`refine`] deliberately requires a
/// saved draft so Accept/Cancel can write back to it. Same prompt, same model
/// chain; only where the inputs come from differs.
pub async fn refine_draft(
runtime: &cm_runtime::Runtime,
title: &str,
template_kind: &str,
phase_kinds: &[String],
raw: &str,
) -> Result<RefineResult, String> {
if raw.trim().is_empty() {
return Err("description is empty — nothing to refine".into());
}
let refined = call_anthropic(runtime, title, template_kind, phase_kinds, raw).await?;
Ok(RefineResult {
original: raw.to_string(),
refined,
})
}
/// Generate a refined description without touching the database. The
/// caller (frontend) reviews the diff and calls `set_description` to
/// commit — that separation makes Accept/Cancel + undo trivial without
/// an audit table.
pub async fn refine(
pool: &PgPool,
runtime: &cm_runtime::Runtime,
workspace_id: cm_domain::WorkspaceId,
mission_id: Uuid,
) -> Result<RefineResult, String> {
let mission = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
.await
.map_err(|e| format!("load mission: {e}"))?
.ok_or_else(|| "mission not found".to_string())?;
if mission.status != "draft" {
return Err(format!(
"mission is {}, refine only allowed on draft",
mission.status
));
}
let raw = mission.description.unwrap_or_default();
if raw.trim().is_empty() {
return Err("description is empty — nothing to refine".into());
}
let phase_kinds: Vec<String> = cm_db::repo::missions::phases_for(pool, mission_id)
.await
.map_err(|e| format!("load phases: {e}"))?
.into_iter()
.map(|p| p.kind)
.collect();
let refined =
call_anthropic(runtime, &mission.title, &mission.template_kind, &phase_kinds, &raw)
.await?;
Ok(RefineResult {
original: raw,
refined,
})
}
async fn call_anthropic(
runtime: &cm_runtime::Runtime,
title: &str,
template_kind: &str,
phase_kinds: &[String],
raw: &str,
) -> Result<String, String> {
let model = model_name();
let system = "You are a technical brief editor for an autonomous software \
engineering platform. Rewrite the user's raw mission description into a \
clean, sectioned Markdown brief that research + coding agents can ingest \
directly. Preserve every concrete fact, requirement, constraint, and \
acceptance criterion the user provided — do not invent new scope. \
Structure the output with these sections when the source material \
supports them (omit sections with nothing to say):\n\
\n\
# <One-line restated goal>\n\
\n\
## Objective\n\
A 13 sentence framing of what success looks like.\n\
\n\
## Context & Background\n\
Any relevant prior art, files, systems, or motivation the user gave.\n\
\n\
## Scope\n\
Bullet list of concrete deliverables (in scope). If the user \
called out non-goals, add an `### Out of scope` subsection.\n\
\n\
## Constraints\n\
Technical, stylistic, or process constraints (languages, versions, \
style guides, migration paths, existing conventions to respect).\n\
\n\
## Acceptance Criteria\n\
Numbered list of concrete, verifiable pass/fail conditions the \
coding agents should treat as done-definitions.\n\
\n\
## Open Questions\n\
Only include if the source material has genuine ambiguity worth \
flagging to the research phase before coding starts.\n\
\n\
Rules:\n\
- Output raw Markdown only — no code fence around the whole doc, \
no preamble like \"Here is the refined brief\".\n\
- Never make up file paths, APIs, repo names, or version numbers.\n\
- If the user's text is very short, produce a short brief — do not \
pad with generic filler.\n\
- Use `**bold**` sparingly for load-bearing terms; do not bold entire \
sentences.\n\
- Prefer bullet lists over paragraphs for scope, constraints, and criteria.";
let user = format!(
"Mission title: {title}\n\
Template kind: {template_kind}\n\
Planned phases: {phases}\n\
\n\
Raw description:\n\
---\n\
{raw}\n\
---",
phases = if phase_kinds.is_empty() {
"(none configured yet)".to_string()
} else {
phase_kinds.join(", ")
}
);
// Opus 4.8 rejects the `temperature` parameter — the model runs at
// its own calibrated setting. Older Claude models accepted 0.01.0, and
// `ChatRequest` does not carry one, so nothing is lost by the move.
let (text, answered_by) =
crate::subscription::complete_with_fallback(runtime, system, &user, &model, 4096, false)
.await?;
let text = text.trim().to_string();
if text.is_empty() {
return Err(format!("{answered_by} returned empty text"));
}
Ok(text)
}