Skip to content
5 changes: 5 additions & 0 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export interface AgentOptions {
thinkingBudgets?: ThinkingBudgets;
transport?: Transport;
maxRetryDelayMs?: number;
maxTokens?: number;
toolExecution?: ToolExecutionMode;
}

Expand Down Expand Up @@ -207,6 +208,8 @@ export class Agent {
public transport: Transport;
/** Optional cap for provider-requested retry delays. */
public maxRetryDelayMs?: number;
/** Optional per-request output token cap forwarded to the stream function. */
public maxTokens?: number;
/** Tool execution strategy for assistant messages that contain multiple tool calls. */
public toolExecution: ToolExecutionMode;

Expand All @@ -228,6 +231,7 @@ export class Agent {
this.thinkingBudgets = options.thinkingBudgets;
this.transport = options.transport ?? "auto";
this.maxRetryDelayMs = options.maxRetryDelayMs;
this.maxTokens = options.maxTokens;
this.toolExecution = options.toolExecution ?? "parallel";
}

Expand Down Expand Up @@ -448,6 +452,7 @@ export class Agent {
transport: this.transport,
thinkingBudgets: this.thinkingBudgets,
maxRetryDelayMs: this.maxRetryDelayMs,
maxTokens: this.maxTokens,
toolExecution: this.toolExecution,
beforeToolCall: this.beforeToolCall,
afterToolCall: this.afterToolCall,
Expand Down
175 changes: 173 additions & 2 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.j
import {
type CreateRlmSubagentRuntimeOptions,
createRlmRunHostHandler,
createRlmSendAdvanceHostHandler,
createRlmSendCloseHostHandler,
createRlmSendCreateHostHandler,
type RlmInternalRunResult,
type RlmRunResult,
type RlmSubagentRuntime,
Expand All @@ -158,7 +161,7 @@ import { type BashOperations, createLocalBashOperations } from "./tools/bash.js"
import { createAllToolDefinitions } from "./tools/index.js";
import { IpythonKernelProvisioner } from "./tools/ipython.js";
import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js";
import { addAssistantUsage, cloneUsage, emptyUsage } from "./usage.js";
import { addAssistantUsage, cloneUsage, emptyUsage, subtractAssistantUsage } from "./usage.js";

export type { GoalState, GoalStatus } from "./goals.js";
export type { SessionStats } from "./session-stats.js";
Expand Down Expand Up @@ -720,6 +723,11 @@ export class AgentSession {
private _rlmParentNodeId?: string;
private _subagentRuntimeHost?: SubagentRuntimeHost;
private _activeRlmChildRuns = new Map<string, RlmChildRun>();
/** Named persistent sub-agents created via rlm.send (kept alive across host requests). */
private _persistentRlmChildren = new Map<
string,
{ runtime: RlmSubagentRuntime; sessionDir: string; lastAttributedUsage: Usage; lastReturnedRlmUsage: RlmUsage }
>();

// Model registry for API key resolution
private _modelRegistry: ModelRegistry;
Expand Down Expand Up @@ -1815,6 +1823,11 @@ export class AgentSession {
if (this._disposed) {
return;
}
try {
await this._closeAllPersistentRlmChildren();
} catch {
// best effort during teardown
}
Comment thread
cursor[bot] marked this conversation as resolved.
try {
await this._ipythonKernelProvisioner?.dispose();
} catch {
Expand Down Expand Up @@ -3681,6 +3694,13 @@ export class AgentSession {
"rlm.run": createRlmRunHostHandler(({ prompt, kwargs, cellSourceCode }) =>
this.runRlmChild(prompt, kwargs, cellSourceCode),
),
"rlm.send.create": createRlmSendCreateHostHandler((request) =>
this._createPersistentRlmChild(request.name, request.max_tokens),
),
"rlm.send.advance": createRlmSendAdvanceHostHandler((request) =>
this._advancePersistentRlmChild(request.name, request.prompt),
),
"rlm.send.close": createRlmSendCloseHostHandler((request) => this._closePersistentRlmChild(request.name)),
};
if (this._includeGoals) {
for (const type of ["goal.get", "goal.create", "goal.complete"]) {
Expand Down Expand Up @@ -3755,8 +3775,19 @@ export class AgentSession {
return undefined;
}

private _createChildRlmSessionDir(): string {
private _createChildRlmSessionDir(name?: string): string {
const parentDir = this._ensureRlmSessionDir() ?? this._createEphemeralRlmSessionDir();
if (name) {
// Named persistent agent: stable, human-readable directory
const safeName =
name
.replace(/[^A-Za-z0-9._-]/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64) || "agent";
const childDir = join(parentDir, `sub-${safeName}`);
mkdirSync(childDir, { recursive: true });
return childDir;
}
for (let i = 0; i < 100; i++) {
const childDir = join(parentDir, `sub-${randomUUID().slice(0, 8)}`);
try {
Expand Down Expand Up @@ -3827,6 +3858,7 @@ export class AgentSession {
spawnCode?: string;
sessionDir: string;
model: Model<any>;
maxTokens?: number;
}): CreateRlmSubagentRuntimeOptions {
return {
parentSession: this,
Expand All @@ -3844,6 +3876,7 @@ export class AgentSession {
rlmDepth: this._rlmDepth + 1,
rlmMaxDepth: this._rlmMaxDepth,
rlmParentNodeId: options.id,
maxTokens: options.maxTokens,
};
}

Expand Down Expand Up @@ -3894,6 +3927,7 @@ export class AgentSession {
thinkingBudgets: this.settingsManager.getThinkingBudgets(),
transport: this.settingsManager.getTransport(),
maxRetryDelayMs: this.settingsManager.getProviderRetrySettings().maxRetryDelayMs,
maxTokens: options.maxTokens,
toolExecution: this.agent.toolExecution,
});

Expand Down Expand Up @@ -4277,6 +4311,143 @@ export class AgentSession {
};
}

// =========================================================================
// Persistent / Background Sub-Agents (rlm.send)
// =========================================================================

/**
* 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 }> {
// If already exists, just return its session dir
const existing = this._persistentRlmChildren.get(name);
if (existing) {
return { session_dir: existing.sessionDir };
}

if (this._rlmDepth >= this._rlmMaxDepth) {
throw new Error(
`RLM recursion depth limit reached (RLM_DEPTH=${this._rlmDepth}, RLM_MAX_DEPTH=${this._rlmMaxDepth})`,
);
}

const model = this.model;
if (!model) {
throw new Error("No model selected");
}

const childSessionDir = this._createChildRlmSessionDir(name);
const childNodeId = `persistent-${name}`;

const subagentOptions = this._createRlmSubagentRuntimeOptions({
id: childNodeId,
prompt: `[persistent agent: ${name}]`,
sessionDir: childSessionDir,
model,
maxTokens,
});
Comment thread
cursor[bot] marked this conversation as resolved.

const childRuntime = await this._createRlmSubagentRuntime(subagentOptions);
this._persistentRlmChildren.set(name, {
runtime: childRuntime,
sessionDir: childSessionDir,
lastAttributedUsage: emptyUsage(),
lastReturnedRlmUsage: emptyRlmUsage(),
});

return { session_dir: childSessionDir };
}

/**
* Send a prompt to an existing persistent sub-agent and await its response.
* Called by the rlm.send.advance host handler.
*/
private async _advancePersistentRlmChild(name: string, prompt: string): Promise<RlmRunResult> {
const child = this._persistentRlmChildren.get(name);
if (!child) {
throw new Error(`No persistent sub-agent named '${name}'; create it with rlm.send.create first`);
}

const session = child.runtime.session;
const parentAssistant = this._findLastAssistantMessage();

await session.prompt(prompt, { expandPromptTemplates: false, source: "extension" });
await session.agent.waitForIdle();

// Guard: the child may have been closed while we were awaiting above.
if (!this._persistentRlmChildren.has(name)) {
throw new Error(`Persistent sub-agent '${name}' was closed during advance`);
}

const answer = session.getLastAssistantText() ?? "";
const cumulativeRlmUsage = session._usageForCurrentMessages();
const cumulativeAssistantUsage = session._assistantUsageForCurrentMessages();

// Compute deltas: only attribute/return usage from THIS advance, not prior ones.
const usageDelta = emptyUsage();
addAssistantUsage(usageDelta, cumulativeAssistantUsage);
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.

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.


const rlmUsageDelta: RlmUsage = {
prompt_tokens: cumulativeRlmUsage.prompt_tokens - child.lastReturnedRlmUsage.prompt_tokens,
completion_tokens: cumulativeRlmUsage.completion_tokens - child.lastReturnedRlmUsage.completion_tokens,
};
child.lastReturnedRlmUsage = { ...cumulativeRlmUsage };

return {
answer,
usage: rlmUsageDelta,
turns: session._assistantTurnCount(),
session_dir: child.sessionDir,
};
}

/**
* Close and dispose a persistent sub-agent.
* Called by the rlm.send.close host handler.
*/
private async _closePersistentRlmChild(name: string): Promise<void> {
const child = this._persistentRlmChildren.get(name);
if (!child) {
return; // Already closed or never existed; idempotent
}
this._persistentRlmChildren.delete(name);
// Abort any in-flight advance so its prompt/waitForIdle settles before we
// dispose the runtime. Without this, disposeAsync can tear down the child
// session while _advancePersistentRlmChild is still mid-await.
try {
await child.runtime.session.abort();
} catch {
// best effort — the session may already be idle
}
const childNodeId = `persistent-${name}`;
const subagentOptions = this._createRlmSubagentRuntimeOptions({
id: childNodeId,
prompt: `[persistent agent: ${name}]`,
sessionDir: child.sessionDir,
model: this.model!,
});
await this._releaseRlmSubagentRuntime(child.runtime, subagentOptions);
}

/**
* Close all persistent sub-agents (called during session teardown).
*/
private async _closeAllPersistentRlmChildren(): Promise<void> {
const names = [...this._persistentRlmChildren.keys()];
for (const name of names) {
try {
await this._closePersistentRlmChild(name);
} catch {
// Best effort during teardown
}
}
}

// =========================================================================
// Auto-Retry
// =========================================================================
Expand Down
4 changes: 2 additions & 2 deletions packages/coding-agent/src/core/kernel/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
import { getPackageDir } from "../../config.js";
import type { PythonSkillRuntimeInfo } from "../skills.js";

const BOOTSTRAP_SCHEMA = 7;
const BOOTSTRAP_SCHEMA = 8;
const PYTHON_VERSION = "3.11";
const IPYKERNEL_REQUIREMENT = "ipykernel";
const RUNTIME_REQUIREMENT = "prime-agent-runtime";
Expand Down Expand Up @@ -51,7 +51,7 @@ const REQUIRED_HARNESS_METHODS = [
"delete_prompt_note",
"record_refinement",
];
const RUNTIME_READY_CHECK = `import inspect; import rlm; from rlm.harness import HarnessEntry; _harness_methods = ${JSON.stringify(REQUIRED_HARNESS_METHODS)}; assert hasattr(rlm, 'run'); assert callable(rlm); assert hasattr(rlm, 'rlm'); assert callable(rlm.rlm); assert callable(rlm.host_request); assert hasattr(rlm, 'harness'); assert hasattr(rlm, 'get_harness_state'); assert hasattr(rlm.rlm, 'harness'); assert hasattr(rlm.rlm, 'get_harness_state'); assert all(callable(getattr(_harness, _method, None)) for _harness in (rlm.harness, rlm.rlm.harness) for _method in _harness_methods); assert 'reference' in HarnessEntry.__dataclass_fields__; assert 'reference' in inspect.signature(rlm.harness.create_skill).parameters; assert 'reference' in inspect.signature(rlm.harness.update_skill).parameters; assert not hasattr(rlm, 'background'); assert not hasattr(rlm.rlm, 'background')`;
const RUNTIME_READY_CHECK = `import inspect; import rlm; from rlm.harness import HarnessEntry; _harness_methods = ${JSON.stringify(REQUIRED_HARNESS_METHODS)}; assert hasattr(rlm, 'run'); assert callable(rlm); assert hasattr(rlm, 'rlm'); assert callable(rlm.rlm); assert callable(rlm.host_request); assert hasattr(rlm, 'send'); assert hasattr(rlm.rlm, 'send'); assert hasattr(rlm, 'harness'); assert hasattr(rlm, 'get_harness_state'); assert hasattr(rlm.rlm, 'harness'); assert hasattr(rlm.rlm, 'get_harness_state'); assert all(callable(getattr(_harness, _method, None)) for _harness in (rlm.harness, rlm.rlm.harness) for _method in _harness_methods); assert 'reference' in HarnessEntry.__dataclass_fields__; assert 'reference' in inspect.signature(rlm.harness.create_skill).parameters; assert 'reference' in inspect.signature(rlm.harness.update_skill).parameters; assert not hasattr(rlm, 'background'); assert not hasattr(rlm.rlm, 'background')`;
const BOOTSTRAP_VERSION_FILE = ".bootstrap-version";
const BOOTSTRAP_LOCK_NAME = ".bootstrap.lock";
const BOOTSTRAP_LOCK_RETRY_MS = 100;
Expand Down
12 changes: 9 additions & 3 deletions packages/coding-agent/src/core/prompts/rlm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export function buildRlmPrompt(options: RlmPromptOptions): string {
skillLines.push(
"Each Python skill may also be available as a shell command by the same name: `<skill> ...`. Discover its CLI usage with `<skill> --help`.",
);
skillLines.push(
"To offload a slow skill call, run it in the background: `handle = <skill>.send(...)`, then check `handle.poll()` later (same handle API as sub-agents).",
);
if (installedSkills.includes("edit")) {
skillLines.push(
"For targeted existing-file edits, prefer the pre-imported async `edit` skill from IPython: `old = '''...'''; new = '''...'''; await edit(path=\"pkg/file.py\", old_str=old, new_str=new)`. Use exact old/new strings; if the text contains triple double quotes, use triple single-quoted variables or build `old`/`new` from inspected file slices.",
Expand All @@ -75,9 +78,12 @@ export function buildRlmPrompt(options: RlmPromptOptions): string {
if (allowRecursion) {
parts.push(
"",
"A callable `rlm` is already in your global namespace — call it directly with `await rlm('sub-task')` to spawn a recursive sub-agent. Returns an `RLMResult` with `.answer` (string), `.usage`, `.turns`, and `.session_dir`.",
"For parallel sub-agents, use normal Python async patterns such as `await asyncio.gather(rlm('task1'), rlm('task2'))`.",
"For sub-agent work that can run in the background, keep the task handle from `asyncio.create_task(rlm('sub-task'))` so you do not block the main execution path; use normal task callbacks, `task.done()`, or `await task` later to observe completion and read the returned `RLMResult.answer`.",
"A callable `rlm` is already in your global namespace for spawning recursive sub-agents.",
"- One-off (blocking): `await rlm('sub-task')` runs a sub-agent to completion and returns an `RLMResult` (`.answer`, `.usage`, `.turns`, `.session_dir`). Run several at once with `await asyncio.gather(rlm('a'), rlm('b'))`.",
"- Persistent (background): `handle = rlm.send('sub-task', name='helper')` (omit `name` for an auto-generated one) starts a named sub-agent and returns a handle immediately — start it in one tool call and check on it in a later one. Re-`send` the same `name` to continue that agent's conversation, so you can keep a specialist around and ask it repeatedly.",
"- `handle.poll()` reports its state without blocking: `.status` is 'running', 'finished', or 'error'; `.results` is a FIFO of finished `RLMResult`s (take the next with `.results.popleft()`); `.queued` is the editable list of not-yet-started prompts; `.error` is the exception if it failed. Use `await handle.wait()` to block for the next result.",
"- When multiple sub-agents are in flight, prefer polling all handles in a single tool call over sequential `await handle.wait()` calls — this lets you check progress and act on whichever results are ready without blocking on one agent at a time.",
"- Keep the handle in a variable to poll it from a later tool call (your IPython namespace persists across calls). Inspect the full API with `help(rlm.send)`.",
);
}

Expand Down
Loading