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.
@@ -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]
@@ -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]