Skip to content

Architecture: design Tool interface for composition — atomic multi-step operations #65

Description

@zjshen14

Background

Tools today are atomic, independent units. Each execute() call is a black box. There is no mechanism to:

  • Chain tools (read → validate → write as a single user-confirmed unit)
  • Wrap a tool with retry, timeout, or fallback logic
  • Share intermediate state between tool calls in the same logical operation

This becomes a real limitation for operations where the LLM must perform multiple tool calls to complete one logical action. For example, an "atomic refactor" requires: read file → apply transform → run type check → write file. Today this requires 4 separate LLM-visible round trips, each with its own confirmation prompt. The user must approve each step independently; there is no way to say "do the whole refactor atomically."

Proposed design

Extend the Tool interface to support composition as a future-facing but non-breaking addition:

export interface Tool {
  name: string;
  description: string;
  parameters: JSONSchema;
  readonly?: boolean;
  execute: (params: Record<string, unknown>, ctx?: ToolExecutionContext) => Promise<ToolResult>;
  requiresConfirmation?: (args: Record<string, unknown>) => boolean;

  // Future composition fields (optional — existing tools unaffected)
  composedOf?: string[];   // names of sub-tools this delegates to
  atomic?: boolean;        // true = confirm once for the entire composed operation
}

export interface ToolExecutionContext {
  registry: ToolRegistry;  // access to other tools for delegation
  tmpDir?: string;
}

A composed tool declares its sub-tools via composedOf. The executor confirms the composed tool once (if atomic: true), then grants its sub-tools implicit confirmation for the duration of the composed call.

Example — an atomic_edit tool that reads, validates, and writes as one confirmed unit:

{
  name: "atomic_edit",
  composedOf: ["read", "edit"],
  atomic: true,
  requiresConfirmation: () => true,
  execute: async (params, ctx) => {
    const current = await ctx.registry.execute("read", { file_path: params.file_path });
    // ... validate, transform ...
    return ctx.registry.execute("edit", { file_path: params.file_path, old_string: ..., new_string: ... });
  }
}

Why design this now

The Tool interface is the lowest-level contract in the system. Retrofitting composition later requires changing every tool's signature. Adding optional fields now (composedOf, atomic, ctx parameter) costs nothing and establishes the interface without breaking existing tools.

Acceptance criteria

  • Tool interface gains optional composedOf, atomic fields and ctx parameter on execute
  • Existing tools continue to work unchanged (all new fields are optional)
  • ToolExecutionContext type is defined with registry and tmpDir
  • ToolRegistry.execute() passes a ToolExecutionContext to each tool
  • At least one composed tool implemented as a proof of concept (e.g. atomic_edit)
  • Confirmation for atomic: true tools fires once, not once per sub-tool

Location

  • src/tools/base.tsTool interface
  • src/tools/registry.tsexecute() method
  • src/agent/executor.ts — confirmation flow

Metadata

Metadata

Assignees

No one assigned

    Labels

    architectureStructural design decisions and system-wide refactorsenhancementNew feature or requesthelp wantedExtra attention is needed

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions