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]>
171 lines
6.3 KiB
Rust
171 lines
6.3 KiB
Rust
//! 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 1–3 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.0–1.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)
|
||
}
|