Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 6 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,12 @@

## Demo

Type `/` for the command list and `@` to complete a filename. Ask what the file
does and Rebon has to read it, so it stops and asks you first. Approve, ask a
follow-up, and it still knows what was just said.

![Rebon core loop](assets/demo/core-loop.webp)

Each `shift+tab` switches permission mode, four in all: default, plan, accept
edits, auto. In auto it stops asking and finishes the rest by itself.

![Rebon permission modes](assets/demo/modes-auto.webp)

`/agent` sends a job to a second agent. It runs in the background, so you don't
wait for it and can keep asking about something else. `/tasks` shows how long
it ran, what it spent, and what it came back with.

![Rebon background sub-agent](assets/demo/background-agent.webp)

Broke something and want out of it? `/rewind` lists every message you sent
earlier. Pick one and both the files and the conversation go back to how they
were before you sent it.

![Rebon rewind](assets/demo/rewind.webp)
| Feature | Demo |
| --- | --- |
| Type `/` for the command list and `@` to complete a filename. Ask what the file does and Rebon has to read it, so it stops and asks you first. Approve, ask a follow-up, and it still knows what was just said. | <img src="assets/demo/core-loop.webp" width="440" alt="Rebon core loop"> |
| Each `shift+tab` switches permission mode, four in all: default, plan, accept edits, auto. In auto it stops asking and finishes the rest by itself. | <img src="assets/demo/modes-auto.webp" width="440" alt="Rebon permission modes"> |
| `/agent` sends a job to a second agent. It runs in the background, so you don't wait for it and can keep asking about something else. `/tasks` shows how long it ran, what it spent, and what it came back with. | <img src="assets/demo/background-agent.webp" width="440" alt="Rebon background sub-agent"> |
| Broke something and want out of it? `/rewind` lists every message you sent earlier. Pick one and both the files and the conversation go back to how they were before you sent it. | <img src="assets/demo/rewind.webp" width="440" alt="Rebon rewind"> |

## What is in this repository

Expand Down
Binary file modified assets/demo/background-agent.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/demo/core-loop.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/demo/modes-auto.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/demo/rewind.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
71 changes: 71 additions & 0 deletions crates/plugins/agents/src/runtime/spawner/routing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ impl FirstPromptModelRouter for Router {
"error" => anyhow::bail!("mock failure"),
"pending" => std::future::pending().await,
_ => Ok(ModelRoutingDecision {
provider: (self.mode == "provider").then(|| "other".into()),
model: Some("selected".into()),
reasoning_effort: Some(ReasoningEffort::Low),
}),
Expand Down Expand Up @@ -102,6 +103,76 @@ async fn automatic_routing_once_per_task_and_parent_isolation() {
assert!(notices[0].1.contains("with low effort"));
}

/// 自动路由选中的 provider 就是 worker 真正跑的那条腿,而不是父会话那一个。
///
/// `ProviderFollower` 把请求的 provider 原样答回:真实 router 会在这一步重建另一个
/// provider 的 client,测试只需要看请求有没有把 provider 换成决策点名的那一个。
#[tokio::test]
async fn automatic_routing_can_move_the_worker_to_another_provider() {
struct ProviderFollower;
#[async_trait::async_trait]
impl rebon_agent_core::model_router::AgentModelRouter for ProviderFollower {
async fn resolve(
&self,
request: ModelRouteRequest,
) -> anyhow::Result<ResolvedModelRuntime> {
Ok(ResolvedModelRuntime {
provider_name: request.provider.clone().unwrap_or_else(|| "mock".into()),
client: Arc::new(MockModelClient::new()),
model: request.model.clone().unwrap_or_else(|| "initial".into()),
reasoning_effort: request.reasoning_effort,
})
}
async fn resolve_automatic(
&self,
request: ModelRouteRequest,
) -> anyhow::Result<ResolvedModelRuntime> {
self.resolve(request).await
}
}
let (kernel, _engine, spawner, router, current) = setup("provider", true);
let notices = Arc::new(Mutex::new(Vec::new()));
let capture = notices.clone();
kernel.context().fork("test-routing-provider-notice").on(
move |notice: &rebon_core::model_routing::ModelRoutingNotice| {
capture.lock().unwrap().push(notice.text.clone());
},
);
let spawner = spawner.with_model_router(Arc::new(ProviderFollower));
let (selected, auto) = spawner
.automatically_route_worker(
&spec("p"),
"one",
ModelRouteRequest::default(),
current.clone(),
)
.await
.unwrap();
assert!(auto);
assert_eq!(selected.provider_name, "other");
assert_eq!(selected.model, "selected");
assert_eq!(router.calls.load(Ordering::SeqCst), 1);
assert!(
notices.lock().unwrap()[0].contains("other / selected"),
"{:?}",
notices.lock().unwrap()
);
// 声明过 provider 的派发不吃自动路由:那是调用方的决定,不是分类器的。
let mut declared = current;
declared.provider_name = "declared".into();
let request = ModelRouteRequest {
provider: Some("declared".into()),
..Default::default()
};
let (kept, auto) = spawner
.automatically_route_worker(&spec("p2"), "two", request, declared)
.await
.unwrap();
assert!(!auto);
assert_eq!(kept.provider_name, "declared");
assert_eq!(router.calls.load(Ordering::SeqCst), 1);
}

#[tokio::test]
async fn automatic_routing_disabled_and_explicit_overrides() {
let (_kernel, _engine, spawner, router, current) = setup("ok", false);
Expand Down
28 changes: 19 additions & 9 deletions crates/plugins/agents/src/runtime/spawner/worker_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,7 @@ impl EngineSubAgentSpawner {
current: rebon_agent_core::model_router::ResolvedModelRuntime,
) -> Result<(rebon_agent_core::model_router::ResolvedModelRuntime, bool), String> {
use rebon_core::model_routing::{
run_bounded, selection_notice, ModelRoutingInput, ModelRoutingNotice,
routed_target, run_bounded, selection_notice, ModelRoutingInput, ModelRoutingNotice,
ModelRoutingService,
};
let Some(engine) = self.engine.upgrade() else {
Expand All @@ -881,8 +881,10 @@ impl EngineSubAgentSpawner {
.or_default()
.clone();
let mut cached = entry.lock().await;
// 后续显式派发设置也必须优先,不能被任务缓存的自动选择覆盖。
if request.model.is_some()
// 后续显式派发设置也必须优先,不能被任务缓存的自动选择覆盖。provider 也
// 算显式设置:路由现在能换 provider,声明过的就更不能被它顶掉。
if request.provider.is_some()
|| request.model.is_some()
|| request.model_profile.is_some()
|| request.reasoning_effort.is_some()
|| self
Expand All @@ -893,7 +895,8 @@ impl EngineSubAgentSpawner {
None,
)
.is_some_and(|selection| {
selection.model.is_some()
selection.provider.is_some()
|| selection.model.is_some()
|| selection.model_profile.is_some()
|| selection.reasoning_effort.is_some()
})
Expand All @@ -920,18 +923,21 @@ impl EngineSubAgentSpawner {
session: SessionHandle::borrowed(current.client.clone()),
};
let decision = router.route(input).await?;
// 换 provider 时 worker 也要换腿,所以目标 runtime 由决策决定而不是当前 provider。
let (provider, model) =
routed_target(&decision, &current.provider_name, &current.model);
let runtime = self
.model_router()
.resolve_automatic(ModelRouteRequest {
provider: Some(current.provider_name.clone()),
model: Some(decision.model.unwrap_or_else(|| current.model.clone())),
provider: Some(provider.clone()),
model: Some(model),
reasoning_effort: decision.reasoning_effort.or(current.reasoning_effort),
..Default::default()
})
.await?;
anyhow::ensure!(
runtime.provider_name == current.provider_name,
"router cannot switch providers"
runtime.provider_name == provider,
"worker router returned a different provider than requested"
);
Ok(runtime)
};
Expand All @@ -941,7 +947,11 @@ impl EngineSubAgentSpawner {
.map_err(|error| error.to_string())?;
let (runtime, text) = match result {
Ok(runtime) => {
let text = selection_notice(&runtime.model, runtime.reasoning_effort);
let text = selection_notice(
&runtime.provider_name,
&runtime.model,
runtime.reasoning_effort.map(|effort| effort.as_str()),
);
*cached = Some(runtime.clone());
(runtime, text)
}
Expand Down
Loading
Loading