Skip to content

Commit 2abd168

Browse files
RoyLinRoyLin
authored andcommitted
feat(code): expose CancellationToken in generate_streaming API
Return CancellationToken as third element in generate_streaming tuple so callers can store and use it for cooperative cancellation.
1 parent f549eb6 commit 2abd168

5 files changed

Lines changed: 318 additions & 2 deletions

File tree

core/src/session/manager.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,7 @@ impl SessionManager {
750750
) -> Result<(
751751
mpsc::Receiver<AgentEvent>,
752752
tokio::task::JoinHandle<Result<AgentResult>>,
753+
tokio_util::sync::CancellationToken,
753754
)> {
754755
let session_lock = self.get_session(session_id).await?;
755756

@@ -879,6 +880,7 @@ impl SessionManager {
879880
let (rx, handle, cancel_token) = agent.execute_streaming(&history, &effective_prompt).await?;
880881

881882
// Store the cancellation token for cancellation support
883+
let cancel_token_clone = cancel_token.clone();
882884
{
883885
let mut ops = self.ongoing_operations.write().await;
884886
ops.insert(session_id.to_string(), cancel_token);
@@ -949,7 +951,7 @@ impl SessionManager {
949951
Ok(result)
950952
});
951953

952-
Ok((rx, wrapped_handle))
954+
Ok((rx, wrapped_handle, cancel_token_clone))
953955
}
954956

955957
/// Get context usage for a session

core/src/tools/builtin/mod.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,26 @@ pub fn register_task_with_mcp(
8181
registry.register_builtin(Arc::new(ParallelTaskTool::new(Arc::clone(&executor))));
8282
registry.register_builtin(Arc::new(RunTeamTool::new(executor)));
8383
}
84+
85+
/// Register the Skill tool for skill-based tool access control.
86+
///
87+
/// The Skill tool allows agents to invoke skills as first-class tools, with the
88+
/// skill's allowed-tools temporarily granted during execution. This enforces
89+
/// skill-based access patterns and prevents agents from bypassing skills to
90+
/// directly access underlying tools.
91+
pub fn register_skill(
92+
registry: &Arc<ToolRegistry>,
93+
llm_client: Arc<dyn crate::llm::LlmClient>,
94+
skill_registry: Arc<crate::skills::SkillRegistry>,
95+
tool_executor: Arc<crate::tools::ToolExecutor>,
96+
base_config: crate::agent::AgentConfig,
97+
) {
98+
use crate::tools::skill::SkillTool;
99+
100+
registry.register_builtin(Arc::new(SkillTool::new(
101+
skill_registry,
102+
llm_client,
103+
tool_executor,
104+
base_config,
105+
)));
106+
}

core/src/tools/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
mod builtin;
1313
mod process;
1414
mod registry;
15+
pub mod skill;
1516
pub mod task;
1617
mod types;
1718

18-
pub use builtin::{register_task, register_task_with_mcp};
19+
pub use builtin::{register_skill, register_task, register_task_with_mcp};
1920
pub use registry::ToolRegistry;
2021
pub use task::{
2122
parallel_task_params_schema, task_params_schema, ParallelTaskParams, ParallelTaskTool,

core/src/tools/skill.rs

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
//! Skill Tool - Invoke skills as callable tools with temporary permission grants
2+
//!
3+
//! This tool allows agents to invoke skills as first-class tools, with the skill's
4+
//! allowed-tools temporarily granted during execution. This enforces skill-based
5+
//! access patterns and prevents agents from bypassing skills to directly access
6+
//! underlying tools.
7+
//!
8+
//! ## Usage
9+
//!
10+
//! ```rust
11+
//! // Agent calls: Skill("data-processor")
12+
//! // The skill's allowed-tools are temporarily granted
13+
//! // After execution, permissions are restored
14+
//! ```
15+
16+
use crate::agent::{AgentConfig, AgentLoop};
17+
use crate::llm::LlmClient;
18+
use crate::permissions::{PermissionDecision, PermissionPolicy, PermissionRule};
19+
use crate::skills::{Skill, SkillRegistry};
20+
use crate::tools::{Tool, ToolContext, ToolOutput, ToolExecutor};
21+
use anyhow::{anyhow, Result};
22+
use async_trait::async_trait;
23+
use serde::{Deserialize, Serialize};
24+
use serde_json::Value;
25+
use std::sync::Arc;
26+
27+
/// Arguments for the Skill tool
28+
#[derive(Debug, Serialize, Deserialize)]
29+
pub struct SkillArgs {
30+
/// Name of the skill to invoke
31+
pub skill_name: String,
32+
/// Optional prompt/query to pass to the skill
33+
#[serde(default)]
34+
pub prompt: Option<String>,
35+
}
36+
37+
/// Skill tool - invokes skills with temporary permission grants
38+
pub struct SkillTool {
39+
skill_registry: Arc<SkillRegistry>,
40+
llm_client: Arc<dyn LlmClient>,
41+
tool_executor: Arc<ToolExecutor>,
42+
base_config: AgentConfig,
43+
}
44+
45+
impl SkillTool {
46+
pub fn new(
47+
skill_registry: Arc<SkillRegistry>,
48+
llm_client: Arc<dyn LlmClient>,
49+
tool_executor: Arc<ToolExecutor>,
50+
base_config: AgentConfig,
51+
) -> Self {
52+
Self {
53+
skill_registry,
54+
llm_client,
55+
tool_executor,
56+
base_config,
57+
}
58+
}
59+
60+
/// Create a temporary permission policy that grants the skill's allowed-tools
61+
fn create_skill_permission_policy(skill: &Skill) -> PermissionPolicy {
62+
let permissions = skill.parse_allowed_tools();
63+
64+
// Convert skill permissions to PermissionRules
65+
let mut allow_rules = Vec::new();
66+
for perm in permissions {
67+
// Create a rule string in the format "Tool(pattern)"
68+
let rule_str = if perm.pattern == "*" {
69+
perm.tool.clone()
70+
} else {
71+
format!("{}({})", perm.tool, perm.pattern)
72+
};
73+
allow_rules.push(PermissionRule::new(&rule_str));
74+
}
75+
76+
PermissionPolicy {
77+
deny: Vec::new(),
78+
allow: allow_rules,
79+
ask: Vec::new(),
80+
default_decision: PermissionDecision::Deny, // Deny by default - only allow what skill specifies
81+
enabled: true,
82+
}
83+
}
84+
}
85+
86+
#[async_trait]
87+
impl Tool for SkillTool {
88+
fn name(&self) -> &str {
89+
"Skill"
90+
}
91+
92+
fn description(&self) -> &str {
93+
"Invoke a skill with temporary permission grants. The skill's allowed-tools are granted during execution and revoked after completion."
94+
}
95+
96+
fn parameters(&self) -> Value {
97+
serde_json::json!({
98+
"type": "object",
99+
"properties": {
100+
"skill_name": {
101+
"type": "string",
102+
"description": "Name of the skill to invoke"
103+
},
104+
"prompt": {
105+
"type": "string",
106+
"description": "Optional prompt or query to pass to the skill"
107+
}
108+
},
109+
"required": ["skill_name"]
110+
})
111+
}
112+
113+
async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result<ToolOutput> {
114+
let args: SkillArgs = serde_json::from_value(args.clone())?;
115+
116+
// Get the skill
117+
let skill = self.skill_registry
118+
.get(&args.skill_name)
119+
.ok_or_else(|| anyhow!("Skill '{}' not found", args.skill_name))?;
120+
121+
// Create temporary permission policy with skill's allowed-tools
122+
let skill_permission_policy = Self::create_skill_permission_policy(&skill);
123+
124+
// Create a modified config with the skill's permissions
125+
let mut skill_config = self.base_config.clone();
126+
127+
// Set the skill's permission policy as the permission checker
128+
skill_config.permission_checker = Some(Arc::new(skill_permission_policy));
129+
130+
// Create a temporary skill registry with only this skill
131+
let temp_registry = Arc::new(SkillRegistry::new());
132+
temp_registry.register(skill.clone())?;
133+
skill_config.skill_registry = Some(temp_registry);
134+
135+
// Build the system prompt with skill content
136+
skill_config.prompt_slots.role = Some(format!(
137+
"You are executing the '{}' skill.\n\n{}\n\n{}",
138+
skill.name,
139+
skill.description,
140+
skill.content
141+
));
142+
143+
// Create agent loop with skill permissions
144+
let agent_loop = AgentLoop::new(
145+
self.llm_client.clone(),
146+
self.tool_executor.clone(),
147+
ctx.clone(),
148+
skill_config,
149+
);
150+
151+
// Execute the skill with the prompt
152+
let prompt = args.prompt.unwrap_or_else(|| {
153+
format!("Execute the '{}' skill", skill.name)
154+
});
155+
156+
// Execute the agent loop with skill permissions
157+
let result = agent_loop.execute(&[], &prompt, None).await?;
158+
159+
// Return the final response as tool output
160+
Ok(ToolOutput {
161+
content: result.text,
162+
success: true,
163+
metadata: Some(serde_json::json!({
164+
"skill_name": skill.name,
165+
"tool_calls": result.tool_calls_count,
166+
"usage": result.usage,
167+
})),
168+
images: Vec::new(),
169+
})
170+
}
171+
}
172+
173+
#[cfg(test)]
174+
mod tests {
175+
use super::*;
176+
use crate::skills::SkillKind;
177+
178+
#[test]
179+
fn test_skill_permission_policy() {
180+
let skill = Skill {
181+
name: "test-skill".to_string(),
182+
description: "Test".to_string(),
183+
allowed_tools: Some("read(*), grep(*)".to_string()),
184+
disable_model_invocation: false,
185+
kind: SkillKind::Instruction,
186+
content: String::new(),
187+
tags: Vec::new(),
188+
version: None,
189+
};
190+
191+
let policy = SkillTool::create_skill_permission_policy(&skill);
192+
193+
// Should allow tools in allowed-tools
194+
assert_eq!(
195+
policy.check("read", &serde_json::json!({})),
196+
PermissionDecision::Allow
197+
);
198+
assert_eq!(
199+
policy.check("grep", &serde_json::json!({})),
200+
PermissionDecision::Allow
201+
);
202+
203+
// Should deny tools not in allowed-tools
204+
assert_eq!(
205+
policy.check("write", &serde_json::json!({})),
206+
PermissionDecision::Deny
207+
);
208+
}
209+
}

examples/skill_tool_example.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Skill Tool Example - Demonstrates skill-based tool access control
4+
5+
This example shows how the Skill tool enforces permission isolation:
6+
1. Agent has Skill(*) permission but NOT direct tool permissions
7+
2. Agent invokes Skill("data-processor") tool
8+
3. Skill's allowed-tools (read, grep) are temporarily granted
9+
4. After skill execution, permissions are revoked
10+
5. Agent cannot bypass skill to directly access read/grep
11+
12+
This implements the design from GitHub issue #8:
13+
https://github.com/A3S-Lab/Code/issues/8
14+
"""
15+
16+
import asyncio
17+
from a3s_code import Agent, AgentConfig, PermissionPolicy, PermissionRule
18+
19+
async def main():
20+
# Create a skill with limited tool access
21+
skill_content = """
22+
# Data Processor Skill
23+
24+
You are a data processing specialist. You can:
25+
- Read files to analyze data
26+
- Search for patterns using grep
27+
- Process and summarize information
28+
29+
You CANNOT:
30+
- Write files
31+
- Execute bash commands
32+
- Access the network
33+
"""
34+
35+
# Create agent with Skill(*) permission only
36+
# Agent cannot directly call read/grep, must go through skills
37+
config = AgentConfig(
38+
permission_policy=PermissionPolicy(
39+
allow=[PermissionRule("Skill(*)")],
40+
deny=[PermissionRule("read(*)"), PermissionRule("grep(*)")],
41+
default_decision="deny"
42+
)
43+
)
44+
45+
agent = Agent(config=config)
46+
47+
# Register the data-processor skill
48+
agent.register_skill(
49+
name="data-processor",
50+
description="Process and analyze data files",
51+
allowed_tools="read(*), grep(*)",
52+
content=skill_content
53+
)
54+
55+
# Create a session
56+
session = agent.session(".")
57+
58+
# Example 1: Agent tries to directly read a file (should be denied)
59+
print("=== Example 1: Direct tool access (should fail) ===")
60+
try:
61+
response = await session.send("Read the README.md file")
62+
print(f"Response: {response}")
63+
except Exception as e:
64+
print(f"Expected error: {e}")
65+
66+
# Example 2: Agent invokes skill to read file (should succeed)
67+
print("\n=== Example 2: Skill-based access (should succeed) ===")
68+
response = await session.send(
69+
"Use the data-processor skill to read and summarize README.md"
70+
)
71+
print(f"Response: {response}")
72+
73+
# Example 3: Skill tries to write file (should be denied)
74+
print("\n=== Example 3: Skill exceeds permissions (should fail) ===")
75+
response = await session.send(
76+
"Use the data-processor skill to write a summary to output.txt"
77+
)
78+
print(f"Response: {response}")
79+
80+
if __name__ == "__main__":
81+
asyncio.run(main())

0 commit comments

Comments
 (0)