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,45 @@
|
|
|
1
|
+
"""Bounded concurrency gate for parallel task limiting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Semaphore:
|
|
13
|
+
"""Async semaphore with active/pending tracking."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, max_concurrency: int) -> None:
|
|
16
|
+
if max_concurrency < 1:
|
|
17
|
+
raise ValueError(f"Concurrency limit must be >= 1, received {max_concurrency}")
|
|
18
|
+
self._semaphore = asyncio.Semaphore(max_concurrency)
|
|
19
|
+
self._active = 0
|
|
20
|
+
self._pending = 0
|
|
21
|
+
|
|
22
|
+
async def acquire(self) -> None:
|
|
23
|
+
self._pending += 1
|
|
24
|
+
await self._semaphore.acquire()
|
|
25
|
+
self._pending -= 1
|
|
26
|
+
self._active += 1
|
|
27
|
+
|
|
28
|
+
def release(self) -> None:
|
|
29
|
+
self._active -= 1
|
|
30
|
+
self._semaphore.release()
|
|
31
|
+
|
|
32
|
+
async def run(self, fn: Callable[[], Awaitable[T]]) -> T:
|
|
33
|
+
await self.acquire()
|
|
34
|
+
try:
|
|
35
|
+
return await fn()
|
|
36
|
+
finally:
|
|
37
|
+
self.release()
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def active(self) -> int:
|
|
41
|
+
return self._active
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def pending(self) -> int:
|
|
45
|
+
return self._pending
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Shared token-usage helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from anycode.types import TokenUsage
|
|
6
|
+
|
|
7
|
+
EMPTY_USAGE = TokenUsage(input_tokens=0, output_tokens=0)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def merge_usage(a: TokenUsage, b: TokenUsage) -> TokenUsage:
|
|
11
|
+
return TokenUsage(
|
|
12
|
+
input_tokens=a.input_tokens + b.input_tokens,
|
|
13
|
+
output_tokens=a.output_tokens + b.output_tokens,
|
|
14
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Provider factory — resolves the appropriate LLM backend on demand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from anycode.types import LLMAdapter
|
|
9
|
+
|
|
10
|
+
SupportedProvider = str # "anthropic" | "openai"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def create_adapter(provider: str, api_key: str | None = None) -> LLMAdapter:
|
|
14
|
+
"""Lazy-load the provider SDK and return an adapter instance."""
|
|
15
|
+
if provider == "anthropic":
|
|
16
|
+
from anycode.providers.anthropic import AnthropicAdapter
|
|
17
|
+
return AnthropicAdapter(api_key=api_key)
|
|
18
|
+
elif provider == "openai":
|
|
19
|
+
from anycode.providers.openai import OpenAIAdapter
|
|
20
|
+
return OpenAIAdapter(api_key=api_key)
|
|
21
|
+
else:
|
|
22
|
+
raise ValueError(f"Unknown provider requested: {provider}")
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Anthropic SDK adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import anthropic
|
|
11
|
+
|
|
12
|
+
from anycode.types import (
|
|
13
|
+
ContentBlock,
|
|
14
|
+
LLMChatOptions,
|
|
15
|
+
LLMMessage,
|
|
16
|
+
LLMResponse,
|
|
17
|
+
LLMStreamOptions,
|
|
18
|
+
LLMToolDef,
|
|
19
|
+
StreamEvent,
|
|
20
|
+
TextBlock,
|
|
21
|
+
TokenUsage,
|
|
22
|
+
ToolUseBlock,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _map_content_block(block: ContentBlock) -> dict[str, Any]:
|
|
27
|
+
if block.type == "text":
|
|
28
|
+
return {"type": "text", "text": block.text}
|
|
29
|
+
elif block.type == "tool_use":
|
|
30
|
+
return {"type": "tool_use", "id": block.id, "name": block.name, "input": block.input}
|
|
31
|
+
elif block.type == "tool_result":
|
|
32
|
+
result: dict[str, Any] = {"type": "tool_result", "tool_use_id": block.tool_use_id, "content": block.content}
|
|
33
|
+
if block.is_error is not None:
|
|
34
|
+
result["is_error"] = block.is_error
|
|
35
|
+
return result
|
|
36
|
+
elif block.type == "image":
|
|
37
|
+
return {"type": "image", "source": {"type": "base64", "media_type": block.source.media_type, "data": block.source.data}}
|
|
38
|
+
raise ValueError(f"Unexpected block type: {block.type}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _map_messages(messages: list[LLMMessage]) -> list[dict[str, Any]]:
|
|
42
|
+
return [{"role": msg.role, "content": [_map_content_block(b) for b in msg.content]} for msg in messages]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _map_tool_defs(tools: list[LLMToolDef]) -> list[dict[str, Any]]:
|
|
46
|
+
return [{"name": t.name, "description": t.description, "input_schema": {"type": "object", **t.input_schema}} for t in tools]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_block(block: Any) -> ContentBlock:
|
|
50
|
+
if block.type == "text":
|
|
51
|
+
return TextBlock(text=block.text)
|
|
52
|
+
elif block.type == "tool_use":
|
|
53
|
+
return ToolUseBlock(id=block.id, name=block.name, input=block.input if isinstance(block.input, dict) else {})
|
|
54
|
+
return TextBlock(text=f"[unrecognized block: {block.type}]")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AnthropicAdapter:
|
|
58
|
+
"""Wraps the Anthropic Python SDK."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
61
|
+
self._client = anthropic.AsyncAnthropic(api_key=api_key or os.environ.get("ANTHROPIC_API_KEY"))
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def name(self) -> str:
|
|
65
|
+
return "anthropic"
|
|
66
|
+
|
|
67
|
+
async def chat(self, messages: list[LLMMessage], options: LLMChatOptions) -> LLMResponse:
|
|
68
|
+
mapped = _map_messages(messages)
|
|
69
|
+
kwargs: dict[str, Any] = {
|
|
70
|
+
"model": options.model,
|
|
71
|
+
"max_tokens": options.max_tokens or 4096,
|
|
72
|
+
"messages": mapped,
|
|
73
|
+
}
|
|
74
|
+
if options.system_prompt:
|
|
75
|
+
kwargs["system"] = options.system_prompt
|
|
76
|
+
if options.tools:
|
|
77
|
+
kwargs["tools"] = _map_tool_defs(options.tools)
|
|
78
|
+
if options.temperature is not None:
|
|
79
|
+
kwargs["temperature"] = options.temperature
|
|
80
|
+
|
|
81
|
+
response = await self._client.messages.create(**kwargs)
|
|
82
|
+
|
|
83
|
+
return LLMResponse(
|
|
84
|
+
id=response.id,
|
|
85
|
+
content=[_parse_block(b) for b in response.content],
|
|
86
|
+
model=response.model,
|
|
87
|
+
stop_reason=response.stop_reason or "end_turn",
|
|
88
|
+
usage=TokenUsage(input_tokens=response.usage.input_tokens, output_tokens=response.usage.output_tokens),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
async def stream(self, messages: list[LLMMessage], options: LLMStreamOptions) -> AsyncIterator[StreamEvent]:
|
|
92
|
+
mapped = _map_messages(messages)
|
|
93
|
+
kwargs: dict[str, Any] = {
|
|
94
|
+
"model": options.model,
|
|
95
|
+
"max_tokens": options.max_tokens or 4096,
|
|
96
|
+
"messages": mapped,
|
|
97
|
+
}
|
|
98
|
+
if options.system_prompt:
|
|
99
|
+
kwargs["system"] = options.system_prompt
|
|
100
|
+
if options.tools:
|
|
101
|
+
kwargs["tools"] = _map_tool_defs(options.tools)
|
|
102
|
+
if options.temperature is not None:
|
|
103
|
+
kwargs["temperature"] = options.temperature
|
|
104
|
+
|
|
105
|
+
json_buffers: dict[int, dict[str, str]] = {}
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
async with self._client.messages.stream(**kwargs) as stream:
|
|
109
|
+
async for event in stream:
|
|
110
|
+
if event.type == "content_block_start":
|
|
111
|
+
block = event.content_block
|
|
112
|
+
if block.type == "tool_use":
|
|
113
|
+
json_buffers[event.index] = {"id": block.id, "name": block.name, "json": ""}
|
|
114
|
+
elif event.type == "content_block_delta":
|
|
115
|
+
delta = event.delta
|
|
116
|
+
if delta.type == "text_delta":
|
|
117
|
+
yield StreamEvent(type="text", data=delta.text)
|
|
118
|
+
elif delta.type == "input_json_delta":
|
|
119
|
+
buf = json_buffers.get(event.index)
|
|
120
|
+
if buf is not None:
|
|
121
|
+
buf["json"] += delta.partial_json
|
|
122
|
+
elif event.type == "content_block_stop":
|
|
123
|
+
buf = json_buffers.pop(event.index, None)
|
|
124
|
+
if buf is not None:
|
|
125
|
+
parsed_input: dict[str, Any] = {}
|
|
126
|
+
try:
|
|
127
|
+
parsed = json.loads(buf["json"])
|
|
128
|
+
if isinstance(parsed, dict):
|
|
129
|
+
parsed_input = parsed
|
|
130
|
+
except (json.JSONDecodeError, TypeError):
|
|
131
|
+
pass
|
|
132
|
+
yield StreamEvent(
|
|
133
|
+
type="tool_use",
|
|
134
|
+
data=ToolUseBlock(id=buf["id"], name=buf["name"], input=parsed_input),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
final = await stream.get_final_message()
|
|
138
|
+
yield StreamEvent(
|
|
139
|
+
type="done",
|
|
140
|
+
data=LLMResponse(
|
|
141
|
+
id=final.id,
|
|
142
|
+
content=[_parse_block(b) for b in final.content],
|
|
143
|
+
model=final.model,
|
|
144
|
+
stop_reason=final.stop_reason or "end_turn",
|
|
145
|
+
usage=TokenUsage(input_tokens=final.usage.input_tokens, output_tokens=final.usage.output_tokens),
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
yield StreamEvent(type="error", data=str(e))
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""OpenAI SDK adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import openai
|
|
11
|
+
|
|
12
|
+
from anycode.types import (
|
|
13
|
+
ContentBlock,
|
|
14
|
+
LLMChatOptions,
|
|
15
|
+
LLMMessage,
|
|
16
|
+
LLMResponse,
|
|
17
|
+
LLMStreamOptions,
|
|
18
|
+
LLMToolDef,
|
|
19
|
+
StreamEvent,
|
|
20
|
+
TextBlock,
|
|
21
|
+
TokenUsage,
|
|
22
|
+
ToolUseBlock,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _map_tool_def(tool: LLMToolDef) -> dict[str, Any]:
|
|
27
|
+
return {"type": "function", "function": {"name": tool.name, "description": tool.description, "parameters": tool.input_schema}}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _map_messages(messages: list[LLMMessage], system_prompt: str | None) -> list[dict[str, Any]]:
|
|
31
|
+
result: list[dict[str, Any]] = []
|
|
32
|
+
if system_prompt:
|
|
33
|
+
result.append({"role": "system", "content": system_prompt})
|
|
34
|
+
|
|
35
|
+
for msg in messages:
|
|
36
|
+
if msg.role == "assistant":
|
|
37
|
+
result.append(_map_assistant(msg))
|
|
38
|
+
else:
|
|
39
|
+
has_tool_results = any(b.type == "tool_result" for b in msg.content)
|
|
40
|
+
if not has_tool_results:
|
|
41
|
+
result.append(_map_user(msg))
|
|
42
|
+
else:
|
|
43
|
+
non_tool = [b for b in msg.content if b.type != "tool_result"]
|
|
44
|
+
if non_tool:
|
|
45
|
+
result.append(_map_user(LLMMessage(role="user", content=non_tool)))
|
|
46
|
+
for b in msg.content:
|
|
47
|
+
if b.type == "tool_result":
|
|
48
|
+
result.append({"role": "tool", "tool_call_id": b.tool_use_id, "content": b.content})
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _map_user(msg: LLMMessage) -> dict[str, Any]:
|
|
53
|
+
if len(msg.content) == 1 and msg.content[0].type == "text":
|
|
54
|
+
return {"role": "user", "content": msg.content[0].text}
|
|
55
|
+
parts: list[dict[str, Any]] = []
|
|
56
|
+
for b in msg.content:
|
|
57
|
+
if b.type == "text":
|
|
58
|
+
parts.append({"type": "text", "text": b.text})
|
|
59
|
+
elif b.type == "image":
|
|
60
|
+
parts.append({"type": "image_url", "image_url": {"url": f"data:{b.source.media_type};base64,{b.source.data}"}})
|
|
61
|
+
return {"role": "user", "content": parts}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _map_assistant(msg: LLMMessage) -> dict[str, Any]:
|
|
65
|
+
tool_calls = []
|
|
66
|
+
text_parts: list[str] = []
|
|
67
|
+
for b in msg.content:
|
|
68
|
+
if b.type == "tool_use":
|
|
69
|
+
tool_calls.append({"id": b.id, "type": "function", "function": {"name": b.name, "arguments": json.dumps(b.input)}})
|
|
70
|
+
elif b.type == "text":
|
|
71
|
+
text_parts.append(b.text)
|
|
72
|
+
result: dict[str, Any] = {"role": "assistant", "content": "".join(text_parts) if text_parts else None}
|
|
73
|
+
if tool_calls:
|
|
74
|
+
result["tool_calls"] = tool_calls
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _map_stop_reason(reason: str | None) -> str:
|
|
79
|
+
mapping = {"stop": "end_turn", "tool_calls": "tool_use", "length": "max_tokens", "content_filter": "content_filter"}
|
|
80
|
+
return mapping.get(reason or "stop", reason or "end_turn")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_json_safe(s: str) -> dict[str, Any]:
|
|
84
|
+
try:
|
|
85
|
+
parsed = json.loads(s)
|
|
86
|
+
if isinstance(parsed, dict):
|
|
87
|
+
return parsed
|
|
88
|
+
except (json.JSONDecodeError, TypeError):
|
|
89
|
+
pass
|
|
90
|
+
return {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class OpenAIAdapter:
|
|
94
|
+
"""Wraps the OpenAI Python SDK."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
97
|
+
self._client = openai.AsyncOpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"))
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def name(self) -> str:
|
|
101
|
+
return "openai"
|
|
102
|
+
|
|
103
|
+
async def chat(self, messages: list[LLMMessage], options: LLMChatOptions) -> LLMResponse:
|
|
104
|
+
oai_msgs = _map_messages(messages, options.system_prompt)
|
|
105
|
+
kwargs: dict[str, Any] = {"model": options.model, "messages": oai_msgs}
|
|
106
|
+
if options.max_tokens:
|
|
107
|
+
kwargs["max_tokens"] = options.max_tokens
|
|
108
|
+
if options.temperature is not None:
|
|
109
|
+
kwargs["temperature"] = options.temperature
|
|
110
|
+
if options.tools:
|
|
111
|
+
kwargs["tools"] = [_map_tool_def(t) for t in options.tools]
|
|
112
|
+
|
|
113
|
+
completion = await self._client.chat.completions.create(**kwargs)
|
|
114
|
+
choice = completion.choices[0]
|
|
115
|
+
content: list[ContentBlock] = []
|
|
116
|
+
|
|
117
|
+
if choice.message.content:
|
|
118
|
+
content.append(TextBlock(text=choice.message.content))
|
|
119
|
+
for tc in choice.message.tool_calls or []:
|
|
120
|
+
content.append(ToolUseBlock(id=tc.id, name=tc.function.name, input=_parse_json_safe(tc.function.arguments)))
|
|
121
|
+
|
|
122
|
+
return LLMResponse(
|
|
123
|
+
id=completion.id,
|
|
124
|
+
content=content,
|
|
125
|
+
model=completion.model,
|
|
126
|
+
stop_reason=_map_stop_reason(choice.finish_reason),
|
|
127
|
+
usage=TokenUsage(
|
|
128
|
+
input_tokens=completion.usage.prompt_tokens if completion.usage else 0,
|
|
129
|
+
output_tokens=completion.usage.completion_tokens if completion.usage else 0,
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async def stream(self, messages: list[LLMMessage], options: LLMStreamOptions) -> AsyncIterator[StreamEvent]:
|
|
134
|
+
oai_msgs = _map_messages(messages, options.system_prompt)
|
|
135
|
+
kwargs: dict[str, Any] = {"model": options.model, "messages": oai_msgs, "stream": True, "stream_options": {"include_usage": True}}
|
|
136
|
+
if options.max_tokens:
|
|
137
|
+
kwargs["max_tokens"] = options.max_tokens
|
|
138
|
+
if options.temperature is not None:
|
|
139
|
+
kwargs["temperature"] = options.temperature
|
|
140
|
+
if options.tools:
|
|
141
|
+
kwargs["tools"] = [_map_tool_def(t) for t in options.tools]
|
|
142
|
+
|
|
143
|
+
completion_id = ""
|
|
144
|
+
completion_model = ""
|
|
145
|
+
last_stop = "stop"
|
|
146
|
+
prompt_tokens = 0
|
|
147
|
+
gen_tokens = 0
|
|
148
|
+
json_buffers: dict[int, dict[str, str]] = {}
|
|
149
|
+
full_text = ""
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
stream_resp = await self._client.chat.completions.create(**kwargs)
|
|
153
|
+
async for chunk in stream_resp:
|
|
154
|
+
completion_id = chunk.id
|
|
155
|
+
completion_model = chunk.model
|
|
156
|
+
|
|
157
|
+
if chunk.usage:
|
|
158
|
+
prompt_tokens = chunk.usage.prompt_tokens
|
|
159
|
+
gen_tokens = chunk.usage.completion_tokens
|
|
160
|
+
|
|
161
|
+
if not chunk.choices:
|
|
162
|
+
continue
|
|
163
|
+
choice = chunk.choices[0]
|
|
164
|
+
delta = choice.delta
|
|
165
|
+
|
|
166
|
+
if delta.content:
|
|
167
|
+
full_text += delta.content
|
|
168
|
+
yield StreamEvent(type="text", data=delta.content)
|
|
169
|
+
|
|
170
|
+
for tc_delta in delta.tool_calls or []:
|
|
171
|
+
idx = tc_delta.index
|
|
172
|
+
if idx not in json_buffers:
|
|
173
|
+
json_buffers[idx] = {"id": tc_delta.id or "", "name": (tc_delta.function and tc_delta.function.name) or "", "args": ""}
|
|
174
|
+
buf = json_buffers[idx]
|
|
175
|
+
if tc_delta.id:
|
|
176
|
+
buf["id"] = tc_delta.id
|
|
177
|
+
if tc_delta.function and tc_delta.function.name:
|
|
178
|
+
buf["name"] = tc_delta.function.name
|
|
179
|
+
if tc_delta.function and tc_delta.function.arguments:
|
|
180
|
+
buf["args"] += tc_delta.function.arguments
|
|
181
|
+
|
|
182
|
+
if choice.finish_reason:
|
|
183
|
+
last_stop = choice.finish_reason
|
|
184
|
+
|
|
185
|
+
# Flush tool call buffers
|
|
186
|
+
tool_blocks: list[ToolUseBlock] = []
|
|
187
|
+
for buf in json_buffers.values():
|
|
188
|
+
block = ToolUseBlock(id=buf["id"], name=buf["name"], input=_parse_json_safe(buf["args"]))
|
|
189
|
+
tool_blocks.append(block)
|
|
190
|
+
yield StreamEvent(type="tool_use", data=block)
|
|
191
|
+
|
|
192
|
+
done_content: list[ContentBlock] = []
|
|
193
|
+
if full_text:
|
|
194
|
+
done_content.append(TextBlock(text=full_text))
|
|
195
|
+
done_content.extend(tool_blocks)
|
|
196
|
+
|
|
197
|
+
yield StreamEvent(
|
|
198
|
+
type="done",
|
|
199
|
+
data=LLMResponse(
|
|
200
|
+
id=completion_id,
|
|
201
|
+
content=done_content,
|
|
202
|
+
model=completion_model,
|
|
203
|
+
stop_reason=_map_stop_reason(last_stop),
|
|
204
|
+
usage=TokenUsage(input_tokens=prompt_tokens, output_tokens=gen_tokens),
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
except Exception as e:
|
|
208
|
+
yield StreamEvent(type="error", data=str(e))
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
from anycode.tasks.queue import TaskQueue
|
|
2
|
+
from anycode.tasks.task import create_task, get_task_dependency_order, is_task_ready, validate_task_dependencies
|
|
3
|
+
|
|
4
|
+
__all__ = ["TaskQueue", "create_task", "is_task_ready", "get_task_dependency_order", "validate_task_dependencies"]
|
anycode/tasks/queue.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Event-driven task queue with dependency resolution and cascading failure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from anycode.tasks.task import is_task_ready
|
|
9
|
+
from anycode.types import Task, TaskStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaskQueue:
|
|
13
|
+
"""Manages tasks with dependency blocking, cascading failure, and event notifications."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._tasks: dict[str, Task] = {}
|
|
17
|
+
self._listeners: dict[str, dict[int, Callable[..., None]]] = {}
|
|
18
|
+
self._next_id = 0
|
|
19
|
+
|
|
20
|
+
def add(self, task: Task) -> None:
|
|
21
|
+
resolved = self._determine_initial_status(task)
|
|
22
|
+
self._tasks[resolved.id] = resolved
|
|
23
|
+
if resolved.status == "pending":
|
|
24
|
+
self._emit("task:ready", resolved)
|
|
25
|
+
|
|
26
|
+
def add_batch(self, tasks: list[Task]) -> None:
|
|
27
|
+
for task in tasks:
|
|
28
|
+
self.add(task)
|
|
29
|
+
|
|
30
|
+
def update(self, task_id: str, **kwargs: str | TaskStatus | None) -> Task:
|
|
31
|
+
task = self._lookup(task_id)
|
|
32
|
+
updated = task.model_copy(update={**kwargs, "updated_at": datetime.now(UTC)})
|
|
33
|
+
self._tasks[task_id] = updated
|
|
34
|
+
return updated
|
|
35
|
+
|
|
36
|
+
def complete(self, task_id: str, result: str | None = None) -> Task:
|
|
37
|
+
completed = self.update(task_id, status="completed", result=result)
|
|
38
|
+
self._emit("task:complete", completed)
|
|
39
|
+
self._promote_blocked(task_id)
|
|
40
|
+
if self.is_done():
|
|
41
|
+
self._emit_all_complete()
|
|
42
|
+
return completed
|
|
43
|
+
|
|
44
|
+
def fail(self, task_id: str, error: str) -> Task:
|
|
45
|
+
failed = self.update(task_id, status="failed", result=error)
|
|
46
|
+
self._emit("task:failed", failed)
|
|
47
|
+
self._propagate_failure(task_id)
|
|
48
|
+
if self.is_done():
|
|
49
|
+
self._emit_all_complete()
|
|
50
|
+
return failed
|
|
51
|
+
|
|
52
|
+
def next(self, assignee: str | None = None) -> Task | None:
|
|
53
|
+
if assignee is None:
|
|
54
|
+
return self.next_available()
|
|
55
|
+
for task in self._tasks.values():
|
|
56
|
+
if task.status == "pending" and task.assignee == assignee:
|
|
57
|
+
return task
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def next_available(self) -> Task | None:
|
|
61
|
+
fallback: Task | None = None
|
|
62
|
+
for task in self._tasks.values():
|
|
63
|
+
if task.status != "pending":
|
|
64
|
+
continue
|
|
65
|
+
if not task.assignee:
|
|
66
|
+
return task
|
|
67
|
+
if fallback is None:
|
|
68
|
+
fallback = task
|
|
69
|
+
return fallback
|
|
70
|
+
|
|
71
|
+
def list(self) -> list[Task]:
|
|
72
|
+
return list(self._tasks.values())
|
|
73
|
+
|
|
74
|
+
def get_by_status(self, status: TaskStatus) -> list[Task]:
|
|
75
|
+
return [t for t in self._tasks.values() if t.status == status]
|
|
76
|
+
|
|
77
|
+
def is_done(self) -> bool:
|
|
78
|
+
return all(t.status in ("completed", "failed") for t in self._tasks.values())
|
|
79
|
+
|
|
80
|
+
def get_progress(self) -> dict[str, int]:
|
|
81
|
+
counts = {"total": len(self._tasks), "completed": 0, "failed": 0, "in_progress": 0, "pending": 0, "blocked": 0}
|
|
82
|
+
for t in self._tasks.values():
|
|
83
|
+
key = t.status if t.status != "in_progress" else "in_progress"
|
|
84
|
+
counts[key] = counts.get(key, 0) + 1
|
|
85
|
+
return counts
|
|
86
|
+
|
|
87
|
+
def on(self, event: str, handler: Callable[..., None]) -> Callable[[], None]:
|
|
88
|
+
subs = self._listeners.setdefault(event, {})
|
|
89
|
+
sub_id = self._next_id
|
|
90
|
+
self._next_id += 1
|
|
91
|
+
subs[sub_id] = handler
|
|
92
|
+
return lambda: subs.pop(sub_id, None)
|
|
93
|
+
|
|
94
|
+
def _determine_initial_status(self, task: Task) -> Task:
|
|
95
|
+
if not task.depends_on:
|
|
96
|
+
return task
|
|
97
|
+
all_current = list(self._tasks.values())
|
|
98
|
+
if is_task_ready(task, all_current):
|
|
99
|
+
return task
|
|
100
|
+
return task.model_copy(update={"status": "blocked", "updated_at": datetime.now(UTC)})
|
|
101
|
+
|
|
102
|
+
def _promote_blocked(self, completed_id: str) -> None:
|
|
103
|
+
all_tasks = list(self._tasks.values())
|
|
104
|
+
task_by_id = {t.id: t for t in all_tasks}
|
|
105
|
+
for task in all_tasks:
|
|
106
|
+
if task.status != "blocked":
|
|
107
|
+
continue
|
|
108
|
+
if not task.depends_on or completed_id not in task.depends_on:
|
|
109
|
+
continue
|
|
110
|
+
as_pending = task.model_copy(update={"status": "pending"})
|
|
111
|
+
if is_task_ready(as_pending, all_tasks, task_by_id):
|
|
112
|
+
unblocked = task.model_copy(update={"status": "pending", "updated_at": datetime.now(UTC)})
|
|
113
|
+
self._tasks[task.id] = unblocked
|
|
114
|
+
task_by_id[task.id] = unblocked
|
|
115
|
+
self._emit("task:ready", unblocked)
|
|
116
|
+
|
|
117
|
+
def _propagate_failure(self, failed_id: str) -> None:
|
|
118
|
+
for task in list(self._tasks.values()):
|
|
119
|
+
if task.status not in ("blocked", "pending"):
|
|
120
|
+
continue
|
|
121
|
+
if not task.depends_on or failed_id not in task.depends_on:
|
|
122
|
+
continue
|
|
123
|
+
cascaded = self.update(task.id, status="failed", result=f'Cancelled: prerequisite "{failed_id}" failed.')
|
|
124
|
+
self._emit("task:failed", cascaded)
|
|
125
|
+
self._propagate_failure(task.id)
|
|
126
|
+
|
|
127
|
+
def _emit(self, event: str, task: Task) -> None:
|
|
128
|
+
for handler in self._listeners.get(event, {}).values():
|
|
129
|
+
handler(task)
|
|
130
|
+
|
|
131
|
+
def _emit_all_complete(self) -> None:
|
|
132
|
+
for handler in self._listeners.get("all:complete", {}).values():
|
|
133
|
+
handler()
|
|
134
|
+
|
|
135
|
+
def _lookup(self, task_id: str) -> Task:
|
|
136
|
+
task = self._tasks.get(task_id)
|
|
137
|
+
if not task:
|
|
138
|
+
raise ValueError(f'TaskQueue: no task with id "{task_id}".')
|
|
139
|
+
return task
|