Skip to content

Per-invocation tool profiles for in-process roles #425

@rockfordlhotka

Description

@rockfordlhotka

Background

The main RockBot.Agent process exposes ~58 native tools to the LLM (6 MCP gateway + 26 direct + 26 memory/skills/rules/tasks). DI already shapes the surface across separate executables (e.g., RockBot.ResearchAgent, RockBot.SampleAgent register far fewer tools), but inside the main agent process several in-process roles share one DI container:

  • UserMessageHandler (main turn)
  • SubagentRunner (spawned in-process; uses parent DI)
  • ScheduledTaskHandler (in-process message handler)
  • A2A receive-side handlers (A2ATaskResultHandler, A2ATaskStatusHandler, A2ATaskErrorHandler, InputRequiredHandler)
  • LateA2ANotificationHandler (added in A2A late-reply fold-back to primary session #424)

The first three (subagent + A2A result) currently use ad-hoc inline .Where/HashSet filters to scope the tool list. ScheduledTaskHandler and the three other A2A handlers leak the full set. There is no shared mechanism, no drift detection, and the existing BuildAgentToolFunctions(... filter) hook is barely used.

Related: #420 (per-server typed MCP tools), #338 (search_conversation_history), #418 (SavedResponseTools) — all propose new tools and benefit from a sane scoping mechanism landing first.

Design

New file src/RockBot.Tools/ToolProfile.cs:

public sealed record ToolProfile(
    string Name,
    ImmutableHashSet<string> AllowedSources,
    ImmutableHashSet<string> DeniedSources,
    ImmutableHashSet<string> AllowedToolNames,
    ImmutableHashSet<string> DeniedToolNames)
{
    public bool Matches(ToolRegistration r) =>
        !DeniedToolNames.Contains(r.Name)
        && !DeniedSources.Contains(r.Source)
        && (AllowedToolNames.Contains(r.Name)
            || AllowedSources.Contains("*")
            || AllowedSources.Contains(r.Source));

    public static ToolProfile All { get; } = /* matches everything */;
}

Static ToolProfiles exposing named profiles built via With/Without composition:

Profile Behavior
Main Everything (default; preserves current behavior)
Subagent Deny source subagent; deny names cancel_scheduled_task, mcp_register_server, mcp_unregister_server. Allows A2A invoke, schedule create/list, MCP invoke.
Scheduled Deny source subagent; deny names mcp_register_server, mcp_unregister_server.
A2ASynthesis Deny source a2a. Applied to all 4 A2A handlers + the late-reply fold-back handler (#424) so the synthesis LLM cannot dispatch a fresh invoke_agent while handling the current result.

New overload: ToolRegistryExtensions.BuildAgentToolFunctions(..., ToolProfile profile, ...) delegates to existing filter path. Existing callers unchanged (default = Main semantics).

In-code, not hot-reloadable. Profiles are a safety boundary (subagents must not register MCP servers, must not recurse). A bad PVC edit silently opening that boundary is a worse failure mode than a redeploy. Revisit only after telemetry shows real usage patterns.

Implementation order

  1. src/RockBot.Tools/ToolProfile.cs (new) — record + composition helpers
  2. src/RockBot.Tools/ToolRegistryExtensions.cs — add BuildAgentToolFunctions(..., ToolProfile, ...) overload; log profile + allowed/denied counts at Information
  3. tests/RockBot.Host.Tests/ToolProfileSnapshotTests.cs (new) — [DataRow] per profile; snapshots tool-name list; catches drift when new sources land
  4. Wire ToolProfiles.Subagent into SubagentRunner.cs (keep inline .Select loop; only the predicate changes — SubagentRegistryToolFunction wrapper preserved)
  5. Wire ToolProfiles.Scheduled into ScheduledTaskHandler.cs
  6. Wire ToolProfiles.A2ASynthesis into the 4 A2A handlers (replacing inline HashSets at A2ATaskResultHandler, A2ATaskStatusHandler, A2ATaskErrorHandler, InputRequiredHandler) and the late-reply fold-back handler from A2A late-reply fold-back to primary session #424
  7. Explicit ToolProfiles.Main in UserMessageHandler.cs and UserFeedbackHandler.cs — no behavior change, locks in intent so future tool additions do not silently leak into restricted profiles

Tests

  • ToolProfileTests — pure-function Matches + composition
  • ToolProfileSnapshotTests — per-profile name lists; drift detector — adding a new ToolRegistrar without updating profiles fails the snapshot, forcing a conscious decision
  • Handler tests asserting filtered tool membership via captured ChatOptions
  • MSTest + Rocks (per repo convention)

Risks

  • Profile drift toward closed, not open. Main matches everything; restricted profiles are explicit allowlists. New tool sources show up in Main automatically and only enter restricted profiles by conscious update (forced by the snapshot test). Fails safe.
  • SubagentRegistryToolFunction vs RegistryToolFunction divergence. Subagent uses a namespace-scoping wrapper. Keep the inline .Select loop; only the predicate changes — no refactor of the wrapper class.

Out of scope

  • Per-MCP-server pruning (sub-profile that includes some MCP servers but not others)
  • Dynamic per-request tool selection by an LLM router
  • MCP gateway rework
  • Renaming any existing tools or Source values
  • Externalizing profiles to a markdown/JSON file on the PVC (deferred until usage telemetry exists)
  • Changing AgentLoopRunner.RunAsync's signature
  • Wisp executor's tool resolution path (resolves by name from the full registry; does not advertise to an LLM, unaffected)

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions