Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
6269dc9
fix(model-picker): refresh local model lists and surface switch failu…
claw3649 Jun 9, 2026
670c5fa
fix(gateway): harden launchd restart and the venv launcher guard
claw3649 Jun 9, 2026
892f5e9
feat(action-runtime): add the unified ExecutionTask/ExecutionResult c…
claw3649 Jun 9, 2026
34242a8
refactor(gateway): honest-status across all exec paths via the Action…
claw3649 Jun 9, 2026
c0ee046
docs(architecture): add the Central Brain + Action Runtime design
claw3649 Jun 9, 2026
c434f52
refactor(gateway): introduce SessionState type at the session creatio…
claw3649 Jun 9, 2026
6fda216
docs(architecture): record Phase 3 Step 1 landing in the decision log
claw3649 Jun 9, 2026
b1b0878
fix(model_metadata): resolve local Ollama context via num_ctx-first p…
claw3649 Jun 10, 2026
a05f856
perf(model-picker): memoize live model-list probes with a short TTL
claw3649 Jun 10, 2026
c962b39
test(tui_gateway): stop dispatch tests leaking the shared handler reg…
claw3649 Jun 10, 2026
a538fb6
refactor(gateway): absorb turn-lifecycle lock dances into SessionState
claw3649 Jun 10, 2026
6c14aa3
feat(action-runtime): task.submit pilot with idempotency and a rich r…
claw3649 Jun 10, 2026
8aefc5f
fix(desktop): drop stale model-switch commits and rollbacks via an in…
claw3649 Jun 10, 2026
2431e1d
docs(architecture): record Phase 3 Step 2 + Phase 4 pilot in the deci…
claw3649 Jun 10, 2026
bcf2747
Merge origin/main: adopt the config.set picker flow, keep honest-stat…
claw3649 Jun 10, 2026
9a55396
refactor(gateway): convert session subscript access to SessionState a…
claw3649 Jun 10, 2026
a49a545
feat(agent): add the flag-gated BrainHost construction seam
claw3649 Jun 10, 2026
43ff726
docs(architecture): record Phase 3 Step 3 + BrainHost seam in the dec…
claw3649 Jun 10, 2026
dca596f
feat(gateway): route the gateway-run and api-server agent constructio…
claw3649 Jun 10, 2026
0e5ee9d
docs(architecture): add the Phase 5 multi-runtime design (AgentTaskRe…
claw3649 Jun 10, 2026
5b5225c
feat(agent): add the brain_host_gate one-line construction helper
claw3649 Jun 10, 2026
1882c66
refactor: route all remaining AIAgent construction sites through brai…
claw3649 Jun 10, 2026
49f7692
refactor(gateway): Phase 3 Step 4 — convert the remaining session sub…
claw3649 Jun 10, 2026
8f78f87
docs(architecture): record the full BrainHost site migration + Phase …
claw3649 Jun 10, 2026
7d0f538
docs(architecture): record the maintainer rulings on Phase 5 open que…
claw3649 Jun 10, 2026
64117ca
docs(architecture): record the Q3 impact-sweep evidence (consumer cen…
claw3649 Jun 10, 2026
3a27ab1
docs(architecture): close Q3 as option B — Q1-Q6 all decided, Phase 5…
claw3649 Jun 10, 2026
ad9d2c2
feat(action-runtime): add the AgentTaskRegistry — Phase 5 Step 1 (add…
claw3649 Jun 10, 2026
5135b1d
docs(architecture): amend the Phase 5 draft to the option-B ruling an…
claw3649 Jun 10, 2026
b945f36
feat(delegate): dual-write subagent runs into the AgentTaskRegistry —…
claw3649 Jun 10, 2026
8b1dcfe
feat(gateway): task.status/cancel/list RPCs + registry-routed interru…
claw3649 Jun 10, 2026
54421fb
docs(architecture): record Phase 5 Steps 1-4 in the decision log
claw3649 Jun 10, 2026
f5b568a
feat(action-runtime): persist completed-task snapshots to _tasks.json…
claw3649 Jun 10, 2026
81f07f8
feat(gateway): task.submit accepts intent="delegate" + idempotency fo…
claw3649 Jun 10, 2026
43ed5a0
feat(gateway): spawn_tree.list merges the registry's _tasks.jsonl wit…
claw3649 Jun 10, 2026
e17e168
fix(action-runtime): harden the registry per the adversarial review —…
claw3649 Jun 10, 2026
d95e44f
fix(gateway): close the review findings on the delegate intent and St…
claw3649 Jun 10, 2026
7a2eb18
docs(architecture): record Phase 5 Steps 5-6, the adversarial review …
claw3649 Jun 10, 2026
ec86c6d
feat(gateway): spawn_tree.load synthesizes a tree payload from regist…
claw3649 Jun 10, 2026
eb3b2b0
feat(tui): /tasks command — live task board over the registry RPCs
claw3649 Jun 10, 2026
091d681
docs(architecture): record the overnight batch — 3 standalone PRs, ob…
claw3649 Jun 11, 2026
dae2efb
Merge origin/main: adopt the slash-action-table + registry-driven sla…
claw3649 Jun 11, 2026
0063d0c
Merge feat/action-runtime-honest-status: carry the upstream main merg…
claw3649 Jun 11, 2026
64fae42
feat(brain-host): context-length resolution memo — first measured Bra…
claw3649 Jun 11, 2026
bbcfde7
docs(architecture): record Track 2 completion — parent linking, deskt…
claw3649 Jun 11, 2026
e1439eb
docs(architecture): record the PR staging — all branches now in the r…
claw3649 Jun 11, 2026
11079c2
test: patch the brain_host_gate seam instead of the bypassed cli.AIAg…
claw3649 Jun 11, 2026
a8e6a32
Merge branch 'feat/phase3-step3-brainhost' into feat/brainhost-schema…
claw3649 Jun 11, 2026
7b21064
fix(tests): stamp fresh timestamps on seeded ws sessions to dodge the…
claw3649 Jun 10, 2026
06b1ddd
test(ws): seed disconnect sessions as SessionState so the attribute-s…
claw3649 Jun 11, 2026
13d30a6
Merge branch 'feat/phase3-step3-brainhost' into feat/brainhost-schema…
claw3649 Jun 11, 2026
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
4 changes: 2 additions & 2 deletions acp_adapter/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ def _make_agent(
if self._agent_factory is not None:
return self._agent_factory()

from run_agent import AIAgent
from agent.brain_host_gate import build_agent
from hermes_cli.config import load_config
from hermes_cli.runtime_provider import resolve_runtime_provider

Expand Down Expand Up @@ -616,7 +616,7 @@ def _make_agent(
logger.debug("ACP session falling back to default provider resolution", exc_info=True)

_register_task_cwd(session_id, cwd)
agent = AIAgent(**kwargs)
agent = build_agent("acp", **kwargs)
# ACP stdio transport requires stdout to remain protocol-only JSON-RPC.
# Route any incidental human-readable agent output to stderr instead.
agent._print_fn = _acp_stderr_print
Expand Down
51 changes: 51 additions & 0 deletions action_runtime/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Action Runtime — the execution layer the Orchestration Core delegates to.

Phase 2 of docs/architecture/central-brain-openclaw.md: a single contract
(:class:`ExecutionTask` / :class:`ExecutionResult`) plus thin per-handler
adapters, so the gateway's exec handlers funnel through one honest-status schema
while their JSON-RPC wire shapes stay byte-compatible.
"""

from __future__ import annotations

from action_runtime.adapters import (
cli_to_result,
cli_to_wire,
plugin_to_result,
plugin_to_wire,
result_to_wire_rich,
shell_to_result,
shell_to_wire,
slash_to_result,
slash_to_wire,
)
from action_runtime.contract import (
Constraints,
ErrorType,
ExecError,
ExecutionResult,
ExecutionTask,
NeedsInput,
SideEffect,
Status,
)

__all__ = [
"Constraints",
"ErrorType",
"ExecError",
"ExecutionResult",
"ExecutionTask",
"NeedsInput",
"SideEffect",
"Status",
"cli_to_result",
"cli_to_wire",
"plugin_to_result",
"plugin_to_wire",
"result_to_wire_rich",
"shell_to_result",
"shell_to_wire",
"slash_to_result",
"slash_to_wire",
]
233 changes: 233 additions & 0 deletions action_runtime/adapters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
"""Per-handler adapters: map a gateway exec handler's native shape onto the
unified :class:`ExecutionResult`, and render it back to the exact wire dict the
handler returns today.

The contract guarantee for every adapter pair is a round-trip identity:

X_to_wire(X_to_result(<native fields>)) == <today's _ok result dict>

i.e. the migration is byte-compatible on the wire (additive-only — see
docs/architecture/central-brain-openclaw.md §12). The round-trip is asserted in
tests/action_runtime/. Handlers are migrated one at a time (shell → cli →
slash); only the adapters for already-migrated handlers live here.
"""

from __future__ import annotations

from typing import Optional

from action_runtime.contract import (
ErrorType,
ExecError,
ExecutionResult,
SideEffect,
Status,
)


# ── shell.exec ───────────────────────────────────────────────────────
# Native: subprocess.run → {stdout (tail 4000), stderr (tail 2000), code}.
# A non-zero exit is NOT an RPC error — it is an honest FAILED result whose
# wire shape is identical to a success (the client distinguishes via `code`).
# Truncation stays in the handler (it is part of the wire contract); the
# adapter receives already-sliced strings and must not re-truncate.


def shell_to_result(
stdout: str, stderr: str, code: int, task_id: Optional[str] = None
) -> ExecutionResult:
if code == 0:
return ExecutionResult(
task_id,
Status.SUCCEEDED,
outputs={"stdout": stdout, "stderr": stderr, "code": code},
)
return ExecutionResult(
task_id,
Status.FAILED,
outputs={"stdout": stdout, "stderr": stderr, "code": code},
error=ExecError(
ErrorType.NONZERO_EXIT,
retryable=False,
message=f"command exited {code}",
),
)


def shell_to_wire(result: ExecutionResult) -> dict:
"""Byte-identical to the shell.exec _ok payload (tui_gateway/server.py)."""
return {
"stdout": result.outputs["stdout"],
"stderr": result.outputs["stderr"],
"code": result.outputs["code"],
}


# ── cli.exec ─────────────────────────────────────────────────────────
# Native: {blocked, code, output} (+ hint when blocked). A pre-exec block gate
# (interactive argv) returns blocked=True/code=-1 WITHOUT spawning; a spawned
# command's non-zero exit is an honest FAILED whose wire shape is unchanged
# (client reads `code`). `output` is already joined+truncated by the handler.


def cli_to_result(
blocked: bool,
code: int,
output: str,
hint: str = "",
task_id: Optional[str] = None,
) -> ExecutionResult:
if blocked:
return ExecutionResult(
task_id,
Status.BLOCKED,
outputs={"output": output, "code": code, "blocked": True},
error=ExecError(ErrorType.DENIED, retryable=False, message=hint),
)
if code == 0:
return ExecutionResult(
task_id,
Status.SUCCEEDED,
outputs={"output": output, "code": code, "blocked": False},
)
return ExecutionResult(
task_id,
Status.FAILED,
outputs={"output": output, "code": code, "blocked": False},
error=ExecError(ErrorType.NONZERO_EXIT, retryable=False, message=f"cli exited {code}"),
)


def cli_to_wire(result: ExecutionResult) -> dict:
"""Byte-identical to the cli.exec _ok payloads (tui_gateway/server.py)."""
if result.outputs["blocked"]:
# The blocked-gate payload carries the hint (held in error.message).
return {
"blocked": True,
"hint": result.error.message if result.error else "",
"code": result.outputs["code"],
"output": result.outputs["output"],
}
return {
"blocked": False,
"code": result.outputs["code"],
"output": result.outputs["output"],
}


# ── slash.exec: plugin path ──────────────────────────────────────────
# A plugin slash command runs its handler inline. Success → {output}; a raised
# handler → {output, error} where both equal "Plugin command error: <e>"
# (the Phase 1a additive contract — a programmatic client detects the failure
# via result.error without parsing the output prose).


def plugin_to_result(
output: str = "", exc: Optional[BaseException] = None, task_id: Optional[str] = None
) -> ExecutionResult:
if exc is None:
return ExecutionResult(task_id, Status.SUCCEEDED, outputs={"output": output})
msg = f"Plugin command error: {exc}"
return ExecutionResult(
task_id,
Status.FAILED,
outputs={"output": msg},
error=ExecError(ErrorType.PROVIDER_ERROR, retryable=False, message=msg),
)


def plugin_to_wire(result: ExecutionResult) -> dict:
"""Byte-identical to the slash.exec plugin-path _ok payload. Phase 4
additive: ``task_id`` is echoed IFF the client supplied one — absent
task_id leaves the wire dict untouched."""
out = {"output": result.outputs["output"]}
if result.error is not None:
out["error"] = result.error.message # == output, per the Phase 1a test
if result.task_id is not None:
out["task_id"] = result.task_id
return out


# ── slash.exec: worker path + live-agent side effects ────────────────
# Runs the persistent slash worker, then mirrors live-agent side effects. The
# outcome of the mirror is a _SlashSideEffect(kind, message) (duck-typed here —
# no import, avoids a cycle): kind in {"", "warning", "failure", "busy"}.
# Wire: {output} (+warning when rendered non-empty) (+error == warning when the
# side effect did NOT take — kind in {failure, busy}). The rendered warning
# string is produced by the handler's _slash_side_effect_warning(effect) and
# passed in, so presentation stays where it is.


def slash_to_result(output: str, effect, task_id: Optional[str] = None) -> ExecutionResult:
"""``effect`` is a _SlashSideEffect with .kind / .message attributes."""
status, err, side = Status.SUCCEEDED, None, []
if effect.kind in ("failure", "busy"):
status = Status.FAILED
etype = ErrorType.TRANSPORT if effect.kind == "busy" else ErrorType.PROVIDER_ERROR
err = ExecError(etype, retryable=(effect.kind == "busy"), message=effect.message)
if effect.kind in ("failure", "busy", "warning"):
side = [
SideEffect(
kind="slash_sync",
detail=effect.message,
applied=effect.kind == "warning",
)
]
return ExecutionResult(
task_id, status, outputs={"output": output}, error=err, side_effects=side
)


def slash_to_wire(result: ExecutionResult, warning: str) -> dict:
"""Byte-identical to the slash.exec worker-path _ok payload. ``warning`` is
the rendered _slash_side_effect_warning(effect) string. Phase 4 additive:
``task_id`` is echoed IFF the client supplied one — absent task_id leaves
the wire dict untouched."""
payload = {"output": result.outputs["output"]}
if warning:
payload["warning"] = warning
# error mirrors warning only when the side effect did NOT take
# (failure/busy) — exactly when result.error is set.
if result.error is not None:
payload["error"] = warning
if result.task_id is not None:
payload["task_id"] = result.task_id
return payload


# ── rich wire renderer (Phase 4: task.submit) ────────────────────────
# The full ExecutionResult, rendered losslessly — no legacy aliasing, no
# omitted-when-empty keys. task.submit returns THIS shape; the legacy
# slash.exec wire above stays byte-compatible and untouched.


def result_to_wire_rich(result: ExecutionResult) -> dict:
"""Render the complete :class:`ExecutionResult` as a wire dict.

Every key is always present: ``error`` is ``None`` (not omitted) on
success, ``side_effects`` is ``[]`` when nothing changed, and ``status``
is the :class:`Status` enum's value string (e.g. ``"succeeded"``).
"""
return {
"task_id": result.task_id,
"status": result.status.value,
"outputs": result.outputs,
"error": (
None
if result.error is None
else {
"type": result.error.type.value,
"retryable": result.error.retryable,
"message": result.error.message,
}
),
"side_effects": [
{
"kind": s.kind,
"detail": s.detail,
"applied": s.applied,
"target": s.target,
}
for s in result.side_effects
],
}
Loading