Skip to content
Open
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
8 changes: 4 additions & 4 deletions docs/docs/api/appkit/Function.runAgent.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 43 additions & 2 deletions docs/docs/api/appkit/Interface.RunAgentInput.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 24 additions & 1 deletion docs/docs/plugins/execution-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ The default is the **service principal**. Work runs on behalf of the user only i
| Agents HTTP routes: the model (LLM) call | app service principal | the model adapter builds its own service-principal client, and the route does not open user scope |
| Agents HTTP routes: plugin-toolkit tool calls (`plugin:<name>`) | signed-in user (OBO) | `executeTool` opens user scope for each call; without user credentials the call rejects |
| Agents HTTP routes: hand-rolled `tool({ execute })` | app service principal | `execute` receives only the validated arguments and runs in the app context, as before |
| Standalone `runAgent` (no HTTP request) | app service principal | there is no request, so no user scope |
| Standalone `runAgent` (no HTTP request) | app service principal by default | there is no request, so no user scope unless you pass `caller` (see [Standalone agents](#standalone-agents)) |

So an agent's **model inference runs as the service principal**, while the tools it calls over the built-in HTTP routes run on behalf of the user. See the [agents plugin](./agents.md) for the tool-level detail.

Expand All @@ -83,6 +83,29 @@ now include the principal namespace even when an explicit legacy user key is
supplied. Existing stored entries will have a cold miss after upgrading; users
and SP continue to have separate cache entries and in-flight work.

## Standalone agents

Standalone `runAgent` can opt into user execution without an HTTP request:

```ts
await runAgent(agent, {
messages: "Summarize my data",
caller: {
token: userToken,
principal: { type: "user", userId },
host: "https://your-workspace.cloud.databricks.com",
workspaceId,
},
});
```

Get credentials through a trusted authentication flow, never from model output.
The caller applies to plugin initialization, model adapters, tools, and nested
agents. Omitting it inherits an existing caller scope or defaults to SP.
Invalid explicit credentials reject even in development. No service context or
CLI profile is initialized implicitly. Standalone execution still has no approval
gate and is intended for trusted scripts and evaluations.

## Development fallback

With `NODE_ENV=development`, `asUser(req)` without a token logs a warning and
Expand Down
73 changes: 61 additions & 12 deletions packages/appkit/src/core/agent/run-agent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";

import type {
AgentAdapter,
Expand All @@ -15,7 +15,11 @@ import {
SUPERVISOR_EXTENSION_KEY,
type SupervisorTool,
} from "../../agents/supervisor-api";
import { type CallerPrincipal, runInCallerContext } from "../../context";
import { getClientOptions } from "../../context/client-options";
import { AuthenticationError, ConfigurationError } from "../../errors";
import { createLogger } from "../../logging/logger";
import { createWorkspaceClient } from "../../workspace-client";
import { consumeAdapterStream } from "./consume-adapter-stream";
import { createPluginsProxy } from "./plugins-map";
import { resolveToolkitFromProvider } from "./toolkit-resolver";
Expand All @@ -41,12 +45,23 @@ export interface RunAgentInput {
messages: string | Message[];
/** Abort signal for cancellation. */
signal?: AbortSignal;
/**
* Explicit user credentials for standalone execution. Host and workspace ID
* are required, so no CLI profile or service-principal identity is selected.
* Omit to inherit the ambient scope, or use SP when no caller scope is open.
* Obtain the token through a trusted authentication flow, not model input.
*/
caller?: {
readonly token: string;
readonly principal: CallerPrincipal;
readonly host: string;
readonly workspaceId: string;
};
/**
* Optional plugin list. Required when `def.tools` is the function form
* `(plugins) => Record<string, AgentTool>` and the function dereferences
* any plugins. `runAgent` constructs a fresh instance per plugin and
* dispatches tool calls against it as the service principal (no OBO —
* there is no HTTP request in standalone mode).
* dispatches tool calls with the run's ambient principal.
*/
plugins?: PluginData<PluginConstructor, unknown, string>[];
}
Expand All @@ -63,12 +78,12 @@ export interface RunAgentResult {
* inline tools, and drives the adapter's `run()` loop to completion.
*
* Limitations vs. running through the agents() plugin:
* - **No OBO and no approval gate** — there is no HTTP request, so plugin
* tools run as the service principal. The agents-plugin approval gate
* - **No approval gate**: tools inherit the run's principal, SP by default.
* Explicit caller credentials enable user execution. The agents-plugin gate
* that prompts for human confirmation on `effect: "write" | "update" |
* "destructive"` tools is also absent. LLM-controlled tool arguments
* flow straight through to the SP. Treat standalone runAgent as a
* trusted-prompt environment (CI, batch eval, internal scripts) — not
* flow straight through to the tools. Treat standalone runAgent as a
* trusted-prompt environment (CI, batch eval, internal scripts), not
* as an exposed user-facing surface.
* - **Hosted tools (MCP) are not supported** — they require a live MCP
* client that only exists inside the agents plugin's lifecycle.
Expand All @@ -89,6 +104,43 @@ export interface RunAgentResult {
export async function runAgent(
def: AgentDefinition,
input: RunAgentInput,
): Promise<RunAgentResult> {
if (input.caller) {
const { principal, host, workspaceId } = input.caller;
const token = input.caller.token.trim();
if (!token) throw AuthenticationError.missingToken("user token");
if (principal.type !== "user" || !principal.userId.trim()) {
throw AuthenticationError.missingUserId();
}
if (!host.trim() || !workspaceId.trim()) {
throw new ConfigurationError(
"runAgent caller requires host and workspaceId",
);
}
const caller = {
principal: { ...principal, userId: principal.userId.trim() },
client: createWorkspaceClient({
token,
host,
authType: "pat",
clientOptions: getClientOptions(),
}),
workspaceId: Promise.resolve(workspaceId),
tokenFingerprint: createHash("sha256")
.update(token)
.digest("hex")
.slice(0, 16),
};
// Credentials are not carried into adapter inputs, tools, or sub-agent options.
const { caller: _credentials, ...scopedInput } = input;
return runInCallerContext(caller, () => runStandalone(def, scopedInput));
}
return runStandalone(def, input);
}

async function runStandalone(
def: AgentDefinition,
input: RunAgentInput,
): Promise<RunAgentResult> {
// Single shared cache for the whole call graph: parent + every nested
// sub-agent dispatch share constructed plugin instances. Without this,
Expand Down Expand Up @@ -585,11 +637,8 @@ function providerCacheLookup(
/**
* Lightweight `ToolProvider` shape check used by standalone `runAgent`.
*
* Distinct from `core/plugin-context.isToolProvider` which also requires
* `asUser` (request-scoped, only meaningful when running inside `createApp`
* with a live HTTP context). Standalone plugins are constructed without a
* `WorkspaceClient` and have no request to scope to, so checking only the
* two `ToolProvider` methods is the right narrowing here.
* Standalone plugins need only these methods. Execution identity is inherited
* from the run's caller scope, without requiring a per-plugin asUser helper.
*/
function isStandaloneToolProvider(value: unknown): value is ToolProvider {
if (typeof value !== "object" || value === null) return false;
Expand Down
Loading
Loading