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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ Read the declaration rather than maintaining a prose copy:
- interaction paths and guarantees: `packages/contracts/src/interaction-guarantees.ts`
- canonical command names: `src/command-catalog.ts`
- device capability admission: `src/core/capabilities.ts`
- common command input fields, and which surface may write an input key (model, operator, retired):
`src/commands/common-input-fields.ts` and `src/commands/input-audience.ts`

Shared selector parsing and matching belongs in `@agent-device/selectors`; request cancellation and
progress in `src/request`; cross-layer contracts in `src/contracts`; CLI flags in
Expand Down
6 changes: 5 additions & 1 deletion docs/agents/cli-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ mattering — threading it further is the common failure, not stopping too early
3. `src/commands/command-projection.ts` and command-family projection helpers: write the input into
the daemon request only if the flag affects daemon execution.
4. `src/commands/*-command-contracts.ts`: add to the command input schema only if the option should
be available through Node.js or MCP as structured input.
be available through Node.js or MCP as structured input. An input key that names a credential,
an endpoint a credential is sent to, or operator infrastructure declares `operatorField(...)`
(`src/commands/command-input.ts`), which is what keeps the MCP and AI SDK tool schemas from
offering the model a parameter to write it into. One of the shared common keys declares the same
audience in its `src/commands/common-input-fields.ts` row instead.
5. `src/client/client-types.ts`: update the public typed client option only when the Node.js
interface exposes it.
6. `src/client/client-normalizers.ts`: update daemon flag normalization only when the request still
Expand Down
7 changes: 6 additions & 1 deletion src/__tests__/eager-closure-budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,12 @@ export const HUB_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
// (`resolveLocalDaemonCodeSignature`) rather than appearing here.
// #2054 splits daemon cleanup and managed web backend into separate neutral contract entries;
// the CLI already loads both command modules, so the second one-module contract is deliberate.
'src/cli.ts': 365,
// #2027 splits the 705-line `commands/command-input.ts` into the three leaf modules the
// common-field table needs to exist without an import cycle: `input-readers.ts` (record
// readers), `input-audience.ts` (who may write a key), and `common-input-fields.ts` (the table
// itself). Every command schema already evaluated all three concerns; the growth is three more
// module records for the same code, with no new subtree behind any of them.
'src/cli.ts': 368,
'src/platform-runtime.ts': 47,
'src/core/capabilities.ts': 73,
'src/core/command-descriptor/registry.ts': 67,
Expand Down
24 changes: 8 additions & 16 deletions src/ai-sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AgentDeviceClient } from '../client/client-types.ts';
import type { JsonSchema } from '../commands/command-contract.ts';
import { resolveCommandFrameworkTier } from '../core/command-descriptor/registry.ts';
import { createCommandToolExecutor, listCommandTools } from '../mcp/command-tools.ts';
import { MCP_TOOL_CONFIG_KEYS } from '../mcp/tool-control-fields.ts';
import { formatToolErrorText } from '../mcp/tool-error.ts';

/**
Expand Down Expand Up @@ -53,22 +54,13 @@ export type AgentDeviceTools = {
// `execute` forwards to the shared executor:
// - `session` is always pinned by this factory — the whole point is that a
// tool call can never target a session other than the one passed in.
// - `stateDir` selects which daemon state directory (and therefore which
// daemon/session namespace) a call resolves against. Left model-visible,
// it would let a call escape the pinned session into another daemon's
// state entirely, defeating that guarantee.
// - `mcpOutputFormat`, `includeCost`, `responseLevel` are MCP tool-config
// knobs, not command arguments; irrelevant here since `execute` below
// returns structuredContent directly and never reads a tool's rendered
// text, and shaping the response is this factory's decision, not the
// model's.
const ALWAYS_HIDDEN_FIELDS = [
'session',
'stateDir',
'mcpOutputFormat',
'includeCost',
'responseLevel',
] as const;
// - every MCP tool-config key: `stateDir` selects which daemon state directory
// (and therefore which daemon/session namespace) a call resolves against, so
// left model-visible it would let a call escape the pinned session into
// another daemon's state entirely; the rest shape the response, which is this
// factory's decision, not the model's (`execute` below returns
// structuredContent directly and never reads a tool's rendered text).
const ALWAYS_HIDDEN_FIELDS: readonly string[] = ['session', ...MCP_TOOL_CONFIG_KEYS];

// The registry's hand-rolled JsonSchema type and `jsonSchema()`'s expected
// JSONSchema7 (re-exported from @ai-sdk/provider, not from `ai` itself) are
Expand Down
2 changes: 1 addition & 1 deletion src/cli/batch-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
BATCH_AVAILABLE_COMMANDS_HINT,
readStructuredBatchCommandName,
} from '../core/batch-policy.ts';
import { assertAllowedKeys } from '../commands/command-input.ts';
import { assertAllowedKeys } from '../commands/input-readers.ts';

/**
* The terminal half of the step-shape refusal. `@agent-device/contracts` states the shape for
Expand Down
89 changes: 89 additions & 0 deletions src/commands/__tests__/input-audience.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import {
COMMON_INPUT_AUDIENCE,
commonProperties,
commonToClientOptions,
readCommonInput,
} from '../common-input-fields.ts';
import { operatorInputRefusal } from '../input-audience.ts';

// The guidance a refused operator key answers with is rendered from the key's
// declared source, not written per key. These pin the four shapes the
// declarations use, so a change to the template or to `buildPrimaryEnvVarName`
// fails here instead of silently degrading every operator refusal at once.
test('an operator refusal names the environment variable and the config key', () => {
assert.equal(
operatorInputRefusal('daemonAuthToken', { operatorConfig: true }),
'daemonAuthToken is not accepted as a tool argument. Set the AGENT_DEVICE_DAEMON_AUTH_TOKEN environment variable (or daemonAuthToken in ~/.agent-device/config.json) for the process serving these tools.',
);
});

test('an operator refusal names every environment variable that carries the value', () => {
assert.equal(
operatorInputRefusal('bearerToken', { envFlagKeys: ['metroBearerToken', 'daemonAuthToken'] }),
'bearerToken is not accepted as a tool argument. Set the AGENT_DEVICE_METRO_BEARER_TOKEN or AGENT_DEVICE_DAEMON_AUTH_TOKEN environment variable for the process serving these tools.',
);
});

test('a key with no environment variable points at the operator config file alone', () => {
assert.equal(
operatorInputRefusal('iosSimulatorDeviceSet', { envFlagKeys: [], operatorConfig: true }),
'iosSimulatorDeviceSet is not accepted as a tool argument. Set iosSimulatorDeviceSet in ~/.agent-device/config.json for the process serving these tools.',
);
});

test('a key neither env nor config resolves states its own operator path', () => {
assert.equal(
operatorInputRefusal('cwd', { operatorPath: 'Start the process elsewhere.' }),
'cwd is not accepted as a tool argument. Start the process elsewhere.',
);
});

// `audience` narrows the MODEL-facing surfaces only. The CLI and the Node client
// still accept an operator key as ordinary input -- that is how an operator
// supplies it, and how env/config defaults reach the command route. A
// classification that also narrowed `commonProperties()` would break both
// without failing the MCP tests, which only assert absence.
// (Which keys are operator-owned is pinned at the boundary that enforces it,
// in `mcp/__tests__/command-tools-operator-inputs.test.ts`.)
test('an operator-owned common key stays in the CLI and Node input schema', () => {
const advertised = commonProperties();
const operatorKeys = Object.keys(COMMON_INPUT_AUDIENCE);
assert.ok(operatorKeys.length > 0, 'the table declares operator-owned common keys');
for (const key of operatorKeys) {
assert.equal(COMMON_INPUT_AUDIENCE[key]?.kind, 'operator');
assert.ok(key in advertised, `${key} must stay readable by the CLI and Node client`);
}
});

// Schema, readers, and projection are three derivations of one table. A row
// that loses its reader or its projection entry drops the key silently at the
// seam nothing else covers, which is the failure `--no-record` hit twice
// (#1304/#1305). Round-tripping every advertised key catches it.
test('every advertised common key is read and projected onto the client options', () => {
const advertised = Object.keys(commonProperties());
const record = Object.fromEntries(
advertised.map((key) => [key, commonInputValueFor(key)]),
) as Record<string, unknown>;
record.noRecord = true;

const input = readCommonInput(record);
const options = commonToClientOptions(input) as Record<string, unknown>;

for (const key of advertised) {
// `deviceTarget` and its `target` alias are one value under two spellings;
// the projection emits the client-facing `target`.
const projected = key === 'deviceTarget' ? 'target' : key;
assert.ok(projected in options, `${key} is dropped between reader and projection`);
}
assert.equal(options.noRecord, true, 'noRecord rides the common seam with no schema of its own');
assert.equal(options.target, 'mobile');
});

function commonInputValueFor(key: string): unknown {
if (key === 'platform') return 'ios';
if (key === 'deviceTarget' || key === 'target') return 'mobile';
if (key === 'debug') return true;
return `value-${key}`;
}
2 changes: 1 addition & 1 deletion src/commands/batch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { commonInputFromFlags } from '../cli-grammar/common.ts';
import type { CliReader } from '../cli-grammar/types.ts';
import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts';
import { defineExecutableCommand } from '../command-contract.ts';
import { commonToClientOptions } from '../command-input.ts';
import { commonToClientOptions } from '../common-input-fields.ts';
import { batchCliOutputFormatters } from './output.ts';
import { createBatchCommandMetadata, type BatchCommandStep, type BatchInput } from './metadata.ts';
import { STRUCTURED_BATCH_COMMAND_NAMES } from '../../core/batch-policy.ts';
Expand Down
22 changes: 9 additions & 13 deletions src/commands/batch/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,19 @@ import {
STRUCTURED_BATCH_COMMAND_NAMES,
readStructuredBatchCommandName,
} from '../../core/batch-policy.ts';
import { type CommandMetadata, type JsonSchema } from '../command-contract.ts';
import {
defineCommandMetadata,
type CommandMetadata,
type JsonSchema,
} from '../command-contract.ts';
import {
assertAllowedKeys,
customField,
enumField,
fieldsInputSchema,
integerField,
readFieldInput,
requiredField,
stringField,
type CommandFieldMap,
type InferCommandInput,
} from '../command-input.ts';
import { defineFieldCommandMetadata } from '../field-command-contract.ts';
import { assertAllowedKeys } from '../input-readers.ts';

export type BatchCommandStep = {
command: string;
Expand All @@ -48,12 +44,12 @@ export function createBatchCommandMetadata(
nestedCommands: readonly string[] = STRUCTURED_BATCH_COMMAND_NAMES,
): CommandMetadata<'batch', BatchInput> {
const fields = batchFields(nestedCommands);
return defineCommandMetadata({
name: 'batch',
description: 'Execute multiple commands in one daemon request.',
inputSchema: fieldsInputSchema(fields),
readInput: (input) => readBatchInput(input, fields),
});
return defineFieldCommandMetadata(
'batch',
'Execute multiple commands in one daemon request.',
fields,
{ readInput: (input) => readBatchInput(input, fields) },
);
}

function batchFields(nestedCommands: readonly string[]) {
Expand Down
3 changes: 2 additions & 1 deletion src/commands/capture/alert.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ALERT_ACTIONS, type AlertAction } from '@agent-device/contracts/alert-contract';
import { PUBLIC_COMMANDS } from '../../command-catalog.ts';
import type { AlertCommandOptions } from '@agent-device/contracts/client';
import { compactRecord, enumField, integerField } from '../command-input.ts';
import { enumField, integerField } from '../command-input.ts';
import { compactRecord } from '../input-readers.ts';
import { defineExecutableCommand } from '../command-contract.ts';
import {
commonInputFromFlags,
Expand Down
9 changes: 2 additions & 7 deletions src/commands/capture/wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,8 @@ import { parseWaitPositionals } from '../../core/wait-positionals.ts';
import { SELECTOR_SNAPSHOT_FLAGS } from '../cli-grammar/flag-groups.ts';
import { AppError } from '@agent-device/kernel/errors';
import { isValidSelectorExpression } from '@agent-device/selectors';
import {
booleanField,
enumField,
integerField,
optionalEnum,
stringField,
} from '../command-input.ts';
import { booleanField, enumField, integerField, stringField } from '../command-input.ts';
import { optionalEnum } from '../input-readers.ts';
import { defineExecutableCommand } from '../command-contract.ts';
import {
direct,
Expand Down
5 changes: 3 additions & 2 deletions src/commands/cli-grammar/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
SELECTOR_EXPRESSION_REQUIRED_MESSAGE,
splitSelectorFromArgs,
} from '@agent-device/selectors';
import { compactRecord, type SelectorSnapshotInput } from '../command-input.ts';
import type { SelectorSnapshotInput } from '../command-input.ts';
import { compactRecord } from '../input-readers.ts';
import type {
CommandInput,
DaemonCommandRequest,
Expand Down Expand Up @@ -62,7 +63,7 @@ export function commonInputFromFlags(flags: CliFlags): Record<string, unknown> {
// the common seam every reader already spreads, so a reader cannot forget
// it and a new reader inherits it for free. The three seams it must survive
// are this one, `readCommonInput`, and `commonToClientOptions`
// (`commands/command-input.ts`) — a drop at any one of them silently
// (`commands/common-input-fields.ts`) — a drop at any one of them silently
// disables the flag (#1304/#1305 fixed only the reader layer, so the flag
// still never reached the daemon).
//
Expand Down
10 changes: 10 additions & 0 deletions src/commands/cli-grammar/flag-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ export const REPEATED_TOUCH_FLAGS = flagKeys(
export const SETTLE_FLAGS = flagKeys('settle', 'settleQuietMs', 'timeoutMs');
export const REPLAY_FLAGS = flagKeys('replayUpdate', 'replayEnv');

// Which flags the CLI PARSER accepts on every command — a different axis from
// the common input keys in `commands/common-input-fields.ts`, which #2027
// unified into one table. Neither list contains the other: 25 of the 42 keys
// below (`remoteConfig`, `stateDir`, `daemonTransport`, `sessionIsolation`,
// `leaseBackend`, `sessionLock`, every `provider*` and `aws*`) never become
// structured command input, while the table's `cwd` and `debug` are not flags
// and its `deviceTarget` row is spelled `target` here. Deriving this list from
// that table would mean 25 rows carrying no schema, reader, or projection, so
// the two stay separate; the table declares only `envFlagKeys`, where a flag key
// names the environment variable an operator-owned input comes from.
export const COMMON_COMMAND_SUPPORTED_FLAG_KEYS = flagKeys(
'remoteConfig',
'stateDir',
Expand Down
19 changes: 14 additions & 5 deletions src/commands/command-contract.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AgentDeviceClient } from '../client/client-types.ts';
import type { InputAudienceMap } from './input-audience.ts';

export type JsonSchema = {
type?: string | readonly string[];
Expand Down Expand Up @@ -34,12 +35,20 @@ export type CommandMetadata<Name extends string, Input> = {
inputSchema: JsonSchema;
readInput: (input: unknown) => Input;
/**
* Released input keys the command removed. Excluded from `inputSchema` (not
* advertised), but still recognized: `readInput` throws migration guidance
* when one is supplied. The MCP admission boundary reads this so it lets a
* retired key reach that message instead of rejecting it as unknown.
* Non-model audiences this command's own input keys declare
* (`commands/input-audience.ts`). `retired` keys are excluded from
* `inputSchema` but still recognized, so the MCP admission boundary lets one
* through to `readInput`'s migration guidance instead of rejecting it as
* unknown; `operator` keys stay in `inputSchema` for the CLI and the Node
* client, and the MCP boundary hides and refuses them.
*
* Required, and empty for most commands: a field that declares an audience is
* only honored because its command carries it here, so leaving this optional
* would make "forgot to wire it up" a silent model-writable credential rather
* than a type error. `defineFieldCommandMetadata` derives it from the field
* map, which is why that is the one construction path for a field command.
*/
retiredInputKeys?: readonly string[];
inputAudience: InputAudienceMap;
};

export type ExecutableCommandContract<Name extends string, Input, Result> = CommandMetadata<
Expand Down
Loading
Loading