Skip to content

Commit d8710fc

Browse files
ZhiXiao-Linclaude
andcommitted
feat(skills): default-on global skills with session merging and auto system prompt injection
- AgentConfig::default() now initializes skill_registry with built-in skills - build_session forks the agent-level registry so session skills never pollute global state - Session-level skill_dirs and skill_registry are merged on top of the global registry - Skill directory listing is always injected into the system prompt automatically - Add SkillRegistry::fork() for isolated per-session copies - Add SessionOptions::skill_dirs / with_skill_dirs() for lazy per-session skill loading - Fix with_skills_from_dir() fallback to use SkillRegistry::new() (not with_builtins) - Bump version to 0.9.2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 26e29ef commit d8710fc

15 files changed

Lines changed: 217 additions & 674 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-code-cli"
3-
version = "0.9.1"
3+
version = "0.9.2"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"

core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-code-core"
3-
version = "0.9.1"
3+
version = "0.9.2"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"

core/examples/01_basic_send.rs

Lines changed: 7 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,14 @@
1-
//! # Basic Send — Non-Streaming Agent Execution
2-
//!
3-
//! The simplest possible A3S Code example: create an agent from config,
4-
//! bind to a workspace, send a prompt, and print the result.
5-
//!
6-
//! ```bash
7-
//! cd crates/code
8-
//! cargo run --example 01_basic_send
9-
//! ```
10-
11-
use a3s_code_core::Agent;
12-
use std::path::PathBuf;
13-
use tempfile::TempDir;
14-
15-
fn find_config() -> PathBuf {
16-
if let Ok(p) = std::env::var("A3S_CONFIG") {
17-
return PathBuf::from(p);
18-
}
19-
let home = dirs::home_dir().expect("no home dir");
20-
let home_cfg = home.join(".a3s/config.hcl");
21-
if home_cfg.exists() {
22-
return home_cfg;
23-
}
24-
// Project root
25-
let project_cfg = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.a3s/config.hcl");
26-
if project_cfg.exists() {
27-
return project_cfg;
28-
}
29-
panic!("Config not found. Create ~/.a3s/config.hcl or set A3S_CONFIG");
30-
}
1+
use a3s_code_core::{Agent, SessionOptions};
312

323
#[tokio::main]
334
async fn main() -> anyhow::Result<()> {
34-
tracing_subscriber::fmt()
35-
.with_env_filter("a3s_code_core=info")
36-
.init();
37-
38-
let config = find_config();
39-
println!("Config: {}", config.display());
40-
41-
// 1. Create agent from config
42-
let agent = Agent::new(config.to_str().unwrap()).await?;
43-
println!("Agent created ✓");
5+
let agent = Agent::new("/Users/roylin/Desktop/ai-lab/a3s/.a3s/config.hcl").await?;
446

45-
// 2. Create a temp workspace
46-
let workspace = TempDir::new()?;
47-
let session = agent.session(workspace.path().to_str().unwrap(), None)?;
48-
println!("Session bound to: {}\n", workspace.path().display());
49-
50-
// 3. Send a prompt (non-streaming)
51-
let result = session
52-
.send(
53-
"Create a file called hello.rs with a main function that prints 'Hello, A3S!'.\n\
54-
Then read the file back and confirm it looks correct.",
55-
None,
56-
)
57-
.await?;
58-
59-
println!("─── Result ───");
60-
println!("Text: {}", truncate(&result.text, 200));
61-
println!("Tool calls: {}", result.tool_calls_count);
62-
println!("Tokens: {} total", result.usage.total_tokens);
63-
64-
// 4. Verify the file
65-
let hello = workspace.path().join("hello.rs");
66-
if hello.exists() {
67-
println!(
68-
"\n✓ hello.rs created ({} bytes)",
69-
std::fs::metadata(&hello)?.len()
70-
);
71-
}
7+
let opts = SessionOptions::new().with_permissive_policy();
8+
let session = agent.session("/tmp", Some(opts))?;
729

10+
let result = session.send("List the files in the current directory.", None).await?;
11+
println!("{}", result.text);
12+
println!("Tokens: {}", result.usage.total_tokens);
7313
Ok(())
7414
}
75-
76-
fn truncate(s: &str, max: usize) -> String {
77-
let s = s.trim();
78-
if s.len() <= max {
79-
s.to_string()
80-
} else {
81-
format!("{}…", &s[..max])
82-
}
83-
}

core/src/agent.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ impl Default for AgentConfig {
157157
planning_enabled: false,
158158
goal_tracking: false,
159159
hook_engine: None,
160-
skill_registry: None,
160+
skill_registry: Some(Arc::new(crate::skills::SkillRegistry::with_builtins())),
161161
max_parse_retries: 2,
162162
tool_timeout_ms: None,
163163
circuit_breaker_threshold: 3,
@@ -2708,6 +2708,11 @@ mod tests {
27082708
assert_eq!(config.max_tool_rounds, MAX_TOOL_ROUNDS);
27092709
assert!(config.permission_checker.is_none());
27102710
assert!(config.context_providers.is_empty());
2711+
// Built-in skills are always present by default
2712+
let registry = config.skill_registry.expect("skill_registry must be Some by default");
2713+
assert!(registry.len() >= 7, "expected at least 7 built-in skills");
2714+
assert!(registry.get("code-search").is_some());
2715+
assert!(registry.get("find-bugs").is_some());
27112716
}
27122717

27132718
// ========================================================================

core/src/agent_api.rs

Lines changed: 103 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ pub struct SessionOptions {
7777
pub planning_enabled: bool,
7878
/// Enable goal tracking
7979
pub goal_tracking: bool,
80+
/// Extra directories to scan for skill files (*.md).
81+
/// Merged with any global `skill_dirs` from [`CodeConfig`].
82+
pub skill_dirs: Vec<PathBuf>,
8083
/// Optional skill registry for instruction injection
8184
pub skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
8285
/// Optional memory store for long-term memory persistence
@@ -138,6 +141,7 @@ impl std::fmt::Debug for SessionOptions {
138141
f.debug_struct("SessionOptions")
139142
.field("model", &self.model)
140143
.field("agent_dirs", &self.agent_dirs)
144+
.field("skill_dirs", &self.skill_dirs)
141145
.field("queue_config", &self.queue_config)
142146
.field("security_provider", &self.security_provider.is_some())
143147
.field("context_providers", &self.context_providers.len())
@@ -278,7 +282,14 @@ impl SessionOptions {
278282
self
279283
}
280284

281-
/// Load skills from a directory
285+
/// Add skill directories to scan for skill files (*.md).
286+
/// Merged with any global `skill_dirs` from [`CodeConfig`] at session build time.
287+
pub fn with_skill_dirs(mut self, dirs: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
288+
self.skill_dirs.extend(dirs.into_iter().map(Into::into));
289+
self
290+
}
291+
292+
/// Load skills from a directory (eager — scans immediately into a registry).
282293
pub fn with_skills_from_dir(mut self, dir: impl AsRef<std::path::Path>) -> Self {
283294
let registry = self
284295
.skill_registry
@@ -503,7 +514,19 @@ impl Agent {
503514
/// Auto-detects: file path (.hcl/.json) vs inline JSON vs inline HCL.
504515
pub async fn new(config_source: impl Into<String>) -> Result<Self> {
505516
let source = config_source.into();
506-
let path = Path::new(&source);
517+
518+
// Expand leading `~/` to the user's home directory
519+
let expanded = if source.starts_with("~/") {
520+
if let Some(home) = std::env::var_os("HOME") {
521+
format!("{}/{}", home.to_string_lossy(), &source[2..])
522+
} else {
523+
source.clone()
524+
}
525+
} else {
526+
source.clone()
527+
};
528+
529+
let path = Path::new(&expanded);
507530

508531
let config = if path.extension().is_some() && path.exists() {
509532
CodeConfig::from_file(path)
@@ -538,11 +561,26 @@ impl Agent {
538561
..AgentConfig::default()
539562
};
540563

541-
Ok(Agent {
564+
let mut agent = Agent {
542565
llm_client,
543566
code_config: config,
544567
config: agent_config,
545-
})
568+
};
569+
570+
// Always initialize the skill registry with built-in skills, then load any user-defined dirs
571+
let registry = Arc::new(crate::skills::SkillRegistry::with_builtins());
572+
for dir in &agent.code_config.skill_dirs.clone() {
573+
if let Err(e) = registry.load_from_dir(dir) {
574+
tracing::warn!(
575+
dir = %dir.display(),
576+
error = %e,
577+
"Failed to load skills from directory — skipping"
578+
);
579+
}
580+
}
581+
agent.config.skill_registry = Some(registry);
582+
583+
Ok(agent)
546584
}
547585

548586
/// Bind to a workspace directory, returning an [`AgentSession`].
@@ -694,16 +732,41 @@ impl Agent {
694732
.clone()
695733
.unwrap_or_else(|| self.config.prompt_slots.clone());
696734

697-
// Append skill instructions to the extra slot
698-
if let Some(ref registry) = opts.skill_registry {
699-
let skill_prompt = registry.to_system_prompt();
700-
if !skill_prompt.is_empty() {
701-
prompt_slots.extra = match prompt_slots.extra {
702-
Some(existing) => Some(format!("{}\n\n{}", existing, skill_prompt)),
703-
None => Some(skill_prompt),
704-
};
735+
// Build effective skill registry: fork the agent-level registry (builtins + global
736+
// skill_dirs), then layer session-level skills on top. Forking ensures session skills
737+
// never pollute the shared agent-level registry.
738+
let base_registry = self
739+
.config
740+
.skill_registry
741+
.as_deref()
742+
.map(|r| r.fork())
743+
.unwrap_or_else(crate::skills::SkillRegistry::with_builtins);
744+
// Merge explicit session registry on top of the fork
745+
if let Some(ref r) = opts.skill_registry {
746+
for skill in r.all() {
747+
base_registry.register_unchecked(skill);
748+
}
749+
}
750+
// Load session-level skill dirs
751+
for dir in &opts.skill_dirs {
752+
if let Err(e) = base_registry.load_from_dir(dir) {
753+
tracing::warn!(
754+
dir = %dir.display(),
755+
error = %e,
756+
"Failed to load session skill dir — skipping"
757+
);
705758
}
706759
}
760+
let effective_registry = Arc::new(base_registry);
761+
762+
// Append skill directory listing to the extra prompt slot
763+
let skill_prompt = effective_registry.to_system_prompt();
764+
if !skill_prompt.is_empty() {
765+
prompt_slots.extra = match prompt_slots.extra {
766+
Some(existing) => Some(format!("{}\n\n{}", existing, skill_prompt)),
767+
None => Some(skill_prompt),
768+
};
769+
}
707770

708771
// Resolve memory store: explicit store takes priority, then file_memory_dir
709772
let mut init_warning: Option<String> = None;
@@ -751,7 +814,7 @@ impl Agent {
751814
context_providers: opts.context_providers.clone(),
752815
planning_enabled: opts.planning_enabled,
753816
goal_tracking: opts.goal_tracking,
754-
skill_registry: opts.skill_registry.clone(),
817+
skill_registry: Some(effective_registry),
755818
max_parse_retries: opts.max_parse_retries.unwrap_or(base.max_parse_retries),
756819
tool_timeout_ms: opts.tool_timeout_ms.or(base.tool_timeout_ms),
757820
circuit_breaker_threshold: opts
@@ -850,6 +913,32 @@ impl Agent {
850913
.clone()
851914
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
852915

916+
// Resolve session store: explicit opts store > config sessions_dir > None
917+
let session_store = if opts.session_store.is_some() {
918+
opts.session_store.clone()
919+
} else if let Some(ref dir) = self.code_config.sessions_dir {
920+
match tokio::runtime::Handle::try_current() {
921+
Ok(handle) => {
922+
let dir = dir.clone();
923+
match tokio::task::block_in_place(|| {
924+
handle.block_on(crate::store::FileSessionStore::new(dir))
925+
}) {
926+
Ok(store) => Some(Arc::new(store) as Arc<dyn crate::store::SessionStore>),
927+
Err(e) => {
928+
tracing::warn!("Failed to create session store from sessions_dir: {}", e);
929+
None
930+
}
931+
}
932+
}
933+
Err(_) => {
934+
tracing::warn!("No async runtime for sessions_dir store — persistence disabled");
935+
None
936+
}
937+
}
938+
} else {
939+
None
940+
};
941+
853942
Ok(AgentSession {
854943
llm_client,
855944
tool_executor,
@@ -860,7 +949,7 @@ impl Agent {
860949
session_id,
861950
history: RwLock::new(Vec::new()),
862951
command_queue,
863-
session_store: opts.session_store.clone(),
952+
session_store,
864953
auto_save: opts.auto_save,
865954
hook_engine: Arc::new(crate::hooks::HookEngine::new()),
866955
init_warning,

core/src/config.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,33 @@ pub struct ProviderConfig {
125125
pub models: Vec<ModelConfig>,
126126
}
127127

128+
/// Apply model capability flags to an LlmConfig.
129+
///
130+
/// - `temperature = false` → omit temperature (model ignores it, e.g. o1)
131+
/// - `reasoning = true` + `thinking_budget` set → pass budget to client
132+
/// - `limit.output > 0` → use as max_tokens
133+
fn apply_model_caps(mut config: LlmConfig, model: &ModelConfig, thinking_budget: Option<usize>) -> LlmConfig {
134+
// reasoning=true + thinking_budget set → pass budget to client (Anthropic only)
135+
if model.reasoning {
136+
if let Some(budget) = thinking_budget {
137+
config = config.with_thinking_budget(budget);
138+
}
139+
}
140+
141+
// limit.output > 0 → use as max_tokens cap
142+
if model.limit.output > 0 {
143+
config = config.with_max_tokens(model.limit.output as usize);
144+
}
145+
146+
// temperature=false models (e.g. o1) must not receive a temperature param.
147+
// Store the flag so the LLM client can gate it at call time.
148+
if !model.temperature {
149+
config.disable_temperature = true;
150+
}
151+
152+
config
153+
}
154+
128155
impl ProviderConfig {
129156
/// Find a model by ID
130157
pub fn find_model(&self, model_id: &str) -> Option<&ModelConfig> {
@@ -387,6 +414,7 @@ impl CodeConfig {
387414
if let Some(url) = base_url {
388415
config = config.with_base_url(url);
389416
}
417+
config = apply_model_caps(config, model, self.thinking_budget);
390418
Some(config)
391419
}
392420

@@ -403,6 +431,7 @@ impl CodeConfig {
403431
if let Some(url) = base_url {
404432
config = config.with_base_url(url);
405433
}
434+
config = apply_model_caps(config, model, self.thinking_budget);
406435
Some(config)
407436
}
408437

0 commit comments

Comments
 (0)