Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
765c249
fix(cli): validate per-model ACP options against the target model
zxch3n Sep 2, 2026
484d31e
feat(cli): warn when the agent's run config differs from the request
zxch3n Sep 3, 2026
14727c4
feat(cli): stop letting a capability snapshot reject a run config
zxch3n Sep 3, 2026
47f3d83
fix(cli): apply permission-bearing config last and report the agent's…
zxch3n Sep 3, 2026
58fbed0
fix(components): keep stored run-config values the capability catalog…
zxch3n Sep 3, 2026
6f9a5cd
docs(cli): record that snapshots report, and permission config applie…
zxch3n Sep 3, 2026
6d92179
fix: bind per-model controls per agent and keep only per-model unknowns
zxch3n Sep 3, 2026
82c2fbb
fix(cli): stop a turn the agent would run with wider permission than …
zxch3n Sep 3, 2026
9f0f61a
fix(components): do not promise a resend path that does not exist yet
zxch3n Sep 3, 2026
7475c1a
feat(components): offer to run a stopped turn with the permission the…
zxch3n Sep 3, 2026
e507a47
fix: cover every permission shape and replay the stopped turn exactly
zxch3n Sep 3, 2026
156318a
fix(components): carry the replay's turn config through every send route
zxch3n Sep 3, 2026
09d9fc9
fix(shared): keep the one-time permission acceptance across every reb…
zxch3n Sep 3, 2026
4e6c99b
fix: never copy a one-time permission acceptance onto a different prompt
zxch3n Sep 3, 2026
92e039a
fix: bind the permission acceptance to the difference the user was shown
zxch3n Sep 3, 2026
1a82d97
fix(shared): stop the notice schema from stripping the permission con…
zxch3n Sep 3, 2026
74e063a
fix: let per-control permission acceptances accumulate for one turn
zxch3n Sep 3, 2026
e7d5323
fix(cli): stop inheriting config options the capability catalog never…
zxch3n Sep 3, 2026
768456b
refactor: delete the run-config reporting nobody reads
zxch3n Sep 3, 2026
7058c9e
fix: keep the permission acceptance with the turn it was given for
zxch3n Sep 3, 2026
cb513fe
feat: let an agent declare what each of its models can do
zxch3n Sep 4, 2026
ca54a38
feat: point the Codex and Claude adapters at their capability declara…
zxch3n Sep 4, 2026
da3ba26
chore: rebase the Claude adapter pointer off an unmerged branch
zxch3n Sep 4, 2026
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
195 changes: 178 additions & 17 deletions apps/cli/AGENTS.md

Large diffs are not rendered by default.

91 changes: 90 additions & 1 deletion apps/cli/src/agent/acp-capability-normalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,80 @@ import {
deriveModelReasoningEffortsFromLegacyModelIds,
type AcpCommandSummary,
type AcpConfigOptionSummary,
type DeclaredModelCapabilities,
} from '@lody/shared';
import type { SessionConfigOption, SessionConfigSelectGroup } from '@agentclientprotocol/sdk';
import { z } from 'zod';
import { filterAcpConfigOptions } from '@/agent/acp-config-option-filter';

/**
* Bounds for the agent's self-declared model catalog.
*
* `_meta` is whatever the other side put there, and this one gets persisted and
* fanned out to every client of the workspace, so it is bounded before it is
* believed. Numbers are generous for a real catalog and small for a payload:
* an agent publishing more models than this is not describing itself.
*/
const DECLARED_MODEL_LIMITS = {
models: 64,
modelIdLength: 128,
effortValues: 16,
effortValueLength: 64,
} as const;

const zDeclaredModelCapabilities = z.object({
_meta: z
.object({
lody: z
.object({
modelCapabilities: z
.object({
version: z.literal(1),
producerRevision: z.string().trim().min(1).max(128).optional(),
models: z
.record(
z.string().trim().min(1).max(DECLARED_MODEL_LIMITS.modelIdLength),
z.object({
effortValues: z
.array(z.string().trim().min(1).max(DECLARED_MODEL_LIMITS.effortValueLength))
.max(DECLARED_MODEL_LIMITS.effortValues)
.optional(),
fastMode: z.boolean().optional(),
})
)
.refine((models) => Object.keys(models).length <= DECLARED_MODEL_LIMITS.models),
})
.nullish(),
})
.nullish(),
})
.nullish(),
});

/**
* Reads the agent's own per-model statement, or nothing.
*
* An unknown `version`, a shape that does not parse, or a catalog past the
* bounds is ignored WHOLE rather than partially: half a catalog would answer
* "this model has no fast mode" for models the agent simply could not fit.
*/
export function readDeclaredModelCapabilities(
sessionResponse: unknown,
receivedAt: number
): DeclaredModelCapabilities | undefined {
const parsed = zDeclaredModelCapabilities.safeParse(sessionResponse);
const declared = parsed.success ? parsed.data._meta?.lody?.modelCapabilities : undefined;
if (!declared || Object.keys(declared.models).length === 0) {
return undefined;
}
return {
version: 1,
models: declared.models,
receivedAt,
...(declared.producerRevision ? { producerRevision: declared.producerRevision } : {}),
};
}

export type AcpCapabilitiesResult = {
modes: Array<{ id: string; name: string; description?: string }>;
models: Array<{ modelId: string; name?: string; description?: string }>;
Expand All @@ -15,6 +84,8 @@ export type AcpCapabilitiesResult = {
sessionFork: boolean;
acknowledgedSteer: boolean;
modelReasoningEfforts?: Record<string, string[]>;
measuredForModelId?: string;
declaredModelCapabilities?: DeclaredModelCapabilities;
};

function isSelectGroup(item: unknown): item is SessionConfigSelectGroup {
Expand Down Expand Up @@ -151,7 +222,11 @@ type AcpSessionCapabilitiesResponse = {
/** Extract cacheable capabilities from a real ACP new/load/resume session response. */
export function normalizeAcpSessionCapabilities(
sessionResponse: AcpSessionCapabilitiesResponse,
lifecycleCapabilities: { sessionFork?: boolean; acknowledgedSteer?: boolean } = {}
lifecycleCapabilities: {
sessionFork?: boolean;
acknowledgedSteer?: boolean;
receivedAt?: number;
} = {}
): AcpCapabilitiesResult {
const modes = (sessionResponse.modes?.availableModes ?? []).map((mode) => ({
id: mode.id,
Expand All @@ -176,6 +251,18 @@ export function normalizeAcpSessionCapabilities(
legacyModels.map((model) => model.modelId)
);

// What the snapshot is a snapshot OF, stored rather than left to each reader
// to infer from the model option's `currentValue`.
const modelOptionValue = modelOption?.currentValue;
const measuredForModelId =
typeof modelOptionValue === 'string'
? modelOptionValue
: (readLegacySessionModelState(sessionResponse)?.currentModelId ?? undefined);
const declaredModelCapabilities = readDeclaredModelCapabilities(
sessionResponse,
lifecycleCapabilities.receivedAt ?? Date.now()
);

return {
modes,
models,
Expand All @@ -184,5 +271,7 @@ export function normalizeAcpSessionCapabilities(
sessionFork: lifecycleCapabilities.sessionFork === true,
acknowledgedSteer: lifecycleCapabilities.acknowledgedSteer === true,
...(modelReasoningEfforts ? { modelReasoningEfforts } : {}),
...(measuredForModelId ? { measuredForModelId } : {}),
...(declaredModelCapabilities ? { declaredModelCapabilities } : {}),
};
}
129 changes: 129 additions & 0 deletions apps/cli/src/agent/acp-declared-model-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import {
findDeclaredEffortValues,
findDeclaredFastModeSupport,
summarizeAgentRunConfigCapabilities,
DECLARED_MODEL_CAPABILITIES_TTL_MS,
type AcpCapabilityCacheEntry,
} from '@lody/shared';

import { normalizeAcpSessionCapabilities } from './acp-capability-normalization';

/** A `session/new` response shaped like Codex's, carrying the Lody declaration. */
const sessionResponse = (models: Record<string, unknown>, extra: Record<string, unknown> = {}) => ({
configOptions: [
{
id: 'model',
name: 'Model',
category: 'model',
type: 'select' as const,
currentValue: 'gpt-5.2',
options: [
{ value: 'gpt-5.2', name: 'GPT-5.2' },
{ value: 'gpt-5.6-luna', name: 'Luna' },
],
},
{
id: 'reasoning_effort',
name: 'Reasoning effort',
category: 'thought_level',
type: 'select' as const,
currentValue: 'medium',
options: [
{ value: 'low', name: 'Low' },
{ value: 'medium', name: 'Medium' },
],
},
],
_meta: { lody: { modelCapabilities: { version: 1, models, ...extra } } },
});

const entryOf = (
response: ReturnType<typeof sessionResponse>,
receivedAt = 1_000
): AcpCapabilityCacheEntry => ({
cliType: 'builtin',
agentType: 'codex',
modes: [],
models: [],
fetchedAt: receivedAt,
...normalizeAcpSessionCapabilities(response, { receivedAt }),
});

describe('declared model capabilities', () => {
const declared = {
'gpt-5.2': { effortValues: ['low', 'medium'], fastMode: false },
'gpt-5.6-luna': { effortValues: ['low', 'medium', 'high', 'xhigh'], fastMode: true },
};

it('answers for a model the snapshot never described', () => {
// The probe ran on gpt-5.2, which has no fast tier, so `configOptions`
// carries no fast toggle at all. That is the exact case where the snapshot
// knows nothing and the declaration does.
const entry = entryOf(sessionResponse(declared));

expect(entry.measuredForModelId).toBe('gpt-5.2');
expect(findDeclaredFastModeSupport(entry, 'gpt-5.6-luna', 1_000)).toBe(true);
expect(findDeclaredFastModeSupport(entry, 'gpt-5.2', 1_000)).toBe(false);
expect(findDeclaredEffortValues(entry, 'gpt-5.6-luna', 1_000)).toEqual([
'low',
'medium',
'high',
'xhigh',
]);
});

it('says nothing about a model the agent did not name', () => {
const entry = entryOf(sessionResponse(declared));
// Unknown, not unsupported: a declaration answers only for what it lists.
expect(findDeclaredFastModeSupport(entry, 'gpt-6-unreleased', 1_000)).toBeUndefined();
});

it('stops speaking once stale or heard under another adapter version', () => {
const entry = entryOf(sessionResponse(declared));
const stale = 1_000 + DECLARED_MODEL_CAPABILITIES_TTL_MS + 1;
expect(findDeclaredFastModeSupport(entry, 'gpt-5.6-luna', stale)).toBeUndefined();

// Same data, but the declaration was heard under a different adapter build.
const moved: AcpCapabilityCacheEntry = {
...entry,
sourceVersion: 'codex@2',
declaredModelCapabilities: entry.declaredModelCapabilities
? { ...entry.declaredModelCapabilities, sourceVersion: 'codex@1' }
: undefined,
};
expect(findDeclaredFastModeSupport(moved, 'gpt-5.6-luna', 1_000)).toBeUndefined();
});

it('reports fast mode for the agent once any model declares it', () => {
// The MCP create-options summary used to answer from the probed model's
// snapshot alone, so an agent whose default model lacks fast published
// `fastMode: false` for every model it has.
const entry = entryOf(sessionResponse(declared));
const summary = summarizeAgentRunConfigCapabilities(entry, 1_000);

expect(summary.fastMode).toBe(true);
expect(summary.measuredForModelId).toBe('gpt-5.2');
expect(
summary.models.find((model) => model.id === 'gpt-5.6-luna')?.reasoningEffortValues
).toEqual(['low', 'medium', 'high', 'xhigh']);
});

it('ignores a declaration it cannot trust, whole rather than in part', () => {
// Unknown version, and a catalog past the bound. Half a catalog would answer
// "no fast mode" for models the agent simply could not fit.
const wrongVersion = {
...sessionResponse(declared),
_meta: { lody: { modelCapabilities: { version: 2, models: declared } } },
};
expect(entryOf(wrongVersion).declaredModelCapabilities).toBeUndefined();

const oversized = Object.fromEntries(
Array.from({ length: 65 }, (_unused, index) => [`model-${index}`, { fastMode: true }])
);
expect(entryOf(sessionResponse(oversized)).declaredModelCapabilities).toBeUndefined();

const noMeta = { ...sessionResponse(declared), _meta: undefined };
expect(entryOf(noMeta).declaredModelCapabilities).toBeUndefined();
});
});
Loading
Loading