Follow-up: device tools in DURABLE (Temporal) runs (a.k.a. “P5”)
!!! success “Status: SHIPPED, but NOT the way this doc planned it.”
This gap was closed as a side effect of the durable-execution consolidation (the
ChatAgentWorkflow now runs ONE coarse run_chat_turn activity that reuses the inline
AgentService.execute_inline_run executor in the worker process). The fine-grained
TemporalAgent path and worker/integration_toolsets.py were deleted, so the
snapshot-based plan below (“How to build it”) is obsolete and is kept only as a record.
What actually closed it: the worker’s AgentService is constructed with a worker
DeviceGateway (worker/resources.py::device_gateway), and execute_inline_run runs the
SAME ToolsetAssembler that reads chat.run_config["devices"] and builds
build_device_toolset(...) per online device. A durable run with a device selected now
gets the device tools, dispatched cross-pod over Redis. See the section below for the live
wiring.
Computer Service instances (the per-user Linux machines that announce coding tools) are fully wired for inline chat runs - registry, WS connectivity, the device toolset, the approval/policy gate, and the global tool guard all work and are deployed. This document originally recorded the one remaining gap (durable runs) and how to close it; that gap is now closed.
What works today (inline)
A run started interactively executes inline (an asyncio task in the FastAPI process,
streamed over SSE). There, ToolsetAssembler.assemble() reads chat.run_config["devices"],
fetches the owned + online devices, and appends a build_device_toolset(...) per device
(src/personal_agent/agent/device_toolset.py). Each tool call goes through
agent/device_policy.gate_device_call (autonomous / allow-rule / judge / human approval) and
dispatches over the in-process DeviceGateway (realtime/device_gateway.py).
The original gap (historical)
When this was written, a durable run rebuilt its toolsets from a frozen ToolsetSnapshot
on the RunSpec via per-toolset DynamicToolsets in worker/integration_toolsets.py, and
device tools were never put into that snapshot - so a durable run with a device selected
silently had no device tools. This only happened when a user sent a chat run “in the
background” with a device selected (triggered workflows/triage never set run_config.devices
and must not, since they run over untrusted content), so it was an unsupported edge case, not
a regression.
That whole snapshot-rebuild machinery is gone. The durable consolidation replaced it with a
single coarse run_chat_turn activity that runs the inline execute_inline_run executor in
the worker, which assembles toolsets (devices included) live in-activity. Frozen Contract #6
(no live DB during a workflow replay) is preserved because the assemble happens inside an
activity, not the workflow, and a chat turn is one non-resumable activity.
How it was going to be built (SUPERSEDED, kept as a record)
!!! note “This plan was never implemented.”
It targeted the fine-grained TemporalAgent worker path and
worker/integration_toolsets.py, both since deleted. The gap was closed differently (see
the status note up top and “How it actually works” below). Steps 4 and 5 still describe
machinery that now exists in spirit: the worker DOES hold its own DeviceGateway and the
cross-pod dispatch + approval gate work from any process.
-
Snapshot —
runspec.py: addDeviceSnapshot(BaseModel, frozen)withdevice_id: str+announced_tools: dict(frozen JSON schemas, Contract #6) andToolsetSnapshot.devices: tuple[DeviceSnapshot, ...] = (). Fill it insrc/personal_agent/api/routers/runs.py::_integration_snapshot(mirror the integration snapshot): for each owned online device incfg["devices"], freeze itsannounced_tools. -
Deps -
src/personal_agent/agent/deps.py: adddevice_ids: list[str] = []toPersonalAgentDeps(set on the durable path alongsideintegration_entry_ids). -
Worker toolset -
src/personal_agent/worker/integration_toolsets.py: adddevice_dynamic_toolset()(mirrorintegration_dynamic_toolset) whose in-activity_buildrebuildsbuild_device_toolset(...)from the snapshot (NOT a live DB query — use the frozenannounced_tools; construct a lightweight device-like object carrying id + name + policy_mode + announced_tools, fetched once in-activity by id is acceptable since the activity may do I/O, but prefer the snapshot for the tool schemas). Register it insrc/personal_agent/worker/agents.pyalongside the other dynamic toolsets. -
Worker gateway + gate - the worker holds no device WS connections (those live on the API pods), so the worker needs its own
DeviceGateway(redis, pubsub_redis, pod_id)instance (wire it in the worker’s resource bootstrap).dispatchwill always take the cross-pod path (_dispatch_remote): publish onpersonal_agent<id>:rpc, the API pod holding the WS forwards it, the reply comes back on the per-request reply channel. The approval gate (gate_device_call/request_tool_approval) already works from any process — it polls thedevice_approvalsrow (shared DB) and pushes thetool_approvalframe over Redis user-events, so it works unchanged inside a Temporal activity (mind activity heartbeats for the up-to-10-min approval wait — heartbeat or raise the activity’sstart_to_close_timeout). -
Global guard in durable —
GuardToolset(agent/tool_guard.py) is aWrapperToolset; wrap the worker’s combined toolset the same way the inline assembler does whenGuardConfigis enabled (resolve guard enablement once at snapshot time → carry a flag inPersonalAgentDeps, or re-read in-activity).
How it actually works (live)
- One executor.
worker/workflows.py::ChatAgentWorkflowschedulesactivities.run_chat_turn, which callsAgentService.execute_inline_runin the worker process - the same code the inline (FastAPI) path runs. So durable chat inherits device tools (plus vision sidecar, context self-heal, transient backoff, resumable partials, follow-ups) for free. - Worker gateway. The worker’s
AgentService(worker/goal_activities.py::_agent_service) is built withdevice_gateway=resources.device_gateway(). ThatDeviceGateway(worker/resources.py) is constructed with apod_idofworker-<hostname>, so it is neveris_localfor a device and always takes the cross-pod path. - Cross-pod dispatch.
DeviceGateway._dispatch_remotepublishes onpersonal_agent<device_id>:rpc(contracts/keys.py::device_rpc_channel); the API pod holding the device WS forwards the call and the reply comes back onpersonal_agentrpc-reply:<req_id>(device_rpc_reply_channel). - Approval gate, unchanged.
agent/device_policy.gate_device_call/request_tool_approval(autonomous / allow-rule / judge / human-approval) poll thedevice_approvalsrow in the shared DB and push the approval over Redis (device_approval_channel), so they work the same inside the activity. The globalGuardToolset(agent/tool_guard.py) is wrapped on by the sharedassemble()itself (wrap_with_guard), so it covers durable runs without any worker-specific code.
!!! note “Stale code comment”
assembler/assembler.py still carries an “INLINE only - the durable/Temporal path doesn’t
snapshot devices yet” comment near the device branch. That comment is out of date: the same
assemble() runs in the worker. The behaviour is correct; only the comment lags.