Skip to content

Limit what a compromised run can do

Every lesson so far tries to stop a bad decision. This one assumes that one got through. The model does what the email said. The earlier controls did not catch it.

Without a bound on the run, a wrong decision runs as often as the model likes and commits itself. With one, it stops, somebody can veto it, and somebody can take it back.

The blast radius of a run is how much one bad run can cost you. Everything on this page makes that number smaller. None of it makes the run correct.

An execution hook is a package you write. UC AI calls before_tool_call before every tool call, in every run, including a plain uc_ai.generate_text call. So it sees everything in the schema, and narrowing it to what you meant is your job.

ap_desk_hook carries three rules of three different kinds:

procedure before_execution(
p_agent_id in number
, p_agent_code in varchar2
, p_created_by in varchar2
, p_apex_app_id in number
, p_session_id in varchar2
)
as
begin
-- Rule 3 below counts, and this is where the count goes back to zero.
g_approve_attempts := 0;
end before_execution;
procedure before_tool_call(
p_agent_id in number
, p_agent_code in varchar2
, p_tool_code in varchar2
, p_created_by in varchar2
, p_session_id in varchar2
, p_apex_app_id in number
)
as
l_ctx uc_ai.t_exec_context;
l_clerk varchar2(255 char);
l_enabled pls_integer;
begin
-- Narrow the control to the agent it is for. This hook fires for every agent
-- and every plain generate_text call in the schema, so a hook that reads only
-- p_tool_code vetoes that tool for everybody.
if p_agent_code != 'AP_DESK' or p_tool_code != 'AP_APPROVE_INVOICE' then
return;
end if;
-- Rule 1, standing state. The kill switch is a row.
select count(*) into l_enabled
from ap_controls c
where c.control_code = 'AGENT_APPROVALS_ENABLED'
and c.control_value = 'Y'
and rownum = 1;
if l_enabled = 0 then
raise_application_error(-20993, 'Agent approvals are switched off.');
end if;
-- Rule 2, identity. The run context reaches the hook.
l_ctx := uc_ai.get_exec_context;
l_clerk := uc_ai.run_context_value(l_ctx.run_context, 'clerk');
if l_clerk is null then
raise_application_error(-20991
, 'An approval needs a named clerk in the run context.');
end if;
-- Rule 3, frequency. However many times the model asks, it asks once.
g_approve_attempts := g_approve_attempts + 1;
if g_approve_attempts > 1 then
raise_application_error(-20992, 'One approval attempt for each run.');
end if;
end before_tool_call;

An exception here ends the run. It is not a refusal the model reads and explains away. That is what makes it a control rather than a suggestion.

Register it for your session while you experiment:

begin
uc_ai_agents_api.set_execution_hook('AP_DESK_HOOK');
end;
/

With the hook registered, ask the desk to approve an invoice and bind no clerk:

l_result := uc_ai_agents_api.execute_agent(
p_agent_code => 'AP_DESK'
, p_input_parameters => json_object_t('{"entity":"Ferrolux Deutschland GmbH"
,"clerk_name":"Petra","today":"2026-08-25"
,"question":"INV-88001 is in order. Approve it."}')
, p_session_id => uc_ai_agents_api.generate_session_id
-- An invoice and a vendor, and deliberately no clerk.
, p_run_context => json_object_t('{"invoice_id":"7001","vendor_no":"V-1001"}')
);
the veto ended the run: ORA-20991: An approval needs a named clerk in the run context.

The error came out of execute_agent. It is not a tool result the model gets to reason about and explain to the clerk. The run is over.

The execution row records it, and records almost nothing else:

STATUS TOOL_CALLS_COUNT TOTAL_INPUT_TOKENS TOTAL_OUTPUT_TOKENS ERROR_MESSAGE
failed 0 0 0 ORA-20991: An approval needs a named clerk...

Verification, part 1: the hook, with no run at all

Section titled “Verification, part 1: the hook, with no run at all”

A hook is a PL/SQL procedure. Call it with the arguments UC AI passes, and assert on the exception. No provider, no tokens, and the same answer every time:

declare
procedure expect_veto(p_label in varchar2, p_tool in varchar2)
as
begin
ap_desk_hook.before_tool_call(
p_agent_id => null
, p_agent_code => 'AP_DESK'
, p_tool_code => p_tool
, p_created_by => 'petra.k'
, p_session_id => 'no-run'
, p_apex_app_id => null
);
sys.dbms_output.put_line(rpad(p_label, 44) || ' -> NOT VETOED');
exception
when others then
sys.dbms_output.put_line(rpad(p_label, 44) || ' -> ' || sqlerrm);
end expect_veto;
begin
expect_veto('a read tool, outside a run', 'AP_GET_INVOICE');
expect_veto('the write tool, with no clerk bound', 'AP_APPROVE_INVOICE');
update ap_controls set control_value = 'N'
where control_code = 'AGENT_APPROVALS_ENABLED';
expect_veto('the write tool, approvals switched off', 'AP_APPROVE_INVOICE');
-- Put it back. A block that leaves this at 'N' switches the desk off for every
-- later lesson, and nothing says why.
update ap_controls set control_value = 'Y'
where control_code = 'AGENT_APPROVALS_ENABLED';
commit;
end;
/
a read tool, outside a run -> NOT VETOED
the write tool, with no clerk bound -> ORA-20991: An approval needs a named clerk in the run context.
the write tool, approvals switched off -> ORA-20993: Agent approvals are switched off.

The kill switch is an update. It needs no deployment, no compile and no release, and somebody who is not you can set it.

The handler does not commit. The approval is written in the transaction of the caller, so the caller decides:

begin
l_result := uc_ai_agents_api.execute_agent( ... );
commit;
exception
when others then
rollback;
raise;
end;

The case to plan for is a run that fails after a write. The approval is still in your transaction. Roll back, or you keep an approval that the clerk never saw and the model never confirmed.

{"status":"approved","approval_no":"AP-5007","invoice_no":"INV-88001","amount":5712,"approved_by":"petra.k"}
approvals before 1, after the rollback 1
approval numbers went 5006 -> 5008, so 1 number(s) are gone

The row went, and the approval number did not come back with it.

Verification, part 3: what the model is told when your code breaks

Section titled “Verification, part 3: what the model is told when your code breaks”

A handler that ends with when others then return sqlerrm; puts your table names, your column names and your line numbers into the tool result. The model repeats them to the clerk, and the clerk forwards the mail to the supplier.

The block below shows the two shapes side by side. It is not a recorded tool call: it takes one database error and prints the string each style of handler returns.

a handler that returns sqlerrm hands the model:
Error executing tool: ORA-06502: PL/SQL: value or conversion error: character to number conversion error
ap_desk_pkg hands the model:
The approval could not be completed. Quote reference ERR-20260825005959444 to support.

The real error and its backtrace go to ap_desk_errors, in a transaction of their own, so the reference survives a rollback of the run that produced it:

ERROR_REF MESSAGE
ERR-20260825005959444 ORA-06502: PL/SQL: value or conversion error: character to n...

g_max_tool_calls bounds the tool loop. The default is 10. What happens at the bound is not the same everywhere either:

  • On most providers UC AI raises ORA-20301, Maximum tool calls exceeded.
  • On the OpenAI Responses API, which is the default OpenAI path, the loop exits quietly and the run finishes.

One round can hold several calls, and UC AI checks the limit between rounds. So the number of calls that run can pass the number you set, by the size of the last round. Read the trace when you need the true count.

Core UC AI has no spend cap and no rate limit. It counts tokens. It does not know what your provider charges, and there is no price column in the schema.

Budgets, per-model pricing and rate limiting are Guardrails, which is part of UC AI Pro. What core gives you is the tool-call cap above, and the hook above that.

begin
uc_ai_agents_api.set_execution_hook(null);
end;
/
  • Bound every run before you trust any of it: a call limit, a veto you own, and a caller that decides when to commit.
  • A hook enforces capability rules, because it sees the tool and the caller. It cannot enforce value rules, because it never sees the arguments.
  • raise_application_error(-20993, 'Agent approvals are switched off.') in before_tool_call is a kill switch that needs no deployment and no prompt change.

Full reference: Execution hooks has the whole contract and the four hook points.