-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Persistent sub-agents #230
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3a1c103
ac9a078
88415bb
bd76d58
012f2ac
aa8f64a
00bd2b8
4395fbe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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"; | ||
|
|
@@ -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; | ||
|
|
@@ -1815,6 +1823,11 @@ export class AgentSession { | |
| if (this._disposed) { | ||
| return; | ||
| } | ||
| try { | ||
| await this._closeAllPersistentRlmChildren(); | ||
| } catch { | ||
| // best effort during teardown | ||
| } | ||
| try { | ||
| await this._ipythonKernelProvisioner?.dispose(); | ||
| } catch { | ||
|
|
@@ -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"]) { | ||
|
|
@@ -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 { | ||
|
|
@@ -3827,6 +3858,7 @@ export class AgentSession { | |
| spawnCode?: string; | ||
| sessionDir: string; | ||
| model: Model<any>; | ||
| maxTokens?: number; | ||
| }): CreateRlmSubagentRuntimeOptions { | ||
| return { | ||
| parentSession: this, | ||
|
|
@@ -3844,6 +3876,7 @@ export class AgentSession { | |
| rlmDepth: this._rlmDepth + 1, | ||
| rlmMaxDepth: this._rlmMaxDepth, | ||
| rlmParentNodeId: options.id, | ||
| maxTokens: options.maxTokens, | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -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, | ||
| }); | ||
|
|
||
|
|
@@ -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, | ||
| }); | ||
|
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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred advance wrong usage parentMedium Severity For Reviewed by Cursor Bugbot for commit 4395fbe. Configure here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Close race drops child usageMedium Severity If Additional Locations (1)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 | ||
| // ========================================================================= | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.