Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion libs/hackbot-runtime/hackbot_runtime/actions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
claude-sdk adapter is ``hackbot_runtime.actions.claude_sdk.actions_server_for``.
"""

from hackbot_runtime.actions import bugzilla, phabricator, slack, testrail, try_server
from hackbot_runtime.actions import (
bugzilla,
phabricator,
recorded_actions,
slack,
testrail,
try_server,
)
from hackbot_runtime.actions.recorder import ActionHook, ActionsRecorder

ACTIONS_SERVER_NAME = "actions"
Expand All @@ -18,6 +25,7 @@
"ActionsRecorder",
"bugzilla",
"phabricator",
"recorded_actions",
"slack",
"testrail",
"try_server",
Expand Down
20 changes: 10 additions & 10 deletions libs/hackbot-runtime/hackbot_runtime/actions/bugzilla.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
)


def _confirm(recorder: ActionsRecorder, action_type: str) -> str:
return f"Recorded {action_type} (#{len(recorder.actions) - 1})."
def _confirm(action: dict) -> str:
return f"Recorded {action['type']} (ID: {action['action_id']})."


@tool
Expand Down Expand Up @@ -61,12 +61,12 @@ async def update_bug(

Recorded into the run summary for human review — does not modify Bugzilla.
"""
recorder.record(
action = recorder.record(
"bugzilla.update_bug",
{"bug_id": bug_id, "changes": changes},
reasoning=reasoning,
)
return _confirm(recorder, "bugzilla.update_bug")
return _confirm(action)


@tool
Expand All @@ -91,12 +91,12 @@ async def add_comment(
summary for human review — does not post to Bugzilla.
"""
text_with_footer = text.rstrip() + "\n\n---\n\n" + _COMMENT_FOOTER
recorder.record(
action = recorder.record(
"bugzilla.add_comment",
{"bug_id": bug_id, "text": text_with_footer, "is_private": is_private},
reasoning=reasoning,
)
return _confirm(recorder, "bugzilla.add_comment")
return _confirm(action)


@tool
Expand Down Expand Up @@ -181,13 +181,13 @@ async def add_attachment(
if comment:
params["comment"] = comment

recorder.record(
action = recorder.record(
"bugzilla.add_attachment",
params,
reasoning=reasoning,
attachments={"file": Path(file_path)},
)
return _confirm(recorder, "bugzilla.add_attachment")
return _confirm(action)


@tool
Expand Down Expand Up @@ -231,8 +231,8 @@ async def create_bug(
for k, v in (extra or {}).items():
body.setdefault(k, v)

recorder.record("bugzilla.create_bug", body, reasoning=reasoning)
return _confirm(recorder, "bugzilla.create_bug")
action = recorder.record("bugzilla.create_bug", body, reasoning=reasoning)
return _confirm(action)


TOOLS = tools_in(__name__)
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from hackbot_runtime.actions import ACTIONS_SERVER_NAME
from hackbot_runtime.actions import bugzilla as _bugzilla
from hackbot_runtime.actions import phabricator as _phabricator
from hackbot_runtime.actions import recorded_actions as _recorded_actions
from hackbot_runtime.actions import slack as _slack
from hackbot_runtime.actions import testrail as _testrail
from hackbot_runtime.actions import try_server as _try_server
Expand All @@ -37,7 +38,8 @@ def actions_server_for(
if recorder is None:
recorder = ActionsRecorder(artifacts_dir=fallback_artifacts_dir)
tools = (
_bugzilla.TOOLS
_recorded_actions.TOOLS
+ _bugzilla.TOOLS
+ _phabricator.TOOLS
+ _testrail.TOOLS
+ _slack.TOOLS
Expand Down
16 changes: 8 additions & 8 deletions libs/hackbot-runtime/hackbot_runtime/actions/phabricator.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
)


def _confirm(recorder: ActionsRecorder, action_type: str) -> str:
return f"Recorded {action_type} (#{len(recorder.actions) - 1})."
def _confirm(action: dict) -> str:
return f"Recorded {action['type']} (ID: {action['action_id']})."


def _validate_summary(summary: str | None) -> None:
Expand Down Expand Up @@ -100,13 +100,13 @@ async def submit_patch(
bug comment).
"""
_validate_summary(summary)
recorder.record(
action = recorder.record(
"phabricator.submit_patch",
{"bug_id": bug_id, "title": title, "summary": summary},
reasoning=reasoning,
ref=ref,
)
return _confirm(recorder, "phabricator.submit_patch")
return _confirm(action)


@tool
Expand Down Expand Up @@ -141,12 +141,12 @@ async def update_patch(
Only the diff changes: the revision keeps its title, summary, and bug
association exactly as they are.
"""
recorder.record(
action = recorder.record(
"phabricator.update_patch",
{"revision_id": revision_id},
reasoning=reasoning,
)
return _confirm(recorder, "phabricator.update_patch")
return _confirm(action)


@tool
Expand All @@ -167,12 +167,12 @@ async def add_comment(
changes, use ``submit_patch`` instead. Recorded into the run summary for
human review; nothing is posted to Phabricator during the run.
"""
recorder.record(
action = recorder.record(
"phabricator.add_comment",
{"revision_id": revision_id, "text": text},
reasoning=reasoning,
)
return _confirm(recorder, "phabricator.add_comment")
return _confirm(action)


TOOLS = tools_in(__name__)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we call it actions_manager or something related instead of recorded_actions? WDYT?

@ayoubdiourin7 ayoubdiourin7 Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actions_manager feels a little generic. What about recorded_actions_manager or action_records? However, I’m also fine with actions_manager.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. Let's go with action_records.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW, the file name should match to keep things consistent.

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Agent-facing tools for inspecting and retracting recorded actions."""

from __future__ import annotations

from typing import Annotated

from agent_tools.registry import tool, tools_in
from pydantic import Field

from hackbot_runtime.actions.recorder import ActionsRecorder


def _table_cell(value: str | None) -> str:
"""Format one value for a Markdown table cell."""
if value is None:
return ""
return "<br>".join(value.splitlines()).replace("|", r"\|")


@tool
async def list_actions(recorder: ActionsRecorder) -> str:
"""List the actions currently proposed by this agent run.

Returns a Markdown table with each action's ID, type, and reasoning.
"""
actions = recorder.list_actions()
if not actions:
return "No recorded actions."

rows = ["| ID | Action | Reasoning |", "| --- | --- | --- |"]
rows.extend(
f"| {_table_cell(action['action_id'])} "
f"| {_table_cell(action['type'])} "
f"| {_table_cell(action.get('reasoning'))} |"
for action in actions
)
return "\n".join(rows)


@tool
async def remove_action(
recorder: ActionsRecorder,
action_id: Annotated[
str,
Field(description="ID of the action to remove."),
],
) -> str:
"""Remove the recorded action with the given ID."""
recorder.remove_action(action_id)
return f"Removed recorded action {action_id}."


TOOLS = tools_in(__name__)
45 changes: 35 additions & 10 deletions libs/hackbot-runtime/hackbot_runtime/actions/recorder.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import copy
import uuid
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path

from agent_tools.registry import ToolError

from hackbot_runtime.artifacts import publish_file
from hackbot_runtime.uploader import SignedPolicyUploader

Expand Down Expand Up @@ -37,7 +41,7 @@ def __init__(
artifacts_dir: Path | None = None,
hooks: Mapping[str, Sequence[ActionHook]] = {},
) -> None:
self._actions: list[dict] = []
self._actions: dict[str, dict] = {}
self._uploader = uploader
self._artifacts_dir = artifacts_dir
self._hooks = {
Expand All @@ -62,17 +66,17 @@ def record(
attachments: dict[str, Path] | None = None,
ref: str | None = None,
) -> dict:
"""Record an intended action.
"""Record an action and return a detached copy with its ID.

``action_type`` uses ``<domain>.<verb>`` (e.g. ``bugzilla.update_bug``,
``phabricator.create_revision``). ``params`` is action-specific data
the apply step will need. ``attachments`` maps a logical name to a
local file path; each file is preserved under the stable key
``attachments/<action_index>/<name>``: uploaded via the runtime
``attachments/<action_id>/<name>``: uploaded via the runtime
uploader when one is configured, otherwise copied into the local
artifacts directory (so it is retrievable from compose/direct runs).
The recorded action references it by that key; the original local
path is not persisted (it disappears with the container).
The recorded action references it by that key; the original local path
is not persisted (it disappears with the container).

``ref`` optionally labels this action so a *later* action in the same
run can reference its apply-time result (e.g. a Bugzilla comment's
Expand All @@ -88,7 +92,7 @@ def record(
recording leaves nothing behind: the action the hooks see carries no
``attachments`` key yet.
"""
idx = len(self._actions)
action_id = f"action-{uuid.uuid4().hex}"
action: dict = {
"type": action_type,
"params": params,
Expand All @@ -106,15 +110,36 @@ def record(
key = publish_file(
self._uploader,
self._artifacts_dir,
f"attachments/{idx}/{name}",
f"attachments/{action_id}/{name}",
path,
)
recorded_attachments.append({"name": name, "uploaded_key": key})
action["attachments"] = recorded_attachments

self._actions.append(action)
return action
self._actions[action_id] = action
return _detach(action_id, action)

def list_actions(self) -> list[dict]:
"""Return complete copies of the current actions with stable in-run IDs."""
return [
_detach(action_id, action) for action_id, action in self._actions.items()
]
Comment thread
ayoubdiourin7 marked this conversation as resolved.

def remove_action(self, action_id: str) -> dict:
"""Remove the action with the given ID and return a copy of it."""
action = self._actions.get(action_id)
if action is None:
raise ToolError(f"No recorded action with ID {action_id!r}.")

removed = _detach(action_id, action)
del self._actions[action_id]
return removed

@property
def actions(self) -> list[dict]:
return list(self._actions)
return list(self._actions.values())


def _detach(action_id: str, action: dict) -> dict:
"""Return a detached copy of an action with its ID."""
return {**copy.deepcopy(action), "action_id": action_id}
4 changes: 2 additions & 2 deletions libs/hackbot-runtime/hackbot_runtime/actions/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ async def post_message(

Recorded into the run summary for human review -- does not post to Slack.
"""
recorder.record(ACTION_TYPE, _params(channel, text), reasoning=reasoning)
return f"Recorded {ACTION_TYPE} (#{len(recorder.actions) - 1})."
action = recorder.record(ACTION_TYPE, _params(channel, text), reasoning=reasoning)
return f"Recorded {ACTION_TYPE} (ID: {action['action_id']})."
Comment on lines +73 to +74

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should file a follow-up issue to centralize the confirmation message.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed #6697



def record_message(
Expand Down
8 changes: 4 additions & 4 deletions libs/hackbot-runtime/hackbot_runtime/actions/testrail.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ def case_ids_must_be_sequential(self) -> "SubmitTestPlanInput":
return self


def _confirm(recorder: ActionsRecorder, action_type: str) -> str:
return f"Recorded {action_type} (#{len(recorder.actions) - 1})."
def _confirm(action: dict) -> str:
return f"Recorded {action['type']} (ID: {action['action_id']})."


def _validated_params(
Expand Down Expand Up @@ -191,8 +191,8 @@ async def submit_test_plan(
generated_test_cases,
summary=summary,
)
recorder.record(ACTION_TYPE, params)
return _confirm(recorder, ACTION_TYPE)
action = recorder.record(ACTION_TYPE, params)
return _confirm(action)


TOOLS = tools_in(__name__)
4 changes: 2 additions & 2 deletions libs/hackbot-runtime/hackbot_runtime/actions/try_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ async def push(
if tests:
params["test_paths"] = validate_test_paths(tests)

recorder.record(TRY_PUSH_ACTION_TYPE, params, reasoning=reasoning, ref=ref)
return f"Recorded {TRY_PUSH_ACTION_TYPE} (#{len(recorder.actions) - 1})."
action = recorder.record(TRY_PUSH_ACTION_TYPE, params, reasoning=reasoning, ref=ref)
return f"Recorded {TRY_PUSH_ACTION_TYPE} (ID: {action['action_id']})."


TOOLS = tools_in(__name__)
Loading