Building an AI Financial Analyst with Oracle Select AI Agent — Part 4

When the Tooling Tells You It Worked

Important

Disclaimer: The writing and musing of the author do not necessarily reflect the views of his employer.

If you have ever had a deploy script report success on every statement, then watched the thing fall over at run time with an error pointing somewhere else entirely, you'll recognize what this post is about.

This is the part I'd actually want to hand to someone starting out.


The short version

If you take one thing from this post: when something in this stack misbehaves, work bottom-up. Three checks, each ruling out a whole category.

# Check Rules out
1 USER_ERRORS — is the object VALID? Compile errors
2 SELECT your_function(...) FROM dual Registration, routing, the reasoning loop
3 USER_AI_AGENT_TOOL_HISTORY Separates routing from narration from a broken function

The rest of this post is why that order, what each layer's errors look like when they impersonate another layer's problem, and the three places this stack reports success on something comprehensively broken.


Success is not the same claim as working

Here's the behavior underneath most of what follows.

A PL/SQL object with a compile error still gets created. It just gets created INVALID. The database raises ORA-24344: success with compilation error, and whether you ever see that depends entirely on your client.

Worth being precise here, because it is the difference between a good warning and a wrong one. SQLcl and SQL Developer do print the compile error immediately. Programmatic drivers frequently do not — python-oracledb, cx_Oracle and plenty of JDBC setups treat ORA-24344 as a warning rather than an exception. So this bites hardest when objects are created by a deploy script or an application, which is exactly where nobody is watching the output.

So your script says fine. Your deploy log says fine. And nothing surfaces until something several layers away tries to use the broken object.

The check that takes five seconds

SELECT object_name, status FROM user_objects WHERE status <> 'VALID';

If anything comes back, the real error — with line and column numbers — is sitting in USER_ERRORS:

SELECT line, position, text FROM user_errors
WHERE  name = 'ACME_PYTHON_EXPENSE_ANALYSIS' ORDER BY sequence;

I now run that as a habit after creating any PL/SQL object involving DBMS_CLOUD or DBMS_CLOUD_AI_AGENT. It costs nothing when the object is valid, and when it isn't, it's the difference between a five-second fix and starting your debugging at the wrong end of the stack.


What it looks like from the agent

Here is what that failure looked like from the agent's side, with no other context:

ORA-20053: Job ACME_ANALYST_TEAM_TASK_0 failed: ORA-20051:
Task Order 0 failed with error: Task failed: ORA-20052:
Error getting argument metadata: ORA-20052: Procedure not found:
ACME_CORP.ACME_PYTHON_EXPENSE_ANALYSIS

Read literally, that's a missing object or a missing grant. Neither was true — the function existed and the grants were correct.

The actual cause, an invalid object, appears nowhere in the message. What you are reading is a symptom: DBMS_CLOUD_AI_AGENT tried to introspect the function's argument metadata to work out how to call it, and introspection fails against an INVALID object. Three layers of error wrapping, and the useful information is in none of them.

There is a second silent success hiding in here, and it is the one that lets the first one travel. CREATE_TOOL does not check that the function it is pointed at is valid — or that it exists at all. Registering a tool against a broken function succeeds without complaint:

BEGIN
  DBMS_CLOUD_AI_AGENT.CREATE_TOOL('MY_TOOL','{
     "instruction":"...",
     "function":"A_FUNCTION_THAT_IS_INVALID"}');
END;
/
-- PL/SQL procedure successfully completed.

So the chain runs: the function compiles INVALID and your client may not say so; the tool registers against it and definitely does not say so; and the first thing that actually objects is the agent, at run time, in an error that names your function and looks like a permissions problem. Three chances to catch it, two of them silent.


The order to investigate in

Back to those three checks, with what each one actually buys you.

1. USER_ERRORS — is the object actually valid? Rules out compile errors, which covers everything above. If it's invalid, stop here. Nothing downstream matters yet.

2. A direct call — SELECT your_function(...) FROM dual. Bypasses the agent entirely and rules out tool registration, routing, and the reasoning loop. If it works standalone but fails through the agent, the problem is in the registration or the call, not the function.

3. USER_AI_AGENT_TOOL_HISTORY. Once you know the function is valid and works alone, this shows what the agent actually sent and what came back.

SELECT tool_name, agent_name, start_date, SUBSTR(tool_output,1,200)
FROM   user_ai_agent_tool_history
ORDER  BY start_date DESC FETCH FIRST 10 ROWS ONLY;

Sensible parameters and correct data but a wrong final answer is a narration problem — usually the task instruction, a different fix altogether. Wrong tool is a routing problem. An error in the output is a function problem. The view tells the three apart, which is more than the error messages manage.

The order matters because each layer's errors impersonate a different layer's problems convincingly. An invalid function produces an agent-level "not found" that reads like a grants issue. A poor routing instruction produces wrong data that reads like a SQL bug. Working bottom-up means you're never debugging the wrong layer — which is exactly what I did for most of a day.


The same pattern, three more times

Once you start looking for "reported success, didn't work", it turns up in places that have nothing to do with PL/SQL compilation.

The agent that answers without tools. The tools array on CREATE_AGENT is optional. Omit it and the agent creates, RUN_TEAM runs, and an answer comes back — with no tool having fired and no error raised anywhere. What you get instead of data varies by model: mine echoed the question back at me across four runs, but nothing prevents a more confident model from filling the gap with plausible figures. Either way the tell is the same, and it is not in the error log: USER_AI_AGENT_TOOL_HISTORY shows nothing fired.

The parameter that quietly becomes its own default. Declare a tool function's parameter as a quoted identifier — "p_department" rather than p_department — and Oracle stores it lowercase. The agent introspects faithfully and sends the lowercase name, but the bind only lands on the uppercase form. The value is dropped, the parameter's DEFAULT fires, and the tool returns valid JSON computed from the wrong input. No error at any layer. If the default is NULL meaning "all departments", every question gets a company-wide answer wearing one department's name.

The INSERT that never happened. Load data containing a literal ampersand — P&L, FP&A, anything finance-shaped — and SQL*Plus treats &L as a substitution variable. It prompts, the prompt cancels, and the statement is skipped. The word "error" appears nowhere in the output. I found nine missing rows this way, in a load that reported clean.

That last one is worth dwelling on, because it changed how I verify things. I had grepped the log for ORA- and found nothing, and concluded the load was fine. The message was Substitution cancelled — no error code, no ORA-. Searching for error text is not a test. Counting the rows you expected is.


The check that lies the other way

Everything above is the tooling reporting success it had not earned. The inverse happens too, and it cost me an hour: a check reporting failure against something that works perfectly.

The grant succeeds, without complaint:

GRANT EXECUTE ON DBMS_CLOUD TO acme_corp;

Then the obvious way to confirm it returns nothing at all:

SELECT table_name FROM dba_tab_privs
WHERE  grantee   = 'ACME_CORP'
AND    privilege = 'EXECUTE'
AND    table_name = 'DBMS_CLOUD';

The reason is that DBMS_CLOUD is a public synonym rather than a package. The real object is version-suffixed and owned by C##CLOUD$SERVICE:

DBMS_CLOUD$PDBCS_260821_1_0

The grant lands on that name. A lookup for the literal string finds nothing and reports a granted package as missing. Worse, the suffix moves when the database is patched, so a check written against today's name breaks on a schedule you do not control.

I hit this writing a verification query for a provisioning script. It printed expect 4 rows, returned 3, and the fourth grant was in place the whole time.

The fix is this post's own principle pointed the other way: do not ask the dictionary whether you can do the thing — do the thing.

BEGIN
    IF 1 = 0 THEN
        DBMS_CLOUD.CREATE_CREDENTIAL(credential_name => 'x',
                                     username        => 'y',
                                     password        => 'z');
    END IF;
END;
/

PL/SQL resolves every identifier when the block is compiled, so a missing grant fails right there. The IF 1 = 0 guarantees nothing ever executes. One compile, no side effects, and it stays correct across patches because it never names the versioned object.

One detail worth carrying: a denied EXECUTE surfaces from a PL/SQL block as PLS-00201, wrapped in ORA-06550 — not as ORA-01031. Match only ORA-01031 and a genuine denial reads to your script as a pass.


What made these expensive

None of the fixes were complicated: a credential lookup became a Vault call, .text became GET_RESPONSE_TEXT(...), SQLERRM got assigned to a variable before use, and the substitution character became ^. One or two lines each.

What made them expensive is that the tooling told me each one had succeeded. There are several places in DBMS_CLOUD-adjacent PL/SQL where "the statement ran without an exception" and "the thing I built works" are not the same claim, and the gap doesn't show up until something several layers away fails with a message that doesn't point back at it.

A USER_ERRORS check after every CREATE OR REPLACE, a fixed order of investigation, and verifying by counting what you expected rather than searching for what you feared — all cheap to adopt. Worth doing before you need them.


Part 5 is about what happened after I'd built this twice by hand and decided I never wanted to do it a third time.

Built on Oracle Autonomous Database 26ai · OCI Generative AI · OML4Py