Skip to content

ToolDefinition has no way for a client to scope a tool away from tool-spawned chats #446

Description

A client contributes tools to a session via SessionActiveClient.tools. Once contributed, a tool is available in every chat of that session — including chats the host creates with ChatOrigin.Tool for sub-agent / worker delegation.

The protocol already models the agent side of tool scoping. AgentCustomization.tools is documented as "Allowlist of tool names the agent is scoped to… A non-empty list restricts the agent to exactly those tools." There is no tool side: a client cannot say where its own tool may be invoked.

Why this matters for client-contributed tools specifically

A client-contributed tool is executed by the client, not by the server — ToolCallClientContributor.clientId makes the identified client "responsible for executing the tool and dispatching chat/toolCallComplete with the result."

Many such tools are only meaningful in a chat the user is actually driving:

  • UI-bound tools — open a picker, render a surface, focus an editor. A tool-spawned worker chat has no foreground surface to drive.
  • User-authority tools — post a message, file a ticket, send mail. Firing these from an autonomously-delegated worker is the wrong default.

The vocabulary for the distinction already exists: ChatOrigin separates user-driven chats (user, fork, sideChat) from tool-spawned ones (tool).

Proposal

An optional field on ToolDefinition:

/** Where in a session this tool may be invoked. Absent means `session`. */
availability?: ToolAvailability;

export const enum ToolAvailability {
  /** Any chat in the session, including tool-spawned worker chats. */
  Session = 'session',
  /** Only chats the user drives directly — not chats with a `tool` origin. */
  UserChats = 'userChats',
}

Semantics that make this a contract rather than a rendering hint:

  • Hosts MUST NOT expose a userChats tool to an agent running in a chat whose origin is tool.
  • If such a call is produced anyway, the host MUST terminalise it itself (a completed call carrying a failed result) and MUST NOT set a ToolCallClientContributor on it — the client was never asked to execute it, so it must not be told it is responsible for doing so.
  • Absence means session, so existing producers and consumers are unaffected.

Why the origin-based workaround isn't sufficient

Without this field, the only portable option is for a client to inspect the invoking chat's origin and refuse when it isn't user. Two problems:

  1. It repurposes an advisory signal as an authorization boundary. The chat-channel spec says clients "MAY use the origin to render contextual UI (parent indicators, fork markers, 'spawned by tool' badges), but origin is not a hierarchy", and that ancestry "is advisory and may be incomplete." origin is also optional on both ChatState and ChatSummary, so a client must decide how to treat absence, and different hosts may reasonably differ.
  2. It is reactive. The refusal happens after the model has already selected and called a tool it was never allowed to use — a wasted turn, an error the model may retry against, and no way to communicate that the tool is simply unavailable in that context.

A declarative field lets the constraint be enforced where the tool list is assembled, which is the only place it can be enforced without cost.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions