Add web terminal, git credentials, OpenSCAD, review loops, automations
- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay) - Per-user git credentials and commit identity (credential store in the user's home) - OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card - Craft phase inferred from tool activity when the agent does not report it - Configurable review loop per mode (rounds, reviewer model, criteria) - Delete config and Markdown agents from the agent editor - Automations: cron-scheduled agents from Markdown job files, schedule tool, page - change_directory tool (only on the user's request) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a0d0e707f2
commit
df1924b0a1
41 files changed
+2809
-34
No files matched your search
@@ -254,6 +254,8 @@ fn run_session_boxed(
|
||||
pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationToken, depth: usize) -> Result<String> {
|
||||
let mut final_text = String::new();
|
||||
let mut stop_hooks = 0;
|
||||
let mut refine_done = 0u32;
|
||||
let turn_start = now_ms();
|
||||
for _ in 0..MAX_STEPS {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
@@ -331,6 +333,26 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let normal_end = matches!(step.stop_reason.as_str(), "stop" | "end_turn" | "");
|
||||
if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() {
|
||||
// Refinement loop: a reviewer checks the turn; findings send the agent back to work.
|
||||
let rounds = agent.refine.as_ref().map(|r| r.rounds).unwrap_or(0);
|
||||
if refine_done < rounds {
|
||||
refine_done += 1;
|
||||
match review_turn(&app, &session, &agent, &model_spec, turn_start, refine_done, rounds, &cancel).await {
|
||||
Ok(Some(feedback)) => {
|
||||
save_user_parts(&app, session_id, vec![Part::Text { text: feedback }])?;
|
||||
continue;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => tracing::warn!("review of {session_id} failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
// Craft: a finished check phase means the result is ready.
|
||||
if agent.name == "craft" {
|
||||
advance_phase(&app, session_id, "ready", Some("check"));
|
||||
}
|
||||
}
|
||||
if calls.is_empty() {
|
||||
match step.stop_reason.as_str() {
|
||||
"max_tokens" | "length" => app.emit(Event::Error {
|
||||
@@ -505,6 +527,7 @@ async fn execute_call(
|
||||
}
|
||||
}
|
||||
let input_path = input["path"].as_str().map(String::from);
|
||||
let input_for_phase = if base.agent == "craft" { input.clone() } else { Value::Null };
|
||||
let res = if name == "task" {
|
||||
// Pass depth through the agent name channel: subagents run with depth+1.
|
||||
run_task(app, &ctx, input, depth).await
|
||||
@@ -537,6 +560,11 @@ async fn execute_call(
|
||||
}
|
||||
e => e,
|
||||
};
|
||||
if matches!(&res, Ok(o) if !o.is_error) && base.agent == "craft" && name != "craft_phase" {
|
||||
if let Some(phase) = infer_phase(name, &input_for_phase, session.clean_room == Some(false)) {
|
||||
advance_phase(app, &session.id, phase, None);
|
||||
}
|
||||
}
|
||||
match res {
|
||||
Ok(out) => Part::ToolResult {
|
||||
call_id: call_id.into(),
|
||||
@@ -969,6 +997,118 @@ pub async fn suggest_title(app: Arc<App>, session_id: &str, model_spec: Option<S
|
||||
Ok(title.chars().take(80).collect())
|
||||
}
|
||||
|
||||
const PHASES: &[&str] = &["study", "specify", "build", "check", "ready"];
|
||||
|
||||
/// Craft phase implied by a tool call (fallback when the agent does not report it).
|
||||
fn infer_phase(tool: &str, input: &Value, deep: bool) -> Option<&'static str> {
|
||||
let path = input["path"].as_str().unwrap_or("");
|
||||
let label = input["label"].as_str().unwrap_or("");
|
||||
let cmd = input["command"].as_str().unwrap_or("");
|
||||
Some(match tool {
|
||||
"write" | "edit" => {
|
||||
let doc = path.contains(".codecraft/spec") || path.starts_with("spec/") || path.contains("/spec/") || path.starts_with("docs/") || path.contains("proposal/");
|
||||
if doc && deep { "specify" } else if doc { return None } else { "build" }
|
||||
}
|
||||
"image_compare" | "openscad" => "check",
|
||||
"browser_snapshot" if label.starts_with("build") => "check",
|
||||
"bash" if ["test", "pytest", "cargo check", "cargo build", "npm run build", "http.server", "serve", "vite"].iter().any(|k| cmd.contains(k)) => "check",
|
||||
"read" | "glob" | "grep" | "list" | "browser_snapshot" | "web_fetch" | "web_search" | "binary_info" | "decompile" => "study",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Move the craft phase forward (never backwards); `only_from` restricts the transition.
|
||||
fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&str>) {
|
||||
let root = app.root_session_id(session_id);
|
||||
let Ok(mut s) = app.session(&root) else { return };
|
||||
let cur = s.phase.as_deref().and_then(|p| PHASES.iter().position(|x| *x == p));
|
||||
if let Some(from) = only_from {
|
||||
if s.phase.as_deref() != Some(from) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(next) = PHASES.iter().position(|x| *x == phase) else { return };
|
||||
// Studying again after building is normal – no step back automatically.
|
||||
if cur.is_some_and(|c| c >= next) {
|
||||
return;
|
||||
}
|
||||
// Clean room has no specify phase.
|
||||
let phase = if phase == "specify" && s.clean_room != Some(false) { "study" } else { phase };
|
||||
if s.phase.as_deref() == Some(phase) {
|
||||
return;
|
||||
}
|
||||
s.phase = Some(phase.to_string());
|
||||
let _ = app.save_session(s);
|
||||
}
|
||||
|
||||
/// Review the last turn against the user's request. Returns feedback for the
|
||||
/// agent, or None when the reviewer approves.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent, model_spec: &str, turn_start: i64, round: u32, rounds: u32, cancel: &CancellationToken) -> Result<Option<String>> {
|
||||
let refine = agent.refine.clone().unwrap_or_default();
|
||||
let spec = refine.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string());
|
||||
let (provider, mut model) = app.registry().resolve(&spec)?;
|
||||
model.effort = Some("medium".into());
|
||||
app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("review:{round}/{rounds}")) });
|
||||
let result = async {
|
||||
let history = active_history(app.store.messages(&session.id)?);
|
||||
// The user's request: the last user message with text that is not feedback/tool output.
|
||||
let req_idx = history
|
||||
.iter()
|
||||
.rposition(|m| matches!(m.role, Role::User) && m.parts.iter().any(|p| matches!(p, Part::Text { text } if !text.starts_with("<refine-feedback") && !text.starts_with("<hook-"))))
|
||||
.unwrap_or(0);
|
||||
let request: String = history.get(req_idx).map(|m| m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")).unwrap_or_default();
|
||||
let mut work = transcript_of(&history[(req_idx + 1).min(history.len())..]);
|
||||
if work.len() > 30_000 {
|
||||
let mut start = work.len() - 30_000;
|
||||
while !work.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
work = format!("…{}", &work[start..]);
|
||||
}
|
||||
// Actual file changes of this turn (checkpoint vs. current content).
|
||||
let mut changes = String::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (path, before) in app.store.checkpoints_since(&session.id, turn_start)? {
|
||||
if !seen.insert(path.clone()) || changes.len() > 30_000 {
|
||||
continue;
|
||||
}
|
||||
let old = before.map(|b| String::from_utf8_lossy(&b).into_owned()).unwrap_or_default();
|
||||
let new = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let diff = similar::TextDiff::from_lines(&old, &new).unified_diff().context_radius(3).header(&path, &path).to_string();
|
||||
changes.push_str(&clip(&diff, 12_000));
|
||||
changes.push('\n');
|
||||
}
|
||||
let criteria = refine.criteria.clone().filter(|c| !c.trim().is_empty()).unwrap_or_else(|| prompts::refine_criteria(&agent.name).to_string());
|
||||
let system = "You review the work of an AI coding agent for the user. Be strict about what matters for the user's request and ignore style nitpicks. \
|
||||
Reply in exactly this format: the first line is APPROVED or CHANGES_NEEDED. For CHANGES_NEEDED, follow with at most 8 concrete, actionable findings as a bullet list \
|
||||
(with file:line where possible). If the important work is done and correct, reply APPROVED.";
|
||||
let body = format!(
|
||||
"<request>\n{request}\n</request>\n\n<criteria>\n{criteria}\n</criteria>\n\n<agent-work mode=\"{}\">\n{work}\n</agent-work>\n\n<file-changes>\n{}\n</file-changes>",
|
||||
agent.name,
|
||||
if changes.is_empty() { "(no files changed in this turn)" } else { changes.as_str() }
|
||||
);
|
||||
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone() };
|
||||
let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?;
|
||||
record_side_usage(app, session, &spec, &model, &usage, "review");
|
||||
let text = strip_think(&text);
|
||||
let mut lines = text.lines().skip_while(|l| l.trim().is_empty());
|
||||
let verdict = lines.next().unwrap_or("").trim().to_uppercase();
|
||||
if verdict.starts_with("APPROVED") || text.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let findings: String = lines.collect::<Vec<_>>().join("\n");
|
||||
let findings = if findings.trim().is_empty() { text.clone() } else { findings };
|
||||
Ok(Some(format!(
|
||||
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\">\n{}\n</refine-feedback>\nAddress these review findings (or explain briefly why one does not apply), verify, then finish.",
|
||||
findings.trim()
|
||||
)))
|
||||
}
|
||||
.await;
|
||||
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
|
||||
result
|
||||
}
|
||||
|
||||
/// Remove a leading `<think>…</think>` block some models put into the text.
|
||||
fn strip_think(text: &str) -> String {
|
||||
let t = text.trim_start();
|
||||
|
||||
Reference in new issue
Block a user