Files
clawmates/crates/cm-llm/src/scripted.rs
T
Omar SobhandClaude Fable 5 add4f79fed Rebrand: TeamClaw -> Clawmates (clawmates.work)
Full-depth rename per the approved plan; the 'claw' product vocabulary
(claws, /claws routes, clawId, Claw Chat) stays — it is now the brand.

- Display brand: Clawmates (manifest, titles, hero, login/rail logo
  'clawmates'); default host app.clawmates.work; registry
  ghcr.io/clawmates
- Crates tc-* -> cm-* (16 crates + all imports); binaries
  clawmates-server/broker/bundler; images clawmates/*; env prefix
  CLAWMATES_* (+ CM_TEST_DATABASE_URL / CM_LIVE_LLM); config
  clawmates.toml; helm chart deploy/helm/clawmates with clawmates-*
  resources; db names clawmates*; sockets /run/clawmates; cookie
  cm_session; kind cluster clawmates-test; seccomp node profile
  clawmates-agent-profile.json
- All 9 Playwright brand assertions updated in lockstep; historical
  spec document left untouched as the only remaining 'TeamClaw'
- Local env migrated: dev pg clawmates-dev-pg/clawmates_dev, shared
  test server clawmates-test-pg, kind cluster recreated with image +
  profile, compose images rebuilt under clawmates/*

Verified end to end: 161 Rust + 68 frontend tests, 29 Playwright
journeys, 4 live kind tests, helm/install/LOC/placeholder gates, and
the clean-room install rehearsal serving the clawmates login page from
a signed bundle of the rebuilt images.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 12:31:25 -05:00

195 lines
6.7 KiB
Rust

//! The deterministic scenario provider.
//!
//! A real, config-selectable production provider (`provider = "scripted"`):
//! it powers TDD, Playwright E2E, and the air-gapped smoke-test mode, so
//! tests exercise the exact seam production uses. Scenarios are TOML; a
//! prompt that contains a scenario's `marker` plays that scenario's turns,
//! anything else gets a deterministic echo.
use futures::stream;
use serde::Deserialize;
use serde_json::Value;
use crate::provider::{
ChatRequest, ContentPart, EventStream, LlmError, LlmEvent, LlmProvider, StopReason,
};
#[derive(Debug, Deserialize)]
struct ScenarioFile {
#[serde(default)]
scenario: Vec<Scenario>,
}
#[derive(Debug, Deserialize)]
struct Scenario {
marker: String,
#[serde(default)]
turns: Vec<Turn>,
}
#[derive(Debug, Deserialize)]
struct Turn {
#[serde(default)]
events: Vec<ScriptedEvent>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ScriptedEvent {
Text {
text: String,
},
ToolUse {
name: String,
#[serde(default)]
input: Value,
},
}
#[derive(Debug)]
pub struct ScriptedProvider {
scenarios: Vec<Scenario>,
}
impl ScriptedProvider {
pub fn from_toml(toml_source: &str) -> Result<ScriptedProvider, LlmError> {
let file: ScenarioFile =
toml::from_str(toml_source).map_err(|e| LlmError::Scenario(e.to_string()))?;
Ok(ScriptedProvider {
scenarios: file.scenario,
})
}
pub fn from_path(path: &std::path::Path) -> Result<ScriptedProvider, LlmError> {
let source = std::fs::read_to_string(path)
.map_err(|e| LlmError::Scenario(format!("read {}: {e}", path.display())))?;
ScriptedProvider::from_toml(&source)
}
/// Streams a fixed text as word-level deltas to exercise real streaming
/// behavior in every consumer.
fn text_deltas(text: &str, out: &mut Vec<Result<LlmEvent, LlmError>>) {
let mut rest = text;
while !rest.is_empty() {
let cut = rest
.char_indices()
.skip_while(|(_, c)| *c == ' ')
.find(|(_, c)| *c == ' ')
.map(|(i, _)| i)
.unwrap_or(rest.len());
let (chunk, tail) = rest.split_at(cut.max(1));
out.push(Ok(LlmEvent::TextDelta(chunk.to_owned())));
rest = tail;
}
}
}
#[async_trait::async_trait]
impl LlmProvider for ScriptedProvider {
async fn stream(&self, request: ChatRequest) -> Result<EventStream, LlmError> {
// Scenario selection keys on the MOST RECENT text carrying any
// marker: earlier turns keep their markers in session history, and
// the latest user intent must win.
let prompt_text: String = request
.messages
.iter()
.rev()
.flat_map(|m| m.parts.iter())
.filter_map(|p| match p {
ContentPart::Text { text } => Some(text.as_str()),
_ => None,
})
.find(|text| self.scenarios.iter().any(|s| text.contains(&s.marker)))
.unwrap_or_default()
.to_owned();
// Which leg of a multi-tool conversation is this? One ToolResult in
// the request means turn 0 already played; play turn 1, and so on.
let turn_index = request
.messages
.iter()
.flat_map(|m| m.parts.iter())
.filter(|p| matches!(p, ContentPart::ToolResult { .. }))
.count();
let mut events: Vec<Result<LlmEvent, LlmError>> = Vec::new();
let scenario = self
.scenarios
.iter()
.find(|s| prompt_text.contains(&s.marker));
match scenario {
Some(scenario) => {
match scenario.turns.get(turn_index) {
Some(turn) => {
let mut stopped_for_tool = false;
for (i, event) in turn.events.iter().enumerate() {
match event {
ScriptedEvent::Text { text } => {
Self::text_deltas(text, &mut events);
}
ScriptedEvent::ToolUse { name, input } => {
events.push(Ok(LlmEvent::ToolUse {
id: format!("scripted-tool-{turn_index}-{i}"),
name: name.clone(),
input: input.clone(),
}));
stopped_for_tool = true;
}
}
}
events.push(Ok(LlmEvent::Stop(if stopped_for_tool {
StopReason::ToolUse
} else {
StopReason::EndTurn
})));
}
// More tool round-trips than scripted turns: end cleanly.
None => events.push(Ok(LlmEvent::Stop(StopReason::EndTurn))),
}
}
None => {
let last_user_text = request
.messages
.iter()
.rev()
.flat_map(|m| m.parts.iter())
.find_map(|p| match p {
ContentPart::Text { text } => Some(text.clone()),
_ => None,
})
.unwrap_or_default();
Self::text_deltas(&format!("I received: {last_user_text}"), &mut events);
events.push(Ok(LlmEvent::Stop(StopReason::EndTurn)));
}
}
// Deterministic accounting: one "token" per whitespace word in and
// out, so billing tests can predict exact charges.
let input_tokens = request
.messages
.iter()
.flat_map(|m| m.parts.iter())
.filter_map(|p| match p {
ContentPart::Text { text } => Some(text.split_whitespace().count()),
_ => None,
})
.sum::<usize>() as u32;
let output_tokens = events
.iter()
.filter_map(|e| match e {
Ok(LlmEvent::TextDelta(t)) => Some(t.split_whitespace().count()),
_ => None,
})
.sum::<usize>() as u32;
let stop_index = events.len().saturating_sub(1);
events.insert(
stop_index,
Ok(LlmEvent::Usage {
input_tokens,
output_tokens,
}),
);
Ok(Box::pin(stream::iter(events)))
}
}