eca-pp 0.4.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.
eca_pp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Standalone, orchestrator-free curation steps (spec: docs/standardize-spec.md)."""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,196 @@
1
+ """``HARNESS=claude`` backend for :mod:`eca_pp.harness`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import shutil
8
+
9
+ from .harness import (
10
+ AgentIncompleteError,
11
+ AgentRunResult,
12
+ AgentTimeout,
13
+ AgentUnavailable,
14
+ ToolSpec,
15
+ )
16
+
17
+ MIN_CLAUDE_AGENT_SDK = (0, 2, 152)
18
+ CLI_ENV = "ECA_PP_CLAUDE_CLI"
19
+ _BUILTIN = {"read": ["Read"], "glob": ["Glob"], "grep": ["Grep"]}
20
+
21
+
22
+ def _version_tuple(value: str) -> tuple[int, ...]:
23
+ result = []
24
+ for part in value.split("."):
25
+ digits = "".join(ch for ch in part if ch.isdigit())
26
+ if not digits:
27
+ break
28
+ result.append(int(digits))
29
+ return tuple(result)
30
+
31
+
32
+ def check_available() -> None:
33
+ from importlib.metadata import PackageNotFoundError, version
34
+
35
+ if not os.environ.get("ANTHROPIC_API_KEY") and not os.path.isfile(
36
+ os.path.expanduser("~/.claude/.credentials.json")
37
+ ):
38
+ raise AgentUnavailable("HARNESS=claude has no API key or Claude CLI credentials")
39
+ try:
40
+ installed = version("claude-agent-sdk")
41
+ except PackageNotFoundError:
42
+ raise AgentUnavailable(
43
+ "HARNESS=claude needs claude-agent-sdk>=0.2.152"
44
+ ) from None
45
+ if _version_tuple(installed) < MIN_CLAUDE_AGENT_SDK:
46
+ floor = ".".join(map(str, MIN_CLAUDE_AGENT_SDK))
47
+ raise AgentUnavailable(
48
+ f"claude-agent-sdk {installed} is too old; install >={floor}"
49
+ )
50
+
51
+
52
+ def _cli_path() -> str | None:
53
+ return os.environ.get(CLI_ENV) or shutil.which("claude")
54
+
55
+
56
+ async def _bounded(stream, deadline, label: str, wall_seconds: float | None):
57
+ iterator = stream.__aiter__()
58
+ try:
59
+ while True:
60
+ timeout = None if deadline is None else max(
61
+ 0.0, deadline - asyncio.get_running_loop().time()
62
+ )
63
+ try:
64
+ yield await asyncio.wait_for(iterator.__anext__(), timeout=timeout)
65
+ except StopAsyncIteration:
66
+ return
67
+ except asyncio.TimeoutError:
68
+ raise AgentTimeout(
69
+ f"[{label}] agent run exceeded {wall_seconds / 60:g} min "
70
+ "(AGENT_WALL_MIN)"
71
+ ) from None
72
+ finally:
73
+ await iterator.aclose()
74
+
75
+
76
+ async def run_agent(
77
+ *,
78
+ tools: list[ToolSpec],
79
+ submit_tool: str,
80
+ prompt: str,
81
+ system_prompt: str | None,
82
+ cwd: str,
83
+ model: str | None,
84
+ effort: str | None,
85
+ max_turns: int,
86
+ allowed_builtin: tuple[str, ...],
87
+ label: str,
88
+ max_buffer_size: int | None,
89
+ wall_seconds: float | None = None,
90
+ ) -> AgentRunResult:
91
+ check_available()
92
+ from claude_agent_sdk import (
93
+ AssistantMessage,
94
+ ClaudeAgentOptions,
95
+ ResultMessage,
96
+ ToolResultBlock,
97
+ ToolUseBlock,
98
+ UserMessage,
99
+ create_sdk_mcp_server,
100
+ query,
101
+ tool,
102
+ )
103
+
104
+ os.makedirs(cwd, exist_ok=True)
105
+ server_name = "eca_pp_tools"
106
+ submitted: dict = {}
107
+
108
+ def wrap(spec: ToolSpec):
109
+ is_submit = spec.name == submit_tool
110
+
111
+ @tool(spec.name, spec.description, spec.input_schema)
112
+ async def handler(args):
113
+ result = await spec.handler(args)
114
+ if is_submit and not result.get("is_error"):
115
+ submitted["value"] = result.get("_submitted", args)
116
+ return {key: value for key, value in result.items() if key != "_submitted"}
117
+
118
+ return handler
119
+
120
+ server = create_sdk_mcp_server(
121
+ name=server_name, version="1.0.0", tools=[wrap(spec) for spec in tools]
122
+ )
123
+ allowed_tools = [
124
+ name for builtin in allowed_builtin for name in _BUILTIN.get(builtin, [])
125
+ ] + [f"mcp__{server_name}__{spec.name}" for spec in tools]
126
+ options = ClaudeAgentOptions(
127
+ mcp_servers={server_name: server},
128
+ allowed_tools=allowed_tools,
129
+ disallowed_tools=[
130
+ "Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "WebFetch",
131
+ "WebSearch", "Agent", "Task",
132
+ ],
133
+ permission_mode="bypassPermissions",
134
+ cwd=cwd,
135
+ max_turns=max_turns,
136
+ system_prompt=system_prompt,
137
+ model=model,
138
+ effort=effort,
139
+ cli_path=_cli_path(),
140
+ setting_sources=[],
141
+ strict_mcp_config=True,
142
+ max_buffer_size=max_buffer_size or 32 * 1024 * 1024,
143
+ )
144
+
145
+ transcript = None
146
+ tools_used: list[dict] = []
147
+ usage = {
148
+ "backend": "claude", "model": model, "cost_usd": None,
149
+ "input_tokens": None, "output_tokens": None,
150
+ "cache_creation_tokens": None, "cache_read_tokens": None,
151
+ "num_turns": None,
152
+ }
153
+ pending: dict[str, str] = {}
154
+ deadline = None if wall_seconds is None else \
155
+ asyncio.get_running_loop().time() + wall_seconds
156
+ async for message in _bounded(query(prompt=prompt, options=options), deadline, label, wall_seconds):
157
+ if isinstance(message, AssistantMessage):
158
+ if getattr(message, "model", None):
159
+ usage["model"] = message.model
160
+ for block in message.content:
161
+ if isinstance(block, ToolUseBlock):
162
+ pending[block.id] = block.name
163
+ target = str(next(iter((block.input or {}).values()), ""))[:200]
164
+ tools_used.append({"tool": block.name, "target": target})
165
+ print(f"== [{label}] agent: {block.name}({target[:80]})", flush=True)
166
+ elif isinstance(message, UserMessage) and isinstance(message.content, list):
167
+ for block in message.content:
168
+ if isinstance(block, ToolResultBlock) and block.is_error:
169
+ detail = block.content if isinstance(block.content, str) else str(block.content)
170
+ print(
171
+ f"== [{label}] tool error in {pending.get(block.tool_use_id, '?')}: "
172
+ f"{detail[:200]!r}", flush=True,
173
+ )
174
+ elif isinstance(message, ResultMessage):
175
+ transcript = message.result
176
+ if message.is_error or message.subtype != "success":
177
+ raise RuntimeError(
178
+ f"[{label}] Claude run ended with {message.subtype}: {message.result}"
179
+ )
180
+ raw = message.usage or {}
181
+ get = raw.get if isinstance(raw, dict) else lambda key, default=None: getattr(raw, key, default)
182
+ usage.update({
183
+ "cost_usd": message.total_cost_usd,
184
+ "input_tokens": get("input_tokens"),
185
+ "output_tokens": get("output_tokens"),
186
+ "cache_creation_tokens": get("cache_creation_input_tokens"),
187
+ "cache_read_tokens": get("cache_read_input_tokens"),
188
+ "num_turns": getattr(message, "num_turns", None),
189
+ })
190
+
191
+ if "value" not in submitted:
192
+ raise AgentIncompleteError(
193
+ f"[{label}] agent finished without a successful {submit_tool} call. "
194
+ f"Final reply:\n{transcript}"
195
+ )
196
+ return AgentRunResult(submitted["value"], transcript, tools_used, usage)