lionagi 0.14.4__py3-none-any.whl → 0.14.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,235 @@
1
+ # Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+
11
+ from pydantic import BaseModel, Field, field_validator, model_validator
12
+
13
+ from lionagi.utils import is_import_installed
14
+
15
+ HAS_CLAUDE_CODE_SDK = is_import_installed("claude_code_sdk")
16
+
17
+ # --------------------------------------------------------------------------- constants
18
+ ClaudePermission = Literal[
19
+ "default",
20
+ "acceptEdits",
21
+ "bypassPermissions",
22
+ "dangerously-skip-permissions",
23
+ ]
24
+
25
+ CLAUDE_CODE_OPTION_PARAMS = {
26
+ "allowed_tools",
27
+ "max_thinking_tokens",
28
+ "mcp_tools",
29
+ "mcp_servers",
30
+ "permission_mode",
31
+ "continue_conversation",
32
+ "resume",
33
+ "max_turns",
34
+ "disallowed_tools",
35
+ "model",
36
+ "permission_prompt_tool_name",
37
+ "cwd",
38
+ "system_prompt",
39
+ "append_system_prompt",
40
+ }
41
+
42
+
43
+ # --------------------------------------------------------------------------- request model
44
+ class ClaudeCodeRequest(BaseModel):
45
+ # -- conversational bits -------------------------------------------------
46
+ prompt: str = Field(description="The prompt for Claude Code")
47
+ system_prompt: str | None = None
48
+ append_system_prompt: str | None = None
49
+ max_turns: int | None = None
50
+ continue_conversation: bool = False
51
+ resume: str | None = None
52
+
53
+ # -- repo / workspace ----------------------------------------------------
54
+ repo: Path = Field(default_factory=Path.cwd, exclude=True)
55
+ ws: str | None = None # sub-directory under repo
56
+ add_dir: str | None = None # extra read-only mount
57
+ allowed_tools: list[str] | None = None
58
+
59
+ # -- runtime & safety ----------------------------------------------------
60
+ model: Literal["sonnet", "opus"] | str | None = "sonnet"
61
+ max_thinking_tokens: int | None = None
62
+ mcp_tools: list[str] = Field(default_factory=list)
63
+ mcp_servers: dict[str, Any] = Field(default_factory=dict)
64
+ permission_mode: ClaudePermission | None = None
65
+ permission_prompt_tool_name: str | None = None
66
+ disallowed_tools: list[str] = Field(default_factory=list)
67
+
68
+ # -- internal use --------------------------------------------------------
69
+ auto_finish: bool = Field(
70
+ default=False,
71
+ exclude=True,
72
+ description="Automatically finish the conversation after the first response",
73
+ )
74
+ verbose_output: bool = Field(default=False, exclude=True)
75
+ cli_display_theme: Literal["light", "dark"] = "light"
76
+
77
+ # ------------------------ validators & helpers --------------------------
78
+ @field_validator("permission_mode", mode="before")
79
+ def _norm_perm(cls, v):
80
+ if v in {
81
+ "dangerously-skip-permissions",
82
+ "--dangerously-skip-permissions",
83
+ }:
84
+ return "bypassPermissions"
85
+ return v
86
+
87
+ # Workspace path derived from repo + ws
88
+ def cwd(self) -> Path:
89
+ if not self.ws:
90
+ return self.repo
91
+
92
+ # Convert to Path object for proper validation
93
+ ws_path = Path(self.ws)
94
+
95
+ # Check for absolute paths or directory traversal attempts
96
+ if ws_path.is_absolute():
97
+ raise ValueError(
98
+ f"Workspace path must be relative, got absolute: {self.ws}"
99
+ )
100
+
101
+ if ".." in ws_path.parts:
102
+ raise ValueError(
103
+ f"Directory traversal detected in workspace path: {self.ws}"
104
+ )
105
+
106
+ # Resolve paths to handle symlinks and normalize
107
+ repo_resolved = self.repo.resolve()
108
+ result = (self.repo / ws_path).resolve()
109
+
110
+ # Ensure the resolved path is within the repository bounds
111
+ try:
112
+ result.relative_to(repo_resolved)
113
+ except ValueError:
114
+ raise ValueError(
115
+ f"Workspace path escapes repository bounds. "
116
+ f"Repository: {repo_resolved}, Workspace: {result}"
117
+ )
118
+
119
+ return result
120
+
121
+ @model_validator(mode="after")
122
+ def _check_perm_workspace(self):
123
+ if self.permission_mode == "bypassPermissions":
124
+ # Use secure path validation with resolved paths
125
+ repo_resolved = self.repo.resolve()
126
+ cwd_resolved = self.cwd().resolve()
127
+
128
+ # Check if cwd is within repo bounds using proper path methods
129
+ try:
130
+ cwd_resolved.relative_to(repo_resolved)
131
+ except ValueError:
132
+ raise ValueError(
133
+ f"With bypassPermissions, workspace must be within repository bounds. "
134
+ f"Repository: {repo_resolved}, Workspace: {cwd_resolved}"
135
+ )
136
+ return self
137
+
138
+ # ------------------------ CLI helpers -----------------------------------
139
+ def as_cmd_args(self) -> list[str]:
140
+ """Build argument list for the *Node* `claude` CLI."""
141
+ args: list[str] = ["-p", self.prompt, "--output-format", "stream-json"]
142
+ if self.allowed_tools:
143
+ args.append("--allowedTools")
144
+ for tool in self.allowed_tools:
145
+ args.append(f'"{tool}"')
146
+
147
+ if self.disallowed_tools:
148
+ args.append("--disallowedTools")
149
+ for tool in self.disallowed_tools:
150
+ args.append(f'"{tool}"')
151
+
152
+ if self.resume:
153
+ args += ["--resume", self.resume]
154
+ elif self.continue_conversation:
155
+ args.append("--continue")
156
+
157
+ if self.max_turns:
158
+ # +1 because CLI counts *pairs*
159
+ args += ["--max-turns", str(self.max_turns + 1)]
160
+
161
+ if self.permission_mode == "bypassPermissions":
162
+ args += ["--dangerously-skip-permissions"]
163
+
164
+ if self.add_dir:
165
+ args += ["--add-dir", self.add_dir]
166
+
167
+ args += ["--model", self.model or "sonnet", "--verbose"]
168
+ return args
169
+
170
+ # ------------------------ SDK helpers -----------------------------------
171
+ def as_claude_options(self):
172
+ from claude_code_sdk import ClaudeCodeOptions
173
+
174
+ data = {
175
+ k: v
176
+ for k, v in self.model_dump(exclude_none=True).items()
177
+ if k in CLAUDE_CODE_OPTION_PARAMS
178
+ }
179
+ return ClaudeCodeOptions(**data)
180
+
181
+ # ------------------------ convenience constructor -----------------------
182
+ @classmethod
183
+ def create(
184
+ cls,
185
+ messages: list[dict[str, Any]],
186
+ resume: str | None = None,
187
+ continue_conversation: bool | None = None,
188
+ **kwargs,
189
+ ):
190
+ if not messages:
191
+ raise ValueError("messages may not be empty")
192
+
193
+ prompt = ""
194
+
195
+ # 1. if resume or continue_conversation, use the last message
196
+ if resume or continue_conversation:
197
+ continue_conversation = True
198
+ prompt = messages[-1]["content"]
199
+ if isinstance(prompt, (dict, list)):
200
+ prompt = json.dumps(prompt)
201
+
202
+ # 2. else, use entire messages except system message
203
+ else:
204
+ prompts = []
205
+ continue_conversation = False
206
+ for message in messages:
207
+ if message["role"] != "system":
208
+ content = message["content"]
209
+ prompts.append(
210
+ json.dumps(content)
211
+ if isinstance(content, (dict, list))
212
+ else content
213
+ )
214
+
215
+ prompt = "\n".join(prompts)
216
+
217
+ # 3. assemble the request data
218
+ data: dict[str, Any] = dict(
219
+ prompt=prompt,
220
+ resume=resume,
221
+ continue_conversation=bool(continue_conversation),
222
+ )
223
+
224
+ # 4. extract system prompt if available
225
+ if (messages[0]["role"] == "system") and (
226
+ resume or continue_conversation
227
+ ):
228
+ data["system_prompt"] = messages[0]["content"]
229
+ if kwargs.get("append_system_prompt"):
230
+ data["append_system_prompt"] = str(
231
+ kwargs.get("append_system_prompt")
232
+ )
233
+
234
+ data.update(kwargs)
235
+ return cls.model_validate(data, strict=False)
@@ -0,0 +1,350 @@
1
+ # Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import codecs
9
+ import contextlib
10
+ import dataclasses
11
+ import json
12
+ import logging
13
+ import shutil
14
+ from collections.abc import AsyncIterator, Callable
15
+ from datetime import datetime
16
+ from functools import partial
17
+ from textwrap import shorten
18
+ from typing import Any
19
+
20
+ from json_repair import repair_json
21
+
22
+ from lionagi.libs.schema.as_readable import as_readable
23
+ from lionagi.utils import is_coro_func
24
+
25
+ from .models import ClaudeCodeRequest
26
+
27
+ CLAUDE = shutil.which("claude") or "claude"
28
+ if not shutil.which(CLAUDE):
29
+ raise RuntimeError(
30
+ "Claude CLI binary not found (npm i -g @anthropic-ai/claude-code)"
31
+ )
32
+ logging.basicConfig(level=logging.INFO)
33
+ log = logging.getLogger("claude-cli")
34
+
35
+
36
+ @dataclasses.dataclass
37
+ class ClaudeChunk:
38
+ """Low-level wrapper around every NDJSON object coming from the CLI."""
39
+
40
+ raw: dict[str, Any]
41
+ type: str
42
+ # convenience views
43
+ thinking: str | None = None
44
+ text: str | None = None
45
+ tool_use: dict[str, Any] | None = None
46
+ tool_result: dict[str, Any] | None = None
47
+
48
+
49
+ @dataclasses.dataclass
50
+ class ClaudeSession:
51
+ """Aggregated view of a whole CLI conversation."""
52
+
53
+ session_id: str | None = None
54
+ model: str | None = None
55
+
56
+ # chronological log
57
+ chunks: list[ClaudeChunk] = dataclasses.field(default_factory=list)
58
+
59
+ # materialised views
60
+ thinking_log: list[str] = dataclasses.field(default_factory=list)
61
+ messages: list[dict[str, Any]] = dataclasses.field(default_factory=list)
62
+ tool_uses: list[dict[str, Any]] = dataclasses.field(default_factory=list)
63
+ tool_results: list[dict[str, Any]] = dataclasses.field(
64
+ default_factory=list
65
+ )
66
+
67
+ # final summary
68
+ result: str = ""
69
+ usage: dict[str, Any] = dataclasses.field(default_factory=dict)
70
+ total_cost_usd: float | None = None
71
+ num_turns: int | None = None
72
+ duration_ms: int | None = None
73
+ duration_api_ms: int | None = None
74
+ is_error: bool = False
75
+
76
+
77
+ # --------------------------------------------------------------------------- helpers
78
+
79
+
80
+ async def ndjson_from_cli(request: ClaudeCodeRequest):
81
+ """
82
+ Yields each JSON object emitted by the *claude-code* CLI.
83
+
84
+ • Robust against UTF-8 splits across chunks (incremental decoder).
85
+ • Robust against braces inside strings (uses json.JSONDecoder.raw_decode)
86
+ • Falls back to `json_repair.repair_json` when necessary.
87
+ """
88
+ workspace = request.cwd()
89
+ workspace.mkdir(parents=True, exist_ok=True)
90
+
91
+ proc = await asyncio.create_subprocess_exec(
92
+ CLAUDE,
93
+ *request.as_cmd_args(),
94
+ cwd=str(workspace),
95
+ stdout=asyncio.subprocess.PIPE,
96
+ stderr=asyncio.subprocess.PIPE,
97
+ )
98
+
99
+ decoder = codecs.getincrementaldecoder("utf-8")()
100
+ json_decoder = json.JSONDecoder()
101
+ buffer: str = "" # text buffer that may hold >1 JSON objects
102
+
103
+ try:
104
+ while True:
105
+ chunk = await proc.stdout.read(4096)
106
+ if not chunk:
107
+ break
108
+
109
+ # 1) decode *incrementally* so we never split multibyte chars
110
+ buffer += decoder.decode(chunk)
111
+
112
+ # 2) try to peel off as many complete JSON objs as possible
113
+ while buffer:
114
+ buffer = buffer.lstrip() # remove leading spaces/newlines
115
+ if not buffer:
116
+ break
117
+ try:
118
+ obj, idx = json_decoder.raw_decode(buffer)
119
+ yield obj
120
+ buffer = buffer[idx:] # keep remainder for next round
121
+ except json.JSONDecodeError:
122
+ # incomplete → need more bytes
123
+ break
124
+
125
+ # 3) flush any tail bytes in the incremental decoder
126
+ buffer += decoder.decode(b"", final=True)
127
+ buffer = buffer.strip()
128
+ if buffer:
129
+ try:
130
+ obj, idx = json_decoder.raw_decode(buffer)
131
+ yield obj
132
+ except json.JSONDecodeError:
133
+ try:
134
+ fixed = repair_json(buffer)
135
+ yield json.loads(fixed)
136
+ log.warning(
137
+ "Repaired malformed JSON fragment at stream end"
138
+ )
139
+ except Exception:
140
+ log.error(
141
+ "Skipped unrecoverable JSON tail: %.120s…", buffer
142
+ )
143
+
144
+ # 4) propagate non-zero exit code
145
+ if await proc.wait() != 0:
146
+ err = (await proc.stderr.read()).decode().strip()
147
+ raise RuntimeError(err or "CLI exited non-zero")
148
+
149
+ finally:
150
+ with contextlib.suppress(ProcessLookupError):
151
+ proc.terminate()
152
+ await proc.wait()
153
+
154
+
155
+ # --------------------------------------------------------------------------- SSE route
156
+ async def stream_events(request: ClaudeCodeRequest):
157
+ async for obj in ndjson_from_cli(request):
158
+ yield obj
159
+ yield {"type": "done"}
160
+
161
+
162
+ print_readable = partial(as_readable, md=True, display_str=True)
163
+
164
+
165
+ def _pp_system(sys_obj: dict[str, Any], theme) -> None:
166
+ txt = (
167
+ f"◼️ **Claude Code Session** \n"
168
+ f"- id: `{sys_obj.get('session_id', '?')}` \n"
169
+ f"- model: `{sys_obj.get('model', '?')}` \n"
170
+ f"- tools: {', '.join(sys_obj.get('tools', [])[:8])}"
171
+ + ("…" if len(sys_obj.get("tools", [])) > 8 else "")
172
+ )
173
+ print_readable(txt, border=False, theme=theme)
174
+
175
+
176
+ def _pp_thinking(thought: str, theme) -> None:
177
+ text = f"""
178
+ 🧠 Thinking:
179
+ {thought}
180
+ """
181
+ print_readable(text, border=True, theme=theme)
182
+
183
+
184
+ def _pp_assistant_text(text: str, theme) -> None:
185
+ txt = f"""
186
+ > 🗣️ Claude:
187
+ {text}
188
+ """
189
+ print_readable(txt, theme=theme)
190
+
191
+
192
+ def _pp_tool_use(tu: dict[str, Any], theme) -> None:
193
+ preview = shorten(str(tu["input"]).replace("\n", " "), 130)
194
+ body = f"- 🔧 Tool Use — {tu['name']}({tu['id']}) - input: {preview}"
195
+ print_readable(body, border=False, panel=False, theme=theme)
196
+
197
+
198
+ def _pp_tool_result(tr: dict[str, Any], theme) -> None:
199
+ body_preview = shorten(str(tr["content"]).replace("\n", " "), 130)
200
+ status = "ERR" if tr.get("is_error") else "OK"
201
+ body = f"- 📄 Tool Result({tr['tool_use_id']}) - {status}\n\n\tcontent: {body_preview}"
202
+ print_readable(body, border=False, panel=False, theme=theme)
203
+
204
+
205
+ def _pp_final(sess: ClaudeSession, theme) -> None:
206
+ usage = sess.usage or {}
207
+ txt = (
208
+ f"### ✅ Session complete - {datetime.utcnow().isoformat(timespec='seconds')} UTC\n"
209
+ f"**Result:**\n\n{sess.result or ''}\n\n"
210
+ f"- cost: **${sess.total_cost_usd:.4f}** \n"
211
+ f"- turns: **{sess.num_turns}** \n"
212
+ f"- duration: **{sess.duration_ms} ms** (API {sess.duration_api_ms} ms) \n"
213
+ f"- tokens in/out: {usage.get('input_tokens', 0)}/{usage.get('output_tokens', 0)}"
214
+ )
215
+ print_readable(txt, theme=theme)
216
+
217
+
218
+ # --------------------------------------------------------------------------- internal utils
219
+
220
+
221
+ async def _maybe_await(func, *args, **kw):
222
+ """Call func which may be sync or async."""
223
+ res = func(*args, **kw) if func else None
224
+ if is_coro_func(res):
225
+ await res
226
+
227
+
228
+ # --------------------------------------------------------------------------- main parser
229
+
230
+
231
+ async def stream_claude_code_cli( # noqa: C901 (complexity from branching is fine here)
232
+ request: ClaudeCodeRequest,
233
+ session: ClaudeSession = ClaudeSession(),
234
+ *,
235
+ on_system: Callable[[dict[str, Any]], None] | None = None,
236
+ on_thinking: Callable[[str], None] | None = None,
237
+ on_text: Callable[[str], None] | None = None,
238
+ on_tool_use: Callable[[dict[str, Any]], None] | None = None,
239
+ on_tool_result: Callable[[dict[str, Any]], None] | None = None,
240
+ on_final: Callable[[ClaudeSession], None] | None = None,
241
+ ) -> AsyncIterator[ClaudeChunk | dict | ClaudeSession]:
242
+ """
243
+ Consume the ND-JSON stream produced by ndjson_from_cli()
244
+ and return a fully-populated ClaudeSession.
245
+
246
+ If callbacks are omitted a default pretty-print is emitted.
247
+ """
248
+ stream = ndjson_from_cli(request)
249
+ theme = request.cli_display_theme or "light"
250
+
251
+ async for obj in stream:
252
+ typ = obj.get("type", "unknown")
253
+ chunk = ClaudeChunk(raw=obj, type=typ)
254
+ session.chunks.append(chunk)
255
+
256
+ # ------------------------ SYSTEM -----------------------------------
257
+ if typ == "system":
258
+ data = obj
259
+ session.session_id = data.get("session_id", session.session_id)
260
+ session.model = data.get("model", session.model)
261
+ await _maybe_await(on_system, data)
262
+ if request.verbose_output:
263
+ _pp_system(data, theme)
264
+ yield data
265
+
266
+ # ------------------------ ASSISTANT --------------------------------
267
+ elif typ == "assistant":
268
+ msg = obj["message"]
269
+ session.messages.append(msg)
270
+
271
+ for blk in msg.get("content", []):
272
+ btype = blk.get("type")
273
+ if btype == "thinking":
274
+ thought = blk.get("thinking", "").strip()
275
+ chunk.thinking = thought
276
+ session.thinking_log.append(thought)
277
+ await _maybe_await(on_thinking, thought)
278
+ if request.verbose_output:
279
+ _pp_thinking(thought, theme)
280
+
281
+ elif btype == "text":
282
+ text = blk.get("text", "")
283
+ chunk.text = text
284
+ await _maybe_await(on_text, text)
285
+ if request.verbose_output:
286
+ _pp_assistant_text(text, theme)
287
+
288
+ elif btype == "tool_use":
289
+ tu = {
290
+ "id": blk["id"],
291
+ "name": blk["name"],
292
+ "input": blk["input"],
293
+ }
294
+ chunk.tool_use = tu
295
+ session.tool_uses.append(tu)
296
+ await _maybe_await(on_tool_use, tu)
297
+ if request.verbose_output:
298
+ _pp_tool_use(tu, theme)
299
+
300
+ elif btype == "tool_result":
301
+ tr = {
302
+ "tool_use_id": blk["tool_use_id"],
303
+ "content": blk["content"],
304
+ "is_error": blk.get("is_error", False),
305
+ }
306
+ chunk.tool_result = tr
307
+ session.tool_results.append(tr)
308
+ await _maybe_await(on_tool_result, tr)
309
+ if request.verbose_output:
310
+ _pp_tool_result(tr, theme)
311
+ yield chunk
312
+
313
+ # ------------------------ USER (tool_result containers) ------------
314
+ elif typ == "user":
315
+ msg = obj["message"]
316
+ session.messages.append(msg)
317
+ for blk in msg.get("content", []):
318
+ if blk.get("type") == "tool_result":
319
+ tr = {
320
+ "tool_use_id": blk["tool_use_id"],
321
+ "content": blk["content"],
322
+ "is_error": blk.get("is_error", False),
323
+ }
324
+ chunk.tool_result = tr
325
+ session.tool_results.append(tr)
326
+ await _maybe_await(on_tool_result, tr)
327
+ if request.verbose_output:
328
+ _pp_tool_result(tr, theme)
329
+ yield chunk
330
+
331
+ # ------------------------ RESULT -----------------------------------
332
+ elif typ == "result":
333
+ session.result = obj.get("result", "").strip()
334
+ session.usage = obj.get("usage", {})
335
+ session.total_cost_usd = obj.get("total_cost_usd")
336
+ session.num_turns = obj.get("num_turns")
337
+ session.duration_ms = obj.get("duration_ms")
338
+ session.duration_api_ms = obj.get("duration_api_ms")
339
+ session.is_error = obj.get("is_error", False)
340
+
341
+ # ------------------------ DONE -------------------------------------
342
+ elif typ == "done":
343
+ break
344
+
345
+ # final pretty print
346
+ await _maybe_await(on_final, session)
347
+ if request.verbose_output:
348
+ _pp_final(session, theme)
349
+
350
+ yield session