Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
67e79c7
fix(runtime): bound tool and agent resource fanout
w0wl0lxd Aug 3, 2026
fb02084
Merge remote-tracking branch 'origin/main' into fix/runtime-sigkill-f…
w0wl0lxd Aug 3, 2026
6ff6d69
docs(config): regenerate workflow limits
w0wl0lxd Aug 3, 2026
ae8a076
refactor(session): propagate authoritative caller identity
w0wl0lxd Aug 3, 2026
f606107
feat(session): add configurable lineage guard
w0wl0lxd Aug 3, 2026
dcdee94
Merge remote-tracking branch 'origin/main' into fix/agent-spawn-recur…
w0wl0lxd Aug 3, 2026
ee57635
feat(lua): add scoped plugin session state runtime
w0wl0lxd Aug 3, 2026
e244668
fix(agent,lua,storage,ui): harden agent spawn and session recursion
w0wl0lxd Aug 3, 2026
7a631e1
fix(agent/lua): defer plugin state store read until after tool normal…
w0wl0lxd Aug 4, 2026
aac68c7
Merge origin/main into fix/agent-spawn-recursion
w0wl0lxd Aug 4, 2026
38754b3
Merge origin/main into fix/agent-spawn-recursion
w0wl0lxd Aug 4, 2026
a2bd779
fix(lint): resolve clippy warnings for agent spawn recursion PR
w0wl0lxd Aug 4, 2026
6184f1e
Merge origin/main into fix/agent-spawn-recursion
w0wl0lxd Aug 4, 2026
cfc94d2
fix(ui): drop unused checkpoint_session after merge
w0wl0lxd Aug 4, 2026
c7090e1
merge: origin/main into fix/agent-spawn-recursion
w0wl0lxd Aug 5, 2026
9cc23bb
Merge branch 'main' into fix/agent-spawn-recursion
w0wl0lxd Aug 5, 2026
c22892d
chore: add changelog fragment
w0wl0lxd Aug 5, 2026
6f97b39
Merge remote-tracking branch
w0wl0lxd Aug 5, 2026
2a60906
fix(n00n-lua): do not discard plugin state on unload; regenerate docs
w0wl0lxd Aug 5, 2026
866824f
fix(n00n-lua): drain non-nested tool requests during lifecycle barriers
w0wl0lxd Aug 5, 2026
5d43091
Merge branch 'main' into fix/agent-spawn-recursion
w0wl0lxd Aug 5, 2026
8ffb63c
Merge branch 'main' into fix/agent-spawn-recursion
w0wl0lxd Aug 7, 2026
f656a80
Merge branch 'main' into fix/agent-spawn-recursion
w0wl0lxd Aug 7, 2026
a607e5d
Merge branch 'main' into fix/agent-spawn-recursion
w0wl0lxd Aug 7, 2026
de9e932
Merge branch 'main' into fix/agent-spawn-recursion
w0wl0lxd Aug 7, 2026
e7f8eae
Merge branch 'main' into fix/agent-spawn-recursion
w0wl0lxd Aug 7, 2026
b1c64fd
Merge branch 'main' into fix/agent-spawn-recursion
w0wl0lxd Aug 7, 2026
4e0ce1d
Merge remote-tracking branch 'origin/fix/agent-spawn-recursion' into …
w0wl0lxd Aug 10, 2026
d26fa69
fix(orchestration): make background bootstraps durable
w0wl0lxd Aug 10, 2026
4113e4a
Merge remote-tracking branch 'origin/main' into fix/background-task-a…
w0wl0lxd Aug 10, 2026
249a3fc
fix(lua): reconcile async task identity merge
w0wl0lxd Aug 10, 2026
658bb74
Merge remote-tracking branch 'origin/main' into fix/background-task-a…
w0wl0lxd Aug 10, 2026
03eaea1
fix(orchestration): close background lifecycle gaps
w0wl0lxd Aug 10, 2026
5536541
test(control): validate TUI session IDs
w0wl0lxd Aug 10, 2026
caf8dfc
test(lua): stabilize deadline waiter timing
w0wl0lxd Aug 10, 2026
7a9e746
fix(orchestration): persist queued session lifecycle
w0wl0lxd Aug 10, 2026
cc88cf5
test(lua): widen deadline cleanup timing margin
w0wl0lxd Aug 10, 2026
699c9eb
fix(orchestration): address lifecycle review findings
w0wl0lxd Aug 10, 2026
b13fd78
fix(orchestration): persist resumable team state
w0wl0lxd Aug 10, 2026
81d3af7
fix(orchestration): close final review gaps
w0wl0lxd Aug 10, 2026
30e4dab
fix(ui): refresh lineage after session reset
w0wl0lxd Aug 12, 2026
549a050
Merge branch 'main' into fix/agent-spawn-recursion
w0wl0lxd Aug 12, 2026
e153af4
Merge branch 'main' into fix/agent-spawn-recursion
w0wl0lxd Aug 12, 2026
16db7cd
Merge branch 'main' into fix/agent-spawn-recursion
w0wl0lxd Aug 12, 2026
efa6fae
chore: merge latest main into agent spawn hardening
w0wl0lxd Aug 12, 2026
173bcef
chore: merge latest main into agent spawn hardening
w0wl0lxd Aug 12, 2026
0ee6fb8
fix(orchestration): preserve crash recovery hardening
w0wl0lxd Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/327.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hardened agent spawn recursion and session lineage handling to prevent runaway subagent loops.
29 changes: 29 additions & 0 deletions n00n-agent/src/agent/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,23 @@ impl<'h> Agent<'h> {
self.total_cost
}

pub async fn run_tool(&self, id: String, name: &str, input: &Value) -> ToolDoneEvent {
let ctx = self.tool_context();
let done = tool_dispatch::run(
&self.registry,
self.mcp.as_ref(),
id,
name,
input,
&ctx,
tool_dispatch::Emit::Notify,
)
.await;
self.event_tx
.try_send(AgentEvent::ToolDone(Box::new(done.clone())));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
done
}

/// Runs the agent loop with the given input.
///
/// # Errors
Expand Down Expand Up @@ -2069,6 +2086,18 @@ mod tests {
(agent, event_rx)
}

#[test]
fn tool_context_preserves_agent_session_identity() {
let mut history = History::new(Vec::new());
let (mut agent, _event_rx) = make_agent(MockProvider::new(Vec::new()), &mut history);
let identity = SessionIdentity::root(SessionRef::generate());
agent.identity = Some(identity.clone());

let ctx = agent.tool_context();

assert_eq!(ctx.identity, Some(identity));
}

fn make_agent_with_config(
provider: MockProvider,
history: &mut History,
Expand Down
52 changes: 52 additions & 0 deletions n00n-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub const MIN_MAX_INPUT_LINES: u32 = 1;
pub const DEFAULT_MCP_TOOL_DESC_MAX_CHARS: usize = 200;

pub const DEFAULT_MAX_CONTINUATION_TURNS: u32 = 3;
pub const DEFAULT_MAX_DEPTH: usize = 4;
pub const DEFAULT_MAX_TOTAL_DESCENDANTS: usize = 16;
pub const DEFAULT_MAX_ACTIVE_DESCENDANTS: usize = 8;
pub const DEFAULT_COMPACTION_BUFFER: CompactionBuffer = CompactionBuffer::Percent(20);

pub const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
Expand All @@ -42,6 +45,9 @@ pub const DEFAULT_INPUT_HISTORY_SIZE: usize = 100;
pub const MIN_OUTPUT_BYTES: usize = 1024;
pub const MIN_OUTPUT_LINES: usize = 10;
pub const MIN_MAX_CONTINUATION_TURNS: u32 = 1;
pub const MIN_MAX_DEPTH: usize = 1;
pub const MIN_MAX_TOTAL_DESCENDANTS: usize = 1;
pub const MIN_MAX_ACTIVE_DESCENDANTS: usize = 1;
pub const MIN_COMPACTION_BUFFER: u32 = 1_000;
const MAX_COMPACTION_PERCENT: u8 = 99;
const COMPACTION_BUFFER_EXPECTED: &str =
Expand Down Expand Up @@ -190,6 +196,16 @@ pub enum ConfigError {
"invalid config: agent.fusion.sidekick_tier must be weak, medium, or strong, got {tier:?}"
)]
InvalidFusionSidekickTier { tier: crate::providers::Tier },
#[error(
"invalid config: agent lineage limits require max_depth <= max_total_descendants and \
max_active_descendants <= max_total_descendants (got max_depth={max_depth}, \
max_total_descendants={max_total_descendants}, max_active_descendants={max_active_descendants})"
)]
InvalidLineageLimits {
max_depth: usize,
max_total_descendants: usize,
max_active_descendants: usize,
},
}

fn check(
Expand Down Expand Up @@ -491,6 +507,9 @@ pub struct AgentFileConfig {
pub max_output_bytes: Option<usize>,
pub max_output_lines: Option<usize>,
pub max_continuation_turns: Option<u32>,
pub max_depth: Option<usize>,
pub max_total_descendants: Option<usize>,
pub max_active_descendants: Option<usize>,
pub compaction_buffer: Option<CompactionBuffer>,
pub mcp_tool_desc_max_chars: Option<usize>,
pub dynamic_tools: Option<DynamicToolFileConfig>,
Expand Down Expand Up @@ -519,6 +538,9 @@ impl AgentFileConfig {
max_output_bytes,
max_output_lines,
max_continuation_turns,
max_depth,
max_total_descendants,
max_active_descendants,
compaction_buffer,
mcp_tool_desc_max_chars
);
Expand Down Expand Up @@ -1094,6 +1116,15 @@ pub struct AgentConfig {
#[config(default = DEFAULT_MAX_CONTINUATION_TURNS, min = MIN_MAX_CONTINUATION_TURNS, desc = "Max automatic continuation turns")]
pub max_continuation_turns: u32,

#[config(default = DEFAULT_MAX_DEPTH, min = MIN_MAX_DEPTH, desc = "Maximum session lineage depth")]
pub max_depth: usize,

#[config(default = DEFAULT_MAX_TOTAL_DESCENDANTS, min = MIN_MAX_TOTAL_DESCENDANTS, desc = "Maximum total descendants per session lineage root")]
pub max_total_descendants: usize,

#[config(default = DEFAULT_MAX_ACTIVE_DESCENDANTS, min = MIN_MAX_ACTIVE_DESCENDANTS, desc = "Maximum active descendants per session lineage root")]
pub max_active_descendants: usize,

#[config(default = DEFAULT_COMPACTION_BUFFER, ty = "u32 | string", default_doc = "20%", desc = "Context reserved for compaction: token count or percent of the context window (e.g. \"20%\")")]
pub compaction_buffer: CompactionBuffer,

Expand Down Expand Up @@ -1194,6 +1225,13 @@ impl AgentConfig {
max_continuation_turns: file
.max_continuation_turns
.unwrap_or_else(|| DEFAULT_MAX_CONTINUATION_TURNS),
max_depth: file.max_depth.unwrap_or_else(|| DEFAULT_MAX_DEPTH),
max_total_descendants: file
.max_total_descendants
.unwrap_or_else(|| DEFAULT_MAX_TOTAL_DESCENDANTS),
max_active_descendants: file
.max_active_descendants
.unwrap_or_else(|| DEFAULT_MAX_ACTIVE_DESCENDANTS),
compaction_buffer: file
.compaction_buffer
.unwrap_or_else(|| DEFAULT_COMPACTION_BUFFER),
Expand All @@ -1207,6 +1245,19 @@ impl AgentConfig {
fusion,
}
}

fn validate_lineage_limits(&self) -> Result<(), ConfigError> {
if self.max_depth > self.max_total_descendants
|| self.max_active_descendants > self.max_total_descendants
{
return Err(ConfigError::InvalidLineageLimits {
max_depth: self.max_depth,
max_total_descendants: self.max_total_descendants,
max_active_descendants: self.max_active_descendants,
});
}
Ok(())
}
}

#[derive(Debug, Clone, ConfigSection)]
Expand Down Expand Up @@ -1381,6 +1432,7 @@ impl Config {
pub fn validate(&self) -> Result<(), ConfigError> {
self.ui.validate_all()?;
self.agent.validate()?;
self.agent.validate_lineage_limits()?;
self.provider.validate()?;
self.provider.validate_openai_coding_plan_slots()?;
if self.agent.fusion.sidekick_tier == crate::providers::Tier::Compaction {
Expand Down
12 changes: 7 additions & 5 deletions n00n-lua/src/api/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ async fn call_tool(
on_buf,
on_ann,
};
let _nested_dispatch = crate::api::tool::enter_nested_dispatch();
let done = dispatch_racing_live(&tctx, &name, &input_json, rx, &cbs).await;
// Same fallback the UI applies on tool completion, so a batch child's
// header carries the annotation its standalone run would get.
Expand Down Expand Up @@ -661,11 +662,6 @@ async fn session(
opts: Table,
) -> LuaResult<Pair<mlua::AnyUserData>> {
let agent_ctx = try_pair!(dispatch_ctx(&ctx, "session")).clone();
let Some(parent_identity) = agent_ctx.identity.clone() else {
return Ok(err_pair("session identity is unavailable"));
};
let plugin_state_store = try_pair!(ctx.plugin_state_store());
drop(ctx);
let model_spec: Option<String> = opts.get("model_spec")?;
let system: Option<String> = opts.get("system")?;
let tools_val: Option<LuaValue> = opts.get("tools")?;
Expand Down Expand Up @@ -800,6 +796,12 @@ async fn session(
None => agent_ctx.opts.thinking,
};

let Some(parent_identity) = agent_ctx.identity.clone() else {
return Ok(err_pair("session identity is unavailable"));
};
let plugin_state_store = try_pair!(ctx.plugin_state_store());
drop(ctx);

let session_id = n00nId::generate();
let child_id = session_id.to_string();
let parent_tool_use_id = child_id.clone();
Expand Down
2 changes: 1 addition & 1 deletion n00n-lua/src/api/async.rs
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,7 @@ mod tests {
fn cancelled_task_handle() -> TaskHandle {
let (trigger, token) = CancelToken::new();
trigger.cancel();
Arc::new(Mutex::new(TaskCell::new(token, None, None)))
Arc::new(Mutex::new(TaskCell::new(token, None, None, None)))
}

#[test_case(0 ; "zero_clamps_to_capacity_one")]
Expand Down
Loading
Loading