Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
25
crates/codegen/xai-grok-subagent-resolution/Cargo.toml
Normal file
25
crates/codegen/xai-grok-subagent-resolution/Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
license = "Apache-2.0"
|
||||
name = "xai-grok-subagent-resolution"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Subagent configuration resolution: merges persona, role, and spawn-time overrides into a resolved spec"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
xai-grok-sampling-types = { path = "../xai-grok-sampling-types" }
|
||||
xai-grok-tools = { path = "../xai-grok-tools" }
|
||||
xai-tool-types.workspace = true
|
||||
# TODO(phase2): add these when resolve_subagent_spec() composition is implemented:
|
||||
# xai-grok-agent = { path = "../xai-grok-agent" } # AgentDefinition lookup
|
||||
# xai-fast-worktree = { path = "../xai-fast-worktree" } # worktree creation
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
357
crates/codegen/xai-grok-subagent-resolution/src/config.rs
Normal file
357
crates/codegen/xai-grok-subagent-resolution/src/config.rs
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
//! Subagent role and persona configuration types.
|
||||
//!
|
||||
//! These are the canonical definitions for `SubagentRole`, `SubagentPersona`,
|
||||
//! and `PersonaIOField`. The shell re-exports them via
|
||||
//! `xai_grok_shell::config::{SubagentRole, SubagentPersona, PersonaIOField}`.
|
||||
//!
|
||||
//! Methods that remain in `xai-grok-shell` (on `SubagentsConfig`):
|
||||
//! - `discover_personas()` / `discover_roles()` — filesystem discovery
|
||||
//! coupled to the shell's config resolution pipeline.
|
||||
//! - `resolve()` — config layering (CLI > env > TOML > remote) is
|
||||
//! shell-specific. This crate receives already-resolved maps.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use xai_grok_tools::implementations::skills::discovery::extract_first_paragraph;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// A declarative subagent role definition from config.
|
||||
///
|
||||
/// Roles provide named presets that callers can reference via the
|
||||
/// `subagent_type` field in the task tool. Each role can specify
|
||||
/// a default capability mode, model override, and custom prompt.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SubagentRole {
|
||||
/// Human-readable description of what this role does.
|
||||
pub description: String,
|
||||
/// Default capability mode for agents using this role.
|
||||
/// One of: "read-only", "read-write", "execute", "all".
|
||||
/// Can be overridden per-spawn via `capability_mode` in the task tool.
|
||||
#[serde(default)]
|
||||
pub default_capability_mode: Option<String>,
|
||||
/// Model override for this role. If set, agents using this role
|
||||
/// default to this model unless the spawn-time `model` override
|
||||
/// is provided.
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
/// Default reasoning effort for this role (e.g. "low", "medium", "high").
|
||||
/// Can be overridden per-spawn via `reasoning_effort` in the task tool.
|
||||
#[serde(default)]
|
||||
pub reasoning_effort: Option<String>,
|
||||
/// Path to a prompt/instruction file (relative to workspace root).
|
||||
/// Loaded at spawn time and prepended to the child's prompt as a
|
||||
/// `<role-instructions>` block.
|
||||
#[serde(default)]
|
||||
pub prompt_file: Option<String>,
|
||||
/// Default isolation mode ("none" or "worktree").
|
||||
#[serde(default)]
|
||||
pub default_isolation: Option<String>,
|
||||
/// Base directory for resolving relative `prompt_file` references.
|
||||
/// Set to the parent dir of the source `.toml` file during discovery.
|
||||
#[serde(skip)]
|
||||
pub source_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// A named persona/SOUL definition controlling tone, style, and behavior.
|
||||
///
|
||||
/// Personas are referenced by name via the `persona` field in the task tool.
|
||||
/// Their instructions are prepended to the child's prompt as a `<persona>`
|
||||
/// XML block.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SubagentPersona {
|
||||
/// Inline instruction text applied as a persona layer.
|
||||
pub instructions: Option<String>,
|
||||
/// Optional short description shown in persona summaries.
|
||||
/// Falls back to first-paragraph extraction from `instructions`.
|
||||
pub description: Option<String>,
|
||||
/// Path to an instruction file (relative to workspace root).
|
||||
/// Content is loaded at spawn time and merged with `instructions`.
|
||||
/// If both are set, `instructions` is prepended before file content.
|
||||
pub instructions_file: Option<String>,
|
||||
/// Declared inputs this persona expects. The parent agent reads these
|
||||
/// to know what file paths or context to provide in the prompt.
|
||||
#[serde(default)]
|
||||
pub inputs: Vec<PersonaIOField>,
|
||||
/// Declared outputs this persona produces. The parent agent reads
|
||||
/// these to know what artifacts to expect and pass to the next agent.
|
||||
#[serde(default)]
|
||||
pub outputs: Vec<PersonaIOField>,
|
||||
/// Default isolation mode when this persona is used.
|
||||
#[serde(default)]
|
||||
pub default_isolation: Option<String>,
|
||||
/// Model override when this persona is used.
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
/// Default reasoning effort for this persona (e.g. "low", "medium", "high").
|
||||
#[serde(default)]
|
||||
pub reasoning_effort: Option<String>,
|
||||
/// Base directory for resolving relative file references.
|
||||
/// Set to the parent dir of the source `.toml` file during discovery.
|
||||
/// When `None`, relative paths resolve against the workspace cwd.
|
||||
#[serde(skip)]
|
||||
pub source_dir: Option<PathBuf>,
|
||||
/// Absolute path to the source file this persona was loaded from.
|
||||
/// Populated during discovery; `None` for inline config personas.
|
||||
#[serde(skip)]
|
||||
pub source_path: Option<String>,
|
||||
}
|
||||
|
||||
/// A declared input or output for a persona.
|
||||
///
|
||||
/// Enables the parent agent to discover what a persona needs (inputs)
|
||||
/// and what it produces (outputs) without hardcoded knowledge of the
|
||||
/// persona's protocol.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, serde::Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PersonaIOField {
|
||||
/// Short identifier (e.g. "review_file", "summary_file").
|
||||
pub name: String,
|
||||
/// What kind of artifact: "file", "text", etc.
|
||||
#[serde(default = "PersonaIOField::default_io_type")]
|
||||
pub io_type: String,
|
||||
/// Whether this input/output is required.
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
/// Human-readable description shown in the task tool help.
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl PersonaIOField {
|
||||
fn default_io_type() -> String {
|
||||
"file".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl SubagentPersona {
|
||||
/// Render a human-readable summary of this persona's IO contract
|
||||
/// for inclusion in the task tool description.
|
||||
pub fn render_io_summary(&self, name: &str) -> String {
|
||||
let fallback;
|
||||
let desc = if let Some(d) = self.description.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||
d
|
||||
} else {
|
||||
fallback = self
|
||||
.instructions
|
||||
.as_deref()
|
||||
.and_then(extract_first_paragraph);
|
||||
fallback.as_deref().unwrap_or("Custom persona")
|
||||
};
|
||||
let scope = match self.source_path.as_deref() {
|
||||
Some(path) if path.contains("/bundled/") => "[bundled]",
|
||||
Some(_) => "[user]",
|
||||
None => "[local]",
|
||||
};
|
||||
let mut lines = vec![format!("- **{name}** {scope}: {desc}")];
|
||||
if let Some(ref path) = self.source_path {
|
||||
lines.push(format!(" Path: {path}"));
|
||||
}
|
||||
if !self.inputs.is_empty() {
|
||||
lines.push(" Expects in prompt:".to_string());
|
||||
for io in &self.inputs {
|
||||
let req = if io.required { "REQUIRED" } else { "optional" };
|
||||
lines.push(format!(
|
||||
" - `{}` ({}, {}): {}",
|
||||
io.name, io.io_type, req, io.description
|
||||
));
|
||||
}
|
||||
}
|
||||
if !self.outputs.is_empty() {
|
||||
lines.push(" Produces:".to_string());
|
||||
for io in &self.outputs {
|
||||
let req = if io.required { "REQUIRED" } else { "optional" };
|
||||
lines.push(format!(
|
||||
" - `{}` ({}, {}): {}",
|
||||
io.name, io.io_type, req, io.description
|
||||
));
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn subagent_role_deserialize_defaults() {
|
||||
let role: SubagentRole = toml::from_str("").unwrap();
|
||||
assert_eq!(role.description, "");
|
||||
assert!(role.default_capability_mode.is_none());
|
||||
assert!(role.model.is_none());
|
||||
assert!(role.prompt_file.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_role_deserialize_full() {
|
||||
let toml_str = r#"
|
||||
description = "Research agent"
|
||||
default_capability_mode = "read-only"
|
||||
model = "grok-3"
|
||||
reasoning_effort = "high"
|
||||
prompt_file = ".grok/prompts/researcher.md"
|
||||
default_isolation = "worktree"
|
||||
"#;
|
||||
let role: SubagentRole = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(role.description, "Research agent");
|
||||
assert_eq!(role.default_capability_mode.as_deref(), Some("read-only"));
|
||||
assert_eq!(role.model.as_deref(), Some("grok-3"));
|
||||
assert_eq!(role.reasoning_effort.as_deref(), Some("high"));
|
||||
assert_eq!(
|
||||
role.prompt_file.as_deref(),
|
||||
Some(".grok/prompts/researcher.md")
|
||||
);
|
||||
assert_eq!(role.default_isolation.as_deref(), Some("worktree"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_persona_deserialize_defaults() {
|
||||
let persona: SubagentPersona = toml::from_str("").unwrap();
|
||||
assert!(persona.instructions.is_none());
|
||||
assert!(persona.description.is_none());
|
||||
assert!(persona.instructions_file.is_none());
|
||||
assert!(persona.inputs.is_empty());
|
||||
assert!(persona.outputs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_persona_deserialize_full() {
|
||||
let toml_str = r#"
|
||||
instructions = "You are a concise writer."
|
||||
description = "A concise writing persona."
|
||||
instructions_file = ".grok/personas/concise.md"
|
||||
model = "grok-3-fast"
|
||||
reasoning_effort = "low"
|
||||
default_isolation = "none"
|
||||
|
||||
[[inputs]]
|
||||
name = "review_file"
|
||||
io_type = "file"
|
||||
required = true
|
||||
description = "Path to the review notes file"
|
||||
|
||||
[[outputs]]
|
||||
name = "summary_file"
|
||||
io_type = "file"
|
||||
required = false
|
||||
description = "Path to write the summary"
|
||||
"#;
|
||||
let persona: SubagentPersona = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(
|
||||
persona.instructions.as_deref(),
|
||||
Some("You are a concise writer.")
|
||||
);
|
||||
assert_eq!(
|
||||
persona.instructions_file.as_deref(),
|
||||
Some(".grok/personas/concise.md")
|
||||
);
|
||||
assert_eq!(persona.model.as_deref(), Some("grok-3-fast"));
|
||||
assert_eq!(persona.reasoning_effort.as_deref(), Some("low"));
|
||||
assert_eq!(persona.inputs.len(), 1);
|
||||
assert_eq!(persona.inputs[0].name, "review_file");
|
||||
assert!(persona.inputs[0].required);
|
||||
assert_eq!(persona.outputs.len(), 1);
|
||||
assert_eq!(persona.outputs[0].name, "summary_file");
|
||||
assert!(!persona.outputs[0].required);
|
||||
assert_eq!(
|
||||
persona.description.as_deref(),
|
||||
Some("A concise writing persona.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_io_field_default_io_type_is_file() {
|
||||
let json = r#"{"name": "test", "description": "a test field"}"#;
|
||||
let field: PersonaIOField = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(field.io_type, "file");
|
||||
assert!(!field.required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_io_summary_uses_explicit_description() {
|
||||
let persona = SubagentPersona {
|
||||
description: Some("A focused code reviewer.".to_owned()),
|
||||
instructions: Some("Ignore this line.\nAnd this one.".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = persona.render_io_summary("reviewer");
|
||||
assert!(summary.contains("A focused code reviewer."));
|
||||
assert!(!summary.contains("Ignore this line"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_io_summary_extracts_first_paragraph_from_instructions() {
|
||||
let persona = SubagentPersona {
|
||||
instructions: Some(
|
||||
"You are a meticulous code reviewer. Review code and produce structured review\n\
|
||||
notes in a Markdown file at the path given in the prompt.\n\n\
|
||||
Process:\n1. Read the code."
|
||||
.to_owned(),
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = persona.render_io_summary("reviewer");
|
||||
assert!(
|
||||
summary.contains("You are a meticulous code reviewer. Review code and produce structured review notes in a Markdown file at the path given in the prompt."),
|
||||
"should join multi-line first paragraph: {summary}"
|
||||
);
|
||||
assert!(!summary.contains("Process"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_io_summary_falls_back_to_custom_persona() {
|
||||
let persona = SubagentPersona::default();
|
||||
let summary = persona.render_io_summary("empty");
|
||||
assert!(summary.contains("Custom persona"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_io_summary_extracts_lead_paragraph_before_list() {
|
||||
let persona = SubagentPersona {
|
||||
instructions: Some(
|
||||
"You are a thorough researcher. When exploring a question:\n\
|
||||
- Exhaust all reasonable search avenues before concluding\n\
|
||||
- Always cite specific file paths"
|
||||
.to_owned(),
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = persona.render_io_summary("researcher");
|
||||
assert!(summary.contains("You are a thorough researcher. When exploring a question:"));
|
||||
assert!(!summary.contains("Always cite specific file paths"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_io_summary_headings_only_instructions_falls_back() {
|
||||
let persona = SubagentPersona {
|
||||
instructions: Some("# Heading\n## Sub".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = persona.render_io_summary("test");
|
||||
assert!(summary.contains("Custom persona"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_io_summary_empty_description_falls_through_to_instructions() {
|
||||
let persona = SubagentPersona {
|
||||
description: Some("".to_owned()),
|
||||
instructions: Some("Actual description here.".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = persona.render_io_summary("test");
|
||||
assert!(summary.contains("Actual description here."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_io_summary_whitespace_description_falls_through_to_instructions() {
|
||||
let persona = SubagentPersona {
|
||||
description: Some(" ".to_owned()),
|
||||
instructions: Some("Real content.".to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = persona.render_io_summary("test");
|
||||
assert!(summary.contains("Real content."));
|
||||
}
|
||||
}
|
||||
1073
crates/codegen/xai-grok-subagent-resolution/src/context.rs
Normal file
1073
crates/codegen/xai-grok-subagent-resolution/src/context.rs
Normal file
File diff suppressed because it is too large
Load diff
37
crates/codegen/xai-grok-subagent-resolution/src/lib.rs
Normal file
37
crates/codegen/xai-grok-subagent-resolution/src/lib.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
//! Subagent configuration resolution crate.
|
||||
//!
|
||||
//! Extracts the pure-logic "resolution" phase of subagent spawning from
|
||||
//! `xai-grok-shell` into a reusable library. Given a spawn request and a
|
||||
//! resolution context (roles, personas, parent state), this crate resolves:
|
||||
//!
|
||||
//! - Effective runtime config (model, persona, capability mode, isolation)
|
||||
//! via precedence: explicit override > role > persona > parent.
|
||||
//! - Persona instruction loading (inline `instructions` + `instructions_file`).
|
||||
//! - Role prompt file loading.
|
||||
//! - Resume identity validation (type/persona match checks; model is soft-ignored).
|
||||
//!
|
||||
//! This crate has no dependency on session, coordinator, or transport types.
|
||||
//! Designed to be consumed by local hosts (e.g. `xai-grok-shell`) and any
|
||||
//! future remote spawn path that only needs pure resolution logic.
|
||||
//!
|
||||
//! ## Planned composition API
|
||||
//!
|
||||
//! Future work may add a higher-level composition helper once shell call sites
|
||||
//! are refactored onto this crate:
|
||||
//!
|
||||
//! - `resolve_subagent_spec()` composition function
|
||||
//! - `SubagentSpec`, `ResolveSubagentRequest`, `ResolutionContext` boundary types
|
||||
//! - Optional deps for `AgentDefinition` lookup and worktree creation
|
||||
//! - Model override resolution chain (global > per-type > role > parent)
|
||||
//! - Capability mode filtering (delegates to `SubagentCapabilityMode::filter_tool_config()`)
|
||||
|
||||
pub mod config;
|
||||
pub mod context;
|
||||
pub mod overrides;
|
||||
pub mod resume;
|
||||
pub mod types;
|
||||
|
||||
pub use config::{PersonaIOField, SubagentPersona, SubagentRole};
|
||||
pub use overrides::resolve_effective_overrides;
|
||||
pub use resume::{ResumeValidationError, validate_resume_identity};
|
||||
pub use types::{ContextSource, EffectiveRuntimeConfig, ResolutionError, ResumeSourceData};
|
||||
768
crates/codegen/xai-grok-subagent-resolution/src/overrides.rs
Normal file
768
crates/codegen/xai-grok-subagent-resolution/src/overrides.rs
Normal file
|
|
@ -0,0 +1,768 @@
|
|||
//! Runtime override resolution: merges explicit, role, and persona defaults.
|
||||
//!
|
||||
//! Extracted from `xai-grok-shell/src/agent/subagent/` `resolve_effective_overrides()`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use xai_grok_tools::implementations::grok_build::task::types::SubagentRuntimeOverrides;
|
||||
use xai_tool_types::{SubagentCapabilityMode, SubagentIsolationMode};
|
||||
|
||||
use crate::config::{SubagentPersona, SubagentRole};
|
||||
use crate::types::EffectiveRuntimeConfig;
|
||||
|
||||
/// Parse a serde-deserializable enum from a plain string value.
|
||||
///
|
||||
/// Used for `SubagentCapabilityMode` and `SubagentIsolationMode` which
|
||||
/// accept kebab-case string variants via `#[serde(rename_all = "kebab-case")]`.
|
||||
fn parse_enum_from_str<T: DeserializeOwned>(s: &str) -> Option<T> {
|
||||
serde_json::from_value::<T>(serde_json::Value::String(s.to_string())).ok()
|
||||
}
|
||||
|
||||
/// Resolve effective runtime config from explicit overrides, role defaults,
|
||||
/// and persona defaults.
|
||||
///
|
||||
/// Precedence for each field:
|
||||
/// 1. Explicit spawn-time override (from `SubagentRuntimeOverrides`)
|
||||
/// 2. Role default (from `SubagentRole` in config)
|
||||
/// 3. Persona default (looked up by name from the personas map)
|
||||
/// 4. None (parent inheritance, handled downstream)
|
||||
///
|
||||
/// Persona instructions are loaded eagerly: if `instructions_file` is set,
|
||||
/// the file is read from disk relative to `source_dir` (or `cwd` as fallback).
|
||||
/// If the file cannot be read, a fatal `persona_error` is set and the function
|
||||
/// returns early with only the persona name and error populated (all other
|
||||
/// fields at their defaults). This matches the shell's fail-closed behavior
|
||||
/// where persona file errors abort resolution before any other fields are wired.
|
||||
///
|
||||
/// Role prompt files follow soft degradation: if `prompt_file` cannot be read,
|
||||
/// a warning is set but the spawn continues without the role prompt.
|
||||
pub fn resolve_effective_overrides(
|
||||
overrides: &SubagentRuntimeOverrides,
|
||||
role: Option<&SubagentRole>,
|
||||
personas: &HashMap<String, SubagentPersona>,
|
||||
cwd: Option<&Path>,
|
||||
role_name: Option<String>,
|
||||
) -> EffectiveRuntimeConfig {
|
||||
// ── Model resolution ─────────────────────────────────────────
|
||||
let model_from_override_or_role = overrides
|
||||
.model
|
||||
.clone()
|
||||
.or_else(|| role.and_then(|r| r.model.clone()));
|
||||
|
||||
// ── Reasoning effort resolution ──────────────────────────────
|
||||
let reasoning_from_override_or_role = overrides
|
||||
.reasoning_effort
|
||||
.clone()
|
||||
.or_else(|| role.and_then(|r| r.reasoning_effort.clone()));
|
||||
|
||||
// ── Capability mode resolution ───────────────────────────────
|
||||
let capability_mode = overrides.capability_mode.or_else(|| {
|
||||
role.and_then(|r| {
|
||||
r.default_capability_mode
|
||||
.as_deref()
|
||||
.and_then(parse_enum_from_str::<SubagentCapabilityMode>)
|
||||
})
|
||||
});
|
||||
|
||||
// ── Persona resolution ───────────────────────────────────────
|
||||
let persona = overrides.persona.clone();
|
||||
let resolved_persona = persona.as_deref().and_then(|name| personas.get(name));
|
||||
|
||||
// Persona model/reasoning cascade after role
|
||||
let model =
|
||||
model_from_override_or_role.or_else(|| resolved_persona.and_then(|p| p.model.clone()));
|
||||
let reasoning_effort = reasoning_from_override_or_role
|
||||
.or_else(|| resolved_persona.and_then(|p| p.reasoning_effort.clone()));
|
||||
|
||||
// ── Persona instructions loading ─────────────────────────────
|
||||
// Fail-closed: if persona resolution produces an error (file unreadable,
|
||||
// not found, empty), return early with only persona + error populated.
|
||||
// All other fields are defaulted. This matches the shell's behavior where
|
||||
// persona errors abort spawn before wiring model/isolation.
|
||||
let (persona_instructions, persona_error, persona_fatal) =
|
||||
resolve_persona_instructions(persona.as_deref(), personas, cwd);
|
||||
// File I/O errors are fatal: return early with defaults so the caller
|
||||
// can abort the spawn. Config-level errors ("not found", "no instructions")
|
||||
// are non-fatal: they set `persona_error` but other fields still resolve.
|
||||
// This matches the shell's original behavior where only the file-read
|
||||
// error path did `return EffectiveRuntimeConfig { ..Default::default() }`.
|
||||
if persona_fatal {
|
||||
return EffectiveRuntimeConfig {
|
||||
persona,
|
||||
persona_error,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
// ── Role prompt file loading (soft degradation) ──────────────
|
||||
let mut role_prompt_warning = None;
|
||||
let role_prompt = role.and_then(|r| {
|
||||
let file_path = r.prompt_file.as_deref()?;
|
||||
let base_dir = r.source_dir.as_deref().or(cwd)?;
|
||||
match std::fs::read_to_string(base_dir.join(file_path)) {
|
||||
Ok(content) => Some(content),
|
||||
Err(e) => {
|
||||
let msg = format!("role prompt_file \"{file_path}\": {e}");
|
||||
tracing::warn!(path = file_path, error = %e, "Failed to read role prompt_file");
|
||||
role_prompt_warning = Some(msg);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Isolation resolution ─────────────────────────────────────
|
||||
let isolation = overrides
|
||||
.isolation
|
||||
.or_else(|| {
|
||||
role.and_then(|r| r.default_isolation.as_deref())
|
||||
.or_else(|| resolved_persona.and_then(|p| p.default_isolation.as_deref()))
|
||||
.and_then(parse_enum_from_str::<SubagentIsolationMode>)
|
||||
})
|
||||
.unwrap_or(SubagentIsolationMode::None);
|
||||
|
||||
EffectiveRuntimeConfig {
|
||||
model,
|
||||
reasoning_effort,
|
||||
capability_mode,
|
||||
persona,
|
||||
persona_instructions,
|
||||
role_prompt,
|
||||
role_prompt_warning,
|
||||
role_name,
|
||||
persona_error,
|
||||
isolation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve persona instructions from inline text and/or instructions_file.
|
||||
///
|
||||
/// Returns `(instructions, error, fatal)`:
|
||||
/// - `(Some(text), None, false)` on success
|
||||
/// - `(None, Some(err), true)` for file I/O errors (caller should early-return with defaults)
|
||||
/// - `(None, Some(err), false)` for config-level errors (persona not found, no instructions)
|
||||
/// - `(None, None, false)` when no persona is requested
|
||||
fn resolve_persona_instructions(
|
||||
persona_name: Option<&str>,
|
||||
personas: &HashMap<String, SubagentPersona>,
|
||||
cwd: Option<&Path>,
|
||||
) -> (Option<String>, Option<String>, bool) {
|
||||
let Some(name) = persona_name else {
|
||||
return (None, None, false);
|
||||
};
|
||||
|
||||
let Some(p) = personas.get(name) else {
|
||||
return (
|
||||
None,
|
||||
Some(format!("persona \"{name}\" not found in config")),
|
||||
false, // not fatal — config error, other fields still resolve
|
||||
);
|
||||
};
|
||||
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let Some(ref inline) = p.instructions {
|
||||
parts.push(inline.clone());
|
||||
}
|
||||
|
||||
if let Some(ref file_path) = p.instructions_file {
|
||||
let base = p.source_dir.as_deref().or(cwd);
|
||||
match base {
|
||||
Some(base_dir) => match std::fs::read_to_string(base_dir.join(file_path)) {
|
||||
Ok(content) => parts.push(content),
|
||||
Err(e) => {
|
||||
let err = format!(
|
||||
"persona \"{name}\": failed to read instructions_file \
|
||||
\"{file_path}\": {e}"
|
||||
);
|
||||
return (None, Some(err), true); // fatal — file I/O error
|
||||
}
|
||||
},
|
||||
None => {
|
||||
let err = format!(
|
||||
"persona \"{name}\": cannot resolve instructions_file \
|
||||
\"{file_path}\": no source_dir or cwd available"
|
||||
);
|
||||
return (None, Some(err), true); // fatal — unresolvable path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
(
|
||||
None,
|
||||
Some(format!(
|
||||
"persona \"{name}\" has no instructions or instructions_file"
|
||||
)),
|
||||
false, // not fatal — config error, other fields still resolve
|
||||
)
|
||||
} else {
|
||||
(Some(parts.join("\n\n")), None, false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use xai_grok_tools::implementations::grok_build::task::types::ModelOverrideProvenance;
|
||||
|
||||
/// Helper to build an overrides struct with only the fields we care about.
|
||||
fn make_overrides(
|
||||
model: Option<&str>,
|
||||
persona: Option<&str>,
|
||||
capability_mode: Option<SubagentCapabilityMode>,
|
||||
isolation: Option<SubagentIsolationMode>,
|
||||
reasoning_effort: Option<&str>,
|
||||
) -> SubagentRuntimeOverrides {
|
||||
SubagentRuntimeOverrides {
|
||||
model: model.map(String::from),
|
||||
model_override_provenance: ModelOverrideProvenance::Harness,
|
||||
reasoning_effort: reasoning_effort.map(String::from),
|
||||
persona: persona.map(String::from),
|
||||
capability_mode,
|
||||
isolation,
|
||||
// Harness override is a /goal-only concern; these resolution tests
|
||||
// exercise model/persona/capability precedence, not the harness.
|
||||
harness_agent_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_personas() -> HashMap<String, SubagentPersona> {
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
// ── Precedence tests ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn explicit_model_overrides_role() {
|
||||
let overrides = make_overrides(Some("grok-light"), None, None, None, None);
|
||||
let role = SubagentRole {
|
||||
model: Some("grok-3".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result =
|
||||
resolve_effective_overrides(&overrides, Some(&role), &empty_personas(), None, None);
|
||||
assert_eq!(result.model.as_deref(), Some("grok-light"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_model_used_when_no_explicit() {
|
||||
let overrides = make_overrides(None, None, None, None, None);
|
||||
let role = SubagentRole {
|
||||
model: Some("grok-3".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result =
|
||||
resolve_effective_overrides(&overrides, Some(&role), &empty_personas(), None, None);
|
||||
assert_eq!(result.model.as_deref(), Some("grok-3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_model_used_when_no_explicit_or_role() {
|
||||
let overrides = make_overrides(None, Some("researcher"), None, None, None);
|
||||
let mut personas = HashMap::new();
|
||||
personas.insert(
|
||||
"researcher".to_string(),
|
||||
SubagentPersona {
|
||||
model: Some("grok-3-fast".into()),
|
||||
instructions: Some("Research things.".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let result = resolve_effective_overrides(&overrides, None, &personas, None, None);
|
||||
assert_eq!(result.model.as_deref(), Some("grok-3-fast"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_model_when_none_specified() {
|
||||
let overrides = make_overrides(None, None, None, None, None);
|
||||
let result = resolve_effective_overrides(&overrides, None, &empty_personas(), None, None);
|
||||
assert!(result.model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_capability_mode_overrides_role() {
|
||||
let overrides = make_overrides(
|
||||
None,
|
||||
None,
|
||||
Some(SubagentCapabilityMode::ReadOnly),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let role = SubagentRole {
|
||||
default_capability_mode: Some("all".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result =
|
||||
resolve_effective_overrides(&overrides, Some(&role), &empty_personas(), None, None);
|
||||
assert_eq!(
|
||||
result.capability_mode,
|
||||
Some(SubagentCapabilityMode::ReadOnly)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_capability_mode_used_when_no_explicit() {
|
||||
let overrides = make_overrides(None, None, None, None, None);
|
||||
let role = SubagentRole {
|
||||
default_capability_mode: Some("read-only".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result =
|
||||
resolve_effective_overrides(&overrides, Some(&role), &empty_personas(), None, None);
|
||||
assert_eq!(
|
||||
result.capability_mode,
|
||||
Some(SubagentCapabilityMode::ReadOnly)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_role_capability_mode_falls_through() {
|
||||
let overrides = make_overrides(None, None, None, None, None);
|
||||
let role = SubagentRole {
|
||||
default_capability_mode: Some("bogus".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result =
|
||||
resolve_effective_overrides(&overrides, Some(&role), &empty_personas(), None, None);
|
||||
assert!(result.capability_mode.is_none());
|
||||
}
|
||||
|
||||
// ── Reasoning effort precedence ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn explicit_reasoning_effort_overrides_role_and_persona() {
|
||||
let overrides = make_overrides(None, Some("p"), None, None, Some("low"));
|
||||
let role = SubagentRole {
|
||||
reasoning_effort: Some("high".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut personas = HashMap::new();
|
||||
personas.insert(
|
||||
"p".to_string(),
|
||||
SubagentPersona {
|
||||
reasoning_effort: Some("medium".into()),
|
||||
instructions: Some("test".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let result = resolve_effective_overrides(&overrides, Some(&role), &personas, None, None);
|
||||
assert_eq!(result.reasoning_effort.as_deref(), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_reasoning_effort_overrides_persona() {
|
||||
let overrides = make_overrides(None, Some("p"), None, None, None);
|
||||
let role = SubagentRole {
|
||||
reasoning_effort: Some("high".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut personas = HashMap::new();
|
||||
personas.insert(
|
||||
"p".to_string(),
|
||||
SubagentPersona {
|
||||
reasoning_effort: Some("medium".into()),
|
||||
instructions: Some("test".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let result = resolve_effective_overrides(&overrides, Some(&role), &personas, None, None);
|
||||
assert_eq!(result.reasoning_effort.as_deref(), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_reasoning_effort_used_when_no_explicit_or_role() {
|
||||
let overrides = make_overrides(None, Some("p"), None, None, None);
|
||||
let mut personas = HashMap::new();
|
||||
personas.insert(
|
||||
"p".to_string(),
|
||||
SubagentPersona {
|
||||
reasoning_effort: Some("medium".into()),
|
||||
instructions: Some("test".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let result = resolve_effective_overrides(&overrides, None, &personas, None, None);
|
||||
assert_eq!(result.reasoning_effort.as_deref(), Some("medium"));
|
||||
}
|
||||
|
||||
// ── Isolation precedence ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn explicit_isolation_overrides_role() {
|
||||
let overrides = make_overrides(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(SubagentIsolationMode::Worktree),
|
||||
None,
|
||||
);
|
||||
let role = SubagentRole {
|
||||
default_isolation: Some("none".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result =
|
||||
resolve_effective_overrides(&overrides, Some(&role), &empty_personas(), None, None);
|
||||
assert_eq!(result.isolation, SubagentIsolationMode::Worktree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_isolation_used_when_no_explicit() {
|
||||
let overrides = make_overrides(None, None, None, None, None);
|
||||
let role = SubagentRole {
|
||||
default_isolation: Some("worktree".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result =
|
||||
resolve_effective_overrides(&overrides, Some(&role), &empty_personas(), None, None);
|
||||
assert_eq!(result.isolation, SubagentIsolationMode::Worktree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolation_defaults_to_none() {
|
||||
let overrides = make_overrides(None, None, None, None, None);
|
||||
let result = resolve_effective_overrides(&overrides, None, &empty_personas(), None, None);
|
||||
assert_eq!(result.isolation, SubagentIsolationMode::None);
|
||||
}
|
||||
|
||||
// ── Persona instruction loading ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn persona_inline_instructions_only() {
|
||||
let overrides = make_overrides(None, Some("writer"), None, None, None);
|
||||
let mut personas = HashMap::new();
|
||||
personas.insert(
|
||||
"writer".to_string(),
|
||||
SubagentPersona {
|
||||
instructions: Some("Be concise.".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let result = resolve_effective_overrides(&overrides, None, &personas, None, None);
|
||||
assert_eq!(result.persona_instructions.as_deref(), Some("Be concise."));
|
||||
assert!(result.persona_error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_file_instructions_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("persona.md");
|
||||
std::fs::write(&file_path, "File-based instructions.").unwrap();
|
||||
|
||||
let overrides = make_overrides(None, Some("writer"), None, None, None);
|
||||
let mut personas = HashMap::new();
|
||||
personas.insert(
|
||||
"writer".to_string(),
|
||||
SubagentPersona {
|
||||
instructions_file: Some("persona.md".into()),
|
||||
source_dir: Some(dir.path().to_path_buf()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let result = resolve_effective_overrides(&overrides, None, &personas, None, None);
|
||||
assert_eq!(
|
||||
result.persona_instructions.as_deref(),
|
||||
Some("File-based instructions.")
|
||||
);
|
||||
assert!(result.persona_error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_inline_and_file_instructions_merged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("extra.md");
|
||||
std::fs::write(&file_path, "From file.").unwrap();
|
||||
|
||||
let overrides = make_overrides(None, Some("writer"), None, None, None);
|
||||
let mut personas = HashMap::new();
|
||||
personas.insert(
|
||||
"writer".to_string(),
|
||||
SubagentPersona {
|
||||
instructions: Some("From inline.".into()),
|
||||
instructions_file: Some("extra.md".into()),
|
||||
source_dir: Some(dir.path().to_path_buf()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let result = resolve_effective_overrides(&overrides, None, &personas, None, None);
|
||||
assert_eq!(
|
||||
result.persona_instructions.as_deref(),
|
||||
Some("From inline.\n\nFrom file.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_not_found_returns_error() {
|
||||
let overrides = make_overrides(None, Some("missing"), None, None, None);
|
||||
let result = resolve_effective_overrides(&overrides, None, &empty_personas(), None, None);
|
||||
assert!(result.persona_instructions.is_none());
|
||||
assert!(result.persona_error.is_some());
|
||||
assert!(result.persona_error.as_ref().unwrap().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_empty_instructions_returns_error() {
|
||||
let overrides = make_overrides(None, Some("empty"), None, None, None);
|
||||
let mut personas = HashMap::new();
|
||||
personas.insert("empty".to_string(), SubagentPersona::default());
|
||||
let result = resolve_effective_overrides(&overrides, None, &personas, None, None);
|
||||
assert!(result.persona_instructions.is_none());
|
||||
assert!(result.persona_error.is_some());
|
||||
assert!(
|
||||
result
|
||||
.persona_error
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("has no instructions")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_file_not_found_returns_error() {
|
||||
let overrides = make_overrides(None, Some("broken"), None, None, None);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut personas = HashMap::new();
|
||||
personas.insert(
|
||||
"broken".to_string(),
|
||||
SubagentPersona {
|
||||
instructions_file: Some("nonexistent.md".into()),
|
||||
source_dir: Some(dir.path().to_path_buf()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let result = resolve_effective_overrides(&overrides, None, &personas, None, None);
|
||||
assert!(result.persona_instructions.is_none());
|
||||
assert!(result.persona_error.is_some());
|
||||
assert!(
|
||||
result
|
||||
.persona_error
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("failed to read")
|
||||
);
|
||||
}
|
||||
|
||||
// ── Role prompt file loading ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn role_prompt_file_loaded_on_success() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let prompt_path = dir.path().join("role.md");
|
||||
std::fs::write(&prompt_path, "Role instructions here.").unwrap();
|
||||
|
||||
let overrides = make_overrides(None, None, None, None, None);
|
||||
let role = SubagentRole {
|
||||
prompt_file: Some("role.md".into()),
|
||||
source_dir: Some(dir.path().to_path_buf()),
|
||||
..Default::default()
|
||||
};
|
||||
let result =
|
||||
resolve_effective_overrides(&overrides, Some(&role), &empty_personas(), None, None);
|
||||
assert_eq!(
|
||||
result.role_prompt.as_deref(),
|
||||
Some("Role instructions here.")
|
||||
);
|
||||
assert!(result.role_prompt_warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_prompt_file_missing_produces_warning() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let overrides = make_overrides(None, None, None, None, None);
|
||||
let role = SubagentRole {
|
||||
prompt_file: Some("missing.md".into()),
|
||||
source_dir: Some(dir.path().to_path_buf()),
|
||||
..Default::default()
|
||||
};
|
||||
let result =
|
||||
resolve_effective_overrides(&overrides, Some(&role), &empty_personas(), None, None);
|
||||
assert!(result.role_prompt.is_none());
|
||||
assert!(result.role_prompt_warning.is_some());
|
||||
}
|
||||
|
||||
// ── No persona requested ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn no_persona_no_instructions() {
|
||||
let overrides = make_overrides(None, None, None, None, None);
|
||||
let result = resolve_effective_overrides(&overrides, None, &empty_personas(), None, None);
|
||||
assert!(result.persona.is_none());
|
||||
assert!(result.persona_instructions.is_none());
|
||||
assert!(result.persona_error.is_none());
|
||||
}
|
||||
|
||||
// ── Persona with cwd fallback for instructions_file ──────────
|
||||
|
||||
#[test]
|
||||
fn persona_instructions_file_uses_cwd_when_no_source_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("instructions.md");
|
||||
std::fs::write(&file_path, "CWD-resolved instructions.").unwrap();
|
||||
|
||||
let overrides = make_overrides(None, Some("cwd_persona"), None, None, None);
|
||||
let mut personas = HashMap::new();
|
||||
personas.insert(
|
||||
"cwd_persona".to_string(),
|
||||
SubagentPersona {
|
||||
instructions_file: Some("instructions.md".into()),
|
||||
// source_dir is None - will fall back to cwd
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let result =
|
||||
resolve_effective_overrides(&overrides, None, &personas, Some(dir.path()), None);
|
||||
assert_eq!(
|
||||
result.persona_instructions.as_deref(),
|
||||
Some("CWD-resolved instructions.")
|
||||
);
|
||||
assert!(result.persona_error.is_none());
|
||||
}
|
||||
|
||||
// ── Persona error early-return (fail-closed) ─────────────────
|
||||
|
||||
#[test]
|
||||
fn persona_not_found_error_is_non_fatal() {
|
||||
// "not found" is a config-level error: persona_error is set but
|
||||
// other fields still resolve from role/overrides.
|
||||
let overrides = make_overrides(Some("grok-3"), Some("missing"), None, None, None);
|
||||
let role = SubagentRole {
|
||||
model: Some("grok-light".into()),
|
||||
default_isolation: Some("worktree".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result =
|
||||
resolve_effective_overrides(&overrides, Some(&role), &empty_personas(), None, None);
|
||||
assert_eq!(
|
||||
result.persona_error.as_deref(),
|
||||
Some("persona \"missing\" not found in config"),
|
||||
);
|
||||
// Non-fatal: other fields ARE resolved (explicit model takes precedence)
|
||||
assert_eq!(
|
||||
result.model.as_deref(),
|
||||
Some("grok-3"),
|
||||
"explicit model should resolve despite persona error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_file_error_returns_early_with_defaults() {
|
||||
// File I/O errors ARE fatal: early return with defaults.
|
||||
let overrides = make_overrides(Some("grok-3"), Some("broken"), None, None, None);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut personas = HashMap::new();
|
||||
personas.insert(
|
||||
"broken".to_string(),
|
||||
SubagentPersona {
|
||||
instructions_file: Some("nonexistent.md".into()),
|
||||
source_dir: Some(dir.path().to_path_buf()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let role = SubagentRole {
|
||||
model: Some("grok-light".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = resolve_effective_overrides(&overrides, Some(&role), &personas, None, None);
|
||||
// Fatal error: persona_error is set
|
||||
assert!(
|
||||
result
|
||||
.persona_error
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("failed to read")
|
||||
);
|
||||
// All other fields are at Default (early return)
|
||||
assert!(
|
||||
result.model.is_none(),
|
||||
"model should be None on fatal persona error"
|
||||
);
|
||||
}
|
||||
|
||||
// ── instructions_file with no base dir ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn persona_instructions_file_no_base_dir_returns_error() {
|
||||
let overrides = make_overrides(None, Some("orphan"), None, None, None);
|
||||
let mut personas = HashMap::new();
|
||||
personas.insert(
|
||||
"orphan".to_string(),
|
||||
SubagentPersona {
|
||||
instructions_file: Some("orphan.md".into()),
|
||||
// source_dir is None AND cwd will be None
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let result = resolve_effective_overrides(&overrides, None, &personas, None, None);
|
||||
assert!(result.persona_error.is_some());
|
||||
assert_eq!(
|
||||
result.persona_error.as_deref(),
|
||||
Some(
|
||||
"persona \"orphan\": cannot resolve instructions_file \
|
||||
\"orphan.md\": no source_dir or cwd available"
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Persona isolation fallback (role has no isolation, persona does) ──
|
||||
|
||||
#[test]
|
||||
fn persona_isolation_used_when_no_explicit_or_role() {
|
||||
let overrides = make_overrides(None, Some("p"), None, None, None);
|
||||
let mut personas = HashMap::new();
|
||||
personas.insert(
|
||||
"p".to_string(),
|
||||
SubagentPersona {
|
||||
instructions: Some("test".into()),
|
||||
default_isolation: Some("worktree".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
// No role, no explicit isolation — should fall through to persona
|
||||
let result = resolve_effective_overrides(&overrides, None, &personas, None, None);
|
||||
assert_eq!(result.isolation, SubagentIsolationMode::Worktree);
|
||||
}
|
||||
|
||||
// ── Role prompt file cwd fallback ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn role_prompt_file_uses_cwd_when_no_source_dir() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let prompt_path = dir.path().join("role.md");
|
||||
std::fs::write(&prompt_path, "CWD role instructions.").unwrap();
|
||||
|
||||
let overrides = make_overrides(None, None, None, None, None);
|
||||
let role = SubagentRole {
|
||||
prompt_file: Some("role.md".into()),
|
||||
// source_dir is None — falls back to cwd
|
||||
..Default::default()
|
||||
};
|
||||
let result = resolve_effective_overrides(
|
||||
&overrides,
|
||||
Some(&role),
|
||||
&empty_personas(),
|
||||
Some(dir.path()),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
result.role_prompt.as_deref(),
|
||||
Some("CWD role instructions."),
|
||||
);
|
||||
assert!(result.role_prompt_warning.is_none());
|
||||
}
|
||||
|
||||
// ── role_name parameter is threaded through ───────────────────
|
||||
|
||||
#[test]
|
||||
fn role_name_parameter_threaded_through() {
|
||||
let overrides = make_overrides(None, None, None, None, None);
|
||||
let result = resolve_effective_overrides(
|
||||
&overrides,
|
||||
None,
|
||||
&empty_personas(),
|
||||
None,
|
||||
Some("my-role".into()),
|
||||
);
|
||||
assert_eq!(result.role_name.as_deref(), Some("my-role"));
|
||||
}
|
||||
}
|
||||
193
crates/codegen/xai-grok-subagent-resolution/src/resume.rs
Normal file
193
crates/codegen/xai-grok-subagent-resolution/src/resume.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
//! Resume identity validation: ensures that a resumed subagent matches the
|
||||
//! source's identity fields (type, persona).
|
||||
//!
|
||||
//! Model is not an identity gate on resume: the shell always inherits/pins the
|
||||
//! source model, and any caller-provided model override is soft-ignored.
|
||||
//!
|
||||
//! Extracted from `xai-grok-shell/src/agent/subagent/` resume validation block.
|
||||
|
||||
use crate::types::ResumeSourceData;
|
||||
|
||||
/// Error type for resume validation failures.
|
||||
///
|
||||
/// Each variant describes a specific identity mismatch between the resume
|
||||
/// request and the source subagent's recorded identity fields.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ResumeValidationError {
|
||||
/// The requested subagent_type differs from the source's type.
|
||||
#[error(
|
||||
"Cannot resume with subagent_type '{requested}': source subagent was '{source_value}'. \
|
||||
Resumed sessions must use the same subagent type as the source."
|
||||
)]
|
||||
TypeMismatch {
|
||||
requested: String,
|
||||
source_value: String,
|
||||
},
|
||||
|
||||
/// The requested persona differs from the source's persona.
|
||||
#[error(
|
||||
"Cannot resume with persona '{requested}': source subagent used {source_value:?}. \
|
||||
Resumed sessions must use the same persona as the source."
|
||||
)]
|
||||
PersonaMismatch {
|
||||
requested: String,
|
||||
source_value: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Validate that a resume request's identity fields match the source subagent.
|
||||
///
|
||||
/// Resume contract: the resumed child inherits the source's raw transcript,
|
||||
/// tool state, and model. System prompt and prompt context are freshly
|
||||
/// rendered from the current agent definition. Reject type/persona overrides
|
||||
/// that conflict with the inherited identity fields. Model overrides are not
|
||||
/// validated here — callers soft-ignore them and pin the source model.
|
||||
///
|
||||
/// Returns `Ok(())` if identity fields match, or `Err(ResumeValidationError)`
|
||||
/// describing the first mismatch found.
|
||||
pub fn validate_resume_identity(
|
||||
requested_type: &str,
|
||||
requested_persona: Option<&str>,
|
||||
source: &ResumeSourceData,
|
||||
) -> Result<(), ResumeValidationError> {
|
||||
// Check subagent type match
|
||||
if requested_type != source.subagent_type {
|
||||
return Err(ResumeValidationError::TypeMismatch {
|
||||
requested: requested_type.to_string(),
|
||||
source_value: source.subagent_type.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check persona match (only if explicitly requested)
|
||||
if let Some(persona) = requested_persona
|
||||
&& source.persona.as_deref() != Some(persona)
|
||||
{
|
||||
return Err(ResumeValidationError::PersonaMismatch {
|
||||
requested: persona.to_string(),
|
||||
source_value: source.persona.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_source(
|
||||
subagent_type: &str,
|
||||
persona: Option<&str>,
|
||||
model_id: Option<&str>,
|
||||
) -> ResumeSourceData {
|
||||
ResumeSourceData {
|
||||
subagent_id: "source-id".into(),
|
||||
subagent_type: subagent_type.into(),
|
||||
persona: persona.map(String::from),
|
||||
model_id: model_id.map(String::from),
|
||||
child_cwd: "/workspace".into(),
|
||||
worktree_path: None,
|
||||
snapshot_ref: None,
|
||||
child_session_id: "child-session".into(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Matching cases ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn matching_type_no_persona() {
|
||||
let source = make_source("general-purpose", None, None);
|
||||
let result = validate_resume_identity("general-purpose", None, &source);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_type_and_persona() {
|
||||
let source = make_source("general-purpose", Some("implementer"), None);
|
||||
let result = validate_resume_identity("general-purpose", Some("implementer"), &source);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_type_and_persona_source_has_model() {
|
||||
let source = make_source("general-purpose", Some("impl"), Some("grok-3"));
|
||||
let result = validate_resume_identity("general-purpose", Some("impl"), &source);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_persona_requested_source_has_persona() {
|
||||
// Not requesting a persona is always valid (no override = inherit)
|
||||
let source = make_source("general-purpose", Some("implementer"), None);
|
||||
let result = validate_resume_identity("general-purpose", None, &source);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_model_is_not_validated() {
|
||||
// Model is not an identity gate; source model is used only for pinning.
|
||||
let source = make_source("general-purpose", None, Some("grok-3"));
|
||||
let result = validate_resume_identity("general-purpose", None, &source);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ── Mismatching cases ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn type_mismatch_rejected() {
|
||||
let source = make_source("general-purpose", None, None);
|
||||
let result = validate_resume_identity("explore", None, &source);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ResumeValidationError::TypeMismatch { .. })
|
||||
));
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.to_string().contains("explore"));
|
||||
assert!(err.to_string().contains("general-purpose"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_mismatch_rejected() {
|
||||
let source = make_source("general-purpose", Some("implementer"), None);
|
||||
let result = validate_resume_identity("general-purpose", Some("reviewer"), &source);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ResumeValidationError::PersonaMismatch { .. })
|
||||
));
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.to_string().contains("reviewer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_requested_but_source_had_none() {
|
||||
let source = make_source("general-purpose", None, None);
|
||||
let result = validate_resume_identity("general-purpose", Some("implementer"), &source);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ResumeValidationError::PersonaMismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
// ── Validation order ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn type_mismatch_checked_before_persona() {
|
||||
let source = make_source("general-purpose", Some("impl"), None);
|
||||
let result = validate_resume_identity("explore", Some("reviewer"), &source);
|
||||
// Should be TypeMismatch, not PersonaMismatch
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ResumeValidationError::TypeMismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_mismatch_still_rejected_when_source_has_model() {
|
||||
let source = make_source("general-purpose", Some("impl"), Some("grok-3"));
|
||||
let result = validate_resume_identity("general-purpose", Some("reviewer"), &source);
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ResumeValidationError::PersonaMismatch { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
141
crates/codegen/xai-grok-subagent-resolution/src/types.rs
Normal file
141
crates/codegen/xai-grok-subagent-resolution/src/types.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
//! Public API types for subagent resolution.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::resume::ResumeValidationError;
|
||||
|
||||
/// How the child session's initial context was bootstrapped.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ContextSource {
|
||||
/// Fresh session with no inherited history.
|
||||
New,
|
||||
/// Resumed from a previously completed peer subagent. The child inherits
|
||||
/// the source's raw transcript, tool state, and model. System prompt and
|
||||
/// prompt context are freshly rendered.
|
||||
Resumed,
|
||||
}
|
||||
|
||||
/// Resolved effective runtime configuration for a child agent.
|
||||
///
|
||||
/// Precedence: explicit spawn-time override > role default > persona default > parent inheritance (None).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EffectiveRuntimeConfig {
|
||||
/// Resolved model ID override (if any).
|
||||
pub model: Option<String>,
|
||||
/// Resolved reasoning effort (e.g. "low", "medium", "high").
|
||||
// TODO(phase2): consider a typed `ReasoningEffort` enum to prevent typos.
|
||||
// Currently stringly-typed for compatibility with the shell's existing API.
|
||||
pub reasoning_effort: Option<String>,
|
||||
/// Resolved capability mode controlling tool access.
|
||||
pub capability_mode: Option<xai_tool_types::SubagentCapabilityMode>,
|
||||
/// Resolved persona name (for metadata/observability).
|
||||
pub persona: Option<String>,
|
||||
/// Resolved persona instructions text (for prompt assembly).
|
||||
pub persona_instructions: Option<String>,
|
||||
/// Role prompt_file content (loaded at resolve time).
|
||||
pub role_prompt: Option<String>,
|
||||
/// Warning when role prompt_file failed to load (soft degradation).
|
||||
pub role_prompt_warning: Option<String>,
|
||||
/// Resolved role name (the key that matched in subagent_roles lookup).
|
||||
pub role_name: Option<String>,
|
||||
/// Error from persona resolution (file unreadable, not found, empty).
|
||||
/// Unlike role prompts, persona errors are fatal: spawn is aborted.
|
||||
pub persona_error: Option<String>,
|
||||
/// Isolation mode for the child execution environment.
|
||||
pub isolation: xai_tool_types::SubagentIsolationMode,
|
||||
}
|
||||
|
||||
/// Data about a completed source subagent, needed for resume validation
|
||||
/// and downstream spawn orchestration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResumeSourceData {
|
||||
/// Source subagent ID.
|
||||
pub subagent_id: String,
|
||||
/// Source subagent type (e.g. "general-purpose", "explore").
|
||||
/// Used by `validate_resume_identity` to check type match.
|
||||
pub subagent_type: String,
|
||||
/// Source subagent persona, if any.
|
||||
/// Used by `validate_resume_identity` to check persona match.
|
||||
pub persona: Option<String>,
|
||||
/// Effective model ID used by the source child session.
|
||||
/// Used by the shell for resume model pinning (model overrides on
|
||||
/// resume are soft-ignored, not identity-gated).
|
||||
pub model_id: Option<String>,
|
||||
/// Effective cwd the source child used. Consumed by the shell's
|
||||
/// spawn orchestration to reconstruct `SessionInfo` for raw
|
||||
/// transcript continuation and worktree reuse.
|
||||
pub child_cwd: String,
|
||||
/// Worktree path if the source used `isolation=worktree`. Consumed
|
||||
/// by the shell to reuse the source's isolated workspace directory
|
||||
/// when resuming a worktree-isolated child.
|
||||
pub worktree_path: Option<PathBuf>,
|
||||
/// Durable git ref holding a snapshot of the source worktree's working
|
||||
/// state, set when the worktree was snapshotted at completion. Consumed
|
||||
/// by the shell to rehydrate a deleted worktree directory on resume.
|
||||
pub snapshot_ref: Option<String>,
|
||||
/// The child session ID of the source subagent. Consumed by the
|
||||
/// shell to locate the source's session directory for raw transcript
|
||||
/// copying (`copy_session_data_sync`).
|
||||
pub child_session_id: String,
|
||||
}
|
||||
|
||||
/// Errors that can occur during subagent resolution.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ResolutionError {
|
||||
/// Persona was explicitly requested but could not be resolved.
|
||||
#[error("persona resolution failed: {0}")]
|
||||
PersonaResolution(String),
|
||||
|
||||
/// Resume identity validation failed.
|
||||
#[error("resume validation failed: {0}")]
|
||||
ResumeValidation(#[from] ResumeValidationError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use xai_tool_types::SubagentIsolationMode;
|
||||
|
||||
#[test]
|
||||
fn effective_runtime_config_default_values() {
|
||||
let config = EffectiveRuntimeConfig::default();
|
||||
assert!(config.model.is_none());
|
||||
assert!(config.reasoning_effort.is_none());
|
||||
assert!(config.capability_mode.is_none());
|
||||
assert!(config.persona.is_none());
|
||||
assert!(config.persona_instructions.is_none());
|
||||
assert!(config.role_prompt.is_none());
|
||||
assert!(config.role_prompt_warning.is_none());
|
||||
assert!(config.role_name.is_none());
|
||||
assert!(config.persona_error.is_none());
|
||||
assert_eq!(config.isolation, SubagentIsolationMode::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_error_persona_display() {
|
||||
let err = ResolutionError::PersonaResolution("persona \"x\" not found".into());
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"persona resolution failed: persona \"x\" not found",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_error_resume_from_typed_error() {
|
||||
let typed = ResumeValidationError::TypeMismatch {
|
||||
requested: "explore".into(),
|
||||
source_value: "general-purpose".into(),
|
||||
};
|
||||
let err = ResolutionError::from(typed);
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("resume validation failed"));
|
||||
assert!(msg.contains("explore"));
|
||||
assert!(msg.contains("general-purpose"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_source_equality() {
|
||||
assert_eq!(ContextSource::New, ContextSource::New);
|
||||
assert_ne!(ContextSource::New, ContextSource::Resumed);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue