feat(coding-agent): add root session controls
14 unmodified lines15161718191914 unmodified linesrequires = ["hatchling"]build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]packages = ["src/rlm"]packages = ["src/rlm", "src/agent"]123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129from __future__ import annotations
import importlibimport unittestfrom unittest.mock import AsyncMock, patch
from agent import sessions
session_module = importlib.import_module("agent.sessions")
SESSION = { "activeSessionId": "active-root", "sessionId": "session-root", "sessionName": "root", "cwd": "/repo", "status": "idle", "isCurrent": True, "isStreaming": False, "isCompacting": False, "attachedClients": 1, "messageCount": 2, "queuedCount": 0, "isSessionActive": False, "capabilities": ["send", "recent_turns", "stop", "restart"],}
class AgentSessionsTest(unittest.IsolatedAsyncioTestCase): async def test_namespace_and_handle_route_typed_operations(self) -> None: calls: list[tuple[str, dict[str, object]]] = []
async def request(request_type: str, payload: dict[str, object] | None = None) -> dict[str, object]: calls.append((request_type, payload or {})) if request_type == "agent.sessions.list": return {"sessions": [SESSION]} if request_type == "agent.sessions.get": return {"session": SESSION} if request_type == "agent.sessions.recent_turns": return { "agent": SESSION, "turns": [ { "user": "question", "assistant": "answer", "toolCalls": 1, "startedAt": 10, "endedAt": 20, "messageCount": 3, "truncated": False, } ], "count": 3, "maxChars": 6000, "truncated": False, } if request_type == "agent.sessions.send": return { "operationId": "op-send", "status": "queued", "delivery": "follow_up", "target": {"activeSessionId": "active-root", "sessionId": "session-root"}, } if request_type == "agent.sessions.stop": return {"operationId": "op-stop", "status": "accepted"} raise AssertionError(request_type)
with patch.object(session_module, "host_request", new=AsyncMock(side_effect=request)): listed = await sessions.list() handle = await sessions.get(active_session_id="active-root") recent = await handle.recent_turns() receipt = await handle.send("continue") stopped = await handle.stop()
self.assertEqual([entry.active_session_id for entry in listed], ["active-root"]) self.assertEqual(handle.name, "root") self.assertEqual(recent.turns[0].tool_calls, 1) self.assertEqual(receipt.operation_id, "op-send") self.assertEqual(stopped.operation_id, "op-stop") self.assertEqual(handle.status, "stopped") self.assertEqual( [request_type for request_type, _ in calls], [ "agent.sessions.list", "agent.sessions.get", "agent.sessions.recent_turns", "agent.sessions.send", "agent.sessions.stop", ], ) self.assertEqual(calls[2][1], {"target": "active-root", "count": 3, "max_chars": 6000})
async def test_missing_capabilities_fail_closed(self) -> None: async def request(request_type: str, payload: dict[str, object] | None = None) -> dict[str, object]: self.assertEqual(request_type, "agent.sessions.get") return {"session": {"activeSessionId": "active-root", "sessionId": "session-root"}}
with patch.object(session_module, "host_request", new=AsyncMock(side_effect=request)): handle = await sessions.get(active_session_id="active-root")
self.assertEqual(handle.capabilities, ()) for operation in ( handle.send("message"), handle.recent_turns(), handle.stop(), handle.restart(), ): with self.assertRaises(RuntimeError): await operation
async def test_get_rejects_multiple_selectors(self) -> None: with self.assertRaises(TypeError): await sessions.get("active-root", active_session_id="also-root")
async def test_handle_validates_send_arguments(self) -> None: handle = session_module.AgentSessionHandle( "active-root", "session-root", capabilities=("send", "recent_turns", "stop", "restart"), ) with self.assertRaises(TypeError): await handle.send("") with self.assertRaises(ValueError): await handle.send("message", "invalid") # type: ignore[arg-type]
if __name__ == "__main__": unittest.main()1- Added host-backed Python controls for listing, inspecting, messaging, stopping, and restarting root agent sessions.174 unmodified linesExpand all17517617717817918018118218318418518618718818919019119219319419519619719819934 unmodified lines174 unmodified linesExpand all```
The default recursion depth allows a root agent to create children. Raising the configured depth allows descendants to recurse further.
### Root session controls
Top-level sessions can inspect and control other top-level daemon sessions through a separate namespace. This is intentionally not part of `rlm`: RLM child handles carry result-contract state and are not root-session handles.
```pythonfrom agent import sessions
roots = await sessions.list()target = await sessions.get(active_session_id="active-session-id")recent = await target.recent_turns(count=3, max_chars=6000)receipt = await target.send("continue the task", delivery="follow_up")```
`agent.sessions` resolves a target by active session ID, durable session ID, display name, or an unambiguous ID suffix. `recent_turns()` returns bounded user/assistant/tool-call summaries and never exposes an unbounded transcript. `send()` uses the native daemon `steer` or `follow_up` queue. `stop()` terminates and archives a target; `restart()` asks the daemon supervisor to rehydrate its worker. A root cannot stop or restart itself, and RLM child kernels cannot access this namespace. Use `await target.refresh()` after a worker restart to update a retained handle.
Root session handles are separate from `RLMSubagent`: they have no result contract, `result()`, or child deletion semantics. The host remains authoritative for session identity, transcript access, worker routing, and lifecycle transitions.
### 3. Skills add programmatic capability
Prime Agent supports the Agent Skills markdown format and extends it with Python-backed skills. Both use `SKILL.md` for discovery, routing, and instructions. A Python-backed skill also contains a Python package that Prime Agent installs into the kernel environment and exposes by import name.
34 unmodified lines714 unmodified linesExpand all715716717718719720721722723997 unmodified linesExpand all714 unmodified linesExpand all rlmResultController: runtimeSessionOptions?.rlmResultController, rlmHeartbeatController: runtimeSessionOptions?.rlmHeartbeatController, agentMessageController: runtimeSessionOptions?.agentMessageController, agentObserveController: runtimeSessionOptions?.agentObserveController, agentSessionController: runtimeSessionOptions?.agentSessionController, autonomous: (runtimeSessionOptions?.rlmDepth ?? 0) > 0 ? mergeAutonomousConfig(sessionOptions.autonomous, { ...runtimeSessionOptions?.autonomous, enabled: false }) : mergeAutonomousConfig(sessionOptions.autonomous, runtimeSessionOptions?.autonomous),997 unmodified linesExpand all123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112import type { AgentMessage } from "@earendil-works/pi-agent-core";import { describe, expect, it } from "vitest";import { type AgentSessionController, createAgentSessionHostHandlers, createAgentSessionTurnSummaries, normalizeAgentSessionMaxChars, normalizeAgentSessionTurnCount,} from "../src/core/agent-sessions.js";
const summary = { activeSessionId: "active-root", sessionId: "session-root", cwd: "/repo", status: "idle", isCurrent: true, isStreaming: false, isCompacting: false, attachedClients: 1, messageCount: 4, queuedCount: 0, isSessionActive: false, capabilities: ["send", "recent_turns", "stop", "restart"] as const,};
function user(text: string, timestamp: number): AgentMessage { return { role: "user", content: [{ type: "text", text }], timestamp } as AgentMessage;}
function assistant(text: string, timestamp: number): AgentMessage { return { role: "assistant", content: [{ type: "text", text }], timestamp } as AgentMessage;}
describe("agent session helpers", () => { it("groups bounded recent turns without exposing unbounded transcript text", () => { const result = createAgentSessionTurnSummaries( [ user("first question", 1), assistant("first answer", 2), user("second question", 3), assistant("second answer", 4), ], 1, 6, );
expect(result).toEqual({ turns: [ { user: "second", assistant: "second", toolCalls: 0, startedAt: 3, endedAt: 4, messageCount: 2, truncated: true, }, ], truncated: true, }); });
it("normalizes the recent-turn bounds", () => { expect(normalizeAgentSessionTurnCount(undefined)).toBe(3); expect(normalizeAgentSessionTurnCount(20)).toBe(20); expect(normalizeAgentSessionMaxChars(undefined)).toBe(6_000); expect(normalizeAgentSessionMaxChars(12_000)).toBe(12_000); expect(() => normalizeAgentSessionTurnCount(0)).toThrow("between 1 and 20"); expect(() => normalizeAgentSessionMaxChars(79)).toThrow("between 80 and 12000"); });
it("validates and routes the typed host handlers", async () => { const controller: AgentSessionController = { listSessions: () => [summary], getSession: (target) => ({ ...summary, activeSessionId: target }), recentTurns: () => ({ agent: summary, turns: [], count: 3, maxChars: 6_000, truncated: false }), send: ({ target, delivery }) => ({ operationId: "op-1", status: "queued", delivery, target: { activeSessionId: target, sessionId: "session-root" }, }), stop: () => ({ operationId: "op-2", status: "accepted" }), restart: () => ({ operationId: "op-3", status: "accepted" }), }; const handlers = createAgentSessionHostHandlers(controller);
expect(await handlers["agent.sessions.list"]({})).toEqual({ sessions: [summary] }); expect(await handlers["agent.sessions.get"]({ target: "root" })).toEqual({ session: { ...summary, activeSessionId: "root" }, }); expect( await handlers["agent.sessions.send"]({ target: "root", message: "continue", delivery: "follow_up" }), ).toEqual({ operationId: "op-1", status: "queued", delivery: "follow_up", target: { activeSessionId: "root", sessionId: "session-root" }, }); await expect( handlers["agent.sessions.send"]({ target: "root", message: "continue", delivery: "bad" }), ).rejects.toThrow("delivery must be 'steer' or 'follow_up'"); expect(await handlers["agent.sessions.stop"]({ target: "root" })).toEqual({ operationId: "op-2", status: "accepted", }); expect(await handlers["agent.sessions.restart"]({ target: "root" })).toEqual({ operationId: "op-3", status: "accepted", }); });});