symphony-code 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coding_agent/__init__.py +20 -0
- coding_agent/_lazy.py +17 -0
- coding_agent/addons/__init__.py +35 -0
- coding_agent/addons/approvals.py +224 -0
- coding_agent/addons/compaction/__init__.py +36 -0
- coding_agent/addons/compaction/addon.py +88 -0
- coding_agent/addons/compaction/compactor.py +138 -0
- coding_agent/addons/compaction/prompts.py +20 -0
- coding_agent/addons/compaction/transcript.py +118 -0
- coding_agent/addons/evaluation/__init__.py +68 -0
- coding_agent/addons/evaluation/addon.py +199 -0
- coding_agent/addons/evaluation/evaluators.py +245 -0
- coding_agent/addons/evaluation/handlers.py +66 -0
- coding_agent/addons/evaluation/policy.py +277 -0
- coding_agent/addons/evaluation/protocol.py +192 -0
- coding_agent/addons/evaluation/state.py +284 -0
- coding_agent/addons/langfuse/__init__.py +24 -0
- coding_agent/addons/langfuse/addon.py +465 -0
- coding_agent/addons/langfuse/install.py +79 -0
- coding_agent/addons/langfuse/serialize.py +158 -0
- coding_agent/addons/learning/__init__.py +33 -0
- coding_agent/addons/learning/addon.py +181 -0
- coding_agent/addons/learning/loop.py +247 -0
- coding_agent/addons/learning/prompts.py +26 -0
- coding_agent/addons/learning/sanitize.py +64 -0
- coding_agent/addons/learning/store.py +522 -0
- coding_agent/addons/manager/__init__.py +18 -0
- coding_agent/addons/manager/bridge.py +74 -0
- coding_agent/addons/manager/discovery.py +34 -0
- coding_agent/addons/manager/report.py +33 -0
- coding_agent/addons/manager/runtime.py +76 -0
- coding_agent/addons/manager/tool_changes.py +16 -0
- coding_agent/addons/persistence/__init__.py +41 -0
- coding_agent/addons/persistence/active.py +132 -0
- coding_agent/addons/persistence/artifacts.py +110 -0
- coding_agent/addons/persistence/collection.py +166 -0
- coding_agent/addons/persistence/compatibility.py +30 -0
- coding_agent/addons/persistence/images.py +133 -0
- coding_agent/addons/persistence/jsonl.py +1195 -0
- coding_agent/addons/persistence/presentation.py +104 -0
- coding_agent/addons/plugins/__init__.py +19 -0
- coding_agent/addons/plugins/install.py +37 -0
- coding_agent/addons/plugins/manager.py +140 -0
- coding_agent/addons/plugins/models.py +50 -0
- coding_agent/addons/skills/__init__.py +28 -0
- coding_agent/addons/skills/addon.py +26 -0
- coding_agent/addons/skills/args.py +78 -0
- coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
- coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
- coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
- coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
- coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
- coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
- coding_agent/addons/skills/frontmatter.py +57 -0
- coding_agent/addons/skills/models.py +57 -0
- coding_agent/addons/skills/registry.py +109 -0
- coding_agent/addons/skills/tool.py +54 -0
- coding_agent/addons/voice/__init__.py +5 -0
- coding_agent/addons/voice/addon.py +106 -0
- coding_agent/addons/voice/credentials.py +42 -0
- coding_agent/addons/voice/playback.py +42 -0
- coding_agent/addons/voice/speak.py +287 -0
- coding_agent/addons/voice/stream.py +651 -0
- coding_agent/addons/voice/summary.py +126 -0
- coding_agent/agent.py +523 -0
- coding_agent/bench/__init__.py +1 -0
- coding_agent/bench/cli.py +200 -0
- coding_agent/bench/patch.py +78 -0
- coding_agent/commands/__init__.py +5 -0
- coding_agent/commands/catalog.py +179 -0
- coding_agent/commands/jev_argument.py +40 -0
- coding_agent/config/__init__.py +51 -0
- coding_agent/config/config.py +376 -0
- coding_agent/config/credentials.py +153 -0
- coding_agent/config/personalities.json +34 -0
- coding_agent/config/personalities.py +170 -0
- coding_agent/config/prompts.py +67 -0
- coding_agent/protocols/__init__.py +5 -0
- coding_agent/protocols/acp/README.md +36 -0
- coding_agent/protocols/acp/__init__.py +27 -0
- coding_agent/protocols/acp/mapping.py +164 -0
- coding_agent/protocols/acp/server.py +261 -0
- coding_agent/protocols/acp/transport.py +72 -0
- coding_agent/protocols/stdio/README.md +36 -0
- coding_agent/protocols/stdio/__init__.py +37 -0
- coding_agent/protocols/stdio/catalog.py +49 -0
- coding_agent/protocols/stdio/commands.py +218 -0
- coding_agent/protocols/stdio/server.py +181 -0
- coding_agent/protocols/stdio/transport.py +71 -0
- coding_agent/py.typed +0 -0
- coding_agent/resources/__init__.py +5 -0
- coding_agent/resources/usage.py +264 -0
- coding_agent/run/__init__.py +1 -0
- coding_agent/run/cli.py +99 -0
- coding_agent/run/detach.py +37 -0
- coding_agent/run/interrupted.py +67 -0
- coding_agent/run/log_sink.py +46 -0
- coding_agent/tools/__init__.py +73 -0
- coding_agent/tools/ask_user.py +61 -0
- coding_agent/tools/base.py +136 -0
- coding_agent/tools/bash.py +288 -0
- coding_agent/tools/bash_jobs.py +116 -0
- coding_agent/tools/generate_image.py +123 -0
- coding_agent/tools/memory.py +100 -0
- coding_agent/tools/patch.py +218 -0
- coding_agent/tools/read_file.py +167 -0
- coding_agent/tools/search.py +433 -0
- coding_agent/tools/web_search.py +151 -0
- coding_agent/tools/write_file.py +40 -0
- coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
- coding_agent/tui/__init__.py +22 -0
- coding_agent/tui/__main__.py +122 -0
- coding_agent/tui/app.py +456 -0
- coding_agent/tui/chrome/__init__.py +33 -0
- coding_agent/tui/chrome/footer.py +274 -0
- coding_agent/tui/chrome/topbar.py +259 -0
- coding_agent/tui/commands/__init__.py +45 -0
- coding_agent/tui/commands/catalog.py +35 -0
- coding_agent/tui/commands/manager.py +557 -0
- coding_agent/tui/composer/__init__.py +18 -0
- coding_agent/tui/composer/input.py +219 -0
- coding_agent/tui/composer/slash_menu.py +341 -0
- coding_agent/tui/composer/surface.py +330 -0
- coding_agent/tui/composer/voice.py +267 -0
- coding_agent/tui/composer/voice_mode.py +491 -0
- coding_agent/tui/runtime/__init__.py +23 -0
- coding_agent/tui/runtime/events.py +728 -0
- coding_agent/tui/runtime/sink.py +115 -0
- coding_agent/tui/runtime/state.py +145 -0
- coding_agent/tui/runtime/subagent.py +531 -0
- coding_agent/tui/runtime/turn.py +206 -0
- coding_agent/tui/screens/__init__.py +54 -0
- coding_agent/tui/screens/ask.py +69 -0
- coding_agent/tui/screens/context.py +195 -0
- coding_agent/tui/screens/dashboard.py +91 -0
- coding_agent/tui/screens/diff.py +302 -0
- coding_agent/tui/screens/extensions.py +68 -0
- coding_agent/tui/screens/file_selector.py +167 -0
- coding_agent/tui/screens/history.py +458 -0
- coding_agent/tui/screens/langfuse.py +142 -0
- coding_agent/tui/screens/learning.py +113 -0
- coding_agent/tui/screens/modal.py +131 -0
- coding_agent/tui/screens/onboard.py +455 -0
- coding_agent/tui/screens/resume.py +163 -0
- coding_agent/tui/theme/__init__.py +49 -0
- coding_agent/tui/theme/load.py +325 -0
- coding_agent/tui/theme/style.py +95 -0
- coding_agent/tui/theme/theme.toml +1687 -0
- coding_agent/tui/tools/__init__.py +53 -0
- coding_agent/tui/tools/activity.py +184 -0
- coding_agent/tui/tools/calls.py +560 -0
- coding_agent/tui/tools/code_view.py +52 -0
- coding_agent/tui/tools/images.py +250 -0
- coding_agent/tui/tools/labels.py +135 -0
- coding_agent/tui/tools/modal.py +137 -0
- coding_agent/tui/tools/partial_args.py +19 -0
- coding_agent/tui/tools/snapshots.py +382 -0
- coding_agent/tui/transcript/__init__.py +36 -0
- coding_agent/tui/transcript/messages.py +408 -0
- coding_agent/tui/transcript/process.py +504 -0
- coding_agent/tui/transcript/surface.py +345 -0
- coding_agent/tui/transcript/thinking_markdown.py +314 -0
- symphony_code-0.0.1.dist-info/METADATA +66 -0
- symphony_code-0.0.1.dist-info/RECORD +169 -0
- symphony_code-0.0.1.dist-info/WHEEL +4 -0
- symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
- symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
coding_agent/__init__.py
ADDED
|
@@ -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
|
+
"""
|