hubble-cli 4.0.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.
- hubble/__init__.py +3 -0
- hubble/__main__.py +4 -0
- hubble/agent.py +546 -0
- hubble/banner.py +484 -0
- hubble/board.py +118 -0
- hubble/main.py +299 -0
- hubble/models.py +92 -0
- hubble/onboarding.py +94 -0
- hubble/permissions.py +139 -0
- hubble/prompts.py +157 -0
- hubble/provider.py +283 -0
- hubble/providers.py +186 -0
- hubble/repl.py +976 -0
- hubble/scanner.py +134 -0
- hubble/session.py +128 -0
- hubble/settings.py +184 -0
- hubble/skills.py +173 -0
- hubble/spinner.py +77 -0
- hubble/tools.py +763 -0
- hubble/ui.py +469 -0
- hubble/web.py +279 -0
- hubble_cli-4.0.0.dist-info/METADATA +275 -0
- hubble_cli-4.0.0.dist-info/RECORD +27 -0
- hubble_cli-4.0.0.dist-info/WHEEL +5 -0
- hubble_cli-4.0.0.dist-info/entry_points.txt +3 -0
- hubble_cli-4.0.0.dist-info/licenses/LICENSE +21 -0
- hubble_cli-4.0.0.dist-info/top_level.txt +1 -0
hubble/__init__.py
ADDED
hubble/__main__.py
ADDED
hubble/agent.py
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""Agent loop: model call -> tool calls -> permission check -> execute -> feed results -> repeat.
|
|
2
|
+
|
|
3
|
+
The loop never prints. It reports progress through an `Events` object so the same loop
|
|
4
|
+
drives the interactive REPL, headless `-p` mode and read-only sub-agents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import itertools
|
|
8
|
+
import json
|
|
9
|
+
import threading
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, wait
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from hubble.permissions import Permissions
|
|
15
|
+
from hubble.prompts import COMPACT_PROMPT, build_system_prompt, load_memory
|
|
16
|
+
from hubble.provider import OpenAICompatProvider, ProviderError, ToolCall, TurnResult, parse_arguments
|
|
17
|
+
from hubble.session import Session, repair_history
|
|
18
|
+
from hubble.skills import Skill, SkillTool, WriteSkillTool, discover_skills, skills_prompt_block
|
|
19
|
+
from hubble.tools import (READ_ONLY_TOOL_NAMES, Tool, ToolContext, ToolError, default_tools, run_tool,
|
|
20
|
+
shell_name, truncate)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def web_tools(settings: Dict[str, Any]) -> List[Tool]:
|
|
24
|
+
if settings.get("web_tools") is False:
|
|
25
|
+
return []
|
|
26
|
+
from hubble.web import WebFetch, WebSearch
|
|
27
|
+
return [WebSearch(settings.get("web_search") or {}), WebFetch()]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def estimate_tokens(text: str) -> int:
|
|
31
|
+
return int(len(text) / 3.8) + 1 if text else 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def estimate_messages(messages: List[Dict[str, Any]]) -> int:
|
|
35
|
+
total = 0
|
|
36
|
+
for m in messages:
|
|
37
|
+
total += 4 + estimate_tokens(m.get("content") or "")
|
|
38
|
+
for tc in m.get("tool_calls") or []:
|
|
39
|
+
total += estimate_tokens(tc["function"]["name"] + tc["function"]["arguments"])
|
|
40
|
+
return total
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Events:
|
|
44
|
+
"""No-op base. The UI overrides what it needs."""
|
|
45
|
+
|
|
46
|
+
def turn_start(self): pass
|
|
47
|
+
def text(self, delta: str): pass
|
|
48
|
+
def reasoning(self, delta: str): pass
|
|
49
|
+
def turn_end(self, result: TurnResult): pass
|
|
50
|
+
def tool_start(self, tool: Tool, args: Dict[str, Any]): pass
|
|
51
|
+
def tool_result(self, tool: Tool, args: Dict[str, Any], output: str, is_error: bool): pass
|
|
52
|
+
def notice(self, message: str, level: str = "info"): pass
|
|
53
|
+
def todos(self, todos: List[Dict[str, str]]): pass
|
|
54
|
+
def batch_start(self, count: int): pass
|
|
55
|
+
def subagent_start(self, key: str, label: str): pass
|
|
56
|
+
def subagent_step(self, key: str, action: str, is_tool: bool = True): pass
|
|
57
|
+
def subagent_tokens(self, key: str, tokens: int): pass
|
|
58
|
+
def subagent_end(self, key: str, status: str, detail: str = ""): pass
|
|
59
|
+
def batch_end(self): pass
|
|
60
|
+
|
|
61
|
+
def ask(self, tool: Tool, args: Dict[str, Any], preview: Optional[str]) -> Tuple[str, str]:
|
|
62
|
+
"""Returns (answer, feedback); answer is yes | always | no. Raise KeyboardInterrupt to stop the turn."""
|
|
63
|
+
return "no", "Tool calls that need approval are denied in non-interactive mode."
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class RunStats:
|
|
68
|
+
prompt_tokens: int = 0
|
|
69
|
+
completion_tokens: int = 0
|
|
70
|
+
duration: float = 0.0
|
|
71
|
+
model_calls: int = 0
|
|
72
|
+
tool_calls: int = 0
|
|
73
|
+
interrupted: bool = False
|
|
74
|
+
error: Optional[str] = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Agent:
|
|
78
|
+
def __init__(self, provider: OpenAICompatProvider, settings: Dict[str, Any], ctx: ToolContext,
|
|
79
|
+
permissions: Permissions, events: Events, session: Optional[Session] = None,
|
|
80
|
+
tools: Optional[List[Tool]] = None, system_override: Optional[str] = None):
|
|
81
|
+
self.provider = provider
|
|
82
|
+
self.provider_name: str = settings.get("provider") or "hubble"
|
|
83
|
+
self.fallback_client: Optional[OpenAICompatProvider] = provider
|
|
84
|
+
# (provider_name, model) -> (client, fallback_model) or None; set by the REPL/headless runner.
|
|
85
|
+
self.fallback_resolver = None
|
|
86
|
+
self.settings = settings
|
|
87
|
+
self.ctx = ctx
|
|
88
|
+
self.permissions = permissions
|
|
89
|
+
self.events = events
|
|
90
|
+
self.session = session
|
|
91
|
+
self.model: str = settings["model"]
|
|
92
|
+
self.persona: str = settings.get("persona", "code")
|
|
93
|
+
self.temperature: float = float(settings.get("temperature", 0.3))
|
|
94
|
+
self.system_override = system_override
|
|
95
|
+
self.messages: List[Dict[str, Any]] = []
|
|
96
|
+
self.pinned: Dict[str, str] = {}
|
|
97
|
+
self.memory = load_memory(ctx.root)
|
|
98
|
+
self.skills: List[Skill] = discover_skills(ctx.root) if tools is None else []
|
|
99
|
+
self.tools = tools if tools is not None else (
|
|
100
|
+
default_tools() + web_tools(settings) + [TaskTool(self), WriteSkillTool()]
|
|
101
|
+
+ ([SkillTool(self.skills)] if self.skills else []))
|
|
102
|
+
self.total_prompt_tokens = 0
|
|
103
|
+
self.total_completion_tokens = 0
|
|
104
|
+
self.context_tokens = 0
|
|
105
|
+
self.last_stats = RunStats()
|
|
106
|
+
# Shared with sub-agents so Ctrl+C stops every thread of a parallel batch.
|
|
107
|
+
self.cancel = threading.Event()
|
|
108
|
+
self.is_subagent = False
|
|
109
|
+
|
|
110
|
+
# ----- state helpers -------------------------------------------------
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def tools_by_name(self) -> Dict[str, Tool]:
|
|
114
|
+
return {t.name: t for t in self.tools}
|
|
115
|
+
|
|
116
|
+
def system_prompt(self) -> str:
|
|
117
|
+
if self.system_override:
|
|
118
|
+
return self.system_override
|
|
119
|
+
if self.ctx.sandbox == "docker":
|
|
120
|
+
shell_label = (f"an isolated Docker container (image {self.ctx.sandbox_image}), reached via "
|
|
121
|
+
f"`sh -lc`; use POSIX/Linux syntax regardless of the host OS. Only /workspace "
|
|
122
|
+
f"(this project) is visible inside it"
|
|
123
|
+
+ ("" if self.ctx.sandbox_network else "; it has no network access"))
|
|
124
|
+
else:
|
|
125
|
+
shell_label = shell_name(self.ctx.shell_argv)
|
|
126
|
+
return build_system_prompt(self.ctx.root, self.persona, shell_label, self.model,
|
|
127
|
+
self.pinned, self.memory, self.permissions.mode,
|
|
128
|
+
skills_prompt_block(self.skills))
|
|
129
|
+
|
|
130
|
+
def reload_skills(self):
|
|
131
|
+
"""Re-scan skill files and refresh the skill tool (called after write_skill saves one)."""
|
|
132
|
+
self.skills = discover_skills(self.ctx.root)
|
|
133
|
+
for t in self.tools:
|
|
134
|
+
if isinstance(t, SkillTool):
|
|
135
|
+
t.by_name = {s.name: s for s in self.skills}
|
|
136
|
+
return
|
|
137
|
+
if self.skills:
|
|
138
|
+
self.tools.append(SkillTool(self.skills))
|
|
139
|
+
|
|
140
|
+
def reload_memory(self):
|
|
141
|
+
self.memory = load_memory(self.ctx.root)
|
|
142
|
+
|
|
143
|
+
def _append(self, message: Dict[str, Any]):
|
|
144
|
+
self.messages.append(message)
|
|
145
|
+
if self.session:
|
|
146
|
+
self.session.append(message)
|
|
147
|
+
|
|
148
|
+
def load_history(self, messages: List[Dict[str, Any]]):
|
|
149
|
+
self.messages = repair_history(messages)
|
|
150
|
+
self.context_tokens = estimate_messages(self.messages)
|
|
151
|
+
self.ctx.reset_state() # checkpoints and reads belong to the previous conversation
|
|
152
|
+
self.pinned = {}
|
|
153
|
+
|
|
154
|
+
def clear(self):
|
|
155
|
+
self.messages = []
|
|
156
|
+
self.context_tokens = 0
|
|
157
|
+
self.ctx.reset_state()
|
|
158
|
+
|
|
159
|
+
def add_user_message(self, content: str):
|
|
160
|
+
# Mistral rejects a user message directly after a tool result (turn cut short by an
|
|
161
|
+
# interrupt, error or max_turns), so close the previous turn first.
|
|
162
|
+
if self.messages and self.messages[-1].get("role") == "tool":
|
|
163
|
+
self._append({"role": "assistant", "content": "(previous turn ended before a final answer)"})
|
|
164
|
+
self._append({"role": "user", "content": content})
|
|
165
|
+
|
|
166
|
+
def context_ratio(self) -> float:
|
|
167
|
+
window = int(self.settings.get("context_window", 128000)) or 1
|
|
168
|
+
return self.context_tokens / window
|
|
169
|
+
|
|
170
|
+
# ----- main loop -----------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def run(self, prompt: str) -> str:
|
|
173
|
+
stats = RunStats()
|
|
174
|
+
self.last_stats = stats
|
|
175
|
+
if not self.is_subagent:
|
|
176
|
+
self.cancel.clear()
|
|
177
|
+
self.ctx.begin_turn()
|
|
178
|
+
self.add_user_message(prompt)
|
|
179
|
+
try:
|
|
180
|
+
return self._loop(stats)
|
|
181
|
+
except KeyboardInterrupt:
|
|
182
|
+
stats.interrupted = True
|
|
183
|
+
self.events.turn_end(TurnResult()) # flush partially streamed text
|
|
184
|
+
if self._partial_text:
|
|
185
|
+
self._append({"role": "assistant", "content": self._partial_text + "\n[interrupted by user]"})
|
|
186
|
+
self._close_dangling_tool_calls()
|
|
187
|
+
self.events.notice("Interrupted.", "warn")
|
|
188
|
+
return ""
|
|
189
|
+
except ProviderError as e:
|
|
190
|
+
stats.error = str(e)
|
|
191
|
+
self.events.turn_end(TurnResult())
|
|
192
|
+
if self._partial_text:
|
|
193
|
+
self._append({"role": "assistant", "content": self._partial_text + "\n[response cut off by an API error]"})
|
|
194
|
+
if self.messages and self.messages[-1] == {"role": "user", "content": prompt}:
|
|
195
|
+
# Nothing happened yet; drop the prompt so a retry does not send it twice.
|
|
196
|
+
self.messages.pop()
|
|
197
|
+
if self.session:
|
|
198
|
+
self.session.reset(self.messages)
|
|
199
|
+
self._close_dangling_tool_calls()
|
|
200
|
+
self.events.notice(f"API error: {e}", "error")
|
|
201
|
+
return ""
|
|
202
|
+
finally:
|
|
203
|
+
self._partial_text = ""
|
|
204
|
+
|
|
205
|
+
_partial_text = ""
|
|
206
|
+
|
|
207
|
+
def _on_text(self, delta: str):
|
|
208
|
+
if self.cancel.is_set():
|
|
209
|
+
raise KeyboardInterrupt
|
|
210
|
+
self._partial_text += delta
|
|
211
|
+
self.events.text(delta)
|
|
212
|
+
|
|
213
|
+
def _loop(self, stats: RunStats) -> str:
|
|
214
|
+
max_turns = int(self.settings.get("max_turns", 40))
|
|
215
|
+
schemas = [t.schema() for t in self.tools]
|
|
216
|
+
for _ in range(max_turns):
|
|
217
|
+
if self.cancel.is_set():
|
|
218
|
+
raise KeyboardInterrupt
|
|
219
|
+
ratio = float(self.settings.get("auto_compact_ratio", 0.8))
|
|
220
|
+
if ratio and self.context_ratio() >= ratio and len(self.messages) > 4:
|
|
221
|
+
self.events.notice(f"Context {self.context_ratio():.0%} full; compacting...", "warn")
|
|
222
|
+
self.compact(mid_turn=True)
|
|
223
|
+
|
|
224
|
+
self._partial_text = ""
|
|
225
|
+
self.events.turn_start()
|
|
226
|
+
result = self._stream_with_fallback(schemas)
|
|
227
|
+
self._partial_text = ""
|
|
228
|
+
self._account(result, stats)
|
|
229
|
+
self.events.turn_end(result)
|
|
230
|
+
|
|
231
|
+
text = result.text
|
|
232
|
+
if not text and not result.tool_calls:
|
|
233
|
+
text = "(empty response)" # some backends reject empty assistant messages
|
|
234
|
+
message: Dict[str, Any] = {"role": "assistant", "content": text}
|
|
235
|
+
if result.tool_calls:
|
|
236
|
+
message["tool_calls"] = [{"id": c.id, "type": "function",
|
|
237
|
+
"function": {"name": c.name, "arguments": c.arguments}}
|
|
238
|
+
for c in result.tool_calls]
|
|
239
|
+
self._append(message)
|
|
240
|
+
|
|
241
|
+
if not result.tool_calls:
|
|
242
|
+
if result.finish_reason == "length":
|
|
243
|
+
self.events.notice("Response hit max_tokens and was cut off. Say 'continue' or raise "
|
|
244
|
+
"max_tokens in settings.", "warn")
|
|
245
|
+
elif not result.text:
|
|
246
|
+
# The model produced neither text nor a tool call. Some backends do this after a long
|
|
247
|
+
# tool-call chain, or when a safety filter drops the reply; either way, say so instead
|
|
248
|
+
# of ending the turn in silence.
|
|
249
|
+
reason = f" (finish_reason={result.finish_reason})" if result.finish_reason else ""
|
|
250
|
+
self.events.notice(f"{self.model} returned an empty response{reason}. Try again, ask "
|
|
251
|
+
"differently, or switch model with /model.", "warn")
|
|
252
|
+
return result.text
|
|
253
|
+
|
|
254
|
+
if self._parallel_ok(result.tool_calls):
|
|
255
|
+
outputs = self._execute_parallel(result.tool_calls)
|
|
256
|
+
else:
|
|
257
|
+
outputs = (self._execute(call) for call in result.tool_calls)
|
|
258
|
+
for call, output in zip(result.tool_calls, outputs):
|
|
259
|
+
stats.tool_calls += 1
|
|
260
|
+
self._append({"role": "tool", "tool_call_id": call.id, "name": call.name, "content": output})
|
|
261
|
+
self.context_tokens += estimate_tokens(output)
|
|
262
|
+
|
|
263
|
+
self.events.notice(f"Stopped after {max_turns} model calls (max_turns). Say 'continue' to keep going.", "warn")
|
|
264
|
+
return ""
|
|
265
|
+
|
|
266
|
+
def _stream_with_fallback(self, schemas) -> TurnResult:
|
|
267
|
+
messages = [{"role": "system", "content": self.system_prompt()}] + self.messages
|
|
268
|
+
kwargs = dict(tools=schemas, temperature=self.temperature,
|
|
269
|
+
max_tokens=int(self.settings.get("max_tokens", 8192)),
|
|
270
|
+
on_text=self._on_text, on_reasoning=self.events.reasoning)
|
|
271
|
+
try:
|
|
272
|
+
return self.provider.stream(self.model, messages, **kwargs)
|
|
273
|
+
except ProviderError as e:
|
|
274
|
+
if not e.transient or self._partial_text:
|
|
275
|
+
raise
|
|
276
|
+
if self.fallback_resolver is not None:
|
|
277
|
+
target = self.fallback_resolver(self.provider_name, self.model)
|
|
278
|
+
else:
|
|
279
|
+
fb = self.settings.get("fallback_model")
|
|
280
|
+
target = (self.fallback_client, fb) if fb and self.fallback_client else None
|
|
281
|
+
if not target or target == (self.provider, self.model):
|
|
282
|
+
raise
|
|
283
|
+
client, fallback = target
|
|
284
|
+
where = "" if client is self.provider else " on another provider"
|
|
285
|
+
self.events.notice(f"{self.model} failed ({e}). Using fallback model {fallback}{where} for this "
|
|
286
|
+
"request; /model to switch, /fallback to choose the fallback.", "warn")
|
|
287
|
+
return client.stream(fallback, messages, **kwargs)
|
|
288
|
+
|
|
289
|
+
def _parallel_ok(self, calls: List[ToolCall]) -> bool:
|
|
290
|
+
"""Several sub-agent tasks in one turn run concurrently. They are read-only and never
|
|
291
|
+
prompt for approval, so running them on threads is safe."""
|
|
292
|
+
tools = self.tools_by_name
|
|
293
|
+
return (len(calls) > 1 and any(c.name == "task" for c in calls)
|
|
294
|
+
and all(c.name in tools and tools[c.name].kind == "read" for c in calls))
|
|
295
|
+
|
|
296
|
+
def _execute_parallel(self, calls: List[ToolCall]) -> List[str]:
|
|
297
|
+
self.events.batch_start(len(calls))
|
|
298
|
+
pool = ThreadPoolExecutor(max_workers=min(4, len(calls)), thread_name_prefix="hubble-task")
|
|
299
|
+
futures = [pool.submit(self._execute, c) for c in calls]
|
|
300
|
+
try:
|
|
301
|
+
# Poll so Ctrl+C reaches the main thread on Windows.
|
|
302
|
+
while not all(f.done() for f in futures):
|
|
303
|
+
wait(futures, timeout=0.2)
|
|
304
|
+
outputs = []
|
|
305
|
+
for f in futures:
|
|
306
|
+
try:
|
|
307
|
+
outputs.append(f.result())
|
|
308
|
+
except KeyboardInterrupt:
|
|
309
|
+
raise
|
|
310
|
+
except Exception as e: # a crash in one task must not lose the others' reports
|
|
311
|
+
outputs.append(f"Error: task crashed: {type(e).__name__}: {e}")
|
|
312
|
+
return outputs
|
|
313
|
+
except KeyboardInterrupt:
|
|
314
|
+
self.cancel.set()
|
|
315
|
+
for f in futures:
|
|
316
|
+
f.cancel()
|
|
317
|
+
raise
|
|
318
|
+
finally:
|
|
319
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
320
|
+
self.events.batch_end()
|
|
321
|
+
|
|
322
|
+
def _account(self, result: TurnResult, stats: RunStats):
|
|
323
|
+
prompt_toks = result.usage.get("prompt_tokens")
|
|
324
|
+
completion_toks = result.usage.get("completion_tokens")
|
|
325
|
+
if prompt_toks is None:
|
|
326
|
+
prompt_toks = estimate_messages(self.messages) + estimate_tokens(self.system_prompt())
|
|
327
|
+
if completion_toks is None:
|
|
328
|
+
completion_toks = estimate_tokens(result.text + result.reasoning) + sum(
|
|
329
|
+
estimate_tokens(c.arguments) for c in result.tool_calls)
|
|
330
|
+
stats.prompt_tokens += prompt_toks
|
|
331
|
+
stats.completion_tokens += completion_toks
|
|
332
|
+
stats.duration += result.duration
|
|
333
|
+
stats.model_calls += 1
|
|
334
|
+
self.total_prompt_tokens += prompt_toks
|
|
335
|
+
self.total_completion_tokens += completion_toks
|
|
336
|
+
self.context_tokens = prompt_toks + completion_toks
|
|
337
|
+
|
|
338
|
+
def _close_dangling_tool_calls(self):
|
|
339
|
+
repaired = repair_history(self.messages)
|
|
340
|
+
if len(repaired) != len(self.messages):
|
|
341
|
+
self.messages = repaired
|
|
342
|
+
if self.session:
|
|
343
|
+
self.session.reset(self.messages)
|
|
344
|
+
|
|
345
|
+
def _execute(self, call: ToolCall) -> str:
|
|
346
|
+
tool = self.tools_by_name.get(call.name)
|
|
347
|
+
if tool is None:
|
|
348
|
+
return f"Error: unknown tool '{call.name}'. Available tools: {', '.join(self.tools_by_name)}"
|
|
349
|
+
try:
|
|
350
|
+
args = parse_arguments(call.arguments)
|
|
351
|
+
tool.validate(args)
|
|
352
|
+
except (ValueError, ToolError) as e:
|
|
353
|
+
output = f"Error: {e}"
|
|
354
|
+
self.events.tool_result(tool, {}, output, True)
|
|
355
|
+
return output
|
|
356
|
+
|
|
357
|
+
target = tool.target(args)
|
|
358
|
+
if tool.kind != "exec" and tool.name not in ("task", "todo_write") and target:
|
|
359
|
+
# Match rules against the canonical workspace-relative path, not the raw argument,
|
|
360
|
+
# so ./secrets/x, absolute paths and a/../secrets/x hit the same rule.
|
|
361
|
+
try:
|
|
362
|
+
target = self.ctx.rel(self.ctx.resolve(target))
|
|
363
|
+
except ToolError:
|
|
364
|
+
pass
|
|
365
|
+
decision, reason = self.permissions.check(tool.name, tool.kind, target)
|
|
366
|
+
if decision == "deny":
|
|
367
|
+
output = f"Permission denied: {reason}."
|
|
368
|
+
self.events.tool_result(tool, args, output, True)
|
|
369
|
+
return output
|
|
370
|
+
if decision == "ask":
|
|
371
|
+
try:
|
|
372
|
+
tool.precheck(args, self.ctx)
|
|
373
|
+
except (ToolError, OSError) as e:
|
|
374
|
+
output = f"Error: {e}"
|
|
375
|
+
self.events.tool_result(tool, args, output, True)
|
|
376
|
+
return output
|
|
377
|
+
try:
|
|
378
|
+
preview = tool.preview(args, self.ctx)
|
|
379
|
+
except (ToolError, OSError) as e:
|
|
380
|
+
preview = f"(preview unavailable: {e})"
|
|
381
|
+
answer, feedback = self.events.ask(tool, args, preview)
|
|
382
|
+
if answer == "always":
|
|
383
|
+
rule = self.permissions.always_rule(tool.name, tool.kind, target)
|
|
384
|
+
self.events.notice(f"Allowed for this session: {rule}")
|
|
385
|
+
elif answer != "yes":
|
|
386
|
+
output = "The user denied this tool call."
|
|
387
|
+
if feedback:
|
|
388
|
+
output += f" User feedback: {feedback}"
|
|
389
|
+
self.events.tool_result(tool, args, output, True)
|
|
390
|
+
return output
|
|
391
|
+
|
|
392
|
+
self.events.tool_start(tool, args)
|
|
393
|
+
output, is_error = run_tool(tool, args, self.ctx)
|
|
394
|
+
self.events.tool_result(tool, args, output, is_error)
|
|
395
|
+
if tool.name == "todo_write" and not is_error:
|
|
396
|
+
self.events.todos(self.ctx.todos)
|
|
397
|
+
elif tool.name == "write_skill" and not is_error:
|
|
398
|
+
self.reload_skills()
|
|
399
|
+
return output
|
|
400
|
+
|
|
401
|
+
# ----- context management -------------------------------------------
|
|
402
|
+
|
|
403
|
+
def compact(self, focus: str = "", mid_turn: bool = False) -> bool:
|
|
404
|
+
if len(self.messages) < 3:
|
|
405
|
+
return False
|
|
406
|
+
lines = []
|
|
407
|
+
for m in self.messages:
|
|
408
|
+
role = m.get("role")
|
|
409
|
+
if role == "tool":
|
|
410
|
+
lines.append(f"[tool result {m.get('name', '')}]\n{truncate(m.get('content') or '', 1500)}")
|
|
411
|
+
else:
|
|
412
|
+
text = m.get("content") or ""
|
|
413
|
+
for tc in m.get("tool_calls") or []:
|
|
414
|
+
text += f"\n[calls {tc['function']['name']}({truncate(tc['function']['arguments'], 400)})]"
|
|
415
|
+
lines.append(f"[{role}]\n{text}")
|
|
416
|
+
budget = int(int(self.settings.get("context_window", 128000)) * 3.8 * 0.6)
|
|
417
|
+
transcript = truncate("\n\n".join(lines), budget)
|
|
418
|
+
instruction = COMPACT_PROMPT + (f"\nFocus especially on: {focus}" if focus else "")
|
|
419
|
+
summary = self.provider.complete(
|
|
420
|
+
self.model,
|
|
421
|
+
[{"role": "system", "content": "You write precise summaries of coding sessions."},
|
|
422
|
+
{"role": "user", "content": f"<transcript>\n{transcript}\n</transcript>\n\n{instruction}"}],
|
|
423
|
+
max_tokens=2500)
|
|
424
|
+
if not summary.strip():
|
|
425
|
+
self.events.notice("Compaction returned an empty summary; history kept.", "warn")
|
|
426
|
+
return False
|
|
427
|
+
head = {"role": "user", "content": f"<conversation-summary>\n{summary.strip()}\n</conversation-summary>\n"
|
|
428
|
+
"The earlier conversation was compacted into the summary above."
|
|
429
|
+
+ (" Continue the current task from where it left off." if mid_turn else "")}
|
|
430
|
+
self.messages = [head] if mid_turn else [head, {"role": "assistant", "content": "Understood. I have the context."}]
|
|
431
|
+
if self.session:
|
|
432
|
+
self.session.reset(self.messages)
|
|
433
|
+
self.context_tokens = estimate_messages(self.messages)
|
|
434
|
+
self.events.notice(f"Compacted history into a {estimate_tokens(summary)}-token summary.")
|
|
435
|
+
return True
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
SUBAGENT_VERBS = {"read_file": "reading", "grep": "searching for", "glob": "finding", "list_dir": "listing",
|
|
439
|
+
"web_search": "searching the web for"}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class _SubagentEvents(Events):
|
|
443
|
+
"""Forwards a sub-agent's progress to the parent UI under a stable key."""
|
|
444
|
+
|
|
445
|
+
def __init__(self, parent: Events, label: str, key: str):
|
|
446
|
+
self.parent = parent
|
|
447
|
+
self.label = label
|
|
448
|
+
self.key = key
|
|
449
|
+
|
|
450
|
+
def turn_start(self):
|
|
451
|
+
self.parent.subagent_step(self.key, "thinking", is_tool=False)
|
|
452
|
+
|
|
453
|
+
def tool_start(self, tool, args):
|
|
454
|
+
verb = SUBAGENT_VERBS.get(tool.name, tool.name)
|
|
455
|
+
self.parent.subagent_step(self.key, f"{verb} {describe_call(tool, args)}".strip())
|
|
456
|
+
|
|
457
|
+
def turn_end(self, result):
|
|
458
|
+
used = result.usage.get("total_tokens") or (
|
|
459
|
+
result.usage.get("prompt_tokens", 0) + result.usage.get("completion_tokens", 0))
|
|
460
|
+
if not used:
|
|
461
|
+
used = estimate_tokens(result.text + result.reasoning)
|
|
462
|
+
self.parent.subagent_tokens(self.key, used)
|
|
463
|
+
|
|
464
|
+
def notice(self, message, level="info"):
|
|
465
|
+
if level in ("warn", "error"):
|
|
466
|
+
self.parent.notice(f" ↳ {self.label}: {message}", level)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
_task_ids = itertools.count(1)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class TaskTool(Tool):
|
|
473
|
+
name = "task"
|
|
474
|
+
description = ("Launch a read-only sub-agent to research a question that needs many searches or file "
|
|
475
|
+
"reads (e.g. 'find where auth tokens are validated and explain the flow'). It returns a "
|
|
476
|
+
"written report. It cannot edit files or run commands.")
|
|
477
|
+
parameters = {"type": "object", "properties": {
|
|
478
|
+
"description": {"type": "string", "description": "3-5 word task label"},
|
|
479
|
+
"prompt": {"type": "string", "description": "Detailed, self-contained instructions for the sub-agent"},
|
|
480
|
+
}, "required": ["description", "prompt"]}
|
|
481
|
+
kind = "read"
|
|
482
|
+
|
|
483
|
+
def __init__(self, parent: Agent):
|
|
484
|
+
self.parent = parent
|
|
485
|
+
|
|
486
|
+
def target(self, args):
|
|
487
|
+
return args.get("description", "")
|
|
488
|
+
|
|
489
|
+
def run(self, args, ctx):
|
|
490
|
+
p = self.parent
|
|
491
|
+
sub_ctx = ToolContext(root=ctx.root, extra_dirs=ctx.extra_dirs, allow_secrets=ctx.allow_secrets,
|
|
492
|
+
shell_argv=ctx.shell_argv, shell_timeout=ctx.shell_timeout)
|
|
493
|
+
# Read-only tools plus web search; web_fetch needs per-domain approval, which sub-agents cannot ask for.
|
|
494
|
+
tools = [t for t in default_tools() + web_tools(p.settings) if t.name in READ_ONLY_TOOL_NAMES | {"web_search"}]
|
|
495
|
+
system = ("You are a read-only research sub-agent inside Hubble. Use the tools to investigate the "
|
|
496
|
+
f"workspace at {ctx.root} and answer the task. Finish with a concise, factual report citing "
|
|
497
|
+
"path:line. You cannot edit files or run commands.")
|
|
498
|
+
label = args.get("description") or "task"
|
|
499
|
+
key = f"task-{next(_task_ids)}"
|
|
500
|
+
p.events.subagent_start(key, label)
|
|
501
|
+
sub = Agent(p.provider, {**p.settings, "model": p.model, "max_turns": 20, "auto_compact_ratio": 0},
|
|
502
|
+
sub_ctx, Permissions("plan", deny=p.permissions.deny),
|
|
503
|
+
_SubagentEvents(p.events, label, key), tools=tools, system_override=system)
|
|
504
|
+
sub.cancel = p.cancel
|
|
505
|
+
sub.is_subagent = True
|
|
506
|
+
# Same provider and fallback route as the parent (it may have switched provider mid-session).
|
|
507
|
+
sub.provider_name = p.provider_name
|
|
508
|
+
sub.fallback_client = p.fallback_client
|
|
509
|
+
sub.fallback_resolver = p.fallback_resolver
|
|
510
|
+
status, detail = "failed", "crashed"
|
|
511
|
+
try:
|
|
512
|
+
report = sub.run(args["prompt"])
|
|
513
|
+
if sub.last_stats.interrupted:
|
|
514
|
+
status, detail = "stopped", "stopped by user"
|
|
515
|
+
raise KeyboardInterrupt # Ctrl+C must stop the parent turn too
|
|
516
|
+
if sub.last_stats.error:
|
|
517
|
+
detail = f"failed: {sub.last_stats.error}"
|
|
518
|
+
raise ToolError(f"sub-agent failed: {sub.last_stats.error}")
|
|
519
|
+
status = "done"
|
|
520
|
+
detail = f"report ready ({len(report or ''):,} chars)" if report else "no report"
|
|
521
|
+
return report or "(sub-agent returned no report)"
|
|
522
|
+
finally:
|
|
523
|
+
p.total_prompt_tokens += sub.total_prompt_tokens
|
|
524
|
+
p.total_completion_tokens += sub.total_completion_tokens
|
|
525
|
+
p.events.subagent_end(key, status, detail)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def describe_call(tool: Tool, args: Dict[str, Any]) -> str:
|
|
529
|
+
"""One-line human label for a tool call."""
|
|
530
|
+
if tool.name == "shell":
|
|
531
|
+
return args.get("command", "")
|
|
532
|
+
if tool.name in ("grep", "glob"):
|
|
533
|
+
where = args.get("path")
|
|
534
|
+
return f"{args.get('pattern', '')}" + (f" in {where}" if where else "")
|
|
535
|
+
if tool.name == "task":
|
|
536
|
+
return args.get("description", "")
|
|
537
|
+
if tool.name == "web_search":
|
|
538
|
+
return args.get("query", "")
|
|
539
|
+
if tool.name == "web_fetch":
|
|
540
|
+
return args.get("url", "")
|
|
541
|
+
if tool.name == "todo_write":
|
|
542
|
+
return f"{len(args.get('todos') or [])} items"
|
|
543
|
+
if tool.name == "read_file" and (args.get("offset") or args.get("limit")):
|
|
544
|
+
return f"{args.get('path')} (from line {args.get('offset') or 1})"
|
|
545
|
+
target = tool.target(args)
|
|
546
|
+
return target or json.dumps(args)[:80]
|