Skip to content

Commit 35ecfd3

Browse files
authored
feat(appkit): one chat integration (#386)
1 parent 0900ea9 commit 35ecfd3

7 files changed

Lines changed: 478 additions & 32 deletions

File tree

docs/docs/api/appkit/Variable.agents.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ const agents: ToPlugin<typeof AgentsPlugin, AgentsPluginConfig, string>;
66

77
Plugin factory for the agents plugin. Reads `config/agents/*.md` by default,
88
resolves toolkits/tools from registered plugins, exposes `appkit.agents.*`
9-
runtime API and mounts `/invocations`.
9+
runtime API and mounts `POST /invocations` and `POST /responses` (aliased
10+
non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable).
1011

1112
## Example
1213

docs/docs/api/appkit/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports.
112112

113113
| Variable | Description |
114114
| ------ | ------ |
115-
| [agents](Variable.agents.md) | Plugin factory for the agents plugin. Reads `config/agents/*.md` by default, resolves toolkits/tools from registered plugins, exposes `appkit.agents.*` runtime API and mounts `/invocations`. |
115+
| [agents](Variable.agents.md) | Plugin factory for the agents plugin. Reads `config/agents/*.md` by default, resolves toolkits/tools from registered plugins, exposes `appkit.agents.*` runtime API and mounts `POST /invocations` and `POST /responses` (aliased non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). |
116116
| [READ\_ACTIONS](Variable.READ_ACTIONS.md) | Actions that only read data. |
117117
| [sql](Variable.sql.md) | SQL helper namespace |
118118
| [WRITE\_ACTIONS](Variable.WRITE_ACTIONS.md) | Actions that mutate data. |

docs/docs/plugins/agents.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This plugin is currently **beta**. APIs may change between minor releases. Impor
66
:::
77
<!-- AUTO-GENERATED: stability-banner-end -->
88

9-
The `agents` plugin turns a Databricks AppKit app into an AI-agent host. It loads agent definitions from markdown on disk (one folder per agent: `config/agents/<id>/agent.md`), from TypeScript (`createAgent(def)`), or both, and exposes them at `POST /invocations` alongside routes for chat, thread management, and cancellation.
9+
The `agents` plugin turns a Databricks AppKit app into an AI-agent host. It loads agent definitions from markdown on disk (one folder per agent: `config/agents/<id>/agent.md`), from TypeScript (`createAgent(def)`), or both, and exposes them at `POST /invocations` and `POST /responses` (non-streaming, aliases) alongside `POST /chat` (streaming) and routes for thread management, cancellation, and HITL approval.
1010

1111
This page covers the full lifecycle. For the hand-written primitives (`tool()`, `mcpServer()`), see [tools](./server.md).
1212

@@ -31,7 +31,7 @@ await createApp({
3131
});
3232
```
3333

34-
That alone gives you a live HTTP server with `POST /invocations` wired to a markdown-driven agent.
34+
That alone gives you a live HTTP server with `POST /invocations` (and its alias `POST /responses`) wired to a markdown-driven agent. Use `POST /chat` instead when you want the streaming, HITL-capable surface.
3535

3636
## Level 1: drop a markdown agent package
3737

@@ -65,7 +65,11 @@ On startup the plugin:
6565

6666
The agent starts with **no tools**. Tools are opt-in — declare them in frontmatter (Level 2 below) or opt into auto-inherit explicitly with `agents({ autoInheritTools: { file: true } })`. See "Auto-inherit posture" further down for what that costs and why it's off by default.
6767

68-
Requests land at `POST /invocations` with an OpenAI Responses-compatible body. Every tool call runs through `asUser(req)` so SQL executes as the requesting user, file access respects Unity Catalog ACLs, and telemetry spans are created automatically.
68+
Requests land at `POST /invocations` (or its alias `POST /responses`) with an OpenAI Responses-compatible body. These endpoints run the agent to completion and return a single JSON response — no SSE. Streaming clients should use `POST /chat`. Every tool call runs through `asUser(req)` so SQL executes as the requesting user, file access respects Unity Catalog ACLs, and telemetry spans are created automatically.
69+
70+
:::warning No HITL on `/invocations` and `/responses`
71+
The non-streaming invoke surface has no way to surface a mid-call approval prompt back to the caller. When `approval.requireForDestructive` is enabled (default) and the resolved agent has any tool annotated with a mutating effect (`effect: "write" | "update" | "destructive"`, or the legacy `destructive: true`), `POST /invocations` and `POST /responses` reject the request with HTTP 400 before the adapter runs. Move HITL-capable agents to `POST /chat`, or disable approval via `agents({ approval: { requireForDestructive: false } })` for autonomous back-office agents.
72+
:::
6973

7074
## Level 2: scope tools in frontmatter
7175

@@ -370,7 +374,7 @@ The route enforces that the decider is the stream owner: an approve from a diffe
370374

371375
The plugin enforces a handful of caps to protect a single-instance deployment from runaway prompts, misbehaving clients, or prompt-injected delegation cycles. Some are static (enforced by the request schema) and some are configurable via `agents({ limits: { ... } })`.
372376

373-
**Static caps** (applied at `POST /chat` and `POST /invocations` request parsing):
377+
**Static caps** (applied at `POST /chat`, `POST /invocations`, and `POST /responses` request parsing):
374378

375379
| Field | Cap | Why |
376380
|---|---|---|

packages/appkit/src/plugins/agents/agents.ts

Lines changed: 223 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
IAppRouter,
1111
Message,
1212
PluginPhase,
13+
ResponseOutputMessage,
1314
ResponseStreamEvent,
1415
Thread,
1516
ToolAnnotations,
@@ -275,7 +276,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
275276
const { agents, defaultAgentName } = await this.buildAgentRegistry();
276277
this.agents = agents;
277278
this.defaultAgentName = defaultAgentName;
278-
this.mountInvocationsRoute();
279+
this.mountInvokeRoutes();
279280
this.printRegistry();
280281
}
281282

@@ -762,15 +763,19 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
762763

763764
// ----------------- Route mounting and handlers ---------------------------
764765

765-
private mountInvocationsRoute() {
766+
/**
767+
* Mount the non-streaming invoke endpoints outside the `/api/<plugin>`
768+
* namespace. `/invocations` and `/responses` are aliases — both run the
769+
* default agent to completion and return a single JSON response. Streaming
770+
* lives on `POST /chat` (mounted in `injectRoutes`).
771+
*/
772+
private mountInvokeRoutes() {
766773
if (!this.context) return;
767-
this.context.addRoute(
768-
"post",
769-
"/invocations",
770-
(req: express.Request, res: express.Response) => {
771-
this._handleInvocations(req, res);
772-
},
773-
);
774+
const handler = (req: express.Request, res: express.Response) => {
775+
this._handleInvoke(req, res);
776+
};
777+
this.context.addRoute("post", "/invocations", handler);
778+
this.context.addRoute("post", "/responses", handler);
774779
}
775780

776781
injectRoutes(router: IAppRouter) {
@@ -896,10 +901,41 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
896901
return this._streamAgent(req, res, registered, thread, userId);
897902
}
898903

899-
private async _handleInvocations(
900-
req: express.Request,
901-
res: express.Response,
902-
) {
904+
/**
905+
* Returns the names of tools in `registered.toolIndex` whose annotations
906+
* would trip the approval gate. Used by the non-streaming invoke path
907+
* (`/invocations`, `/responses`) to fail-fast before the adapter runs:
908+
* those endpoints have no channel back to the user mid-call, so an agent
909+
* whose tool surface includes approval-gated tools cannot be served.
910+
*
911+
* Returns an empty list when the plugin is configured with
912+
* `approval.requireForDestructive: false` — operators who explicitly
913+
* disabled HITL keep the invoke surface unrestricted.
914+
*/
915+
private collectApprovalRequiredToolNames(
916+
registered: RegisteredAgent,
917+
): string[] {
918+
if (!this.resolvedApprovalPolicy.requireForDestructive) return [];
919+
const names: string[] = [];
920+
for (const entry of registered.toolIndex.values()) {
921+
if (requiresApproval(entry.def.annotations)) {
922+
names.push(entry.def.name);
923+
}
924+
}
925+
return names;
926+
}
927+
928+
/**
929+
* Shared handler for `POST /invocations` and `POST /responses`. Runs the
930+
* default agent to completion and returns a single JSON response in the
931+
* OpenAI Responses non-streaming shape. The two endpoints are aliases —
932+
* streaming clients must use `POST /chat`.
933+
*
934+
* Rejects with HTTP 400 when the resolved agent has any approval-gated
935+
* tool in scope: HITL requires a live SSE channel, which this surface
936+
* does not provide. See {@link collectApprovalRequiredToolNames}.
937+
*/
938+
private async _handleInvoke(req: express.Request, res: express.Response) {
903939
const parsed = invocationsRequestSchema.safeParse(req.body);
904940
if (!parsed.success) {
905941
res.status(400).json({
@@ -914,6 +950,24 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
914950
res.status(400).json({ error: "No agent registered" });
915951
return;
916952
}
953+
954+
// Pre-flight HITL gate. The non-streaming invoke surface has no way to
955+
// surface an approval prompt back to the caller and no way to receive
956+
// a decision mid-run, so we reject up-front instead of having the
957+
// approval gate auto-deny mid-stream (which would leave the caller
958+
// with a confusing "denied by user" tool result in the final text).
959+
const approvalGated = this.collectApprovalRequiredToolNames(registered);
960+
if (approvalGated.length > 0) {
961+
res.status(400).json({
962+
error:
963+
`Agent '${registered.name}' exposes ${approvalGated.length} approval-gated tool(s) ` +
964+
`(${approvalGated.join(", ")}); /invocations and /responses are non-streaming and ` +
965+
"cannot run HITL. Use POST /chat for HITL-capable agents, or disable approval via " +
966+
"agents({ approval: { requireForDestructive: false } }).",
967+
});
968+
return;
969+
}
970+
917971
const userId = this.resolveUserId(req);
918972

919973
// Match the rate-limit gate on /chat. Without this, a client can bypass
@@ -962,7 +1016,7 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
9621016
return;
9631017
}
9641018

965-
return this._streamAgent(req, res, registered, thread, userId);
1019+
return this._runAgentNonStreaming(req, res, registered, thread, userId);
9661020
}
9671021

9681022
private async _streamAgent(
@@ -1123,6 +1177,159 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
11231177
);
11241178
}
11251179

1180+
/**
1181+
* Non-streaming counterpart to {@link _streamAgent} used by `/invocations`
1182+
* and `/responses`. Drives the adapter to completion, persists the
1183+
* assistant turn to the thread store, and returns a single JSON envelope
1184+
* shaped like the OpenAI Responses non-streaming API.
1185+
*
1186+
* No `EventChannel`, no `AgentEventTranslator`, no SSE — the caller is
1187+
* waiting on one HTTP response. The approval gate is force-disabled in
1188+
* the per-run state as defense-in-depth: `_handleInvoke` already rejects
1189+
* up-front if any tool in scope would require approval, but pinning
1190+
* `requireForDestructive: false` here means a tool that somehow slips
1191+
* past the precheck (e.g. annotations mutated at runtime) still won't
1192+
* stall the request waiting for an approval prompt that no one can
1193+
* answer.
1194+
*
1195+
* The `RunState` shape is otherwise unchanged so {@link dispatchToolCall}
1196+
* — including sub-agent recursion via {@link runSubAgent} — keeps the
1197+
* same tool-call budget, abort signal, and timeout enforcement as the
1198+
* streaming path. A still-typed translator is constructed but only
1199+
* consulted for `finalize()` so any in-flight `approval_pending` event
1200+
* synthesis (which would have been a coding bug given the precheck) is
1201+
* a dropped no-op instead of a runtime crash.
1202+
*/
1203+
private async _runAgentNonStreaming(
1204+
req: express.Request,
1205+
res: express.Response,
1206+
registered: RegisteredAgent,
1207+
thread: Thread,
1208+
userId: string,
1209+
): Promise<void> {
1210+
const abortController = new AbortController();
1211+
const signal = abortController.signal;
1212+
const requestId = randomUUID();
1213+
this.trackStream(requestId, userId, abortController);
1214+
1215+
const tools = Array.from(registered.toolIndex.values()).map((e) => e.def);
1216+
const limits = this.resolvedLimits;
1217+
1218+
const runState: RunState = {
1219+
req,
1220+
userId,
1221+
requestId,
1222+
abortController,
1223+
signal,
1224+
// Force approval off for the non-streaming invoke surface. The
1225+
// precheck in `_handleInvoke` already guarantees no approval-gated
1226+
// tool is reachable; this is belt-and-braces.
1227+
approvalPolicy: { requireForDestructive: false, timeoutMs: 0 },
1228+
limits,
1229+
translator: new AgentEventTranslator(),
1230+
outboundEvents: new EventChannel<ResponseStreamEvent>(),
1231+
toolCallsUsed: { count: 0 },
1232+
};
1233+
1234+
const executeTool = (name: string, args: unknown): Promise<unknown> =>
1235+
this.dispatchToolCall(runState, registered.toolIndex, name, args, 0);
1236+
1237+
let fullContent = "";
1238+
try {
1239+
const pluginNames = this.context
1240+
? this.context
1241+
.getPluginNames()
1242+
.filter((n) => n !== this.name && n !== "server")
1243+
: [];
1244+
const fullPrompt = composePromptForAgent(
1245+
registered,
1246+
this.config.baseSystemPrompt,
1247+
{
1248+
agentName: registered.name,
1249+
pluginNames,
1250+
toolNames: tools.map((t) => t.name),
1251+
},
1252+
);
1253+
1254+
const messagesWithSystem: Message[] = [
1255+
{
1256+
id: "system",
1257+
role: "system",
1258+
content: fullPrompt,
1259+
createdAt: new Date(),
1260+
},
1261+
...thread.messages,
1262+
];
1263+
1264+
const stream = registered.adapter.run(
1265+
{
1266+
messages: messagesWithSystem,
1267+
tools,
1268+
threadId: thread.id,
1269+
signal,
1270+
},
1271+
{ executeTool, signal },
1272+
);
1273+
1274+
fullContent = await consumeAdapterStream(stream, { signal });
1275+
1276+
if (fullContent) {
1277+
await this.threadStore.addMessage(thread.id, userId, {
1278+
id: randomUUID(),
1279+
role: "assistant",
1280+
content: fullContent,
1281+
createdAt: new Date(),
1282+
});
1283+
}
1284+
} catch (error) {
1285+
if (signal.aborted) {
1286+
res.status(499).json({ error: "Request aborted" });
1287+
return;
1288+
}
1289+
logger.error("Agent invoke error: %O", error);
1290+
const message =
1291+
process.env.NODE_ENV === "production"
1292+
? "Internal server error"
1293+
: error instanceof Error
1294+
? error.message
1295+
: String(error);
1296+
res.status(500).json({ error: message });
1297+
return;
1298+
} finally {
1299+
this.approvalGate.abortStream(requestId);
1300+
this.untrackStream(requestId);
1301+
if (registered.ephemeral) {
1302+
try {
1303+
await this.threadStore.delete(thread.id, userId);
1304+
} catch (err) {
1305+
logger.warn(
1306+
"Failed to delete ephemeral thread %s: %O",
1307+
thread.id,
1308+
err,
1309+
);
1310+
}
1311+
}
1312+
}
1313+
1314+
const responseId = `resp_${randomUUID()}`;
1315+
const messageId = `msg_${randomUUID()}`;
1316+
const message: ResponseOutputMessage = {
1317+
type: "message",
1318+
id: messageId,
1319+
status: "completed",
1320+
role: "assistant",
1321+
content: [{ type: "output_text", text: fullContent }],
1322+
};
1323+
res.json({
1324+
id: responseId,
1325+
object: "response",
1326+
created_at: Math.floor(Date.now() / 1000),
1327+
status: "completed",
1328+
thread_id: thread.id,
1329+
output: [message],
1330+
});
1331+
}
1332+
11261333
/**
11271334
* Dispatch a single tool call from either the top-level adapter or a
11281335
* sub-agent. Centralising this in one method is what makes the budget
@@ -1528,7 +1735,8 @@ function composePromptForAgent(
15281735
/**
15291736
* Plugin factory for the agents plugin. Reads `config/agents/*.md` by default,
15301737
* resolves toolkits/tools from registered plugins, exposes `appkit.agents.*`
1531-
* runtime API and mounts `/invocations`.
1738+
* runtime API and mounts `POST /invocations` and `POST /responses` (aliased
1739+
* non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable).
15321740
*
15331741
* @example
15341742
* ```ts

packages/appkit/src/plugins/agents/schemas.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ export const invocationsRequestSchema = z.object({
5858
`input array exceeds the ${MAX_INVOCATIONS_INPUT_ITEMS}-item limit`,
5959
),
6060
]),
61-
stream: z.boolean().optional().default(true),
6261
model: z.string().optional(),
6362
});
6463

packages/appkit/src/plugins/agents/tests/dos-limits.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,12 +258,12 @@ describe("POST /chat — per-user concurrent-stream limit", () => {
258258
const { res, setHeader, json } = mockRes();
259259
await (
260260
plugin as unknown as {
261-
_handleInvocations: (
261+
_handleInvoke: (
262262
r: express.Request,
263263
w: express.Response,
264264
) => Promise<void>;
265265
}
266-
)._handleInvocations(mockReq({ input: "hi" }, "alice"), res);
266+
)._handleInvoke(mockReq({ input: "hi" }, "alice"), res);
267267

268268
expect(res.status).toHaveBeenCalledWith(429);
269269
expect(setHeader).toHaveBeenCalledWith("Retry-After", "5");

0 commit comments

Comments
 (0)