diff --git a/docs/design/gateway-tool-rendering/README.md b/docs/design/gateway-tool-rendering/README.md new file mode 100644 index 0000000000..97b80dfc22 --- /dev/null +++ b/docs/design/gateway-tool-rendering/README.md @@ -0,0 +1,48 @@ +# Canonical gateway tool rendering in the playground + +Design workspace for fixing the playground read path so it renders connected-app +(gateway) tools written in the **canonical persisted shape**, not just the legacy +function-name encoding the UI itself writes. + +This is a **frontend-only, read-path** design. No backend or SDK wire changes: both +tool encodings are already equivalent server-side (the SDK compat layer converts +legacy → canonical, and the resolve path enriches description + schema from the live +catalog at run time). Per the standing rule, we normalize on the frontend. + +## The one-line symptom + +A builder agent authored three Slack tools in the canonical shape +(`{"type":"gateway","provider":"composio","integration":"slack","action":"OPEN_DM",…}`). +The playground showed all three as **"gateway · built-in"** rows under a BUILT-IN +header, each opening a raw JSON editor — instead of "Connected app tool" rows grouped +under a Slack card with a humanized action name. + +## Files + +| File | What it holds | +| --- | --- | +| [context.md](context.md) | Why this exists, the symptom, goals, non-goals, the standing constraints. | +| [research.md](research.md) | Verified findings: the two encodings, every consumer that keys off the legacy slug, and exactly where the canonical shape falls through. All with file:line citations. | +| [plan.md](plan.md) | The design: one shared shape-detection helper, the read-path changes per consumer, the drill-in decision, the removal/dedupe fix, phasing, and the open questions for Mahmoud. | +| [status.md](status.md) | Current state, decisions taken, and what's blocked on Mahmoud. | + +## Live repro + +`:8280` dev stack — app `019f3d51-1f93-7452-8133-dff2f0d91385`, revision +`019f3d56-90f3-7870-b1c4-bd67f4313e18` ("Support triage") shows the three misrendered +tools. + +## Status at a glance + +Design only, approved with Mahmoud's review round folded in (2026-07-07). Both open +questions are closed (see [plan.md § Decisions](plan.md#decisions-resolved-2026-07-07-mahmoud) +and [context.md § Review round](context.md#review-round--2026-07-07-decider-mahmoud)): + +1. **Drill-in: Option B.** Fetch the catalog action detail and populate the **existing** + gateway view — no new component. An unresolvable tool fails safe to raw JSON with a + warning. This is the only place a canonical tool may look different from a legacy one. +2. **Convergence: deferred.** The drawer keeps writing the legacy shape on add; read-side + canonical support is unaffected. + +The governing product invariant: a connected-app tool looks the same regardless of who +authored it. diff --git a/docs/design/gateway-tool-rendering/context.md b/docs/design/gateway-tool-rendering/context.md new file mode 100644 index 0000000000..554e25292c Binary files /dev/null and b/docs/design/gateway-tool-rendering/context.md differ diff --git a/docs/design/gateway-tool-rendering/plan.md b/docs/design/gateway-tool-rendering/plan.md new file mode 100644 index 0000000000..da31ef80aa --- /dev/null +++ b/docs/design/gateway-tool-rendering/plan.md @@ -0,0 +1,290 @@ +# Plan — canonical gateway tool rendering + +Frontend read-path only. **Product invariant first:** nothing about the tool UI changes for +the user. The playground Tools section looks exactly the same before and after this work, +and an agent-created tool looks exactly like a UI-created one — same row, same grouping, +same drill-in. The only place the two can ever look different is the fail-safe for a tool +that cannot be resolved (Question 2). + +The mechanism is a **simplification**, not a product change. Today two code paths handle the +same concept: one keys off the legacy `function.name` slug, the other never recognizes the +canonical `type:"gateway"` object at all, so it falls through to the built-in fallback. We +collapse the detection into **one shared shape-detection helper** that both encodings resolve +through, so the descriptor, the list grouping, the drill-in, and the drawer all read one +normalized view. Less branching, same output. + +## The shared helper (the keystone) + +Add to `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/toolUtils.ts`. The +legacy branch composes the **shared** string parser `parseGatewayToolSlug` from +`@agenta/shared/utils` (the canonical one; `toolUtils.ts`'s own `parseGatewayFunctionName` +is a duplicate of it — codex finding, folded). + +```ts +export interface ParsedGatewayTool { + provider: string + integration: string + action: string + connection: string + /** Which encoding it was read from — protocol context only; never displayed or + * persisted. */ + encoding: "canonical" | "legacy" + /** Per-tool permission when present (top-level on both shapes). */ + permission?: string +} + +/** Normalize either encoding of a connected-app tool into one view; null if it is not one. */ +export function parseGatewayTool(tool: unknown): ParsedGatewayTool | null + +/** Stable identity for the drawer's added-state (double-add prevention + toggle-off), + * independent of encoding. The four segments joined by a NUL — a connection slug may + * contain a dot, so a dotted key is not collision-safe (codex finding). */ +export function gatewayToolIdentity(view: ParsedGatewayTool): string +``` + +`parseGatewayTool` logic: + +1. If `tool` is an object with `type === "gateway"` → read + `provider` (default `"composio"`), `integration`, `action`, `connection`, + `permission`; `encoding: "canonical"`. +2. Else if `tool.function?.name` parses via the shared `parseGatewayToolSlug` → return that + with `encoding: "legacy"` and `permission` from the top-level `permission` key. +3. Else `null`. + +### Interface-role check (design-interfaces lens) + +- `provider / integration / action / connection` — **routing identity** (data). They name + *which* catalog action on *which* connection. The stable identity key is derived from + exactly these four, matching the server's `provider.integration.action.connection` + reference (`GatewayToolConfig.reference`, `models.py:113`). +- `permission` — **policy**, not identity. It is deliberately excluded from + `gatewayToolIdentity` so two entries for the same action with different permissions + still count as the same tool (you don't add it twice). +- `encoding` — **protocol/encoding context**, not domain data. It exists only so the read + path knows how to match a drawer action back to an existing entry for the added-state. + It must never leak into a persisted value or a display label. + +`parseGatewayTool` is the object-level parser; the shared `parseGatewayToolSlug` +(`@agenta/shared/utils`) stays the string-level primitive it composes for the legacy +branch. + +## Question 1 — Rendering (row descriptor + grouping) + +**The product invariant.** A connected-app tool must render identically no matter which +encoding authored it: same humanized name, same integration logo, same tag, same "Connected +app tool" subtitle, same provider-card grouping. The legacy encoding already renders that +way. This section only widens the detection so the canonical object reaches the **same +branch** and stops falling through to the built-in fallback. The row's appearance does not +change; we change which shapes reach the existing rendering. + +**`describeTool()` (`itemDescriptors.tsx:143`):** replace the gateway detection line +`const gateway = fnName ? parseGatewayFunctionName(fnName) : null` with +`const gateway = parseGatewayTool(t)`. The existing gateway branch (lines 176–192) then +runs unchanged for both shapes: `humanizeActionKey(actionKey)`, `monogram(integration)`, +tag `[integration]`, `typeLabel:"third-party"`, subtitle `Connected app tool · {integration}`. + +- Ordering is already safe: the `type:"reference"` branch is checked first; a + `type:"gateway"` object flows into the gateway branch before the builtin fallback. +- The row's structure is identical for both encodings. The canonical object carries no + `function.description`, so the row's optional secondary description line has no local + text; the humanized action name is the label, exactly as legacy shows it. Question 2's + Option-B catalog fetch supplies the real description in the drill-in, so the two converge + where the description is actually shown. + +**Grouping (`ToolManagementList.tsx:243`):** replace +`parseGatewayFunctionName(toolName(item))` with `parseGatewayTool(item)`. Canonical +entries then group under the **Connected apps** provider card keyed by `gw.integration`, +exactly like legacy. No change to `CollapsibleProviderGroup` / `GatewayGroups`. + +## Question 2 — The drill-in + +**Decision (Mahmoud, 2026-07-07): reuse the existing view; no new product surface.** A +canonical entry must open the **same drill-in** a legacy entry gets. The agent-template +drill-in is the shared `ConfigItemDrawer` whose Form slot is `ToolFormView` and whose JSON +slot is `JsonObjectEditor` (**not** `ToolItemControl` — that is the prompt surface; codex +correction). So the gateway-aware branch lands in **`ToolFormView`**, mirroring its +existing `type:"reference"` → `ReferenceToolFormView` branch — an established extension +point, not new chrome. + +**The layering fix (codex, folded).** Resolvability depends on an async catalog fetch, so it +**cannot** be decided in `itemKinds.editView`, which is a synchronous +`(item) => "form" | "json"`. `editView` therefore only says: **a syntactic gateway tool +opens the Form** (`parseGatewayTool(item)` non-null → `"form"`; `jsonOnly` stays false so +the JSON toggle remains the lossless escape hatch). The mounted gateway body owns the fetch, +the loading state, and the fail-safe. + +**`ToolFormView` gateway branch (pixel-identical, legacy untouched — Mahmoud's constraint +round).** Legacy gateway tools are function tools; they must render byte-for-byte as they do +today. So the existing `ToolFormView` body is extracted verbatim into an inner +`FunctionToolForm` (a code-only move, zero render change), and only a **canonical** +`type:"gateway"` object is routed to a wrapper: + +- `editView` routes a canonical gateway object to the Form (legacy already opens the Form). +- The wrapper `CanonicalGatewayToolForm` runs `useToolActionDetail(integration, action)` + (`useToolActionDetail.ts:29`, cached, `staleTime` 5 min; composio-only today — see note); +- **while pending** → a spinner (never a flash of raw JSON or an empty form); +- **on resolve** → it builds the **exact legacy function shape the add drawer would have + written** — `{type:"function", function:{name: buildGatewayToolSlug(...), description: + catalog.description, parameters: normalize(catalog.schemas.inputs)}}` with `permission` + preserved — and renders `FunctionToolForm` with it. So a canonical tool's drill-in is the + same component, same fields, same layout as a legacy tool's. The synthesized shape is + **display-only**; the on-disk draft stays canonical and nothing is persisted unless the + user edits (read-path only). +- **on terminal failure / action not confirmed** → the fail-safe (below). + +Legacy gateway tools do **not** go through the wrapper; their existing editable param tree +is unchanged. This satisfies the invariant (canonical == legacy in the drill-in) without +altering anything a user sees for legacy tools. + +**Where the description + schema come from: Option B (decided).** The catalog is +authoritative; the fetch supplies the description and the parameters. One cached request, +only while the drawer is open. + +**Note — provider.** `useToolActionDetail` fetches under `provider = "composio"`. Every real +gateway tool is composio today. A non-composio canonical tool would resolve through the +composio fetch and, if absent there, land on the fail-safe. `provider` stays in the parsed +identity for correctness; threading it through the fetch is a documented follow-up. + +### The fail-safe — the one allowed divergence (new design item) + +An agent writes tool configs programmatically. It can write a `type:"gateway"` object that +names an integration / action / connection that does not resolve: a typo, a renamed action, +a connection that no longer exists. When the catalog fetch fails or the action cannot be +confirmed, there is nothing to populate the detail view with. + +**Fail-safe behavior:** `CanonicalGatewayToolForm` shows a **warning banner** ("Couldn't +resolve this tool …") above a **read-only raw-JSON view** of the object (today's raw-JSON +behavior), and the drawer's **JSON toggle** stays the editable escape hatch. This is the +**only** place a gateway tool looks different, and only because the tool itself is broken. +It is a terminal `!isLoading && !action` from the hook (loading shows a spinner first), so +there is no flash of the wrong view while the fetch is in flight. + +Slices and tests: Cases resolve / pending / fail each get a test (see Phasing and Testing). + +## Question 3 — Add (the drawer's only identity responsibility) + +**Decision (Mahmoud, 2026-07-07): the frontend does not deduplicate.** Showing what exists, +including duplicates, is correct. The list renders every tool the config holds. Removal +removes exactly the entry the user selected, nothing more. The frontend's only use of tool +identity is the **add path**: show a tool as already added so the user cannot add it twice. + +So `gatewayToolIdentity` serves exactly three drawer behaviors, all on the add side: + +- **Added-state.** In the drawer, an action + connection that already exists in the config + (in either encoding) shows as selected. Derive `selectedGatewayIds: Set` in + `useAgentTools.ts:130` from the **same** `tools` memo `selectedToolNames` comes from + (`parseGatewayTool(tool)` → `gatewayToolIdentity(view)`), so the two sets never drift — + both are pure derivations of `tools`, neither is independent state (codex finding). The + drawer's `slugFor`/`itemState` (`AgentIntegrationDrawer.tsx:140,155,250`) compare the + chosen action's identity against that set. +- **Double-add prevention.** Because the matched action reads as selected, the drawer blocks + adding it again for the same action + connection. +- **Toggle-off of the matched entry.** Toggling a selected action off removes **exactly one** + identity-matched entry via a new `removeGatewayToolByIdentity(identity)` in `useAgentTools` + (derived from the same snapshot). It removes one deterministic match, **never all** + duplicates (codex finding). If a duplicate remains the action stays selected — which is + correct: showing what exists beats silently sweeping. `handleRemoveToolByName` (which + filters *all* same-name entries and can't match canonical) is left for the row remove + button's existing legacy path. + +`addedCount` (`AgentIntegrationDrawer.tsx:283`) counts these identities so canonical tools +are counted in the drawer's added total. + +**Out of scope:** no display-dedupe — the list shows duplicates as they are. No +removal-dedupe — the row's own remove button removes exactly that row and keeps its current +behavior. Reading canonical is independent of writing it. + +## Question 4 — Convergence (DEFERRED — Mahmoud, 2026-07-07) + +**Decision: do not change the write logic for now.** The drawer keeps writing the legacy +`function.name` shape on add. We do not switch it to write the canonical +`{type:"gateway",…}` object in this work. + +Read-side canonical support is **unaffected**: the read path renders and drills into both +shapes regardless of what the drawer writes, and `parseGatewayFunctionName` stays in both +the read and the write path. Convergence — making the FE-authored shape identical to the +SDK-authored shape by writing canonical on add — stays available as a future cleanup, out +of scope here. When revisited, it would drop the add-time `fetchToolActionDetail` round-trip +and the `agenta_metadata` bookkeeping (the resolver re-enriches anyway), at the cost of its +own QA pass on what the drawer persists. + +## Question 5 — Where the shared helper lives + +`toolUtils.ts` — it already holds `parseGatewayFunctionName`, `GatewayToolParsed`, and the +provider/builtin metadata, and it is already imported by `describeTool`, +`ToolManagementList`, `ToolItemControl`, `ToolFormView`, and `AgentIntegrationDrawer`. +Adding `parseGatewayTool` + `gatewayToolIdentity` there means every consumer imports one +parser from one place. No new module. + +## Phasing + +**Phase 1 — Read-path rendering (the fix Mahmoud hit).** +`parseGatewayTool` + `gatewayToolIdentity` in `toolUtils.ts`; `describeTool` and +`ToolManagementList` switched to it. After this, canonical Slack tools render as "Connected +app tool" rows under a Slack card, identical to legacy. Smallest shippable slice. + +**Phase 2 — Drill-in through the existing view.** `editView` routes a canonical gateway +object to the Form (legacy already does). The existing `ToolFormView` body becomes +`FunctionToolForm` (verbatim, legacy untouched); `CanonicalGatewayToolForm` runs the Option-B +`useToolActionDetail` fetch, synthesizes the legacy function shape, and renders +`FunctionToolForm` with it — a spinner while pending, the **fail-safe** (warning + read-only +JSON) on terminal failure. The async lives in the mounted body, never in the synchronous +`editView`. + +**Phase 3 — Add-path identity.** `selectedGatewayIds` and `removeGatewayToolByIdentity`, both +derived from the same `tools` snapshot, so canonical entries show as already-added, block a +double-add, and toggle off exactly one identity match. No display or removal dedupe. + +**Convergence (Question 4) is deferred** — no write-path change in this work. + +## Out of scope / follow-ups (from the codex review) + +- **Prompt surface** (`ToolSelectorPopover`, `PromptSchemaControl`, `ToolItemControl`) still + keys gateway selection/removal and drill-in off the legacy slug; a canonical tool in a + completion/chat prompt would still misrender there. This PR is the agent Tools section + only. +- **`commitDiff` identity** (`workflow/commitDiff/identity.ts` `agentItemIdentity`) keys + tools by `function.name`, so a canonical gateway tool gets positional identity in the + change-diff / commit-summary layer. Separate surface, pre-existing, deferred. +- **`provider` threading** into `useToolActionDetail` (composio-only today). + +## Testing + +Unit tests belong in `web/packages/agenta-entity-ui/tests/unit/` (package convention). + +- `parseGatewayTool`: canonical object, legacy function-name, dotted/`__` slug variants, + non-gateway function tool, builtin, reference, junk → correct view or `null`; `provider` + defaults to `composio` when absent; `encoding` is set correctly. +- `gatewayToolIdentity`: legacy and canonical for the same action + connection produce the + **same** identity (this is what makes the drawer's added-state match across encodings); + different connection/action differ; `permission` and `encoding` do not affect it; a + connection slug containing a dot does not collide with a different split. +- `describeTool`: canonical `type:"gateway"` → name = humanized action, tag = integration, + subtitle "Connected app tool · …", **not** built-in. +- `ToolManagementList` partition: a canonical entry lands in the provider group, not + `builtins`; and a canonical + a legacy entry for the same integration land in the **same** + provider group. +- `editView`: a syntactic gateway tool (either encoding) → `"form"`, `jsonOnly` false. +- Drill-in body (component test of `GatewayToolFormView`): pending → spinner (no raw-JSON + flash); resolved → identity + description + schema preview + permission; terminal failure + → warning banner (fail-safe), JSON toggle still present. +- Add-path: `selectedGatewayIds` derived from a config holding a canonical tool marks the + matching drawer action selected; `removeGatewayToolByIdentity` removes exactly one match, + leaving a duplicate in place. +- Live check on the `:8280` repro (app `019f3d51-1f93-7452-8133-dff2f0d91385`, revision + `019f3d56-90f3-7870-b1c4-bd67f4313e18`): the three Slack tools render under a Slack card + with humanized names. + +## Decisions (resolved 2026-07-07, Mahmoud) + +Both former open questions are closed. + +1. **Drill-in richness (Q2): Option B.** Fetch the catalog action detail and populate the + **existing** gateway view. Canonical and legacy tools show exactly the same, in the + outside list and in the drill-in. No new component. +2. **Convergence (Q4): deferred.** The drawer keeps writing the legacy shape on add. No + write-path change now; read-side canonical support is unaffected. + +The product invariant governs the whole design: a connected-app tool looks the same +regardless of who authored it. The single exception is the fail-safe for a canonical tool +that cannot be resolved (raw JSON + warning). diff --git a/docs/design/gateway-tool-rendering/research.md b/docs/design/gateway-tool-rendering/research.md new file mode 100644 index 0000000000..a24b746427 --- /dev/null +++ b/docs/design/gateway-tool-rendering/research.md @@ -0,0 +1,159 @@ +# Research + +All citations verified against the working tree on 2026-07-07. Paths are relative to the +repo root. + +## The two encodings + +### Canonical (persisted / SDK-authored) + +`sdks/python/agenta/sdk/agents/tools/models.py:105` + +```python +class GatewayToolConfig(ToolConfigBase): + type: Literal["gateway"] = "gateway" + provider: str = Field(default="composio", min_length=1) + integration: str = Field(min_length=1) + action: str = Field(min_length=1) + connection: str = Field(min_length=1) + name: Optional[str] = Field(default=None, min_length=1) +``` + +No `function` key. `permission` is inherited from `ToolConfigBase`. + +### Legacy (UI-authored) + +`web/…/agentTemplate/AgentIntegrationDrawer.tsx:181` writes an OpenAI-style function tool +whose `function.name` is the gateway slug and stashes routing hints in `agenta_metadata`: + +```ts +onAddTool( + { type: "function", function: { name: slug, description, parameters } }, + { source: "gateway", provider, toolCode, integrationKey, connectionSlug, needsConfig }, +) +``` + +where `slug = tools__{provider}__{integration}__{action}__{connection}` (built by +`buildToolSlug`, mirrors `parseGatewayFunctionName`). + +### They are equivalent server-side + +- `sdks/python/agenta/sdk/agents/tools/compat.py:36` `_parse_gateway_slug` converts a + legacy `tools__…` (or dotted) slug into the canonical dict; `coerce_tool_config` + (`compat.py:62`) short-circuits a `type:"gateway"` object straight to + `parse_tool_config`. Both arrive at the same `GatewayToolConfig`. +- `api/oss/src/core/tools/service.py:432` `_resolve_composio_tool` enriches + **description + input schema** from the live catalog action at run time (`get_action` + → `action.schemas.inputs`). So the FE never needs to persist the schema; the catalog + is authoritative. + +**Conclusion:** the config is correct and runs. The gap is entirely the FE read path. + +## The single legacy parser everything keys off + +`web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/toolUtils.ts:34` + +```ts +export function parseGatewayFunctionName(name: string | undefined): GatewayToolParsed | null { + const parts = (name ?? "").split("__") + if (parts.length !== 5 || parts[0] !== "tools") return null + const [, provider, integration, action, connection] = parts + return { provider, integration, action, connection } +} +``` + +It takes a **string function name**. Every consumer below calls it with +`tool.function.name`, so the canonical object (no `function`) produces `null` everywhere. + +## Where the canonical shape falls through — per consumer + +### 1. Row descriptor — `itemDescriptors.tsx` + +`describeTool()` (`itemDescriptors.tsx:143`): + +- Line 175: `const gateway = fnName ? parseGatewayFunctionName(fnName) : null` — `fnName` + is `tool.function?.name`, undefined for canonical → `gateway = null`. +- Lines 196–210 (builtin fallback): with no `function`, it names the row from + `t.type` → **"gateway"**, tags **["built-in"]**, `typeLabel:"built-in"`, subtitle + "Provider built-in tool". **This is the misrender.** +- The `type:"reference"` branch (line 152) is checked first and shows the pattern to + copy: a discriminator branch **before** the builtin fallback. + +### 2. List grouping — `ToolManagementList.tsx` + +Partition logic (`ToolManagementList.tsx:237`): + +```ts +const gw = parseGatewayFunctionName(toolName(item)) // toolName = item.function.name +if (gw) { /* group under gw.integration */ return } +… +if (!isFunctionTool(item)) { builtins.push(...); return } // canonical lands here +``` + +`toolName(item)` returns `item.function?.name` (`itemDescriptors.tsx:35`) → undefined → +`gw = null`. `isFunctionTool` requires a `function` object (`itemDescriptors.tsx:72`) → +false → canonical is pushed to **`builtins`**, rendered under the flat **"Built-in"** +sub-section (`ToolManagementList.tsx:306`). Confirmed. + +The gateway grouping target already exists: **Connected apps** → `CollapsibleProviderGroup` +keyed by `gw.integration` (`ToolManagementList.tsx:175`, `:243`). + +### 3. Drill-in editor view — `itemKinds.tsx` + `ToolItemControl.tsx` / `ToolFormView.tsx` + +- `itemKinds.tsx:85` + `editView: (item) => (isFunctionTool(item) || isReferenceTool(item) ? "form" : "json")` + — canonical is neither → opens **JSON-only** (`jsonOnly` also true, `itemKinds.tsx:86`). +- `ToolItemControl.tsx:619` `parseGatewayFunctionName(functionName)` → null; + `isGatewayTool` (`:620`) also checks `agenta_metadata.source === "gateway"`, absent on + canonical → **false** → no gateway header, raw JSON body. `inferIsBuiltinTool` + (`ToolItemControl.tsx:92`) returns true for the bare `type:"gateway"` object, so the + header label becomes "Gateway". +- `ToolFormView.tsx:33,153` also keys gateway rendering off + `parseGatewayFunctionName(fn.name)` — canonical never reaches the Form anyway (it's + JSON-only), but the Form's gateway-awareness is legacy-slug-only too. + +### 4. Add / remove / dedupe — `useAgentTools.ts` + `AgentIntegrationDrawer.tsx` + +- `selectedToolNames` (`useAgentTools.ts:130`) = `new Set(tools.map(toolName)…)` — the set + of legacy `function.name` slugs. Canonical tools contribute **nothing** to this set. +- The drawer decides add-vs-remove and the "selected" checkmark from that set: + `AgentIntegrationDrawer.tsx:155` `selectedToolNames.has(slug)`, `:250` `itemState`, + `:285` `addedCount`. A canonical Slack `OPEN_DM` tool therefore reads as **not added** → + the user can add a duplicate, and cannot toggle it off. +- Removal: `onRemoveTool(slug)` → `handleRemoveToolByName` (`useAgentTools.ts:111`) filters + by `toolName(tool) !== name`. Canonical has no `function.name` → never matches → **cannot + be removed** through the drawer toggle. (`handleRemoveBuiltinTool` matches canonical by + `isBuiltinPayloadMatch`, which is how the row's own remove button still works — but the + drawer's action toggle uses the name path.) + +## Catalog action-detail endpoint (available for drill-in enrichment) + +- API: `GET /tools/catalog/providers/{provider}/integrations/{integration}/actions/{action}`. +- FE plumbing already exists: + - `fetchToolActionDetail(provider, integration, action)` — + `web/packages/agenta-entities/src/gatewayTool/api/api.ts:137`. + - `useToolActionDetail(integrationKey, actionKey)` hook (cached, `staleTime` 5 min, + returns `{ action, isLoading, error }`) — + `web/packages/agenta-entities/src/gatewayTool/hooks/useToolActionDetail.ts:29`. + - `action.schemas.inputs` carries the model-facing input schema; `action.description` + the friendly text. +- Integration logo for the header: `gatewayTools.useIntegrationInfo(integrationKey)` is + already wired through `useDrillInUI()` and consumed by `ToolItemControl`'s gateway + header (`ToolItemControl.tsx:330`). + +## Files in scope (read path only) + +All under `web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/`: + +- `toolUtils.ts` — home of the shared parser (add `parseGatewayTool` + identity helper). +- `agentTemplate/itemDescriptors.tsx` — `describeTool` gateway branch. +- `agentTemplate/ToolManagementList.tsx` — partition/grouping. +- `agentTemplate/itemKinds.tsx` — `editView` / `jsonOnly` for gateway. +- `agentTemplate/useAgentTools.ts` — `selectedToolNames`, removal by identity. +- `agentTemplate/AgentIntegrationDrawer.tsx` — compare against normalized identity. +- `ToolItemControl.tsx` and/or a new gateway detail view — the drill-in. +- `ToolFormView.tsx` — only if we route gateway drill-in through it. + +**None** of these are the sibling files carrying uncommitted secret-isolation edits +(`connectionUtils.ts`, `ProviderCredentialsSection.tsx`, `useModelHarness.tsx`, +`CustomProviderForm.tsx`, `ModelNameInput.tsx`). The two change sets are disjoint. diff --git a/docs/design/gateway-tool-rendering/status.md b/docs/design/gateway-tool-rendering/status.md new file mode 100644 index 0000000000..79bd3a24fa --- /dev/null +++ b/docs/design/gateway-tool-rendering/status.md @@ -0,0 +1,89 @@ +# Status + +**State:** Implemented and live-QA'd on `:8280` (PR #5140, lane `docs/gateway-tool-rendering`). +Design approved with Mahmoud's review round, a codex xhigh design review, and Mahmoud's +mid-implementation 1-to-1 constraint round all folded in (2026-07-07) — see +[context.md](context.md). + +## Implemented + +- **Shared helper** `parseGatewayTool` + `gatewayToolIdentity` in `toolUtils.ts` + (`parseGatewayFunctionName` now aliases the shared `parseGatewayToolSlug`). +- **Phase 1 rendering:** `describeTool` + `ToolManagementList` grouping read through + `parseGatewayTool`; canonical tools render identically to legacy. +- **Phase 2 drill-in:** `ToolFormView` body extracted to `FunctionToolForm` (legacy + untouched); `CanonicalGatewayToolForm` resolves the catalog (Option B) and feeds + `FunctionToolForm` the synthesized legacy shape — canonical drill-in is pixel-identical to + legacy. Fail-safe = warning + read-only JSON. `editView`/`jsonOnly` widened for canonical. +- **Phase 3 add-path:** `selectedGatewayIds` + `removeGatewayToolByIdentity` (both derived + from the same `tools` memo); the drawer matches added-state by identity, counts canonical + tools, and toggles off exactly one match. +- **Tests:** `tests/unit/gatewayTool.test.ts` — parser/identity, `describeTool`, `editView` + routing, add-path identity. Package `lint` / `types:check` / `test` (158) / `build` green. + +## Live QA (repro app `019f3d51-1f93-7452-8133-dff2f0d91385`, rev `019f3d56-…`) + +1. **List** — the three canonical Slack tools render under a **Slack** card in **Connected + apps** with humanized names ("Open dm", "Send message", "Retrieve message permalink URL"). + PASS. +2. **Drill-in** — canonical opens the same `ToolFormView` a legacy tool gets: catalog-resolved + PARAMETERS, slug Name, catalog Description, Permission = Allow. The JSON view shows the + **untouched canonical object** (read-path only, no shape mutation). PASS. +3. **Fail-safe** — verified by code review (Opus) and logic; not live-crafted (the Lexical + JSON editor rejects synthetic edits and the package has no jsdom/testing-library render + harness). Low risk (a terminal `!isLoading && !action` → warning + read-only JSON). +4. **Add-path** — the drawer preselected to Slack shows the canonical actions as selected, + footer "3 app tools added"; toggle-off removes exactly one (3→2); re-add restores (2→3, + as a legacy entry — cross-encoding identity match). PASS. +5. **Dark theme** — list and canonical drill-in render correctly in dark. PASS. + +Legacy parity: a re-added legacy OPEN_DM rendered identically to the canonical entries in +both the list and the drawer; legacy code paths are unchanged. + +**Date:** 2026-07-07 +**Session:** https://claude.ai/code/session_01EcGku1uKvh1Yo48ZU2xN5e +**Branch / PR:** `docs/gateway-tool-rendering` → draft PR #5140 against `big-agents`. + +## What's decided + +- **Root cause confirmed** (all citations verified, see [research.md](research.md)): every + FE consumer keys connected-app tools off the legacy `function.name` slug via + `parseGatewayFunctionName`. The canonical `type:"gateway"` object has no `function`, so + it falls through to the built-in fallback — misrendered as "gateway · built-in" under + the BUILT-IN header with a raw JSON drill-in. +- **Fix shape:** one shared `parseGatewayTool` + `gatewayToolIdentity` helper in + `toolUtils.ts` that normalizes both encodings; every consumer reads through it. +- **Frontend read-path only.** No backend, no SDK wire changes. Both encodings are already + equivalent server-side. +- **Disjoint from the uncommitted secret-isolation edits** — none of the in-scope files + overlap with `connectionUtils.ts` / `ProviderCredentialsSection.tsx` / + `useModelHarness.tsx` etc. +- **Phasing:** Phase 1 (rendering + grouping) fixes the reported symptom and ships alone; + Phase 2 (drill-in through the existing view + fail-safe) and Phase 3 (add-path identity) + follow. Convergence (write canonical) is deferred, not phased. + +## Review round folded in (2026-07-07, Mahmoud) + +Five decisions from PR #5140, now recorded in [context.md](context.md) and reflected in +[plan.md](plan.md): + +1. **Product invariant leads.** The tool UI looks identical before/after and across + authoring sources; the shared parser is a simplification, not a product change. +2. **Drill-in = Option B (open question #1 CLOSED).** Fetch catalog detail and populate the + existing view. Same appearance for UI-created and agent-created tools, in the list and the + drill-in. +3. **No frontend dedupe.** Identity serves only the drawer's add path (added-state, + double-add prevention, toggle-off of the matched entry). +4. **Reuse the existing drill-in view; no new component.** Plus a new fail-safe: an + unresolvable canonical tool falls back to raw JSON with a warning. +5. **Convergence deferred (open question #2 CLOSED).** The drawer keeps writing the legacy + shape on add; read-side canonical support is unaffected. + +## Next actions (on Mahmoud's go) + +- Implement Phase 1 behind the shared helper; add the `toolUtils` unit tests. +- Widen `itemKinds` routing so resolvable canonical tools open the existing gateway view via + the Option-B fetch; add the fail-safe (raw JSON + warning) with its own test. +- Add identity-based `selectedGatewayIds` for the drawer's added-state / double-add / + toggle-off. No dedupe. +- Verify on the `:8280` repro revision. diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx index 273b381bbc..2a2c46eb61 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx @@ -311,6 +311,8 @@ export function AgentTemplateControl({ handleRemoveToolByName, handleRemoveBuiltinTool, selectedToolNames, + selectedGatewayIds, + removeGatewayToolByIdentity, referenceableWorkflows, } = useAgentTools({config, onChange, configRef, openCreate, workflowReference}) @@ -920,8 +922,8 @@ export function AgentTemplateControl({ setIntegrationDefaultKey(undefined) }} onAddTool={handleAddTool} - onRemoveTool={handleRemoveToolByName} - selectedToolNames={selectedToolNames} + onRemoveToolByIdentity={removeGatewayToolByIdentity} + selectedGatewayIds={selectedGatewayIds} defaultIntegrationKey={integrationDefaultKey} /> )} diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ToolFormView.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ToolFormView.tsx index b712214a4d..7176072170 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ToolFormView.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/ToolFormView.tsx @@ -11,10 +11,12 @@ * Built to match the sibling drawers (WorkflowReferenceSelector, trigger drawers): 240px rail with a * right border, independent scroll, shared `RowRemoveButton`, semantic `--ag-color*` tokens (dark-safe). */ -import {useState} from "react" +import {useMemo, useState} from "react" -import {Code} from "@phosphor-icons/react" -import {Input, Select, Switch} from "antd" +import {useToolActionDetail, type ToolCatalogActionDetails} from "@agenta/entities/gatewayTool" +import {buildGatewayToolSlug, safeStringify} from "@agenta/shared/utils" +import {Code, WarningCircle} from "@phosphor-icons/react" +import {Input, Select, Spin, Switch} from "antd" import {RailField} from "../../drawers/shared/RailField" @@ -30,7 +32,7 @@ import { type Seg, } from "./agentTemplate/schemaPaths" import {ReferenceToolFormView} from "./ReferenceToolFormView" -import {parseGatewayFunctionName} from "./toolUtils" +import {parseGatewayFunctionName, parseGatewayTool, type ParsedGatewayTool} from "./toolUtils" export interface ToolFormViewProps { value: Record @@ -182,6 +184,91 @@ function ToolBasics({ ) } +/** Normalize a catalog input schema into the object schema the form/runner expect (mirrors the + * add drawer's normalizeParameters, so a resolved canonical tool matches a UI-added one). */ +function normalizeParameters(inputs: unknown): Record { + if (!isRecord(inputs)) { + return {type: "object", properties: {}, required: [], additionalProperties: false} + } + const schema = {...(inputs as Record)} + if (schema.type !== "object") schema.type = "object" + if (!isRecord(schema.properties)) schema.properties = {} + if (!Array.isArray(schema.required)) schema.required = [] + if (typeof schema.additionalProperties !== "boolean") schema.additionalProperties = false + return schema +} + +/** + * The canonical `{type:"gateway",…}` object persists no name/description/schema (the catalog is + * authoritative; the runner re-enriches at run time). To render its drill-in **pixel-identical** to + * a legacy UI-added gateway tool, we fetch the catalog detail and feed {@link FunctionToolForm} the + * exact legacy function shape the drawer would have written. Nothing is persisted unless the user + * edits. Fail-safe: an action/connection that can't be resolved falls back to today's raw-JSON view + * plus a warning (the drawer's JSON toggle stays the editable escape hatch). + */ +function CanonicalGatewayToolForm({ + value, + view, + onChange, + disabled, +}: { + value: Record + view: ParsedGatewayTool + onChange: (next: Record) => void + disabled?: boolean +}) { + const {action, isLoading} = useToolActionDetail(view.integration, view.action) + const resolved = !isLoading && !!action + const legacyValue = useMemo(() => { + if (!resolved || !action) return null + const details = "schemas" in action ? (action as ToolCatalogActionDetails) : null + return { + type: "function", + function: { + name: buildGatewayToolSlug( + view.provider, + view.integration, + view.action, + view.connection, + ), + description: action.description || action.name || action.key || "", + parameters: normalizeParameters(details?.schemas?.inputs), + }, + ...(view.permission ? {permission: view.permission} : {}), + } as Record + }, [resolved, action, view]) + + if (isLoading) { + return ( +
+ +
+ ) + } + if (!legacyValue) { + // Fail-safe: the tool can't be resolved (renamed/removed action or connection). Show today's + // raw JSON plus a warning; the drawer's JSON toggle stays the editable view. + return ( +
+
+ + + Couldn't resolve this tool. The action or connection may have been + renamed or removed. Use the JSON view to inspect the raw tool. + +
+
+                    {safeStringify(value)}
+                
+
+ ) + } + return +} + export function ToolFormView({value, onChange, disabled}: ToolFormViewProps) { const tool = (value ?? {}) as Record // A workflow-reference tool has no editable `function` — it gets its own detail view (exposed @@ -189,6 +276,25 @@ export function ToolFormView({value, onChange, disabled}: ToolFormViewProps) { if (tool.type === "reference") { return } + // A canonical gateway object carries no `function`, so it can't feed the form directly. Resolve it + // against the catalog and render it exactly like a legacy gateway tool. A legacy gateway tool is a + // function tool and flows through the normal form below UNCHANGED. + const gateway = parseGatewayTool(tool) + if (gateway?.encoding === "canonical") { + return ( + + ) + } + return +} + +function FunctionToolForm({value, onChange, disabled}: ToolFormViewProps) { + const tool = (value ?? {}) as Record const fn = (tool.function ?? {}) as Record const parameters: Schema = isRecord(fn.parameters) ? (fn.parameters as Schema) : {} diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentIntegrationDrawer.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentIntegrationDrawer.tsx index 125166fb9f..30bac11ee4 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentIntegrationDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/AgentIntegrationDrawer.tsx @@ -10,7 +10,7 @@ * header, `closeOnLayoutClick={false}` so an accidental backdrop click mid-connect never drops the * flow, and a footer whose count reflects the app tools added so far + a Done exit. */ -import {useCallback, useMemo, useState} from "react" +import {useCallback, useState} from "react" import { buildToolSlug, @@ -37,7 +37,7 @@ import {CatalogChooser} from "../../../drawers/shared/CatalogChooser" import ConnectDrawer from "../../../gatewayTool/drawers/ConnectDrawer" import {useReconnectToolConnection} from "../../../gatewayTool/hooks/useReconnectToolConnection" import type {ToolSelectionMeta} from "../ToolSelectorPopover" -import {parseGatewayFunctionName, type ToolObj} from "../toolUtils" +import {gatewayToolIdentity, type ToolObj} from "../toolUtils" type CatalogIntegrationItem = ToolCatalogIntegration | ToolCatalogIntegrationDetails @@ -45,8 +45,8 @@ export interface AgentIntegrationDrawerProps { open: boolean onClose: () => void onAddTool: (tool: ToolObj, meta?: ToolSelectionMeta) => void - onRemoveTool?: (toolName: string) => void - selectedToolNames: Set + onRemoveToolByIdentity?: (identity: string) => void + selectedGatewayIds: Set /** Preselect this app on open (a provider group's "Add {app} tool" → its actions directly). */ defaultIntegrationKey?: string } @@ -129,14 +129,15 @@ function useToolActionList(integrationKey: string) { // run in the background. function ToolCatalogContent({ onAddTool, - onRemoveTool, - selectedToolNames, + onRemoveToolByIdentity, + selectedGatewayIds, defaultIntegrationKey, }: Omit) { const [pending, setPending] = useState(null) const {connections} = useToolConnectionsQuery() const {reconnect, reconnectingId} = useReconnectToolConnection() + // The in-flight spinner is still keyed by slug; the added-state is keyed by identity. const slugFor = useCallback( (conn: ToolConnection, actionKey: string) => buildToolSlug( @@ -148,14 +149,28 @@ function ToolCatalogContent({ [], ) + // Encoding-independent identity — matches a canonical or legacy entry already in the config. + const idFor = useCallback( + (conn: ToolConnection, actionKey: string) => + gatewayToolIdentity({ + provider: conn.provider_key ?? "composio", + integration: conn.integration_key, + action: actionKey, + connection: conn.slug ?? "", + encoding: "legacy", + }), + [], + ) + // Add the chosen action as a function tool (toggles off if already added). const toggle = useCallback( async (conn: ToolConnection, action: ToolCatalogAction) => { - const slug = slugFor(conn, action.key) - if (selectedToolNames.has(slug)) { - onRemoveTool?.(slug) + const id = idFor(conn, action.key) + if (selectedGatewayIds.has(id)) { + onRemoveToolByIdentity?.(id) return } + const slug = slugFor(conn, action.key) setPending(slug) // The model-facing input schema comes from the per-action detail endpoint, which // errors provider-side for some actions. That must NOT block the add: the tool @@ -204,7 +219,7 @@ function ToolCatalogContent({ setPending(null) } }, - [slugFor, selectedToolNames, onAddTool, onRemoveTool], + [slugFor, idFor, selectedGatewayIds, onAddTool, onRemoveToolByIdentity], ) return ( @@ -248,9 +263,8 @@ function ToolCatalogContent({ emptyItemsText="No actions for this app" onPickItem={(conn, action) => void toggle(conn, action)} itemState={(conn, action) => { - const slug = slugFor(conn, action.key) - if (pending === slug) return "pending" - return selectedToolNames.has(slug) ? "selected" : "add" + if (pending === slugFor(conn, action.key)) return "pending" + return selectedGatewayIds.has(idFor(conn, action.key)) ? "selected" : "add" }} renderConnect={(integration, handlers) => ( { - let n = 0 - for (const name of selectedToolNames) if (parseGatewayFunctionName(name)) n++ - return n - }, [selectedToolNames]) + const addedCount = selectedGatewayIds.size return ( diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ToolManagementList.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ToolManagementList.tsx index 9f88ecbc01..0df385167e 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ToolManagementList.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ToolManagementList.tsx @@ -19,9 +19,9 @@ import {atomWithStorage} from "jotai/utils" import type {ConfigItemView} from "../ConfigItemDrawer" import {CollapsibleProviderGroup, SubSectionHeader} from "../sectionGroups" -import {parseGatewayFunctionName} from "../toolUtils" +import {parseGatewayTool} from "../toolUtils" -import {describeTool, isFunctionTool, toolName} from "./itemDescriptors" +import {describeTool, isFunctionTool} from "./itemDescriptors" import {ITEM_KINDS} from "./itemKinds" import {ItemChildRow, ItemRow, type ItemRowStatus} from "./ItemRow" @@ -250,7 +250,7 @@ export function ToolManagementList({ references.push({item, index}) return } - const gw = parseGatewayFunctionName(toolName(item)) + const gw = parseGatewayTool(item) if (gw) { let group = groups.get(gw.integration) if (!group) { diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/itemDescriptors.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/itemDescriptors.tsx index 4353057f4b..835ef064df 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/itemDescriptors.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/itemDescriptors.tsx @@ -5,7 +5,7 @@ */ import {FileText, GraphIcon, Plugs} from "@phosphor-icons/react" -import {parseGatewayFunctionName, type ToolObj} from "../toolUtils" +import {parseGatewayTool, type ToolObj} from "../toolUtils" /** How a config-item row presents itself: avatar, name + description, and type tags. */ export interface ItemDescriptor { @@ -171,8 +171,8 @@ export function describeTool(tool: unknown): ItemDescriptor { } } - // Third-party / gateway tool: tools__provider__integration__action__connection. - const gateway = fnName ? parseGatewayFunctionName(fnName) : null + // Third-party / gateway tool: canonical object or legacy slug. + const gateway = parseGatewayTool(t) if (gateway) { // Some action keys repeat the integration (GITHUB_ADD_...) — drop it; the group header // already names the app. Then humanize the key into a readable label. diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/itemKinds.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/itemKinds.tsx index a1777667a0..404400a058 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/itemKinds.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/itemKinds.tsx @@ -11,6 +11,7 @@ import type {ConfigItemView} from "../ConfigItemDrawer" import {McpServerFormView} from "../McpServerFormView" import {SkillFormView} from "../SkillFormView" import {ToolFormView} from "../ToolFormView" +import {parseGatewayTool} from "../toolUtils" import { describeMcp, @@ -80,10 +81,15 @@ export const ITEM_KINDS: Record = { const name = describeTool(draft).name return name && name !== "Tool" ? name : "New tool" }, - // Function tools and workflow-reference tools both have a structured Form; only bare - // builtin/provider tools (a naked `type`) stay JSON-only. - editView: (item) => (isFunctionTool(item) || isReferenceTool(item) ? "form" : "json"), - jsonOnly: (draft) => !isFunctionTool(draft) && !isReferenceTool(draft), + // Function, workflow-reference, and gateway tools (either encoding) have a structured Form; + // only bare builtin/provider tools (a naked `type`) stay JSON-only. A canonical gateway + // object opens the Form via `parseGatewayTool`; a legacy one is already a function tool. + editView: (item) => + isFunctionTool(item) || isReferenceTool(item) || parseGatewayTool(item) + ? "form" + : "json", + jsonOnly: (draft) => + !isFunctionTool(draft) && !isReferenceTool(draft) && !parseGatewayTool(draft), isReadOnly: () => false, // Unused for tools: creation seeds from the picker (buildInlineFunctionTool), not this. createSeed: () => ({}), diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useAgentTools.ts b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useAgentTools.ts index 09ed06434a..68670775d3 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useAgentTools.ts +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useAgentTools.ts @@ -8,7 +8,7 @@ import {useCallback, useMemo, type MutableRefObject} from "react" import type {WorkflowReferenceBridge, WorkflowReferencePayload} from "@agenta/ui/drill-in" import type {ToolSelectionMeta} from "../ToolSelectorPopover" -import type {ToolObj} from "../toolUtils" +import {gatewayToolIdentity, parseGatewayTool, type ToolObj} from "../toolUtils" import {isBuiltinPayloadMatch, toolName, toolReferenceSlug} from "./itemDescriptors" import type {ItemKind} from "./itemKinds" @@ -132,6 +132,40 @@ export function useAgentTools({ [tools], ) + // Encoding-independent identities of the gateway tools present — the drawer's added-state. + // Derived from the SAME `tools` memo as `selectedToolNames`, so the two never drift. + const selectedGatewayIds = useMemo( + () => + new Set( + tools + .map((t) => { + const v = parseGatewayTool(t) + return v ? gatewayToolIdentity(v) : null + }) + .filter((s): s is string => Boolean(s)), + ), + [tools], + ) + + // Remove EXACTLY ONE identity match (toggle-off) — never all duplicates, per the design. + const removeGatewayToolByIdentity = useCallback( + (identity: string) => { + let removed = false + setTools( + tools.filter((t) => { + if (removed) return true + const v = parseGatewayTool(t) + if (v && gatewayToolIdentity(v) === identity) { + removed = true + return false + } + return true + }), + ) + }, + [tools, setTools], + ) + // Workflows not yet referenced as a tool — the pool the selector drawer offers. const referenceableWorkflows = useMemo(() => { const referenced = new Set( @@ -147,6 +181,8 @@ export function useAgentTools({ handleRemoveToolByName, handleRemoveBuiltinTool, selectedToolNames, + selectedGatewayIds, + removeGatewayToolByIdentity, referenceableWorkflows, } } diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/toolUtils.ts b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/toolUtils.ts index 12fa5f8ccb..f4e6d1728e 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/toolUtils.ts +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/toolUtils.ts @@ -5,6 +5,7 @@ * Contains provider metadata and builtin tool specs for detecting * provider-specific tools (OpenAI, Anthropic, Google Gemini). */ +import {parseGatewayToolSlug} from "@agenta/shared/utils" // ============================================================================ // TYPES @@ -28,18 +29,52 @@ export interface GatewayToolParsed { connection: string } -// Gateway tools are encoded as function names: -// tools__{provider}__{integration}__{action}__{connection} -// Double-underscore is the segment separator because dots are not allowed. -export function parseGatewayFunctionName(name: string | undefined): GatewayToolParsed | null { - if (!name) return null - const parts = name.split("__") - if (parts.length !== 5 || parts[0] !== "tools") return null +/** @deprecated alias — use parseGatewayToolSlug (shared) or parseGatewayTool (object-level). */ +export const parseGatewayFunctionName = parseGatewayToolSlug - const [, provider, integration, action, connection] = parts - if (!provider || !integration || !action || !connection) return null +/** Normalized view of a connected-app tool from either encoding; null if it isn't one. */ +export interface ParsedGatewayTool { + provider: string + integration: string + action: string + connection: string + /** Encoding it was read from — protocol context only; never displayed or persisted. */ + encoding: "canonical" | "legacy" + /** Per-tool permission when present (top-level on both shapes). */ + permission?: string +} + +/** Normalize either encoding of a connected-app tool into one view. */ +export function parseGatewayTool(tool: unknown): ParsedGatewayTool | null { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return null + const t = tool as Record + const permission = typeof t.permission === "string" ? t.permission : undefined + // Canonical discriminated object. + if (t.type === "gateway") { + const integration = typeof t.integration === "string" ? t.integration : "" + const action = typeof t.action === "string" ? t.action : "" + const connection = typeof t.connection === "string" ? t.connection : "" + if (!integration || !action || !connection) return null + const provider = typeof t.provider === "string" && t.provider ? t.provider : "composio" + return {provider, integration, action, connection, encoding: "canonical", permission} + } + // Legacy function-name slug. + const fn = t.function + const name = fn && typeof fn === "object" ? (fn as Record).name : undefined + const parsed = parseGatewayToolSlug(typeof name === "string" ? name : undefined) + if (parsed) return {...parsed, encoding: "legacy", permission} + return null +} + +// NUL join — a connection slug can contain a dot, so a dotted key is not collision-safe. +const GATEWAY_IDENTITY_SEP = "\u0000" - return {provider, integration, action, connection} +/** Stable identity for the drawer's added-state, independent of encoding. Excludes + * permission (policy, not identity) and encoding. */ +export function gatewayToolIdentity(view: ParsedGatewayTool): string { + return [view.provider, view.integration, view.action, view.connection].join( + GATEWAY_IDENTITY_SEP, + ) } // ============================================================================ diff --git a/web/packages/agenta-entity-ui/tests/unit/gatewayTool.test.ts b/web/packages/agenta-entity-ui/tests/unit/gatewayTool.test.ts new file mode 100644 index 0000000000..82eca2eb4d --- /dev/null +++ b/web/packages/agenta-entity-ui/tests/unit/gatewayTool.test.ts @@ -0,0 +1,223 @@ +/** + * Unit tests for the object-level connected-app ("gateway") tool parser and its stable identity. + * + * Connected-app tools exist in two equivalent encodings — a canonical {type:"gateway", ...} object + * (SDK/agent-authored) and a legacy OpenAI function tool whose function.name is the + * tools__provider__integration__action__connection slug (UI-authored). parseGatewayTool normalizes + * both into one view so the playground renders them identically. Runs under @agenta/entity-ui's own + * vitest runner. + */ +import {describe, expect, it} from "vitest" + +import {describeTool} from "../../src/DrillInView/SchemaControls/agentTemplate/itemDescriptors" +import {ITEM_KINDS} from "../../src/DrillInView/SchemaControls/agentTemplate/itemKinds" +import {gatewayToolIdentity, parseGatewayTool} from "../../src/DrillInView/SchemaControls/toolUtils" + +const legacyTool = (name: string, extra: Record = {}) => ({ + type: "function", + function: {name}, + ...extra, +}) + +describe("parseGatewayTool", () => { + it("reads a canonical object into fields with encoding:canonical", () => { + const view = parseGatewayTool({ + type: "gateway", + provider: "composio", + integration: "slack", + action: "OPEN_DM", + connection: "slack-pnt", + permission: "allow", + }) + expect(view).toEqual({ + provider: "composio", + integration: "slack", + action: "OPEN_DM", + connection: "slack-pnt", + encoding: "canonical", + permission: "allow", + }) + }) + + it("defaults provider to composio when absent on a canonical object", () => { + const view = parseGatewayTool({ + type: "gateway", + integration: "slack", + action: "OPEN_DM", + connection: "c", + }) + expect(view?.provider).toBe("composio") + expect(view?.encoding).toBe("canonical") + }) + + it("returns null when a canonical object is missing integration/action/connection", () => { + expect(parseGatewayTool({type: "gateway", action: "OPEN_DM", connection: "c"})).toBeNull() + expect( + parseGatewayTool({type: "gateway", integration: "slack", connection: "c"}), + ).toBeNull() + expect( + parseGatewayTool({type: "gateway", integration: "slack", action: "OPEN_DM"}), + ).toBeNull() + }) + + it("reads a legacy function-name tool into fields with encoding:legacy", () => { + const view = parseGatewayTool(legacyTool("tools__composio__slack__OPEN_DM__slack-pnt")) + expect(view).toEqual({ + provider: "composio", + integration: "slack", + action: "OPEN_DM", + connection: "slack-pnt", + encoding: "legacy", + permission: undefined, + }) + }) + + it("reads permission from the top level on a legacy tool", () => { + const view = parseGatewayTool( + legacyTool("tools__composio__slack__OPEN_DM__slack-pnt", {permission: "ask"}), + ) + expect(view?.permission).toBe("ask") + expect(view?.encoding).toBe("legacy") + }) + + it("returns null for non-gateway and junk inputs", () => { + expect(parseGatewayTool(legacyTool("get_weather"))).toBeNull() + expect(parseGatewayTool({type: "web_search_preview"})).toBeNull() + expect(parseGatewayTool({type: "reference", slug: "wf"})).toBeNull() + expect(parseGatewayTool(null)).toBeNull() + expect(parseGatewayTool(undefined)).toBeNull() + expect(parseGatewayTool([])).toBeNull() + expect(parseGatewayTool("tools__composio__slack__OPEN_DM__c")).toBeNull() + expect(parseGatewayTool(42)).toBeNull() + }) +}) + +describe("gatewayToolIdentity", () => { + const canonical = { + type: "gateway", + provider: "composio", + integration: "slack", + action: "OPEN_DM", + connection: "slack-pnt", + } + const legacy = legacyTool("tools__composio__slack__OPEN_DM__slack-pnt") + + it("gives the same identity for both encodings of the same tool", () => { + const a = gatewayToolIdentity(parseGatewayTool(canonical)!) + const b = gatewayToolIdentity(parseGatewayTool(legacy)!) + expect(a).toBe(b) + }) + + it("differs when the connection or action differs", () => { + const base = gatewayToolIdentity(parseGatewayTool(canonical)!) + const otherConn = gatewayToolIdentity( + parseGatewayTool({...canonical, connection: "slack-other"})!, + ) + const otherAction = gatewayToolIdentity( + parseGatewayTool({...canonical, action: "CLOSE_DM"})!, + ) + expect(otherConn).not.toBe(base) + expect(otherAction).not.toBe(base) + }) + + it("ignores permission — identity is policy-independent", () => { + const allow = gatewayToolIdentity(parseGatewayTool({...canonical, permission: "allow"})!) + const ask = gatewayToolIdentity(parseGatewayTool({...canonical, permission: "ask"})!) + expect(allow).toBe(ask) + }) + + it("does not collide when a connection slug contains a dot", () => { + // "slack.prod" as the connection must not be split into a differently-shaped identity. + const dotted = gatewayToolIdentity( + parseGatewayTool({...canonical, connection: "slack.prod"})!, + ) + const shifted = gatewayToolIdentity( + parseGatewayTool({...canonical, action: "OPEN_DM.slack", connection: "prod"})!, + ) + expect(dotted).not.toBe(shifted) + }) +}) + +describe("describeTool on a canonical gateway object", () => { + const descriptor = describeTool({ + type: "gateway", + integration: "slack", + action: "OPEN_DM", + connection: "c", + }) + + it("humanizes the action into a prose (non-mono) name", () => { + expect(descriptor.name.toLowerCase()).toBe("open dm") + expect(descriptor.name).not.toBe("OPEN_DM") + expect(descriptor.monoName).toBe(false) + }) + + it("tags the integration and labels it third-party, not built-in", () => { + expect(descriptor.tags).toContain("slack") + expect(descriptor.tags).not.toContain("built-in") + expect(descriptor.typeLabel).toBe("third-party") + }) + + it("uses the connected-app subtitle", () => { + expect(descriptor.subtitle.startsWith("Connected app tool")).toBe(true) + }) +}) + +describe("ITEM_KINDS.tool drill-in routing", () => { + const canonical = {type: "gateway", integration: "slack", action: "OPEN_DM", connection: "c"} + const builtin = {type: "web_search_preview"} + + it("opens a canonical gateway tool in the Form, JSON toggle available", () => { + expect(ITEM_KINDS.tool.editView(canonical)).toBe("form") + expect(ITEM_KINDS.tool.jsonOnly(canonical)).toBe(false) + }) + + it("keeps a bare builtin tool JSON-only (regression)", () => { + expect(ITEM_KINDS.tool.editView(builtin)).toBe("json") + expect(ITEM_KINDS.tool.jsonOnly(builtin)).toBe(true) + }) +}) + +describe("add-path identity (useAgentTools derivations, pure)", () => { + // Mirror how useAgentTools builds `selectedGatewayIds` from the config's tools array. + const selectedGatewayIds = (tools: unknown[]) => + new Set( + tools + .map((t) => { + const v = parseGatewayTool(t) + return v ? gatewayToolIdentity(v) : null + }) + .filter((s): s is string => Boolean(s)), + ) + + // Mirror how the drawer builds an action's identity to compare against that set. + const idForAction = gatewayToolIdentity({ + provider: "composio", + integration: "slack", + action: "OPEN_DM", + connection: "c", + encoding: "legacy", + }) + + it("a canonical tool in the config marks the matching drawer action as selected", () => { + const tools = [{type: "gateway", integration: "slack", action: "OPEN_DM", connection: "c"}] + expect(selectedGatewayIds(tools).has(idForAction)).toBe(true) + }) + + it("removeGatewayToolByIdentity removes exactly one of two duplicate entries", () => { + const dup = {type: "gateway", integration: "slack", action: "OPEN_DM", connection: "c"} + const tools: unknown[] = [dup, dup] + // Same one-match filter the hook uses. + let removed = false + const next = tools.filter((t) => { + if (removed) return true + const v = parseGatewayTool(t) + if (v && gatewayToolIdentity(v) === idForAction) { + removed = true + return false + } + return true + }) + expect(next).toHaveLength(1) + }) +})