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,20 @@
1
+ from coding_agent.agent import CodingAgent, build_agent
2
+ from coding_agent.addons.learning import LearningAddon, LearningLoop, LearningReview, LearningStore, Lesson
3
+ from coding_agent.addons.persistence import JsonlPersistence, SessionSummary
4
+ from coding_agent.config.prompts import SUBAGENT_SYSTEM_PROMPT, SYSTEM_PROMPT
5
+ from coding_agent.tools import (
6
+ AskUserArgs, AskUserTool, BashArgs, BashTool,
7
+ GenerateImageArgs, GenerateImageTool, PatchArgs, PatchTool, ReadFileArgs, ReadFileTool,
8
+ SearchArgs, SearchTool, TOOL_CLASSES, ToolArgsModel, WebSearchArgs, WebSearchTool, WorkspaceTool,
9
+ WriteFileArgs, WriteFileTool, build_tools,
10
+ )
11
+
12
+ __all__ = [
13
+ "AskUserArgs", "AskUserTool", "BashArgs", "BashTool", "CodingAgent",
14
+ "GenerateImageArgs", "GenerateImageTool", "LearningAddon", "LearningLoop", "LearningReview",
15
+ "LearningStore", "Lesson", "PatchArgs", "PatchTool", "ReadFileArgs",
16
+ "ReadFileTool", "SUBAGENT_SYSTEM_PROMPT", "SYSTEM_PROMPT", "SearchArgs", "SearchTool",
17
+ "JsonlPersistence", "SessionSummary", "TOOL_CLASSES", "ToolArgsModel", "WebSearchArgs", "WebSearchTool",
18
+ "WorkspaceTool",
19
+ "WriteFileArgs", "WriteFileTool", "build_agent", "build_tools",
20
+ ]
coding_agent/_lazy.py ADDED
@@ -0,0 +1,17 @@
1
+ """Defer an attribute until the module that owns it has finished importing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import import_module
6
+ from typing import Any
7
+
8
+
9
+ def resolve(module_globals: dict[str, Any], name: str, exports: dict[str, tuple[str, str]]) -> Any:
10
+ target = exports.get(name)
11
+ if target is None:
12
+ module_name = module_globals.get("__name__", "module")
13
+ raise AttributeError(f"module {module_name!r} has no attribute {name!r}")
14
+ source, attr = target
15
+ value = getattr(import_module(source), attr)
16
+ module_globals[name] = value
17
+ return value
@@ -0,0 +1,35 @@
1
+ """Product harness add-ons.
2
+
3
+ Feature packages keep each add-on's hooks and supporting machinery together.
4
+ This package exports the add-ons that ``CodingAgent`` mounts.
5
+ """
6
+
7
+ from coding_agent.addons.approvals import ApprovalAddon, ApprovalPolicy
8
+ from coding_agent.addons.compaction.addon import AiCompactionAddon, ai_compaction_from_config
9
+ from coding_agent.addons.evaluation.addon import JevAddon, jev_from_config
10
+ from coding_agent.addons.langfuse.addon import LangfuseAddon, langfuse_from_config
11
+ from coding_agent.addons.learning.addon import (
12
+ LEARNING_IDLE_DELAY_SECONDS,
13
+ LearningAddon,
14
+ SessionMemoryAddon,
15
+ inject_memory,
16
+ strip_memory_context,
17
+ )
18
+ from coding_agent.addons.skills.addon import SkillsAddon
19
+
20
+ __all__ = [
21
+ "AiCompactionAddon",
22
+ "ApprovalAddon",
23
+ "ApprovalPolicy",
24
+ "JevAddon",
25
+ "LEARNING_IDLE_DELAY_SECONDS",
26
+ "LangfuseAddon",
27
+ "LearningAddon",
28
+ "SessionMemoryAddon",
29
+ "SkillsAddon",
30
+ "ai_compaction_from_config",
31
+ "inject_memory",
32
+ "jev_from_config",
33
+ "langfuse_from_config",
34
+ "strip_memory_context",
35
+ ]
@@ -0,0 +1,224 @@
1
+ """Product approval rules, independent of the TUI.
2
+
3
+ The harness calls ``Addon.before_tool`` before running a tool. This add-on
4
+ implements symphony-code policy: durable deny/allow rules, which tools need
5
+ a prompt, how answers map to allow/deny, and that children do not inherit
6
+ the gate (except the deny rules in unattended runs). The TUI only renders the
7
+ question and returns the answer. Unattended runs never prompt: each decision a
8
+ human would have made is emitted as an ``auto_decision`` event instead.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from fnmatch import fnmatchcase
15
+ from pathlib import Path
16
+ from typing import Any, Dict, Optional, Sequence, Union
17
+
18
+ from core_harness import Addon, EventSink
19
+
20
+ from coding_agent.config import ApprovalConfig, ensure_spawn_settings
21
+
22
+ ALLOW_ONCE = "Allow once"
23
+ ALLOW_ALWAYS = "Always allow"
24
+ DENY = "Deny"
25
+ APPROVAL_CHOICES = (ALLOW_ONCE, ALLOW_ALWAYS, DENY)
26
+ AUTO_DECISION = "auto_decision"
27
+ RULE_SUBJECTS = {
28
+ "bash": "command", "write_file": "path", "patch": "path", "generate_image": "path",
29
+ "web_search": "query",
30
+ }
31
+
32
+
33
+ def rule_subjects(tool_name: str, arguments: Dict[str, Any], workspace: Path) -> set[str]:
34
+ """The bash command, ``web_search:<query>``, or a path normalized so ``../`` and symlinks cannot dodge a rule."""
35
+ field = RULE_SUBJECTS.get(tool_name)
36
+ raw = str(arguments.get(field) or "").strip() if field else ""
37
+ if not raw or field == "command":
38
+ return {raw} if raw else set()
39
+ if field == "query":
40
+ # Prefixed so a bash rule such as ``git *`` cannot allow a search.
41
+ return {f"{tool_name}:{raw}"}
42
+ joined = os.path.join(workspace, os.path.expanduser(raw))
43
+ absolute, resolved = os.path.normpath(joined), str(Path(joined).resolve())
44
+ return {absolute, resolved, os.path.relpath(absolute, workspace)}
45
+
46
+
47
+ def matching_rule(
48
+ patterns: Sequence[str], tool_name: str, arguments: Dict[str, Any], workspace: Path
49
+ ) -> Optional[str]:
50
+ """First pattern matching the bash command, the prefixed query, or any normalized form of the path."""
51
+ subjects = rule_subjects(tool_name, arguments, workspace)
52
+ return next((p for p in patterns if any(fnmatchcase(s, p) for s in subjects)), None)
53
+
54
+
55
+ class ApprovalPolicy:
56
+ """Path-aware rules. Callers pass the current ``ApprovalConfig``."""
57
+
58
+ def __init__(self, workspace: Union[str, Path]) -> None:
59
+ self.workspace = Path(workspace).expanduser().resolve()
60
+
61
+ def prompt_for(
62
+ self,
63
+ config: ApprovalConfig,
64
+ tool_name: str,
65
+ arguments: Dict[str, Any],
66
+ ) -> str:
67
+ """Return the question to ask, or ``""`` if the call is allowed.
68
+
69
+ Deny rules are not a prompt; ``ApprovalAddon`` checks them first.
70
+ """
71
+ if config.mode == "always_allow" or matching_rule(config.allow, tool_name, arguments, self.workspace):
72
+ return ""
73
+ if tool_name == "bash" and config.require_for_bash:
74
+ command = str(arguments.get("command") or "").strip()
75
+ return f"Allow bash command once?\n`{command}`"
76
+ if tool_name == "web_search" and config.require_for_web_search:
77
+ query = str(arguments.get("query") or "").strip()
78
+ return f"Allow web search once?\n`{query}`"
79
+ if tool_name in {"write_file", "generate_image"}:
80
+ path = str(arguments.get("path") or "").strip()
81
+ if config.require_for_overwrite and path and self._exists(path):
82
+ return f"Overwrite existing file `{path}`?"
83
+ return ""
84
+ if tool_name == "patch" and config.require_for_broad_patch:
85
+ path = str(arguments.get("path") or "").strip()
86
+ old = str(arguments.get("old_str") or arguments.get("old_string") or "")
87
+ replace_all = bool(arguments.get("replace_all"))
88
+ if replace_all or len(old) > config.broad_patch_chars:
89
+ kind = "global" if replace_all else "large"
90
+ return f"Apply a {kind} patch to `{path}`?"
91
+ return ""
92
+
93
+ def interpret(
94
+ self,
95
+ config: ApprovalConfig,
96
+ answer: str,
97
+ ) -> tuple[bool, bool]:
98
+ """Map a UI answer to ``(allowed, promote_to_always_allow)``."""
99
+ if config.mode == "always_allow":
100
+ return True, False
101
+ normalized = answer.strip().lower()
102
+ if normalized == ALLOW_ALWAYS.lower():
103
+ return True, True
104
+ return normalized in config.allow_answers, False
105
+
106
+ def _exists(self, path: str) -> bool:
107
+ try:
108
+ candidate = Path(path).expanduser()
109
+ target = (
110
+ candidate.resolve()
111
+ if candidate.is_absolute()
112
+ else (self.workspace / candidate).resolve()
113
+ )
114
+ return target.exists()
115
+ except OSError:
116
+ return False
117
+
118
+
119
+ class ApprovalAddon(Addon):
120
+ """Gate tools through deny rules, then :class:`ApprovalPolicy` prompts.
121
+
122
+ The live approval state is ``approvals`` on this add-on, the same for
123
+ every client. ``set_mode`` changes it and reports ``config_changed``.
124
+
125
+ Deny rules apply in every mode. ``fork_for_child`` returns ``None`` so
126
+ spawned children skip this gate, except in unattended runs where children
127
+ get a deny-only fork. That is product policy, documented in ``SECURITY.md``.
128
+ """
129
+
130
+ name = "approval"
131
+
132
+ def __init__(
133
+ self,
134
+ workspace: Union[str, Path],
135
+ plane: EventSink,
136
+ *,
137
+ approvals: Optional[ApprovalConfig] = None,
138
+ unattended: bool = False,
139
+ deny_only: bool = False,
140
+ ) -> None:
141
+ self.policy = ApprovalPolicy(workspace)
142
+ self.plane = plane
143
+ self.approvals = approvals
144
+ self.unattended = unattended
145
+ self.deny_only = deny_only
146
+
147
+ def fork_for_child(self, parent_harness: Any) -> Optional["ApprovalAddon"]:
148
+ del parent_harness
149
+ if not self.unattended:
150
+ return None
151
+ return ApprovalAddon(
152
+ self.policy.workspace, self.plane,
153
+ approvals=self.approvals, unattended=True, deny_only=True,
154
+ )
155
+
156
+ async def before_tool(
157
+ self,
158
+ *,
159
+ tool_name: str,
160
+ arguments: Dict[str, Any],
161
+ sink: Optional[EventSink] = None,
162
+ emit: Any = None,
163
+ tool_call: Any = None,
164
+ **_: Any,
165
+ ) -> Optional[str]:
166
+ approvals = self.approvals
167
+ if approvals is None:
168
+ return None
169
+ emit = emit or self.plane.emit
170
+ rule = matching_rule(approvals.deny, tool_name, arguments, self.policy.workspace)
171
+ if rule:
172
+ await emit(AUTO_DECISION, {"kind": "approval", "tool": tool_name, "decision": "deny", "rule": rule})
173
+ return f"tool call denied by rule {rule!r}"
174
+ if self.unattended and tool_name == "ask_user":
175
+ await emit(AUTO_DECISION, {"kind": "ask_user", "question": str(arguments.get("question") or "")})
176
+ if self.deny_only:
177
+ return None
178
+ prompt = self.policy.prompt_for(approvals, tool_name, arguments)
179
+ if not prompt:
180
+ return None
181
+ if self.unattended:
182
+ await emit(AUTO_DECISION, {"kind": "approval", "tool": tool_name, "decision": "allow", "rule": "unattended"})
183
+ return None
184
+ ask_on = sink or self.plane
185
+ answer = await ask_on.interaction.request_user_input(
186
+ question=prompt,
187
+ choices=APPROVAL_CHOICES,
188
+ default=ALLOW_ONCE,
189
+ kind="approval",
190
+ metadata={"tool_name": tool_name, "tool_call_id": str(getattr(tool_call, "id", "") or "")},
191
+ )
192
+ allowed, promote = self.policy.interpret(approvals, answer)
193
+ if promote:
194
+ await self.set_mode("always_allow", emit=emit)
195
+ if not allowed:
196
+ return "tool call denied by user"
197
+ return None
198
+
199
+ async def set_mode(self, mode: str, *, persist: bool = False, emit: Any = None) -> None:
200
+ """Change the approval mode for this agent and tell every client.
201
+
202
+ ``persist`` also writes it to the workspace settings.
203
+ """
204
+ current = self.approvals or ApprovalConfig()
205
+ self.approvals = ApprovalConfig.model_validate({**current.model_dump(), "mode": mode})
206
+ if persist:
207
+ config = ensure_spawn_settings(self.policy.workspace)
208
+ ensure_spawn_settings(
209
+ self.policy.workspace,
210
+ config=config.model_copy(update={"approvals": self.approvals}),
211
+ )
212
+ await (emit or self.plane.emit)("config_changed", {"approvals": {"mode": mode}})
213
+
214
+
215
+ __all__ = [
216
+ "ALLOW_ALWAYS",
217
+ "ALLOW_ONCE",
218
+ "APPROVAL_CHOICES",
219
+ "AUTO_DECISION",
220
+ "ApprovalAddon",
221
+ "ApprovalPolicy",
222
+ "DENY",
223
+ "matching_rule",
224
+ ]
@@ -0,0 +1,36 @@
1
+ """Model-backed conversation compaction for the coding agent.
2
+
3
+ The harness owns the ``Compactor`` protocol and the keep/drop rule
4
+ (``plan_keep_drop``). ``InferenceCompactor`` implements that protocol and
5
+ writes the compacted-context message with the active model. The add-on that
6
+ mounts it lives in ``coding_agent.addons.compaction.addon`` so this package does not
7
+ import the add-on layer while it is still loading.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from coding_agent._lazy import resolve
15
+ from coding_agent.addons.compaction.compactor import InferenceCompactor
16
+ from coding_agent.addons.compaction.transcript import (
17
+ build_compacted_message,
18
+ render_dropped_turns,
19
+ )
20
+
21
+ _ADDON_EXPORTS = {
22
+ "AiCompactionAddon": ("coding_agent.addons.compaction.addon", "AiCompactionAddon"),
23
+ "ai_compaction_from_config": ("coding_agent.addons.compaction.addon", "ai_compaction_from_config"),
24
+ }
25
+
26
+ __all__ = [
27
+ "AiCompactionAddon",
28
+ "InferenceCompactor",
29
+ "ai_compaction_from_config",
30
+ "build_compacted_message",
31
+ "render_dropped_turns",
32
+ ]
33
+
34
+
35
+ def __getattr__(name: str) -> Any:
36
+ return resolve(globals(), name, _ADDON_EXPORTS)
@@ -0,0 +1,88 @@
1
+ """Harness add-on that mounts ``InferenceCompactor``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from core_harness.addons import Addon
8
+ from core_harness.config import HarnessConfig
9
+
10
+ from coding_agent.addons.compaction.compactor import (
11
+ DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS,
12
+ InferenceCompactor,
13
+ )
14
+ from coding_agent.addons.compaction.transcript import DEFAULT_TRANSCRIPT_MAX_CHARS
15
+ from coding_agent.config import CompactionConfig
16
+
17
+
18
+ class AiCompactionAddon(Addon):
19
+ """Mount an ``InferenceCompactor`` on ``harness.state.compactor``.
20
+
21
+ Compaction is a before-model hook: the harness runs the mounted compactor
22
+ from ``maybe_compact`` ahead of each model turn and from ``/compact`` on
23
+ demand, then fires ``on_compact`` for the other add-ons. This add-on only
24
+ decides *which* compactor is mounted. It binds the compactor to the
25
+ harness registry and its *current* ``model_id`` so the summary follows
26
+ ``/model`` switches.
27
+
28
+ It occupies the ``compaction`` add-on slot, so it replaces the harness
29
+ ``CompactionAddon`` rather than sitting beside it. Children fork a fresh
30
+ add-on with the same settings; the child then binds to its own registry
31
+ and model on attach.
32
+ """
33
+
34
+ name = "compaction"
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ keep_recent_tools: int = 32,
40
+ target_tokens: Optional[int] = None,
41
+ max_output_tokens: int = DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS,
42
+ max_transcript_chars: int = DEFAULT_TRANSCRIPT_MAX_CHARS,
43
+ model_id: Optional[str] = None,
44
+ ) -> None:
45
+ self.keep_recent_tools = keep_recent_tools
46
+ self.target_tokens = target_tokens
47
+ self.max_output_tokens = max_output_tokens
48
+ self.max_transcript_chars = max_transcript_chars
49
+ self.model_id = model_id
50
+ self.compactor: Optional[InferenceCompactor] = None
51
+
52
+ def attach(self, harness: Any) -> None:
53
+ self.compactor = InferenceCompactor(
54
+ registry=harness.registry,
55
+ model_id=self.model_id or (lambda: str(harness.model_id)),
56
+ keep_recent_tools=self.keep_recent_tools,
57
+ target_tokens=self.target_tokens,
58
+ max_output_tokens=self.max_output_tokens,
59
+ max_transcript_chars=self.max_transcript_chars,
60
+ )
61
+ harness.state.compactor = self.compactor
62
+
63
+ def fork_for_child(self, parent_harness: Any) -> AiCompactionAddon:
64
+ """Fresh add-on with the same settings; binds to the child on attach."""
65
+ del parent_harness
66
+ return AiCompactionAddon(
67
+ keep_recent_tools=self.keep_recent_tools,
68
+ target_tokens=self.target_tokens,
69
+ max_output_tokens=self.max_output_tokens,
70
+ max_transcript_chars=self.max_transcript_chars,
71
+ model_id=self.model_id,
72
+ )
73
+
74
+
75
+ def ai_compaction_from_config(
76
+ harness_config: HarnessConfig,
77
+ compaction: CompactionConfig,
78
+ ) -> AiCompactionAddon:
79
+ """Build the add-on from harness keep/drop limits plus summary settings."""
80
+ return AiCompactionAddon(
81
+ keep_recent_tools=harness_config.compaction_keep_recent_tools,
82
+ target_tokens=harness_config.context_target_tokens,
83
+ max_output_tokens=compaction.max_output_tokens,
84
+ max_transcript_chars=compaction.max_transcript_chars,
85
+ )
86
+
87
+
88
+ __all__ = ["AiCompactionAddon", "ai_compaction_from_config"]
@@ -0,0 +1,138 @@
1
+ """``InferenceCompactor``: harness keep/drop plan, model-written summary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Callable, List, Optional, Union
7
+
8
+ from core_ai.registry import ModelRegistry
9
+ from core_ai.types import Message
10
+ from core_harness.addons.compaction import (
11
+ KeepDropPlan,
12
+ plan_keep_drop,
13
+ )
14
+
15
+ from coding_agent.addons.compaction.prompts import COMPACTION_SYSTEM_PROMPT
16
+ from coding_agent.addons.compaction.transcript import (
17
+ DEFAULT_TRANSCRIPT_MAX_CHARS,
18
+ build_compacted_message,
19
+ render_dropped_turns,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ ModelIdSource = Union[str, Callable[[], str]]
25
+
26
+ DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS = 700
27
+
28
+
29
+ class InferenceCompactor:
30
+ """``Compactor`` that summarises dropped history with one model call.
31
+
32
+ Keep/drop is the harness rule (``plan_keep_drop``): system prompt, pinned
33
+ first user task, atomic assistant/tool groups, recent window, token target.
34
+ The dropped slice is rendered to a bounded transcript and sent to the
35
+ active model exactly once; the reply becomes the single
36
+ ``COMPACTED_CONTEXT_MARK`` user message that replaces that slice. When the
37
+ provider fails or returns nothing, the harness template summary is used
38
+ for the same slice so a compact never leaves the conversation broken.
39
+
40
+ ``model_id`` may be a callable so the summary follows ``/model`` switches.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ *,
46
+ registry: ModelRegistry,
47
+ model_id: ModelIdSource,
48
+ keep_recent_tools: int = 32,
49
+ target_tokens: Optional[int] = None,
50
+ max_output_tokens: int = DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS,
51
+ max_transcript_chars: int = DEFAULT_TRANSCRIPT_MAX_CHARS,
52
+ ) -> None:
53
+ if keep_recent_tools < 1:
54
+ raise ValueError("keep_recent_tools must be >= 1")
55
+ if target_tokens is not None and target_tokens < 1:
56
+ raise ValueError("target_tokens must be positive or None")
57
+ self.registry = registry
58
+ self._model_id = model_id
59
+ self.keep_recent_tools = keep_recent_tools
60
+ self.target_tokens = target_tokens
61
+ self.max_output_tokens = max_output_tokens
62
+ self.max_transcript_chars = max_transcript_chars
63
+
64
+ @property
65
+ def model_id(self) -> str:
66
+ source = self._model_id
67
+ return source() if callable(source) else source
68
+
69
+ def plan(self, messages: List[Message], *, context_limit: Optional[int]) -> KeepDropPlan:
70
+ return plan_keep_drop(
71
+ messages,
72
+ keep_recent_tools=self.keep_recent_tools,
73
+ target_tokens=self.target_tokens,
74
+ context_limit=context_limit,
75
+ )
76
+
77
+ async def compact(
78
+ self,
79
+ messages: List[Message],
80
+ *,
81
+ turn: int,
82
+ context_limit: Optional[int],
83
+ tokens_used: int,
84
+ context_left: Optional[int],
85
+ ) -> List[Message]:
86
+ del turn, tokens_used, context_left
87
+ plan = self.plan(messages, context_limit=context_limit)
88
+ summary = await self.summarize(plan) if plan.dropped else None
89
+ return plan.assemble(summary)
90
+
91
+ async def summarize(self, plan: KeepDropPlan) -> Message:
92
+ """One compacted-context message for ``plan.dropped``; template on failure."""
93
+ transcript = render_dropped_turns(plan.dropped, max_chars=self.max_transcript_chars)
94
+ try:
95
+ narrative = await self.narrate(transcript)
96
+ except Exception: # noqa: BLE001
97
+ logger.warning(
98
+ "model compaction summary failed; using template summary", exc_info=True
99
+ )
100
+ narrative = ""
101
+ if not narrative.strip():
102
+ template = plan.template_summary()
103
+ assert template is not None
104
+ return template
105
+ return build_compacted_message(
106
+ plan.dropped, narrative=narrative, tokens=plan.dropped_tokens
107
+ )
108
+
109
+ async def narrate(self, transcript: str) -> str:
110
+ """One tool-free model call that returns the summary text."""
111
+ messages = [
112
+ Message(role="system", content=COMPACTION_SYSTEM_PROMPT),
113
+ Message(
114
+ role="user",
115
+ content=(
116
+ "Transcript turns being removed from context:\n\n"
117
+ f"{transcript}\n\n"
118
+ "Write the handoff summary."
119
+ ),
120
+ ),
121
+ ]
122
+ chunks: List[str] = []
123
+ async for event in self.registry.stream(
124
+ self.model_id,
125
+ messages,
126
+ tools=[],
127
+ max_output_tokens=self.max_output_tokens,
128
+ ):
129
+ if event.type == "text_delta" and event.delta:
130
+ chunks.append(event.delta)
131
+ return "".join(chunks).strip()
132
+
133
+
134
+ __all__ = [
135
+ "DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS",
136
+ "InferenceCompactor",
137
+ "ModelIdSource",
138
+ ]
@@ -0,0 +1,20 @@
1
+ """Prompt for the model-backed compaction summary."""
2
+
3
+ COMPACTION_SYSTEM_PROMPT = """You compact the conversation of an AI coding agent.
4
+
5
+ You receive older transcript turns that are about to be removed from the
6
+ agent's context window. Write a dense handoff so the agent can keep working
7
+ without them. Cover, in this order and only when present:
8
+
9
+ - User goals and constraints, including anything the user said not to do.
10
+ - Decisions taken and the reasons behind them.
11
+ - Files, paths, and commands touched, with the outcome of each.
12
+ - What was verified (tests run, results) and what is still unverified.
13
+ - Open items, blockers, and the immediate next step.
14
+
15
+ Rules:
16
+ - Preserve exact identifiers: paths, function names, flags, error strings.
17
+ - Do not invent details. If something is unclear, say so briefly.
18
+ - No preamble, no closing remarks. Use short bullets.
19
+ - Stay under about 250 words.
20
+ """