Skip to content

Persistent sub-agents - #230

Closed
snimu wants to merge 8 commits into
mainfrom
sebastian/persistent-agents-2026-06-18
Closed

Persistent sub-agents#230
snimu wants to merge 8 commits into
mainfrom
sebastian/persistent-agents-2026-06-18

Conversation

@snimu

@snimu snimu commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Introduces persistent sub-agents via a .send() mechanism.


Note

Medium Risk
New cross-language lifecycle (create/advance/close/abort) and usage attribution for nested sessions; mistakes could leak child sessions or mis-attribute tokens, but behavior is covered by new tests and idempotent close paths.

Overview
Adds named persistent sub-agents that stay alive across IPython cells and host requests, alongside one-off await rlm(...) runs.

The Python runtime (prime-agent-runtime 0.2.0) introduces rlm.send(...) and a new async_runtime module with Registry, BackgroundWorker, and Handle (poll() / wait()). The TypeScript host wires rlm.send.create, rlm.send.advance, and rlm.send.close so the kernel can create children under stable sub-<name> session dirs, send further prompts on the same session, and tear down (including on parent disposeAsync). Usage returned per advance is delta-only so parent billing is not double-counted.

Agent and child sessions gain optional maxTokens for sub-agent completion caps. Wrapped Python skills get .send for ephemeral background runs via the same handle API. Bootstrap schema bumps to 8 and readiness checks require rlm.send; RLM system prompts document persistent vs blocking sub-agents and skill .send.

Reviewed by Cursor Bugbot for commit 4395fbe. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add persistent sub-agents to the coding agent via rlm.send

  • Introduces named persistent sub-agents that survive across turns, created/advanced/closed via new rlm.send.create, rlm.send.advance, and rlm.send.close host bridge requests handled in agent-session.ts.
  • Adds a Python rlm.async_runtime module with BackgroundWorker, Handle, Registry, and attach_background primitives; rlm.send() submits work to named persistent workers backed by the host bridge.
  • Named sub-agents get stable, human-readable session directories (sub-<safeName>) instead of random UUIDs, and per-advance token usage deltas are computed and attributed to the parent assistant message.
  • Bumps kernel bootstrap schema from 7 to 8, requiring rlm.send to be present before the kernel is considered ready; updates system prompt guidance to reference rlm.send and handle.poll()/wait() flows.
  • Risk: existing kernels on schema 7 will be forced to re-bootstrap on upgrade.

Macroscope summarized 4395fbe.

snimu and others added 7 commits June 18, 2026 11:11
…andle.wait

Port the persistent sub-agent layer from the rlm persistent-tools branch.
The model can now launch named, long-lived sub-agents that survive across
tool calls and hold multi-turn conversations.

New kernel-side API (prime-agent-runtime):
- rlm.send(prompt, name='helper') -> Handle (returns immediately)
- handle.poll() -> ToolState (status, results FIFO, queued, error)
- await handle.wait() -> RLMResult (blocks for next result)
- Re-sending the same name continues the agent's conversation
- <skill>.send(...) for background skill execution

New host-side handlers:
- rlm.send.create: creates a persistent sub-agent session
- rlm.send.advance: sends a prompt to an existing sub-agent
- rlm.send.close: tears down a persistent sub-agent

Infrastructure:
- async_runtime.py: BackgroundWorker, Handle, Registry, ToolState
  (ported from rlm's async_runtime.py)
- attach_background() adds .send to skill modules in bootstrap
- Persistent children tracked in AgentSession._persistentRlmChildren
- Named session dirs (sub-<name>) for stable, human-readable paths
- Cleanup in disposeAsync() tears down all persistent children

System prompt updated to document send/poll/wait lifecycle.
Tests updated to reflect new prompt text (zero new test failures).
- send() is now sync (not async), matching rlm's API: handle = rlm.send(...)
  instead of handle = await rlm.send(...)
- Session creation (host_request rlm.send.create) is deferred to the worker's
  first process() call, so send() returns immediately without blocking
- Processor lazily creates the host session and updates worker.session_dir
- Prompt updated: 'handle = rlm.send(...)' (no await)
…ebuild

The kernel venv was caching the old prime-agent-runtime wheel (0.1.0)
which did not include async_runtime.py. Bumping to 0.2.0 busts the uv
wheel cache, and bumping BOOTSTRAP_SCHEMA to 8 forces a full venv
rebuild on next start.
…t sub-agents

Also fix pre-existing unused-parameter warning in agent-session.ts.
The persistent/background sub-agent path (rlm.send / handle.poll / handle.wait)
was wired on both sides but only exercised by the system-prompt assertion.

TS (AgentSession): create -> advance -> advance under the same name -> close,
reusing the rlm.run harness with a scripted streamFn. Covers multi-turn
continuation reusing one sub-<name> session, create-twice dedup, the
unknown-name advance error, and the recursion depth-cap error.

Python (prime-agent-runtime): async_runtime worker semantics with a fake
processor: resident continuation under one name, non-consuming FIFO poll,
running->finished status, error-halt plus re-send rebuild, ephemeral one-shot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/coding-agent/src/core/agent-session.ts Outdated
Comment thread packages/coding-agent/src/core/agent-session.ts
Comment thread packages/coding-agent/src/core/agent-session.ts
Comment on lines +67 to +70
const request: RlmSendCreateRequest = {
name: payload.name,
max_tokens: typeof payload.max_tokens === "number" ? payload.max_tokens : undefined,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low core/rlm-runtime.ts:67

createRlmSendCreateHostHandler constructs RlmSendCreateRequest by explicitly copying only name and max_tokens, so any additional properties in the payload are silently dropped even though the interface accepts arbitrary keys via its index signature. Add ...payload to the request object so extra properties are forwarded to the handler.

Suggested change
const request: RlmSendCreateRequest = {
name: payload.name,
max_tokens: typeof payload.max_tokens === "number" ? payload.max_tokens : undefined,
};
const request: RlmSendCreateRequest = {
...payload,
name: payload.name,
max_tokens: typeof payload.max_tokens === "number" ? payload.max_tokens : undefined,
};
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/core/rlm-runtime.ts around lines 67-70:

`createRlmSendCreateHostHandler` constructs `RlmSendCreateRequest` by explicitly copying only `name` and `max_tokens`, so any additional properties in the payload are silently dropped even though the interface accepts arbitrary keys via its index signature. Add `...payload` to the request object so extra properties are forwarded to the handler.

Evidence trail:
packages/coding-agent/src/core/rlm-runtime.ts lines 34-38 (RlmSendCreateRequest interface with index signature), lines 62-74 (createRlmSendCreateHostHandler only copying name and max_tokens), prime-agent-runtime/src/rlm/__init__.py lines 200-214 (Python sender spreading **self._kwargs into payload), packages/coding-agent/src/core/agent-session.ts lines 3694-3696 (handler registration only using request.name and request.max_tokens)

* Create a persistent sub-agent session that survives across host requests.
* Called by the rlm.send.create host handler.
*/
private async _createPersistentRlmChild(name: string, _maxTokens?: number): Promise<{ session_dir: string | null }> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium core/agent-session.ts:4316

The existing check and the set on line 4345 are separated by an awaited async call. If two concurrent requests use the same name, the second overwrites the first in _persistentRlmChildren, so the first runtime is leaked — _closeAllPersistentRlmChildren only iterates the final map keys and never disposes it. Consider storing a creation promise in the map so concurrent calls with the same name share one runtime, or re-checking the map after the await and disposing the duplicate before overwriting.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/core/agent-session.ts around line 4316:

The `existing` check and the `set` on line 4345 are separated by an awaited async call. If two concurrent requests use the same `name`, the second overwrites the first in `_persistentRlmChildren`, so the first runtime is leaked — `_closeAllPersistentRlmChildren` only iterates the final map keys and never disposes it. Consider storing a creation promise in the map so concurrent calls with the same `name` share one runtime, or re-checking the map after the await and disposing the duplicate before overwriting.

Evidence trail:
packages/coding-agent/src/core/agent-session.ts lines 4316-4351 (REVIEWED_COMMIT): _createPersistentRlmChild method with check-then-await-then-set pattern. Line 4318: map.get check, line 4344: await _createRlmSubagentRuntime, line 4345: map.set. Lines 4405-4414: _closeAllPersistentRlmChildren iterates only current map keys. Line 3694-3695: caller passes request.name from kernel host handler.

…wn race, and ignored max_tokens

- _advancePersistentRlmChild attributed cumulative child usage on every
  advance instead of the delta since the last advance, inflating parent
  session cost totals and the RlmRunResult returned to Python callers.
  Track lastAttributedUsage and lastReturnedRlmUsage per persistent child
  and compute only the per-advance delta.

- _closePersistentRlmChild disposed the child runtime without aborting an
  in-flight advance first, so disposeAsync could tear down state while
  _advancePersistentRlmChild was mid-prompt/waitForIdle. Abort the child
  session before releasing the runtime, and guard the advance against the
  child being removed from the map during the await.

- rlm.send.create accepted max_tokens from the Python caller but discarded
  it (_maxTokens). Add maxTokens to AgentOptions, forward it through
  CreateRlmSubagentRuntimeOptions, and apply it when constructing the
  child Agent so per-request output token caps take effect.

- Add regression test for usage delta correctness across multiple advances.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4395fbe. Configure here.

subtractAssistantUsage(usageDelta, child.lastAttributedUsage);
child.lastAttributedUsage = cloneUsage(cumulativeAssistantUsage);

this._attributeRlmChildUsageToParent(usageDelta, parentAssistant);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred advance wrong usage parent

Medium Severity

For rlm.send.advance, child token usage is tied to whichever assistant message is last on the parent when the host handler runs, not when the user queued the background send. After the parent completes another turn, usage from an earlier sub-agent job can be attributed to the wrong assistant message.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4395fbe. Configure here.

subtractAssistantUsage(usageDelta, child.lastAttributedUsage);
child.lastAttributedUsage = cloneUsage(cumulativeAssistantUsage);

this._attributeRlmChildUsageToParent(usageDelta, parentAssistant);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Close race drops child usage

Medium Severity

If rlm.send.close runs while rlm.send.advance is in flight, an advance that already finished the child turn can hit the post-waitForIdle guard, throw, and skip _attributeRlmChildUsageToParent even though the sub-agent consumed tokens.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4395fbe. Configure here.

@sethkarten

sethkarten commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

I think this should reuse the existing continual harness + user-orchestrator abstractions instead of adding a parallel persistent-agent lifecycle. I double-checked the underlying code paths below so this is grounded in the current implementation.

Existing continual harness paths:

  • prime-agent-runtime/src/rlm/harness.py

    • HarnessKind = Literal["prompt", "memory", "skill", "subagent"]
    • HarnessEntry.path
    • _state_file(...), _DEFAULT_HARNESS_DIR_NAME = "harness", _DEFAULT_FILE_NAME = "harness_state.json"
    • HarnessState.create_subagent(...)
    • HarnessState.update_subagent(...)
    • HarnessState.delete_subagent(...)
  • packages/coding-agent/src/core/refinement/refinement.ts

    • REFINEMENT_SYSTEM_PROMPT already treats subagent as a reusable delegation spec
    • getHarnessStatePath(...), loadHarnessState(...), saveHarnessState(...)
    • the system-prompt overview section renders saved prompt notes, memories, skills, and subagent specs from the persisted harness state
  • packages/coding-agent/src/core/prompts/rlm.ts

    • the RLM prompt already describes harness subagent entries as reusable delegation specs and gives the current RLM-native invocation contract

So if this PR is adding named persistent RLM agents, I think the durable definition should extend/reuse the existing harness subagent entry shape rather than become a private runtime-only map. Concretely, the persistent RLM definition should be represented as something like:

rlm.harness.create_subagent(
    title=...,
    content=...,
    id=name,
    path="rlm",
    metadata={...},
)
rlm.harness.update_subagent(...)
rlm.harness.delete_subagent(...)

Important correction to the earlier shorthand: this is not a subagents/rlm/<name> directory in the current code. The current implementation persists harness entries through rlm.harness into the global harness state file, normally ~/.prime/agent/harness/harness_state.json unless RLM_HARNESS_STATE_DIR is set.

Then live execution should be separate and reuse the existing runtime/orchestration paths:

Existing live subagent runtime paths:

  • packages/coding-agent/src/core/rlm-runtime.ts

    • CreateRlmSubagentRuntimeOptions
    • SubagentRuntimeHost
    • SubagentRuntimeHost.createRlmSubagentRuntime(...)
    • SubagentRuntimeHost.releaseRlmSubagentRuntime(...)
  • packages/coding-agent/src/modes/daemon/daemon-mode.ts

    • createSubagentRuntimeHost(...)
    • createRlmSubagentRuntime(...)
    • runtime metadata already records kind: "subagent", parentActiveSessionId, parentSessionId, rlmChildId, rlmParentNodeId, prompt, spawnCode, and sessionDir
    • daemon-created subagents are already wired with agentMessageController and agentObserveController

Existing user-orchestrator messaging/observation paths:

  • packages/coding-agent/src/core/agent-messages.ts

    • AgentSessionMessageEndpoint
    • AgentSessionMessageController
    • createAgentMessageHostHandlers(...)
    • host requests: agent_message.list, agent_message.send
    • metadata already includes runtimeKind, parentActiveSessionId, and rlmChildId
  • packages/coding-agent/skills/agent-message/src/agent_message/__init__.py

    • Python-facing list_agents()
    • Python-facing send(...)
  • packages/coding-agent/src/core/agent-observe.ts

    • AgentObserveAgentSummary
    • AgentObserveController
    • createAgentObserveHostHandlers(...)
    • host requests: agent_observe.list, agent_observe.get, agent_observe.recent
    • summaries already include runtimeKind, parentActiveSessionId, and rlmChildId
  • packages/coding-agent/skills/agent-observe/src/agent_observe/__init__.py

    • Python-facing list_agents()
    • Python-facing get_agent(...)
    • Python-facing recent_messages(...)

So the shape I’d expect is:

harness entry: kind="subagent", path="rlm", id=<name>
-> create/reuse live daemon subagent via SubagentRuntimeHost
-> send work via agent_message.send / AgentSessionMessageController
-> inspect status/transcript via agent_observe.* / AgentObserveController

Concrete places where this PR currently seems to fork that stack:

  • packages/coding-agent/src/core/agent-session.ts
    • _persistentRlmChildren
    • _createPersistentRlmChild(...)
    • _advancePersistentRlmChild(...)
    • _closePersistentRlmChild(...)
    • host handler registration for rlm.send.create, rlm.send.advance, rlm.send.close
    • _advancePersistentRlmChild(...) calls session.prompt(...) directly

This makes a private AgentSession map the source of truth for persistent children. I think the durable source of truth should instead be the continual harness subagent entry with path: "rlm"; AgentSession/daemon state should only manage the live runtime instance for that artifact.

  • packages/coding-agent/src/core/rlm-runtime.ts
    • RlmSendCreateRequest
    • RlmSendAdvanceRequest
    • RlmSendCloseRequest
    • createRlmSendCreateHostHandler(...)
    • createRlmSendAdvanceHostHandler(...)
    • createRlmSendCloseHostHandler(...)

These look like a separate host protocol for creating, messaging, and closing subagents, even though the existing abstractions above already cover durable harness entries, live daemon subagents, message delivery, and observation.

  • prime-agent-runtime/src/rlm/__init__.py
    • sanitize_name(...)
    • REGISTRY = Registry()
    • _HostRlmProcessor
    • send(...)
    • _HostRlmProcessor currently calls host_request("rlm.send.create" ...), host_request("rlm.send.advance" ...), and host_request("rlm.send.close" ...)

The Python-facing rlm.send(...) API seems useful, but I think its host implementation should resolve/create the named harness subagent entry with path: "rlm", launch or reuse a daemon-visible subagent runtime through SubagentRuntimeHost, and deliver follow-up work through agent_message.send rather than using a new rlm.send.* lifecycle protocol.

I’d also update the tests to assert public behavior rather than private fields. Right now packages/coding-agent/test/agent-session-recursion.test.ts reaches into:

  • _persistentRlmChildren
  • _createPersistentRlmChild(...)
  • _advancePersistentRlmChild(...)
  • _closePersistentRlmChild(...)

I’d prefer tests for:

  • named persistent RLM child is represented as a harness subagent entry with path: "rlm"
  • creating/sending to the same name reuses the same daemon-visible subagent runtime
  • messages are delivered through agent_message.send / AgentSessionMessageController
  • status/transcript are visible through agent_observe.* / AgentObserveController
  • closing releases the live runtime through SubagentRuntimeHost.releaseRlmSubagentRuntime(...) without deleting the durable harness artifact

That keeps persistent RLM subagents composable with continual harness refinement, while also reusing the messaging/observation machinery from #207 instead of creating another control plane.

@snimu snimu closed this Jun 23, 2026
@kevinjosethomas
kevinjosethomas deleted the sebastian/persistent-agents-2026-06-18 branch June 26, 2026 00:37
@kevinjosethomas
kevinjosethomas restored the sebastian/persistent-agents-2026-06-18 branch June 26, 2026 00:37
@kevinjosethomas
kevinjosethomas deleted the sebastian/persistent-agents-2026-06-18 branch July 8, 2026 00:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants