feat: add file edit diff progress view

Capture file edit snapshots through runner tool lifecycle hooks and render unified diffs in the WebUI with folding and truncation controls.
This commit is contained in:
chengyongru
2026-07-09 10:42:43 +08:00
committed by Xubin Ren
parent 207813d3b5
commit 7768672c5b
40 changed files with 2224 additions and 1753 deletions
+72
View File
@@ -93,6 +93,35 @@ class AgentHook:
async def before_execute_tools(self, context: AgentHookContext) -> None:
pass
async def before_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
) -> None:
pass
async def after_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
result: Any,
) -> None:
pass
async def on_execute_tool_error(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
error: Any,
) -> None:
pass
async def emit_reasoning(self, reasoning_content: str | None) -> None:
pass
@@ -166,6 +195,49 @@ class CompositeHook(AgentHook):
async def before_execute_tools(self, context: AgentHookContext) -> None:
await self._for_each_hook_safe("before_execute_tools", context)
async def before_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
) -> None:
await self._for_each_hook_safe("before_execute_tool", context, tool_call, tool, params)
async def after_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
result: Any,
) -> None:
await self._for_each_hook_safe(
"after_execute_tool",
context,
tool_call,
tool,
params,
result,
)
async def on_execute_tool_error(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
error: Any,
) -> None:
await self._for_each_hook_safe(
"on_execute_tool_error",
context,
tool_call,
tool,
params,
error,
)
async def emit_reasoning(self, reasoning_content: str | None) -> None:
await self._for_each_hook_safe("emit_reasoning", reasoning_content)
+11
View File
@@ -0,0 +1,11 @@
"""Concrete agent hook implementations."""
from nanobot.agent.hooks.file_edit_activity import (
FileEditActivityHook,
create_file_edit_activity_hook,
)
__all__ = [
"FileEditActivityHook",
"create_file_edit_activity_hook",
]
+109
View File
@@ -0,0 +1,109 @@
"""Agent hook that observes file-editing tools and emits file-edit activity."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentTurnHookContext
from nanobot.providers.base import ToolCallRequest
from nanobot.utils.file_edit_events import (
FileEditTracker,
build_file_edit_end_event,
build_file_edit_error_event,
build_file_edit_start_event,
prepare_file_edit_trackers,
)
from nanobot.utils.progress_events import (
invoke_file_edit_progress,
on_progress_accepts_file_edit_events,
)
class FileEditActivityHook(AgentHook):
"""Translate file-editing tool lifecycle events into WebUI progress events."""
def __init__(
self,
*,
on_progress: Callable[..., Awaitable[None]] | None,
workspace: Path | None,
) -> None:
super().__init__()
self._on_progress = (
on_progress
if on_progress is not None and on_progress_accepts_file_edit_events(on_progress)
else None
)
self._workspace = workspace
self._trackers_by_call: dict[str, list[FileEditTracker]] = {}
async def before_iteration(self, context: AgentHookContext) -> None:
self._trackers_by_call.clear()
async def before_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
) -> None:
if self._on_progress is None or not isinstance(params, dict):
return
trackers = prepare_file_edit_trackers(
call_id=tool_call.id,
tool_name=tool_call.name,
tool=tool,
workspace=self._workspace,
params=params,
)
if not trackers:
return
self._trackers_by_call[self._tool_call_key(tool_call)] = trackers
await self._emit([build_file_edit_start_event(tracker, params) for tracker in trackers])
async def after_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
result: Any,
) -> None:
trackers = self._trackers_by_call.pop(self._tool_call_key(tool_call), [])
if trackers:
await self._emit([build_file_edit_end_event(tracker) for tracker in trackers])
async def on_execute_tool_error(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
error: Any,
) -> None:
trackers = self._trackers_by_call.pop(self._tool_call_key(tool_call), [])
if trackers:
await self._emit([
build_file_edit_error_event(tracker, str(error)) for tracker in trackers
])
async def _emit(self, events: list[dict[str, Any]]) -> None:
if self._on_progress is not None:
await invoke_file_edit_progress(self._on_progress, events)
@staticmethod
def _tool_call_key(tool_call: ToolCallRequest) -> str:
call_id = getattr(tool_call, "id", "") or ""
return f"{call_id}|{tool_call.name}" if call_id else f"{id(tool_call)}|{tool_call.name}"
def create_file_edit_activity_hook(context: AgentTurnHookContext) -> AgentHook | None:
"""Create the default file-edit observer for one agent turn."""
if context.on_progress is None:
return None
return FileEditActivityHook(
on_progress=context.on_progress,
workspace=context.workspace,
)
+26 -96
View File
@@ -21,16 +21,6 @@ from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.utils.file_edit_events import (
StreamingFileEditTracker,
build_file_edit_end_event,
build_file_edit_error_event,
build_file_edit_start_event,
prepare_file_edit_trackers,
)
from nanobot.utils.file_edit_events import (
prepare_file_edit_tracker as _prepare_file_edit_tracker,
)
from nanobot.utils.helpers import (
IncrementalThinkExtractor,
build_assistant_message,
@@ -40,10 +30,6 @@ from nanobot.utils.helpers import (
strip_reasoning_tags,
strip_think,
)
from nanobot.utils.progress_events import (
invoke_file_edit_progress,
on_progress_accepts_file_edit_events,
)
from nanobot.utils.prompt_templates import render_template
from nanobot.utils.runtime import (
EMPTY_FINAL_RESPONSE_MESSAGE,
@@ -68,10 +54,6 @@ _MAX_EMPTY_RETRIES = 2
_MAX_LENGTH_RECOVERIES = 3
_MAX_INJECTIONS_PER_TURN = 3
_MAX_INJECTION_CYCLES = 5
# Backward-compatible module attribute for tests/extensions that monkeypatch
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
prepare_file_edit_tracker = _prepare_file_edit_tracker
@dataclass(slots=True)
class AgentRunSpec:
@@ -461,6 +443,8 @@ class AgentRunner:
response.tool_calls,
external_lookup_counts,
workspace_violation_counts,
hook,
context,
)
tool_events.extend(new_events)
tools_used.extend(
@@ -766,24 +750,6 @@ class AgentRunner:
)
progress_state: dict[str, bool] | None = None
live_file_edits: StreamingFileEditTracker | None = None
if (
spec.progress_callback is not None
and on_progress_accepts_file_edit_events(spec.progress_callback)
):
async def _emit_live_file_edits(events: list[dict[str, Any]]) -> None:
await invoke_file_edit_progress(spec.progress_callback, events)
live_file_edits = StreamingFileEditTracker(
workspace=spec.workspace,
tools=spec.tools,
emit=_emit_live_file_edits,
)
async def _tool_call_delta(delta: dict[str, Any]) -> None:
if live_file_edits is not None:
await live_file_edits.update(delta)
if wants_streaming:
thinking_buf = ""
@@ -812,7 +778,6 @@ class AgentRunner:
**kwargs,
on_content_delta=_stream,
on_thinking_delta=_thinking,
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
on_stream_recover=_stream_recover,
)
elif wants_progress_streaming:
@@ -843,7 +808,6 @@ class AgentRunner:
coro = self.provider.chat_stream_with_retry(
**kwargs,
on_content_delta=_stream_progress,
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
)
else:
coro = self.provider.chat_with_retry(**kwargs)
@@ -858,14 +822,6 @@ class AgentRunner:
await coro if outer_timeout_s is None
else await asyncio.wait_for(coro, timeout=outer_timeout_s)
)
if live_file_edits is not None:
await live_file_edits.flush()
if response.should_execute_tools:
live_file_edits.apply_final_call_ids(response.tool_calls)
await live_file_edits.error_unmatched(
response.tool_calls if response.should_execute_tools else [],
"Tool call did not complete.",
)
except asyncio.TimeoutError:
if outer_timeout_s is None:
return LLMResponse(
@@ -1131,14 +1087,23 @@ class AgentRunner:
tool_calls: list[ToolCallRequest],
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
hook: AgentHook | None = None,
context: AgentHookContext | None = None,
) -> tuple[list[Any], list[dict[str, str]], BaseException | None]:
hook = hook or AgentHook()
context = context or AgentHookContext(iteration=0, messages=[])
batches = self._partition_tool_batches(spec, tool_calls)
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
for batch in batches:
if spec.concurrent_tools and len(batch) > 1:
batch_results = await asyncio.gather(*(
self._run_tool(
spec, tool_call, external_lookup_counts, workspace_violation_counts,
spec,
tool_call,
external_lookup_counts,
workspace_violation_counts,
hook,
context,
)
for tool_call in batch
))
@@ -1147,7 +1112,12 @@ class AgentRunner:
batch_results = []
for tool_call in batch:
result = await self._run_tool(
spec, tool_call, external_lookup_counts, workspace_violation_counts,
spec,
tool_call,
external_lookup_counts,
workspace_violation_counts,
hook,
context,
)
tool_results.append(result)
batch_results.append(result)
@@ -1168,7 +1138,11 @@ class AgentRunner:
tool_call: ToolCallRequest,
external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int],
hook: AgentHook | None = None,
context: AgentHookContext | None = None,
) -> tuple[Any, dict[str, str], BaseException | None]:
hook = hook or AgentHook()
context = context or AgentHookContext(iteration=0, messages=[])
hint = "\n\n[Analyze the error above and try a different approach.]"
lookup_error = repeated_external_lookup_error(
tool_call.name,
@@ -1209,30 +1183,7 @@ class AgentRunner:
return prep_error + hint, event, (
RuntimeError(prep_error) if spec.fail_on_tool_error else None
)
emit_file_edit_events = (
spec.progress_callback is not None
and on_progress_accepts_file_edit_events(spec.progress_callback)
)
progress_callback = spec.progress_callback if emit_file_edit_events else None
file_edit_trackers = (
prepare_file_edit_trackers(
call_id=tool_call.id,
tool_name=tool_call.name,
tool=tool,
workspace=spec.workspace,
params=params if isinstance(params, dict) else None,
)
if progress_callback is not None
else None
)
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_start_event(
file_edit_tracker,
params if isinstance(params, dict) else None,
) for file_edit_tracker in file_edit_trackers],
)
await hook.before_execute_tool(context, tool_call, tool, params)
try:
if tool is not None:
result = await tool.execute(**params)
@@ -1241,14 +1192,7 @@ class AgentRunner:
except asyncio.CancelledError:
raise
except BaseException as exc:
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[
build_file_edit_error_event(file_edit_tracker, str(exc))
for file_edit_tracker in file_edit_trackers
],
)
await hook.on_execute_tool_error(context, tool_call, tool, params, exc)
event = {
"name": tool_call.name,
"status": "error",
@@ -1270,14 +1214,7 @@ class AgentRunner:
return payload, event, None
if is_tool_error_result(tool_call.name, result):
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[
build_file_edit_error_event(file_edit_tracker, result)
for file_edit_tracker in file_edit_trackers
],
)
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
event = {
"name": tool_call.name,
"status": "error",
@@ -1296,14 +1233,7 @@ class AgentRunner:
return result + hint, event, RuntimeError(result)
return result + hint, event, None
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_end_event(
file_edit_tracker,
params if isinstance(params, dict) else None,
) for file_edit_tracker in file_edit_trackers],
)
await hook.after_execute_tool(context, tool_call, tool, params, result)
detail = "" if result is None else str(result)
detail = detail.replace("\n", " ").strip()
+4
View File
@@ -61,6 +61,7 @@ from rich.text import Text # noqa: E402
from nanobot import __logo__, __version__ # noqa: E402
from nanobot import optional_features as feature_support # noqa: E402
from nanobot.agent.hooks import create_file_edit_activity_hook # noqa: E402
from nanobot.agent.loop import AgentLoop # noqa: E402
from nanobot.bus.outbound_events import ( # noqa: E402
ProgressEvent,
@@ -1177,6 +1178,7 @@ def serve(
runtime_config, bus,
session_manager=session_manager,
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
hook_factories=[create_file_edit_activity_hook],
)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
@@ -1429,6 +1431,7 @@ def _run_gateway(
provider_signature=provider_snapshot.signature,
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
local_trigger_store=trigger_store,
hook_factories=[create_file_edit_activity_hook],
)
WebuiTurnCoordinator(
bus=bus,
@@ -1914,6 +1917,7 @@ def agent(
config, bus,
cron_service=cron,
image_generation_provider_configs=image_gen_provider_configs(config),
hook_factories=[create_file_edit_activity_hook],
)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
+2
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from typing import Any
from nanobot.agent.hook import AgentHook, SDKCaptureHook
from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop
from nanobot.config.schema import Config
from nanobot.providers.image_generation import image_gen_provider_configs
@@ -120,6 +121,7 @@ class Nanobot:
loop = AgentLoop.from_config(
config,
image_generation_provider_configs=image_gen_provider_configs(config),
hook_factories=[create_file_edit_activity_hook],
)
return cls(loop, config=config)
File diff suppressed because it is too large Load Diff
+43 -9
View File
@@ -1525,16 +1525,50 @@ def replay_transcript_to_ui_messages(
)
):
return i
existing_tool_events = candidate.get("toolEvents")
if isinstance(existing_tool_events, list):
for event in existing_tool_events:
if not isinstance(event, dict):
continue
key = _tool_event_file_edit_key(event)
if key and key in incoming_tool_event_keys:
return i
return None
def trace_message_is_empty(message: dict[str, Any]) -> bool:
traces = message.get("traces")
if isinstance(traces, list):
has_trace = any(isinstance(trace, str) and trace.strip() for trace in traces)
else:
has_trace = bool(str(message.get("content") or "").strip())
return (
message.get("kind") == "trace"
and not has_trace
and not message.get("toolEvents")
and not message.get("fileEdits")
and not message.get("media")
)
def strip_covered_file_edit_tool_hints_from_recent_messages(
edits: list[dict[str, Any]],
turn_fields: dict[str, Any],
) -> None:
nonlocal messages
if not edits:
return
next_messages = list(messages)
changed = False
for i in range(len(next_messages) - 1, -1, -1):
candidate = next_messages[i]
if candidate.get("role") == "user":
break
if candidate.get("kind") != "trace":
continue
if not _same_turn(candidate, turn_fields):
continue
cleaned = _strip_covered_file_edit_tool_hints(candidate, edits)
if cleaned is candidate:
continue
changed = True
if trace_message_is_empty(cleaned):
next_messages.pop(i)
else:
next_messages[i] = cleaned
if changed:
messages = next_messages
def upsert_file_edits(
edits: list[dict[str, Any]],
idx: int,
@@ -1549,12 +1583,12 @@ def replay_transcript_to_ui_messages(
segment = _new_activity_segment(activate=False)
active_file_edit_segment_id = segment
demote_interrupted_assistant(segment)
strip_covered_file_edit_tool_hints_from_recent_messages(edits, turn_fields)
target_index = find_file_edit_trace_index(segment, edits)
if target_index is not None:
last = messages[target_index]
segment = str(last.get("activitySegmentId") or segment or _new_activity_segment(activate=False))
active_file_edit_segment_id = segment
last = _strip_covered_file_edit_tool_hints(last, edits)
else:
if not segment:
segment = _new_activity_segment(activate=False)