feat(agent-as-tool): configure sub-agents as callable tools#197
feat(agent-as-tool): configure sub-agents as callable tools#197unrealandychan wants to merge 2 commits into
Conversation
Closes BerriAI#193. Schema: - prisma/schema.prisma: add `agent_tools` JSON field to Agent - prisma/migrations/0006_agent_tools: ALTER TABLE migration Types / Validation: - src/server/types.ts: AgentToolSpec interface + AgentToolSpecSchema zod schema; agent_tools field on CreateAgentBody, UpdateAgentBody, Agent interface; toApiAgent() maps the new field - src/server/auth/agent-token.ts: extend AgentScope to include 'tool' API: - PATCH /agents/:id — now accepts agent_tools (UpdateAgentBody) - GET /agents/:id/tools — list sub-agent tools - PUT /agents/:id/tools — replace tools list (validates ref'd agents exist, guards against self-reference) - POST /agents/:id/tools/invoke — invoke a sub-agent synchronously; cold-starts a Session, sends the input, returns the response text. Auth: per-agent token (scope=tool) or master key. Guard: sub_agent_id must be in the caller's agent_tools list. Runtime injection: - src/server/k8s.ts: inject AGENT_TOOLS_JSON into every sandbox pod so harnesses can register sub-agents as first-class tools UI: - src/components/agent-as-tool-picker.tsx: new component — add/remove/edit sub-agent tools with an agent selector, tool name input, and description textarea. Validates alphanumeric tool names, caps at 10 entries. - src/lib/api.ts: add agent_tools to AgentRow + UpdateAgentRequest - src/app/agents/[id]/page.tsx: wire AgentAsToolPicker into agent settings with optimistic updates
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds “sub-agent tools” so an orchestrator agent can delegate work to configured sub-agents via a persisted agent_tools list, exposed in the UI/API, and injected into the harness as AGENT_TOOLS_JSON.
Changes:
- Add
agent_toolsto agent create/update schemas, API responses, and Kubernetes container env (AGENT_TOOLS_JSON). - Persist
agent_toolsin Prisma (schema + migration) and add managed API routes to set/list tools and invoke a sub-agent. - Add UI picker to configure sub-agent tools on the agent detail page.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/server/types.ts | Adds AgentToolSpec, validation, request/response wiring, and toApiAgent mapping for agent_tools. |
| src/server/k8s.ts | Injects AGENT_TOOLS_JSON into sandbox container environment. |
| src/server/auth/agent-token.ts | Extends agent token scope to include "tool". |
| src/lib/api.ts | Extends client-side types to include agent_tools in agent row/update request. |
| src/components/agent-as-tool-picker.tsx | New UI component to pick/configure sub-agent tools. |
| src/app/api/v1/managed_agents/agents/[agent_id]/tools/route.ts | New API route to list/replace an agent’s agent_tools. |
| src/app/api/v1/managed_agents/agents/[agent_id]/tools/invoke/route.ts | New API route to invoke a sub-agent as a tool with token scope "tool". |
| src/app/api/v1/managed_agents/agents/[agent_id]/route.ts | Allows PATCHing agent_tools through the existing agent update route. |
| src/app/agents/[id]/page.tsx | Adds the picker UI and saves agent_tools via updateAgent. |
| prisma/schema.prisma | Adds agent_tools JSON column to Agent. |
| prisma/migrations/0006_agent_tools/migration.sql | Migration adding agent_tools JSONB column with default []. |
| package.json | Bumps TypeScript version. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| const available = agents.filter( | ||
| (a) => !value.some((t, _idx) => t.agent_id === a.agent_id) || true, |
| <AgentAsToolPicker | ||
| self_agent_id={agent.id} | ||
| value={agent.agent_tools ?? []} | ||
| disabled={agentToolsSaving} | ||
| onChange={(tools) => { | ||
| // Optimistic update | ||
| setAgent({ ...agent, agent_tools: tools }); | ||
| void handleAgentToolsSave(tools); | ||
| }} | ||
| /> |
| * /api/v1/managed_agents/agents/{agent_id}/tools | ||
| * | ||
| * GET — list sub-agent tools configured on this agent. | ||
| * POST — replace the agent's sub-agent tools list (idempotent upsert). |
| const AgentToolSpecSchema = z.object({ | ||
| agent_id: z.string().uuid(), | ||
| name: z | ||
| .string() | ||
| .min(1) | ||
| .max(64) | ||
| .regex(/^[a-zA-Z0-9_-]+$/, "name must be alphanumeric + underscores/hyphens"), | ||
| description: z.string().min(1).max(256), | ||
| }); |
| const TIMEOUT_MS = | ||
| parseInt(process.env.AGENT_TOOL_INVOKE_TIMEOUT_MS ?? "120000", 10); |
| import { harnessCreateSession, harnessSendMessage } from "@/server/harness"; | ||
| import { httpError, type AgentRow } from "@/server/types"; | ||
| import { wrap } from "@/server/route-helpers"; | ||
| import { env } from "@/server/env"; |
| agent_tools: Array.isArray((row as Record<string, unknown>).agent_tools) | ||
| ? ((row as Record<string, unknown>).agent_tools as AgentToolSpec[]) | ||
| : [], |
Greptile SummaryThis PR introduces the "agent-as-tool" feature: a new database column (
Confidence Score: 3/5Not safe to merge: the agent-as-tool flow is non-functional end-to-end and the picker UI errors on every Add click. Three independent paths through the new feature each have a present defect: pods are minted tokens with only the memory scope so the invoke endpoint rejects every harness-initiated call with 401; the picker addEntry fires a save with blank fields the server rejects on every click; and the PATCH route bypasses the existence and self-reference checks enforced by the dedicated PUT /tools route. src/server/k8s.ts (agentScopes must include tool), src/components/agent-as-tool-picker.tsx (addEntry fires saves with invalid data), src/app/api/v1/managed_agents/agents/[agent_id]/route.ts (PATCH missing validation), src/app/api/v1/managed_agents/agents/[agent_id]/tools/invoke/route.ts (double timeout, no stopTask on success)
|
| Filename | Overview |
|---|---|
| src/app/api/v1/managed_agents/agents/[agent_id]/tools/invoke/route.ts | New invoke endpoint; contains the double-timeout bug and does not call stopTask after a successful run. |
| src/components/agent-as-tool-picker.tsx | Fires onChange with blank entries immediately on Add click, causing guaranteed 400 errors from the server on every interaction. |
| src/server/k8s.ts | Adds AGENT_TOOLS_JSON injection but agentScopes stays hardcoded to ["memory"] — pods cannot authenticate to the invoke endpoint. |
| src/app/agents/[id]/page.tsx | Wires AgentAsToolPicker with fire-and-forget save; correct pattern but amplifies the picker's premature-save bug. |
| src/app/api/v1/managed_agents/agents/[agent_id]/tools/route.ts | GET/PUT endpoints correct; AgentToolSpecSchema duplicated from types.ts. |
| src/app/api/v1/managed_agents/agents/[agent_id]/route.ts | PATCH handler missing existence and self-reference validation that PUT /tools enforces. |
| src/server/types.ts | Adds AgentToolSpec and AgentToolSpecSchema; schema duplicated in tools/route.ts. |
| src/server/auth/agent-token.ts | Adds "tool" to AgentScope union type; straightforward change. |
| prisma/migrations/0006_agent_tools/migration.sql | Correct migration with IF NOT EXISTS guard and purpose comment. |
| prisma/schema.prisma | Adds agent_tools Json field matching the migration. |
Reviews (2): Last reviewed commit: "Update src/components/agent-as-tool-pick..." | Re-trigger Greptile
| if (body.agent_tools !== undefined) { | ||
| data.agent_tools = body.agent_tools as unknown as Prisma.InputJsonValue; | ||
| } |
There was a problem hiding this comment.
PATCH route skips validation that the PUT
/tools route enforces
The PUT /tools endpoint validates (a) each referenced agent_id exists in the database and (b) the agent cannot reference itself. Neither check is present here. Since the UI's handleAgentToolsSave calls updateAgent which hits this PATCH route, a user can persist a self-referential loop (agent A lists itself as a tool) or store non-existent agent_id references — both cases the invoke endpoint will mis-handle at runtime.
| sandboxUrl = await waitRunningGetUrl(task_arn, subAgent as AgentRow, TIMEOUT_MS); | ||
|
|
||
| await prisma.session.update({ | ||
| where: { session_id }, | ||
| data: { sandbox_url: sandboxUrl, phase: "waiting_http" }, | ||
| }); | ||
|
|
||
| await waitHttpReady(sandboxUrl, TIMEOUT_MS); |
There was a problem hiding this comment.
Each wait phase consumes its own full
TIMEOUT_MS budget
waitRunningGetUrl and waitHttpReady are each given the full TIMEOUT_MS value independently. In the worst case the bring-up phase can block for up to 2 × TIMEOUT_MS (240 s with the default), which contradicts the stated "Timeout: 120 s" contract at the top of the file.
| sandboxUrl = await waitRunningGetUrl(task_arn, subAgent as AgentRow, TIMEOUT_MS); | |
| await prisma.session.update({ | |
| where: { session_id }, | |
| data: { sandbox_url: sandboxUrl, phase: "waiting_http" }, | |
| }); | |
| await waitHttpReady(sandboxUrl, TIMEOUT_MS); | |
| const deadline = Date.now() + TIMEOUT_MS; | |
| sandboxUrl = await waitRunningGetUrl(task_arn, subAgent as AgentRow, deadline - Date.now()); | |
| await prisma.session.update({ | |
| where: { session_id }, | |
| data: { sandbox_url: sandboxUrl, phase: "waiting_http" }, | |
| }); | |
| await waitHttpReady(sandboxUrl, deadline - Date.now()); |
| name: z | ||
| .string() | ||
| .min(1) | ||
| .max(64) | ||
| .regex(/^[a-zA-Z0-9_-]+$/, "name must be alphanumeric + underscores/hyphens"), | ||
| description: z.string().min(1).max(256), | ||
| }); | ||
|
|
||
| const PutToolsBody = z.object({ |
There was a problem hiding this comment.
AgentToolSpecSchema is duplicated across two files
An identical AgentToolSpecSchema definition was added here and independently in src/server/types.ts. If the two schemas drift, this copy will silently accept values that the shared layer rejects (or vice-versa). Import the schema from types.ts instead of redeclaring it.
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
|
@greptile review this again |
| function removeEntry(i: number) { | ||
| onChange(value.filter((_, idx) => idx !== i)); | ||
| } | ||
|
|
||
| function updateEntry(i: number, patch: Partial<AgentToolSpec>) { | ||
| onChange(value.map((entry, idx) => (idx === i ? { ...entry, ...patch } : entry))); | ||
| } |
There was a problem hiding this comment.
Blank entry saved to server immediately on "Add" click
addEntry calls onChange with { agent_id: "", name: "", description: "" }. The parent's onChange handler in page.tsx fires handleAgentToolsSave immediately (fire-and-forget), which sends the partial entry to the PATCH endpoint. The server parses with AgentToolSpecSchema, where "" fails both z.string().uuid() and .min(1), so every click on "Add sub-agent tool" results in a 400 error displayed to the user. The same applies on each keystroke while fields are being filled in — saves race against each other, producing a flickering error state until every field on every entry is valid simultaneously.
Closes #193.