Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added completion follow-ups when background kernel `bash()` commands finish so agents can inspect results and continue.
9 changes: 9 additions & 0 deletions packages/coding-agent/docs/rlm.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ result = await bash("npm run check")
print(result.output)
```

For a long command, keep the live handle and let the turn end instead of blocking:

```python
checks = bash("npm test")
checks.pid
```

When a handle left running beyond its creating cell finishes, Prime Agent sends the agent a follow-up with its PID and exit code. The follow-up asks the agent to inspect the saved handle with `poll()`, `output()`, or `tail()` and continue the task. `await bash(...)` stays synchronous from the agent's perspective and does not send a second completion follow-up.

Each `bash()` call is its own process, while Python state, `os.chdir(...)`, and `os.environ[...]` changes persist in the kernel and apply to later `bash()` calls. Prime Agent extensions may intentionally add custom tools, but the built-in RLM design does not require a separate model tool for every capability.

### 2. Subagents are native RLM calls
Expand Down
15 changes: 15 additions & 0 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,13 @@ import { type RestoreResult, snapshotPathIn } from "./kernel/state-snapshot.js";
import type { AcpMcpServerConfig } from "./mcp/acp-mcp-types.js";
import type { McpManager } from "./mcp/mcp-manager.js";
import {
ASYNC_BASH_COMPLETION_CUSTOM_TYPE,
type BashExecutionMessage,
type CompactionOutcome,
type CompactionOutcomeReason,
type CustomMessage,
convertToLlm,
createAsyncBashCompletionMessage,
createCompactionOutcomeMessage,
createHeartbeatPromptMessage,
createRefinementOutcomeMessage,
Expand Down Expand Up @@ -214,6 +216,7 @@ import { resolveConfigValue } from "./resolve-config-value.js";
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.js";
import {
type CreateRlmSubagentRuntimeOptions,
createAsyncBashCompletionHostHandler,
createDefaultRlmSubagentSessionName,
createRlmDeleteSubagentHostHandler,
createRlmFindModelsHostHandler,
Expand Down Expand Up @@ -811,6 +814,8 @@ function injectedMessagePreviewLabel(message: CustomMessage): string | undefined
switch (message.customType) {
case HEARTBEAT_PROMPT_CUSTOM_TYPE:
return HEARTBEAT_PROMPT_PREVIEW_LABEL;
case ASYNC_BASH_COMPLETION_CUSTOM_TYPE:
return "Async shell completed";
case GOAL_CONTEXT_CUSTOM_TYPE:
return GOAL_CONTEXT_PREVIEW_LABEL;
default:
Expand Down Expand Up @@ -9346,6 +9351,16 @@ export class AgentSession {
"rlm.run": createRlmRunHostHandler(async ({ prompt, kwargs, cellSourceCode }) => ({
...(await this.runRlmChild(prompt, kwargs, cellSourceCode)),
})),
"bash.completed": createAsyncBashCompletionHostHandler(async (details) => {
const message = createAsyncBashCompletionMessage(details);
await this._promptInjectedMessage(message.content, message, {
streamingBehavior: "followUp",
queueIfBusy: true,
resumeIfIdle: true,
returnAfterAccepted: true,
suppressAutonomousContinuation: true,
});
}),
"rlm.find_models": createRlmFindModelsHostHandler((query, limit) => this.findRlmModels(query, limit)),
"rlm.list_subagents": createRlmListSubagentsHostHandler(() => this.listRlmSubagents()),
"rlm.delete_subagent": createRlmDeleteSubagentHostHandler((target) => this.deleteRlmSubagent(target)),
Expand Down
29 changes: 29 additions & 0 deletions packages/coding-agent/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const COMPACTION_OUTCOME_CUSTOM_TYPE = "compaction_outcome";
export const REFINEMENT_OUTCOME_CUSTOM_TYPE = "refinement_outcome";
export const RLM_CHILD_FAILURE_CUSTOM_TYPE = "rlm_child_failure";
export const RLM_CHILD_TERMINAL_NOTICE_CUSTOM_TYPE = "rlm_child_terminal_notice";
export const ASYNC_BASH_COMPLETION_CUSTOM_TYPE = "async_bash_completion";

export interface SessionSlashCommandDetails {
command: SessionSlashCommand;
Expand Down Expand Up @@ -109,6 +110,34 @@ export type RlmChildTerminalNoticeDetails =
lastAssistantTextPreview?: string;
};

export interface AsyncBashCompletionDetails {
pid: number;
command: string;
exitCode: number;
}

interface AsyncBashCompletionMessage extends CustomMessage<AsyncBashCompletionDetails> {
customType: typeof ASYNC_BASH_COMPLETION_CUSTOM_TYPE;
content: string;
}

export function createAsyncBashCompletionMessage(
details: AsyncBashCompletionDetails,
timestamp = Date.now(),
): AsyncBashCompletionMessage {
return {
role: "custom",
customType: ASYNC_BASH_COMPLETION_CUSTOM_TYPE,
content: `Async bash command completed (pid ${details.pid}, exit code ${details.exitCode}).
Command: ${JSON.stringify(details.command)}

Inspect the saved BashHandle with .poll(), .output(), or .tail(), then continue the task.`,
display: true,
details,
timestamp,
};
}

export function createRlmChildFailureMessage(
details: RlmChildFailureDetails,
timestamp = Date.now(),
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/core/prompts/rlm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface RlmPromptOptions {
}

const LONG_RUNNING_WORK_PROMPT = [
"For slow or independently completing work, use a nonblocking control loop: start the work, record its handle or output location, then end your turn. Read the result on a later turn or when a reply arrives.",
"For slow or independently completing work, use a nonblocking control loop: start the work, record its handle or output location, then end your turn. A `bash()` handle left running beyond its creating cell sends a completion follow-up; when it arrives, inspect the saved handle and continue.",
"When delegation is available and useful, assign independent substantive tasks to separate workers. Start independent workers without waiting for each one sequentially, and let them run in parallel.",
"Do not keep the turn open by polling with `time.sleep()` or shell `sleep`, and do not replace polling with a long blocking `await`. Await only the short operation needed to start work or inspect a result that is already available; otherwise end the turn.",
].join("\n");
Expand Down
26 changes: 26 additions & 0 deletions packages/coding-agent/src/core/rlm-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ export interface RlmFindModelsResult {
}

export type RlmRunHandler = (request: RlmRunRequest) => Promise<Record<string, unknown>>;

interface AsyncBashCompletionRequest {
pid: number;
command: string;
exitCode: number;
}

type AsyncBashCompletionHandler = (request: AsyncBashCompletionRequest) => void | Promise<void>;
export type RlmListSubagentsHandler = () => RlmListSubagentsResult | Promise<RlmListSubagentsResult>;
export type RlmDeleteSubagentHandler = (target: string) => Promise<RlmDeleteSubagentResult>;
export type RlmFindModelsHandler = (query: string, limit: number) => RlmFindModelsResult | Promise<RlmFindModelsResult>;
Expand Down Expand Up @@ -178,6 +186,24 @@ export function createRlmRunHostHandler(handler: RlmRunHandler): HostRequestHand
};
}

/** Adapt detached kernel bash completions into a validated host notification. */
export function createAsyncBashCompletionHostHandler(handler: AsyncBashCompletionHandler): HostRequestHandler {
return async (payload) => {
const { pid, command, exitCode } = payload;
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
throw new Error("bash.completed pid must be a positive integer");
}
if (typeof command !== "string" || !command) {
throw new Error("bash.completed command must be a non-empty string");
}
if (typeof exitCode !== "number" || !Number.isInteger(exitCode)) {
throw new Error("bash.completed exitCode must be an integer");
}
await handler({ pid, command, exitCode });
return {};
};
}

/** Search a bounded authenticated model catalog without adding it to the system prompt. */
export function createRlmFindModelsHostHandler(handler: RlmFindModelsHandler): HostRequestHandler {
return async (payload) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
} from "@earendil-works/pi-tui";
import { GOAL_CONTEXT_CUSTOM_TYPE, type GoalContextDetails } from "../../../core/goals.js";
import {
ASYNC_BASH_COMPLETION_CUSTOM_TYPE,
type AsyncBashCompletionDetails,
type CustomMessage,
HEARTBEAT_PROMPT_CUSTOM_TYPE,
type HeartbeatPromptDetails,
Expand All @@ -24,6 +26,7 @@ import { getMarkdownTheme, theme } from "../theme/theme.js";
import { expandCollapseHint } from "./keybinding-hints.js";

type InjectedPromptDetails =
| AsyncBashCompletionDetails
| GoalContextDetails
| HeartbeatPromptDetails
| IpythonStateRestoredDetails
Expand All @@ -34,7 +37,8 @@ type InjectedPromptMessage = CustomMessage<InjectedPromptDetails>;
export function isInjectedPromptMessage(message: AgentMessage): message is InjectedPromptMessage {
return (
message.role === "custom" &&
(message.customType === HEARTBEAT_PROMPT_CUSTOM_TYPE ||
(message.customType === ASYNC_BASH_COMPLETION_CUSTOM_TYPE ||
message.customType === HEARTBEAT_PROMPT_CUSTOM_TYPE ||
message.customType === GOAL_CONTEXT_CUSTOM_TYPE ||
message.customType === IPYTHON_STATE_RESTORED_CUSTOM_TYPE ||
message.customType === RLM_CHILD_FAILURE_CUSTOM_TYPE ||
Expand Down Expand Up @@ -125,6 +129,12 @@ export class InjectedPromptMessageComponent extends Container {
if (this.message.customType === HEARTBEAT_PROMPT_CUSTOM_TYPE) {
return this.heartbeatHeaderText();
}
if (this.message.customType === ASYNC_BASH_COMPLETION_CUSTOM_TYPE) {
const details = this.message.details as AsyncBashCompletionDetails | undefined;
const status = details ? ` · exit ${details.exitCode}` : "";
const hint = this.expanded ? "" : ` ${expandCollapseHint("app.tools.expand", false)}`;
return theme.fg("muted", "Async shell completed") + theme.fg("dim", status + hint);
}
if (this.message.customType === IPYTHON_STATE_RESTORED_CUSTOM_TYPE) {
const details = this.message.details as IpythonStateRestoredDetails | undefined;
const label = details?.restored === false ? "Started fresh Python kernel" : "Restored Python kernel state";
Expand Down
29 changes: 28 additions & 1 deletion packages/coding-agent/test/agent-session-recursion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { AgentSession, type RlmChildAgentSnapshot } from "../src/core/agent-sess
import { AuthStorage } from "../src/core/auth-storage.js";
import type { LoadExtensionsResult } from "../src/core/extensions/index.js";
import { type HostRequestHandlers, ReplKernelManager } from "../src/core/kernel/index.js";
import { convertToLlm } from "../src/core/messages.js";
import { ASYNC_BASH_COMPLETION_CUSTOM_TYPE, convertToLlm } from "../src/core/messages.js";
import { ModelRegistry } from "../src/core/model-registry.js";
import {
createDefaultRlmSubagentSessionName,
Expand Down Expand Up @@ -820,6 +820,33 @@ describe("AgentSession rlm recursion", () => {
expect(doneUpdate?.toolUseCount).toBeUndefined();
});

it("wakes the agent with a follow-up when a detached bash handle completes", async () => {
const prompts: string[] = [];
const root = createSession({
streamFn: (_model, context) => {
prompts.push(userText(context));
return streamAnswer("checked shell result");
},
});
const handlers = (root as unknown as InspectableRlmSession)._createKernelHostHandlers();
const completed = handlers["bash.completed"];
if (!completed) throw new Error("Missing bash.completed host handler");

await expect(completed({ pid: 42, command: "npm test", exitCode: 1 })).resolves.toEqual({});
await root.waitForIdle();

expect(prompts).toEqual([
expect.stringContaining("Inspect the saved BashHandle with .poll(), .output(), or .tail()"),
]);
expect(root.messages).toContainEqual(
expect.objectContaining({
role: "custom",
customType: ASYNC_BASH_COMPLETION_CUSTOM_TYPE,
details: { pid: 42, command: "npm test", exitCode: 1 },
}),
);
});

it("marks an in-cell roled send to the parent as replied", async () => {
const sendAgentMessage = vi.fn(async () => ({
id: "agentmsg-reply",
Expand Down
47 changes: 47 additions & 0 deletions packages/coding-agent/test/async-bash-completion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import {
ASYNC_BASH_COMPLETION_CUSTOM_TYPE,
convertToLlm,
createAsyncBashCompletionMessage,
} from "../src/core/messages.js";
import { createAsyncBashCompletionHostHandler } from "../src/core/rlm-runtime.js";

describe("async bash completion", () => {
it("creates a model-visible instruction to inspect the saved handle", () => {
const message = createAsyncBashCompletionMessage({
pid: 42,
command: "npm test",
exitCode: 1,
});

expect(message.customType).toBe(ASYNC_BASH_COMPLETION_CUSTOM_TYPE);
expect(message.content).toContain("pid 42, exit code 1");
expect(message.content).toContain("npm test");
expect(message.content).toContain(".poll(), .output(), or .tail()");
expect(convertToLlm([message])).toEqual([
{
role: "user",
content: [{ type: "text", text: message.content }],
timestamp: message.timestamp,
},
]);
});

it("validates and forwards kernel completion payloads", async () => {
const completion = vi.fn();
const handler = createAsyncBashCompletionHostHandler(completion);
const payload = { pid: 42, command: "npm test", exitCode: 0 };

await expect(handler(payload)).resolves.toEqual({});
expect(completion).toHaveBeenCalledWith(payload);
});

it.each([
[{ pid: 0, command: "ok", exitCode: 0 }, "positive integer"],
[{ pid: 1, command: "", exitCode: 0 }, "non-empty string"],
[{ pid: 1, command: "ok", exitCode: 0.5 }, "exitCode"],
])("rejects an invalid payload %#", async (payload, error) => {
const handler = createAsyncBashCompletionHostHandler(() => undefined);
await expect(handler(payload)).rejects.toThrow(error);
});
});
1 change: 1 addition & 0 deletions packages/coding-agent/test/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ describe("buildRlmPrompt", () => {
});

expect(prompt).toContain("Use `bash()` to invoke programs, not to write shell programs");
expect(prompt).toContain("A `bash()` handle left running beyond its creating cell sends a completion follow-up");
});

test("documents preferring Python for reading and searching files when ipython is active", () => {
Expand Down
Loading
Loading