Skip to content
Merged
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
182 changes: 182 additions & 0 deletions bwh_hive/bwh_hive/agent_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# Copyright (c) 2026, BWH Studios and contributors
# For license information, please see license.txt

"""Box → Hive callback API (specs/v2 §5.1).

Endpoints the in-VM control plane calls to report agent lifecycle state back to a Hive
Task. Auth is the shared "Agent" bot service key (00-architecture.md §2.2): every method
asserts (a) the calling session is the Agent bot (by role), and (b) the target task is
currently assigned to that bot — so a leaked key cannot drive arbitrary tasks.

Status changes route through `orchestrator.service.set_agent_status` so transition
validation and side effects stay centralized.

All methods are type-annotated — Hive enforces require_type_annotated_api_methods.
"""

import json

import frappe

from bwh_hive.bwh_hive.notifications.dispatcher import notify
from bwh_hive.bwh_hive.notifications.events import EventType, NotificationEvent
from bwh_hive.bwh_hive.orchestrator import service

AGENT_BOT_ROLE = "Agent Bot"


def _assert_agent_caller() -> None:
"""Reject callers that are not the Agent bot user (identified by role)."""
if AGENT_BOT_ROLE not in frappe.get_roles(frappe.session.user):
frappe.throw("Only the Agent bot may call agent callbacks.", frappe.PermissionError)


def _assert_task_assigned(task: str) -> None:
"""Reject writes to a task the calling Agent bot is not assigned to."""
if not frappe.db.exists("Hive Task", task):
frappe.throw(f"Hive Task {task} not found", frappe.DoesNotExistError)
assignees = json.loads(frappe.db.get_value("Hive Task", task, "_assign") or "[]")
if frappe.session.user not in assignees:
frappe.throw("This task is not assigned to the Agent bot.", frappe.PermissionError)


def _guard(task: str) -> None:
_assert_agent_caller()
_assert_task_assigned(task)


@frappe.whitelist(methods=["POST"])
def report_agent_status(task: str, status: str, message: str | None = None) -> dict:
"""Set a Hive Task's agent_status (box actor) and optionally append a comment."""
_guard(task)
prev = frappe.db.get_value("Hive Task", task, "agent_status")
service.set_agent_status(task, status, actor="box", message=message)
if status == "Provisioning" and prev != "Provisioning":
notify(NotificationEvent.from_task(EventType.PROVISIONING, task))
return {"ok": True, "agent_status": status}


@frappe.whitelist(methods=["POST"])
def set_spec_ready(
task: str,
code_url: str | None = None,
site_url: str | None = None,
spec_path: str | None = None,
branch: str | None = None,
) -> dict:
"""Record spec coordinates and advance the task to Spec Created."""
_guard(task)
doc = frappe.get_doc("Hive Task", task)
updates: dict = {}
if code_url:
updates["agent_code_url"] = code_url
if site_url:
updates["agent_site_url"] = site_url
if spec_path:
updates["agent_spec_path"] = spec_path
if branch:
updates["agent_branch"] = branch
if updates:
doc.db_set(updates)
prev = doc.agent_status
service.set_agent_status(doc, "Spec Created", actor="box", message="Spec ready for review.")
if prev != "Spec Created":
notify(NotificationEvent.from_task(EventType.SPEC_CREATED, task))
return {"ok": True, "agent_status": "Spec Created"}


@frappe.whitelist(methods=["POST"])
def set_pr_ready(task: str, pr_url: str, branch: str | None = None) -> dict:
"""Record the PR link and advance the task to PR Ready."""
_guard(task)
doc = frappe.get_doc("Hive Task", task)
updates: dict = {"pr_link": pr_url}
if branch:
updates["agent_branch"] = branch
doc.db_set(updates)
prev = doc.agent_status
service.set_agent_status(doc, "PR Ready", actor="box", message=f"PR ready: {pr_url}")
if prev != "PR Ready":
notify(NotificationEvent.from_task(EventType.PR_READY, task))
return {"ok": True, "agent_status": "PR Ready"}


@frappe.whitelist(methods=["POST"])
def report_agent_error(task: str, error: str, phase: str | None = None) -> dict:
"""Record a failure and move the task to Failed."""
_guard(task)
doc = frappe.get_doc("Hive Task", task)
doc.db_set("agent_last_error", error)
msg = f"Agent error ({phase}): {error}" if phase else f"Agent error: {error}"
prev = doc.agent_status
service.set_agent_status(doc, "Failed", actor="box", message=msg)
if prev != "Failed":
notify(NotificationEvent.from_task(EventType.FAILED, task, message=error, payload={"phase": phase}))
return {"ok": True, "agent_status": "Failed"}


@frappe.whitelist(methods=["POST"])
def append_agent_log(task: str, log: str, stream: str = "stdout") -> dict:
"""Append a cheap, frequent log line as a task comment."""
_guard(task)
frappe.get_doc(
{
"doctype": "Hive Task Comment",
"task": task,
"content": f"[{stream}] {log}",
"posted_by": frappe.session.user,
}
).insert(ignore_permissions=True)
# Nudge any open task sheet to refetch its comment feed live (specs/v2 09 realtime).
frappe.publish_realtime("hive_agent_log", {"task": task}, after_commit=True)
return {"ok": True}


@frappe.whitelist(methods=["GET"])
def get_task(task: str) -> dict:
"""Return task context so the box can compose spec/implement prompts (read-only)."""
_guard(task)
doc = frappe.get_doc("Hive Task", task)
return {
"name": doc.name,
"title": doc.title,
"description": doc.description,
"project": doc.project,
"agent_status": doc.agent_status,
"agent_branch": doc.agent_branch,
"agent_spec_path": doc.agent_spec_path,
"github_issue_url": doc.github_issue_url,
"pr_link": doc.pr_link,
"prompts": resolve_prompts(doc.project),
}


# Prompt fields configurable in Hive: a project override wins over the global default; a
# blank/absent value falls through so the box uses its built-in (or SKILLS_REPO) template.
_PROMPT_FIELDS = {
"spec": "agent_spec_prompt",
"implement": "agent_implement_prompt",
"changes": "agent_changes_prompt",
}


def resolve_prompts(project: str | None) -> dict:
"""Resolve {spec,implement,changes} prompt templates: project override → global default.

Only non-empty values are returned; a missing key means "no Hive-configured prompt — box
falls back to its SKILLS_REPO file / shipped default" (00-architecture.md §5.1).
"""
settings = frappe.get_cached_doc("Hive Settings")
project_doc = (
frappe.get_cached_doc("Hive Project", project)
if project and frappe.db.exists("Hive Project", project)
else None
)
resolved: dict = {}
for key, fieldname in _PROMPT_FIELDS.items():
value = (project_doc and (project_doc.get(fieldname) or "").strip()) or (
settings.get(fieldname) or ""
).strip()
if value:
resolved[key] = value
return resolved
83 changes: 83 additions & 0 deletions bwh_hive/bwh_hive/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -932,3 +932,86 @@ def resolve_project_slug(slug: str):
if not name:
frappe.throw("Project not found", frappe.DoesNotExistError)
return name


# --------------------------------------------------------------------------- #
# Agent surface — thin frontend-facing wrappers (specs/v2 09).
#
# The React app can't call doctype methods the way the desk does, so these wrap
# the whitelisted Hive Task agent methods with a flat (task, ...) signature the
# frontend calls via useFrappePostCall. Each underlying method re-asserts its
# own guard (identity + write permission) — these wrappers add no trust boundary.
# --------------------------------------------------------------------------- #
@frappe.whitelist(methods=["POST"])
def agent_approve_spec(task: str, note: str | None = None):
"""Approve the agent's spec (wraps Hive Task.approve_spec)."""
return frappe.get_doc("Hive Task", task).approve_spec(note=note)


@frappe.whitelist(methods=["POST"])
def agent_request_changes(task: str, comment: str, path: str | None = None, line: str | None = None):
"""Request another iteration, sending a single review comment as the §5.3 payload."""
body = (comment or "").strip()
if not body:
frappe.throw("Provide a review comment.")
entry: dict = {"author": frappe.session.user, "body": body}
if path:
entry["path"] = path
if line not in (None, ""):
entry["line"] = line
return frappe.get_doc("Hive Task", task).request_agent_changes([entry])


@frappe.whitelist(methods=["POST"])
def agent_mark_merged(task: str):
"""Record that the PR was merged (wraps Hive Task.mark_agent_merged)."""
return frappe.get_doc("Hive Task", task).mark_agent_merged()


@frappe.whitelist(methods=["POST"])
def agent_retry(task: str):
"""Re-provision a clean box for a Failed task (wraps Hive Task.retry_agent)."""
return frappe.get_doc("Hive Task", task).retry_agent()


@frappe.whitelist(methods=["POST"])
def agent_cancel(task: str):
"""Cancel an in-flight agent task (wraps Hive Task.cancel_agent)."""
return frappe.get_doc("Hive Task", task).cancel_agent()


@frappe.whitelist(methods=["POST"])
def agent_teardown_now(task: str):
"""Force-deprovision a Failed box (wraps Hive Task.teardown_agent_now)."""
return frappe.get_doc("Hive Task", task).teardown_agent_now()


@frappe.whitelist(methods=["POST"])
def agent_handoff(task: str):
"""Start the agent loop from the product by assigning the task to the Agent bot.

Assigning to the Agent user is what triggers provisioning (Phase 1 _assign hook).
Re-asserts write permission — the same gate the desk assign flow enforces.
"""
from frappe.desk.form.assign_to import add as assign_add

from bwh_hive.bwh_hive.orchestrator import service

doc = frappe.get_doc("Hive Task", task)
doc.check_permission("write")
agent_user = service.get_agent_user()
if not agent_user:
frappe.throw("No Agent bot user is configured.")
assign_add({"doctype": "Hive Task", "name": task, "assign_to": [agent_user]})
return {"ok": True, "agent_user": agent_user}


@frappe.whitelist()
def resolved_prompts(project: str | None = None):
"""Return the resolved {spec,implement,changes} prompts (project override → global).

Lets the per-project settings UI show which prompt the box would actually receive.
"""
from bwh_hive.bwh_hive.agent_api import resolve_prompts

return resolve_prompts(project)
101 changes: 100 additions & 1 deletion bwh_hive/bwh_hive/doctype/hive_project/hive_project.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,19 @@
"links_section",
"links",
"is_archived",
"github_repo"
"github_repo",
"agent_section",
"agent_enabled",
"github_pat",
"agent_template_slug",
"target_app_name",
"target_app_repo",
"target_app_branch",
"skills_repo_override",
"agent_prompts_section",
"agent_spec_prompt",
"agent_implement_prompt",
"agent_changes_prompt"
],
"fields": [
{
Expand Down Expand Up @@ -113,6 +125,93 @@
"fieldtype": "Data",
"label": "GitHub Repository",
"description": "GitHub repository in owner/repo format (e.g. BuildWithHussain/hive)"
},
{
"collapsible": 1,
"fieldname": "agent_section",
"fieldtype": "Section Break",
"label": "Agent (v2)"
},
{
"default": "0",
"description": "Only agent-enabled projects can spawn agent boxes (specs/v2 §4.3).",
"fieldname": "agent_enabled",
"fieldtype": "Check",
"label": "Agent Enabled"
},
{
"depends_on": "agent_enabled",
"description": "Project PAT used inside the box for gh / git push (GIT_PAT).",
"fieldname": "github_pat",
"fieldtype": "Password",
"label": "GitHub PAT"
},
{
"depends_on": "agent_enabled",
"description": "BenchSpace template/golden to provision (defaults to Hive Settings).",
"fieldname": "agent_template_slug",
"fieldtype": "Data",
"label": "Agent Template Slug"
},
{
"depends_on": "agent_enabled",
"description": "Frappe app to install in the box (TARGET_APP_NAME).",
"fieldname": "target_app_name",
"fieldtype": "Data",
"label": "Target App Name"
},
{
"depends_on": "agent_enabled",
"description": "App source URL (TARGET_APP_REPO).",
"fieldname": "target_app_repo",
"fieldtype": "Data",
"label": "Target App Repo"
},
{
"default": "develop",
"depends_on": "agent_enabled",
"description": "App branch (TARGET_APP_BRANCH).",
"fieldname": "target_app_branch",
"fieldtype": "Data",
"label": "Target App Branch"
},
{
"depends_on": "agent_enabled",
"description": "Optional override for the global SKILLS_REPO.",
"fieldname": "skills_repo_override",
"fieldtype": "Data",
"label": "Skills Repo Override"
},
{
"collapsible": 1,
"depends_on": "agent_enabled",
"fieldname": "agent_prompts_section",
"fieldtype": "Section Break",
"label": "Agent Prompt Overrides"
},
{
"depends_on": "agent_enabled",
"description": "Overrides the global Spec Prompt for this project. Blank = inherit the global default.",
"fieldname": "agent_spec_prompt",
"fieldtype": "Code",
"label": "Spec Prompt Override",
"options": "Text"
},
{
"depends_on": "agent_enabled",
"description": "Overrides the global Implement Prompt for this project. Blank = inherit the global default.",
"fieldname": "agent_implement_prompt",
"fieldtype": "Code",
"label": "Implement Prompt Override",
"options": "Text"
},
{
"depends_on": "agent_enabled",
"description": "Overrides the global Changes Prompt for this project. Blank = inherit the global default.",
"fieldname": "agent_changes_prompt",
"fieldtype": "Code",
"label": "Changes Prompt Override",
"options": "Text"
}
],
"grid_page_length": 50,
Expand Down
Loading
Loading