feat: enable worker→orchestrator dialogue via `ao send` with auto-sender prefix (#1787)
* feat: enable workers to message orchestrator via AO_ORCHESTRATOR_SESSION_ID
Worker sessions can already be messaged by the orchestrator via `ao send`,
but the reverse direction was undiscoverable: workers had no way to learn
their orchestrator's session ID. The transport already exists (`ao send`
routes to any session in the project, and the orchestrator's ID is
deterministic at `${sessionPrefix}-orchestrator`) — only the discoverability
piece was missing.
Changes:
- Add `orchestratorSessionId?: SessionId` to `AgentLaunchConfig`.
- Populate it in spawnWorker and the worker restore path; deliberately
omit it for spawnOrchestrator (an orchestrator is not its own parent).
- Each agent plugin (claude-code, codex, opencode, aider, cursor, kimicode)
now injects `AO_ORCHESTRATOR_SESSION_ID` into the agent env when the
field is present.
- Worker prompt preamble teaches the new channel with two restraints:
(1) only ping when genuinely blocked, (2) always prefix with
`[from $AO_SESSION_ID]` because the orchestrator receives raw input
with no `from:` metadata.
No new CLI verb, no new file format, no new transport — just env wiring
plus prompt copy.
Closes #1786
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: only set AO_ORCHESTRATOR_SESSION_ID when orchestrator metadata exists
Greptile review on #1787: spawn() unconditionally injected the env var
for every worker, including ad-hoc `ao spawn` workers in projects that
never had an orchestrator running. The AgentLaunchConfig JSDoc claimed
the field was unset for ad-hoc sessions, but the implementation didn't
honor it — so a worker following the prompt's `ao send
$AO_ORCHESTRATOR_SESSION_ID …` instruction would get an opaque
"session does not exist" error.
Both spawn() and restore() now check `readMetadataRaw(sessionsDir,
"<prefix>-orchestrator")` and only propagate the field when the
orchestrator's metadata is actually on disk. Existence-on-disk is the
right signal: if metadata was ever written for the canonical
orchestrator ID, the orchestrator workflow is in play for this project.
Tests updated:
- spawn: split into two cases — "passes when orchestrator exists" (now
spawns the orchestrator first) and "omits for ad-hoc workers".
- restore: added a third case for ad-hoc worker restore (no orchestrator
metadata) alongside the existing worker-with-orchestrator and
orchestrator-restore cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: document orchestrator-send command for POSIX, PowerShell, and cmd.exe
Greptile review on #1787: the prompt's `ao send $AO_ORCHESTRATOR_SESSION_ID
"[from $AO_SESSION_ID] …"` example is bash-only. On Windows PowerShell
(the default shell), bare `$AO_ORCHESTRATOR_SESSION_ID` resolves to
$null, so the command silently sends to an empty session ID instead of
the orchestrator. CROSS_PLATFORM.md flags exactly this footgun.
Both prompt variants now show the correct form for the three shells AO
supports as a first-class platform: POSIX bash/zsh, PowerShell
(`$env:NAME`), and cmd.exe (`%NAME%`). The agent picks the form for its
shell. Quotes added around the POSIX expansion as a defensive measure
in case the env var ever expands to whitespace.
prompt-builder tests now assert all three syntaxes appear in both the
full and no-repo prompts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: literal-render orchestrator ID in prompt; auto-prefix [from <id>] in ao send
Two simplifications collapsed into the same feature now that the
worker→orchestrator dialogue lives in the prompt + send.ts only:
1. **Drop AO_ORCHESTRATOR_SESSION_ID env var entirely.** The orchestrator
session ID is deterministic (`${sessionPrefix}-orchestrator`) and known
at prompt-build time, so prompt-builder just renders it literally:
ao send my-orchestrator "<your message>"
No env var, no AgentLaunchConfig field, no per-plugin wiring, no
PowerShell/cmd.exe/POSIX shell-syntax variants. The orchestrator
existence check moves into session-manager's buildPrompt call site —
one place instead of duplicated across env injection + prompt mention.
2. **Auto-prefix `[from $AO_SESSION_ID]` in `ao send` itself.** The prompt
no longer teaches the agent to self-identify because that's
infrastructure's job. send.ts wraps the message when AO_SESSION_ID is
set, which covers all session→session traffic (worker→orchestrator,
orchestrator→worker, worker→worker). Humans running ao send from
their own terminal stay unprefixed.
Net: 21 files changed, +174 / −249. Zero plugin code touched. No
cross-platform shell footgun.
Files:
- types.ts: drop orchestratorSessionId field
- session-manager.ts: drop spawn/restore field injection; pass
orchestratorSessionId into buildPrompt instead, gated on metadata
existence on disk
- 6 agent plugins: drop AO_ORCHESTRATOR_SESSION_ID env injection + tests
- prompt-builder.ts: add orchestratorSessionId to PromptBuildConfig;
conditionally emit "Talking to the Orchestrator" section with literal
ID; remove the section from the static base prompts
- send.ts: auto-prefix when AO_SESSION_ID is set
- send.test.ts: 3 new tests for prefix-set / prefix-unset / SessionManager
delivery; existing tests preserved by clearing AO_SESSION_ID in beforeEach
- changeset: scope drops to ao-core + ao-cli only
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
71326bc87e
commit
fe33bb7330
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@aoagents/ao-core": minor
|
||||
"@aoagents/ao-cli": minor
|
||||
---
|
||||
|
||||
Worker sessions now learn how to message the orchestrator that spawned them. When a project has an orchestrator running, the worker's system prompt gains a "Talking to the Orchestrator" section with the literal `ao send <prefix>-orchestrator "<message>"` command (rendered at prompt-build time, no env var, no shell-syntax variants). `ao send` itself now auto-prefixes outgoing messages with `[from $AO_SESSION_ID]` when invoked from inside an AO session, so the receiver always knows who's writing — symmetric across worker→orchestrator, orchestrator→worker, and worker→worker. Humans running `ao send` from a normal terminal stay unprefixed. (#1786)
|
||||
|
|
@ -68,6 +68,7 @@ let program: Command;
|
|||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let savedSessionEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
|
@ -86,6 +87,12 @@ beforeEach(() => {
|
|||
mockSessionManager.send.mockReset();
|
||||
mockConfigRef.current = null;
|
||||
mockExec.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
// Tests assume the caller is a human (no AO session). Tests that need to
|
||||
// simulate session-to-session sends override AO_SESSION_ID explicitly. This
|
||||
// matters because the test process itself often runs inside an AO worker,
|
||||
// which would leak its own AO_SESSION_ID into the auto-prefix logic.
|
||||
savedSessionEnv = process.env["AO_SESSION_ID"];
|
||||
delete process.env["AO_SESSION_ID"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -93,6 +100,8 @@ afterEach(() => {
|
|||
consoleSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
if (savedSessionEnv === undefined) delete process.env["AO_SESSION_ID"];
|
||||
else process.env["AO_SESSION_ID"] = savedSessionEnv;
|
||||
});
|
||||
|
||||
describe("send command", () => {
|
||||
|
|
@ -275,6 +284,103 @@ describe("send command", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("auto-prefix from AO_SESSION_ID", () => {
|
||||
it("prefixes the message with [from <session>] when AO_SESSION_ID is set", async () => {
|
||||
process.env["AO_SESSION_ID"] = "app-7";
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "has-session") return "";
|
||||
if (args[0] === "capture-pane") return "❯ ";
|
||||
return "";
|
||||
});
|
||||
mockDetectActivity.mockReturnValueOnce("idle").mockReturnValueOnce("active");
|
||||
|
||||
await program.parseAsync(["node", "test", "send", "app-orchestrator", "hi", "boss"]);
|
||||
|
||||
expect(mockExec).toHaveBeenCalledWith("tmux", [
|
||||
"send-keys",
|
||||
"-t",
|
||||
"app-orchestrator",
|
||||
"-l",
|
||||
"[from app-7] hi boss",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not prefix when AO_SESSION_ID is unset (human caller)", async () => {
|
||||
// beforeEach already deletes AO_SESSION_ID — exercise that path.
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "has-session") return "";
|
||||
if (args[0] === "capture-pane") return "❯ ";
|
||||
return "";
|
||||
});
|
||||
mockDetectActivity.mockReturnValueOnce("idle").mockReturnValueOnce("active");
|
||||
|
||||
await program.parseAsync(["node", "test", "send", "app-1", "hi", "there"]);
|
||||
|
||||
expect(mockExec).toHaveBeenCalledWith("tmux", [
|
||||
"send-keys",
|
||||
"-t",
|
||||
"app-1",
|
||||
"-l",
|
||||
"hi there",
|
||||
]);
|
||||
});
|
||||
|
||||
it("auto-prefixes when delivering through SessionManager.send too", async () => {
|
||||
process.env["AO_SESSION_ID"] = "app-orchestrator";
|
||||
mockConfigRef.current = {
|
||||
configPath: "/tmp/agent-orchestrator.yaml",
|
||||
defaults: {
|
||||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: [],
|
||||
},
|
||||
projects: {
|
||||
"my-app": {
|
||||
name: "My App",
|
||||
sessionPrefix: "app",
|
||||
path: "/tmp/my-app",
|
||||
defaultBranch: "main",
|
||||
repo: "org/my-app",
|
||||
agent: "claude-code",
|
||||
runtime: "tmux",
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {},
|
||||
reactions: {},
|
||||
};
|
||||
mockSessionManager.get.mockResolvedValue({
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "idle",
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: "tmux-target-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: { agent: "opencode" },
|
||||
});
|
||||
mockSessionManager.send.mockResolvedValue(undefined);
|
||||
mockTmux.mockImplementation(async (...args: string[]) => {
|
||||
if (args[0] === "capture-pane") return "❯ ";
|
||||
return "";
|
||||
});
|
||||
mockDetectActivity.mockReturnValue("idle");
|
||||
|
||||
await program.parseAsync(["node", "test", "send", "app-1", "fix", "the", "build"]);
|
||||
|
||||
expect(mockSessionManager.send).toHaveBeenCalledWith(
|
||||
"app-1",
|
||||
"[from app-orchestrator] fix the build",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session manager integration", () => {
|
||||
function makeConfig(): Record<string, unknown> {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -135,7 +135,17 @@ export function registerSend(program: Command): void {
|
|||
sessionManager,
|
||||
} = await resolveSessionContext(session);
|
||||
|
||||
const message = await readMessageInput(opts, messageParts);
|
||||
const rawMessage = await readMessageInput(opts, messageParts);
|
||||
// Auto-prefix with the sender's session ID when ao send is invoked
|
||||
// from inside an AO session (worker → orchestrator, orchestrator →
|
||||
// worker, worker → worker). The receiver gets the message as raw
|
||||
// terminal input with no `from:` metadata, so the prefix is the only
|
||||
// way to identify who's writing. Humans running ao send from their
|
||||
// own terminal have no AO_SESSION_ID and stay unprefixed.
|
||||
const senderSessionId = process.env["AO_SESSION_ID"];
|
||||
const message = senderSessionId
|
||||
? `[from ${senderSessionId}] ${rawMessage}`
|
||||
: rawMessage;
|
||||
|
||||
const parsedTimeout = parseInt(opts.timeout || "600", 10);
|
||||
const timeoutMs = (isNaN(parsedTimeout) || parsedTimeout <= 0 ? 600 : parsedTimeout) * 1000;
|
||||
|
|
|
|||
|
|
@ -72,6 +72,36 @@ describe("buildPrompt split output", () => {
|
|||
|
||||
expect(taskPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders the orchestrator back-channel with the literal orchestrator session ID", () => {
|
||||
const { systemPrompt } = buildPrompt({
|
||||
project,
|
||||
projectId: "test-app",
|
||||
orchestratorSessionId: "test-orchestrator",
|
||||
});
|
||||
expect(systemPrompt).toContain("## Talking to the Orchestrator");
|
||||
expect(systemPrompt).toContain('ao send test-orchestrator "<your message>"');
|
||||
// No env vars or shell-syntax variants — literal ID only.
|
||||
expect(systemPrompt).not.toContain("AO_ORCHESTRATOR_SESSION_ID");
|
||||
expect(systemPrompt).not.toContain("$env:");
|
||||
expect(systemPrompt).not.toContain("%AO");
|
||||
});
|
||||
|
||||
it("renders the same back-channel in the no-repo prompt when orchestrator exists", () => {
|
||||
const { systemPrompt } = buildPrompt({
|
||||
project: { ...project, repo: undefined },
|
||||
projectId: "test-app",
|
||||
orchestratorSessionId: "test-orchestrator",
|
||||
});
|
||||
expect(systemPrompt).toContain(BASE_AGENT_PROMPT_NO_REPO);
|
||||
expect(systemPrompt).toContain('ao send test-orchestrator "<your message>"');
|
||||
});
|
||||
|
||||
it("omits the orchestrator section when no orchestratorSessionId is provided", () => {
|
||||
const { systemPrompt } = buildPrompt({ project, projectId: "test-app" });
|
||||
expect(systemPrompt).not.toContain("## Talking to the Orchestrator");
|
||||
expect(systemPrompt).not.toContain("ao send");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPrompt", () => {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { ProjectConfig } from "./types.js";
|
||||
import type { ProjectConfig, SessionId } from "./types.js";
|
||||
|
||||
// =============================================================================
|
||||
// LAYER 1: BASE AGENT PROMPT
|
||||
|
|
@ -94,6 +94,14 @@ export interface PromptBuildConfig {
|
|||
|
||||
/** Explicit user prompt (appended last) */
|
||||
userPrompt?: string;
|
||||
|
||||
/**
|
||||
* Session ID of the orchestrator the worker can message back via `ao send`.
|
||||
* When provided, the prompt gains a "Talking to the Orchestrator" section
|
||||
* with the literal command. Caller should pass this only when an
|
||||
* orchestrator session actually exists for the project.
|
||||
*/
|
||||
orchestratorSessionId?: SessionId;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -190,6 +198,23 @@ export function buildPrompt(
|
|||
// Use trimmed prompt when no repo is configured (PR/CI instructions don't apply).
|
||||
systemSections.push(config.project.repo ? BASE_AGENT_PROMPT : BASE_AGENT_PROMPT_NO_REPO);
|
||||
|
||||
// Layer 1b: Orchestrator back-channel — only rendered when caller passes an
|
||||
// orchestratorSessionId (i.e., an orchestrator is actually running for this
|
||||
// project). `ao send` auto-prefixes `[from <sender-session-id>]`, so the
|
||||
// example here is just the bare command.
|
||||
if (config.orchestratorSessionId) {
|
||||
systemSections.push(
|
||||
[
|
||||
"## Talking to the Orchestrator",
|
||||
`You can message the orchestrator session that spawned you with:`,
|
||||
``,
|
||||
`\`ao send ${config.orchestratorSessionId} "<your message>"\``,
|
||||
``,
|
||||
`Only do this when you genuinely cannot proceed alone — cross-session coordination, a decision only the human-facing orchestrator can make, or a blocker outside your repo's scope. Do NOT ping for things you can resolve yourself (research, retries, normal CI/review fixes go through \`ao report\` and the existing flow). \`ao send\` automatically tags the message with your session ID, so the orchestrator always knows who's writing.`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
// Layer 2: Worker sessions are scoped to a single issue, so issue/task
|
||||
// context belongs in the system prompt with the rest of the session context.
|
||||
systemSections.push(buildConfigLayer(config));
|
||||
|
|
|
|||
|
|
@ -1248,12 +1248,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
}
|
||||
}
|
||||
|
||||
// If an orchestrator session exists on disk for this project, give the
|
||||
// worker the literal command to message it. Existence-on-disk is the
|
||||
// signal: if metadata was ever written for the canonical orchestrator
|
||||
// ID, the orchestrator workflow is in play here.
|
||||
const orchestratorSessionId = `${project.sessionPrefix}-orchestrator`;
|
||||
const orchestratorExists = readMetadataRaw(sessionsDir, orchestratorSessionId) !== null;
|
||||
|
||||
const { systemPrompt, taskPrompt } = buildPrompt({
|
||||
project,
|
||||
projectId: spawnConfig.projectId,
|
||||
issueId: spawnConfig.issueId,
|
||||
issueContext,
|
||||
userPrompt: spawnConfig.prompt,
|
||||
...(orchestratorExists && { orchestratorSessionId }),
|
||||
});
|
||||
|
||||
const baseDir = getProjectDir(spawnConfig.projectId);
|
||||
|
|
|
|||
Loading…
Reference in New Issue