Skip to content
Open
60 changes: 60 additions & 0 deletions packages/appkit/src/plugins/agents/active-stream-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Tracks active SSE streams and a per-user stream count for the agents plugin's
* concurrency limit. The count is kept in sync with the stream map on every
* {@link track}/{@link untrack} so the limit check is O(1) instead of O(n) over
* all active streams on every request. `track` and `untrack` are the only
* writers, which is what keeps the counter from drifting from the map.
*
* Distinct from the SSE-layer `StreamRegistry` in `src/stream/` (which buffers
* events per connection for reconnection replay) — this only counts streams.
*/
export class ActiveStreamTracker {
private readonly activeStreams = new Map<
string,
{ controller: AbortController; userId: string }
>();
private readonly userStreamCounts = new Map<string, number>();

/** Count active streams owned by a given user. O(1). */
count(userId: string): number {
return this.userStreamCounts.get(userId) ?? 0;
}

/** Total active streams across all users. */
get size(): number {
return this.activeStreams.size;
}

/** Look up an active stream by request id. */
get(
requestId: string,
): { controller: AbortController; userId: string } | undefined {
return this.activeStreams.get(requestId);
}

/** Register a stream for `userId` and bump the per-user counter. */
track(requestId: string, userId: string, controller: AbortController): void {
this.activeStreams.set(requestId, { controller, userId });
this.userStreamCounts.set(
userId,
(this.userStreamCounts.get(userId) ?? 0) + 1,
);
}

/**
* Remove a stream and decrement the per-user counter. Idempotent — calling
* twice for the same `requestId` is a no-op. Drops the counter key entirely
* when it reaches zero so the map can't grow unbounded across many users.
*/
untrack(requestId: string): void {
const entry = this.activeStreams.get(requestId);
if (!entry) return;
this.activeStreams.delete(requestId);
const next = (this.userStreamCounts.get(entry.userId) ?? 0) - 1;
if (next <= 0) {
this.userStreamCounts.delete(entry.userId);
} else {
this.userStreamCounts.set(entry.userId, next);
}
}
}
97 changes: 97 additions & 0 deletions packages/appkit/src/plugins/agents/adapter-extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { AgentAdapter } from "shared";

import {
SUPERVISOR_EXTENSION_KEY,
type SupervisorTool,
} from "../../agents/supervisor-api";
import type { ResolvedToolEntry } from "../../core/agent/types";
import { createLogger } from "../../logging/logger";

const logger = createLogger("agents");

/**
* Pulls the LLM-readable description off any {@link SupervisorTool} kind.
* Used to populate the synthetic placeholder `def.description` on
* hosted-supervisor tool-index entries.
*/
export function supervisorToolDescription(spec: SupervisorTool): string {
switch (spec.type) {
case "genie_space":
return spec.genie_space.description;
case "uc_function":
return spec.uc_function.description;
case "knowledge_assistant":
return spec.knowledge_assistant.description;
case "app":
return spec.app.description;
case "uc_connection":
return spec.uc_connection.description;
}
}

/**
* Builds the `AgentInput.extensions` payload from a tool index, aggregating
* the hosted-supervisor specs under {@link SUPERVISOR_EXTENSION_KEY}. Returns
* `undefined` when there are no adapter-side hosted tools so the field stays
* absent on the wire — adapters that don't read extensions never see it.
*/
export function buildAdapterExtensions(
toolIndex: Map<string, ResolvedToolEntry>,
): Readonly<Record<string, unknown>> | undefined {
const supervisorSpecs: SupervisorTool[] = [];
for (const entry of toolIndex.values()) {
if (entry.source === "hosted-supervisor") {
supervisorSpecs.push(entry.spec);
}
}
if (supervisorSpecs.length === 0) return undefined;
return {
[SUPERVISOR_EXTENSION_KEY]: { hostedTools: supervisorSpecs },
};
}

/**
* Compares the adapter's declared capabilities against the tool index and
* logs a warning when the agent's tool declarations would be silently
* dropped at runtime. Warn-not-throw: misconfiguration is loud enough to
* notice without taking the whole app down.
*/
export function warnOnCapabilityMismatch(
agentName: string,
adapter: AgentAdapter,
toolIndex: Map<string, ResolvedToolEntry>,
): void {
const accepted = new Set(adapter.acceptsExtensions ?? []);

const hostedSupervisorKeys: string[] = [];
const inputToolKeys: string[] = [];
for (const [key, entry] of toolIndex) {
if (entry.source === "hosted-supervisor") {
hostedSupervisorKeys.push(key);
} else {
inputToolKeys.push(key);
}
}

if (
hostedSupervisorKeys.length > 0 &&
!accepted.has(SUPERVISOR_EXTENSION_KEY)
) {
logger.warn(
`Agent '${agentName}' declares hosted-supervisor tools (${hostedSupervisorKeys.join(", ")}) ` +
"but its model adapter does not accept the 'databricks.supervisor' extension. " +
"These tools will not reach the model. Pair them with `DatabricksAdapter.fromSupervisorApi(...)`, or remove them.",
);
}

// `consumesInputTools` defaults to true. Only warn when an adapter
// explicitly opts out (`false`) and an input tool would be silently
// ignored.
if (adapter.consumesInputTools === false && inputToolKeys.length > 0) {
logger.warn(
`Agent '${agentName}' declares function tools / sub-agents / MCP tools (${inputToolKeys.join(", ")}) ` +
"but its model adapter does not consume input.tools (Supervisor API owns its own tool loop). " +
"These tools will not be exposed to the model. See docs/plugins/agents.md.",
);
}
}
Loading