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
5 changes: 5 additions & 0 deletions .changeset/strict-agent-profile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tangle-network/agent-interface": minor
---

Reject unknown fields at every defined AgentProfile object boundary, make profile and candidate MCP definitions exact local, remote, or disabled configurations, align reasoning capabilities with the native CLI controls, and bind exact system-prompt replacement to candidate profile-plan identity.
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,27 @@ describe("agentCandidateExecutionPlanMaterialSchema", () => {
).toThrow(/lexicographically sorted/);
});

it("carries an exact typed system-prompt replacement in profile-plan identity", () => {
const material = {
sourceProfileDigest: candidateSha("0"),
harness: "claude-code",
files: [],
env: {},
flags: [],
systemPrompt: { kind: "public", value: "Use the repository rules." },
unsupported: [],
};
expect(agentCandidateProfilePlanMaterialSchema.parse(material)).toEqual(
material,
);
expect(() =>
agentCandidateProfilePlanMaterialSchema.parse({
...material,
systemPrompt: "Use the repository rules.",
}),
).toThrow();
});

it("requires a workspace root for candidate-targeted profile files", () => {
const plan = planFixture();
expect(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export const agentCandidateProfilePlanMaterialSchema = z
),
env: environmentConfigSchema,
flags: z.array(agentCandidateConfigValueSchema),
systemPrompt: agentCandidateConfigValueSchema.optional(),
unsupported: z
.array(
z
Expand Down
69 changes: 29 additions & 40 deletions packages/agent-interface/src/agent-candidate-profile-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,46 +27,35 @@ const executableSchema = z
.string()
.refine(isSafeExecutable, "executable must be a canonical non-shell command");

export const agentCandidateMcpServerSchema = z
.object({
transport: z.literal("stdio").optional(),
command: executableSchema.optional(),
args: z.array(agentCandidateConfigValueSchema).optional(),
env: environmentConfigSchema.optional(),
cwd: z
.string()
.refine(
(value) => isSafeRelativePath(value, true),
"MCP cwd must be a canonical workspace-relative path",
)
.optional(),
enabled: z.boolean().optional(),
})
.strict()
.superRefine((server, ctx) => {
if (server.enabled === false) {
if (
server.transport !== undefined ||
server.command !== undefined ||
server.args !== undefined ||
server.env !== undefined ||
server.cwd !== undefined
) {
ctx.addIssue({
code: "custom",
message: "disabled MCP servers cannot carry process fields",
});
}
return;
}
if (server.command === undefined) {
ctx.addIssue({
code: "custom",
path: ["command"],
message: "enabled stdio MCP servers require a command",
});
}
}) satisfies z.ZodType<AgentCandidateMcpServer>;
const agentCandidateLocalMcpServerSchema = z.strictObject({
transport: z.literal("stdio").optional(),
command: executableSchema,
args: z.array(agentCandidateConfigValueSchema).optional(),
env: environmentConfigSchema.optional(),
cwd: z
.string()
.refine(
(value) => isSafeRelativePath(value, true),
"MCP cwd must be a canonical workspace-relative path",
)
.optional(),
enabled: z.literal(true).optional(),
});

const agentCandidateDisabledMcpServerSchema = z.strictObject({
enabled: z.literal(false),
transport: z.undefined().optional(),
command: z.undefined().optional(),
args: z.undefined().optional(),
env: z.undefined().optional(),
cwd: z.undefined().optional(),
});

export const agentCandidateMcpServerSchema: z.ZodType<AgentCandidateMcpServer> =
z.union([
agentCandidateLocalMcpServerSchema,
agentCandidateDisabledMcpServerSchema,
]);

export const agentCandidateHookCommandSchema = z
.object({
Expand Down
25 changes: 19 additions & 6 deletions packages/agent-interface/src/agent-candidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type {
AgentProfile,
AgentProfileFileMount,
AgentProfileHookCommand,
AgentProfileMcpServer,
AgentProfileMode,
AgentProfileModelHints,
AgentProfileResourceRef,
Expand Down Expand Up @@ -128,16 +127,28 @@ export interface AgentCandidateResources
failOnError: true;
}

export interface AgentCandidateMcpServer
extends Omit<
AgentProfileMcpServer,
"transport" | "args" | "env" | "headers" | "url" | "metadata"
> {
interface AgentCandidateLocalMcpServer {
transport?: "stdio";
command: string;
args?: AgentCandidateConfigValue[];
env?: Record<string, AgentCandidateConfigValue>;
cwd?: string;
enabled?: true;
}

interface AgentCandidateDisabledMcpServer {
enabled: false;
transport?: never;
command?: never;
args?: never;
env?: never;
cwd?: never;
}

export type AgentCandidateMcpServer =
| AgentCandidateLocalMcpServer
| AgentCandidateDisabledMcpServer;

export type AgentCandidateModelHints = Omit<AgentProfileModelHints, "metadata">;
export type AgentCandidateSubagentProfile = Omit<
AgentSubagentProfile,
Expand Down Expand Up @@ -398,6 +409,8 @@ export interface AgentCandidateProfilePlanMaterial {
}>;
env: Record<string, AgentCandidateConfigValue>;
flags: AgentCandidateConfigValue[];
/** Exact system-prompt replacement supplied to the harness, when supported. */
systemPrompt?: AgentCandidateConfigValue;
unsupported: Array<{ dimension: string; reason: string }>;
}

Expand Down
48 changes: 39 additions & 9 deletions packages/agent-interface/src/agent-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,9 @@ export interface AgentProfileResources {
* - `none` — extended thinking OFF (no reasoning budget at all)
* - `minimal` — thinking ON, the lowest budget (distinct from `none`)
* - `low` / `medium` / `high` / `xhigh`
* - `ultracode` — maximum (claude-code's "ultracode" run mode; codex's `max` reconciles here).
* A backend without a matching native tier clamps to its nearest (e.g. codex maps `ultracode` → `xhigh`
* on models that support it).
* - `ultracode` — maximum (Claude Code's `max` and Codex's `ultra` reconcile here).
* A backend without a matching native tier may clamp down to its strongest supported level, but it
* must never turn reasoning on for `none` or silently increase a requested effort.
*/
export type ReasoningEffort =
| "none"
Expand Down Expand Up @@ -230,18 +230,48 @@ export interface AgentProfileConfidential {
/**
* Generic MCP server configuration.
*/
export interface AgentProfileMcpServer {
transport?: "stdio" | "sse" | "http";
command?: string;
interface AgentProfileMcpServerBase {
metadata?: Record<string, unknown>;
}

interface AgentProfileLocalMcpServer extends AgentProfileMcpServerBase {
enabled?: true;
transport?: "stdio";
command: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
url?: string;
url?: never;
headers?: never;
}

interface AgentProfileRemoteMcpServer extends AgentProfileMcpServerBase {
enabled?: true;
transport?: "sse" | "http";
command?: never;
args?: never;
env?: never;
cwd?: never;
url: string;
headers?: Record<string, string>;
enabled?: boolean;
metadata?: Record<string, unknown>;
}

interface AgentProfileDisabledMcpServer extends AgentProfileMcpServerBase {
enabled: false;
transport?: never;
command?: never;
args?: never;
env?: never;
cwd?: never;
url?: never;
headers?: never;
}

export type AgentProfileMcpServer =
| AgentProfileLocalMcpServer
| AgentProfileRemoteMcpServer
| AgentProfileDisabledMcpServer;

/**
* Hub-managed integration grant. The sandbox runtime resolves each declared
* connection/capability pair into an MCP tool backed by Tangle Hub policy.
Expand Down
35 changes: 29 additions & 6 deletions packages/agent-interface/src/harness-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,32 @@ describe("reasoning effort support", () => {
it("offers each harness its real adapter set, not the generic ladder", () => {
// no-thinking runners
expect(harnessReasoningEfforts("cli-base")).toEqual(["none"]);
// clamp-based: `none` dropped (inert ≡ auto); capped at the adapter's real ceiling
expect(harnessReasoningEfforts("codex")).toEqual(["low", "medium", "high", "xhigh"]);
expect(harnessReasoningEfforts("pi")).toEqual(["minimal", "low", "medium", "high", "xhigh"]);
expect(harnessReasoningEfforts("openclaw")).toEqual(["minimal", "low", "medium", "high", "xhigh"]);
// Explicit sets match each native CLI; model data narrows them later.
expect(harnessReasoningEfforts("codex")).toEqual([
"minimal",
"low",
"medium",
"high",
"xhigh",
"ultracode",
]);
expect(harnessReasoningEfforts("pi")).toEqual([
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
]);
expect(harnessReasoningEfforts("openclaw")).toEqual([
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"ultracode",
]);
// claude: real `--effort` ladder low…max (ultracode stands in for max); no none/minimal
expect(harnessReasoningEfforts("claude-code")).toEqual([
"low",
Expand All @@ -127,8 +149,8 @@ describe("reasoning effort support", () => {
"xhigh",
"ultracode",
]);
// kimi: binary toggle (minimal = off, high = on)
expect(harnessReasoningEfforts("kimi-code")).toEqual(["minimal", "high"]);
// kimi: binary toggle (none = off, high = on)
expect(harnessReasoningEfforts("kimi-code")).toEqual(["none", "high"]);
// pass-through / router-driven: full ladder (narrowed later by the model)
expect(harnessReasoningEfforts("opencode")).toContain("ultracode");
});
Expand All @@ -145,6 +167,7 @@ describe("reasoning effort support", () => {
"medium",
]);
expect(reasoningEffortsFor("codex", { maxEffort: "high" })).toEqual([
"minimal",
"low",
"medium",
"high",
Expand Down
37 changes: 23 additions & 14 deletions packages/agent-interface/src/harness-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { canonicalizeHarness, type HarnessType } from "./harness.js";
* cli-bridge backends, the sandbox UI pickers, and the router all read one truth instead of each
* hand-rolling a divergent copy.
*
* Grounded in cli-bridge's real backend clamps (codex is `low..xhigh` for current models, kimi is
* binary on/off, claude carries the full range, cli-base has no agent) — NOT a guessed matrix. The
* Grounded in the native CLI controls (Codex is `minimal..ultra`, Kimi is binary on/off, Claude is
* `low..max`, cli-base has no agent) — NOT a guessed matrix. The
* per-MODEL reasoning capability (does this specific model reason at all) is dynamic catalog data the
* caller supplies.
*/
Expand Down Expand Up @@ -150,23 +150,32 @@ export function snapHarnessToModel(
/**
* The explicit reasoning-effort set a harness's runtime accepts when it ISN'T a plain `none…ceiling`
* slice — grounded in the cli-bridge adapters (NOT the canonical ladder):
* - codex: current models advertise `low|medium|high|xhigh`; `none` is omitted (use `auto`) and
* legacy `minimal` requests clamp up to `low`.
* - claude-code: `--effort` accepts `low|medium|high|xhigh|max`. `ultracode` is the ladder's stand-in
* for claude's `max` (clamped at runtime); `minimal`→`low` and `none`/`auto`→no flag, so both are
* dropped as redundant.
* - pi / openclaw: `--thinking` accepts `minimal…xhigh` (max/ultracode clamp to `xhigh`; no `none`).
* - kimi-code: `--thinking` is BINARY (off/on) — `minimal` is the only value that emits
* `--no-thinking`, `high` is "thinking on". So two levels, not five.
* - codex: `model_reasoning_effort` accepts `minimal|low|medium|high|xhigh|max|ultra`; canonical
* `ultracode` maps to native `ultra`. Per-model catalog data narrows this list.
* - claude-code: `--effort` accepts `low|medium|high|xhigh|max`; canonical `ultracode` maps to
* native `max`. It cannot express `none` or `minimal`.
* - pi: `--thinking` accepts `off|minimal|low|medium|high|xhigh`; canonical `none` maps to `off`.
* - openclaw: `--thinking` accepts `off|minimal|low|medium|high|xhigh|max`; canonical `none` maps
* to `off` and `ultracode` maps to `max`.
* - kimi-code: `--thinking` is binary. Canonical `none` emits `--no-thinking`; any non-none level
* emits `--thinking`, represented here by `high`.
*/
const harnessReasoningEffortsOverride: Partial<
Record<HarnessType, readonly ReasoningEffort[]>
> = {
codex: ["low", "medium", "high", "xhigh"],
codex: ["minimal", "low", "medium", "high", "xhigh", "ultracode"],
"claude-code": ["low", "medium", "high", "xhigh", "ultracode"],
pi: ["minimal", "low", "medium", "high", "xhigh"],
openclaw: ["minimal", "low", "medium", "high", "xhigh"],
"kimi-code": ["minimal", "high"],
pi: ["none", "minimal", "low", "medium", "high", "xhigh"],
openclaw: [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"ultracode",
],
"kimi-code": ["none", "high"],
};

/**
Expand Down
Loading
Loading