Skip to content

Commit f783c85

Browse files
authored
Merge pull request #63 from efecnc/feat/semble-scout
feat: add Semble Scout agent
2 parents 955cb68 + e58b96c commit f783c85

11 files changed

Lines changed: 161 additions & 40 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Git worktrees (`git_worktree`): **off** in a minimal `config.toml` until you set
3737

3838
Sub-agents (`subagent_spawn`, `task_*`, `subagent_plan_execute`, `task_history_list`): **off** in a minimal config until `[harness.subagents] enabled = true`. The onboarding template enables this for new workspaces. Sub-agents run a second `run_reasoning_loop` with a synthetic chat id (`subagent-…`) and optional tool allowlist. `cancel_children_on_parent_cancel` (default true) controls whether an **explicit** parent cancel (`BusMessage::Cancel` / API cancel / terminal **`/cancel`**) also cancels those child tasks (not triggered by queued follow-up user messages). Completed runs are recorded in the workspace SQLite table **`subagent_tasks`** (same DB as session memory). See `docs/harness-implementation-plan.md` Phase 5.
3939

40-
**Named agents** (Phase 5b): `subagent_spawn` now accepts an optional `agent` parameter to invoke specialized sub-agents defined in config. Each named agent has its own system prompt, tool allowlist, and optional model/temperature overrides (wired at spawn in `subagent.rs` via `ProviderCredentials`). Three built-in defaults are registered when no `[agents.<name>]` blocks exist in `config.toml`: **researcher** (web+read tools, temp 0.1), **coder** (full harness allowlist, temp 0.2), and **evaluator** (read-only review tools, temp 0.0). Tools: `agent_list` (list available agents), `task_dashboard` (unified active+history view). The coordinator system prompt is auto-injected with agent descriptions. `wake_on_completion` (default true) enqueues a synthetic inbound when a subagent finishes so the parent can consume the result without polling. Config keys (under `[harness.subagents]`): `wake_on_completion`, `task_history_retention`. Agent definitions use `[agents.<name>]` blocks or `[harness.agents.<name>]` (harness-level wins in merge): `description` (required), `mode` ("subagent"), `system_prompt`, `system_prompt_file`, `allowed_tools`, `model`, `temperature`, `max_iterations`, `hidden`, `color`. Implementation: `src/agent/registry.rs`, `src/agent/subagent.rs`. **`subagent_plan_execute`** accepts optional per-step `"agent"` in plan JSON (same schema as spawn).
40+
**Named agents** (Phase 5b): `subagent_spawn` now accepts an optional `agent` parameter to invoke specialized agents defined in config. Four built-in defaults are registered when no `[agents.<name>]` blocks exist in `config.toml`: **Semble Scout** (modelsiz local code retrieval), **researcher** (web+read tools, temp 0.1), **coder** (full harness allowlist, temp 0.2), and **evaluator** (read-only review tools, temp 0.0). Semble Scout is a deterministic `mode = "semble_scout"` worker: it invokes `uvx --from semble[mcp]==0.5.1 semble search` against the host-supplied project root, never accepts a root from the LLM, and keeps its cache under `.isanagent/cache/semble`. Tools: `agent_list` (list available agents), `task_dashboard` (unified active+history view). The coordinator system prompt is auto-injected with agent descriptions. `wake_on_completion` (default true) enqueues a synthetic inbound when a subagent finishes so the parent can consume the result without polling. Config keys (under `[harness.subagents]`): `wake_on_completion`, `task_history_retention`. Agent definitions use `[agents.<name>]` blocks or `[harness.agents.<name>]` (harness-level wins in merge): `description` (required), `mode` (`"subagent"` or `"semble_scout"`), `system_prompt`, `system_prompt_file`, `allowed_tools`, `model`, `temperature`, `max_iterations`, `hidden`, `color`. Implementation: `src/agent/registry.rs`, `src/agent/subagent.rs`. **`subagent_plan_execute`** accepts optional per-step `"agent"` in plan JSON (same schema as spawn).
4141

4242
**MaxEvolve kernel porting** (`[harness.kernel_porting] enabled = true`): Triton/PyTorch→JAX/Pallas workflow via named agents (`kernel_orchestrator`, `gpu_to_jax`, `evolve_orchestrator`, …), `kernel-porting` skill, Python validators under `skills/kernel-porting/scripts/`, MAP-Elites tools (`kernel_db_init`, `kernel_db_sample`, `kernel_db_insert`, `kernel_db_status` in `src/tools/kernel_porting.rs`), and Colab/SSH hardware profiling. Operator guide: `docs/kernel-porting-user-guide.md`. Projects live under `kernels/projects/{id}/`. Onboarding copies prompts to `.agents/prompts/`, reference docs to `kernels/reference/`, and benchmarks to `benchmarks/`.
4343

src/agent/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1510,6 +1510,9 @@ pub struct SubagentHarnessParams {
15101510
pub wake_on_completion: bool,
15111511
pub task_history_retention: usize,
15121512
pub bus_tx: Option<tokio::sync::mpsc::Sender<crate::bus::BusMessage>>,
1513+
/// Canonical project root available to deterministic local worker agents.
1514+
/// This is intentionally separate from IsanAgent's state directory.
1515+
pub workspace_dir: std::path::PathBuf,
15131516
}
15141517

15151518
/// The central logic for an autonomous Agent running inside an ActorNode.
@@ -1609,6 +1612,7 @@ impl AgentLogic {
16091612
wake_on_completion: p.wake_on_completion,
16101613
task_history_retention: p.task_history_retention,
16111614
bus_tx: p.bus_tx.clone(),
1615+
workspace_dir: p.workspace_dir.clone(),
16121616
}))
16131617
});
16141618

src/agent/registry.rs

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,20 @@ impl AgentRegistry {
8484
return String::new();
8585
}
8686
let mut s = String::from("\n\n## Available Specialized Agents\n\n");
87-
s.push_str("You are a coordinator. Delegate work to specialized sub-agents using the `agent_spawn` tool. ");
87+
s.push_str("You are a coordinator. Delegate work to specialized agents using the `subagent_spawn` tool. ");
8888
s.push_str(
8989
"Use `agent_list` to refresh your knowledge of available agents at any time.\n\n",
9090
);
9191
for m in &visible {
92-
let tools_summary = match &m.allowed_tools {
93-
None => "inherits harness allowlist".to_string(),
94-
Some(v) if v.is_empty() => "read-only, no tools".to_string(),
95-
Some(v) => v.join(", "),
92+
let tools_summary = if m.mode == AgentMode::SembleScout {
93+
"local Semble code search only (no model, arbitrary shell, or project writes)"
94+
.to_string()
95+
} else {
96+
match &m.allowed_tools {
97+
None => "inherits harness allowlist".to_string(),
98+
Some(v) if v.is_empty() => "read-only, no tools".to_string(),
99+
Some(v) => v.join(", "),
100+
}
96101
};
97102
let iter_hint = match m.max_iterations {
98103
Some(n) => format!(", max {} iterations", n),
@@ -104,6 +109,7 @@ impl AgentRegistry {
104109
));
105110
}
106111
s.push_str("\nGuidelines:\n- For research: delegate to a research-capable agent.\n");
112+
s.push_str("- For exploring an unfamiliar local codebase, delegate to Semble Scout before broad grep or full-file reads.\n");
107113
s.push_str("- For code changes: delegate to a coder agent.\n");
108114
s.push_str("- For review: delegate to a read-only review agent.\n");
109115
s.push_str("- Use `wait=false` for parallel work; `wait=true` when result is needed.\n");
@@ -168,6 +174,21 @@ fn resolve_system_prompt(def: &AgentDefinition, sandbox_dir: &std::path::Path) -
168174

169175
pub fn default_agent_definitions() -> HashMap<String, AgentDefinition> {
170176
let mut map = HashMap::new();
177+
map.insert(
178+
"semble-scout".to_string(),
179+
AgentDefinition {
180+
description: "Find the most relevant local code snippets for a natural-language or symbol query using Semble".to_string(),
181+
mode: AgentMode::SembleScout,
182+
system_prompt: None,
183+
system_prompt_file: None,
184+
allowed_tools: Some(vec![]),
185+
model: None,
186+
temperature: None,
187+
max_iterations: None,
188+
hidden: false,
189+
color: Some("#8B5CF6".into()),
190+
},
191+
);
171192
map.insert(
172193
"researcher".to_string(),
173194
AgentDefinition {
@@ -275,7 +296,11 @@ mod tests {
275296
#[test]
276297
fn default_agents_have_expected_roles() {
277298
let defs = default_agent_definitions();
278-
assert_eq!(defs.len(), 3);
299+
assert_eq!(defs.len(), 4);
300+
assert!(matches!(
301+
defs.get("semble-scout").map(|agent| agent.mode.clone()),
302+
Some(AgentMode::SembleScout)
303+
));
279304
assert!(defs.contains_key("researcher"));
280305
assert!(defs.contains_key("coder"));
281306
assert!(defs.contains_key("evaluator"));

src/agent/subagent.rs

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use async_trait::async_trait;
77
use dashmap::DashMap;
88
use serde::Deserialize;
99
use serde_json::Value;
10+
use std::path::{Path, PathBuf};
1011
use tokio_util::sync::CancellationToken;
1112

1213
use super::ReasoningLoopCtx;
@@ -17,7 +18,7 @@ use crate::channels::terminal_ui::protocol::{
1718
METADATA_SUBAGENT_STATUS, METADATA_SUBAGENT_TASK_ID,
1819
};
1920
use crate::clarification::ClarificationHub;
20-
use crate::config::ResolvedShellPolicy;
21+
use crate::config::{AgentMode, ResolvedShellPolicy};
2122
use crate::logging::LoggerHandle;
2223
use crate::memory::{MemoryMessage, SharedReply};
2324
use crate::session::SessionManager;
@@ -82,6 +83,9 @@ pub struct SubagentSpawnDeps {
8283
pub task_history_retention: usize,
8384
/// Optional extra bus sender for enqueuing synthetic follow-up messages.
8485
pub bus_tx: Option<tokio::sync::mpsc::Sender<BusMessage>>,
86+
/// Project root used by deterministic local worker agents. The worker
87+
/// never accepts a root from an LLM tool argument.
88+
pub workspace_dir: PathBuf,
8589
}
8690

8791
struct TaskRecord {
@@ -260,6 +264,74 @@ impl SubagentHarness {
260264
.ok_or_else(|| "Subagent harness is not wired to tools yet".to_string())
261265
}
262266

267+
/// Run Semble as a constrained local worker. The LLM controls only the
268+
/// query text; the search root, executable shape, cache location, and
269+
/// content policy are owned by the host application.
270+
async fn run_semble_scout(workspace_dir: &Path, query: &str) -> Result<String, String> {
271+
let query = query.trim();
272+
if query.is_empty() {
273+
return Err("Semble Scout needs a non-empty code search query.".to_string());
274+
}
275+
if !workspace_dir.is_dir() {
276+
return Err(format!(
277+
"Semble Scout workspace is unavailable: {}",
278+
workspace_dir.display()
279+
));
280+
}
281+
282+
let cache_dir = workspace_dir
283+
.join(".isanagent")
284+
.join("cache")
285+
.join("semble");
286+
tokio::fs::create_dir_all(&cache_dir)
287+
.await
288+
.map_err(|e| format!("Could not prepare Semble cache: {e}"))?;
289+
290+
let mut command = tokio::process::Command::new("uvx");
291+
command
292+
.args([
293+
"--from",
294+
"semble[mcp]==0.5.1",
295+
"semble",
296+
"search",
297+
"--content",
298+
"code",
299+
"--top-k",
300+
"8",
301+
"--max-snippet-lines",
302+
"16",
303+
"--",
304+
query,
305+
])
306+
.arg(workspace_dir)
307+
.current_dir(workspace_dir)
308+
.env("SEMBLE_CACHE_LOCATION", &cache_dir)
309+
// A cancelled parent must not leave a package install or indexer
310+
// process running after its agent turn is gone.
311+
.kill_on_drop(true);
312+
313+
// The first `uvx` run may need to download Semble and its model assets.
314+
// Do not impose an arbitrary timeout here: on a slow but healthy
315+
// network that would turn a one-time install into a false failure.
316+
// `kill_on_drop(true)` still cleans up the child if the runtime itself
317+
// drops this task during shutdown.
318+
let output = command.output().await.map_err(|e| {
319+
format!("Semble Scout could not start `uvx`: {e}. Install uv first, then retry.")
320+
})?;
321+
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
322+
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
323+
if !output.status.success() {
324+
let detail = if stderr.is_empty() { stdout } else { stderr };
325+
return Err(format!("Semble Scout search failed: {detail}"));
326+
}
327+
if stdout.is_empty() {
328+
return Ok("Semble Scout found no matching code snippets.".to_string());
329+
}
330+
Ok(format!(
331+
"Semble Scout results (local code search):\n\n{stdout}"
332+
))
333+
}
334+
263335
pub async fn spawn(&self, spec: SubagentSpawnSpec) -> Result<String, String> {
264336
let SubagentSpawnSpec {
265337
parent_channel,
@@ -301,6 +373,24 @@ impl SubagentHarness {
301373
None => None,
302374
};
303375

376+
// Semble Scout is deliberately a named agent rather than an MCP
377+
// integration: no LLM/provider is spawned for it. It receives the
378+
// coordinator's query, searches only the configured project root,
379+
// and returns the retrieved snippets directly to that coordinator.
380+
if manifest
381+
.as_ref()
382+
.is_some_and(|agent| agent.mode == AgentMode::SembleScout)
383+
{
384+
let result = Self::run_semble_scout(&self.inner.deps.workspace_dir, &prompt).await?;
385+
return Ok(serde_json::json!({
386+
"agent": "semble-scout",
387+
"status": "completed",
388+
"wait": true,
389+
"result": result,
390+
})
391+
.to_string());
392+
}
393+
304394
let tools = self.tools()?;
305395
let task_id = uuid::Uuid::new_v4().simple().to_string();
306396
let child_chat_id = format!("subagent-{}", &task_id[..12.min(task_id.len())]);
@@ -1334,6 +1424,7 @@ mod tests {
13341424
wake_on_completion: false,
13351425
task_history_retention: 20,
13361426
bus_tx: None,
1427+
workspace_dir: skills_dir.path().to_path_buf(),
13371428
}));
13381429

13391430
let err = harness
@@ -1355,4 +1446,14 @@ mod tests {
13551446
"unexpected error: {err}"
13561447
);
13571448
}
1449+
1450+
#[tokio::test]
1451+
async fn semble_scout_rejects_a_missing_workspace_before_spawning_processes() {
1452+
let missing =
1453+
std::env::temp_dir().join(format!("isanagent-absent-{}", uuid::Uuid::new_v4()));
1454+
let err = SubagentHarness::run_semble_scout(&missing, "find agent startup")
1455+
.await
1456+
.expect_err("missing workspace must be rejected");
1457+
assert!(err.contains("workspace is unavailable"));
1458+
}
13581459
}

src/channels/terminal_ui/run.rs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -545,8 +545,7 @@ fn jobs_strip_lines(app: &App, max_width: usize, include_stream_tail: bool) -> V
545545
if let Some(last) = app
546546
.execution_stream_recent
547547
.lines()
548-
.filter(|s| !s.trim().is_empty())
549-
.next_back()
548+
.rfind(|s| !s.trim().is_empty())
550549
{
551550
let label = app
552551
.execution_stream_label
@@ -1591,12 +1590,7 @@ pub(crate) fn run_ratatui_main(config: RatatuiMainConfig) -> io::Result<()> {
15911590
.and_then(|v| v.as_str())
15921591
{
15931592
// General background job output: update strip, don't add to transcript.
1594-
if let Some(line) = msg
1595-
.content
1596-
.lines()
1597-
.filter(|l| !l.trim().is_empty())
1598-
.next_back()
1599-
{
1593+
if let Some(line) = msg.content.lines().rfind(|l| !l.trim().is_empty()) {
16001594
app.job_strip_set_last_line(jid, line);
16011595
}
16021596

src/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ pub enum AgentMode {
161161
/// Invoked by the coordinator via tools.
162162
#[default]
163163
Subagent,
164+
/// A deterministic, local-only code retrieval worker powered by Semble.
165+
/// It never invokes an LLM and may only search the configured workspace.
166+
#[serde(rename = "semble_scout")]
167+
SembleScout,
164168
}
165169

166170
/// HF ml-intern–style ML policy overlay + optional autonomy hints (see `assets/ml_engineer_overlay.md`).

src/execution/jupyter.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -818,16 +818,12 @@ fn fold_execute_ws_message(v: &Value, exec_msg_id: &str, ctx: &mut ExecuteFoldCt
818818
// Shell reply: sync status / exit only. Traceback belongs on iopub `error` to avoid duplicates.
819819
let status = v["content"]["status"].as_str().unwrap_or("");
820820
match status {
821-
"error" => {
822-
if ctx.exit_code.is_none() || *ctx.exit_code == Some(0) {
823-
*ctx.exit_code = Some(1);
824-
}
821+
"error" if ctx.exit_code.is_none() || *ctx.exit_code == Some(0) => {
822+
*ctx.exit_code = Some(1);
825823
}
826824
"abort" => *ctx.exit_code = Some(130),
827-
"ok" => {
828-
if ctx.exit_code.is_none() || *ctx.exit_code == Some(0) {
829-
*ctx.exit_code = Some(0);
830-
}
825+
"ok" if ctx.exit_code.is_none() || *ctx.exit_code == Some(0) => {
826+
*ctx.exit_code = Some(0);
831827
}
832828
_ => {}
833829
}

src/execution/ssh.rs

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -627,17 +627,15 @@ async fn run_ssh_channel_oneway(
627627
break;
628628
};
629629
match msg {
630-
ChannelMsg::Data { data } => {
631-
if stdout.len() < max_each {
632-
let take = (max_each - stdout.len()).min(data.len());
633-
stdout.extend_from_slice(&data[..take]);
634-
}
630+
ChannelMsg::Data { data } if stdout.len() < max_each => {
631+
let take = (max_each - stdout.len()).min(data.len());
632+
stdout.extend_from_slice(&data[..take]);
635633
}
636-
ChannelMsg::ExtendedData { data, ext } => {
637-
if ext == SSH_STDERR && stderr.len() < max_each {
638-
let take = (max_each - stderr.len()).min(data.len());
639-
stderr.extend_from_slice(&data[..take]);
640-
}
634+
ChannelMsg::ExtendedData { data, ext }
635+
if ext == SSH_STDERR && stderr.len() < max_each =>
636+
{
637+
let take = (max_each - stderr.len()).min(data.len());
638+
stderr.extend_from_slice(&data[..take]);
641639
}
642640
ChannelMsg::ExitStatus { exit_status } => {
643641
code = Some(exit_status);

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,7 @@ Enable [api], [slack], or [email] (with enabled = true) so the agent can receive
790790
wake_on_completion: workspace.config.subagent_wake_on_completion(),
791791
task_history_retention: workspace.config.subagent_task_history_retention(),
792792
bus_tx: Some(bus_tx.clone()),
793+
workspace_dir: workspace.sandbox_dir.clone(),
793794
})
794795
} else {
795796
None

src/skills.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -342,11 +342,10 @@ impl SkillRegistry {
342342
}
343343
}
344344

345-
if specific_skill.is_some() && installed_skills.is_empty() {
345+
if let Some(skill) = specific_skill.filter(|_| installed_skills.is_empty()) {
346346
return Err(format!(
347347
"Skill '{}' not found in repository {}",
348-
specific_skill.unwrap(),
349-
full_repo_url
348+
skill, full_repo_url
350349
));
351350
}
352351

0 commit comments

Comments
 (0)