From 73a419ff29d637afbfc0aeb214dedbff2cbe8fdf Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 30 Jun 2026 18:39:47 +0800 Subject: [PATCH] feat(agent-core-v2): implement and register remaining builtin tools - background: add TaskList / TaskOutput / TaskStop and register them in BackgroundService - goal: add CreateGoal / GetGoal / SetGoalBudget / UpdateGoal; extend IGoalService with markComplete, markBlocked, setBudgetLimits - skill: register the Skill tool in AgentSkillService - question: add AskUserQuestion (foreground + background) via a new Agent-scoped QuestionToolsService - web: add FetchURL / WebSearch with LocalFetchURL and Moonshot fetch/search providers; add @mozilla/readability and linkedom deps - move ToolResultBuilder into the tool domain so it can be shared across tool domains - wire IQuestionToolsService and IWebService into AgentRPCService so the new registration services are instantiated --- packages/agent-core-v2/package.json | 2 + .../src/background/backgroundService.ts | 9 + .../src/background/tools/format.ts | 14 + .../src/background/tools/task-list.md | 24 ++ .../src/background/tools/task-list.ts | 69 +++++ .../src/background/tools/task-output.md | 12 + .../src/background/tools/task-output.ts | 171 ++++++++++++ .../src/background/tools/task-stop.md | 13 + .../src/background/tools/task-stop.ts | 91 +++++++ packages/agent-core-v2/src/goal/goal.ts | 7 + .../agent-core-v2/src/goal/goalService.ts | 13 + .../src/goal/tools/create-goal.md | 20 ++ .../src/goal/tools/create-goal.ts | 80 ++++++ .../agent-core-v2/src/goal/tools/get-goal.md | 5 + .../agent-core-v2/src/goal/tools/get-goal.ts | 36 +++ .../src/goal/tools/outcome-prompts.ts | 46 ++++ .../agent-core-v2/src/goal/tools/serialize.ts | 17 ++ .../src/goal/tools/set-goal-budget.md | 26 ++ .../src/goal/tools/set-goal-budget.ts | 118 ++++++++ .../src/goal/tools/update-goal.md | 8 + .../src/goal/tools/update-goal.ts | 88 ++++++ packages/agent-core-v2/src/question/index.ts | 2 + .../src/question/questionTools.ts | 18 ++ .../src/question/questionToolsService.ts | 39 +++ .../src/question/tools/ask-user.md | 20 ++ .../src/question/tools/ask-user.ts | 257 ++++++++++++++++++ packages/agent-core-v2/src/rpc/rpcService.ts | 4 + .../src/shellTools/tools/result-builder.ts | 145 +--------- .../agent-core-v2/src/skill/skillService.ts | 4 + .../agent-core-v2/src/tool/result-builder.ts | 150 ++++++++++ packages/agent-core-v2/src/web/index.ts | 8 + .../src/web/providers/local-fetch-url.ts | 191 +++++++++++++ .../src/web/providers/moonshot-fetch-url.ts | 99 +++++++ .../src/web/providers/moonshot-web-search.ts | 131 +++++++++ .../agent-core-v2/src/web/tools/fetch-url.md | 3 + .../agent-core-v2/src/web/tools/fetch-url.ts | 129 +++++++++ .../agent-core-v2/src/web/tools/web-search.md | 3 + .../agent-core-v2/src/web/tools/web-search.ts | 159 +++++++++++ packages/agent-core-v2/src/web/web.ts | 30 ++ packages/agent-core-v2/src/web/webService.ts | 41 +++ .../test/background/background.test.ts | 4 + .../agent-core-v2/test/skill/skill.test.ts | 4 + pnpm-lock.yaml | 6 + 43 files changed, 2176 insertions(+), 140 deletions(-) create mode 100644 packages/agent-core-v2/src/background/tools/format.ts create mode 100644 packages/agent-core-v2/src/background/tools/task-list.md create mode 100644 packages/agent-core-v2/src/background/tools/task-list.ts create mode 100644 packages/agent-core-v2/src/background/tools/task-output.md create mode 100644 packages/agent-core-v2/src/background/tools/task-output.ts create mode 100644 packages/agent-core-v2/src/background/tools/task-stop.md create mode 100644 packages/agent-core-v2/src/background/tools/task-stop.ts create mode 100644 packages/agent-core-v2/src/goal/tools/create-goal.md create mode 100644 packages/agent-core-v2/src/goal/tools/create-goal.ts create mode 100644 packages/agent-core-v2/src/goal/tools/get-goal.md create mode 100644 packages/agent-core-v2/src/goal/tools/get-goal.ts create mode 100644 packages/agent-core-v2/src/goal/tools/outcome-prompts.ts create mode 100644 packages/agent-core-v2/src/goal/tools/serialize.ts create mode 100644 packages/agent-core-v2/src/goal/tools/set-goal-budget.md create mode 100644 packages/agent-core-v2/src/goal/tools/set-goal-budget.ts create mode 100644 packages/agent-core-v2/src/goal/tools/update-goal.md create mode 100644 packages/agent-core-v2/src/goal/tools/update-goal.ts create mode 100644 packages/agent-core-v2/src/question/questionTools.ts create mode 100644 packages/agent-core-v2/src/question/questionToolsService.ts create mode 100644 packages/agent-core-v2/src/question/tools/ask-user.md create mode 100644 packages/agent-core-v2/src/question/tools/ask-user.ts create mode 100644 packages/agent-core-v2/src/tool/result-builder.ts create mode 100644 packages/agent-core-v2/src/web/index.ts create mode 100644 packages/agent-core-v2/src/web/providers/local-fetch-url.ts create mode 100644 packages/agent-core-v2/src/web/providers/moonshot-fetch-url.ts create mode 100644 packages/agent-core-v2/src/web/providers/moonshot-web-search.ts create mode 100644 packages/agent-core-v2/src/web/tools/fetch-url.md create mode 100644 packages/agent-core-v2/src/web/tools/fetch-url.ts create mode 100644 packages/agent-core-v2/src/web/tools/web-search.md create mode 100644 packages/agent-core-v2/src/web/tools/web-search.ts create mode 100644 packages/agent-core-v2/src/web/web.ts create mode 100644 packages/agent-core-v2/src/web/webService.ts diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json index 987b417ee..3b01ef099 100644 --- a/packages/agent-core-v2/package.json +++ b/packages/agent-core-v2/package.json @@ -57,6 +57,7 @@ "dependencies": { "@antfu/utils": "^9.3.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@mozilla/readability": "^0.6.0", "@moonshot-ai/kaos": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kimi-telemetry": "workspace:^", @@ -65,6 +66,7 @@ "chokidar": "^4.0.3", "ignore": "^5.3.2", "js-yaml": "^4.1.1", + "linkedom": "^0.18.12", "nunjucks": "^3.2.4", "pathe": "^2.0.3", "picomatch": "^4.0.4", diff --git a/packages/agent-core-v2/src/background/backgroundService.ts b/packages/agent-core-v2/src/background/backgroundService.ts index a983de1b9..6f3b01886 100644 --- a/packages/agent-core-v2/src/background/backgroundService.ts +++ b/packages/agent-core-v2/src/background/backgroundService.ts @@ -33,6 +33,7 @@ import { IPromptService } from '#/prompt'; import { ISessionContext } from '#/session-context'; import { IAtomicDocumentStore, IStorageService } from '#/storage'; import { ITelemetryService } from '#/telemetry'; +import { IToolRegistry } from '#/toolRegistry'; import type { WireRecord } from '#/wireRecord'; import { IWireRecord } from '#/wireRecord'; import { @@ -47,6 +48,9 @@ import { } from './background'; import { BACKGROUND_SECTION, type BackgroundConfig, BackgroundConfigSchema } from './configSection'; import { BackgroundTaskPersistence } from './persist'; +import { TaskListTool } from './tools/task-list'; +import { TaskOutputTool } from './tools/task-output'; +import { TaskStopTool } from './tools/task-stop'; declare module '#/wireRecord' { interface WireRecordMap { @@ -135,6 +139,7 @@ export class BackgroundService extends Disposable implements IBackgroundService @IPromptService private readonly prompt: IPromptService, @IExternalHooksService private readonly externalHooks: IExternalHooksService, @IContextMemory private readonly context: IContextMemory, + @IToolRegistry toolRegistry: IToolRegistry, @IConfigRegistry configRegistry: IConfigRegistry, @IConfigService private readonly config: IConfigService, @IAtomicDocumentStore atomicDocs: IAtomicDocumentStore, @@ -179,6 +184,10 @@ export class BackgroundService extends Disposable implements IBackgroundService } }), ); + + 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 { diff --git a/packages/agent-core-v2/src/background/tools/format.ts b/packages/agent-core-v2/src/background/tools/format.ts new file mode 100644 index 000000000..0f14bc127 --- /dev/null +++ b/packages/agent-core-v2/src/background/tools/format.ts @@ -0,0 +1,14 @@ +function formatValue(value: unknown): string { + return typeof value === 'string' ? value : String(value); +} + +function fieldName(key: string): string { + return key.replaceAll(/[A-Z]/g, (match) => `_${match.toLowerCase()}`); +} + +export function formatPlainObject(record: object): string { + return Object.entries(record) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => `${fieldName(key)}: ${formatValue(value)}`) + .join('\n'); +} diff --git a/packages/agent-core-v2/src/background/tools/task-list.md b/packages/agent-core-v2/src/background/tools/task-list.md new file mode 100644 index 000000000..c2826c1ef --- /dev/null +++ b/packages/agent-core-v2/src/background/tools/task-list.md @@ -0,0 +1,24 @@ +List background tasks and their current status. + +Use this tool to discover which background tasks exist and where each one +stands. It is the entry point for inspecting background work: it returns a +task ID, status, command, description, and PID for every task it reports, +plus the exit code and stop reason for tasks that have already finished. + +Guidelines: + +- After a context compaction, or whenever you are unsure which background + tasks are running or what their task IDs are, call this tool to + re-enumerate them instead of guessing a task ID. +- Prefer the default `active_only=true`, which lists only non-terminal tasks. + Pass `active_only=false` only when you specifically need to see tasks that + have already finished. With `active_only=false` the result may also include + `lost` tasks — tasks left over from a previous process that can no longer be + inspected or controlled; treat them as already terminated. +- `limit` caps how many tasks are returned. It accepts a value between 1 and + 100 and defaults to 20 when omitted. +- This tool only lists tasks; it does not return their output. Use it first + to locate the task ID you need, then call `TaskOutput` with that ID to read + the task's output and details. +- This tool is read-only and does not change any state, so it is always safe + to call, including in plan mode. diff --git a/packages/agent-core-v2/src/background/tools/task-list.ts b/packages/agent-core-v2/src/background/tools/task-list.ts new file mode 100644 index 000000000..a0a160a83 --- /dev/null +++ b/packages/agent-core-v2/src/background/tools/task-list.ts @@ -0,0 +1,69 @@ +/** + * TaskListTool — list background tasks. + */ + +import { z } from 'zod'; + +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import { matchesGlobRuleSubject } from '#/_base/tools/support/rule-match'; +import type { BuiltinTool, ToolExecution } from '#/tool'; + +import type { BackgroundTaskInfo, IBackgroundService } from '../background'; +import { formatPlainObject } from './format'; +import TASK_LIST_DESCRIPTION from './task-list.md?raw'; + +// ── Input schema ───────────────────────────────────────────────────── + +export const TaskListInputSchema = z.object({ + active_only: z + .boolean() + .optional() + .default(true) + .describe('Whether to list only non-terminal background tasks.'), + limit: z + .number() + .int() + .min(1) + .max(100) + .default(20) + .describe('Maximum number of tasks to return.') + .optional(), +}); + +export type TaskListInput = z.infer; + +// ── Implementation ─────────────────────────────────────────────────── + +function formatTaskList(tasks: readonly BackgroundTaskInfo[], activeOnly: boolean): string { + // `active_only=false` mixes in terminal/lost tasks, so the count is no + // longer purely "active" — use a neutral label to avoid mislabeling them. + const label = activeOnly ? 'active_background_tasks' : 'background_tasks'; + const header = `${label}: ${String(tasks.length)}`; + if (tasks.length === 0) return `${header}\nNo background tasks found.`; + return `${header}\n${tasks.map((task) => formatPlainObject(task)).join('\n---\n')}`; +} + +export class TaskListTool implements BuiltinTool { + readonly name = 'TaskList' as const; + readonly description = TASK_LIST_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TaskListInputSchema); + + constructor(private readonly background: IBackgroundService) {} + + resolveExecution(args: TaskListInput): ToolExecution { + const listScope = (args.active_only ?? true) ? 'active' : 'all'; + return { + description: 'Listing background tasks', + approvalRule: this.name, + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, listScope), + execute: async () => { + const activeOnly = args.active_only ?? true; + const tasks = this.background.list(activeOnly, args.limit ?? 20); + return { + output: formatTaskList(tasks, activeOnly), + isError: false, + }; + }, + }; + } +} diff --git a/packages/agent-core-v2/src/background/tools/task-output.md b/packages/agent-core-v2/src/background/tools/task-output.md new file mode 100644 index 000000000..1b62c7784 --- /dev/null +++ b/packages/agent-core-v2/src/background/tools/task-output.md @@ -0,0 +1,12 @@ +Retrieve output from a running or completed background task. + +Use this after `Bash(run_in_background=true)` or `Agent(run_in_background=true)` when you need to inspect progress or explicitly wait for completion. + +Guidelines: +- Prefer relying on automatic completion notifications. Use this tool only when you need task output before the automatic notification arrives. +- By default this tool is non-blocking and returns a current status/output snapshot. +- Use block=true only when you intentionally want to wait for completion or timeout. +- This tool returns structured task metadata, a fixed-size output preview, and an output_path for the full log. +- For a terminal task, the metadata also explains why it ended: `status: timed_out` when a task was aborted by its deadline, and `stop_reason` when the task was explicitly stopped. `terminal_reason` is a categorical label for the same event — its value is `timed_out` or `stopped` — and is emitted alongside the matching status / `stop_reason` field. A task that ended on its own emits neither `stop_reason` nor `terminal_reason`. +- The full, never-truncated log is always available at output_path; use the `Read` tool with that path to page through it, whether or not the preview was truncated. +- This tool works with the generic background task system and should remain the primary read path for future task types, not just bash. diff --git a/packages/agent-core-v2/src/background/tools/task-output.ts b/packages/agent-core-v2/src/background/tools/task-output.ts new file mode 100644 index 000000000..c54518dff --- /dev/null +++ b/packages/agent-core-v2/src/background/tools/task-output.ts @@ -0,0 +1,171 @@ +/** + * TaskOutputTool — read output from a background task. + * + * Returns structured task metadata plus a fixed-size tail preview of the + * task's output. The full, never-truncated output lives on disk at + * `output_path`; the caller is always pointed at the `Read` tool to page + * through the complete log, and the preview also carries a banner when it + * has been truncated to a tail. + * + * For terminal tasks the output also surfaces why the task ended: + * `stop_reason` records the concrete reason; `terminal_reason` classifies + * timeout vs. explicit stop vs. failure for callers that need stable labels. + */ + +import { z } from 'zod'; + +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import { matchesGlobRuleSubject } from '#/_base/tools/support/rule-match'; +import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/tool'; + +import type { + BackgroundTaskInfo, + BackgroundTaskOutputSnapshot, + IBackgroundService, +} from '../background'; +import { type BackgroundTaskStatus, TERMINAL_STATUSES } from '../task'; +import { formatPlainObject } from './format'; +import TASK_OUTPUT_DESCRIPTION from './task-output.md?raw'; + +/** + * Maximum bytes of output included inline as a preview. Output larger + * than this is truncated to its tail; the full log is read separately + * via the `Read` tool with the returned `output_path`. + */ +const OUTPUT_PREVIEW_BYTES = 32 * 1024; // 32 KiB + +/** Number of lines the paging hint suggests reading per `Read` call. */ +const PAGING_HINT_LINES = 300; + +// ── Input schema ───────────────────────────────────────────────────── + +export const TaskOutputInputSchema = z.object({ + task_id: z.string().describe('The background task ID to inspect.'), + block: z + .boolean() + .default(false) + .describe('Whether to wait for the task to finish before returning.') + .optional(), + timeout: z + .number() + .int() + .min(0) + .max(3600) + .default(30) + .describe('Maximum number of seconds to wait when block=true.') + .optional(), +}); + +export type TaskOutputInput = z.infer; + +// ── Implementation ─────────────────────────────────────────────────── + +function retrievalStatus( + status: BackgroundTaskStatus, + block: boolean | undefined, +): 'success' | 'timeout' | 'not_ready' { + if (TERMINAL_STATUSES.has(status)) return 'success'; + return block ? 'timeout' : 'not_ready'; +} + +function terminalReason(info: BackgroundTaskInfo): 'timed_out' | 'stopped' | 'failed' | undefined { + if (info.status === 'timed_out') return 'timed_out'; + if (info.status === 'killed' && info.stopReason !== undefined) return 'stopped'; + if (info.status === 'failed' && info.stopReason !== undefined) return 'failed'; + return undefined; +} + +function fullOutputHint(output: BackgroundTaskOutputSnapshot): string | undefined { + if (!output.fullOutputAvailable || output.outputPath === undefined) return undefined; + if (output.truncated) { + return ( + `Only the last ${String(OUTPUT_PREVIEW_BYTES)} bytes are shown above. ` + + 'Use the Read tool with the output_path to page through the full log ' + + `(parameters: path, line_offset, n_lines; read about ${String(PAGING_HINT_LINES)} ` + + 'lines per page).' + ); + } + return ( + 'The preview above is the complete output. Use the Read tool with the output_path ' + + 'if you need to re-read the full log later ' + + `(parameters: path, line_offset, n_lines; read about ${String(PAGING_HINT_LINES)} ` + + 'lines per page).' + ); +} + +export class TaskOutputTool implements BuiltinTool { + readonly name = 'TaskOutput' as const; + readonly description: string = TASK_OUTPUT_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TaskOutputInputSchema); + + constructor(private readonly background: IBackgroundService) {} + + resolveExecution(args: TaskOutputInput): ToolExecution { + return { + description: `Reading output of task ${args.task_id}`, + approvalRule: this.name, + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.task_id), + execute: () => this.execute(args), + }; + } + + private async execute(args: TaskOutputInput): Promise { + const info = this.background.getTask(args.task_id); + if (!info) { + return { isError: true, output: `Task not found: ${args.task_id}` }; + } + + if (args.block && !TERMINAL_STATUSES.has(info.status)) { + await this.background.wait(args.task_id, (args.timeout ?? 30) * 1000); + } + + // Re-fetch after potential wait. + const current = this.background.getTask(args.task_id); + if (!current) { + return { isError: true, output: `Task not found: ${args.task_id}` }; + } + + // A single manager-owned snapshot drives the tail window and every + // reported metric below. Persisted logs remain authoritative when + // available; detached managers fall back to their live ring buffer. + const output = await this.background.getOutputSnapshot(args.task_id, OUTPUT_PREVIEW_BYTES); + + const lines = [ + formatPlainObject({ + retrievalStatus: retrievalStatus(current.status, args.block), + ...current, + outputPath: output.outputPath, + terminalReason: terminalReason(current), + outputSizeBytes: output.outputSizeBytes, + outputPreviewBytes: output.previewBytes, + outputTruncated: output.truncated, + fullOutputAvailable: output.fullOutputAvailable, + fullOutputTool: + output.fullOutputAvailable && output.outputPath !== undefined ? 'Read' : undefined, + fullOutputHint: fullOutputHint(output), + }), + '', + ]; + + // When the preview omits the head of the log, emit an explicit + // banner just before the `[output]` marker so the model knows it is + // looking at a tail, not the full output. + if (output.truncated) { + lines.push( + output.fullOutputAvailable && output.outputPath !== undefined + ? `[Truncated. Full output: ${output.outputPath}]` + : '[Truncated. No persisted full log is available for this task.]', + ); + } + lines.push('[output]', output.preview || '[no output available]'); + + // Side-channel brief for the host UI / log readers. Distinct from + // the `output` body which is parsed by the LLM. Kept short so log + // readers can render it as a one-liner. + return { + output: lines.join('\n'), + isError: false, + message: 'Task snapshot retrieved.', + }; + } +} diff --git a/packages/agent-core-v2/src/background/tools/task-stop.md b/packages/agent-core-v2/src/background/tools/task-stop.md new file mode 100644 index 000000000..c1ff20a01 --- /dev/null +++ b/packages/agent-core-v2/src/background/tools/task-stop.md @@ -0,0 +1,13 @@ +Stop a running background task. + +Only use this when a task must genuinely be cancelled — for a task that is +finishing normally, wait for its completion notification or inspect it with +`TaskOutput` instead of stopping it. + +Guidelines: +- This is a general-purpose stop capability for any background task. It is not + a bash-specific kill. +- Stopping a task is destructive: it may leave partial side effects behind. + Use it with care. +- If the task has already finished, this tool simply returns its current + status. diff --git a/packages/agent-core-v2/src/background/tools/task-stop.ts b/packages/agent-core-v2/src/background/tools/task-stop.ts new file mode 100644 index 000000000..36f13b59a --- /dev/null +++ b/packages/agent-core-v2/src/background/tools/task-stop.ts @@ -0,0 +1,91 @@ +/** + * TaskStopTool — stop a running background task. + */ + +import { z } from 'zod'; + +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import { matchesGlobRuleSubject } from '#/_base/tools/support/rule-match'; +import type { BuiltinTool, ToolExecution } from '#/tool'; + +import type { IBackgroundService } from '../background'; +import { TERMINAL_STATUSES } from '../task'; +import TASK_STOP_DESCRIPTION from './task-stop.md?raw'; + +// ── Input schema ───────────────────────────────────────────────────── + +export const TaskStopInputSchema = z.object({ + task_id: z.string().describe('The background task ID to stop.'), + reason: z + .string() + .default('Stopped by TaskStop') + .describe('Short reason recorded when the task is stopped.') + .optional(), +}); + +export type TaskStopInput = z.infer; + +// ── Implementation ─────────────────────────────────────────────────── + +export class TaskStopTool implements BuiltinTool { + readonly name = 'TaskStop' as const; + readonly description = TASK_STOP_DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(TaskStopInputSchema); + + constructor(private readonly background: IBackgroundService) {} + + resolveExecution(args: TaskStopInput): ToolExecution { + return { + description: `Stopping task ${args.task_id}`, + approvalRule: this.name, + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.task_id), + execute: async () => { + const info = this.background.getTask(args.task_id); + if (!info) { + return { isError: true, output: `Task not found: ${args.task_id}` }; + } + + // A blank or whitespace-only reason falls back to the default. `?? default` + // would not cover the empty-string case, so trim and coalesce explicitly. + const trimmedReason = args.reason?.trim(); + const reason = + trimmedReason === undefined || trimmedReason.length === 0 + ? 'Stopped by TaskStop' + : trimmedReason; + + if (TERMINAL_STATUSES.has(info.status)) { + // Already-terminal tasks report their current state using the same + // structured multi-line format as the normal stop path below. + return { + output: + `task_id: ${info.taskId}\n` + + `status: ${info.status}\n` + + // A task persisted by an older build may carry a blank stopReason; + // `??` would not coalesce `''`, so trim-and-`||` to the placeholder. + `reason: ${terminalStopReason(info.stopReason)}`, + isError: false, + }; + } + + await this.background.suppressTerminalNotification(args.task_id); + const result = await this.background.stop(args.task_id, reason); + if (!result) { + return { isError: true, output: `Failed to stop task: ${args.task_id}` }; + } + + return { + output: + `task_id: ${result.taskId}\n` + + `status: ${result.status}\n` + + `reason: ${result.stopReason ?? reason}`, + isError: false, + }; + }, + }; + } +} + +function terminalStopReason(reason: string | undefined): string { + const trimmed = reason?.trim(); + return trimmed === undefined || trimmed.length === 0 ? 'Task already in terminal state' : trimmed; +} diff --git a/packages/agent-core-v2/src/goal/goal.ts b/packages/agent-core-v2/src/goal/goal.ts index ec208b49d..e28fcd8ec 100644 --- a/packages/agent-core-v2/src/goal/goal.ts +++ b/packages/agent-core-v2/src/goal/goal.ts @@ -2,6 +2,7 @@ import { createDecorator } from "#/_base/di"; import type { CreateGoalInput, GoalActor, + GoalBudgetLimits, GoalSnapshot, GoalToolResult, } from './types'; @@ -17,6 +18,12 @@ export interface IGoalService { pauseGoal(input?: GoalReasonInput, actor?: GoalActor): Promise; resumeGoal(input?: GoalReasonInput, actor?: GoalActor): Promise; cancelGoal(actor?: GoalActor): Promise; + setBudgetLimits( + input: { readonly budgetLimits: GoalBudgetLimits }, + actor?: GoalActor, + ): Promise; + markComplete(input?: GoalReasonInput, actor?: GoalActor): Promise; + markBlocked(input?: GoalReasonInput, actor?: GoalActor): Promise; } export const IGoalService = createDecorator('agentGoalService'); diff --git a/packages/agent-core-v2/src/goal/goalService.ts b/packages/agent-core-v2/src/goal/goalService.ts index 012fd2e8a..b55ee5f48 100644 --- a/packages/agent-core-v2/src/goal/goalService.ts +++ b/packages/agent-core-v2/src/goal/goalService.ts @@ -9,10 +9,12 @@ import { import { ErrorCodes, KimiError } from "#/errors"; import { IContextInjector } from '../contextInjector'; import { IEventSink } from '../eventSink'; +import { IPermissionModeService } from '#/permissionMode'; import { IReplayBuilderService } from '#/replayBuilder'; import { ISystemReminderService } from '#/systemReminder'; import type { TelemetryProperties } from '#/telemetry'; import { ITelemetryService } from '#/telemetry'; +import { IToolRegistry } from '#/toolRegistry'; import type { WireRecord } from '#/wireRecord'; import { IWireRecord } from '#/wireRecord'; import { @@ -34,6 +36,10 @@ import type { GoalStatus, GoalToolResult, } from './types'; +import { CreateGoalTool } from './tools/create-goal'; +import { GetGoalTool } from './tools/get-goal'; +import { SetGoalBudgetTool } from './tools/set-goal-budget'; +import { UpdateGoalTool } from './tools/update-goal'; declare module '#/wireRecord' { interface WireRecordMap { @@ -101,6 +107,8 @@ export class GoalService extends Disposable implements IGoalService { @IReplayBuilderService private readonly replayBuilder: IReplayBuilderService, @ITelemetryService private readonly telemetry: ITelemetryService, @IContextInjector private readonly dynamicInjector: IContextInjector, + @IToolRegistry toolRegistry: IToolRegistry, + @IPermissionModeService private readonly permissionMode: IPermissionModeService, ) { super(); this._register( @@ -138,6 +146,11 @@ export class GoalService extends Disposable implements IGoalService { this.normalizeAfterReplay(); }), ); + + 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, this.reminders))); } get enabled(): boolean { diff --git a/packages/agent-core-v2/src/goal/tools/create-goal.md b/packages/agent-core-v2/src/goal/tools/create-goal.md new file mode 100644 index 000000000..bd1c72c6d --- /dev/null +++ b/packages/agent-core-v2/src/goal/tools/create-goal.md @@ -0,0 +1,20 @@ +Create a durable, structured goal that the runtime will pursue across multiple turns. + +Call `CreateGoal` only when: + +- the user explicitly asks you to start a goal or work autonomously toward an outcome, or +- a host goal-intake prompt asks you to create one. + +Do NOT create a goal for greetings, ordinary questions, or vague requests that lack a +verifiable completion condition. A goal needs a checkable end state. + +When the request is vague, ask the user for the missing completion criterion before creating +the goal. If the user clearly insists after you warn them that the wording is vague or risky, +respect that and create the goal. + +Include a `completionCriterion` when the user provides one, or when it can be stated without +inventing new requirements. Keep `objective` concise; reference long task descriptions by file +path rather than pasting them. + +Use `replace: true` only when the user explicitly wants to abandon the current goal and start a +new one. diff --git a/packages/agent-core-v2/src/goal/tools/create-goal.ts b/packages/agent-core-v2/src/goal/tools/create-goal.ts new file mode 100644 index 000000000..5e352c19f --- /dev/null +++ b/packages/agent-core-v2/src/goal/tools/create-goal.ts @@ -0,0 +1,80 @@ +/** + * CreateGoalTool — lets the main agent start an explicit goal on the user's + * behalf. The goal becomes durable, structured state owned by the agent's + * goal service, not text parsed from a slash command. + */ + +import { z } from 'zod'; + +import type { ToolInputDisplay } from '@moonshot-ai/protocol'; + +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import type { IPermissionModeService } from '#/permissionMode'; +import type { BuiltinTool, ToolExecution } from '#/tool'; + +import type { IGoalService } from '../goal'; +import DESCRIPTION from './create-goal.md?raw'; +import { goalForModel } from './serialize'; + +export const CreateGoalToolInputSchema = z + .object({ + objective: z.string().min(1).describe('The objective to pursue. Must have a verifiable end state.'), + completionCriterion: z + .string() + .optional() + .describe('How to verify the goal is complete. Include when the user provides one.'), + replace: z + .boolean() + .optional() + .describe('Replace an existing active or paused goal instead of failing.'), + }) + .strict(); + +export type CreateGoalToolInput = z.infer; + +export class CreateGoalTool implements BuiltinTool { + readonly name = 'CreateGoal' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(CreateGoalToolInputSchema); + + constructor( + private readonly goal: IGoalService, + private readonly permissionMode: IPermissionModeService, + ) {} + + resolveExecution(args: CreateGoalToolInput): ToolExecution { + return { + description: 'Creating a goal', + display: this.resolveGoalStartDisplay(args), + approvalRule: this.name, + execute: async () => { + const snapshot = await this.goal.createGoal( + { + objective: args.objective, + completionCriterion: args.completionCriterion, + replace: args.replace, + }, + 'model', + ); + return { output: JSON.stringify({ goal: goalForModel(snapshot) }, null, 2) }; + }, + }; + } + + /** + * Starting a goal switches the agent into autonomous, multi-turn work, so its + * approval reuses the same choice the `/goal` command offers: pick the + * permission mode to run under, or decline. `auto` mode auto-approves the goal + * upstream and never reaches this prompt, so the menu only covers manual/yolo. + */ + private resolveGoalStartDisplay(args: CreateGoalToolInput): ToolInputDisplay | undefined { + const mode = this.permissionMode.mode; + if (mode === 'auto') return undefined; + return { + kind: 'goal_start', + objective: args.objective, + completionCriterion: args.completionCriterion, + mode, + }; + } +} diff --git a/packages/agent-core-v2/src/goal/tools/get-goal.md b/packages/agent-core-v2/src/goal/tools/get-goal.md new file mode 100644 index 000000000..26f61f7c9 --- /dev/null +++ b/packages/agent-core-v2/src/goal/tools/get-goal.md @@ -0,0 +1,5 @@ +Read the current goal: its objective, completion criterion, status, budgets (turns, tokens, +time, and how much remains), the latest self-report, and the latest evaluator verdict. + +Use `GetGoal` before deciding whether to continue working, report completion, report a blocker, +or respect a pause. It returns `{ "goal": null }` when there is no current goal. diff --git a/packages/agent-core-v2/src/goal/tools/get-goal.ts b/packages/agent-core-v2/src/goal/tools/get-goal.ts new file mode 100644 index 000000000..0d58f6ba4 --- /dev/null +++ b/packages/agent-core-v2/src/goal/tools/get-goal.ts @@ -0,0 +1,36 @@ +/** + * GetGoalTool — returns the current goal snapshot (objective, status, budgets, + * and usage counters) so the model can decide whether to continue, report + * completion via UpdateGoal, report a blocker, or respect a pause. + */ + +import { z } from 'zod'; + +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import type { BuiltinTool, ToolExecution } from '#/tool'; + +import type { IGoalService } from '../goal'; +import DESCRIPTION from './get-goal.md?raw'; +import { goalResultForModel } from './serialize'; + +export const GetGoalToolInputSchema = z.object({}).strict(); +export type GetGoalToolInput = z.infer; + +export class GetGoalTool implements BuiltinTool { + readonly name = 'GetGoal' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(GetGoalToolInputSchema); + + constructor(private readonly goal: IGoalService) {} + + resolveExecution(_args: GetGoalToolInput): ToolExecution { + return { + description: 'Reading the current goal', + approvalRule: this.name, + execute: async () => { + const result = this.goal.getGoal(); + return { output: JSON.stringify(goalResultForModel(result), null, 2) }; + }, + }; + } +} diff --git a/packages/agent-core-v2/src/goal/tools/outcome-prompts.ts b/packages/agent-core-v2/src/goal/tools/outcome-prompts.ts new file mode 100644 index 000000000..568d56507 --- /dev/null +++ b/packages/agent-core-v2/src/goal/tools/outcome-prompts.ts @@ -0,0 +1,46 @@ +import type { GoalSnapshot } from '../types'; + +export function buildGoalCompletionSummaryPrompt(goal: GoalSnapshot): string { + return [ + buildGoalCompletionPromptMessage(goal), + '', + 'Write a concise final message for the user. State that the goal is complete, summarize the main work completed, and mention any validation you ran. Do not call more goal tools.', + ].join('\n'); +} + +export function buildGoalBlockedReasonPrompt(goal: GoalSnapshot): string { + return [ + buildGoalBlockedMessage(goal), + '', + 'Write a concise final message for the user. State that the goal is blocked, explain the concrete blocker, and say what input or change is needed before work can continue. Do not call more goal tools.', + ].join('\n'); +} + +function buildGoalCompletionPromptMessage(goal: GoalSnapshot): string { + const head = `Goal completed successfully${goal.terminalReason ? `: ${goal.terminalReason}` : ''}.`; + const turns = `${goal.turnsUsed} turn${goal.turnsUsed === 1 ? '' : 's'}`; + const stats = `Worked ${turns} over ${formatElapsed(goal.wallClockMs)}, using ${formatTokens(goal.tokensUsed)} tokens.`; + return `${head}\n${stats}`; +} + +function buildGoalBlockedMessage(goal: GoalSnapshot): string { + const turns = `${goal.turnsUsed} turn${goal.turnsUsed === 1 ? '' : 's'}`; + const stats = `Worked ${turns} over ${formatElapsed(goal.wallClockMs)}, using ${formatTokens(goal.tokensUsed)} tokens.`; + return `Goal blocked.\n${stats}`; +} + +function formatElapsed(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${String(totalSeconds)}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return `${String(minutes)}m${seconds.toString().padStart(2, '0')}s`; + const hours = Math.floor(minutes / 60); + return `${String(hours)}h${(minutes % 60).toString().padStart(2, '0')}m`; +} + +function formatTokens(tokens: number): string { + if (tokens < 1000) return String(tokens); + if (tokens < 1_000_000) return `${(tokens / 1000).toFixed(1)}k`; + return `${(tokens / 1_000_000).toFixed(1)}M`; +} diff --git a/packages/agent-core-v2/src/goal/tools/serialize.ts b/packages/agent-core-v2/src/goal/tools/serialize.ts new file mode 100644 index 000000000..cc52782fe --- /dev/null +++ b/packages/agent-core-v2/src/goal/tools/serialize.ts @@ -0,0 +1,17 @@ +import type { GoalSnapshot, GoalToolResult } from '../types'; + +/** + * The goalId is a random UUID with no user-facing meaning, and no goal tool + * takes one (there is only ever one goal at a time). Keep it out of what the + * model sees so it never echoes the id back to the user as if it mattered. + */ +export function goalForModel(goal: GoalSnapshot): Omit { + const { goalId: _goalId, ...rest } = goal; + return rest; +} + +export function goalResultForModel( + result: GoalToolResult, +): { goal: Omit | null } { + return { goal: result.goal === null ? null : goalForModel(result.goal) }; +} diff --git a/packages/agent-core-v2/src/goal/tools/set-goal-budget.md b/packages/agent-core-v2/src/goal/tools/set-goal-budget.md new file mode 100644 index 000000000..13af49d29 --- /dev/null +++ b/packages/agent-core-v2/src/goal/tools/set-goal-budget.md @@ -0,0 +1,26 @@ +Set a hard budget limit for the current goal. + +Use this only when the user clearly gives a runtime limit, such as: + +- "stop after 20 turns" +- "use no more than 500k tokens" +- "finish within 30 minutes" + +Do not invent limits. Do not call this for vague wording such as "spend some time" or +"try to be quick". + +If the user gives a compound time, convert it to one supported unit before calling this tool. +For example, "2 hours and 3 minutes" can be set as `value: 123, unit: "minutes"`. + +If the requested budget is not reasonable, do not set it. Tell the user that the requested +budget is not reasonable. Examples include a time budget that is too short to act on, such as +1 millisecond, or too long for an interactive goal run, such as 1 year. + +Supported units: + +- `turns` +- `tokens` +- `milliseconds` +- `seconds` +- `minutes` +- `hours` diff --git a/packages/agent-core-v2/src/goal/tools/set-goal-budget.ts b/packages/agent-core-v2/src/goal/tools/set-goal-budget.ts new file mode 100644 index 000000000..ebd85b2e4 --- /dev/null +++ b/packages/agent-core-v2/src/goal/tools/set-goal-budget.ts @@ -0,0 +1,118 @@ +/** + * SetGoalBudgetTool — lets the model record a user-stated hard runtime limit + * for the current goal. The tool accepts one limit at a time, converts supported + * time units to milliseconds, and rejects obviously unreasonable time limits. + */ + +import { z } from 'zod'; + +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import type { BuiltinTool, ToolExecution } from '#/tool'; + +import type { IGoalService } from '../goal'; +import type { GoalBudgetLimits } from '../types'; +import DESCRIPTION from './set-goal-budget.md?raw'; + +const MIN_REASONABLE_TIME_BUDGET_MS = 1_000; +const MAX_REASONABLE_TIME_BUDGET_MS = 24 * 60 * 60 * 1000; +const BUDGET_UNITS = ['turns', 'tokens', 'milliseconds', 'seconds', 'minutes', 'hours'] as const; + +export const SetGoalBudgetToolInputSchema = z + .object({ + // Keep the provider-facing schema simple. Fractional turn/token budgets + // are normalized during execution instead of rejected at schema validation. + value: z.number().positive().describe('The positive numeric budget value.'), + unit: z.enum(BUDGET_UNITS), + }) + .strict(); + +export type SetGoalBudgetToolInput = z.infer; + +export class SetGoalBudgetTool implements BuiltinTool { + readonly name = 'SetGoalBudget' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(SetGoalBudgetToolInputSchema); + + constructor(private readonly goal: IGoalService) {} + + resolveExecution(args: SetGoalBudgetToolInput): ToolExecution { + const normalizedArgs = normalizeBudgetInput(args); + return { + description: `Setting goal budget: ${formatBudget( + normalizedArgs.value, + normalizedArgs.unit, + )}`, + approvalRule: this.name, + execute: async () => { + const budget = budgetLimitsFromInput(normalizedArgs); + if (budget === null) { + return { + output: + `Goal budget not set: ${formatBudget(normalizedArgs.value, normalizedArgs.unit)} is not a ` + + 'reasonable goal budget.', + }; + } + await this.goal.setBudgetLimits({ budgetLimits: budget }, 'model'); + return { + output: `Goal budget set: ${formatBudget(normalizedArgs.value, normalizedArgs.unit)}.`, + }; + }, + }; + } +} + +function normalizeBudgetInput(input: SetGoalBudgetToolInput): SetGoalBudgetToolInput { + switch (input.unit) { + case 'turns': + case 'tokens': + return { ...input, value: Math.max(1, Math.round(input.value)) }; + case 'milliseconds': + case 'seconds': + case 'minutes': + case 'hours': + return input; + } +} + +function budgetLimitsFromInput(input: SetGoalBudgetToolInput): GoalBudgetLimits | null { + switch (input.unit) { + case 'turns': + return { turnBudget: input.value }; + case 'tokens': + return { tokenBudget: input.value }; + case 'milliseconds': + case 'seconds': + case 'minutes': + case 'hours': { + const wallClockBudgetMs = Math.round(toMilliseconds(input.value, input.unit)); + if ( + wallClockBudgetMs < MIN_REASONABLE_TIME_BUDGET_MS || + wallClockBudgetMs > MAX_REASONABLE_TIME_BUDGET_MS + ) { + return null; + } + return { wallClockBudgetMs }; + } + } +} + +function toMilliseconds( + value: number, + unit: Extract, +): number { + switch (unit) { + case 'milliseconds': + return value; + case 'seconds': + return value * 1000; + case 'minutes': + return value * 60 * 1000; + case 'hours': + return value * 60 * 60 * 1000; + } +} + +function formatBudget(value: number, unit: SetGoalBudgetToolInput['unit']): string { + const singular = unit.endsWith('s') ? unit.slice(0, -1) : unit; + return `${String(value)} ${value === 1 ? singular : unit}`; +} diff --git a/packages/agent-core-v2/src/goal/tools/update-goal.md b/packages/agent-core-v2/src/goal/tools/update-goal.md new file mode 100644 index 000000000..e27ef8c4a --- /dev/null +++ b/packages/agent-core-v2/src/goal/tools/update-goal.md @@ -0,0 +1,8 @@ +Set the status of the current goal. This is how you resume, end, or yield an autonomous goal. + +- `active` — resume a paused or blocked goal when the user explicitly asks you to work on that goal. +- `complete` — the objective is satisfied and any stated validation has passed. The goal ends and a completion summary is recorded. +- `blocked` — an external condition or required user input prevents progress, or the objective cannot be completed as stated. The goal stops but can be resumed later. +- `paused` — set the goal aside for now (e.g. to hand control back to the user). It can be resumed later. + +If the goal is active and you do not call this, the goal keeps running: after your turn ends you will be prompted to continue. Call `complete` only when all required work is done, any stated validation has passed, and there is no useful next action. Do not call `complete` after only producing a plan, summary, first pass, or partial result. If you call `blocked`, you will be prompted to explain the blocker in your next message. This tool only records the status. diff --git a/packages/agent-core-v2/src/goal/tools/update-goal.ts b/packages/agent-core-v2/src/goal/tools/update-goal.ts new file mode 100644 index 000000000..81bf4c9cf --- /dev/null +++ b/packages/agent-core-v2/src/goal/tools/update-goal.ts @@ -0,0 +1,88 @@ +/** + * UpdateGoalTool — the model's single lever over the goal lifecycle. It updates + * the goal's status directly; the turn driver reads the status at each turn + * boundary and stops (`complete` / `blocked` / `paused`) or keeps going + * (`active`). + * + * The argument is intentionally just a status enum — no reason or evidence. The + * model explains itself in its own reply; the status is the machine-readable + * signal. The tool is only offered to the model while a goal exists. + */ + +import { z } from 'zod'; + +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import type { ISystemReminderService } from '#/systemReminder'; +import type { BuiltinTool, ToolExecution } from '#/tool'; + +import type { IGoalService } from '../goal'; +import { + buildGoalBlockedReasonPrompt, + buildGoalCompletionSummaryPrompt, +} from './outcome-prompts'; +import DESCRIPTION from './update-goal.md?raw'; + +const GOAL_COMPLETION_REMINDER_NAME = 'goal_completion_summary'; +const GOAL_BLOCKED_REMINDER_NAME = 'goal_blocked_reason'; + +export const UpdateGoalToolInputSchema = z + .object({ + status: z + .enum(['active', 'complete', 'paused', 'blocked']) + .describe('The lifecycle status to set for the current goal.'), + }) + .strict(); + +export type UpdateGoalToolInput = z.infer; + +export class UpdateGoalTool implements BuiltinTool { + readonly name = 'UpdateGoal' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(UpdateGoalToolInputSchema); + + constructor( + private readonly goal: IGoalService, + private readonly reminders: ISystemReminderService, + ) {} + + resolveExecution(args: UpdateGoalToolInput): ToolExecution { + return { + description: `Setting goal status: ${args.status}`, + stopBatchAfterThis: args.status !== 'active', + approvalRule: this.name, + execute: async () => { + if (args.status === 'active') { + await this.goal.resumeGoal({}, 'model'); + return { output: 'Goal resumed.' }; + } + if (args.status === 'complete') { + const completed = await this.goal.markComplete({}, 'model'); + // `complete` is transient: markComplete announces then clears the + // record. Store the summary request as a system reminder, so the next + // provider request ends with a user message after the UpdateGoal tool + // result. Anthropic-compatible providers reject trailing assistant + // messages as unsupported prefill. + if (completed !== null) { + this.reminders.appendSystemReminder(buildGoalCompletionSummaryPrompt(completed), { + kind: 'system_trigger', + name: GOAL_COMPLETION_REMINDER_NAME, + }); + } + return { output: 'Goal marked complete.', stopTurn: true }; + } + if (args.status === 'blocked') { + const blocked = await this.goal.markBlocked({}, 'model'); + if (blocked !== null) { + this.reminders.appendSystemReminder(buildGoalBlockedReasonPrompt(blocked), { + kind: 'system_trigger', + name: GOAL_BLOCKED_REMINDER_NAME, + }); + } + return { output: 'Goal marked blocked.', stopTurn: true }; + } + await this.goal.pauseGoal({}, 'model'); + return { output: 'Goal paused.', stopTurn: true }; + }, + }; + } +} diff --git a/packages/agent-core-v2/src/question/index.ts b/packages/agent-core-v2/src/question/index.ts index c5ace199e..f30ce2f13 100644 --- a/packages/agent-core-v2/src/question/index.ts +++ b/packages/agent-core-v2/src/question/index.ts @@ -6,3 +6,5 @@ export * from './question'; export * from './questionService'; +export * from './questionTools'; +export * from './questionToolsService'; diff --git a/packages/agent-core-v2/src/question/questionTools.ts b/packages/agent-core-v2/src/question/questionTools.ts new file mode 100644 index 000000000..fa8f8861f --- /dev/null +++ b/packages/agent-core-v2/src/question/questionTools.ts @@ -0,0 +1,18 @@ +/** + * `question` domain (L7) — ask-user tool registration contract. + * + * `IQuestionToolsService` is a marker: its implementation registers the + * built-in `AskUserQuestion` tool into the agent `IToolRegistry` on + * construction. Bound at Agent scope (the tool needs the agent-scoped + * `IToolRegistry` and `IBackgroundService`, plus the session-scoped + * `IQuestionService`). + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IQuestionToolsService { + readonly _serviceBrand: undefined; +} + +export const IQuestionToolsService: ServiceIdentifier = + createDecorator('questionToolsService'); diff --git a/packages/agent-core-v2/src/question/questionToolsService.ts b/packages/agent-core-v2/src/question/questionToolsService.ts new file mode 100644 index 000000000..e751d8348 --- /dev/null +++ b/packages/agent-core-v2/src/question/questionToolsService.ts @@ -0,0 +1,39 @@ +/** + * `question` domain (L7) — `IQuestionToolsService` implementation. + * + * Registers the built-in `AskUserQuestion` tool into the agent `IToolRegistry` + * on construction, wiring it to the session `IQuestionService` (ask-user + * broker), the agent `IBackgroundService` (background-question lifecycle) and + * `ITelemetryService`. Bound at Agent scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IBackgroundService } from '#/background'; +import { ITelemetryService } from '#/telemetry'; +import { IToolRegistry } from '#/toolRegistry'; + +import { IQuestionService } from './question'; +import { IQuestionToolsService } from './questionTools'; +import { AskUserQuestionTool } from './tools/ask-user'; + +export class QuestionToolsService implements IQuestionToolsService { + declare readonly _serviceBrand: undefined; + + constructor( + @IToolRegistry toolRegistry: IToolRegistry, + @IQuestionService question: IQuestionService, + @IBackgroundService background: IBackgroundService, + @ITelemetryService telemetry: ITelemetryService, + ) { + toolRegistry.register(new AskUserQuestionTool(question, background, telemetry)); + } +} + +registerScopedService( + LifecycleScope.Agent, + IQuestionToolsService, + QuestionToolsService, + InstantiationType.Delayed, + 'questionTools', +); diff --git a/packages/agent-core-v2/src/question/tools/ask-user.md b/packages/agent-core-v2/src/question/tools/ask-user.md new file mode 100644 index 000000000..68e394aec --- /dev/null +++ b/packages/agent-core-v2/src/question/tools/ask-user.md @@ -0,0 +1,20 @@ +Use this tool when you need to ask the user questions with structured options during execution. This allows you to: +1. Collect user preferences or requirements before proceeding +2. Resolve ambiguous or underspecified instructions +3. Let the user decide between implementation approaches as you work +4. Present concrete options when multiple valid directions exist + +**When NOT to use:** +- When you can infer the answer from context — be decisive and proceed +- Trivial decisions that don't materially affect the outcome + +Overusing this tool interrupts the user's flow. Only use it when the user's input genuinely changes your next action. + +**Usage notes:** +- Users always have an "Other" option for custom input — don't create one yourself +- Use multi_select to allow multiple answers to be selected for a question +- Keep option labels concise (1-5 words), use descriptions for trade-offs and details +- Each question should have 2-4 meaningful, distinct options +- You can ask 1-4 questions at a time; group related questions to minimize interruptions +- If you recommend a specific option, list it first and append "(Recommended)" to its label +- Set background=true when you can keep working without the answer. This starts a background question task and returns a task_id immediately. The answer arrives automatically in a later turn — you do not need to poll, sleep, or check on it. Continue with other work; never fabricate or predict the answer. diff --git a/packages/agent-core-v2/src/question/tools/ask-user.ts b/packages/agent-core-v2/src/question/tools/ask-user.ts new file mode 100644 index 000000000..1f32d1632 --- /dev/null +++ b/packages/agent-core-v2/src/question/tools/ask-user.ts @@ -0,0 +1,257 @@ +/** + * AskUserQuestionTool — structured user question tool. + * + * The LLM calls this tool when it needs structured input from the user + * (multiple-choice, preference selection, disambiguation). The tool delegates + * to the `question` domain (backed by the `interaction` kernel), which owns + * the actual UI interaction. With `background=true` the request is parked as a + * background task and the answer arrives in a later turn. + */ + +import { z } from 'zod'; + +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import { QuestionBackgroundTask, type IBackgroundService } from '#/background'; +import type { ITelemetryService, TelemetryProperties } from '#/telemetry'; +import type { + BuiltinTool, + ExecutableToolContext, + ExecutableToolResult, + ToolExecution, +} from '#/tool'; + +import type { + IQuestionService, + QuestionAnswers, + QuestionAnswerMethod, + QuestionResponse, + QuestionResult, +} from '../question'; +import DESCRIPTION from './ask-user.md?raw'; + +// ── Input schema ───────────────────────────────────────────────────── + +const QuestionOptionSchema = z.object({ + label: z + .string() + .describe("Concise display text (1-5 words). If recommended, append '(Recommended)'."), + description: z.string().default('').describe('Brief explanation of trade-offs or implications.'), +}); + +const QuestionItemSchema = z.object({ + question: z.string().describe("A specific, actionable question. End with '?'."), + header: z + .string() + .default('') + .describe("Short category tag (max 12 chars, e.g. 'Auth', 'Style')."), + options: z + .array(QuestionOptionSchema) + .min(2) + .max(4) + .describe( + "2-4 meaningful, distinct options. Do NOT include an 'Other' option — the system adds one automatically.", + ), + multi_select: z + .boolean() + .default(false) + .describe('Whether the user can select multiple options.'), +}); + +export interface AskUserQuestionInput { + background?: boolean; + questions: Array<{ + question: string; + header: string; + options: Array<{ label: string; description: string }>; + multi_select: boolean; + }>; +} + +const AskUserQuestionInputBaseSchema = z.object({ + questions: z + .array(QuestionItemSchema) + .min(1) + .max(4) + .describe('The questions to ask the user (1-4 questions).'), +}); + +const AskUserQuestionInputSchemaWithBackground = AskUserQuestionInputBaseSchema.extend({ + background: z + .boolean() + .default(false) + .describe( + 'Set true to ask in the background and return immediately with a background task_id. Use TaskOutput to read the answer later.', + ), +}); + +export const AskUserQuestionInputSchema: z.ZodType = + AskUserQuestionInputSchemaWithBackground; + +const QUESTION_DISMISSED_MESSAGE = 'User dismissed the question without answering.'; + +// ── Implementation ─────────────────────────────────────────────────── + +export class AskUserQuestionTool implements BuiltinTool { + readonly name = 'AskUserQuestion' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema( + AskUserQuestionInputSchemaWithBackground, + ); + + constructor( + private readonly question: IQuestionService, + private readonly background: IBackgroundService, + private readonly telemetry: ITelemetryService, + ) {} + + resolveExecution(args: AskUserQuestionInput): ToolExecution { + const isBackground = args.background === true; + return { + description: isBackground + ? `Starting background question: ${questionDescription(args.questions)}` + : 'Asking user questions', + approvalRule: this.name, + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: AskUserQuestionInput, + { toolCallId, signal, turnId }: ExecutableToolContext, + ): Promise { + if (args.background === true) { + return this.executeInBackground(args, { toolCallId, turnId, signal }); + } + return this.executeQuestion(args, { toolCallId, turnId }); + } + + private async executeQuestion( + args: AskUserQuestionInput, + { toolCallId, turnId }: Pick, + ): Promise { + const result = await this.question.request({ + turnId: numericTurnId(turnId), + toolCallId, + questions: args.questions.map((q) => ({ + question: q.question, + header: q.header.length > 0 ? q.header : undefined, + options: q.options.map((o) => ({ + label: o.label, + description: o.description.length > 0 ? o.description : undefined, + })), + multiSelect: q.multi_select, + })), + }); + + const normalized = normalizeQuestionResult(result); + if (normalized === null || Object.keys(normalized.answers).length === 0) { + this.telemetry.track('question_dismissed'); + return dismissedQuestionResult(); + } + + const properties: TelemetryProperties = + normalized.method !== undefined + ? { answered: Object.keys(normalized.answers).length, method: normalized.method } + : { answered: Object.keys(normalized.answers).length }; + this.telemetry.track('question_answered', properties); + return { + isError: false, + output: JSON.stringify({ answers: normalized.answers }), + }; + } + + private executeInBackground( + args: AskUserQuestionInput, + { + toolCallId, + signal, + turnId, + }: Pick, + ): ExecutableToolResult { + if (signal.aborted) { + signal.throwIfAborted(); + } + + const description = questionDescription(args.questions); + let taskId: string; + try { + taskId = this.background.registerTask( + new QuestionBackgroundTask( + () => this.executeQuestion(args, { toolCallId, turnId }), + description, + { + questionCount: args.questions.length, + toolCallId, + }, + ), + ); + } catch (error) { + return { + isError: true, + output: errorMessage(error), + }; + } + + const status = this.background.getTask(taskId)?.status ?? 'running'; + return { + isError: false, + output: + `task_id: ${taskId}\n` + + `description: ${description}\n` + + `status: ${status}\n` + + `automatic_notification: true\n` + + 'next_step: Continue your current work; the answer will arrive automatically when the user responds.\n' + + 'next_step: Use TaskOutput with this task_id for a non-blocking status/answer snapshot.\n' + + 'next_step: Use TaskStop only if the question should be cancelled.\n' + + 'human_shell_hint: The pending question is also visible in /tasks.', + message: `Started ${taskId}`, + }; + } +} + +function dismissedQuestionResult(): ExecutableToolResult { + return { + isError: false, + output: JSON.stringify({ + answers: {}, + note: QUESTION_DISMISSED_MESSAGE, + }), + }; +} + +function numericTurnId(turnId: string): number | undefined { + if (turnId.trim().length === 0) return undefined; + const parsed = Number(turnId); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function questionDescription(questions: AskUserQuestionInput['questions']): string { + const first = questions[0]?.question.trim(); + const label = first === undefined || first.length === 0 ? 'Ask user question' : first; + if (questions.length <= 1) return label; + return `${label} (+${String(questions.length - 1)} more)`; +} + +function normalizeQuestionResult( + result: QuestionResult, +): { readonly answers: QuestionAnswers; readonly method?: QuestionAnswerMethod | undefined } | null { + if (result === null) return null; + if (isQuestionResponse(result)) { + return { + answers: result.answers, + method: result.method, + }; + } + return { answers: result }; +} + +function isQuestionResponse(result: Exclude): result is QuestionResponse { + if (typeof result !== 'object' || result === null) return false; + if (!Object.hasOwn(result, 'answers')) return false; + const answers = (result as { readonly answers?: unknown }).answers; + return typeof answers === 'object' && answers !== null && !Array.isArray(answers); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agent-core-v2/src/rpc/rpcService.ts b/packages/agent-core-v2/src/rpc/rpcService.ts index 0a359edc6..ad57791a0 100644 --- a/packages/agent-core-v2/src/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/rpc/rpcService.ts @@ -12,6 +12,7 @@ import { IPermissionModeService } from '#/permissionMode/permissionMode'; import { IPlanService } from '../plan'; import { IProfileService } from '#/profile'; import { IPromptService } from '#/prompt'; +import { IQuestionToolsService } from '#/question'; import { IShellToolsService } from '#/shellTools'; import { IAgentSkillService } from '#/skill'; import { ISubagentHost } from '#/subagentHost'; @@ -21,6 +22,7 @@ import { IToolRegistry } from '#/toolRegistry'; import { ITurnService } from '../turn'; import { IUsageService } from '#/usage'; import { IUserToolService } from '#/userTool'; +import { IWebService } from '#/web'; import type { ActivateSkillPayload, BeginCompactionPayload, @@ -69,6 +71,8 @@ export class AgentRPCService implements IAgentRPCService { @IUsageService private readonly usage: IUsageService, @ITelemetryService private readonly telemetry: ITelemetryService, @IGoalService private readonly goal: IGoalService, + @IQuestionToolsService private readonly questionTools: IQuestionToolsService, + @IWebService private readonly web: IWebService, ) { } prompt(payload: PromptPayload): PromptLaunchResult | undefined { diff --git a/packages/agent-core-v2/src/shellTools/tools/result-builder.ts b/packages/agent-core-v2/src/shellTools/tools/result-builder.ts index e083465d6..9528479b7 100644 --- a/packages/agent-core-v2/src/shellTools/tools/result-builder.ts +++ b/packages/agent-core-v2/src/shellTools/tools/result-builder.ts @@ -1,140 +1,5 @@ -import type { ExecutableToolErrorResult, ExecutableToolSuccessResult } from '#/tool'; - -const DEFAULT_MAX_CHARS = 50_000; -const DEFAULT_MAX_LINE_LENGTH = 2000; -const TRUNCATION_MARKER = '[...truncated]'; -const TRUNCATION_MESSAGE = 'Output is truncated to fit in the message.'; - -export interface ToolResultBuilderOptions { - readonly maxChars?: number; - readonly maxLineLength?: number | null; -} - -export type ExecutableToolResultBuilderResult = ( - | ExecutableToolSuccessResult - | ExecutableToolErrorResult -) & { - readonly output: string; - readonly message: string; - readonly truncated: boolean; -}; - -export class ToolResultBuilder { - private readonly maxChars: number; - private readonly maxLineLength: number | null; - - private readonly buffer: string[] = []; - private nCharsValue = 0; - private truncationHappened = false; - - constructor(options: ToolResultBuilderOptions = {}) { - this.maxChars = options.maxChars ?? DEFAULT_MAX_CHARS; - this.maxLineLength = - options.maxLineLength === undefined ? DEFAULT_MAX_LINE_LENGTH : options.maxLineLength; - - if (this.maxLineLength !== null && this.maxLineLength <= TRUNCATION_MARKER.length) { - throw new Error('maxLineLength must be greater than the truncation marker length.'); - } - } - - get nChars(): number { - return this.nCharsValue; - } - - write(text: string): number { - if (this.nCharsValue >= this.maxChars) { - if (text.length > 0 && !this.truncationHappened) { - this.buffer.push(TRUNCATION_MARKER); - this.nCharsValue += TRUNCATION_MARKER.length; - this.truncationHappened = true; - } - return 0; - } - - const lines = text.match(/[^\r\n]*(?:\r\n|[\n\r])|[^\r\n]+/g) ?? []; - if (lines.length === 0) return 0; - - let charsWritten = 0; - for (const originalLine of lines) { - if (this.nCharsValue >= this.maxChars) { - if (!this.truncationHappened) { - this.buffer.push(TRUNCATION_MARKER); - this.nCharsValue += TRUNCATION_MARKER.length; - this.truncationHappened = true; - } - break; - } - - const remainingChars = this.maxChars - this.nCharsValue; - const limit = - this.maxLineLength === null - ? remainingChars - : Math.min(remainingChars, this.maxLineLength); - let line = originalLine; - if (line.length > limit) { - const lineBreak = /[\r\n]+$/.exec(line)?.[0] ?? ''; - const suffix = TRUNCATION_MARKER + lineBreak; - const effectiveMaxLength = Math.max(limit, suffix.length); - line = line.slice(0, effectiveMaxLength - suffix.length) + suffix; - } - if (line !== originalLine) { - this.truncationHappened = true; - } - - this.buffer.push(line); - charsWritten += line.length; - this.nCharsValue += line.length; - } - - return charsWritten; - } - - ok(message = ''): ExecutableToolResultBuilderResult { - let finalMessage = message; - if (finalMessage.length > 0 && !finalMessage.endsWith('.')) { - finalMessage += '.'; - } - if (this.truncationHappened) { - finalMessage = - finalMessage.length === 0 ? TRUNCATION_MESSAGE : `${finalMessage} ${TRUNCATION_MESSAGE}`; - } - - const output = this.buffer.join(''); - const shouldAppendMessage = - finalMessage.length > 0 && (this.truncationHappened || output.length === 0); - return { - isError: false, - output: shouldAppendMessage - ? output.length === 0 - ? finalMessage - : output.endsWith('\n') - ? `${output}${finalMessage}` - : `${output}\n${finalMessage}` - : output, - message: finalMessage, - truncated: this.truncationHappened, - }; - } - - error(message: string): ExecutableToolResultBuilderResult { - const finalMessage = this.truncationHappened - ? message.length === 0 - ? TRUNCATION_MESSAGE - : `${message} ${TRUNCATION_MESSAGE}` - : message; - const output = this.buffer.join(''); - return { - isError: true, - output: - finalMessage.length === 0 - ? output - : output.length === 0 - ? finalMessage - : output.endsWith('\n') - ? `${output}${finalMessage}` - : `${output}\n${finalMessage}`, - message: finalMessage, - truncated: this.truncationHappened, - }; - } -} +export { + ToolResultBuilder, + type ExecutableToolResultBuilderResult, + type ToolResultBuilderOptions, +} from '#/tool/result-builder'; diff --git a/packages/agent-core-v2/src/skill/skillService.ts b/packages/agent-core-v2/src/skill/skillService.ts index e1e1e3a71..c2aba611c 100644 --- a/packages/agent-core-v2/src/skill/skillService.ts +++ b/packages/agent-core-v2/src/skill/skillService.ts @@ -22,6 +22,7 @@ import { import { IEventSink } from '../eventSink'; import { IPromptService } from '#/prompt'; import { ITelemetryService } from '#/telemetry'; +import { IToolRegistry } from '#/toolRegistry'; import type { Turn } from '#/turn'; import { IWireRecord } from '#/wireRecord'; import { @@ -30,6 +31,7 @@ import { type SkillActivationInput, } from './skill'; import { ISkillCatalog } from './skillCatalog'; +import { SkillTool } from './tools/skill'; declare module '#/wireRecord' { interface WireRecordMap { @@ -48,6 +50,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService @IEventSink private readonly events: IEventSink, @IWireRecord private readonly wireRecord: IWireRecord, @ITelemetryService private readonly telemetry: ITelemetryService, + @IToolRegistry toolRegistry: IToolRegistry, ) { super(); this._register( @@ -55,6 +58,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService this.publishActivation(record.origin); }), ); + this._register(toolRegistry.register(new SkillTool(this))); } async activate(input: SkillActivationInput): Promise { diff --git a/packages/agent-core-v2/src/tool/result-builder.ts b/packages/agent-core-v2/src/tool/result-builder.ts new file mode 100644 index 000000000..c4a680765 --- /dev/null +++ b/packages/agent-core-v2/src/tool/result-builder.ts @@ -0,0 +1,150 @@ +/** + * `tool` domain (L3) — buffered tool-result builder. + * + * Shared helper for tools that stream text into a bounded output buffer with + * optional per-line and total-char truncation. Lives in the foundational tool + * domain so every tool implementation (file, shell, web, …) can build + * consistently-truncated `ExecutableToolResult`s without depending on a + * sibling tool domain. Pure helper; no scoped service. + */ + +import type { ExecutableToolErrorResult, ExecutableToolSuccessResult } from './toolContract'; + +const DEFAULT_MAX_CHARS = 50_000; +const DEFAULT_MAX_LINE_LENGTH = 2000; +const TRUNCATION_MARKER = '[...truncated]'; +const TRUNCATION_MESSAGE = 'Output is truncated to fit in the message.'; + +export interface ToolResultBuilderOptions { + readonly maxChars?: number; + readonly maxLineLength?: number | null; +} + +export type ExecutableToolResultBuilderResult = ( + | ExecutableToolSuccessResult + | ExecutableToolErrorResult +) & { + readonly output: string; + readonly message: string; + readonly truncated: boolean; +}; + +export class ToolResultBuilder { + private readonly maxChars: number; + private readonly maxLineLength: number | null; + + private readonly buffer: string[] = []; + private nCharsValue = 0; + private truncationHappened = false; + + constructor(options: ToolResultBuilderOptions = {}) { + this.maxChars = options.maxChars ?? DEFAULT_MAX_CHARS; + this.maxLineLength = + options.maxLineLength === undefined ? DEFAULT_MAX_LINE_LENGTH : options.maxLineLength; + + if (this.maxLineLength !== null && this.maxLineLength <= TRUNCATION_MARKER.length) { + throw new Error('maxLineLength must be greater than the truncation marker length.'); + } + } + + get nChars(): number { + return this.nCharsValue; + } + + write(text: string): number { + if (this.nCharsValue >= this.maxChars) { + if (text.length > 0 && !this.truncationHappened) { + this.buffer.push(TRUNCATION_MARKER); + this.nCharsValue += TRUNCATION_MARKER.length; + this.truncationHappened = true; + } + return 0; + } + + const lines = text.match(/[^\r\n]*(?:\r\n|[\n\r])|[^\r\n]+/g) ?? []; + if (lines.length === 0) return 0; + + let charsWritten = 0; + for (const originalLine of lines) { + if (this.nCharsValue >= this.maxChars) { + if (!this.truncationHappened) { + this.buffer.push(TRUNCATION_MARKER); + this.nCharsValue += TRUNCATION_MARKER.length; + this.truncationHappened = true; + } + break; + } + + const remainingChars = this.maxChars - this.nCharsValue; + const limit = + this.maxLineLength === null + ? remainingChars + : Math.min(remainingChars, this.maxLineLength); + let line = originalLine; + if (line.length > limit) { + const lineBreak = /[\r\n]+$/.exec(line)?.[0] ?? ''; + const suffix = TRUNCATION_MARKER + lineBreak; + const effectiveMaxLength = Math.max(limit, suffix.length); + line = line.slice(0, effectiveMaxLength - suffix.length) + suffix; + } + if (line !== originalLine) { + this.truncationHappened = true; + } + + this.buffer.push(line); + charsWritten += line.length; + this.nCharsValue += line.length; + } + + return charsWritten; + } + + ok(message = ''): ExecutableToolResultBuilderResult { + let finalMessage = message; + if (finalMessage.length > 0 && !finalMessage.endsWith('.')) { + finalMessage += '.'; + } + if (this.truncationHappened) { + finalMessage = + finalMessage.length === 0 ? TRUNCATION_MESSAGE : `${finalMessage} ${TRUNCATION_MESSAGE}`; + } + + const output = this.buffer.join(''); + const shouldAppendMessage = + finalMessage.length > 0 && (this.truncationHappened || output.length === 0); + return { + isError: false, + output: shouldAppendMessage + ? output.length === 0 + ? finalMessage + : output.endsWith('\n') + ? `${output}${finalMessage}` + : `${output}\n${finalMessage}` + : output, + message: finalMessage, + truncated: this.truncationHappened, + }; + } + + error(message: string): ExecutableToolResultBuilderResult { + const finalMessage = this.truncationHappened + ? message.length === 0 + ? TRUNCATION_MESSAGE + : `${message} ${TRUNCATION_MESSAGE}` + : message; + const output = this.buffer.join(''); + return { + isError: true, + output: + finalMessage.length === 0 + ? output + : output.length === 0 + ? finalMessage + : output.endsWith('\n') + ? `${output}${finalMessage}` + : `${output}\n${finalMessage}`, + message: finalMessage, + truncated: this.truncationHappened, + }; + } +} diff --git a/packages/agent-core-v2/src/web/index.ts b/packages/agent-core-v2/src/web/index.ts new file mode 100644 index 000000000..593a7f6f0 --- /dev/null +++ b/packages/agent-core-v2/src/web/index.ts @@ -0,0 +1,8 @@ +/** + * `web` domain barrel — re-exports the web contract (`web`) and its scoped + * service (`webService`). Importing this barrel registers the `IWebService` + * binding into the scope registry. + */ + +export * from './web'; +export * from './webService'; diff --git a/packages/agent-core-v2/src/web/providers/local-fetch-url.ts b/packages/agent-core-v2/src/web/providers/local-fetch-url.ts new file mode 100644 index 000000000..09a7fcc75 --- /dev/null +++ b/packages/agent-core-v2/src/web/providers/local-fetch-url.ts @@ -0,0 +1,191 @@ +import { Readability } from '@mozilla/readability'; +import { parseHTML as rawParseHTML } from 'linkedom'; + +import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../tools/fetch-url'; + +// Readability's .d.ts references the global `Document` type, but this +// package compiles with `lib: ES2023` (no DOM). Extracting the +// constructor parameter type keeps us off the global `Document` name +// while still accepting whatever Readability wants. +type ReadabilityDocument = ConstructorParameters[0]; + +// linkedom's published types depend on DOM libs we don't load. Declare +// the minimal surface we actually use so the rest of the file stays +// type-safe without pulling lib.dom.d.ts into the host build. +interface DomElementLike { + textContent: string | null; + querySelector(selector: string): DomElementLike | null; +} +interface DomParseResult { + document: DomElementLike; +} +const parseHTML = rawParseHTML as unknown as (html: string) => DomParseResult; + +const DEFAULT_USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + +const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; + +export interface LocalFetchURLProviderOptions { + userAgent?: string; + fetchImpl?: typeof fetch; + maxBytes?: number; + /** + * Allow fetching loopback / RFC 1918 / link-local / ULA addresses. + * Defaults to `false` — enabled only for tests and explicit opt-in. + * + * Note: the guard below is a static string check against the URL host; it + * does not resolve DNS, so a hostname that resolves to a private address + * (DNS rebinding) is not blocked. Do not rely on this as a security boundary + * against a determined attacker. + */ + allowPrivateAddresses?: boolean; +} + +export class LocalFetchURLProvider implements UrlFetcher { + private readonly userAgent: string; + private readonly fetchImpl: typeof fetch; + private readonly maxBytes: number; + private readonly allowPrivateAddresses: boolean; + + constructor(options: LocalFetchURLProviderOptions = {}) { + this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + this.allowPrivateAddresses = options.allowPrivateAddresses ?? false; + } + + async fetch(url: string, _options?: { toolCallId?: string }): Promise { + assertSafeFetchTarget(url, this.allowPrivateAddresses); + + const response = await this.fetchImpl(url, { + method: 'GET', + headers: { 'User-Agent': this.userAgent }, + }); + + if (response.status >= 400) { + await response.body?.cancel().catch(() => { + /* already closed */ + }); + throw new HttpFetchError( + response.status, + `HTTP ${String(response.status)} ${response.statusText}`, + ); + } + + const contentLengthRaw = response.headers.get('content-length'); + if (contentLengthRaw !== null) { + const cl = Number(contentLengthRaw); + if (Number.isFinite(cl) && cl > this.maxBytes) { + throw new Error( + `Response body too large: ${String(cl)} bytes exceeds maxBytes (${String(this.maxBytes)}).`, + ); + } + } + + const body = await response.text(); + + const actualBytes = Buffer.byteLength(body, 'utf8'); + if (actualBytes > this.maxBytes) { + throw new Error( + `Response body too large: ${String(actualBytes)} bytes exceeds maxBytes (${String(this.maxBytes)}).`, + ); + } + + const contentType = (response.headers.get('content-type') ?? '').toLowerCase(); + if (contentType.startsWith('text/plain') || contentType.startsWith('text/markdown')) { + return { content: body, kind: 'passthrough' }; + } + + return { content: this.extractMainContent(body), kind: 'extracted' }; + } + + private extractMainContent(html: string): string { + const primary = parseHTML(html); + try { + const reader = new Readability(primary.document as unknown as ReadabilityDocument, { + charThreshold: 0, + }); + const article = reader.parse(); + if (article !== null) { + const text = (article.textContent ?? '').trim(); + if (text.length > 0) { + const title = (article.title ?? '').trim(); + return title.length > 0 ? `# ${title}\n\n${text}` : text; + } + } + } catch { + // Fall through to the container-based fallback. + } + + const { document } = parseHTML(html); + const titleText = (document.querySelector('title')?.textContent ?? '').trim(); + const container = + document.querySelector('article') ?? + document.querySelector('main') ?? + document.querySelector('body'); + const fallbackText = (container?.textContent ?? '').trim(); + + if (fallbackText.length === 0) { + throw new Error( + 'Failed to extract meaningful content from the page. The page may require JavaScript to render.', + ); + } + + return titleText.length > 0 ? `# ${titleText}\n\n${fallbackText}` : fallbackText; + } +} + +function assertSafeFetchTarget(url: string, allowPrivate: boolean): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid URL: "${url}"`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Unsupported URL scheme "${parsed.protocol}" — only http(s) allowed.`); + } + if (allowPrivate) return; + const hostRaw = parsed.hostname.toLowerCase(); + const host = hostRaw.startsWith('[') && hostRaw.endsWith(']') ? hostRaw.slice(1, -1) : hostRaw; + if (host === 'localhost' || host.endsWith('.localhost')) { + throw new Error(`Refusing to fetch private host: "${host}"`); + } + if ( + host === '::1' || + host === '::' || + host.startsWith('fe80:') || + host.startsWith('fc') || + host.startsWith('fd') + ) { + throw new Error(`Refusing to fetch private host: "${host}"`); + } + const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); + if (v4 !== null) { + const octets = [v4[1], v4[2], v4[3], v4[4]].map(Number); + if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { + throw new Error(`Invalid IPv4 literal: "${host}"`); + } + const [a, b] = octets as [number, number, number, number]; + const isLoopback = a === 127; + const isPrivate10 = a === 10; + const isPrivate192 = a === 192 && b === 168; + const isPrivate172 = a === 172 && b >= 16 && b <= 31; + const isLinkLocal = a === 169 && b === 254; + const isZero = a === 0; + const isCgnat = a === 100 && b >= 64 && b <= 127; + if ( + isLoopback || + isPrivate10 || + isPrivate192 || + isPrivate172 || + isLinkLocal || + isZero || + isCgnat + ) { + throw new Error(`Refusing to fetch private address: "${host}"`); + } + } +} diff --git a/packages/agent-core-v2/src/web/providers/moonshot-fetch-url.ts b/packages/agent-core-v2/src/web/providers/moonshot-fetch-url.ts new file mode 100644 index 000000000..e7810166e --- /dev/null +++ b/packages/agent-core-v2/src/web/providers/moonshot-fetch-url.ts @@ -0,0 +1,99 @@ +import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../tools/fetch-url'; + +export interface BearerTokenProvider { + getAccessToken(options?: { readonly force?: boolean | undefined }): Promise; +} + +export interface MoonshotFetchURLProviderOptions { + tokenProvider?: BearerTokenProvider; + apiKey?: string; + baseUrl: string; + defaultHeaders?: Record; + customHeaders?: Record; + localFallback: UrlFetcher; + fetchImpl?: typeof fetch; +} + +export class MoonshotFetchURLProvider implements UrlFetcher { + private readonly tokenProvider: BearerTokenProvider | undefined; + private readonly apiKey: string | undefined; + private readonly baseUrl: string; + private readonly defaultHeaders: Record; + private readonly customHeaders: Record; + private readonly localFallback: UrlFetcher; + private readonly fetchImpl: typeof fetch; + + constructor(options: MoonshotFetchURLProviderOptions) { + this.tokenProvider = options.tokenProvider; + this.apiKey = options.apiKey; + this.baseUrl = options.baseUrl; + this.defaultHeaders = options.defaultHeaders ?? {}; + this.customHeaders = options.customHeaders ?? {}; + this.localFallback = options.localFallback; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + } + + async fetch(url: string, options?: { toolCallId?: string }): Promise { + try { + const content = await this.fetchViaMoonshot(url, options?.toolCallId); + // The service returns text it has already extracted from the page. + return { content, kind: 'extracted' }; + } catch { + // Forward an explicit options object even when the caller passed + // none, so downstream consumers always see a defined second arg. + return this.localFallback.fetch(url, options ?? {}); + } + } + + private async fetchViaMoonshot(url: string, toolCallId: string | undefined): Promise { + const bodyJson = JSON.stringify({ url }); + const response = await this.post(bodyJson, toolCallId); + + if (response.status !== 200) { + let detail = ''; + try { + detail = await response.text(); + } catch { + /* ignore */ + } + throw new HttpFetchError( + response.status, + `Moonshot fetch request failed: HTTP ${String(response.status)}. ${detail}`.trim(), + ); + } + return response.text(); + } + + private async post(bodyJson: string, toolCallId: string | undefined): Promise { + const accessToken = await this.resolveApiKey(); + return this.fetchImpl(this.baseUrl, { + method: 'POST', + headers: { + ...this.defaultHeaders, + Authorization: `Bearer ${accessToken}`, + Accept: 'text/markdown', + 'Content-Type': 'application/json', + ...(toolCallId !== undefined && toolCallId.length > 0 + ? { 'X-Msh-Tool-Call-Id': toolCallId } + : {}), + ...this.customHeaders, + }, + body: bodyJson, + }); + } + + private async resolveApiKey(): Promise { + if (this.tokenProvider !== undefined) { + try { + const token = await this.tokenProvider.getAccessToken(); + if (token.trim().length > 0) return token; + if (this.apiKey !== undefined && this.apiKey.length > 0) return this.apiKey; + } catch (error) { + if (this.apiKey !== undefined && this.apiKey.length > 0) return this.apiKey; + throw error; + } + } + if (this.apiKey !== undefined && this.apiKey.length > 0) return this.apiKey; + throw new Error('Moonshot fetch service is not configured: missing API key or token provider.'); + } +} diff --git a/packages/agent-core-v2/src/web/providers/moonshot-web-search.ts b/packages/agent-core-v2/src/web/providers/moonshot-web-search.ts new file mode 100644 index 000000000..6ede5b6c2 --- /dev/null +++ b/packages/agent-core-v2/src/web/providers/moonshot-web-search.ts @@ -0,0 +1,131 @@ +import type { WebSearchProvider, WebSearchResult } from '../tools/web-search'; + +export interface BearerTokenProvider { + getAccessToken(options?: { readonly force?: boolean | undefined }): Promise; +} + +export interface MoonshotWebSearchProviderOptions { + tokenProvider?: BearerTokenProvider; + apiKey?: string; + baseUrl: string; + defaultHeaders?: Record; + customHeaders?: Record; + fetchImpl?: typeof fetch; +} + +interface MoonshotSearchResult { + site_name?: string; + title?: string; + url?: string; + snippet?: string; + content?: string; + date?: string; + icon?: string; + mime?: string; +} + +interface MoonshotSearchResponse { + search_results?: MoonshotSearchResult[]; +} + +export class MoonshotWebSearchProvider implements WebSearchProvider { + private readonly tokenProvider: BearerTokenProvider | undefined; + private readonly apiKey: string | undefined; + private readonly baseUrl: string; + private readonly defaultHeaders: Record; + private readonly customHeaders: Record; + private readonly fetchImpl: typeof fetch; + + constructor(options: MoonshotWebSearchProviderOptions) { + this.tokenProvider = options.tokenProvider; + this.apiKey = options.apiKey; + this.baseUrl = options.baseUrl; + this.defaultHeaders = options.defaultHeaders ?? {}; + this.customHeaders = options.customHeaders ?? {}; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + } + + async search( + query: string, + options?: { limit?: number; includeContent?: boolean; toolCallId?: string }, + ): Promise { + const body = { + text_query: query, + limit: options?.limit ?? 5, + enable_page_crawling: options?.includeContent ?? false, + timeout_seconds: 30, + }; + const bodyJson = JSON.stringify(body); + + const toolCallId = options?.toolCallId; + const response = await this.post(bodyJson, toolCallId); + + if (response.status === 401) { + const detail = await safeReadText(response); + throw new Error( + `Moonshot search request failed: HTTP 401 (auth/unauthorized). ${detail}`.trim(), + ); + } + + if (response.status !== 200) { + const detail = await safeReadText(response); + throw new Error( + `Moonshot search request failed: HTTP ${String(response.status)}. ${detail}`.trim(), + ); + } + + const json = (await response.json()) as MoonshotSearchResponse; + const raw = Array.isArray(json.search_results) ? json.search_results : []; + + return raw.map((r): WebSearchResult => { + const out: WebSearchResult = { + title: r.title ?? '', + url: r.url ?? '', + snippet: r.snippet ?? '', + }; + if (typeof r.date === 'string' && r.date.length > 0) out.date = r.date; + if (typeof r.content === 'string' && r.content.length > 0) out.content = r.content; + return out; + }); + } + + private async post(bodyJson: string, toolCallId: string | undefined): Promise { + const accessToken = await this.resolveApiKey(); + return this.fetchImpl(this.baseUrl, { + method: 'POST', + headers: { + ...this.defaultHeaders, + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...(toolCallId !== undefined && toolCallId.length > 0 + ? { 'X-Msh-Tool-Call-Id': toolCallId } + : {}), + ...this.customHeaders, + }, + body: bodyJson, + }); + } + + private async resolveApiKey(): Promise { + if (this.tokenProvider !== undefined) { + try { + return await this.tokenProvider.getAccessToken(); + } catch (error) { + if (this.apiKey !== undefined && this.apiKey.length > 0) return this.apiKey; + throw error; + } + } + if (this.apiKey !== undefined && this.apiKey.length > 0) return this.apiKey; + throw new Error( + 'Moonshot search service is not configured: missing API key or token provider.', + ); + } +} + +async function safeReadText(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} diff --git a/packages/agent-core-v2/src/web/tools/fetch-url.md b/packages/agent-core-v2/src/web/tools/fetch-url.md new file mode 100644 index 000000000..f2356e690 --- /dev/null +++ b/packages/agent-core-v2/src/web/tools/fetch-url.md @@ -0,0 +1,3 @@ +Fetch content from a URL. Returns the main text content extracted from the page. Use this when you need to read a specific web page. + +Only public `http`/`https` URLs are supported. Requests to private, loopback, or link-local addresses are refused, and responses larger than 10 MiB are rejected. diff --git a/packages/agent-core-v2/src/web/tools/fetch-url.ts b/packages/agent-core-v2/src/web/tools/fetch-url.ts new file mode 100644 index 000000000..288cf6679 --- /dev/null +++ b/packages/agent-core-v2/src/web/tools/fetch-url.ts @@ -0,0 +1,129 @@ +/** + * FetchURLTool — host-injected URL fetcher. + * + * agent-core-v2 defines the interface; the host provides the real fetch + * implementation via `UrlFetcher`. If no fetcher is supplied, the tool + * falls back to the built-in `LocalFetchURLProvider`. + */ + +import { z } from 'zod'; + +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import { literalRulePattern, matchesGlobRuleSubject } from '#/_base/tools/support/rule-match'; +import type { + BuiltinTool, + ExecutableToolContext, + ExecutableToolResult, + ToolExecution, +} from '#/tool'; +import { ToolAccesses } from '#/tool'; +import { ToolResultBuilder } from '#/tool/result-builder'; + +import DESCRIPTION from './fetch-url.md?raw'; + +// ── Provider interface (host-injected) ─────────────────────────────── + +/** + * How the returned content relates to the original response body. + * + * - `passthrough` — the body was already plain text / markdown and is + * returned verbatim, in full. + * - `extracted` — the body was an HTML page; only the main article text + * was extracted and returned. + */ +export type UrlFetchKind = 'passthrough' | 'extracted'; + +export interface UrlFetchResult { + /** The text handed to the LLM. */ + readonly content: string; + /** Whether `content` is a verbatim passthrough or extracted main text. */ + readonly kind: UrlFetchKind; +} + +export interface UrlFetcher { + fetch(url: string, options?: { toolCallId?: string }): Promise; +} + +/** + * Thrown by a `UrlFetcher` when the upstream HTTP request completed but + * returned a non-success status. The tool branches on this to surface + * `Status: N` in the error message; non-HTTP failures (DNS, timeout, + * connection reset, …) keep flowing through as plain `Error`. + */ +export class HttpFetchError extends Error { + override readonly name = 'HttpFetchError'; + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +// ── Input schema ───────────────────────────────────────────────────── + +export const FetchURLInputSchema = z.object({ + url: z.string().describe('The URL to fetch content from.'), +}); + +export type FetchURLInput = z.infer; + +// ── Implementation ─────────────────────────────────────────────────── + +export class FetchURLTool implements BuiltinTool { + readonly name = 'FetchURL' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(FetchURLInputSchema); + + constructor(private readonly fetcher: UrlFetcher) {} + + resolveExecution(args: FetchURLInput): ToolExecution { + const preview = args.url.length > 50 ? `${args.url.slice(0, 50)}…` : args.url; + return { + accesses: ToolAccesses.none(), + description: `Fetching: ${preview}`, + display: { kind: 'url_fetch', url: args.url }, + approvalRule: literalRulePattern(this.name, args.url), + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.url), + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: FetchURLInput, + { toolCallId }: ExecutableToolContext, + ): Promise { + try { + const { content, kind } = await this.fetcher.fetch(args.url, { toolCallId }); + + if (!content) { + return { + output: 'The response body is empty.', + isError: false, + }; + } + + const builder = new ToolResultBuilder({ maxLineLength: null }); + builder.write(content); + // Tell the LLM whether it received the whole body or only the + // extracted article text, so it can judge how complete the + // content is. + const message = + kind === 'passthrough' + ? 'The returned content is the full response body, returned verbatim.' + : 'The returned content is the main text extracted from the page.'; + return builder.ok(message); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (error instanceof HttpFetchError) { + return { + isError: true, + output: `Failed to fetch URL. Status: ${String(error.status)}. ${msg}`, + }; + } + return { + isError: true, + output: `Failed to fetch URL due to network error: ${args.url}. ${msg}`, + }; + } + } +} diff --git a/packages/agent-core-v2/src/web/tools/web-search.md b/packages/agent-core-v2/src/web/tools/web-search.md new file mode 100644 index 000000000..fbab5c828 --- /dev/null +++ b/packages/agent-core-v2/src/web/tools/web-search.md @@ -0,0 +1,3 @@ +Search the web for information. Use this when you need up-to-date information from the internet. + +Each result includes its title, URL, snippet, and—when available—a publication date. When `include_content` is enabled, the full page content—when available—is appended after the snippet. diff --git a/packages/agent-core-v2/src/web/tools/web-search.ts b/packages/agent-core-v2/src/web/tools/web-search.ts new file mode 100644 index 000000000..0dddf9d78 --- /dev/null +++ b/packages/agent-core-v2/src/web/tools/web-search.ts @@ -0,0 +1,159 @@ +/** + * WebSearchTool — host-injected web search. + * + * agent-core-v2 defines the interface; the host provides the real search + * implementation via `WebSearchProvider`. If no provider is supplied, + * the tool is not registered (there is no local search backend). + */ + +import { z } from 'zod'; + +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import { literalRulePattern, matchesGlobRuleSubject } from '#/_base/tools/support/rule-match'; +import type { + BuiltinTool, + ExecutableToolContext, + ExecutableToolResult, + ToolExecution, +} from '#/tool'; +import { ToolAccesses } from '#/tool'; +import { ToolResultBuilder } from '#/tool/result-builder'; + +import DESCRIPTION from './web-search.md?raw'; + +// ── Provider interface (host-injected) ─────────────────────────────── + +export interface WebSearchResult { + title: string; + url: string; + snippet: string; + date?: string; + content?: string; +} + +export interface WebSearchProvider { + search( + query: string, + options?: { limit?: number; includeContent?: boolean; toolCallId?: string }, + ): Promise; +} + +// ── Input schema ───────────────────────────────────────────────────── + +export const WebSearchInputSchema = z.object({ + query: z.string().describe('The query text to search for.'), + limit: z + .number() + .int() + .min(1) + .max(20) + .default(5) + .describe( + 'The number of results to return. Typically you do not need to set this value. When the results do not contain what you need, you probably want to give a more concrete query.', + ) + .optional(), + include_content: z + .boolean() + .default(false) + .describe( + 'Whether to include the content of the web pages in the results. It can consume a large amount of tokens when this is set to true. You should avoid enabling this when `limit` is set to a large value.', + ) + .optional(), +}); + +export type WebSearchInput = z.infer; + +// ── Implementation ─────────────────────────────────────────────────── + +export class WebSearchTool implements BuiltinTool { + readonly name = 'WebSearch' as const; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(WebSearchInputSchema); + + constructor(private readonly provider: WebSearchProvider) {} + + resolveExecution(args: WebSearchInput): ToolExecution { + const preview = args.query.length > 40 ? `${args.query.slice(0, 40)}…` : args.query; + return { + accesses: ToolAccesses.none(), + description: `Searching: ${preview}`, + display: { kind: 'search', query: args.query }, + approvalRule: literalRulePattern(this.name, args.query), + matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.query), + execute: (ctx) => this.execution(args, ctx), + }; + } + + private async execution( + args: WebSearchInput, + { toolCallId }: ExecutableToolContext, + ): Promise { + try { + const opts: { limit?: number; includeContent?: boolean; toolCallId?: string } = { + toolCallId, + }; + if (args.limit !== undefined) opts.limit = args.limit; + if (args.include_content !== undefined) opts.includeContent = args.include_content; + const results = await this.provider.search(args.query, opts); + const builder = new ToolResultBuilder({ maxLineLength: null }); + + if (results.length === 0) { + builder.write('No search results found.'); + return builder.ok(); + } + + let first = true; + for (const result of results) { + if (!first) builder.write('---\n\n'); + first = false; + + builder.write(`Title: ${result.title}\n`); + if (result.date) builder.write(`Date: ${result.date}\n`); + builder.write(`URL: ${result.url}\n`); + builder.write(`Snippet: ${result.snippet}\n\n`); + if (result.content) builder.write(`${result.content}\n\n`); + } + + return builder.ok(); + } catch (error) { + return { + isError: true, + output: classifySearchError(error), + }; + } + } +} + +// ── Error classification ───────────────────────────────────────────── + +/** + * Maps a thrown search error to a categorised, human-readable message. + * + * The original error text is always preserved so the model can still see the + * underlying detail; the prefix only adds a category so failures are easier to + * reason about (e.g. retry vs. surface to the user). + */ +function classifySearchError(error: unknown): string { + const name = error instanceof Error ? error.name : ''; + const message = error instanceof Error ? error.message : String(error); + const lower = message.toLowerCase(); + + if (name === 'AbortError' || lower.includes('abort')) { + return `Search cancelled: ${message}`; + } + if (name === 'TimeoutError' || lower.includes('timed out') || lower.includes('timeout')) { + return `Search timed out: ${message}`; + } + if (lower.includes('401') || lower.includes('unauthorized') || lower.includes('auth')) { + return `Search failed (authentication): ${message}`; + } + if ( + lower.includes('http ') || + lower.includes('network') || + lower.includes('fetch') || + name === 'TypeError' + ) { + return `Search failed (network): ${message}`; + } + return `Search failed: ${message}`; +} diff --git a/packages/agent-core-v2/src/web/web.ts b/packages/agent-core-v2/src/web/web.ts new file mode 100644 index 000000000..492fd5f8f --- /dev/null +++ b/packages/agent-core-v2/src/web/web.ts @@ -0,0 +1,30 @@ +/** + * `web` domain (L4) — web tool registration contract and provider options. + * + * `IWebService` is a marker: its implementation registers the built-in + * `FetchURL` tool (always, falling back to `LocalFetchURLProvider`) and the + * `WebSearch` tool (only when a `WebSearchProvider` is supplied) into the + * agent `IToolRegistry` on construction. Bound at Agent scope. + * + * The actual fetch/search backends are host-injected through + * `WebServiceOptions` so this domain stays independent of config and OAuth. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { UrlFetcher } from './tools/fetch-url'; +import type { WebSearchProvider } from './tools/web-search'; + +export interface WebServiceOptions { + /** URL fetch backend. Defaults to the built-in `LocalFetchURLProvider`. */ + readonly urlFetcher?: UrlFetcher; + /** Web search backend. When omitted, `WebSearch` is not registered. */ + readonly webSearcher?: WebSearchProvider; +} + +export interface IWebService { + readonly _serviceBrand: undefined; +} + +export const IWebService: ServiceIdentifier = + createDecorator('webService'); diff --git a/packages/agent-core-v2/src/web/webService.ts b/packages/agent-core-v2/src/web/webService.ts new file mode 100644 index 000000000..d0008abb2 --- /dev/null +++ b/packages/agent-core-v2/src/web/webService.ts @@ -0,0 +1,41 @@ +/** + * `web` domain (L4) — `IWebService` implementation. + * + * Registers the built-in web tools into the agent `IToolRegistry` 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. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IToolRegistry } from '#/toolRegistry'; + +import { LocalFetchURLProvider } from './providers/local-fetch-url'; +import { FetchURLTool } from './tools/fetch-url'; +import { WebSearchTool } from './tools/web-search'; +import { IWebService, type WebServiceOptions } from './web'; + +export class WebService implements IWebService { + declare readonly _serviceBrand: undefined; + + constructor( + private readonly options: WebServiceOptions = {}, + @IToolRegistry toolRegistry: IToolRegistry, + ) { + const fetcher = options.urlFetcher ?? new LocalFetchURLProvider(); + toolRegistry.register(new FetchURLTool(fetcher)); + if (options.webSearcher !== undefined) { + toolRegistry.register(new WebSearchTool(options.webSearcher)); + } + } +} + +registerScopedService( + LifecycleScope.Agent, + IWebService, + WebService, + InstantiationType.Delayed, + 'web', +); diff --git a/packages/agent-core-v2/test/background/background.test.ts b/packages/agent-core-v2/test/background/background.test.ts index 1bbfb0efd..cc5920958 100644 --- a/packages/agent-core-v2/test/background/background.test.ts +++ b/packages/agent-core-v2/test/background/background.test.ts @@ -13,6 +13,7 @@ import { IPromptService } from '#/prompt'; import { ISessionContext } from '#/session-context'; import { IAtomicDocumentStore, IStorageService } from '#/storage'; import { ITelemetryService } from '#/telemetry'; +import { IToolRegistry } from '#/toolRegistry'; import { IWireRecord } from '#/wireRecord'; import { stubContextMemory, stubWireRecord } from '../contextMemory/stubs'; @@ -38,6 +39,9 @@ describe('BackgroundService', () => { ix.stub(IContextMemory, stubContextMemory()); ix.stub(IEventSink, { emit: () => {}, on: () => toDisposable(() => {}) }); ix.stub(ITelemetryService, { track: () => {} }); + ix.stub(IToolRegistry, { + register: () => toDisposable(() => {}), + }); ix.stub(IPromptService, { steer: () => undefined }); ix.stub(IExternalHooksService, { triggerNotification: () => {} }); ix.stub(IConfigRegistry, { registerSection: () => {} }); diff --git a/packages/agent-core-v2/test/skill/skill.test.ts b/packages/agent-core-v2/test/skill/skill.test.ts index 30b7a04e6..219064a66 100644 --- a/packages/agent-core-v2/test/skill/skill.test.ts +++ b/packages/agent-core-v2/test/skill/skill.test.ts @@ -9,6 +9,7 @@ import { IPromptService } from '#/prompt'; import { IAgentSkillService, InMemorySkillCatalog, ISkillCatalog } from '#/skill'; import { AgentSkillService } from '#/skill/skillService'; import { ITelemetryService } from '#/telemetry'; +import { IToolRegistry } from '#/toolRegistry'; import type { Turn } from '#/turn'; import { IWireRecord } from '#/wireRecord'; import { stubWireRecord } from '../contextMemory/stubs'; @@ -58,6 +59,9 @@ describe('AgentSkillService', () => { }); reg.defineInstance(IWireRecord, stubWireRecord()); reg.definePartialInstance(ITelemetryService, { track: () => {} }); + reg.definePartialInstance(IToolRegistry, { + register: () => ({ dispose: () => {} }), + }); }, }); const skills = new InMemorySkillCatalog(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c33e4668..dac089e94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -453,6 +453,9 @@ importers: '@moonshot-ai/protocol': specifier: workspace:^ version: link:../protocol + '@mozilla/readability': + specifier: ^0.6.0 + version: 0.6.0 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -462,6 +465,9 @@ importers: js-yaml: specifier: ^4.1.1 version: 4.1.1 + linkedom: + specifier: ^0.18.12 + version: 0.18.12 nunjucks: specifier: ^3.2.4 version: 3.2.4(chokidar@4.0.3)