From bea8e9936fc5db57ef0713c9a491af3e386bf34b Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Sat, 12 Sep 2026 12:16:39 -0700 Subject: [PATCH 1/2] feat(delegation): a delegated run inherits its parent's caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delegate_to hands the sub-agent the parent's current_user before its action runs, so its own before_action callbacks and any scope its tools read through decide against the same person. A parent authorized as one user no longer hands its specialists an unattributed run — which a correctly written host scope reads as "no access". A parent with no caller still delegates an unattributed run, never someone else's. Builds on the caller seam (#443). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XMSRnSxYS9mRx1hSjytB9Z --- CHANGELOG.md | 8 +++++ lib/active_agent/delegation/runner.rb | 17 +++++++++++ test/authorization_test.rb | 43 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d672532f..84a3f9f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A delegated run inherits its parent's caller.** `delegate_to` hands the + sub-agent the parent's `current_user` before its action runs, so its own + `before_action` callbacks and any scope its tools read through decide + against the same person. A parent authorized as one user no longer hands + its specialists an unattributed run — which a correctly written host scope + reads as "no access", a wrong answer wearing a right one's clothes. A + parent with no caller still delegates an unattributed run, never someone + else's. - **An agent knows who it is running for, so an authorization gem has something to decide against.** `ActiveAgent::Base#current_user` carries the caller, assigned by whatever authenticated the call diff --git a/lib/active_agent/delegation/runner.rb b/lib/active_agent/delegation/runner.rb index e05b985e..f78afead 100644 --- a/lib/active_agent/delegation/runner.rb +++ b/lib/active_agent/delegation/runner.rb @@ -117,6 +117,7 @@ def coerce_arguments(arguments) def generate(arguments) agent = definition.resolved_agent_class.new agent.params = resolved_params(arguments) + inherit_actor(agent) agent.process(definition.action, **arguments) definition.backend.apply(agent) @@ -126,6 +127,22 @@ def generate(arguments) agent.process_prompt end + # A delegated generation runs on behalf of whoever the parent runs for. + # The sub-agent gets the parent's caller before its action runs, so its + # own before_action callbacks and any scope its tools read through decide + # against the same person — a parent authorized as one user must not + # hand its specialists an unattributed run, which a correctly written + # host scope reads as "no access". Hosts on an older framework, where an + # agent has no caller to carry, are left as they were. + # + # @param agent [ActiveAgent::Base] + # @return [void] + def inherit_actor(agent) + return unless owner.respond_to?(:current_user) && agent.respond_to?(:current_user=) + + agent.current_user = owner.current_user + end + # A delegated generation is part of its parent's work, so it carries the # parent's trace id — otherwise the sub-agent's tokens and latency land # in a separate trace and the budget you set has nothing to show for it. diff --git a/test/authorization_test.rb b/test/authorization_test.rb index 42d6e587..16c60bb6 100644 --- a/test/authorization_test.rb +++ b/test/authorization_test.rb @@ -164,4 +164,47 @@ def tool_call(agent_class, action, actor: nil, **kwargs) assert_equal "an unauthenticated caller is not allowed to call `delete_ticket`", agent.tools_function.call(:delete_ticket, id: 7)[:error] end + + # A parent authorized as one caller must hand its specialists the same + # caller: an unattributed delegated run reads, through any host scope, as + # "no access", which is a wrong answer wearing a right one's clothes. + class SpecialistAgent < ActiveAgent::Base + generate_with :mock + + SEEN = [] + + before_action { SEEN << current_user&.name } + + delegation :summarize, description: "Condense text" do + string :text, required: true + end + + def summarize(text:) + prompt(message: text) + end + end + + class OrchestratorAgent < ActiveAgent::Base + generate_with :mock + + delegate_to SpecialistAgent + end + + test "a delegated run inherits the parent's caller" do + SpecialistAgent::SEEN.clear + orchestrator = OrchestratorAgent.new + orchestrator.current_user = Caller.new("alice") + + orchestrator.perform_delegation(:summarize, text: "The order shipped on Monday.") + + assert_equal [ "alice" ], SpecialistAgent::SEEN + end + + test "a parent with no caller delegates an unattributed run, never someone else's" do + SpecialistAgent::SEEN.clear + + OrchestratorAgent.new.perform_delegation(:summarize, text: "The order shipped.") + + assert_equal [ nil ], SpecialistAgent::SEEN + end end From 6444afa8067f086fda879299aca07c67cbf7158d Mon Sep 17 00:00:00 2001 From: Justin Bowen Date: Sat, 12 Sep 2026 12:29:40 -0700 Subject: [PATCH 2/2] docs(changelog): restore this branch's entry dropped in the last merge --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13076a03..028243cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A delegated run inherits its parent's caller.** `delegate_to` hands the + sub-agent the parent's `current_user` before its action runs, so its own + `before_action` callbacks and any scope its tools read through decide + against the same person. A parent authorized as one user no longer hands + its specialists an unattributed run — which a correctly written host scope + reads as "no access", a wrong answer wearing a right one's clothes. A + parent with no caller still delegates an unattributed run, never someone + else's. + ### Fixed - **The caller can no longer be named by the model, or by the client.**