copass-agent-router 0.5.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.
- copass_agent_router-0.5.0/.gitignore +38 -0
- copass_agent_router-0.5.0/PKG-INFO +76 -0
- copass_agent_router-0.5.0/README.md +50 -0
- copass_agent_router-0.5.0/pyproject.toml +54 -0
- copass_agent_router-0.5.0/src/copass_agent_router/__init__.py +32 -0
- copass_agent_router-0.5.0/src/copass_agent_router/connect_flow.py +195 -0
- copass_agent_router-0.5.0/src/copass_agent_router/router.py +194 -0
- copass_agent_router-0.5.0/src/copass_agent_router/sse.py +116 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
|
|
4
|
+
# Build output
|
|
5
|
+
dist/
|
|
6
|
+
*.tsbuildinfo
|
|
7
|
+
|
|
8
|
+
# Environment
|
|
9
|
+
.env
|
|
10
|
+
.env.*
|
|
11
|
+
|
|
12
|
+
# IDE
|
|
13
|
+
.vscode/
|
|
14
|
+
.idea/
|
|
15
|
+
*.swp
|
|
16
|
+
*.swo
|
|
17
|
+
*~
|
|
18
|
+
|
|
19
|
+
# OS
|
|
20
|
+
.DS_Store
|
|
21
|
+
Thumbs.db
|
|
22
|
+
|
|
23
|
+
# Test
|
|
24
|
+
coverage/
|
|
25
|
+
|
|
26
|
+
# Lerna
|
|
27
|
+
lerna-debug.log
|
|
28
|
+
.nx/cache
|
|
29
|
+
.nx/workspace-data
|
|
30
|
+
|
|
31
|
+
# Python
|
|
32
|
+
__pycache__/
|
|
33
|
+
*.pyc
|
|
34
|
+
*.pyo
|
|
35
|
+
*.egg-info/
|
|
36
|
+
.venv/
|
|
37
|
+
venv/
|
|
38
|
+
.olane
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: copass-agent-router
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: High-level Copass agent SDK — hosted agent-runtime routing + integrations + one-liner OAuth flow (Python mirror of @copass/agent-router)
|
|
5
|
+
Project-URL: Homepage, https://github.com/olane-labs/copass
|
|
6
|
+
Project-URL: Repository, https://github.com/olane-labs/copass.git
|
|
7
|
+
Author: Olane Inc.
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: agent-router,agents,copass,integrations,oauth,pipedream
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: copass-core-agents>=0.1.0
|
|
17
|
+
Requires-Dist: copass-core>=0.2.0
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# copass-agent-router
|
|
28
|
+
|
|
29
|
+
High-level Copass agent SDK. Python mirror of [`@copass/agent-router`](../../typescript/packages/agent-router) — wraps `copass-core` + `copass-core-agents` into a one-import surface that runs a full agent lifecycle: connect an integration, run an agent turn, stream events.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install copass-agent-router
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import asyncio, webbrowser
|
|
41
|
+
from copass_agent_router import AgentRouter, RunAgentOptions
|
|
42
|
+
from copass_core import ApiKeyAuth
|
|
43
|
+
|
|
44
|
+
async def main():
|
|
45
|
+
router = AgentRouter(
|
|
46
|
+
auth=ApiKeyAuth(key="olk_..."),
|
|
47
|
+
sandbox_id="sb_...",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Connect an integration (OAuth, local browser, webhook fallback via reconcile).
|
|
51
|
+
result = await router.integrations.connect(
|
|
52
|
+
"github",
|
|
53
|
+
on_connect_url=lambda url: webbrowser.open(url),
|
|
54
|
+
)
|
|
55
|
+
print("connected:", result.connection["app"], result.connection["name"])
|
|
56
|
+
|
|
57
|
+
# Run an agent.
|
|
58
|
+
async for event in router.run(RunAgentOptions(
|
|
59
|
+
provider="anthropic",
|
|
60
|
+
model="claude-opus-4-7",
|
|
61
|
+
system="You are a helpful agent.",
|
|
62
|
+
message="Summarize my latest GitHub issues.",
|
|
63
|
+
end_user_id="u-123",
|
|
64
|
+
)):
|
|
65
|
+
t = type(event).__name__
|
|
66
|
+
if t == "AgentTextDelta":
|
|
67
|
+
print(event.text, end="", flush=True)
|
|
68
|
+
elif t == "AgentFinish":
|
|
69
|
+
print(f"\n[done] {event.stop_reason}")
|
|
70
|
+
|
|
71
|
+
asyncio.run(main())
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# copass-agent-router
|
|
2
|
+
|
|
3
|
+
High-level Copass agent SDK. Python mirror of [`@copass/agent-router`](../../typescript/packages/agent-router) — wraps `copass-core` + `copass-core-agents` into a one-import surface that runs a full agent lifecycle: connect an integration, run an agent turn, stream events.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install copass-agent-router
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import asyncio, webbrowser
|
|
15
|
+
from copass_agent_router import AgentRouter, RunAgentOptions
|
|
16
|
+
from copass_core import ApiKeyAuth
|
|
17
|
+
|
|
18
|
+
async def main():
|
|
19
|
+
router = AgentRouter(
|
|
20
|
+
auth=ApiKeyAuth(key="olk_..."),
|
|
21
|
+
sandbox_id="sb_...",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Connect an integration (OAuth, local browser, webhook fallback via reconcile).
|
|
25
|
+
result = await router.integrations.connect(
|
|
26
|
+
"github",
|
|
27
|
+
on_connect_url=lambda url: webbrowser.open(url),
|
|
28
|
+
)
|
|
29
|
+
print("connected:", result.connection["app"], result.connection["name"])
|
|
30
|
+
|
|
31
|
+
# Run an agent.
|
|
32
|
+
async for event in router.run(RunAgentOptions(
|
|
33
|
+
provider="anthropic",
|
|
34
|
+
model="claude-opus-4-7",
|
|
35
|
+
system="You are a helpful agent.",
|
|
36
|
+
message="Summarize my latest GitHub issues.",
|
|
37
|
+
end_user_id="u-123",
|
|
38
|
+
)):
|
|
39
|
+
t = type(event).__name__
|
|
40
|
+
if t == "AgentTextDelta":
|
|
41
|
+
print(event.text, end="", flush=True)
|
|
42
|
+
elif t == "AgentFinish":
|
|
43
|
+
print(f"\n[done] {event.stop_reason}")
|
|
44
|
+
|
|
45
|
+
asyncio.run(main())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "copass-agent-router"
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
description = "High-level Copass agent SDK — hosted agent-runtime routing + integrations + one-liner OAuth flow (Python mirror of @copass/agent-router)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Olane Inc." }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
keywords = ["copass", "agents", "agent-router", "oauth", "integrations", "pipedream"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"copass-core>=0.2.0",
|
|
23
|
+
"copass-core-agents>=0.1.0",
|
|
24
|
+
"httpx>=0.27",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8.0",
|
|
30
|
+
"pytest-asyncio>=0.23",
|
|
31
|
+
"respx>=0.21",
|
|
32
|
+
"mypy>=1.10",
|
|
33
|
+
"ruff>=0.5",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/olane-labs/copass"
|
|
38
|
+
Repository = "https://github.com/olane-labs/copass.git"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/copass_agent_router"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 100
|
|
49
|
+
target-version = "py310"
|
|
50
|
+
|
|
51
|
+
[tool.mypy]
|
|
52
|
+
python_version = "3.10"
|
|
53
|
+
strict = true
|
|
54
|
+
packages = ["copass_agent_router"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""High-level Copass agent SDK.
|
|
2
|
+
|
|
3
|
+
Mirror of ``@copass/agent-router`` on the TypeScript side. Wraps
|
|
4
|
+
``copass-core`` and the provider-neutral agent events so one import
|
|
5
|
+
runs the full lifecycle: connect an integration, start an agent turn,
|
|
6
|
+
stream events.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from copass_agent_router.router import AgentRouter, IntegrationsFacade, RunAgentOptions
|
|
10
|
+
from copass_agent_router.connect_flow import ConnectFlowResult, run_connect_flow
|
|
11
|
+
from copass_core_agents.events import (
|
|
12
|
+
AgentEvent,
|
|
13
|
+
AgentFinish,
|
|
14
|
+
AgentTextDelta,
|
|
15
|
+
AgentToolCall,
|
|
16
|
+
AgentToolResult,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AgentRouter",
|
|
21
|
+
"IntegrationsFacade",
|
|
22
|
+
"RunAgentOptions",
|
|
23
|
+
"ConnectFlowResult",
|
|
24
|
+
"run_connect_flow",
|
|
25
|
+
"AgentEvent",
|
|
26
|
+
"AgentTextDelta",
|
|
27
|
+
"AgentToolCall",
|
|
28
|
+
"AgentToolResult",
|
|
29
|
+
"AgentFinish",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
__version__ = "0.5.0"
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""End-to-end OAuth connect flow helper.
|
|
2
|
+
|
|
3
|
+
Mirror of the TS ``runConnectFlow``. Uses Python's ``http.server`` for
|
|
4
|
+
the short-lived success/error redirect listener and polls
|
|
5
|
+
``integrations.reconcile`` until the DataSource lands.
|
|
6
|
+
|
|
7
|
+
Server-side / CLI flows use this; webapp flows typically skip the
|
|
8
|
+
listener and handle redirects in the browser directly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import http.server
|
|
15
|
+
import logging
|
|
16
|
+
import socket
|
|
17
|
+
import threading
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Awaitable, Callable, Optional, Union
|
|
20
|
+
|
|
21
|
+
from copass_core import CopassClient
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
OnConnectUrl = Callable[[str], Union[None, Awaitable[None]]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ConnectFlowResult:
|
|
31
|
+
connection: dict
|
|
32
|
+
session_id: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _RedirectListener:
|
|
36
|
+
"""One-shot HTTP listener on ``127.0.0.1:<random>`` capturing
|
|
37
|
+
``/oauth/success`` or ``/oauth/error``."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._outcome: Optional[str] = None
|
|
41
|
+
self._event = threading.Event()
|
|
42
|
+
self._server: Optional[http.server.HTTPServer] = None
|
|
43
|
+
self._thread: Optional[threading.Thread] = None
|
|
44
|
+
|
|
45
|
+
def start(self) -> tuple[str, str]:
|
|
46
|
+
outer = self
|
|
47
|
+
|
|
48
|
+
class _Handler(http.server.BaseHTTPRequestHandler):
|
|
49
|
+
def do_GET(self) -> None: # noqa: N802
|
|
50
|
+
path = (self.path or "/").split("?")[0]
|
|
51
|
+
if path == "/oauth/success":
|
|
52
|
+
outer._outcome = "success"
|
|
53
|
+
body = (
|
|
54
|
+
"<html><body style='font-family:sans-serif;padding:3rem'>"
|
|
55
|
+
"<h2>\u2713 Connection complete</h2>"
|
|
56
|
+
"<p>You can close this tab.</p></body></html>"
|
|
57
|
+
)
|
|
58
|
+
elif path == "/oauth/error":
|
|
59
|
+
outer._outcome = "error"
|
|
60
|
+
body = (
|
|
61
|
+
"<html><body style='font-family:sans-serif;padding:3rem'>"
|
|
62
|
+
"<h2>\u2717 Connection failed</h2>"
|
|
63
|
+
"<p>You can close this tab and retry.</p></body></html>"
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
self.send_response(404)
|
|
67
|
+
self.end_headers()
|
|
68
|
+
return
|
|
69
|
+
self.send_response(200)
|
|
70
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
71
|
+
self.end_headers()
|
|
72
|
+
self.wfile.write(body.encode("utf-8"))
|
|
73
|
+
outer._event.set()
|
|
74
|
+
|
|
75
|
+
def log_message(self, *_args: Any) -> None: # silence stdlib stdout
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
79
|
+
sock.bind(("127.0.0.1", 0))
|
|
80
|
+
port = sock.getsockname()[1]
|
|
81
|
+
sock.close()
|
|
82
|
+
self._server = http.server.HTTPServer(("127.0.0.1", port), _Handler)
|
|
83
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
84
|
+
self._thread.start()
|
|
85
|
+
base = f"http://127.0.0.1:{port}"
|
|
86
|
+
return f"{base}/oauth/success", f"{base}/oauth/error"
|
|
87
|
+
|
|
88
|
+
async def wait(self, timeout_seconds: float) -> Optional[str]:
|
|
89
|
+
"""Return 'success' | 'error' | None (timeout)."""
|
|
90
|
+
loop = asyncio.get_event_loop()
|
|
91
|
+
await loop.run_in_executor(None, self._event.wait, timeout_seconds)
|
|
92
|
+
return self._outcome
|
|
93
|
+
|
|
94
|
+
def close(self) -> None:
|
|
95
|
+
if self._server is not None:
|
|
96
|
+
try:
|
|
97
|
+
self._server.shutdown()
|
|
98
|
+
self._server.server_close()
|
|
99
|
+
except Exception: # pragma: no cover
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def run_connect_flow(
|
|
104
|
+
client: CopassClient,
|
|
105
|
+
sandbox_id: str,
|
|
106
|
+
*,
|
|
107
|
+
app: str,
|
|
108
|
+
on_connect_url: OnConnectUrl,
|
|
109
|
+
scope: str = "user",
|
|
110
|
+
project_id: Optional[str] = None,
|
|
111
|
+
timeout_seconds: float = 300.0,
|
|
112
|
+
success_uri: Optional[str] = None,
|
|
113
|
+
error_uri: Optional[str] = None,
|
|
114
|
+
) -> ConnectFlowResult:
|
|
115
|
+
"""Run the Copass integrations connect flow end-to-end.
|
|
116
|
+
|
|
117
|
+
1. Snapshots current connections for diff detection.
|
|
118
|
+
2. Starts a localhost listener for the success redirect (unless
|
|
119
|
+
caller supplied ``success_uri`` / ``error_uri``).
|
|
120
|
+
3. Mints a Connect URL via :meth:`IntegrationsResource.connect`.
|
|
121
|
+
4. Calls ``on_connect_url(url)`` — typically opens the browser.
|
|
122
|
+
5. Polls ``integrations.reconcile`` every 2s until a new connection
|
|
123
|
+
not in the pre-snapshot appears, OR the listener fires error, OR
|
|
124
|
+
the timeout elapses.
|
|
125
|
+
"""
|
|
126
|
+
# Snapshot
|
|
127
|
+
try:
|
|
128
|
+
existing = await client.integrations.list(sandbox_id, app=app)
|
|
129
|
+
known_before = {c["source_id"] for c in existing.get("items", [])}
|
|
130
|
+
except Exception: # pragma: no cover — non-fatal
|
|
131
|
+
known_before = set()
|
|
132
|
+
|
|
133
|
+
listener: Optional[_RedirectListener] = None
|
|
134
|
+
if success_uri is None or error_uri is None:
|
|
135
|
+
listener = _RedirectListener()
|
|
136
|
+
s, e = listener.start()
|
|
137
|
+
success_uri = success_uri or s
|
|
138
|
+
error_uri = error_uri or e
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
resp = await client.integrations.connect(
|
|
142
|
+
sandbox_id,
|
|
143
|
+
app=app,
|
|
144
|
+
scope=scope,
|
|
145
|
+
success_redirect_uri=success_uri,
|
|
146
|
+
error_redirect_uri=error_uri,
|
|
147
|
+
project_id=project_id,
|
|
148
|
+
)
|
|
149
|
+
session_id = str(resp["session_id"])
|
|
150
|
+
connect_url = str(resp["connect_url"])
|
|
151
|
+
result = on_connect_url(connect_url)
|
|
152
|
+
if asyncio.iscoroutine(result):
|
|
153
|
+
await result
|
|
154
|
+
|
|
155
|
+
# Poll reconcile until a new connection appears or the listener
|
|
156
|
+
# signals 'error' or the timeout elapses.
|
|
157
|
+
deadline = asyncio.get_event_loop().time() + timeout_seconds
|
|
158
|
+
redirect_task: Optional[asyncio.Task[Optional[str]]] = (
|
|
159
|
+
asyncio.create_task(listener.wait(timeout_seconds)) if listener else None
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
163
|
+
try:
|
|
164
|
+
r = await client.integrations.reconcile(
|
|
165
|
+
sandbox_id, app=app, scope=scope
|
|
166
|
+
)
|
|
167
|
+
for c in r.get("connections", []):
|
|
168
|
+
if c["source_id"] not in known_before:
|
|
169
|
+
if redirect_task is not None:
|
|
170
|
+
redirect_task.cancel()
|
|
171
|
+
return ConnectFlowResult(
|
|
172
|
+
connection=dict(c), session_id=session_id
|
|
173
|
+
)
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
if redirect_task is not None and redirect_task.done():
|
|
178
|
+
outcome = redirect_task.result()
|
|
179
|
+
if outcome == "error":
|
|
180
|
+
raise RuntimeError(
|
|
181
|
+
"User denied the authorization or provider returned an error."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
await asyncio.sleep(2.0)
|
|
185
|
+
|
|
186
|
+
raise TimeoutError(
|
|
187
|
+
f"Timed out after {int(timeout_seconds)}s — the connection may still "
|
|
188
|
+
"land. Check `client.integrations.list(sandbox_id)` or run reconcile."
|
|
189
|
+
)
|
|
190
|
+
finally:
|
|
191
|
+
if listener is not None:
|
|
192
|
+
listener.close()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
__all__ = ["run_connect_flow", "ConnectFlowResult", "OnConnectUrl"]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""``AgentRouter`` — high-level Copass agent SDK.
|
|
2
|
+
|
|
3
|
+
Mirrors ``@copass/agent-router`` on the TS side. Hides SSE parsing and
|
|
4
|
+
OAuth connect-flow orchestration behind a :class:`CopassClient` built
|
|
5
|
+
from the caller's auth.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, AsyncIterator, List, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from copass_core import CopassClient
|
|
16
|
+
from copass_core.client import AuthConfig
|
|
17
|
+
from copass_core_agents.events import AgentEvent
|
|
18
|
+
|
|
19
|
+
from copass_agent_router.connect_flow import (
|
|
20
|
+
ConnectFlowResult,
|
|
21
|
+
OnConnectUrl,
|
|
22
|
+
run_connect_flow,
|
|
23
|
+
)
|
|
24
|
+
from copass_agent_router.sse import frame_to_agent_event, iterate_sse_frames
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_API_URL = "https://ai.copass.id"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class RunAgentOptions:
|
|
32
|
+
"""Options for :meth:`AgentRouter.run`."""
|
|
33
|
+
|
|
34
|
+
provider: str
|
|
35
|
+
model: str
|
|
36
|
+
system: str
|
|
37
|
+
end_user_id: str
|
|
38
|
+
message: Optional[str] = None
|
|
39
|
+
messages: Optional[List[dict]] = None
|
|
40
|
+
session_id: Optional[str] = None
|
|
41
|
+
reasoning_engine_id: Optional[str] = None
|
|
42
|
+
location: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class IntegrationsFacade:
|
|
46
|
+
"""Provider-neutral integrations surface with flow helpers."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, client: CopassClient, default_sandbox_id: str) -> None:
|
|
49
|
+
self._client = client
|
|
50
|
+
self._default_sandbox_id = default_sandbox_id
|
|
51
|
+
|
|
52
|
+
def _sb(self, sandbox_id: Optional[str]) -> str:
|
|
53
|
+
sid = sandbox_id or self._default_sandbox_id
|
|
54
|
+
if not sid:
|
|
55
|
+
raise ValueError("sandbox_id is required (no default on AgentRouter).")
|
|
56
|
+
return sid
|
|
57
|
+
|
|
58
|
+
async def catalog(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
q: Optional[str] = None,
|
|
62
|
+
limit: Optional[int] = None,
|
|
63
|
+
cursor: Optional[str] = None,
|
|
64
|
+
sandbox_id: Optional[str] = None,
|
|
65
|
+
) -> dict:
|
|
66
|
+
return await self._client.integrations.catalog(
|
|
67
|
+
self._sb(sandbox_id), q=q, limit=limit, cursor=cursor
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def connect(
|
|
71
|
+
self,
|
|
72
|
+
app: str,
|
|
73
|
+
*,
|
|
74
|
+
on_connect_url: OnConnectUrl,
|
|
75
|
+
scope: str = "user",
|
|
76
|
+
project_id: Optional[str] = None,
|
|
77
|
+
timeout_seconds: float = 300.0,
|
|
78
|
+
success_uri: Optional[str] = None,
|
|
79
|
+
error_uri: Optional[str] = None,
|
|
80
|
+
sandbox_id: Optional[str] = None,
|
|
81
|
+
) -> ConnectFlowResult:
|
|
82
|
+
return await run_connect_flow(
|
|
83
|
+
self._client,
|
|
84
|
+
self._sb(sandbox_id),
|
|
85
|
+
app=app,
|
|
86
|
+
on_connect_url=on_connect_url,
|
|
87
|
+
scope=scope,
|
|
88
|
+
project_id=project_id,
|
|
89
|
+
timeout_seconds=timeout_seconds,
|
|
90
|
+
success_uri=success_uri,
|
|
91
|
+
error_uri=error_uri,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def list(
|
|
95
|
+
self, *, app: Optional[str] = None, sandbox_id: Optional[str] = None
|
|
96
|
+
) -> dict:
|
|
97
|
+
return await self._client.integrations.list(self._sb(sandbox_id), app=app)
|
|
98
|
+
|
|
99
|
+
async def disconnect(
|
|
100
|
+
self, source_id: str, *, sandbox_id: Optional[str] = None
|
|
101
|
+
) -> None:
|
|
102
|
+
await self._client.integrations.disconnect(self._sb(sandbox_id), source_id)
|
|
103
|
+
|
|
104
|
+
async def reconcile(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
app: Optional[str] = None,
|
|
108
|
+
scope: str = "user",
|
|
109
|
+
project_id: Optional[str] = None,
|
|
110
|
+
sandbox_id: Optional[str] = None,
|
|
111
|
+
) -> dict:
|
|
112
|
+
return await self._client.integrations.reconcile(
|
|
113
|
+
self._sb(sandbox_id), app=app, scope=scope, project_id=project_id
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AgentRouter:
|
|
118
|
+
"""Top-level agent router SDK."""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
*,
|
|
123
|
+
auth: AuthConfig,
|
|
124
|
+
sandbox_id: str,
|
|
125
|
+
api_url: str = DEFAULT_API_URL,
|
|
126
|
+
client: Optional[CopassClient] = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
self.client = client or CopassClient(auth=auth, api_url=api_url)
|
|
129
|
+
self._api_url = api_url
|
|
130
|
+
self._default_sandbox_id = sandbox_id
|
|
131
|
+
self.integrations = IntegrationsFacade(self.client, sandbox_id)
|
|
132
|
+
|
|
133
|
+
async def run(
|
|
134
|
+
self,
|
|
135
|
+
options: RunAgentOptions,
|
|
136
|
+
*,
|
|
137
|
+
sandbox_id: Optional[str] = None,
|
|
138
|
+
) -> AsyncIterator[AgentEvent]:
|
|
139
|
+
"""Run an agent turn and yield neutral :class:`AgentEvent` values."""
|
|
140
|
+
sb = sandbox_id or self._default_sandbox_id
|
|
141
|
+
if not sb:
|
|
142
|
+
raise ValueError("sandbox_id is required.")
|
|
143
|
+
|
|
144
|
+
messages = options.messages
|
|
145
|
+
if messages is None:
|
|
146
|
+
if not options.message:
|
|
147
|
+
raise ValueError("Either `message` or `messages` must be supplied.")
|
|
148
|
+
messages = [{"role": "user", "content": options.message}]
|
|
149
|
+
|
|
150
|
+
body: dict[str, Any] = {
|
|
151
|
+
"provider": options.provider,
|
|
152
|
+
"model": options.model,
|
|
153
|
+
"system_prompt": options.system,
|
|
154
|
+
"messages": messages,
|
|
155
|
+
"end_user_id": options.end_user_id,
|
|
156
|
+
}
|
|
157
|
+
if options.session_id is not None:
|
|
158
|
+
body["session_id"] = options.session_id
|
|
159
|
+
if options.reasoning_engine_id is not None:
|
|
160
|
+
body["reasoning_engine_id"] = options.reasoning_engine_id
|
|
161
|
+
if options.location is not None:
|
|
162
|
+
body["location"] = options.location
|
|
163
|
+
|
|
164
|
+
# Resolve auth via the client's auth provider so headers match.
|
|
165
|
+
auth_provider = getattr(self.client, "_auth_provider", None)
|
|
166
|
+
session = (
|
|
167
|
+
await auth_provider.get_session() if auth_provider is not None else None
|
|
168
|
+
)
|
|
169
|
+
headers = {
|
|
170
|
+
"content-type": "application/json",
|
|
171
|
+
"accept": "text/event-stream",
|
|
172
|
+
}
|
|
173
|
+
token = getattr(session, "access_token", None) if session else None
|
|
174
|
+
if token:
|
|
175
|
+
headers["authorization"] = f"Bearer {token}"
|
|
176
|
+
|
|
177
|
+
url = (
|
|
178
|
+
f"{self._api_url.rstrip('/')}"
|
|
179
|
+
f"/api/v1/storage/sandboxes/{sb}/agents/run"
|
|
180
|
+
)
|
|
181
|
+
async with httpx.AsyncClient(timeout=None) as http:
|
|
182
|
+
async with http.stream("POST", url, headers=headers, json=body) as resp:
|
|
183
|
+
if resp.status_code >= 400:
|
|
184
|
+
text = (await resp.aread()).decode("utf-8", errors="replace")
|
|
185
|
+
raise RuntimeError(
|
|
186
|
+
f"agents.run: HTTP {resp.status_code} {text[:300]}"
|
|
187
|
+
)
|
|
188
|
+
async for frame in iterate_sse_frames(resp):
|
|
189
|
+
event = frame_to_agent_event(frame)
|
|
190
|
+
if event is not None:
|
|
191
|
+
yield event
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
__all__ = ["AgentRouter", "IntegrationsFacade", "RunAgentOptions"]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Minimal SSE parser for Copass's agent-run endpoint.
|
|
2
|
+
|
|
3
|
+
Translates raw SSE frames into neutral :class:`AgentEvent` values from
|
|
4
|
+
``copass-core-agents``. Handles CRLF/LF line endings, multi-line
|
|
5
|
+
``data:`` fields, and comment/id/retry skipping.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import AsyncIterator, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from copass_core_agents.events import (
|
|
17
|
+
AgentEvent,
|
|
18
|
+
AgentFinish,
|
|
19
|
+
AgentTextDelta,
|
|
20
|
+
AgentToolCall,
|
|
21
|
+
AgentToolResult,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class RawSseFrame:
|
|
27
|
+
event: str
|
|
28
|
+
data: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_block(block: str) -> Optional[RawSseFrame]:
|
|
32
|
+
event = "message"
|
|
33
|
+
data_lines: list[str] = []
|
|
34
|
+
for raw in block.split("\n"):
|
|
35
|
+
line = raw.rstrip("\r")
|
|
36
|
+
if not line or line.startswith(":"):
|
|
37
|
+
continue
|
|
38
|
+
colon = line.find(":")
|
|
39
|
+
if colon < 0:
|
|
40
|
+
continue
|
|
41
|
+
field = line[:colon]
|
|
42
|
+
value = line[colon + 1 :]
|
|
43
|
+
if value.startswith(" "):
|
|
44
|
+
value = value[1:]
|
|
45
|
+
if field == "event":
|
|
46
|
+
event = value
|
|
47
|
+
elif field == "data":
|
|
48
|
+
data_lines.append(value)
|
|
49
|
+
if not data_lines:
|
|
50
|
+
return None
|
|
51
|
+
return RawSseFrame(event=event, data="\n".join(data_lines))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def iterate_sse_frames(response: httpx.Response) -> AsyncIterator[RawSseFrame]:
|
|
55
|
+
"""Async-iterate SSE frames off an ``httpx`` streaming Response.
|
|
56
|
+
|
|
57
|
+
Caller is expected to have opened the response with ``stream=True``
|
|
58
|
+
(i.e. via ``client.stream(...)``). We don't read the full body in
|
|
59
|
+
one shot — each frame is yielded as it arrives.
|
|
60
|
+
"""
|
|
61
|
+
buffer = ""
|
|
62
|
+
async for chunk in response.aiter_text():
|
|
63
|
+
buffer += chunk
|
|
64
|
+
buffer = buffer.replace("\r\n", "\n")
|
|
65
|
+
while True:
|
|
66
|
+
sep = buffer.find("\n\n")
|
|
67
|
+
if sep < 0:
|
|
68
|
+
break
|
|
69
|
+
block = buffer[:sep]
|
|
70
|
+
buffer = buffer[sep + 2 :]
|
|
71
|
+
frame = _parse_block(block)
|
|
72
|
+
if frame is not None:
|
|
73
|
+
yield frame
|
|
74
|
+
tail = buffer.strip()
|
|
75
|
+
if tail:
|
|
76
|
+
frame = _parse_block(tail)
|
|
77
|
+
if frame is not None:
|
|
78
|
+
yield frame
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def frame_to_agent_event(frame: RawSseFrame) -> Optional[AgentEvent]:
|
|
82
|
+
"""Translate a Copass SSE frame into a neutral :class:`AgentEvent`.
|
|
83
|
+
|
|
84
|
+
Returns ``None`` for unrecognized event names or malformed JSON.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
payload = json.loads(frame.data)
|
|
88
|
+
except (ValueError, TypeError):
|
|
89
|
+
return None
|
|
90
|
+
if not isinstance(payload, dict):
|
|
91
|
+
return None
|
|
92
|
+
if frame.event == "agent_text_delta":
|
|
93
|
+
return AgentTextDelta(text=str(payload.get("text", "")))
|
|
94
|
+
if frame.event == "agent_tool_call":
|
|
95
|
+
return AgentToolCall(
|
|
96
|
+
call_id=str(payload.get("call_id", "")),
|
|
97
|
+
name=str(payload.get("name", "")),
|
|
98
|
+
arguments=dict(payload.get("arguments") or {}),
|
|
99
|
+
)
|
|
100
|
+
if frame.event == "agent_tool_result":
|
|
101
|
+
return AgentToolResult(
|
|
102
|
+
call_id=str(payload.get("call_id", "")),
|
|
103
|
+
name=str(payload.get("name", "")),
|
|
104
|
+
result=dict(payload.get("result") or {}),
|
|
105
|
+
error=(str(payload["error"]) if payload.get("error") else None),
|
|
106
|
+
)
|
|
107
|
+
if frame.event == "agent_finish":
|
|
108
|
+
return AgentFinish(
|
|
109
|
+
stop_reason=str(payload.get("stop_reason", "unknown")),
|
|
110
|
+
session_id=(payload.get("session_id") or None),
|
|
111
|
+
usage=dict(payload.get("usage") or {}),
|
|
112
|
+
)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__all__ = ["RawSseFrame", "iterate_sse_frames", "frame_to_agent_event"]
|