Skip to content
Merged
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
52 changes: 52 additions & 0 deletions docs/fleet-instructions-injection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Fleet Instructions Injection — per-backend behaviour

How AgEnD delivers fleet context (identity, role, workflow, decisions, custom prompt) into each CLI backend's prompt, and how that interacts with the MCP `instructions` capability.

Background: see issue [#55](https://github.com/suzuke/AgEnD/issues/55) — before this fix, every non-Claude backend received the same fleet context twice (once via a workspace project doc, once via the MCP `initialize` response's `instructions` field), wasting tokens and diluting attention.

## The MCP `instructions` capability

The MCP spec lets a server return an `instructions` string in its `InitializeResult`. The client may surface that string to the model as system-level guidance. Whether the client actually does so — and how it merges with the user's prompt — is **per-CLI implementation**, not guaranteed by the spec. So we cannot rely on it as a sole delivery channel without per-backend evidence.

## The `nativeInstructionsMechanism` flag

Each backend declares one of three values in `src/backend/<name>.ts`:

| Value | Meaning |
|----------------|------------------------------------------------------------------------------------------|
| `append-flag` | CLI flag or config field points at a file outside the workspace. No workspace artefact. |
| `project-doc` | Workspace markdown auto-loaded by the CLI. |
| `none` | No native injection — daemon falls back to MCP `instructions` capability. |

The daemon (`src/daemon.ts:buildBackendConfig`) reads this flag:

- **`append-flag` / `project-doc`** → set `AGEND_DISABLE_MCP_INSTRUCTIONS=1` in the MCP server's env, drop the fleet-context env vars (`AGEND_DISPLAY_NAME`, `AGEND_DESCRIPTION`, `AGEND_WORKFLOW`, `AGEND_CUSTOM_PROMPT`, `AGEND_DECISIONS`). The MCP server (`src/channel/mcp-server.ts`) then omits its `instructions` capability entirely.
- **`none`** → keep all fleet-context env vars. The MCP server emits `instructions: buildMcpInstructions()` so the model still receives fleet context.

Either way the backend's `writeConfig()` always receives the assembled `instructions` string in its `CliBackendConfig`; whether and how to write it is the backend's choice.

## Per-backend behaviour

| Backend | Mechanism | Where the fleet context lands | MCP `instructions` capability |
|---------------|----------------|---------------------------------------------------------------------|-------------------------------|
| `claude-code` | `append-flag` | `<instance_dir>/fleet-instructions.md`, loaded via `--append-system-prompt-file`. Claude re-reads the file on `--resume`. | omitted |
| `opencode` | `append-flag` | `<instance_dir>/fleet-instructions.md`, listed in `opencode.json:instructions`. | omitted |
| `gemini-cli` | `project-doc` | `<workingDirectory>/GEMINI.md`, marker block keyed by instance name. | omitted |
| `codex` | `project-doc` | `<workingDirectory>/AGENTS.md`, marker block keyed by instance name. Codex enforces a 32 KiB limit on this file — the backend warns if exceeded. | omitted |
| `kiro-cli` | `project-doc` | `<workingDirectory>/.kiro/steering/agend-<instance>.md`. | omitted |
| `mock` | `none` | Not written anywhere. | active (fallback) |

## Verifying surface behaviour for a new backend

When adding a backend that has neither a flag nor a project-doc convention, `nativeInstructionsMechanism: "none"` is the safe default — the model still sees fleet context via MCP `instructions`, **but** that requires the CLI to surface the capability into its model prompt. To verify:

1. Start the instance with `customPrompt: "Reply with exactly the string FLEET_OK if you see fleet context in your system prompt."`.
2. Send any user message.
3. If the agent replies `FLEET_OK`, the CLI surfaces MCP `instructions`. Promote it to `none`.
4. If it does not, the CLI ignores the field and you must add a native injection mechanism (write a project doc or use a `--append-system-prompt`-style flag) and tag the backend `project-doc` / `append-flag`.

If a CLI exposes a CLI flag that points at a file (the cleanest option), prefer `append-flag` — workspace markdown leaves an artefact users may find surprising.

## Resume behaviour

Some backends do not re-read their instructions source on session resume; the `instructionsReloadedOnResume` flag captures this. The daemon's `trySpawn()` watches `prev-instructions` and forces a fresh session when the instructions text changes. This is independent of `nativeInstructionsMechanism`: changing only the delivery channel (e.g. via this fix) does not change the instructions text, so existing sessions are not invalidated by the upgrade itself.
1 change: 1 addition & 0 deletions src/backend/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { type CliBackend, type CliBackendConfig, type ErrorPattern, type Runtime
export class ClaudeCodeBackend implements CliBackend {
readonly binaryName = "claude";
readonly instructionsReloadedOnResume = true;
readonly nativeInstructionsMechanism = "append-flag" as const;
private binaryPath: string;

constructor(private instanceDir: string) {
Expand Down
1 change: 1 addition & 0 deletions src/backend/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const CODEX_PROJECT_DOC_MAX_BYTES = 32_768;

export class CodexBackend implements CliBackend {
readonly binaryName = "codex";
readonly nativeInstructionsMechanism = "project-doc" as const;
private binaryPath: string;

constructor(private instanceDir: string) {
Expand Down
1 change: 1 addition & 0 deletions src/backend/gemini-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { appendWithMarker, removeMarker } from "./marker-utils.js";

export class GeminiCliBackend implements CliBackend {
readonly binaryName = "gemini";
readonly nativeInstructionsMechanism = "project-doc" as const;
private binaryPath: string;

constructor(private instanceDir: string) {
Expand Down
1 change: 1 addition & 0 deletions src/backend/kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type CliBackend, type CliBackendConfig, type ErrorPattern, type Startup

export class KiroBackend implements CliBackend {
readonly binaryName = "kiro-cli";
readonly nativeInstructionsMechanism = "project-doc" as const;
private binaryPath: string;

constructor(private instanceDir: string) {
Expand Down
1 change: 1 addition & 0 deletions src/backend/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const __dirname = dirname(__filename);
*/
export class MockBackend implements CliBackend {
readonly binaryName = "node";
readonly nativeInstructionsMechanism = "none" as const;

constructor(private instanceDir: string) {}

Expand Down
1 change: 1 addition & 0 deletions src/backend/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type CliBackend, type CliBackendConfig, type ErrorPattern, type Startup

export class OpenCodeBackend implements CliBackend {
readonly binaryName = "opencode";
readonly nativeInstructionsMechanism = "append-flag" as const;
private binaryPath: string;

constructor(private instanceDir: string) {
Expand Down
16 changes: 16 additions & 0 deletions src/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ export interface CliBackend {
/** Whether this backend re-reads instruction files on --resume (e.g. Claude Code's --append-system-prompt-file). */
readonly instructionsReloadedOnResume?: boolean;

/**
* How this backend natively injects fleet instructions into the CLI's prompt:
* - `'append-flag'`: dedicated CLI flag / config field pointing at a file outside
* the workspace (e.g. Claude `--append-system-prompt-file`, OpenCode
* `instructions: [path]`). Cleanest, no workspace-visible artefact.
* - `'project-doc'`: workspace markdown the CLI auto-loads (e.g. Gemini
* `GEMINI.md`, Codex `AGENTS.md`, Kiro `.kiro/steering/`).
* - `'none'`: no native mechanism — daemon must rely on the MCP `instructions`
* capability to deliver fleet context.
*
* Daemon uses this to gate the MCP `instructions` capability: backends that
* already inject natively must NOT also receive the same content via MCP, or
* the model sees it twice (Bug #55).
*/
readonly nativeInstructionsMechanism: "append-flag" | "project-doc" | "none";

/** Pre-approve a working directory to skip trust dialogs on startup. */
preTrust?(workingDirectory: string): void;

Expand Down
17 changes: 13 additions & 4 deletions src/channel/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,16 @@ function ipcRequest(

// ---------------------------------------------------------------------------
// MCP instructions — thin wrapper around shared buildFleetInstructions().
// Phase 1 (dual track): MCP instructions kept as fallback for backends that
// read the MCP instructions field. Content delegates to the shared module
// to avoid drift between MCP and additive system prompt paths.
//
// The daemon owns the policy: when the backend has a native injection
// mechanism (CLI flag, project doc, etc.) the same content is delivered into
// the model's prompt that way, so emitting it here as the MCP `instructions`
// capability would duplicate it (Bug #55). The daemon signals that case by
// setting `AGEND_DISABLE_MCP_INSTRUCTIONS=1` and omitting the fleet-context
// env vars; we honour that by skipping the capability altogether. Backends
// that opt into MCP instructions (e.g. future backends with no native
// mechanism, or operators running this server standalone) leave that env var
// unset and get the previous behaviour.
// ---------------------------------------------------------------------------

function buildMcpInstructions(): string {
Expand All @@ -209,13 +216,15 @@ function buildMcpInstructions(): string {
});
}

const mcpInstructionsDisabled = process.env.AGEND_DISABLE_MCP_INSTRUCTIONS === "1";

const mcp = new Server(
{ name: "agend", version: "0.3.0" },
{
capabilities: {
tools: {},
},
instructions: buildMcpInstructions(),
...(mcpInstructionsDisabled ? {} : { instructions: buildMcpInstructions() }),
},
);

Expand Down
31 changes: 24 additions & 7 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1007,19 +1007,36 @@ export class Daemon extends EventEmitter {
}
}

// ── MCP server env (dual-track: still passes env vars for MCP instructions fallback) ──
// ── MCP server env ──
// Fleet-context env vars (display_name, description, workflow, custom
// prompt, decisions) feed mcp-server.ts:buildMcpInstructions(), which is
// exposed via the MCP `instructions` capability. Backends with their own
// native injection (Claude `--append-system-prompt-file`, Gemini
// `GEMINI.md`, Codex `AGENTS.md`, Kiro `.kiro/steering/`, OpenCode
// `opencode.json` instructions array) already deliver the same content
// into the model's context, so passing it through MCP would inject the
// identical text twice (Bug #55). Gate on the backend's
// `nativeInstructionsMechanism`: when the backend injects natively, we
// drop the env vars AND set AGEND_DISABLE_MCP_INSTRUCTIONS=1 so the MCP
// server omits its `instructions` capability entirely.
const nativeMech = this.backend?.nativeInstructionsMechanism ?? "none";
const mcpInstructionsActive = nativeMech === "none";
const mcpEnv: Record<string, string> = {
AGEND_SOCKET_PATH: sockPath,
AGEND_INSTANCE_NAME: this.name,
AGEND_WORKING_DIR: this.config.working_directory,
};
if (this.config.tool_set) mcpEnv.AGEND_TOOL_SET = this.config.tool_set;
if (this.config.display_name) mcpEnv.AGEND_DISPLAY_NAME = this.config.display_name;
if (this.config.description) mcpEnv.AGEND_DESCRIPTION = this.config.description;
if (resolvedWorkflow === false) mcpEnv.AGEND_WORKFLOW = "false";
else if (resolvedWorkflow) mcpEnv.AGEND_WORKFLOW = resolvedWorkflow;
if (resolvedCustomPrompt) mcpEnv.AGEND_CUSTOM_PROMPT = resolvedCustomPrompt;
if (process.env.AGEND_DECISIONS) mcpEnv.AGEND_DECISIONS = process.env.AGEND_DECISIONS;
if (mcpInstructionsActive) {
if (this.config.display_name) mcpEnv.AGEND_DISPLAY_NAME = this.config.display_name;
if (this.config.description) mcpEnv.AGEND_DESCRIPTION = this.config.description;
if (resolvedWorkflow === false) mcpEnv.AGEND_WORKFLOW = "false";
else if (resolvedWorkflow) mcpEnv.AGEND_WORKFLOW = resolvedWorkflow;
if (resolvedCustomPrompt) mcpEnv.AGEND_CUSTOM_PROMPT = resolvedCustomPrompt;
if (process.env.AGEND_DECISIONS) mcpEnv.AGEND_DECISIONS = process.env.AGEND_DECISIONS;
} else {
mcpEnv.AGEND_DISABLE_MCP_INSTRUCTIONS = "1";
}

// ── Fleet instructions for additive system prompt injection ──
let instructions: string;
Expand Down
42 changes: 42 additions & 0 deletions tests/backend/native-instructions-mechanism.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { ClaudeCodeBackend } from "../../src/backend/claude-code.js";
import { GeminiCliBackend } from "../../src/backend/gemini-cli.js";
import { CodexBackend } from "../../src/backend/codex.js";
import { KiroBackend } from "../../src/backend/kiro.js";
import { OpenCodeBackend } from "../../src/backend/opencode.js";
import { MockBackend } from "../../src/backend/mock.js";

// Bug #55: every backend must declare how it natively delivers fleet
// instructions. Daemon uses this to decide whether to also expose the MCP
// `instructions` capability — otherwise the same content is injected twice.
describe("CliBackend.nativeInstructionsMechanism (Bug #55)", () => {
it("Claude uses --append-system-prompt-file (append-flag)", () => {
const b = new ClaudeCodeBackend("/tmp/test-instance");
expect(b.nativeInstructionsMechanism).toBe("append-flag");
});

it("OpenCode uses opencode.json instructions array (append-flag)", () => {
const b = new OpenCodeBackend("/tmp/test-instance");
expect(b.nativeInstructionsMechanism).toBe("append-flag");
});

it("Gemini writes GEMINI.md (project-doc)", () => {
const b = new GeminiCliBackend("/tmp/test-instance");
expect(b.nativeInstructionsMechanism).toBe("project-doc");
});

it("Codex writes AGENTS.md (project-doc)", () => {
const b = new CodexBackend("/tmp/test-instance");
expect(b.nativeInstructionsMechanism).toBe("project-doc");
});

it("Kiro writes .kiro/steering/ (project-doc)", () => {
const b = new KiroBackend("/tmp/test-instance");
expect(b.nativeInstructionsMechanism).toBe("project-doc");
});

it("Mock backend has no native injection (none)", () => {
const b = new MockBackend("/tmp/test-instance");
expect(b.nativeInstructionsMechanism).toBe("none");
});
});
112 changes: 112 additions & 0 deletions tests/daemon-build-backend-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Daemon } from "../src/daemon.js";
import type { InstanceConfig } from "../src/types.js";
import { ClaudeCodeBackend } from "../src/backend/claude-code.js";
import { GeminiCliBackend } from "../src/backend/gemini-cli.js";
import { CodexBackend } from "../src/backend/codex.js";
import { KiroBackend } from "../src/backend/kiro.js";
import { OpenCodeBackend } from "../src/backend/opencode.js";
import { MockBackend } from "../src/backend/mock.js";
import type { CliBackend, CliBackendConfig } from "../src/backend/types.js";

// Bug #55 regression: verify daemon.buildBackendConfig only injects fleet
// context env vars (display_name, description, workflow, custom prompt,
// decisions) into the MCP server when the backend has no native injection
// mechanism. Backends with native injection should receive
// AGEND_DISABLE_MCP_INSTRUCTIONS=1 instead, so the MCP server omits its
// `instructions` capability.

const FLEET_CONTEXT_KEYS = [
"AGEND_DISPLAY_NAME",
"AGEND_DESCRIPTION",
"AGEND_WORKFLOW",
"AGEND_CUSTOM_PROMPT",
"AGEND_DECISIONS",
] as const;

function makeConfig(overrides?: Partial<InstanceConfig>): InstanceConfig {
return {
working_directory: "/tmp/test-bb-config",
display_name: "TestAgent",
description: "for testing dual-injection gate",
systemPrompt: "Be terse.",
workflow: "## inline workflow",
restart_policy: { max_retries: 1, backoff: "exponential", reset_after: 60 },
context_guardian: { grace_period_ms: 0, max_age_hours: 1 },
log_level: "warn",
...overrides,
} as InstanceConfig;
}

interface BackendCase {
label: string;
make: (instanceDir: string) => CliBackend;
expectedMech: "append-flag" | "project-doc" | "none";
}

const cases: BackendCase[] = [
{ label: "claude-code", make: (d) => new ClaudeCodeBackend(d), expectedMech: "append-flag" },
{ label: "opencode", make: (d) => new OpenCodeBackend(d), expectedMech: "append-flag" },
{ label: "gemini-cli", make: (d) => new GeminiCliBackend(d), expectedMech: "project-doc" },
{ label: "codex", make: (d) => new CodexBackend(d), expectedMech: "project-doc" },
{ label: "kiro-cli", make: (d) => new KiroBackend(d), expectedMech: "project-doc" },
{ label: "mock", make: (d) => new MockBackend(d), expectedMech: "none" },
];

describe("Daemon.buildBackendConfig — Bug #55 fleet context gate", () => {
let instanceDir: string;
let prevDecisions: string | undefined;

beforeEach(() => {
instanceDir = join(tmpdir(), `ccd-bb-config-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(instanceDir, { recursive: true });
// Simulate fleet-manager.ts:440 setting AGEND_DECISIONS for the daemon
prevDecisions = process.env.AGEND_DECISIONS;
process.env.AGEND_DECISIONS = JSON.stringify([{ title: "scope", content: "test scope" }]);
});

afterEach(() => {
rmSync(instanceDir, { recursive: true, force: true });
if (prevDecisions === undefined) delete process.env.AGEND_DECISIONS;
else process.env.AGEND_DECISIONS = prevDecisions;
});

for (const c of cases) {
it(`${c.label}: native=${c.expectedMech} → MCP env follows the gate`, () => {
const backend = c.make(instanceDir);
const daemon = new Daemon("test-bb", makeConfig(), instanceDir, false, backend);
const cfg: CliBackendConfig = (daemon as unknown as { buildBackendConfig(): CliBackendConfig }).buildBackendConfig();
const env = cfg.mcpServers["agend"]?.env ?? {};

// Identity / operational vars are always present
expect(env.AGEND_INSTANCE_NAME).toBe("test-bb");
expect(env.AGEND_WORKING_DIR).toBe("/tmp/test-bb-config");
expect(env.AGEND_SOCKET_PATH).toBeTruthy();

// Backend always receives the assembled instructions string for native injection
expect(cfg.instructions).toBeTruthy();
expect(cfg.instructions).toContain("test-bb");

if (c.expectedMech === "none") {
// No native injection → MCP instructions capability stays active.
expect(env.AGEND_DISABLE_MCP_INSTRUCTIONS).toBeUndefined();
// All fleet context env vars present so MCP server can rebuild instructions.
expect(env.AGEND_DISPLAY_NAME).toBe("TestAgent");
expect(env.AGEND_DESCRIPTION).toBe("for testing dual-injection gate");
expect(env.AGEND_WORKFLOW).toBeTruthy();
expect(env.AGEND_CUSTOM_PROMPT).toBe("Be terse.");
expect(env.AGEND_DECISIONS).toBeTruthy();
} else {
// Native injection present → MCP instructions capability disabled, fleet
// context env vars dropped to avoid duplicate injection.
expect(env.AGEND_DISABLE_MCP_INSTRUCTIONS).toBe("1");
for (const key of FLEET_CONTEXT_KEYS) {
expect(env[key], `${c.label}: ${key} must NOT be passed when backend injects natively`).toBeUndefined();
}
}
});
}
});
Loading