symphony-code 0.0.1__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.
- coding_agent/__init__.py +20 -0
- coding_agent/_lazy.py +17 -0
- coding_agent/addons/__init__.py +35 -0
- coding_agent/addons/approvals.py +224 -0
- coding_agent/addons/compaction/__init__.py +36 -0
- coding_agent/addons/compaction/addon.py +88 -0
- coding_agent/addons/compaction/compactor.py +138 -0
- coding_agent/addons/compaction/prompts.py +20 -0
- coding_agent/addons/compaction/transcript.py +118 -0
- coding_agent/addons/evaluation/__init__.py +68 -0
- coding_agent/addons/evaluation/addon.py +199 -0
- coding_agent/addons/evaluation/evaluators.py +245 -0
- coding_agent/addons/evaluation/handlers.py +66 -0
- coding_agent/addons/evaluation/policy.py +277 -0
- coding_agent/addons/evaluation/protocol.py +192 -0
- coding_agent/addons/evaluation/state.py +284 -0
- coding_agent/addons/langfuse/__init__.py +24 -0
- coding_agent/addons/langfuse/addon.py +465 -0
- coding_agent/addons/langfuse/install.py +79 -0
- coding_agent/addons/langfuse/serialize.py +158 -0
- coding_agent/addons/learning/__init__.py +33 -0
- coding_agent/addons/learning/addon.py +181 -0
- coding_agent/addons/learning/loop.py +247 -0
- coding_agent/addons/learning/prompts.py +26 -0
- coding_agent/addons/learning/sanitize.py +64 -0
- coding_agent/addons/learning/store.py +522 -0
- coding_agent/addons/manager/__init__.py +18 -0
- coding_agent/addons/manager/bridge.py +74 -0
- coding_agent/addons/manager/discovery.py +34 -0
- coding_agent/addons/manager/report.py +33 -0
- coding_agent/addons/manager/runtime.py +76 -0
- coding_agent/addons/manager/tool_changes.py +16 -0
- coding_agent/addons/persistence/__init__.py +41 -0
- coding_agent/addons/persistence/active.py +132 -0
- coding_agent/addons/persistence/artifacts.py +110 -0
- coding_agent/addons/persistence/collection.py +166 -0
- coding_agent/addons/persistence/compatibility.py +30 -0
- coding_agent/addons/persistence/images.py +133 -0
- coding_agent/addons/persistence/jsonl.py +1195 -0
- coding_agent/addons/persistence/presentation.py +104 -0
- coding_agent/addons/plugins/__init__.py +19 -0
- coding_agent/addons/plugins/install.py +37 -0
- coding_agent/addons/plugins/manager.py +140 -0
- coding_agent/addons/plugins/models.py +50 -0
- coding_agent/addons/skills/__init__.py +28 -0
- coding_agent/addons/skills/addon.py +26 -0
- coding_agent/addons/skills/args.py +78 -0
- coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
- coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
- coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
- coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
- coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
- coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
- coding_agent/addons/skills/frontmatter.py +57 -0
- coding_agent/addons/skills/models.py +57 -0
- coding_agent/addons/skills/registry.py +109 -0
- coding_agent/addons/skills/tool.py +54 -0
- coding_agent/addons/voice/__init__.py +5 -0
- coding_agent/addons/voice/addon.py +106 -0
- coding_agent/addons/voice/credentials.py +42 -0
- coding_agent/addons/voice/playback.py +42 -0
- coding_agent/addons/voice/speak.py +287 -0
- coding_agent/addons/voice/stream.py +651 -0
- coding_agent/addons/voice/summary.py +126 -0
- coding_agent/agent.py +523 -0
- coding_agent/bench/__init__.py +1 -0
- coding_agent/bench/cli.py +200 -0
- coding_agent/bench/patch.py +78 -0
- coding_agent/commands/__init__.py +5 -0
- coding_agent/commands/catalog.py +179 -0
- coding_agent/commands/jev_argument.py +40 -0
- coding_agent/config/__init__.py +51 -0
- coding_agent/config/config.py +376 -0
- coding_agent/config/credentials.py +153 -0
- coding_agent/config/personalities.json +34 -0
- coding_agent/config/personalities.py +170 -0
- coding_agent/config/prompts.py +67 -0
- coding_agent/protocols/__init__.py +5 -0
- coding_agent/protocols/acp/README.md +36 -0
- coding_agent/protocols/acp/__init__.py +27 -0
- coding_agent/protocols/acp/mapping.py +164 -0
- coding_agent/protocols/acp/server.py +261 -0
- coding_agent/protocols/acp/transport.py +72 -0
- coding_agent/protocols/stdio/README.md +36 -0
- coding_agent/protocols/stdio/__init__.py +37 -0
- coding_agent/protocols/stdio/catalog.py +49 -0
- coding_agent/protocols/stdio/commands.py +218 -0
- coding_agent/protocols/stdio/server.py +181 -0
- coding_agent/protocols/stdio/transport.py +71 -0
- coding_agent/py.typed +0 -0
- coding_agent/resources/__init__.py +5 -0
- coding_agent/resources/usage.py +264 -0
- coding_agent/run/__init__.py +1 -0
- coding_agent/run/cli.py +99 -0
- coding_agent/run/detach.py +37 -0
- coding_agent/run/interrupted.py +67 -0
- coding_agent/run/log_sink.py +46 -0
- coding_agent/tools/__init__.py +73 -0
- coding_agent/tools/ask_user.py +61 -0
- coding_agent/tools/base.py +136 -0
- coding_agent/tools/bash.py +288 -0
- coding_agent/tools/bash_jobs.py +116 -0
- coding_agent/tools/generate_image.py +123 -0
- coding_agent/tools/memory.py +100 -0
- coding_agent/tools/patch.py +218 -0
- coding_agent/tools/read_file.py +167 -0
- coding_agent/tools/search.py +433 -0
- coding_agent/tools/web_search.py +151 -0
- coding_agent/tools/write_file.py +40 -0
- coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
- coding_agent/tui/__init__.py +22 -0
- coding_agent/tui/__main__.py +122 -0
- coding_agent/tui/app.py +456 -0
- coding_agent/tui/chrome/__init__.py +33 -0
- coding_agent/tui/chrome/footer.py +274 -0
- coding_agent/tui/chrome/topbar.py +259 -0
- coding_agent/tui/commands/__init__.py +45 -0
- coding_agent/tui/commands/catalog.py +35 -0
- coding_agent/tui/commands/manager.py +557 -0
- coding_agent/tui/composer/__init__.py +18 -0
- coding_agent/tui/composer/input.py +219 -0
- coding_agent/tui/composer/slash_menu.py +341 -0
- coding_agent/tui/composer/surface.py +330 -0
- coding_agent/tui/composer/voice.py +267 -0
- coding_agent/tui/composer/voice_mode.py +491 -0
- coding_agent/tui/runtime/__init__.py +23 -0
- coding_agent/tui/runtime/events.py +728 -0
- coding_agent/tui/runtime/sink.py +115 -0
- coding_agent/tui/runtime/state.py +145 -0
- coding_agent/tui/runtime/subagent.py +531 -0
- coding_agent/tui/runtime/turn.py +206 -0
- coding_agent/tui/screens/__init__.py +54 -0
- coding_agent/tui/screens/ask.py +69 -0
- coding_agent/tui/screens/context.py +195 -0
- coding_agent/tui/screens/dashboard.py +91 -0
- coding_agent/tui/screens/diff.py +302 -0
- coding_agent/tui/screens/extensions.py +68 -0
- coding_agent/tui/screens/file_selector.py +167 -0
- coding_agent/tui/screens/history.py +458 -0
- coding_agent/tui/screens/langfuse.py +142 -0
- coding_agent/tui/screens/learning.py +113 -0
- coding_agent/tui/screens/modal.py +131 -0
- coding_agent/tui/screens/onboard.py +455 -0
- coding_agent/tui/screens/resume.py +163 -0
- coding_agent/tui/theme/__init__.py +49 -0
- coding_agent/tui/theme/load.py +325 -0
- coding_agent/tui/theme/style.py +95 -0
- coding_agent/tui/theme/theme.toml +1687 -0
- coding_agent/tui/tools/__init__.py +53 -0
- coding_agent/tui/tools/activity.py +184 -0
- coding_agent/tui/tools/calls.py +560 -0
- coding_agent/tui/tools/code_view.py +52 -0
- coding_agent/tui/tools/images.py +250 -0
- coding_agent/tui/tools/labels.py +135 -0
- coding_agent/tui/tools/modal.py +137 -0
- coding_agent/tui/tools/partial_args.py +19 -0
- coding_agent/tui/tools/snapshots.py +382 -0
- coding_agent/tui/transcript/__init__.py +36 -0
- coding_agent/tui/transcript/messages.py +408 -0
- coding_agent/tui/transcript/process.py +504 -0
- coding_agent/tui/transcript/surface.py +345 -0
- coding_agent/tui/transcript/thinking_markdown.py +314 -0
- symphony_code-0.0.1.dist-info/METADATA +66 -0
- symphony_code-0.0.1.dist-info/RECORD +169 -0
- symphony_code-0.0.1.dist-info/WHEEL +4 -0
- symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
- symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Decide whether the most recent session is an interrupted run worth continuing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from core_harness import Checkpoint
|
|
11
|
+
|
|
12
|
+
from coding_agent.addons.persistence import JsonlPersistence, SessionSummary
|
|
13
|
+
from coding_agent.addons.persistence.artifacts import safe_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def interrupted_session(persistence: JsonlPersistence) -> Optional[tuple[SessionSummary, Checkpoint]]:
|
|
17
|
+
"""Most recent session, only if its last checkpoint still says ``running``."""
|
|
18
|
+
sessions = await persistence.list_sessions()
|
|
19
|
+
if not sessions:
|
|
20
|
+
return None
|
|
21
|
+
checkpoint = await persistence.load_checkpoint(session_id=sessions[0].session_id)
|
|
22
|
+
if checkpoint is None or checkpoint.status != "running":
|
|
23
|
+
return None
|
|
24
|
+
return sessions[0], checkpoint
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def detached_run_pid(persistence: JsonlPersistence, session_id: str) -> Optional[int]:
|
|
28
|
+
"""Pid recorded for a detached run, if run.json has one."""
|
|
29
|
+
try:
|
|
30
|
+
path = safe_path(persistence.session_dir(session_id), "run.json")
|
|
31
|
+
except ValueError:
|
|
32
|
+
return None
|
|
33
|
+
if not path.is_file():
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
37
|
+
except (OSError, ValueError):
|
|
38
|
+
return None
|
|
39
|
+
if not isinstance(payload, dict):
|
|
40
|
+
return None
|
|
41
|
+
pid = payload.get("pid")
|
|
42
|
+
if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 1:
|
|
43
|
+
return None
|
|
44
|
+
return pid
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def stop_orphaned_jobs(jobs: object) -> list[str]:
|
|
48
|
+
"""SIGKILL job process groups (own session, so pgid == pid) that outlived the killed run."""
|
|
49
|
+
stopped = []
|
|
50
|
+
for job_id, pid in jobs.items() if isinstance(jobs, dict) else []:
|
|
51
|
+
if not isinstance(pid, int) or pid <= 1:
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
os.killpg(pid, signal.SIGKILL)
|
|
55
|
+
except (ProcessLookupError, PermissionError):
|
|
56
|
+
continue
|
|
57
|
+
stopped.append(f"{job_id} (pid {pid})")
|
|
58
|
+
return stopped
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resume_note(summary: SessionSummary, checkpoint: Checkpoint, stopped: list[str]) -> str:
|
|
62
|
+
goal = checkpoint.metadata.get("goal") or summary.first_message
|
|
63
|
+
return (
|
|
64
|
+
f"previous run was interrupted at {summary.updated_at}; "
|
|
65
|
+
f"background jobs {', '.join(stopped) or 'none'} were stopped; "
|
|
66
|
+
f"continue toward the original task: {goal}"
|
|
67
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Headless event sink: one log line per notable event, nothing kept in memory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
from core_harness import EventSink
|
|
10
|
+
|
|
11
|
+
LOGGED_EVENTS = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"run_started",
|
|
14
|
+
"tool_execution_started",
|
|
15
|
+
"auto_decision",
|
|
16
|
+
"waiting_for_children",
|
|
17
|
+
"message_injected",
|
|
18
|
+
"compaction_completed",
|
|
19
|
+
"run_completed",
|
|
20
|
+
"run_failed",
|
|
21
|
+
"run_cancelled",
|
|
22
|
+
"run_limit_exceeded",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
QUIET_KEYS = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"run_id",
|
|
28
|
+
"session_id",
|
|
29
|
+
"seq",
|
|
30
|
+
"ts",
|
|
31
|
+
"schema_version",
|
|
32
|
+
"parent_id",
|
|
33
|
+
"started_at",
|
|
34
|
+
"context",
|
|
35
|
+
"usage",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LogLineSink(EventSink):
|
|
41
|
+
async def deliver(self, event_type: str, payload: Dict[str, Any]) -> None:
|
|
42
|
+
name = event_type
|
|
43
|
+
if name not in LOGGED_EVENTS:
|
|
44
|
+
return
|
|
45
|
+
detail = {key: value for key, value in payload.items() if key not in QUIET_KEYS}
|
|
46
|
+
print(f"{time.strftime('%H:%M:%S')} {name} {json.dumps(detail, default=str)[:400]}", flush=True)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Coding-agent tools."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Type
|
|
5
|
+
|
|
6
|
+
from core_harness import Tool
|
|
7
|
+
|
|
8
|
+
from coding_agent.config import ToolsConfig
|
|
9
|
+
from coding_agent.tools.base import ToolArgsModel, WorkspaceTool
|
|
10
|
+
from coding_agent.tools.ask_user import AskUserArgs, AskUserTool
|
|
11
|
+
from coding_agent.tools.bash import BashArgs, BashTool
|
|
12
|
+
from coding_agent.tools.bash_jobs import BashJobs
|
|
13
|
+
from coding_agent.tools.generate_image import GenerateImageArgs, GenerateImageTool
|
|
14
|
+
from coding_agent.tools.memory import MemoryArgs, MemoryGetTool, MemorySearchTool, MemoryTool
|
|
15
|
+
from coding_agent.tools.patch import PatchArgs, PatchTool
|
|
16
|
+
from coding_agent.tools.read_file import ReadFileArgs, ReadFileTool
|
|
17
|
+
from coding_agent.tools.search import SearchArgs, SearchTool
|
|
18
|
+
from coding_agent.tools.web_search import WebSearchArgs, WebSearchTool
|
|
19
|
+
from coding_agent.tools.write_file import WriteFileArgs, WriteFileTool
|
|
20
|
+
|
|
21
|
+
TOOL_CLASSES: tuple[Type[WorkspaceTool], ...] = (
|
|
22
|
+
ReadFileTool,
|
|
23
|
+
WriteFileTool,
|
|
24
|
+
GenerateImageTool,
|
|
25
|
+
MemoryTool,
|
|
26
|
+
MemorySearchTool,
|
|
27
|
+
MemoryGetTool,
|
|
28
|
+
PatchTool,
|
|
29
|
+
BashTool,
|
|
30
|
+
SearchTool,
|
|
31
|
+
WebSearchTool,
|
|
32
|
+
AskUserTool,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AskUserArgs", "AskUserTool", "BashArgs", "BashJobs", "BashTool",
|
|
37
|
+
"GenerateImageArgs", "GenerateImageTool", "MemoryArgs", "MemoryTool", "PatchArgs", "PatchTool",
|
|
38
|
+
"ReadFileArgs", "ReadFileTool", "SearchArgs", "SearchTool", "TOOL_CLASSES",
|
|
39
|
+
"ToolArgsModel", "WebSearchArgs", "WebSearchTool", "WorkspaceTool", "WriteFileArgs", "WriteFileTool",
|
|
40
|
+
"build_tools",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_tools(
|
|
45
|
+
workspace: str | Path,
|
|
46
|
+
*,
|
|
47
|
+
config: ToolsConfig | None = None,
|
|
48
|
+
learning_enabled: bool = False,
|
|
49
|
+
unattended: bool = False,
|
|
50
|
+
bash_jobs: BashJobs | None = None,
|
|
51
|
+
learning_store: object | None = None,
|
|
52
|
+
) -> List[Tool]:
|
|
53
|
+
tools_config = config or ToolsConfig()
|
|
54
|
+
configured = {
|
|
55
|
+
AskUserTool: {"unattended": unattended},
|
|
56
|
+
BashTool: {"config": tools_config.bash, "jobs": bash_jobs},
|
|
57
|
+
ReadFileTool: {"config": tools_config.read_file},
|
|
58
|
+
SearchTool: {"config": tools_config.search},
|
|
59
|
+
WebSearchTool: {"config": tools_config.web_search},
|
|
60
|
+
MemoryTool: {"store": learning_store},
|
|
61
|
+
MemorySearchTool: {"store": learning_store},
|
|
62
|
+
MemoryGetTool: {"store": learning_store},
|
|
63
|
+
}
|
|
64
|
+
skipped: set[Type[WorkspaceTool]] = set()
|
|
65
|
+
if not learning_enabled:
|
|
66
|
+
skipped |= {MemoryTool, MemorySearchTool, MemoryGetTool}
|
|
67
|
+
if not tools_config.web_search.enabled:
|
|
68
|
+
skipped.add(WebSearchTool)
|
|
69
|
+
classes = tuple(tool for tool in TOOL_CLASSES if tool not in skipped)
|
|
70
|
+
return [
|
|
71
|
+
tool_class(workspace, **configured.get(tool_class, {})).as_harness_tool()
|
|
72
|
+
for tool_class in classes
|
|
73
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Ask the human a clarifying question and wait for an answer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from core_harness.events import EventSink
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from coding_agent.tools.base import ToolArgsModel, WorkspaceTool
|
|
10
|
+
|
|
11
|
+
NO_HUMAN_ANSWER = "no human available; pick the safest reasonable option and continue"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AskUserArgs(ToolArgsModel):
|
|
15
|
+
question: str = Field(..., min_length=1, description="Clarifying question to show the user.")
|
|
16
|
+
choices: list[str] = Field(default_factory=list, description="Optional preset answers to present.")
|
|
17
|
+
default: str = Field(default="", description="Optional suggested answer.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AskUserTool(WorkspaceTool):
|
|
21
|
+
name = "ask_user"
|
|
22
|
+
description = (
|
|
23
|
+
"Ask the user a clarifying question during an interactive run and return their answer. "
|
|
24
|
+
"Use when the task is ambiguous or blocked on a human decision. "
|
|
25
|
+
"Keep the question concise and specific."
|
|
26
|
+
)
|
|
27
|
+
args_model = AskUserArgs
|
|
28
|
+
|
|
29
|
+
def __init__(self, workspace: str, *, unattended: bool = False) -> None:
|
|
30
|
+
self.unattended = unattended
|
|
31
|
+
super().__init__(workspace)
|
|
32
|
+
|
|
33
|
+
async def run(
|
|
34
|
+
self,
|
|
35
|
+
question: str,
|
|
36
|
+
choices: list[str] | None = None,
|
|
37
|
+
default: str = "",
|
|
38
|
+
sink: EventSink | None = None,
|
|
39
|
+
) -> str:
|
|
40
|
+
question = question.strip()
|
|
41
|
+
if not question:
|
|
42
|
+
return "error: question must be a non-empty string"
|
|
43
|
+
|
|
44
|
+
if self.unattended:
|
|
45
|
+
# ApprovalAddon records the auto_decision; never block on a human here.
|
|
46
|
+
hints = [f"default: {default}"] if default else []
|
|
47
|
+
hints += [f"choices: {', '.join(choices)}"] if choices else []
|
|
48
|
+
return "\n".join([NO_HUMAN_ANSWER, *hints])
|
|
49
|
+
|
|
50
|
+
if sink is None:
|
|
51
|
+
return "error: interactive user questions require an interactive control plane"
|
|
52
|
+
|
|
53
|
+
answer = await sink.interaction.request_user_input(
|
|
54
|
+
question=question,
|
|
55
|
+
choices=list(choices),
|
|
56
|
+
default=default,
|
|
57
|
+
)
|
|
58
|
+
answer = str(answer or "").strip()
|
|
59
|
+
if not answer and default:
|
|
60
|
+
return default
|
|
61
|
+
return answer or ""
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Tool base: working-directory relative paths, schema generation, validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated, Any, ClassVar, Dict, Type
|
|
10
|
+
|
|
11
|
+
from pydantic import AfterValidator, BaseModel, ConfigDict, ValidationError
|
|
12
|
+
|
|
13
|
+
from core_harness import Tool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _strip_path(value: str) -> str:
|
|
17
|
+
stripped = value.strip()
|
|
18
|
+
if not stripped:
|
|
19
|
+
raise ValueError("path must be a non-empty string")
|
|
20
|
+
return stripped
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# A path argument with surrounding whitespace removed. Use it on models that
|
|
24
|
+
# keep str_strip_whitespace off so file content stays whitespace-exact.
|
|
25
|
+
NonEmptyPath = Annotated[str, AfterValidator(_strip_path)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WorkspaceTool(Tool, ABC):
|
|
29
|
+
"""File/shell tool. Relative paths start at the working directory."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
description: str
|
|
33
|
+
args_model: ClassVar[Type[BaseModel]]
|
|
34
|
+
|
|
35
|
+
def __init__(self, workspace: str | Path, *, parallel: bool = False) -> None:
|
|
36
|
+
"""``parallel`` marks a read-only tool the harness may batch with others.
|
|
37
|
+
|
|
38
|
+
The harness caps a batch at ``max_parallel_tool_calls``; tools that write
|
|
39
|
+
or run commands must leave this ``False`` so they stay serial.
|
|
40
|
+
"""
|
|
41
|
+
self.workspace = Path(workspace).expanduser().resolve()
|
|
42
|
+
self.workspace.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
super().__init__(
|
|
44
|
+
name=type(self).name,
|
|
45
|
+
description=type(self).description,
|
|
46
|
+
parameters=self.parameters_schema(),
|
|
47
|
+
parallel=parallel,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def resolve_path(self, path: str) -> Path:
|
|
51
|
+
"""Resolve ``path``. Absolute and ``~`` paths are used as-is; others join the working directory."""
|
|
52
|
+
if not isinstance(path, str):
|
|
53
|
+
raise TypeError(f"path must be a string, got {type(path).__name__}")
|
|
54
|
+
stripped = path.strip()
|
|
55
|
+
if not stripped:
|
|
56
|
+
raise ValueError("path must be a non-empty string")
|
|
57
|
+
candidate = Path(stripped).expanduser()
|
|
58
|
+
if not candidate.is_absolute():
|
|
59
|
+
candidate = self.workspace / candidate
|
|
60
|
+
return candidate.resolve()
|
|
61
|
+
|
|
62
|
+
def parameters_schema(self) -> Dict[str, Any]:
|
|
63
|
+
"""JSON Schema for tool arguments, including optional UI activity metadata."""
|
|
64
|
+
schema = self.args_model.model_json_schema()
|
|
65
|
+
schema.pop("title", None)
|
|
66
|
+
schema.setdefault("type", "object")
|
|
67
|
+
schema.setdefault("additionalProperties", False)
|
|
68
|
+
properties = schema.setdefault("properties", {})
|
|
69
|
+
properties["activity"] = {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"description": (
|
|
72
|
+
"Optional UI context as a nested object. Do not put verb, reason, "
|
|
73
|
+
"goal, or group at the top level of the tool arguments. "
|
|
74
|
+
"Set activity.verb to a short present-participle label such as "
|
|
75
|
+
"'Searching' or 'Reading', activity.reason to why this call is "
|
|
76
|
+
"needed, and activity.group to a stable label shared by related calls."
|
|
77
|
+
),
|
|
78
|
+
"properties": {
|
|
79
|
+
"verb": {
|
|
80
|
+
"type": "string",
|
|
81
|
+
"description": "Short present-participle label, e.g. Searching, Reading.",
|
|
82
|
+
},
|
|
83
|
+
"reason": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"description": "Why this call is needed.",
|
|
86
|
+
},
|
|
87
|
+
"group": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"description": "Stable label shared by related calls.",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
"additionalProperties": False,
|
|
93
|
+
}
|
|
94
|
+
return schema
|
|
95
|
+
|
|
96
|
+
def validate_args(self, **kwargs: Any) -> BaseModel:
|
|
97
|
+
"""Validate and coerce tool kwargs with the tool's pydantic args model."""
|
|
98
|
+
try:
|
|
99
|
+
return self.args_model.model_validate(kwargs)
|
|
100
|
+
except ValidationError as exc:
|
|
101
|
+
raise ValueError(f"invalid {self.name} arguments: {exc}") from exc
|
|
102
|
+
|
|
103
|
+
def prepare_args(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
104
|
+
"""Remove presentation-only metadata before schema validation."""
|
|
105
|
+
prepared = dict(args)
|
|
106
|
+
for key in ("activity", "verb", "reason", "goal", "group"):
|
|
107
|
+
prepared.pop(key, None)
|
|
108
|
+
return prepared
|
|
109
|
+
|
|
110
|
+
@abstractmethod
|
|
111
|
+
def run(self, *args: Any, **kwargs: Any) -> str | list[dict[str, Any]]:
|
|
112
|
+
"""Execute the tool and return a string or multimodal content for the model."""
|
|
113
|
+
|
|
114
|
+
async def execute(self, *, sink: Any, args: Dict[str, Any]) -> Any:
|
|
115
|
+
run_signature = inspect.signature(self.run)
|
|
116
|
+
accepts_sink = "sink" in run_signature.parameters
|
|
117
|
+
validated = self.validate_args(**self.prepare_args(args))
|
|
118
|
+
kwargs = {**validated.model_dump(), **({"sink": sink} if accepts_sink else {})}
|
|
119
|
+
if self.parallel and not inspect.iscoroutinefunction(self.run):
|
|
120
|
+
# Parallel tools run off the event loop so a batch actually overlaps.
|
|
121
|
+
result = await asyncio.to_thread(self.run, **kwargs)
|
|
122
|
+
else:
|
|
123
|
+
result = self.run(**kwargs)
|
|
124
|
+
if inspect.isawaitable(result):
|
|
125
|
+
result = await result
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
def as_harness_tool(self) -> Tool:
|
|
129
|
+
"""Return ``self`` — these tools already are harness tools."""
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ToolArgsModel(BaseModel):
|
|
134
|
+
"""Strict base for tool argument models."""
|
|
135
|
+
|
|
136
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Run a shell command inside the workspace without blocking the event loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import codecs
|
|
7
|
+
import contextlib
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import TYPE_CHECKING, Literal
|
|
12
|
+
|
|
13
|
+
from core_harness import EventSink
|
|
14
|
+
from core_harness.tools import current_tool_call_id
|
|
15
|
+
from pydantic import Field
|
|
16
|
+
|
|
17
|
+
from coding_agent.config import BashConfig
|
|
18
|
+
from coding_agent.tools.base import ToolArgsModel, WorkspaceTool
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from coding_agent.tools.bash_jobs import BashJobs
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASH_CONFIG = BashConfig()
|
|
24
|
+
# Live output for UIs (the TUI's running Bash card shows its tail). Chunks are
|
|
25
|
+
# coalesced so a chatty command costs at most one event per interval.
|
|
26
|
+
OUTPUT_EVENT = "tool_execution_output"
|
|
27
|
+
OUTPUT_FLUSH_SECONDS = 0.1
|
|
28
|
+
# Most characters sent per event. A UI shows a tail, so when a burst exceeds
|
|
29
|
+
# this only its newest lines are sent; the tool result is not affected.
|
|
30
|
+
OUTPUT_EVENT_CHARS = 4096
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BashArgs(ToolArgsModel):
|
|
34
|
+
command: str = Field(
|
|
35
|
+
default="",
|
|
36
|
+
description=(
|
|
37
|
+
"Shell command to run with cwd set to the working directory. Pipelines, &&, loops, "
|
|
38
|
+
"and heredoc scripts are fine (e.g. 'python -m pytest -q 2>&1 | tail -20', "
|
|
39
|
+
"'rg -l foo | xargs wc -l'). Required for action=run."
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
timeout: int = Field(
|
|
43
|
+
default=DEFAULT_BASH_CONFIG.default_timeout_seconds,
|
|
44
|
+
ge=1,
|
|
45
|
+
description="Seconds to wait before killing the process group.",
|
|
46
|
+
)
|
|
47
|
+
background: bool = Field(
|
|
48
|
+
default=False,
|
|
49
|
+
description=(
|
|
50
|
+
"Run detached and return a job id plus log path at once (for long builds or "
|
|
51
|
+
"test suites). When it exits you are sent its status and output tail "
|
|
52
|
+
"automatically; do not poll. The run cannot finish while a job is alive."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
action: Literal["run", "output", "stop"] = Field(
|
|
56
|
+
default="run",
|
|
57
|
+
description="run a command; output: tail a background job's log; stop: kill a background job.",
|
|
58
|
+
)
|
|
59
|
+
job_id: str = Field(default="", description="Background job id for action=output or stop.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BashTool(WorkspaceTool):
|
|
63
|
+
name = "bash"
|
|
64
|
+
description = (
|
|
65
|
+
"Run a shell command (bash) in the working directory and return combined stdout/stderr. "
|
|
66
|
+
"Reach for this early and often: one well-built command usually beats many small tool calls. "
|
|
67
|
+
"Use pipelines and chaining (rg -n 'TODO' src | head -50, git log --oneline -20 && git status, "
|
|
68
|
+
"ls -R | wc -l), loops, heredocs, and short inline scripts (python - <<'EOF' ... EOF) to explore, "
|
|
69
|
+
"count, compare, transform, and verify in a single call. Prefer it for builds, tests, git, "
|
|
70
|
+
"package managers, codebase surveys, and quick checks; keep read_file for reading a file you "
|
|
71
|
+
"will edit and patch/write_file for edits. "
|
|
72
|
+
"Non-zero exits are returned as text (prefixed with exit=N), not as a tool failure. "
|
|
73
|
+
"Timeouts include captured output. Output is capped to the last bytes, so filter or limit "
|
|
74
|
+
"noisy output in the command itself. "
|
|
75
|
+
"Child processes are killed as a group according to the configured limits."
|
|
76
|
+
)
|
|
77
|
+
args_model = BashArgs
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self, workspace: str, *, config: BashConfig = DEFAULT_BASH_CONFIG, jobs: BashJobs | None = None
|
|
81
|
+
) -> None:
|
|
82
|
+
self.config = config
|
|
83
|
+
self.jobs = jobs
|
|
84
|
+
super().__init__(workspace)
|
|
85
|
+
timeout_schema = self.parameters["properties"]["timeout"]
|
|
86
|
+
timeout_schema["default"] = config.default_timeout_seconds
|
|
87
|
+
timeout_schema["maximum"] = config.max_timeout_seconds
|
|
88
|
+
|
|
89
|
+
def prepare_args(self, args: dict[str, object]) -> dict[str, object]:
|
|
90
|
+
prepared = super().prepare_args(args)
|
|
91
|
+
prepared.setdefault("timeout", self.config.default_timeout_seconds)
|
|
92
|
+
return prepared
|
|
93
|
+
|
|
94
|
+
async def run(
|
|
95
|
+
self,
|
|
96
|
+
command: str,
|
|
97
|
+
timeout: int,
|
|
98
|
+
background: bool = False,
|
|
99
|
+
action: str = "run",
|
|
100
|
+
job_id: str = "",
|
|
101
|
+
sink: EventSink | None = None,
|
|
102
|
+
) -> str:
|
|
103
|
+
if background or action != "run":
|
|
104
|
+
return await self.run_job(command, action, job_id)
|
|
105
|
+
if not isinstance(command, str) or not command.strip():
|
|
106
|
+
return "error: command must be a non-empty string"
|
|
107
|
+
timeout = min(max(int(timeout), 1), self.config.max_timeout_seconds)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
proc = await asyncio.create_subprocess_shell(
|
|
111
|
+
command,
|
|
112
|
+
stdout=asyncio.subprocess.PIPE,
|
|
113
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
114
|
+
cwd=self.workspace,
|
|
115
|
+
start_new_session=True,
|
|
116
|
+
)
|
|
117
|
+
except OSError as exc:
|
|
118
|
+
return f"error: failed to run command: {exc}"
|
|
119
|
+
|
|
120
|
+
tool_call_id = current_tool_call_id.get()
|
|
121
|
+
live = OutputStream(sink, tool_call_id) if sink is not None and tool_call_id else None
|
|
122
|
+
try:
|
|
123
|
+
async with contextlib.AsyncExitStack() as stack:
|
|
124
|
+
if live is not None:
|
|
125
|
+
await stack.enter_async_context(live)
|
|
126
|
+
output, truncated, timed_out = await stream_output(
|
|
127
|
+
proc,
|
|
128
|
+
timeout,
|
|
129
|
+
max_output_bytes=self.config.max_output_bytes,
|
|
130
|
+
live=live,
|
|
131
|
+
)
|
|
132
|
+
except asyncio.CancelledError:
|
|
133
|
+
await stop_process_group(proc)
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
text = decode_capped(output, truncated, self.config.max_output_bytes)
|
|
137
|
+
if timed_out:
|
|
138
|
+
prefix = f"timed out after {timeout}s"
|
|
139
|
+
return f"{prefix}\n{text}".rstrip() if text else prefix
|
|
140
|
+
if proc.returncode not in (0, None):
|
|
141
|
+
return f"exit={proc.returncode}\n{text}".rstrip() if text else f"exit={proc.returncode}"
|
|
142
|
+
return text or "(no output)"
|
|
143
|
+
|
|
144
|
+
async def run_job(self, command: str, action: str, job_id: str) -> str:
|
|
145
|
+
if self.jobs is None:
|
|
146
|
+
return "error: background jobs are not available here"
|
|
147
|
+
if action == "run":
|
|
148
|
+
if not command.strip():
|
|
149
|
+
return "error: command must be a non-empty string"
|
|
150
|
+
return await self.jobs.start(command, self.workspace)
|
|
151
|
+
if not job_id:
|
|
152
|
+
return f"error: job_id is required for action={action}"
|
|
153
|
+
if action == "output":
|
|
154
|
+
return self.jobs.output(job_id, self.config.max_output_bytes)
|
|
155
|
+
return await self.jobs.stop(job_id)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def decode_capped(output: bytes, truncated: bool, cap: int) -> str:
|
|
159
|
+
if truncated:
|
|
160
|
+
index = 0
|
|
161
|
+
while index < len(output) and output[index] & 0xC0 == 0x80:
|
|
162
|
+
index += 1
|
|
163
|
+
output = output[index:]
|
|
164
|
+
text = output.decode("utf-8", errors="replace").rstrip()
|
|
165
|
+
if not truncated:
|
|
166
|
+
return text
|
|
167
|
+
notice = f"...[earlier output truncated, showing last {cap} bytes]..."
|
|
168
|
+
return f"{notice}\n{text}" if text else notice
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class OutputStream:
|
|
172
|
+
"""Send a running command's output to the sink as ``tool_execution_output``.
|
|
173
|
+
|
|
174
|
+
``feed`` only buffers; while the context is open a pump task emits the
|
|
175
|
+
buffer every ``OUTPUT_FLUSH_SECONDS``, and leaving the context normally
|
|
176
|
+
(exit or timeout) sends what is left. A cancelled command sends nothing
|
|
177
|
+
more. Payload: ``tool_call_id``, ``tool_name`` and the text ``delta``.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(self, sink: EventSink, tool_call_id: str) -> None:
|
|
181
|
+
self.sink = sink
|
|
182
|
+
self.tool_call_id = tool_call_id
|
|
183
|
+
self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
184
|
+
self.pending = ""
|
|
185
|
+
self.at_line_start = True
|
|
186
|
+
self.pump: asyncio.Task[None] | None = None
|
|
187
|
+
|
|
188
|
+
def feed(self, data: bytes) -> None:
|
|
189
|
+
self.pending += self.decoder.decode(data)
|
|
190
|
+
|
|
191
|
+
async def flush(self) -> None:
|
|
192
|
+
delta = self.pending
|
|
193
|
+
self.pending = ""
|
|
194
|
+
if len(delta) > OUTPUT_EVENT_CHARS:
|
|
195
|
+
# Keep the newest whole lines; restart the line the UI had open.
|
|
196
|
+
start = len(delta) - OUTPUT_EVENT_CHARS
|
|
197
|
+
newline = delta.find("\n", start)
|
|
198
|
+
delta = delta[newline + 1 :] if 0 <= newline < len(delta) - 1 else delta[start:]
|
|
199
|
+
if not self.at_line_start:
|
|
200
|
+
delta = "\n" + delta
|
|
201
|
+
if not delta:
|
|
202
|
+
return
|
|
203
|
+
self.at_line_start = delta.endswith("\n")
|
|
204
|
+
await self.sink.emit(
|
|
205
|
+
OUTPUT_EVENT,
|
|
206
|
+
{"tool_call_id": self.tool_call_id, "tool_name": BashTool.name, "delta": delta},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
async def run_pump(self) -> None:
|
|
210
|
+
while True:
|
|
211
|
+
await asyncio.sleep(OUTPUT_FLUSH_SECONDS)
|
|
212
|
+
await self.flush()
|
|
213
|
+
|
|
214
|
+
async def __aenter__(self) -> OutputStream:
|
|
215
|
+
self.pump = asyncio.create_task(self.run_pump())
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
async def __aexit__(
|
|
219
|
+
self,
|
|
220
|
+
exc_type: type[BaseException] | None,
|
|
221
|
+
exc: BaseException | None,
|
|
222
|
+
traceback: TracebackType | None,
|
|
223
|
+
) -> None:
|
|
224
|
+
if self.pump is not None:
|
|
225
|
+
self.pump.cancel()
|
|
226
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
227
|
+
await self.pump
|
|
228
|
+
if exc_type is None:
|
|
229
|
+
self.pending += self.decoder.decode(b"", final=True)
|
|
230
|
+
await self.flush()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def stream_output(
|
|
234
|
+
proc: asyncio.subprocess.Process,
|
|
235
|
+
timeout: float,
|
|
236
|
+
*,
|
|
237
|
+
max_output_bytes: int,
|
|
238
|
+
live: OutputStream | None = None,
|
|
239
|
+
) -> tuple[bytes, bool, bool]:
|
|
240
|
+
if proc.stdout is None:
|
|
241
|
+
return b"", False, False
|
|
242
|
+
|
|
243
|
+
buf = bytearray()
|
|
244
|
+
truncated = False
|
|
245
|
+
loop = asyncio.get_running_loop()
|
|
246
|
+
deadline = loop.time() + timeout
|
|
247
|
+
|
|
248
|
+
while True:
|
|
249
|
+
remaining = deadline - loop.time()
|
|
250
|
+
if remaining <= 0:
|
|
251
|
+
await stop_process_group(proc)
|
|
252
|
+
return bytes(buf), truncated, True
|
|
253
|
+
try:
|
|
254
|
+
chunk = await asyncio.wait_for(proc.stdout.read(4096), timeout=remaining)
|
|
255
|
+
except TimeoutError:
|
|
256
|
+
await stop_process_group(proc)
|
|
257
|
+
return bytes(buf), truncated, True
|
|
258
|
+
if not chunk:
|
|
259
|
+
break
|
|
260
|
+
if live is not None:
|
|
261
|
+
live.feed(chunk)
|
|
262
|
+
buf.extend(chunk)
|
|
263
|
+
if len(buf) > max_output_bytes:
|
|
264
|
+
truncated = True
|
|
265
|
+
del buf[:-max_output_bytes]
|
|
266
|
+
|
|
267
|
+
remaining = deadline - loop.time()
|
|
268
|
+
try:
|
|
269
|
+
await asyncio.wait_for(proc.wait(), timeout=max(remaining, 0.01))
|
|
270
|
+
except TimeoutError:
|
|
271
|
+
await stop_process_group(proc)
|
|
272
|
+
return bytes(buf), truncated, True
|
|
273
|
+
return bytes(buf), truncated, False
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def stop_process_group(proc: asyncio.subprocess.Process) -> None:
|
|
277
|
+
if proc.returncode is not None:
|
|
278
|
+
return
|
|
279
|
+
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
280
|
+
try:
|
|
281
|
+
os.killpg(proc.pid, sig)
|
|
282
|
+
except ProcessLookupError:
|
|
283
|
+
break
|
|
284
|
+
try:
|
|
285
|
+
await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
286
|
+
return
|
|
287
|
+
except asyncio.TimeoutError:
|
|
288
|
+
continue
|