semora 0.1.0__tar.gz
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.
- semora-0.1.0/.gitignore +28 -0
- semora-0.1.0/PKG-INFO +88 -0
- semora-0.1.0/README.md +49 -0
- semora-0.1.0/pyproject.toml +55 -0
- semora-0.1.0/src/semora/__init__.py +60 -0
- semora-0.1.0/src/semora/background.py +156 -0
- semora-0.1.0/src/semora/builtins/__init__.py +248 -0
- semora-0.1.0/src/semora/builtins/_exec.py +302 -0
- semora-0.1.0/src/semora/builtins/_files.py +256 -0
- semora-0.1.0/src/semora/builtins/_search.py +383 -0
- semora-0.1.0/src/semora/builtins/_types.py +118 -0
- semora-0.1.0/src/semora/builtins/_web.py +208 -0
- semora-0.1.0/src/semora/contracts/__init__.py +79 -0
- semora-0.1.0/src/semora/contracts/agent.py +40 -0
- semora-0.1.0/src/semora/contracts/events.py +244 -0
- semora-0.1.0/src/semora/contracts/types.py +197 -0
- semora-0.1.0/src/semora/controls.py +356 -0
- semora-0.1.0/src/semora/dispatch.py +379 -0
- semora-0.1.0/src/semora/driver.py +32 -0
- semora-0.1.0/src/semora/engines/__init__.py +5 -0
- semora-0.1.0/src/semora/engines/plain/__init__.py +5 -0
- semora-0.1.0/src/semora/engines/plain/loop.py +914 -0
- semora-0.1.0/src/semora/goal.py +88 -0
- semora-0.1.0/src/semora/history.py +296 -0
- semora-0.1.0/src/semora/ids.py +11 -0
- semora-0.1.0/src/semora/orchestration.py +162 -0
- semora-0.1.0/src/semora/orchestrator.py +1340 -0
- semora-0.1.0/src/semora/plan_mode.py +133 -0
- semora-0.1.0/src/semora/prompts.py +98 -0
- semora-0.1.0/src/semora/providers.py +211 -0
- semora-0.1.0/src/semora/py.typed +0 -0
- semora-0.1.0/src/semora/runtime.py +1146 -0
- semora-0.1.0/src/semora/sandbox_remote.py +499 -0
- semora-0.1.0/src/semora/skills.py +424 -0
- semora-0.1.0/src/semora/subagents.py +784 -0
- semora-0.1.0/src/semora/tool_search.py +225 -0
- semora-0.1.0/src/semora/tools.py +690 -0
- semora-0.1.0/src/semora/transcript.py +255 -0
- semora-0.1.0/src/semora/workspace.py +981 -0
semora-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
.pytest_cache/
|
|
3
|
+
.mypy_cache/
|
|
4
|
+
.ruff_cache/
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*.egg-info/
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
.coverage
|
|
11
|
+
htmlcov/
|
|
12
|
+
.env
|
|
13
|
+
.env.*
|
|
14
|
+
!.env.example
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Local tool/editor state — machine-specific, never pushed.
|
|
18
|
+
.claude/
|
|
19
|
+
.codecanvas/
|
|
20
|
+
.vscode/
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Superpowers design/spec scratch — working notes, not project documentation.
|
|
24
|
+
docs/superpowers/
|
|
25
|
+
|
|
26
|
+
# 로컬 자격증명 — 절대 커밋 금지.
|
|
27
|
+
a.txt
|
|
28
|
+
*.token
|
semora-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: semora
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Durable multi-agent runtime for Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/donggyun112/semora
|
|
6
|
+
Project-URL: Source, https://github.com/donggyun112/semora
|
|
7
|
+
Project-URL: Changelog, https://github.com/donggyun112/semora/blob/main/CHANGELOG.md
|
|
8
|
+
Author: donggyun112
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: langchain-core<2,>=1
|
|
17
|
+
Requires-Dist: loguru<1,>=0.7
|
|
18
|
+
Requires-Dist: semora-llm==0.1.0
|
|
19
|
+
Requires-Dist: semora-store==0.1.0
|
|
20
|
+
Provides-Extra: anthropic
|
|
21
|
+
Requires-Dist: langchain-anthropic<2,>=1; extra == 'anthropic'
|
|
22
|
+
Provides-Extra: fork
|
|
23
|
+
Requires-Dist: semora-fork==0.1.0; extra == 'fork'
|
|
24
|
+
Provides-Extra: google
|
|
25
|
+
Requires-Dist: langchain-google-genai<5,>=4; extra == 'google'
|
|
26
|
+
Provides-Extra: openai
|
|
27
|
+
Requires-Dist: langchain-openai<2,>=1; extra == 'openai'
|
|
28
|
+
Provides-Extra: openrouter
|
|
29
|
+
Requires-Dist: langchain-openai<2,>=1; extra == 'openrouter'
|
|
30
|
+
Provides-Extra: permissions
|
|
31
|
+
Requires-Dist: semora-permissions==0.1.0; extra == 'permissions'
|
|
32
|
+
Provides-Extra: postgres
|
|
33
|
+
Requires-Dist: semora-store-pg==0.1.0; extra == 'postgres'
|
|
34
|
+
Provides-Extra: ui
|
|
35
|
+
Requires-Dist: semora-ui==0.1.0; extra == 'ui'
|
|
36
|
+
Provides-Extra: xai
|
|
37
|
+
Requires-Dist: langchain-xai<2,>=1; extra == 'xai'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# semora
|
|
41
|
+
|
|
42
|
+
Durable agent runtime for Python. This is the core: contracts, control points, tool
|
|
43
|
+
execution, the orchestrator, the plain `async while` planner, and `AgentRuntime`.
|
|
44
|
+
|
|
45
|
+
By default `AgentRuntime` drives the planner directly — no orchestrator, no ledger:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from semora import Agent, AgentRuntime, ChatModel
|
|
49
|
+
|
|
50
|
+
model = ChatModel(model="gpt-4.1")
|
|
51
|
+
agent = Agent("reviewer", "Reviews repositories", model, tools, system_prompt)
|
|
52
|
+
outcome = await AgentRuntime().run("attempt-42", agent, "inspect this repository")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This path cannot suspend or recover a crashed round. Attach a ledger when those
|
|
56
|
+
guarantees are required:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from semora import AgentRuntime, MemorySteps
|
|
60
|
+
|
|
61
|
+
runtime = AgentRuntime(store=MemorySteps())
|
|
62
|
+
outcome = await runtime.run("attempt-42", agent, "inspect this repository")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Policy lives on `Controls` / `ControlPlane` (`on_inputs`, `before_model`, `pre_tool_use`,
|
|
66
|
+
`after_tool_call`, `before_finish`, `on_resume`, `on_suspend`). `before_finish` can refuse
|
|
67
|
+
an ending and send the loop around again.
|
|
68
|
+
|
|
69
|
+
`semora.builtins.builtin_tools()` supplies `read`, `write`, `edit`, `grep`, `glob`, `Bash`,
|
|
70
|
+
and `web_fetch`. `web_search` is not included. `Bash` stays disabled until
|
|
71
|
+
`ExecToolOptions.allow_list` is set. File and process effects use the `WorkspaceProvider`
|
|
72
|
+
injected by `AgentRuntime` (`semora.workspace`).
|
|
73
|
+
|
|
74
|
+
Install extras beside it:
|
|
75
|
+
|
|
76
|
+
| | |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `semora` | the runtime, with in-memory `MemorySteps` |
|
|
79
|
+
| `semora[openai]` | `langchain-openai` |
|
|
80
|
+
| `semora[anthropic]` | `langchain-anthropic` |
|
|
81
|
+
| `semora[google]` | `langchain-google-genai` |
|
|
82
|
+
| `semora[xai]` | `langchain-xai` |
|
|
83
|
+
| `semora[openrouter]` | OpenAI adapter for OpenRouter |
|
|
84
|
+
| `semora[postgres]` | Postgres ledger (`semora_store_pg`) |
|
|
85
|
+
| `semora[permissions]` | permission rule table (`semora_permissions`) |
|
|
86
|
+
| `semora[ui]` | local console (`semora_ui`) |
|
|
87
|
+
|
|
88
|
+
See the [repository README](../../README.md) for examples.
|
semora-0.1.0/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# semora
|
|
2
|
+
|
|
3
|
+
Durable agent runtime for Python. This is the core: contracts, control points, tool
|
|
4
|
+
execution, the orchestrator, the plain `async while` planner, and `AgentRuntime`.
|
|
5
|
+
|
|
6
|
+
By default `AgentRuntime` drives the planner directly — no orchestrator, no ledger:
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from semora import Agent, AgentRuntime, ChatModel
|
|
10
|
+
|
|
11
|
+
model = ChatModel(model="gpt-4.1")
|
|
12
|
+
agent = Agent("reviewer", "Reviews repositories", model, tools, system_prompt)
|
|
13
|
+
outcome = await AgentRuntime().run("attempt-42", agent, "inspect this repository")
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
This path cannot suspend or recover a crashed round. Attach a ledger when those
|
|
17
|
+
guarantees are required:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from semora import AgentRuntime, MemorySteps
|
|
21
|
+
|
|
22
|
+
runtime = AgentRuntime(store=MemorySteps())
|
|
23
|
+
outcome = await runtime.run("attempt-42", agent, "inspect this repository")
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Policy lives on `Controls` / `ControlPlane` (`on_inputs`, `before_model`, `pre_tool_use`,
|
|
27
|
+
`after_tool_call`, `before_finish`, `on_resume`, `on_suspend`). `before_finish` can refuse
|
|
28
|
+
an ending and send the loop around again.
|
|
29
|
+
|
|
30
|
+
`semora.builtins.builtin_tools()` supplies `read`, `write`, `edit`, `grep`, `glob`, `Bash`,
|
|
31
|
+
and `web_fetch`. `web_search` is not included. `Bash` stays disabled until
|
|
32
|
+
`ExecToolOptions.allow_list` is set. File and process effects use the `WorkspaceProvider`
|
|
33
|
+
injected by `AgentRuntime` (`semora.workspace`).
|
|
34
|
+
|
|
35
|
+
Install extras beside it:
|
|
36
|
+
|
|
37
|
+
| | |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `semora` | the runtime, with in-memory `MemorySteps` |
|
|
40
|
+
| `semora[openai]` | `langchain-openai` |
|
|
41
|
+
| `semora[anthropic]` | `langchain-anthropic` |
|
|
42
|
+
| `semora[google]` | `langchain-google-genai` |
|
|
43
|
+
| `semora[xai]` | `langchain-xai` |
|
|
44
|
+
| `semora[openrouter]` | OpenAI adapter for OpenRouter |
|
|
45
|
+
| `semora[postgres]` | Postgres ledger (`semora_store_pg`) |
|
|
46
|
+
| `semora[permissions]` | permission rule table (`semora_permissions`) |
|
|
47
|
+
| `semora[ui]` | local console (`semora_ui`) |
|
|
48
|
+
|
|
49
|
+
See the [repository README](../../README.md) for examples.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "semora"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Durable multi-agent runtime for Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "donggyun112" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
urls = { Homepage = "https://github.com/donggyun112/semora", Source = "https://github.com/donggyun112/semora", Changelog = "https://github.com/donggyun112/semora/blob/main/CHANGELOG.md" }
|
|
21
|
+
dependencies = [
|
|
22
|
+
"langchain-core>=1,<2",
|
|
23
|
+
"loguru>=0.7,<1",
|
|
24
|
+
"semora-llm==0.1.0",
|
|
25
|
+
"semora-store==0.1.0",
|
|
26
|
+
]
|
|
27
|
+
# The core: contracts, controls, tool execution, the orchestrator, the engine, and the facade over
|
|
28
|
+
# them. Those are layers of one thing rather than separate products — they share a dependency
|
|
29
|
+
# footprint and nobody installs them apart — so they are subpackages here.
|
|
30
|
+
# `tests/test_packaging.py` keeps them layered, which is the job separate manifests used to do.
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
postgres = ["semora-store-pg==0.1.0"]
|
|
34
|
+
permissions = ["semora-permissions==0.1.0"]
|
|
35
|
+
ui = ["semora-ui==0.1.0"]
|
|
36
|
+
fork = ["semora-fork==0.1.0"]
|
|
37
|
+
# Chat models stay LangChain's. These extras only install the provider adapter;
|
|
38
|
+
# `from langchain_openai import ChatOpenAI` is still how a caller constructs one.
|
|
39
|
+
openai = ["langchain-openai>=1,<2"]
|
|
40
|
+
anthropic = ["langchain-anthropic>=1,<2"]
|
|
41
|
+
google = ["langchain-google-genai>=4,<5"]
|
|
42
|
+
xai = ["langchain-xai>=1,<2"]
|
|
43
|
+
# OpenRouter speaks the OpenAI wire. Same adapter, different base URL.
|
|
44
|
+
openrouter = ["langchain-openai>=1,<2"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/semora"]
|
|
48
|
+
|
|
49
|
+
[tool.uv.sources]
|
|
50
|
+
semora-fork = { workspace = true }
|
|
51
|
+
semora-llm = { workspace = true }
|
|
52
|
+
semora-permissions = { workspace = true }
|
|
53
|
+
semora-store = { workspace = true }
|
|
54
|
+
semora-store-pg = { workspace = true }
|
|
55
|
+
semora-ui = { workspace = true }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Semora's public Python package."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from semora_llm import ChatModel
|
|
6
|
+
from semora_store import ExecutionContext, MemorySteps
|
|
7
|
+
|
|
8
|
+
from .contracts import Agent, AgentDefinition, PendingInput, ToolCall, Tools
|
|
9
|
+
from .controls import (
|
|
10
|
+
Continue,
|
|
11
|
+
ControlPlane,
|
|
12
|
+
Controls,
|
|
13
|
+
Ctx,
|
|
14
|
+
Deny,
|
|
15
|
+
FinishPolicy,
|
|
16
|
+
Halt,
|
|
17
|
+
Ingress,
|
|
18
|
+
Permissions,
|
|
19
|
+
Proceed,
|
|
20
|
+
ResumeInput,
|
|
21
|
+
Suspend,
|
|
22
|
+
gate,
|
|
23
|
+
)
|
|
24
|
+
from .ids import new_run_id
|
|
25
|
+
from .runtime import AgentRuntime, run
|
|
26
|
+
from .workspace import HostWorkspaceProvider
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
__version__ = version("semora")
|
|
30
|
+
except PackageNotFoundError: # pragma: no cover - source tree without installation
|
|
31
|
+
__version__ = "0.0.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Agent",
|
|
35
|
+
"AgentDefinition",
|
|
36
|
+
"AgentRuntime",
|
|
37
|
+
"ChatModel",
|
|
38
|
+
"Continue",
|
|
39
|
+
"ControlPlane",
|
|
40
|
+
"Controls",
|
|
41
|
+
"Ctx",
|
|
42
|
+
"Deny",
|
|
43
|
+
"ExecutionContext",
|
|
44
|
+
"FinishPolicy",
|
|
45
|
+
"Halt",
|
|
46
|
+
"HostWorkspaceProvider",
|
|
47
|
+
"Ingress",
|
|
48
|
+
"MemorySteps",
|
|
49
|
+
"PendingInput",
|
|
50
|
+
"Permissions",
|
|
51
|
+
"Proceed",
|
|
52
|
+
"ResumeInput",
|
|
53
|
+
"Suspend",
|
|
54
|
+
"ToolCall",
|
|
55
|
+
"Tools",
|
|
56
|
+
"__version__",
|
|
57
|
+
"gate",
|
|
58
|
+
"new_run_id",
|
|
59
|
+
"run",
|
|
60
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Process-local registry and result types for agent-launched background work."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BackgroundResult",
|
|
11
|
+
"BackgroundTask",
|
|
12
|
+
"BackgroundTasks",
|
|
13
|
+
"TaskStatus",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
TaskStatus = Literal["running", "done", "error", "cancelled"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class BackgroundResult:
|
|
21
|
+
"""Result delivered when managed background work settles."""
|
|
22
|
+
|
|
23
|
+
task_id: str
|
|
24
|
+
kind: str
|
|
25
|
+
label: str
|
|
26
|
+
content: str
|
|
27
|
+
is_error: bool = False
|
|
28
|
+
|
|
29
|
+
def as_message(self) -> str:
|
|
30
|
+
"""Render the result as a model-visible background notification."""
|
|
31
|
+
state = "failed" if self.is_error else "completed"
|
|
32
|
+
header = f'[background {self.kind} "{self.label}" {state}] (task {self.task_id})'
|
|
33
|
+
return f"{header}\n{self.content}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class BackgroundTask:
|
|
38
|
+
"""Registered background task and its observable lifecycle state."""
|
|
39
|
+
|
|
40
|
+
task_id: str
|
|
41
|
+
kind: str
|
|
42
|
+
label: str
|
|
43
|
+
started_at: float
|
|
44
|
+
task: asyncio.Task[Any]
|
|
45
|
+
status: TaskStatus = "running"
|
|
46
|
+
settled_at: float | None = None
|
|
47
|
+
read_output: Callable[[], str] | None = None
|
|
48
|
+
|
|
49
|
+
def snapshot(self) -> dict[str, Any]:
|
|
50
|
+
"""Return a serializable task status snapshot."""
|
|
51
|
+
return {
|
|
52
|
+
"task_id": self.task_id,
|
|
53
|
+
"kind": self.kind,
|
|
54
|
+
"label": self.label,
|
|
55
|
+
"status": self.status,
|
|
56
|
+
"started_at": self.started_at,
|
|
57
|
+
**({"settled_at": self.settled_at} if self.settled_at is not None else {}),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class BackgroundTasks:
|
|
63
|
+
"""Manage process-local background tasks launched by one parent run."""
|
|
64
|
+
|
|
65
|
+
max_settled_retained: int = 50
|
|
66
|
+
|
|
67
|
+
_tasks: dict[str, BackgroundTask] = field(default_factory=dict, repr=False)
|
|
68
|
+
_listeners: list[Callable[[str, TaskStatus], None]] = field(default_factory=list, repr=False)
|
|
69
|
+
|
|
70
|
+
def register(
|
|
71
|
+
self,
|
|
72
|
+
task_id: str,
|
|
73
|
+
kind: str,
|
|
74
|
+
label: str,
|
|
75
|
+
task: asyncio.Task[Any],
|
|
76
|
+
*,
|
|
77
|
+
read_output: Callable[[], str] | None = None,
|
|
78
|
+
) -> BackgroundTask:
|
|
79
|
+
"""Register a launched task without notifying settlement listeners."""
|
|
80
|
+
entry = BackgroundTask(
|
|
81
|
+
task_id=task_id,
|
|
82
|
+
kind=kind,
|
|
83
|
+
label=label,
|
|
84
|
+
started_at=time.time(),
|
|
85
|
+
task=task,
|
|
86
|
+
read_output=read_output,
|
|
87
|
+
)
|
|
88
|
+
self._tasks[task_id] = entry
|
|
89
|
+
return entry
|
|
90
|
+
|
|
91
|
+
def settle(self, task_id: str, status: TaskStatus) -> None:
|
|
92
|
+
"""Record the first terminal status for a task."""
|
|
93
|
+
entry = self._tasks.get(task_id)
|
|
94
|
+
if entry is None or entry.status != "running":
|
|
95
|
+
return
|
|
96
|
+
entry.status = status
|
|
97
|
+
entry.settled_at = time.time()
|
|
98
|
+
self._prune()
|
|
99
|
+
self._notify(task_id, status)
|
|
100
|
+
|
|
101
|
+
def cancel(self, task_id: str) -> bool:
|
|
102
|
+
"""Cancel a running task and report whether cancellation was applied."""
|
|
103
|
+
entry = self._tasks.get(task_id)
|
|
104
|
+
if entry is None or entry.status != "running":
|
|
105
|
+
return False
|
|
106
|
+
entry.status = "cancelled"
|
|
107
|
+
entry.settled_at = time.time()
|
|
108
|
+
entry.task.cancel()
|
|
109
|
+
self._prune()
|
|
110
|
+
self._notify(task_id, "cancelled")
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
def cancel_all(self) -> int:
|
|
114
|
+
"""Cancel all running tasks and return the number cancelled."""
|
|
115
|
+
return sum(self.cancel(task_id) for task_id in list(self._tasks))
|
|
116
|
+
|
|
117
|
+
def get(self, task_id: str) -> BackgroundTask | None:
|
|
118
|
+
"""Return a task record by identifier."""
|
|
119
|
+
return self._tasks.get(task_id)
|
|
120
|
+
|
|
121
|
+
def status(self, task_id: str) -> TaskStatus | None:
|
|
122
|
+
"""Return a task status by identifier."""
|
|
123
|
+
entry = self._tasks.get(task_id)
|
|
124
|
+
return entry.status if entry is not None else None
|
|
125
|
+
|
|
126
|
+
def list(self) -> list[dict[str, Any]]:
|
|
127
|
+
"""Return task snapshots in launch order."""
|
|
128
|
+
return [
|
|
129
|
+
entry.snapshot()
|
|
130
|
+
for entry in sorted(self._tasks.values(), key=lambda item: item.started_at)
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
def subscribe(self, listener: Callable[[str, TaskStatus], None]) -> Callable[[], None]:
|
|
134
|
+
"""Subscribe to task settlements and return an unsubscribe callback."""
|
|
135
|
+
self._listeners.append(listener)
|
|
136
|
+
|
|
137
|
+
def unsubscribe() -> None:
|
|
138
|
+
if listener in self._listeners:
|
|
139
|
+
self._listeners.remove(listener)
|
|
140
|
+
|
|
141
|
+
return unsubscribe
|
|
142
|
+
|
|
143
|
+
def _notify(self, task_id: str, status: TaskStatus) -> None:
|
|
144
|
+
for listener in list(self._listeners):
|
|
145
|
+
try:
|
|
146
|
+
listener(task_id, status)
|
|
147
|
+
except Exception: # one bad listener must not break settling
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
def _prune(self) -> None:
|
|
151
|
+
settled = sorted(
|
|
152
|
+
(item for item in self._tasks.values() if item.status != "running"),
|
|
153
|
+
key=lambda item: item.settled_at or 0.0,
|
|
154
|
+
)
|
|
155
|
+
for entry in settled[: max(0, len(settled) - self.max_settled_retained)]:
|
|
156
|
+
del self._tasks[entry.task_id]
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""TS-compatible core built-in tool bundle.
|
|
2
|
+
|
|
3
|
+
The bundle intentionally contains file/process tools plus ``web_fetch``. It does not include
|
|
4
|
+
``web_search``; applications can add their own search provider when they need one.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from functools import partial
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..workspace import ToolContext
|
|
13
|
+
from ._exec import exec_is_read_only, exec_tool
|
|
14
|
+
from ._files import edit_tool, read_tool, write_tool
|
|
15
|
+
from ._search import glob_tool, grep_tool
|
|
16
|
+
from ._types import (
|
|
17
|
+
BuiltinToolState,
|
|
18
|
+
ExecToolOptions,
|
|
19
|
+
Handler,
|
|
20
|
+
WebFetchResponse,
|
|
21
|
+
WebFetchSummarizer,
|
|
22
|
+
WebFetchToolOptions,
|
|
23
|
+
WebFetchTransport,
|
|
24
|
+
error_result,
|
|
25
|
+
)
|
|
26
|
+
from ._web import UrllibWebFetchTransport, web_fetch_tool
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BuiltinTools",
|
|
30
|
+
"ExecToolOptions",
|
|
31
|
+
"UrllibWebFetchTransport",
|
|
32
|
+
"WebFetchResponse",
|
|
33
|
+
"WebFetchSummarizer",
|
|
34
|
+
"WebFetchToolOptions",
|
|
35
|
+
"WebFetchTransport",
|
|
36
|
+
"builtin_tools",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BuiltinTools:
|
|
41
|
+
"""Context-aware implementation of the TS sandbox bundle plus ``web_fetch``."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
context: ToolContext | None = None,
|
|
47
|
+
exec_options: ExecToolOptions | None = None,
|
|
48
|
+
web_fetch_options: WebFetchToolOptions | None = None,
|
|
49
|
+
_state: BuiltinToolState | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Configure tool policy independently from a turn's workspace session."""
|
|
52
|
+
self._context = context or ToolContext(workdir=".")
|
|
53
|
+
self._exec_options = exec_options or ExecToolOptions()
|
|
54
|
+
self._web_fetch_options = web_fetch_options or WebFetchToolOptions()
|
|
55
|
+
self._state = _state or BuiltinToolState()
|
|
56
|
+
self._handlers: dict[str, Handler] = {
|
|
57
|
+
"read": read_tool,
|
|
58
|
+
"write": write_tool,
|
|
59
|
+
"edit": edit_tool,
|
|
60
|
+
"glob": glob_tool,
|
|
61
|
+
"grep": grep_tool,
|
|
62
|
+
"Bash": partial(exec_tool, options=self._exec_options),
|
|
63
|
+
"web_fetch": partial(web_fetch_tool, options=self._web_fetch_options),
|
|
64
|
+
}
|
|
65
|
+
self._definitions = _definitions(self._exec_options.allow_shell)
|
|
66
|
+
|
|
67
|
+
async def execute(self, name: str, call_id: str, arguments: Any) -> dict[str, Any]:
|
|
68
|
+
"""Execute a named built-in against the currently bound context."""
|
|
69
|
+
handler = self._handlers.get(name)
|
|
70
|
+
if handler is None:
|
|
71
|
+
return error_result(f"Unknown tool: {name}")
|
|
72
|
+
return dict(await handler(call_id, arguments, self._context, self._state))
|
|
73
|
+
|
|
74
|
+
def get(self, name: str) -> dict[str, Any] | None:
|
|
75
|
+
"""Return one model-visible tool definition."""
|
|
76
|
+
definition = self._definitions.get(name)
|
|
77
|
+
return dict(definition) if definition is not None else None
|
|
78
|
+
|
|
79
|
+
def list(self) -> list[dict[str, Any]]:
|
|
80
|
+
"""Return the stable core set, deliberately excluding ``web_search``."""
|
|
81
|
+
return [dict(definition) for definition in self._definitions.values()]
|
|
82
|
+
|
|
83
|
+
def get_context(self) -> ToolContext:
|
|
84
|
+
"""Return the immutable execution context currently bound to the tools."""
|
|
85
|
+
return self._context
|
|
86
|
+
|
|
87
|
+
def with_context(self, context: ToolContext) -> BuiltinTools:
|
|
88
|
+
"""Bind a runtime workspace while retaining locks and fetch cache across turns."""
|
|
89
|
+
return BuiltinTools(
|
|
90
|
+
context=context,
|
|
91
|
+
exec_options=self._exec_options,
|
|
92
|
+
web_fetch_options=self._web_fetch_options,
|
|
93
|
+
_state=self._state,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def builtin_tools(
|
|
98
|
+
*,
|
|
99
|
+
context: ToolContext | None = None,
|
|
100
|
+
exec_options: ExecToolOptions | None = None,
|
|
101
|
+
web_fetch_options: WebFetchToolOptions | None = None,
|
|
102
|
+
) -> BuiltinTools:
|
|
103
|
+
"""Create the standard bundle, optionally bound to a caller-managed workspace context."""
|
|
104
|
+
return BuiltinTools(
|
|
105
|
+
context=context,
|
|
106
|
+
exec_options=exec_options,
|
|
107
|
+
web_fetch_options=web_fetch_options,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _definitions(allow_shell: bool) -> dict[str, dict[str, Any]]:
|
|
112
|
+
return {
|
|
113
|
+
"read": {
|
|
114
|
+
"name": "read",
|
|
115
|
+
"description": (
|
|
116
|
+
"Read a file or directory from the workspace. Text is line-numbered and paged; "
|
|
117
|
+
"images return inline and Jupyter notebooks return readable cell text."
|
|
118
|
+
),
|
|
119
|
+
"parameters": {
|
|
120
|
+
"type": "object",
|
|
121
|
+
"properties": {
|
|
122
|
+
"path": {"type": "string"},
|
|
123
|
+
"offset": {"type": "number"},
|
|
124
|
+
"limit": {"type": "number"},
|
|
125
|
+
"pages": {"type": "string"},
|
|
126
|
+
},
|
|
127
|
+
"required": ["path"],
|
|
128
|
+
},
|
|
129
|
+
"is_read_only": True,
|
|
130
|
+
"is_concurrency_safe": True,
|
|
131
|
+
},
|
|
132
|
+
"write": {
|
|
133
|
+
"name": "write",
|
|
134
|
+
"description": "Create or replace a UTF-8 file inside the workspace.",
|
|
135
|
+
"parameters": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
|
138
|
+
"required": ["path", "content"],
|
|
139
|
+
},
|
|
140
|
+
"is_read_only": False,
|
|
141
|
+
"is_concurrency_safe": False,
|
|
142
|
+
},
|
|
143
|
+
"edit": {
|
|
144
|
+
"name": "edit",
|
|
145
|
+
"description": "Replace an exact string in a UTF-8 workspace file.",
|
|
146
|
+
"parameters": {
|
|
147
|
+
"type": "object",
|
|
148
|
+
"properties": {
|
|
149
|
+
"path": {"type": "string"},
|
|
150
|
+
"old_string": {"type": "string"},
|
|
151
|
+
"new_string": {"type": "string"},
|
|
152
|
+
"replace_all": {"type": "boolean"},
|
|
153
|
+
},
|
|
154
|
+
"required": ["path", "old_string", "new_string"],
|
|
155
|
+
},
|
|
156
|
+
"is_read_only": False,
|
|
157
|
+
"is_concurrency_safe": False,
|
|
158
|
+
},
|
|
159
|
+
"grep": {
|
|
160
|
+
"name": "grep",
|
|
161
|
+
"description": (
|
|
162
|
+
"Search workspace file contents with ripgrep, falling back to system grep. "
|
|
163
|
+
"Supports content, files_with_matches, and count output modes."
|
|
164
|
+
),
|
|
165
|
+
"parameters": {
|
|
166
|
+
"type": "object",
|
|
167
|
+
"properties": {
|
|
168
|
+
"pattern": {"type": "string"},
|
|
169
|
+
"path": {"type": "string"},
|
|
170
|
+
"glob": {"type": "string"},
|
|
171
|
+
"type": {"type": "string"},
|
|
172
|
+
"output_mode": {
|
|
173
|
+
"type": "string",
|
|
174
|
+
"enum": ["content", "files_with_matches", "count"],
|
|
175
|
+
},
|
|
176
|
+
"-A": {"type": "number"},
|
|
177
|
+
"-B": {"type": "number"},
|
|
178
|
+
"-C": {"type": "number"},
|
|
179
|
+
"context": {"type": "number"},
|
|
180
|
+
"-n": {"type": "boolean"},
|
|
181
|
+
"-i": {"type": "boolean"},
|
|
182
|
+
"head_limit": {"type": "number"},
|
|
183
|
+
"offset": {"type": "number"},
|
|
184
|
+
"multiline": {"type": "boolean"},
|
|
185
|
+
},
|
|
186
|
+
"required": ["pattern"],
|
|
187
|
+
},
|
|
188
|
+
"is_read_only": True,
|
|
189
|
+
"is_concurrency_safe": True,
|
|
190
|
+
},
|
|
191
|
+
"glob": {
|
|
192
|
+
"name": "glob",
|
|
193
|
+
"description": "Find workspace files by glob pattern with ripgrep.",
|
|
194
|
+
"parameters": {
|
|
195
|
+
"type": "object",
|
|
196
|
+
"properties": {
|
|
197
|
+
"pattern": {"type": "string"},
|
|
198
|
+
"path": {"type": "string"},
|
|
199
|
+
"head_limit": {"type": "number"},
|
|
200
|
+
"offset": {"type": "number"},
|
|
201
|
+
},
|
|
202
|
+
"required": ["pattern"],
|
|
203
|
+
},
|
|
204
|
+
"is_read_only": True,
|
|
205
|
+
"is_concurrency_safe": True,
|
|
206
|
+
},
|
|
207
|
+
"Bash": {
|
|
208
|
+
"name": "Bash",
|
|
209
|
+
"description": (
|
|
210
|
+
"Execute an allow-listed command in the workspace. "
|
|
211
|
+
+ (
|
|
212
|
+
"argv is preferred; shell strings are enabled for pipes and globs."
|
|
213
|
+
if allow_shell
|
|
214
|
+
else "Shell-string mode is disabled; use argv."
|
|
215
|
+
)
|
|
216
|
+
),
|
|
217
|
+
"parameters": {
|
|
218
|
+
"type": "object",
|
|
219
|
+
"properties": {
|
|
220
|
+
"argv": {"type": "array", "items": {"type": "string"}},
|
|
221
|
+
"command": {"type": "string"},
|
|
222
|
+
"timeoutMs": {"type": "number"},
|
|
223
|
+
"cwd": {"type": "string"},
|
|
224
|
+
"run_in_background": {"type": "boolean"},
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
"is_read_only": exec_is_read_only,
|
|
228
|
+
"is_concurrency_safe": exec_is_read_only,
|
|
229
|
+
},
|
|
230
|
+
"web_fetch": {
|
|
231
|
+
"name": "web_fetch",
|
|
232
|
+
"description": (
|
|
233
|
+
"Fetch an HTTPS URL and return readable content. HTTP is upgraded to HTTPS; "
|
|
234
|
+
"results are cached by URL and prompt."
|
|
235
|
+
),
|
|
236
|
+
"parameters": {
|
|
237
|
+
"type": "object",
|
|
238
|
+
"properties": {
|
|
239
|
+
"url": {"type": "string"},
|
|
240
|
+
"prompt": {"type": "string"},
|
|
241
|
+
"max_chars": {"type": "number"},
|
|
242
|
+
},
|
|
243
|
+
"required": ["url"],
|
|
244
|
+
},
|
|
245
|
+
"is_read_only": True,
|
|
246
|
+
"is_concurrency_safe": True,
|
|
247
|
+
},
|
|
248
|
+
}
|