diff --git a/CHANGELOG.md b/CHANGELOG.md index 515a603e..146c5e4d 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. - **`rails generate active_agent:schema_tools Reservation`** writes a starter `ActiveAgent::SchemaTools` class under `app/agent_tools`. It exposes nothing beyond `id` until a column is moved into `filterable` or `returns`; every 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