diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 0bb3bd60b..5ad22e714 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -155,6 +155,8 @@ const V1_PACKAGE = '@moonshot-ai/agent-core'; * storage backend bindings). * * - `permissionGate>approval` : permissionGate(Agent) requests approval(Session broker). + * - `userTool>interaction` : userTool(Agent) requests host-side execution + * through the Session interaction broker. * - `skill>turn` : skill activate starts a turn (same Agent scope intent). * - `turn>agent-lifecycle` : turn cancels sub-agents via lifecycle handle. * - `swarm>agent-lifecycle`: swarm spawns/manages sub-agents. @@ -177,6 +179,7 @@ const V1_PACKAGE = '@moonshot-ai/agent-core'; const ALLOWED_EXCEPTIONS = new Set([ 'bootstrap>skill', 'permissionGate>approval', + 'userTool>interaction', 'skill>turn', 'turn>agent-lifecycle', 'swarm>agent-lifecycle', diff --git a/packages/agent-core-v2/src/config/configService.ts b/packages/agent-core-v2/src/config/configService.ts index 8c05e8d1b..60d4e810f 100644 --- a/packages/agent-core-v2/src/config/configService.ts +++ b/packages/agent-core-v2/src/config/configService.ts @@ -115,6 +115,23 @@ function applySectionEnv(base: unknown, env: AnyEnvBindings, getEnv: GetEnv): un return target; } +function isSameSection( + existing: ConfigSection, + schema: ConfigSchema, + options: RegisterSectionOptions, +): boolean { + return ( + existing.schema === schema && + existing.merge === (options.merge ?? deepMerge) && + existing.scope === (options.scope ?? ConfigScope.Core) && + existing.env === (options.env as ConfigSection['env']) && + existing.stripEnv === (options.stripEnv as ConfigSection['stripEnv']) && + existing.fromToml === options.fromToml && + existing.toToml === options.toToml && + deepEqual(existing.defaultValue, options.defaultValue) + ); +} + export class ConfigRegistry implements IConfigRegistry { declare readonly _serviceBrand: undefined; private readonly sections = new Map(); @@ -131,7 +148,22 @@ export class ConfigRegistry implements IConfigRegistry { schema: ConfigSchema, options: RegisterSectionOptions = {}, ): void { - if (this.sections.has(domain)) { + const existing = this.sections.get(domain); + if (existing !== undefined) { + // A section's owner may live in a child scope (Session/Agent) that is + // instantiated more than once per process (e.g. one Agent scope per + // session), so the same owner can register its section again. Treat an + // identical re-registration as a no-op; only a conflicting registration + // from a different owner is an error. + if ( + isSameSection( + existing, + schema as ConfigSchema, + options as RegisterSectionOptions, + ) + ) { + return; + } throw new Error(`ConfigRegistry: section '${domain}' is already registered`); } this.sections.set(domain, { diff --git a/packages/agent-core-v2/src/filestore/fileStoreService.ts b/packages/agent-core-v2/src/filestore/fileStoreService.ts index 31e793519..7da445491 100644 --- a/packages/agent-core-v2/src/filestore/fileStoreService.ts +++ b/packages/agent-core-v2/src/filestore/fileStoreService.ts @@ -28,6 +28,7 @@ import { const BLOB_SCOPE = 'files'; const INDEX_SCOPE = 'filestore'; const INDEX_KEY = 'index.json'; +const FILE_ID_REGEX = /^f_[A-Za-z0-9][A-Za-z0-9_-]*$/; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -37,12 +38,16 @@ interface IndexFile { readonly files: FileMeta[]; } +function isFileId(value: string): boolean { + return FILE_ID_REGEX.test(value); +} + function isFileMeta(value: unknown): value is FileMeta { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; const meta = value as Record; return ( typeof meta['id'] === 'string' && - meta['id'].startsWith('f_') && + isFileId(meta['id']) && typeof meta['name'] === 'string' && typeof meta['media_type'] === 'string' && typeof meta['size'] === 'number' && @@ -97,6 +102,9 @@ export class FileStoreService implements IFileStore { } async get(fileId: string): Promise { + if (!isFileId(fileId)) { + throw fileNotFoundError(fileId); + } await this.ensureIndex(); const meta = this.indexCache!.get(fileId); if (meta === undefined) { @@ -114,6 +122,9 @@ export class FileStoreService implements IFileStore { } async delete(fileId: string): Promise { + if (!isFileId(fileId)) { + throw fileNotFoundError(fileId); + } await this.ensureIndex(); if (!this.indexCache!.has(fileId)) { throw fileNotFoundError(fileId); diff --git a/packages/agent-core-v2/src/interaction/interaction.ts b/packages/agent-core-v2/src/interaction/interaction.ts index 83f52a780..9e15cbd4c 100644 --- a/packages/agent-core-v2/src/interaction/interaction.ts +++ b/packages/agent-core-v2/src/interaction/interaction.ts @@ -6,15 +6,15 @@ * response primitive (`request` → `respond`) with change notification * (`onDidChange`), a non-blocking enqueue (`enqueue`) for callers that observe * the outcome through the `onDidResolve` stream, and a `listPending` view. - * `approval` and `question` are typed specializations layered on top of this - * kernel; the kernel itself is domain-agnostic. Session-scoped — the pending - * set is keyed by session and dies with it. + * `approval`, `question`, and user-tool execution are typed specializations + * layered on top of this kernel; the kernel itself is domain-agnostic. + * Session-scoped — the pending set is keyed by session and dies with it. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; -export type InteractionKind = 'approval' | 'question'; +export type InteractionKind = 'approval' | 'question' | 'user_tool'; export interface InteractionOrigin { readonly agentId?: string; diff --git a/packages/agent-core-v2/src/model/envOverlay.ts b/packages/agent-core-v2/src/model/envOverlay.ts index 9c2ed6ad8..927961d0c 100644 --- a/packages/agent-core-v2/src/model/envOverlay.ts +++ b/packages/agent-core-v2/src/model/envOverlay.ts @@ -104,7 +104,31 @@ function withoutKey(value: unknown, key: string): unknown { export const kimiModelEnvOverlay: ConfigEffectiveOverlay = { apply(effective, getEnv, validate) { const model = trimmed(getEnv('KIMI_MODEL_NAME')); - if (model === undefined) return []; + const temperature = parseFloatEnv( + getEnv('KIMI_MODEL_TEMPERATURE'), + 'KIMI_MODEL_TEMPERATURE', + ); + const topP = parseFloatEnv(getEnv('KIMI_MODEL_TOP_P'), 'KIMI_MODEL_TOP_P'); + const thinkingKeep = trimmed(getEnv('KIMI_MODEL_THINKING_KEEP')); + const maxCompletionTokens = + parseCompletionTokens(getEnv('KIMI_MODEL_MAX_COMPLETION_TOKENS')) ?? + parseCompletionTokens(getEnv('KIMI_MODEL_MAX_TOKENS')); + + const changed: string[] = []; + + if (model === undefined) { + const modelOverrides = collectModelOverrides({ + temperature, + topP, + thinkingKeep, + maxCompletionTokens, + }); + if (modelOverrides !== undefined) { + effective['modelOverrides'] = modelOverrides; + changed.push('modelOverrides'); + } + return changed; + } const maxContextRaw = trimmed(getEnv('KIMI_MODEL_MAX_CONTEXT_SIZE')); const maxContextSize = @@ -136,18 +160,6 @@ export const kimiModelEnvOverlay: ConfigEffectiveOverlay = { if (reasoningKey !== undefined) alias['reasoningKey'] = reasoningKey; if (adaptiveThinking !== undefined) alias['adaptiveThinking'] = adaptiveThinking; - const temperature = parseFloatEnv( - getEnv('KIMI_MODEL_TEMPERATURE'), - 'KIMI_MODEL_TEMPERATURE', - ); - const topP = parseFloatEnv(getEnv('KIMI_MODEL_TOP_P'), 'KIMI_MODEL_TOP_P'); - const thinkingKeep = trimmed(getEnv('KIMI_MODEL_THINKING_KEEP')); - const maxCompletionTokens = - parseCompletionTokens(getEnv('KIMI_MODEL_MAX_COMPLETION_TOKENS')) ?? - parseCompletionTokens(getEnv('KIMI_MODEL_MAX_TOKENS')); - - const changed: string[] = []; - const models = asRecord(effective['models']); const nextModels = { ...models, [ENV_MODEL_ALIAS_KEY]: alias }; effective['models'] = validate('models', nextModels); @@ -156,12 +168,13 @@ export const kimiModelEnvOverlay: ConfigEffectiveOverlay = { effective['defaultModel'] = ENV_MODEL_ALIAS_KEY; changed.push('defaultModel'); - const modelOverrides: Record = {}; - if (temperature !== undefined) modelOverrides['temperature'] = temperature; - if (topP !== undefined) modelOverrides['topP'] = topP; - if (thinkingKeep !== undefined) modelOverrides['thinkingKeep'] = thinkingKeep; - if (maxCompletionTokens !== undefined) modelOverrides['maxCompletionTokens'] = maxCompletionTokens; - if (Object.keys(modelOverrides).length > 0) { + const modelOverrides = collectModelOverrides({ + temperature, + topP, + thinkingKeep, + maxCompletionTokens, + }); + if (modelOverrides !== undefined) { effective['modelOverrides'] = modelOverrides; changed.push('modelOverrides'); } @@ -183,3 +196,19 @@ export const kimiModelEnvOverlay: ConfigEffectiveOverlay = { } }, }; + +function collectModelOverrides(input: { + readonly temperature: number | undefined; + readonly topP: number | undefined; + readonly thinkingKeep: string | undefined; + readonly maxCompletionTokens: number | undefined; +}): Record | undefined { + const modelOverrides: Record = {}; + if (input.temperature !== undefined) modelOverrides['temperature'] = input.temperature; + if (input.topP !== undefined) modelOverrides['topP'] = input.topP; + if (input.thinkingKeep !== undefined) modelOverrides['thinkingKeep'] = input.thinkingKeep; + if (input.maxCompletionTokens !== undefined) { + modelOverrides['maxCompletionTokens'] = input.maxCompletionTokens; + } + return Object.keys(modelOverrides).length > 0 ? modelOverrides : undefined; +} diff --git a/packages/agent-core-v2/src/shellTools/shellToolsService.ts b/packages/agent-core-v2/src/shellTools/shellToolsService.ts index 7a9831ee6..e590cdc17 100644 --- a/packages/agent-core-v2/src/shellTools/shellToolsService.ts +++ b/packages/agent-core-v2/src/shellTools/shellToolsService.ts @@ -12,6 +12,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IAgentBackgroundService } from '#/background'; import { IKaos } from '#/kaos'; import { ISessionProcessRunner } from '#/process'; +import { IAgentProfileService } from '#/profile'; import { IAgentToolRegistryService } from '#/toolRegistry'; import { IAgentShellToolsService } from './shellTools'; @@ -25,8 +26,12 @@ export class AgentShellToolsService implements IAgentShellToolsService { @ISessionProcessRunner runner: ISessionProcessRunner, @IKaos kaos: IKaos, @IAgentBackgroundService background: IAgentBackgroundService, + @IAgentProfileService profile: IAgentProfileService, ) { - toolRegistry.register(new BashTool(runner, kaos, background)); + toolRegistry.register(new BashTool(runner, kaos, background, { + allowBackground: () => + profile.isToolActive('TaskOutput') && profile.isToolActive('TaskStop'), + })); } } diff --git a/packages/agent-core-v2/src/shellTools/tools/bash.ts b/packages/agent-core-v2/src/shellTools/tools/bash.ts index 0aad1ef57..086820fc7 100644 --- a/packages/agent-core-v2/src/shellTools/tools/bash.ts +++ b/packages/agent-core-v2/src/shellTools/tools/bash.ts @@ -157,25 +157,30 @@ function withoutBackgroundDescription(description: string): string { export class BashTool implements BuiltinTool { readonly name = 'Bash' as const; - readonly description: string; readonly parameters: Record = toInputJsonSchema(BashInputSchema); private readonly isWindowsBash: boolean; - private readonly allowBackground: boolean; + private readonly renderedDescription: string; + private readonly allowBackground: () => boolean; constructor( private readonly runner: ISessionProcessRunner, private readonly kaos: IKaos, private readonly background: IAgentBackgroundService, options?: { - allowBackground?: boolean; + allowBackground?: () => boolean; }, ) { this.isWindowsBash = this.kaos.osEnv.osKind === 'Windows'; - this.allowBackground = options?.allowBackground ?? true; - const rendered = renderBashDescription(this.kaos.osEnv.shellName); - this.description = this.allowBackground ? rendered : withoutBackgroundDescription(rendered); + this.allowBackground = options?.allowBackground ?? (() => true); + this.renderedDescription = renderBashDescription(this.kaos.osEnv.shellName); + } + + get description(): string { + return this.allowBackground() + ? this.renderedDescription + : withoutBackgroundDescription(this.renderedDescription); } resolveExecution(args: BashInput): ToolExecution { @@ -327,7 +332,7 @@ export class BashTool implements BuiltinTool { if (signal.aborted) return { isError: true, output: 'Aborted before command started' }; if (args.command.length === 0) return { isError: true, output: 'Command cannot be empty.' }; if (args.run_in_background !== true) return undefined; - if (!this.allowBackground) { + if (!this.allowBackground()) { return { isError: true, output: @@ -416,14 +421,16 @@ export class BashTool implements BuiltinTool { // The user explicitly moved a foreground call to the background to avoid // blocking the current turn. Steer the model away from waiting on it. // Only mention TaskOutput when the tool is actually available. - const avoid = this.allowBackground ? 'do NOT wait, poll, or call TaskOutput on it' : 'do NOT wait or poll'; + const avoid = this.allowBackground() + ? 'do NOT wait, poll, or call TaskOutput on it' + : 'do NOT wait or poll'; return ( 'next_step: The task now runs in the background. You will be automatically notified ' + `when it completes — ${avoid}; continue with your current work.\n` ); } // background_started: the model chose to launch in the background. - if (!this.allowBackground) { + if (!this.allowBackground()) { return 'next_step: You will be automatically notified when it completes.\n'; } return ( diff --git a/packages/agent-core-v2/src/userTool/userTool.ts b/packages/agent-core-v2/src/userTool/userTool.ts index 802499fae..f423eaacf 100644 --- a/packages/agent-core-v2/src/userTool/userTool.ts +++ b/packages/agent-core-v2/src/userTool/userTool.ts @@ -1,4 +1,4 @@ -import { createDecorator } from "#/_base/di"; +import { createDecorator } from '#/_base/di'; export interface UserToolRegistration { readonly name: string; diff --git a/packages/agent-core-v2/src/userTool/userToolService.ts b/packages/agent-core-v2/src/userTool/userToolService.ts index 146ca5cdd..a3462502d 100644 --- a/packages/agent-core-v2/src/userTool/userToolService.ts +++ b/packages/agent-core-v2/src/userTool/userToolService.ts @@ -1,15 +1,17 @@ import { Disposable, type IDisposable, -} from "#/_base/di"; +} from '#/_base/di'; +import { abortable } from '#/_base/utils/abort'; import type { ExecutableTool, ExecutableToolContext, ExecutableToolResult, + ToolResult, } from '#/tool'; +import { ISessionInteractionService } from '#/interaction'; import { IAgentProfileService } from '#/profile'; import { IAgentToolRegistryService } from '#/toolRegistry'; -import type { ToolResult } from '#/tool'; import { IAgentWireRecordService } from '#/wireRecord'; import { IAgentUserToolService, @@ -18,6 +20,13 @@ import { import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +interface UserToolExecutionRequest { + readonly turnId?: number; + readonly toolCallId: string; + readonly name: string; + readonly args: unknown; +} + declare module '#/wireRecord' { interface WireRecordMap { 'tools.register_user_tool': UserToolRegistration; @@ -36,6 +45,7 @@ export class AgentUserToolService extends Disposable implements IAgentUserToolSe @IAgentToolRegistryService private readonly registry: IAgentToolRegistryService, @IAgentProfileService private readonly profile: IAgentProfileService, @IAgentWireRecordService private readonly wireRecord: IAgentWireRecordService, + @ISessionInteractionService private readonly interaction: ISessionInteractionService, ) { super(); this._register( @@ -86,14 +96,42 @@ export class AgentUserToolService extends Disposable implements IAgentUserToolSe } private async executeUserTool( - _context: ExecutableToolContext, - _name: string, - _args: unknown, + context: ExecutableToolContext, + name: string, + args: unknown, ): Promise { - throw new Error('TODO'); + const request = this.interaction.request({ + id: context.toolCallId, + kind: 'user_tool', + payload: { + turnId: numericTurnId(context.turnId), + toolCallId: context.toolCallId, + name, + args, + }, + origin: { + turnId: numericTurnId(context.turnId), + }, + }); + try { + return await abortable(request, context.signal); + } catch (error) { + if (context.signal.aborted) { + this.interaction.respond(context.toolCallId, { + output: `User tool "${name}" was aborted.`, + isError: true, + }); + } + throw error; + } } } +function numericTurnId(turnId: string): number | undefined { + const parsed = Number(turnId); + return Number.isFinite(parsed) ? parsed : undefined; +} + function toExecutableToolResult(result: ToolResult): ExecutableToolResult { if (result.isError === true) { return { diff --git a/packages/agent-core-v2/test/_base/tools/input-schema.test.ts b/packages/agent-core-v2/test/_base/tools/input-schema.test.ts new file mode 100644 index 000000000..b646c0f06 --- /dev/null +++ b/packages/agent-core-v2/test/_base/tools/input-schema.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { + compileToolArgsValidator, + validateToolArgs, +} from '#/_base/tools/args-validator'; +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; + +function collectRequired(schema: unknown, acc: string[] = []): string[] { + if (Array.isArray(schema)) { + for (const item of schema) collectRequired(item, acc); + return acc; + } + if (typeof schema !== 'object' || schema === null) return acc; + for (const [key, value] of Object.entries(schema)) { + if (key === 'required' && Array.isArray(value)) { + for (const name of value) if (typeof name === 'string') acc.push(name); + } else { + collectRequired(value, acc); + } + } + return acc; +} + +describe('tool input JSON Schema', () => { + const inputSchema = z + .object({ + mode: z.enum(['read', 'write']).default('read'), + items: z + .array( + z + .object({ + label: z.string(), + description: z.string().default(''), + }) + .strict(), + ) + .default([]), + }) + .strict(); + + it('keeps defaulted fields out of `required`', () => { + const schema = toInputJsonSchema(inputSchema); + const required = collectRequired(schema); + + expect(required).not.toContain('mode'); + expect(required).not.toContain('items'); + expect(required).not.toContain('description'); + expect(required).toContain('label'); + }); + + it('accepts an empty object through runtime argument validation', () => { + const schema = toInputJsonSchema(inputSchema); + const validator = compileToolArgsValidator(schema); + + expect(validateToolArgs(validator, {})).toBeNull(); + }); + + it('rejects an unknown top-level argument through runtime validation', () => { + const schema = toInputJsonSchema(inputSchema); + const validator = compileToolArgsValidator(schema); + + expect(validateToolArgs(validator, { bogus: true })).not.toBeNull(); + }); + + it('rejects an unknown nested argument through runtime validation', () => { + const schema = toInputJsonSchema(inputSchema); + const validator = compileToolArgsValidator(schema); + + expect( + validateToolArgs(validator, { + items: [{ label: 'A', bogus: true }], + }), + ).not.toBeNull(); + }); +}); diff --git a/packages/agent-core-v2/test/config/config.test.ts b/packages/agent-core-v2/test/config/config.test.ts index c738cbfe5..29aa742da 100644 --- a/packages/agent-core-v2/test/config/config.test.ts +++ b/packages/agent-core-v2/test/config/config.test.ts @@ -195,15 +195,15 @@ describe('Agent config', () => { input: [{ type: 'text', text: 'Look up before config changes' }], }); expect(await ctx.untilApproval(true)).toMatchInlineSnapshot(` - [wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up before config changes" } ], "toolCalls": [] } ], "time": "