Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Schema tools can be defined at runtime.** `ActiveAgent::SchemaTools.define(Reservation,
filterable:, returns:, scope: | policy:)` builds the same bounded roster a
file under `app/agent_tools` would — same allowlists, same `call`, named
`ReservationTools` for logs — from a declaration held anywhere: a table, a
dashboard form, a test. The class is registered under its model and a
redefinition replaces the previous one, so a registry rebuilt on every
change holds one class per model; `undefine` drops it. The dashboard
discovers registry entries beside the files and lets a runtime definition
supersede a file for the same model. What is persisted, and where, stays the
host's decision; this is the seam a persisted declaration builds on. (#441)

### Fixed

- **The caller can no longer be named by the model, or by the client.**
`actor:` reached `AgentToolbox.call` in the same keyword namespace as the
arguments a provider parsed out of a model's tool call, and
`params[params][actor]` in an execute request would have won over the
controller's own. Both are now stripped: the caller is a property of the
run, set once by whatever authenticated it. Scoped `SchemaTools` reads are
also excluded from the tool-result cache, so one caller's rows are never
replayed for the next.
- **A superseded runtime tool class is no longer offered twice.** Discovery
read every `SchemaTools` subclass out of `descendants`, where a class built
at runtime stays until it is collected, so rebuilding a model's tools
Expand Down
2 changes: 1 addition & 1 deletion actionagent/app/assets/builds/action_agent.css

Large diffs are not rendered by default.

108 changes: 54 additions & 54 deletions actionagent/app/assets/builds/action_agent.js

Large diffs are not rendered by default.

44 changes: 39 additions & 5 deletions actionagent/app/controllers/action_agent/api/agents_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@ class AgentsController < BaseController
# Conversations returned to the runner's picker when no limit is asked for.
CONVERSATIONS_LIMIT = 50
# Keywords Agent#execute takes in its own right, which per-run overrides
# must never supply (see #execution_params).
RESERVED_EXECUTION_KEYS = [ :attachments, :action ].freeze
# must never supply (see #execution_params). `actor` is here for the
# same reason as the rest and one more: a keyword splat wins over the
# arguments before it, so a client sending params[params][actor] would
# otherwise name the caller its own run is authorized as.
RESERVED_EXECUTION_KEYS = [ :attachments, :action, :actor, :current_user ].freeze

before_action :set_agent, only: [
:show, :update, :destroy, :versions, :runs, :execute, :test, :restore, :duplicate, :export, :analytics,
:conversations, :create_conversation
:tool_roster, :conversations, :create_conversation
]
before_action :require_execution_enabled!, only: [ :execute, :test ]
before_action :require_owner!, only: [ :execute, :test ]
Expand Down Expand Up @@ -170,6 +173,7 @@ def execute
execution_prompt,
action: params[:action_name],
attachments: uploaded_attachments,
actor: agent_actor,
**execution_params
)
record_execution_usage
Expand All @@ -183,6 +187,7 @@ def test
execution_prompt,
action: params[:action_name],
attachments: uploaded_attachments,
actor: agent_actor,
**execution_params
)
record_execution_usage
Expand Down Expand Up @@ -256,6 +261,19 @@ def export
}
end

# GET /api/agents/:id/tool_roster
#
# What the Tools tab edits: the MCP services this agent can be given
# and the tools it can be offered, each with the calls, errors and
# latency recorded for it in the window.
def tool_roster
render json: AgentToolRoster.new(
agent: @agent,
traces: owned_traces,
hours: params.fetch(:hours, ToolDiscovery::DEFAULT_WINDOW_HOURS).to_i
).as_json
end

# GET /api/agents/:id/analytics
#
# Every execution of this agent, whoever ran it — the same merged model
Expand Down Expand Up @@ -478,17 +496,33 @@ def set_agent
end

def agent_params
params.require(:agent).permit(
permitted = params.require(:agent).permit(
:name, :description, :provider, :model, :instructions,
:preset_type, :agent_class_name, :status,
appearance: {},
action_prompts: [ :name, :prompt, :expose_as_tool ],
instruction_sets: [],
tools: [],
mcp_servers: [],
model_config: {},
response_format: {}
)
permitted[:mcp_servers] = mcp_server_params if params[:agent].key?(:mcp_servers)
permitted
end

# An agent names its MCP servers either as bare strings or as hashes —
# the Tools tab writes { key, name, tools } so a service can be enabled
# with only some of what it serves. Both shapes are permitted, because
# every agent saved before the tab existed carries the first one and a
# round-trip through the editor must not rewrite it.
def mcp_server_params
Array(params[:agent][:mcp_servers]).map do |entry|
if entry.respond_to?(:permit)
entry.permit(:key, :name, :url, :command, :transport, tools: []).to_h
else
entry.to_s
end
end
end

def version_json(version, include_diff: false)
Expand Down
17 changes: 17 additions & 0 deletions actionagent/app/controllers/action_agent/api/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,23 @@ def owned_traces
ActionAgent.trace_model.for_account(current_account)
end

# The caller an agent run executes on behalf of.
#
# The host's seam first (ActionAgent.agent_actor_resolver), then the
# signed-in user. Never the tenant: an account is who is billed, not
# who is allowed, and handing a Pundit policy an account would either
# raise or quietly authorize as the whole workspace.
def agent_actor
return @agent_actor if defined?(@agent_actor)

@agent_actor =
if (resolver = ActionAgent.agent_actor_resolver)
resolver.arity.zero? ? resolver.call : resolver.call(self)
else
current_user
end
end

# The tenant, when the host app has one. current_owner already
# resolves it in multi-tenant mode; single-tenant installs have none.
def current_account
Expand Down
44 changes: 43 additions & 1 deletion actionagent/app/controllers/action_agent/api/mcp_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ class MCPController < BaseController
JSONRPC_METHOD_NOT_FOUND = -32601
JSONRPC_INVALID_PARAMS = -32602
JSONRPC_SERVER_ERROR = -32000
# No JSON-RPC code means "forbidden", and the MCP spec leaves -32000..
# -32099 to the server. A refusal gets its own so a client can tell it
# from a run that merely failed.
JSONRPC_FORBIDDEN = -32003

# POST /mcp
def create
Expand Down Expand Up @@ -102,6 +106,38 @@ def key_agents
ActionAgent.agents_for(@owner).where.not(status: :archived).order(:slug)
end

# The caller an MCP-invoked run executes on behalf of.
#
# The key's owner is the identity that authenticated this request, so
# it is the default; a host issuing keys per end user overrides it
# with ActionAgent.agent_actor_resolver, which is handed this
# controller and can read the request however it likes.
#
# The agent's own callbacks decide what the actor may do — that is the
# point of carrying one. This only answers *who*.
def agent_actor
return @agent_actor if defined?(@agent_actor)

@agent_actor =
if (resolver = ActionAgent.agent_actor_resolver)
resolver.arity.zero? ? resolver.call : resolver.call(self)
else
@api_key.respond_to?(:user) && @api_key.user ? @api_key.user : @owner
end
end

# Whether the run ended because the agent refused this caller, rather
# than because something broke. Matched on the error class the
# framework raises (and the ones a host names with `denies_with`), not
# on the message.
def run_refused?(run)
klass = run.output_metadata.is_a?(Hash) ? run.output_metadata["error_class"] : nil
return false if klass.blank?

klass.to_s == "ActiveAgent::NotAuthorized" ||
ActiveAgent::Base.authorization_errors.any? { |error| error.name == klass.to_s }
end

def initialize_result
{
protocolVersion: PROTOCOL_VERSION,
Expand Down Expand Up @@ -160,10 +196,16 @@ def tools_call
raise McpError.new(denial.is_a?(Hash) ? denial[:message] || denial["message"] : denial)
end

run = agent.test_execute(message, action: action)
run = agent.test_execute(message, action: action, actor: agent_actor)
ActionAgent.record_usage(@owner, :execution)

if run.failed?
# A refusal is not a result. An agent that declined on this
# caller's behalf answers as a JSON-RPC error, so the client sees
# "not allowed" rather than an empty, confident answer — the
# failure mode a nil-actor scope produces on its own.
raise McpError.new(run.error_message.to_s, JSONRPC_FORBIDDEN) if run_refused?(run)

{ content: [ { type: "text", text: "Agent run failed: #{run.error_message}" } ], isError: true }
else
{
Expand Down
5 changes: 4 additions & 1 deletion actionagent/app/jobs/action_agent/agent_execution_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ def perform(run_id)
status: :failed,
completed_at: Time.current,
error_message: e.message,
error_backtrace: e.backtrace&.first(10)&.join("\n")
error_backtrace: e.backtrace&.first(10)&.join("\n"),
# Same as the synchronous path: the class is what distinguishes
# a refusal from a crash.
output_metadata: run.output_metadata.to_h.merge("error_class" => e.class.name)
)
run.add_log("Execution failed: #{e.message}", level: :error)
end
Expand Down
43 changes: 36 additions & 7 deletions actionagent/app/models/action_agent/agent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,25 @@ def self.polymorphic_name
terminal playwright filesystem code database slack fetch search edit translate memory agents ui
].freeze

# One line per capability, for the roster rows that offer them. A name
# alone ("ui", "agents") doesn't say what enabling it gives the model,
# and the Tools tab is where that question gets asked.
TOOL_DESCRIPTIONS = {
"terminal" => "Runs a shell command in the workspace sandbox.",
"playwright" => "Drives a headless browser: navigate, click, read the page.",
"filesystem" => "Reads and writes files under an allow-listed set of directories.",
"code" => "Reads and edits files in the connected repository.",
"database" => "Runs read-only SQL against the app database.",
"slack" => "Reads channels and posts messages as the workspace bot.",
"fetch" => "Fetches a URL and converts the page to markdown for the model to read.",
"search" => "Web search through the workspace provider.",
"edit" => "Applies a structured edit to a document.",
"translate" => "Translates text through the translation agent.",
"memory" => "Reads and writes durable notes across runs of this agent.",
"agents" => "Delegates a task to another agent in this workspace.",
"ui" => "Renders a form or table back into the chat surface."
}.freeze

# Every tool an agent may enable: the built-ins plus each tool generated by
# the host's declared ActiveAgent::SchemaTools classes (ActionAgent.schema_tools).
#
Expand Down Expand Up @@ -254,9 +273,12 @@ def perform
# before the job is enqueued, so a worker on another machine finds them
# attached. +params+ (provider/model overrides, the context_id of a
# conversation to continue) are kept on the run as input_params.
def execute(input_prompt, action: nil, attachments: [], **params)
def execute(input_prompt, action: nil, attachments: [], actor: nil, **params)
ensure_executable!
run = create_run(input_prompt, action: action, attachments: attachments, params: params, status: :pending)
run = create_run(
input_prompt, action: action, attachments: attachments, params: params,
actor: actor, status: :pending
)

# Queue the execution job
AgentExecutionJob.perform_later(run.id)
Expand All @@ -265,12 +287,13 @@ def execute(input_prompt, action: nil, attachments: [], **params)
end

# Quick test execution (synchronous)
def test_execute(input_prompt, action: nil, attachments: [], **params)
def test_execute(input_prompt, action: nil, attachments: [], actor: nil, **params)
ensure_executable!
run = create_run(
input_prompt, action: action, attachments: attachments, params: params,
status: :running, started_at: Time.current
actor: actor, status: :running, started_at: Time.current
)
run.actor = actor

begin
# Build and execute the agent
Expand All @@ -291,7 +314,11 @@ def test_execute(input_prompt, action: nil, attachments: [], **params)
status: :failed,
completed_at: Time.current,
error_message: e.message,
error_backtrace: e.backtrace&.first(10)&.join("\n")
error_backtrace: e.backtrace&.first(10)&.join("\n"),
# The class, not only the message: an agent that refused this
# caller and an agent that broke both fail the run, and only the
# class tells them apart without reading prose.
output_metadata: run.output_metadata.to_h.merge("error_class" => e.class.name)
)
end

Expand All @@ -317,14 +344,16 @@ def ensure_executable!

# Refuses files before creating anything: a run that exists but lost
# its attachments would execute against the wrong prompt.
def create_run(input_prompt, action:, attachments:, params:, **attributes)
def create_run(input_prompt, action:, attachments:, params:, actor: nil, **attributes)
files = Array.wrap(attachments).compact
raise AgentRun::AttachmentsUnavailable if files.any? && !AgentRun.attachments_available?

run = agent_runs.create!(
input_prompt: input_prompt,
action_name: normalized_action(action),
input_params: params,
# The caller is recorded beside the run's own parameters rather than
# among them: a client may send provider overrides, never an actor.
input_params: AgentRun.params_with_actor(params, actor),
trace_id: SecureRandom.uuid,
**attributes
)
Expand Down
59 changes: 59 additions & 0 deletions actionagent/app/models/action_agent/agent_run.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,55 @@ def initialize(message = "Attachments need Active Storage in the host app (run `
# --skip-active-storage has no has_many_attached to call.
has_many_attached :attachments if defined?(ActiveStorage)

# The key the caller's identity is recorded under in +input_params+.
# Underscored so it cannot collide with a provider override, and
# stripped from anything a client sends (see Api::AgentsController).
ACTOR_PARAM = "_actor_gid"

# +input_params+ with the caller recorded alongside them.
#
# The actor is stored as a Global ID rather than as the record, so the
# worker that picks the run up — on another machine, minutes later —
# authorizes as the same person who asked for the run. A caller the host
# cannot address that way (a plain object, a service account) is simply
# not recorded: the run then executes unattributed, which a host scope
# reads as "no access", rather than executing as somebody else.
#
# @param params [Hash] the run's own parameters
# @param actor [Object, nil] the caller
# @return [Hash]
def self.params_with_actor(params, actor)
params = (params || {}).to_h.except(ACTOR_PARAM, ACTOR_PARAM.to_sym)
gid = actor.respond_to?(:to_global_id) ? actor.to_global_id.to_s : nil
gid ? params.merge(ACTOR_PARAM => gid) : params
rescue StandardError => e
Rails.logger.warn("[AgentRun] could not record the run's actor: #{e.class} - #{e.message}")
params
end

# The caller this run executes on behalf of.
#
# Set in memory for a synchronous run; rehydrated from the stored Global
# ID for one picked up by a worker. A Global ID that no longer resolves
# (the user was deleted) yields nil, so the run loses access rather than
# inheriting someone else's.
# @return [Object, nil]
def actor
return @actor if defined?(@actor)

@actor = locate_actor
end

attr_writer :actor

# Whether this run knows who it is for. A run with a recorded actor that
# no longer resolves is *not* unattributed — it is broken, and callers
# that care can tell the two apart.
# @return [Boolean]
def actor_recorded?
input_params.is_a?(Hash) && input_params[ACTOR_PARAM].present?
end

# Whether runs can carry files in this host app: Active Storage loaded,
# the macro applied, and its tables migrated. Never raises — a host
# that skipped `rails active_storage:install` still runs agents, it
Expand Down Expand Up @@ -220,6 +269,16 @@ def cancel!

private

def locate_actor
return nil unless actor_recorded?
return nil unless defined?(GlobalID::Locator)

GlobalID::Locator.locate(input_params[ACTOR_PARAM])
rescue StandardError => e
Rails.logger.warn("[AgentRun] could not resolve the run's actor: #{e.class} - #{e.message}")
nil
end

def set_trace_id
self.trace_id ||= SecureRandom.uuid
end
Expand Down
Loading