diff --git a/api/oss/src/apis/fastapi/applications/models.py b/api/oss/src/apis/fastapi/applications/models.py index e69015a663..a75ee0d7e7 100644 --- a/api/oss/src/apis/fastapi/applications/models.py +++ b/api/oss/src/apis/fastapi/applications/models.py @@ -1,4 +1,4 @@ -from typing import Optional, List +from typing import Any, Dict, Optional, List from pydantic import BaseModel, Field @@ -599,6 +599,49 @@ class SimpleApplicationQueryRequest(BaseModel): ) +class AgentTemplateOverlay(BaseModel): + """A documented subset of the `parameters.agent` authoring shape. + + Carries the platform-owned tools, authoring skills, and sandbox elevation the playground + layers on top of the draft for the build kit. Entries are intentionally open (platform-op + configs and `@ag.embed` references), so they are typed loosely: the full `parameters.agent` + authoring template has no shared Pydantic model today (it rides as free-form + `data.parameters`), and the SDK's runtime `AgentTemplate` is the flattened parse with + different field names, so neither can be reused 1:1 to type this overlay. + """ + + tools: List[Dict[str, Any]] = Field( + default_factory=list, + description="Platform tool configs and `@ag.embed` tool references.", + ) + skills: List[Dict[str, Any]] = Field( + default_factory=list, + description="`@ag.embed` references to authoring skills.", + ) + sandbox: Optional[Dict[str, Any]] = Field( + default=None, + description="Sandbox section overlay, e.g. `{permissions: {...}}`.", + ) + + +class PlaygroundBuildKitContext(BaseModel): + """Read-only playground build-kit context for one inspect/fetch response.""" + + agent_template_overlay: Optional[AgentTemplateOverlay] = Field( + default=None, + description="Partial `parameters.agent` overlay applied by the playground only.", + ) + + +class SimpleApplicationAdditionalContext(BaseModel): + """Platform-supplied read-only context for a simple-application response.""" + + playground_build_kit: Optional[PlaygroundBuildKitContext] = Field( + default=None, + description="Playground-only build kit data that is never persisted on the app.", + ) + + class SimpleApplicationResponse(BaseModel): """Simple-application single-row response envelope.""" @@ -613,6 +656,10 @@ class SimpleApplicationResponse(BaseModel): "revision's `data` merged. `data.url` is the invocation URL." ), ) + additional_context: Optional[SimpleApplicationAdditionalContext] = Field( + default=None, + description="Read-only platform context derived for this response.", + ) class SimpleApplicationsResponse(BaseModel): diff --git a/api/oss/src/apis/fastapi/applications/overlay.py b/api/oss/src/apis/fastapi/applications/overlay.py new file mode 100644 index 0000000000..c61a2379f5 --- /dev/null +++ b/api/oss/src/apis/fastapi/applications/overlay.py @@ -0,0 +1,92 @@ +"""Read-only overlays attached to application inspect/fetch responses.""" + +from typing import Any, Dict, List, Optional + +from agenta.sdk.agents.adapters.agenta_builtins import GETTING_STARTED_WITH_AGENTA_SLUG +from agenta.sdk.agents.platform.op_catalog import PLATFORM_OPS + +from oss.src.core.workflows.static_catalog import ( + STATIC_SLUG_PREFIX, + StaticWorkflowCatalog, + _STATIC_WORKFLOWS, +) + + +def _workflow_embed( + slug: str, + *, + name: Optional[str], + selector_path: str, +) -> Dict[str, Any]: + # The selector is load-bearing: without it the embed resolves to the whole revision.data + # (``{uri, parameters: {skill|tool: ...}}``), which neither the SDK skill parser nor the tool + # coercer accepts. ``parameters.skill`` / ``parameters.tool`` extracts the flat inline value + # the agent template expects (see test_skill_template_catalog canonical embed shape). + embed: Dict[str, Any] = { + "@ag.embed": { + "@ag.references": {"workflow": {"slug": slug}}, + "@ag.selector": {"path": selector_path}, + } + } + # The display name rides alongside the embed so the playground shows the workflow's name, not + # the raw ``__ag__*`` slug. Resolution replaces the whole entry, so this sibling is discarded + # before the tool/skill parser ever sees it. + if name: + embed["name"] = name + return embed + + +def _reserved_static_tool_embeds( + catalog: StaticWorkflowCatalog, +) -> List[Dict[str, Any]]: + """Tool embeds for the reserved static workflows that are tools (not skills). + + Only confirmed non-skill static workflows with a resolvable revision are included; a missing + revision or missing flags is skipped so an invalid tool embed can't leak into the playground. + """ + embeds: List[Dict[str, Any]] = [] + for slug in _STATIC_WORKFLOWS: + if not slug.startswith(STATIC_SLUG_PREFIX): + continue + revision = catalog.retrieve_revision(slug=slug) + if not revision or not revision.flags or revision.flags.is_skill: + continue + embeds.append( + _workflow_embed( + slug, + name=revision.name, + selector_path="parameters.tool", + ) + ) + return embeds + + +def build_agent_template_overlay() -> Dict[str, Any]: + """Build the playground-only agent-template overlay from platform-owned sources.""" + + catalog = StaticWorkflowCatalog() + + skills: List[Dict[str, Any]] = [] + authoring_skill = catalog.retrieve_revision(slug=GETTING_STARTED_WITH_AGENTA_SLUG) + if authoring_skill: + skills.append( + _workflow_embed( + GETTING_STARTED_WITH_AGENTA_SLUG, + name=authoring_skill.name, + selector_path="parameters.skill", + ) + ) + + return { + "tools": [ + *[{"type": "platform", "op": op_name} for op_name in PLATFORM_OPS], + *_reserved_static_tool_embeds(catalog), + ], + "skills": skills, + "sandbox": { + "permissions": { + "write_files": "allow", + "execute_code": "allow", + } + }, + } diff --git a/api/oss/src/apis/fastapi/applications/router.py b/api/oss/src/apis/fastapi/applications/router.py index d0a812dc4c..828d47f517 100644 --- a/api/oss/src/apis/fastapi/applications/router.py +++ b/api/oss/src/apis/fastapi/applications/router.py @@ -66,9 +66,12 @@ SimpleApplicationCreateRequest, SimpleApplicationEditRequest, SimpleApplicationQueryRequest, + SimpleApplicationAdditionalContext, SimpleApplicationResponse, SimpleApplicationsResponse, + PlaygroundBuildKitContext, ) +from oss.src.apis.fastapi.applications.overlay import build_agent_template_overlay from oss.src.apis.fastapi.applications.utils import ( parse_application_variant_query_request_from_params, parse_application_variant_query_request_from_body, @@ -1902,9 +1905,28 @@ async def fetch_simple_application( application_id=application_id, ) + # Build the read-only playground overlay defensively: this handler returns a controlled + # default on error (``@suppress_exceptions``), so letting overlay synthesis raise would + # blank the whole fetched application instead of just dropping the optional context. + additional_context = None + if simple_application: + try: + additional_context = SimpleApplicationAdditionalContext( + playground_build_kit=PlaygroundBuildKitContext( + agent_template_overlay=build_agent_template_overlay(), + ), + ) + except Exception: # noqa: BLE001 - overlay is best-effort; never blank the response + log.warning( + "Failed to build playground build-kit overlay for application %s", + application_id, + exc_info=True, + ) + simple_application_response = SimpleApplicationResponse( count=1 if simple_application else 0, application=simple_application, + additional_context=additional_context, ) return simple_application_response diff --git a/api/oss/src/core/workflows/static_catalog.py b/api/oss/src/core/workflows/static_catalog.py index 735a6f3e30..b19284d7ca 100644 --- a/api/oss/src/core/workflows/static_catalog.py +++ b/api/oss/src/core/workflows/static_catalog.py @@ -18,8 +18,14 @@ from uuid import UUID, uuid5 from agenta.sdk.agents.adapters.agenta_builtins import ( + BUILD_YOUR_FIRST_APP_SKILL, + BUILD_YOUR_FIRST_APP_SLUG, + DISCOVER_AND_WIRE_TOOLS_SKILL, + DISCOVER_AND_WIRE_TOOLS_SLUG, GETTING_STARTED_WITH_AGENTA_SKILL, GETTING_STARTED_WITH_AGENTA_SLUG, + SET_UP_TRIGGERS_SKILL, + SET_UP_TRIGGERS_SLUG, ) from agenta.sdk.agents.platform.workflow import ( REQUEST_CONNECTION_TOOL_NAME, @@ -121,6 +127,24 @@ def _client_tool_revision() -> WorkflowRevision: "v1": _client_tool_revision(), }, }, + BUILD_YOUR_FIRST_APP_SLUG: { + "latest": "v1", + "versions": { + "v1": _skill_revision(BUILD_YOUR_FIRST_APP_SKILL), + }, + }, + DISCOVER_AND_WIRE_TOOLS_SLUG: { + "latest": "v1", + "versions": { + "v1": _skill_revision(DISCOVER_AND_WIRE_TOOLS_SKILL), + }, + }, + SET_UP_TRIGGERS_SLUG: { + "latest": "v1", + "versions": { + "v1": _skill_revision(SET_UP_TRIGGERS_SKILL), + }, + }, } diff --git a/api/oss/tests/pytest/unit/applications/test_build_kit_overlay.py b/api/oss/tests/pytest/unit/applications/test_build_kit_overlay.py new file mode 100644 index 0000000000..5adc21d864 --- /dev/null +++ b/api/oss/tests/pytest/unit/applications/test_build_kit_overlay.py @@ -0,0 +1,192 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from agenta.sdk.agents.adapters.agenta_builtins import GETTING_STARTED_WITH_AGENTA_SLUG +from agenta.sdk.agents.dtos import AgentTemplate +from agenta.sdk.agents.platform.op_catalog import PLATFORM_OPS +from agenta.sdk.agents.tools.models import ClientToolConfig, PlatformToolConfig + +from oss.src.apis.fastapi.applications import router as applications_router_module +from oss.src.apis.fastapi.applications.overlay import build_agent_template_overlay +from oss.src.apis.fastapi.applications.router import SimpleApplicationsRouter +from oss.src.core.applications.dtos import SimpleApplication +from oss.src.core.embeds.service import EmbedsService +from oss.src.core.workflows.dtos import WorkflowRevision, WorkflowRevisionData +from oss.src.core.workflows.service import WorkflowsService +from oss.src.core.workflows.static_catalog import ( + STATIC_SLUG_PREFIX, + StaticWorkflowCatalog, + _STATIC_WORKFLOWS, +) + + +def _embed_slug(entry: dict) -> str | None: + refs = entry.get("@ag.embed", {}).get("@ag.references", {}) + workflow = refs.get("workflow") or refs.get("workflow_revision") or {} + return workflow.get("slug") + + +def test_agent_template_overlay_contains_platform_ops_authoring_skill_and_permissions(): + overlay = build_agent_template_overlay() + + platform_tools = [ + tool + for tool in overlay["tools"] + if isinstance(tool, dict) and tool.get("type") == "platform" + ] + assert platform_tools == [ + {"type": "platform", "op": op_name} for op_name in PLATFORM_OPS + ] + + authoring_skill = StaticWorkflowCatalog().retrieve_revision( + slug=GETTING_STARTED_WITH_AGENTA_SLUG + ) + assert overlay["skills"] == [ + { + "name": authoring_skill.name, + "@ag.embed": { + "@ag.references": { + "workflow": {"slug": GETTING_STARTED_WITH_AGENTA_SLUG} + }, + "@ag.selector": {"path": "parameters.skill"}, + }, + } + ] + assert overlay["sandbox"] == { + "permissions": {"write_files": "allow", "execute_code": "allow"} + } + + +def test_agent_template_overlay_includes_reserved_static_workflow_tool_embeds(): + overlay = build_agent_template_overlay() + tool_embeds = [ + tool + for tool in overlay["tools"] + if isinstance(tool, dict) and "@ag.embed" in tool + ] + tool_embed_slugs = {_embed_slug(tool) for tool in tool_embeds} + # Each tool embed must carry the canonical ``parameters.tool`` selector so it resolves to the + # flat inline tool config the SDK coercer accepts (regression: missing selector -> HTTP 500 + # "Unsupported tool configuration shape"). + assert all( + tool["@ag.embed"].get("@ag.selector") == {"path": "parameters.tool"} + for tool in tool_embeds + ) + catalog = StaticWorkflowCatalog() + + # Each tool embed carries the workflow's display name so the playground renders that instead of + # the raw ``__ag__*`` slug. + for tool in tool_embeds: + revision = catalog.retrieve_revision(slug=_embed_slug(tool)) + assert tool.get("name") == revision.name + + expected_slugs = set() + for slug in _STATIC_WORKFLOWS: + revision = catalog.retrieve_revision(slug=slug) + if ( + slug.startswith(STATIC_SLUG_PREFIX) + and revision + and revision.flags + and not revision.flags.is_skill + ): + expected_slugs.add(slug) + + assert tool_embed_slugs == expected_slugs + + +@pytest.mark.asyncio +async def test_fetch_simple_application_includes_build_kit_context(monkeypatch): + project_id = uuid4() + user_id = uuid4() + application_id = uuid4() + + class DummySimpleApplicationsService: + applications_service = object() + + async def fetch(self, **kwargs): + assert kwargs["project_id"] == project_id + assert kwargs["application_id"] == application_id + return SimpleApplication(id=application_id, slug="agent") + + monkeypatch.setattr( + applications_router_module, + "check_action_access", + AsyncMock(return_value=True), + raising=False, + ) + + router = SimpleApplicationsRouter( + simple_applications_service=DummySimpleApplicationsService() + ) + request = SimpleNamespace( + state=SimpleNamespace(project_id=str(project_id), user_id=str(user_id)) + ) + + response = await router.fetch_simple_application( + request, + application_id=application_id, + ) + + overlay = ( + response.additional_context.playground_build_kit.agent_template_overlay + if response.additional_context + and response.additional_context.playground_build_kit + else None + ) + assert response.application is not None + # The overlay is now a typed `AgentTemplateOverlay`; its JSON projection is the wire payload and + # must match the platform-built overlay dict byte for byte. + assert overlay is not None + assert overlay.model_dump(mode="json") == build_agent_template_overlay() + + +@pytest.mark.asyncio +async def test_resolved_build_kit_overlay_parses_through_from_params(): + """The overlay must survive embed resolution and parse with no error. + + Regression: each ``@ag.embed`` reference dropped its ``@ag.selector``, so the resolver inlined + the whole ``revision.data`` and ``AgentTemplate.from_params`` raised HTTP 500 + ``Unsupported tool configuration shape``. Exercises overlay -> embed resolution (static + catalogue, no DB) -> ``from_params`` end to end. + """ + workflows_dao = AsyncMock() + workflows_service = WorkflowsService( + workflows_dao=workflows_dao, + static_catalog=StaticWorkflowCatalog(), + ) + workflows_service.embeds_service = EmbedsService( + workflows_service=workflows_service + ) + + revision = WorkflowRevision( + id=uuid4(), + workflow_id=uuid4(), + workflow_variant_id=uuid4(), + slug="agent-default-config", + data=WorkflowRevisionData(parameters={"agent": build_agent_template_overlay()}), + ) + + resolved, _ = await workflows_service.resolve_workflow_revision( + project_id=uuid4(), + workflow_revision=revision, + ) + # No reserved embed may touch Postgres. + workflows_dao.fetch_revision.assert_not_awaited() + workflows_dao.fetch_artifact.assert_not_awaited() + + template = AgentTemplate.from_params({"agent": resolved.data.parameters["agent"]}) + + platform_ops = [ + tool for tool in template.tools if isinstance(tool, PlatformToolConfig) + ] + client_tools = [ + tool for tool in template.tools if isinstance(tool, ClientToolConfig) + ] + assert any(tool.op == "find_capabilities" for tool in platform_ops) + # The request_connection embed must coerce to a client tool, not a builtin. + assert [tool.name for tool in client_tools] == ["request_connection"] + assert client_tools[0].render == {"kind": "connect"} + assert [skill.name for skill in template.skills] == ["agenta-getting-started"] diff --git a/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py b/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py index 0a70d4795e..db8472051a 100644 --- a/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py +++ b/sdks/python/agenta/sdk/agents/adapters/agenta_builtins.py @@ -65,6 +65,9 @@ # skill by this slug; the server-side StaticWorkflowCatalog resolves the slug to the # SkillTemplate below. Kept here so the catalogue and the forced path share one slug constant. GETTING_STARTED_WITH_AGENTA_SLUG = "__ag__getting_started_with_agenta" +BUILD_YOUR_FIRST_APP_SLUG = "__ag__build_your_first_app" +DISCOVER_AND_WIRE_TOOLS_SLUG = "__ag__discover_and_wire_tools" +SET_UP_TRIGGERS_SLUG = "__ag__set_up_triggers" # Canonical SKILL.md body for the platform "getting started" skill. Single source of the body # text: the server-side StaticWorkflowCatalog imports this constant rather than redeclaring it. @@ -99,6 +102,219 @@ body=_GETTING_STARTED_BODY, ) +_BUILD_YOUR_FIRST_APP_BODY = """\ +# Build your first app + +You are helping the user turn a plain-language goal into a working app. You are building +yourself: the app you configure is you. This skill is the map. It names the order and the +points where you stop for the user. Read the focused skill for a step before you act on it. + +## When to use + +Use this when the user asks you to build, set up, or automate something, and the app does not +exist yet. + +## The flow + +1. Clarify the goal. Ask what the app should do, what should start it (a message, a schedule, + an outside event), and what tools or data it needs. Do not guess. +2. See what exists. Call `query_workflows` to check the project for work you can reuse. +3. Find the tools. Follow the `discover-and-wire-tools` skill. It calls `find_capabilities` + and reports which integrations need a connection. +4. Connect the integrations. Hand the user the connection link, wait for them to finish, then + re-check. You never connect on their behalf. +5. Configure yourself. Edit your own instructions and attach the tools, then commit with + `commit_revision`. This stops for the user's approval. +6. Set the trigger. Follow the `set-up-triggers` skill for a cron job or an event trigger. + Each one stops for the user's approval. +7. Test. Run once against a sample, then confirm the result with the user. +8. Report. Tell the user what you became, what is connected, and what is now scheduled. + +## Stop points + +You pause for the user at every connection, every commit, every schedule, and every +subscription. These are approval gates by design. Say what you are about to do, then wait. +""" + +BUILD_YOUR_FIRST_APP_SKILL = SkillTemplate( + name="build-your-first-app", + description=( + "Guide the user through building their first Agenta app end to end. Use at the start " + "of a build conversation to plan the work, find and wire tools, set a trigger, and " + "commit. This skill is the map. Read the focused skill for each step." + ), + body=_BUILD_YOUR_FIRST_APP_BODY, +) + +_DISCOVER_AND_WIRE_TOOLS_BODY = """\ +# Discover and wire tools with `find_capabilities` + +You are configuring yourself as an app. Before you can act in the world, you need tools: +the right integration actions, working connections, and the schemas your model will call. +`find_capabilities` does the discovery in one step so you do not guess slugs or stitch the +catalog by hand. + +This skill is the discover -> resolve-connections -> configure -> test loop. It pairs with +the configure step in the `build-your-first-app` skill: once the tools are chosen, commit +the tools and instructions onto this agent. + +> **Availability (2026-06-27):** the server side is live, but the SDK does not yet declare +> `find_capabilities` as a tool the model can call directly (that lands in Workstream A). Until +> then, reach the same discovery from setup code: `POST /tools/discover` with +> `{"use_cases": [...]}`, or `POST /tools/call` with call_ref `tools.agenta.find_capabilities`. +> The response below is identical either way. + +## When to use it + +Use it whenever you are wiring tools for this agent and the task is described in plain +language ("listen in Slack and file GitHub issues") rather than as exact tool slugs. One call +returns the best-match tool per use case, alternatives the one-line request omitted, the input +schemas, the connection state per integration, and operating guidance. + +## The loop + +### 1. Discover + +Call `find_capabilities` with one short fragment per capability the agent needs. Keep each +fragment to a single action ("create a github issue"), not a whole workflow. + +```jsonc +find_capabilities({ + "use_cases": [ + "search github issues for a matching report", + "create a github issue", + "post a reply in a slack thread with a link" + ] +}) +``` + +Project scope comes from your run's caller auth, so the connection state you get back is your +project's real state. You do not pass a project id or a Composio user id. + +### 2. Read the response (it is already in Agenta terms) + +You never see Composio. Each capability is Agenta-shaped: + +- `capability.tool` — a `gateway` tool config (`provider` / `integration` / `action`), ready to + drop into this agent's `tools`. It also carries the `input_schema` and `description` the + model needs, plus `provider_action` (opaque, debugging only — do not show it). +- `capability.tool.connection` — filled **only** when the integration is `ready`. If it is + missing, the connection is not set up yet (see step 3). +- `capability.alternatives` — companion or prerequisite actions the one-line request omitted + (for example `slack.FIND_CHANNELS` before `slack.SEND_MESSAGE`). Add the ones the task needs. +- `capability.connection.state` — `ready`, `needs_auth`, or `needs_input`. +- `connections[]` — one entry per integration, deduped, with what to do when it is not ready. +- `guidance` — `plan_steps` and `pitfalls` you compose into this agent's + `instructions.agents_md`. +- `ready` — `true` only when every primary connection is ready (you can configure and run now). +- `notes` — scope notes, e.g. a use case that looks like a trigger (see "Triggers" below). + +### 3. Resolve connections (a human approves; you never auto-connect) + +For each integration in `connections[]`: + +- **`ready`** — reuse it. The `slug` is already on `capability.tool.connection`. Nothing to do. +- **`needs_auth`** (OAuth) — run the returned `connect` affordance + (`POST /tools/connections/` with the given `body`). It returns a `redirect_url`. Surface that + link to the human and **pause** until they finish authorizing. Then re-run `find_capabilities` + (or check the connection) to confirm the integration flipped to `ready`. +- **`needs_input`** (API key) — ask the human for the secret the integration needs, then create + the connection with the `connect` affordance. + +Do not create connections silently. A human approves OAuth and supplies secrets. + +### 4. Configure this agent + +Once the tools are chosen and their connections are `ready`, build this agent's template: + +- Put each chosen `capability.tool` (and any needed `alternatives`, shaped as gateway tools + with a `connection`) into `tools` on the agent template. +- Compose `instructions.agents_md` from `guidance`: turn `plan_steps` into the operating + procedure and `pitfalls` into "things to avoid". The guidance already uses friendly + `integration.action` names, so it reads cleanly. + +Then return to the `build-your-first-app` configure step: edit this agent's own template and +commit it with `commit_revision`. If a tool call fails on a missing connection, return to +step 3. + +## Triggers (listening for events) are a separate step + +`find_capabilities` covers **action** tools (do a thing). It does not discover triggers +(listen for an event), because the engine has no semantic trigger search. If a use case reads +like a trigger ("listen for new messages...", "when a new issue is created..."), the response +flags it in `notes` and on that `capability.note`. Treat the listening half as a trigger +subscription with the `set-up-triggers` skill, and wire the action tools as usual. + +## Good habits + +- One capability per `use_case` fragment; let discovery return the alternatives. +- Always check `connection.state` before assuming a tool will run; `ready` means it will + resolve at invoke time. +- Never surface `provider_action` or any raw provider slug to the user — speak Agenta. +- Re-run discovery after a human finishes a connection to confirm `ready` before creating. +""" + +DISCOVER_AND_WIRE_TOOLS_SKILL = SkillTemplate( + name="discover-and-wire-tools", + description=( + "Use find_capabilities to discover the right Agenta tools for an agent you are " + "configuring, report what each integration needs to connect, and wire the tools into " + "this agent's template. Use when a setup/builder agent must turn a plain-language task " + "into attached, connected, ready-to-run tools." + ), + body=_DISCOVER_AND_WIRE_TOOLS_BODY, +) + +_SET_UP_TRIGGERS_BODY = """\ +# Set up triggers + +A trigger makes the app run on its own. There are two kinds. A schedule runs on a clock. A +subscription runs when an outside event arrives. Either way, the trigger targets you: it is +set on this agent automatically, and you never name a destination. + +## When to use + +Use this when the user says the app should run on a timer, on a cron, or whenever something +happens in a connected tool. + +## Schedules (cron) + +1. Get the cron expression right. Five fields, UTC, one-minute floor. Confirm the user's + timezone and convert to UTC. +2. Set the optional window if the job should only run between two dates. +3. Map the inputs the job passes to the app on each run. +4. Create it with `create_schedule`. This stops for the user's approval. + +## Subscriptions (events) + +1. Find the event. Call `find_triggers` with a short keyword for the event you want. +2. Make sure the connection exists. A subscription needs a connected integration. If it is + missing, run the connection round-trip first and wait. +3. Map the event into the run inputs. +4. Create it with `create_subscription`. This stops for the user's approval. + +## Confirm it works + +Test before you go live. If the catalog has a sample event, map the sample and run yourself +on it, with no connection. To prove the live wiring, call `test_subscription`, then read the +delivery with `list_deliveries`. Tell the user what fired and what it produced. + +## Footguns + +- Cron is UTC. Always convert from the user's timezone. +- A subscription with no connection never fires. Connect first. +- The inputs must match what the app expects, or the run starts empty. +""" + +SET_UP_TRIGGERS_SKILL = SkillTemplate( + name="set-up-triggers", + description=( + "Set up a cron job (a schedule) or an event trigger (a subscription) for the app. Use " + "when the user wants the app to run on a timer or react to an outside event." + ), + body=_SET_UP_TRIGGERS_BODY, +) + # Platform skills every pi_agenta run carries, regardless of the author's config. These are the # actually-forced skills (see module docstring); unioned in by `force_skills`. AGENTA_FORCED_SKILLS: List[SkillTemplate] = [GETTING_STARTED_WITH_AGENTA_SKILL] diff --git a/services/agent/skills/agenta-getting-started/SKILL.md b/services/agent/skills/agenta-getting-started/SKILL.md deleted file mode 100644 index 44bc6a7a6b..0000000000 --- a/services/agent/skills/agenta-getting-started/SKILL.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: agenta-getting-started -description: Baseline guidance for agents running on the Agenta platform. Use at the start of a task to recall how to work with the tools and skills Agenta provides and how to report results clearly. ---- - -# Agenta getting started - -This is a placeholder Agenta skill that ships with the `AgentaHarness`. It proves the -forced-skill path end to end; replace its content with real Agenta guidance. - -## When to use - -Read this when you begin a task and want a reminder of the Agenta conventions below. - -## Conventions - -- Prefer the provided tools and skills over guessing; call a tool when one fits. -- When another skill matches the task, read its `SKILL.md` fully before acting. -- Keep answers grounded in what the tools and skills actually return. Do not fabricate - results or tool output. -- Be concise. State what you did, what it returned, and what is left. diff --git a/services/oss/src/agent/schemas.py b/services/oss/src/agent/schemas.py index 2b6ea1ec46..cf9effb8b3 100644 --- a/services/oss/src/agent/schemas.py +++ b/services/oss/src/agent/schemas.py @@ -8,7 +8,6 @@ change and stays out of the handler logic. """ -from agenta.sdk.agents.adapters.agenta_builtins import GETTING_STARTED_WITH_AGENTA_SLUG from agenta.sdk.utils.types import build_agent_v0_default _SCHEMA = "https://json-schema.org/draft/2020-12/schema" @@ -37,22 +36,9 @@ # catalog type keeps the typed tools/mcps shape in one place; this schema only carries the default the # playground pre-fills. The agent handler passes `parameters` verbatim to `AgentTemplate.from_params`, # which reads the template at `parameters.agent` (so a tool is at `parameters.agent.tools`). -# Reserved slug of the static default skill, served from code by the StaticWorkflowCatalog -# (api/oss/src/core/workflows/static_catalog.py), never the database. The default config -# references it by stable slug through an @ag.embed; the embed resolver inlines the catalogue's -# SkillTemplate (at the canonical parameters.skill selector) before the runner sees it. The -# `__ag__` prefix is reserved: a user cannot author or shadow it. This replaces both -# AGENTA_FORCED_SKILLS and the old per-project skill seeder. Single source: the SDK constant. -_DEFAULT_SKILL_SLUG = GETTING_STARTED_WITH_AGENTA_SLUG - -# The service default = the shared builder (single source, in the SDK) plus the two service-only -# choices: the static default skill (inlined from the reserved slug) and the declared Layer-2 -# sandbox boundary the playground pre-fills. The SDK builtin interface uses the same builder -# without these, so a new default field changes one place. -_DEFAULT_AGENT_TEMPLATE = build_agent_v0_default( - skill_slug=_DEFAULT_SKILL_SLUG, - include_sandbox_permission=True, -) +# The published default stays bare. The playground build kit is served as read-only inspect/fetch +# context and applied only to playground runs; it is not part of the committed agent template. +_DEFAULT_AGENT_TEMPLATE = build_agent_v0_default() AGENT_TEMPLATE_SCHEMA = { "type": "object", diff --git a/services/oss/tests/pytest/unit/agent/test_default_agent_template.py b/services/oss/tests/pytest/unit/agent/test_default_agent_template.py index b3c7bab29a..c7674050cb 100644 --- a/services/oss/tests/pytest/unit/agent/test_default_agent_template.py +++ b/services/oss/tests/pytest/unit/agent/test_default_agent_template.py @@ -16,7 +16,7 @@ from agenta.sdk.engines.running.interfaces import agent_v0_interface from agenta.sdk.utils.types import build_agent_v0_default -from oss.src.agent.schemas import AGENT_SCHEMAS, _DEFAULT_SKILL_SLUG +from oss.src.agent.schemas import AGENT_SCHEMAS def _inspect_agent_default() -> dict: @@ -35,50 +35,40 @@ def test_builtin_default_is_the_bare_builder(): assert _builtin_agent_default() == build_agent_v0_default() -def test_service_default_is_the_builder_plus_service_only_choices(): - # The service default is the same builder plus the two service-only choices, passed as - # named args (not a second copy): the platform default skill and the declared sandbox boundary. - assert _inspect_agent_default() == build_agent_v0_default( - skill_slug=_DEFAULT_SKILL_SLUG, - include_sandbox_permission=True, - ) +def test_service_default_is_the_bare_builder(): + # The playground build kit carries authoring extras; the published default stays bare. + assert _inspect_agent_default() == build_agent_v0_default() def test_inspect_default_parses_into_the_runtime_selection(): # The default the playground pre-fills on `/inspect` must parse cleanly into the same runtime - # values `AgentTemplate.from_params` produces, so what the user sees is what the agent runs. The - # `@ag.embed` skill resolves server-side before this parse, so the config-level round-trip is - # asserted on the non-skill fields plus the execution selectors. + # values `AgentTemplate.from_params` produces, so what the user sees is what the agent runs. inspect_default = _inspect_agent_default() - no_skill = {k: v for k, v in inspect_default.items() if k != "skills"} - params = {"agent": no_skill} + params = {"agent": inspect_default} config = AgentTemplate.from_params(params) assert config.model == inspect_default["llm"]["model"] assert config.instructions == inspect_default["instructions"]["agents_md"] - assert ( - config.sandbox_permission is not None - ) # the service boundary survives the parse + assert config.sandbox_permission is None assert config.harness == "pi_core" assert config.sandbox == "local" assert config.permission_policy == "auto" -def test_service_only_extras_present_in_inspect_absent_from_builtin(): - # The platform default skill and the sandbox boundary ride the SERVICE default (the playground - # pre-fill + the runtime fallback) and are intentionally ABSENT from the SDK builtin, which is - # the minimal harness-agnostic shape with no platform opinion. They are in both inspect and - # runtime via the SAME service default object, so they cannot drift between the two. +def test_authoring_extras_absent_from_every_published_default(): + # Platform tools, the authoring skill, and elevated sandbox permissions belong to the + # playground build-kit overlay, not to the published default template. inspect_default = _inspect_agent_default() builtin_default = _builtin_agent_default() - assert "permissions" in inspect_default["sandbox"] - assert ( - inspect_default["skills"][0]["@ag.embed"]["@ag.references"]["workflow"]["slug"] - == _DEFAULT_SKILL_SLUG - ) + assert inspect_default["tools"] == [] + assert "skills" not in inspect_default + assert "permissions" not in inspect_default["sandbox"] + assert "execute_code" not in inspect_default["sandbox"] + assert "write_files" not in inspect_default["sandbox"] + assert builtin_default["tools"] == [] assert "permissions" not in builtin_default["sandbox"] assert "skills" not in builtin_default diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index 11a6dfdca3..2909bee1a9 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -1,5 +1,6 @@ import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from "react" +import {invalidateAgentCommittedRevisionCache} from "@agenta/entities/workflow" import {agentShouldResumeAfterApproval, buildAgentRequest} from "@agenta/playground" import {simulatedAgentRunAtomFamily} from "@agenta/shared/state" import {generateId} from "@agenta/shared/utils" @@ -23,6 +24,7 @@ import { import {filesToParts} from "./assets/files" import {messageText, sideEffectingToolsInRange} from "./assets/rewind" import AgentMessage from "./components/AgentMessage" +import type {ClientToolOutputHandler} from "./components/clientTools" import ComposerAttachments from "./components/ComposerAttachments" import QueuedMessages from "./components/QueuedMessages" import SessionHistoryMenu from "./components/SessionHistoryMenu" @@ -247,6 +249,7 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: regenerate, setMessages, addToolApprovalResponse, + addToolOutput, error, } = useChat({ id: sessionId, @@ -275,6 +278,30 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: return () => setSessionStreaming({id: sessionId, streaming: false}) }, [sessionId, setSessionStreaming]) + // Settle a parked client tool (#4920). The dispatcher calls this from a widget (e.g. the connect + // widget) with the structured reference; `addToolOutput` matches the part by `toolCallId` on the + // last turn and the resume predicate auto-resends. `tool` is only the typed-tools key — matching + // is by id — so a cast onto the untyped UIMessage tool map is safe. + const handleClientToolOutput = useCallback( + ({toolName, toolCallId, output, errorText}) => { + if (errorText !== undefined) { + addToolOutput({ + state: "output-error", + tool: toolName as never, + toolCallId, + errorText, + }).catch(ignoreStreamRejection) + } else { + addToolOutput({ + tool: toolName as never, + toolCallId, + output: (output ?? {}) as never, + }).catch(ignoreStreamRejection) + } + }, + [addToolOutput], + ) + // ── "Run in playground" seam (producer: a trigger drawer's Run-in-playground) ── // A trigger fires server-side and never reaches the playground; this lets a user // channel a trigger's resolved inputs into the active session. Only the ACTIVE @@ -374,6 +401,27 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: persistMessages({id: sessionId, messages}) }, [messages, status, sessionId, persistMessages]) + // ── #4920 Application 1: refresh the config on a committed revision ── + // When the agent commits a new revision of itself, the backend emits a one-way + // `data-committed-revision` part (same channel as `data-trace`), in BOTH the gated approval path + // and the direct `needs_approval=false` path. On receipt we invalidate the latest-revision and + // inspect caches so the config panel, section drawers, and build-kit view all re-read the new + // config. Deduped by revision id so a re-render (token stream) doesn't re-invalidate. + const committedRevisionsSeenRef = useRef>(new Set()) + useEffect(() => { + for (const message of messages) { + for (const part of message.parts) { + if ((part as {type?: string}).type !== "data-committed-revision") continue + const data = (part as {data?: {revisionId?: string; version?: string}}).data + // A stable key per commit: prefer the revision id, fall back to the whole payload. + const key = data?.revisionId ?? JSON.stringify(data ?? {}) ?? "committed" + if (committedRevisionsSeenRef.current.has(key)) continue + committedRevisionsSeenRef.current.add(key) + invalidateAgentCommittedRevisionCache() + } + } + }, [messages]) + // ── DT3 cancelled state: wrap stop() to mark the in-flight assistant turn ── const markStopped = useCallback(() => { const last = messages[messages.length - 1] @@ -748,8 +796,10 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: handleRewind(message)} onApprovalResponse={addToolApprovalResponse} + onClientToolOutput={handleClientToolOutput} precededByEmptyAssistant={ index > 0 && isEmptyAssistantTurn(messages[index - 1]) } diff --git a/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx b/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx index cc01e08e09..83ec68067a 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentChatConversation.tsx @@ -16,6 +16,7 @@ import {createAgentChatTransport} from "../assets/transport" import {persistSessionMessagesAtom, sessionMessagesAtom} from "../state/sessions" import AgentMessage from "./AgentMessage" +import type {ClientToolOutputHandler} from "./clientTools" const {Text} = Typography @@ -84,6 +85,7 @@ const AgentChatConversation = ({ regenerate, setMessages, addToolApprovalResponse, + addToolOutput, error, } = useChat({ id: sessionId, @@ -101,6 +103,29 @@ const AgentChatConversation = ({ const busy = status === "submitted" || status === "streaming" + // Settle a parked client tool (#4920) — same wrapper as AgentChatPanel. `addToolOutput` matches + // the part by `toolCallId` on the last turn; `tool` is only the typed-tools key, so a cast onto + // the untyped UIMessage tool map is safe. + const handleClientToolOutput = useCallback( + ({toolName, toolCallId, output, errorText}) => { + if (errorText !== undefined) { + addToolOutput({ + state: "output-error", + tool: toolName as never, + toolCallId, + errorText, + }).catch(ignoreStreamRejection) + } else { + addToolOutput({ + tool: toolName as never, + toolCallId, + output: (output ?? {}) as never, + }).catch(ignoreStreamRejection) + } + }, + [addToolOutput], + ) + // `handleRewind` must stay referentially stable (it's passed to every memo'd `AgentMessage`) // so a streamed token doesn't recreate it and re-render the whole list. `messages`/`busy` // change every token, so read them through refs instead of capturing them in the closure. @@ -239,8 +264,10 @@ const AgentChatConversation = ({ key={message.id} message={message} isStreaming={busy && index === messages.length - 1} + isLastMessage={index === messages.length - 1} onRewind={handleRewind} onApprovalResponse={addToolApprovalResponse} + onClientToolOutput={handleClientToolOutput} /> ))} {status === "submitted" && messages[messages.length - 1]?.role !== "assistant" && ( diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx index 4ca53e60d1..dedfa60a88 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx @@ -28,6 +28,7 @@ import { type MessageUsageMetrics, } from "../assets/trace" +import {ClientToolPart, isClientToolPart, type ClientToolOutputHandler} from "./clientTools" import ToolActivity from "./ToolActivity" const {Text} = Typography @@ -48,10 +49,15 @@ interface AgentMessageProps { /** This is the last message AND the conversation is streaming — i.e. the one being * generated right now. Only it shows the loading state; settled turns never do. */ isStreaming?: boolean + /** This is the last message in the conversation. A parked client tool only lands on the last + * turn, so the unknown-client-tool fallback only arms there (see `isClientToolPart`). */ + isLastMessage?: boolean /** Stable across renders (parent passes a `useCallback`'d handler) so the `memo()` below * isn't defeated — the message to rewind to is passed in, not closed over per render. */ onRewind: (message: UIMessage) => void onApprovalResponse: (args: {id: string; approved: boolean}) => void + /** Settle a parked client tool (#4920) — the dispatcher calls this from a widget. */ + onClientToolOutput: ClientToolOutputHandler /** The previous turn was also an empty (content-less) assistant turn. Used to collapse a * run of "no response" bubbles down to the first one. */ precededByEmptyAssistant?: boolean @@ -164,8 +170,10 @@ const avatarFor = (isUser: boolean) => ( const AgentMessage = ({ message, isStreaming = false, + isLastMessage = false, onRewind, onApprovalResponse, + onClientToolOutput, precededByEmptyAssistant = false, }: AgentMessageProps) => { const openTraceDrawer = useSetAtom(openTraceDrawerAtom) @@ -240,9 +248,16 @@ const AgentMessage = ({ type RenderItem = | {kind: "part"; part: UIMessage["parts"][number]; index: number} | {kind: "tools"; parts: ToolUIPart[]; index: number} + | {kind: "clientTool"; part: ToolUIPart; index: number} const renderItems: RenderItem[] = [] message.parts.forEach((part, i) => { if (isToolPart(part.type)) { + // A browser-fulfilled client tool (#4920) renders as its own widget/chip, NOT folded + // into the "Used N tools" group — so it breaks any current tool run. + if (isClientToolPart(part as ToolUIPart, {isStreaming, isLastMessage})) { + renderItems.push({kind: "clientTool", part: part as ToolUIPart, index: i}) + return + } const last = renderItems[renderItems.length - 1] if (last && last.kind === "tools") last.parts.push(part as ToolUIPart) else renderItems.push({kind: "tools", parts: [part as ToolUIPart], index: i}) @@ -320,6 +335,15 @@ const AgentMessage = ({ /> ) } + if (item.kind === "clientTool") { + return ( + + ) + } return renderLeafPart(item.part, item.index) })} diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/ClientToolPart.tsx b/web/oss/src/components/AgentChatSlice/components/clientTools/ClientToolPart.tsx new file mode 100644 index 0000000000..e1acdea98b --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/clientTools/ClientToolPart.tsx @@ -0,0 +1,57 @@ +/** + * Client-tool dispatcher (#4920) — the sibling to `ToolActivity` that renders a single client-tool + * part. It resolves the widget by `render.kind` → `toolName` (the registry) and falls back to the + * explicit "can't handle that" surface for an unknown client tool. The widget settles the part via + * `settle`, which calls the panel's `addToolOutput`; the resume predicate then auto-resends. + */ +import {createElement, memo, useCallback} from "react" + +import type {ToolUIPart} from "ai" + +import {clientToolMeta} from "./meta" +import {resolveClientToolHandler} from "./registry" +import UnhandledClientTool from "./UnhandledClientTool" + +/** Settle a parked client tool. The panel maps this onto `addToolOutput` (success or error). */ +export type ClientToolOutputHandler = (args: { + toolName: string + toolCallId: string + output?: Record + errorText?: string +}) => void + +const ClientToolPart = ({ + part, + onOutput, +}: { + part: ToolUIPart + onOutput: ClientToolOutputHandler +}) => { + const meta = clientToolMeta(part) + // The handler is a STABLE module-level component picked from the registry (not created during + // render), so dispatch via `createElement` — `` would trip the static-components rule. + const handler = resolveClientToolHandler(meta) ?? UnhandledClientTool + + const settle = useCallback( + (args: {output: Record} | {errorText: string}) => { + if ("errorText" in args) { + onOutput({ + toolName: meta.toolName, + toolCallId: meta.toolCallId, + errorText: args.errorText, + }) + } else { + onOutput({ + toolName: meta.toolName, + toolCallId: meta.toolCallId, + output: args.output, + }) + } + }, + [onOutput, meta.toolName, meta.toolCallId], + ) + + return createElement(handler, {meta, settle}) +} + +export default memo(ClientToolPart) diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/ConnectToolWidget.tsx b/web/oss/src/components/AgentChatSlice/components/clientTools/ConnectToolWidget.tsx new file mode 100644 index 0000000000..a90bcbd0b5 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/clientTools/ConnectToolWidget.tsx @@ -0,0 +1,305 @@ +/** + * Connect widget — the `request_connection` / `render.kind: "connect"` client tool (#4920). + * + * The agent asked for a connection it lacks (e.g. GitHub). This widget runs the Agenta OAuth flow in + * the playground and settles the parked call with a **reference, never a secret**: the runner + * re-resolves the credential from the project vault on resume. It reuses the existing connection + * machinery (`useToolsConnections` → `POST /tools/connections/`, then a popup on the returned + * `redirect_url`) rather than reinventing the OAuth call. + * + * Security (hard requirement, design §"Security"): the popup posts back a `tools:oauth:complete` + * message; we trust it ONLY when `event.origin` equals the Agenta API origin (the callback page's + * origin) and the payload shape matches. Everything else is dropped. + * + * Settle on every terminal path (design §"Settle on every path"), so the run never hangs: + * success → {connected:true, integration, slug} · cancel/abandon → {connected:false, + * reason:"cancelled"} · timeout → {connected:false, reason:"timeout"} · failure → errorText. + * + * Result UX is U1 — an inline status chip in the same visual language as approve/deny: "Connect + * GitHub" → "Connecting GitHub…" → "GitHub connected" ✓, or "Connection not completed" + Retry. + */ +import {useCallback, useEffect, useRef, useState} from "react" + +import {ArrowClockwise, CheckCircle, Plugs, Spinner, Warning} from "@phosphor-icons/react" +import {Button, Typography} from "antd" + +import {getAgentaApiUrl} from "@/oss/lib/helpers/api" + +import {useToolsConnections} from "../../../pages/settings/Tools/hooks/useToolsConnections" + +import type {ClientToolHandlerProps} from "./types" + +const {Text} = Typography + +/** + * No terminal signal within this bound settles the call as a timeout so the run can't wait forever. + * Armed only once the popup is open (the user is mid-flow). 3 minutes covers a real OAuth consent. + * NOTE for Mahmoud: confirm the bound — open question §"abandon timeout". + */ +const CONNECT_TIMEOUT_MS = 180_000 +/** Popup-closed poll cadence, matching the existing ConnectModal. */ +const POPUP_POLL_MS = 1000 + +/** The settled call's reference shape (what the runner re-resolves against). */ +interface ConnectOutput { + connected?: boolean + integration?: string + slug?: string + reason?: string +} + +/** `github` → `GitHub`-ish: a readable label without a provider catalog lookup. */ +const prettyIntegration = (key: string): string => + key ? key.charAt(0).toUpperCase() + key.slice(1) : "the service" + +/** Read the API origin the OAuth callback page posts from; null if it can't be resolved. */ +const agentaApiOrigin = (): string | null => { + try { + const url = getAgentaApiUrl() + if (!url) return null + return new URL(url, typeof window !== "undefined" ? window.location.href : undefined).origin + } catch { + return null + } +} + +type Phase = "idle" | "connecting" | "error" + +const ConnectToolWidget = ({meta, settle}: ClientToolHandlerProps) => { + const input = (meta.input ?? {}) as Record + const integration = typeof input.integration === "string" ? input.integration : "" + // Connection slug: the call may pin one; default to the integration key. The output carries it + // back as the reference the runner re-resolves. + const slug = + typeof input.slug === "string" && input.slug ? input.slug : integration || "default" + const mode = input.mode === "api_key" ? "api_key" : "oauth" + const label = prettyIntegration(integration) + + const {handleCreate, invalidate} = useToolsConnections(integration) + + const [phase, setPhase] = useState("idle") + const [errorText, setErrorText] = useState(null) + // A retry started AFTER the parked call already settled (as a failure) succeeded. The settled + // part can't be re-resolved, but the connection now exists in the vault, so we flip the chip to + // "connected" — the agent's re-ask resolves cleanly on its next turn. + const [manuallyConnected, setManuallyConnected] = useState(false) + + // One-shot guard so the parked call settles exactly once, plus shared cleanup for the running + // popup's listener/poll/timeout. + const settledRef = useRef(false) + const popupRef = useRef(null) + const cleanupRef = useRef<(() => void) | null>(null) + + const teardown = useCallback(() => { + cleanupRef.current?.() + cleanupRef.current = null + popupRef.current = null + }, []) + + // Settle the parked part exactly once (success/cancel/timeout/failure all route through here). + const finish = useCallback( + (result: ConnectOutput | {errorText: string}) => { + if (settledRef.current) return + settledRef.current = true + teardown() + if ("errorText" in result) settle({errorText: result.errorText}) + else settle({output: result as Record}) + }, + [settle, teardown], + ) + + useEffect(() => () => teardown(), [teardown]) + + /** + * Run the Agenta OAuth flow: create the connection, open the popup, and watch its three terminal + * signals (origin-validated success message, popup closed without success, or timeout backstop). + * + * `settleParkedCall` distinguishes the two callers: + * - the live parked interaction (`true`): each terminal signal settles the parked tool call so + * the run resumes; + * - a manual retry after the call already settled (`false`): nothing to settle, so success just + * flips the local "connected" chip and primes the vault for the agent's re-ask. + */ + const runConnect = useCallback( + async (settleParkedCall: boolean) => { + if (phase === "connecting") return + if (settleParkedCall && settledRef.current) return + setErrorText(null) + setPhase("connecting") + try { + const result = await handleCreate({slug, name: slug, mode}) + const redirectUrl = + typeof result.connection?.data?.redirect_url === "string" + ? result.connection.data.redirect_url + : undefined + + const onSuccess = () => { + invalidate() + if (settleParkedCall) finish({connected: true, integration, slug}) + else { + setManuallyConnected(true) + setPhase("idle") + } + } + + if (!redirectUrl) { + // No OAuth step (e.g. api_key created inline): the connection already exists. + onSuccess() + return + } + + const popup = window.open( + redirectUrl, + "tools_oauth", + "width=600,height=700,popup=yes", + ) + if (!popup) { + setPhase("error") + setErrorText("Couldn’t open the connection window. Allow popups and retry.") + return + } + popupRef.current = popup + + const apiOrigin = agentaApiOrigin() + let succeeded = false + + const onMessage = (event: MessageEvent) => { + // HARD requirement: only trust the callback from the Agenta API origin. + if (apiOrigin && event.origin !== apiOrigin) return + const data = event.data as {type?: unknown} | null + if (!data || data.type !== "tools:oauth:complete") return + succeeded = true + teardown() + onSuccess() + } + window.addEventListener("message", onMessage) + + const poll = window.setInterval(() => { + if (!popupRef.current?.closed || succeeded) return + // Abandon: closed without a success message. + teardown() + if (settleParkedCall) + finish({connected: false, integration, slug, reason: "cancelled"}) + else setPhase("idle") + }, POPUP_POLL_MS) + + const timeout = window.setTimeout(() => { + if (succeeded) return + teardown() + if (settleParkedCall) + finish({connected: false, integration, slug, reason: "timeout"}) + else setPhase("idle") + }, CONNECT_TIMEOUT_MS) + + cleanupRef.current = () => { + window.removeEventListener("message", onMessage) + window.clearInterval(poll) + window.clearTimeout(timeout) + try { + popupRef.current?.close() + } catch { + // best effort + } + } + } catch (err) { + const message = err instanceof Error ? err.message : "Connection failed." + // A create failure is terminal for the parked call: settle so the run resumes; for a + // manual retry just surface the reason with another Retry. + setPhase("error") + setErrorText(message) + if (settleParkedCall) finish({connected: false, integration, slug, reason: message}) + } + }, + [phase, handleCreate, slug, mode, invalidate, finish, teardown, integration], + ) + + // Explicit cancel while the popup is open: settle the parked call as cancelled (or, for a manual + // retry, just stop). + const cancel = useCallback(() => { + teardown() + if (!settledRef.current) finish({connected: false, integration, slug, reason: "cancelled"}) + else setPhase("idle") + }, [finish, teardown, integration, slug]) + + // ── Connecting: popup open (either the live flow or a manual retry) ────────────────────────── + if (phase === "connecting") { + return ( + }> + + Connecting {label}… + + + + ) + } + + // ── Settled: the result chip (U1) ─────────────────────────────────────────────────────────── + if (meta.settled) { + const output = (meta.output ?? {}) as ConnectOutput + if (manuallyConnected || output.connected === true) { + return ( + } + > + {label} connected + + ) + } + // Cancelled / timeout / failed: a Retry re-runs the OAuth fresh (the parked call already + // resolved, so this primes the vault and flips the chip on success). + return ( + }> + + Connection not completed + + runConnect(false)} /> + + ) + } + + // ── Error (create failed, popup blocked) on the live flow: show reason + Retry ─────────────── + if (phase === "error") { + return ( + }> + + {errorText ?? "Connection failed."} + + runConnect(true)} /> + + ) + } + + // ── Idle: the initial prompt ──────────────────────────────────────────────────────────────── + return ( + }> + Connect {label} + + + ) +} + +/** A compact tool-activity row, matching ToolActivity's visual language. */ +const ChipRow = ({icon, children}: {icon: React.ReactNode; children: React.ReactNode}) => ( +
+ {icon} + {children} +
+) + +const RetryButton = ({onClick, disabled}: {onClick: () => void; disabled?: boolean}) => ( + +) + +export default ConnectToolWidget diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/UnhandledClientTool.tsx b/web/oss/src/components/AgentChatSlice/components/clientTools/UnhandledClientTool.tsx new file mode 100644 index 0000000000..1abe6d32f6 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/clientTools/UnhandledClientTool.tsx @@ -0,0 +1,33 @@ +/** + * Fallback surface for a parked client tool with no registered widget (design §"Where dispatch + * lives"). It must SETTLE the part so the run never hangs silently: on mount it settles an error, + * which resumes the run and lets the agent re-ask or move on. The row stays as a brief explanation. + */ +import {useEffect, useRef} from "react" + +import {Warning} from "@phosphor-icons/react" +import {Typography} from "antd" + +import type {ClientToolHandlerProps} from "./types" + +const {Text} = Typography + +const UnhandledClientTool = ({meta, settle}: ClientToolHandlerProps) => { + const settledRef = useRef(false) + useEffect(() => { + if (settledRef.current || meta.settled) return + settledRef.current = true + settle({errorText: `This app can’t handle the "${meta.toolName}" request.`}) + }, [meta.settled, meta.toolName, settle]) + + return ( +
+ + + Can’t handle the “{meta.toolName}” request + +
+ ) +} + +export default UnhandledClientTool diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/index.ts b/web/oss/src/components/AgentChatSlice/components/clientTools/index.ts new file mode 100644 index 0000000000..dcac8668ae --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/clientTools/index.ts @@ -0,0 +1,9 @@ +/** + * Client-tool round-trip (#4920): a browser-fulfilled tool the runner emits-and-parks, dispatched + * here by `render.kind` → `toolName` → an explicit "can't handle that" fallback. v1 ships the + * connect widget (`request_connection`). See `types.ts` for the contract. + */ +export {default as ClientToolPart, type ClientToolOutputHandler} from "./ClientToolPart" +export {clientToolMeta, isClientToolPart, clientToolName} from "./meta" +export {hasClientToolHandler, resolveClientToolHandler} from "./registry" +export type {ClientToolMeta, ClientToolHandlerProps} from "./types" diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/meta.ts b/web/oss/src/components/AgentChatSlice/components/clientTools/meta.ts new file mode 100644 index 0000000000..36ca4a7411 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/clientTools/meta.ts @@ -0,0 +1,66 @@ +/** + * Normalise a tool UI part into the {@link ClientToolMeta} the dispatcher reads, and decide whether + * a part is a client tool the playground must fulfill (vs an ordinary server tool or an approval + * gate, which `ToolActivity` owns). + */ +import type {ToolUIPart} from "ai" + +import {hasClientToolHandler} from "./registry" +import type {ClientToolMeta} from "./types" + +const SETTLED = new Set(["output-available", "output-error"]) +const APPROVAL = new Set(["approval-requested", "approval-responded"]) + +/** Friendly tool name: `tool-` carries it in the type; `dynamic-tool` on `toolName`. */ +export const clientToolName = (part: ToolUIPart): string => { + const type = part.type as string + if (type === "dynamic-tool") return (part as {toolName?: string}).toolName || "tool" + return type.replace(/^tool-/, "") +} + +/** Read the optional render hint off the part (may be absent on the wire in v1). */ +const renderKindOf = (part: ToolUIPart): string | undefined => { + const render = (part as {render?: {kind?: unknown}}).render + return render && typeof render.kind === "string" ? render.kind : undefined +} + +export const clientToolMeta = (part: ToolUIPart): ClientToolMeta => { + const state = part.state as string + return { + toolCallId: part.toolCallId, + toolName: clientToolName(part), + renderKind: renderKindOf(part), + state, + input: (part as {input?: unknown}).input, + output: (part as {output?: unknown}).output, + settled: SETTLED.has(state), + part, + } +} + +/** + * Whether a tool part is a client tool the playground renders (a widget or a settled chip), rather + * than letting it fall through to `ToolActivity`. Two ways a part qualifies: + * + * 1. **Known client tool** — its `render.kind`/`toolName` is in the registry. Rendered in every + * state so the result UX (chip) shows after it settles. + * 2. **Parked unknown client tool** — the turn has finished (not streaming) yet a non-provider- + * executed tool part is still unsettled and is not an approval gate. The runner only leaves a + * part in this "turn done, part unsettled, not providerExecuted" state for a client tool, so we + * surface the explicit "can't handle that" widget (which settles the part so it never hangs). + */ +export const isClientToolPart = ( + part: ToolUIPart, + ctx: {isStreaming: boolean; isLastMessage: boolean}, +): boolean => { + const state = part.state as string + if (APPROVAL.has(state)) return false + if ((part as {providerExecuted?: boolean}).providerExecuted === true) return false + + const meta = clientToolMeta(part) + if (hasClientToolHandler(meta)) return true + + // Parked unknown client tool: the run ended with this part still unsettled. + const parkedUnsettled = !ctx.isStreaming && ctx.isLastMessage && !meta.settled + return parkedUnsettled +} diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/registry.tsx b/web/oss/src/components/AgentChatSlice/components/clientTools/registry.tsx new file mode 100644 index 0000000000..c1b033ba88 --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/clientTools/registry.tsx @@ -0,0 +1,39 @@ +/** + * Client-tool handler registry (#4920). + * + * Dispatch precedence is **`render.kind` → `toolName` → generic fallback** (design §"Where dispatch + * lives"). v1 ships exactly one real entry, `request_connection` (the connect widget), keyed by both + * its `render.kind` (`connect`) and its `toolName` so it dispatches whether or not the render hint + * reaches the browser (verify-first seam #1: for v1 we dispatch by `toolName`). Each later client + * tool is one added entry, not a protocol change. + * + * A streamed client tool with no entry is NOT an error here — `ClientToolPart` renders the explicit + * "this app can't handle that request" surface and settles the part so the run never hangs. + */ +import type {ComponentType} from "react" + +import ConnectToolWidget from "./ConnectToolWidget" +import type {ClientToolHandlerProps, ClientToolMeta} from "./types" + +type ClientToolHandler = ComponentType + +/** Handlers keyed by `render.kind` (checked first — the finer dispatch axis). */ +const BY_RENDER_KIND: Record = { + connect: ConnectToolWidget, +} + +/** Handlers keyed by `toolName` (checked when no render hint matched). */ +const BY_TOOL_NAME: Record = { + request_connection: ConnectToolWidget, +} + +/** Resolve the widget for a client tool, or `null` when none is registered. */ +export const resolveClientToolHandler = (meta: ClientToolMeta): ClientToolHandler | null => { + if (meta.renderKind && BY_RENDER_KIND[meta.renderKind]) return BY_RENDER_KIND[meta.renderKind] + if (BY_TOOL_NAME[meta.toolName]) return BY_TOOL_NAME[meta.toolName] + return null +} + +/** Whether this client tool has a dedicated widget (used to route known tools in every state). */ +export const hasClientToolHandler = (meta: ClientToolMeta): boolean => + resolveClientToolHandler(meta) !== null diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/types.ts b/web/oss/src/components/AgentChatSlice/components/clientTools/types.ts new file mode 100644 index 0000000000..e5d553fb2a --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/components/clientTools/types.ts @@ -0,0 +1,52 @@ +/** + * Shared types for the client-tool round-trip (#4920). + * + * A *client tool* is a tool the playground fulfills, not the sandbox. The runner streams the call + * as a standard unsettled tool part (`tool-input-available`, no output, `providerExecuted` falsy) + * and parks the turn. The playground dispatches a widget; the widget drives the interaction and + * settles the part with a structured **reference** (never a secret) via `addToolOutput`. The + * `sendAutomaticallyWhen` predicate then auto-resends and the runner resumes on cold-replay. + * + * These types are structural (no `ai` import) so the dispatcher, registry, and widgets agree on the + * one shape they read off a UI message part. + */ +import type {ToolUIPart} from "ai" + +/** The optional presentation hint that rides the one-way render channel (`data-`). v1 may not + * see it on the wire — dispatch falls back to `toolName` — but the shape is reserved so a future + * `render.kind` (e.g. `config-diff`) lands with no protocol change. */ +export interface ClientToolRenderHint { + kind?: string +} + +/** + * Normalised view of a tool part the dispatcher works with. `toolName` is read off the typed + * `tool-` part type or a `dynamic-tool`'s `toolName`; `renderKind` off the (optional) render + * hint. `state` is the AI SDK tool-part state machine value. + */ +export interface ClientToolMeta { + toolCallId: string + toolName: string + renderKind?: string + state: string + input: unknown + output: unknown + /** A browser-fulfilled result already settled it (`output-available`/`output-error`). */ + settled: boolean + /** The raw part, for handlers that need fields beyond the normalised view. */ + part: ToolUIPart +} + +/** Settle the parked part. Mirrors `addToolOutput` but keyed by the values a widget already holds. + * Exactly one of `output` / `errorText` is supplied (success vs error envelope). */ +export interface SettleClientTool { + (args: {output: Record}): void + (args: {errorText: string}): void +} + +/** Props every client-tool widget receives. */ +export interface ClientToolHandlerProps { + meta: ClientToolMeta + /** Settle the part (resumes the run). No-op once already settled. */ + settle: SettleClientTool +} diff --git a/web/packages/agenta-entities/src/workflow/api/api.ts b/web/packages/agenta-entities/src/workflow/api/api.ts index 7f0f17321b..658f262fcd 100644 --- a/web/packages/agenta-entities/src/workflow/api/api.ts +++ b/web/packages/agenta-entities/src/workflow/api/api.ts @@ -17,6 +17,7 @@ import {getAgentaSdkClient} from "@agenta/sdk" import {getAgentaApiUrl, axios} from "@agenta/shared/api" import {dereferenceSchema, generateId} from "@agenta/shared/utils" +import {z} from "zod" import {parseRevisionUri, safeParseWithLogging} from "../../shared" import {extractAllEndpointSchemas, type OpenAPISpec} from "../../shared/openapi" @@ -486,6 +487,82 @@ export async function inspectWorkflow( return response.data ?? {} } +// ============================================================================ +// SIMPLE APPLICATION FETCH (carries the read-only playground build-kit overlay) +// ============================================================================ + +/** + * Response shape from `GET /simple/applications/{application_id}`. + * + * The playground build kit's `agent_template_overlay` rides here, on + * `additional_context` — the backend's read-only, platform-derived container on + * `SimpleApplicationResponse`. The agent-service `/inspect` response carries no + * behavior-changing meta, so the overlay is read from this app fetch instead. + */ +export interface SimpleApplicationFetchResponse { + count?: number + application?: Record | null + additional_context?: { + playground_build_kit?: { + agent_template_overlay?: Record | null + } | null + } | null +} + +// Boundary validation for the inspect/fetch overlay. Kept permissive — the overlay is a free-form +// `parameters.agent` subset (platform ops + `@ag.embed` refs) — but it gates the shape that reaches +// `workflowAgentTemplateOverlayAtomFamily`, so a non-object overlay is rejected here, not downstream. +const simpleApplicationFetchResponseSchema = z.object({ + count: z.number().optional(), + application: z.record(z.string(), z.unknown()).nullable().optional(), + additional_context: z + .object({ + playground_build_kit: z + .object({ + agent_template_overlay: z.record(z.string(), z.unknown()).nullable().optional(), + }) + .nullable() + .optional(), + }) + .nullable() + .optional(), +}) + +/** + * Fetch a single simple application by id. + * + * Endpoint: `GET /simple/applications/{application_id}`. Returns the app with + * its current variant/revision `data` merged, plus `additional_context` holding + * the playground-only build-kit overlay. + * + * @param applicationId - The application (workflow artifact) id + * @param projectId - Project ID + */ +export async function fetchSimpleApplication( + applicationId: string, + projectId: string, +): Promise { + if (!projectId || !applicationId) return null + + // Fern-generated client (single source of truth for the request/response + // shape). Its types under-declare the backend's `extra="allow"` + // `additional_context`, so it is read defensively below. + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + const data = await client.applications.fetchSimpleApplication( + {application_id: applicationId}, + {queryParams: {project_id: projectId}}, + ) + + // Validate at the boundary before the overlay reaches the atom family. Fern under-declares the + // backend's `extra="allow"` `additional_context`, so the local schema is the drift check. + const validated = safeParseWithLogging( + simpleApplicationFetchResponseSchema, + data, + "[fetchSimpleApplication]", + ) + return (validated ?? null) as SimpleApplicationFetchResponse | null +} + // ============================================================================ // INTERFACE SCHEMAS FETCH (for builtin workflows) // ============================================================================ diff --git a/web/packages/agenta-entities/src/workflow/api/index.ts b/web/packages/agenta-entities/src/workflow/api/index.ts index 996bc01412..7494aa11b8 100644 --- a/web/packages/agenta-entities/src/workflow/api/index.ts +++ b/web/packages/agenta-entities/src/workflow/api/index.ts @@ -19,6 +19,9 @@ export { // Inspect (resolve full schema including inputs) inspectWorkflow, type InspectWorkflowResponse, + // Simple application fetch (carries the playground build-kit overlay) + fetchSimpleApplication, + type SimpleApplicationFetchResponse, // Interface schemas fetch (builtin workflow fallback) fetchInterfaceSchemas, type InterfaceSchemasResponse, diff --git a/web/packages/agenta-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts index 9c520ff89b..56a13b809d 100644 --- a/web/packages/agenta-entities/src/workflow/index.ts +++ b/web/packages/agenta-entities/src/workflow/index.ts @@ -53,6 +53,12 @@ export { type HarnessCapabilitiesMap, } from "./state/inspectMeta" +export { + workflowAgentTemplateOverlayAtomFamily, + workflowBuildKitEnabledAtomFamily, + type AgentTemplate, +} from "./state" + // ============================================================================ // SCHEMAS & TYPES // ============================================================================ @@ -230,6 +236,7 @@ export { // Cache invalidation invalidateWorkflowsListCache, invalidateWorkflowCache, + invalidateAgentCommittedRevisionCache, seedCreatedWorkflowCache, // ListQueryState wrappers (for selection adapters and relations) workflowVariantsListQueryStateAtomFamily, diff --git a/web/packages/agenta-entities/src/workflow/state/commit.ts b/web/packages/agenta-entities/src/workflow/state/commit.ts index 48ab23ef6c..2a33490be1 100644 --- a/web/packages/agenta-entities/src/workflow/state/commit.ts +++ b/web/packages/agenta-entities/src/workflow/state/commit.ts @@ -69,6 +69,8 @@ function prepareCommitParameters( entity: Workflow, flatParams: Record | null, ): Record | undefined { + // The playground build-kit overlay lives in read-only additional_context/session atoms. Commit + // reads only the user-owned revision config here, so platform ops and sandbox elevation stay out. const rawParams = stripAgentaMetadataDeep(entity.data?.parameters) as | Record | undefined diff --git a/web/packages/agenta-entities/src/workflow/state/index.ts b/web/packages/agenta-entities/src/workflow/state/index.ts index 9e031e2a4b..57bc315ad7 100644 --- a/web/packages/agenta-entities/src/workflow/state/index.ts +++ b/web/packages/agenta-entities/src/workflow/state/index.ts @@ -56,12 +56,16 @@ export { workflowEntityAtomFamily, workflowIsDirtyAtomFamily, workflowIsEphemeralAtomFamily, + workflowAgentTemplateOverlayAtomFamily, + workflowBuildKitEnabledAtomFamily, + type AgentTemplate, // Mutations updateWorkflowDraftAtom, discardWorkflowDraftAtom, // Cache invalidation invalidateWorkflowsListCache, invalidateWorkflowCache, + invalidateAgentCommittedRevisionCache, seedCreatedWorkflowCache, // ListQueryState wrappers (for selection adapters and relations) workflowVariantsListQueryStateAtomFamily, diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index 55cc8abd76..7db3b52e8c 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -23,11 +23,17 @@ import {nestEvaluatorConfiguration, nestEvaluatorSchema} from "../../runnable/ev import {syncPromptInputKeysInParameters} from "../../runnable/utils" import type {StoreOptions, ListQueryState} from "../../shared" import {generateLocalId, isLocalDraftId, isPlaceholderId} from "../../shared" -import type {InspectWorkflowResponse, InterfaceSchemasResponse, AppOpenApiSchemas} from "../api" +import type { + InspectWorkflowResponse, + InterfaceSchemasResponse, + AppOpenApiSchemas, + SimpleApplicationFetchResponse, +} from "../api" import { extractDefaultsFromSchema, fetchWorkflowRevisionsByIdsBatch, inspectWorkflow, + fetchSimpleApplication, fetchWorkflowAppOpenApiSchema, fetchAgTypeSchema, fetchWorkflowsBatch, @@ -1097,6 +1103,59 @@ export const workflowInspectAtomFamily = atomFamily((revisionId: string) => }), ) +// ============================================================================ +// PLAYGROUND BUILD KIT SESSION STATE +// ============================================================================ + +export type AgentTemplate = Record + +/** + * Fetch the simple-application envelope for one app id. + * + * The playground build-kit overlay rides on this response's + * `additional_context`, not on the agent-service `/inspect` response (which + * carries no behavior-changing meta by design). + */ +export const simpleApplicationQueryAtomFamily = atomFamily((applicationId: string) => + atomWithQuery((get) => { + const projectId = get(workflowProjectIdAtom) + return { + queryKey: ["simpleApplication", applicationId, projectId], + queryFn: async (): Promise => { + if (!projectId || !applicationId) return null + return fetchSimpleApplication(applicationId, projectId) + }, + enabled: get(sessionAtom) && !!projectId && !!applicationId, + staleTime: 60_000, + refetchOnWindowFocus: false, + } + }), +) + +export const workflowAgentTemplateOverlayAtomFamily = atomFamily((revisionId: string) => + atom((get) => { + // The app id (workflow artifact id) the revision belongs to. Server data + // wins; fall back to the local base entity so a draft agent still resolves. + const revisionData = get(workflowQueryAtomFamily(revisionId)).data ?? null + const applicationId = + revisionData?.workflow_id ?? + get(workflowBaseEntityAtomFamily(revisionId))?.workflow_id ?? + null + if (!applicationId) return null + + const appData = get(simpleApplicationQueryAtomFamily(applicationId)).data ?? null + const overlay = + appData?.additional_context?.playground_build_kit?.agent_template_overlay ?? null + return overlay && typeof overlay === "object" && !Array.isArray(overlay) + ? (overlay as AgentTemplate) + : null + }), +) + +export const workflowBuildKitEnabledAtomFamily = atomFamily((_revisionId: string) => + atom(true), +) + // ============================================================================ // AG-TYPE SCHEMA QUERY (resolves x-ag-type-ref targets into full schemas) // ============================================================================ @@ -2473,3 +2532,25 @@ export function invalidateWorkflowRevisionsByVariantCache( } store.set(workflowRevisionsQueryAtomFamily(variantId)) } + +/** + * Refresh the playground's read-only views after the agent commits a new revision of itself + * (#4920 — Application 1). A commit lands a new revision, so this invalidates both the + * latest-revision query (the config panel + section drawers re-read the new config) and the inspect + * query (harness-capabilities / any inspect-derived view). + * + * Fired on the one-way `data-committed-revision` stream signal (which the backend emits in BOTH the + * gated approval path and the direct `needs_approval=false` path), not on approval — so a single + * emit point covers both. The payload's ids aren't needed: a prefix invalidation refetches every + * active observer, which is exactly the set the playground has mounted. + */ +export function invalidateAgentCommittedRevisionCache(options?: StoreOptions) { + const store = getStore(options) + try { + const qc = store.get(queryClientAtom) + qc.invalidateQueries({queryKey: ["workflows", "latestRevision"], exact: false}) + qc.invalidateQueries({queryKey: ["workflows", "inspect"], exact: false}) + } catch { + // queryClientAtom may not be initialized yet + } +} 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 f0b7401682..80dc100851 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 @@ -173,7 +173,7 @@ export function describeMcp(server: unknown): ItemDescriptor { } } -function asObj(value: unknown): Record | undefined { +export function asObj(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined @@ -194,13 +194,22 @@ export function isEmbedRefSkill(skill: unknown): boolean { const STATIC_SKILL_SLUG_PREFIX = "__ag__" /** The slug an `@ag.embed` entry points at (a `workflow` or pinned `workflow_revision` reference). */ -function staticEmbedSlug(skill: Record): string | undefined { +export function staticEmbedSlug(skill: Record): string | undefined { const refs = asObj(asObj(skill["@ag.embed"])?.["@ag.references"]) if (!refs) return undefined const slug = asObj(refs.workflow)?.slug ?? asObj(refs.workflow_revision)?.slug return typeof slug === "string" ? slug : undefined } +/** Display name for an embedded skill: the embed's sibling `name`, else the referenced workflow's + * `name`. Callers fall back to the slug when this is undefined. */ +export function staticEmbedName(skill: Record): string | undefined { + if (typeof skill.name === "string" && skill.name) return skill.name + const refs = asObj(asObj(skill["@ag.embed"])?.["@ag.references"]) + const wfName = asObj(refs?.workflow)?.name ?? asObj(refs?.workflow_revision)?.name + return typeof wfName === "string" && wfName ? wfName : undefined +} + /** A pinned revision's version, when the embed references a `workflow_revision`. */ function embedRevisionVersion(skill: Record): string | undefined { const refs = asObj(asObj(skill["@ag.embed"])?.["@ag.references"]) @@ -228,7 +237,7 @@ export function describeSkill(skill: unknown): ItemDescriptor { const slug = staticEmbedSlug(s) const version = embedRevisionVersion(s) return { - name: slug ?? "Static skill", + name: staticEmbedName(s) ?? slug ?? "Static skill", mono: "sk", color: "#6b7280", tags: version ? ["static", `v${version}`] : ["static"], @@ -238,7 +247,7 @@ export function describeSkill(skill: unknown): ItemDescriptor { } if (isEmbedRefSkill(s)) { return { - name: "Skill reference", + name: staticEmbedName(s) ?? staticEmbedSlug(s) ?? "Skill reference", mono: "sk", color: "#b45309", tags: ["@ag.embed"], diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useBuildKit.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useBuildKit.tsx new file mode 100644 index 0000000000..662745e689 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useBuildKit.tsx @@ -0,0 +1,312 @@ +/** + * useBuildKit — the playground-only "build kit" overlay shown in the Advanced section. + * + * The default agent config carries a server-side overlay of playground-only tools, skills, and + * sandbox permissions that help the assistant build and revise the agent. None of it is part of the + * published agent (the backend strips it on commit). This hook reads that overlay (keyed by the open + * revision) plus the user's build-kit on/off toggle, and returns: + * - `hasBuildKitOverlay`: whether to render the build-kit block / extend the Advanced section, + * - `buildKitSection`: the read-only drawer block (platform tools, embedded tools/skills, sandbox + * permissions) with the enable/disable switch, + * - `permissionOverrideHint`: the inline warning to show above SandboxPermissionControl when the + * build kit overrides one of the user's permission values. + * + * Kept beside useModelHarness (which owns the Advanced section) so the overlay and the user's own + * sandbox/permission controls render together. + */ +import {useMemo, useState} from "react" + +import { + workflowAgentTemplateOverlayAtomFamily, + workflowBuildKitEnabledAtomFamily, +} from "@agenta/entities/workflow" +import {cn} from "@agenta/ui/styles" +import {CaretRight, Warning, Wrench} from "@phosphor-icons/react" +import {Switch, Tag, Tooltip, Typography} from "antd" +import {useAtom, useAtomValue} from "jotai" + +import {asObj, staticEmbedSlug, type ItemDescriptor} from "./itemDescriptors" +import {ItemAvatar} from "./ItemRow" + +/** Display name for an `@ag.embed` row: the overlay's sibling `name`, else the referenced + * workflow's `name`, else undefined (callers fall back to the slug). */ +function embedDisplayName(entry: Record): string | undefined { + if (typeof entry.name === "string" && entry.name) return entry.name + const refs = asObj(asObj(entry["@ag.embed"])?.["@ag.references"]) + const wfName = asObj(refs?.workflow)?.name ?? asObj(refs?.workflow_revision)?.name + return typeof wfName === "string" && wfName ? wfName : undefined +} + +function ReadOnlyItemRow({descriptor}: {descriptor: ItemDescriptor}) { + return ( +
+ +
+
{descriptor.name}
+ {descriptor.description ? ( + + {descriptor.description} + + ) : null} +
+
+ {descriptor.tags.map((tag) => ( + + {tag} + + ))} + Locked +
+
+ ) +} + +function isEmbedRefEntry(entry: unknown): entry is Record { + return Boolean( + entry && typeof entry === "object" && "@ag.embed" in (entry as Record), + ) +} + +function describeBuildKitPlatformTool(tool: Record): ItemDescriptor { + const op = typeof tool.op === "string" ? tool.op : "platform tool" + return { + name: op, + description: "Platform-owned playground tool", + mono: "", + color: "#0d9488", + icon: , + tags: ["platform"], + typeLabel: "platform", + typeColor: "cyan", + subtitle: "Platform tool", + } +} + +function describeBuildKitEmbed( + entry: Record, + kind: "tool" | "skill", +): ItemDescriptor { + const slug = staticEmbedSlug(entry) + return { + name: embedDisplayName(entry) ?? slug ?? `${kind} reference`, + description: "Provided by Agenta. This item cannot be edited or removed.", + mono: kind === "tool" ? "wf" : "sk", + color: kind === "tool" ? "#0d9488" : "#6b7280", + tags: ["@ag.embed"], + typeLabel: "@ag.embed", + typeColor: "blue", + subtitle: "Agenta-owned reference", + } +} + +function formatPermissionValue(value: unknown): string { + if (typeof value === "string") return value + if (typeof value === "number" || typeof value === "boolean") return String(value) + if (value == null) return "null" + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function stableString(value: unknown): string { + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function overriddenPermissionKeys( + userPermissions: Record | null | undefined, + overlayPermissions: Record | null | undefined, +): string[] { + if (!userPermissions || !overlayPermissions) return [] + return Object.entries(overlayPermissions) + .filter(([key, overlayValue]) => { + if (!(key in userPermissions)) return false + return stableString(userPermissions[key]) !== stableString(overlayValue) + }) + .map(([key]) => key) +} + +export function useBuildKit({ + revisionId, + sandboxPermissions, + disabled, +}: { + revisionId: string | null + sandboxPermissions: Record | null + disabled?: boolean +}) { + const agentTemplateOverlay = useAtomValue( + useMemo(() => workflowAgentTemplateOverlayAtomFamily(revisionId ?? ""), [revisionId]), + ) + const [buildKitEnabled, setBuildKitEnabled] = useAtom( + useMemo(() => workflowBuildKitEnabledAtomFamily(revisionId ?? ""), [revisionId]), + ) + const [buildKitExpanded, setBuildKitExpanded] = useState(true) + + const overlayTools = useMemo( + () => (Array.isArray(agentTemplateOverlay?.tools) ? agentTemplateOverlay.tools : []), + [agentTemplateOverlay], + ) + const overlaySkills = useMemo( + () => (Array.isArray(agentTemplateOverlay?.skills) ? agentTemplateOverlay.skills : []), + [agentTemplateOverlay], + ) + const overlaySandbox = useMemo( + () => asObj(agentTemplateOverlay?.sandbox), + [agentTemplateOverlay], + ) + const overlayPermissions = useMemo(() => asObj(overlaySandbox?.permissions), [overlaySandbox]) + const platformOverlayTools = useMemo( + () => + overlayTools.filter((tool): tool is Record => + Boolean(asObj(tool)?.type === "platform"), + ), + [overlayTools], + ) + const embeddedOverlayTools = useMemo(() => overlayTools.filter(isEmbedRefEntry), [overlayTools]) + const embeddedOverlaySkills = useMemo( + () => overlaySkills.filter(isEmbedRefEntry), + [overlaySkills], + ) + const hasBuildKitOverlay = Boolean( + agentTemplateOverlay && + (platformOverlayTools.length > 0 || + embeddedOverlayTools.length > 0 || + embeddedOverlaySkills.length > 0 || + Object.keys(overlayPermissions ?? {}).length > 0), + ) + const sandboxPermissionOverrideKeys = useMemo( + () => + buildKitEnabled ? overriddenPermissionKeys(sandboxPermissions, overlayPermissions) : [], + [buildKitEnabled, sandboxPermissions, overlayPermissions], + ) + + const buildKitSection = hasBuildKitOverlay ? ( +
+ + {buildKitExpanded ? ( +
+

+ These playground-only tools, skills, and permissions help the assistant + build and revise this agent. None of this is part of the published agent. +

+ {!buildKitEnabled ? ( +
+ The assistant can no longer create files, run code, or edit the agent + here. +
+ ) : null} + {platformOverlayTools.length > 0 ? ( +
+ + Platform tools + + {platformOverlayTools.map((tool, index) => ( + + ))} +
+ ) : null} + {embeddedOverlayTools.length > 0 ? ( +
+ + Embedded tools + + {embeddedOverlayTools.map((tool, index) => ( + + ))} +
+ ) : null} + {embeddedOverlaySkills.length > 0 ? ( +
+ + Embedded skills + + {embeddedOverlaySkills.map((skill, index) => ( + + ))} +
+ ) : null} + {overlayPermissions && Object.keys(overlayPermissions).length > 0 ? ( +
+ + Sandbox permissions + +
+ {Object.entries(overlayPermissions).map(([key, value]) => ( +
+ {key} + + {formatPermissionValue(value)} + +
+ ))} +
+
+ ) : null} +
+ ) : null} +
+ ) : null + + const permissionOverrideHint = + sandboxPermissionOverrideKeys.length > 0 ? ( + +
+ + Build kit overrides {sandboxPermissionOverrideKeys.join(", ")} +
+
+ ) : null + + return { + hasBuildKitOverlay, + buildKitSection, + permissionOverrideHint, + } +} diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx index b5799244e2..ca877ff205 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx @@ -34,6 +34,7 @@ import {HarnessSelectControl} from "../HarnessSelectControl" import {SandboxPermissionControl} from "../SandboxPermissionControl" import {enumLabel} from "./agentTemplateUtils" +import {useBuildKit} from "./useBuildKit" export function useModelHarness({ schema, @@ -247,12 +248,22 @@ export function useModelHarness({ const hasModelOrHarness = Boolean(props.llm || harnessProps.kind) const hasClaudePermissions = harnessValue === "claude" + + // Playground-only "build kit" overlay (read-only) shown at the top of Advanced. It also flags + // sandbox-permission keys the overlay overrides for the user's own permission control below. + const {hasBuildKitOverlay, buildKitSection, permissionOverrideHint} = useBuildKit({ + revisionId, + sandboxPermissions: (sandbox.permissions as Record | null) ?? null, + disabled, + }) + const hasAdvanced = Boolean( props.llm || // Authentication lives in Advanced now sandboxProps.kind || sandboxProps.permissions || runnerProps.interactions || - hasClaudePermissions, + hasClaudePermissions || + hasBuildKitOverlay, ) // The Model picker (inspect-filtered when available, else the schema catalog). @@ -671,8 +682,15 @@ export function useModelHarness({ // Shared Advanced controls, rendered by both the wide drawer body and the tabs-inline body. const advancedControls = ( <> + {buildKitSection} + {authControls ? ( -
+
Authentication @@ -705,15 +723,19 @@ export function useModelHarness({ /> )} {sandboxProps.permissions ? ( - | null) ?? null - } - onChange={(v) => - setSection("sandbox", {...sandbox, permissions: v}) - } - disabled={disabled} - /> +
+ {permissionOverrideHint} + | null) ?? + null + } + onChange={(v) => + setSection("sandbox", {...sandbox, permissions: v}) + } + disabled={disabled} + /> +
) : null}
diff --git a/web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts b/web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts index 8dbf28ffe2..d608054bc8 100644 --- a/web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts +++ b/web/packages/agenta-playground/src/state/execution/agentApprovalResume.ts @@ -1,21 +1,26 @@ /** - * Agent-lane HITL resume predicate. + * Agent-lane resume predicate — for BOTH parked client tools and HITL approval gates. * - * `useChat`'s `sendAutomaticallyWhen` decides when the conversation auto-resends after the - * user resolves a tool-approval gate. The AI SDK ships - * `lastAssistantMessageIsCompleteWithApprovalResponses`, which DOES fire for a deny-only - * decision (a denied tool part is still `approval-responded`). We wrap it in an explicit, - * unit-tested predicate so the deny → resume contract is pinned at the FE seam rather than - * left implicit in the SDK internals: + * `useChat`'s `sendAutomaticallyWhen` decides when the conversation auto-resends after a parked + * client interaction settles. The agent FE round-trip (#4920) generalizes the existing approval + * round-trip to an arbitrary browser-fulfilled tool: the runner emits the tool call and parks the + * turn; the playground fulfills it with `addToolOutput`; the turn must then auto-resend so the + * runner cold-replays and resumes. Two settle shapes drive a resume here: * - * - Approve and Deny BOTH resume. On resume the runner receives the `{approved}` envelope - * (the SDK ingress maps an `approval-responded` tool part to a `tool_result`), maps a - * deny to reject → tool-error, and the model continues — no deadlock, no limbo - * `approval-responded` state (the F-036 dead-end). - * - A pending gate (`approval-requested`, still awaiting the user) does NOT resume. + * - **Approval response.** Approve AND Deny both resume — a denied tool part is still + * `approval-responded`, so the runner gets the denial round-trip (the SDK ingress maps it to a + * `tool_result`, a deny → tool-error) and the model continues. No `approval-responded` limbo + * (the F-036 dead-end). + * - **Client-tool result.** A parked client tool fulfilled by the browser settles to + * `output-available`/`output-error` with `providerExecuted` falsy (it was NOT run server-side). + * That fulfilled output must resume the run exactly as an approval does. * - * Pure + structurally typed so the package needs no `ai` dependency: it reads only the - * fields the AI SDK puts on a UI message (`role`, `parts[].type/state/approval`). + * A pending interaction (`approval-requested`, or a still-`input-available` client tool awaiting the + * user) does NOT resume. Server-executed tool parts (`providerExecuted === true`) are ignored — they + * settle within the turn and never park, so they neither gate nor trigger a resume. + * + * Pure + structurally typed so the package needs no `ai` dependency: it reads only the fields the AI + * SDK puts on a UI message (`role`, `parts[].type/state/providerExecuted`). */ interface ApprovalLike { @@ -43,6 +48,24 @@ const isToolPart = (part: ToolPartLike): boolean => { const isRespondedToolPart = (part: ToolPartLike): boolean => isToolPart(part) && part.state === "approval-responded" +/** + * A browser-fulfilled client-tool result: a tool part the playground settled via `addToolOutput` + * (`output-available`/`output-error`) that the server did NOT run (`providerExecuted` falsy) and + * carries NO approval metadata. This is how a parked `request_connection` (or any client tool) reads + * once the widget settles it. + * + * The `approval == null` guard is load-bearing: an approval-gated tool that was approved and then RAN + * also lands in `output-available` with `providerExecuted` falsy, but it is NOT a parked client tool + * (its turn already continued) — it keeps its `approval` field, so excluding it here stops a spurious + * resume (and stops the queue gate, which composes this predicate, from holding forever). v1 client + * tools are never approval-gated; an approval-gated client tool would need a richer signal. + */ +const isClientToolResult = (part: ToolPartLike): boolean => + isToolPart(part) && + part.providerExecuted !== true && + part.approval == null && + (part.state === "output-available" || part.state === "output-error") + /** A resolved tool part is settled when it has run, errored, or carries a decision. */ const isSettledToolPart = (part: ToolPartLike): boolean => isToolPart(part) && @@ -51,10 +74,14 @@ const isSettledToolPart = (part: ToolPartLike): boolean => part.state === "approval-responded") /** - * Resume when the last assistant turn carries at least one responded approval and EVERY - * non-provider-executed tool part on it is settled. Deny-only counts: a denied tool part is - * `approval-responded`, so a turn the user only denied still resumes and the runner gets the - * denial round-trip (the fix for the deny dead-end). + * Resume when the last assistant turn carries at least one freshly-resolved parked interaction (an + * approval response OR a browser-fulfilled client-tool result) and EVERY non-provider-executed tool + * part on it is settled. Both paths share one rule so a single `sendAutomaticallyWhen` covers + * approvals and client tools alike: + * - Approval (approve OR deny): a denied tool part is `approval-responded`, so a deny-only turn + * still resumes and the runner gets the denial round-trip (the deny dead-end fix). + * - Client tool: a `request_connection` the widget settled (success, cancel, failure, abandon) + * reads as a client-tool result, so the run resumes and the runner re-resolves on cold-replay. */ export function agentShouldResumeAfterApproval({messages}: {messages: MessageLike[]}): boolean { const last = messages[messages.length - 1] @@ -65,7 +92,9 @@ export function agentShouldResumeAfterApproval({messages}: {messages: MessageLik ) if (toolParts.length === 0) return false - const hasResponded = toolParts.some(isRespondedToolPart) + const hasResolved = toolParts.some( + (part) => isRespondedToolPart(part) || isClientToolResult(part), + ) const allSettled = toolParts.every(isSettledToolPart) - return hasResponded && allSettled + return hasResolved && allSettled } diff --git a/web/packages/agenta-playground/src/state/execution/agentRequest.ts b/web/packages/agenta-playground/src/state/execution/agentRequest.ts index 3e60aaefb4..b60cadfbdf 100644 --- a/web/packages/agenta-playground/src/state/execution/agentRequest.ts +++ b/web/packages/agenta-playground/src/state/execution/agentRequest.ts @@ -24,13 +24,23 @@ * - `project_id` / `application_id` ride the URL QUERY (never the body), and * `project_id` only travels alongside auth — mirroring `executionItems.ts`. */ -import {workflowMolecule} from "@agenta/entities/workflow" +import { + workflowAgentTemplateOverlayAtomFamily, + workflowBuildKitEnabledAtomFamily, + workflowMolecule, + type AgentTemplate, +} from "@agenta/entities/workflow" import {projectIdAtom} from "@agenta/shared/state" import {getDefaultStore} from "jotai" +import {withBuildKitOverlay} from "./buildKitOverlay" import {agentChannelModeAtom} from "./channelMode" import {executionHeadersAtom} from "./webWorkerIntegration" +// Re-exported so existing consumers keep importing it from the request builder; the merge +// implementation now lives in `buildKitOverlay.ts`. +export {applyBuildKitOverlay} from "./buildKitOverlay" + export interface AgentRequest { invocationUrl: string requestBody: Record @@ -306,10 +316,17 @@ export async function buildAgentRequest( | undefined // The execution sections (`harness`/`runner`/`sandbox`) are nested in the template at // `parameters.agent`. Default them, never overriding values the resolved config carries. - const parameters = pruneBlankEntries(withAgentRunDefaults(config ?? {})) as Record< - string, - unknown - > + const buildKitEnabled = store.get(workflowBuildKitEnabledAtomFamily(entityId)) as boolean + const agentTemplateOverlay = store.get( + workflowAgentTemplateOverlayAtomFamily(entityId), + ) as AgentTemplate | null + const parameters = pruneBlankEntries( + withBuildKitOverlay( + withAgentRunDefaults(config ?? {}) as Record, + agentTemplateOverlay, + buildKitEnabled, + ), + ) as Record const entity = store.get(workflowMolecule.selectors.data(entityId)) as | RevisionLike diff --git a/web/packages/agenta-playground/src/state/execution/buildKitOverlay.ts b/web/packages/agenta-playground/src/state/execution/buildKitOverlay.ts new file mode 100644 index 0000000000..199ba74318 --- /dev/null +++ b/web/packages/agenta-playground/src/state/execution/buildKitOverlay.ts @@ -0,0 +1,149 @@ +/** + * Build-kit overlay merge — the playground-only overlay applied to the throwaway agent run copy. + * + * Extracted from `agentRequest.ts` so the request builder stays focused on composing the + * `/invoke` envelope. None of this touches the draft or the commit tree: `applyBuildKitOverlay` + * returns new objects and the request builder applies it only to the per-run `parameters`. + * + * Merge semantics: + * - object sections (`sandbox`/`runner`/`harness`/`llm`/`instructions`) deep-merge (overlay wins + * at the leaf), + * - list sections (`tools`/`skills`/`mcps`) identity-merge: an overlay entry replaces a base entry + * with the same identity (platform op, embed slug, or name), otherwise it is appended. + */ +import {type AgentTemplate} from "@agenta/entities/workflow" + +type AgentTemplateListKey = "tools" | "skills" | "mcps" +type AgentTemplateObjectKey = "sandbox" | "runner" | "harness" | "llm" | "instructions" + +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === "object" && !Array.isArray(value)) + +const embedWorkflowSlug = (entry: unknown): string | undefined => { + if (!isRecord(entry)) return undefined + const embed = entry["@ag.embed"] + if (!isRecord(embed)) return undefined + const refs = embed["@ag.references"] + if (!isRecord(refs)) return undefined + const workflow = refs.workflow + if (isRecord(workflow) && typeof workflow.slug === "string") return workflow.slug + const revision = refs.workflow_revision + if (isRecord(revision) && typeof revision.slug === "string") return revision.slug + return undefined +} + +const deepMerge = ( + base: Record, + overlay: Record, +): Record => { + const result: Record = {...base} + for (const [key, value] of Object.entries(overlay)) { + const existing = result[key] + result[key] = isRecord(existing) && isRecord(value) ? deepMerge(existing, value) : value + } + return result +} + +const getToolIdentity = (entry: unknown): string | undefined => { + if (!isRecord(entry)) return undefined + if (entry.type === "platform" && typeof entry.op === "string") return `platform:${entry.op}` + const slug = embedWorkflowSlug(entry) + if (slug) return `workflow:${slug}` + return typeof entry.name === "string" ? `name:${entry.name}` : undefined +} + +const getSkillIdentity = (entry: unknown): string | undefined => { + const slug = embedWorkflowSlug(entry) + return slug ? `workflow:${slug}` : undefined +} + +const getMcpIdentity = (entry: unknown): string | undefined => { + if (!isRecord(entry)) return undefined + return typeof entry.name === "string" ? entry.name : undefined +} + +const identityMerge = ( + base: unknown[], + overlay: unknown[], + getIdentity: (entry: unknown) => string | undefined, +): unknown[] => { + const result = [...base] + const indexByIdentity = new Map() + result.forEach((entry, index) => { + const identity = getIdentity(entry) + if (identity) indexByIdentity.set(identity, index) + }) + overlay.forEach((entry) => { + const identity = getIdentity(entry) + const index = identity ? indexByIdentity.get(identity) : undefined + if (index !== undefined) { + result[index] = entry + return + } + if (identity) indexByIdentity.set(identity, result.length) + result.push(entry) + }) + return result +} + +export function applyBuildKitOverlay( + base: AgentTemplate, + overlay: Partial, +): AgentTemplate { + const result: AgentTemplate = {...base} + + for (const key of [ + "sandbox", + "runner", + "harness", + "llm", + "instructions", + ] as const satisfies readonly AgentTemplateObjectKey[]) { + const overlayValue = overlay[key] + if (overlayValue !== undefined) { + result[key] = deepMerge( + isRecord(base[key]) ? (base[key] as Record) : {}, + isRecord(overlayValue) ? overlayValue : {}, + ) + } + } + + const listMergers: Record string | undefined> = { + tools: getToolIdentity, + skills: getSkillIdentity, + mcps: getMcpIdentity, + } + + for (const key of Object.keys(listMergers) as AgentTemplateListKey[]) { + const overlayValue = overlay[key] + if (Array.isArray(overlayValue)) { + result[key] = identityMerge( + Array.isArray(base[key]) ? (base[key] as unknown[]) : [], + overlayValue, + listMergers[key], + ) + } + } + + return result +} + +/** + * Apply the overlay to the run parameters. Handles both shapes `buildAgentRequest` produces: a + * `{agent: