refactor(agent-core-v2): register built-in tools via DI

Make built-in tool registration DI-native:
- each tool is now a DI class whose dependencies are injected via @IX
  instead of hand-wired through a parent service constructor
- each domain registers its tools from an Eager Agent-scope *ToolsService
  that builds them with IInstantiationService.createInstance
- split tool registration out of domain services that the tools call back
  into (cron/background/goal/plan/swarm/skill) to avoid constructor cycles
- drop the manual force-pulls (rpc marker injections, the
  initializeBuiltinTools callback, runChildAgent ensureAgentTool) now that
  Eager registration runs at Agent scope creation
- defer IHostEnvironment reads in GlobTool/BashTool to getters so Eager
  construction does not require environment readiness

Media tools (ReadMediaFile) are left as-is pending a capability-gated
registration design.
This commit is contained in:
haozhe.yang 2026-07-02 16:04:39 +08:00
parent 37e5dacd52
commit b293d6f030
72 changed files with 908 additions and 588 deletions

View file

@ -12,9 +12,9 @@
import { z } from 'zod';
import type { BuiltinTool } from '#/agent/tool';
import type { ILogger } from '#/app/log';
import { ILogService } from '#/app/log';
import { collectGitContext } from '#/session/agentFs';
import type { ISessionProcessRunner } from '#/session/process';
import { ISessionProcessRunner } from '#/session/process';
import { ToolAccesses } from '#/agent/tool';
import { isAbortError } from '#/agent/loop/errors';
import type {
@ -25,12 +25,14 @@ import type {
import { isUserCancellation } from '#/_base/utils/abort';
import {
AgentBackgroundTask,
type IAgentBackgroundService,
IAgentBackgroundService,
type RegisterBackgroundTaskOptions,
} from '#/agent/background';
import type { IAgentProfileService } from '#/agent/profile';
import type { IAgentLifecycleService } from '#/session/agentLifecycle';
import type { ISessionMetadata } from '#/session/sessionMetadata';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IExecContext } from '#/session/execContext';
import { IAgentLifecycleService } from '#/session/agentLifecycle';
import { ISessionMetadata } from '#/session/sessionMetadata';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { matchesGlobRuleSubject } from '#/_base/tools/support/rule-match';
import {
@ -123,53 +125,40 @@ export interface AgentToolSubagentProfile {
export type AgentToolSubagentMap = Readonly<Record<string, AgentToolSubagentProfile>>;
export interface AgentToolOptions {
readonly lifecycle: IAgentLifecycleService;
readonly callerAgentId: string;
readonly metadata?: ISessionMetadata;
readonly background: IAgentBackgroundService;
readonly profile: IAgentProfileService;
readonly cwd: string;
readonly processRunner: ISessionProcessRunner;
readonly log?: ILogger;
readonly runOverride?: AgentToolRunOverride;
}
// ── AgentTool class ──────────────────────────────────────────────────
export class AgentTool implements BuiltinTool<AgentToolInput> {
readonly name: string = 'Agent';
readonly parameters: Record<string, unknown> = toInputJsonSchema(AgentToolInputSchema);
private readonly lifecycle: IAgentLifecycleService;
private readonly callerAgentId: string;
private readonly metadata?: ISessionMetadata;
private readonly background: IAgentBackgroundService;
private readonly log?: ILogger;
private readonly runOverride?: AgentToolRunOverride;
private readonly typeLines: string;
private readonly gitContext: { cwd: string; runner: ISessionProcessRunner };
private readonly typeLines: string;
private readonly canRunInBackground: () => boolean;
constructor(options: AgentToolOptions) {
this.lifecycle = options.lifecycle;
this.callerAgentId = options.callerAgentId;
this.metadata = options.metadata;
this.background = options.background;
this.log = options.log;
this.runOverride = options.runOverride;
this.gitContext = { cwd: options.cwd, runner: options.processRunner };
constructor(
private readonly runOverride: AgentToolRunOverride | undefined,
@IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService,
@IAgentScopeContext scopeContext: IAgentScopeContext,
@ISessionMetadata private readonly metadata: ISessionMetadata,
@IAgentBackgroundService private readonly background: IAgentBackgroundService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@IExecContext execContext: IExecContext,
@ISessionProcessRunner processRunner: ISessionProcessRunner,
@ILogService private readonly log: ILogService,
) {
this.callerAgentId = scopeContext.agentId;
this.gitContext = { cwd: execContext.cwd, runner: processRunner };
this.typeLines = buildSubagentDescriptions(DEFAULT_AGENT_SUBAGENT_PROFILES);
this.canRunInBackground = () => {
return (
options.profile.isToolActive('TaskList') &&
options.profile.isToolActive('TaskOutput') &&
options.profile.isToolActive('TaskStop')
this.profile.isToolActive('TaskList') &&
this.profile.isToolActive('TaskOutput') &&
this.profile.isToolActive('TaskStop')
);
};
}
private readonly canRunInBackground: () => boolean;
private get run(): AgentToolRunOverride {
return (
this.runOverride ?? {

View file

@ -1,29 +1,23 @@
/**
* `agentTool` domain (L5) registers the `Agent` collaboration tool for an agent.
*
* Registers the `Agent` tool into the `toolRegistry` so the agent can spawn task
* subagents, bound to this agent as the caller (`callerAgentId` from the agent
* `scopeContext`). The optional first static `runner` argument is a test seam
* (`AgentToolRunOverride`) that lets tests substitute the `runChildAgent`
* helpers; the scoped registry supplies none. Bound at Agent scope; reads its
* identity through `scopeContext`, creates child agents through
* `agentLifecycle`, reads the parent check through `sessionMetadata`, gates
* background execution through the agent `profile`, and gathers git context
* through `kaos` (cwd) + `process` (runner).
* Eager Agent-scope registration service for the `Agent` tool, which lets the
* agent spawn task subagents. The tool is a DI class created via
* `IInstantiationService.createInstance` (its dependencies identity via
* `scopeContext`, child creation via `agentLifecycle`, parent check via
* `sessionMetadata`, background gating via `profile`, git context via
* `execContext` + `process` are injected) and registered into the agent
* `IAgentToolRegistryService`. The optional leading static `runner` argument is a
* test seam (`AgentToolRunOverride`) that lets tests substitute the
* `runChildAgent` helpers; the scoped registry supplies none. Eager so the tool
* is registered when the Agent scope is created, before the first turn.
*/
import { Disposable } from '#/_base/di';
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentBackgroundService } from '#/agent/background';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IExecContext } from '#/session/execContext';
import { ILogService } from '#/app/log';
import { IAgentLifecycleService } from '#/session/agentLifecycle';
import { ISessionProcessRunner } from '#/session/process';
import { ISessionMetadata } from '#/session/sessionMetadata';
import { AgentTool } from './agentTool';
import { IAgentToolService } from './agentToolServiceToken';
@ -34,31 +28,12 @@ export class AgentToolService extends Disposable implements IAgentToolService {
constructor(
runner: AgentToolRunOverride | undefined,
@IAgentScopeContext ctx: IAgentScopeContext,
@IAgentLifecycleService lifecycle: IAgentLifecycleService,
@ISessionMetadata metadata: ISessionMetadata,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentBackgroundService background: IAgentBackgroundService,
@IAgentProfileService profile: IAgentProfileService,
@IExecContext execContext: IExecContext,
@ISessionProcessRunner processRunner: ISessionProcessRunner,
@ILogService log?: ILogService,
) {
super();
this._register(
toolRegistry.register(
new AgentTool({
lifecycle,
callerAgentId: ctx.agentId,
metadata,
background,
profile,
cwd: execContext.cwd,
processRunner,
log,
runOverride: runner,
}),
),
toolRegistry.register(instantiationService.createInstance(AgentTool, runner)),
);
}
}
@ -67,6 +42,6 @@ registerScopedService(
LifecycleScope.Agent,
IAgentToolService,
AgentToolService,
InstantiationType.Delayed,
InstantiationType.Eager,
'agentTool',
);

View file

@ -18,7 +18,6 @@ export {
} from './agentTool';
export type {
AgentToolInput,
AgentToolOptions,
AgentToolOutput,
AgentToolSubagentMap,
AgentToolSubagentProfile,

View file

@ -38,7 +38,6 @@ import { IAgentPromptService } from '#/agent/prompt';
import { IAgentUsageService } from '#/agent/usage';
import type { Turn } from '#/agent/turn';
import { IAgentToolService } from './agentToolServiceToken';
import { DEFAULT_AGENT_SUBAGENT_PROFILES, EXPLORE_ROLE_ADDITIONAL } from './profiles';
import {
DEFAULT_SUBAGENT_TIMEOUT_MS,
@ -82,7 +81,6 @@ export async function spawnChildAgent(args: SpawnChildAgentArgs): Promise<Subage
swarmItem: options.swarmItem,
});
configureChild(caller, child, options.profileName);
ensureAgentTool(child);
emitSpawned(caller, callerAgentId, child.id, options.profileName, options);
const completion = runWithActiveChild(
child,
@ -143,16 +141,9 @@ async function requireAgent(
): Promise<IAgentScopeHandle> {
const handle = lifecycle.getHandle(agentId);
if (handle === undefined) throw new Error(`Agent instance "${agentId}" does not exist`);
ensureAgentTool(handle);
return handle;
}
function ensureAgentTool(child: IAgentScopeHandle): void {
// Force-instantiate the child agent's `Agent` tool registrar so its `Agent`
// tool is registered before the child's first turn builds its tool list.
child.accessor.get(IAgentToolService);
}
function configureChild(source: IAgentScopeHandle, child: IAgentScopeHandle, profileName: string): void {
const sourceProfile = source.accessor.get(IAgentProfileService);
const childProfile = child.accessor.get(IAgentProfileService);

View file

@ -32,7 +32,6 @@ import { IAgentPromptService } from '#/agent/prompt';
import { ISessionContext } from '#/session/sessionContext';
import { IAtomicDocumentStore, IStorageService } from '#/app/storage';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentRecordService, type AgentRecord } from '#/agent/record';
import {
IAgentBackgroundService,
@ -136,7 +135,6 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
@IAgentPromptService private readonly prompt: IAgentPromptService,
@IAgentExternalHooksService private readonly externalHooks: IAgentExternalHooksService,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IConfigService private readonly config: IConfigService,
@IAtomicDocumentStore atomicDocs: IAtomicDocumentStore,
@IStorageService byteStore: IStorageService,
@ -183,10 +181,6 @@ export class AgentBackgroundService extends Disposable implements IAgentBackgrou
}
}),
);
this._register(toolRegistry.register(new TaskListTool(this)));
this._register(toolRegistry.register(new TaskOutputTool(this)));
this._register(toolRegistry.register(new TaskStopTool(this)));
}
registerTask(task: BackgroundTask, options: RegisterBackgroundTaskOptions = {}): string {

View file

@ -0,0 +1,16 @@
/**
* `backgroundTools` domain (L4) `IAgentBackgroundToolsService` registration contract.
*
* Marker service: its implementation registers the built-in background-task tools
* (TaskList / TaskOutput / TaskStop) into the agent `IAgentToolRegistryService` on
* construction. Bound at Agent scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IAgentBackgroundToolsService {
readonly _serviceBrand: undefined;
}
export const IAgentBackgroundToolsService: ServiceIdentifier<IAgentBackgroundToolsService> =
createDecorator<IAgentBackgroundToolsService>('agentBackgroundToolsService');

View file

@ -0,0 +1,45 @@
/**
* `backgroundTools` domain (L4) `IAgentBackgroundToolsService` implementation.
*
* Eager Agent-scope registration service for the built-in background-task tools
* (TaskList / TaskOutput / TaskStop). Each tool is a DI class created via
* `IInstantiationService.createInstance` (they inject `IAgentBackgroundService`
* themselves) and registered into the agent `IAgentToolRegistryService`. Eager so
* the tools are registered when the Agent scope is created, before the first turn.
*
* Split out of `AgentBackgroundService` so the tools can inject
* `IAgentBackgroundService` without forming a constructor-instantiation cycle.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentBackgroundToolsService } from './backgroundTools';
import { TaskListTool } from '#/agent/background/tools/task-list';
import { TaskOutputTool } from '#/agent/background/tools/task-output';
import { TaskStopTool } from '#/agent/background/tools/task-stop';
export class AgentBackgroundToolsService extends Disposable implements IAgentBackgroundToolsService {
declare readonly _serviceBrand: undefined;
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
) {
super();
this._register(toolRegistry.register(instantiationService.createInstance(TaskListTool)));
this._register(toolRegistry.register(instantiationService.createInstance(TaskOutputTool)));
this._register(toolRegistry.register(instantiationService.createInstance(TaskStopTool)));
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentBackgroundToolsService,
AgentBackgroundToolsService,
InstantiationType.Eager,
'background',
);

View file

@ -1,10 +1,14 @@
/**
* `background` domain barrel re-exports the background contract
* (`background`) and its scoped service (`backgroundService`). Importing this
* barrel registers the `IAgentBackgroundService` binding into the scope registry.
* (`background`) and its scoped service (`backgroundService`), plus the
* `backgroundTools` registrar. Importing this barrel registers the
* `IAgentBackgroundService` and `IAgentBackgroundToolsService` bindings into the
* scope registry.
*/
import './configSection';
export * from './background';
export * from './backgroundService';
export * from './backgroundTools';
export * from './backgroundToolsService';

View file

@ -8,7 +8,8 @@ import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { matchesGlobRuleSubject } from '#/_base/tools/support/rule-match';
import type { BuiltinTool, ToolExecution } from '#/agent/tool';
import type { BackgroundTaskInfo, IAgentBackgroundService } from '#/agent/background/background';
import { IAgentBackgroundService } from '#/agent/background/background';
import type { BackgroundTaskInfo } from '#/agent/background/background';
import { formatPlainObject } from './format';
import TASK_LIST_DESCRIPTION from './task-list.md?raw';
@ -48,7 +49,7 @@ export class TaskListTool implements BuiltinTool<TaskListInput> {
readonly description = TASK_LIST_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(TaskListInputSchema);
constructor(private readonly background: IAgentBackgroundService) {}
constructor(@IAgentBackgroundService private readonly background: IAgentBackgroundService) {}
resolveExecution(args: TaskListInput): ToolExecution {
const listScope = (args.active_only ?? true) ? 'active' : 'all';

View file

@ -18,10 +18,10 @@ import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { matchesGlobRuleSubject } from '#/_base/tools/support/rule-match';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { IAgentBackgroundService } from '#/agent/background/background';
import type {
BackgroundTaskInfo,
BackgroundTaskOutputSnapshot,
IAgentBackgroundService,
} from '#/agent/background/background';
import { type BackgroundTaskStatus, TERMINAL_STATUSES } from '#/agent/background/task';
import { formatPlainObject } from './format';
@ -98,7 +98,7 @@ export class TaskOutputTool implements BuiltinTool<TaskOutputInput> {
readonly description: string = TASK_OUTPUT_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(TaskOutputInputSchema);
constructor(private readonly background: IAgentBackgroundService) {}
constructor(@IAgentBackgroundService private readonly background: IAgentBackgroundService) {}
resolveExecution(args: TaskOutputInput): ToolExecution {
return {

View file

@ -8,7 +8,7 @@ import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { matchesGlobRuleSubject } from '#/_base/tools/support/rule-match';
import type { BuiltinTool, ToolExecution } from '#/agent/tool';
import type { IAgentBackgroundService } from '#/agent/background/background';
import { IAgentBackgroundService } from '#/agent/background/background';
import { TERMINAL_STATUSES } from '#/agent/background/task';
import TASK_STOP_DESCRIPTION from './task-stop.md?raw';
@ -32,7 +32,7 @@ export class TaskStopTool implements BuiltinTool<TaskStopInput> {
readonly description = TASK_STOP_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(TaskStopInputSchema);
constructor(private readonly background: IAgentBackgroundService) {}
constructor(@IAgentBackgroundService private readonly background: IAgentBackgroundService) {}
resolveExecution(args: TaskStopInput): ToolExecution {
return {

View file

@ -29,7 +29,6 @@ import type { ContextMessage } from '#/agent/contextMemory';
import { IAgentPromptService } from '#/agent/prompt';
import { IAgentRecordService } from '#/agent/record';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import type { Turn } from '#/agent/turn';
import { IAgentTurnService } from '#/agent/turn';
import { ISessionContext } from '#/session/sessionContext';
@ -160,7 +159,6 @@ export class AgentCronService extends Disposable implements IAgentCronService {
@IAgentRecordService private readonly record: IAgentRecordService,
@IAgentTurnService private readonly turnService: IAgentTurnService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
@IConfigService private readonly config: IConfigService,
@IAtomicDocumentStore private readonly atomicDocs: IAtomicDocumentStore,
@ISessionContext private readonly session: ISessionContext,
@ -222,10 +220,6 @@ export class AgentCronService extends Disposable implements IAgentCronService {
);
if (this.enabled) {
this._register(this.toolRegistry.register(new CronCreateTool(this, this.cronConfig.disabled)));
this._register(this.toolRegistry.register(new CronListTool(this)));
this._register(this.toolRegistry.register(new CronDeleteTool(this)));
this.start();
}

View file

@ -0,0 +1,16 @@
/**
* `cronTools` domain (L4) `IAgentCronToolsService` registration contract.
*
* Marker service: its implementation registers the built-in cron tools
* (CronCreate / CronList / CronDelete) into the agent `IAgentToolRegistryService`
* on construction. Bound at Agent scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IAgentCronToolsService {
readonly _serviceBrand: undefined;
}
export const IAgentCronToolsService: ServiceIdentifier<IAgentCronToolsService> =
createDecorator<IAgentCronToolsService>('agentCronToolsService');

View file

@ -0,0 +1,61 @@
/**
* `cronTools` domain (L4) `IAgentCronToolsService` implementation.
*
* Eager Agent-scope registration service for the built-in cron tools
* (CronCreate / CronList / CronDelete). Each tool is a DI class created via
* `IInstantiationService.createInstance` (they inject `IAgentCronService`
* themselves) and registered into the agent `IAgentToolRegistryService`.
*
* Registration is gated on `IAgentCronService.isEnabled` (cron only runs on the
* main agent), matching the previous in-service behavior. The global
* `cron.disabled` killswitch (`KIMI_DISABLE_CRON`) is read from config and passed
* to `CronCreateTool` as a leading static argument so the tool can surface a
* friendly error when scheduling is disabled.
*
* Split out of `AgentCronService` so the tools can inject `IAgentCronService`
* without forming a constructor-instantiation cycle. Eager so the tools are
* registered when the Agent scope is created, before the first turn.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IConfigService } from '#/app/config';
import { IAgentCronService } from '#/agent/cron';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { type CronConfig, CRON_SECTION, DEFAULT_CRON_CONFIG } from './configSection';
import { IAgentCronToolsService } from './cronTools';
import { CronCreateTool } from '#/agent/cron/tools/cron-create';
import { CronDeleteTool } from '#/agent/cron/tools/cron-delete';
import { CronListTool } from '#/agent/cron/tools/cron-list';
export class AgentCronToolsService extends Disposable implements IAgentCronToolsService {
declare readonly _serviceBrand: undefined;
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentCronService cron: IAgentCronService,
@IConfigService config: IConfigService,
) {
super();
if (!cron.isEnabled) return;
const disabled =
config.get<CronConfig>(CRON_SECTION)?.disabled ?? DEFAULT_CRON_CONFIG.disabled;
this._register(
toolRegistry.register(instantiationService.createInstance(CronCreateTool, disabled)),
);
this._register(toolRegistry.register(instantiationService.createInstance(CronListTool)));
this._register(toolRegistry.register(instantiationService.createInstance(CronDeleteTool)));
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentCronToolsService,
AgentCronToolsService,
InstantiationType.Eager,
'cron',
);

View file

@ -1,10 +1,13 @@
/**
* `cron` domain barrel re-exports the cron contract (`cron`) and its scoped
* service (`cronService`). Importing this barrel registers the `IAgentCronService`
* binding into the scope registry.
* service (`cronService`), plus the `cronTools` registrar. Importing this barrel
* registers the `IAgentCronService` and `IAgentCronToolsService` bindings into the
* scope registry.
*/
import './configSection';
export * from './cron';
export * from './cronService';
export * from './cronTools';
export * from './cronToolsService';

View file

@ -29,7 +29,7 @@ import { z } from 'zod';
import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/agent/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { literalRulePattern } from '#/_base/tools/support/rule-match';
import type { IAgentCronService } from '#/agent/cron';
import { IAgentCronService } from '#/agent/cron';
import {
computeNextCronRun,
cronToHuman,
@ -121,8 +121,8 @@ export class CronCreateTool implements BuiltinTool<CronCreateInput> {
);
constructor(
private readonly cron: IAgentCronService,
private readonly disabled: boolean = false,
@IAgentCronService private readonly cron: IAgentCronService,
) {}
resolveExecution(args: CronCreateInput): ToolExecution {

View file

@ -39,7 +39,7 @@ import { z } from 'zod';
import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/agent/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import type { IAgentCronService } from '#/agent/cron';
import { IAgentCronService } from '#/agent/cron';
import CRON_DELETE_DESCRIPTION from './cron-delete.md?raw';
// ── Constants ────────────────────────────────────────────────────────
@ -71,7 +71,7 @@ export class CronDeleteTool implements BuiltinTool<CronDeleteInput> {
CronDeleteInputSchema,
);
constructor(private readonly cron: IAgentCronService) {}
constructor(@IAgentCronService private readonly cron: IAgentCronService) {}
resolveExecution(args: CronDeleteInput): ToolExecution {
// Format check up front. The store would reject the lookup anyway,

View file

@ -44,7 +44,8 @@ import { z } from 'zod';
import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/agent/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import type { CronTask, IAgentCronService } from '#/agent/cron';
import { IAgentCronService } from '#/agent/cron';
import type { CronTask } from '#/agent/cron';
import {
cronToHuman,
parseCronExpression,
@ -90,7 +91,7 @@ export class CronListTool implements BuiltinTool<CronListInput> {
CronListInputSchema,
);
constructor(private readonly cron: IAgentCronService) {}
constructor(@IAgentCronService private readonly cron: IAgentCronService) {}
resolveExecution(_args: CronListInput): ToolExecution {
return {

View file

@ -1,22 +1,18 @@
/**
* `fileTools` domain (L4) `IAgentFileToolsService` implementation.
*
* Registers the built-in file tools (Read / Write / Edit / Grep / Glob) into
* the agent `IAgentToolRegistryService` on construction, wiring each to the session
* `ISessionAgentFileSystem` (file IO), `ISessionFsService` (workspace search/grep),
* `ISessionProcessRunner` (rg subprocess for Glob), `IHostEnvironment`
* (path semantics) and the session workspace. Bound at Agent scope.
* Eager Agent-scope registration service for the built-in file tools
* (Read / Write / Edit / Grep / Glob). Each tool is a DI class created via
* `IInstantiationService.createInstance` (so its session/app dependencies are
* injected) and registered into the agent `IAgentToolRegistryService`. Eager so
* the tools are registered when the Agent scope is created, before the first turn.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import { ISessionAgentFileSystem, ISessionFsService } from '#/session/agentFs';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ISessionProcessRunner } from '#/session/process';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { IAgentFileToolsService } from './fileTools';
import { EditTool } from '#/agent/fileTools/tools/edit';
@ -25,27 +21,19 @@ import { GrepTool } from '#/agent/fileTools/tools/grep';
import { ReadTool } from '#/agent/fileTools/tools/read';
import { WriteTool } from '#/agent/fileTools/tools/write';
export class AgentFileToolsService implements IAgentFileToolsService {
export class AgentFileToolsService extends Disposable implements IAgentFileToolsService {
declare readonly _serviceBrand: undefined;
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@ISessionAgentFileSystem fs: ISessionAgentFileSystem,
@IHostEnvironment env: IHostEnvironment,
@ISessionWorkspaceContext workspace: ISessionWorkspaceContext,
@ISessionFsService fsService: ISessionFsService,
@ISessionProcessRunner runner: ISessionProcessRunner,
@ITelemetryService telemetry: ITelemetryService,
) {
const workspaceConfig: WorkspaceConfig = {
workspaceDir: workspace.workDir,
additionalDirs: workspace.additionalDirs,
};
toolRegistry.register(new ReadTool(fs, env, workspaceConfig));
toolRegistry.register(new WriteTool(fs, env, workspaceConfig));
toolRegistry.register(new EditTool(fs, env, workspaceConfig));
toolRegistry.register(new GrepTool(fsService, env, workspaceConfig));
toolRegistry.register(new GlobTool(fs, env, runner, workspaceConfig, telemetry));
super();
this._register(toolRegistry.register(instantiationService.createInstance(ReadTool)));
this._register(toolRegistry.register(instantiationService.createInstance(WriteTool)));
this._register(toolRegistry.register(instantiationService.createInstance(EditTool)));
this._register(toolRegistry.register(instantiationService.createInstance(GrepTool)));
this._register(toolRegistry.register(instantiationService.createInstance(GlobTool)));
}
}
@ -53,6 +41,6 @@ registerScopedService(
LifecycleScope.Agent,
IAgentFileToolsService,
AgentFileToolsService,
InstantiationType.Delayed,
InstantiationType.Eager,
'fileTools',
);

View file

@ -29,6 +29,7 @@ import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import { renderPrompt } from '#/_base/utils/render-prompt';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
@ -77,15 +78,22 @@ export class EditTool implements BuiltinTool<EditInput> {
readonly parameters: Record<string, unknown> = toInputJsonSchema(EditInputSchema);
constructor(
private readonly fs: ISessionAgentFileSystem,
private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
@ISessionAgentFileSystem private readonly fs: ISessionAgentFileSystem,
@IHostEnvironment private readonly env: IHostEnvironment,
@ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext,
) {}
private get workspaceConfig(): WorkspaceConfig {
return {
workspaceDir: this.workspaceCtx.workDir,
additionalDirs: this.workspaceCtx.additionalDirs,
};
}
resolveExecution(args: EditInput): ToolExecution {
const path = resolvePathAccessPath(args.path, {
env: this.env,
workspace: this.workspace,
workspace: this.workspaceConfig,
operation: 'write',
});
return {
@ -101,7 +109,7 @@ export class EditTool implements BuiltinTool<EditInput> {
approvalRule: literalRulePattern(this.name, path),
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
cwd: this.workspaceConfig.workspaceDir,
pathClass: this.env.pathClass,
homeDir: this.env.homeDir,
}),

View file

@ -57,8 +57,9 @@ import {
shouldRetryRipgrepEagain,
} from '#/session/agentFs/runRg';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { ISessionProcessRunner } from '#/session/process';
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry';
import { ITelemetryService } from '#/app/telemetry';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import {
@ -144,30 +145,35 @@ export class GlobTool implements BuiltinTool<GlobInput> {
readonly name = 'Glob' as const;
readonly description: string;
readonly parameters: Record<string, unknown> = toInputJsonSchema(GlobInputSchema);
private readonly telemetry: ITelemetryService;
constructor(
private readonly fs: ISessionAgentFileSystem,
private readonly env: IHostEnvironment,
private readonly runner: ISessionProcessRunner,
private readonly workspace: WorkspaceConfig,
telemetry: ITelemetryService = noopTelemetryService,
@ISessionAgentFileSystem private readonly fs: ISessionAgentFileSystem,
@IHostEnvironment private readonly env: IHostEnvironment,
@ISessionProcessRunner private readonly runner: ISessionProcessRunner,
@ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext,
@ITelemetryService private readonly telemetry: ITelemetryService,
) {
this.telemetry = telemetry;
this.description =
this.env.pathClass === 'win32' ? globDescription + WINDOWS_PATH_HINT : globDescription;
}
private get workspaceConfig(): WorkspaceConfig {
return {
workspaceDir: this.workspaceCtx.workDir,
additionalDirs: this.workspaceCtx.additionalDirs,
};
}
resolveExecution(args: GlobInput): ToolExecution {
let path: string | undefined;
if (args.path !== undefined) {
path = resolvePathAccessPath(args.path, {
env: this.env,
workspace: this.workspace,
workspace: this.workspaceConfig,
operation: 'search',
policy: { guardMode: 'absolute-outside-allowed', checkSensitive: false },
});
}
const searchRoots = [path ?? this.workspace.workspaceDir];
const searchRoots = [path ?? this.workspaceConfig.workspaceDir];
const detailParts: string[] = [`pattern: ${args.pattern}`];
if (args.path !== undefined) {
@ -197,7 +203,7 @@ export class GlobTool implements BuiltinTool<GlobInput> {
signal: AbortSignal,
searchRoots: readonly string[],
): Promise<ExecutableToolResult> {
const searchRoot = searchRoots[0] ?? this.workspace.workspaceDir;
const searchRoot = searchRoots[0] ?? this.workspaceConfig.workspaceDir;
// `rg --files <file>` exits 0 and lists the file itself, so without this
// check a file root would be returned as its own match instead of
@ -340,7 +346,7 @@ export class GlobTool implements BuiltinTool<GlobInput> {
// later resolved against workspaceDir, so additionalDir matches stay
// absolute to keep follow-up Read/Edit calls on the same file.
const pathClass = this.env.pathClass;
const shouldRelativize = isWithinDirectory(searchRoot, this.workspace.workspaceDir, pathClass);
const shouldRelativize = isWithinDirectory(searchRoot, this.workspaceConfig.workspaceDir, pathClass);
const displayLines = limited.map((p) =>
shouldRelativize ? relativizeIfUnder(p, searchRoot, pathClass) : p,
);

View file

@ -30,6 +30,7 @@ import { z } from 'zod';
import { ISessionFsService } from '#/session/agentFs';
import { ErrorCodes, isKimiError } from '#/errors';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
@ -147,23 +148,30 @@ export class GrepTool implements BuiltinTool<GrepInput> {
readonly description = GREP_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(GrepInputSchema);
constructor(
private readonly fs: ISessionFsService,
private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
@ISessionFsService private readonly fs: ISessionFsService,
@IHostEnvironment private readonly env: IHostEnvironment,
@ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext,
) {}
private get workspaceConfig(): WorkspaceConfig {
return {
workspaceDir: this.workspaceCtx.workDir,
additionalDirs: this.workspaceCtx.additionalDirs,
};
}
resolveExecution(args: GrepInput): ToolExecution {
let searchPath: string | undefined;
if (args.path !== undefined) {
searchPath = resolvePathAccessPath(args.path, {
env: this.env,
workspace: this.workspace,
workspace: this.workspaceConfig,
operation: 'search',
policy: { guardMode: 'absolute-outside-allowed', checkSensitive: false },
});
}
const accessPath = searchPath ?? this.workspace.workspaceDir;
const displayPath = args.path ?? this.workspace.workspaceDir;
const accessPath = searchPath ?? this.workspaceConfig.workspaceDir;
const displayPath = args.path ?? this.workspaceConfig.workspaceDir;
return {
accesses: ToolAccesses.searchTree(accessPath),
description: `Searching for '${args.pattern}' in ${displayPath}`,

View file

@ -26,6 +26,7 @@ import { z } from 'zod';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
@ -224,15 +225,22 @@ export class ReadTool implements BuiltinTool<ReadInput> {
readonly description = READ_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(ReadInputSchema);
constructor(
private readonly fs: ISessionAgentFileSystem,
private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
@ISessionAgentFileSystem private readonly fs: ISessionAgentFileSystem,
@IHostEnvironment private readonly env: IHostEnvironment,
@ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext,
) {}
private get workspaceConfig(): WorkspaceConfig {
return {
workspaceDir: this.workspaceCtx.workDir,
additionalDirs: this.workspaceCtx.additionalDirs,
};
}
resolveExecution(args: ReadInput): ToolExecution {
const path = resolvePathAccessPath(args.path, {
env: this.env,
workspace: this.workspace,
workspace: this.workspaceConfig,
operation: 'read',
});
return {
@ -242,7 +250,7 @@ export class ReadTool implements BuiltinTool<ReadInput> {
approvalRule: literalRulePattern(this.name, path),
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
cwd: this.workspaceConfig.workspaceDir,
pathClass: this.env.pathClass,
homeDir: this.env.homeDir,
}),

View file

@ -19,8 +19,10 @@
import { dirname } from 'pathe';
import { z } from 'zod';
import type { AgentFileStat, ISessionAgentFileSystem } from '#/session/agentFs';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import type { AgentFileStat } from '#/session/agentFs';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { ToolAccesses } from '#/agent/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
@ -62,15 +64,22 @@ export class WriteTool implements BuiltinTool<WriteInput> {
readonly parameters: Record<string, unknown> = toInputJsonSchema(WriteInputSchema);
constructor(
private readonly fs: ISessionAgentFileSystem,
private readonly env: IHostEnvironment,
private readonly workspace: WorkspaceConfig,
@ISessionAgentFileSystem private readonly fs: ISessionAgentFileSystem,
@IHostEnvironment private readonly env: IHostEnvironment,
@ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext,
) {}
private get workspaceConfig(): WorkspaceConfig {
return {
workspaceDir: this.workspaceCtx.workDir,
additionalDirs: this.workspaceCtx.additionalDirs,
};
}
resolveExecution(args: WriteInput): ToolExecution {
const path = resolvePathAccessPath(args.path, {
env: this.env,
workspace: this.workspace,
workspace: this.workspaceConfig,
operation: 'write',
});
return {
@ -80,7 +89,7 @@ export class WriteTool implements BuiltinTool<WriteInput> {
approvalRule: literalRulePattern(this.name, path),
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
cwd: this.workspaceConfig.workspaceDir,
pathClass: this.env.pathClass,
homeDir: this.env.homeDir,
}),

View file

@ -29,7 +29,6 @@ import {
type PromptOrigin,
} from '#/agent/contextMemory';
import { IAgentLoopService } from '#/agent/loop';
import { IAgentPermissionModeService } from '#/agent/permissionMode';
import { IAgentReplayBuilderService } from '#/agent/replayBuilder';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import {
@ -41,7 +40,6 @@ import {
} from '#/agent/turn';
import type { TelemetryProperties } from '#/app/telemetry';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentRecordService, type AgentRecord } from '#/agent/record';
import {
IAgentGoalService,
@ -174,8 +172,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentTurnService private readonly turnService: IAgentTurnService,
@IAgentLoopService loopService: IAgentLoopService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService,
) {
super();
this._register(
@ -258,11 +254,6 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
}
}),
);
this._register(toolRegistry.register(new CreateGoalTool(this, this.permissionMode)));
this._register(toolRegistry.register(new GetGoalTool(this)));
this._register(toolRegistry.register(new SetGoalBudgetTool(this)));
this._register(toolRegistry.register(new UpdateGoalTool(this)));
}
get enabled(): boolean {

View file

@ -0,0 +1,16 @@
/**
* `goalTools` domain (L4) `IAgentGoalToolsService` registration contract.
*
* Marker service: its implementation registers the built-in goal tools
* (CreateGoal / GetGoal / SetGoalBudget / UpdateGoal) into the agent
* `IAgentToolRegistryService` on construction. Bound at Agent scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IAgentGoalToolsService {
readonly _serviceBrand: undefined;
}
export const IAgentGoalToolsService: ServiceIdentifier<IAgentGoalToolsService> =
createDecorator<IAgentGoalToolsService>('agentGoalToolsService');

View file

@ -0,0 +1,49 @@
/**
* `goalTools` domain (L4) `IAgentGoalToolsService` implementation.
*
* Eager Agent-scope registration service for the built-in goal tools
* (CreateGoal / GetGoal / SetGoalBudget / UpdateGoal). Each tool is a DI class
* created via `IInstantiationService.createInstance` (they inject
* `IAgentGoalService`, and `CreateGoal` also injects
* `IAgentPermissionModeService`, themselves) and registered into the agent
* `IAgentToolRegistryService`. Eager so the tools are registered when the Agent
* scope is created, before the first turn.
*
* Split out of `AgentGoalService` so the tools can inject `IAgentGoalService`
* without forming a constructor-instantiation cycle.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentGoalToolsService } from './goalTools';
import { CreateGoalTool } from '#/agent/goal/tools/create-goal';
import { GetGoalTool } from '#/agent/goal/tools/get-goal';
import { SetGoalBudgetTool } from '#/agent/goal/tools/set-goal-budget';
import { UpdateGoalTool } from '#/agent/goal/tools/update-goal';
export class AgentGoalToolsService extends Disposable implements IAgentGoalToolsService {
declare readonly _serviceBrand: undefined;
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
) {
super();
this._register(toolRegistry.register(instantiationService.createInstance(CreateGoalTool)));
this._register(toolRegistry.register(instantiationService.createInstance(GetGoalTool)));
this._register(toolRegistry.register(instantiationService.createInstance(SetGoalBudgetTool)));
this._register(toolRegistry.register(instantiationService.createInstance(UpdateGoalTool)));
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentGoalToolsService,
AgentGoalToolsService,
InstantiationType.Eager,
'goal',
);

View file

@ -1,9 +1,12 @@
/**
* `goal` domain barrel re-exports the goal contract (`goal`) and its scoped
* service (`goalService`). Importing this barrel registers the `IAgentGoalService`
* binding into the scope registry.
* service (`goalService`), plus the `goalTools` registrar. Importing this barrel
* registers the `IAgentGoalService` and `IAgentGoalToolsService` bindings into the
* scope registry.
*/
export * from './goal';
export * from './goalService';
export * from './goalTools';
export * from './goalToolsService';
export * from './types';

View file

@ -9,10 +9,10 @@ import { z } from 'zod';
import type { ToolInputDisplay } from '@moonshot-ai/protocol';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import type { IAgentPermissionModeService } from '#/agent/permissionMode';
import { IAgentPermissionModeService } from '#/agent/permissionMode';
import type { BuiltinTool, ToolExecution } from '#/agent/tool';
import type { IAgentGoalService } from '#/agent/goal/goal';
import { IAgentGoalService } from '#/agent/goal/goal';
import DESCRIPTION from './create-goal.md?raw';
import { goalForModel } from './serialize';
@ -38,8 +38,8 @@ export class CreateGoalTool implements BuiltinTool<CreateGoalToolInput> {
readonly parameters: Record<string, unknown> = toInputJsonSchema(CreateGoalToolInputSchema);
constructor(
private readonly goal: IAgentGoalService,
private readonly permissionMode: IAgentPermissionModeService,
@IAgentGoalService private readonly goal: IAgentGoalService,
@IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService,
) {}
resolveExecution(args: CreateGoalToolInput): ToolExecution {

View file

@ -9,7 +9,7 @@ import { z } from 'zod';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import type { BuiltinTool, ToolExecution } from '#/agent/tool';
import type { IAgentGoalService } from '#/agent/goal/goal';
import { IAgentGoalService } from '#/agent/goal/goal';
import DESCRIPTION from './get-goal.md?raw';
import { goalResultForModel } from './serialize';
@ -21,7 +21,7 @@ export class GetGoalTool implements BuiltinTool<GetGoalToolInput> {
readonly description: string = DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(GetGoalToolInputSchema);
constructor(private readonly goal: IAgentGoalService) {}
constructor(@IAgentGoalService private readonly goal: IAgentGoalService) {}
resolveExecution(_args: GetGoalToolInput): ToolExecution {
return {

View file

@ -9,7 +9,7 @@ import { z } from 'zod';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import type { BuiltinTool, ToolExecution } from '#/agent/tool';
import type { IAgentGoalService } from '#/agent/goal/goal';
import { IAgentGoalService } from '#/agent/goal/goal';
import type { GoalBudgetLimits } from '#/agent/goal/types';
import DESCRIPTION from './set-goal-budget.md?raw';
@ -33,7 +33,7 @@ export class SetGoalBudgetTool implements BuiltinTool<SetGoalBudgetToolInput> {
readonly description: string = DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(SetGoalBudgetToolInputSchema);
constructor(private readonly goal: IAgentGoalService) {}
constructor(@IAgentGoalService private readonly goal: IAgentGoalService) {}
resolveExecution(args: SetGoalBudgetToolInput): ToolExecution {
const normalizedArgs = normalizeBudgetInput(args);

View file

@ -14,7 +14,7 @@ import { z } from 'zod';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import type { BuiltinTool, ToolExecution } from '#/agent/tool';
import type { IAgentGoalService } from '#/agent/goal/goal';
import { IAgentGoalService } from '#/agent/goal/goal';
import DESCRIPTION from './update-goal.md?raw';
export const UpdateGoalToolInputSchema = z
@ -32,7 +32,7 @@ export class UpdateGoalTool implements BuiltinTool<UpdateGoalToolInput> {
readonly description: string = DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(UpdateGoalToolInputSchema);
constructor(private readonly goal: IAgentGoalService) {}
constructor(@IAgentGoalService private readonly goal: IAgentGoalService) {}
resolveExecution(args: UpdateGoalToolInput): ToolExecution {
return {

View file

@ -1,8 +1,11 @@
/**
* `plan` domain barrel re-exports the plan contract (`plan`) and its scoped
* service (`planService`). Importing this barrel registers the `IAgentPlanService`
* binding into the scope registry.
* service (`planService`), plus the `planTools` registrar. Importing this barrel
* registers the `IAgentPlanService` and `IAgentPlanToolsService` bindings into the
* scope registry.
*/
export * from './plan';
export * from './planService';
export * from './planTools';
export * from './planToolsService';

View file

@ -11,24 +11,16 @@ import {
import {
Disposable,
} from "#/_base/di";
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { generateHeroSlug } from "#/_base/utils/hero-slug";
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
import { IAgentContextInjectorService } from '#/agent/contextInjector';
import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IAgentProfileService } from '#/agent/profile';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentRecordService } from '#/agent/record';
import type { ToolInputDisplay } from '@moonshot-ai/protocol';
import type { ExecutableToolResult } from '#/agent/tool';
import { EnterPlanModeInputSchema } from '#/agent/plan/tools/enter-plan-mode';
import ENTER_PLAN_MODE_DESCRIPTION from './tools/enter-plan-mode.md?raw';
import {
ExitPlanModeInputSchema,
type ExitPlanModeInput,
} from '#/agent/plan/tools/exit-plan-mode';
import EXIT_PLAN_MODE_DESCRIPTION from './tools/exit-plan-mode.md?raw';
import { type ExitPlanModeInput } from '#/agent/plan/tools/exit-plan-mode';
import {
IAgentPlanService,
type PlanData,
@ -66,7 +58,6 @@ export class AgentPlanService extends Disposable implements IAgentPlanService {
@IAgentRecordService private readonly record: IAgentRecordService,
@ISessionAgentFileSystem private readonly agentFs: ISessionAgentFileSystem,
@IAgentProfileService private readonly profile: IAgentProfileService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
@ITelemetryService private readonly telemetry: ITelemetryService,
) {
@ -96,37 +87,6 @@ export class AgentPlanService extends Disposable implements IAgentPlanService {
}),
);
this._register(
toolRegistry.register({
name: 'EnterPlanMode',
description: ENTER_PLAN_MODE_DESCRIPTION,
parameters: toInputJsonSchema(EnterPlanModeInputSchema),
resolveExecution: () => {
return {
description: 'Requesting to enter plan mode',
approvalRule: 'EnterPlanMode',
execute: async () => this.enterPlanModeToolResult(),
};
},
}),
);
this._register(
toolRegistry.register({
name: 'ExitPlanMode',
description: EXIT_PLAN_MODE_DESCRIPTION,
parameters: toInputJsonSchema(ExitPlanModeInputSchema),
resolveExecution: async (args: unknown) => {
const input = args as ExitPlanModeInput;
return {
description: 'Presenting plan and exiting plan mode',
display: await this.resolvePlanReviewDisplay(input),
approvalRule: 'ExitPlanMode',
execute: async () => this.exitPlanModeToolResult(input),
};
},
}),
);
let wasActive = false;
this._register(
dynamicInjector.register(PLAN_MODE_INJECTION_VARIANT, async ({ lastInjectedAt: injectedAt }) => {

View file

@ -0,0 +1,16 @@
/**
* `planTools` domain (L4) `IAgentPlanToolsService` registration contract.
*
* Marker service: its implementation registers the built-in plan-mode tools
* (EnterPlanMode / ExitPlanMode) into the agent `IAgentToolRegistryService` on
* construction. Bound at Agent scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IAgentPlanToolsService {
readonly _serviceBrand: undefined;
}
export const IAgentPlanToolsService: ServiceIdentifier<IAgentPlanToolsService> =
createDecorator<IAgentPlanToolsService>('agentPlanToolsService');

View file

@ -0,0 +1,45 @@
/**
* `planTools` domain (L4) `IAgentPlanToolsService` implementation.
*
* Eager Agent-scope registration service for the built-in plan-mode tools
* (EnterPlanMode / ExitPlanMode). Each tool is a DI class created via
* `IInstantiationService.createInstance` (they inject `IAgentPlanService` and
* `ITelemetryService` themselves) and registered into the agent
* `IAgentToolRegistryService`. Eager so the tools are registered when the Agent
* scope is created, before the first turn.
*
* Split out of `AgentPlanService` so the tools can inject `IAgentPlanService`
* without forming a constructor-instantiation cycle; this also replaces the
* previous inline object-literal registrations with real DI tool classes.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentPlanToolsService } from './planTools';
import { EnterPlanModeTool } from '#/agent/plan/tools/enter-plan-mode';
import { ExitPlanModeTool } from '#/agent/plan/tools/exit-plan-mode';
export class AgentPlanToolsService extends Disposable implements IAgentPlanToolsService {
declare readonly _serviceBrand: undefined;
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
) {
super();
this._register(toolRegistry.register(instantiationService.createInstance(EnterPlanModeTool)));
this._register(toolRegistry.register(instantiationService.createInstance(ExitPlanModeTool)));
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentPlanToolsService,
AgentPlanToolsService,
InstantiationType.Eager,
'plan',
);

View file

@ -10,8 +10,8 @@ import { z } from 'zod';
import type { BuiltinTool } from '#/agent/tool';
import type { ToolExecution } from '#/agent/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import type { ITelemetryService } from '#/app/telemetry';
import type { IAgentPlanService } from '#/agent/plan/plan';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentPlanService } from '#/agent/plan/plan';
import DESCRIPTION from './enter-plan-mode.md?raw';
// ── Input schema ─────────────────────────────────────────────────────
@ -25,8 +25,8 @@ export class EnterPlanModeTool implements BuiltinTool<EnterPlanModeInput> {
readonly parameters: Record<string, unknown> = toInputJsonSchema(EnterPlanModeInputSchema);
constructor(
private readonly planMode: IAgentPlanService,
private readonly telemetry: ITelemetryService,
@IAgentPlanService private readonly planMode: IAgentPlanService,
@ITelemetryService private readonly telemetry: ITelemetryService,
) {}
resolveExecution(_args: EnterPlanModeInput): ToolExecution {

View file

@ -12,8 +12,9 @@ import { z } from 'zod';
import type { BuiltinTool } from '#/agent/tool';
import type { ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import type { ITelemetryService } from '#/app/telemetry';
import type { IAgentPlanService, PlanData } from '#/agent/plan/plan';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentPlanService } from '#/agent/plan/plan';
import type { PlanData } from '#/agent/plan/plan';
import DESCRIPTION from './exit-plan-mode.md?raw';
// ── Input schema ─────────────────────────────────────────────────────
@ -85,8 +86,8 @@ export class ExitPlanModeTool implements BuiltinTool<ExitPlanModeInput> {
readonly parameters: Record<string, unknown> = toInputJsonSchema(ExitPlanModeInputSchema);
constructor(
private readonly planMode: IAgentPlanService,
private readonly telemetry: ITelemetryService,
@IAgentPlanService private readonly planMode: IAgentPlanService,
@ITelemetryService private readonly telemetry: ITelemetryService,
) {}
async resolveExecution(args: ExitPlanModeInput): Promise<ToolExecution> {

View file

@ -1,32 +1,32 @@
/**
* `questionTools` domain (L7) `IAgentQuestionToolsService` implementation.
*
* Registers the built-in `AskUserQuestion` tool into the agent `IAgentToolRegistryService`
* on construction, wiring it to the session `ISessionQuestionService` (ask-user
* broker), the agent `IAgentBackgroundService` (background-question lifecycle) and
* `ITelemetryService`. Bound at Agent scope.
* Eager Agent-scope registration service for the built-in `AskUserQuestion` tool.
* The tool is a DI class created via `IInstantiationService.createInstance` and
* registered into the agent `IAgentToolRegistryService`. Eager so the tool is
* registered when the Agent scope is created, before the first turn.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentBackgroundService } from '#/agent/background';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { ISessionQuestionService } from '#/session/question/question';
import { IAgentQuestionToolsService } from './questionTools';
import { AskUserQuestionTool } from '#/agent/questionTools/tools/ask-user';
export class AgentQuestionToolsService implements IAgentQuestionToolsService {
export class AgentQuestionToolsService extends Disposable implements IAgentQuestionToolsService {
declare readonly _serviceBrand: undefined;
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@ISessionQuestionService question: ISessionQuestionService,
@IAgentBackgroundService background: IAgentBackgroundService,
@ITelemetryService telemetry: ITelemetryService,
) {
toolRegistry.register(new AskUserQuestionTool(question, background, telemetry));
super();
this._register(
toolRegistry.register(instantiationService.createInstance(AskUserQuestionTool)),
);
}
}
@ -34,6 +34,6 @@ registerScopedService(
LifecycleScope.Agent,
IAgentQuestionToolsService,
AgentQuestionToolsService,
InstantiationType.Delayed,
InstantiationType.Eager,
'questionTools',
);

View file

@ -11,8 +11,9 @@
import { z } from 'zod';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { QuestionBackgroundTask, type IAgentBackgroundService } from '#/agent/background';
import type { ITelemetryService, TelemetryProperties } from '#/app/telemetry';
import { QuestionBackgroundTask, IAgentBackgroundService } from '#/agent/background';
import { ITelemetryService } from '#/app/telemetry';
import type { TelemetryProperties } from '#/app/telemetry';
import type {
BuiltinTool,
ExecutableToolContext,
@ -20,8 +21,8 @@ import type {
ToolExecution,
} from '#/agent/tool';
import { ISessionQuestionService } from '#/session/question/question';
import type {
ISessionQuestionService,
QuestionAnswers,
QuestionAnswerMethod,
QuestionResponse,
@ -99,9 +100,9 @@ export class AskUserQuestionTool implements BuiltinTool<AskUserQuestionInput> {
);
constructor(
private readonly question: ISessionQuestionService,
private readonly background: IAgentBackgroundService,
private readonly telemetry: ITelemetryService,
@ISessionQuestionService private readonly question: ISessionQuestionService,
@IAgentBackgroundService private readonly background: IAgentBackgroundService,
@ITelemetryService private readonly telemetry: ITelemetryService,
) {}
resolveExecution(args: AskUserQuestionInput): ToolExecution {

View file

@ -5,7 +5,6 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentBackgroundService } from '#/agent/background';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentContextSizeService } from '#/agent/contextSize';
import { IAgentFileToolsService } from '#/agent/fileTools';
import { IAgentFullCompactionService } from '#/agent/fullCompaction';
import { IAgentGoalService } from '#/agent/goal';
import { IAgentRecordService } from '#/agent/record';
@ -14,16 +13,11 @@ import { userCancellationReason } from '#/_base/utils/abort';
import { IAgentPermissionGate } from '#/agent/permissionGate';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import { IAgentPlanService } from '#/agent/plan';
import { IExecContext } from '#/session/execContext';
import { expandCommandArguments, IPluginService } from '#/app/plugin';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentPromptService } from '#/agent/prompt';
import { IAgentQuestionToolsService } from '#/agent/questionTools';
import { ISessionMetadata, type SessionMetaPatch } from '#/session/sessionMetadata';
import { BashTool, IAgentShellToolsService } from '#/agent/shellTools';
import { IAgentSkillService } from '#/agent/skill';
import { ISessionProcessRunner } from '#/session/process';
import { IAgentToolService } from '#/agent/agentTool';
import { IAgentSwarmService } from '#/agent/swarm';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
@ -31,7 +25,6 @@ import type { ToolUpdate } from '#/agent/tool';
import { IAgentTurnService } from '#/agent/turn';
import { IAgentUsageService } from '#/agent/usage';
import { IAgentUserToolService } from '#/agent/userTool';
import { IAgentWebService } from '#/agent/web';
import type {
ActivatePluginCommandPayload,
ActivateSkillPayload,
@ -82,21 +75,14 @@ export class AgentRPCService implements IAgentRPCService {
@IAgentFullCompactionService private readonly fullCompaction: IAgentFullCompactionService,
@IAgentUserToolService private readonly userTools: IAgentUserToolService,
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
@IAgentFileToolsService private readonly fileTools: IAgentFileToolsService,
@IAgentShellToolsService private readonly shellTools: IAgentShellToolsService,
@ISessionProcessRunner private readonly processRunner: ISessionProcessRunner,
@IExecContext private readonly execContext: IExecContext,
@IAgentBackgroundService private readonly background: IAgentBackgroundService,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentContextSizeService private readonly contextSize: IAgentContextSizeService,
@IAgentSkillService private readonly skills: IAgentSkillService,
@IAgentToolService private readonly agentTool: IAgentToolService,
@IAgentUsageService private readonly usage: IAgentUsageService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentGoalService private readonly goal: IAgentGoalService,
@IAgentRecordService private readonly record: IAgentRecordService,
@IAgentQuestionToolsService private readonly questionTools: IAgentQuestionToolsService,
@IAgentWebService private readonly web: IAgentWebService,
@IPluginService private readonly plugins: IPluginService,
@ISessionMetadata private readonly metadata: ISessionMetadata,
) { }
@ -111,10 +97,10 @@ export class AgentRPCService implements IAgentRPCService {
}
private ensureBashTool() {
const existing = this.toolRegistry.resolve('Bash');
if (existing !== undefined) return existing;
const bash = new BashTool(this.processRunner, this.execContext, this.background);
this.toolRegistry.register(bash);
const bash = this.toolRegistry.resolve('Bash');
if (bash === undefined) {
throw new Error('Bash tool is not registered.');
}
return bash;
}

View file

@ -1,39 +1,30 @@
/**
* `shellTools` domain (L4) `IAgentShellToolsService` implementation.
*
* Registers the built-in Bash tool into the agent `IAgentToolRegistryService` on
* construction, wiring it to the session `ISessionProcessRunner` (process spawn),
* `IHostEnvironment` (OS / shell probe), `IExecContext` (session cwd) and
* `IAgentBackgroundService` (background-task lifecycle). Bound at Agent scope.
* Eager Agent-scope registration service for the built-in Bash tool. The tool is
* a DI class created via `IInstantiationService.createInstance` and registered
* into the agent `IAgentToolRegistryService`. Eager so Bash is registered when
* the Agent scope is created, before the first turn.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentBackgroundService } from '#/agent/background';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { IExecContext } from '#/session/execContext';
import { ISessionProcessRunner } from '#/session/process';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentShellToolsService } from './shellTools';
import { BashTool } from '#/agent/shellTools/tools/bash';
export class AgentShellToolsService implements IAgentShellToolsService {
export class AgentShellToolsService extends Disposable implements IAgentShellToolsService {
declare readonly _serviceBrand: undefined;
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@ISessionProcessRunner runner: ISessionProcessRunner,
@IHostEnvironment env: IHostEnvironment,
@IExecContext ctx: IExecContext,
@IAgentBackgroundService background: IAgentBackgroundService,
@IAgentProfileService profile: IAgentProfileService,
) {
toolRegistry.register(new BashTool(runner, env, ctx, background, {
allowBackground: () =>
profile.isToolActive('TaskOutput') && profile.isToolActive('TaskStop'),
}));
super();
this._register(toolRegistry.register(instantiationService.createInstance(BashTool)));
}
}
@ -41,6 +32,6 @@ registerScopedService(
LifecycleScope.Agent,
IAgentShellToolsService,
AgentShellToolsService,
InstantiationType.Delayed,
InstantiationType.Eager,
'shellTools',
);

View file

@ -32,11 +32,12 @@
import { z } from 'zod';
import { ProcessBackgroundTask } from '#/agent/background';
import type { IAgentBackgroundService } from '#/agent/background';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { IExecContext } from '#/session/execContext';
import type { IProcess, ISessionProcessRunner } from '#/session/process';
import { ProcessBackgroundTask, IAgentBackgroundService } from '#/agent/background';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { IExecContext } from '#/session/execContext';
import { ISessionProcessRunner } from '#/session/process';
import type { IProcess } from '#/session/process';
import { IAgentProfileService } from '#/agent/profile';
import type { BuiltinTool, ExecutableToolResult, ToolExecution, ToolUpdate } from '#/agent/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '#/_base/tools/support/rule-match';
@ -164,22 +165,22 @@ export class BashTool implements BuiltinTool<BashInput> {
private readonly isWindowsBash: boolean;
private readonly renderedDescription: string;
private readonly allowBackground: () => boolean;
constructor(
private readonly runner: ISessionProcessRunner,
private readonly env: IHostEnvironment,
private readonly ctx: IExecContext,
private readonly background: IAgentBackgroundService,
options?: {
allowBackground?: () => boolean;
},
@ISessionProcessRunner private readonly runner: ISessionProcessRunner,
@IHostEnvironment private readonly env: IHostEnvironment,
@IExecContext private readonly ctx: IExecContext,
@IAgentBackgroundService private readonly background: IAgentBackgroundService,
@IAgentProfileService private readonly profile: IAgentProfileService,
) {
this.isWindowsBash = this.env.osKind === 'Windows';
this.allowBackground = options?.allowBackground ?? (() => true);
this.renderedDescription = renderBashDescription(this.env.shellName);
}
private allowBackground(): boolean {
return this.profile.isToolActive('TaskOutput') && this.profile.isToolActive('TaskStop');
}
get description(): string {
return this.allowBackground()
? this.renderedDescription

View file

@ -1,8 +1,11 @@
/**
* `skill` domain barrel re-exports the agent skill contract and its
* Agent-scope service. Importing this barrel registers the
* `IAgentSkillService` binding into the scope registry.
* Agent-scope service, plus the `skillTools` registrar. Importing this barrel
* registers the `IAgentSkillService` and `IAgentSkillToolsService` bindings into
* the scope registry.
*/
export * from './skill';
export * from './skillService';
export * from './skillTools';
export * from './skillToolsService';

View file

@ -1,4 +1,5 @@
import { createDecorator } from "#/_base/di";
import type { SkillActivationOrigin } from '#/agent/contextMemory';
import type { Turn } from '#/agent/turn';
export interface SkillActivationInput {
@ -10,6 +11,14 @@ export interface IAgentSkillService {
readonly _serviceBrand: undefined;
activate(input: SkillActivationInput): Promise<Turn>;
/**
* Records a model-tool skill activation (an inline skill loaded through the
* `Skill` tool) without opening a new turn the tool builds and steers its
* own message into the current turn. Publishes the activation and emits
* telemetry, matching the user-slash `activate` path's side effects.
*/
recordModelToolActivation(origin: SkillActivationOrigin): void;
}
export const IAgentSkillService =

View file

@ -11,12 +11,10 @@ import { ErrorCodes, KimiError } from "#/errors";
import { isUserActivatableSkillType, type SkillDefinition } from '#/app/globalSkillCatalog/types';
import { IAgentPromptService } from '#/agent/prompt';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import type { Turn } from '#/agent/turn';
import { IAgentRecordService } from '#/agent/record';
import { IAgentSkillService, type SkillActivationInput } from './skill';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
import { SkillTool, type SkillToolDeps } from '#/agent/skill/tools/skill';
declare module '#/agent/wireRecord' {
interface WireRecordMap {
@ -34,7 +32,6 @@ export class AgentSkillService extends Disposable implements IAgentSkillService
@IAgentPromptService private readonly prompt: IAgentPromptService,
@IAgentRecordService private readonly records: IAgentRecordService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
) {
super();
this._register(
@ -44,7 +41,6 @@ export class AgentSkillService extends Disposable implements IAgentSkillService
},
}),
);
this._register(toolRegistry.register(new SkillTool(this.skillToolDeps())));
}
async activate(input: SkillActivationInput): Promise<Turn> {
@ -90,12 +86,8 @@ export class AgentSkillService extends Disposable implements IAgentSkillService
)!;
}
protected skillToolDeps(): SkillToolDeps {
return {
catalog: this.skillCatalog,
prompt: this.prompt,
recordActivation: (origin) => this.recordActivation(origin),
};
recordModelToolActivation(origin: SkillActivationOrigin): void {
this.recordActivation(origin);
}
private recordActivation(

View file

@ -0,0 +1,16 @@
/**
* `skillTools` domain (L4) `IAgentSkillToolsService` registration contract.
*
* Marker service: its implementation registers the built-in `Skill` collaboration
* tool into the agent `IAgentToolRegistryService` on construction. Bound at Agent
* scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IAgentSkillToolsService {
readonly _serviceBrand: undefined;
}
export const IAgentSkillToolsService: ServiceIdentifier<IAgentSkillToolsService> =
createDecorator<IAgentSkillToolsService>('agentSkillToolsService');

View file

@ -0,0 +1,43 @@
/**
* `skillTools` domain (L4) `IAgentSkillToolsService` implementation.
*
* Eager Agent-scope registration service for the built-in `Skill` collaboration
* tool. The tool is a DI class created via `IInstantiationService.createInstance`
* (it injects `ISessionSkillCatalog`, `IAgentPromptService` and
* `IAgentSkillService` itself) and registered into the agent
* `IAgentToolRegistryService`. Eager so the tool is registered when the Agent
* scope is created, before the first turn.
*
* Split out of `AgentSkillService` so the tool can inject `IAgentSkillService`
* without forming a constructor-instantiation cycle, and so the previous
* `recordActivation` closure can be replaced by a direct service call.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentSkillToolsService } from './skillTools';
import { SkillTool } from '#/agent/skill/tools/skill';
export class AgentSkillToolsService extends Disposable implements IAgentSkillToolsService {
declare readonly _serviceBrand: undefined;
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
) {
super();
this._register(toolRegistry.register(instantiationService.createInstance(SkillTool)));
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentSkillToolsService,
AgentSkillToolsService,
InstantiationType.Eager,
'skill',
);

View file

@ -23,12 +23,13 @@ import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import type { ContextMessage, SkillActivationOrigin } from '#/agent/contextMemory';
import type { IAgentPromptService } from '#/agent/prompt';
import { IAgentPromptService } from '#/agent/prompt';
import { IAgentSkillService } from '#/agent/skill/skill';
import { renderModelToolSkillPrompt } from '#/agent/skill/prompt';
import type { BuiltinTool } from '#/agent/tool';
import type { ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { isInlineSkillType } from '#/app/globalSkillCatalog/types';
import type { ISessionSkillCatalog } from '#/session/sessionSkillCatalog';
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog';
import { renderPrompt } from '#/_base/utils/render-prompt';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { matchesGlobRuleSubject } from '#/_base/tools/support/rule-match';
@ -61,24 +62,6 @@ export const SkillToolInputSchema: z.ZodType<SkillToolInput> = z.object({
args: z.string().optional(),
});
export interface SkillToolOptions {
/**
* Current inline skill recursion depth.
*/
readonly queryDepth?: number;
/**
* Alias for `queryDepth`. Kept so older call sites can seed the
* inline recursion depth without knowing the internal field name.
*/
readonly initialQueryDepth?: number;
}
export interface SkillToolDeps {
readonly catalog: ISessionSkillCatalog;
readonly prompt: IAgentPromptService;
readonly recordActivation: (origin: SkillActivationOrigin) => void;
}
export class SkillTool implements BuiltinTool<SkillToolInput> {
readonly name = 'Skill';
readonly description: string = renderPrompt(skillDescriptionTemplate, {
@ -86,9 +69,17 @@ export class SkillTool implements BuiltinTool<SkillToolInput> {
});
readonly parameters: Record<string, unknown> = toInputJsonSchema(SkillToolInputSchema);
/**
* Current inline-skill recursion depth. Zero for the root tool; set on clones
* produced by `withInitialQueryDepth` so a SkillSkill chain cannot recurse
* past `MAX_SKILL_QUERY_DEPTH`.
*/
private queryDepth: number = 0;
constructor(
private readonly deps: SkillToolDeps,
private readonly options: SkillToolOptions = {},
@ISessionSkillCatalog private readonly catalog: ISessionSkillCatalog,
@IAgentPromptService private readonly prompt: IAgentPromptService,
@IAgentSkillService private readonly skill: IAgentSkillService,
) {}
resolveExecution(args: SkillToolInput): ToolExecution {
@ -102,34 +93,35 @@ export class SkillTool implements BuiltinTool<SkillToolInput> {
}
withInitialQueryDepth(initialQueryDepth: number): SkillTool {
return new SkillTool(this.deps, {
...this.options,
initialQueryDepth,
});
const clone = new SkillTool(this.catalog, this.prompt, this.skill);
clone.queryDepth = initialQueryDepth;
return clone;
}
private async execution(args: SkillToolInput): Promise<ExecutableToolResult> {
return executeModelSkill(this.deps, args, this.options);
return executeModelSkill(this.catalog, this.prompt, this.skill, args, this.queryDepth);
}
}
export async function executeModelSkill(
deps: SkillToolDeps,
catalog: ISessionSkillCatalog,
prompt: IAgentPromptService,
skillService: IAgentSkillService,
args: SkillToolInput,
options: SkillToolOptions,
queryDepth: number,
): Promise<ExecutableToolResult> {
// Recursion hard cap. Once `currentDepth` has reached
// MAX_SKILL_QUERY_DEPTH, firing another Skill call would push the
// child to depth+1 which violates the invariant. Throw a structured
// error (rather than a soft tool-error) so Runtime can distinguish
// "LLM mis-dispatched" from "safety net fired".
const currentDepth = options.initialQueryDepth ?? options.queryDepth ?? 0;
const currentDepth = queryDepth;
if (currentDepth >= MAX_SKILL_QUERY_DEPTH) {
throw new NestedSkillTooDeepError(MAX_SKILL_QUERY_DEPTH, args.skill);
}
await deps.catalog.ready;
const skill = deps.catalog.catalog.getSkill(args.skill);
await catalog.ready;
const skill = catalog.catalog.getSkill(args.skill);
if (skill === undefined) {
return errorResult(`Skill "${args.skill}" not found in the current skill listing.`);
}
@ -158,7 +150,7 @@ export async function executeModelSkill(
skillPath: skill.path,
skillSource: skill.source,
};
const skillContent = deps.catalog.catalog.renderSkillPrompt(skill, skillArgs);
const skillContent = catalog.catalog.renderSkillPrompt(skill, skillArgs);
const message: ContextMessage = {
role: 'user',
content: [
@ -177,8 +169,8 @@ export async function executeModelSkill(
toolCalls: [],
origin,
};
deps.recordActivation(origin);
deps.prompt.steer(message);
skillService.recordModelToolActivation(origin);
prompt.steer(message);
return {
output: `Skill "${skill.name}" loaded inline. Follow its instructions.`,
};

View file

@ -1,8 +1,11 @@
/**
* `swarm` domain barrel re-exports the swarm contract (`swarm`) and its
* scoped service (`swarmService`). Importing this barrel registers the
* `IAgentSwarmService` binding into the scope registry.
* scoped service (`swarmService`), plus the `swarmTools` registrar. Importing
* this barrel registers the `IAgentSwarmService` and `IAgentSwarmToolsService`
* bindings into the scope registry.
*/
export * from './swarm';
export * from './swarmService';
export * from './swarmTools';
export * from './swarmToolsService';

View file

@ -2,24 +2,18 @@
* `swarm` domain (L4) `IAgentSwarmService` implementation.
*
* Tracks swarm-mode enter/exit (mirroring it into `wireRecord` and
* `systemReminder`), auto-exits on turn end, and registers the `AgentSwarm`
* tool bound to this agent as the caller. Bound at Agent scope; reads its
* identity through `scopeContext`, registers the tool through `toolRegistry`,
* and delegates batch runs to the Session-scoped `sessionSwarm` service.
* `systemReminder`) and auto-exits on turn end. Bound at Agent scope. The
* `AgentSwarm` tool is registered separately by `AgentSwarmToolsService`.
*/
import { Disposable } from '#/_base/di';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentRecordService } from '#/agent/record';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentTurnService } from '#/agent/turn';
import { ISessionSwarmService } from '#/session/swarm';
import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw';
import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw';
import { AgentSwarmTool } from '#/agent/swarm/tools/agent-swarm';
import {
IAgentSwarmService,
type SwarmModeTrigger,
@ -43,9 +37,6 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService
@IAgentRecordService private readonly record: IAgentRecordService,
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@IAgentTurnService turnService: IAgentTurnService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentScopeContext ctx: IAgentScopeContext,
@ISessionSwarmService swarmService: ISessionSwarmService,
) {
super();
this._register(
@ -71,11 +62,6 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService
return done;
}),
);
this._register(
toolRegistry.register(
new AgentSwarmTool({ swarmService, callerAgentId: ctx.agentId }, this),
),
);
}
enter(trigger: SwarmModeTrigger): void {

View file

@ -0,0 +1,16 @@
/**
* `swarmTools` domain (L4) `IAgentSwarmToolsService` registration contract.
*
* Marker service: its implementation registers the built-in `AgentSwarm`
* collaboration tool into the agent `IAgentToolRegistryService` on construction.
* Bound at Agent scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IAgentSwarmToolsService {
readonly _serviceBrand: undefined;
}
export const IAgentSwarmToolsService: ServiceIdentifier<IAgentSwarmToolsService> =
createDecorator<IAgentSwarmToolsService>('agentSwarmToolsService');

View file

@ -0,0 +1,43 @@
/**
* `swarmTools` domain (L4) `IAgentSwarmToolsService` implementation.
*
* Eager Agent-scope registration service for the built-in `AgentSwarm`
* collaboration tool. The tool is a DI class created via
* `IInstantiationService.createInstance` (it injects `ISessionSwarmService` for
* batch runs, `IAgentScopeContext` for the caller identity, and
* `IAgentSwarmService` to enter swarm mode) and registered into the agent
* `IAgentToolRegistryService`. Eager so the tool is registered when the Agent
* scope is created, before the first turn.
*
* Split out of `AgentSwarmService` so the tool can inject `IAgentSwarmService`
* without forming a constructor-instantiation cycle.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentSwarmToolsService } from './swarmTools';
import { AgentSwarmTool } from '#/agent/swarm/tools/agent-swarm';
export class AgentSwarmToolsService extends Disposable implements IAgentSwarmToolsService {
declare readonly _serviceBrand: undefined;
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
) {
super();
this._register(toolRegistry.register(instantiationService.createInstance(AgentSwarmTool)));
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentSwarmToolsService,
AgentSwarmToolsService,
InstantiationType.Eager,
'swarm',
);

View file

@ -17,7 +17,10 @@ import {
import { ToolAccesses } from '#/agent/tool';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import type { ISessionSwarmService, SessionSwarmTask } from '#/session/swarm';
import { ISessionSwarmService } from '#/session/swarm';
import type { SessionSwarmTask } from '#/session/swarm';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IAgentSwarmService } from '#/agent/swarm/swarm';
import AGENT_SWARM_DESCRIPTION from './agent-swarm.md?raw';
const DEFAULT_SUBAGENT_TYPE = 'coder';
@ -93,24 +96,20 @@ interface SwarmRunResult {
readonly error?: string;
}
interface AgentSwarmMode {
enter(trigger: 'tool'): void;
}
export interface AgentSwarmToolHost {
readonly swarmService: ISessionSwarmService;
readonly callerAgentId: string;
}
export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
readonly name = 'AgentSwarm' as const;
readonly description = AGENT_SWARM_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(AgentSwarmToolInputSchema);
private readonly callerAgentId: string;
constructor(
private readonly host: AgentSwarmToolHost,
private readonly swarmMode: AgentSwarmMode,
) {}
@ISessionSwarmService private readonly swarmService: ISessionSwarmService,
@IAgentScopeContext scopeContext: IAgentScopeContext,
@IAgentSwarmService private readonly swarmMode: IAgentSwarmService,
) {
this.callerAgentId = scopeContext.agentId;
}
resolveExecution(args: AgentSwarmToolInput): ToolExecution {
const agentCount = (args.items?.length ?? 0) + Object.keys(args.resume_agent_ids ?? {}).length;
@ -178,8 +177,8 @@ export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
kind: 'spawn' as const,
};
});
const results = await this.host.swarmService.run({
callerAgentId: this.host.callerAgentId,
const results = await this.swarmService.run({
callerAgentId: this.callerAgentId,
tasks,
});
for (const result of results) {

View file

@ -1,6 +1,7 @@
import {
Disposable,
} from "#/_base/di";
import { IInstantiationService } from "#/_base/di/instantiation";
import {
TODO_LIST_TOOL_NAME,
TODO_STORE_KEY,
@ -30,9 +31,10 @@ export class AgentTodoListService extends Disposable implements IAgentTodoListSe
@IAgentToolStoreService private readonly toolStore: IAgentToolStoreService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
) {
super();
this._register(toolRegistry.register(new TodoListTool(toolStore)));
this._register(toolRegistry.register(instantiationService.createInstance(TodoListTool)));
this._register(
dynamicInjector.register(TODO_LIST_REMINDER_VARIANT, () => this.staleReminder()),
);

View file

@ -18,7 +18,7 @@ import { z } from 'zod';
import type { BuiltinTool } from '#/agent/tool';
import type { ToolExecution } from '#/agent/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import type { ToolStore } from '#/agent/toolStore';
import { IAgentToolStoreService } from '#/agent/toolStore';
import DESCRIPTION from './todo-list.md?raw';
import TODO_LIST_WRITE_REMINDER from './todo-list-write-reminder.md?raw';
@ -111,7 +111,7 @@ export class TodoListTool implements BuiltinTool<TodoListInput> {
readonly description: string = DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(TodoListInputSchema);
constructor(private readonly store: ToolStore) {}
constructor(@IAgentToolStoreService private readonly store: IAgentToolStoreService) {}
resolveExecution(args: TodoListInput): ToolExecution {
const description =

View file

@ -1,14 +1,18 @@
/**
* `web` domain (L4) `IAgentWebService` implementation.
*
* Registers the built-in web tools into the agent `IAgentToolRegistryService` on
* construction: `FetchURL` is always registered (using the injected
* `UrlFetcher` or the built-in `LocalFetchURLProvider` fallback); `WebSearch`
* is registered only when a `WebSearchProvider` is supplied via options, since
* there is no local search backend. Bound at Agent scope.
* Eager Agent-scope registration service for the built-in web tools: `FetchURL`
* is always registered (using the host-injected `UrlFetcher` or the built-in
* `LocalFetchURLProvider` fallback); `WebSearch` is registered only when a
* `WebSearchProvider` is supplied via options. Each tool is created via
* `IInstantiationService.createInstance` (the provider is passed as a leading
* static argument) and registered into the agent `IAgentToolRegistryService`.
* Eager so the tools are registered when the Agent scope is created.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { IInstantiationService } from '#/_base/di/instantiation';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
@ -17,17 +21,25 @@ import { FetchURLTool } from '#/agent/web/tools/fetch-url';
import { WebSearchTool } from '#/agent/web/tools/web-search';
import { IAgentWebService, type WebServiceOptions } from './web';
export class AgentWebService implements IAgentWebService {
export class AgentWebService extends Disposable implements IAgentWebService {
declare readonly _serviceBrand: undefined;
constructor(
private readonly options: WebServiceOptions = {},
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
) {
super();
const fetcher = options.urlFetcher ?? new LocalFetchURLProvider();
toolRegistry.register(new FetchURLTool(fetcher));
this._register(
toolRegistry.register(instantiationService.createInstance(FetchURLTool, fetcher)),
);
if (options.webSearcher !== undefined) {
toolRegistry.register(new WebSearchTool(options.webSearcher));
this._register(
toolRegistry.register(
instantiationService.createInstance(WebSearchTool, options.webSearcher),
),
);
}
}
}
@ -36,6 +48,6 @@ registerScopedService(
LifecycleScope.Agent,
IAgentWebService,
AgentWebService,
InstantiationType.Delayed,
InstantiationType.Eager,
'web',
);

View file

@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { userCancellationReason } from '#/_base/utils/abort';
import { IAgentBackgroundService } from '#/agent/background';
import type { ILogger, LogPayload } from '#/app/log';
import { ILogService } from '#/app/log';
import type { LogPayload } from '#/app/log';
import { IAgentProfileService } from '#/agent/profile';
import { createExecContext } from '#/session/execContext';
import {
AgentTool,
AgentToolInputSchema,
@ -12,6 +14,7 @@ import {
} from '#/agent/agentTool';
import { ToolAccesses } from '#/agent/tool';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { ISessionMetadata } from '#/session/sessionMetadata';
import { executeTool } from '../tools/fixtures/execute-tool';
import {
agentToolServices,
@ -33,16 +36,20 @@ function context<Input>(args: Input, toolCallId = 'call_agent') {
}
function createLogCapture(): {
readonly logger: ILogger;
readonly logger: ILogService;
readonly entries: CapturedLogEntry[];
} {
const entries: CapturedLogEntry[] = [];
const logger: ILogger = {
const logger: ILogService = {
_serviceBrand: undefined,
level: 'info',
error: (message, payload) => entries.push({ level: 'error', message, payload }),
warn: (message, payload) => entries.push({ level: 'warn', message, payload }),
info: (message, payload) => entries.push({ level: 'info', message, payload }),
debug: (message, payload) => entries.push({ level: 'debug', message, payload }),
child: () => logger,
setLevel: () => {},
flush: () => Promise.resolve(),
};
return { logger, entries };
}
@ -98,7 +105,7 @@ describe('AgentTool direct contract', () => {
readonly run?: AgentToolRunOverride;
readonly maxRunningTasks?: number;
readonly isToolActive?: (name: string) => boolean;
readonly log?: ILogger;
readonly log?: ILogService;
} = {}): {
readonly ctx: TestAgentContext;
readonly background: IAgentBackgroundService;
@ -113,20 +120,22 @@ describe('AgentTool direct contract', () => {
});
contexts.push(ctx);
const background = ctx.get(IAgentBackgroundService);
const logService = log ?? ctx.get(ILogService);
return {
ctx,
background,
run,
tool: new AgentTool({
lifecycle: fakeLifecycle(),
callerAgentId: PARENT_AGENT_ID,
tool: new AgentTool(
run,
fakeLifecycle(),
{ _serviceBrand: undefined, agentId: PARENT_AGENT_ID },
ctx.get(ISessionMetadata),
background,
profile: fakeProfile(isToolActive),
cwd: '/repo',
processRunner: fakeProcessRunner(),
log,
runOverride: run,
}),
fakeProfile(isToolActive),
createExecContext('/repo'),
fakeProcessRunner(),
logService,
),
};
}

View file

@ -96,7 +96,7 @@ describe('Cron — session E2E (P1.9)', () => {
// bypass `emitScheduled` telemetry and skip the byte-length /
// expression checks; that would not be the production code path
// this commit is meant to smoke.
const createTool = new CronCreateTool(cron);
const createTool = new CronCreateTool(false, cron);
const execution = createTool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'cron-fired prompt',
@ -149,7 +149,7 @@ describe('Cron — session E2E (P1.9)', () => {
// Optional second case from the P1.9 plan: prove the three-tool
// surface composes correctly end-to-end on the real manager. No
// clock manipulation needed — list/delete are time-invariant.
const createTool = new CronCreateTool(cron);
const createTool = new CronCreateTool(false, cron);
const listTool = new CronListTool(cron);
const deleteTool = new CronDeleteTool(cron);
const ctxArgs = {

View file

@ -187,7 +187,7 @@ function pad(value: number): string {
describe('CronCreateTool', () => {
it('schedules a recurring task and emits scheduled telemetry through the manager', async () => {
const harness = createToolHarness();
const tool = new CronCreateTool(harness.cron);
const tool = new CronCreateTool(false, harness.cron);
const out = assertSuccess(
await runTool<CronCreateInput>(tool, {
@ -217,7 +217,7 @@ describe('CronCreateTool', () => {
it('stores explicit one-shot tasks with recurring=false', async () => {
const harness = createToolHarness();
const tool = new CronCreateTool(harness.cron);
const tool = new CronCreateTool(false, harness.cron);
const out = assertSuccess(
await runTool<CronCreateInput>(tool, {
@ -237,7 +237,7 @@ describe('CronCreateTool', () => {
it('returns an error when scheduling is disabled', async () => {
const harness = createToolHarness();
const tool = new CronCreateTool(harness.cron, true);
const tool = new CronCreateTool(true, harness.cron);
const output = assertError(
await runTool<CronCreateInput>(tool, {
@ -253,7 +253,7 @@ describe('CronCreateTool', () => {
it('rejects an unparseable cron expression', async () => {
const harness = createToolHarness();
const tool = new CronCreateTool(harness.cron);
const tool = new CronCreateTool(false, harness.cron);
const output = assertError(
await runTool<CronCreateInput>(tool, {
@ -269,7 +269,7 @@ describe('CronCreateTool', () => {
it('rejects a legal expression that has no fire inside the supported window', async () => {
const harness = createToolHarness();
const tool = new CronCreateTool(harness.cron);
const tool = new CronCreateTool(false, harness.cron);
const output = assertError(
await runTool<CronCreateInput>(tool, {
@ -284,7 +284,7 @@ describe('CronCreateTool', () => {
it('refuses to schedule past the session cap', async () => {
const harness = createToolHarness();
const tool = new CronCreateTool(harness.cron);
const tool = new CronCreateTool(false, harness.cron);
for (let i = 0; i < MAX_CRON_JOBS_PER_SESSION; i++) {
harness.store.add({ cron: '*/5 * * * *', prompt: `seed-${i}`, recurring: true }, harness.now());
@ -303,7 +303,7 @@ describe('CronCreateTool', () => {
it('rechecks the session cap inside execute', async () => {
const harness = createToolHarness();
const tool = new CronCreateTool(harness.cron);
const tool = new CronCreateTool(false, harness.cron);
for (let i = 0; i < MAX_CRON_JOBS_PER_SESSION - 1; i++) {
harness.store.add({ cron: '*/5 * * * *', prompt: `seed-${i}`, recurring: true }, harness.now());
@ -340,7 +340,7 @@ describe('CronCreateTool', () => {
it('rejects prompts over the UTF-8 byte budget', async () => {
const harness = createToolHarness();
const tool = new CronCreateTool(harness.cron);
const tool = new CronCreateTool(false, harness.cron);
const prompt = '\u4f60'.repeat(3000);
const output = assertError(
@ -356,7 +356,7 @@ describe('CronCreateTool', () => {
it('normalizes cron field whitespace before storing and rendering', async () => {
const harness = createToolHarness();
const tool = new CronCreateTool(harness.cron);
const tool = new CronCreateTool(false, harness.cron);
const out = assertSuccess(
await runTool<CronCreateInput>(tool, {
@ -373,7 +373,7 @@ describe('CronCreateTool', () => {
it('uses the execution-time clock for createdAt', async () => {
const harness = createToolHarness();
const tool = new CronCreateTool(harness.cron);
const tool = new CronCreateTool(false, harness.cron);
const execution = await tool.resolveExecution({
cron: '*/5 * * * *',
prompt: 'delayed approval',
@ -393,7 +393,7 @@ describe('CronCreateTool', () => {
it('includes the normalized payload in the approval rule', async () => {
const harness = createToolHarness();
const tool = new CronCreateTool(harness.cron);
const tool = new CronCreateTool(false, harness.cron);
const a = await tool.resolveExecution({
cron: '*/5\n* * * *',

View file

@ -11,14 +11,14 @@
import { describe, expect, it, vi } from 'vitest';
import { PathSecurityError } from '../../src/_base/tools/policies/path-access';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import { stubWorkspaceContext } from './stub-workspace-context';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import { type EditInput, EditInputSchema, EditTool } from '#/agent/fileTools/tools/edit';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
const signal = new AbortController().signal;
const PERMISSIVE_WORKSPACE: WorkspaceConfig = { workspaceDir: '/', additionalDirs: [] };
const PERMISSIVE_WORKSPACE = stubWorkspaceContext('/');
function createTestEnv(home = '/home'): IHostEnvironment {
return {
@ -391,10 +391,7 @@ describe('EditTool', () => {
it('rejects relative traversal edits before reading', async () => {
const readText = vi.fn().mockResolvedValue('secret');
const { fs } = createSpiedEditFs({ readText });
const tool = new EditTool(fs, createTestEnv(), {
workspaceDir: '/workspace/project',
additionalDirs: [],
});
const tool = new EditTool(fs, createTestEnv(), stubWorkspaceContext('/workspace/project'));
const result = await execute(tool, {
path: '../outside.txt',
@ -491,10 +488,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('old content'),
writeText,
});
const tool = new EditTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const tool = new EditTool(fs, createTestEnv(), stubWorkspaceContext('/workspace'));
const result = await execute(tool, {
path: '/tmp/outside.txt',
@ -514,10 +508,7 @@ describe('EditTool', () => {
readText: vi.fn().mockResolvedValue('content'),
writeText,
});
const tool = new EditTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const tool = new EditTool(fs, createTestEnv(), stubWorkspaceContext('/workspace'));
const result = await execute(tool, {
path: '/workspace-sneaky/test.txt',

View file

@ -1,13 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
import type { ISessionAgentFileSystem, ISessionFsService } from '#/session/agentFs';
import { TestInstantiationService } from '#/_base/di/test';
import { ISessionAgentFileSystem, ISessionFsService } from '#/session/agentFs';
import { AgentFileToolsService } from '#/agent/fileTools';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ISessionProcessRunner } from '#/session/process';
import { noopTelemetryService } from '#/app/telemetry';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { ISessionProcessRunner } from '#/session/process';
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry';
import type { IDisposable } from '#/_base/di';
import type { IAgentToolRegistryService } from '#/agent/toolRegistry';
import type { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
function fakeToolRegistry(): { registry: IAgentToolRegistryService; names: () => string[] } {
const tools = new Map<string, unknown>();
@ -44,15 +45,14 @@ const fakeWorkspace = {
describe('AgentFileToolsService', () => {
it('registers Read/Write/Edit/Grep/Glob into the tool registry', () => {
const { registry, names } = fakeToolRegistry();
new AgentFileToolsService(
registry,
fakeFs,
fakeEnv,
fakeWorkspace,
fakeFsService,
fakeRunner,
noopTelemetryService,
);
const ix = new TestInstantiationService();
ix.set(ISessionAgentFileSystem, fakeFs);
ix.set(ISessionFsService, fakeFsService);
ix.set(IHostEnvironment, fakeEnv);
ix.set(ISessionProcessRunner, fakeRunner);
ix.set(ITelemetryService, noopTelemetryService);
ix.set(ISessionWorkspaceContext, fakeWorkspace);
new AgentFileToolsService(ix, registry);
expect(names()).toEqual(['Edit', 'Glob', 'Grep', 'Read', 'Write']);
});
});

View file

@ -18,7 +18,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ensureRgPath } from '#/session/agentFs/rgLocator';
import { PathSecurityError, type PathClass } from '../../src/_base/tools/policies/path-access';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import { noopTelemetryService } from '#/app/telemetry';
import type { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { stubWorkspaceContext } from './stub-workspace-context';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import { SessionAgentFileSystem } from '#/session/agentFs/agentFsService';
import {
@ -54,7 +56,7 @@ vi.mock('#/session/agentFs/rgLocator', () => ({
const RG_AVAILABLE = spawnSync('rg', ['--version'], { stdio: 'ignore' }).status === 0;
const signal = new AbortController().signal;
const workspace: WorkspaceConfig = { workspaceDir: '/workspace', additionalDirs: ['/extra'] };
const workspace = stubWorkspaceContext('/workspace', ['/extra']);
/** Fake fs with a spied `readdir` for the directory pre-check. */
function createTestFs(opts: { readdir?: ReturnType<typeof vi.fn> } = {}) {
@ -184,16 +186,20 @@ function toolContentString(result: ExecutableToolResult): string {
/** Build a `GlobTool` with the given exec spy, using a fake env + runner. */
function makeTool(
workspaceConfig: WorkspaceConfig,
workspaceConfig: ISessionWorkspaceContext,
opts: { home?: string; pathClass?: PathClass; exec?: ReturnType<typeof vi.fn>; readdir?: ReturnType<typeof vi.fn>; telemetry?: ITelemetryService } = {},
): { tool: GlobTool; exec: ReturnType<typeof vi.fn>; withCwd: ReturnType<typeof withCwdOf> } {
const exec = opts.exec ?? execReturning('');
const { fs } = createTestFs({ readdir: opts.readdir });
const runner = createTestRunner(exec);
const env = createTestEnv({ home: opts.home, pathClass: opts.pathClass });
const tool = opts.telemetry !== undefined
? new GlobTool(fs, env, runner, workspaceConfig, opts.telemetry)
: new GlobTool(fs, env, runner, workspaceConfig);
const tool = new GlobTool(
fs,
env,
runner,
workspaceConfig,
opts.telemetry ?? noopTelemetryService,
);
return { tool, exec, withCwd: withCwdOf(exec) };
}
@ -254,7 +260,7 @@ describe('GlobTool', () => {
it('uses the backend path class when displaying paths relative to a windows root', async () => {
const exec = execReturning('C:\\workspace\\src\\old.ts\n');
const { tool, withCwd } = makeTool(
{ workspaceDir: 'C:\\workspace', additionalDirs: [] },
stubWorkspaceContext('C:\\workspace'),
{ pathClass: 'win32', exec },
);
@ -353,7 +359,7 @@ describe('GlobTool', () => {
Array.from({ length: MAX_MATCHES + 1 }, (_, i) => `/workspace/${String(i)}.ts`).join('\n') +
'\n';
const exec = execReturning(stdout);
const { tool } = makeTool({ workspaceDir: '/workspace', additionalDirs: [] }, { exec });
const { tool } = makeTool(stubWorkspaceContext('/workspace'), { exec });
const result = await execute(tool, { pattern: '*.ts' });
@ -368,7 +374,7 @@ describe('GlobTool', () => {
'\n',
) + '\n';
const exec = execReturning(stdout);
const { tool } = makeTool({ workspaceDir: '/workspace', additionalDirs: [] }, { exec });
const { tool } = makeTool(stubWorkspaceContext('/workspace'), { exec });
const result = await execute(tool, { pattern: '*.txt' });
@ -380,7 +386,7 @@ describe('GlobTool', () => {
Array.from({ length: MAX_MATCHES }, (_, i) => `/workspace/test_${String(i)}.py`).join('\n') +
'\n';
const exec = execReturning(stdout);
const { tool } = makeTool({ workspaceDir: '/workspace', additionalDirs: [] }, { exec });
const { tool } = makeTool(stubWorkspaceContext('/workspace'), { exec });
const result = await execute(tool, { pattern: '*.py' });
@ -468,10 +474,7 @@ describe('GlobTool', () => {
});
describe('skills / additional dirs', () => {
const skillsWorkspace: WorkspaceConfig = {
workspaceDir: '/workspace',
additionalDirs: ['/skills'],
};
const skillsWorkspace = stubWorkspaceContext('/workspace', ['/skills']);
it('searches inside a registered additionalDir entry', async () => {
const exec = execReturning('/skills/read_content.py\n/skills/utils.py\n');
@ -501,7 +504,7 @@ describe('GlobTool', () => {
it('rejects a relative path that escapes both workspace and additionalDirs', async () => {
const exec = vi.fn();
const { tool, withCwd } = makeTool(
{ workspaceDir: '/workspace/project', additionalDirs: ['/skills'] },
stubWorkspaceContext('/workspace/project', ['/skills']),
{ exec },
);
@ -673,7 +676,7 @@ describe('GlobTool', () => {
it('shows absolute paths when explicit search root is outside all workspace roots', async () => {
const exec = execReturning('/extra/test.py\n');
const { tool, withCwd } = makeTool(
{ workspaceDir: '/workspace', additionalDirs: [] },
stubWorkspaceContext('/workspace'),
{ exec },
);
@ -684,7 +687,7 @@ describe('GlobTool', () => {
});
it('keeps absolute paths when explicit search root is an additionalDir', async () => {
const registered: WorkspaceConfig = { workspaceDir: '/workspace', additionalDirs: ['/extra'] };
const registered = stubWorkspaceContext('/workspace', ['/extra']);
const exec = execReturning('/extra/test.py\n');
const { tool } = makeTool(registered, { exec });
@ -708,7 +711,7 @@ describe('GlobTool', () => {
it('expands a leading "~/" path before searching outside the workspace', async () => {
const exec = execReturning('');
const { tool, withCwd } = makeTool(
{ workspaceDir: '/workspace', additionalDirs: [] },
stubWorkspaceContext('/workspace'),
{ home: '/home/test', exec },
);
@ -723,7 +726,7 @@ describe('GlobTool', () => {
it('allows a path sharing the workspace prefix when it is absolute', async () => {
const exec = execReturning('');
const { tool, withCwd } = makeTool(
{ workspaceDir: '/parent/workdir', additionalDirs: [] },
stubWorkspaceContext('/parent/workdir'),
{ exec },
);
@ -747,7 +750,7 @@ describe('GlobTool', () => {
it('mentions Windows path forms in the description on win32 backends', () => {
const { tool } = makeTool(
{ workspaceDir: 'C:\\workspace', additionalDirs: [] },
stubWorkspaceContext('C:\\workspace'),
{ pathClass: 'win32' },
);
@ -825,13 +828,13 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
await fs.utimes(full, mtime, mtime);
}
const ws = (): WorkspaceConfig => ({ workspaceDir: tmpDir!, additionalDirs: [] });
const ws = () => stubWorkspaceContext(tmpDir!);
it('returns files newest-first by modification time (--sortr=modified)', async () => {
await touch('old.ts', new Date('2020-01-01T00:00:00Z'));
await touch('mid.ts', new Date('2022-01-01T00:00:00Z'));
await touch('new.ts', new Date('2024-01-01T00:00:00Z'));
const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const tool = new GlobTool(realFs, realEnv, realRunner, ws(), noopTelemetryService);
const result = await execute(tool, { pattern: '*.ts', path: tmpDir! });
@ -842,7 +845,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
await touch('root.ts', new Date('2024-01-01T00:00:00Z'));
await touch('src/a.ts', new Date('2023-01-01T00:00:00Z'));
await touch('src/sub/b.ts', new Date('2022-01-01T00:00:00Z'));
const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const tool = new GlobTool(realFs, realEnv, realRunner, ws(), noopTelemetryService);
const result = await execute(tool, { pattern: '*.ts', path: tmpDir! });
@ -855,7 +858,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
await touch('src/a.ts', new Date('2024-01-01T00:00:00Z'));
await touch('test/a.ts', new Date('2023-01-01T00:00:00Z'));
await touch('other/a.ts', new Date('2022-01-01T00:00:00Z'));
const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const tool = new GlobTool(realFs, realEnv, realRunner, ws(), noopTelemetryService);
const result = await execute(tool, { pattern: '{src,test}/*.ts', path: tmpDir! });
@ -868,7 +871,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
await touch('src/a.ts', new Date('2024-01-01T00:00:00Z'));
await touch('src/sub/b.ts', new Date('2023-01-01T00:00:00Z'));
await touch('other/c.ts', new Date('2022-01-01T00:00:00Z'));
const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const tool = new GlobTool(realFs, realEnv, realRunner, ws(), noopTelemetryService);
const result = await execute(tool, { pattern: 'src/**/*.ts', path: tmpDir! });
@ -879,7 +882,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
it('treats an escaped brace as a literal filename', async () => {
await touch('{a,b}.ts', new Date('2024-01-01T00:00:00Z'));
const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const tool = new GlobTool(realFs, realEnv, realRunner, ws(), noopTelemetryService);
const result = await execute(tool, { pattern: '\\{a,b\\}.ts', path: tmpDir! });
@ -891,7 +894,7 @@ describe.skipIf(!RG_AVAILABLE)('GlobTool integration (real ripgrep)', () => {
try {
const extFile = path.join(externalDir, 'pkg.ts');
await fs.writeFile(extFile, '');
const tool = new GlobTool(realFs, realEnv, realRunner, ws());
const tool = new GlobTool(realFs, realEnv, realRunner, ws(), noopTelemetryService);
const result = await execute(tool, { pattern: '*.ts', path: externalDir });

View file

@ -13,7 +13,7 @@
import type { FsGrepFileHit, FsGrepRequest, FsGrepResponse } from '@moonshot-ai/protocol';
import { describe, expect, it, vi } from 'vitest';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import { stubWorkspaceContext } from './stub-workspace-context';
import type { ISessionFsService } from '#/session/agentFs';
import {
type GrepInput,
@ -24,7 +24,7 @@ import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
const signal = new AbortController().signal;
const workspace: WorkspaceConfig = { workspaceDir: '/workspace', additionalDirs: ['/extra'] };
const workspace = stubWorkspaceContext('/workspace', ['/extra']);
function fileHit(path: string, lines: number[] = [1], text = 'hit'): FsGrepFileHit {
return {

View file

@ -16,7 +16,7 @@ import { describe, expect, it, vi } from 'vitest';
import { PathSecurityError } from '../../src/_base/tools/policies/path-access';
import { MEDIA_SNIFF_BYTES } from '../../src/_base/tools/support/file-type';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import { stubWorkspaceContext } from './stub-workspace-context';
import type { ISessionAgentFileSystem } from '#/session/agentFs';
import {
MAX_BYTES,
@ -30,7 +30,7 @@ import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
const signal = new AbortController().signal;
const PERMISSIVE_WORKSPACE: WorkspaceConfig = { workspaceDir: '/', additionalDirs: [] };
const PERMISSIVE_WORKSPACE = stubWorkspaceContext('/');
function linesFromContent(content: string): string[] {
if (content === '') return [];
@ -136,7 +136,7 @@ function createSpiedMapFs(files: Record<string, FakeFile>) {
return { fs, readBytes, readLines, readText, stat };
}
function toolWithContent(content: string, workspace: WorkspaceConfig = PERMISSIVE_WORKSPACE) {
function toolWithContent(content: string, workspace = PERMISSIVE_WORKSPACE) {
return new ReadTool(createSpiedFs(content).fs, createTestEnv(), workspace);
}
@ -305,10 +305,7 @@ describe('ReadTool', () => {
it('rejects relative traversal before reading', async () => {
const { fs, readText } = createSpiedFs('secret');
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace/project',
additionalDirs: [],
});
const tool = new ReadTool(fs, createTestEnv(), stubWorkspaceContext('/workspace/project'));
const result = await execute(tool, { path: '../../outside.txt' });
@ -319,10 +316,7 @@ describe('ReadTool', () => {
it('allows explicit absolute paths outside the workspace', async () => {
const { fs, readBytes, readLines } = createSpiedFs('external');
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const tool = new ReadTool(fs, createTestEnv(), stubWorkspaceContext('/workspace'));
const result = await execute(tool, { path: '/tmp/external.txt' });
@ -338,10 +332,7 @@ describe('ReadTool', () => {
it('returns a friendly error for missing files before sniffing bytes', async () => {
const { fs, readBytes, readLines } = createSpiedMapFs({});
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const tool = new ReadTool(fs, createTestEnv(), stubWorkspaceContext('/workspace'));
const result = await execute(tool, { path: '/workspace/missing.txt' });
@ -357,10 +348,7 @@ describe('ReadTool', () => {
const { fs, readBytes, readLines } = createSpiedMapFs({
'/workspace/src': { bytes: Buffer.alloc(0), isFile: false, isDirectory: true },
});
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const tool = new ReadTool(fs, createTestEnv(), stubWorkspaceContext('/workspace'));
const result = await execute(tool, { path: '/workspace/src' });
@ -374,10 +362,7 @@ describe('ReadTool', () => {
it('expands leading tilde paths using the kaos home directory', async () => {
const { fs, readBytes, readLines } = createSpiedFs('home note');
const tool = new ReadTool(fs, createTestEnv('/home/test'), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const tool = new ReadTool(fs, createTestEnv('/home/test'), stubWorkspaceContext('/workspace'));
const result = await execute(tool, { path: '~/notes/today.txt' });
@ -393,10 +378,7 @@ describe('ReadTool', () => {
it('blocks sensitive files independently from workspace access', async () => {
const { fs, readText } = createSpiedFs('SECRET=value');
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const tool = new ReadTool(fs, createTestEnv(), stubWorkspaceContext('/workspace'));
const result = await execute(tool, { path: '/workspace/.env' });
@ -672,10 +654,7 @@ describe('ReadTool', () => {
it('reads files inside additional_dirs via absolute path', async () => {
const { fs } = createSpiedFs('extra-dir note');
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: ['/extra'],
});
const tool = new ReadTool(fs, createTestEnv(), stubWorkspaceContext('/workspace', ['/extra']));
const result = await execute(tool, { path: '/extra/notes.txt' });
@ -685,10 +664,7 @@ describe('ReadTool', () => {
it('reports nonexistent files with the expected does-not-exist phrasing', async () => {
const { fs } = createSpiedMapFs({});
const tool = new ReadTool(fs, createTestEnv(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const tool = new ReadTool(fs, createTestEnv(), stubWorkspaceContext('/workspace'));
const result = await execute(tool, { path: '/workspace/ghost.txt' });

View file

@ -0,0 +1,25 @@
import type { ISessionWorkspaceContext } from '#/session/workspaceContext';
/**
* Builds a minimal `ISessionWorkspaceContext` stub for file-tool unit tests.
*
* The file tools only read `workDir` / `additionalDirs`; the remaining members
* are no-op stubs so tests can construct tools without standing up a full
* session scope.
*/
export function stubWorkspaceContext(
workDir: string,
additionalDirs: readonly string[] = [],
): ISessionWorkspaceContext {
return {
_serviceBrand: undefined,
workDir,
additionalDirs,
setWorkDir: () => {},
resolve: (rel) => `${workDir}/${rel}`,
isWithin: () => true,
assertAllowed: (absPath) => absPath,
addAdditionalDir: () => {},
removeAdditionalDir: () => {},
};
}

View file

@ -16,13 +16,13 @@ import { describe, expect, it, vi } from 'vitest';
import { PathSecurityError } from '../../src/_base/tools/policies/path-access';
import type { AgentFileStat, ISessionAgentFileSystem } from '#/session/agentFs';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import { stubWorkspaceContext } from './stub-workspace-context';
import { type WriteInput, WriteInputSchema, WriteTool } from '#/agent/fileTools/tools/write';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
const signal = new AbortController().signal;
const PERMISSIVE_WORKSPACE: WorkspaceConfig = { workspaceDir: '/', additionalDirs: [] };
const PERMISSIVE_WORKSPACE = stubWorkspaceContext('/');
function toolContentString(result: ExecutableToolResult): string {
const c = result.output;
@ -80,7 +80,7 @@ function createWriteFs(options: WriteFsOptions = {}) {
return { fs, readText, writeText, stat, mkdir };
}
function makeTool(options: WriteFsOptions = {}, workspace: WorkspaceConfig = PERMISSIVE_WORKSPACE) {
function makeTool(options: WriteFsOptions = {}, workspace = PERMISSIVE_WORKSPACE) {
const fakes = createWriteFs(options);
const tool = new WriteTool(fakes.fs, createTestEnv(), workspace);
return { tool, ...fakes };
@ -178,7 +178,7 @@ describe('WriteTool', () => {
});
it('matches permission args with negated glob path semantics', () => {
const { tool } = makeTool({}, { workspaceDir: '/workspace', additionalDirs: [] });
const { tool } = makeTool({}, stubWorkspaceContext('/workspace'));
const insideSrc = tool.resolveExecution({ path: './src/a.ts', content: 'x' });
const outsideSrc = tool.resolveExecution({ path: './README.md', content: 'x' });
if (insideSrc.isError === true || outsideSrc.isError === true) {
@ -343,7 +343,7 @@ describe('WriteTool', () => {
});
it('allows explicit absolute writes outside the workspace', async () => {
const { tool, writeText } = makeTool({}, { workspaceDir: '/workspace', additionalDirs: [] });
const { tool, writeText } = makeTool({}, stubWorkspaceContext('/workspace'));
const result = await execute(tool, { path: '/tmp/pwned.txt', content: 'x' });
@ -354,7 +354,7 @@ describe('WriteTool', () => {
it('rejects relative traversal writes before fs I/O', async () => {
const { tool, writeText } = makeTool(
{},
{ workspaceDir: '/workspace/project', additionalDirs: [] },
stubWorkspaceContext('/workspace/project'),
);
const result = await execute(tool, { path: '../outside.txt', content: 'x' });
@ -365,7 +365,7 @@ describe('WriteTool', () => {
});
it('blocks sensitive file writes', async () => {
const { tool, writeText } = makeTool({}, { workspaceDir: '/workspace', additionalDirs: [] });
const { tool, writeText } = makeTool({}, stubWorkspaceContext('/workspace'));
const result = await execute(tool, { path: '/workspace/id_rsa', content: 'key' });
@ -433,7 +433,7 @@ describe('WriteTool', () => {
it('allows absolute writes to a sibling dir that merely shares the work-dir prefix', async () => {
// Path policy must distinguish "shares a prefix with workspaceDir" from
// "is inside workspaceDir". /workspace-sneaky/* is outside /workspace.
const { tool, writeText } = makeTool({}, { workspaceDir: '/workspace', additionalDirs: [] });
const { tool, writeText } = makeTool({}, stubWorkspaceContext('/workspace'));
const result = await execute(tool, { path: '/workspace-sneaky/file.txt', content: 'content' });

View file

@ -33,6 +33,7 @@ import {
} from '#/agent/background';
import type { BackgroundTaskSettlement } from '#/agent/background/task';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import type { IAgentProfileService } from '#/agent/profile';
import { createExecContext, type IExecContext } from '#/session/execContext';
import type { IProcess, ISessionProcessRunner } from '#/session/process';
import { type BashInput, BashInputSchema, BashTool } from '#/agent/shellTools/tools/bash';
@ -673,14 +674,21 @@ async function executeTool(
return execution.execute(executionContext as ExecutableToolContext);
}
function stubProfile(isToolActive: (name: string) => boolean = () => true): IAgentProfileService {
return {
_serviceBrand: undefined,
isToolActive,
} as unknown as IAgentProfileService;
}
function bashTool(
runner: ISessionProcessRunner,
env: IHostEnvironment = createTestEnv(),
ctx: IExecContext = createTestCtx(),
background: IAgentBackgroundService = createFakeBackgroundService().service,
options?: ConstructorParameters<typeof BashTool>[4],
profile: IAgentProfileService = stubProfile(),
): BashTool {
return new BashTool(runner, env, ctx, background, options);
return new BashTool(runner, env, ctx, background, profile);
}
// ── Tests ────────────────────────────────────────────────────────────
@ -1243,7 +1251,7 @@ describe('BashTool background mode', () => {
const { proc, finish } = pendingProcess();
const { runner } = createTestRunner(proc);
const { service } = createFakeBackgroundService();
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service, { allowBackground: () => false });
const tool = bashTool(runner, createTestEnv(), createTestCtx(), service, stubProfile(() => false));
const running = executeTool(tool, context({ command: 'sleep 10', timeout: 60 }));
await vi.waitFor(() => {
@ -1314,7 +1322,7 @@ describe('BashTool background mode', () => {
runner,
createTestEnv(), createTestCtx(),
createFakeBackgroundService().service,
{ allowBackground: () => false },
stubProfile(() => false),
);
const unavailable = await executeTool(
@ -1589,9 +1597,7 @@ describe('BashTool prompt / runtime consistency', () => {
[...enabledTool.description.matchAll(/`(Task[A-Za-z]+)`/g)].map((match) => match[1]),
);
const tool = bashTool(runner, createTestEnv(), createTestCtx(), createFakeBackgroundService().service, {
allowBackground: () => false,
});
const tool = bashTool(runner, createTestEnv(), createTestCtx(), createFakeBackgroundService().service, stubProfile(() => false));
const result = await executeTool(
tool,
context({ command: 'sleep 10', run_in_background: true, description: 'watch' }),

View file

@ -1,11 +1,12 @@
import { describe, expect, it, vi } from 'vitest';
import type { IAgentBackgroundService } from '#/agent/background';
import { IAgentBackgroundService } from '#/agent/background';
import { TestInstantiationService } from '#/_base/di/test';
import type { IDisposable } from '#/_base/di';
import type { IHostEnvironment } from '#/app/hostEnvironment';
import { createExecContext, type IExecContext } from '#/session/execContext';
import type { ISessionProcessRunner } from '#/session/process';
import type { IAgentProfileService } from '#/agent/profile';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { createExecContext, IExecContext } from '#/session/execContext';
import { ISessionProcessRunner } from '#/session/process';
import { IAgentProfileService } from '#/agent/profile';
import { AgentShellToolsService } from '#/agent/shellTools';
import type { IAgentToolRegistryService } from '#/agent/toolRegistry';
@ -43,7 +44,13 @@ const fakeProfile = {
describe('AgentShellToolsService', () => {
it('registers Bash into the tool registry', () => {
const { registry, names } = fakeToolRegistry();
new AgentShellToolsService(registry, fakeRunner, fakeEnv, fakeCtx, fakeBackground, fakeProfile);
const ix = new TestInstantiationService();
ix.set(ISessionProcessRunner, fakeRunner);
ix.set(IHostEnvironment, fakeEnv);
ix.set(IExecContext, fakeCtx);
ix.set(IAgentBackgroundService, fakeBackground);
ix.set(IAgentProfileService, fakeProfile);
new AgentShellToolsService(ix, registry);
expect(names()).toEqual(['Bash']);
});
});

View file

@ -4,7 +4,6 @@ import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import type { ContextMessage } from '#/agent/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IAgentPromptService } from '#/agent/prompt';
import { IAgentSkillService } from '#/agent/skill';
import { InMemorySkillCatalog } from '#/app/globalSkillCatalog';
@ -14,7 +13,6 @@ import {
MAX_SKILL_QUERY_DEPTH,
NestedSkillTooDeepError,
SkillTool,
type SkillToolDeps,
} from '#/agent/skill/tools/skill';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
@ -68,10 +66,6 @@ describe('AgentSkillService', () => {
undo: () => 0,
clear: () => {},
});
reg.definePartialInstance(IAgentEventSinkService, {
emit: () => {},
on: () => ({ dispose: () => {} }),
});
reg.defineInstance(IAgentWireRecordService, stubWireRecord());
reg.definePartialInstance(IAgentReplayBuilderService, {
push: () => {},
@ -175,10 +169,6 @@ describe('SkillTool', () => {
undo: () => 0,
clear: () => {},
});
reg.definePartialInstance(IAgentEventSinkService, {
emit: () => {},
on: () => ({ dispose: () => {} }),
});
reg.defineInstance(IAgentWireRecordService, stubWireRecord());
reg.definePartialInstance(IAgentReplayBuilderService, {
push: () => {},
@ -215,16 +205,25 @@ describe('SkillTool', () => {
};
}
function skillToolDeps(ix: TestInstantiationService): SkillToolDeps {
function stubSkillService(): IAgentSkillService {
return {
catalog: ix.get(ISessionSkillCatalog),
prompt: ix.get(IAgentPromptService),
recordActivation: () => {},
_serviceBrand: undefined,
activate: () => Promise.reject(new Error('not implemented')),
recordModelToolActivation: () => {},
};
}
function makeTool(ix: TestInstantiationService, depth?: number): SkillTool {
const tool = new SkillTool(
ix.get(ISessionSkillCatalog),
ix.get(IAgentPromptService),
stubSkillService(),
);
return depth === undefined ? tool : tool.withInitialQueryDepth(depth);
}
it('exposes metadata and schema for model-invoked skills', () => {
const tool = new SkillTool(skillToolDeps(ix));
const tool = makeTool(ix);
expect(tool.name).toBe('Skill');
expect(tool.description).toContain('Invoke a registered skill');
@ -242,7 +241,7 @@ describe('SkillTool', () => {
it('returns a tool error when the skill is unknown', async () => {
const result = await executeTool(
new SkillTool(skillToolDeps(ix)),
makeTool(ix),
toolContext({ skill: 'missing' }),
);
@ -256,7 +255,7 @@ describe('SkillTool', () => {
skills.register(stubSkill('private', { metadata: { disableModelInvocation: true } }));
const result = await executeTool(
new SkillTool(skillToolDeps(ix)),
makeTool(ix),
toolContext({ skill: 'private' }),
);
@ -270,7 +269,7 @@ describe('SkillTool', () => {
skills.register(stubSkill('flow-only', { metadata: { type: 'flow' } }));
const result = await executeTool(
new SkillTool(skillToolDeps(ix)),
makeTool(ix),
toolContext({ skill: 'flow-only' }),
);
@ -282,7 +281,7 @@ describe('SkillTool', () => {
it('loads inline skills through the model-tool wrapper without exposing the body in output', async () => {
const result = await executeTool(
new SkillTool(skillToolDeps(ix)),
makeTool(ix),
toolContext({ skill: 'commit', args: 'src/app.ts' }),
);
@ -310,11 +309,11 @@ describe('SkillTool', () => {
it('honors initialQueryDepth as an alias for queryDepth', async () => {
await executeTool(
new SkillTool(skillToolDeps(ix), { initialQueryDepth: 2 }),
makeTool(ix, 2),
toolContext({ skill: 'commit' }),
);
await executeTool(
new SkillTool(skillToolDeps(ix), { initialQueryDepth: 0 }),
makeTool(ix, 0),
toolContext({ skill: 'commit' }),
);
@ -332,7 +331,7 @@ describe('SkillTool', () => {
it('throws a structured recursion error when nested skill invocation is too deep', async () => {
await expect(
executeTool(
new SkillTool(skillToolDeps(ix), { initialQueryDepth: MAX_SKILL_QUERY_DEPTH }),
makeTool(ix, MAX_SKILL_QUERY_DEPTH),
toolContext({ skill: 'commit' }),
),
).rejects.toBeInstanceOf(NestedSkillTooDeepError);

View file

@ -4,7 +4,7 @@ import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore, toDisposable } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IAgentRecordService } from '#/agent/record';
import {
DEFAULT_SUBAGENT_TIMEOUT_MS,
} from '#/agent/agentTool';
@ -51,7 +51,7 @@ function mockSwarmHost({
}
function mockSwarmMode() {
return { enter: vi.fn() };
return { _serviceBrand: undefined, isActive: false, enter: vi.fn(), exit: vi.fn() };
}
describe('AgentSwarmService', () => {
@ -63,7 +63,12 @@ describe('AgentSwarmService', () => {
ix = disposables.add(new TestInstantiationService());
ix.stub(IAgentContextMemoryService, stubContextMemory());
ix.stub(IAgentWireRecordService, stubWireRecord());
ix.stub(IAgentEventSinkService, { emit: () => {}, on: () => toDisposable(() => {}) });
ix.stub(IAgentRecordService, {
append: () => {},
signal: () => {},
on: () => toDisposable(() => {}),
define: () => toDisposable(() => {}),
});
ix.stub(IAgentTurnService, stubTurnWithHooks());
ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
ix.stub(IAgentLifecycleService, {});
@ -129,7 +134,7 @@ describe('AgentSwarmTool', () => {
]),
});
const swarmMode = mockSwarmMode();
const tool = new AgentSwarmTool(host, swarmMode);
const tool = new AgentSwarmTool(host.swarmService, { _serviceBrand: undefined, agentId: host.callerAgentId }, swarmMode);
const input = {
description: 'Review files',
prompt_template: 'Review {{item}}',
@ -215,7 +220,8 @@ describe('AgentSwarmTool', () => {
});
it('does not expose permission rule argument matching', () => {
const tool = new AgentSwarmTool(mockSwarmHost(), mockSwarmMode());
const host = mockSwarmHost();
const tool = new AgentSwarmTool(host.swarmService, { _serviceBrand: undefined, agentId: host.callerAgentId }, mockSwarmMode());
const execution = tool.resolveExecution({
description: 'Review files',
prompt_template: 'Review {{item}}',
@ -274,7 +280,7 @@ describe('AgentSwarmTool', () => {
for (const testCase of cases) {
const host = mockSwarmHost();
const tool = new AgentSwarmTool(host, mockSwarmMode());
const tool = new AgentSwarmTool(host.swarmService, { _serviceBrand: undefined, agentId: host.callerAgentId }, mockSwarmMode());
const result = await executeTool(tool, context(testCase.input));
@ -307,7 +313,7 @@ describe('AgentSwarmTool', () => {
},
);
const host = mockSwarmHost({ run });
const tool = new AgentSwarmTool(host, mockSwarmMode());
const tool = new AgentSwarmTool(host.swarmService, { _serviceBrand: undefined, agentId: host.callerAgentId }, mockSwarmMode());
// Seed the module-level swarm item map so resume_agent_ids can recover the original items.
await executeTool(
tool,
@ -434,7 +440,7 @@ describe('AgentSwarmTool', () => {
},
);
const host = mockSwarmHost({ run });
const tool = new AgentSwarmTool(host, mockSwarmMode());
const tool = new AgentSwarmTool(host.swarmService, { _serviceBrand: undefined, agentId: host.callerAgentId }, mockSwarmMode());
// Seed the module-level swarm item map so resume_agent_ids can recover the original item.
await executeTool(
tool,
@ -502,7 +508,7 @@ describe('AgentSwarmTool', () => {
},
]),
});
const tool = new AgentSwarmTool(host, mockSwarmMode());
const tool = new AgentSwarmTool(host.swarmService, { _serviceBrand: undefined, agentId: host.callerAgentId }, mockSwarmMode());
const result = await executeTool(
tool,
@ -541,7 +547,7 @@ describe('AgentSwarmTool', () => {
},
]),
});
const tool = new AgentSwarmTool(host, mockSwarmMode());
const tool = new AgentSwarmTool(host.swarmService, { _serviceBrand: undefined, agentId: host.callerAgentId }, mockSwarmMode());
const result = await executeTool(
tool,
@ -588,7 +594,7 @@ describe('AgentSwarmTool', () => {
},
]),
});
const tool = new AgentSwarmTool(host, mockSwarmMode());
const tool = new AgentSwarmTool(host.swarmService, { _serviceBrand: undefined, agentId: host.callerAgentId }, mockSwarmMode());
const result = await executeTool(
tool,

View file

@ -7,25 +7,28 @@ import {
TodoListTool,
type TodoItem,
} from '#/agent/todoList/tools/todo-list';
import type { ToolStore } from '#/agent/toolStore';
import type { IAgentToolStoreService } from '#/agent/toolStore';
import { executeTool } from '../tools/fixtures/execute-tool';
const signal = new AbortController().signal;
function makeStore(initial: readonly TodoItem[] = []): {
readonly store: ToolStore;
readonly store: IAgentToolStoreService;
readonly getTodos: () => readonly TodoItem[];
} {
let todos = [...initial];
return {
store: {
get: (key) => (key === TODO_STORE_KEY ? todos : undefined),
set: (key, value) => {
_serviceBrand: undefined,
get: (key: string) => (key === TODO_STORE_KEY ? todos : undefined),
set: (key: string, value: unknown) => {
if (key === TODO_STORE_KEY) {
todos = [...(value as readonly TodoItem[])];
}
},
},
data: () => ({ [TODO_STORE_KEY]: todos }),
hooks: { onUpdated: { register: () => ({ dispose: () => {} }) } },
} as unknown as IAgentToolStoreService,
getTodos: () => todos,
};
}