- User management: local accounts (Argon2) and OpenID Connect, admin/user roles, per-user folders plus admin-shared folders, read-only session sharing, event filtering per user - File manager with CodeMirror editor and git client (status, diff, stage, commit, branches, fetch/pull/push, log, clone/init) - Image generation via fal.ai: generate_image tool and image page - Budgets: monthly limits per provider, provider balances/costs (OpenRouter, Anthropic admin cost report, fal.ai), price discovery - Subagents on other models, background tasks, concurrency limits per host/model/subagent - MCP tools loaded on demand (mcp_search/mcp_call), read-only hints - Permission mode "auto", safe read-only commands always allowed - Persistent message queue with attachments, deletable - Live reasoning incl. <think> tags, activity animations, CSS themes, mobile layout, statistics per user, upload size handling - Docker: named volume for state, localhost-bound port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
7.2 KiB
Rust
178 lines
7.2 KiB
Rust
//! Tools that interact with the harness itself: todo list, questions to the
|
||
//! user and subagents.
|
||
|
||
use super::{Tool, ToolCtx, ToolOutput, arg_str};
|
||
use crate::types::Question;
|
||
use anyhow::{Result, bail};
|
||
use async_trait::async_trait;
|
||
use serde_json::{Value, json};
|
||
|
||
pub struct TodoWrite;
|
||
|
||
#[async_trait]
|
||
impl Tool for TodoWrite {
|
||
fn name(&self) -> &str {
|
||
"todo_write"
|
||
}
|
||
fn description(&self) -> String {
|
||
"Maintain a task list for the current work, shown live to the user. Send the complete \
|
||
list each time. Use it for multi-step tasks: exactly one item in_progress at a time, \
|
||
mark items completed as soon as they are done."
|
||
.into()
|
||
}
|
||
fn schema(&self) -> Value {
|
||
json!({
|
||
"type": "object",
|
||
"properties": {
|
||
"todos": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"content": {"type": "string"},
|
||
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
|
||
},
|
||
"required": ["content", "status"]
|
||
}
|
||
}
|
||
},
|
||
"required": ["todos"]
|
||
})
|
||
}
|
||
fn read_only(&self) -> bool {
|
||
true
|
||
}
|
||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||
let todos = input.get("todos").cloned().unwrap_or(json!([]));
|
||
if !todos.is_array() {
|
||
bail!("todos must be an array");
|
||
}
|
||
ctx.app.set_todos(&ctx.session_id, todos.clone())?;
|
||
let open = todos.as_array().unwrap().iter().filter(|t| t["status"] != "completed").count();
|
||
Ok(ToolOutput::text(format!("Todo list updated ({open} open).")).with_meta(json!({"todos": todos})))
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub struct AskUser;
|
||
|
||
#[async_trait]
|
||
impl Tool for AskUser {
|
||
fn name(&self) -> &str {
|
||
"ask_user"
|
||
}
|
||
fn description(&self) -> String {
|
||
"Ask the user one or more questions and wait for the answers. Use this only when you are \
|
||
blocked on a decision that is genuinely the user's (requirements, preferences, choices \
|
||
between valid approaches) – not for things you can find out yourself. Offer 2-4 concrete \
|
||
options per question; the user can always answer with free text instead."
|
||
.into()
|
||
}
|
||
fn schema(&self) -> Value {
|
||
json!({
|
||
"type": "object",
|
||
"properties": {
|
||
"questions": {
|
||
"type": "array",
|
||
"minItems": 1,
|
||
"maxItems": 4,
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"question": {"type": "string"},
|
||
"header": {"type": "string", "description": "Very short label (max 12 chars)"},
|
||
"multi_select": {"type": "boolean"},
|
||
"options": {
|
||
"type": "array",
|
||
"items": {
|
||
"type": "object",
|
||
"properties": {
|
||
"label": {"type": "string"},
|
||
"description": {"type": "string"}
|
||
},
|
||
"required": ["label"]
|
||
}
|
||
}
|
||
},
|
||
"required": ["question"]
|
||
}
|
||
}
|
||
},
|
||
"required": ["questions"]
|
||
})
|
||
}
|
||
fn read_only(&self) -> bool {
|
||
true
|
||
}
|
||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||
let questions: Vec<Question> = serde_json::from_value(input["questions"].clone())?;
|
||
if questions.is_empty() {
|
||
bail!("at least one question is required");
|
||
}
|
||
let answers = ctx
|
||
.app
|
||
.ask_user(&ctx.session_id, "agent", questions.clone(), ctx.cancel.clone())
|
||
.await?;
|
||
let Some(answers) = answers else {
|
||
return Ok(ToolOutput::error("The user dismissed the questions without answering."));
|
||
};
|
||
let mut out = String::from("The user answered:\n");
|
||
for q in &questions {
|
||
let a = answers.get(&q.question).map(|v| match v {
|
||
Value::String(s) => s.clone(),
|
||
Value::Array(a) => a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "),
|
||
other => other.to_string(),
|
||
});
|
||
out.push_str(&format!("- {}\n → {}\n", q.question, a.unwrap_or_else(|| "(no answer)".into())));
|
||
}
|
||
Ok(ToolOutput::text(out).with_meta(json!({"answers": answers})))
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub struct Task;
|
||
|
||
#[async_trait]
|
||
impl Tool for Task {
|
||
fn name(&self) -> &str {
|
||
"task"
|
||
}
|
||
fn description(&self) -> String {
|
||
"Launch a subagent to handle an independent, multi-step task in its own context (e.g. a \
|
||
broad codebase search, a focused review). The subagent only knows what you put in the \
|
||
prompt – write a complete, self-contained brief. Its final message is returned to you. \
|
||
Several task calls in one response run in parallel (subject to the configured limits). \
|
||
With background=true you continue working and receive the result later."
|
||
.into()
|
||
}
|
||
fn schema(&self) -> Value {
|
||
json!({
|
||
"type": "object",
|
||
"properties": {
|
||
"description": {"type": "string", "description": "Short (3-5 words) task description"},
|
||
"prompt": {"type": "string", "description": "The full brief for the subagent"},
|
||
"subagent_type": {"type": "string", "description": "Subagent to use (default: general)"},
|
||
"background": {"type": "boolean", "description": "Run in the background: returns immediately, the result arrives later as a message. Use for work you don't need right away (e.g. writing docs, summaries, translations)."}
|
||
},
|
||
"required": ["description", "prompt"]
|
||
})
|
||
}
|
||
fn read_only(&self) -> bool {
|
||
// Read-only agents (plan, explore) can spawn read-only subagents; the runner enforces it.
|
||
true
|
||
}
|
||
fn title(&self, input: &Value) -> String {
|
||
format!("Subagent: {}", input["description"].as_str().unwrap_or("task"))
|
||
}
|
||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||
let prompt = arg_str(&input, "prompt")?.to_string();
|
||
let description = input["description"].as_str().unwrap_or("subagent").to_string();
|
||
let agent = input["subagent_type"].as_str().unwrap_or("general").to_string();
|
||
let (child_id, result) =
|
||
crate::runner::run_subagent(ctx.app.clone(), ctx, &agent, &description, prompt).await?;
|
||
Ok(ToolOutput::text(result).with_meta(json!({"session_id": child_id, "agent": agent})))
|
||
}
|
||
}
|