Skip to content
Open
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1797,7 +1797,8 @@ Locked invariants (pinned by [src/composio/resolve-composio-server.test.ts](src/
3. **Failure is soft.** Composio unreachable, rate-limiting, or rejecting a stale key logs a warning and boots without it. A third-party SaaS broker must never stand between the operator and their own shell, files and browser.
4. **`userId` is a minted UUID, persisted, and never an email.** Composio scopes connected accounts to it, so regenerating it silently orphans every app the operator has already authorised. An email would also hand PII to a third party for no benefit.
5. **The workbench stays disabled.** `createComposioSession` always posts `workbench: { enable: false }`, dropping `COMPOSIO_REMOTE_WORKBENCH` / `COMPOSIO_REMOTE_BASH_TOOL`. They duplicate `os.shell.run` and would quietly route the operator's shell work through a third-party sandbox.
6. **Trust stays `approval_gated`.** Discovery is still unprompted, because `mcp-tool-adapter.ts` exempts tools annotated `readOnlyHint === true` and Composio tags `COMPOSIO_SEARCH_TOOLS` / `COMPOSIO_GET_TOOL_SCHEMAS` exactly that way, while tagging `COMPOSIO_MULTI_EXECUTE_TOOL` / `COMPOSIO_MANAGE_CONNECTIONS` destructive. Every write to a real SaaS account therefore hits the approval gate, and the seamlessness costs nothing in consent. Loosening the server's trust to `pure_read` would un-gate the writes too — do not.
6. **The `### integrations` prefix section is derived, not flagged.** [src/prompt/composio-guidance.ts](src/prompt/composio-guidance.ts) keys off `mcp.composio.COMPOSIO_SEARCH_TOOLS` being in the descriptor list, so the guidance cannot drift out of sync with what actually mounted. It renders between `### capabilities` and `### instructions`, leaving persona / rules / skills / the tools catalog byte-identical whether or not Composio is configured, and is absent entirely with no key. The text is **ours**, not Composio's `experimental.assistive_prompt`: piping a remote-controlled string into the system prompt would let a third party re-steer the agent, and any edit on their side would invalidate the KV-cached prefix for every user at once. The list of connected apps is deliberately left out — it changes mid-session, and the stable prefix must not move.
7. **Trust stays `approval_gated`.** Discovery is still unprompted, because `mcp-tool-adapter.ts` exempts tools annotated `readOnlyHint === true` and Composio tags `COMPOSIO_SEARCH_TOOLS` / `COMPOSIO_GET_TOOL_SCHEMAS` exactly that way, while tagging `COMPOSIO_MULTI_EXECUTE_TOOL` / `COMPOSIO_MANAGE_CONNECTIONS` destructive. Every write to a real SaaS account therefore hits the approval gate, and the seamlessness costs nothing in consent. Loosening the server's trust to `pure_read` would un-gate the writes too — do not.

## Project path resolution (`os.fs.locate_project`)

Expand Down
97 changes: 97 additions & 0 deletions src/prompt/composio-guidance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";

import {
COMPOSIO_GUIDANCE,
COMPOSIO_SEARCH_TOOL,
isComposioActive,
} from "./composio-guidance.js";
import { buildStablePrefix, type ToolDescriptor } from "./stable-prefix.js";
import type { CapabilitiesSummary } from "./capabilities.js";

function descriptor(name: string): ToolDescriptor {
return { name, summary: `${name} summary`, argsSchema: "{}" };
}

const CAPS: CapabilitiesSummary = {
platform: "linux",
} as unknown as CapabilitiesSummary;

function prefixWith(descriptors: readonly ToolDescriptor[]): string {
return buildStablePrefix({
toolDescriptors: descriptors,
capabilities: CAPS,
skillCatalog: [],
});
}

describe("isComposioActive", () => {
it("keys off the search tool actually being mounted", () => {
expect(isComposioActive([descriptor(COMPOSIO_SEARCH_TOOL)])).toBe(true);
expect(isComposioActive([descriptor("os.fs.read")])).toBe(false);
expect(isComposioActive([])).toBe(false);
});

it("is not fooled by an unrelated mcp server", () => {
expect(isComposioActive([descriptor("mcp.github.search")])).toBe(false);
});
});

describe("the ### integrations prefix section", () => {
it("is absent when Composio is not mounted", () => {
// An install with no key must pay nothing for the integration --
// not a token, not a byte of KV-cached prefix.
const prefix = prefixWith([descriptor("os.fs.read")]);
expect(prefix).not.toContain("### integrations");
expect(prefix).not.toContain("Composio");
});

it("appears once Composio is mounted", () => {
const prefix = prefixWith([
descriptor("os.fs.read"),
descriptor(COMPOSIO_SEARCH_TOOL),
]);
expect(prefix).toContain("### integrations");
expect(prefix).toContain(COMPOSIO_GUIDANCE);
});

it("sits between capabilities and instructions", () => {
// Placement is load-bearing for the KV cache: everything above it
// -- persona, rules, skills, the whole tools catalog -- stays
// byte-identical whether or not Composio is configured.
const prefix = prefixWith([descriptor(COMPOSIO_SEARCH_TOOL)]);
expect(prefix.indexOf("### capabilities")).toBeLessThan(
prefix.indexOf("### integrations"),
);
expect(prefix.indexOf("### integrations")).toBeLessThan(
prefix.indexOf("### instructions"),
);
});

it("leaves everything above it byte-identical", () => {
const without = prefixWith([descriptor("os.fs.read")]);
const with_ = prefixWith([descriptor("os.fs.read")]);
expect(with_.slice(0, with_.indexOf("### capabilities"))).toBe(
without.slice(0, without.indexOf("### capabilities")),
);
});

it("names the search tool first and the execute tool after", () => {
// The failure mode this guards is the model guessing an app tool
// name instead of discovering it, which Composio rejects.
const search = COMPOSIO_GUIDANCE.indexOf("COMPOSIO_SEARCH_TOOLS");
const exec = COMPOSIO_GUIDANCE.indexOf("COMPOSIO_MULTI_EXECUTE_TOOL");
expect(search).toBeGreaterThanOrEqual(0);
expect(exec).toBeGreaterThan(search);
});

it("tells the model to surface the connect link through reply", () => {
// Tool results are not linkified in chat; a `reply` is. Routing the
// URL through reply is what makes it clickable for the user.
expect(COMPOSIO_GUIDANCE).toContain("`reply`");
expect(COMPOSIO_GUIDANCE).toContain("COMPOSIO_MANAGE_CONNECTIONS");
});

it("stays short enough to live in every turn's prefix", () => {
expect(COMPOSIO_GUIDANCE.length).toBeLessThan(1200);
});
});
53 changes: 53 additions & 0 deletions src/prompt/composio-guidance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* The `### integrations` section of the stable prefix.
*
* Composio's meta-tools are useless if the model never reaches for
* them. Without this section the catalogue reads as four opaque
* `mcp.composio.COMPOSIO_*` entries with no hint that "email this to
* Ivan" is a thing they can do — so the model answers "I can't send
* email" while holding a tool that sends email. This block is the
* difference between the tools existing and the tools being used.
*
* The text is ours, not Composio's. Their session response ships an
* `experimental.assistive_prompt` that would drop in here verbatim,
* but wiring a remote-controlled string straight into the system
* prompt means a third party can silently re-steer the agent, and any
* edit on their side invalidates the KV-cached prefix for every user
* at once. A short local paragraph costs a few dozen tokens and keeps
* both properties.
*
* Deliberately omitted: the list of already-connected apps. It would
* be genuinely useful, but it changes the moment the operator
* authorises something — i.e. mid-session — and the stable prefix is
* the one part of the prompt that must not move. The model can ask
* Composio directly; the cache stays intact.
*/

import type { ToolDescriptor } from "./stable-prefix.js";

/** Discovery tool whose presence means a Composio session is mounted. */
export const COMPOSIO_SEARCH_TOOL = "mcp.composio.COMPOSIO_SEARCH_TOOLS";

/**
* Live iff the Composio search tool is in the catalog.
*
* Derived from the descriptors rather than passed in as a flag: the
* descriptors already reflect exactly what got mounted this boot, so
* the guidance cannot drift out of sync with the tools it describes.
*/
export function isComposioActive(
descriptors: readonly ToolDescriptor[],
): boolean {
return descriptors.some((d) => d.name === COMPOSIO_SEARCH_TOOL);
}

/**
* The section body. Kept to four sentences: it sits in the KV-cached
* prefix of every single turn, so each line has to earn its tokens.
*/
export const COMPOSIO_GUIDANCE = [
"External accounts — Gmail, Slack, Notion, Linear, Jira, GitHub, Discord and ~1500 more apps — are reachable through Composio, which also handles their sign-in.",
"When the user asks for something that lives in one of those apps, call `mcp.composio.COMPOSIO_SEARCH_TOOLS` with the use case (e.g. `{ queries: [{ use_case: \"send an email\" }] }`) before anything else — never guess an app tool's name or arguments.",
"Then run what it found via `mcp.composio.COMPOSIO_MULTI_EXECUTE_TOOL` (use `mcp.composio.COMPOSIO_GET_TOOL_SCHEMAS` first if you need the exact arguments).",
"If the account is not connected yet, `mcp.composio.COMPOSIO_MANAGE_CONNECTIONS` returns a sign-in link: put that URL in a `reply` so the user can click it, wait for them to confirm, then retry. Connections persist, so this happens once per app.",
].join("\n");
9 changes: 9 additions & 0 deletions src/prompt/stable-prefix.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { ToolCallTransport } from "../llm/provider/completion-types.js";
import {
COMPOSIO_GUIDANCE,
isComposioActive,
} from "./composio-guidance.js";
import { formatSkillCatalogLine } from "../skills/skill-catalog.js";

/**
Expand Down Expand Up @@ -183,6 +187,10 @@ export const WINDOWS_PLATFORM_HINT = [
].join("\n");

export function buildStablePrefix(input: StablePrefixInput): string {
// Present only while a Composio session is mounted, so an install
// with no key pays nothing for it and its prefix is byte-identical
// to before the integration existed.
const composioActive = isComposioActive(input.toolDescriptors);
const nativeTools = input.toolTransport === "native_tools";
const persona =
input.systemPersona ??
Expand Down Expand Up @@ -242,6 +250,7 @@ export function buildStablePrefix(input: StablePrefixInput): string {
`### capabilities`,
caps,
``,
...(composioActive ? [`### integrations`, COMPOSIO_GUIDANCE, ``] : []),
`### instructions`,
// The emission instructions are the one transport-dependent block.
// Grammar links parse text-JSON (GBNF-constrained locally), so they
Expand Down