orkestra-runtime 0.1.2__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.
- orkestra/__init__.py +1 -0
- orkestra/adapters/__init__.py +12 -0
- orkestra/adapters/antigravity_cli.py +231 -0
- orkestra/adapters/base.py +85 -0
- orkestra/adapters/claude_code.py +211 -0
- orkestra/adapters/codex_cli.py +219 -0
- orkestra/adapters/external.py +160 -0
- orkestra/adapters/fake.py +30 -0
- orkestra/adapters/fake_worker.py +195 -0
- orkestra/adapters/gemini_cli.py +232 -0
- orkestra/adapters/jsonl.py +68 -0
- orkestra/adapters/registry.py +42 -0
- orkestra/adapters/runner.py +216 -0
- orkestra/adapters/testkit.py +148 -0
- orkestra/app.py +87 -0
- orkestra/capabilities/__init__.py +6 -0
- orkestra/capabilities/ledger.py +48 -0
- orkestra/capabilities/matrix.py +87 -0
- orkestra/capabilities/probes.py +164 -0
- orkestra/cli/__init__.py +1 -0
- orkestra/cli/main.py +685 -0
- orkestra/cli/template.py +109 -0
- orkestra/director/__init__.py +5 -0
- orkestra/director/heuristic.py +112 -0
- orkestra/director/prompts.py +147 -0
- orkestra/director/service.py +253 -0
- orkestra/errors.py +43 -0
- orkestra/ids.py +57 -0
- orkestra/kernel/__init__.py +1 -0
- orkestra/kernel/dag.py +126 -0
- orkestra/kernel/prepare.py +158 -0
- orkestra/kernel/retry.py +46 -0
- orkestra/kernel/scheduler.py +902 -0
- orkestra/kernel/states.py +81 -0
- orkestra/policy/__init__.py +5 -0
- orkestra/policy/engine.py +117 -0
- orkestra/py.typed +0 -0
- orkestra/redact.py +139 -0
- orkestra/report/__init__.py +5 -0
- orkestra/report/final.py +144 -0
- orkestra/schemas/__init__.py +84 -0
- orkestra/schemas/agent.py +112 -0
- orkestra/schemas/capability.py +62 -0
- orkestra/schemas/common.py +65 -0
- orkestra/schemas/config.py +145 -0
- orkestra/schemas/decision.py +41 -0
- orkestra/schemas/director.py +79 -0
- orkestra/schemas/task.py +48 -0
- orkestra/store/__init__.py +6 -0
- orkestra/store/db.py +92 -0
- orkestra/store/migrations.py +126 -0
- orkestra/store/repo.py +532 -0
- orkestra/verify/__init__.py +5 -0
- orkestra/verify/runner.py +130 -0
- orkestra/workspace/__init__.py +6 -0
- orkestra/workspace/git.py +193 -0
- orkestra/workspace/worktrees.py +155 -0
- orkestra_runtime-0.1.2.dist-info/METADATA +290 -0
- orkestra_runtime-0.1.2.dist-info/RECORD +63 -0
- orkestra_runtime-0.1.2.dist-info/WHEEL +4 -0
- orkestra_runtime-0.1.2.dist-info/entry_points.txt +2 -0
- orkestra_runtime-0.1.2.dist-info/licenses/LICENSE +202 -0
- orkestra_runtime-0.1.2.dist-info/licenses/NOTICE +10 -0
orkestra/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Agent adapter layer."""
|
|
2
|
+
|
|
3
|
+
from orkestra.adapters.base import AdapterInfo, AgentAdapter, InvocationSpec
|
|
4
|
+
from orkestra.adapters.registry import build_adapter, builtin_adapter_ids
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AdapterInfo",
|
|
8
|
+
"AgentAdapter",
|
|
9
|
+
"InvocationSpec",
|
|
10
|
+
"build_adapter",
|
|
11
|
+
"builtin_adapter_ids",
|
|
12
|
+
]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Antigravity CLI adapter (`agy`) — first-party Google adapter.
|
|
2
|
+
|
|
3
|
+
Surface verified live 2026-07-24 against agy 1.1.6 (samples/):
|
|
4
|
+
|
|
5
|
+
- ``agy -p --output-format stream-json`` emits JSONL: ``init``
|
|
6
|
+
(conversation_id, tools, permission_mode), ``step_update``
|
|
7
|
+
(``step_type: agent_response`` carries ``text_delta`` + usage), and a
|
|
8
|
+
terminal ``result`` envelope (``conversation_id``, ``status``,
|
|
9
|
+
``response``, ``usage``).
|
|
10
|
+
- ``--output-format`` is accepted although undocumented in ``--help``;
|
|
11
|
+
the parser therefore also handles plain-text output as a fallback.
|
|
12
|
+
- No schema-constrained output: structured requests use prompt-level
|
|
13
|
+
JSON plus :func:`extract_json_object` with kernel-side repair retries.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from orkestra.adapters.base import AdapterInfo, AgentAdapter, InvocationSpec, StreamParser
|
|
22
|
+
from orkestra.adapters.jsonl import extract_json_object, try_parse_json
|
|
23
|
+
from orkestra.adapters.runner import run_capture
|
|
24
|
+
from orkestra.schemas.agent import (
|
|
25
|
+
AgentEvent,
|
|
26
|
+
AgentResult,
|
|
27
|
+
AuthStatus,
|
|
28
|
+
ErrorKind,
|
|
29
|
+
EventKind,
|
|
30
|
+
ResultStatus,
|
|
31
|
+
SessionRef,
|
|
32
|
+
Usage,
|
|
33
|
+
)
|
|
34
|
+
from orkestra.schemas.task import TaskBrief
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AntigravityParser(StreamParser):
|
|
38
|
+
def __init__(self, expect_structured: bool) -> None:
|
|
39
|
+
self.expect_structured = expect_structured
|
|
40
|
+
self.conversation_id = ""
|
|
41
|
+
self.final: dict[str, Any] | None = None
|
|
42
|
+
self.plain_lines: list[str] = []
|
|
43
|
+
self.stderr_tail: list[str] = []
|
|
44
|
+
|
|
45
|
+
def feed_line(self, line: str, *, is_stderr: bool) -> Iterable[AgentEvent]:
|
|
46
|
+
if is_stderr:
|
|
47
|
+
if line.strip():
|
|
48
|
+
self.stderr_tail = [*self.stderr_tail, line][-20:]
|
|
49
|
+
return
|
|
50
|
+
obj = try_parse_json(line)
|
|
51
|
+
if obj is None:
|
|
52
|
+
# Plain-text fallback (older versions / flag removed upstream).
|
|
53
|
+
self.plain_lines.append(line)
|
|
54
|
+
if line.strip():
|
|
55
|
+
yield AgentEvent(kind=EventKind.TEXT, text=line[:2000])
|
|
56
|
+
return
|
|
57
|
+
event = obj.get("event")
|
|
58
|
+
if event == "init":
|
|
59
|
+
init = obj.get("init") or {}
|
|
60
|
+
self.conversation_id = str(obj.get("conversation_id") or "")
|
|
61
|
+
yield AgentEvent(
|
|
62
|
+
kind=EventKind.STARTED,
|
|
63
|
+
text="antigravity session initialized",
|
|
64
|
+
data={
|
|
65
|
+
"conversation_id": self.conversation_id,
|
|
66
|
+
"permission_mode": init.get("permission_mode", ""),
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
elif event == "step_update":
|
|
70
|
+
step = obj.get("step_update") or {}
|
|
71
|
+
step_type = step.get("step_type")
|
|
72
|
+
if step_type == "agent_response" and step.get("text_delta"):
|
|
73
|
+
yield AgentEvent(kind=EventKind.TEXT, text=str(step["text_delta"])[:4000])
|
|
74
|
+
elif step_type not in (None, "user_input", "checkpoint", "unknown"):
|
|
75
|
+
yield AgentEvent(kind=EventKind.TOOL, text=str(step_type))
|
|
76
|
+
elif event == "result":
|
|
77
|
+
self.final = obj.get("result") or {}
|
|
78
|
+
|
|
79
|
+
def result(self, exit_code: int | None, duration_s: float, cwd: str) -> AgentResult:
|
|
80
|
+
stderr = "\n".join(self.stderr_tail)
|
|
81
|
+
if self.final is not None:
|
|
82
|
+
usage_raw = self.final.get("usage") or {}
|
|
83
|
+
usage = Usage(
|
|
84
|
+
input_tokens=int(usage_raw.get("input_tokens") or 0),
|
|
85
|
+
output_tokens=int(usage_raw.get("output_tokens") or 0),
|
|
86
|
+
)
|
|
87
|
+
conversation_id = str(self.final.get("conversation_id") or self.conversation_id)
|
|
88
|
+
status = str(self.final.get("status") or "")
|
|
89
|
+
text = str(self.final.get("response") or "")
|
|
90
|
+
ok = status.upper() == "SUCCESS" and (exit_code in (0, None))
|
|
91
|
+
structured = extract_json_object(text) if self.expect_structured else None
|
|
92
|
+
if ok and self.expect_structured and structured is None:
|
|
93
|
+
return AgentResult(
|
|
94
|
+
status=ResultStatus.ERROR,
|
|
95
|
+
error_kind=ErrorKind.INVALID_OUTPUT,
|
|
96
|
+
error_detail="expected JSON in response, none found",
|
|
97
|
+
final_text=text,
|
|
98
|
+
exit_code=exit_code,
|
|
99
|
+
duration_s=duration_s,
|
|
100
|
+
)
|
|
101
|
+
return AgentResult(
|
|
102
|
+
status=ResultStatus.OK if ok else ResultStatus.ERROR,
|
|
103
|
+
error_kind=ErrorKind.NONE if ok else ErrorKind.UNKNOWN,
|
|
104
|
+
error_detail="" if ok else f"status={status}",
|
|
105
|
+
final_text=text,
|
|
106
|
+
structured=structured,
|
|
107
|
+
session=(
|
|
108
|
+
SessionRef(session_id=conversation_id, cwd=cwd) if conversation_id else None
|
|
109
|
+
),
|
|
110
|
+
usage=usage,
|
|
111
|
+
exit_code=exit_code,
|
|
112
|
+
duration_s=duration_s,
|
|
113
|
+
)
|
|
114
|
+
# Plain-text fallback path.
|
|
115
|
+
text = "\n".join(self.plain_lines).strip()
|
|
116
|
+
lower = (stderr + text).lower()
|
|
117
|
+
if exit_code == 0 and text:
|
|
118
|
+
structured = extract_json_object(text) if self.expect_structured else None
|
|
119
|
+
if self.expect_structured and structured is None:
|
|
120
|
+
return AgentResult(
|
|
121
|
+
status=ResultStatus.ERROR,
|
|
122
|
+
error_kind=ErrorKind.INVALID_OUTPUT,
|
|
123
|
+
error_detail="expected JSON in response, none found",
|
|
124
|
+
final_text=text,
|
|
125
|
+
exit_code=exit_code,
|
|
126
|
+
duration_s=duration_s,
|
|
127
|
+
)
|
|
128
|
+
return AgentResult(
|
|
129
|
+
status=ResultStatus.OK,
|
|
130
|
+
final_text=text,
|
|
131
|
+
structured=structured,
|
|
132
|
+
exit_code=exit_code,
|
|
133
|
+
duration_s=duration_s,
|
|
134
|
+
)
|
|
135
|
+
if "auth" in lower or "sign in" in lower or "log in" in lower:
|
|
136
|
+
kind = ErrorKind.AUTH
|
|
137
|
+
elif "rate limit" in lower or "quota" in lower:
|
|
138
|
+
kind = ErrorKind.RATE_LIMIT
|
|
139
|
+
else:
|
|
140
|
+
kind = ErrorKind.CRASH if exit_code else ErrorKind.INVALID_OUTPUT
|
|
141
|
+
return AgentResult(
|
|
142
|
+
status=ResultStatus.ERROR,
|
|
143
|
+
error_kind=kind,
|
|
144
|
+
error_detail=(stderr or text or f"exit {exit_code}")[:2000],
|
|
145
|
+
exit_code=exit_code,
|
|
146
|
+
duration_s=duration_s,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class AntigravityCliAdapter(AgentAdapter):
|
|
151
|
+
adapter_id = "antigravity-cli"
|
|
152
|
+
executable = "agy"
|
|
153
|
+
|
|
154
|
+
def __init__(self, model: str | None = None, autonomy: str = "safe") -> None:
|
|
155
|
+
self.model = model
|
|
156
|
+
self.autonomy = autonomy
|
|
157
|
+
|
|
158
|
+
async def detect(self) -> AdapterInfo:
|
|
159
|
+
path = self.which()
|
|
160
|
+
if not path:
|
|
161
|
+
return AdapterInfo(self.adapter_id, available=False, detail="`agy` not found on PATH")
|
|
162
|
+
code, out, err = await run_capture([path, "--version"])
|
|
163
|
+
if code != 0:
|
|
164
|
+
return AdapterInfo(
|
|
165
|
+
self.adapter_id,
|
|
166
|
+
available=False,
|
|
167
|
+
executable=path,
|
|
168
|
+
detail=f"--version failed: {err.strip()[:200]}",
|
|
169
|
+
)
|
|
170
|
+
return AdapterInfo(
|
|
171
|
+
self.adapter_id,
|
|
172
|
+
available=True,
|
|
173
|
+
version=out.strip(),
|
|
174
|
+
executable=path,
|
|
175
|
+
features=frozenset({"resume", "stream", "os_sandbox"}),
|
|
176
|
+
detail=(
|
|
177
|
+
"note: Antigravity ToS restricts third-party access to the "
|
|
178
|
+
"service; Orkestra only launches the official binary under "
|
|
179
|
+
"your own login — review antigravity.google/terms"
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def check_auth(self) -> AuthStatus:
|
|
184
|
+
path = self.which()
|
|
185
|
+
if not path:
|
|
186
|
+
return AuthStatus(ready=False, detail="`agy` not found on PATH")
|
|
187
|
+
# `agy models` succeeds only when authenticated and consumes no
|
|
188
|
+
# model quota (verified locally 2026-07-24).
|
|
189
|
+
code, out, err = await run_capture([path, "models"], timeout_s=30)
|
|
190
|
+
if code == 0 and out.strip():
|
|
191
|
+
return AuthStatus(ready=True, detail="authenticated (models listed)")
|
|
192
|
+
return AuthStatus(
|
|
193
|
+
ready=False,
|
|
194
|
+
detail=(err or out).strip()[:200] or "run `agy` interactively to sign in",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def build_invocation(self, brief: TaskBrief) -> InvocationSpec:
|
|
198
|
+
timeout_arg = f"{max(brief.timeout_s, 60)}s"
|
|
199
|
+
instructions = brief.instructions
|
|
200
|
+
if brief.json_schema is not None:
|
|
201
|
+
# No native schema flag: embed the schema in the prompt and rely
|
|
202
|
+
# on extract_json_object + kernel-side validation/retries.
|
|
203
|
+
import json as _json
|
|
204
|
+
|
|
205
|
+
instructions += (
|
|
206
|
+
"\n\nRespond with ONLY one JSON object (no prose, no code "
|
|
207
|
+
"fences) conforming to this JSON Schema:\n" + _json.dumps(brief.json_schema)
|
|
208
|
+
)
|
|
209
|
+
argv = [
|
|
210
|
+
self.which() or self.executable,
|
|
211
|
+
"-p",
|
|
212
|
+
instructions,
|
|
213
|
+
"--output-format",
|
|
214
|
+
"stream-json",
|
|
215
|
+
"--print-timeout",
|
|
216
|
+
timeout_arg,
|
|
217
|
+
]
|
|
218
|
+
if self.autonomy == "unsafe-full":
|
|
219
|
+
argv += ["--dangerously-skip-permissions"]
|
|
220
|
+
elif brief.kind.value in ("research", "plan", "review"):
|
|
221
|
+
argv += ["--mode", "plan"]
|
|
222
|
+
else:
|
|
223
|
+
argv += ["--mode", "accept-edits"]
|
|
224
|
+
if self.model:
|
|
225
|
+
argv += ["--model", self.model]
|
|
226
|
+
if brief.resume_session_id:
|
|
227
|
+
argv += ["--conversation", brief.resume_session_id]
|
|
228
|
+
return InvocationSpec(argv=argv, cwd=brief.cwd, timeout_s=brief.timeout_s + 30)
|
|
229
|
+
|
|
230
|
+
def make_parser(self, brief: TaskBrief) -> StreamParser:
|
|
231
|
+
return AntigravityParser(expect_structured=brief.json_schema is not None)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Adapter contract.
|
|
2
|
+
|
|
3
|
+
An adapter is a pure translator between Orkestra's task brief and one
|
|
4
|
+
agent CLI: it builds an argv (never a shell string), parses the CLI's
|
|
5
|
+
output stream into normalized ``AgentEvent`` objects, and produces a
|
|
6
|
+
structured ``AgentResult``. Process supervision (spawn, stream, timeout,
|
|
7
|
+
cancel, kill) lives in :mod:`orkestra.adapters.runner`, shared by all
|
|
8
|
+
adapters.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import shutil
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from orkestra.schemas.agent import AgentEvent, AgentResult, AuthStatus
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from orkestra.schemas.task import TaskBrief
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class AdapterInfo:
|
|
27
|
+
"""Detection result for one adapter."""
|
|
28
|
+
|
|
29
|
+
adapter_id: str
|
|
30
|
+
available: bool
|
|
31
|
+
version: str = ""
|
|
32
|
+
executable: str = ""
|
|
33
|
+
detail: str = ""
|
|
34
|
+
features: frozenset[str] = frozenset()
|
|
35
|
+
"""Feature flags, e.g. {"structured_output", "resume", "structured_director"}."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class InvocationSpec:
|
|
40
|
+
"""Everything the runner needs to launch one attempt."""
|
|
41
|
+
|
|
42
|
+
argv: list[str]
|
|
43
|
+
cwd: str
|
|
44
|
+
env_extra: dict[str, str] = field(default_factory=dict)
|
|
45
|
+
stdin_data: bytes | None = None
|
|
46
|
+
timeout_s: int = 1800
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class StreamParser(ABC):
|
|
50
|
+
"""Incremental parser turning raw CLI output lines into events."""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def feed_line(self, line: str, *, is_stderr: bool) -> Iterable[AgentEvent]:
|
|
54
|
+
"""Parse one output line into zero or more normalized events."""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def result(self, exit_code: int | None, duration_s: float, cwd: str) -> AgentResult:
|
|
58
|
+
"""Produce the final structured result after process exit."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AgentAdapter(ABC):
|
|
62
|
+
"""Stable contract implemented by all adapters (first- and third-party)."""
|
|
63
|
+
|
|
64
|
+
adapter_id: str
|
|
65
|
+
executable: str
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
async def detect(self) -> AdapterInfo:
|
|
69
|
+
"""Report availability, version, and feature flags. Never raises."""
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
async def check_auth(self) -> AuthStatus:
|
|
73
|
+
"""Non-invasive auth readiness check (never reads credential stores)."""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def build_invocation(self, brief: TaskBrief) -> InvocationSpec:
|
|
77
|
+
"""Translate a task brief into a concrete CLI invocation."""
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def make_parser(self, brief: TaskBrief) -> StreamParser:
|
|
81
|
+
"""Fresh parser for one attempt's output stream."""
|
|
82
|
+
|
|
83
|
+
def which(self) -> str | None:
|
|
84
|
+
"""Locate the executable on PATH (helper for detect())."""
|
|
85
|
+
return shutil.which(self.executable)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Claude Code adapter (`claude`).
|
|
2
|
+
|
|
3
|
+
Surface verified 2026-07-24 against claude 2.1.219 (see
|
|
4
|
+
docs/research/AGENT_INTEGRATION_RESEARCH.md and samples/):
|
|
5
|
+
|
|
6
|
+
- ``claude -p --output-format stream-json --verbose`` emits JSONL:
|
|
7
|
+
``system/init``, ``assistant``/``user`` messages, ``system/api_retry``
|
|
8
|
+
(rate-limit/auth categories), and a terminal ``type:"result"`` object
|
|
9
|
+
with ``result``, ``session_id``, ``is_error``, ``usage``,
|
|
10
|
+
``total_cost_usd``.
|
|
11
|
+
- ``--json-schema`` yields ``structured_output`` in the result envelope.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from orkestra.adapters.base import AdapterInfo, AgentAdapter, InvocationSpec, StreamParser
|
|
21
|
+
from orkestra.adapters.jsonl import try_parse_json
|
|
22
|
+
from orkestra.adapters.runner import run_capture
|
|
23
|
+
from orkestra.schemas.agent import (
|
|
24
|
+
AgentEvent,
|
|
25
|
+
AgentResult,
|
|
26
|
+
AuthStatus,
|
|
27
|
+
ErrorKind,
|
|
28
|
+
EventKind,
|
|
29
|
+
ResultStatus,
|
|
30
|
+
SessionRef,
|
|
31
|
+
Usage,
|
|
32
|
+
)
|
|
33
|
+
from orkestra.schemas.task import TaskBrief
|
|
34
|
+
|
|
35
|
+
_API_ERROR_TO_KIND = {
|
|
36
|
+
"authentication_failed": ErrorKind.AUTH,
|
|
37
|
+
"oauth_org_not_allowed": ErrorKind.AUTH,
|
|
38
|
+
"billing_error": ErrorKind.AUTH,
|
|
39
|
+
"rate_limit": ErrorKind.RATE_LIMIT,
|
|
40
|
+
"overloaded": ErrorKind.RATE_LIMIT,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ClaudeParser(StreamParser):
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self.final: dict[str, Any] | None = None
|
|
47
|
+
self.last_error_kind = ErrorKind.NONE
|
|
48
|
+
self.stderr_tail: list[str] = []
|
|
49
|
+
|
|
50
|
+
def feed_line(self, line: str, *, is_stderr: bool) -> Iterable[AgentEvent]:
|
|
51
|
+
if is_stderr:
|
|
52
|
+
if line.strip():
|
|
53
|
+
self.stderr_tail = [*self.stderr_tail, line][-20:]
|
|
54
|
+
return
|
|
55
|
+
obj = try_parse_json(line)
|
|
56
|
+
if obj is None:
|
|
57
|
+
if line.strip():
|
|
58
|
+
yield AgentEvent(kind=EventKind.RAW, text=line[:2000])
|
|
59
|
+
return
|
|
60
|
+
kind = obj.get("type")
|
|
61
|
+
if kind == "system":
|
|
62
|
+
subtype = obj.get("subtype")
|
|
63
|
+
if subtype == "init":
|
|
64
|
+
yield AgentEvent(
|
|
65
|
+
kind=EventKind.STARTED,
|
|
66
|
+
text="claude session initialized",
|
|
67
|
+
data={"session_id": obj.get("session_id", "")},
|
|
68
|
+
)
|
|
69
|
+
elif subtype == "api_retry":
|
|
70
|
+
category = str(obj.get("error", "unknown"))
|
|
71
|
+
self.last_error_kind = _API_ERROR_TO_KIND.get(category, ErrorKind.UNKNOWN)
|
|
72
|
+
yield AgentEvent(
|
|
73
|
+
kind=EventKind.WARNING,
|
|
74
|
+
text=f"api retry ({category})",
|
|
75
|
+
data={"category": category},
|
|
76
|
+
)
|
|
77
|
+
elif kind == "assistant":
|
|
78
|
+
message = obj.get("message") or {}
|
|
79
|
+
for block in message.get("content") or []:
|
|
80
|
+
if isinstance(block, dict):
|
|
81
|
+
if block.get("type") == "text" and block.get("text"):
|
|
82
|
+
yield AgentEvent(kind=EventKind.TEXT, text=str(block["text"])[:4000])
|
|
83
|
+
elif block.get("type") == "tool_use":
|
|
84
|
+
yield AgentEvent(
|
|
85
|
+
kind=EventKind.TOOL,
|
|
86
|
+
text=str(block.get("name", "tool")),
|
|
87
|
+
)
|
|
88
|
+
elif kind == "result":
|
|
89
|
+
self.final = obj
|
|
90
|
+
|
|
91
|
+
def result(self, exit_code: int | None, duration_s: float, cwd: str) -> AgentResult:
|
|
92
|
+
if self.final is not None:
|
|
93
|
+
usage_raw = self.final.get("usage") or {}
|
|
94
|
+
usage = Usage(
|
|
95
|
+
input_tokens=int(usage_raw.get("input_tokens") or 0),
|
|
96
|
+
output_tokens=int(usage_raw.get("output_tokens") or 0),
|
|
97
|
+
cached_input_tokens=int(usage_raw.get("cache_read_input_tokens") or 0),
|
|
98
|
+
total_cost_usd=self.final.get("total_cost_usd"),
|
|
99
|
+
)
|
|
100
|
+
session_id = str(self.final.get("session_id") or "")
|
|
101
|
+
structured = self.final.get("structured_output")
|
|
102
|
+
is_error = bool(self.final.get("is_error"))
|
|
103
|
+
error_kind = ErrorKind.NONE
|
|
104
|
+
detail = ""
|
|
105
|
+
if is_error:
|
|
106
|
+
error_kind = (
|
|
107
|
+
self.last_error_kind
|
|
108
|
+
if self.last_error_kind is not ErrorKind.NONE
|
|
109
|
+
else ErrorKind.UNKNOWN
|
|
110
|
+
)
|
|
111
|
+
detail = str(self.final.get("subtype") or "error")
|
|
112
|
+
return AgentResult(
|
|
113
|
+
status=ResultStatus.ERROR if is_error else ResultStatus.OK,
|
|
114
|
+
error_kind=error_kind,
|
|
115
|
+
error_detail=detail,
|
|
116
|
+
final_text=str(self.final.get("result") or ""),
|
|
117
|
+
structured=structured if isinstance(structured, dict) else None,
|
|
118
|
+
session=SessionRef(session_id=session_id, cwd=cwd) if session_id else None,
|
|
119
|
+
usage=usage,
|
|
120
|
+
exit_code=exit_code,
|
|
121
|
+
duration_s=duration_s,
|
|
122
|
+
)
|
|
123
|
+
stderr = "\n".join(self.stderr_tail)
|
|
124
|
+
lower = stderr.lower()
|
|
125
|
+
if "log in" in lower or "authentication" in lower or "api key" in lower:
|
|
126
|
+
kind = ErrorKind.AUTH
|
|
127
|
+
elif self.last_error_kind is not ErrorKind.NONE:
|
|
128
|
+
kind = self.last_error_kind
|
|
129
|
+
else:
|
|
130
|
+
kind = ErrorKind.CRASH if exit_code else ErrorKind.INVALID_OUTPUT
|
|
131
|
+
return AgentResult(
|
|
132
|
+
status=ResultStatus.ERROR,
|
|
133
|
+
error_kind=kind,
|
|
134
|
+
error_detail=(stderr or "no result envelope received")[:2000],
|
|
135
|
+
exit_code=exit_code,
|
|
136
|
+
duration_s=duration_s,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ClaudeCodeAdapter(AgentAdapter):
|
|
141
|
+
adapter_id = "claude-code"
|
|
142
|
+
executable = "claude"
|
|
143
|
+
|
|
144
|
+
def __init__(self, model: str | None = None, autonomy: str = "safe") -> None:
|
|
145
|
+
self.model = model
|
|
146
|
+
self.autonomy = autonomy
|
|
147
|
+
|
|
148
|
+
async def detect(self) -> AdapterInfo:
|
|
149
|
+
path = self.which()
|
|
150
|
+
if not path:
|
|
151
|
+
return AdapterInfo(
|
|
152
|
+
self.adapter_id, available=False, detail="`claude` not found on PATH"
|
|
153
|
+
)
|
|
154
|
+
code, out, err = await run_capture([path, "--version"])
|
|
155
|
+
if code != 0:
|
|
156
|
+
return AdapterInfo(
|
|
157
|
+
self.adapter_id,
|
|
158
|
+
available=False,
|
|
159
|
+
executable=path,
|
|
160
|
+
detail=f"--version failed: {err.strip()[:200]}",
|
|
161
|
+
)
|
|
162
|
+
return AdapterInfo(
|
|
163
|
+
self.adapter_id,
|
|
164
|
+
available=True,
|
|
165
|
+
version=out.strip().split()[0] if out.strip() else "",
|
|
166
|
+
executable=path,
|
|
167
|
+
features=frozenset({"structured_output", "resume", "structured_director", "stream"}),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
async def check_auth(self) -> AuthStatus:
|
|
171
|
+
# `claude -p` with a trivial prompt would consume quota; instead rely
|
|
172
|
+
# on detection plus the documented failure envelope at run time. A
|
|
173
|
+
# cheap deterministic signal: `claude auth status` isn't universally
|
|
174
|
+
# available, so report ready-if-installed with a caveat.
|
|
175
|
+
info = await self.detect()
|
|
176
|
+
if not info.available:
|
|
177
|
+
return AuthStatus(ready=False, detail=info.detail)
|
|
178
|
+
return AuthStatus(
|
|
179
|
+
ready=True,
|
|
180
|
+
detail="installed; auth verified lazily on first invocation",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def build_invocation(self, brief: TaskBrief) -> InvocationSpec:
|
|
184
|
+
argv = [
|
|
185
|
+
self.which() or self.executable,
|
|
186
|
+
"-p",
|
|
187
|
+
"--output-format",
|
|
188
|
+
"stream-json",
|
|
189
|
+
"--verbose",
|
|
190
|
+
"--setting-sources",
|
|
191
|
+
"user",
|
|
192
|
+
]
|
|
193
|
+
if self.autonomy == "unsafe-full":
|
|
194
|
+
argv += ["--permission-mode", "bypassPermissions"]
|
|
195
|
+
else:
|
|
196
|
+
argv += ["--permission-mode", "acceptEdits"]
|
|
197
|
+
if self.model:
|
|
198
|
+
argv += ["--model", self.model]
|
|
199
|
+
if brief.json_schema is not None:
|
|
200
|
+
# --json-schema requires --output-format json; the single result
|
|
201
|
+
# envelope is still parsed by ClaudeParser.
|
|
202
|
+
argv[argv.index("stream-json")] = "json"
|
|
203
|
+
argv.remove("--verbose")
|
|
204
|
+
argv += ["--json-schema", json.dumps(brief.json_schema)]
|
|
205
|
+
if brief.resume_session_id:
|
|
206
|
+
argv += ["--resume", brief.resume_session_id]
|
|
207
|
+
argv.append(brief.instructions)
|
|
208
|
+
return InvocationSpec(argv=argv, cwd=brief.cwd, timeout_s=brief.timeout_s)
|
|
209
|
+
|
|
210
|
+
def make_parser(self, brief: TaskBrief) -> StreamParser:
|
|
211
|
+
return ClaudeParser()
|