Skip to content
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__)
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""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."),
],
) -> dict:
"""Remove and return a recorded action."""
return recorder.remove_action(action_id)


TOOLS = tools_in(__name__)
49 changes: 39 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,8 @@ def __init__(
artifacts_dir: Path | None = None,
hooks: Mapping[str, Sequence[ActionHook]] = {},
) -> None:
self._actions: list[dict] = []
self._actions: dict[str, dict] = {}
self._next_action_sequence = 0
self._uploader = uploader
self._artifacts_dir = artifacts_dir
self._hooks = {
Expand All @@ -62,17 +67,18 @@ 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_sequence>/<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 sequence is never reused, even after action removal. 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 +94,9 @@ def record(
recording leaves nothing behind: the action the hooks see carries no
``attachments`` key yet.
"""
idx = len(self._actions)
sequence = self._next_action_sequence

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.

Also, sequential IDs would be easy for the agent to guess and might remove things by accident. We could use UUIDs, which would be harder to guess.

self._next_action_sequence += 1
action_id = f"action-{uuid.uuid4().hex}"
action: dict = {
"type": action_type,
"params": params,
Expand All @@ -106,15 +114,36 @@ def record(
key = publish_file(
self._uploader,
self._artifacts_dir,
f"attachments/{idx}/{name}",
f"attachments/{sequence}/{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 on lines +128 to +130

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 would return MD table, with ID, Action (based on the tool name), and reasoning.


def remove_action(self, action_id: str) -> dict:
"""Remove and return an action."""
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 @@ -64,8 +64,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']})."


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 @@ -87,8 +87,8 @@ def feature_must_not_be_blank(cls, value: str) -> str:
return value


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(feature: str, generated_test_cases: list[Any]) -> dict[str, Any]:
Expand Down Expand Up @@ -128,8 +128,8 @@ async def submit_test_plan(
Nothing is sent to TestRail during the agent run.
"""
params = _validated_params(feature, generated_test_cases)
recorder.record(ACTION_TYPE, params)
return _confirm(recorder, ACTION_TYPE)
action = recorder.record(ACTION_TYPE, params)
return _confirm(action)


def record_test_plan(
Expand Down
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 @@ -150,8 +150,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