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
11 changed files with 1555 additions and 280 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"]
}
+2 -260
View File
@@ -7,10 +7,9 @@
//! - `commands` - System commands and job handlers
//! - `thread_ops` - Thread/session operations (user input, undo, approval, persistence)
use std::sync::{Arc, LazyLock};
use std::sync::Arc;
use futures::StreamExt;
use regex::Regex;
use uuid::Uuid;
use crate::agent::context_monitor::ContextMonitor;
@@ -63,38 +62,6 @@ pub(crate) fn truncate_for_preview(output: &str, max_chars: usize) -> String {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SensitiveChatCredential {
TelegramBotToken,
}
impl SensitiveChatCredential {
fn extension_name(self) -> &'static str {
match self {
Self::TelegramBotToken => "telegram",
}
}
fn redirect_message(self) -> &'static str {
match self {
Self::TelegramBotToken => {
"Telegram bot tokens can't be accepted in normal chat. Use the secure Telegram setup flow instead."
}
}
}
}
static TELEGRAM_BOT_TOKEN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\d{6,}:[A-Za-z0-9_-]{20,}$").expect("TELEGRAM_BOT_TOKEN_RE")); // safety: hardcoded literal
fn detect_sensitive_chat_credential(content: &str) -> Option<SensitiveChatCredential> {
let trimmed = content.trim();
if TELEGRAM_BOT_TOKEN_RE.is_match(trimmed) {
return Some(SensitiveChatCredential::TelegramBotToken);
}
None
}
#[cfg(test)]
fn resolve_routine_notification_user(metadata: &serde_json::Value) -> Option<String> {
resolve_owner_scope_notification_user(
@@ -224,28 +191,6 @@ pub struct Agent {
}
impl Agent {
async fn intercept_sensitive_chat_credential(
&self,
message: &IncomingMessage,
credential: SensitiveChatCredential,
) -> String {
let instructions = credential.redirect_message().to_string();
let _ = self
.channels
.send_status(
&message.channel,
crate::channels::StatusUpdate::AuthRequired {
extension_name: credential.extension_name().to_string(),
instructions: Some(instructions.clone()),
auth_url: None,
setup_url: None,
},
&message.metadata,
)
.await;
instructions
}
pub(super) fn owner_id(&self) -> &str {
if let Some(workspace) = self.deps.workspace.as_ref() {
debug_assert_eq!(
@@ -1180,15 +1125,6 @@ impl Agent {
}
}
if let Submission::UserInput { ref content } = submission {
if let Some(credential) = detect_sensitive_chat_credential(content) {
return Ok(Some(
self.intercept_sensitive_chat_credential(message, credential)
.await,
));
}
}
tracing::trace!(
"Received message from {} on {} ({} chars)",
message.user_id,
@@ -1385,26 +1321,11 @@ impl Agent {
#[cfg(test)]
mod tests {
use super::{
Agent, AgentDeps, SensitiveChatCredential, chat_tool_execution_metadata,
detect_sensitive_chat_credential, resolve_routine_notification_user,
chat_tool_execution_metadata, resolve_routine_notification_user,
should_fallback_routine_notification, truncate_for_preview,
};
use crate::agent::session::Thread;
use crate::channels::IncomingMessage;
use crate::error::ChannelError;
use crate::testing::{StubChannel, StubLlm};
use crate::{
agent::cost_guard::{CostGuard, CostGuardConfig},
channels::{ChannelManager, StatusUpdate},
config::{AgentConfig, SafetyConfig, SkillsConfig},
context::ContextManager,
hooks::HookRegistry,
safety::SafetyLayer,
tools::ToolRegistry,
};
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
#[test]
fn test_truncate_short_input() {
@@ -1562,183 +1483,4 @@ mod tests {
assert!(should_fallback_routine_notification(&error)); // safety: test-only assertion
}
#[test]
fn detects_telegram_bot_token_messages() {
let detected = detect_sensitive_chat_credential("123456789:AABBccDDeeFFgg_Test-Token");
assert_eq!(detected, Some(SensitiveChatCredential::TelegramBotToken));
}
#[test]
fn ignores_normal_telegram_setup_messages() {
let detected = detect_sensitive_chat_credential(
"Can you help me connect Telegram without sharing the token here?",
);
assert_eq!(detected, None);
}
async fn make_gateway_test_agent(
llm: Arc<StubLlm>,
) -> (Agent, Arc<std::sync::Mutex<Vec<StatusUpdate>>>) {
let llm_provider: Arc<dyn crate::llm::LlmProvider> = llm;
let (stub, _sender) = StubChannel::new("gateway");
let statuses = stub.captured_statuses_handle();
let channel_manager = ChannelManager::new();
channel_manager.add(Box::new(stub)).await;
let deps = AgentDeps {
owner_id: "default".to_string(),
store: None,
llm: llm_provider,
cheap_llm: None,
safety: Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
})),
tools: Arc::new(ToolRegistry::new()),
workspace: None,
extension_manager: None,
skill_registry: None,
skill_catalog: None,
skills_config: SkillsConfig::default(),
hooks: Arc::new(HookRegistry::new()),
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
sse_tx: None,
http_interceptor: None,
transcription: None,
document_extraction: None,
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
builder: None,
};
let agent = Agent::new(
AgentConfig {
name: "test-agent".to_string(),
max_parallel_jobs: 1,
job_timeout: Duration::from_secs(60),
stuck_threshold: Duration::from_secs(60),
repair_check_interval: Duration::from_secs(30),
max_repair_attempts: 1,
use_planning: false,
session_idle_timeout: Duration::from_secs(300),
allow_local_tools: false,
max_cost_per_day_cents: None,
max_actions_per_hour: None,
max_tool_iterations: 5,
auto_approve_tools: false,
default_timezone: "UTC".to_string(),
max_tokens_per_job: 0,
},
deps,
Arc::new(channel_manager),
None,
None,
None,
Some(Arc::new(ContextManager::new(1))),
None,
);
(agent, statuses)
}
#[tokio::test]
async fn telegram_bot_token_messages_are_redirected_before_llm() {
let llm = Arc::new(StubLlm::new("this should never be used"));
let llm_handle = Arc::clone(&llm);
let (agent, statuses) = make_gateway_test_agent(llm).await;
let message = IncomingMessage::new(
"gateway",
"test-user",
"123456789:AABBccDDeeFFgg_Test-Token",
);
let response = agent
.handle_message(&message)
.await
.expect("handle_message");
assert_eq!(
response.as_deref(),
Some(
"Telegram bot tokens can't be accepted in normal chat. Use the secure Telegram setup flow instead."
)
);
assert_eq!(llm_handle.calls(), 0, "LLM should not see raw bot tokens");
let statuses = statuses.lock().expect("poisoned");
assert_eq!(statuses.len(), 1);
assert!(matches!(
&statuses[0],
StatusUpdate::AuthRequired {
extension_name,
instructions,
auth_url: None,
setup_url: None,
} if extension_name == "telegram"
&& instructions.as_deref()
== Some(
"Telegram bot tokens can't be accepted in normal chat. Use the secure Telegram setup flow instead."
)
));
}
#[tokio::test]
async fn telegram_bot_token_messages_still_flow_through_pending_auth_mode() {
let llm = Arc::new(StubLlm::new("this should never be used"));
let llm_handle = Arc::clone(&llm);
let (agent, statuses) = make_gateway_test_agent(llm).await;
let thread_id = Uuid::new_v4();
let session = agent
.session_manager
.get_or_create_session("test-user")
.await;
{
let mut sess = session.lock().await;
let mut thread = Thread::with_id(thread_id, sess.id);
thread.enter_auth_mode("telegram".to_string());
sess.threads.insert(thread_id, thread);
sess.active_thread = Some(thread_id);
}
agent
.session_manager
.register_thread("test-user", "gateway", thread_id, Arc::clone(&session))
.await;
let message = IncomingMessage::new(
"gateway",
"test-user",
"123456789:AABBccDDeeFFgg_Test-Token",
)
.with_thread(thread_id.to_string());
let response = agent
.handle_message(&message)
.await
.expect("handle_message");
assert_eq!(
response.as_deref(),
Some("Extension manager not available."),
"pending auth should consume the token instead of treating it as normal chat"
);
assert_eq!(llm_handle.calls(), 0, "LLM should not see auth-mode tokens");
let statuses = statuses.lock().expect("poisoned");
assert!(
statuses.is_empty(),
"no redirect status should be emitted when auth mode consumes the token"
);
let sess = session.lock().await;
let pending_auth = sess
.threads
.get(&thread_id)
.and_then(|thread| thread.pending_auth.as_ref());
assert!(
pending_auth.is_none(),
"auth mode should be cleared after the token is processed"
);
}
}
+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(
+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
-4
View File
@@ -35,10 +35,6 @@ If they're interested, set it up right here using the extension tools:
3. Use `tool_auth` to collect credentials (e.g. Telegram bot token from @BotFather)
4. The channel will be hot-activated — no restart needed
Never ask the user to paste tokens, passwords, API keys, or other secrets into
normal chat. If an extension has a secure auth/setup flow, always use that flow
and keep the secret out of the conversation history.
Don't push if they're not interested — note their preference and move on.
## Step 3: Save What You Learned (MANDATORY after 3 user messages)
+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