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,126 @@
1
+ """Split the "## Spoken summary" section off a reply and clean it for TTS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Spoken replies stay short so a long tool report is not read in full.
6
+ SPOKEN_REPLY_LIMIT = 600
7
+
8
+
9
+ _SPOKEN_SUMMARY = "## Spoken summary"
10
+
11
+
12
+ def split_spoken_summary(text: str) -> tuple[str, str]:
13
+ """Separate the chat answer from the trailing spoken section.
14
+
15
+ The heading is only for TTS. A heading that is still being typed stays
16
+ out of the chat as well, so the customer never sees it appear.
17
+ """
18
+ lowered = text.lower()
19
+ marker = lowered.rfind(_SPOKEN_SUMMARY.lower())
20
+ if marker < 0:
21
+ partial = _partial_heading(lowered)
22
+ if partial:
23
+ return text[: len(text) - partial].rstrip(), ""
24
+ return text, ""
25
+ return text[:marker].rstrip(), text[marker + len(_SPOKEN_SUMMARY):].strip()
26
+
27
+
28
+ def _partial_heading(lowered: str) -> int:
29
+ """How many trailing characters are an unfinished ``## Spoken summary``."""
30
+ heading = _SPOKEN_SUMMARY.lower()
31
+ tail = lowered.rstrip()
32
+ trimmed = len(lowered) - len(tail)
33
+ for size in range(min(len(heading) - 1, len(tail)), 1, -1):
34
+ if tail.endswith(heading[:size]):
35
+ return size + trimmed
36
+ return 0
37
+
38
+
39
+ class SpokenSummaryStream:
40
+ """``split_spoken_summary(text)[0]`` for one reply that only grows.
41
+
42
+ The presenter asks for the visible text on every streamed delta. Lowering
43
+ and searching the whole reply each time made a reply O(n^2). This keeps
44
+ the last heading position and searches only the characters added since
45
+ the previous call, plus enough overlap for a heading split across deltas.
46
+ A text shorter than the last one is a restarted reply and starts over.
47
+ """
48
+
49
+ _HEADING = _SPOKEN_SUMMARY.lower()
50
+
51
+ def __init__(self) -> None:
52
+ self._scanned = 0
53
+ self._marker = -1
54
+ self._before_marker = ""
55
+ # ``str.lower`` can lengthen a character (U+0130). Positions in the
56
+ # lowered window then stop matching the original text, so such a
57
+ # reply falls back to the whole-text split.
58
+ self._aligned = True
59
+
60
+ def visible(self, text: str) -> str:
61
+ if len(text) < self._scanned:
62
+ self.__init__()
63
+ added = text[self._scanned:]
64
+ if self._aligned and len(added.lower()) != len(added):
65
+ self._aligned = False
66
+ if not self._aligned:
67
+ self._scanned = len(text)
68
+ return split_spoken_summary(text)[0]
69
+ start = max(0, self._scanned - (len(self._HEADING) - 1))
70
+ found = text[start:].lower().rfind(self._HEADING)
71
+ self._scanned = len(text)
72
+ if found >= 0:
73
+ # Any match in the window ends past the old one, so it is the last.
74
+ self._marker = start + found
75
+ self._before_marker = text[: self._marker].rstrip()
76
+ if self._marker >= 0:
77
+ return self._before_marker
78
+ partial = _partial_heading(self._tail(text).lower())
79
+ if partial:
80
+ return text[: len(text) - partial].rstrip()
81
+ return text
82
+
83
+ def _tail(self, text: str) -> str:
84
+ """The trailing whitespace plus the heading-length run before it."""
85
+ size = 32
86
+ while True:
87
+ window = text[-size:]
88
+ stripped = window.rstrip()
89
+ if stripped or size >= len(text):
90
+ break
91
+ size *= 2
92
+ trailing = len(window) - len(stripped)
93
+ return text[-(trailing + len(self._HEADING) - 1):]
94
+
95
+
96
+
97
+ def spoken_summary(text: str, *, limit: int = SPOKEN_REPLY_LIMIT) -> str:
98
+ """The summary the model wrote into its final answer, ready for TTS.
99
+
100
+ The section is part of the same response, not a later call. If the model
101
+ left it out, nothing is spoken: reading the answer aloud would dictate
102
+ the whole output.
103
+ """
104
+ _answer, section = split_spoken_summary(text)
105
+ if not section:
106
+ return ""
107
+ return spoken_text(section, limit=limit)
108
+
109
+
110
+ def spoken_text(text: str, *, limit: int = SPOKEN_REPLY_LIMIT) -> str:
111
+ """Plain text safe to send to TTS. Markup and fences are dropped."""
112
+ cleaned = text.strip()
113
+ if not cleaned:
114
+ return ""
115
+ kept: list[str] = []
116
+ in_fence = False
117
+ for line in cleaned.splitlines():
118
+ if line.strip().startswith("```"):
119
+ in_fence = not in_fence
120
+ continue
121
+ if not in_fence and line.strip():
122
+ kept.append(line.strip())
123
+ cleaned = " ".join(kept)
124
+ if len(cleaned) <= limit:
125
+ return cleaned
126
+ return cleaned[: limit - 1].rstrip() + "…"
coding_agent/agent.py ADDED
@@ -0,0 +1,523 @@
1
+ """Coding agent built on core_harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Any, Callable, List, Optional, Union
10
+
11
+ from core_ai.content import text_from_content
12
+ from core_ai.registry import ModelRegistry
13
+ from core_ai.types import Content, Message
14
+ from core_harness import (
15
+ ChildConfig,
16
+ ControlPlaneEventType,
17
+ EventSink,
18
+ CoreHarness,
19
+ HarnessConfig,
20
+ HarnessResult,
21
+ Persistence,
22
+ Tool,
23
+ )
24
+ from core_harness.addons.persistence import PersistenceAddon
25
+ from core_harness.addons.subagent import SubagentAddon
26
+ from core_harness.context import ContextReport, build_context_report, estimate_prompt_tokens
27
+
28
+ from coding_agent.addons import (
29
+ ApprovalAddon,
30
+ JevAddon,
31
+ LearningAddon,
32
+ SessionMemoryAddon,
33
+ SkillsAddon,
34
+ ai_compaction_from_config,
35
+ jev_from_config,
36
+ langfuse_from_config,
37
+ )
38
+ from coding_agent.config import (
39
+ CompactionConfig,
40
+ EvaluationConfig,
41
+ LangfuseConfig,
42
+ SettingsSource,
43
+ ensure_spawn_settings,
44
+ resolve_coding_agent_config,
45
+ )
46
+ from coding_agent.addons.evaluation import JEV_SYSTEM_SEGMENT
47
+ from coding_agent.addons.learning import LearningLoop, LearningStore
48
+ from coding_agent.tools.memory import MemoryGetTool, MemorySearchTool, MemoryTool
49
+ from coding_agent.addons.persistence import JsonlPersistence, sessions_dir
50
+ from coding_agent.config.personalities import compose_system_prompt
51
+ from coding_agent.addons.plugins import LoadedPlugin, PluginManager
52
+ from coding_agent.config.prompts import SYSTEM_PROMPT
53
+ from coding_agent.addons.skills import SkillRegistry, bundled_skills_root
54
+ from coding_agent.addons.manager import UserAddonRuntime, load_user_addons
55
+ from core_harness.extensions import EMPTY_SNAPSHOT
56
+ from coding_agent.tools import BashJobs, build_tools
57
+
58
+
59
+ def default_addons(
60
+ *,
61
+ persistence: Persistence,
62
+ harness_config: HarnessConfig,
63
+ compaction: Optional[CompactionConfig] = None,
64
+ spawn_configure: Any = None,
65
+ include_subagent: bool = True,
66
+ langfuse: Optional[LangfuseConfig] = None,
67
+ evaluation: Optional[EvaluationConfig] = None,
68
+ learning: Optional[LearningLoop] = None,
69
+ should_review: Optional[Callable[[], bool]] = None,
70
+ ) -> list:
71
+ """Product defaults: persistence, AI compaction, spawn_agent, Langfuse, Jev, learning.
72
+
73
+ Compaction is always ``AiCompactionAddon`` (``InferenceCompactor``); the
74
+ harness template compactor is not mounted by coding_agent. Langfuse is
75
+ mounted when enabled in config; the add-on stays silent without keys.
76
+ Jev critic mode mounts only when ``evaluation.enabled`` is true.
77
+ Learning mounts only when a ``LearningLoop`` is passed; omit it so children
78
+ skip (matches ``LearningAddon.fork_for_child`` → ``None``).
79
+ """
80
+ compaction = compaction or CompactionConfig()
81
+ addons: list = [
82
+ PersistenceAddon(persistence),
83
+ ai_compaction_from_config(harness_config, compaction),
84
+ ]
85
+ if include_subagent:
86
+ addons.append(SubagentAddon(configure=spawn_configure, background=True))
87
+ langfuse_addon = langfuse_from_config(langfuse or LangfuseConfig())
88
+ if langfuse_addon is not None:
89
+ addons.append(langfuse_addon)
90
+ jev_addon = jev_from_config(
91
+ evaluation or EvaluationConfig(),
92
+ )
93
+ if jev_addon is not None:
94
+ addons.append(jev_addon)
95
+ if learning is not None:
96
+ addons.append(LearningAddon(learning, should_review=should_review))
97
+ return addons
98
+
99
+
100
+ class CodingAgent:
101
+ """Workspace tools, persisted conversation, and optional post-run learning."""
102
+
103
+ def __init__(
104
+ self,
105
+ *,
106
+ registry: ModelRegistry,
107
+ model_id: str,
108
+ workspace: Union[str, Path],
109
+ config: SettingsSource | None = None,
110
+ sink: Optional[EventSink] = None,
111
+ persistence: Optional[Persistence] = None,
112
+ session_id: Optional[str] = None,
113
+ system_prompt: str = SYSTEM_PROMPT,
114
+ tools: Optional[List[Tool]] = None,
115
+ ) -> None:
116
+ self.workspace = Path(workspace).resolve()
117
+ self.workspace.mkdir(parents=True, exist_ok=True)
118
+ loaded = None if config is None else resolve_coding_agent_config(config)
119
+ self.config = ensure_spawn_settings(self.workspace, config=loaded)
120
+ self.sink = sink or EventSink()
121
+ self.registry = registry
122
+ self.session_id = session_id or str(uuid.uuid4())
123
+ self.persistence = persistence or JsonlPersistence(sessions_dir(self.workspace))
124
+ self.goal = ""
125
+ if isinstance(self.persistence, JsonlPersistence):
126
+ self.persistence.checkpoint_metadata = self._checkpoint_metadata
127
+ self.base_system_prompt = system_prompt.rstrip()
128
+ self.unattended = self.config.unattended
129
+ self.learning_store = LearningStore(
130
+ self.workspace,
131
+ session_id=self.session_id,
132
+ max_lessons=self.config.learning.max_lessons,
133
+ session_dir=(self.persistence.session_dir(self.session_id)
134
+ if isinstance(self.persistence, JsonlPersistence) else None),
135
+ )
136
+ self.learning_loop = (
137
+ LearningLoop(
138
+ self.learning_store,
139
+ registry=registry,
140
+ model_id=model_id,
141
+ max_output_tokens=self.config.learning.max_output_tokens,
142
+ context_limit=self.config.learning.context_limit,
143
+ context_max_chars=self.config.learning.context_max_chars,
144
+ )
145
+ if self.config.learning.enabled
146
+ else None
147
+ )
148
+ skill_roots = [
149
+ ("bundled", bundled_skills_root()),
150
+ ("user", Path.home() / ".symphony" / "skills"),
151
+ *[("configured", root) for root in self.config.skills.roots],
152
+ ]
153
+ plugin_addons = []
154
+ self.plugin_diagnostics = ()
155
+ self.loaded_plugins: tuple[LoadedPlugin, ...] = ()
156
+ if self.config.plugins.enabled:
157
+ # Global config must not be able to authorize executable code.
158
+ # Authorization is supplied by the trusted process environment;
159
+ # config may only select already-authorized plugins.
160
+ trusted_roots = [
161
+ Path(value)
162
+ for value in os.environ.get("SYMPHONY_PLUGIN_AUTHORIZED_ROOTS", "").split(os.pathsep)
163
+ if value
164
+ ]
165
+ plugin_manager = PluginManager(self.workspace, authorized_roots=trusted_roots)
166
+ configured_plugins = tuple(self.config.plugins.entries)
167
+ discovered_plugins = plugin_manager.discover()
168
+ # Explicit entries override auto-discovered paths by resolved path;
169
+ # this keeps the installed layout convenient without losing settings.
170
+ configured_paths = {entry.path.expanduser().resolve() for entry in configured_plugins}
171
+ plugin_entries = configured_plugins + tuple(
172
+ entry for entry in discovered_plugins
173
+ if entry.path.expanduser().resolve() not in configured_paths
174
+ )
175
+ plugin_addons, plugin_skill_roots, self.loaded_plugins, self.plugin_diagnostics = (
176
+ plugin_manager.load(plugin_entries)
177
+ )
178
+ if self.plugin_diagnostics:
179
+ details = "; ".join(
180
+ f"{diagnostic.source}: {diagnostic.message}"
181
+ for diagnostic in self.plugin_diagnostics
182
+ )
183
+ raise ValueError(f"Plugin loading failed: {details}")
184
+ skill_roots.extend(plugin_skill_roots)
185
+ self.skill_registry, self.skill_diagnostics = SkillRegistry.discover(
186
+ skill_roots,
187
+ max_skills=self.config.skills.max_skills,
188
+ )
189
+ self.bash_jobs = BashJobs(
190
+ sessions_dir(self.workspace) / "jobs",
191
+ max_seconds=self.config.tools.bash.max_background_seconds,
192
+ )
193
+ self.tools = tools if tools is not None else build_tools(
194
+ self.workspace,
195
+ config=self.config.tools,
196
+ learning_enabled=self.config.learning.enabled,
197
+ unattended=self.unattended,
198
+ bash_jobs=self.bash_jobs,
199
+ learning_store=self.learning_store,
200
+ )
201
+ for tool in self.tools:
202
+ if isinstance(tool, (MemoryTool, MemorySearchTool, MemoryGetTool)):
203
+ tool.learning_store = self.learning_store
204
+ include_subagent = tools is None
205
+ addons = default_addons(
206
+ persistence=self.persistence,
207
+ harness_config=self.config.harness,
208
+ compaction=self.config.compaction,
209
+ spawn_configure=self._spawn_child_config if include_subagent else None,
210
+ include_subagent=include_subagent,
211
+ langfuse=self.config.langfuse,
212
+ evaluation=self.config.evaluation,
213
+ learning=self.learning_loop,
214
+ )
215
+ self.approval = ApprovalAddon(
216
+ self.workspace, self.sink, approvals=self.config.approvals, unattended=self.unattended
217
+ )
218
+ addons.append(self.approval)
219
+ if self.config.skills.enabled:
220
+ addons.append(SkillsAddon(str(self.workspace), self.skill_registry))
221
+ self.harness = CoreHarness(
222
+ registry=registry,
223
+ model_id=model_id,
224
+ system_prompt=self.base_system_prompt + "\n",
225
+ config=self.config.harness,
226
+ tools=self.tools,
227
+ sink=self.sink,
228
+ session_id=self.session_id,
229
+ agent_id=self.session_id,
230
+ addons=addons + plugin_addons,
231
+ )
232
+ self.bash_jobs.bind(self.harness)
233
+ # User addons come last so built-in names (tools, commands, addons) win
234
+ # and the tool list keeps one order across reloads.
235
+ self.user_addons = UserAddonRuntime(
236
+ builtin_tool_names=tuple(self.harness.tools),
237
+ reserved_names=tuple(addon.name for addon in self.harness.addons),
238
+ loader=load_user_addons if include_subagent else lambda _names: EMPTY_SNAPSHOT,
239
+ )
240
+ self._install_addon_tools()
241
+ self._unsubscribe_addons = self.sink.subscribe(self.user_addons.on_event)
242
+ self._active_runs = 0
243
+ self.apply_system_prompt()
244
+
245
+ def _install_addon_tools(self) -> None:
246
+ for tool in self.user_addons.surface.tools:
247
+ self.harness.register_tool(tool)
248
+ self.tools.append(tool)
249
+
250
+ def _remove_addon_tools(self) -> None:
251
+ addon_tools = self.user_addons.surface.tools
252
+ for tool in addon_tools:
253
+ self.harness.tools.pop(tool.name, None)
254
+ self.tools[:] = [tool for tool in self.tools if all(tool is not item for item in addon_tools)]
255
+
256
+ def reload_user_addons(self) -> str:
257
+ """Rebuild user addons between turns; queue the reload during a run."""
258
+ if self._active_runs:
259
+ self.user_addons.reload_pending = True
260
+ return "Addon reload queued · it applies when the current run ends."
261
+ surface = self.user_addons.build()
262
+ self._remove_addon_tools()
263
+ notice = self.user_addons.swap(surface)
264
+ self._install_addon_tools()
265
+ return notice
266
+
267
+ def dispose_user_addons(self) -> None:
268
+ """Detach this agent's user addons (the TUI rebuilds the agent on /reload)."""
269
+ self._unsubscribe_addons()
270
+ self.user_addons.dispose()
271
+
272
+ def _spawn_child_config(
273
+ self,
274
+ *,
275
+ prompt: str = "",
276
+ label: str = "",
277
+ model_id: Optional[str] = None,
278
+ max_turns: Optional[int] = None,
279
+ **_: Any,
280
+ ) -> ChildConfig:
281
+ """Children share the parent sink and skip ApprovalAddon (deny-only fork when unattended)."""
282
+ del prompt, label
283
+ turns = max(1, int(max_turns)) if max_turns else None
284
+ mid = str(model_id).strip() if model_id else None
285
+ return ChildConfig(
286
+ model_id=mid or None,
287
+ max_turns=turns,
288
+ sink=self.sink,
289
+ # addon_factory replaces fork_for_child. Omit learning (and Jev):
290
+ # LearningAddon.fork_for_child returns None — children skip observe.
291
+ addon_factory=self._child_addons,
292
+ )
293
+
294
+ def _child_addons(self, parent: CoreHarness) -> list:
295
+ addons = default_addons(
296
+ persistence=self.persistence, harness_config=parent.config,
297
+ compaction=self.config.compaction, include_subagent=False,
298
+ langfuse=self.config.langfuse,
299
+ )
300
+ addons.append(SessionMemoryAddon(
301
+ lambda session_id: LearningStore(
302
+ self.workspace, max_lessons=self.config.learning.max_lessons,
303
+ session_dir=(self.persistence.session_dir(session_id)
304
+ if isinstance(self.persistence, JsonlPersistence) else None),
305
+ ),
306
+ context_limit=self.config.learning.context_limit,
307
+ context_max_chars=self.config.learning.context_max_chars,
308
+ should_inject=self.config.learning.enabled,
309
+ ))
310
+ gate = self.approval.fork_for_child(parent)
311
+ return addons + ([gate] if gate is not None else [])
312
+
313
+ async def run(
314
+ self,
315
+ user_input: Content,
316
+ *,
317
+ conversation: Optional[List[Message]] = None,
318
+ session_id: Optional[str] = None,
319
+ ) -> HarnessResult:
320
+ """Run the agent. Rejected OAuth tokens are refreshed inside the provider."""
321
+ # UI/protocol state must not be bypassed by direct API or headless resume.
322
+ from coding_agent.addons.persistence.compatibility import validate_session_workflow
323
+
324
+ effective_session = session_id or self.session_id
325
+ directory = (self.persistence.session_dir(effective_session)
326
+ if isinstance(self.persistence, JsonlPersistence) else None)
327
+ await asyncio.to_thread(validate_session_workflow, directory, effective_session)
328
+ self._active_runs += 1
329
+ try:
330
+ return await self._run_turns(user_input, conversation=conversation, session_id=session_id)
331
+ finally:
332
+ self._active_runs -= 1
333
+ if not self._active_runs and self.user_addons.reload_pending:
334
+ self.user_addons.reload_pending = False
335
+ notice = self.reload_user_addons()
336
+ await self.sink.emit(ControlPlaneEventType.CONFIG_CHANGED, {"addons": notice})
337
+
338
+ async def _run_turns(
339
+ self,
340
+ user_input: Content,
341
+ *,
342
+ conversation: Optional[List[Message]] = None,
343
+ session_id: Optional[str] = None,
344
+ ) -> HarnessResult:
345
+ if isinstance(self.persistence, JsonlPersistence):
346
+ directory = self.persistence.session_dir(session_id or self.session_id)
347
+ if self.learning_store.session_dir != directory.resolve():
348
+ if self.learning_loop is not None:
349
+ self.learning_loop.cancel()
350
+ await self.learning_loop.wait()
351
+ self.learning_store = LearningStore(
352
+ self.workspace, session_id=session_id or self.session_id,
353
+ max_lessons=self.config.learning.max_lessons,
354
+ session_dir=directory,
355
+ )
356
+ if self.learning_loop is not None:
357
+ self.learning_loop.store = self.learning_store
358
+ for tool in self.tools:
359
+ if isinstance(tool, (MemoryTool, MemorySearchTool, MemoryGetTool)):
360
+ tool.learning_store = self.learning_store
361
+ task_text = text_from_content(user_input)
362
+ self.apply_system_prompt()
363
+ jev_addon = self.harness.find_addon(JevAddon)
364
+ if jev_addon is not None:
365
+ jev_addon.follow_ups = 0
366
+
367
+ if not self.goal:
368
+ goal = getattr(self.persistence, "checkpoint_goal", None)
369
+ if callable(goal):
370
+ self.goal = await asyncio.to_thread(goal, session_id or self.session_id)
371
+ self.goal = self.goal or task_text
372
+ try:
373
+ result = await self.harness.run(
374
+ user_input,
375
+ conversation=conversation,
376
+ session_id=session_id or self.session_id,
377
+ )
378
+ except BaseException:
379
+ # Persisted partial context is available even when the turn stopped.
380
+ # Capture is fail-open and may be retried from its durable job later.
381
+ if self.learning_loop is not None:
382
+ try:
383
+ partial = await self.persistence.load_conversation(session_id=session_id or self.session_id)
384
+ self.learning_loop.capture(task_text, partial)
385
+ except Exception:
386
+ pass
387
+ raise
388
+ follow_up = self._jev_follow_up()
389
+ if follow_up:
390
+ result = await self.harness.run(
391
+ follow_up,
392
+ conversation=result.messages,
393
+ session_id=session_id or self.session_id,
394
+ )
395
+ return result
396
+
397
+ def _checkpoint_metadata(self) -> dict[str, Any]:
398
+ """Goal (first prompt) and live background jobs."""
399
+ return {"goal": self.goal, "background_jobs": self.bash_jobs.running()}
400
+
401
+ def _jev_follow_up(self) -> Optional[str]:
402
+ addon = self.harness.find_addon(JevAddon)
403
+ if addon is None:
404
+ return None
405
+ prompt = addon.consume_follow_up()
406
+ return prompt if isinstance(prompt, str) and prompt.strip() else None
407
+
408
+ def apply_system_prompt(self) -> None:
409
+ """Recompose ``harness.system_prompt`` from base, Jev, and personality."""
410
+ skills = ""
411
+ if self.config.skills.enabled and self.skill_registry.skills:
412
+ lines = [
413
+ "Available skills (load one with the skill tool when it applies):"
414
+ ]
415
+ lines.extend(skill.catalog_line() for skill in self.skill_registry.skills)
416
+ skills = "\n".join(lines)
417
+ self.harness.system_prompt = compose_system_prompt(
418
+ self.base_system_prompt,
419
+ self.config.personality,
420
+ skills=skills,
421
+ jev=JEV_SYSTEM_SEGMENT if self.config.evaluation.enabled else "",
422
+ )
423
+
424
+ async def wait_for_learning(self) -> None:
425
+ """Drain the current memory capture worker; failed jobs remain durable."""
426
+ if self.learning_loop is not None:
427
+ await self.learning_loop.wait()
428
+
429
+ async def shutdown_learning(self) -> None:
430
+ """Stop capture execution without discarding durable jobs."""
431
+ if self.learning_loop is not None:
432
+ await self.learning_loop.shutdown()
433
+
434
+ async def compact_conversation(self) -> tuple[int, int]:
435
+ """Compact the persisted conversation through the harness-mounted compactor.
436
+
437
+ Emits ``compaction_started`` / ``compaction_completed`` (``manual`` set)
438
+ with message and estimated token counts, fires ``on_compact`` for the
439
+ other add-ons, and persists the result. Returns
440
+ ``(messages_before, messages_after)``.
441
+ """
442
+ state = self.harness.state
443
+ if state.compactor is None:
444
+ raise RuntimeError("No compactor is mounted on the harness.")
445
+ messages = await self.persistence.load_conversation(session_id=self.session_id)
446
+ before = len(messages)
447
+ if not messages:
448
+ return (0, 0)
449
+
450
+ context_limit = state.context_limit(self.harness.model_id)
451
+ tokens_used = estimate_prompt_tokens(messages)
452
+ context_left = (
453
+ max(context_limit - tokens_used, 0) if context_limit is not None else None
454
+ )
455
+ compacted = await state.compact(
456
+ messages,
457
+ turn=0,
458
+ context_limit=context_limit,
459
+ tokens_used=tokens_used,
460
+ context_left=context_left,
461
+ emit=self.sink.emit,
462
+ manual=True,
463
+ )
464
+ await self.harness.notify_addons(
465
+ "on_compact",
466
+ turn=0,
467
+ messages=compacted,
468
+ context_limit=context_limit,
469
+ tokens_used=tokens_used,
470
+ context_left=context_left,
471
+ )
472
+ await self.persistence.save_conversation(
473
+ session_id=self.session_id,
474
+ messages=compacted,
475
+ )
476
+ return (before, len(compacted))
477
+
478
+ async def context_report(self) -> ContextReport:
479
+ """Stored conversation vs the payload that would be sent to the model."""
480
+ messages = await self.persistence.load_conversation(session_id=self.session_id)
481
+ return build_context_report(
482
+ messages,
483
+ context_limit=self.harness.state.context_limit(self.harness.model_id),
484
+ )
485
+
486
+
487
+ def build_agent(
488
+ *,
489
+ workspace: Union[str, Path],
490
+ sink: Optional[EventSink] = None,
491
+ model_id: Optional[str] = None,
492
+ session_id: Optional[str] = None,
493
+ enable_learning: Optional[bool] = None,
494
+ enable_jev: Optional[bool] = None,
495
+ config: Optional[SettingsSource] = None,
496
+ client: str = "",
497
+ ) -> CodingAgent:
498
+ """Build a coding agent from whatever provider credentials are available."""
499
+ from core_ai import build_default_registry, default_model_id
500
+
501
+ registry = build_default_registry()
502
+ loaded = None if config is None else resolve_coding_agent_config(config)
503
+ overrides: dict[str, Any] = {}
504
+ if enable_learning is not None:
505
+ overrides["learning"] = {"enabled": enable_learning}
506
+ if enable_jev is not None:
507
+ overrides["evaluation"] = {"enabled": enable_jev}
508
+ resolved = ensure_spawn_settings(
509
+ workspace,
510
+ config=loaded,
511
+ overrides=overrides or None,
512
+ )
513
+ agent = CodingAgent(
514
+ registry=registry,
515
+ model_id=default_model_id(registry, model_id or resolved.last_model),
516
+ workspace=workspace,
517
+ sink=sink,
518
+ session_id=session_id,
519
+ config=resolved,
520
+ persistence=JsonlPersistence(sessions_dir(workspace, client=client)),
521
+ )
522
+ agent.persistence.client = client
523
+ return agent
@@ -0,0 +1 @@
1
+ """Headless bench runner: one ``CodingAgent.run``, then a git patch."""