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.
- anycode/__init__.py +121 -0
- anycode/collaboration/__init__.py +6 -0
- anycode/collaboration/kv_store.py +49 -0
- anycode/collaboration/message_bus.py +70 -0
- anycode/collaboration/shared_mem.py +55 -0
- anycode/collaboration/team.py +96 -0
- anycode/core/__init__.py +7 -0
- anycode/core/agent.py +141 -0
- anycode/core/orchestrator.py +267 -0
- anycode/core/pool.py +99 -0
- anycode/core/runner.py +176 -0
- anycode/core/scheduler.py +126 -0
- anycode/helpers/__init__.py +4 -0
- anycode/helpers/concurrency_gate.py +45 -0
- anycode/helpers/usage_tracker.py +14 -0
- anycode/providers/__init__.py +3 -0
- anycode/providers/adapter.py +22 -0
- anycode/providers/anthropic.py +149 -0
- anycode/providers/openai.py +208 -0
- anycode/tasks/__init__.py +4 -0
- anycode/tasks/queue.py +139 -0
- anycode/tasks/task.py +104 -0
- anycode/tools/__init__.py +21 -0
- anycode/tools/bash.py +67 -0
- anycode/tools/built_in.py +18 -0
- anycode/tools/executor.py +51 -0
- anycode/tools/file_edit.py +70 -0
- anycode/tools/file_read.py +52 -0
- anycode/tools/file_write.py +49 -0
- anycode/tools/grep.py +127 -0
- anycode/tools/registry.py +84 -0
- anycode/types.py +313 -0
- anycode_py-0.1.0.dist-info/METADATA +399 -0
- anycode_py-0.1.0.dist-info/RECORD +36 -0
- anycode_py-0.1.0.dist-info/WHEEL +4 -0
- anycode_py-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|