anycode-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,267 @@
1
+ """AnyCode — top-level coordinator for agents, teams, and task execution pipelines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from anycode.collaboration.team import Team
8
+ from anycode.core.agent import Agent
9
+ from anycode.core.pool import AgentPool
10
+ from anycode.core.scheduler import Scheduler
11
+ from anycode.helpers.usage_tracker import EMPTY_USAGE, merge_usage
12
+ from anycode.tasks.queue import TaskQueue
13
+ from anycode.tasks.task import create_task, get_task_dependency_order, validate_task_dependencies
14
+ from anycode.tools.built_in import register_built_in_tools
15
+ from anycode.tools.executor import ToolExecutor
16
+ from anycode.tools.registry import ToolRegistry
17
+ from anycode.types import (
18
+ AgentConfig,
19
+ AgentRunResult,
20
+ OrchestratorConfig,
21
+ OrchestratorEvent,
22
+ Task,
23
+ TeamConfig,
24
+ TeamRunResult,
25
+ TokenUsage,
26
+ )
27
+
28
+
29
+ class TaskSpec:
30
+ def __init__(self, title: str, description: str, assignee: str | None = None, depends_on: list[str] | None = None) -> None:
31
+ self.title = title
32
+ self.description = description
33
+ self.assignee = assignee
34
+ self.depends_on = depends_on or []
35
+
36
+
37
+ class AnyCode:
38
+ """Top-level orchestration engine for agents, teams, and task pipelines."""
39
+
40
+ def __init__(self, config: OrchestratorConfig | dict[str, object] | None = None) -> None:
41
+ self._config = OrchestratorConfig.model_validate(config) if isinstance(config, dict) else (config or OrchestratorConfig())
42
+ self._pool = AgentPool(self._config.max_concurrency or 5)
43
+ self._scheduler = Scheduler("dependency-first")
44
+ self._teams: dict[str, Team] = {}
45
+
46
+ def build_agent(self, config: AgentConfig | dict[str, object]) -> Agent:
47
+ """Construct a fully wired Agent instance with all default tools."""
48
+ typed_config = AgentConfig.model_validate(config) if isinstance(config, dict) else config
49
+ registry = ToolRegistry()
50
+ register_built_in_tools(registry)
51
+ executor = ToolExecutor(registry)
52
+ return Agent(typed_config, registry, executor)
53
+
54
+ def create_team(self, name: str, config: TeamConfig) -> Team:
55
+ """Instantiate a team and enrol its agents into the shared pool."""
56
+ team = Team(config)
57
+ self._teams[name] = team
58
+ for agent_cfg in config.agents:
59
+ if not self._pool.get(agent_cfg.name):
60
+ self._pool.add(self.build_agent(agent_cfg))
61
+ return team
62
+
63
+ async def run_agent(self, config: AgentConfig | dict[str, object], prompt: str) -> AgentRunResult:
64
+ """Create and run a single agent on one prompt."""
65
+ typed_config = AgentConfig.model_validate(config) if isinstance(config, dict) else config
66
+ agent = self.build_agent(typed_config)
67
+ self._emit(OrchestratorEvent(type="agent_start", agent=typed_config.name))
68
+ try:
69
+ result = await agent.run(prompt)
70
+ self._emit(OrchestratorEvent(type="agent_complete", agent=typed_config.name, data=result))
71
+ return result
72
+ except Exception as e:
73
+ self._emit(OrchestratorEvent(type="error", agent=typed_config.name, data=e))
74
+ return AgentRunResult(success=False, output=str(e), messages=[], token_usage=EMPTY_USAGE, tool_calls=[])
75
+
76
+ async def run_team(self, team: Team, goal: str) -> TeamRunResult:
77
+ """Decompose a high-level goal into tasks using a coordinator agent, then execute."""
78
+ agents = team.get_agents()
79
+ if not agents:
80
+ return TeamRunResult(success=False, agent_results={}, total_token_usage=EMPTY_USAGE)
81
+
82
+ coordinator_cfg = agents[0]
83
+ coordinator = self.build_agent(coordinator_cfg)
84
+ coordinator_prompt = self._build_coordinator_prompt(agents, goal)
85
+
86
+ self._emit(OrchestratorEvent(type="agent_start", agent=coordinator_cfg.name))
87
+ plan_result = await coordinator.run(coordinator_prompt)
88
+ self._emit(OrchestratorEvent(type="agent_complete", agent=coordinator_cfg.name, data=plan_result))
89
+
90
+ if not plan_result.success:
91
+ return TeamRunResult(
92
+ success=False,
93
+ agent_results={coordinator_cfg.name: plan_result},
94
+ total_token_usage=plan_result.token_usage,
95
+ )
96
+
97
+ task_specs = self._parse_task_specs(plan_result.output, agents)
98
+ if not task_specs:
99
+ return TeamRunResult(
100
+ success=True,
101
+ agent_results={coordinator_cfg.name: plan_result},
102
+ total_token_usage=plan_result.token_usage,
103
+ )
104
+
105
+ task_results = await self._execute_tasks(team, task_specs)
106
+ combined = {coordinator_cfg.name: plan_result, **task_results.agent_results}
107
+ total = plan_result.token_usage
108
+ for r in task_results.agent_results.values():
109
+ total = merge_usage(total, r.token_usage)
110
+
111
+ return TeamRunResult(success=task_results.success, agent_results=combined, total_token_usage=total)
112
+
113
+ async def run_tasks(self, team: Team, task_specs: list[TaskSpec]) -> TeamRunResult:
114
+ """Run an explicit set of task specs with full dependency resolution."""
115
+ return await self._execute_tasks(team, task_specs)
116
+
117
+ async def _execute_tasks(self, team: Team, specs: list[TaskSpec]) -> TeamRunResult:
118
+ resolved = self._resolve_specs(specs)
119
+ queue = TaskQueue()
120
+ agent_results: dict[str, AgentRunResult] = {}
121
+ total_usage: TokenUsage = EMPTY_USAGE
122
+ all_succeeded = True
123
+
124
+ for task in resolved:
125
+ queue.add(task)
126
+
127
+ self._scheduler.auto_assign(queue, team.get_agents())
128
+ ordered = get_task_dependency_order(queue.list())
129
+ waves = self._build_waves(ordered, queue)
130
+
131
+ for wave in waves:
132
+ async def _run_task(task: Task) -> None:
133
+ nonlocal all_succeeded, total_usage
134
+ assignee = task.assignee
135
+ if not assignee:
136
+ queue.fail(task.id, "Unassigned task — no agent available.")
137
+ return
138
+
139
+ self._emit(OrchestratorEvent(type="task_start", task=task.id, agent=assignee, data=task))
140
+ queue.update(task.id, status="in_progress")
141
+
142
+ prompt = self._build_task_prompt(task, queue)
143
+ agent = self._pool.get(assignee) or self._build_agent_for_team(assignee, team)
144
+
145
+ self._emit(OrchestratorEvent(type="agent_start", agent=assignee))
146
+ try:
147
+ result = await agent.run(prompt)
148
+ self._emit(OrchestratorEvent(type="agent_complete", agent=assignee, data=result))
149
+ if result.success:
150
+ queue.complete(task.id, result.output)
151
+ self._emit(OrchestratorEvent(type="task_complete", task=task.id, agent=assignee))
152
+ else:
153
+ queue.fail(task.id, result.output)
154
+ all_succeeded = False
155
+ agent_results[assignee] = result
156
+ total_usage = merge_usage(total_usage, result.token_usage)
157
+ except Exception as e:
158
+ queue.fail(task.id, str(e))
159
+ all_succeeded = False
160
+ agent_results[assignee] = AgentRunResult(
161
+ success=False, output=str(e), messages=[], token_usage=EMPTY_USAGE, tool_calls=[]
162
+ )
163
+
164
+ await asyncio.gather(*[_run_task(t) for t in wave])
165
+
166
+ return TeamRunResult(success=all_succeeded, agent_results=agent_results, total_token_usage=total_usage)
167
+
168
+ def _build_agent_for_team(self, agent_name: str, team: Team) -> Agent:
169
+ config = team.get_agent(agent_name)
170
+ if not config:
171
+ raise ValueError(f'AnyCode: "{agent_name}" is not part of team "{team.name}".')
172
+ agent = self.build_agent(config)
173
+ try:
174
+ self._pool.add(agent)
175
+ except ValueError:
176
+ pass
177
+ return agent
178
+
179
+ def _resolve_specs(self, specs: list[TaskSpec]) -> list[Task]:
180
+ title_map: dict[str, str] = {}
181
+ pending: list[tuple[Task, list[str]]] = []
182
+
183
+ for spec in specs:
184
+ task = create_task(title=spec.title, description=spec.description, assignee=spec.assignee)
185
+ title_map[spec.title] = task.id
186
+ pending.append((task, spec.depends_on))
187
+
188
+ resolved: list[Task] = []
189
+ for task, raw_deps in pending:
190
+ dep_ids = [title_map[d] for d in raw_deps if d in title_map]
191
+ final = task.model_copy(update={"depends_on": dep_ids}) if dep_ids else task
192
+ resolved.append(final)
193
+
194
+ valid, errors = validate_task_dependencies(resolved)
195
+ if not valid:
196
+ raise ValueError("AnyCode: task dependency errors:\n" + "\n".join(errors))
197
+
198
+ return resolved
199
+
200
+ def _build_waves(self, ordered: list[Task], queue: TaskQueue) -> list[list[Task]]:
201
+ if not ordered:
202
+ return []
203
+ done: set[str] = set()
204
+ remaining = list(ordered)
205
+ waves: list[list[Task]] = []
206
+
207
+ while remaining:
208
+ wave = [t for t in remaining if all(d in done for d in (t.depends_on or []))]
209
+ if not wave:
210
+ for t in remaining:
211
+ queue.fail(t.id, "Unresolvable or circular dependency.")
212
+ break
213
+ waves.append(wave)
214
+ done.update(t.id for t in wave)
215
+ remaining = [t for t in remaining if t.id not in done]
216
+
217
+ return waves
218
+
219
+ def _build_coordinator_prompt(self, agents: list[AgentConfig], goal: str) -> str:
220
+ roster = "\n".join(f"- {a.name}: {(a.system_prompt or 'general-purpose assistant')[:80]}" for a in agents)
221
+ return (
222
+ f"You are the lead coordinator of a team. Here are your team members:\n{roster}\n\n"
223
+ f"Objective: {goal}\n\n"
224
+ "If you can accomplish this objective entirely by yourself with the tools you have, go ahead and do so now.\n"
225
+ "Otherwise, decompose the objective into concrete tasks and list them in the following format (one per line):\n\n"
226
+ "ASSIGN: <agent_name> | <task_title> | <task_description>\n\n"
227
+ "Only output ASSIGN: lines when you need to delegate. Pick the best-suited team member for each task.\n"
228
+ "List tasks in execution order — later tasks may depend on earlier ones."
229
+ )
230
+
231
+ def _build_task_prompt(self, task: Task, queue: TaskQueue) -> str:
232
+ dep_context_parts = []
233
+ for dep_id in task.depends_on or []:
234
+ dep_task = next((t for t in queue.list() if t.id == dep_id), None)
235
+ if dep_task and dep_task.result:
236
+ dep_context_parts.append(f"[{dep_task.title}]: {dep_task.result[:500]}")
237
+
238
+ context = "\n\nRelevant output from prerequisite tasks:\n" + "\n\n".join(dep_context_parts) if dep_context_parts else ""
239
+ return f"Task: {task.title}\n\n{task.description}{context}"
240
+
241
+ def _parse_task_specs(self, output: str, agents: list[AgentConfig]) -> list[TaskSpec]:
242
+ lines = [ln for ln in output.split("\n") if ln.strip().upper().startswith("ASSIGN:")]
243
+ if not lines:
244
+ return []
245
+
246
+ valid_names = {a.name for a in agents}
247
+ specs: list[TaskSpec] = []
248
+ titles: list[str] = []
249
+
250
+ for line in lines:
251
+ body = line.split(":", 1)[1].strip() if ":" in line else ""
252
+ segments = [s.strip() for s in body.split("|")]
253
+ if len(segments) < 3:
254
+ continue
255
+ assignee, title, *desc_parts = segments
256
+ description = " | ".join(desc_parts)
257
+ if assignee not in valid_names:
258
+ continue
259
+ depends_on = [titles[-1]] if titles else None
260
+ specs.append(TaskSpec(title=title, description=description, assignee=assignee, depends_on=depends_on))
261
+ titles.append(title)
262
+
263
+ return specs
264
+
265
+ def _emit(self, event: OrchestratorEvent) -> None:
266
+ if self._config.on_progress:
267
+ self._config.on_progress(event)
anycode/core/pool.py ADDED
@@ -0,0 +1,99 @@
1
+ """Agent registry with semaphore-bounded concurrency and round-robin dispatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import TYPE_CHECKING
7
+
8
+ from anycode.helpers.concurrency_gate import Semaphore
9
+ from anycode.helpers.usage_tracker import EMPTY_USAGE
10
+ from anycode.types import AgentRunResult, PoolStatus
11
+
12
+ if TYPE_CHECKING:
13
+ from anycode.core.agent import Agent
14
+
15
+
16
+ class AgentPool:
17
+ """Manages named agents with bounded concurrency."""
18
+
19
+ def __init__(self, max_concurrency: int = 5) -> None:
20
+ self._agents: dict[str, Agent] = {}
21
+ self._semaphore = Semaphore(max_concurrency)
22
+ self._rr_index = 0
23
+
24
+ def add(self, agent: Agent) -> None:
25
+ if agent.name in self._agents:
26
+ raise ValueError(f'Pool: agent "{agent.name}" already exists in the registry.')
27
+ self._agents[agent.name] = agent
28
+
29
+ def remove(self, name: str) -> None:
30
+ if name not in self._agents:
31
+ raise ValueError(f'Pool: no agent named "{name}" is registered.')
32
+ del self._agents[name]
33
+
34
+ def get(self, name: str) -> Agent | None:
35
+ return self._agents.get(name)
36
+
37
+ def list(self) -> list[Agent]:
38
+ return list(self._agents.values())
39
+
40
+ async def run(self, agent_name: str, prompt: str) -> AgentRunResult:
41
+ agent = self._require(agent_name)
42
+ await self._semaphore.acquire()
43
+ try:
44
+ return await agent.run(prompt)
45
+ finally:
46
+ self._semaphore.release()
47
+
48
+ async def run_parallel(self, jobs: list[dict[str, str]]) -> dict[str, AgentRunResult]:
49
+ results: dict[str, AgentRunResult] = {}
50
+
51
+ async def _job(agent_name: str, prompt: str) -> None:
52
+ try:
53
+ results[agent_name] = await self.run(agent_name, prompt)
54
+ except Exception as e:
55
+ results[agent_name] = AgentRunResult(
56
+ success=False, output=str(e), messages=[], token_usage=EMPTY_USAGE, tool_calls=[]
57
+ )
58
+
59
+ await asyncio.gather(*[_job(j["agent"], j["prompt"]) for j in jobs])
60
+ return results
61
+
62
+ async def run_any(self, prompt: str) -> AgentRunResult:
63
+ agents = self.list()
64
+ if not agents:
65
+ raise ValueError("Pool: run_any() requires at least one registered agent.")
66
+ self._rr_index = self._rr_index % len(agents)
67
+ agent = agents[self._rr_index]
68
+ self._rr_index = (self._rr_index + 1) % len(agents)
69
+
70
+ await self._semaphore.acquire()
71
+ try:
72
+ return await agent.run(prompt)
73
+ finally:
74
+ self._semaphore.release()
75
+
76
+ def get_status(self) -> PoolStatus:
77
+ idle = running = completed = error = 0
78
+ for agent in self._agents.values():
79
+ s = agent.get_state().status
80
+ if s == "idle":
81
+ idle += 1
82
+ elif s == "running":
83
+ running += 1
84
+ elif s == "completed":
85
+ completed += 1
86
+ elif s == "error":
87
+ error += 1
88
+ return PoolStatus(total=len(self._agents), idle=idle, running=running, completed=completed, error=error)
89
+
90
+ async def shutdown(self) -> None:
91
+ for agent in self._agents.values():
92
+ agent.reset()
93
+
94
+ def _require(self, name: str) -> Agent:
95
+ agent = self._agents.get(name)
96
+ if agent is None:
97
+ available = ", ".join(self._agents.keys())
98
+ raise ValueError(f'Pool: "{name}" not found. Available agents: [{available}]')
99
+ return agent
anycode/core/runner.py ADDED
@@ -0,0 +1,176 @@
1
+ """Agentic dialogue driver — manages LLM interactions, tool dispatch, and turn looping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import AsyncGenerator, Callable
7
+
8
+ from anycode.helpers.usage_tracker import EMPTY_USAGE, merge_usage
9
+ from anycode.tools.executor import ToolExecutor
10
+ from anycode.tools.registry import ToolRegistry
11
+ from anycode.types import (
12
+ AgentInfo,
13
+ ContentBlock,
14
+ LLMAdapter,
15
+ LLMChatOptions,
16
+ LLMMessage,
17
+ RunnerOptions,
18
+ RunResult,
19
+ StreamEvent,
20
+ TextBlock,
21
+ TokenUsage,
22
+ ToolCallRecord,
23
+ ToolResult,
24
+ ToolResultBlock,
25
+ ToolUseBlock,
26
+ ToolUseContext,
27
+ )
28
+
29
+
30
+ def _pull_text(blocks: list[ContentBlock]) -> str:
31
+ return "".join(b.text for b in blocks if isinstance(b, TextBlock))
32
+
33
+
34
+ def _filter_tool_calls(blocks: list[ContentBlock]) -> list[ToolUseBlock]:
35
+ return [b for b in blocks if isinstance(b, ToolUseBlock)]
36
+
37
+
38
+ class AgentRunner:
39
+ """Orchestrates the full model-tool-model turn loop until completion or limit."""
40
+
41
+ def __init__(
42
+ self,
43
+ adapter: LLMAdapter,
44
+ tool_registry: ToolRegistry,
45
+ tool_executor: ToolExecutor,
46
+ options: RunnerOptions,
47
+ ) -> None:
48
+ self._adapter = adapter
49
+ self._registry = tool_registry
50
+ self._executor = tool_executor
51
+ self._options = options
52
+ self._turn_limit = options.max_turns or 10
53
+
54
+ async def run(
55
+ self,
56
+ messages: list[LLMMessage],
57
+ on_message: Callable[[LLMMessage], None] | None = None,
58
+ ) -> RunResult:
59
+ fallback = RunResult(messages=[], output="", tool_calls=[], token_usage=EMPTY_USAGE, turns=0)
60
+ async for event in self.stream(messages, on_message=on_message):
61
+ if event.type == "done":
62
+ return event.data # type: ignore[return-value]
63
+ return fallback
64
+
65
+ async def stream(
66
+ self,
67
+ seed_messages: list[LLMMessage],
68
+ on_message: Callable[[LLMMessage], None] | None = None,
69
+ ) -> AsyncGenerator[StreamEvent, None]:
70
+ conversation = list(seed_messages)
71
+ cumulative_usage: TokenUsage = EMPTY_USAGE
72
+ tool_calls: list[ToolCallRecord] = []
73
+ last_output = ""
74
+ turn_count = 0
75
+
76
+ # Prepare tool defs
77
+ all_defs = self._registry.to_tool_defs()
78
+ active_defs = (
79
+ [d for d in all_defs if d.name in self._options.allowed_tools]
80
+ if self._options.allowed_tools
81
+ else all_defs
82
+ )
83
+
84
+ chat_params = LLMChatOptions(
85
+ model=self._options.model,
86
+ tools=active_defs if active_defs else None,
87
+ max_tokens=self._options.max_tokens,
88
+ temperature=self._options.temperature,
89
+ system_prompt=self._options.system_prompt,
90
+ )
91
+
92
+ try:
93
+ while turn_count < self._turn_limit:
94
+ turn_count += 1
95
+ response = await self._adapter.chat(conversation, chat_params)
96
+ cumulative_usage = merge_usage(cumulative_usage, response.usage)
97
+
98
+ assistant_msg = LLMMessage(role="assistant", content=response.content)
99
+ conversation.append(assistant_msg)
100
+ if on_message:
101
+ on_message(assistant_msg)
102
+
103
+ turn_text = _pull_text(response.content)
104
+ if turn_text:
105
+ yield StreamEvent(type="text", data=turn_text)
106
+
107
+ tool_blocks = _filter_tool_calls(response.content)
108
+ for block in tool_blocks:
109
+ yield StreamEvent(type="tool_use", data=block)
110
+
111
+ if not tool_blocks:
112
+ last_output = turn_text
113
+ break
114
+
115
+ ctx = self._build_context()
116
+ results: list[tuple[ToolResultBlock, ToolCallRecord]] = []
117
+
118
+ for block in tool_blocks:
119
+ began = time.monotonic()
120
+ try:
121
+ result = await self._executor.execute(block.name, block.input, ctx)
122
+ except Exception as e:
123
+ result = ToolResult(data=str(e), is_error=True)
124
+
125
+ result_block = ToolResultBlock(
126
+ tool_use_id=block.id,
127
+ content=result.data,
128
+ is_error=result.is_error,
129
+ )
130
+ record = ToolCallRecord(
131
+ tool_name=block.name,
132
+ input=block.input,
133
+ output=result.data,
134
+ duration=time.monotonic() - began,
135
+ )
136
+ results.append((result_block, record))
137
+
138
+ result_blocks: list[ContentBlock] = [r[0] for r in results]
139
+ for _, record in results:
140
+ tool_calls.append(record)
141
+ yield StreamEvent(type="tool_result", data=record)
142
+
143
+ tool_msg = LLMMessage(role="user", content=result_blocks)
144
+ conversation.append(tool_msg)
145
+ if on_message:
146
+ on_message(tool_msg)
147
+
148
+ except Exception as e:
149
+ yield StreamEvent(type="error", data=e)
150
+ return
151
+
152
+ if not last_output and conversation:
153
+ for msg in reversed(conversation):
154
+ if msg.role == "assistant":
155
+ last_output = _pull_text(msg.content)
156
+ break
157
+
158
+ yield StreamEvent(
159
+ type="done",
160
+ data=RunResult(
161
+ messages=conversation[len(seed_messages):],
162
+ output=last_output,
163
+ tool_calls=tool_calls,
164
+ token_usage=cumulative_usage,
165
+ turns=turn_count,
166
+ ),
167
+ )
168
+
169
+ def _build_context(self) -> ToolUseContext:
170
+ return ToolUseContext(
171
+ agent=AgentInfo(
172
+ name=self._options.agent_name or "runner",
173
+ role=self._options.agent_role or "assistant",
174
+ model=self._options.model,
175
+ )
176
+ )
@@ -0,0 +1,126 @@
1
+ """Scheduling strategies for distributing tasks across agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+
8
+ from anycode.types import AgentConfig, SchedulingStrategy, Task
9
+
10
+ if TYPE_CHECKING:
11
+ from anycode.tasks.queue import TaskQueue
12
+
13
+ NOISE_WORDS = {
14
+ "the", "and", "for", "that", "this", "with", "are", "from", "have",
15
+ "will", "your", "you", "can", "all", "each", "when", "then", "they",
16
+ "them", "about", "into", "more", "also", "should", "must", "been",
17
+ "some", "what", "than",
18
+ }
19
+
20
+
21
+ def _tokenize(text: str) -> list[str]:
22
+ words = set(re.split(r"\W+", text.lower()))
23
+ return [w for w in words if len(w) > 3 and w not in NOISE_WORDS]
24
+
25
+
26
+ def _relevance_score(text: str, terms: list[str]) -> int:
27
+ lower = text.lower()
28
+ return sum(1 for t in terms if t in lower)
29
+
30
+
31
+ def _downstream_block_count(task_id: str, children: dict[str, list[str]], task_ids: set[str]) -> int:
32
+ seen: set[str] = set()
33
+ frontier = [task_id]
34
+ while frontier:
35
+ current = frontier.pop(0)
36
+ for child in children.get(current, []):
37
+ if child not in seen and child in task_ids:
38
+ seen.add(child)
39
+ frontier.append(child)
40
+ return len(seen)
41
+
42
+
43
+ def _build_children_map(tasks: list[Task]) -> tuple[dict[str, list[str]], set[str]]:
44
+ task_ids = {t.id for t in tasks}
45
+ children: dict[str, list[str]] = {}
46
+ for t in tasks:
47
+ for dep_id in t.depends_on or []:
48
+ children.setdefault(dep_id, []).append(t.id)
49
+ return children, task_ids
50
+
51
+
52
+ class Scheduler:
53
+ """Distributes tasks across agents using one of four strategies."""
54
+
55
+ def __init__(self, strategy: SchedulingStrategy = "dependency-first") -> None:
56
+ self._strategy = strategy
57
+ self._rr_cursor = 0
58
+
59
+ def schedule(self, tasks: list[Task], agents: list[AgentConfig]) -> dict[str, str]:
60
+ if not agents:
61
+ return {}
62
+ pending = [t for t in tasks if t.status == "pending" and not t.assignee]
63
+
64
+ match self._strategy:
65
+ case "round-robin":
66
+ return self._round_robin(pending, agents)
67
+ case "least-busy":
68
+ return self._least_busy(pending, agents, tasks)
69
+ case "capability-match":
70
+ return self._capability_match(pending, agents)
71
+ case "dependency-first":
72
+ return self._dependency_first(pending, agents, tasks)
73
+
74
+ def auto_assign(self, queue: TaskQueue, agents: list[AgentConfig]) -> None:
75
+ snapshot = queue.list()
76
+ assignments = self.schedule(snapshot, agents)
77
+ for task_id, agent_name in assignments.items():
78
+ try:
79
+ queue.update(task_id, assignee=agent_name)
80
+ except Exception:
81
+ pass
82
+
83
+ def _round_robin(self, pending: list[Task], agents: list[AgentConfig]) -> dict[str, str]:
84
+ result: dict[str, str] = {}
85
+ for task in pending:
86
+ result[task.id] = agents[self._rr_cursor % len(agents)].name
87
+ self._rr_cursor = (self._rr_cursor + 1) % len(agents)
88
+ return result
89
+
90
+ def _least_busy(self, pending: list[Task], agents: list[AgentConfig], all_tasks: list[Task]) -> dict[str, str]:
91
+ workload = {a.name: 0 for a in agents}
92
+ for t in all_tasks:
93
+ if t.status == "in_progress" and t.assignee:
94
+ workload[t.assignee] = workload.get(t.assignee, 0) + 1
95
+
96
+ result: dict[str, str] = {}
97
+ for task in pending:
98
+ pick = min(agents, key=lambda a: workload.get(a.name, 0))
99
+ result[task.id] = pick.name
100
+ workload[pick.name] = workload.get(pick.name, 0) + 1
101
+ return result
102
+
103
+ def _capability_match(self, pending: list[Task], agents: list[AgentConfig]) -> dict[str, str]:
104
+ agent_terms = {a.name: _tokenize(f"{a.name} {a.system_prompt or ''} {a.model}") for a in agents}
105
+ result: dict[str, str] = {}
106
+ for task in pending:
107
+ task_text = f"{task.title} {task.description}"
108
+ task_terms = _tokenize(task_text)
109
+ best = max(
110
+ agents,
111
+ key=lambda a: _relevance_score(f"{a.name} {a.system_prompt or ''}", task_terms)
112
+ + _relevance_score(task_text, agent_terms.get(a.name, [])),
113
+ )
114
+ result[task.id] = best.name
115
+ return result
116
+
117
+ def _dependency_first(self, pending: list[Task], agents: list[AgentConfig], all_tasks: list[Task]) -> dict[str, str]:
118
+ children, task_ids = _build_children_map(all_tasks)
119
+ ranked = sorted(pending, key=lambda t: _downstream_block_count(t.id, children, task_ids), reverse=True)
120
+ result: dict[str, str] = {}
121
+ cursor = self._rr_cursor
122
+ for task in ranked:
123
+ result[task.id] = agents[cursor % len(agents)].name
124
+ cursor = (cursor + 1) % len(agents)
125
+ self._rr_cursor = cursor
126
+ return result
@@ -0,0 +1,4 @@
1
+ from anycode.helpers.concurrency_gate import Semaphore
2
+ from anycode.helpers.usage_tracker import EMPTY_USAGE, merge_usage
3
+
4
+ __all__ = ["Semaphore", "EMPTY_USAGE", "merge_usage"]