Compare commits

..
Author SHA1 Message Date
Emil Bogomolov 113137c5cd get the feature on par with current state 2026-03-24 14:49:23 -07:00
Emil Bogomolov 33d2ccadd6 add preliminary version of near intents with limited amount of tools 2026-03-24 10:14:26 -07:00
Henry ParkandGitHub dea789cca9 Default new lightweight routines to tools-enabled (#1573)
* Default new lightweight routines to tools-enabled

* Fix fmt and clippy on lightweight routine PR

* Use grouped execution field in routine no-tools fixture

* Align CLI routine defaults with tools-enabled lightweight mode
2026-03-23 11:01:26 -07:00
10 changed files with 1589 additions and 99 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"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,6 +1440,7 @@ 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(
+36 -83
View File
@@ -102,9 +102,6 @@ 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,
@@ -119,57 +116,51 @@ impl SessionManager {
external_thread_id: external_thread_id.map(String::from),
};
// 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 = {
// Check if we have a mapping
{
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);
}
}
}
// 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
// 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.
}
} 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
}
}
@@ -918,44 +909,6 @@ 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};
+47 -2
View File
@@ -340,8 +340,8 @@ async fn create(
prompt: prompt.to_string(),
context_paths: Vec::new(),
max_tokens: 4096,
use_tools: false,
max_tool_rounds: 0,
use_tools: true,
max_tool_rounds: 3,
},
guardrails: RoutineGuardrails {
cooldown: std::time::Duration::from_secs(cooldown_secs),
@@ -685,6 +685,7 @@ fn truncate(s: &str, max_chars: usize) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::routine::RoutineAction;
#[test]
fn format_relative_future() {
@@ -743,4 +744,48 @@ 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:?}"),
}
}
}
+79 -8
View File
@@ -140,7 +140,8 @@ fn execution_properties() -> Value {
},
"use_tools": {
"type": "boolean",
"description": "Only applies to lightweight mode. When true, safe non-approval tools are available."
"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."
},
"max_tool_rounds": {
"type": "integer",
@@ -290,7 +291,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'.",
"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.",
"properties": {
"mode": {
"type": "string",
@@ -304,7 +305,8 @@ fn lightweight_execution_variant() -> Value {
},
"use_tools": {
"type": "boolean",
"description": "When true, safe non-approval tools are available."
"default": true,
"description": "Defaults to true for new lightweight routines. When enabled, the routine can use the owner's live autonomous tool scope."
},
"max_tool_rounds": {
"type": "integer",
@@ -335,7 +337,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.",
"description": "Optional execution settings. Omit this block for the default lightweight mode with tools enabled.",
"properties": execution_properties(),
"oneOf": [
lightweight_execution_variant(),
@@ -408,7 +410,8 @@ 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.".into(),
"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 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(),
@@ -852,11 +855,15 @@ fn parse_execution_mode(value: Option<String>) -> Result<NormalizedExecutionMode
}
}
fn parse_routine_execution(params: &Value) -> Result<NormalizedExecutionRequest, ToolError> {
fn parse_routine_execution(
params: &Value,
default_use_tools: bool,
) -> 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(false);
let use_tools =
bool_field(params, "execution", "use_tools", &["use_tools"]).unwrap_or(default_use_tools);
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)
@@ -888,7 +895,7 @@ fn parse_routine_create_request(
.unwrap_or("")
.to_string();
let trigger = parse_routine_trigger(params)?;
let execution = parse_routine_execution(params)?;
let execution = parse_routine_execution(params, true)?;
let delivery = parse_routine_delivery(params);
let cooldown_secs =
u64_field(params, "advanced", "cooldown_secs", &["cooldown_secs"]).unwrap_or(300);
@@ -1863,6 +1870,56 @@ 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!({
@@ -2201,6 +2258,20 @@ 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
+47 -6
View File
@@ -205,11 +205,11 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 5: routine_manual_create
// Test 5: routine_manual_create_defaults_to_tools_enabled
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_manual_create() {
async fn routine_manual_create_defaults_to_tools_enabled() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_manual_create.json"
@@ -237,8 +237,8 @@ mod tests {
assert!(matches!(routine.trigger, Trigger::Manual));
assert!(
matches!(&routine.action, RoutineAction::Lightweight { use_tools, .. } if !*use_tools),
"manual routine should default to lightweight without tools: {:?}",
matches!(&routine.action, RoutineAction::Lightweight { use_tools, .. } if *use_tools),
"manual routine should default to lightweight with tools enabled: {:?}",
routine.action
);
@@ -246,7 +246,48 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 6: routine_history
// 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: {:?}",
routine.action
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 7: routine_history
// -----------------------------------------------------------------------
#[tokio::test]
@@ -283,7 +324,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 7: routine_system_event_emit
// Test 8: routine_system_event_emit
// -----------------------------------------------------------------------
#[tokio::test]
@@ -0,0 +1,39 @@
{
"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
@@ -0,0 +1,23 @@
[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]
@@ -0,0 +1,58 @@
{
"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