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.
Files changed (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. 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