harness-tools + app: task tool, subagent spawner, board wiring (M4)
The `task` tool delegates to a SubagentSpawner (owned by the composition root): resolves the agent, enforces the depth limit, applies permission intersection, filters tools per-agent, and runs the child session foreground or background. Foreground returns the child's final text; background registers on the job board and detaches under the parent run token. The run loop injects the board into primary-agent requests and reconciles terminal jobs each step. Integration tests cover foreground run + alias reuse continuing the same child session, depth-limit and unknown-agent rejection, and board prompt injection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,10 +18,16 @@ use crate::types::ModelRef;
|
||||
const SUBAGENTS_MARKER: &str = "{{SUBAGENTS}}";
|
||||
|
||||
const BUNDLED: &[(&str, &str)] = &[
|
||||
("orchestrator", include_str!("../../assets/agents/orchestrator.md")),
|
||||
(
|
||||
"orchestrator",
|
||||
include_str!("../../assets/agents/orchestrator.md"),
|
||||
),
|
||||
("explorer", include_str!("../../assets/agents/explorer.md")),
|
||||
("oracle", include_str!("../../assets/agents/oracle.md")),
|
||||
("librarian", include_str!("../../assets/agents/librarian.md")),
|
||||
(
|
||||
"librarian",
|
||||
include_str!("../../assets/agents/librarian.md"),
|
||||
),
|
||||
("fixer", include_str!("../../assets/agents/fixer.md")),
|
||||
("designer", include_str!("../../assets/agents/designer.md")),
|
||||
];
|
||||
@@ -107,7 +113,10 @@ fn parse_model_ref(s: &str) -> Option<ModelRef> {
|
||||
/// Splits a markdown agent file into (frontmatter, body). A file without a leading `---`
|
||||
/// fence is treated as an all-body prompt with empty frontmatter.
|
||||
fn split_frontmatter(content: &str) -> (&str, &str) {
|
||||
let rest = match content.strip_prefix("---\n").or_else(|| content.strip_prefix("---\r\n")) {
|
||||
let rest = match content
|
||||
.strip_prefix("---\n")
|
||||
.or_else(|| content.strip_prefix("---\r\n"))
|
||||
{
|
||||
Some(r) => r,
|
||||
None => return ("", content),
|
||||
};
|
||||
@@ -149,7 +158,11 @@ fn parse_agent(
|
||||
Ok(Some(AgentDef {
|
||||
name: name.to_string(),
|
||||
description: fm.description.unwrap_or_default(),
|
||||
mode: fm.mode.as_deref().and_then(AgentMode::parse).unwrap_or_default(),
|
||||
mode: fm
|
||||
.mode
|
||||
.as_deref()
|
||||
.and_then(AgentMode::parse)
|
||||
.unwrap_or_default(),
|
||||
model: fm.model.as_deref().and_then(parse_model_ref),
|
||||
temperature: fm.temperature,
|
||||
prompt: body.trim_end().to_string(),
|
||||
@@ -207,7 +220,10 @@ impl AgentRegistry {
|
||||
/// Just the bundled agents — the default when no overrides are configured (tests, headless).
|
||||
pub fn bundled() -> Self {
|
||||
let mut reg = Self::default();
|
||||
reg.load_markdown_layer(BUNDLED.iter().map(|(n, c)| (n.to_string(), *c)), AgentSource::Bundled);
|
||||
reg.load_markdown_layer(
|
||||
BUNDLED.iter().map(|(n, c)| (n.to_string(), *c)),
|
||||
AgentSource::Bundled,
|
||||
);
|
||||
reg.generate_routing();
|
||||
reg
|
||||
}
|
||||
@@ -338,7 +354,14 @@ mod tests {
|
||||
#[test]
|
||||
fn bundled_loads_all_six_agents() {
|
||||
let reg = AgentRegistry::bundled();
|
||||
for name in ["orchestrator", "explorer", "oracle", "librarian", "fixer", "designer"] {
|
||||
for name in [
|
||||
"orchestrator",
|
||||
"explorer",
|
||||
"oracle",
|
||||
"librarian",
|
||||
"fixer",
|
||||
"designer",
|
||||
] {
|
||||
assert!(reg.get(name).is_some(), "missing {name}");
|
||||
}
|
||||
assert_eq!(reg.len(), 6);
|
||||
@@ -356,10 +379,15 @@ mod tests {
|
||||
\x20 - { permission: \"edit\", pattern: \"*\", action: deny }\n\
|
||||
---\n\
|
||||
You are a test agent.\n";
|
||||
let def = parse_agent("tester", AgentSource::Bundled, md).unwrap().unwrap();
|
||||
let def = parse_agent("tester", AgentSource::Bundled, md)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(def.description, "test agent");
|
||||
assert_eq!(def.mode, AgentMode::Subagent);
|
||||
assert_eq!(def.model, Some(ModelRef::new("anthropic", "claude-haiku-4-5")));
|
||||
assert_eq!(
|
||||
def.model,
|
||||
Some(ModelRef::new("anthropic", "claude-haiku-4-5"))
|
||||
);
|
||||
assert_eq!(def.temperature, Some(0.1));
|
||||
assert_eq!(def.tools.get("write"), Some(&false));
|
||||
assert_eq!(def.tools.get("bash"), Some(&true));
|
||||
@@ -379,9 +407,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn disable_true_removes_agent() {
|
||||
assert!(parse_agent("x", AgentSource::Config, "---\ndisable: true\n---\nbody")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(
|
||||
parse_agent("x", AgentSource::Config, "---\ndisable: true\n---\nbody")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -413,15 +413,24 @@ mod tests {
|
||||
async fn alias_increments_per_agent() {
|
||||
let (_store, board, parent) = board(2).await;
|
||||
let a1 = board
|
||||
.register_launch(spec("t1", parent.clone(), SessionId::new(), "explorer", None), 1)
|
||||
.register_launch(
|
||||
spec("t1", parent.clone(), SessionId::new(), "explorer", None),
|
||||
1,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let a2 = board
|
||||
.register_launch(spec("t2", parent.clone(), SessionId::new(), "explorer", None), 2)
|
||||
.register_launch(
|
||||
spec("t2", parent.clone(), SessionId::new(), "explorer", None),
|
||||
2,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let f1 = board
|
||||
.register_launch(spec("t3", parent.clone(), SessionId::new(), "fixer", None), 3)
|
||||
.register_launch(
|
||||
spec("t3", parent.clone(), SessionId::new(), "fixer", None),
|
||||
3,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(a1, "exp-1");
|
||||
@@ -492,7 +501,13 @@ mod tests {
|
||||
for (i, ts) in [(1, 10), (2, 20), (3, 30)] {
|
||||
board
|
||||
.register_launch(
|
||||
spec(&format!("t{i}"), parent.clone(), SessionId::new(), "explorer", None),
|
||||
spec(
|
||||
&format!("t{i}"),
|
||||
parent.clone(),
|
||||
SessionId::new(),
|
||||
"explorer",
|
||||
None,
|
||||
),
|
||||
ts,
|
||||
)
|
||||
.await
|
||||
@@ -543,7 +558,10 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
board
|
||||
.register_launch(spec("t1", parent.clone(), SessionId::new(), "explorer", None), 1)
|
||||
.register_launch(
|
||||
spec("t1", parent.clone(), SessionId::new(), "explorer", None),
|
||||
1,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ pub struct RunConfig {
|
||||
pub instructions: Vec<String>,
|
||||
/// Pricing for `model`, from models.dev metadata. `None` leaves cost at 0.
|
||||
pub cost: Option<crate::types::ModelCost>,
|
||||
/// Whether to append the background job board to requests (primary/delegating agents).
|
||||
pub inject_job_board: bool,
|
||||
}
|
||||
|
||||
/// Adds a step's usage/cost onto the persisted session and republishes it. Cost accounting is
|
||||
@@ -198,6 +200,22 @@ pub async fn run_session(
|
||||
wire_messages.extend(convert_message(message, &parts));
|
||||
}
|
||||
|
||||
// Append the (synthetic, non-persisted) job board to the last user message so the
|
||||
// orchestrator sees running/reusable subtasks. docs/04-multiagent.md.
|
||||
if run_config.inject_job_board {
|
||||
if let Some(board) = &ctx.job_board {
|
||||
if let Some(block) = board.format_for_prompt() {
|
||||
if let Some(last_user) = wire_messages
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|m| m.role == WireRole::User)
|
||||
{
|
||||
last_user.content.push(WireContent::Text { text: block });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let system_blocks = system::assemble(
|
||||
system::env_header(&ctx.cwd),
|
||||
&run_config.agent_prompt,
|
||||
@@ -260,6 +278,15 @@ pub async fn run_session(
|
||||
}
|
||||
Ok(outcome) => {
|
||||
accumulate_session_usage(&ctx, &outcome.usage, outcome.cost, now_fn()).await;
|
||||
// A completed step means the orchestrator has now seen any terminal jobs
|
||||
// that were on the board this turn; mark them reconciled.
|
||||
if run_config.inject_job_board {
|
||||
if let Some(board) = &ctx.job_board {
|
||||
if let Err(e) = board.reconcile_terminal(now_fn()).await {
|
||||
tracing::warn!(error = %e, "failed to reconcile job board");
|
||||
}
|
||||
}
|
||||
}
|
||||
match outcome.result {
|
||||
StepResult::Continue => continue,
|
||||
StepResult::Stop => return RunOutcome::Stopped,
|
||||
@@ -376,11 +403,14 @@ mod tests {
|
||||
permissions,
|
||||
static_rules: Vec::new(),
|
||||
extra_rules: Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
parent_rules: Vec::new(),
|
||||
session_id,
|
||||
cwd: cwd.clone(),
|
||||
data_dir: cwd.join("tool-output"),
|
||||
cancel: CancellationToken::new(),
|
||||
now: 1,
|
||||
spawner: None,
|
||||
job_board: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +492,7 @@ mod tests {
|
||||
output: 15.0,
|
||||
..Default::default()
|
||||
}),
|
||||
inject_job_board: false,
|
||||
};
|
||||
|
||||
let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await;
|
||||
@@ -569,6 +600,7 @@ mod tests {
|
||||
max_steps: 10,
|
||||
instructions: Vec::new(),
|
||||
cost: None,
|
||||
inject_job_board: false,
|
||||
};
|
||||
|
||||
let outcome = run_session(Arc::new(provider), ctx, &run_config, || 2).await;
|
||||
@@ -578,4 +610,128 @@ mod tests {
|
||||
let messages = store.messages(session_id).await.unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
}
|
||||
|
||||
/// Records the last request it was asked to stream so tests can assert on prompt content.
|
||||
struct CapturingProvider {
|
||||
last: StdMutex<Option<LlmRequest>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for CapturingProvider {
|
||||
fn id(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn stream(
|
||||
&self,
|
||||
req: LlmRequest,
|
||||
_cancel: CancellationToken,
|
||||
) -> Result<LlmEventStream, ProviderError> {
|
||||
*self.last.lock().unwrap() = Some(req);
|
||||
let events = vec![
|
||||
Ok(LlmEvent::TextStart { id: "t".into() }),
|
||||
Ok(LlmEvent::TextDelta {
|
||||
id: "t".into(),
|
||||
text: "ok".into(),
|
||||
}),
|
||||
Ok(LlmEvent::TextEnd { id: "t".into() }),
|
||||
Ok(LlmEvent::Finish {
|
||||
reason: FinishReason::Stop,
|
||||
usage: usage(1, 1),
|
||||
}),
|
||||
];
|
||||
Ok(Box::pin(futures::stream::iter(events)))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_board_is_injected_into_the_last_user_message() {
|
||||
use crate::engine::jobs::{JobBoard, LaunchSpec};
|
||||
|
||||
let store = Store::open_in_memory().unwrap();
|
||||
let bus = EventBus::new();
|
||||
let model = ModelRef::new("mock", "mock-model");
|
||||
let session = Session::new_root("orchestrator", model.clone(), 1);
|
||||
let session_id = session.id.clone();
|
||||
store.upsert_session(session).await.unwrap();
|
||||
|
||||
let user_message = Message::new_user(session_id.clone(), 1);
|
||||
store.upsert_message(user_message.clone()).await.unwrap();
|
||||
store
|
||||
.upsert_part(Part {
|
||||
id: crate::types::PartId::new(),
|
||||
message_id: user_message.id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
idx: 0,
|
||||
body: PartBody::Text {
|
||||
text: "carry on".into(),
|
||||
synthetic: false,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// A board with one running job for this session.
|
||||
let board = std::sync::Arc::new(
|
||||
JobBoard::load(store.clone(), bus.clone(), &session_id, 2)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
board
|
||||
.register_launch(
|
||||
LaunchSpec {
|
||||
task_id: "t1".into(),
|
||||
parent_session: session_id.clone(),
|
||||
child_session: SessionId::new(),
|
||||
agent: "explorer".into(),
|
||||
description: "map auth".into(),
|
||||
objective: Some("map the auth flow".into()),
|
||||
},
|
||||
1,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cwd = tempfile::tempdir().unwrap();
|
||||
let mut ctx = make_ctx(store, bus, session_id, cwd.path().to_path_buf()).await;
|
||||
ctx.job_board = Some(board);
|
||||
let run_config = RunConfig {
|
||||
agent_name: "orchestrator".into(),
|
||||
agent_prompt: "You orchestrate.".into(),
|
||||
model,
|
||||
temperature: None,
|
||||
max_steps: 1,
|
||||
instructions: Vec::new(),
|
||||
cost: None,
|
||||
inject_job_board: true,
|
||||
};
|
||||
|
||||
let provider = std::sync::Arc::new(CapturingProvider {
|
||||
last: StdMutex::new(None),
|
||||
});
|
||||
let outcome = run_session(provider.clone(), ctx, &run_config, || 2).await;
|
||||
assert!(matches!(outcome, RunOutcome::Stopped));
|
||||
|
||||
let req = provider.last.lock().unwrap().clone().expect("a request");
|
||||
let last_user = req
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.role == WireRole::User)
|
||||
.expect("a user message");
|
||||
let text: String = last_user
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
WireContent::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(text.contains("Background Job Board"), "got: {text}");
|
||||
assert!(text.contains("exp-1"), "got: {text}");
|
||||
assert!(text.contains("map the auth flow"), "got: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ use crate::event::{AppEvent, EventBus};
|
||||
use crate::llm::{FinishReason, LlmEvent, LlmEventStream, ProviderError};
|
||||
use crate::permission::{PermissionService, Ruleset};
|
||||
use crate::store::Store;
|
||||
use crate::tool::{MetadataSink, PermissionHandle, Tool, ToolCtx, ToolError, ToolRegistry};
|
||||
use crate::tool::{
|
||||
MetadataSink, PermissionHandle, SubagentSpawner, Tool, ToolCtx, ToolError, ToolRegistry,
|
||||
};
|
||||
use crate::types::{Message, MessageId, Part, PartBody, PartId, SessionId, TokenUsage, ToolState};
|
||||
|
||||
use super::doomloop::DoomLoopGuard;
|
||||
use super::jobs::JobBoard;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StepResult {
|
||||
@@ -47,6 +50,9 @@ pub struct StepContext {
|
||||
pub permissions: Arc<PermissionService>,
|
||||
pub static_rules: Ruleset,
|
||||
pub extra_rules: Arc<Mutex<Ruleset>>,
|
||||
/// Parent-effective ruleset for a subagent session; empty for a root session. Enables
|
||||
/// permission intersection on this session's tool calls.
|
||||
pub parent_rules: Ruleset,
|
||||
pub session_id: SessionId,
|
||||
pub cwd: PathBuf,
|
||||
/// Session's `tool-output` spill directory (see `tool::truncate`).
|
||||
@@ -54,6 +60,11 @@ pub struct StepContext {
|
||||
pub cancel: CancellationToken,
|
||||
/// Wall-clock for `created_at` stamps — passed in so tests stay deterministic.
|
||||
pub now: i64,
|
||||
/// Lets the `task` tool spawn subagents. `None` disables delegation (headless/tests).
|
||||
pub spawner: Option<Arc<dyn SubagentSpawner>>,
|
||||
/// This session's background job board (as a parent). Injected into requests when the
|
||||
/// running agent can delegate; `None` disables the board.
|
||||
pub job_board: Option<Arc<JobBoard>>,
|
||||
}
|
||||
|
||||
struct FlushTracker {
|
||||
@@ -483,7 +494,8 @@ impl<'a> Run<'a> {
|
||||
self.ctx.static_rules.clone(),
|
||||
self.ctx.extra_rules.clone(),
|
||||
call_cancel.clone(),
|
||||
);
|
||||
)
|
||||
.with_parent_rules(self.ctx.parent_rules.clone());
|
||||
let tool_ctx = ToolCtx {
|
||||
session_id: self.ctx.session_id.clone(),
|
||||
message_id: self.message_id(),
|
||||
@@ -493,6 +505,7 @@ impl<'a> Run<'a> {
|
||||
cancel: call_cancel.clone(),
|
||||
ask,
|
||||
metadata: metadata_sink,
|
||||
spawner: self.ctx.spawner.clone(),
|
||||
};
|
||||
|
||||
let result = tokio::select! {
|
||||
|
||||
@@ -8,7 +8,7 @@ use ulid::Ulid;
|
||||
use crate::event::{AppEvent, EventBus, PermissionRequest};
|
||||
use crate::types::SessionId;
|
||||
|
||||
use super::rule::{evaluate, Action, Rule, Ruleset};
|
||||
use super::rule::{evaluate, evaluate_intersected, Action, Rule, Ruleset};
|
||||
|
||||
pub struct AskInput {
|
||||
pub permission: String,
|
||||
@@ -73,6 +73,24 @@ impl PermissionService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`ask`](Self::ask), but for a subagent: the verdict is the more restrictive of
|
||||
/// the `parent_stack` (rules inherited from the spawning chain) and `child_stack` (the
|
||||
/// subagent's own rules). Used so a child can never widen what its parent forbids.
|
||||
pub async fn ask_intersected(
|
||||
&self,
|
||||
session_id: &SessionId,
|
||||
parent_stack: &[&Ruleset],
|
||||
child_stack: &[&Ruleset],
|
||||
input: AskInput,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<AskDecision, AskError> {
|
||||
match evaluate_intersected(parent_stack, child_stack, &input.permission, &input.pattern) {
|
||||
Action::Allow => Ok(AskDecision::Allowed),
|
||||
Action::Deny => Err(AskError::Denied),
|
||||
Action::Ask => self.ask_user(session_id, input, cancel).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bypasses ruleset evaluation entirely — used by the doom-loop guard, which must ask
|
||||
/// regardless of any `Allow` rule.
|
||||
pub async fn force_ask(
|
||||
|
||||
@@ -104,10 +104,7 @@ impl Store {
|
||||
self.call(|reply| StoreCmd::DeleteJob(task_id, reply)).await
|
||||
}
|
||||
|
||||
pub async fn jobs_for_parent(
|
||||
&self,
|
||||
parent: SessionId,
|
||||
) -> Result<Vec<JobRecord>, StoreError> {
|
||||
pub async fn jobs_for_parent(&self, parent: SessionId) -> Result<Vec<JobRecord>, StoreError> {
|
||||
self.call(|reply| StoreCmd::JobsForParent(parent, reply))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -10,6 +10,50 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::permission::{AskDecision, AskError, AskInput, PermissionService, Ruleset};
|
||||
use crate::types::{MessageId, SessionId};
|
||||
|
||||
/// A request from the `task` tool to run a subagent. The spawner (owned by the composition
|
||||
/// root) resolves the agent, enforces the depth limit, applies permission intersection, and
|
||||
/// runs the child session foreground or background. See `docs/04-multiagent.md`.
|
||||
pub struct SpawnRequest {
|
||||
pub parent_session_id: SessionId,
|
||||
pub parent_message_id: MessageId,
|
||||
pub agent: String,
|
||||
pub description: String,
|
||||
pub prompt: String,
|
||||
/// Alias or task id of a completed job to reuse (continue its child session).
|
||||
pub reuse_task_id: Option<String>,
|
||||
pub background: bool,
|
||||
/// The tool call's cancellation token — used for foreground child runs. Background runs
|
||||
/// are childed from the parent session's run token by the spawner instead.
|
||||
pub cancel: CancellationToken,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SpawnOutcome {
|
||||
pub child_session_id: SessionId,
|
||||
pub background: bool,
|
||||
/// Board alias assigned to a background launch.
|
||||
pub alias: Option<String>,
|
||||
/// Final assistant text of a foreground run.
|
||||
pub final_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SpawnError {
|
||||
#[error("unknown subagent {0:?}, or it is not usable as a subagent")]
|
||||
InvalidAgent(String),
|
||||
#[error("subagent depth limit reached — do this work yourself instead of delegating further")]
|
||||
DepthExceeded,
|
||||
#[error("cannot reuse {0:?}: no completed job with that alias for this session")]
|
||||
ReuseNotFound(String),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait SubagentSpawner: Send + Sync {
|
||||
async fn spawn(&self, req: SpawnRequest) -> Result<SpawnOutcome, SpawnError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ToolError {
|
||||
#[error("permission denied")]
|
||||
@@ -60,6 +104,9 @@ pub struct PermissionHandle {
|
||||
session_id: SessionId,
|
||||
static_rules: Ruleset,
|
||||
extra_rules: Arc<Mutex<Ruleset>>,
|
||||
/// Parent-effective ruleset for a subagent session; empty for a root session. When
|
||||
/// non-empty, verdicts are intersected so a child can only ever be *more* restricted.
|
||||
parent_rules: Ruleset,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
@@ -76,10 +123,17 @@ impl PermissionHandle {
|
||||
session_id,
|
||||
static_rules,
|
||||
extra_rules,
|
||||
parent_rules: Vec::new(),
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the parent-effective ruleset so this handle intersects verdicts (subagent runs).
|
||||
pub fn with_parent_rules(mut self, parent_rules: Ruleset) -> Self {
|
||||
self.parent_rules = parent_rules;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn ask(
|
||||
&self,
|
||||
permission: impl Into<String>,
|
||||
@@ -88,21 +142,29 @@ impl PermissionHandle {
|
||||
metadata: serde_json::Value,
|
||||
) -> Result<(), ToolError> {
|
||||
let extra_snapshot = self.extra_rules.lock().unwrap().clone();
|
||||
let stack: [&Ruleset; 2] = [&self.static_rules, &extra_snapshot];
|
||||
let decision = self
|
||||
.service
|
||||
.ask(
|
||||
&self.session_id,
|
||||
&stack,
|
||||
AskInput {
|
||||
permission: permission.into(),
|
||||
pattern: pattern.into(),
|
||||
always_pattern: always_pattern.into(),
|
||||
metadata,
|
||||
},
|
||||
&self.cancel,
|
||||
)
|
||||
.await?;
|
||||
let child_stack: [&Ruleset; 2] = [&self.static_rules, &extra_snapshot];
|
||||
let input = AskInput {
|
||||
permission: permission.into(),
|
||||
pattern: pattern.into(),
|
||||
always_pattern: always_pattern.into(),
|
||||
metadata,
|
||||
};
|
||||
let decision = if self.parent_rules.is_empty() {
|
||||
self.service
|
||||
.ask(&self.session_id, &child_stack, input, &self.cancel)
|
||||
.await?
|
||||
} else {
|
||||
let parent_stack: [&Ruleset; 1] = [&self.parent_rules];
|
||||
self.service
|
||||
.ask_intersected(
|
||||
&self.session_id,
|
||||
&parent_stack,
|
||||
&child_stack,
|
||||
input,
|
||||
&self.cancel,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
if let AskDecision::AllowedAlways(rule) = decision {
|
||||
self.extra_rules.lock().unwrap().push(rule);
|
||||
}
|
||||
@@ -120,6 +182,9 @@ pub struct ToolCtx {
|
||||
pub cancel: CancellationToken,
|
||||
pub ask: PermissionHandle,
|
||||
pub metadata: MetadataSink,
|
||||
/// Present when the engine can spawn subagents (the `task` tool's capability). `None`
|
||||
/// in headless/test contexts with no orchestration wired in.
|
||||
pub spawner: Option<Arc<dyn SubagentSpawner>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
Reference in New Issue
Block a user