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