agentpool 2.1.9__py3-none-any.whl → 2.2.3__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.
- acp/__init__.py +13 -0
- acp/bridge/README.md +15 -2
- acp/bridge/__init__.py +3 -2
- acp/bridge/__main__.py +60 -19
- acp/bridge/ws_server.py +173 -0
- acp/bridge/ws_server_cli.py +89 -0
- acp/notifications.py +2 -1
- acp/stdio.py +39 -9
- acp/transports.py +362 -2
- acp/utils.py +15 -2
- agentpool/__init__.py +4 -1
- agentpool/agents/__init__.py +2 -0
- agentpool/agents/acp_agent/acp_agent.py +203 -88
- agentpool/agents/acp_agent/acp_converters.py +46 -21
- agentpool/agents/acp_agent/client_handler.py +157 -3
- agentpool/agents/acp_agent/session_state.py +4 -1
- agentpool/agents/agent.py +314 -107
- agentpool/agents/agui_agent/__init__.py +0 -2
- agentpool/agents/agui_agent/agui_agent.py +90 -21
- agentpool/agents/agui_agent/agui_converters.py +0 -131
- agentpool/agents/base_agent.py +163 -1
- agentpool/agents/claude_code_agent/claude_code_agent.py +626 -179
- agentpool/agents/claude_code_agent/converters.py +71 -3
- agentpool/agents/claude_code_agent/history.py +474 -0
- agentpool/agents/context.py +40 -0
- agentpool/agents/events/__init__.py +2 -0
- agentpool/agents/events/builtin_handlers.py +2 -1
- agentpool/agents/events/event_emitter.py +29 -2
- agentpool/agents/events/events.py +20 -0
- agentpool/agents/modes.py +54 -0
- agentpool/agents/tool_call_accumulator.py +213 -0
- agentpool/common_types.py +21 -0
- agentpool/config_resources/__init__.py +38 -1
- agentpool/config_resources/claude_code_agent.yml +3 -0
- agentpool/delegation/pool.py +37 -29
- agentpool/delegation/team.py +1 -0
- agentpool/delegation/teamrun.py +1 -0
- agentpool/diagnostics/__init__.py +53 -0
- agentpool/diagnostics/lsp_manager.py +1593 -0
- agentpool/diagnostics/lsp_proxy.py +41 -0
- agentpool/diagnostics/lsp_proxy_script.py +229 -0
- agentpool/diagnostics/models.py +398 -0
- agentpool/mcp_server/__init__.py +0 -2
- agentpool/mcp_server/client.py +12 -3
- agentpool/mcp_server/manager.py +25 -31
- agentpool/mcp_server/registries/official_registry_client.py +25 -0
- agentpool/mcp_server/tool_bridge.py +78 -66
- agentpool/messaging/__init__.py +0 -2
- agentpool/messaging/compaction.py +72 -197
- agentpool/messaging/message_history.py +12 -0
- agentpool/messaging/messages.py +52 -9
- agentpool/messaging/processing.py +3 -1
- agentpool/models/acp_agents/base.py +0 -22
- agentpool/models/acp_agents/mcp_capable.py +8 -148
- agentpool/models/acp_agents/non_mcp.py +129 -72
- agentpool/models/agents.py +35 -13
- agentpool/models/claude_code_agents.py +33 -2
- agentpool/models/manifest.py +43 -0
- agentpool/repomap.py +1 -1
- agentpool/resource_providers/__init__.py +9 -1
- agentpool/resource_providers/aggregating.py +52 -3
- agentpool/resource_providers/base.py +57 -1
- agentpool/resource_providers/mcp_provider.py +23 -0
- agentpool/resource_providers/plan_provider.py +130 -41
- agentpool/resource_providers/pool.py +2 -0
- agentpool/resource_providers/static.py +2 -0
- agentpool/sessions/__init__.py +2 -1
- agentpool/sessions/manager.py +31 -2
- agentpool/sessions/models.py +50 -0
- agentpool/skills/registry.py +13 -8
- agentpool/storage/manager.py +217 -1
- agentpool/testing.py +537 -19
- agentpool/utils/file_watcher.py +269 -0
- agentpool/utils/identifiers.py +121 -0
- agentpool/utils/pydantic_ai_helpers.py +46 -0
- agentpool/utils/streams.py +690 -1
- agentpool/utils/subprocess_utils.py +155 -0
- agentpool/utils/token_breakdown.py +461 -0
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/METADATA +27 -7
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/RECORD +170 -112
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/WHEEL +1 -1
- agentpool_cli/__main__.py +4 -0
- agentpool_cli/serve_acp.py +41 -20
- agentpool_cli/serve_agui.py +87 -0
- agentpool_cli/serve_opencode.py +119 -0
- agentpool_commands/__init__.py +30 -0
- agentpool_commands/agents.py +74 -1
- agentpool_commands/history.py +62 -0
- agentpool_commands/mcp.py +176 -0
- agentpool_commands/models.py +56 -3
- agentpool_commands/tools.py +57 -0
- agentpool_commands/utils.py +51 -0
- agentpool_config/builtin_tools.py +77 -22
- agentpool_config/commands.py +24 -1
- agentpool_config/compaction.py +258 -0
- agentpool_config/mcp_server.py +131 -1
- agentpool_config/storage.py +46 -1
- agentpool_config/tools.py +7 -1
- agentpool_config/toolsets.py +92 -148
- agentpool_server/acp_server/acp_agent.py +134 -150
- agentpool_server/acp_server/commands/acp_commands.py +216 -51
- agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +10 -10
- agentpool_server/acp_server/server.py +23 -79
- agentpool_server/acp_server/session.py +181 -19
- agentpool_server/opencode_server/.rules +95 -0
- agentpool_server/opencode_server/ENDPOINTS.md +362 -0
- agentpool_server/opencode_server/__init__.py +27 -0
- agentpool_server/opencode_server/command_validation.py +172 -0
- agentpool_server/opencode_server/converters.py +869 -0
- agentpool_server/opencode_server/dependencies.py +24 -0
- agentpool_server/opencode_server/input_provider.py +269 -0
- agentpool_server/opencode_server/models/__init__.py +228 -0
- agentpool_server/opencode_server/models/agent.py +53 -0
- agentpool_server/opencode_server/models/app.py +60 -0
- agentpool_server/opencode_server/models/base.py +26 -0
- agentpool_server/opencode_server/models/common.py +23 -0
- agentpool_server/opencode_server/models/config.py +37 -0
- agentpool_server/opencode_server/models/events.py +647 -0
- agentpool_server/opencode_server/models/file.py +88 -0
- agentpool_server/opencode_server/models/mcp.py +25 -0
- agentpool_server/opencode_server/models/message.py +162 -0
- agentpool_server/opencode_server/models/parts.py +190 -0
- agentpool_server/opencode_server/models/provider.py +81 -0
- agentpool_server/opencode_server/models/pty.py +43 -0
- agentpool_server/opencode_server/models/session.py +99 -0
- agentpool_server/opencode_server/routes/__init__.py +25 -0
- agentpool_server/opencode_server/routes/agent_routes.py +442 -0
- agentpool_server/opencode_server/routes/app_routes.py +139 -0
- agentpool_server/opencode_server/routes/config_routes.py +241 -0
- agentpool_server/opencode_server/routes/file_routes.py +392 -0
- agentpool_server/opencode_server/routes/global_routes.py +94 -0
- agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
- agentpool_server/opencode_server/routes/message_routes.py +705 -0
- agentpool_server/opencode_server/routes/pty_routes.py +299 -0
- agentpool_server/opencode_server/routes/session_routes.py +1205 -0
- agentpool_server/opencode_server/routes/tui_routes.py +139 -0
- agentpool_server/opencode_server/server.py +430 -0
- agentpool_server/opencode_server/state.py +121 -0
- agentpool_server/opencode_server/time_utils.py +8 -0
- agentpool_storage/__init__.py +16 -0
- agentpool_storage/base.py +103 -0
- agentpool_storage/claude_provider.py +907 -0
- agentpool_storage/file_provider.py +129 -0
- agentpool_storage/memory_provider.py +61 -0
- agentpool_storage/models.py +3 -0
- agentpool_storage/opencode_provider.py +730 -0
- agentpool_storage/project_store.py +325 -0
- agentpool_storage/session_store.py +6 -0
- agentpool_storage/sql_provider/__init__.py +4 -2
- agentpool_storage/sql_provider/models.py +48 -0
- agentpool_storage/sql_provider/sql_provider.py +134 -1
- agentpool_storage/sql_provider/utils.py +10 -1
- agentpool_storage/text_log_provider.py +1 -0
- agentpool_toolsets/builtin/__init__.py +0 -8
- agentpool_toolsets/builtin/code.py +95 -56
- agentpool_toolsets/builtin/debug.py +16 -21
- agentpool_toolsets/builtin/execution_environment.py +99 -103
- agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
- agentpool_toolsets/builtin/skills.py +86 -4
- agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
- agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
- agentpool_toolsets/fsspec_toolset/grep.py +74 -2
- agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
- agentpool_toolsets/fsspec_toolset/toolset.py +159 -38
- agentpool_toolsets/mcp_discovery/__init__.py +5 -0
- agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
- agentpool_toolsets/mcp_discovery/toolset.py +454 -0
- agentpool_toolsets/mcp_run_toolset.py +84 -6
- agentpool_toolsets/builtin/agent_management.py +0 -239
- agentpool_toolsets/builtin/history.py +0 -36
- agentpool_toolsets/builtin/integration.py +0 -85
- agentpool_toolsets/builtin/tool_management.py +0 -90
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/entry_points.txt +0 -0
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""TUI routes for external control of the TUI.
|
|
2
|
+
|
|
3
|
+
These endpoints allow external integrations (e.g., VSCode extension) to control
|
|
4
|
+
the TUI by broadcasting events that the TUI listens for via SSE.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from agentpool_server.opencode_server.dependencies import StateDep # noqa: TC001
|
|
15
|
+
from agentpool_server.opencode_server.models.events import (
|
|
16
|
+
TuiCommandExecuteEvent,
|
|
17
|
+
TuiPromptAppendEvent,
|
|
18
|
+
TuiToastShowEvent,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
router = APIRouter(prefix="/tui", tags=["tui"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AppendPromptRequest(BaseModel):
|
|
26
|
+
"""Request body for appending text to the prompt."""
|
|
27
|
+
|
|
28
|
+
text: str = Field(..., description="Text to append to the prompt")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ExecuteCommandRequest(BaseModel):
|
|
32
|
+
"""Request body for executing a TUI command."""
|
|
33
|
+
|
|
34
|
+
command: str = Field(
|
|
35
|
+
...,
|
|
36
|
+
description="Command to execute (e.g., 'prompt.submit', 'prompt.clear', 'session.new')",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ShowToastRequest(BaseModel):
|
|
41
|
+
"""Request body for showing a toast notification."""
|
|
42
|
+
|
|
43
|
+
title: str | None = Field(None, description="Optional toast title")
|
|
44
|
+
message: str = Field(..., description="Toast message")
|
|
45
|
+
variant: Literal["info", "success", "warning", "error"] = Field(
|
|
46
|
+
"info", description="Toast variant"
|
|
47
|
+
)
|
|
48
|
+
duration: int = Field(5000, description="Duration in milliseconds")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.post("/append-prompt")
|
|
52
|
+
async def append_prompt(request: AppendPromptRequest, state: StateDep) -> bool:
|
|
53
|
+
"""Append text to the TUI prompt.
|
|
54
|
+
|
|
55
|
+
Used by external integrations (e.g., VSCode) to insert text like file
|
|
56
|
+
references into the prompt input.
|
|
57
|
+
"""
|
|
58
|
+
await state.broadcast_event(TuiPromptAppendEvent.create(request.text))
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.post("/submit-prompt")
|
|
63
|
+
async def submit_prompt(state: StateDep) -> bool:
|
|
64
|
+
"""Submit the current prompt.
|
|
65
|
+
|
|
66
|
+
Triggers the TUI to submit whatever is currently in the prompt input.
|
|
67
|
+
"""
|
|
68
|
+
await state.broadcast_event(TuiCommandExecuteEvent.create("prompt.submit"))
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/clear-prompt")
|
|
73
|
+
async def clear_prompt(state: StateDep) -> bool:
|
|
74
|
+
"""Clear the TUI prompt.
|
|
75
|
+
|
|
76
|
+
Clears any text currently in the prompt input.
|
|
77
|
+
"""
|
|
78
|
+
await state.broadcast_event(TuiCommandExecuteEvent.create("prompt.clear"))
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.post("/execute-command")
|
|
83
|
+
async def execute_command(request: ExecuteCommandRequest, state: StateDep) -> bool:
|
|
84
|
+
"""Execute a TUI command.
|
|
85
|
+
|
|
86
|
+
Available commands:
|
|
87
|
+
- session.list, session.new, session.share, session.interrupt, session.compact
|
|
88
|
+
- session.page.up, session.page.down, session.half.page.up, session.half.page.down
|
|
89
|
+
- session.first, session.last
|
|
90
|
+
- prompt.clear, prompt.submit
|
|
91
|
+
- agent.cycle
|
|
92
|
+
"""
|
|
93
|
+
await state.broadcast_event(TuiCommandExecuteEvent.create(request.command))
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.post("/show-toast")
|
|
98
|
+
async def show_toast(request: ShowToastRequest, state: StateDep) -> bool:
|
|
99
|
+
"""Show a toast notification in the TUI."""
|
|
100
|
+
await state.broadcast_event(
|
|
101
|
+
TuiToastShowEvent.create(
|
|
102
|
+
message=request.message,
|
|
103
|
+
variant=request.variant,
|
|
104
|
+
title=request.title,
|
|
105
|
+
duration=request.duration,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Additional convenience endpoints matching OpenCode's API
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.post("/open-help")
|
|
115
|
+
async def open_help(state: StateDep) -> bool:
|
|
116
|
+
"""Open the help dialog."""
|
|
117
|
+
await state.broadcast_event(TuiCommandExecuteEvent.create("help.open"))
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.post("/open-sessions")
|
|
122
|
+
async def open_sessions(state: StateDep) -> bool:
|
|
123
|
+
"""Open the session selector."""
|
|
124
|
+
await state.broadcast_event(TuiCommandExecuteEvent.create("session.list"))
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@router.post("/open-themes")
|
|
129
|
+
async def open_themes(state: StateDep) -> bool:
|
|
130
|
+
"""Open the theme selector."""
|
|
131
|
+
await state.broadcast_event(TuiCommandExecuteEvent.create("theme.list"))
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@router.post("/open-models")
|
|
136
|
+
async def open_models(state: StateDep) -> bool:
|
|
137
|
+
"""Open the model selector."""
|
|
138
|
+
await state.broadcast_event(TuiCommandExecuteEvent.create("model.list"))
|
|
139
|
+
return True
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""OpenCode-compatible FastAPI server.
|
|
2
|
+
|
|
3
|
+
This server implements the OpenCode API endpoints to allow OpenCode SDK clients
|
|
4
|
+
to interact with AgentPool agents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, Request # noqa: TC002
|
|
14
|
+
from fastapi.encoders import jsonable_encoder
|
|
15
|
+
from fastapi.exceptions import RequestValidationError
|
|
16
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
17
|
+
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
|
18
|
+
|
|
19
|
+
from agentpool import AgentPool
|
|
20
|
+
from agentpool_server.opencode_server.routes import (
|
|
21
|
+
agent_router,
|
|
22
|
+
app_router,
|
|
23
|
+
config_router,
|
|
24
|
+
file_router,
|
|
25
|
+
global_router,
|
|
26
|
+
lsp_router,
|
|
27
|
+
message_router,
|
|
28
|
+
pty_router,
|
|
29
|
+
session_router,
|
|
30
|
+
tui_router,
|
|
31
|
+
)
|
|
32
|
+
from agentpool_server.opencode_server.state import ServerState
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OpenCodeJSONResponse(JSONResponse):
|
|
36
|
+
"""Custom JSON response that excludes None values (like OpenCode does)."""
|
|
37
|
+
|
|
38
|
+
def render(self, content: Any) -> bytes:
|
|
39
|
+
return super().render(jsonable_encoder(content, exclude_none=True))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from collections.abc import AsyncIterator, Set as AbstractSet
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
VERSION = "0.1.0"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def check_pypi_version(package: str = "agentpool") -> str | None:
|
|
50
|
+
"""Check PyPI for the latest version of a package.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
package: Package name to check
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Latest version string, or None if check fails
|
|
57
|
+
"""
|
|
58
|
+
import httpx
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
62
|
+
response = await client.get(f"https://pypi.org/pypi/{package}/json")
|
|
63
|
+
if response.status_code == 200: # noqa: PLR2004
|
|
64
|
+
data: dict[str, Any] = response.json()
|
|
65
|
+
info: dict[str, Any] = data.get("info", {})
|
|
66
|
+
version: str | None = info.get("version")
|
|
67
|
+
return version
|
|
68
|
+
except Exception: # noqa: BLE001
|
|
69
|
+
pass
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def compare_versions(current: str, latest: str) -> bool:
|
|
74
|
+
"""Check if latest version is newer than current.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
current: Current version string
|
|
78
|
+
latest: Latest version string
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
True if latest is newer than current
|
|
82
|
+
"""
|
|
83
|
+
from packaging.version import Version
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
return Version(latest) > Version(current)
|
|
87
|
+
except Exception: # noqa: BLE001
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def create_app( # noqa: PLR0915
|
|
92
|
+
*,
|
|
93
|
+
pool: AgentPool[Any],
|
|
94
|
+
agent_name: str | None = None,
|
|
95
|
+
working_dir: str | None = None,
|
|
96
|
+
) -> FastAPI:
|
|
97
|
+
"""Create the FastAPI application.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
pool: AgentPool for session persistence and agent access.
|
|
101
|
+
agent_name: Name of the agent to use for handling messages.
|
|
102
|
+
If None, uses the first agent in the pool.
|
|
103
|
+
working_dir: Working directory for file operations. Defaults to cwd.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Configured FastAPI application.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
ValueError: If specified agent_name not found or pool has no agents.
|
|
110
|
+
"""
|
|
111
|
+
# Resolve the agent from the pool
|
|
112
|
+
import logfire
|
|
113
|
+
|
|
114
|
+
if agent_name:
|
|
115
|
+
agent = pool.all_agents.get(agent_name)
|
|
116
|
+
if agent is None:
|
|
117
|
+
msg = f"Agent '{agent_name}' not found in pool"
|
|
118
|
+
raise ValueError(msg)
|
|
119
|
+
else:
|
|
120
|
+
# Use first agent as default
|
|
121
|
+
agent = next(iter(pool.all_agents.values()), None)
|
|
122
|
+
if agent is None:
|
|
123
|
+
msg = "Pool has no agents"
|
|
124
|
+
raise ValueError(msg)
|
|
125
|
+
|
|
126
|
+
state = ServerState(
|
|
127
|
+
working_dir=working_dir or str(Path.cwd()),
|
|
128
|
+
pool=pool,
|
|
129
|
+
agent=agent,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Set up todo change callback to broadcast events
|
|
133
|
+
async def on_todo_change(tracker: Any) -> None:
|
|
134
|
+
"""Broadcast todo updates to all active sessions."""
|
|
135
|
+
from agentpool_server.opencode_server.models.events import Todo, TodoUpdatedEvent
|
|
136
|
+
|
|
137
|
+
# Convert tracker entries to OpenCode Todo models
|
|
138
|
+
todos = [
|
|
139
|
+
Todo(id=e.id, content=e.content, status=e.status, priority=e.priority)
|
|
140
|
+
for e in tracker.entries
|
|
141
|
+
]
|
|
142
|
+
# Broadcast to all active sessions
|
|
143
|
+
for session_id in state.sessions:
|
|
144
|
+
event = TodoUpdatedEvent.create(session_id=session_id, todos=todos)
|
|
145
|
+
await state.broadcast_event(event)
|
|
146
|
+
|
|
147
|
+
pool.todos.on_change = on_todo_change
|
|
148
|
+
|
|
149
|
+
# Watchers for VCS and file events
|
|
150
|
+
git_branch_watcher: Any = None
|
|
151
|
+
project_file_watcher: Any = None
|
|
152
|
+
|
|
153
|
+
@asynccontextmanager
|
|
154
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
155
|
+
nonlocal git_branch_watcher, project_file_watcher
|
|
156
|
+
import logging
|
|
157
|
+
|
|
158
|
+
from watchfiles import Change
|
|
159
|
+
|
|
160
|
+
from agentpool.utils.file_watcher import FileWatcher, GitBranchWatcher
|
|
161
|
+
from agentpool_server.opencode_server.models.events import (
|
|
162
|
+
FileWatcherUpdatedEvent,
|
|
163
|
+
VcsBranchUpdatedEvent,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
log = logging.getLogger(__name__)
|
|
167
|
+
|
|
168
|
+
# --- Git branch watcher ---
|
|
169
|
+
async def on_branch_change(branch: str | None) -> None:
|
|
170
|
+
"""Broadcast branch change to all subscribers."""
|
|
171
|
+
log.info("Broadcasting vcs.branch.updated event: %s", branch)
|
|
172
|
+
event = VcsBranchUpdatedEvent.create(branch=branch)
|
|
173
|
+
await state.broadcast_event(event)
|
|
174
|
+
|
|
175
|
+
log.info("Setting up GitBranchWatcher for: %s", state.working_dir)
|
|
176
|
+
git_branch_watcher = GitBranchWatcher(
|
|
177
|
+
repo_path=state.working_dir,
|
|
178
|
+
callback=on_branch_change,
|
|
179
|
+
)
|
|
180
|
+
await git_branch_watcher.start()
|
|
181
|
+
log.info("GitBranchWatcher started, current branch: %s", git_branch_watcher.current_branch)
|
|
182
|
+
|
|
183
|
+
# --- Project file watcher ---
|
|
184
|
+
# Map watchfiles Change types to OpenCode event types
|
|
185
|
+
change_type_map: dict[Change, str] = {
|
|
186
|
+
Change.added: "add",
|
|
187
|
+
Change.modified: "change",
|
|
188
|
+
Change.deleted: "unlink",
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async def on_file_change(changes: AbstractSet[tuple[Change, str]]) -> None:
|
|
192
|
+
"""Broadcast file changes to all subscribers."""
|
|
193
|
+
for change_type, file_path in changes:
|
|
194
|
+
# Skip .git directory changes
|
|
195
|
+
if "/.git/" in file_path or file_path.endswith("/.git"):
|
|
196
|
+
continue
|
|
197
|
+
event_type = change_type_map.get(change_type, "change")
|
|
198
|
+
log.info("Broadcasting file.watcher.updated: %s %s", event_type, file_path)
|
|
199
|
+
event = FileWatcherUpdatedEvent.create(file=file_path, event=event_type) # type: ignore[arg-type]
|
|
200
|
+
await state.broadcast_event(event)
|
|
201
|
+
|
|
202
|
+
log.info("Setting up project FileWatcher for: %s", state.working_dir)
|
|
203
|
+
project_file_watcher = FileWatcher(
|
|
204
|
+
paths=[state.working_dir],
|
|
205
|
+
callback=on_file_change,
|
|
206
|
+
debounce=500, # 500ms debounce to batch rapid changes
|
|
207
|
+
)
|
|
208
|
+
await project_file_watcher.start()
|
|
209
|
+
log.info("Project FileWatcher started")
|
|
210
|
+
|
|
211
|
+
# --- Version update check (triggered when first client connects) ---
|
|
212
|
+
async def check_for_updates() -> None:
|
|
213
|
+
"""Check PyPI for updates and notify via toast."""
|
|
214
|
+
from agentpool import __version__ as current_version
|
|
215
|
+
from agentpool_server.opencode_server.models.events import TuiToastShowEvent
|
|
216
|
+
|
|
217
|
+
latest = await check_pypi_version("agentpool")
|
|
218
|
+
if latest and compare_versions(current_version, latest):
|
|
219
|
+
log.info("Update available: %s -> %s", current_version, latest)
|
|
220
|
+
event = TuiToastShowEvent.create(
|
|
221
|
+
title="Update Available",
|
|
222
|
+
message=f"agentpool {latest} is available (current: {current_version})",
|
|
223
|
+
variant="info",
|
|
224
|
+
duration=10000,
|
|
225
|
+
)
|
|
226
|
+
await state.broadcast_event(event)
|
|
227
|
+
|
|
228
|
+
# Register callback to run when first SSE client connects
|
|
229
|
+
state.on_first_subscriber = check_for_updates
|
|
230
|
+
|
|
231
|
+
yield
|
|
232
|
+
|
|
233
|
+
# Shutdown - clean up
|
|
234
|
+
pool.todos.on_change = None
|
|
235
|
+
if git_branch_watcher:
|
|
236
|
+
await git_branch_watcher.stop()
|
|
237
|
+
if project_file_watcher:
|
|
238
|
+
await project_file_watcher.stop()
|
|
239
|
+
# Clean up LSP servers
|
|
240
|
+
if state.lsp_manager is not None:
|
|
241
|
+
await state.lsp_manager.stop_all()
|
|
242
|
+
|
|
243
|
+
app = FastAPI(
|
|
244
|
+
title="OpenCode-Compatible API",
|
|
245
|
+
description="AgentPool server with OpenCode API compatibility",
|
|
246
|
+
version=VERSION,
|
|
247
|
+
lifespan=lifespan,
|
|
248
|
+
default_response_class=OpenCodeJSONResponse,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Add CORS middleware (required for OpenCode TUI)
|
|
252
|
+
app.add_middleware(
|
|
253
|
+
CORSMiddleware,
|
|
254
|
+
allow_origins=["*"],
|
|
255
|
+
allow_credentials=True,
|
|
256
|
+
allow_methods=["*"],
|
|
257
|
+
allow_headers=["*"],
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Store state on app for access in routes
|
|
261
|
+
app.state.server_state = state
|
|
262
|
+
|
|
263
|
+
@app.exception_handler(RequestValidationError)
|
|
264
|
+
async def validation_exception_handler(
|
|
265
|
+
request: Request, exc: RequestValidationError
|
|
266
|
+
) -> JSONResponse:
|
|
267
|
+
body = await request.body()
|
|
268
|
+
print(f"Validation error for {request.url}")
|
|
269
|
+
print(f"Body: {body.decode()}")
|
|
270
|
+
print(f"Errors: {exc.errors()}")
|
|
271
|
+
return JSONResponse(
|
|
272
|
+
status_code=422,
|
|
273
|
+
content={"detail": exc.errors(), "body": body.decode()},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Register routers
|
|
277
|
+
app.include_router(global_router)
|
|
278
|
+
app.include_router(app_router)
|
|
279
|
+
app.include_router(config_router)
|
|
280
|
+
app.include_router(session_router)
|
|
281
|
+
app.include_router(message_router)
|
|
282
|
+
app.include_router(file_router)
|
|
283
|
+
app.include_router(agent_router)
|
|
284
|
+
app.include_router(pty_router)
|
|
285
|
+
app.include_router(tui_router)
|
|
286
|
+
app.include_router(lsp_router)
|
|
287
|
+
|
|
288
|
+
# OpenAPI doc redirect
|
|
289
|
+
@app.get("/doc")
|
|
290
|
+
async def get_doc() -> RedirectResponse:
|
|
291
|
+
"""Redirect to OpenAPI docs."""
|
|
292
|
+
return RedirectResponse(url="/docs")
|
|
293
|
+
|
|
294
|
+
# Proxy catch-all for OpenCode's hosted web UI
|
|
295
|
+
# This must be registered LAST so it doesn't catch API routes
|
|
296
|
+
@app.api_route("/{path:path}", methods=["GET", "HEAD", "OPTIONS"])
|
|
297
|
+
async def proxy_web_ui(request: Request, path: str) -> Response:
|
|
298
|
+
"""Proxy unmatched GET requests to OpenCode's hosted web UI.
|
|
299
|
+
|
|
300
|
+
This allows users to open http://localhost:4096 in a browser and get
|
|
301
|
+
the full OpenCode web interface, which then makes API calls back to
|
|
302
|
+
this local server for all data operations.
|
|
303
|
+
"""
|
|
304
|
+
import httpx
|
|
305
|
+
|
|
306
|
+
# Build target URL
|
|
307
|
+
url = f"https://app.opencode.ai/{path}"
|
|
308
|
+
if request.url.query:
|
|
309
|
+
url += f"?{request.url.query}"
|
|
310
|
+
|
|
311
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
312
|
+
# Forward the request
|
|
313
|
+
response = await client.request(
|
|
314
|
+
method=request.method,
|
|
315
|
+
url=url,
|
|
316
|
+
headers={"host": "app.opencode.ai"},
|
|
317
|
+
follow_redirects=True,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Filter out hop-by-hop headers that shouldn't be forwarded
|
|
321
|
+
excluded_headers = {
|
|
322
|
+
"content-encoding",
|
|
323
|
+
"content-length",
|
|
324
|
+
"transfer-encoding",
|
|
325
|
+
"connection",
|
|
326
|
+
}
|
|
327
|
+
headers = {
|
|
328
|
+
k: v for k, v in response.headers.items() if k.lower() not in excluded_headers
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return Response(
|
|
332
|
+
content=response.content,
|
|
333
|
+
status_code=response.status_code,
|
|
334
|
+
headers=headers,
|
|
335
|
+
media_type=response.headers.get("content-type"),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
logfire.instrument_fastapi(app)
|
|
339
|
+
return app
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class OpenCodeServer:
|
|
343
|
+
"""OpenCode-compatible server wrapper.
|
|
344
|
+
|
|
345
|
+
Provides a convenient interface for running the server.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
def __init__(
|
|
349
|
+
self,
|
|
350
|
+
pool: AgentPool[Any],
|
|
351
|
+
*,
|
|
352
|
+
host: str = "127.0.0.1",
|
|
353
|
+
port: int = 4096,
|
|
354
|
+
agent_name: str | None = None,
|
|
355
|
+
working_dir: str | None = None,
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Initialize the server.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
pool: AgentPool for session persistence and agent access.
|
|
361
|
+
host: Host to bind to.
|
|
362
|
+
port: Port to listen on.
|
|
363
|
+
agent_name: Name of the agent to use for handling messages.
|
|
364
|
+
working_dir: Working directory for file operations.
|
|
365
|
+
"""
|
|
366
|
+
self.host = host
|
|
367
|
+
self.port = port
|
|
368
|
+
self.pool = pool
|
|
369
|
+
self.agent_name = agent_name
|
|
370
|
+
self.working_dir = working_dir
|
|
371
|
+
self._app: FastAPI | None = None
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def app(self) -> FastAPI:
|
|
375
|
+
"""Get or create the FastAPI application."""
|
|
376
|
+
if self._app is None:
|
|
377
|
+
self._app = create_app(
|
|
378
|
+
pool=self.pool,
|
|
379
|
+
agent_name=self.agent_name,
|
|
380
|
+
working_dir=self.working_dir,
|
|
381
|
+
)
|
|
382
|
+
return self._app
|
|
383
|
+
|
|
384
|
+
def run(self) -> None:
|
|
385
|
+
"""Run the server (blocking)."""
|
|
386
|
+
import uvicorn
|
|
387
|
+
|
|
388
|
+
uvicorn.run(self.app, host=self.host, port=self.port)
|
|
389
|
+
|
|
390
|
+
async def run_async(self) -> None:
|
|
391
|
+
"""Run the server asynchronously."""
|
|
392
|
+
import uvicorn
|
|
393
|
+
|
|
394
|
+
config = uvicorn.Config(self.app, host=self.host, port=self.port, ws="websockets-sansio")
|
|
395
|
+
server = uvicorn.Server(config)
|
|
396
|
+
await server.serve()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def run_server(
|
|
400
|
+
pool: AgentPool[Any],
|
|
401
|
+
*,
|
|
402
|
+
host: str = "127.0.0.1",
|
|
403
|
+
port: int = 4096,
|
|
404
|
+
agent_name: str | None = None,
|
|
405
|
+
working_dir: str | None = None,
|
|
406
|
+
) -> None:
|
|
407
|
+
"""Run the OpenCode-compatible server.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
pool: AgentPool for session persistence and agent access.
|
|
411
|
+
host: Host to bind to.
|
|
412
|
+
port: Port to listen on.
|
|
413
|
+
agent_name: Name of the agent to use for handling messages.
|
|
414
|
+
working_dir: Working directory for file operations.
|
|
415
|
+
"""
|
|
416
|
+
server = OpenCodeServer(
|
|
417
|
+
pool,
|
|
418
|
+
host=host,
|
|
419
|
+
port=port,
|
|
420
|
+
agent_name=agent_name,
|
|
421
|
+
working_dir=working_dir,
|
|
422
|
+
)
|
|
423
|
+
server.run()
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
if __name__ == "__main__":
|
|
427
|
+
from agentpool import config_resources
|
|
428
|
+
|
|
429
|
+
pool = AgentPool(config_resources.CLAUDE_CODE_ASSISTANT)
|
|
430
|
+
run_server(pool)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Server state management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Coroutine
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
import time
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
import asyncio
|
|
13
|
+
|
|
14
|
+
from agentpool import AgentPool
|
|
15
|
+
from agentpool.agents.base_agent import BaseAgent
|
|
16
|
+
from agentpool.diagnostics.lsp_manager import LSPManager
|
|
17
|
+
from agentpool_server.opencode_server.input_provider import OpenCodeInputProvider
|
|
18
|
+
from agentpool_server.opencode_server.models import (
|
|
19
|
+
Event,
|
|
20
|
+
MessageWithParts,
|
|
21
|
+
Session,
|
|
22
|
+
SessionStatus,
|
|
23
|
+
Todo,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Type alias for async callback
|
|
27
|
+
OnFirstSubscriberCallback = Callable[[], Coroutine[Any, Any, None]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ServerState:
|
|
32
|
+
"""Shared state for the OpenCode server.
|
|
33
|
+
|
|
34
|
+
Uses AgentPool for session persistence and storage.
|
|
35
|
+
In-memory state tracks active sessions and runtime data.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
working_dir: str
|
|
39
|
+
pool: AgentPool[Any]
|
|
40
|
+
agent: BaseAgent[Any, Any]
|
|
41
|
+
start_time: float = field(default_factory=time.time)
|
|
42
|
+
|
|
43
|
+
# Active sessions cache (session_id -> OpenCode Session model)
|
|
44
|
+
# This is a cache of sessions loaded from pool.sessions
|
|
45
|
+
sessions: dict[str, Session] = field(default_factory=dict)
|
|
46
|
+
session_status: dict[str, SessionStatus] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
# Message storage (session_id -> messages)
|
|
49
|
+
# Runtime cache - messages are also persisted via pool.storage
|
|
50
|
+
messages: dict[str, list[MessageWithParts]] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
# Todo storage (session_id -> todos)
|
|
53
|
+
# Uses pool.todos for persistence
|
|
54
|
+
todos: dict[str, list[Todo]] = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
# Input providers for permission handling (session_id -> provider)
|
|
57
|
+
input_providers: dict[str, OpenCodeInputProvider] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
# SSE event subscribers
|
|
60
|
+
event_subscribers: list[asyncio.Queue[Event]] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
# Callback for first subscriber connection (e.g., for update check)
|
|
63
|
+
on_first_subscriber: OnFirstSubscriberCallback | None = None
|
|
64
|
+
_first_subscriber_triggered: bool = field(default=False, repr=False)
|
|
65
|
+
|
|
66
|
+
# Background tasks (for cleanup on shutdown)
|
|
67
|
+
background_tasks: set[asyncio.Task[Any]] = field(default_factory=set)
|
|
68
|
+
|
|
69
|
+
# LSP manager for language server integration (initialized lazily)
|
|
70
|
+
lsp_manager: LSPManager | None = None
|
|
71
|
+
|
|
72
|
+
def create_background_task(self, coro: Any, *, name: str | None = None) -> asyncio.Task[Any]:
|
|
73
|
+
"""Create and track a background task."""
|
|
74
|
+
import asyncio
|
|
75
|
+
|
|
76
|
+
task = asyncio.create_task(coro, name=name)
|
|
77
|
+
self.background_tasks.add(task)
|
|
78
|
+
task.add_done_callback(self.background_tasks.discard)
|
|
79
|
+
return task
|
|
80
|
+
|
|
81
|
+
async def cleanup_tasks(self) -> None:
|
|
82
|
+
"""Cancel and wait for all background tasks."""
|
|
83
|
+
for task in self.background_tasks:
|
|
84
|
+
task.cancel()
|
|
85
|
+
if self.background_tasks:
|
|
86
|
+
import asyncio
|
|
87
|
+
|
|
88
|
+
await asyncio.gather(*self.background_tasks, return_exceptions=True)
|
|
89
|
+
self.background_tasks.clear()
|
|
90
|
+
|
|
91
|
+
async def broadcast_event(self, event: Event) -> None:
|
|
92
|
+
"""Broadcast an event to all SSE subscribers."""
|
|
93
|
+
print(f"Broadcasting event: {event.type} to {len(self.event_subscribers)} subscribers")
|
|
94
|
+
for queue in self.event_subscribers:
|
|
95
|
+
await queue.put(event)
|
|
96
|
+
|
|
97
|
+
def get_or_create_lsp_manager(self) -> LSPManager:
|
|
98
|
+
"""Get or create the LSP manager.
|
|
99
|
+
|
|
100
|
+
Creates the LSP manager lazily using the agent's execution environment.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The LSP manager instance.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
RuntimeError: If the agent doesn't have an execution environment.
|
|
107
|
+
"""
|
|
108
|
+
if self.lsp_manager is not None:
|
|
109
|
+
return self.lsp_manager
|
|
110
|
+
|
|
111
|
+
from agentpool.diagnostics.lsp_manager import LSPManager
|
|
112
|
+
|
|
113
|
+
# Get the execution environment from the agent
|
|
114
|
+
env = getattr(self.agent, "env", None)
|
|
115
|
+
if env is None:
|
|
116
|
+
msg = "Agent does not have an execution environment for LSP"
|
|
117
|
+
raise RuntimeError(msg)
|
|
118
|
+
|
|
119
|
+
self.lsp_manager = LSPManager(env=env)
|
|
120
|
+
self.lsp_manager.register_defaults()
|
|
121
|
+
return self.lsp_manager
|