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