mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-03 03:01:51 +08:00
Merge remote-tracking branch 'origin/rcc/subagent' into dev/rust
This commit is contained in:
2
rust/Cargo.lock
generated
2
rust/Cargo.lock
generated
@@ -1545,10 +1545,12 @@ dependencies = [
|
|||||||
name = "tools"
|
name = "tools"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"api",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"runtime",
|
"runtime",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ mod sse;
|
|||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
pub use client::{
|
pub use client::{
|
||||||
oauth_token_is_expired, read_base_url, resolve_saved_oauth_token,
|
oauth_token_is_expired, read_base_url, resolve_saved_oauth_token, resolve_startup_auth_source,
|
||||||
resolve_startup_auth_source, AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
|
AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
|
||||||
};
|
};
|
||||||
pub use error::ApiError;
|
pub use error::ApiError;
|
||||||
pub use sse::{parse_frame, SseParser};
|
pub use sse::{parse_frame, SseParser};
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ license.workspace = true
|
|||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
api = { path = "../api" }
|
||||||
runtime = { path = "../runtime" }
|
runtime = { path = "../runtime" }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use api::{
|
||||||
|
read_base_url, AnthropicClient, ContentBlockDelta, InputContentBlock, InputMessage,
|
||||||
|
MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice,
|
||||||
|
ToolDefinition, ToolResultContentBlock,
|
||||||
|
};
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use runtime::{
|
use runtime::{
|
||||||
edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput,
|
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
|
||||||
GrepSearchInput, PermissionMode,
|
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
|
||||||
|
ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
|
||||||
|
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -702,7 +709,7 @@ struct SkillOutput {
|
|||||||
prompt: String,
|
prompt: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct AgentOutput {
|
struct AgentOutput {
|
||||||
#[serde(rename = "agentId")]
|
#[serde(rename = "agentId")]
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
@@ -718,6 +725,20 @@ struct AgentOutput {
|
|||||||
manifest_file: String,
|
manifest_file: String,
|
||||||
#[serde(rename = "createdAt")]
|
#[serde(rename = "createdAt")]
|
||||||
created_at: String,
|
created_at: String,
|
||||||
|
#[serde(rename = "startedAt", skip_serializing_if = "Option::is_none")]
|
||||||
|
started_at: Option<String>,
|
||||||
|
#[serde(rename = "completedAt", skip_serializing_if = "Option::is_none")]
|
||||||
|
completed_at: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct AgentJob {
|
||||||
|
manifest: AgentOutput,
|
||||||
|
prompt: String,
|
||||||
|
system_prompt: Vec<String>,
|
||||||
|
allowed_tools: BTreeSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -1315,7 +1336,18 @@ fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
|
|||||||
Err(format!("unknown skill: {requested}"))
|
Err(format!("unknown skill: {requested}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
||||||
|
const DEFAULT_AGENT_SYSTEM_DATE: &str = "2026-03-31";
|
||||||
|
const DEFAULT_AGENT_MAX_ITERATIONS: usize = 32;
|
||||||
|
|
||||||
fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
|
fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
|
||||||
|
execute_agent_with_spawn(input, spawn_agent_job)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_agent_with_spawn<F>(input: AgentInput, spawn_fn: F) -> Result<AgentOutput, String>
|
||||||
|
where
|
||||||
|
F: FnOnce(AgentJob) -> Result<(), String>,
|
||||||
|
{
|
||||||
if input.description.trim().is_empty() {
|
if input.description.trim().is_empty() {
|
||||||
return Err(String::from("description must not be empty"));
|
return Err(String::from("description must not be empty"));
|
||||||
}
|
}
|
||||||
@@ -1329,6 +1361,7 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
|
|||||||
let output_file = output_dir.join(format!("{agent_id}.md"));
|
let output_file = output_dir.join(format!("{agent_id}.md"));
|
||||||
let manifest_file = output_dir.join(format!("{agent_id}.json"));
|
let manifest_file = output_dir.join(format!("{agent_id}.json"));
|
||||||
let normalized_subagent_type = normalize_subagent_type(input.subagent_type.as_deref());
|
let normalized_subagent_type = normalize_subagent_type(input.subagent_type.as_deref());
|
||||||
|
let model = resolve_agent_model(input.model.as_deref());
|
||||||
let agent_name = input
|
let agent_name = input
|
||||||
.name
|
.name
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -1336,6 +1369,8 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
|
|||||||
.filter(|name| !name.is_empty())
|
.filter(|name| !name.is_empty())
|
||||||
.unwrap_or_else(|| slugify_agent_name(&input.description));
|
.unwrap_or_else(|| slugify_agent_name(&input.description));
|
||||||
let created_at = iso8601_now();
|
let created_at = iso8601_now();
|
||||||
|
let system_prompt = build_agent_system_prompt(&normalized_subagent_type)?;
|
||||||
|
let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type);
|
||||||
|
|
||||||
let output_contents = format!(
|
let output_contents = format!(
|
||||||
"# Agent Task
|
"# Agent Task
|
||||||
@@ -1359,21 +1394,514 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
|
|||||||
name: agent_name,
|
name: agent_name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
subagent_type: Some(normalized_subagent_type),
|
subagent_type: Some(normalized_subagent_type),
|
||||||
model: input.model,
|
model: Some(model),
|
||||||
status: String::from("queued"),
|
status: String::from("running"),
|
||||||
output_file: output_file.display().to_string(),
|
output_file: output_file.display().to_string(),
|
||||||
manifest_file: manifest_file.display().to_string(),
|
manifest_file: manifest_file.display().to_string(),
|
||||||
created_at,
|
created_at: created_at.clone(),
|
||||||
|
started_at: Some(created_at),
|
||||||
|
completed_at: None,
|
||||||
|
error: None,
|
||||||
};
|
};
|
||||||
std::fs::write(
|
write_agent_manifest(&manifest)?;
|
||||||
&manifest_file,
|
|
||||||
serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?,
|
let manifest_for_spawn = manifest.clone();
|
||||||
)
|
let job = AgentJob {
|
||||||
.map_err(|error| error.to_string())?;
|
manifest: manifest_for_spawn,
|
||||||
|
prompt: input.prompt,
|
||||||
|
system_prompt,
|
||||||
|
allowed_tools,
|
||||||
|
};
|
||||||
|
if let Err(error) = spawn_fn(job) {
|
||||||
|
let error = format!("failed to spawn sub-agent: {error}");
|
||||||
|
persist_agent_terminal_state(&manifest, "failed", None, Some(error.clone()))?;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(manifest)
|
Ok(manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_agent_job(job: AgentJob) -> Result<(), String> {
|
||||||
|
let thread_name = format!("clawd-agent-{}", job.manifest.agent_id);
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name(thread_name)
|
||||||
|
.spawn(move || {
|
||||||
|
let result =
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_agent_job(&job)));
|
||||||
|
match result {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
let _ =
|
||||||
|
persist_agent_terminal_state(&job.manifest, "failed", None, Some(error));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = persist_agent_terminal_state(
|
||||||
|
&job.manifest,
|
||||||
|
"failed",
|
||||||
|
None,
|
||||||
|
Some(String::from("sub-agent thread panicked")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_agent_job(job: &AgentJob) -> Result<(), String> {
|
||||||
|
let mut runtime = build_agent_runtime(job)?.with_max_iterations(DEFAULT_AGENT_MAX_ITERATIONS);
|
||||||
|
let summary = runtime
|
||||||
|
.run_turn(job.prompt.clone(), None)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let final_text = final_assistant_text(&summary);
|
||||||
|
persist_agent_terminal_state(&job.manifest, "completed", Some(final_text.as_str()), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_agent_runtime(
|
||||||
|
job: &AgentJob,
|
||||||
|
) -> Result<ConversationRuntime<AnthropicRuntimeClient, SubagentToolExecutor>, String> {
|
||||||
|
let model = job
|
||||||
|
.manifest
|
||||||
|
.model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
|
||||||
|
let allowed_tools = job.allowed_tools.clone();
|
||||||
|
let api_client = AnthropicRuntimeClient::new(model, allowed_tools.clone())?;
|
||||||
|
let tool_executor = SubagentToolExecutor::new(allowed_tools);
|
||||||
|
Ok(ConversationRuntime::new(
|
||||||
|
Session::new(),
|
||||||
|
api_client,
|
||||||
|
tool_executor,
|
||||||
|
agent_permission_policy(),
|
||||||
|
job.system_prompt.clone(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_agent_system_prompt(subagent_type: &str) -> Result<Vec<String>, String> {
|
||||||
|
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||||
|
let mut prompt = load_system_prompt(
|
||||||
|
cwd,
|
||||||
|
DEFAULT_AGENT_SYSTEM_DATE.to_string(),
|
||||||
|
std::env::consts::OS,
|
||||||
|
"unknown",
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
prompt.push(format!(
|
||||||
|
"You are a background sub-agent of type `{subagent_type}`. Work only on the delegated task, use only the tools available to you, do not ask the user questions, and finish with a concise result."
|
||||||
|
));
|
||||||
|
Ok(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_agent_model(model: Option<&str>) -> String {
|
||||||
|
model
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|model| !model.is_empty())
|
||||||
|
.unwrap_or(DEFAULT_AGENT_MODEL)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
|
||||||
|
let tools = match subagent_type {
|
||||||
|
"Explore" => vec![
|
||||||
|
"read_file",
|
||||||
|
"glob_search",
|
||||||
|
"grep_search",
|
||||||
|
"WebFetch",
|
||||||
|
"WebSearch",
|
||||||
|
"ToolSearch",
|
||||||
|
"Skill",
|
||||||
|
"StructuredOutput",
|
||||||
|
],
|
||||||
|
"Plan" => vec![
|
||||||
|
"read_file",
|
||||||
|
"glob_search",
|
||||||
|
"grep_search",
|
||||||
|
"WebFetch",
|
||||||
|
"WebSearch",
|
||||||
|
"ToolSearch",
|
||||||
|
"Skill",
|
||||||
|
"TodoWrite",
|
||||||
|
"StructuredOutput",
|
||||||
|
"SendUserMessage",
|
||||||
|
],
|
||||||
|
"Verification" => vec![
|
||||||
|
"bash",
|
||||||
|
"read_file",
|
||||||
|
"glob_search",
|
||||||
|
"grep_search",
|
||||||
|
"WebFetch",
|
||||||
|
"WebSearch",
|
||||||
|
"ToolSearch",
|
||||||
|
"TodoWrite",
|
||||||
|
"StructuredOutput",
|
||||||
|
"SendUserMessage",
|
||||||
|
"PowerShell",
|
||||||
|
],
|
||||||
|
"claude-code-guide" => vec![
|
||||||
|
"read_file",
|
||||||
|
"glob_search",
|
||||||
|
"grep_search",
|
||||||
|
"WebFetch",
|
||||||
|
"WebSearch",
|
||||||
|
"ToolSearch",
|
||||||
|
"Skill",
|
||||||
|
"StructuredOutput",
|
||||||
|
"SendUserMessage",
|
||||||
|
],
|
||||||
|
"statusline-setup" => vec![
|
||||||
|
"bash",
|
||||||
|
"read_file",
|
||||||
|
"write_file",
|
||||||
|
"edit_file",
|
||||||
|
"glob_search",
|
||||||
|
"grep_search",
|
||||||
|
"ToolSearch",
|
||||||
|
],
|
||||||
|
_ => vec![
|
||||||
|
"bash",
|
||||||
|
"read_file",
|
||||||
|
"write_file",
|
||||||
|
"edit_file",
|
||||||
|
"glob_search",
|
||||||
|
"grep_search",
|
||||||
|
"WebFetch",
|
||||||
|
"WebSearch",
|
||||||
|
"TodoWrite",
|
||||||
|
"Skill",
|
||||||
|
"ToolSearch",
|
||||||
|
"NotebookEdit",
|
||||||
|
"Sleep",
|
||||||
|
"SendUserMessage",
|
||||||
|
"Config",
|
||||||
|
"StructuredOutput",
|
||||||
|
"REPL",
|
||||||
|
"PowerShell",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
tools.into_iter().map(str::to_string).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn agent_permission_policy() -> PermissionPolicy {
|
||||||
|
mvp_tool_specs().into_iter().fold(
|
||||||
|
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||||
|
|policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_agent_manifest(manifest: &AgentOutput) -> Result<(), String> {
|
||||||
|
std::fs::write(
|
||||||
|
&manifest.manifest_file,
|
||||||
|
serde_json::to_string_pretty(manifest).map_err(|error| error.to_string())?,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_agent_terminal_state(
|
||||||
|
manifest: &AgentOutput,
|
||||||
|
status: &str,
|
||||||
|
result: Option<&str>,
|
||||||
|
error: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
append_agent_output(
|
||||||
|
&manifest.output_file,
|
||||||
|
&format_agent_terminal_output(status, result, error.as_deref()),
|
||||||
|
)?;
|
||||||
|
let mut next_manifest = manifest.clone();
|
||||||
|
next_manifest.status = status.to_string();
|
||||||
|
next_manifest.completed_at = Some(iso8601_now());
|
||||||
|
next_manifest.error = error;
|
||||||
|
write_agent_manifest(&next_manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_agent_output(path: &str, suffix: &str) -> Result<(), String> {
|
||||||
|
use std::io::Write as _;
|
||||||
|
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(path)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
file.write_all(suffix.as_bytes())
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_agent_terminal_output(status: &str, result: Option<&str>, error: Option<&str>) -> String {
|
||||||
|
let mut sections = vec![format!("\n## Result\n\n- status: {status}\n")];
|
||||||
|
if let Some(result) = result.filter(|value| !value.trim().is_empty()) {
|
||||||
|
sections.push(format!("\n### Final response\n\n{}\n", result.trim()));
|
||||||
|
}
|
||||||
|
if let Some(error) = error.filter(|value| !value.trim().is_empty()) {
|
||||||
|
sections.push(format!("\n### Error\n\n{}\n", error.trim()));
|
||||||
|
}
|
||||||
|
sections.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AnthropicRuntimeClient {
|
||||||
|
runtime: tokio::runtime::Runtime,
|
||||||
|
client: AnthropicClient,
|
||||||
|
model: String,
|
||||||
|
allowed_tools: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnthropicRuntimeClient {
|
||||||
|
fn new(model: String, allowed_tools: BTreeSet<String>) -> Result<Self, String> {
|
||||||
|
let client = AnthropicClient::from_env()
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.with_base_url(read_base_url());
|
||||||
|
Ok(Self {
|
||||||
|
runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?,
|
||||||
|
client,
|
||||||
|
model,
|
||||||
|
allowed_tools,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiClient for AnthropicRuntimeClient {
|
||||||
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||||
|
let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools))
|
||||||
|
.into_iter()
|
||||||
|
.map(|spec| ToolDefinition {
|
||||||
|
name: spec.name.to_string(),
|
||||||
|
description: Some(spec.description.to_string()),
|
||||||
|
input_schema: spec.input_schema,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let message_request = MessageRequest {
|
||||||
|
model: self.model.clone(),
|
||||||
|
max_tokens: 32_000,
|
||||||
|
messages: convert_messages(&request.messages),
|
||||||
|
system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")),
|
||||||
|
tools: (!tools.is_empty()).then_some(tools),
|
||||||
|
tool_choice: (!self.allowed_tools.is_empty()).then_some(ToolChoice::Auto),
|
||||||
|
stream: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.runtime.block_on(async {
|
||||||
|
let mut stream = self
|
||||||
|
.client
|
||||||
|
.stream_message(&message_request)
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let mut pending_tool: Option<(String, String, String)> = None;
|
||||||
|
let mut saw_stop = false;
|
||||||
|
|
||||||
|
while let Some(event) = stream
|
||||||
|
.next_event()
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::new(error.to_string()))?
|
||||||
|
{
|
||||||
|
match event {
|
||||||
|
ApiStreamEvent::MessageStart(start) => {
|
||||||
|
for block in start.message.content {
|
||||||
|
push_output_block(block, &mut events, &mut pending_tool, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApiStreamEvent::ContentBlockStart(start) => {
|
||||||
|
push_output_block(
|
||||||
|
start.content_block,
|
||||||
|
&mut events,
|
||||||
|
&mut pending_tool,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
||||||
|
ContentBlockDelta::TextDelta { text } => {
|
||||||
|
if !text.is_empty() {
|
||||||
|
events.push(AssistantEvent::TextDelta(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ContentBlockDelta::InputJsonDelta { partial_json } => {
|
||||||
|
if let Some((_, _, input)) = &mut pending_tool {
|
||||||
|
input.push_str(&partial_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ApiStreamEvent::ContentBlockStop(_) => {
|
||||||
|
if let Some((id, name, input)) = pending_tool.take() {
|
||||||
|
events.push(AssistantEvent::ToolUse { id, name, input });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApiStreamEvent::MessageDelta(delta) => {
|
||||||
|
events.push(AssistantEvent::Usage(TokenUsage {
|
||||||
|
input_tokens: delta.usage.input_tokens,
|
||||||
|
output_tokens: delta.usage.output_tokens,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
ApiStreamEvent::MessageStop(_) => {
|
||||||
|
saw_stop = true;
|
||||||
|
events.push(AssistantEvent::MessageStop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !saw_stop
|
||||||
|
&& events.iter().any(|event| {
|
||||||
|
matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty())
|
||||||
|
|| matches!(event, AssistantEvent::ToolUse { .. })
|
||||||
|
})
|
||||||
|
{
|
||||||
|
events.push(AssistantEvent::MessageStop);
|
||||||
|
}
|
||||||
|
|
||||||
|
if events
|
||||||
|
.iter()
|
||||||
|
.any(|event| matches!(event, AssistantEvent::MessageStop))
|
||||||
|
{
|
||||||
|
return Ok(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.send_message(&MessageRequest {
|
||||||
|
stream: false,
|
||||||
|
..message_request.clone()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
|
Ok(response_to_events(response))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubagentToolExecutor {
|
||||||
|
allowed_tools: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubagentToolExecutor {
|
||||||
|
fn new(allowed_tools: BTreeSet<String>) -> Self {
|
||||||
|
Self { allowed_tools }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolExecutor for SubagentToolExecutor {
|
||||||
|
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
||||||
|
if !self.allowed_tools.contains(tool_name) {
|
||||||
|
return Err(ToolError::new(format!(
|
||||||
|
"tool `{tool_name}` is not enabled for this sub-agent"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let value = serde_json::from_str(input)
|
||||||
|
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
|
||||||
|
execute_tool(tool_name, &value).map_err(ToolError::new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet<String>>) -> Vec<ToolSpec> {
|
||||||
|
mvp_tool_specs()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|message| {
|
||||||
|
let role = match message.role {
|
||||||
|
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
|
||||||
|
MessageRole::Assistant => "assistant",
|
||||||
|
};
|
||||||
|
let content = message
|
||||||
|
.blocks
|
||||||
|
.iter()
|
||||||
|
.map(|block| match block {
|
||||||
|
ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() },
|
||||||
|
ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse {
|
||||||
|
id: id.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
input: serde_json::from_str(input)
|
||||||
|
.unwrap_or_else(|_| serde_json::json!({ "raw": input })),
|
||||||
|
},
|
||||||
|
ContentBlock::ToolResult {
|
||||||
|
tool_use_id,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
|
..
|
||||||
|
} => InputContentBlock::ToolResult {
|
||||||
|
tool_use_id: tool_use_id.clone(),
|
||||||
|
content: vec![ToolResultContentBlock::Text {
|
||||||
|
text: output.clone(),
|
||||||
|
}],
|
||||||
|
is_error: *is_error,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
(!content.is_empty()).then(|| InputMessage {
|
||||||
|
role: role.to_string(),
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_output_block(
|
||||||
|
block: OutputContentBlock,
|
||||||
|
events: &mut Vec<AssistantEvent>,
|
||||||
|
pending_tool: &mut Option<(String, String, String)>,
|
||||||
|
streaming_tool_input: bool,
|
||||||
|
) {
|
||||||
|
match block {
|
||||||
|
OutputContentBlock::Text { text } => {
|
||||||
|
if !text.is_empty() {
|
||||||
|
events.push(AssistantEvent::TextDelta(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutputContentBlock::ToolUse { id, name, input } => {
|
||||||
|
let initial_input = if streaming_tool_input
|
||||||
|
&& input.is_object()
|
||||||
|
&& input.as_object().is_some_and(serde_json::Map::is_empty)
|
||||||
|
{
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
input.to_string()
|
||||||
|
};
|
||||||
|
*pending_tool = Some((id, name, initial_input));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_to_events(response: MessageResponse) -> Vec<AssistantEvent> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let mut pending_tool = None;
|
||||||
|
|
||||||
|
for block in response.content {
|
||||||
|
push_output_block(block, &mut events, &mut pending_tool, false);
|
||||||
|
if let Some((id, name, input)) = pending_tool.take() {
|
||||||
|
events.push(AssistantEvent::ToolUse { id, name, input });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push(AssistantEvent::Usage(TokenUsage {
|
||||||
|
input_tokens: response.usage.input_tokens,
|
||||||
|
output_tokens: response.usage.output_tokens,
|
||||||
|
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
|
||||||
|
cache_read_input_tokens: response.usage.cache_read_input_tokens,
|
||||||
|
}));
|
||||||
|
events.push(AssistantEvent::MessageStop);
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
|
||||||
|
summary
|
||||||
|
.assistant_messages
|
||||||
|
.last()
|
||||||
|
.map(|message| {
|
||||||
|
message
|
||||||
|
.blocks
|
||||||
|
.iter()
|
||||||
|
.filter_map(|block| match block {
|
||||||
|
ContentBlock::Text { text } => Some(text.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
|
fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
|
||||||
let deferred = deferred_tool_specs();
|
let deferred = deferred_tool_specs();
|
||||||
@@ -2207,7 +2735,7 @@ fn execute_shell_command(
|
|||||||
persisted_output_path: None,
|
persisted_output_path: None,
|
||||||
persisted_output_size: None,
|
persisted_output_size: None,
|
||||||
sandbox_status: None,
|
sandbox_status: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut process = std::process::Command::new(shell);
|
let mut process = std::process::Command::new(shell);
|
||||||
@@ -2276,7 +2804,7 @@ Command exceeded timeout of {timeout_ms} ms",
|
|||||||
persisted_output_path: None,
|
persisted_output_path: None,
|
||||||
persisted_output_size: None,
|
persisted_output_size: None,
|
||||||
sandbox_status: None,
|
sandbox_status: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
@@ -2365,6 +2893,7 @@ fn parse_skill_description(contents: &str) -> Option<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::collections::BTreeSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::{SocketAddr, TcpListener};
|
use std::net::{SocketAddr, TcpListener};
|
||||||
@@ -2373,7 +2902,12 @@ mod tests {
|
|||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::{execute_tool, mvp_tool_specs};
|
use super::{
|
||||||
|
agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
|
||||||
|
execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state,
|
||||||
|
AgentInput, AgentJob, SubagentToolExecutor,
|
||||||
|
};
|
||||||
|
use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn env_lock() -> &'static Mutex<()> {
|
fn env_lock() -> &'static Mutex<()> {
|
||||||
@@ -2765,32 +3299,48 @@ mod tests {
|
|||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
let dir = temp_path("agent-store");
|
let dir = temp_path("agent-store");
|
||||||
std::env::set_var("CLAWD_AGENT_STORE", &dir);
|
std::env::set_var("CLAWD_AGENT_STORE", &dir);
|
||||||
|
let captured = Arc::new(Mutex::new(None::<AgentJob>));
|
||||||
|
let captured_for_spawn = Arc::clone(&captured);
|
||||||
|
|
||||||
let result = execute_tool(
|
let manifest = execute_agent_with_spawn(
|
||||||
"Agent",
|
AgentInput {
|
||||||
&json!({
|
description: "Audit the branch".to_string(),
|
||||||
"description": "Audit the branch",
|
prompt: "Check tests and outstanding work.".to_string(),
|
||||||
"prompt": "Check tests and outstanding work.",
|
subagent_type: Some("Explore".to_string()),
|
||||||
"subagent_type": "Explore",
|
name: Some("ship-audit".to_string()),
|
||||||
"name": "ship-audit"
|
model: None,
|
||||||
}),
|
},
|
||||||
|
move |job| {
|
||||||
|
*captured_for_spawn
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(job);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.expect("Agent should succeed");
|
.expect("Agent should succeed");
|
||||||
std::env::remove_var("CLAWD_AGENT_STORE");
|
std::env::remove_var("CLAWD_AGENT_STORE");
|
||||||
|
|
||||||
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
|
assert_eq!(manifest.name, "ship-audit");
|
||||||
assert_eq!(output["name"], "ship-audit");
|
assert_eq!(manifest.subagent_type.as_deref(), Some("Explore"));
|
||||||
assert_eq!(output["subagentType"], "Explore");
|
assert_eq!(manifest.status, "running");
|
||||||
assert_eq!(output["status"], "queued");
|
assert!(!manifest.created_at.is_empty());
|
||||||
assert!(output["createdAt"].as_str().is_some());
|
assert!(manifest.started_at.is_some());
|
||||||
let manifest_file = output["manifestFile"].as_str().expect("manifest file");
|
assert!(manifest.completed_at.is_none());
|
||||||
let output_file = output["outputFile"].as_str().expect("output file");
|
let contents = std::fs::read_to_string(&manifest.output_file).expect("agent file exists");
|
||||||
let contents = std::fs::read_to_string(output_file).expect("agent file exists");
|
|
||||||
let manifest_contents =
|
let manifest_contents =
|
||||||
std::fs::read_to_string(manifest_file).expect("manifest file exists");
|
std::fs::read_to_string(&manifest.manifest_file).expect("manifest file exists");
|
||||||
assert!(contents.contains("Audit the branch"));
|
assert!(contents.contains("Audit the branch"));
|
||||||
assert!(contents.contains("Check tests and outstanding work."));
|
assert!(contents.contains("Check tests and outstanding work."));
|
||||||
assert!(manifest_contents.contains("\"subagentType\": \"Explore\""));
|
assert!(manifest_contents.contains("\"subagentType\": \"Explore\""));
|
||||||
|
assert!(manifest_contents.contains("\"status\": \"running\""));
|
||||||
|
let captured_job = captured
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.clone()
|
||||||
|
.expect("spawn job should be captured");
|
||||||
|
assert_eq!(captured_job.prompt, "Check tests and outstanding work.");
|
||||||
|
assert!(captured_job.allowed_tools.contains("read_file"));
|
||||||
|
assert!(!captured_job.allowed_tools.contains("Agent"));
|
||||||
|
|
||||||
let normalized = execute_tool(
|
let normalized = execute_tool(
|
||||||
"Agent",
|
"Agent",
|
||||||
@@ -2819,6 +3369,195 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(dir);
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_fake_runner_can_persist_completion_and_failure() {
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let dir = temp_path("agent-runner");
|
||||||
|
std::env::set_var("CLAWD_AGENT_STORE", &dir);
|
||||||
|
|
||||||
|
let completed = execute_agent_with_spawn(
|
||||||
|
AgentInput {
|
||||||
|
description: "Complete the task".to_string(),
|
||||||
|
prompt: "Do the work".to_string(),
|
||||||
|
subagent_type: Some("Explore".to_string()),
|
||||||
|
name: Some("complete-task".to_string()),
|
||||||
|
model: Some("claude-sonnet-4-6".to_string()),
|
||||||
|
},
|
||||||
|
|job| {
|
||||||
|
persist_agent_terminal_state(
|
||||||
|
&job.manifest,
|
||||||
|
"completed",
|
||||||
|
Some("Finished successfully"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("completed agent should succeed");
|
||||||
|
|
||||||
|
let completed_manifest = std::fs::read_to_string(&completed.manifest_file)
|
||||||
|
.expect("completed manifest should exist");
|
||||||
|
let completed_output =
|
||||||
|
std::fs::read_to_string(&completed.output_file).expect("completed output should exist");
|
||||||
|
assert!(completed_manifest.contains("\"status\": \"completed\""));
|
||||||
|
assert!(completed_output.contains("Finished successfully"));
|
||||||
|
|
||||||
|
let failed = execute_agent_with_spawn(
|
||||||
|
AgentInput {
|
||||||
|
description: "Fail the task".to_string(),
|
||||||
|
prompt: "Do the failing work".to_string(),
|
||||||
|
subagent_type: Some("Verification".to_string()),
|
||||||
|
name: Some("fail-task".to_string()),
|
||||||
|
model: None,
|
||||||
|
},
|
||||||
|
|job| {
|
||||||
|
persist_agent_terminal_state(
|
||||||
|
&job.manifest,
|
||||||
|
"failed",
|
||||||
|
None,
|
||||||
|
Some(String::from("simulated failure")),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("failed agent should still spawn");
|
||||||
|
|
||||||
|
let failed_manifest =
|
||||||
|
std::fs::read_to_string(&failed.manifest_file).expect("failed manifest should exist");
|
||||||
|
let failed_output =
|
||||||
|
std::fs::read_to_string(&failed.output_file).expect("failed output should exist");
|
||||||
|
assert!(failed_manifest.contains("\"status\": \"failed\""));
|
||||||
|
assert!(failed_manifest.contains("simulated failure"));
|
||||||
|
assert!(failed_output.contains("simulated failure"));
|
||||||
|
|
||||||
|
let spawn_error = execute_agent_with_spawn(
|
||||||
|
AgentInput {
|
||||||
|
description: "Spawn error task".to_string(),
|
||||||
|
prompt: "Never starts".to_string(),
|
||||||
|
subagent_type: None,
|
||||||
|
name: Some("spawn-error".to_string()),
|
||||||
|
model: None,
|
||||||
|
},
|
||||||
|
|_| Err(String::from("thread creation failed")),
|
||||||
|
)
|
||||||
|
.expect_err("spawn errors should surface");
|
||||||
|
assert!(spawn_error.contains("failed to spawn sub-agent"));
|
||||||
|
let spawn_error_manifest = std::fs::read_dir(&dir)
|
||||||
|
.expect("agent dir should exist")
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
|
||||||
|
.find_map(|path| {
|
||||||
|
let contents = std::fs::read_to_string(&path).ok()?;
|
||||||
|
contents
|
||||||
|
.contains("\"name\": \"spawn-error\"")
|
||||||
|
.then_some(contents)
|
||||||
|
})
|
||||||
|
.expect("failed manifest should still be written");
|
||||||
|
assert!(spawn_error_manifest.contains("\"status\": \"failed\""));
|
||||||
|
assert!(spawn_error_manifest.contains("thread creation failed"));
|
||||||
|
|
||||||
|
std::env::remove_var("CLAWD_AGENT_STORE");
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_tool_subset_mapping_is_expected() {
|
||||||
|
let general = allowed_tools_for_subagent("general-purpose");
|
||||||
|
assert!(general.contains("bash"));
|
||||||
|
assert!(general.contains("write_file"));
|
||||||
|
assert!(!general.contains("Agent"));
|
||||||
|
|
||||||
|
let explore = allowed_tools_for_subagent("Explore");
|
||||||
|
assert!(explore.contains("read_file"));
|
||||||
|
assert!(explore.contains("grep_search"));
|
||||||
|
assert!(!explore.contains("bash"));
|
||||||
|
|
||||||
|
let plan = allowed_tools_for_subagent("Plan");
|
||||||
|
assert!(plan.contains("TodoWrite"));
|
||||||
|
assert!(plan.contains("StructuredOutput"));
|
||||||
|
assert!(!plan.contains("Agent"));
|
||||||
|
|
||||||
|
let verification = allowed_tools_for_subagent("Verification");
|
||||||
|
assert!(verification.contains("bash"));
|
||||||
|
assert!(verification.contains("PowerShell"));
|
||||||
|
assert!(!verification.contains("write_file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct MockSubagentApiClient {
|
||||||
|
calls: usize,
|
||||||
|
input_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl runtime::ApiClient for MockSubagentApiClient {
|
||||||
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||||
|
self.calls += 1;
|
||||||
|
match self.calls {
|
||||||
|
1 => {
|
||||||
|
assert_eq!(request.messages.len(), 1);
|
||||||
|
Ok(vec![
|
||||||
|
AssistantEvent::ToolUse {
|
||||||
|
id: "tool-1".to_string(),
|
||||||
|
name: "read_file".to_string(),
|
||||||
|
input: json!({ "path": self.input_path }).to_string(),
|
||||||
|
},
|
||||||
|
AssistantEvent::MessageStop,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
assert!(request.messages.len() >= 3);
|
||||||
|
Ok(vec![
|
||||||
|
AssistantEvent::TextDelta("Scope: completed mock review".to_string()),
|
||||||
|
AssistantEvent::MessageStop,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
_ => panic!("unexpected mock stream call"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subagent_runtime_executes_tool_loop_with_isolated_session() {
|
||||||
|
let _guard = env_lock()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let path = temp_path("subagent-input.txt");
|
||||||
|
std::fs::write(&path, "hello from child").expect("write input file");
|
||||||
|
|
||||||
|
let mut runtime = ConversationRuntime::new(
|
||||||
|
Session::new(),
|
||||||
|
MockSubagentApiClient {
|
||||||
|
calls: 0,
|
||||||
|
input_path: path.display().to_string(),
|
||||||
|
},
|
||||||
|
SubagentToolExecutor::new(BTreeSet::from([String::from("read_file")])),
|
||||||
|
agent_permission_policy(),
|
||||||
|
vec![String::from("system prompt")],
|
||||||
|
);
|
||||||
|
|
||||||
|
let summary = runtime
|
||||||
|
.run_turn("Inspect the delegated file", None)
|
||||||
|
.expect("subagent loop should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
final_assistant_text(&summary),
|
||||||
|
"Scope: completed mock review"
|
||||||
|
);
|
||||||
|
assert!(runtime
|
||||||
|
.session()
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.flat_map(|message| message.blocks.iter())
|
||||||
|
.any(|block| matches!(
|
||||||
|
block,
|
||||||
|
runtime::ContentBlock::ToolResult { output, .. }
|
||||||
|
if output.contains("hello from child")
|
||||||
|
)));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn agent_rejects_blank_required_fields() {
|
fn agent_rejects_blank_required_fields() {
|
||||||
let missing_description = execute_tool(
|
let missing_description = execute_tool(
|
||||||
|
|||||||
Reference in New Issue
Block a user