orchestrator: per-node model selection via node.attrs["agent"]

TurnRequest gains an optional `agent` sourced from the graph node's
attrs["agent"]. The ZeroClaw executor binds a node to that alias directly
when present, falling back to the role→alias map otherwise. This lets a
single POST /api/topologies/run specify a different model per role
(heterogeneous topologies) entirely in the graph JSON — no server
ZEROCLAW_AGENT_MAP change or recreate per configuration, which makes
quota-frugal model×role sweeps practical.

cm-orchestrator 13 + cm-api topology_exec 4 tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-17 12:52:18 -07:00
co-authored by Claude Opus 4.8
parent ae517a18c1
commit 99e2fd3ec3
2 changed files with 35 additions and 1 deletions
+28 -1
View File
@@ -256,7 +256,15 @@ impl ZeroClawDriveExecutor {
impl TurnExecutor for ZeroClawDriveExecutor { impl TurnExecutor for ZeroClawDriveExecutor {
async fn run_turn(&self, req: TurnRequest) -> Result<TurnOutcome, OrchestratorError> { async fn run_turn(&self, req: TurnRequest) -> Result<TurnOutcome, OrchestratorError> {
let alias = self.alias_for(&req.role); // An explicit per-node agent (graph `node.attrs["agent"]`) wins, so one
// request can pin a different model per role; otherwise use the map.
let alias = req
.agent
.as_deref()
.map(str::trim)
.filter(|a| !a.is_empty())
.map(str::to_string)
.unwrap_or_else(|| self.alias_for(&req.role));
let prompt = Self::build_prompt(&req); let prompt = Self::build_prompt(&req);
self.drive(&alias, &prompt).await self.drive(&alias, &prompt).await
} }
@@ -334,11 +342,30 @@ mod tests {
TurnRequest { TurnRequest {
node_id: "a".into(), node_id: "a".into(),
role: "researcher".into(), role: "researcher".into(),
agent: None,
task: "say hi".into(), task: "say hi".into(),
context: vec![], context: vec![],
} }
} }
#[test]
fn explicit_node_agent_overrides_role_map() {
let mut map = HashMap::new();
map.insert("researcher".to_string(), "worker_glm".to_string());
let exec = ZeroClawDriveExecutor::new("http://x".into(), "c".into(), map, "scout".into());
// role map → worker_glm
assert_eq!(exec.alias_for("researcher"), "worker_glm");
// but an explicit per-node agent should win in run_turn's selection
let agent = Some("worker_kimi".to_string());
let chosen = agent
.as_deref()
.map(str::trim)
.filter(|a| !a.is_empty())
.map(str::to_string)
.unwrap_or_else(|| exec.alias_for("researcher"));
assert_eq!(chosen, "worker_kimi");
}
#[tokio::test] #[tokio::test]
async fn drives_a_turn_and_accumulates_output_and_tokens() { async fn drives_a_turn_and_accumulates_output_and_tokens() {
let router = Router::new() let router = Router::new()
+7
View File
@@ -78,6 +78,12 @@ pub struct TurnRequest {
pub node_id: String, pub node_id: String,
/// The node's role. /// The node's role.
pub role: String, pub role: String,
/// Optional explicit agent/model alias for this node, from the graph
/// (`node.attrs["agent"]`). When set, the executor binds this node to this
/// alias directly — letting one request pin a different model per role
/// (heterogeneous topologies) without reconfiguring the server. Falls back
/// to the role→alias map when absent.
pub agent: Option<String>,
/// The top-level task. /// The top-level task.
pub task: String, pub task: String,
/// Upstream context (task and/or prior step outputs) for this turn. /// Upstream context (task and/or prior step outputs) for this turn.
@@ -186,6 +192,7 @@ pub async fn execute<E: TurnExecutor>(
.run_turn(TurnRequest { .run_turn(TurnRequest {
node_id: node.id.clone(), node_id: node.id.clone(),
role: node.role.clone(), role: node.role.clone(),
agent: node.attrs.get("agent").cloned(),
task: task.to_string(), task: task.to_string(),
context, context,
}) })