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,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,3 @@
1
+ from anycode.providers.adapter import create_adapter
2
+
3
+ __all__ = ["create_adapter"]
@@ -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