opencode-agent 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.
- opencode_agent/__init__.py +26 -0
- opencode_agent/agent.py +137 -0
- opencode_agent/cli.py +183 -0
- opencode_agent/client.py +331 -0
- opencode_agent/config.py +108 -0
- opencode_agent/exceptions.py +25 -0
- opencode_agent/logging.py +16 -0
- opencode_agent/managers.py +147 -0
- opencode_agent/models.py +141 -0
- opencode_agent/orchestrator.py +293 -0
- opencode_agent-0.1.0.dist-info/METADATA +209 -0
- opencode_agent-0.1.0.dist-info/RECORD +15 -0
- opencode_agent-0.1.0.dist-info/WHEEL +5 -0
- opencode_agent-0.1.0.dist-info/entry_points.txt +2 -0
- opencode_agent-0.1.0.dist-info/top_level.txt +1 -0
opencode_agent/config.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "http://localhost:4096"
|
|
11
|
+
DEFAULT_AGENT = "build"
|
|
12
|
+
DEFAULT_TIMEOUT_SECONDS = 120.0
|
|
13
|
+
DEFAULT_MCP_CONFIG = "mcp.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_environment_file(path: str | Path = ".env") -> None:
|
|
17
|
+
env_path = Path(path)
|
|
18
|
+
if not env_path.exists():
|
|
19
|
+
return
|
|
20
|
+
for raw_line in env_path.read_text().splitlines():
|
|
21
|
+
line = raw_line.strip()
|
|
22
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
23
|
+
continue
|
|
24
|
+
key, value = line.split("=", 1)
|
|
25
|
+
key = key.strip()
|
|
26
|
+
value = value.strip().strip('"').strip("'")
|
|
27
|
+
os.environ.setdefault(key, value)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_json_file(path: str | Path) -> dict[str, Any]:
|
|
31
|
+
json_path = Path(path)
|
|
32
|
+
if not json_path.exists():
|
|
33
|
+
return {}
|
|
34
|
+
return json.loads(json_path.read_text())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class RetryPolicy:
|
|
39
|
+
attempts: int = 3
|
|
40
|
+
backoff_seconds: float = 0.5
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class OpenCodeConfig:
|
|
45
|
+
base_url: str = DEFAULT_BASE_URL
|
|
46
|
+
project_path: str = ""
|
|
47
|
+
default_model: str | None = None
|
|
48
|
+
default_agent: str = DEFAULT_AGENT
|
|
49
|
+
streaming: bool = False
|
|
50
|
+
verbose: bool = False
|
|
51
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
|
|
52
|
+
retry_policy: RetryPolicy = field(default_factory=RetryPolicy)
|
|
53
|
+
default_tools: list[str] = field(default_factory=list)
|
|
54
|
+
mcp_servers: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
55
|
+
mcp_config_path: str = DEFAULT_MCP_CONFIG
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_env(cls) -> "OpenCodeConfig":
|
|
59
|
+
load_environment_file(".env")
|
|
60
|
+
load_environment_file(".environment")
|
|
61
|
+
base_url = os.getenv("OPENCODE_SERVER") or os.getenv("OPENCODE_BASE_URL") or DEFAULT_BASE_URL
|
|
62
|
+
project_path = os.getenv("OPENCODE_PROJECT_PATH", "").strip()
|
|
63
|
+
if not project_path:
|
|
64
|
+
raise ValueError("OPENCODE_PROJECT_PATH is required in .env")
|
|
65
|
+
default_model = os.getenv("OPENCODE_MODEL")
|
|
66
|
+
default_agent = os.getenv("OPENCODE_AGENT", DEFAULT_AGENT)
|
|
67
|
+
streaming = os.getenv("OPENCODE_STREAMING", "false").lower() == "true"
|
|
68
|
+
verbose = os.getenv("OPENCODE_VERBOSE", "false").lower() == "true"
|
|
69
|
+
timeout_seconds = float(os.getenv("OPENCODE_TIMEOUT", str(DEFAULT_TIMEOUT_SECONDS)))
|
|
70
|
+
attempts = int(os.getenv("OPENCODE_RETRY_ATTEMPTS", "3"))
|
|
71
|
+
backoff_seconds = float(os.getenv("OPENCODE_RETRY_BACKOFF", "0.5"))
|
|
72
|
+
tools = [tool for tool in os.getenv("OPENCODE_TOOLS", "").split(",") if tool]
|
|
73
|
+
mcp_config_path = os.getenv("OPENCODE_MCP_CONFIG", DEFAULT_MCP_CONFIG)
|
|
74
|
+
mcp_servers = load_json_file(mcp_config_path).get("servers", {})
|
|
75
|
+
return cls(
|
|
76
|
+
base_url=base_url,
|
|
77
|
+
project_path=project_path,
|
|
78
|
+
default_model=default_model,
|
|
79
|
+
default_agent=default_agent,
|
|
80
|
+
streaming=streaming,
|
|
81
|
+
verbose=verbose,
|
|
82
|
+
timeout_seconds=timeout_seconds,
|
|
83
|
+
retry_policy=RetryPolicy(attempts=attempts, backoff_seconds=backoff_seconds),
|
|
84
|
+
default_tools=tools,
|
|
85
|
+
mcp_servers=mcp_servers,
|
|
86
|
+
mcp_config_path=mcp_config_path,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_file(cls, path: str | Path) -> "OpenCodeConfig":
|
|
91
|
+
data = json.loads(Path(path).read_text())
|
|
92
|
+
retry_data = data.get("retry_policy", {})
|
|
93
|
+
return cls(
|
|
94
|
+
base_url=data.get("base_url", DEFAULT_BASE_URL),
|
|
95
|
+
project_path=data.get("project_path", ""),
|
|
96
|
+
default_model=data.get("default_model"),
|
|
97
|
+
default_agent=data.get("default_agent", DEFAULT_AGENT),
|
|
98
|
+
streaming=data.get("streaming", False),
|
|
99
|
+
verbose=data.get("verbose", False),
|
|
100
|
+
timeout_seconds=data.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS),
|
|
101
|
+
retry_policy=RetryPolicy(
|
|
102
|
+
attempts=retry_data.get("attempts", 3),
|
|
103
|
+
backoff_seconds=retry_data.get("backoff_seconds", 0.5),
|
|
104
|
+
),
|
|
105
|
+
default_tools=data.get("default_tools", []),
|
|
106
|
+
mcp_servers=data.get("mcp_servers", {}),
|
|
107
|
+
mcp_config_path=data.get("mcp_config_path", DEFAULT_MCP_CONFIG),
|
|
108
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class OpenCodeError(Exception):
|
|
2
|
+
"""Base error for orchestration failures."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class OpenCodeHTTPError(OpenCodeError):
|
|
6
|
+
def __init__(self, status_code: int, message: str):
|
|
7
|
+
super().__init__(f"HTTP {status_code}: {message}")
|
|
8
|
+
self.status_code = status_code
|
|
9
|
+
self.message = message
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SessionNotFoundError(OpenCodeError):
|
|
13
|
+
"""Raised when referencing a session ID that does not exist."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MCPReadinessError(OpenCodeError):
|
|
17
|
+
"""Raised when a required MCP server is not available and cannot be registered."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TaskTimeoutError(OpenCodeError):
|
|
21
|
+
"""Raised when a task execution exceeds the configured timeout."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ToolNotFoundError(OpenCodeError):
|
|
25
|
+
"""Raised when a requested tool is not available on the OpenCode server."""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def configure_logging(verbose: bool = False) -> None:
|
|
9
|
+
logging.basicConfig(
|
|
10
|
+
level=logging.DEBUG if verbose else logging.INFO,
|
|
11
|
+
format="%(message)s",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def log_event(logger: logging.Logger, event: str, **payload: Any) -> None:
|
|
16
|
+
logger.info(json.dumps({"event": event, **payload}, default=str))
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .client import OpenCodeClient
|
|
7
|
+
from .exceptions import MCPReadinessError, SessionNotFoundError, ToolNotFoundError
|
|
8
|
+
from .models import MCPServer, Session, ToolSchema
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class SessionRecord:
|
|
13
|
+
task_id: str
|
|
14
|
+
session_id: str
|
|
15
|
+
parent_session_id: str | None = None
|
|
16
|
+
title: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SessionManager:
|
|
20
|
+
def __init__(self, client: OpenCodeClient):
|
|
21
|
+
self.client = client
|
|
22
|
+
self._registry: dict[str, SessionRecord] = {}
|
|
23
|
+
|
|
24
|
+
def create(self, task_id: str, title: str | None = None, parent_session_id: str | None = None) -> Session:
|
|
25
|
+
session = self.client.create_session(title=title, parent_id=parent_session_id)
|
|
26
|
+
self._registry[task_id] = SessionRecord(
|
|
27
|
+
task_id=task_id,
|
|
28
|
+
session_id=session.id,
|
|
29
|
+
parent_session_id=parent_session_id,
|
|
30
|
+
title=title,
|
|
31
|
+
)
|
|
32
|
+
return session
|
|
33
|
+
|
|
34
|
+
def reuse_or_create(self, task_id: str, session_id: str | None = None, title: str | None = None) -> Session:
|
|
35
|
+
if session_id:
|
|
36
|
+
for record in self._registry.values():
|
|
37
|
+
if record.session_id == session_id:
|
|
38
|
+
session = Session(id=session_id, title=title or record.title)
|
|
39
|
+
self._registry[task_id] = SessionRecord(task_id=task_id, session_id=session.id, title=session.title)
|
|
40
|
+
return session
|
|
41
|
+
session = self.client.get_session(session_id)
|
|
42
|
+
self._registry[task_id] = SessionRecord(task_id=task_id, session_id=session.id, title=session.title)
|
|
43
|
+
return session
|
|
44
|
+
return self.create(task_id=task_id, title=title)
|
|
45
|
+
|
|
46
|
+
def get_record(self, task_id: str) -> SessionRecord:
|
|
47
|
+
if task_id not in self._registry:
|
|
48
|
+
raise SessionNotFoundError(f"No session registered for task {task_id}")
|
|
49
|
+
return self._registry[task_id]
|
|
50
|
+
|
|
51
|
+
def link_existing(self, task_id: str, session_id: str, parent_session_id: str | None = None, title: str | None = None) -> None:
|
|
52
|
+
self._registry[task_id] = SessionRecord(
|
|
53
|
+
task_id=task_id,
|
|
54
|
+
session_id=session_id,
|
|
55
|
+
parent_session_id=parent_session_id,
|
|
56
|
+
title=title,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def fork(self, parent_task_id: str, child_task_id: str, message_id: str | None = None) -> Session:
|
|
60
|
+
parent = self.get_record(parent_task_id)
|
|
61
|
+
session = self.client.fork_session(parent.session_id, message_id=message_id)
|
|
62
|
+
self._registry[child_task_id] = SessionRecord(
|
|
63
|
+
task_id=child_task_id,
|
|
64
|
+
session_id=session.id,
|
|
65
|
+
parent_session_id=parent.session_id,
|
|
66
|
+
title=session.title,
|
|
67
|
+
)
|
|
68
|
+
return session
|
|
69
|
+
|
|
70
|
+
def abort(self, task_id: str) -> None:
|
|
71
|
+
record = self.get_record(task_id)
|
|
72
|
+
self.client.abort_session(record.session_id)
|
|
73
|
+
|
|
74
|
+
def delete(self, task_id: str) -> None:
|
|
75
|
+
record = self.get_record(task_id)
|
|
76
|
+
self.client.delete_session(record.session_id)
|
|
77
|
+
del self._registry[task_id]
|
|
78
|
+
|
|
79
|
+
def list_registry(self) -> list[SessionRecord]:
|
|
80
|
+
return list(self._registry.values())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ToolManager:
|
|
84
|
+
def __init__(self, client: OpenCodeClient):
|
|
85
|
+
self.client = client
|
|
86
|
+
self._tool_ids: list[str] | None = None
|
|
87
|
+
self._tools_by_model: dict[tuple[str | None, str | None], list[ToolSchema]] = {}
|
|
88
|
+
|
|
89
|
+
def get_tool_ids(self, refresh: bool = False) -> list[str]:
|
|
90
|
+
if refresh or self._tool_ids is None:
|
|
91
|
+
self._tool_ids = self.client.list_tool_ids()
|
|
92
|
+
return self._tool_ids
|
|
93
|
+
|
|
94
|
+
def get_tools(self, provider: str | None = None, model: str | None = None, refresh: bool = False) -> list[ToolSchema]:
|
|
95
|
+
cache_key = (provider, model)
|
|
96
|
+
if refresh or cache_key not in self._tools_by_model:
|
|
97
|
+
self._tools_by_model[cache_key] = self.client.list_tools(provider=provider, model=model)
|
|
98
|
+
return self._tools_by_model[cache_key]
|
|
99
|
+
|
|
100
|
+
def resolve_allowlist(self, tools: list[str] | None, *, strict: bool = False) -> list[str] | None:
|
|
101
|
+
"""Return tools filtered to those actually available on the OpenCode server.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
tools: list of tool names requested by the caller.
|
|
105
|
+
strict: if True, raise ToolNotFoundError for any tool not available
|
|
106
|
+
on the server instead of silently ignoring it.
|
|
107
|
+
"""
|
|
108
|
+
if tools is None:
|
|
109
|
+
return None
|
|
110
|
+
available = set(self.get_tool_ids())
|
|
111
|
+
resolved = []
|
|
112
|
+
missing = []
|
|
113
|
+
for tool in tools:
|
|
114
|
+
if tool in available:
|
|
115
|
+
resolved.append(tool)
|
|
116
|
+
else:
|
|
117
|
+
missing.append(tool)
|
|
118
|
+
if missing and strict:
|
|
119
|
+
raise ToolNotFoundError(
|
|
120
|
+
f"The following tools are not available on the OpenCode server: {', '.join(missing)}. "
|
|
121
|
+
f"Available tools: {', '.join(sorted(available))}"
|
|
122
|
+
)
|
|
123
|
+
return resolved if resolved else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class MCPManager:
|
|
127
|
+
def __init__(self, client: OpenCodeClient):
|
|
128
|
+
self.client = client
|
|
129
|
+
|
|
130
|
+
def list_servers(self) -> list[MCPServer]:
|
|
131
|
+
return self.client.get_mcp_status()
|
|
132
|
+
|
|
133
|
+
def register(self, name: str, config: dict[str, Any]) -> Any:
|
|
134
|
+
return self.client.register_mcp_server(name=name, config=config)
|
|
135
|
+
|
|
136
|
+
def ensure_servers(self, required: list[str], configured_servers: dict[str, dict[str, Any]]) -> list[MCPServer]:
|
|
137
|
+
available = {server.name: server for server in self.list_servers()}
|
|
138
|
+
missing = [name for name in required if name not in available]
|
|
139
|
+
for name in missing:
|
|
140
|
+
if name not in configured_servers:
|
|
141
|
+
raise MCPReadinessError(f"Required MCP server '{name}' is unavailable and not configured")
|
|
142
|
+
self.register(name, configured_servers[name])
|
|
143
|
+
refreshed = {server.name: server for server in self.list_servers()}
|
|
144
|
+
still_missing = [name for name in required if name not in refreshed]
|
|
145
|
+
if still_missing:
|
|
146
|
+
raise MCPReadinessError(f"Required MCP servers unavailable after registration: {', '.join(still_missing)}")
|
|
147
|
+
return [refreshed[name] for name in required]
|
opencode_agent/models.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class MessageInfo:
|
|
9
|
+
id: str
|
|
10
|
+
role: str
|
|
11
|
+
time: int | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class MessagePart:
|
|
16
|
+
type: str
|
|
17
|
+
data: dict[str, Any]
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def text(self) -> str | None:
|
|
21
|
+
return self.data.get("text")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class Message:
|
|
26
|
+
info: MessageInfo
|
|
27
|
+
parts: list[MessagePart]
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_dict(cls, payload: dict[str, Any]) -> "Message":
|
|
31
|
+
info = payload.get("info", {})
|
|
32
|
+
parts = []
|
|
33
|
+
for part in payload.get("parts", []):
|
|
34
|
+
part_type = part.get("type", "unknown")
|
|
35
|
+
parts.append(MessagePart(type=part_type, data=part))
|
|
36
|
+
return cls(
|
|
37
|
+
info=MessageInfo(
|
|
38
|
+
id=info.get("id", ""),
|
|
39
|
+
role=info.get("role", "assistant"),
|
|
40
|
+
time=info.get("time"),
|
|
41
|
+
),
|
|
42
|
+
parts=parts,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def text_output(self) -> str:
|
|
47
|
+
return "\n".join(part.text for part in self.parts if part.type == "text" and part.text)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def tool_events(self) -> list[dict[str, Any]]:
|
|
51
|
+
return [part.data for part in self.parts if part.type in {"tool-call", "tool-result", "tool"}]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class Session:
|
|
56
|
+
id: str
|
|
57
|
+
title: str | None
|
|
58
|
+
directory: str | None = None
|
|
59
|
+
parent_id: str | None = None
|
|
60
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_dict(cls, payload: dict[str, Any]) -> "Session":
|
|
64
|
+
return cls(
|
|
65
|
+
id=payload["id"],
|
|
66
|
+
title=payload.get("title"),
|
|
67
|
+
directory=payload.get("directory"),
|
|
68
|
+
parent_id=payload.get("parentID") or payload.get("parent_id"),
|
|
69
|
+
raw=payload,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(slots=True)
|
|
74
|
+
class SessionState:
|
|
75
|
+
session_id: str
|
|
76
|
+
status: str
|
|
77
|
+
active_form: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(slots=True)
|
|
81
|
+
class ToolSchema:
|
|
82
|
+
name: str
|
|
83
|
+
description: str | None
|
|
84
|
+
input_schema: dict[str, Any]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(slots=True)
|
|
88
|
+
class MCPServer:
|
|
89
|
+
name: str
|
|
90
|
+
status: str | None
|
|
91
|
+
tools: list[str] = field(default_factory=list)
|
|
92
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(slots=True)
|
|
96
|
+
class AgentDefinition:
|
|
97
|
+
id: str
|
|
98
|
+
name: str | None = None
|
|
99
|
+
description: str | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(slots=True)
|
|
103
|
+
class StreamEvent:
|
|
104
|
+
type: str
|
|
105
|
+
payload: dict[str, Any]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(slots=True)
|
|
109
|
+
class ExecutionMetadata:
|
|
110
|
+
task_id: str
|
|
111
|
+
session_id: str
|
|
112
|
+
agent: str
|
|
113
|
+
status: str
|
|
114
|
+
message_id: str | None = None
|
|
115
|
+
tool_events: list[dict[str, Any]] = field(default_factory=list)
|
|
116
|
+
logs: list[dict[str, Any]] = field(default_factory=list)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(slots=True)
|
|
120
|
+
class ExecutionResult:
|
|
121
|
+
output: str
|
|
122
|
+
metadata: ExecutionMetadata
|
|
123
|
+
message: Message | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(slots=True)
|
|
127
|
+
class SubtaskTrace:
|
|
128
|
+
task_id: str
|
|
129
|
+
session_id: str
|
|
130
|
+
agent: str
|
|
131
|
+
prompt: str
|
|
132
|
+
output: str
|
|
133
|
+
message_id: str | None
|
|
134
|
+
status: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(slots=True)
|
|
138
|
+
class MultiAgentResult:
|
|
139
|
+
output: str
|
|
140
|
+
metadata: ExecutionMetadata
|
|
141
|
+
trace: list[SubtaskTrace]
|