Skip to content

Commit 15c6337

Browse files
committed
feat(cli-agent): add runtime bootstrap, tool policy, and request context modules
1 parent 14652b3 commit 15c6337

22 files changed

Lines changed: 1075 additions & 847 deletions

File tree

Cargo.toml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,23 @@ exclude = [".github/"]
1616
resolver = "3"
1717

1818
[workspace.dependencies]
19-
# Agent framework
19+
# internal crates
20+
bob-adapters = { path = "crates/bob-adapters" }
21+
bob-runtime = { path = "crates/bob-runtime" }
22+
bob-core = { path = "crates/bob-core" }
23+
24+
# external crates
2025
agent-skills = "0.2"
2126
async-trait = "0.1"
22-
futures-core = "0.3"
27+
futures-core = "0.3.32"
2328
futures-util = "0.3"
2429
genai = "=0.6.0-beta.1"
2530
rmcp = { version = "0.16", features = [
2631
"client",
2732
"transport-child-process",
2833
"transport-streamable-http-client",
2934
] }
30-
scc = "3"
35+
scc = "3.6.4"
3136
serde = "1.0.228"
3237
serde_json = "1"
3338
thiserror = "2.0.18"
@@ -44,7 +49,7 @@ tracing = "0.1.44"
4449
tracing-subscriber = "0.3.22"
4550

4651
# CLI
47-
clap = "4.5.56"
52+
clap = "4.5.60"
4853
config = "0.15.19"
4954
eyre = "0.6.12"
5055

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ max_selected = 3
127127
token_budget_ratio = 0.1
128128

129129
[[skills.sources]]
130-
source_type = "directory"
130+
type = "directory"
131131
path = "./skills"
132132
recursive = false
133133

bin/cli-agent/Cargo.toml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,13 @@ categories = ["command-line-utilities", "development-tools"]
1212
readme = "README.md"
1313

1414
[dependencies]
15-
async-trait = { workspace = true }
16-
bob-adapters = { path = "../../crates/bob-adapters" }
17-
bob-runtime = { path = "../../crates/bob-runtime" }
15+
bob-adapters = { workspace = true }
16+
bob-runtime = { workspace = true }
1817
clap = { workspace = true, features = ["derive"] }
1918
config = { workspace = true }
2019
eyre = { workspace = true }
2120
genai = { workspace = true }
2221
serde = { workspace = true, features = ["derive"] }
23-
serde_json = { workspace = true }
2422
tokio = { workspace = true, features = ["io-std", "io-util"] }
2523
tracing-subscriber = { workspace = true, features = ["env-filter"] }
2624

bin/cli-agent/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ max_selected = 3
5757
token_budget_ratio = 0.1
5858

5959
[[skills.sources]]
60-
source_type = "directory"
60+
type = "directory"
6161
path = "./skills"
6262
recursive = false
6363

bin/cli-agent/src/bootstrap.rs

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
use std::sync::Arc;
2+
3+
use bob_adapters::{
4+
observe::TracingEventSink,
5+
skills_agent::{SkillPromptComposer, SkillSelectionPolicy, SkillSourceConfig},
6+
store_memory::InMemorySessionStore,
7+
};
8+
use bob_runtime::{
9+
AgentBootstrap, AgentRuntime, NoOpToolPort, RuntimeBuilder, TimeoutToolLayer, ToolLayer,
10+
composite::CompositeToolPort,
11+
};
12+
use eyre::WrapErr;
13+
14+
use crate::config::{
15+
AgentConfig, McpServerEntry, McpTransport, SkillSourceType, SkillsConfig,
16+
resolve_env_placeholders,
17+
};
18+
19+
pub(crate) const DEFAULT_TOOL_TIMEOUT_MS: u64 = 15_000;
20+
pub(crate) const DEFAULT_SKILLS_TOKEN_BUDGET: usize = 1_800;
21+
pub(crate) const DEFAULT_MODEL_CONTEXT_TOKENS: usize = 128_000;
22+
23+
#[derive(Debug, Clone)]
24+
pub(crate) struct SkillsRuntimeContext {
25+
pub composer: SkillPromptComposer,
26+
pub selection_policy: SkillSelectionPolicy,
27+
}
28+
29+
/// Build the runtime from a loaded config.
30+
pub(crate) async fn build_runtime(
31+
cfg: &AgentConfig,
32+
) -> eyre::Result<(Arc<dyn AgentRuntime>, Option<SkillsRuntimeContext>)> {
33+
let client = genai::Client::default();
34+
let llm: Arc<dyn bob_adapters::core::ports::LlmPort> =
35+
Arc::new(bob_adapters::llm_genai::GenAiLlmAdapter::new(client));
36+
37+
let tools = build_tool_port(cfg).await?;
38+
let store: Arc<dyn bob_adapters::core::ports::SessionStore> =
39+
Arc::new(InMemorySessionStore::new());
40+
let events: Arc<dyn bob_adapters::core::ports::EventSink> = Arc::new(TracingEventSink::new());
41+
42+
let tool_timeout_ms = cfg.mcp.as_ref().map_or(DEFAULT_TOOL_TIMEOUT_MS, |mcp_cfg| {
43+
mcp_cfg
44+
.servers
45+
.iter()
46+
.map(|server| server.tool_timeout_ms.unwrap_or(DEFAULT_TOOL_TIMEOUT_MS))
47+
.max()
48+
.unwrap_or(DEFAULT_TOOL_TIMEOUT_MS)
49+
});
50+
51+
let policy = bob_adapters::core::types::TurnPolicy {
52+
max_steps: cfg.runtime.max_steps.unwrap_or(12),
53+
turn_timeout_ms: cfg.runtime.turn_timeout_ms.unwrap_or(90_000),
54+
tool_timeout_ms,
55+
..bob_adapters::core::types::TurnPolicy::default()
56+
};
57+
58+
let runtime = RuntimeBuilder::new()
59+
.with_llm(llm)
60+
.with_tools(tools)
61+
.with_store(store)
62+
.with_events(events)
63+
.with_default_model(cfg.runtime.default_model.clone())
64+
.with_policy(policy)
65+
.build()
66+
.wrap_err("failed to build runtime")?;
67+
68+
let skills_context = build_skills_composer(cfg)?;
69+
Ok((runtime, skills_context))
70+
}
71+
72+
async fn build_tool_port(
73+
cfg: &AgentConfig,
74+
) -> eyre::Result<Arc<dyn bob_adapters::core::ports::ToolPort>> {
75+
let Some(mcp_cfg) = cfg.mcp.as_ref() else {
76+
return Ok(Arc::new(NoOpToolPort));
77+
};
78+
if mcp_cfg.servers.is_empty() {
79+
return Ok(Arc::new(NoOpToolPort));
80+
}
81+
82+
if mcp_cfg.servers.len() == 1 {
83+
return build_single_tool_port(&mcp_cfg.servers[0]).await;
84+
}
85+
86+
let mut ports: Vec<(String, Arc<dyn bob_adapters::core::ports::ToolPort>)> =
87+
Vec::with_capacity(mcp_cfg.servers.len());
88+
for entry in &mcp_cfg.servers {
89+
let port = build_single_tool_port(entry).await?;
90+
ports.push((entry.id.clone(), port));
91+
}
92+
Ok(Arc::new(CompositeToolPort::new(ports)))
93+
}
94+
95+
async fn build_single_tool_port(
96+
entry: &McpServerEntry,
97+
) -> eyre::Result<Arc<dyn bob_adapters::core::ports::ToolPort>> {
98+
let env_vec = resolve_mcp_env(entry.env.as_ref())?;
99+
let adapter = match entry.transport {
100+
McpTransport::Stdio => bob_adapters::mcp_rmcp::McpToolAdapter::connect_stdio(
101+
&entry.id,
102+
&entry.command,
103+
&entry.args,
104+
&env_vec,
105+
)
106+
.await
107+
.wrap_err_with(|| format!("failed to connect MCP server '{}'", entry.id))?,
108+
};
109+
let inner: Arc<dyn bob_adapters::core::ports::ToolPort> = Arc::new(adapter);
110+
let timeout_layer =
111+
TimeoutToolLayer::new(entry.tool_timeout_ms.unwrap_or(DEFAULT_TOOL_TIMEOUT_MS));
112+
Ok(timeout_layer.wrap(inner))
113+
}
114+
115+
fn resolve_mcp_env(
116+
env: Option<&std::collections::HashMap<String, String>>,
117+
) -> eyre::Result<Vec<(String, String)>> {
118+
let Some(env) = env else {
119+
return Ok(Vec::new());
120+
};
121+
122+
let mut resolved = Vec::with_capacity(env.len());
123+
for (key, value) in env {
124+
let parsed = resolve_env_placeholders(value)
125+
.wrap_err_with(|| format!("failed to resolve env placeholder for key '{key}'"))?;
126+
resolved.push((key.clone(), parsed));
127+
}
128+
Ok(resolved)
129+
}
130+
131+
pub(crate) fn build_skills_composer(
132+
cfg: &AgentConfig,
133+
) -> eyre::Result<Option<SkillsRuntimeContext>> {
134+
let Some(skills_cfg) = cfg.skills.as_ref() else {
135+
return Ok(None);
136+
};
137+
if skills_cfg.sources.is_empty() {
138+
return Ok(None);
139+
}
140+
141+
let sources = skills_cfg
142+
.sources
143+
.iter()
144+
.map(|source| match source.source_type {
145+
SkillSourceType::Directory => SkillSourceConfig {
146+
path: std::path::PathBuf::from(&source.path),
147+
recursive: source.recursive.unwrap_or(false),
148+
},
149+
})
150+
.collect::<Vec<_>>();
151+
152+
let composer =
153+
SkillPromptComposer::from_sources(&sources, skills_cfg.max_selected.unwrap_or(3))
154+
.wrap_err("failed to load skills from configured sources")?;
155+
156+
let (deny_tools, allow_tools) = cfg.policy.as_ref().map_or_else(
157+
|| (Vec::new(), None),
158+
|policy| (policy.deny_tools.clone().unwrap_or_default(), policy.allow_tools.clone()),
159+
);
160+
let token_budget_tokens = resolve_skills_token_budget(&cfg.runtime, skills_cfg)?;
161+
let selection_policy = SkillSelectionPolicy { deny_tools, allow_tools, token_budget_tokens };
162+
163+
Ok(Some(SkillsRuntimeContext { composer, selection_policy }))
164+
}
165+
166+
pub(crate) fn resolve_skills_token_budget(
167+
runtime: &crate::config::RuntimeConfig,
168+
skills: &SkillsConfig,
169+
) -> eyre::Result<usize> {
170+
if let Some(tokens) = skills.token_budget_tokens {
171+
return Ok(tokens.max(1));
172+
}
173+
174+
if let Some(ratio) = skills.token_budget_ratio {
175+
if !(0.0..=1.0).contains(&ratio) || ratio == 0.0 {
176+
return Err(eyre::eyre!(
177+
"invalid skills.token_budget_ratio '{ratio}', expected 0.0 < ratio <= 1.0"
178+
));
179+
}
180+
181+
let context_tokens = runtime.model_context_tokens.unwrap_or(DEFAULT_MODEL_CONTEXT_TOKENS);
182+
let budget = (ratio * context_tokens as f64).round() as usize;
183+
return Ok(budget.max(1));
184+
}
185+
186+
Ok(DEFAULT_SKILLS_TOKEN_BUDGET)
187+
}
188+
189+
#[cfg(test)]
190+
mod tests {
191+
use super::*;
192+
use crate::config::{RuntimeConfig, SkillSourceEntry};
193+
194+
#[test]
195+
fn resolves_skills_budget_from_ratio() -> eyre::Result<()> {
196+
let runtime = RuntimeConfig {
197+
default_model: "openai:gpt-4o-mini".to_string(),
198+
max_steps: Some(12),
199+
turn_timeout_ms: Some(90_000),
200+
model_context_tokens: Some(20_000),
201+
};
202+
let skills = SkillsConfig {
203+
sources: vec![SkillSourceEntry {
204+
source_type: SkillSourceType::Directory,
205+
path: "./skills".to_string(),
206+
recursive: Some(false),
207+
}],
208+
max_selected: Some(3),
209+
token_budget_tokens: None,
210+
token_budget_ratio: Some(0.10),
211+
};
212+
213+
let budget = resolve_skills_token_budget(&runtime, &skills)?;
214+
assert_eq!(budget, 2_000);
215+
Ok(())
216+
}
217+
218+
#[test]
219+
fn invalid_ratio_is_rejected() {
220+
let runtime = RuntimeConfig {
221+
default_model: "openai:gpt-4o-mini".to_string(),
222+
max_steps: None,
223+
turn_timeout_ms: None,
224+
model_context_tokens: None,
225+
};
226+
let skills = SkillsConfig {
227+
sources: vec![],
228+
max_selected: None,
229+
token_budget_tokens: None,
230+
token_budget_ratio: Some(1.2),
231+
};
232+
233+
let result = resolve_skills_token_budget(&runtime, &skills);
234+
assert!(result.is_err(), "ratio > 1.0 must be rejected");
235+
let msg = match result {
236+
Err(err) => err.to_string(),
237+
Ok(value) => format!("unexpected budget: {value}"),
238+
};
239+
assert!(msg.contains("token_budget_ratio"));
240+
}
241+
}

0 commit comments

Comments
 (0)