Compare commits

..
Author SHA1 Message Date
Claude e0d77b3424 style: fix formatting
https://claude.ai/code/session_01GG37cPSH8vfi9nriukuorf
2026-03-23 13:12:23 +00:00
ZakiandClaude Opus 4.6 e1754ce8c5 perf(agent): reduce lock contention in thread resolution (#1489)
Consolidate two separate read-lock acquisitions into one in
resolve_thread(). The write-lock upgrade still uses double-checked
locking but the read path is now a single acquisition.

Closes #1489

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-23 05:57:10 -07:00
10 changed files with 98 additions and 1588 deletions
-16
View File
@@ -1,16 +0,0 @@
{
"name": "near-intents",
"display_name": "Near Intents",
"kind": "tool",
"version": "0.1.0",
"wit_version": "0.3.0",
"description": "Token resolution, balance queries, and reverse lookups for NEAR Intents (Defuse protocol)",
"keywords": ["near", "intents", "defuse", "defi", "trading", "token"],
"source": {
"dir": "tools-src/near-intents",
"capabilities": "near-intents-tool.capabilities.json",
"crate_name": "near-intents-tool"
},
"artifacts": {},
"tags": ["trading"]
}
-1
View File
@@ -1440,7 +1440,6 @@ fn handle_text_response(
/// This is a simplified version of the full dispatcher loop:
/// - Max 3-5 iterations (configurable)
/// - Sequential tool execution (not parallel)
/// - Uses the owner's live autonomous tool scope when lightweight tools are enabled
/// - Auto-approval of non-Always tools
/// - No hooks or approval dialogs
async fn execute_lightweight_with_tools(
+83 -36
View File
@@ -102,6 +102,9 @@ impl SessionManager {
/// Resolve an external thread ID to an internal thread.
///
/// Returns the session and thread ID. Creates both if they don't exist.
///
/// Uses a single read-lock acquisition for both the key lookup and the UUID
/// adoption check to reduce contention under concurrent approval load.
pub async fn resolve_thread(
&self,
user_id: &str,
@@ -116,51 +119,57 @@ impl SessionManager {
external_thread_id: external_thread_id.map(String::from),
};
// Check if we have a mapping
{
// Parse UUID once outside the lock (if applicable)
let ext_uuid = external_thread_id.and_then(|s| Uuid::parse_str(s).ok());
// Single read lock for both the key lookup and UUID adoption check
let adoptable_uuid = {
let thread_map = self.thread_map.read().await;
// Fast path: exact key match
if let Some(&thread_id) = thread_map.get(&key) {
// Verify thread still exists in session
let sess = session.lock().await;
if sess.threads.contains_key(&thread_id) {
return (Arc::clone(&session), thread_id);
}
}
}
// Check if external_thread_id is itself a known thread UUID that
// exists in the session but was never registered in the thread_map
// (e.g. created by chat_new_thread_handler or hydrated from DB).
// We only adopt it if no thread_map entry maps to this UUID —
// otherwise it belongs to a different channel scope.
if let Some(ext_tid) = external_thread_id
&& let Ok(ext_uuid) = Uuid::parse_str(ext_tid)
{
let thread_map = self.thread_map.read().await;
let mapped_elsewhere = thread_map.values().any(|&v| v == ext_uuid);
drop(thread_map);
if !mapped_elsewhere {
let sess = session.lock().await;
if sess.threads.contains_key(&ext_uuid) {
drop(sess);
let mut thread_map = self.thread_map.write().await;
// Re-check after acquiring write lock to prevent race condition
// where another task mapped this UUID between our read and write.
if !thread_map.values().any(|&v| v == ext_uuid) {
thread_map.insert(key, ext_uuid);
drop(thread_map);
// Ensure undo manager exists
let mut undo_managers = self.undo_managers.write().await;
undo_managers
.entry(ext_uuid)
.or_insert_with(|| Arc::new(Mutex::new(UndoManager::new())));
return (session, ext_uuid);
}
// If it was mapped elsewhere while we were unlocked, fall through
// to create a new thread, preserving channel isolation.
// UUID adoption check (still under the same read lock).
// If external_thread_id is a valid UUID not mapped elsewhere,
// it may be a thread created by chat_new_thread_handler or
// hydrated from DB that we can adopt.
if let Some(ext_uuid) = ext_uuid {
let mapped_elsewhere = thread_map.values().any(|&v| v == ext_uuid);
if !mapped_elsewhere {
Some(ext_uuid)
} else {
None
}
} else {
None
}
}; // Single read lock dropped here
// If we found an adoptable UUID, verify it exists in session and acquire write lock
if let Some(ext_uuid) = adoptable_uuid {
let sess = session.lock().await;
if sess.threads.contains_key(&ext_uuid) {
drop(sess);
let mut thread_map = self.thread_map.write().await;
// Re-check after acquiring write lock to prevent race condition
// where another task mapped this UUID between our read and write.
if !thread_map.values().any(|&v| v == ext_uuid) {
thread_map.insert(key, ext_uuid);
drop(thread_map);
// Ensure undo manager exists
let mut undo_managers = self.undo_managers.write().await;
undo_managers
.entry(ext_uuid)
.or_insert_with(|| Arc::new(Mutex::new(UndoManager::new())));
return (session, ext_uuid);
}
// If mapped elsewhere while unlocked, fall through to create new thread
}
}
@@ -909,6 +918,44 @@ mod tests {
}
}
#[tokio::test]
async fn test_resolve_thread_consolidates_read_path() {
// Verify that resolve_thread still correctly handles:
// 1. Fast path: key exists in thread_map
// 2. UUID adoption: external_thread_id is a UUID in session but not in map
// 3. New thread: neither path matches
use crate::agent::session::Thread;
let manager = SessionManager::new();
// Case 1: Normal resolution creates thread and maps it
let (session1, tid1) = manager
.resolve_thread("user1", "chan1", Some("ext-1"))
.await;
// Resolving again with same key should return same thread (fast path)
let (_, tid1_again) = manager
.resolve_thread("user1", "chan1", Some("ext-1"))
.await;
assert_eq!(tid1, tid1_again);
// Case 2: UUID adoption - insert a thread directly into session
let adopted_id = Uuid::new_v4();
{
let mut sess = session1.lock().await;
let thread = Thread::with_id(adopted_id, sess.id);
sess.threads.insert(adopted_id, thread);
}
// Resolve with the UUID as external_thread_id -- should adopt it
let (_, resolved) = manager
.resolve_thread("user1", "chan1", Some(&adopted_id.to_string()))
.await;
assert_eq!(resolved, adopted_id);
// Case 3: Different channel gets different thread
let (_, tid2) = manager.resolve_thread("user1", "chan2", None).await;
assert_ne!(tid1, tid2);
}
#[tokio::test]
async fn test_resolve_thread_finds_existing_session_thread_by_uuid() {
use crate::agent::session::{Session, Thread};
+2 -47
View File
@@ -340,8 +340,8 @@ async fn create(
prompt: prompt.to_string(),
context_paths: Vec::new(),
max_tokens: 4096,
use_tools: true,
max_tool_rounds: 3,
use_tools: false,
max_tool_rounds: 0,
},
guardrails: RoutineGuardrails {
cooldown: std::time::Duration::from_secs(cooldown_secs),
@@ -685,7 +685,6 @@ fn truncate(s: &str, max_chars: usize) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::routine::RoutineAction;
#[test]
fn format_relative_future() {
@@ -744,48 +743,4 @@ mod tests {
assert!(notify.on_failure); // safety: test-only assertion
assert!(!notify.on_success); // safety: test-only assertion
}
#[cfg(feature = "libsql")]
#[tokio::test]
async fn cli_create_defaults_lightweight_routines_to_tools_enabled() {
let harness = crate::testing::TestHarnessBuilder::new().build().await;
let db = harness.db.clone();
run_routines_command(
RoutinesCommand::Create {
name: "cli-digest".to_string(),
schedule: "0 0 9 * * *".to_string(),
prompt: "Prepare the morning digest.".to_string(),
description: "CLI created routine".to_string(),
timezone: Some("UTC".to_string()),
cooldown: 300,
notify_channel: None,
},
db.clone(),
"user1",
)
.await
.expect("create routine");
let routine = db
.get_routine_by_name("user1", "cli-digest")
.await
.expect("get routine by name")
.expect("cli-digest should exist");
match routine.action {
RoutineAction::Lightweight {
use_tools,
max_tool_rounds,
..
} => {
assert!(
use_tools,
"CLI-created lightweight routines should default to tools"
);
assert_eq!(max_tool_rounds, 3);
}
other => panic!("expected lightweight action, got {other:?}"),
}
}
}
+8 -79
View File
@@ -140,8 +140,7 @@ fn execution_properties() -> Value {
},
"use_tools": {
"type": "boolean",
"default": true,
"description": "Only applies to lightweight mode. New lightweight routines default this to true; when enabled, the routine can use the owner's live autonomous tool scope."
"description": "Only applies to lightweight mode. When true, safe non-approval tools are available."
},
"max_tool_rounds": {
"type": "integer",
@@ -291,7 +290,7 @@ fn routine_request_discovery_schema() -> Value {
fn lightweight_execution_variant() -> Value {
serde_json::json!({
"type": "object",
"description": "Default lightweight execution. Applies when execution is omitted or execution.mode='lightweight'. New lightweight routines default to tools enabled unless execution.use_tools=false is set.",
"description": "Default lightweight execution. Applies when execution is omitted or execution.mode='lightweight'.",
"properties": {
"mode": {
"type": "string",
@@ -305,8 +304,7 @@ fn lightweight_execution_variant() -> Value {
},
"use_tools": {
"type": "boolean",
"default": true,
"description": "Defaults to true for new lightweight routines. When enabled, the routine can use the owner's live autonomous tool scope."
"description": "When true, safe non-approval tools are available."
},
"max_tool_rounds": {
"type": "integer",
@@ -337,7 +335,7 @@ fn full_job_execution_variant() -> Value {
fn execution_discovery_schema() -> Value {
serde_json::json!({
"type": "object",
"description": "Optional execution settings. Omit this block for the default lightweight mode with tools enabled.",
"description": "Optional execution settings. Omit this block for the default lightweight mode.",
"properties": execution_properties(),
"oneOf": [
lightweight_execution_variant(),
@@ -410,8 +408,7 @@ fn routine_create_tool_summary() -> ToolDiscoverySummary {
"execution.mode='full_job' uses the owner's live autonomous tool scope and ignores use_tools, max_tool_rounds, and context_paths.".into(),
],
notes: vec![
"Omitting execution defaults to lightweight mode with tools enabled.".into(),
"Set execution.use_tools=false to keep a new lightweight routine text-only.".into(),
"Omitting execution defaults to lightweight mode.".into(),
"Omitting delivery.user falls back to the owner's last-seen notification target.".into(),
"advanced.cooldown_secs defaults to 300.".into(),
"Legacy flat aliases are still accepted for compatibility, but grouped fields are preferred.".into(),
@@ -855,15 +852,11 @@ fn parse_execution_mode(value: Option<String>) -> Result<NormalizedExecutionMode
}
}
fn parse_routine_execution(
params: &Value,
default_use_tools: bool,
) -> Result<NormalizedExecutionRequest, ToolError> {
fn parse_routine_execution(params: &Value) -> Result<NormalizedExecutionRequest, ToolError> {
let mode = parse_execution_mode(string_field(params, "execution", "mode", &["action_type"]))?;
let context_paths =
string_array_field(params, "execution", "context_paths", &["context_paths"]);
let use_tools =
bool_field(params, "execution", "use_tools", &["use_tools"]).unwrap_or(default_use_tools);
let use_tools = bool_field(params, "execution", "use_tools", &["use_tools"]).unwrap_or(false);
let max_tool_rounds = u64_field(params, "execution", "max_tool_rounds", &["max_tool_rounds"])
.unwrap_or(3)
.clamp(1, crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT as u64)
@@ -895,7 +888,7 @@ fn parse_routine_create_request(
.unwrap_or("")
.to_string();
let trigger = parse_routine_trigger(params)?;
let execution = parse_routine_execution(params, true)?;
let execution = parse_routine_execution(params)?;
let delivery = parse_routine_delivery(params);
let cooldown_secs =
u64_field(params, "advanced", "cooldown_secs", &["cooldown_secs"]).unwrap_or(300);
@@ -1870,56 +1863,6 @@ mod tests {
);
}
#[test]
fn parses_lightweight_create_with_tools_enabled_by_default() {
let params = serde_json::json!({
"name": "manual-check",
"prompt": "Inspect the repo for issues.",
"request": {
"kind": "manual"
}
});
let parsed = parse_routine_create_request(&params).expect("parse default lightweight");
assert!(
matches!(parsed.execution.mode, NormalizedExecutionMode::Lightweight),
"expected lightweight execution mode",
);
assert!(
parsed.execution.use_tools,
"new lightweight routines should default use_tools=true",
);
assert_eq!(parsed.execution.max_tool_rounds, 3);
}
#[test]
fn parses_lightweight_create_with_explicit_tools_disabled() {
let params = serde_json::json!({
"name": "manual-check",
"prompt": "Inspect the repo for issues.",
"request": {
"kind": "manual"
},
"execution": {
"use_tools": false
}
});
let parsed =
parse_routine_create_request(&params).expect("parse lightweight with tools disabled");
assert!(
matches!(parsed.execution.mode, NormalizedExecutionMode::Lightweight),
"expected lightweight execution mode",
);
assert!(
!parsed.execution.use_tools,
"explicit use_tools=false should be preserved",
);
assert_eq!(parsed.execution.max_tool_rounds, 3);
}
#[test]
fn parses_context_paths_with_trim_drop_empty_and_stable_dedupe() {
let params = serde_json::json!({
@@ -2258,20 +2201,6 @@ mod tests {
.any(|rule| rule.contains("request.kind='cron'")),
"summary should explain cron requirement",
);
assert!(
summary
.notes
.iter()
.any(|note| note.contains("lightweight mode with tools enabled")),
"summary should mention the new lightweight default",
);
assert!(
summary
.notes
.iter()
.any(|note| note.contains("execution.use_tools=false")),
"summary should mention the text-only opt-out",
);
assert!(
summary
.notes
+5 -46
View File
@@ -205,11 +205,11 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 5: routine_manual_create_defaults_to_tools_enabled
// Test 5: routine_manual_create
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_manual_create_defaults_to_tools_enabled() {
async fn routine_manual_create() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_manual_create.json"
@@ -235,51 +235,10 @@ mod tests {
.expect("get_routine_by_name")
.expect("manual-triage should exist");
assert!(matches!(routine.trigger, Trigger::Manual));
assert!(
matches!(&routine.action, RoutineAction::Lightweight { use_tools, .. } if *use_tools),
"manual routine should default to lightweight with tools enabled: {:?}",
routine.action
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 6: routine_manual_create_explicit_no_tools
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_manual_create_explicit_no_tools() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_manual_create_no_tools.json"
))
.expect("failed to load routine_manual_create_no_tools.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Create a manual routine for quiet text-only bug triage")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let routine = rig
.database()
.get_routine_by_name("test-user", "manual-triage-no-tools")
.await
.expect("get_routine_by_name")
.expect("manual-triage-no-tools should exist");
assert!(matches!(routine.trigger, Trigger::Manual));
assert!(
matches!(&routine.action, RoutineAction::Lightweight { use_tools, .. } if !*use_tools),
"manual routine should preserve explicit use_tools=false: {:?}",
"manual routine should default to lightweight without tools: {:?}",
routine.action
);
@@ -287,7 +246,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 7: routine_history
// Test 6: routine_history
// -----------------------------------------------------------------------
#[tokio::test]
@@ -324,7 +283,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 8: routine_system_event_emit
// Test 7: routine_system_event_emit
// -----------------------------------------------------------------------
#[tokio::test]
@@ -1,39 +0,0 @@
{
"model_name": "test-routine-manual-create-no-tools",
"expects": {
"tools_used": ["routine_create"],
"all_tools_succeeded": true,
"min_responses": 1
},
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rc_manual_2",
"name": "routine_create",
"arguments": {
"name": "manual-triage-no-tools",
"trigger_type": "manual",
"prompt": "Summarize the latest bug reports when this routine is fired.",
"execution": {
"use_tools": false
}
}
}
],
"input_tokens": 90,
"output_tokens": 24
}
},
{
"response": {
"type": "text",
"content": "Created the manual-triage-no-tools routine. It will only run when explicitly fired and stay text-only.",
"input_tokens": 140,
"output_tokens": 18
}
}
]
}
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "near-intents-tool"
version = "0.1.0"
edition = "2021"
description = "Near Intents tools for token resolution, balance queries, and reverse lookups (WASM component)"
license = "MIT OR Apache-2.0"
publish = false
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wit-bindgen = "0.41.0"
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
[workspace]
@@ -1,58 +0,0 @@
{
"version": "0.1.0",
"wit_version": "0.3.0",
"description": "Near Intents tools for token resolution, balance queries, and reverse lookups on the Defuse protocol.",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["resolve_token", "reverse_resolve_token", "get_balance"],
"description": "Which action to perform"
},
"query": {
"type": "string",
"description": "Token reference to resolve (for resolve_token)"
},
"list_all": {
"type": "boolean",
"description": "Return all tokens (for resolve_token)",
"default": false
},
"asset_id": {
"type": "string",
"description": "Defuse asset ID (for reverse_resolve_token)"
},
"account_id": {
"type": "string",
"description": "NEAR wallet address (for get_balance)"
},
"token_ids": {
"type": "array",
"items": { "type": "string" },
"description": "Specific defuse asset IDs to query (for get_balance)"
}
},
"required": ["action"]
},
"capabilities": {
"http": {
"allowlist": [
{
"host": "1click.chaindefuser.com",
"path_prefix": "/v0/tokens",
"methods": ["GET"]
},
{
"host": "rpc.mainnet.near.org",
"path_prefix": "/",
"methods": ["POST"]
}
],
"rate_limit": {
"requests_per_minute": 60
}
}
},
"tags": ["trading", "near", "defi", "intents"]
}
File diff suppressed because it is too large Load Diff