coding-agent-wrapper 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.
caw/storage.py ADDED
@@ -0,0 +1,184 @@
1
+ """Session data persistence — writes raw session data to disk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from caw.models import TextBlock, ThinkingBlock, ToolUse, Trajectory, Turn
11
+
12
+
13
+ class JsonlWriter:
14
+ """Append-only JSONL writer with file locking for concurrent safety.
15
+
16
+ When *subagent* is set, every entry is tagged with ``"subagent": name``
17
+ so readers can distinguish parent vs. subagent events.
18
+ """
19
+
20
+ def __init__(self, path: str | Path, *, subagent: str | None = None) -> None:
21
+ self._path = Path(path)
22
+ self._subagent = subagent
23
+
24
+ def append(self, entry: dict[str, Any]) -> None:
25
+ """Append a single JSON object as one line (file-locked)."""
26
+ if self._subagent:
27
+ entry = {**entry, "subagent": self._subagent}
28
+ with open(self._path, "a", encoding="utf-8") as f:
29
+ fcntl.flock(f, fcntl.LOCK_EX)
30
+ f.write(json.dumps(entry) + "\n")
31
+
32
+ # ------------------------------------------------------------------
33
+ # High-level helpers
34
+ # ------------------------------------------------------------------
35
+
36
+ def write_metadata(self, trajectory: Trajectory) -> None:
37
+ """Write a metadata entry (typically once at session start)."""
38
+ self.append(
39
+ {
40
+ "type": "metadata",
41
+ "session_id": trajectory.session_id,
42
+ "created_at": trajectory.created_at,
43
+ "agent": trajectory.agent,
44
+ "model": trajectory.model,
45
+ "system_prompt": trajectory.system_prompt,
46
+ "reasoning": trajectory.reasoning,
47
+ "mcp_servers": [
48
+ {"name": s.name, "command": s.command, "args": s.args, "env": s.env, "url": s.url}
49
+ for s in trajectory.mcp_servers
50
+ ],
51
+ "metadata": trajectory.metadata,
52
+ }
53
+ )
54
+
55
+ def write_turn_events(self, turn: Turn, turn_index: int) -> None:
56
+ """Write per-event JSONL lines for a completed turn."""
57
+ # User message
58
+ self.append({"type": "user", "message": turn.input, "turn_index": turn_index})
59
+
60
+ # Content blocks — mirrors Display event order
61
+ for block in turn.output:
62
+ if isinstance(block, ThinkingBlock):
63
+ self.append(
64
+ {
65
+ "type": "thinking",
66
+ "text": block.text,
67
+ "turn_index": turn_index,
68
+ }
69
+ )
70
+ elif isinstance(block, TextBlock):
71
+ self.append(
72
+ {
73
+ "type": "text",
74
+ "text": block.text,
75
+ "turn_index": turn_index,
76
+ }
77
+ )
78
+ elif isinstance(block, ToolUse):
79
+ self.append(
80
+ {
81
+ "type": "tool_call",
82
+ "id": block.id,
83
+ "name": block.name,
84
+ "arguments": block.arguments,
85
+ "turn_index": turn_index,
86
+ }
87
+ )
88
+ result_entry: dict[str, Any] = {
89
+ "type": "tool_result",
90
+ "id": block.id,
91
+ "name": block.name,
92
+ "output": block.output,
93
+ "is_error": block.is_error,
94
+ "turn_index": turn_index,
95
+ }
96
+ if block.subagent_trajectory:
97
+ result_entry["subagent_trajectory"] = block.subagent_trajectory.to_dict()
98
+ self.append(result_entry)
99
+
100
+ # Turn-end stats
101
+ self.append(
102
+ {
103
+ "type": "turn_end",
104
+ "turn_index": turn_index,
105
+ "usage": turn.usage.to_dict(),
106
+ "duration_ms": turn.duration_ms,
107
+ }
108
+ )
109
+
110
+
111
+ class SessionStore:
112
+ """Persists session data to a directory on disk.
113
+
114
+ Layout::
115
+
116
+ <data_dir>/sessions/<session_id>/
117
+ traj.jsonl # incremental append-only event log
118
+ trajectory.json # full trajectory, overwritten after each turn
119
+ turns/
120
+ 000_input.txt
121
+ 000_raw_output.jsonl
122
+ """
123
+
124
+ def __init__(self, data_dir: str | Path, session_id: str) -> None:
125
+ self._session_dir = Path(data_dir) / "sessions" / session_id
126
+ self._turns_dir = self._session_dir / "turns"
127
+ self._turns_dir.mkdir(parents=True, exist_ok=True)
128
+ self._turn_counter = 0
129
+ self._jsonl = JsonlWriter(self._session_dir / "traj.jsonl")
130
+
131
+ @property
132
+ def session_dir(self) -> Path:
133
+ """Path to this session's directory."""
134
+ return self._session_dir
135
+
136
+ @property
137
+ def jsonl_path(self) -> Path:
138
+ """Path to the traj.jsonl file."""
139
+ return self._jsonl._path
140
+
141
+ # ------------------------------------------------------------------
142
+ # JSONL delegation
143
+ # ------------------------------------------------------------------
144
+
145
+ def write_metadata(self, trajectory: Trajectory) -> None:
146
+ """Append a metadata entry to traj.jsonl (called once at session start)."""
147
+ self._jsonl.write_metadata(trajectory)
148
+
149
+ # ------------------------------------------------------------------
150
+ # Trajectory
151
+ # ------------------------------------------------------------------
152
+
153
+ def _save_trajectory(self, trajectory: Trajectory) -> None:
154
+ """Overwrite trajectory.json with the full trajectory."""
155
+ path = self._session_dir / "trajectory.json"
156
+ path.write_text(json.dumps(trajectory.to_dict(), indent=2) + "\n", encoding="utf-8")
157
+
158
+ def finalize(self, trajectory: Trajectory) -> None:
159
+ """Write trajectory.json with the complete session record."""
160
+ self._save_trajectory(trajectory)
161
+
162
+ # ------------------------------------------------------------------
163
+ # Turn files
164
+ # ------------------------------------------------------------------
165
+
166
+ def append_turn(self, turn: Turn, trajectory: Trajectory, raw_output: str | None = None) -> None:
167
+ """Record a completed turn: event JSONL lines, trajectory snapshot, and raw files."""
168
+ prefix = f"{self._turn_counter:03d}"
169
+
170
+ # Raw turn files
171
+ input_path = self._turns_dir / f"{prefix}_input.txt"
172
+ input_path.write_text(turn.input, encoding="utf-8")
173
+
174
+ if raw_output is not None:
175
+ output_path = self._turns_dir / f"{prefix}_raw_output.jsonl"
176
+ output_path.write_text(raw_output, encoding="utf-8")
177
+
178
+ # Per-event JSONL lines
179
+ self._jsonl.write_turn_events(turn, self._turn_counter)
180
+
181
+ # Full trajectory snapshot
182
+ self._save_trajectory(trajectory)
183
+
184
+ self._turn_counter += 1
caw/toolkit.py ADDED
@@ -0,0 +1,198 @@
1
+ """ToolKit base class and @tool decorator for declarative MCP tool servers.
2
+
3
+ Usage::
4
+
5
+ from caw import Agent, ToolKit, tool
6
+
7
+ class UserDB(ToolKit, server_name="user_db", display_name="User Database"):
8
+ def __init__(self):
9
+ self.users = ["Alice", "Bob"]
10
+
11
+ @tool(description="List all users")
12
+ async def list_users(self) -> str:
13
+ return ", ".join(self.users)
14
+
15
+ db = UserDB()
16
+ agent = Agent(system_prompt="You have a user DB.", tool_servers=[db])
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import inspect
23
+ import threading
24
+ import uuid as uuid_mod
25
+ from typing import Any, ClassVar
26
+
27
+ from caw.mcp import (
28
+ Context,
29
+ MCPServerHandle,
30
+ create_mcp_http_server_bundle,
31
+ get_state_from_context,
32
+ register_tool,
33
+ )
34
+
35
+
36
+ # -- @tool decorator ----------------------------------------------------------
37
+
38
+
39
+ def tool(
40
+ name: str | None = None,
41
+ *,
42
+ description: str | None = None,
43
+ title: str | None = None,
44
+ annotations: Any | None = None,
45
+ structured_output: bool | None = None,
46
+ ):
47
+ """Mark a method as an MCP tool. Does NOT modify the function itself."""
48
+
49
+ def decorator(method):
50
+ method._toolkit_tool_info = {
51
+ "name": name,
52
+ "description": description,
53
+ "title": title,
54
+ "annotations": annotations,
55
+ "structured_output": structured_output,
56
+ }
57
+ return method
58
+
59
+ return decorator
60
+
61
+
62
+ # -- ToolKit base class -------------------------------------------------------
63
+
64
+
65
+ class ToolKit:
66
+ """Base class for declarative MCP tool servers.
67
+
68
+ Subclass, decorate methods with ``@tool``, call ``as_server()``
69
+ to get an :class:`MCPServerHandle`.
70
+ """
71
+
72
+ _server_name: ClassVar[str] = ""
73
+ _display_name: ClassVar[str] = ""
74
+ _thread_safe: ClassVar[bool] = False
75
+
76
+ def __init_subclass__(
77
+ cls,
78
+ server_name: str = "",
79
+ display_name: str = "",
80
+ thread_safe: bool = False,
81
+ **kwargs: Any,
82
+ ) -> None:
83
+ super().__init_subclass__(**kwargs)
84
+ cls._server_name = server_name or cls._server_name
85
+ cls._display_name = display_name or cls._display_name
86
+ if thread_safe:
87
+ cls._thread_safe = True
88
+
89
+ def as_server(self, server_id: str | None = None) -> MCPServerHandle:
90
+ """Build and return an :class:`MCPServerHandle` with all ``@tool`` methods registered."""
91
+ cls = type(self)
92
+ if cls._thread_safe and not hasattr(self, "_toolkit_lock"):
93
+ self._toolkit_lock = threading.Lock()
94
+ sid = server_id or f"{cls._server_name or cls.__name__}_{uuid_mod.uuid4().hex[:6]}"
95
+
96
+ handle = create_mcp_http_server_bundle(
97
+ sid,
98
+ display_name=cls._display_name or cls._server_name or cls.__name__,
99
+ state_instance=self,
100
+ )
101
+
102
+ for attr_name in dir(cls):
103
+ attr = getattr(cls, attr_name, None)
104
+ if attr is None:
105
+ continue
106
+ info = getattr(attr, "_toolkit_tool_info", None)
107
+ if info is None:
108
+ continue
109
+ wrapper = _make_tool_func(attr, info)
110
+ register_tool(handle.server, wrapper)
111
+
112
+ return handle
113
+
114
+
115
+ # -- Wrapper generator --------------------------------------------------------
116
+
117
+
118
+ def _make_tool_func(method, info: dict[str, Any]):
119
+ """Create an MCP-compatible free function from a ToolKit method.
120
+
121
+ The generated wrapper:
122
+ - Drops ``self`` from the signature.
123
+ - Appends ``ctx: Context`` (or keeps it if the user already declared it).
124
+ - At call time, retrieves the ToolKit instance from context and delegates.
125
+ """
126
+ sig = inspect.signature(method)
127
+ params = list(sig.parameters.values())
128
+
129
+ # Remove 'self'
130
+ if params and params[0].name == "self":
131
+ params = params[1:]
132
+
133
+ # Check if the user declared a 'ctx' parameter
134
+ user_has_ctx = any(p.name == "ctx" for p in params)
135
+
136
+ # Build new parameter list: all user params (minus ctx if present) + ctx at end
137
+ new_params = [p for p in params if p.name != "ctx"]
138
+ ctx_param = inspect.Parameter(
139
+ "ctx",
140
+ inspect.Parameter.KEYWORD_ONLY,
141
+ annotation=Context,
142
+ )
143
+ new_params.append(ctx_param)
144
+
145
+ new_sig = sig.replace(parameters=new_params)
146
+
147
+ is_async = asyncio.iscoroutinefunction(method)
148
+
149
+ if is_async:
150
+
151
+ async def wrapper(**kwargs):
152
+ ctx = kwargs.pop("ctx")
153
+ self_instance = get_state_from_context(ctx)
154
+ if user_has_ctx:
155
+ kwargs["ctx"] = ctx
156
+ lock = getattr(self_instance, "_toolkit_lock", None)
157
+ if lock is not None:
158
+ with lock:
159
+ return await method(self_instance, **kwargs)
160
+ return await method(self_instance, **kwargs)
161
+
162
+ else:
163
+
164
+ async def wrapper(**kwargs):
165
+ ctx = kwargs.pop("ctx")
166
+ self_instance = get_state_from_context(ctx)
167
+ if user_has_ctx:
168
+ kwargs["ctx"] = ctx
169
+ lock = getattr(self_instance, "_toolkit_lock", None)
170
+ if lock is not None:
171
+ with lock:
172
+ return method(self_instance, **kwargs)
173
+ return method(self_instance, **kwargs)
174
+
175
+ tool_name = info.get("name") or method.__name__
176
+ wrapper.__name__ = tool_name
177
+ wrapper.__qualname__ = tool_name
178
+ wrapper.__doc__ = info.get("description") or method.__doc__ or ""
179
+ wrapper.__signature__ = new_sig
180
+ # Set __annotations__ so typing.get_type_hints() can find the Context
181
+ # parameter — FastMCP uses get_type_hints (not __signature__) to detect
182
+ # which params to strip from the input schema and inject at call time.
183
+ wrapper.__annotations__ = {
184
+ p.name: p.annotation for p in new_sig.parameters.values() if p.annotation is not inspect.Parameter.empty
185
+ }
186
+ if new_sig.return_annotation is not inspect.Signature.empty:
187
+ wrapper.__annotations__["return"] = new_sig.return_annotation
188
+
189
+ # Attach _mcp_tool_info for register_tool()
190
+ wrapper._mcp_tool_info = {
191
+ "name": tool_name,
192
+ "title": info.get("title"),
193
+ "description": info.get("description") or method.__doc__ or "",
194
+ "annotations": info.get("annotations"),
195
+ "structured_output": info.get("structured_output"),
196
+ }
197
+
198
+ return wrapper
caw/viewer/__init__.py ADDED
@@ -0,0 +1,149 @@
1
+ """Trajectory viewer web interface.
2
+
3
+ Provides a simple HTTP server that serves a self-contained trajectory viewer.
4
+ The viewer loads trajectory JSON files by absolute path.
5
+
6
+ Usage::
7
+
8
+ from caw.viewer import start_viewer_server
9
+
10
+ server = start_viewer_server() # auto host/port
11
+ server = start_viewer_server(port=8080) # fixed port
12
+ print(server.url) # http://localhost:8080
13
+ server.check_status() # True / False
14
+ server.stop()
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import socket
21
+ import threading
22
+ from http.server import BaseHTTPRequestHandler, HTTPServer
23
+ from pathlib import Path
24
+ from socketserver import ThreadingMixIn
25
+ from urllib.parse import parse_qs, urlparse
26
+
27
+ __all__ = ["ViewerServer", "start_viewer_server"]
28
+
29
+ STATIC_DIR = Path(__file__).parent / "static"
30
+
31
+
32
+ def _find_free_port() -> int:
33
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
34
+ s.bind(("", 0))
35
+ return s.getsockname()[1]
36
+
37
+
38
+ class _ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
39
+ daemon_threads = True
40
+
41
+
42
+ class ViewerServer:
43
+ """Handle for a running trajectory viewer server."""
44
+
45
+ def __init__(
46
+ self,
47
+ httpd: HTTPServer,
48
+ thread: threading.Thread,
49
+ host: str,
50
+ port: int,
51
+ ) -> None:
52
+ self._httpd = httpd
53
+ self._thread = thread
54
+ self.host = host
55
+ self.port = port
56
+
57
+ @property
58
+ def url(self) -> str:
59
+ return f"http://{self.host}:{self.port}"
60
+
61
+ def check_status(self) -> bool:
62
+ """Return True if the server is running."""
63
+ return self._thread.is_alive()
64
+
65
+ def stop(self) -> None:
66
+ """Shut down the server."""
67
+ self._httpd.shutdown()
68
+ self._thread.join(timeout=5)
69
+
70
+ def __repr__(self) -> str:
71
+ status = "running" if self.check_status() else "stopped"
72
+ return f"ViewerServer({self.url}, {status})"
73
+
74
+
75
+ class _Handler(BaseHTTPRequestHandler):
76
+ def log_message(self, format, *args): # noqa: A002
77
+ pass # suppress request logging
78
+
79
+ def do_GET(self): # noqa: N802
80
+ parsed = urlparse(self.path)
81
+ path = parsed.path
82
+
83
+ if path == "/" or path == "/index.html":
84
+ self._serve_file(STATIC_DIR / "index.html", "text/html")
85
+ elif path == "/api/trajectory":
86
+ self._handle_trajectory(parse_qs(parsed.query))
87
+ else:
88
+ self.send_error(404)
89
+
90
+ def _send_json(self, data, status: int = 200):
91
+ body = json.dumps(data).encode()
92
+ self.send_response(status)
93
+ self.send_header("Content-Type", "application/json")
94
+ self.send_header("Content-Length", str(len(body)))
95
+ self.send_header("Access-Control-Allow-Origin", "*")
96
+ self.end_headers()
97
+ self.wfile.write(body)
98
+
99
+ def _serve_file(self, filepath: Path, content_type: str):
100
+ if not filepath.is_file():
101
+ self.send_error(404)
102
+ return
103
+ content = filepath.read_bytes()
104
+ self.send_response(200)
105
+ self.send_header("Content-Type", f"{content_type}; charset=utf-8")
106
+ self.send_header("Content-Length", str(len(content)))
107
+ self.end_headers()
108
+ self.wfile.write(content)
109
+
110
+ def _handle_trajectory(self, params):
111
+ paths = params.get("path", [])
112
+ if not paths:
113
+ self._send_json({"detail": "Missing 'path' parameter"}, 400)
114
+ return
115
+ filepath = Path(paths[0]).resolve()
116
+ if not filepath.is_file():
117
+ self._send_json({"detail": f"File not found: {paths[0]}"}, 404)
118
+ return
119
+ try:
120
+ raw = filepath.read_text()
121
+ data = json.loads(raw)
122
+ except json.JSONDecodeError as e:
123
+ self._send_json({"detail": f"Invalid JSON: {e}"}, 500)
124
+ return
125
+ self._send_json(data)
126
+
127
+
128
+ def start_viewer_server(
129
+ host: str | None = None,
130
+ port: int | None = None,
131
+ ) -> ViewerServer:
132
+ """Start a trajectory viewer web server.
133
+
134
+ Args:
135
+ host: Host to bind to. Defaults to ``"localhost"``.
136
+ port: Port to bind to. If *None*, a free port is chosen automatically.
137
+
138
+ Returns:
139
+ A :class:`ViewerServer` handle.
140
+ """
141
+ host = host or "localhost"
142
+ port = port or _find_free_port()
143
+
144
+ httpd = _ThreadedHTTPServer((host, port), _Handler)
145
+
146
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
147
+ thread.start()
148
+
149
+ return ViewerServer(httpd, thread, host, port)