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 ADDED
@@ -0,0 +1,3 @@
1
+ """Hubble: an agentic coding CLI for OpenAI-compatible gateways such as AIHub."""
2
+
3
+ __version__ = "4.0.0"
hubble/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from hubble.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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]