pysolated 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.
pysolated/__init__.py ADDED
@@ -0,0 +1,177 @@
1
+ """pysolated — orchestrate AI coding agents inside sandboxes via `run()`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .agents import (
6
+ ClaudeCode,
7
+ Codex,
8
+ CodexEffort,
9
+ PermissionMode,
10
+ claude_code,
11
+ codex,
12
+ parse_codex_session_usage,
13
+ parse_codex_stream_line,
14
+ parse_session_usage,
15
+ parse_stream_line,
16
+ )
17
+ from .completion import match_completion_signal
18
+ from .core import (
19
+ AgentCommandOptions,
20
+ AgentProvider,
21
+ Command,
22
+ Display,
23
+ ExecResult,
24
+ ResultEvent,
25
+ RunResult,
26
+ Sandbox,
27
+ SandboxProvider,
28
+ SessionIdEvent,
29
+ Severity,
30
+ StreamEvent,
31
+ TextEvent,
32
+ ToolCallEvent,
33
+ Usage,
34
+ )
35
+ from .display import FileDisplay, TerminalDisplay
36
+ from .errors import (
37
+ AgentExecutionError,
38
+ BranchAlreadyCheckedOutError,
39
+ IdleTimeoutError,
40
+ MergeConflictError,
41
+ PysolatedError,
42
+ )
43
+ from .orchestrator import (
44
+ DEFAULT_COMPLETION_SIGNAL,
45
+ DEFAULT_COMPLETION_TIMEOUT_SECONDS,
46
+ DEFAULT_IDLE_TIMEOUT_SECONDS,
47
+ DEFAULT_IDLE_WARNING_INTERVAL_SECONDS,
48
+ run,
49
+ )
50
+ from .prompts import (
51
+ PromptArgumentError,
52
+ PromptError,
53
+ PromptExecutor,
54
+ PromptExpansionError,
55
+ expand_shell_expressions,
56
+ resolve_prompt,
57
+ substitute_arguments,
58
+ )
59
+ from .sandboxes import (
60
+ Docker,
61
+ DockerHandle,
62
+ DockerImageNotFoundError,
63
+ DockerImageUidMismatchError,
64
+ DockerLaunchError,
65
+ Mount,
66
+ NoSandbox,
67
+ NoSandboxHandle,
68
+ Podman,
69
+ PodmanHandle,
70
+ PodmanImageNotFoundError,
71
+ PodmanLaunchError,
72
+ docker,
73
+ no_sandbox,
74
+ podman,
75
+ )
76
+ from .structured_output import (
77
+ Output,
78
+ OutputDefinition,
79
+ OutputObject,
80
+ OutputString,
81
+ StructuredOutputError,
82
+ extract_structured_output,
83
+ )
84
+ from .worktrees import (
85
+ BranchStrategy,
86
+ FinalizedRun,
87
+ HeadStrategy,
88
+ MergeToHeadStrategy,
89
+ NamedBranchStrategy,
90
+ PreparedRun,
91
+ )
92
+
93
+ __all__ = [
94
+ # Entry point
95
+ "run",
96
+ # Providers
97
+ "claude_code",
98
+ "ClaudeCode",
99
+ "codex",
100
+ "Codex",
101
+ "CodexEffort",
102
+ "no_sandbox",
103
+ "NoSandbox",
104
+ "NoSandboxHandle",
105
+ "podman",
106
+ "Podman",
107
+ "PodmanHandle",
108
+ "docker",
109
+ "Docker",
110
+ "DockerHandle",
111
+ "Mount",
112
+ "PermissionMode",
113
+ # Display
114
+ "TerminalDisplay",
115
+ "FileDisplay",
116
+ # Seams (Protocols)
117
+ "AgentProvider",
118
+ "SandboxProvider",
119
+ "Sandbox",
120
+ "Display",
121
+ # Pure parsers / matchers
122
+ "parse_stream_line",
123
+ "parse_session_usage",
124
+ "parse_codex_stream_line",
125
+ "parse_codex_session_usage",
126
+ "match_completion_signal",
127
+ # Prompt pipeline
128
+ "resolve_prompt",
129
+ "substitute_arguments",
130
+ "expand_shell_expressions",
131
+ "PromptExecutor",
132
+ # Structured output
133
+ "Output",
134
+ "OutputDefinition",
135
+ "OutputObject",
136
+ "OutputString",
137
+ "extract_structured_output",
138
+ # Defaults
139
+ "DEFAULT_COMPLETION_SIGNAL",
140
+ "DEFAULT_IDLE_TIMEOUT_SECONDS",
141
+ "DEFAULT_COMPLETION_TIMEOUT_SECONDS",
142
+ "DEFAULT_IDLE_WARNING_INTERVAL_SECONDS",
143
+ # Branch strategies
144
+ "BranchStrategy",
145
+ "HeadStrategy",
146
+ "MergeToHeadStrategy",
147
+ "NamedBranchStrategy",
148
+ "PreparedRun",
149
+ "FinalizedRun",
150
+ # Value types
151
+ "RunResult",
152
+ "Usage",
153
+ "Command",
154
+ "ExecResult",
155
+ "AgentCommandOptions",
156
+ "StreamEvent",
157
+ "TextEvent",
158
+ "ToolCallEvent",
159
+ "SessionIdEvent",
160
+ "ResultEvent",
161
+ "Severity",
162
+ # Errors
163
+ "PysolatedError",
164
+ "AgentExecutionError",
165
+ "BranchAlreadyCheckedOutError",
166
+ "IdleTimeoutError",
167
+ "MergeConflictError",
168
+ "PromptError",
169
+ "PromptArgumentError",
170
+ "PromptExpansionError",
171
+ "StructuredOutputError",
172
+ "PodmanImageNotFoundError",
173
+ "PodmanLaunchError",
174
+ "DockerImageNotFoundError",
175
+ "DockerImageUidMismatchError",
176
+ "DockerLaunchError",
177
+ ]
@@ -0,0 +1,45 @@
1
+ """Agent providers — command building and stream parsing.
2
+
3
+ v1 ships one provider, `claude_code`. The stream parser and usage parser are
4
+ pure module-level functions (the provider delegates to them) so they can be
5
+ table-tested directly without constructing a provider.
6
+
7
+ The package layout mirrors `sandboxes/` (see issue #24):
8
+
9
+ - `claude_code.py` — the Claude Code provider plus its pure parsers.
10
+ - `_parsing.py` — shared stream-parsing helpers (the tool-input allowlist and
11
+ the assistant-content block parser) that Claude — and the later Copilot
12
+ provider — both use.
13
+ - `_registry.py` — empty scaffold for the registry + `build_agent` to land in
14
+ a follow-up slice (issue #32).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .claude_code import (
20
+ ClaudeCode,
21
+ PermissionMode,
22
+ claude_code,
23
+ parse_session_usage,
24
+ parse_stream_line,
25
+ )
26
+ from .codex import (
27
+ Codex,
28
+ CodexEffort,
29
+ codex,
30
+ parse_codex_session_usage,
31
+ parse_codex_stream_line,
32
+ )
33
+
34
+ __all__ = [
35
+ "ClaudeCode",
36
+ "Codex",
37
+ "CodexEffort",
38
+ "PermissionMode",
39
+ "claude_code",
40
+ "codex",
41
+ "parse_codex_session_usage",
42
+ "parse_codex_stream_line",
43
+ "parse_session_usage",
44
+ "parse_stream_line",
45
+ ]
@@ -0,0 +1,55 @@
1
+ """Shared stream-parsing helpers used by multiple agent providers.
2
+
3
+ Claude Code — and the later Copilot provider — both emit stream-json with an
4
+ `assistant` content-block shape; this module centralises that parsing and the
5
+ allowlist of tool names whose input fields we are willing to surface.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..core import StreamEvent, TextEvent, ToolCallEvent
11
+
12
+ # Allowlisted tools, mapped to the input field carrying the display arg.
13
+ # Anything not listed here is dropped — we never surface arbitrary tool input.
14
+ TOOL_ARG_FIELDS: dict[str, str] = {
15
+ "Bash": "command",
16
+ "WebSearch": "query",
17
+ "WebFetch": "url",
18
+ "Agent": "description",
19
+ }
20
+
21
+
22
+ def parse_assistant_content_blocks(content: list[object]) -> list[StreamEvent]:
23
+ """Decode an `assistant` message's `content` blocks into stream events.
24
+
25
+ Pure. Text blocks concatenate into `TextEvent`s; allowlisted `tool_use`
26
+ blocks become `ToolCallEvent`s. Pending text is flushed before each tool
27
+ call so events stay in source order. Unknown / wrong-typed blocks are
28
+ silently skipped.
29
+ """
30
+ events: list[StreamEvent] = []
31
+ texts: list[str] = []
32
+ for block in content:
33
+ if not isinstance(block, dict):
34
+ continue
35
+ block_type = block.get("type")
36
+ if block_type == "text" and isinstance(block.get("text"), str):
37
+ texts.append(block["text"])
38
+ elif (
39
+ block_type == "tool_use"
40
+ and isinstance(block.get("name"), str)
41
+ and isinstance(block.get("input"), dict)
42
+ ):
43
+ arg_field = TOOL_ARG_FIELDS.get(block["name"])
44
+ if arg_field is None:
45
+ continue # not allowlisted
46
+ arg_value = block["input"].get(arg_field)
47
+ if not isinstance(arg_value, str):
48
+ continue # missing / wrong-typed arg field
49
+ if texts:
50
+ events.append(TextEvent(text="".join(texts)))
51
+ texts = []
52
+ events.append(ToolCallEvent(name=block["name"], args=arg_value))
53
+ if texts:
54
+ events.append(TextEvent(text="".join(texts)))
55
+ return events
@@ -0,0 +1,85 @@
1
+ """Agent registry + CLI-builder.
2
+
3
+ A name → factory map keyed on each provider's ``.name``, plus ``build_agent``
4
+ — the CLI's one resolver. Library callers construct providers directly via
5
+ their typed factories and never touch this module; it exists only for the
6
+ string-name boundary the CLI (and later init/config) sits behind.
7
+
8
+ ``build_agent`` applies provider-specific option handling here so the CLI
9
+ grows no ``if name == …`` ladder as agents are added. Argument errors raise
10
+ ``ValueError``; the CLI translates that into ``typer.Exit(2)``, consistent
11
+ with the existing ``--prompt`` / ``--prompt-arg`` rejections.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Callable
17
+
18
+ from ..core import AgentProvider
19
+ from .claude_code import PermissionMode, claude_code
20
+ from .codex import codex
21
+
22
+ _CLAUDE_CODE_DEFAULT_MODEL = "claude-opus-4-7"
23
+
24
+
25
+ def _build_claude_code(
26
+ *,
27
+ model: str | None,
28
+ effort: str | None,
29
+ permission_mode: str | None,
30
+ ) -> AgentProvider:
31
+ if effort is not None:
32
+ raise ValueError("--effort is not supported by the claude-code agent.")
33
+ resolved_model = model if model is not None else _CLAUDE_CODE_DEFAULT_MODEL
34
+ return claude_code(
35
+ resolved_model,
36
+ permission_mode=permission_mode, # type: ignore[arg-type]
37
+ )
38
+
39
+
40
+ def _build_codex(
41
+ *,
42
+ model: str | None,
43
+ effort: str | None,
44
+ permission_mode: str | None,
45
+ ) -> AgentProvider:
46
+ if permission_mode is not None:
47
+ raise ValueError("--permission-mode is not supported by the codex agent.")
48
+ if model is None:
49
+ raise ValueError("--model is required for the codex agent.")
50
+ return codex(model, effort=effort) # type: ignore[arg-type]
51
+
52
+
53
+ # Keyed on the provider's ``.name``. Each entry resolves CLI options into a
54
+ # concrete provider; provider-specific rejections live here, not in the CLI.
55
+ _REGISTRY: dict[
56
+ str,
57
+ Callable[..., AgentProvider],
58
+ ] = {
59
+ "claude-code": _build_claude_code,
60
+ "codex": _build_codex,
61
+ }
62
+
63
+
64
+ def agent_names() -> list[str]:
65
+ """The registered agent names, in insertion order — for error messages."""
66
+ return list(_REGISTRY)
67
+
68
+
69
+ def build_agent(
70
+ name: str,
71
+ *,
72
+ model: str | None,
73
+ effort: str | None = None,
74
+ permission_mode: PermissionMode | None = None,
75
+ ) -> AgentProvider:
76
+ """Resolve a CLI agent name to a configured ``AgentProvider``.
77
+
78
+ Raises ``ValueError`` for an unknown name, a model that's required but
79
+ missing for the chosen agent, or a flag the chosen agent does not accept.
80
+ """
81
+ factory = _REGISTRY.get(name)
82
+ if factory is None:
83
+ valid = ", ".join(agent_names())
84
+ raise ValueError(f"Unknown --agent {name!r}. Valid agents: {valid}.")
85
+ return factory(model=model, effort=effort, permission_mode=permission_mode)
@@ -0,0 +1,161 @@
1
+ """The Claude Code agent provider.
2
+
3
+ The stream parser and usage parser are pure module-level functions (the
4
+ provider delegates to them) so they can be table-tested directly without
5
+ constructing a provider.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from typing import Literal
13
+
14
+ from ..core import (
15
+ AgentCommandOptions,
16
+ Command,
17
+ SessionIdEvent,
18
+ StreamEvent,
19
+ Usage,
20
+ )
21
+ from ._parsing import parse_assistant_content_blocks
22
+
23
+
24
+ def parse_stream_line(line: str) -> list[StreamEvent]:
25
+ """Decode one Claude `stream-json` JSONL line into zero or more events.
26
+
27
+ Pure. Non-JSON / unknown / malformed lines yield no events.
28
+
29
+ - `assistant` lines yield `text` events (content text blocks concatenated)
30
+ and `tool_call` events for allowlisted tools, in source order.
31
+ - `system`/`init` lines yield a single `session_id` event.
32
+ """
33
+ if not line.startswith("{"):
34
+ return []
35
+ try:
36
+ obj = json.loads(line)
37
+ except (json.JSONDecodeError, ValueError):
38
+ return []
39
+ if not isinstance(obj, dict):
40
+ return []
41
+
42
+ message = obj.get("message")
43
+ if (
44
+ obj.get("type") == "assistant"
45
+ and isinstance(message, dict)
46
+ and isinstance(message.get("content"), list)
47
+ ):
48
+ return parse_assistant_content_blocks(message["content"])
49
+
50
+ if (
51
+ obj.get("type") == "system"
52
+ and obj.get("subtype") == "init"
53
+ and isinstance(obj.get("session_id"), str)
54
+ ):
55
+ return [SessionIdEvent(session_id=obj["session_id"])]
56
+
57
+ return []
58
+
59
+
60
+ _USAGE_FIELDS = (
61
+ "input_tokens",
62
+ "cache_creation_input_tokens",
63
+ "cache_read_input_tokens",
64
+ "output_tokens",
65
+ )
66
+
67
+
68
+ def parse_session_usage(content: str) -> Usage | None:
69
+ """Extract the session's authoritative token usage from streamed content.
70
+
71
+ Pure. Scans the accumulated stream-json content from the end and returns the
72
+ first complete usage block it finds, or `None` when no usage was emitted.
73
+
74
+ The authoritative totals live on the terminal `result` line: an `assistant`
75
+ line's usage is the `message_start` snapshot, captured before the response
76
+ streamed, so its `output_tokens` is only a partial count (often 1). Scanning
77
+ from the end reaches the `result` line first, so its totals win; an
78
+ `assistant` line is the fallback for truncated streams that never reached a
79
+ `result`. The usage block sits at the top level on a `result` line and under
80
+ `message` on an `assistant` line.
81
+ """
82
+ for line in reversed(content.split("\n")):
83
+ if not line.startswith("{"):
84
+ continue
85
+ try:
86
+ obj = json.loads(line)
87
+ except (json.JSONDecodeError, ValueError):
88
+ continue
89
+ if not isinstance(obj, dict):
90
+ continue
91
+ if obj.get("type") == "result":
92
+ usage = obj.get("usage")
93
+ elif obj.get("type") == "assistant":
94
+ message = obj.get("message")
95
+ usage = message.get("usage") if isinstance(message, dict) else None
96
+ else:
97
+ continue
98
+ if not isinstance(usage, dict):
99
+ continue
100
+ if all(isinstance(usage.get(name), int) for name in _USAGE_FIELDS):
101
+ return Usage(
102
+ input_tokens=usage["input_tokens"],
103
+ cache_creation_input_tokens=usage["cache_creation_input_tokens"],
104
+ cache_read_input_tokens=usage["cache_read_input_tokens"],
105
+ output_tokens=usage["output_tokens"],
106
+ )
107
+ return None
108
+
109
+
110
+ # Maps directly to Claude's `--permission-mode` flag. Mutually exclusive with
111
+ # `--dangerously-skip-permissions` on Claude's CLI.
112
+ PermissionMode = Literal[
113
+ "default", "acceptEdits", "plan", "auto", "dontAsk", "bypassPermissions"
114
+ ]
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class ClaudeCode:
119
+ """The Claude Code agent provider.
120
+
121
+ Build it via `claude_code(...)` rather than constructing directly.
122
+ """
123
+
124
+ model: str
125
+ permission_mode: PermissionMode | None = None
126
+ env: dict[str, str] = field(default_factory=dict)
127
+ name: str = "claude-code"
128
+
129
+ def build_command(self, options: AgentCommandOptions) -> Command:
130
+ """Build the print-mode argv, with the prompt delivered on stdin.
131
+
132
+ `permission_mode` and `--dangerously-skip-permissions` are mutually
133
+ exclusive: an explicit mode replaces the default skip-permissions flag.
134
+ """
135
+ argv = ["claude", "--print", "--verbose"]
136
+ if self.permission_mode is not None:
137
+ argv += ["--permission-mode", self.permission_mode]
138
+ else:
139
+ argv.append("--dangerously-skip-permissions")
140
+ argv += ["--output-format", "stream-json", "--model", self.model, "-p", "-"]
141
+ return Command(argv=argv, stdin=options.prompt)
142
+
143
+ def parse_stream_line(self, line: str) -> list[StreamEvent]:
144
+ return parse_stream_line(line)
145
+
146
+ def parse_session_usage(self, content: str) -> Usage | None:
147
+ return parse_session_usage(content)
148
+
149
+
150
+ def claude_code(
151
+ model: str,
152
+ *,
153
+ permission_mode: PermissionMode | None = None,
154
+ env: dict[str, str] | None = None,
155
+ ) -> ClaudeCode:
156
+ """Create a Claude Code agent provider.
157
+
158
+ `model` selects the Claude model. `permission_mode`, when given, replaces the
159
+ default `--dangerously-skip-permissions` flag (the two are mutually exclusive).
160
+ """
161
+ return ClaudeCode(model=model, permission_mode=permission_mode, env=env or {})