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
22 changes: 22 additions & 0 deletions docs/tui-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,28 @@ Offline regression tests cover cascade ownership, delivery suppression, late
task creation, repeated stops, append completion/shutdown, and queue fallback.
The host integration tests use a scripted runner and real background processes;
they do not establish live-model or cross-platform acceptance.
## Agent spawn policy
Declarative agent-to-agent spawn rules are Kinetick Code behavior, carried by
a Custom Agent's canonical `agent.md` under `x-mavis`. They compose with — and
never replace — the runtime-owned built-in feature policy (`features.delegation`)
and the canonical role tool ceilings; those decide capability, this decides
permission between named Agents.

- `x-mavis.spawnMode: subagent-only | master-only | both` (default `both`,
today's behavior). A `master-only` Agent rejects `task` spawns from a task
child; a `subagent-only` Agent rejects user-initiated Session creation
(TUI, CLI, and `POST /sessions`) and remains spawnable by other Agents.
- `x-mavis.canSpawn: [names]` restricts which parent Agents may spawn this
Agent through `task`. Entries are stable Agent names (case-insensitive). A
task-child spawn with an allowlist and an unresolvable parent identity is
denied (fail closed on an explicit restriction).
- An Agent with no `x-mavis` policy block behaves exactly as before: the gate
fails open on missing policy, missing port, and unreadable policy files.
- Built-in Agents (`mavis`, `explore`, `worker`, `verifier`) carry no Custom
file policy; their runtime-owned policy remains the only gate.
- `agent list` (native `mavis` tool) and the management `listAgents` response
expose `spawnMode`/`canSpawn` so clients can render the spawn graph.

## Skill directory links
Workspace `.agents/skills`, `.claude/skills`, and `.minimax/skills` support
directory symlinks, both for the entire skill root and for individual skill
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export interface AgentSessionPorts {
readonly roots: RootAgentPort;
readonly resolveWriteTarget: (requestRef: string) => Promise<string>;
readonly requireExactAgentKey: (requestRef: string) => Promise<string>;
readonly getSpawnPolicy: (
requestRef: string,
) => Promise<{ readonly spawnMode?: "subagent-only" | "master-only" | "both" }>;
}

/** Existing Root application API; AgentApplication never reimplements root promotion. */
Expand Down Expand Up @@ -557,6 +560,7 @@ export function createAgentSessionPorts(service: LocalAgentService): AgentSessio
roots,
resolveWriteTarget: (requestRef) => service.resolveAgentWriteTarget(requestRef),
requireExactAgentKey: (requestRef) => service.requireExactAgentKey(requestRef),
getSpawnPolicy: (requestRef) => service.getSpawnPolicy(requestRef),
directory: {
get: async (agentName) => {
const view = await service.getPersistedOwner(agentName);
Expand Down
7 changes: 7 additions & 0 deletions packages/local-runtime-v2/src/application/initialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export interface InitializeApplicationsOptions {
readonly compatibility: V1SessionCompatibility;
readonly attachmentRegistration: LocalAttachmentRegistrationPort;
readonly resolveAgentWriteTarget: (requestRef: string) => Promise<string>;
/** Declarative spawn policy read for the user-create subagent-only gate. */
readonly getAgentSpawnPolicy?: (
requestRef: string,
) => Promise<{ readonly spawnMode?: "subagent-only" | "master-only" | "both" }>;
readonly requireExactAgentKey: SessionRootApplicationOptions["requireExactAgentKey"];
readonly turn: SessionRootApplicationOptions["turn"] & {
sessionDeletion(
Expand Down Expand Up @@ -105,6 +109,9 @@ export const initializeApplications: InitializeApplications = (options) => {
const createSideSession =
options.conversationMutationWorkflow?.createSideSession;
const lifecycle = new SessionLifecycleApplication({
...(options.getAgentSpawnPolicy
? { getAgentSpawnPolicy: options.getAgentSpawnPolicy }
: {}),
lifecycle: options.sessionSystem.session.lifecycle,
...(createSideSession
? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ export interface SessionLifecycleApplicationOptions
}): Promise<SessionRecord>;
};
readonly resolveAgentWriteTarget: (requestRef: string) => Promise<string>;
/**
* Declarative spawn policy of one Agent, read at user-facing Session create
* to reject `subagent-only` targets. Absent = gate disabled (tests, hosts
* without an Agent service).
*/
readonly getAgentSpawnPolicy?: (
requestRef: string,
) => Promise<{ readonly spawnMode?: 'subagent-only' | 'master-only' | 'both' }>
readonly runPluginHookSessionEndFence?: <T>(
sessionId: string,
operation: () => Promise<T>,
Expand Down Expand Up @@ -228,6 +236,7 @@ export class SessionLifecycleApplication {
});
}
const input = toCreateInput(req);
await this.assertUserCreatableAgent(req);
return this.options.lifecycle.createSession({
...input,
...whenDefined("isDefaultWorkspace", isDefaultWorkspace),
Expand All @@ -245,6 +254,29 @@ export class SessionLifecycleApplication {
);
}

/**
* A user-initiated Session (no parent) may not target a `subagent-only`
* Agent. Task children never reach this path (they create through the V1
* agentRoutes); the task-spawn gate owns them.
*/
private async assertUserCreatableAgent(req: CreateSessionReq): Promise<void> {
const getPolicy = this.options.getAgentSpawnPolicy;
if (!getPolicy || req.parentSessionId) return;
let policy;
try {
policy = await getPolicy(req.name);
} catch {
return; // fail open: unreadable policy keeps the Agent user-creatable
}
if (policy.spawnMode === "subagent-only") {
throw new AppError(
403,
"AGENT_SPAWN_MODE_FORBIDDEN",
`Agent "${req.name}" is subagent-only and cannot start a user session.`,
);
}
}

async updateSession(
_context: ApplicationContext,
req: UpdateSessionReq,
Expand Down
31 changes: 29 additions & 2 deletions packages/local-runtime-v2/src/local/agent-runtime-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,26 @@ export function createV2AgentRuntimeManagementPort(input: {
const controller = createAgentManagementApplication(input.application);
const context: ApplicationContext = {};
return {
listAgents: (request: ListAgentsReq): Promise<ListAgentsResp> =>
controller.listAgents(context, request),
listAgents: async (request: ListAgentsReq): Promise<ListAgentsResp> => {
const resp = await controller.listAgents(context, request);
if (!resp.agents) return resp;
const agents = await Promise.all(
resp.agents.map(async (agent) => {
if (typeof agent.name !== "string") return agent;
try {
const policy = await input.service.getSpawnPolicy(agent.name);
return {
...agent,
...(policy.spawnMode ? { spawnMode: policy.spawnMode } : {}),
...(policy.canSpawn ? { canSpawn: [...policy.canSpawn] } : {}),
};
} catch {
return agent;
}
}),
);
return { ...resp, agents };
},
createAgent: (request: CreateAgentReq): Promise<CreateAgentResp> =>
controller.createAgent(context, request),
getAgent: (request: GetAgentReq): Promise<GetAgentResp> =>
Expand Down Expand Up @@ -78,6 +96,15 @@ export function createV2AgentRuntimeManagementPort(input: {
(tools === undefined || tools.includes("bash"))
);
},
getAgentSpawnPolicy: async (agentName: string) => {
try {
return await input.service.getSpawnPolicy(agentName);
} catch {
// The spawn gate fails open: an unreadable policy must not make an
// Agent unspawnable, matching the port's no-policy = allow behavior.
return {};
}
},
listLegacyPinnedAgentRefs: async () => [
...(await input.application.listLegacyPinnedAgentRefs()),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ import {
toPrimaryFamilyMembers,
type PrimaryExecutionIdentity,
} from '../domain/primary-identity.js';
import type { AgentMavisSpawnPolicy } from '../domain/spawn-policy.js';
import type {
AgentConfigDocument,
AgentConfigPutInput,
Expand Down Expand Up @@ -271,6 +272,31 @@ export class LocalAgentService extends AgentConfigDocuments {
return readCanonicalCustomAvatar(this.options.repository, meta.name);
}

/**
* Declarative spawn policy for one Agent, read from its canonical file.
* Builtin Agents carry no Custom-file policy: the runtime-owned feature
* policy remains the only gate for them, so this returns no policy.
*/
async getSpawnPolicy(requestRef: string): Promise<AgentMavisSpawnPolicy> {
const scope = await this.resolveAgentReadScope(requestRef);
const meta = await this.requireMeta(scope.exactOwnerName);
if (this.isBuiltin(meta)) return {};
try {
const canonical = await readCanonicalAgentConfig(this.options.repository, meta.name);
return canonical.xMavis ?? {};
} catch (error) {
// A missing canonical file leaves the Agent runnable through its legacy
// profile surface; it must not become unspawnable.
if (
error instanceof AgentServiceError &&
error.code === 'AGENT_CONFIG_NOT_FOUND'
) {
return {};
}
throw error;
}
}

async create(input: AgentCreateInput): Promise<AgentView> {
// The route-level name identifies the new owner. A complete definition may
// carry a presentation name, but never chooses a different durable owner.
Expand Down
137 changes: 137 additions & 0 deletions packages/local-runtime-v2/src/service/agent/domain/spawn-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { resolveCanonicalSubagentRole } from '@mavis/agent-tools/desktop/subagent-roles';

/**
* The spawn-policy shape this module consumes. It is structurally identical
* to `CanonicalAgentMavisConfig`'s policy fields; declaring it locally keeps
* the V1 task runner host ports free of a storage-module import.
*/
export interface AgentMavisSpawnPolicy {
readonly spawnMode?: AgentSpawnMode;
readonly canSpawn?: readonly string[];
}

/**
* Declarative spawn policy carried by a Custom Agent's canonical file
* (`x-mavis.spawnMode` / `x-mavis.canSpawn`). It composes with — never
* replaces — the runtime-owned builtin feature policy: a Builtin Agent
* without an explicit policy keeps today's behavior exactly.
*/
export type AgentSpawnMode = 'subagent-only' | 'master-only' | 'both';

const SPAWN_MODES = new Set<AgentSpawnMode>(['subagent-only', 'master-only', 'both']);

/** Parses `x-mavis.spawnMode`; absent means 'both' (today's behavior). */
export function parseAgentSpawnMode(
source: Record<string, unknown>,
invalid: (field: string, message: string) => Error,
): AgentSpawnMode | undefined {
const value = source['spawnMode'];
if (value === undefined) return undefined;
if (typeof value !== 'string' || !SPAWN_MODES.has(value as AgentSpawnMode)) {
throw invalid(
'x-mavis.spawnMode',
'spawnMode must be one of subagent-only, master-only, both.',
);
}
return value as AgentSpawnMode;
}

/** Normalizes `x-mavis.canSpawn` to a trimmed, deduplicated, frozen list. */
export function normalizeCanSpawn(
values: readonly string[] | undefined,
): readonly string[] | undefined {
if (values === undefined) return undefined;
const seen = new Set<string>();
for (const value of values) {
const normalized = value.trim().toLowerCase();
if (normalized.length > 0) seen.add(normalized);
}
return Object.freeze([...seen]);
}

export interface AgentSpawnGateInput {
/** Resolved stable name of the target Agent (canonical custom owner name). */
readonly targetAgentName: string;
readonly targetPolicy: AgentMavisSpawnPolicy;
/** Resolved stable name of the requesting parent Agent; absent for user-initiated. */
readonly parentAgentName?: string;
/** True when the requesting session is a task child (spawned via `task`). */
readonly parentIsTaskChild: boolean;
}

export type AgentSpawnGateDecision =
| { readonly allowed: true }
| {
readonly allowed: false;
readonly code: 'AGENT_SPAWN_MODE_FORBIDDEN' | 'AGENT_SPAWN_NOT_PERMITTED';
readonly reason: string;
};

function targetMatchesEntries(
entries: readonly string[],
targetAgentName: string,
): boolean {
const normalized = targetAgentName.trim().toLowerCase();
if (entries.includes(normalized)) return true;
// A canSpawn entry naming a canonical role selects the trusted builtin
// target; custom rows must be named by their stable name.
const role = resolveCanonicalSubagentRole(normalized);
return role !== undefined && entries.includes(role) && normalized === role;
}

/**
* Single enforcement point for the declarative agent-to-agent spawn graph.
* Callers run this after name resolution, before creating the child Session.
* An absent policy (`undefined`) allows everything, so every agent without
* an explicit `x-mavis` block behaves exactly as before this feature.
*/
export function evaluateAgentSpawnGate(
input: AgentSpawnGateInput,
): AgentSpawnGateDecision {
const mode = input.targetPolicy.spawnMode ?? 'both';
const parent = input.parentAgentName?.trim();

if (input.parentAgentName !== undefined && input.parentIsTaskChild) {
// A master-only Agent exists for user-initiated sessions only.
if (mode === 'master-only') {
return {
allowed: false,
code: 'AGENT_SPAWN_MODE_FORBIDDEN',
reason: `Agent "${input.targetAgentName}" is master-only and cannot be spawned by a subagent.`,
};
}
const canSpawn = input.targetPolicy.canSpawn;
if (canSpawn !== undefined && parent && !targetMatchesEntries(canSpawn, parent)) {
return {
allowed: false,
code: 'AGENT_SPAWN_NOT_PERMITTED',
reason: `Agent "${input.targetAgentName}" only accepts spawns from: ${canSpawn.join(', ')}.`,
};
}
}

if (input.parentAgentName === undefined && mode === 'subagent-only') {
// A subagent-only Agent is not a user-facing session target.
return {
allowed: false,
code: 'AGENT_SPAWN_MODE_FORBIDDEN',
reason: `Agent "${input.targetAgentName}" is subagent-only and cannot start a user session.`,
};
}

return { allowed: true };
}

/** Convenience overload for callers holding only a full canonical config. */
export function evaluateAgentSpawnGateForConfig(
input: Omit<AgentSpawnGateInput, 'targetPolicy'> & {
readonly targetConfig: { readonly xMavis?: AgentMavisSpawnPolicy };
},
): AgentSpawnGateDecision {
return evaluateAgentSpawnGate({
targetAgentName: input.targetAgentName,
targetPolicy: input.targetConfig.xMavis ?? {},
parentAgentName: input.parentAgentName,
parentIsTaskChild: input.parentIsTaskChild,
});
}
10 changes: 10 additions & 0 deletions packages/local-runtime-v2/src/service/agent/public.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
export * from './contracts.js';
export * from './errors.js';
export {
evaluateAgentSpawnGate,
evaluateAgentSpawnGateForConfig,
normalizeCanSpawn,
parseAgentSpawnMode,
type AgentMavisSpawnPolicy,
type AgentSpawnGateDecision,
type AgentSpawnGateInput,
type AgentSpawnMode,
} from './domain/spawn-policy.js';
export { describeAgentPromptSnapshot } from './domain/prompt-snapshot.js';
export { LocalAgentService, type LocalAgentServiceOptions } from './application/agent.service.js';
export { AgentFavorites } from './application/favorites.js';
Expand Down
Loading
Loading