comodor 0.1.0__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.
- comodor/__init__.py +18 -0
- comodor/__main__.py +6 -0
- comodor/agent/__init__.py +9 -0
- comodor/agent/context.py +149 -0
- comodor/agent/loop.py +409 -0
- comodor/agent/prompts.py +209 -0
- comodor/agent/tokens.py +131 -0
- comodor/cli.py +295 -0
- comodor/config.py +394 -0
- comodor/events.py +211 -0
- comodor/learning/__init__.py +8 -0
- comodor/learning/bm25.py +147 -0
- comodor/learning/hotindex.py +228 -0
- comodor/learning/memory.py +450 -0
- comodor/learning/progress.py +184 -0
- comodor/learning/reflect.py +152 -0
- comodor/learning/rules.py +418 -0
- comodor/learning/signals.py +310 -0
- comodor/learning/store.py +992 -0
- comodor/learning/writer.py +185 -0
- comodor/net/__init__.py +11 -0
- comodor/net/http.py +2036 -0
- comodor/net/sse.py +113 -0
- comodor/paths.py +120 -0
- comodor/providers/__init__.py +24 -0
- comodor/providers/anthropic.py +267 -0
- comodor/providers/base.py +266 -0
- comodor/providers/fake.py +136 -0
- comodor/providers/gateway.py +275 -0
- comodor/providers/openai_compat.py +281 -0
- comodor/providers/registry.py +178 -0
- comodor/safety/__init__.py +11 -0
- comodor/safety/checkpoints.py +232 -0
- comodor/safety/permissions.py +199 -0
- comodor/safety/redact.py +92 -0
- comodor/session/__init__.py +5 -0
- comodor/session/store.py +203 -0
- comodor/tools/__init__.py +7 -0
- comodor/tools/base.py +173 -0
- comodor/tools/fs.py +314 -0
- comodor/tools/registry.py +85 -0
- comodor/tools/search.py +252 -0
- comodor/tools/shell.py +234 -0
- comodor/tools/todo.py +93 -0
- comodor/tools/web.py +174 -0
- comodor/ui/__init__.py +8 -0
- comodor/ui/app.py +1159 -0
- comodor/ui/console.py +125 -0
- comodor/ui/input/__init__.py +15 -0
- comodor/ui/input/keys.py +322 -0
- comodor/ui/input/reader.py +288 -0
- comodor/ui/layout.py +189 -0
- comodor/ui/markdown.py +76 -0
- comodor/ui/screen.py +185 -0
- comodor/ui/theme.py +251 -0
- comodor/ui/widgets/__init__.py +21 -0
- comodor/ui/widgets/buttons.py +108 -0
- comodor/ui/widgets/chat.py +236 -0
- comodor/ui/widgets/history.py +131 -0
- comodor/ui/widgets/overlay.py +246 -0
- comodor/ui/widgets/panel.py +91 -0
- comodor/ui/widgets/progress.py +143 -0
- comodor/ui/widgets/prompt.py +310 -0
- comodor/ui/widgets/statusbar.py +193 -0
- comodor/ui/widgets/toast.py +67 -0
- comodor-0.1.0.dist-info/METADATA +313 -0
- comodor-0.1.0.dist-info/RECORD +70 -0
- comodor-0.1.0.dist-info/WHEEL +4 -0
- comodor-0.1.0.dist-info/entry_points.txt +2 -0
- comodor-0.1.0.dist-info/licenses/LICENSE +21 -0
comodor/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Comodor — a self-improving terminal coding agent.
|
|
2
|
+
|
|
3
|
+
The package is layered so each piece can be used on its own:
|
|
4
|
+
|
|
5
|
+
comodor.net zero-dependency HTTP + SSE transport
|
|
6
|
+
comodor.providers LLM backends and the health-aware model gateway
|
|
7
|
+
comodor.agent the reason/act loop, context budgeting, prompts
|
|
8
|
+
comodor.tools the capabilities the agent can invoke
|
|
9
|
+
comodor.safety permissions, checkpoints, secret redaction
|
|
10
|
+
comodor.learning the persistent brain that makes it better over time
|
|
11
|
+
comodor.session conversation persistence and export
|
|
12
|
+
comodor.ui the Rich terminal interface
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
APP_NAME = "Comodor"
|
|
17
|
+
|
|
18
|
+
__all__ = ["__version__", "APP_NAME"]
|
comodor/__main__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The reasoning core: the loop, the context budget, and the prompts."""
|
|
2
|
+
|
|
3
|
+
from .context import Conversation
|
|
4
|
+
from .loop import AgentLoop, TurnResult
|
|
5
|
+
from .prompts import build_system_prompt
|
|
6
|
+
from .tokens import TokenCounter, humanise
|
|
7
|
+
|
|
8
|
+
__all__ = ["AgentLoop", "TurnResult", "Conversation", "build_system_prompt",
|
|
9
|
+
"TokenCounter", "humanise"]
|
comodor/agent/context.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Conversation state and the context budget.
|
|
2
|
+
|
|
3
|
+
A long agent session will always outgrow its context window — tool output is
|
|
4
|
+
verbose and there is a lot of it. When usage crosses the configured fraction of
|
|
5
|
+
the window, the oldest middle section is replaced by an LLM-written brief.
|
|
6
|
+
|
|
7
|
+
The subtle part is *where* to cut. Every assistant message that requests tools
|
|
8
|
+
must keep its matching tool results, or the next request is rejected outright by
|
|
9
|
+
the provider. So compaction only ever cuts at a boundary where no tool call is
|
|
10
|
+
outstanding, and the original request is always preserved — losing the goal is
|
|
11
|
+
the one failure a summary cannot recover from.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
from ..providers.base import Message, Role, ToolSpec, Usage
|
|
20
|
+
from .tokens import TokenCounter
|
|
21
|
+
|
|
22
|
+
Summariser = Callable[[list[Message]], str]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Conversation:
|
|
27
|
+
"""The message history plus everything we track about its size."""
|
|
28
|
+
|
|
29
|
+
messages: list[Message] = field(default_factory=list)
|
|
30
|
+
counter: TokenCounter = field(default_factory=TokenCounter)
|
|
31
|
+
usage: Usage = field(default_factory=Usage)
|
|
32
|
+
compactions: int = 0
|
|
33
|
+
|
|
34
|
+
# -- basics ----------------------------------------------------------- #
|
|
35
|
+
|
|
36
|
+
def add(self, message: Message) -> Message:
|
|
37
|
+
self.messages.append(message)
|
|
38
|
+
return message
|
|
39
|
+
|
|
40
|
+
def extend(self, messages: list[Message]) -> None:
|
|
41
|
+
self.messages.extend(messages)
|
|
42
|
+
|
|
43
|
+
def clear(self) -> None:
|
|
44
|
+
self.messages.clear()
|
|
45
|
+
self.usage = Usage()
|
|
46
|
+
self.compactions = 0
|
|
47
|
+
|
|
48
|
+
def render(self, system_prompt: str) -> list[Message]:
|
|
49
|
+
"""The full payload for one request."""
|
|
50
|
+
return [Message.system(system_prompt), *self.messages]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def last_user_text(self) -> str:
|
|
54
|
+
for message in reversed(self.messages):
|
|
55
|
+
if message.role is Role.USER:
|
|
56
|
+
return message.content
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
# -- accounting ------------------------------------------------------- #
|
|
60
|
+
|
|
61
|
+
def used_tokens(self, system_prompt: str = "", tools: list[ToolSpec] | None = None) -> int:
|
|
62
|
+
payload = self.render(system_prompt) if system_prompt else self.messages
|
|
63
|
+
return self.counter.count(payload, tools)
|
|
64
|
+
|
|
65
|
+
def record_usage(self, usage: Usage) -> None:
|
|
66
|
+
self.usage = self.usage.merge(usage)
|
|
67
|
+
|
|
68
|
+
def fill(self, limit: int, system_prompt: str = "",
|
|
69
|
+
tools: list[ToolSpec] | None = None) -> float:
|
|
70
|
+
"""How full the context window is, as a fraction."""
|
|
71
|
+
if limit <= 0:
|
|
72
|
+
return 0.0
|
|
73
|
+
return min(1.0, self.used_tokens(system_prompt, tools) / limit)
|
|
74
|
+
|
|
75
|
+
# -- compaction ------------------------------------------------------- #
|
|
76
|
+
|
|
77
|
+
def needs_compaction(self, limit: int, threshold: float,
|
|
78
|
+
system_prompt: str = "",
|
|
79
|
+
tools: list[ToolSpec] | None = None) -> bool:
|
|
80
|
+
return self.fill(limit, system_prompt, tools) >= threshold
|
|
81
|
+
|
|
82
|
+
def safe_cut(self, keep_recent: int = 8) -> int:
|
|
83
|
+
"""Index up to which messages may be summarised away.
|
|
84
|
+
|
|
85
|
+
A cut is only safe where the conversation is *settled*: a user turn
|
|
86
|
+
with no assistant tool call still awaiting its result. Returns 0 when
|
|
87
|
+
no safe point exists, which simply means compaction waits a turn.
|
|
88
|
+
"""
|
|
89
|
+
if len(self.messages) <= keep_recent + 2:
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
latest_allowed = len(self.messages) - keep_recent
|
|
93
|
+
pending: set[str] = set()
|
|
94
|
+
last_safe = 0
|
|
95
|
+
|
|
96
|
+
for index, message in enumerate(self.messages):
|
|
97
|
+
if message.role is Role.ASSISTANT and message.tool_calls:
|
|
98
|
+
pending.update(call.id for call in message.tool_calls)
|
|
99
|
+
elif message.role is Role.TOOL:
|
|
100
|
+
pending.discard(message.tool_call_id)
|
|
101
|
+
|
|
102
|
+
# A user message with nothing outstanding is a clean seam.
|
|
103
|
+
if (index > 0 and not pending and message.role is Role.USER
|
|
104
|
+
and index <= latest_allowed):
|
|
105
|
+
last_safe = index
|
|
106
|
+
|
|
107
|
+
return last_safe
|
|
108
|
+
|
|
109
|
+
def compact(self, summarise: Summariser, keep_recent: int = 8) -> int:
|
|
110
|
+
"""Replace the middle of the history with a brief. Returns messages removed."""
|
|
111
|
+
cut = self.safe_cut(keep_recent)
|
|
112
|
+
if cut <= 1:
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
head = self.messages[0] # the original request stays verbatim
|
|
116
|
+
middle = self.messages[1:cut]
|
|
117
|
+
tail = self.messages[cut:]
|
|
118
|
+
if not middle:
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
brief = summarise(middle).strip()
|
|
123
|
+
except Exception:
|
|
124
|
+
# A failed summary must not lose messages; better a full context
|
|
125
|
+
# and a hard error later than silently discarded work now.
|
|
126
|
+
return 0
|
|
127
|
+
if not brief:
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
marker = Message(
|
|
131
|
+
role=Role.USER,
|
|
132
|
+
content=("[Earlier in this session — compacted summary]\n\n" + brief),
|
|
133
|
+
meta={"compacted": True, "replaced": len(middle)},
|
|
134
|
+
)
|
|
135
|
+
self.messages = [head, marker, *tail]
|
|
136
|
+
self.compactions += 1
|
|
137
|
+
return len(middle)
|
|
138
|
+
|
|
139
|
+
# -- introspection ---------------------------------------------------- #
|
|
140
|
+
|
|
141
|
+
def summary_line(self) -> str:
|
|
142
|
+
roles = {role: 0 for role in ("user", "assistant", "tool")}
|
|
143
|
+
for message in self.messages:
|
|
144
|
+
key = message.role.value
|
|
145
|
+
if key in roles:
|
|
146
|
+
roles[key] += 1
|
|
147
|
+
return (f"{len(self.messages)} messages "
|
|
148
|
+
f"({roles['user']} user, {roles['assistant']} assistant, "
|
|
149
|
+
f"{roles['tool']} tool)")
|
comodor/agent/loop.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""The reason/act loop.
|
|
2
|
+
|
|
3
|
+
One turn is: assemble context, stream a response, run whatever tools it asked
|
|
4
|
+
for, feed the results back, repeat. It ends when the model stops asking for
|
|
5
|
+
tools, or when a guard trips — step count, wall clock, or spend.
|
|
6
|
+
|
|
7
|
+
Three details are worth knowing:
|
|
8
|
+
|
|
9
|
+
*Loop off means one step.* The ``Loop`` switch in the status bar decides whether
|
|
10
|
+
the agent may iterate after the first round of tools or must hand control back.
|
|
11
|
+
|
|
12
|
+
*Read-only tools run in parallel.* When a round contains only ``SAFE`` calls and
|
|
13
|
+
they need no approval, they execute concurrently — several file reads should not
|
|
14
|
+
cost several round trips of latency. Results are still reported in call order so
|
|
15
|
+
the transcript stays deterministic.
|
|
16
|
+
|
|
17
|
+
*Cancellation is cooperative and checked everywhere.* Between steps, between
|
|
18
|
+
stream chunks, and inside the shell tool, so Esc stops the agent within a
|
|
19
|
+
fraction of a second rather than at the end of the current model response.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import time
|
|
25
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Callable
|
|
29
|
+
|
|
30
|
+
from ..config import Config
|
|
31
|
+
from ..events import Cancellation, Cancelled, EventBus, Kind
|
|
32
|
+
from ..providers.base import (
|
|
33
|
+
EventType,
|
|
34
|
+
Message,
|
|
35
|
+
ProviderError,
|
|
36
|
+
ToolCall,
|
|
37
|
+
ToolSpec,
|
|
38
|
+
Usage,
|
|
39
|
+
)
|
|
40
|
+
from ..providers.gateway import Gateway
|
|
41
|
+
from ..safety import PermissionEngine, Risk
|
|
42
|
+
from ..tools import ToolContext, ToolRegistry, ToolResult
|
|
43
|
+
from .context import Conversation
|
|
44
|
+
from .prompts import COMPACT_PROMPT, build_system_prompt
|
|
45
|
+
|
|
46
|
+
MAX_PARALLEL_TOOLS = 6
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class TurnResult:
|
|
51
|
+
"""What one user turn produced."""
|
|
52
|
+
|
|
53
|
+
text: str = ""
|
|
54
|
+
steps: int = 0
|
|
55
|
+
tool_calls: int = 0
|
|
56
|
+
usage: Usage = field(default_factory=Usage)
|
|
57
|
+
stopped: str = "done" # done | max_steps | budget | cancelled | error
|
|
58
|
+
error: str = ""
|
|
59
|
+
elapsed: float = 0.0
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def ok(self) -> bool:
|
|
63
|
+
return self.stopped in ("done", "max_steps")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AgentLoop:
|
|
67
|
+
"""Drives one conversation against one gateway and one tool set."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, config: Config, gateway: Gateway, tools: ToolRegistry,
|
|
70
|
+
bus: EventBus, permissions: PermissionEngine,
|
|
71
|
+
conversation: Conversation | None = None,
|
|
72
|
+
memory: Any = None) -> None:
|
|
73
|
+
self.config = config
|
|
74
|
+
self.gateway = gateway
|
|
75
|
+
self.tools = tools
|
|
76
|
+
self.bus = bus
|
|
77
|
+
self.permissions = permissions
|
|
78
|
+
self.conversation = conversation or Conversation()
|
|
79
|
+
self.memory = memory # LearningEngine, or None
|
|
80
|
+
self.cancel = Cancellation()
|
|
81
|
+
self.tool_context: ToolContext | None = None
|
|
82
|
+
self._recalled: list[Any] = []
|
|
83
|
+
|
|
84
|
+
# -- public API ------------------------------------------------------- #
|
|
85
|
+
|
|
86
|
+
def run(self, user_text: str, images: list[str] | None = None) -> TurnResult:
|
|
87
|
+
"""Handle one user message from start to finish."""
|
|
88
|
+
started = time.monotonic()
|
|
89
|
+
self.cancel.reset()
|
|
90
|
+
result = TurnResult()
|
|
91
|
+
|
|
92
|
+
self.conversation.add(Message.user(user_text, images=images or []))
|
|
93
|
+
self.bus.emit(Kind.TURN_START, text=user_text)
|
|
94
|
+
|
|
95
|
+
playbook = self._recall(user_text)
|
|
96
|
+
deadline = started + self.config.agent.max_seconds
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
result = self._iterate(playbook, deadline)
|
|
100
|
+
except Cancelled:
|
|
101
|
+
result.stopped = "cancelled"
|
|
102
|
+
self.bus.emit(Kind.CANCELLED)
|
|
103
|
+
except ProviderError as exc:
|
|
104
|
+
result.stopped = "error"
|
|
105
|
+
result.error = str(exc)
|
|
106
|
+
self.bus.emit(Kind.ERROR, text=str(exc))
|
|
107
|
+
except Exception as exc: # a bug here must not kill the UI
|
|
108
|
+
result.stopped = "error"
|
|
109
|
+
result.error = f"{type(exc).__name__}: {exc}"
|
|
110
|
+
self.bus.emit(Kind.ERROR, text=result.error)
|
|
111
|
+
|
|
112
|
+
result.elapsed = time.monotonic() - started
|
|
113
|
+
result.usage = self.conversation.usage
|
|
114
|
+
self.bus.emit(Kind.TURN_END, stopped=result.stopped, steps=result.steps,
|
|
115
|
+
elapsed=result.elapsed, error=result.error)
|
|
116
|
+
|
|
117
|
+
self._learn(user_text, result)
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
def interrupt(self) -> None:
|
|
121
|
+
self.cancel.cancel()
|
|
122
|
+
|
|
123
|
+
# -- the loop --------------------------------------------------------- #
|
|
124
|
+
|
|
125
|
+
def _iterate(self, playbook: str, deadline: float) -> TurnResult:
|
|
126
|
+
result = TurnResult()
|
|
127
|
+
agent = self.config.agent
|
|
128
|
+
|
|
129
|
+
while True:
|
|
130
|
+
self.cancel.raise_if_cancelled()
|
|
131
|
+
result.steps += 1
|
|
132
|
+
|
|
133
|
+
specs = self.tools.specs(agent.mode)
|
|
134
|
+
system_prompt = build_system_prompt(self.config, playbook)
|
|
135
|
+
self._maybe_compact(system_prompt, specs)
|
|
136
|
+
|
|
137
|
+
completion = self._stream_once(system_prompt, specs)
|
|
138
|
+
assistant = completion["message"]
|
|
139
|
+
self.conversation.add(assistant)
|
|
140
|
+
|
|
141
|
+
calls: list[ToolCall] = assistant.tool_calls
|
|
142
|
+
if not calls:
|
|
143
|
+
result.text = assistant.content
|
|
144
|
+
result.stopped = "done"
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
result.tool_calls += len(calls)
|
|
148
|
+
self._execute(calls)
|
|
149
|
+
self.bus.emit(Kind.STEP, step=result.steps, tool_calls=len(calls))
|
|
150
|
+
|
|
151
|
+
if not agent.loop:
|
|
152
|
+
# Loop off: run the tools the model asked for, then stop and
|
|
153
|
+
# let the user decide whether to continue.
|
|
154
|
+
result.stopped = "done"
|
|
155
|
+
result.text = assistant.content
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
if result.steps >= agent.max_steps:
|
|
159
|
+
result.stopped = "max_steps"
|
|
160
|
+
self._note(f"Stopped after {agent.max_steps} steps — the step limit "
|
|
161
|
+
f"for one task. Say 'continue' to keep going.")
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
if time.monotonic() > deadline:
|
|
165
|
+
result.stopped = "budget"
|
|
166
|
+
self._note(f"Stopped after {agent.max_seconds:.0f}s — the time limit "
|
|
167
|
+
f"for one task.")
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
spent = self.conversation.usage.cost_usd
|
|
171
|
+
if agent.max_cost_usd and spent >= agent.max_cost_usd:
|
|
172
|
+
result.stopped = "budget"
|
|
173
|
+
self._note(f"Stopped at ${spent:.2f} — the spend limit for one task.")
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
# -- model call ------------------------------------------------------- #
|
|
177
|
+
|
|
178
|
+
def _stream_once(self, system_prompt: str, specs: list[ToolSpec]) -> dict[str, Any]:
|
|
179
|
+
"""One streamed assistant response, surfaced to the UI as it arrives."""
|
|
180
|
+
payload = self.conversation.render(system_prompt)
|
|
181
|
+
agent = self.config.agent
|
|
182
|
+
|
|
183
|
+
self.bus.emit(Kind.ASSISTANT_START)
|
|
184
|
+
text_parts: list[str] = []
|
|
185
|
+
reasoning_parts: list[str] = []
|
|
186
|
+
tool_calls: list[ToolCall] = []
|
|
187
|
+
usage = Usage()
|
|
188
|
+
|
|
189
|
+
stream = self.gateway.stream(
|
|
190
|
+
payload,
|
|
191
|
+
tools=specs or None,
|
|
192
|
+
model=self.config.model,
|
|
193
|
+
temperature=agent.temperature,
|
|
194
|
+
max_tokens=agent.max_output_tokens,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
for event in stream:
|
|
198
|
+
self.cancel.raise_if_cancelled()
|
|
199
|
+
if event.type is EventType.TEXT:
|
|
200
|
+
text_parts.append(event.text)
|
|
201
|
+
self.bus.emit(Kind.ASSISTANT_DELTA, text=event.text)
|
|
202
|
+
elif event.type is EventType.REASONING:
|
|
203
|
+
reasoning_parts.append(event.text)
|
|
204
|
+
self.bus.emit(Kind.REASONING_DELTA, text=event.text)
|
|
205
|
+
elif event.type is EventType.TOOL_CALL and event.tool_call:
|
|
206
|
+
tool_calls.append(event.tool_call)
|
|
207
|
+
elif event.type is EventType.USAGE and event.usage:
|
|
208
|
+
usage = usage.merge(event.usage)
|
|
209
|
+
|
|
210
|
+
text = "".join(text_parts)
|
|
211
|
+
self.bus.emit(Kind.ASSISTANT_END, text=text,
|
|
212
|
+
tool_calls=[call.name for call in tool_calls])
|
|
213
|
+
|
|
214
|
+
self.conversation.record_usage(usage)
|
|
215
|
+
if usage.input_tokens:
|
|
216
|
+
self.conversation.counter.observe_usage(payload, specs, usage.input_tokens)
|
|
217
|
+
self._emit_usage(system_prompt, specs)
|
|
218
|
+
|
|
219
|
+
message = Message.assistant(text, tool_calls)
|
|
220
|
+
if reasoning_parts:
|
|
221
|
+
message.meta["reasoning"] = "".join(reasoning_parts)
|
|
222
|
+
route = self.gateway.last_route
|
|
223
|
+
if route:
|
|
224
|
+
message.meta["provider"] = route.provider
|
|
225
|
+
message.meta["model"] = route.model
|
|
226
|
+
if route.failed_over_from:
|
|
227
|
+
self._note(f"{', '.join(route.failed_over_from)} failed — "
|
|
228
|
+
f"served by {route.provider}.")
|
|
229
|
+
return {"message": message, "usage": usage}
|
|
230
|
+
|
|
231
|
+
# -- tools ------------------------------------------------------------ #
|
|
232
|
+
|
|
233
|
+
def _execute(self, calls: list[ToolCall]) -> None:
|
|
234
|
+
context = self._tool_context()
|
|
235
|
+
parallel = self._can_parallelise(calls)
|
|
236
|
+
|
|
237
|
+
if parallel and len(calls) > 1:
|
|
238
|
+
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_TOOLS, len(calls))) as pool:
|
|
239
|
+
futures = [pool.submit(self._run_one, call, context) for call in calls]
|
|
240
|
+
results = [future.result() for future in futures]
|
|
241
|
+
else:
|
|
242
|
+
results = [self._run_one(call, context) for call in calls]
|
|
243
|
+
|
|
244
|
+
for call, result in zip(calls, results):
|
|
245
|
+
self.conversation.add(Message.tool(
|
|
246
|
+
call_id=call.id, name=call.name,
|
|
247
|
+
content=result.content, is_error=not result.ok,
|
|
248
|
+
))
|
|
249
|
+
|
|
250
|
+
def _run_one(self, call: ToolCall, context: ToolContext) -> ToolResult:
|
|
251
|
+
self.bus.emit(Kind.TOOL_START, id=call.id, name=call.name,
|
|
252
|
+
arguments=call.arguments,
|
|
253
|
+
summary=self._describe(call))
|
|
254
|
+
if self.cancel.cancelled:
|
|
255
|
+
result = ToolResult.failure("cancelled before the tool ran")
|
|
256
|
+
else:
|
|
257
|
+
result = self.tools.invoke(call.name, context, call.arguments)
|
|
258
|
+
self.bus.emit(Kind.TOOL_END, id=call.id, name=call.name, ok=result.ok,
|
|
259
|
+
content=result.content, display=result.rendered,
|
|
260
|
+
elapsed=result.elapsed, meta=result.meta)
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
def _describe(self, call: ToolCall) -> str:
|
|
264
|
+
tool = self.tools.get(call.name)
|
|
265
|
+
return tool.summary(call.arguments) if tool else call.name
|
|
266
|
+
|
|
267
|
+
def _can_parallelise(self, calls: list[ToolCall]) -> bool:
|
|
268
|
+
"""Only when nothing in the batch could stop to ask a question."""
|
|
269
|
+
if not self.config.safety.auto_approve_safe:
|
|
270
|
+
return False
|
|
271
|
+
for call in calls:
|
|
272
|
+
tool = self.tools.get(call.name)
|
|
273
|
+
if tool is None or tool.risk is not Risk.SAFE:
|
|
274
|
+
return False
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
def _tool_context(self) -> ToolContext:
|
|
278
|
+
if self.tool_context is None:
|
|
279
|
+
from ..safety import CheckpointStore, Redactor
|
|
280
|
+
|
|
281
|
+
secrets = [entry.api_key for entry in self.config.providers.values()
|
|
282
|
+
if entry.api_key]
|
|
283
|
+
self.tool_context = ToolContext(
|
|
284
|
+
config=self.config,
|
|
285
|
+
permissions=self.permissions,
|
|
286
|
+
checkpoints=CheckpointStore(self.config.paths.checkpoints),
|
|
287
|
+
bus=self.bus,
|
|
288
|
+
redact=Redactor(secrets),
|
|
289
|
+
cancel=self.cancel,
|
|
290
|
+
cwd=Path(self.config.paths.project),
|
|
291
|
+
emit_output=lambda text: self.bus.emit(Kind.TOOL_OUTPUT, text=text),
|
|
292
|
+
)
|
|
293
|
+
return self.tool_context
|
|
294
|
+
|
|
295
|
+
# -- context management ----------------------------------------------- #
|
|
296
|
+
|
|
297
|
+
def _maybe_compact(self, system_prompt: str, specs: list[ToolSpec]) -> None:
|
|
298
|
+
agent = self.config.agent
|
|
299
|
+
limit = agent.context_limit or 128_000
|
|
300
|
+
if not self.conversation.needs_compaction(limit, agent.compact_at,
|
|
301
|
+
system_prompt, specs):
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
removed = self.conversation.compact(self._summarise)
|
|
305
|
+
if removed:
|
|
306
|
+
self._note(f"Compacted {removed} earlier messages to free context.")
|
|
307
|
+
self._emit_usage(system_prompt, specs)
|
|
308
|
+
|
|
309
|
+
def _summarise(self, messages: list[Message]) -> str:
|
|
310
|
+
"""Ask the model to write the brief that replaces old history."""
|
|
311
|
+
from ..providers.base import collapse
|
|
312
|
+
|
|
313
|
+
transcript = "\n\n".join(
|
|
314
|
+
f"[{message.role.value}] {message.content[:2000]}"
|
|
315
|
+
for message in messages if message.content
|
|
316
|
+
)
|
|
317
|
+
completion = collapse(self.gateway.stream(
|
|
318
|
+
[Message.system(COMPACT_PROMPT), Message.user(transcript)],
|
|
319
|
+
model=self.config.model, temperature=0.2, max_tokens=1500,
|
|
320
|
+
))
|
|
321
|
+
return completion.text
|
|
322
|
+
|
|
323
|
+
def _emit_usage(self, system_prompt: str, specs: list[ToolSpec]) -> None:
|
|
324
|
+
limit = self.config.agent.context_limit or 128_000
|
|
325
|
+
used = self.conversation.used_tokens(system_prompt, specs)
|
|
326
|
+
usage = self.conversation.usage
|
|
327
|
+
self.bus.emit(
|
|
328
|
+
Kind.USAGE,
|
|
329
|
+
context_used=used,
|
|
330
|
+
context_limit=limit,
|
|
331
|
+
fill=min(1.0, used / limit) if limit else 0.0,
|
|
332
|
+
input_tokens=usage.input_tokens,
|
|
333
|
+
output_tokens=usage.output_tokens,
|
|
334
|
+
cost_usd=usage.cost_usd,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
def _note(self, text: str) -> None:
|
|
338
|
+
self.bus.emit(Kind.NOTICE, text=text)
|
|
339
|
+
|
|
340
|
+
# -- learning --------------------------------------------------------- #
|
|
341
|
+
|
|
342
|
+
def _recall(self, user_text: str) -> str:
|
|
343
|
+
"""Apply anything the user changed, then build the memory block.
|
|
344
|
+
|
|
345
|
+
The order matters. Corrections are folded in *before* recall, so a fix
|
|
346
|
+
the user made a minute ago governs the answer they are about to get,
|
|
347
|
+
rather than the one after that.
|
|
348
|
+
"""
|
|
349
|
+
if self.memory is None or not self.config.learning.enabled:
|
|
350
|
+
return ""
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
self.memory.before_turn(user_text)
|
|
354
|
+
except Exception:
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
# The UI usually computed this while the user was still typing.
|
|
359
|
+
lessons = self.memory.take_prefetched(user_text)
|
|
360
|
+
if lessons is None:
|
|
361
|
+
lessons = self.memory.recall(user_text)
|
|
362
|
+
rules = self.memory.active_rules()
|
|
363
|
+
except Exception:
|
|
364
|
+
return ""
|
|
365
|
+
|
|
366
|
+
self._recalled = lessons
|
|
367
|
+
if lessons:
|
|
368
|
+
self.bus.emit(Kind.MEMORY, action="recalled",
|
|
369
|
+
items=[lesson.as_dict() for lesson in lessons],
|
|
370
|
+
rules=len(rules))
|
|
371
|
+
if not lessons and not rules:
|
|
372
|
+
return ""
|
|
373
|
+
return self.memory.render_playbook(lessons, rules=rules)
|
|
374
|
+
|
|
375
|
+
def _learn(self, user_text: str, result: TurnResult) -> None:
|
|
376
|
+
"""Credit the lessons that were in play, then reflect on the episode."""
|
|
377
|
+
if self.memory is None or not self.config.learning.enabled:
|
|
378
|
+
return
|
|
379
|
+
approvals, _ = self.permissions.take_stats()
|
|
380
|
+
try:
|
|
381
|
+
self.memory.record_outcome(
|
|
382
|
+
goal=user_text,
|
|
383
|
+
messages=self.conversation.messages,
|
|
384
|
+
recalled=self._recalled,
|
|
385
|
+
success=result.ok and result.stopped == "done",
|
|
386
|
+
stopped=result.stopped,
|
|
387
|
+
steps=result.steps,
|
|
388
|
+
elapsed=result.elapsed,
|
|
389
|
+
approvals=approvals,
|
|
390
|
+
tokens=self.conversation.usage.total,
|
|
391
|
+
)
|
|
392
|
+
except Exception:
|
|
393
|
+
# Learning is a background nicety; it must never break a turn.
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def make_summariser(gateway: Gateway, model: str) -> Callable[[list[Message]], str]:
|
|
398
|
+
"""Standalone summariser, used by session export and tests."""
|
|
399
|
+
from ..providers.base import collapse
|
|
400
|
+
|
|
401
|
+
def summarise(messages: list[Message]) -> str:
|
|
402
|
+
transcript = "\n\n".join(f"[{m.role.value}] {m.content[:2000]}"
|
|
403
|
+
for m in messages if m.content)
|
|
404
|
+
return collapse(gateway.stream(
|
|
405
|
+
[Message.system(COMPACT_PROMPT), Message.user(transcript)],
|
|
406
|
+
model=model, temperature=0.2, max_tokens=1500,
|
|
407
|
+
)).text
|
|
408
|
+
|
|
409
|
+
return summarise
|