diff --git a/packages/eve/src/cli/dev/tui/tool-presentation.test.ts b/packages/eve/src/cli/dev/tui/tool-presentation.test.ts index e4b25af13..34f9d7f29 100644 --- a/packages/eve/src/cli/dev/tui/tool-presentation.test.ts +++ b/packages/eve/src/cli/dev/tui/tool-presentation.test.ts @@ -178,6 +178,10 @@ describe("presentTool", () => { grep: { pattern: "useEve" }, load_skill: { skill: "commit" }, read_file: { filePath: "/workspace/a.ts" }, + task_await: { taskIds: ["task_abc"] }, + task_cancel: { taskIds: ["task_abc"] }, + task_peek: { taskIds: ["task_abc"] }, + task_sleep: { seconds: 30 }, todo: { todos: [] }, web_fetch: { url: "https://example.com" }, web_search: { query: "eve framework" }, diff --git a/packages/eve/src/cli/dev/tui/tool-presentation.ts b/packages/eve/src/cli/dev/tui/tool-presentation.ts index 0bd23fbb8..54e262bb1 100644 --- a/packages/eve/src/cli/dev/tui/tool-presentation.ts +++ b/packages/eve/src/cli/dev/tui/tool-presentation.ts @@ -123,6 +123,38 @@ const BUILTIN_TOOL_COPY: Readonly> = { singularNoun: "file", pluralNoun: "files", }, + task_await: { + verb: "Await", + pastVerb: "Awaited", + argKey: "taskIds", + extractItem: taskIdsArg, + singularNoun: "task", + pluralNoun: "tasks", + }, + task_cancel: { + verb: "Cancel", + pastVerb: "Cancelled", + argKey: "taskIds", + extractItem: taskIdsArg, + singularNoun: "task", + pluralNoun: "tasks", + }, + task_peek: { + verb: "Check", + pastVerb: "Checked", + argKey: "taskIds", + extractItem: taskIdsArg, + singularNoun: "task", + pluralNoun: "tasks", + }, + task_sleep: { + verb: "Pause", + pastVerb: "Paused", + argKey: "seconds", + extractItem: sleepSecondsArg, + singularNoun: "pause", + pluralNoun: "pauses", + }, web_fetch: { verb: "Fetch", pastVerb: "Fetched", @@ -359,6 +391,24 @@ function webSearchActionArg(input: unknown): string | undefined { * renders verbatim in aggregated rows, so a model-controlled value must lose * its terminal controls here, not at the render call sites. */ +/** Joins a `taskIds: string[]` argument into one salient line. */ +function taskIdsArg(input: unknown): string | undefined { + if (input === null || typeof input !== "object" || Array.isArray(input)) return undefined; + const value = (input as Record).taskIds; + if (!Array.isArray(value)) return undefined; + const ids = value.filter((id): id is string => typeof id === "string"); + if (ids.length === 0) return undefined; + return salientLine(ids.join(", ")); +} + +/** Formats a `seconds: number` argument as a duration. */ +function sleepSecondsArg(input: unknown): string | undefined { + if (input === null || typeof input !== "object" || Array.isArray(input)) return undefined; + const value = (input as Record).seconds; + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return `${String(value)}s`; +} + function salientArg(input: unknown, key: string): string | undefined { if (input === null || typeof input !== "object" || Array.isArray(input)) return undefined; const value = (input as Record)[key]; diff --git a/packages/eve/src/execution/node-step.test.ts b/packages/eve/src/execution/node-step.test.ts index 01848a05b..6b582f56d 100644 --- a/packages/eve/src/execution/node-step.test.ts +++ b/packages/eve/src/execution/node-step.test.ts @@ -269,6 +269,51 @@ describe("createNodeHarnessTools", () => { expect(tools.has("agent")).toBe(false); }); + + it("does not inject task tools without experimental.tasks", () => { + const tools = createNodeHarnessTools({ node: createTestNode() }); + + for (const name of ["task_peek", "task_await", "task_cancel", "task_sleep"]) { + expect(tools.has(name)).toBe(false); + } + }); + + it("injects the task tools when experimental.tasks is on", () => { + const node = createTestNode(); + const tools = createNodeHarnessTools({ + node: { + ...node, + agent: { + ...node.agent, + config: { experimental: { tasks: true }, model: { id: "test-model" }, name: "test" }, + }, + }, + }); + + for (const name of ["task_peek", "task_await", "task_cancel"]) { + expect(tools.get(name)?.runtimeAction).toEqual({ kind: "task-control" }); + expect(tools.get(name)?.execute).toBeUndefined(); + } + expect(tools.get("task_sleep")?.execute).toBeDefined(); + expect(tools.get("task_sleep")?.runtimeAction).toBeUndefined(); + }); + + it("respects disableTool for individual task tools", () => { + const node = createTestNode(); + const tools = createNodeHarnessTools({ + node: { + ...node, + agent: { + ...node.agent, + config: { experimental: { tasks: true }, model: { id: "test-model" }, name: "test" }, + disabledFrameworkTools: ["task_cancel"], + }, + }, + }); + + expect(tools.has("task_peek")).toBe(true); + expect(tools.has("task_cancel")).toBe(false); + }); }); describe("createExecutionNodeStep", () => { diff --git a/packages/eve/src/execution/node-step.ts b/packages/eve/src/execution/node-step.ts index 3e50f0305..ef7631c47 100644 --- a/packages/eve/src/execution/node-step.ts +++ b/packages/eve/src/execution/node-step.ts @@ -21,6 +21,10 @@ import { AGENT_TOOL_NAME, isImplicitAgentToolAvailable, } from "#runtime/framework-tools/agent.js"; +import { + createTaskToolHarnessDefinitions, + isTaskToolAvailable, +} from "#runtime/framework-tools/tasks.js"; import type { ResolvedRuntimeAgentNode } from "#runtime/graph.js"; import type { PreparedRuntimeTool } from "#runtime/sessions/turn.js"; @@ -218,6 +222,20 @@ export function createNodeHarnessTools(input: { }); } + const tasksEnabled = input.node.agent.config?.experimental?.tasks === true; + for (const definition of createTaskToolHarnessDefinitions()) { + if ( + isTaskToolAvailable({ + disabledFrameworkTools: input.node.agent.disabledFrameworkTools, + hasAuthoredTool: tools.has(definition.name), + tasksEnabled, + toolName: definition.name, + }) + ) { + tools.set(definition.name, definition); + } + } + return tools; } diff --git a/packages/eve/src/harness/advertised-tools.test.ts b/packages/eve/src/harness/advertised-tools.test.ts index f5b3eacce..ebe962214 100644 --- a/packages/eve/src/harness/advertised-tools.test.ts +++ b/packages/eve/src/harness/advertised-tools.test.ts @@ -134,6 +134,35 @@ describe("getAdvertisedTools for definition arrays", () => { expect(advertisedTools.map((tool) => tool.name)).toEqual(["add", "delegate"]); }); + + it("keeps the task tools in the root session", () => { + const tools = new Map([ + ["add", createTool("add")], + ["task_peek", createTaskControlTool("task_peek")], + ["task_sleep", createTool("task_sleep")], + ]) satisfies HarnessToolMap; + + const advertisedTools = getAdvertisedTools({ session: {}, tools }); + + expect([...advertisedTools.keys()]).toEqual(["add", "task_peek", "task_sleep"]); + }); + + it("removes the task tools from delegated sessions", () => { + const tools = new Map([ + ["add", createTool("add")], + ["task_await", createTaskControlTool("task_await")], + ["task_cancel", createTaskControlTool("task_cancel")], + ["task_peek", createTaskControlTool("task_peek")], + ["task_sleep", createTool("task_sleep")], + ]) satisfies HarnessToolMap; + + const advertisedTools = getAdvertisedTools({ + session: { rootSessionId: "root-session", subagentDepth: 1 }, + tools, + }); + + expect([...advertisedTools.keys()]).toEqual(["add"]); + }); }); function createTool(name: string): HarnessToolDefinition { @@ -166,6 +195,13 @@ function createBuiltInAgentTool(): HarnessToolDefinition { }; } +function createTaskControlTool(name: string): HarnessToolDefinition { + return { + ...createTool(name), + runtimeAction: { kind: "task-control" }, + }; +} + function createSession(overrides: Partial = {}): HarnessSession { return { agent: { diff --git a/packages/eve/src/harness/advertised-tools.ts b/packages/eve/src/harness/advertised-tools.ts index e1df9f000..301fccbaa 100644 --- a/packages/eve/src/harness/advertised-tools.ts +++ b/packages/eve/src/harness/advertised-tools.ts @@ -2,6 +2,7 @@ import type { ToolSet } from "ai"; import type { HarnessToolDefinition } from "#harness/execute-tool.js"; import { resolveSubagentDepth } from "#harness/subagent-depth.js"; import { AGENT_TOOL_NAME } from "#runtime/framework-tools/agent.js"; +import { TASK_TOOL_NAMES } from "#runtime/framework-tools/tasks.js"; import { ROOT_RUNTIME_AGENT_NODE_ID } from "#runtime/graph.js"; import { ensureWorkflowContinuationSecurity, @@ -162,15 +163,29 @@ function shouldHideDelegationTool( definition: HarnessToolDefinition, session: AdvertisedToolSession, ): boolean { + if (isRootOnlyFrameworkTool(definition)) { + return session.rootSessionId !== undefined || resolveSubagentDepth(session).currentDepth > 0; + } + + return false; +} + +/** + * Tools that only a root session may see. The `agent` self-delegation + * tool and the `experimental.tasks` controls are injected from the root + * node's config, which self-delegated children share — session shape is + * the only signal that separates the root from its children. + */ +function isRootOnlyFrameworkTool(definition: HarnessToolDefinition): boolean { if ( - definition.name !== AGENT_TOOL_NAME || - definition.runtimeAction?.kind !== "subagent-call" || - definition.runtimeAction.nodeId !== ROOT_RUNTIME_AGENT_NODE_ID + definition.name === AGENT_TOOL_NAME && + definition.runtimeAction?.kind === "subagent-call" && + definition.runtimeAction.nodeId === ROOT_RUNTIME_AGENT_NODE_ID ) { - return false; + return true; } - return session.rootSessionId !== undefined || resolveSubagentDepth(session).currentDepth > 0; + return TASK_TOOL_NAMES.has(definition.name); } function isToolDefinitionList( diff --git a/packages/eve/src/harness/execute-tool.ts b/packages/eve/src/harness/execute-tool.ts index ad92b038e..2042f0f91 100644 --- a/packages/eve/src/harness/execute-tool.ts +++ b/packages/eve/src/harness/execute-tool.ts @@ -8,13 +8,20 @@ import type { ToolExecuteOptions } from "#shared/tool-definition.js"; * * These tools are surfaced to the model without a local `execute` function. * The harness records the tool call and the runtime executes it later. + * + * `task-control` marks the `experimental.tasks` parent tools + * (`task_peek`, `task_await`, `task_cancel`): they carry no child + * address of their own — the dispatch step resolves targets through the + * session task index by tool name. */ -export type HarnessRuntimeActionDefinition = { - readonly kind: "remote-agent-call" | "subagent-call"; - readonly nodeId: string; - readonly remoteAgentName?: string; - readonly subagentName: string; -}; +export type HarnessRuntimeActionDefinition = + | { + readonly kind: "remote-agent-call" | "subagent-call"; + readonly nodeId: string; + readonly remoteAgentName?: string; + readonly subagentName: string; + } + | { readonly kind: "task-control" }; /** * Unified harness-owned tool definition. diff --git a/packages/eve/src/harness/workflow-runtime-action-state.ts b/packages/eve/src/harness/workflow-runtime-action-state.ts index 46d20864a..36246045a 100644 --- a/packages/eve/src/harness/workflow-runtime-action-state.ts +++ b/packages/eve/src/harness/workflow-runtime-action-state.ts @@ -36,15 +36,21 @@ export function buildRuntimeActionFromWorkflowInterrupt( }; } - return { - callId, - description: "", - input: toolInput, - kind: "subagent-call", - name: toolName, - nodeId: runtimeAction.nodeId, - subagentName: runtimeAction.subagentName, - }; + if (runtimeAction.kind === "subagent-call") { + return { + callId, + description: "", + input: toolInput, + kind: "subagent-call", + name: toolName, + nodeId: runtimeAction.nodeId, + subagentName: runtimeAction.subagentName, + }; + } + + // Dynamic workflows only interrupt on delegation tools; task controls + // never enter a workflow sandbox. + throw new Error(`Workflow runtime actions cannot carry "${runtimeAction.kind}" tools.`); } /** Returns every pending runtime-action interrupt in deterministic ledger order. */ diff --git a/packages/eve/src/runtime/framework-tools/index.ts b/packages/eve/src/runtime/framework-tools/index.ts index 77bb83208..2bbec8459 100644 --- a/packages/eve/src/runtime/framework-tools/index.ts +++ b/packages/eve/src/runtime/framework-tools/index.ts @@ -8,6 +8,7 @@ import { createSkillToolDefinition, SKILL_TOOL_DEFINITION, } from "#runtime/framework-tools/skill.js"; +import { TASK_TOOL_DEFINITIONS } from "#runtime/framework-tools/tasks.js"; import { TODO_TOOL_DEFINITION } from "#runtime/framework-tools/todo.js"; import { WEB_FETCH_TOOL_DEFINITION } from "#runtime/framework-tools/web-fetch.js"; import { WEB_SEARCH_TOOL_DEFINITION } from "#runtime/framework-tools/web-search.js"; @@ -37,6 +38,7 @@ const REGISTERED_FRAMEWORK_TOOLS: readonly ResolvedToolDefinition[] = [ const ALL_FRAMEWORK_TOOLS: readonly ResolvedToolDefinition[] = [ ...REGISTERED_FRAMEWORK_TOOLS, AGENT_TOOL_DEFINITION, + ...TASK_TOOL_DEFINITIONS, ]; /** diff --git a/packages/eve/src/runtime/framework-tools/tasks.ts b/packages/eve/src/runtime/framework-tools/tasks.ts new file mode 100644 index 000000000..e5cd08f2b --- /dev/null +++ b/packages/eve/src/runtime/framework-tools/tasks.ts @@ -0,0 +1,186 @@ +import { z } from "#compiled/zod/index.js"; + +import { requestTurnSleep } from "#harness/turn-sleep.js"; +import type { HarnessToolDefinition } from "#harness/execute-tool.js"; +import type { ResolvedToolDefinition } from "#runtime/types.js"; + +/** + * Framework task tools for `experimental.tasks`. + * + * With the flag on, subagent calls return a task receipt instead of + * blocking the parent turn; these tools are the model's controls over + * that delegated work. `task_peek`, `task_await`, and `task_cancel` are + * execute-less runtime actions — they need durable session state and + * world access, so the runtime-action dispatch step executes them. + * `task_sleep` only records a durable pause and executes in-loop. + */ + +export const TASK_PEEK_TOOL_NAME = "task_peek"; +export const TASK_AWAIT_TOOL_NAME = "task_await"; +export const TASK_CANCEL_TOOL_NAME = "task_cancel"; +export const TASK_SLEEP_TOOL_NAME = "task_sleep"; + +/** Every model-visible task tool name, for gating and dispatch matching. */ +export const TASK_TOOL_NAMES: ReadonlySet = new Set([ + TASK_PEEK_TOOL_NAME, + TASK_AWAIT_TOOL_NAME, + TASK_CANCEL_TOOL_NAME, + TASK_SLEEP_TOOL_NAME, +]); + +/** Task-control tools executed by the runtime-action dispatch step. */ +export const TASK_CONTROL_TOOL_NAMES: ReadonlySet = new Set([ + TASK_PEEK_TOOL_NAME, + TASK_AWAIT_TOOL_NAME, + TASK_CANCEL_TOOL_NAME, +]); + +const TASK_IDS_SCHEMA = z + .array(z.string().min(1)) + .min(1) + .describe("Task ids from earlier subagent task receipts."); + +export const TASK_PEEK_INPUT_SCHEMA = z.strictObject({ taskIds: TASK_IDS_SCHEMA }); +export const TASK_AWAIT_INPUT_SCHEMA = z.strictObject({ taskIds: TASK_IDS_SCHEMA }); +export const TASK_CANCEL_INPUT_SCHEMA = z.strictObject({ taskIds: TASK_IDS_SCHEMA }); + +const MAX_SLEEP_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1_000); + +export const TASK_SLEEP_INPUT_SCHEMA = z.strictObject({ + seconds: z.number().positive().max(MAX_SLEEP_SECONDS).describe("How long to wait, in seconds."), +}); + +const TASK_VIEW_SCHEMA = z.object({ + inputRequests: z.array(z.unknown()).optional(), + lastOutput: z + .object({ + data: z.unknown(), + type: z.enum(["result", "error"]), + }) + .optional(), + metadata: z.object({ + childSessionId: z.string(), + kind: z.literal("subagent"), + mode: z.enum(["local", "remote"]), + name: z.string(), + url: z.string().optional(), + }), + status: z.enum(["working", "input_required", "completed", "failed", "cancelled"]), + statusMessage: z.string().optional(), + taskId: z.string(), +}); + +export const TASK_VIEWS_OUTPUT_SCHEMA = z.object({ + tasks: z.array(TASK_VIEW_SCHEMA), +}); + +export const TASK_SLEEP_OUTPUT_SCHEMA = z.strictObject({ + waitedSeconds: z.number().positive(), +}); + +const TASK_PEEK_DESCRIPTION = + "Read the current state of one or more background tasks without waiting. " + + "Returns each task's status and, for finished tasks, its output. Does not wake or change the task."; + +const TASK_AWAIT_DESCRIPTION = + "Wait until every selected background task is finished (completed, failed, or cancelled) or needs input. " + + "Tasks that are already in one of those states return immediately. Returns the same views as task_peek."; + +const TASK_CANCEL_DESCRIPTION = + "Request cooperative cancellation of one or more background tasks. " + + "Cancellation is final: a task that finishes after you cancel it stays cancelled. Cancelling an already-finished task changes nothing."; + +const TASK_SLEEP_DESCRIPTION = + "Pause durably before continuing, for paced background-task checks. " + + "Does not read or change any task; follow it with task_peek or task_await."; + +/** + * Builds the harness definitions injected when the root agent enables + * `experimental.tasks`. Follows the implicit `agent` tool pattern: + * inline definitions, no registry entry, session-shape hiding in + * advertised-tools, and re-validation at dispatch. + */ +export function createTaskToolHarnessDefinitions(): readonly HarnessToolDefinition[] { + return [ + { + description: TASK_PEEK_DESCRIPTION, + inputSchema: TASK_PEEK_INPUT_SCHEMA, + name: TASK_PEEK_TOOL_NAME, + outputSchema: TASK_VIEWS_OUTPUT_SCHEMA, + runtimeAction: { kind: "task-control" }, + }, + { + description: TASK_AWAIT_DESCRIPTION, + inputSchema: TASK_AWAIT_INPUT_SCHEMA, + name: TASK_AWAIT_TOOL_NAME, + outputSchema: TASK_VIEWS_OUTPUT_SCHEMA, + runtimeAction: { kind: "task-control" }, + }, + { + description: TASK_CANCEL_DESCRIPTION, + inputSchema: TASK_CANCEL_INPUT_SCHEMA, + name: TASK_CANCEL_TOOL_NAME, + outputSchema: TASK_VIEWS_OUTPUT_SCHEMA, + runtimeAction: { kind: "task-control" }, + }, + { + description: TASK_SLEEP_DESCRIPTION, + execute: async (input: { readonly seconds: number }) => { + requestTurnSleep(Math.ceil(input.seconds * 1_000)); + return { waitedSeconds: input.seconds }; + }, + inputSchema: TASK_SLEEP_INPUT_SCHEMA, + name: TASK_SLEEP_TOOL_NAME, + outputSchema: TASK_SLEEP_OUTPUT_SCHEMA, + }, + ]; +} + +/** + * Whether one node's sessions receive the task tools. + * + * Mirrors `isImplicitAgentToolAvailable`: the compile step already + * rejects `experimental.tasks` on subagents, authored tools with the + * same name shadow the framework tool, and `disableTool(name)` removes + * individual tools. Root-node self-delegated children share this node's + * config, so advertised-tools additionally hides the tools from any + * child-shaped session. + */ +export function isTaskToolAvailable(input: { + readonly disabledFrameworkTools: readonly string[]; + readonly hasAuthoredTool: boolean; + readonly tasksEnabled: boolean; + readonly toolName: string; +}): boolean { + return ( + input.tasksEnabled && + !input.disabledFrameworkTools.includes(input.toolName) && + !input.hasAuthoredTool + ); +} + +function createResolvedTaskToolStub(input: { + readonly description: string; + readonly name: string; +}): ResolvedToolDefinition { + return { + description: input.description, + inputSchema: null, + logicalPath: `eve:framework/${input.name}`, + name: input.name, + sourceId: `eve:${input.name}-tool`, + sourceKind: "module", + }; +} + +/** + * Registry-shaped metadata for the task tools. Not registered in the + * tool registry (the harness injects the real definitions per node); + * these entries exist so `disableTool(name)` validates the names. + */ +export const TASK_TOOL_DEFINITIONS: readonly ResolvedToolDefinition[] = [ + createResolvedTaskToolStub({ description: TASK_PEEK_DESCRIPTION, name: TASK_PEEK_TOOL_NAME }), + createResolvedTaskToolStub({ description: TASK_AWAIT_DESCRIPTION, name: TASK_AWAIT_TOOL_NAME }), + createResolvedTaskToolStub({ description: TASK_CANCEL_DESCRIPTION, name: TASK_CANCEL_TOOL_NAME }), + createResolvedTaskToolStub({ description: TASK_SLEEP_DESCRIPTION, name: TASK_SLEEP_TOOL_NAME }), +]; diff --git a/packages/eve/test/runtime-agent-framework-tools.test.ts b/packages/eve/test/runtime-agent-framework-tools.test.ts index d9ad5e544..c8007fa46 100644 --- a/packages/eve/test/runtime-agent-framework-tools.test.ts +++ b/packages/eve/test/runtime-agent-framework-tools.test.ts @@ -114,6 +114,6 @@ describe("runtime agent framework tools", () => { { kind: "subagent" }, ]); const runtimeAction = createNodeHarnessTools({ node: graph.root }).get("agent")?.runtimeAction; - expect(runtimeAction?.subagentName).toBe("agent"); + expect(runtimeAction).toMatchObject({ kind: "subagent-call", subagentName: "agent" }); }); });