Skip to content

Commit b32b5d4

Browse files
RoyLinRoyLin
authored andcommitted
feat(sdk): add TeamRunner.create() with per-member model/prompt/workspace overrides
Introduce a simplified TeamRunner factory API that collapses addMember + bindAgent into single calls with full per-member configuration support: - TeamRunner.create(agent, workspace[, agentDirs]) — stores a default agent context so callers never need to repeat agent/workspace/dirs - addLead / addWorker / addReviewer accept optional TeamMemberOptions (workspace, model, role, guidelines, response_style, extra, max_tool_rounds) for per-member overrides - Worker IDs auto-generated as "worker-1", "worker-2", etc. - Slot-level inheritance: unset slots fall through to AgentDefinition prompt, then to Agent base config (not all-or-nothing override) - Fix session_for_agent prompt_slots merge: setting role no longer silently discards the agent definition's built-in prompt Bump version to 1.2.1.
1 parent 275650f commit b32b5d4

14 files changed

Lines changed: 2283 additions & 235 deletions

File tree

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 = "1.2.0"
3+
version = "1.2.1"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"

core/src/agent_api.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -802,13 +802,18 @@ impl Agent {
802802
}
803803
}
804804

805-
// Inject agent system prompt via the extra slot (same as task tool).
806-
if opts.prompt_slots.is_none() {
807-
if let Some(ref prompt) = def.prompt {
808-
opts.prompt_slots = Some(crate::prompts::SystemPromptSlots {
809-
extra: Some(prompt.clone()),
810-
..Default::default()
811-
});
805+
// Inject agent system prompt into the extra slot.
806+
//
807+
// Merge slot-by-slot rather than all-or-nothing: if the caller already
808+
// set some slots (e.g. `role`), only fill in `extra` from the definition
809+
// if the caller left it unset. This lets per-member overrides coexist
810+
// with per-role prompts defined in the agent file.
811+
if let Some(ref prompt) = def.prompt {
812+
let slots = opts
813+
.prompt_slots
814+
.get_or_insert_with(crate::prompts::SystemPromptSlots::default);
815+
if slots.extra.is_none() {
816+
slots.extra = Some(prompt.clone());
812817
}
813818
}
814819

core/src/agent_teams.rs

Lines changed: 228 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ impl Default for TeamConfig {
7070
}
7171

7272
/// Role of a team member.
73-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
74+
#[serde(rename_all = "snake_case")]
7475
pub enum TeamRole {
7576
/// Decomposes goals into tasks, assigns work.
7677
Lead,
@@ -540,11 +541,58 @@ Result: {result}
540541
If the result satisfactorily completes the task, respond with \"APPROVED: <brief reason>\".
541542
If the result is incomplete or incorrect, respond with \"REJECTED: <specific feedback for improvement>\".";
542543

544+
/// Per-member session overrides for [`TeamRunner::add_lead`], [`TeamRunner::add_worker`],
545+
/// and [`TeamRunner::add_reviewer`].
546+
#[derive(Debug, Default, Clone)]
547+
pub struct TeamMemberOptions {
548+
/// Override the workspace for this member.
549+
///
550+
/// When set, this member uses the given path (e.g. a git worktree) instead
551+
/// of the default workspace from [`TeamRunner::with_agent`].
552+
pub workspace: Option<String>,
553+
/// Model override. Format: `"provider/model"` (e.g. `"openai/gpt-4o"`).
554+
pub model: Option<String>,
555+
/// Prompt slot customization (role, guidelines, response style, extra).
556+
pub prompt_slots: Option<crate::prompts::SystemPromptSlots>,
557+
/// Override maximum tool-call rounds for this member's session.
558+
pub max_tool_rounds: Option<usize>,
559+
}
560+
561+
impl TeamMemberOptions {
562+
fn into_session_options(self) -> Option<crate::agent_api::SessionOptions> {
563+
if self.model.is_none() && self.prompt_slots.is_none() && self.max_tool_rounds.is_none() {
564+
return None;
565+
}
566+
let mut opts = crate::agent_api::SessionOptions::new();
567+
if let Some(m) = self.model {
568+
opts = opts.with_model(m);
569+
}
570+
if let Some(slots) = self.prompt_slots {
571+
opts = opts.with_prompt_slots(slots);
572+
}
573+
if let Some(rounds) = self.max_tool_rounds {
574+
opts = opts.with_max_tool_rounds(rounds);
575+
}
576+
Some(opts)
577+
}
578+
}
579+
580+
/// Default agent context stored on [`TeamRunner`] to support simplified member
581+
/// addition via [`TeamRunner::add_lead`], [`TeamRunner::add_worker`], and
582+
/// [`TeamRunner::add_reviewer`].
583+
struct DefaultAgentContext {
584+
agent: Arc<crate::agent_api::Agent>,
585+
workspace: String,
586+
registry: Arc<crate::subagent::AgentRegistry>,
587+
}
588+
543589
/// Binds an `AgentTeam` to concrete `AgentExecutor` sessions, enabling
544590
/// Lead → Worker → Reviewer automated workflows.
545591
pub struct TeamRunner {
546592
team: AgentTeam,
547593
sessions: HashMap<String, Arc<dyn AgentExecutor>>,
594+
default_ctx: Option<DefaultAgentContext>,
595+
worker_count: usize,
548596
}
549597

550598
impl TeamRunner {
@@ -553,6 +601,32 @@ impl TeamRunner {
553601
Self {
554602
team,
555603
sessions: HashMap::new(),
604+
default_ctx: None,
605+
worker_count: 0,
606+
}
607+
}
608+
609+
/// Create a runner with a default agent context for simplified member addition.
610+
///
611+
/// Unlike [`TeamRunner::new`], this constructor lets you call
612+
/// [`add_lead`](Self::add_lead), [`add_worker`](Self::add_worker), and
613+
/// [`add_reviewer`](Self::add_reviewer) without repeating the agent,
614+
/// workspace, and registry on every call.
615+
pub fn with_agent(
616+
team: AgentTeam,
617+
agent: Arc<crate::agent_api::Agent>,
618+
workspace: &str,
619+
registry: Arc<crate::subagent::AgentRegistry>,
620+
) -> Self {
621+
Self {
622+
team,
623+
sessions: HashMap::new(),
624+
default_ctx: Some(DefaultAgentContext {
625+
agent,
626+
workspace: workspace.to_string(),
627+
registry,
628+
}),
629+
worker_count: 0,
556630
}
557631
}
558632

@@ -604,19 +678,124 @@ impl TeamRunner {
604678
self.bind_session(member_id, Arc::new(session))
605679
}
606680

681+
/// Create a session from the default agent context, applying optional member overrides.
682+
///
683+
/// Returns an error if no default context is set or the agent name is not
684+
/// found in the registry.
685+
fn create_session_from_default(
686+
&self,
687+
agent_name: &str,
688+
member_opts: Option<TeamMemberOptions>,
689+
) -> crate::error::Result<crate::agent_api::AgentSession> {
690+
let ctx = self.default_ctx.as_ref().ok_or_else(|| {
691+
anyhow::anyhow!("no default agent context; use TeamRunner::with_agent")
692+
})?;
693+
let def = ctx
694+
.registry
695+
.get(agent_name)
696+
.ok_or_else(|| anyhow::anyhow!("agent '{}' not found in registry", agent_name))?;
697+
let workspace = member_opts
698+
.as_ref()
699+
.and_then(|o| o.workspace.clone())
700+
.unwrap_or_else(|| ctx.workspace.clone());
701+
let session_opts = member_opts.and_then(|o| o.into_session_options());
702+
ctx.agent.session_for_agent(workspace, &def, session_opts)
703+
}
704+
705+
/// Add a Lead member and bind it to the named agent definition.
706+
///
707+
/// Requires a default agent context set via [`TeamRunner::with_agent`].
708+
/// The member ID is fixed to `"lead"`.
709+
///
710+
/// Use `opts` to override the model, prompt slots, or workspace (e.g. a git worktree).
711+
///
712+
/// # Errors
713+
///
714+
/// Returns an error if no default context is set or `agent_name` is not
715+
/// found in the registry.
716+
pub fn add_lead(
717+
&mut self,
718+
agent_name: &str,
719+
opts: Option<TeamMemberOptions>,
720+
) -> crate::error::Result<()> {
721+
let session = self.create_session_from_default(agent_name, opts)?;
722+
self.team.add_member("lead", TeamRole::Lead);
723+
self.sessions.insert("lead".to_string(), Arc::new(session));
724+
Ok(())
725+
}
726+
727+
/// Add a Worker member and bind it to the named agent definition.
728+
///
729+
/// Requires a default agent context set via [`TeamRunner::with_agent`].
730+
/// Member IDs are auto-generated as `"worker-1"`, `"worker-2"`, etc.
731+
/// Multiple workers can be added; they run concurrently during execution.
732+
///
733+
/// Use `opts` to override the model, prompt slots, or workspace — set
734+
/// `workspace` to an isolated git worktree path to prevent filesystem
735+
/// conflicts between concurrent workers.
736+
///
737+
/// # Errors
738+
///
739+
/// Returns an error if no default context is set or `agent_name` is not
740+
/// found in the registry.
741+
pub fn add_worker(
742+
&mut self,
743+
agent_name: &str,
744+
opts: Option<TeamMemberOptions>,
745+
) -> crate::error::Result<()> {
746+
self.worker_count += 1;
747+
let id = format!("worker-{}", self.worker_count);
748+
let session = self.create_session_from_default(agent_name, opts)?;
749+
self.team.add_member(&id, TeamRole::Worker);
750+
self.sessions.insert(id, Arc::new(session));
751+
Ok(())
752+
}
753+
754+
/// Add a Reviewer member and bind it to the named agent definition.
755+
///
756+
/// Requires a default agent context set via [`TeamRunner::with_agent`].
757+
/// The member ID is fixed to `"reviewer"`.
758+
///
759+
/// Use `opts` to override the model, prompt slots, or workspace.
760+
///
761+
/// # Errors
762+
///
763+
/// Returns an error if no default context is set or `agent_name` is not
764+
/// found in the registry.
765+
pub fn add_reviewer(
766+
&mut self,
767+
agent_name: &str,
768+
opts: Option<TeamMemberOptions>,
769+
) -> crate::error::Result<()> {
770+
let session = self.create_session_from_default(agent_name, opts)?;
771+
self.team.add_member("reviewer", TeamRole::Reviewer);
772+
self.sessions
773+
.insert("reviewer".to_string(), Arc::new(session));
774+
Ok(())
775+
}
776+
777+
/// Get a mutable reference to the underlying team.
778+
pub fn team_mut(&mut self) -> &mut AgentTeam {
779+
&mut self.team
780+
}
781+
607782
/// Access the shared task board.
608783
pub fn task_board(&self) -> Arc<TeamTaskBoard> {
609784
self.team.task_board_arc()
610785
}
611786

612787
/// Run the full Lead → Worker → Reviewer workflow until all tasks are done
613-
/// or `max_rounds` is exceeded.
788+
/// or `max_rounds` retry cycles are exceeded.
614789
///
615790
/// Steps:
616791
/// 1. Lead decomposes `goal` into tasks via JSON response.
617-
/// 2. Workers concurrently claim and execute tasks.
618-
/// 3. Reviewer approves or rejects completed tasks.
619-
/// 4. Rejected tasks re-enter the work queue for retry.
792+
/// 2. Workers run concurrently until all tasks are in review or done.
793+
/// 3. Reviewer processes all InReview tasks (after workers finish).
794+
/// 4. If rejected tasks remain, workers retry them (back to step 2).
795+
///
796+
/// Running the reviewer after workers (rather than concurrently) prevents a
797+
/// race where the reviewer's polling timeout fires before long-running agent
798+
/// calls have produced any InReview tasks.
620799
pub async fn run_until_done(&self, goal: &str) -> crate::error::Result<TeamRunResult> {
621800
// --- Step 1: Lead decomposes the goal ---
622801
let lead = self
@@ -640,7 +819,13 @@ impl TeamRunner {
640819
board.post(desc, &lead.id, None);
641820
}
642821

643-
// --- Step 2 & 3: Spawn workers and reviewer concurrently ---
822+
// --- Step 2 & 3: Workers then reviewer, cycling until all tasks Done ---
823+
//
824+
// Workers run to completion first, then the reviewer processes all
825+
// InReview tasks. If the reviewer rejects any tasks, workers are
826+
// re-spawned to retry them. This sequential-then-cycle approach avoids
827+
// a race where the reviewer's polling timeout fires before long-running
828+
// LLM agent calls have produced any InReview tasks.
644829
let poll = Duration::from_millis(self.team.config.poll_interval_ms);
645830
let max_rounds = self.team.config.max_rounds;
646831

@@ -666,37 +851,54 @@ impl TeamRunner {
666851
.map(|e| (m.id.clone(), Arc::clone(e)))
667852
});
668853

669-
let mut worker_handles = Vec::new();
670-
for (id, executor) in workers {
671-
let b = Arc::clone(&board);
672-
let handle = tokio::spawn(async move {
673-
run_worker(id, executor, b, max_rounds, poll).await;
674-
});
675-
worker_handles.push(handle);
676-
}
677-
678-
let reviewer_rounds = if let Some((id, executor)) = reviewer {
679-
let b = Arc::clone(&board);
680-
let handle =
681-
tokio::spawn(async move { run_reviewer(id, executor, b, max_rounds, poll).await });
682-
for h in worker_handles {
683-
let _ = h.await;
854+
let mut total_reviewer_rounds = 0usize;
855+
856+
for _cycle in 0..max_rounds {
857+
// Run all workers concurrently until no claimable work remains.
858+
let mut worker_handles = Vec::new();
859+
for (id, executor) in &workers {
860+
let b = Arc::clone(&board);
861+
let id = id.clone();
862+
let executor = Arc::clone(executor);
863+
let handle = tokio::spawn(async move {
864+
run_worker(id, executor, b, max_rounds, poll).await;
865+
});
866+
worker_handles.push(handle);
684867
}
685-
handle.await.unwrap_or(0)
686-
} else {
687868
for h in worker_handles {
688869
let _ = h.await;
689870
}
690-
0
691-
};
871+
872+
// Run reviewer on all InReview tasks (workers are done; no race).
873+
if let Some((ref id, ref executor)) = reviewer {
874+
let rounds = run_reviewer(
875+
id.clone(),
876+
Arc::clone(executor),
877+
Arc::clone(&board),
878+
max_rounds,
879+
poll,
880+
)
881+
.await;
882+
total_reviewer_rounds += rounds;
883+
}
884+
885+
// Stop if no rejected tasks remain to retry.
886+
let (open, in_progress, in_review, _, rejected) = board.stats();
887+
if open == 0 && in_progress == 0 && in_review == 0 && rejected == 0 {
888+
break;
889+
}
890+
if rejected == 0 {
891+
break;
892+
}
893+
}
692894

693895
let done_tasks = board.by_status(TaskStatus::Done);
694896
let rejected_tasks = board.by_status(TaskStatus::Rejected);
695897

696898
Ok(TeamRunResult {
697899
done_tasks,
698900
rejected_tasks,
699-
rounds: reviewer_rounds,
901+
rounds: total_reviewer_rounds,
700902
})
701903
}
702904
}

core/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ pub use a3s_lane::MetricsSnapshot;
9090
pub use agent::{AgentConfig, AgentEvent, AgentLoop, AgentResult};
9191
pub use agent_api::{Agent, AgentSession, SessionOptions, ToolCallResult};
9292
pub use agent_teams::{
93-
AgentExecutor, AgentTeam, TeamConfig, TeamMember, TeamMessage, TeamRole, TeamRunResult,
94-
TeamRunner, TeamTaskBoard,
93+
AgentExecutor, AgentTeam, TeamConfig, TeamMember, TeamMemberOptions, TeamMessage, TeamRole,
94+
TeamRunResult, TeamRunner, TeamTaskBoard,
9595
};
9696
pub use commands::{CommandAction, CommandContext, CommandOutput, CommandRegistry, SlashCommand};
9797
pub use config::{CodeConfig, ModelConfig, ModelCost, ModelLimit, ModelModalities, ProviderConfig};
@@ -101,6 +101,7 @@ pub use llm::{
101101
AnthropicClient, Attachment, ContentBlock, ImageSource, LlmClient, LlmResponse, Message,
102102
OpenAiClient, TokenUsage,
103103
};
104+
pub use orchestrator::AgentSlot;
104105
pub use prompts::SystemPromptSlots;
105106
pub use queue::{
106107
ExternalTask, ExternalTaskResult, LaneHandlerConfig, SessionCommand, SessionLane,

sdk/node/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-node"
3-
version = "1.2.0"
3+
version = "1.2.1"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"

0 commit comments

Comments
 (0)