diff --git a/.env.example b/.env.example index a93ba826..437b1192 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,6 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope # -- Runtime Overrides (Optional) -- # export LOG_LEVEL=debug # Defaults to "error"; Channel lifecycle breadcrumbs log at "warn". -# export AGENT_AUTH_HEADER="Bearer ..." # Forwarded to an agent that requires authentication. # export INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai # export INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai # export INTELLIGENCE_LEARNING_CONTAINER_ID=support-quality # Existing container in the API key's project. @@ -61,3 +60,44 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope # -- Notion (Optional) -- # export NOTION_MCP_URL=https://your-notion-mcp.example.com/mcp # export NOTION_MCP_AUTH_TOKEN=your-remote-mcp-bearer-token + +# -- Composio (Optional) -- +# Connect any Composio toolkit without writing an MCP block. Three steps per +# app: add the toolkit at https://app.composio.dev, name its slug below, and +# restart the agent. A shared toolkit is also connected once, see setup.md. +# Slugs are Composio's own — lowercase, unspaced: `googlecalendar`, not `gcal`. +# COMPOSIO_API_KEY is the master switch; without it nothing is constructed. +# export COMPOSIO_API_KEY=ak_... +# +# One shared identity everyone in Slack reaches. Connect each of these once with +# cd agent && uv run python -m composio_tools.connect_cli +# Do not also configure the same app over MCP: `linear` here plus LINEAR_API_KEY +# above gives the agent two sets of Linear tools and startup says so. +# export COMPOSIO_TOOLKITS=jira,salesforce +# +# Each person's own account. They connect it themselves from a Slack thread: +# the agent posts a Connect card, and whoever clicks gets their own private link. +# Two requirements: a Slack-backed Channel (Teams has no private message, so the +# link cannot be delivered there) and a non-empty AGENT_AUTH_HEADER below on both +# services — empty reads as unconfigured and every mint is refused. +# export COMPOSIO_USER_TOOLKITS=gmail,googlecalendar +# +# on (default) | off — whether a call that is not a read waits for a person. +# `destructive` and `writes` are the old spellings; both still parse as `on`. +# export COMPOSIO_APPROVALS=on +# +# The Composio user_id shared toolkits act as. Defaults to the agent's own +# INTELLIGENCE_CHANNEL_NAME, and to "open-tag" when that is unset there. +# export COMPOSIO_WORKSPACE_USER_ID=open-tag +# +# Pins which auth config a toolkit connects against when it has several. +# Ids are case-sensitive. Unset, Composio picks one from the project. +# export COMPOSIO_AUTH_CONFIGS=jira:ac_ExAmPle1 + +# -- Agent authentication (Optional; required to connect personal accounts) -- +# One shared secret, the same non-empty value on both services: the runtime sends +# it and the agent checks it. Unset or empty, the connect endpoint refuses to mint +# a link, since that link is a bearer capability. Ordinary agent traffic is only +# checked once this holds a value, so leaving it out changes nothing else. +# Quote it — the value contains a space. +# export AGENT_AUTH_HEADER="Bearer generate-a-long-random-string" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 718326bf..68b1b7cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: - run: pnpm test - name: Install AWS deployment dependencies run: pnpm --dir deployment/aws install --frozen-lockfile + - name: Typecheck AWS deployment + run: pnpm --dir deployment/aws build - name: Test AWS deployment run: pnpm --dir deployment/aws test - name: Validate Railway graph diff --git a/.gitignore b/.gitignore index 2a1463bd..d3c4a1fc 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ state.db-wal # AWS CDK synthesis artifacts deployment/aws/cdk.out deployment/aws/cdk.context.json + +# Composio working documents — design, plan, and the agent-port plan. Kept +# local for the same reason 0577c63 removed docs/superpowers specs and plans. +docs/composio-tools-design.md +docs/composio-tools-plan.md +docs/composio-agent-port-plan.md diff --git a/.railway/railway.ts b/.railway/railway.ts index a71abfa6..f8663b5d 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -41,6 +41,22 @@ export default defineRailway(() => { LINEAR_API_KEY: preserve(), NOTION_MCP_URL: preserve(), NOTION_MCP_AUTH_TOKEN: preserve(), + // Composio is read by the agent, which is where the toolkits live. The + // runtime carries only the shared secret it presents when asking for a + // connect link. + COMPOSIO_API_KEY: preserve(), + COMPOSIO_TOOLKITS: preserve(), + COMPOSIO_USER_TOOLKITS: preserve(), + COMPOSIO_APPROVALS: preserve(), + COMPOSIO_WORKSPACE_USER_ID: preserve(), + COMPOSIO_AUTH_CONFIGS: preserve(), + // The agent side of the shared secret the runtime presents; see the + // runtime's copy below. Both services have to hold the same value or + // every request the runtime makes comes back 401. + AGENT_AUTH_HEADER: preserve(), + // Read by the agent as the default Composio workspace user id, and by the + // runtime as the Channel to attach to. Both, and the same value. + INTELLIGENCE_CHANNEL_NAME: "open-tag", }, }); @@ -71,6 +87,8 @@ export default defineRailway(() => { "wss://realtime.intelligence.copilotkit.ai", INTELLIGENCE_LEARNING_CONTAINER_ID: preserve(), INTELLIGENCE_CHANNEL_NAME: "open-tag", + // The runtime side of the pair the agent declares above. + AGENT_AUTH_HEADER: preserve(), PLAYWRIGHT_BROWSERS_PATH: "0", RAILPACK_DEPLOY_APT_PACKAGES: "fonts-liberation fonts-noto-color-emoji fonts-unifont libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libdrm2 libexpat1 libfontconfig1 libfreetype6 libgbm1 libglib2.0-0 libnspr4 libnss3 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 libxrender1 libxshmfence1", diff --git a/AGENTS.md b/AGENTS.md index 1883b265..a133da77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,8 @@ files. | Agent | `agent/agent.py` | LangGraph deep agent served over AG-UI | | AG-UI adapter | `agent/agui.py` | Slack recursion limit and user-facing graph-stop handling | | Persona | `agent/prompts/` | `system.py` is the base system prompt | -| Approval gate | `agent/write_confirmation.py` | Emits `confirm_write` before Linear or Notion writes | +| Approval gate | `agent/write_confirmation.py` | Emits `confirm_write` before a Linear, Notion, or Composio write | +| Composio | `agent/composio_tools/` | Toolkit sessions, per-person identity, effect classification, connect links | | Coder | `agent/coding/` | GitHub credentials, Daytona sandbox, repository publish tools, coder prompt | | Coder skills | `agent/coding/skills/` | Committed skills. Do not put them in `agent/skills/` | | Deployment | `.railway/railway.ts` | Two services, declared as code | @@ -80,10 +81,21 @@ you actually ran; do not claim a check that did not run. - **Channel names claim deliveries.** Two runtimes declaring the same name in one Intelligence project race per delivery and the loser is silently starved. Give a local runtime its own project, key, and Channel name — never reuse `open-tag`. +- **Socket Mode stays off on the Slack app.** With it on, Slack delivers events + over the socket and stops posting them to the Request URL, so Intelligence — + and therefore your runtime — receives nothing while the app still reads as + installed. Do not turn it on to "test locally"; there is nothing here that + needs it. +- **Personal Composio toolkits are Slack-only, and need a non-empty + `AGENT_AUTH_HEADER` on both services.** The speaker arrives as + `forwardedProps.channelActor`, and `agent/composio_tools/state.py` is the one + place that decides what counts as an identity — `agent/agui.py` applies it to + every run. `agent/agent_auth.py` treats an empty secret as unconfigured and + refuses to mint a connect link on that basis. - **Slash commands and modals are registered but unverified on the managed - path.** Delivery depends on the generated Slack manifest declaring - `slash_commands`; as of the 0.7.0 verification it declared none. Do not describe - them as working without sending a real command. + path.** Delivery depends on the generated Slack manifest, which Intelligence + produces server-side — nothing in this repository decides it. Do not describe + them as working without sending a real command against your own Channel. - **Trigger routing is not symmetric.** A mentioned turn goes to `onMention` if registered and falls back to `onMessage`; an unmentioned turn reaches `onMessage` only. `onMention` subscribes the thread. Always verify with a @@ -94,12 +106,20 @@ you actually ran; do not claim a check that did not run. - **Pinned SDK versions live in `package.json` and nowhere else.** Do not restate `@copilotkit/channels` or `@copilotkit/runtime` versions in prose or in a test - assertion. Three copies of `0.7.0` drifted at once when the deps were bumped, - and one of them broke the build. `app/cleanup.test.ts` asserts the pin *shape* + assertion. Three copies of an earlier pin drifted at once when the deps were + bumped, and one broke the build. `app/cleanup.test.ts` asserts the pin *shape* for this reason. -- **No Slack or Teams credential belongs in this repository.** Intelligence owns - the adapters. One root `.env` configures both services; the Python agent loads - it explicitly for local development. +- **No platform credential belongs here.** Intelligence owns the adapters, so no + Slack or Teams token, signing secret, or app token goes in this repository and + this app attaches no adapter of its own. It briefly held `SLACK_BOT_TOKEN` and + `SLACK_APP_TOKEN` so a Composio connect link could reach one person privately; + the managed adapter in the pair pinned in `package.json` does that itself, and + the pair was worse than useless — a second Slack ingress answered every message + twice, and its Socket Mode connection stopped Slack delivering events to + Intelligence from 24 August until it was turned off. Both tokens and the + adapter are gone from this repository; `app/server.test.ts` pins the removal. + One root `.env` configures both services; the Python agent loads it explicitly + for local development. - **`@copilotkit/channels` and `@copilotkit/runtime` upgrade together.** They ship as a tested pair. - Commit messages follow the conventional prefixes already in the log (`feat:`, diff --git a/README.md b/README.md index b9485b4b..97e2877d 100644 --- a/README.md +++ b/README.md @@ -146,11 +146,16 @@ For Microsoft Teams, use `--adapter teams`. Two Teams steps stay yours because nothing can work around them: granting tenant admin consent, and uploading the app package through **Apps → Manage your apps → Upload an app**. -#### Three Slack details that cost the most time +#### Four Slack details that cost the most time These apply on either path. Follow the CLI's emitted `nextAction` rather than remembered Slack steps, and watch for: +- **Leave Socket Mode off.** Managed delivery never uses it, and a Slack app with + Socket Mode on installs green and delivers nothing to the Request URL — so + nothing reaches Intelligence and nothing reaches your runtime. It is the one + failure here that looks exactly like success. See + [`setup.md`](./setup.md#leave-socket-mode-off). - After creating the app from the link, open **OAuth & Permissions** and choose **Reinstall to Workspace**. Slack applies the manifest's real scopes only on reinstall. @@ -320,7 +325,8 @@ agent (Python + LangGraph deepagents) ├── GitHub MCP (optional, read-only) ├── PostHog MCP (optional, read-only) ├── Linear MCP (optional) - └── Notion MCP (optional remote server) + ├── Notion MCP (optional remote server) + └── Composio toolkits (optional; shared or per-person accounts) ``` | You run | CopilotKit Intelligence manages | @@ -329,16 +335,27 @@ agent (Python + LangGraph deepagents) | The long-running Node Channels runtime | Platform ingress and credentialed delivery | | Deployment, state, and logs | Runtime registration, health, and reconnects | -Neither leg is Socket Mode, and neither needs a tunnel or a public URL of your -own. Slack reaches Intelligence over HTTPS, authenticated by the signing secret -Intelligence holds. Intelligence reaches your runtime over a websocket your -process opens outbound, authenticated by `INTELLIGENCE_API_KEY`. +Neither of those legs is Socket Mode, and neither needs a tunnel or a public URL +of your own. Slack reaches Intelligence over HTTPS, authenticated by the signing +secret Intelligence holds. Intelligence reaches your runtime over a websocket +your process opens outbound, authenticated by `INTELLIGENCE_API_KEY`. There is one canonical runtime host: [`server.ts`](./server.ts). [`app/index.ts`](./app/index.ts) composes one `CopilotKitIntelligence`, one -`CopilotRuntime`, and one adapter-free managed Channel. Intelligence owns the -Slack and Microsoft Teams adapters, their credentials, and attachments — no -platform credential belongs in this repository's environment. +`CopilotRuntime`, and one adapter-free managed Channel. +Intelligence owns the Slack and Microsoft Teams adapters, their credentials, and +attachments. + +No platform credential belongs here at all. Composio's per-person toolkits need +a connect link to reach one person privately, and the managed adapter delivers +that itself with the SDK pair pinned in [`package.json`](./package.json) — +proven end to end on Slack. +Teams has no private message, so a Teams-backed Channel cannot deliver a connect +link and says so rather than posting one in the thread. What per-person toolkits +do need is `AGENT_AUTH_HEADER`, a shared secret between the two services, set to +a **non-empty** value: the agent treats an empty string as unconfigured and +refuses to mint. Leave it unset and nothing else changes. +See [`setup.md`](./setup.md#composio). `@copilotkit/channels` and `@copilotkit/runtime` are pinned for reproducible deploys. [`package.json`](./package.json) is the source of truth for both @@ -361,6 +378,7 @@ knowledge work, and renders UI from model knowledge. | `GITHUB_PERSONAL_ACCESS_TOKEN` | Read-only repository, code, PR, and CI search | | `POSTHOG_PERSONAL_API_KEY` | PostHog analytics, read-only (use the **MCP Server** key preset) | | `LINEAR_API_KEY` | Hosted Linear MCP | +| `COMPOSIO_API_KEY` | Composio toolkits, under one shared team account or under each person's own (per-person accounts are Slack-only and need `AGENT_AUTH_HEADER`; see setup.md) | | `NOTION_MCP_URL` + `NOTION_MCP_AUTH_TOKEN` | Remote Notion MCP; setting only one disables it | | `DAYTONA_API_KEY` + a PAT or GitHub App | Coding subagent: edit in Daytona, then push and publish a draft PR after `confirm_write` | diff --git a/agent/.gitignore b/agent/.gitignore index 83e43780..4af28ef1 100644 --- a/agent/.gitignore +++ b/agent/.gitignore @@ -3,3 +3,5 @@ __pycache__/ *.pyc .env /reports/ +*.egg-info/ +dist/ diff --git a/agent/agent.py b/agent/agent.py index d8fd0879..39e72f6a 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -1,7 +1,9 @@ """OpenTag's general-purpose knowledge-work Deep Agent.""" +import logging import os from pathlib import Path +from uuid import uuid4 from copilotkit import CopilotKitMiddleware from deepagents import ( @@ -24,8 +26,13 @@ log_configuration_warnings, ) from coding.subagent import build_coder_subagent -from copilotkit.langgraph import copilotkit_emit_message +from ag_ui_langgraph import CustomEventNames +from langchain_core.callbacks.manager import adispatch_custom_event from langchain_core.runnables.config import ensure_config +from composio_tools.config import DEFAULT_WORKSPACE_USER_ID +from composio_tools.runtime import composio_runtime +from composio_tools.state import ComposioAgentState +from composio_tools.tools import build_composio_tools from internal_sources import internal_source_toolsets from prompts import ( BASE_SYSTEM_PROMPT, @@ -36,9 +43,12 @@ CODING_ON_ADDENDUM, current_date_prompt, build_base_system_prompt, + composio_addendum, ) from tools import web_search +logger = logging.getLogger(__name__) + load_dotenv(Path(__file__).resolve().parent.parent / ".env") @@ -85,13 +95,28 @@ async def awrap_tool_call(self, request, handler): print(f"[TOOL] start {name}") if name == "task": try: - await copilotkit_emit_message( - ensure_config(), - "Starting the coder in a Daytona sandbox. " - "This can take a few minutes.", + # The adapter turns this event into a complete text message. + # It requires message_id/message; a role/content payload makes + # the event consumer fail after dispatch has already returned. + await adispatch_custom_event( + CustomEventNames.ManuallyEmitMessage.value, + { + "message_id": str(uuid4()), + "message": ( + "Starting the coder in a Daytona sandbox. " + "This can take a few minutes." + ), + }, + config=ensure_config(), ) except Exception: - pass + # A note that did not arrive must not take the coder run with + # it. Logged rather than swallowed: silence here is what let the + # broken dispatch above go unnoticed. + logger.warning( + "[TOOL] could not tell the thread the coder was starting", + exc_info=True, + ) try: result = await handler(request) except GraphRecursionError as error: @@ -174,17 +199,42 @@ def build_agent(): internal_tools = [ tool for tools in source_toolsets.values() for tool in tools ] + # The same runtime the connect route uses, built once per process. Two + # session caches would mean two sessions per identity, and one process + # holding one session is the reason this moved into the agent at all. + # `DEFAULT_WORKSPACE_USER_ID`, not a second spelling of it. The runtime + # caches on this id, and `main.py` passes the constant for the connect + # route: two spellings would build two caches, so the account an operator + # connected through the route is not the one a turn runs in. + composio = composio_runtime( + default_user_id=os.environ.get( + "INTELLIGENCE_CHANNEL_NAME", DEFAULT_WORKSPACE_USER_ID + ), + ) + composio_tools: list = ( + [] + if composio is None + else build_composio_tools(composio.config, composio.cache, composio.effects) + ) + main_tools = ( - [web_search, *internal_tools] + [web_search, *internal_tools, *composio_tools] if has_web_search - else [*internal_tools] + else [*internal_tools, *composio_tools] ) agent_display_name = ( os.environ.get("AGENT_DISPLAY_NAME", DEFAULT_AGENT_DISPLAY_NAME).strip() or DEFAULT_AGENT_DISPLAY_NAME ) - system_prompt = build_base_system_prompt(agent_display_name) + ( + # Only claim the internal-source tools that were actually registered. The + # prompt used to describe Notion, Linear and GitHub tools unconditionally, + # so an agent holding only the Composio search believed it already had them + # and answered without looking. + system_prompt = build_base_system_prompt( + agent_display_name, + internal_sources=tuple(name for name, tools in source_toolsets.items() if tools), + ) + ( WEB_SEARCH_TOOL_ADDENDUM if has_web_search else NO_WEB_SEARCH_TOOL_ADDENDUM @@ -192,6 +242,12 @@ def build_agent(): system_prompt = system_prompt + ( CODING_ON_ADDENDUM if coding_on else CODING_OFF_ADDENDUM ) + # Which apps exist is known here and was never passed on, so the model + # answered questions about its own reach by guessing. It names apps only; + # `search_my_tools` still owns which actions each one has. + system_prompt = system_prompt + composio_addendum( + composio.config if composio is not None else None + ) checkpointer = MemorySaver() create_kwargs = { @@ -208,6 +264,11 @@ def build_agent(): # create_agent rejects duplicate middleware names. "backend": StateBackend(), "checkpointer": checkpointer, + # Declared whether or not Composio is configured. The Channel forwards + # the actor on every run and the AG-UI adapter drops a forwarded key the + # state schema does not name, so leaving it out would make "who spoke" + # depend on an unrelated feature flag. + "state_schema": ComposioAgentState, } if coding_on: assert providers.coding is not None @@ -229,6 +290,18 @@ def build_agent(): print(f"[AGENT] web search: {'enabled' if has_web_search else 'disabled'}") print(f"[AGENT] coding: {'enabled' if coding_on else 'disabled'}") print(f"[AGENT] internal-source tools: {len(internal_tools)}") + print( + "[AGENT] composio: " + + ( + "disabled" + if composio is None + else "shared=" + + (",".join(composio.config.workspace_toolkits) or "none") + + " personal=" + + (",".join(composio.config.user_toolkits) or "none") + + f" approvals={composio.config.approvals}" + ) + ) print(f"[AGENT] Main tools: {[t.name for t in main_tools]}") # A coding turn uses many GitHub MCP reads before task(). 25 steps is diff --git a/agent/agent_auth.py b/agent/agent_auth.py new file mode 100644 index 00000000..0f3d9182 --- /dev/null +++ b/agent/agent_auth.py @@ -0,0 +1,156 @@ +"""The shared secret between the runtime and this agent. + +The runtime has always sent `AGENT_AUTH_HEADER` as its `Authorization` header and +this service has always ignored it. In the deployed topology that was survivable: +the runtime reaches the agent over Railway's private domain, so nothing off the +project could call it anyway. It is not survivable for an endpoint that mints +connect links, because such a link is a bearer capability — whoever opens it +binds an account to the user id it was minted for. + +Two different rules, on purpose: + +- Ordinary traffic is checked only when a secret is configured. A local `pnpm + dev` has no secret and must keep working, and switching enforcement on for + every existing deployment would take them down on upgrade. +- Anything that mints a capability requires a secret unconditionally. With none + configured the route reports itself unavailable rather than serving + unauthenticated. Fail closed where it counts, unchanged everywhere else. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from hmac import compare_digest + +#: Paths served without a secret even when one is configured. The platform's +#: health probe has no way to send one. Written without a trailing slash; +#: `_public_path` is what compares them. +#: +#: `//health` is the second one on purpose, and it is not a typo. +#: `add_langgraph_fastapi_endpoint` builds its own health route as +#: `f"{path}/health"`, and `main` registers the agent at `"/"`, so the literal +#: path it serves is `//health`. It is a health route and it answers like one; +#: refusing it meant a probe aimed there reported the service down for as long +#: as a secret was configured. Listed rather than reached by collapsing +#: repeated slashes in `_public_path`, because that would quietly make every +#: path with a doubled slash a different path from the one written here. +PUBLIC_PATHS = frozenset({"/health", "//health"}) + + +def _public_path(path: str) -> str: + """ + The spelling of `path` that `PUBLIC_PATHS` is written in. + + `/health/` and `/health` are the same endpoint — the router redirects one to + the other — but that redirect happens after this check, so an exactly + matched path refuses `/health/` before routing ever runs and the probe sees + a 401 it can do nothing about. + """ + return path.rstrip("/") or "/" + + +def configured_secret(env: Mapping[str, str] | None = None) -> str | None: + """The expected `Authorization` value, or `None` when none is configured.""" + source = os.environ if env is None else env + return (source.get("AGENT_AUTH_HEADER") or "").strip() or None + + +def _presented_forms(presented: str) -> tuple[bytes, bytes]: + """ + The texts a client could have meant by this header, as bytes. + + HTTP gives a header value no encoding. RFC 9110 §5.5 calls anything past + US-ASCII opaque octets, so clients disagree about how a non-ASCII secret is + transmitted, and both live conventions have to be read: + + * Isomorphic — one code unit, one byte. What the Fetch standard specifies + and what Node's `undici` therefore does, which makes it what this + deployment's own callers send: the Channel's `fetch` for the connect + route and the runtime's `HttpAgent` for the graph. Measured against a raw + socket rather than assumed. Starlette decodes with latin-1, which inverts + it exactly, so the value arrives already spelled the way it was + configured — the first element below is that spelling untouched. + * UTF-8 — what curl, httpx, requests, Go and Java send, and therefore what + an operator reproducing a 401 by hand sends. Starlette's latin-1 decode + mangles it (`café` arrives as `café`), so the second element undoes the + decode and reads the same bytes as the UTF-8 they were. + + Only one of the two can be the truth for any given request, and both are + spellings of the *same* configured secret — a caller still has to present + the whole of it either way. What this does not do is guess: it never drops, + replaces or normalizes a character, so an attacker gains no shorter or + fuzzier value to present. + + Always a pair, even when both halves hold the same value, so the number of + `compare_digest` calls does not vary with the shape of what arrived. + + `os.fsencode` for the final encode because it is the exact inverse of the + decode `os.environ` applied to the expected value: a byte the locale could + not decode round-trips through the surrogate that stands for it instead of + raising here. Bytes at all because `compare_digest` refuses non-ASCII `str` + outright — it raises `TypeError` rather than returning `False`, and an + accent in a *wrong* secret used to crash the comparison into a 500 instead + of the 401 it deserves. + """ + try: + transcoded = presented.encode("latin-1").decode("utf-8") + except (UnicodeEncodeError, UnicodeDecodeError): + # Not a UTF-8 transmission, so there is no second reading. Repeat the + # first rather than returning one form: the work stays uniform. + transcoded = presented + # Stripped after transcoding, never before: `str.strip()` counts U+00A0 as + # whitespace, and that is also a UTF-8 continuation byte, so stripping the + # latin-1 spelling first would eat the tail of a legitimate character. + return os.fsencode(presented.strip()), os.fsencode(transcoded.strip()) + + +def header_matches(presented: str | None, expected: str) -> bool: + """ + Whether a presented header is the configured secret. + + Compared with `compare_digest` rather than `==`: an early-exit comparison + leaks the length of the matching prefix, and this value is the only thing + standing in front of the agent. Both readings of the presented value are + compared unconditionally, and the results combined with `|=` rather than + `or`, so neither the count nor the order of comparisons depends on which + one matched. + """ + if not presented: + return False + wanted = os.fsencode(expected) + matched = False + for candidate in _presented_forms(presented): + matched |= compare_digest(candidate, wanted) + return matched + + +def is_authorized( + path: str, + presented: str | None, + env: Mapping[str, str] | None = None, +) -> bool: + """Whether ordinary traffic for `path` may proceed.""" + if _public_path(path) in PUBLIC_PATHS: + return True + expected = configured_secret(env) + if expected is None: + return True + return header_matches(presented, expected) + + +def authorizes_capability( + presented: str | None, + env: Mapping[str, str] | None = None, +) -> bool: + """ + Whether a capability-minting request may proceed. + + Unlike `is_authorized`, an absent secret is a refusal. There is no + configuration in which handing out connect links to unauthenticated callers + is the intended behaviour. + """ + expected = configured_secret(env) + if expected is None: + return False + return header_matches(presented, expected) diff --git a/agent/agui.py b/agent/agui.py index c2ff184f..c0805de5 100644 --- a/agent/agui.py +++ b/agent/agui.py @@ -1,5 +1,8 @@ """AG-UI adapter behavior for the OpenTag graph.""" +import json +import logging +import re import uuid from ag_ui.core import ( @@ -13,6 +16,9 @@ from langgraph.errors import GraphRecursionError from agent import graph_recursion_limit +from composio_tools.state import ACTOR_STATE_KEY, with_forwarded_actor + +logger = logging.getLogger(__name__) AGENT_NAME = "opentag_research" AGENT_DESCRIPTION = ( @@ -26,12 +32,183 @@ ) +def with_interrupt_id(event): + """Bind an approval envelope to the graph interrupt that emitted it. + + A stale card must resume only its originating interrupt, never whichever + write happens to be paused when a delayed click reaches the graph. + """ + if ( + getattr(event, "type", None) != EventType.CUSTOM + or getattr(event, "name", None) != "on_interrupt" + ): + return event + + value = event.value + serialized = isinstance(value, str) + if serialized: + try: + value = json.loads(value) + except (ValueError, TypeError): + return event + if not isinstance(value, dict): + return event + + # Normal streams serialize raw_event; the adapter's already-paused path + # yields the Interrupt object itself. Both contain the same graph ID. + raw = getattr(event, "raw_event", None) + identifier = raw.get("id") if isinstance(raw, dict) else getattr(raw, "id", None) + payload = {**value} + payload.pop("__opentag_interrupt_id__", None) + if isinstance(identifier, str) and re.fullmatch(r"[0-9a-f]{32}", identifier): + payload["__opentag_interrupt_id__"] = identifier + else: + # Do not trust a marker supplied in the interrupt's own value. Leaving + # it absent makes the Channel refuse an uncorrelated approval card. + logger.warning("[agent] interrupt has no valid graph ID; approval unavailable") + return event.model_copy( + update={"value": json.dumps(payload) if serialized else payload} + ) + + +def with_trusted_actor(input_data): + """One run, with its identity taken from the forwarded actor and nothing else. + + This is the only point that sees the trusted value and the untrusted one + side by side. Below it they are the same key: the adapter merges forwarded + properties and the request's `state` into one graph input, and `state` wins + — so a body naming somebody else would decide whose account a turn runs in. + Above it there is no run object to rewrite. + + Rewriting `state` rather than dropping the caller's key is deliberate. The + key must be *present* on every run: the graph is checkpointed per thread, so + a turn that forwards nobody has to say so out loud to clear the last speaker + rather than inherit them. + """ + return input_data.model_copy( + update={ + "state": with_forwarded_actor( + getattr(input_data, "state", None), + getattr(input_data, "forwarded_props", None), + ) + } + ) + + class OpenTagAGUIAgent(LangGraphAGUIAgent): """Serve the graph and turn a graph-level step-limit crash into a reply.""" async def run(self, input_data): - async for event in iter_agent_events(super().run, input_data): - yield event + async for event in iter_agent_events( + super().run, + with_trusted_actor(input_data), + recovery=self.committed_snapshots, + ): + yield with_interrupt_id(event) + + async def committed_snapshots(self, thread_id, run_id): + """What the crashed run did commit, told the way a normal exit tells it. + + A run that ends normally finishes with a state snapshot and a messages + snapshot taken from the checkpoint. A run that hit the step limit + finished with neither, so the client kept only what it had been streamed + while the checkpoint kept everything the graph wrote. The next turn then + sends fewer messages than the checkpoint holds, which is exactly the + shape the adapter reads as a time-travel edit — and the regeneration + path is the one entry point that takes its identity from a checkpoint + instead of from the turn. + + The adapter's own emitter is reused rather than reimplemented: it + decides message filtering and output-key trimming, and a second copy of + those rules here would drift from it silently. It asserts an active run, + which `_handle_stream_events` has already torn down by the time the + error surfaces, so a minimal one is put back for the duration. + + Failing to read the checkpoint is not allowed to swallow the reply: this + is recovery from a crash already in progress, and the person waiting on + the thread needs the sentence more than the client needs the snapshot. + """ + if not thread_id: + return + config = {"configurable": {"thread_id": thread_id}} + previous = self.active_run + self.active_run = { + "id": run_id, + "thread_id": thread_id, + "schema_keys": self.get_schema_keys(config), + } + try: + async for event in self.get_state_and_messages_snapshots(config): + if event is not None: + yield event + except Exception as error: # noqa: BLE001 - checkpointer errors vary + logger.warning( + "[agent] could not snapshot the checkpoint after a step-limit " + "crash on thread %s: %s", + thread_id, + error, + ) + finally: + self.active_run = previous + + def langgraph_default_merge_state(self, state, messages, input): + """Every graph input, with its identity stamped by this run's actor. + + Rewriting `input.state` is not enough on its own. The adapter has two + entry points: `prepare_stream` reads `input.state`, and + `prepare_regenerate_stream` — which it enters on a message-shape + heuristic, not on a flag anybody sets — forks from + `time_travel_checkpoint.values` and reads neither `input.state` nor the + forwarded properties. A turn taking that path used to run as whoever + spoke when that checkpoint was written. + + That is reachable on the managed adapter, which keeps one LangGraph + thread per conversation: from the second turn on, the transcript arrives + carrying ids the checkpoint has never seen, which is exactly what the + heuristic reads as an edit. + + This method is the one seam both paths pass through, and it is the last + point before the graph runs, so the actor is decided here for every run + whichever way the adapter got there. + """ + merged = super().langgraph_default_merge_state(state, messages, input) + return with_forwarded_actor(merged, getattr(input, "forwarded_props", None)) + + def get_schema_keys(self, config): + """The adapter's schema keys, with the actor key guaranteed present. + + `prepare_stream` filters a run's input down to the graph's declared + input keys before handing it to LangGraph, and it learns those keys by + introspecting the graph. That introspection has a documented + warning-only fallback — an older LangGraph, a custom graph class, a + Pydantic skew — and the fallback answer is a fixed list that does not + include `channel_actor`. + + On that path the key this class writes on every run is filtered back + out, and the run reaches a checkpointed graph without it. An absent key + is not a cleared key: the previous speaker's identity stands, and an + anonymous turn runs in their connected accounts. Worse, the raw + forwarded properties are merged *under* the filtered payload, so what + does arrive is the untrimmed `ProviderActor` — display name and work + address included — instead of the three fields `personal_actor` keeps. + + Adding the key back is not a widening of what the graph accepts: + `ComposioAgentState` declares it, so on the introspection path it is + already there. This only makes the fallback agree with the schema + instead of silently disagreeing with it. Refusing the run would also + clear the actor, but it would take the whole deployment down for a + best-effort introspection failure that costs nothing else. + """ + keys = super().get_schema_keys(config) + if ACTOR_STATE_KEY not in keys["input"]: + logger.warning( + "[agent] the adapter could not read the graph's input schema; " + "adding %r back so an anonymous turn still clears the previous " + "speaker", + ACTOR_STATE_KEY, + ) + keys["input"] = [*keys["input"], ACTOR_STATE_KEY] + return keys def build_agui_agent(graph, *, recursion_limit: int | None = None): @@ -49,13 +226,33 @@ def build_agui_agent(graph, *, recursion_limit: int | None = None): ) -async def iter_agent_events(run, input_data): - """Yield AG-UI events. A graph step-limit becomes a user message.""" +async def iter_agent_events(run, input_data, *, recovery=None): + """Yield AG-UI events. A graph step-limit becomes a user message. + + `recovery` is called with the run's own thread and run ids and may yield + events to send before the reply — the snapshots a normal exit would have + sent. They go first on purpose: a messages snapshot replaces the client's + list, so one arriving after the reply would delete it. + + The run is finished under the ids the adapter *started* it with, not the + ones the request carried. `_handle_stream_events` mints a thread id when a + request arrives without one and opens the run under that, so echoing the + request's own value closes a run nobody opened and leaves the open one + hanging. + """ + started = None try: async for event in run(input_data): + if getattr(event, "type", None) == EventType.RUN_STARTED: + started = event yield event except GraphRecursionError as error: - print(f"[AGENT] GraphRecursionError: {error}") + logger.warning("[agent] the graph hit its step limit: %s", error) + thread_id = started.thread_id if started else input_data.thread_id + run_id = started.run_id if started else input_data.run_id + if recovery is not None: + async for event in recovery(thread_id, run_id): + yield event message_id = str(uuid.uuid4()) yield TextMessageStartEvent( type=EventType.TEXT_MESSAGE_START, @@ -73,6 +270,6 @@ async def iter_agent_events(run, input_data): ) yield RunFinishedEvent( type=EventType.RUN_FINISHED, - thread_id=input_data.thread_id, - run_id=input_data.run_id, + thread_id=thread_id, + run_id=run_id, ) diff --git a/agent/coding/repository_tools.py b/agent/coding/repository_tools.py index f871c585..e8418c77 100644 --- a/agent/coding/repository_tools.py +++ b/agent/coding/repository_tools.py @@ -395,6 +395,11 @@ def publish_changes( confirmed = require_write_confirmation( action="Push branch and publish pull request", fields=fields, + # Said, because the card now assumes the worst of anything that + # does not say. Pushing a branch and opening a pull request adds + # things and destroys none, and a card that cries danger over + # every ordinary write teaches people to approve red ones. + effect="write", ) if not confirmed: return ( diff --git a/agent/composio_tools/__init__.py b/agent/composio_tools/__init__.py new file mode 100644 index 00000000..68a1e5ff --- /dev/null +++ b/agent/composio_tools/__init__.py @@ -0,0 +1,6 @@ +"""Composio integration for the OpenTag agent. + +Named `composio_tools`, not `composio`: this directory sits on the agent's +import path, so a package called `composio` would shadow the SDK of the same +name and `import composio` inside these modules would find itself. +""" diff --git a/agent/composio_tools/classify.py b/agent/composio_tools/classify.py new file mode 100644 index 00000000..8b14980f --- /dev/null +++ b/agent/composio_tools/classify.py @@ -0,0 +1,91 @@ +"""Effect classification from Composio's MCP behaviour tags. + +The vocabulary is MCP's: `readOnlyHint`, `destructiveHint`, `idempotentHint`, +`openWorldHint`. Composio carries them as tag names on a tool, and its own +session filters accept the same four literals. + +Two things this module refuses to do, both of which read as safe and are not: + +* Treat "nobody said" as "nothing dangerous". `effect_of` answers `None` when + the tags claim nothing, and the caller decides — `EffectMap` gates it. The + default approval mode gates destructive calls only, so calling an + unclassified tool a write is indistinguishable from not gating it at all. +* Read a hint's *name* as its *value*. When the tags arrive as a mapping, + `{"readOnlyHint": False}` is a tool saying it is **not** read-only; the word + being present says nothing on its own. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +READ = "read" +#: A change that is not destructive. `effect_of` never answers this — the tag +#: vocabulary cannot distinguish a plain write from an unclassified tool, and +#: this module does not guess. It exists for a caller that classifies by other +#: means (the MCP interceptor's `readOnlyHint` metadata), and it gates, because +#: only a read goes through unasked. +#: +#: That it is unreachable from the tags is exactly why the old `writes` and +#: `destructive` approval modes could never differ. See `config.APPROVAL_MODES`. +WRITE = "write" +DESTRUCTIVE = "destructive" + +READ_ONLY_HINT = "readOnlyHint" +DESTRUCTIVE_HINT = "destructiveHint" + + +def _claimed_hints(tags: Any) -> frozenset[str]: + """The hints these tags positively assert, as names. + + A mapping is read by value, because that is the shape that carries one: a + hint set to `False` asserts the opposite of what its key looks like, and + only `True` — not merely truthy — is an assertion, since MCP hints are + booleans. + + A `str` is not treated as a one-element tag list. Iterating one yields + characters, and a shape nobody meant to send must not be able to talk this + module down to `read`. + """ + if isinstance(tags, Mapping): + return frozenset( + str(name) for name, value in tags.items() if value is True + ) + if tags is None or isinstance(tags, (str, bytes)): + return frozenset() + try: + return frozenset(tag for tag in tags if isinstance(tag, str)) + except TypeError: + # Not iterable. Same answer as no tags: nothing was claimed. + return frozenset() + + +def effect_of(tags: Any) -> str | None: + """The effect these tags claim, or `None` when they claim nothing. + + `None` is not "safe" and not "write" — it is "unclassified", and the caller + is the one that turns it into a gate. + """ + claimed = _claimed_hints(tags) + if DESTRUCTIVE_HINT in claimed: + return DESTRUCTIVE + if READ_ONLY_HINT in claimed: + return READ + return None + + +def needs_approval(effect: str, mode: str) -> bool: + """Whether an effect must be confirmed by a person under this approval mode. + + One gating rule, because there was only ever one. `destructive` and `writes` + used to be separate modes and gated an identical set — the tag vocabulary + cannot express a write that is not destructive, and an unclassified tool is + gated as destructive rather than guessed at. `config.APPROVAL_MODES` records + the collapse; both old spellings still parse. + + A read is the only thing that goes through unasked. + """ + if mode == "off": + return False + return effect != READ diff --git a/agent/composio_tools/config.py b/agent/composio_tools/config.py new file mode 100644 index 00000000..7a5ac546 --- /dev/null +++ b/agent/composio_tools/config.py @@ -0,0 +1,166 @@ +"""Environment contract for the optional Composio integration. + +Absent `COMPOSIO_API_KEY` returns `None` and nothing downstream is constructed — +absent, not disabled, so the agent never carries a tool it can see but must not +call. + +The variable names and their meanings are unchanged from the channel-side +implementation this replaces. An operator who configured that one does not have +to relearn anything. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Mapping +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +APPROVAL_MODES = ("off", "on") + +#: The shared Composio identity when nothing names one. Every caller passes the +#: channel name as `default_user_id`, and that variable can be present and +#: empty — `INTELLIGENCE_CHANNEL_NAME=` is routine — which is not a name. An +#: empty user id is a real Composio identity that nothing else ever resolves to, +#: so the shared connection would land where no turn looks. +DEFAULT_WORKSPACE_USER_ID = "open-tag" + +#: `destructive` and `writes` were two modes that could never differ. +#: +#: The gate reads Composio's MCP behaviour tags, and those can say exactly two +#: things: `readOnlyHint` (a read) and `destructiveHint` (destructive). There is +#: no tag for "a write that is definitely not destructive", and `idempotentHint` +#: cannot stand in for one — DELETE is idempotent. Anything the tags do not +#: classify is gated as destructive, because calling it a write would have left +#: it ungated under the default mode. So every call is a read or destructive, +#: `writes` and `destructive` gated exactly the same set, and an operator +#: choosing between them was choosing between two spellings of one behaviour. +#: +#: Still accepted, because refusing them would fail an existing deployment at +#: boot over a value that always meant `on`. +DEPRECATED_APPROVAL_MODES = {"destructive": "on", "writes": "on"} + + +class ComposioConfigError(ValueError): + """An operator set a Composio variable to something unusable.""" + + +@dataclass(frozen=True) +class ComposioConfig: + api_key: str + workspace_toolkits: tuple[str, ...] + user_toolkits: tuple[str, ...] + approvals: str + workspace_user_id: str + #: Auth-config choices shared by the operator connect script and sessions + #: used for discovery, execution, and personal account connections. + #: + #: `session.authorize()` takes no auth config id, but the session does: + #: `sessions.create(auth_configs={"linear": "ac_..."})` pins one per + #: toolkit, and both session creation paths pass this through. This settles + #: the case the variable exists for — a toolkit holding several auth configs, + #: where an unpinned session lets the project resolve whichever it likes. + #: + #: `hash=False` because a dict is unhashable and `frozen=True` generates a + #: `__hash__` from every comparing field: without it, hashing a config that + #: named an auth config raised `TypeError`, and only that config. + auth_configs: Mapping[str, str] = field(default_factory=dict, hash=False) + + +def _env(env: Mapping[str, str] | None) -> Mapping[str, str]: + return os.environ if env is None else env + + +def _value(source: Mapping[str, str], name: str) -> str: + return (source.get(name) or "").strip() + + +def _slug_list(raw: str) -> tuple[str, ...]: + return tuple( + slug for slug in (item.strip().lower() for item in raw.split(",")) if slug + ) + + +def _approval_mode(raw: str) -> str: + """ + Empty or whitespace-only means unset, not invalid. + + `COMPOSIO_APPROVALS=` is routine in `.env` files and in compose passthrough, + and must not take the agent down at boot. + + `destructive` and `writes` are folded to `on`; see + `DEPRECATED_APPROVAL_MODES` for why they could never have differed. + """ + value = raw.strip().lower() or "on" + value = DEPRECATED_APPROVAL_MODES.get(value, value) + if value not in APPROVAL_MODES: + raise ComposioConfigError( + f'Invalid COMPOSIO_APPROVALS: "{raw}" — expected one of ' + + ", ".join(APPROVAL_MODES) + ) + return value + + +def _auth_config_map(raw: str) -> dict[str, str]: + """ + Parse `toolkit:auth_config_id` pairs. + + Toolkit keys are lowercased to match the toolkit lists. Ids are preserved + verbatim, because real ones are mixed case (`ac_ExAmPle1-aB`) and a + lowercased id does not resolve. Splits on the first colon only, so an id + containing one is not truncated. + """ + pairs: dict[str, str] = {} + for entry in raw.split(","): + separator = entry.find(":") + if separator == -1: + continue + toolkit = entry[:separator].strip().lower() + identifier = entry[separator + 1 :].strip() + if toolkit and identifier: + pairs[toolkit] = identifier + return pairs + + +def read_composio_config( + env: Mapping[str, str] | None = None, + *, + default_user_id: str, +) -> ComposioConfig | None: + """Read the Composio contract, or `None` when the feature is not configured.""" + source = _env(env) + api_key = _value(source, "COMPOSIO_API_KEY") + if not api_key: + return None + + workspace_toolkits = _slug_list(_value(source, "COMPOSIO_TOOLKITS")) + user_toolkits = _slug_list(_value(source, "COMPOSIO_USER_TOOLKITS")) + # A key with no toolkits names nothing to reach. Treated as unconfigured + # rather than as an empty-but-enabled integration, so the agent does not + # advertise tools that can only answer "nothing is set up". + # + # Said out loud, unlike an absent key: setting a key and no toolkit is a + # half-finished setup rather than a decision not to use the feature, and it + # used to turn the whole integration off in silence. + if not workspace_toolkits and not user_toolkits: + logger.warning( + "[composio] COMPOSIO_API_KEY is set but neither COMPOSIO_TOOLKITS " + "nor COMPOSIO_USER_TOOLKITS names a toolkit, so connected apps are " + "off. Name at least one toolkit in either." + ) + return None + + return ComposioConfig( + api_key=api_key, + workspace_toolkits=workspace_toolkits, + user_toolkits=user_toolkits, + approvals=_approval_mode(_value(source, "COMPOSIO_APPROVALS")), + workspace_user_id=( + _value(source, "COMPOSIO_WORKSPACE_USER_ID") + or default_user_id.strip() + or DEFAULT_WORKSPACE_USER_ID + ), + auth_configs=_auth_config_map(_value(source, "COMPOSIO_AUTH_CONFIGS")), + ) diff --git a/agent/composio_tools/connect.py b/agent/composio_tools/connect.py new file mode 100644 index 00000000..454158c6 --- /dev/null +++ b/agent/composio_tools/connect.py @@ -0,0 +1,91 @@ +"""Minting a connect link for one person and one app. + +A connect link is a bearer capability: whoever opens it binds their account to +the Composio user id the link was minted for. So it is minted per clicker, on +demand, and handed back to the surface for private delivery — never posted where +somebody else can open it, and never shown to the model. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from composio_tools.runtime import ComposioRuntime +from composio_tools.scopes import ResolvedScope + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ConnectRefused: + """Why no link was minted, in words an operator can act on.""" + + reason: str + + +@dataclass(frozen=True) +class ConnectLink: + url: str + + +def connect_link( + runtime: ComposioRuntime, + *, + identity: str, + toolkit: str, +) -> ConnectLink | ConnectRefused: + """ + A link that connects `identity`'s own account for `toolkit`. + + `identity` is the platform-namespaced actor key, the same value a turn uses + to pick that person's session. A link minted against anything else connects + an account the agent will never look at again. + + A toolkit that is not personal is refused rather than handled. A shared + toolkit runs as one workspace identity, so a link minted for a clicker would + connect an account no shared call ever uses — the same broken end state the + operator connect script exists to prevent. + """ + slug = toolkit.strip().lower() + if not slug: + return ConnectRefused(reason="No app was named.") + if slug not in runtime.config.user_toolkits: + return ConnectRefused( + reason=( + f'"{slug}" is not one of the apps people connect for themselves. ' + "Shared apps are connected once by an operator, not from Slack." + ) + ) + + scope = ResolvedScope(user_id=identity, toolkits=(slug,), personal=True) + try: + session = runtime.cache.for_scope(scope).session + authorization = session.authorize(slug) + except Exception as error: # noqa: BLE001 - provider errors vary + # The identity, not the failure detail, is what an operator needs here, + # and the reason may quote provider text of unknown shape. + logger.warning( + "[composio] could not mint a %s connect link for %s: %s", + slug, + identity, + error, + ) + return ConnectRefused( + reason=f"Could not start the {slug} connection. Try again shortly." + ) + + url = getattr(authorization, "redirect_url", None) or getattr( + authorization, "redirectUrl", None + ) + if not isinstance(url, str) or not url: + logger.warning( + "[composio] %s authorization for %s returned no link", slug, identity + ) + return ConnectRefused( + reason=f"Could not start the {slug} connection. Try again shortly." + ) + + # Never logged. The whole point of the private delivery is that this string + # reaches exactly one person, and a log is not that. + return ConnectLink(url=url) diff --git a/agent/composio_tools/connect_cli.py b/agent/composio_tools/connect_cli.py new file mode 100644 index 00000000..f6f30604 --- /dev/null +++ b/agent/composio_tools/connect_cli.py @@ -0,0 +1,163 @@ +"""Connect a shared toolkit, once, as the workspace identity. + +A shared toolkit runs as one Composio identity that everyone in Slack reaches, +so nobody in Slack can connect it: a link clicked by a person binds to that +person's id, and no shared call would ever look there. The dashboard cannot do it +either — a connection made there binds to the dashboard's own user id, which this +deployment never passes. It is a test button. + +So this is the only correct path, and it needs no running agent: + + cd agent && uv run python -m composio_tools.connect_cli + +It reads the repo `.env` itself. Nothing it imports loads that file — only +`agent.py` does, and this script does not import the agent — so without it the +one correct path exited saying Composio was not configured on a deployment +where it was. +""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Mapping +from pathlib import Path + +from composio import Composio +from dotenv import dotenv_values + +from composio_tools.config import ( + DEFAULT_WORKSPACE_USER_ID, + ComposioConfig, + read_composio_config, +) + +DASHBOARD_URL = "https://app.composio.dev" + +#: The repo `.env`, the same file the agent itself reads at import. This module +#: imports nothing that loads it, and an operator running the script has no +#: reason to have exported the variables into their shell. +ENV_FILE = Path(__file__).resolve().parents[2] / ".env" + + +def operator_environment( + env: Mapping[str, str] | None = None, + *, + env_file: Path, +) -> Mapping[str, str]: + """ + What the operator configured: the process environment over the repo `.env`. + + Read rather than loaded — `dotenv_values` returns a mapping instead of + writing into `os.environ` — because nothing else in this process needs the + file's contents, and a script that mutates the environment it read is harder + to test than one that does not. + + Exported variables win, matching `load_dotenv`'s default: an operator who + exports a key for one run gets that key. + """ + if env is not None: + return env + from_file = { + name: value + for name, value in dotenv_values(env_file).items() + if value is not None + } + return {**from_file, **os.environ} + + +def resolve_shared_toolkit( + config: ComposioConfig, requested: str | None +) -> tuple[str | None, str | None]: + """The slug to connect, or the sentence the operator should read.""" + slug = (requested or "").strip().lower() + if not slug: + listed = ", ".join(config.workspace_toolkits) or "none configured" + return None, ( + "Usage: uv run python -m composio_tools.connect_cli \n" + f"Shared toolkits on this deployment: {listed}" + ) + if slug in config.user_toolkits: + return None, ( + f'"{slug}" is in COMPOSIO_USER_TOOLKITS, so it runs as each person ' + "and they connect it themselves from a thread. Minting a shared link " + "for it would connect one account every personal call then ignores." + ) + if slug not in config.workspace_toolkits: + listed = ", ".join(config.workspace_toolkits) or "none configured" + return None, ( + f'"{slug}" is not in COMPOSIO_TOOLKITS. Shared toolkits: {listed}' + ) + return slug, None + + +def main(argv: list[str] | None = None, env: Mapping[str, str] | None = None) -> int: + arguments = sys.argv[1:] if argv is None else argv + source = operator_environment(env, env_file=ENV_FILE) + + config = read_composio_config( + source, + default_user_id=source.get( + "INTELLIGENCE_CHANNEL_NAME", DEFAULT_WORKSPACE_USER_ID + ), + ) + if config is None: + print( + "Composio is not configured. Set COMPOSIO_API_KEY and at least one " + "of COMPOSIO_TOOLKITS or COMPOSIO_USER_TOOLKITS.", + file=sys.stderr, + ) + return 1 + + slug, message = resolve_shared_toolkit( + config, arguments[0] if arguments else None + ) + if slug is None: + print(message, file=sys.stderr) + return 1 + + # Pinned when the operator named one. A toolkit can hold several auth + # configs and the project resolves an unpinned one on its own — which is the + # ambiguity `COMPOSIO_AUTH_CONFIGS` exists to settle. The SDK takes the + # mapping when the session is created; `authorize()` has no argument for it. + pinned = config.auth_configs.get(slug) + + composio = Composio(api_key=config.api_key) + session = composio.sessions.create( + user_id=config.workspace_user_id, + toolkits=[slug], + sandbox={"enable": False}, + # Not optional, and defaulted to True by the SDK: left on, the session + # carries tools that initiate and manage connected accounts. Nothing + # here needs them — `authorize()` mints the link over the session's own + # REST endpoint and does not read this flag — and the runtime's session + # cache already turns them off. + manage_connections=False, + auth_configs={slug: pinned} if pinned else None, + ) + request = session.authorize(slug) + # Both spellings, the way the connect route reads them. The Python SDK + # answers `redirect_url`; reading only that turns a camelCase answer into + # "Composio returned no link" on a request that worked. + url = getattr(request, "redirect_url", None) or getattr( + request, "redirectUrl", None + ) + if not url: + print( + f"Composio returned no link for {slug}. Check that its auth config " + f"exists at {DASHBOARD_URL}.", + file=sys.stderr, + ) + return 1 + + pinned_note = f"\nAuth config: {pinned}." if pinned else "" + print( + f"Open this once, signed in as the account the team should share:\n\n{url}\n\n" + f"It connects {slug} for the shared identity " + f'"{config.workspace_user_id}". Anyone in Slack then reaches it.{pinned_note}' + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover - entry point + raise SystemExit(main()) diff --git a/agent/composio_tools/effects.py b/agent/composio_tools/effects.py new file mode 100644 index 00000000..55f8ed4d --- /dev/null +++ b/agent/composio_tools/effects.py @@ -0,0 +1,86 @@ +"""What a slug does, resolved one slug at a time and remembered. + +The channel-side implementation this replaces built the whole map up front with +a fixed limit, which meant a real slug past that limit was unclassified through +no fault of the model. A per-slug lookup has no cap, so the only unclassified +slug left is one that does not exist. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from composio_tools.classify import DESTRUCTIVE, effect_of + +logger = logging.getLogger(__name__) + + +class EffectMap: + """Per-slug effects, cached for the life of the process. + + A tool's tags do not change between calls, so one lookup per slug is enough + and a cache miss costs a single round trip on first use. + """ + + def __init__(self, client_factory) -> None: + self._client_factory = client_factory + self._effects: dict[str, str] = {} + + def effect_for(self, slug: str) -> str: + """ + The effect of one slug, erring towards the dangerous reading. + + A slug that cannot be *classified* is destructive, not a write, and it + does not matter whether the lookup failed or succeeded and said + nothing. `writes` mode gates both, but `destructive` mode — the default + — gates only the first, so calling an unclassified slug a write would + run it unapproved in the mode most deployments ship with. A hallucinated + slug and a prompt-injected one both arrive here looking exactly like a + real one, and so does a real tool nobody has tagged yet. + + Only a positive answer is cached. The fail-safe one is a statement about + what is *not* known, and freezing it into the cache would outlive the + day Composio classifies the tool — a cache entry must never be able to + become the reason something is or is not gated. + """ + cached = self._effects.get(slug) + if cached is not None: + return cached + + try: + tool: Any = self._client_factory().tools.get_raw_composio_tool_by_slug( + slug + ) + except (TypeError, AttributeError): + # Not a provider having a bad day: a call that no longer matches the + # SDK, or a client that no longer carries `tools`. Folded into the + # branch below it becomes "could not look it up, treating it as + # destructive" for every slug, for the life of the process — a + # sentence that describes an outage and leads nobody to the actual + # cause. Raised instead, because a build whose SDK calls no longer + # land is broken rather than degraded, and gating every read behind + # an approval card is a symptom that gets blamed on something else. + raise + except Exception as error: # noqa: BLE001 - provider errors vary + logger.warning( + "[composio] could not look %s up, treating it as destructive: %s", + slug, + error, + ) + # Deliberately not cached. A lookup that failed for a transient + # reason should get another chance, and the fail-safe answer costs + # only an approval prompt in the meantime. + return DESTRUCTIVE + + effect = effect_of(getattr(tool, "tags", None)) + if effect is None: + logger.warning( + "[composio] %s carries no behaviour tag, so it is gated as " + "destructive rather than assumed harmless.", + slug, + ) + return DESTRUCTIVE + + self._effects[slug] = effect + return effect diff --git a/agent/composio_tools/runtime.py b/agent/composio_tools/runtime.py new file mode 100644 index 00000000..4f1f2081 --- /dev/null +++ b/agent/composio_tools/runtime.py @@ -0,0 +1,120 @@ +"""One Composio setup per process, shared by the graph and the HTTP surface. + +The graph needs it to register tools. The connect route needs it to mint a link +for one person. Both must be the same object: two session caches would mean two +sessions per identity, and the point of moving this into the agent was that only +one process holds a Composio session. +""" + +from __future__ import annotations + +import logging +import threading +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from composio_tools.config import ComposioConfig, read_composio_config +from composio_tools.effects import EffectMap +from composio_tools.scopes import startup_warnings +from composio_tools.sessions import SessionCache + +logger = logging.getLogger(__name__) + +_runtime: ComposioRuntime | None = None +_built = False +#: The arguments the cached answer was built from. A cache that ignores the +#: arguments it was called with is not a cache, it is a wrong answer that is +#: right the first time. +_built_from: Any = None + +#: Serialises the build so the "one setup per process" in this module's first +#: sentence is an invariant rather than a hope. Two callers reach here at once +#: in an ordinary deployment: the graph builds the runtime while the connect +#: route serves a click, and FastAPI runs a sync route in a threadpool so two +#: clicks alone are enough. Unguarded, both see an empty cache, both build, and +#: the loser's `ComposioRuntime` — with its own `SessionCache` — is already held +#: by whoever asked first. That is exactly the two-caches-per-identity state +#: this module exists to prevent, and it is invisible: both objects work. +#: +#: Reentrant because the build is not a leaf. `read_composio_config` and +#: `startup_warnings` are ordinary Python today, but a future call back into +#: `composio_runtime` from inside them would deadlock a plain lock and only in +#: production. +_lock = threading.RLock() + + +@dataclass(frozen=True) +class ComposioRuntime: + config: ComposioConfig + cache: SessionCache + effects: EffectMap + + +def build_composio_runtime( + env: Mapping[str, str] | None = None, + *, + default_user_id: str, +) -> ComposioRuntime | None: + """Read the configuration and construct the shared pieces, or `None`.""" + config = read_composio_config(env, default_user_id=default_user_id) + if config is None: + return None + + # Said once, at boot, rather than once per message. + for warning in startup_warnings(config, env): + logger.warning("[composio] %s", warning) + + cache = SessionCache(config) + return ComposioRuntime(config=config, cache=cache, effects=EffectMap(cache.client)) + + +def composio_runtime( + env: Mapping[str, str] | None = None, + *, + default_user_id: str = "open-tag", +) -> ComposioRuntime | None: + """The process-wide runtime, built on first use. + + Cached including the `None` answer: an unconfigured deployment must not + re-read the environment and re-log on every request to the connect route. + """ + global _runtime, _built, _built_from + key = (env, default_user_id) + # Read once outside the lock: the hit is the common case by a wide margin, + # and a hit needs no exclusion — the three globals are only ever published + # together, under the lock, after the build has finished. + if _built and _built_from == key: + return _runtime + + with _lock: + # Checked again inside. Between the read above and this line another + # thread may have built the answer, and building a second one would + # hand this caller a second session cache for the same deployment. + if _built and _built_from == key: + return _runtime + + # Cleared *before* the build, so a `ComposioConfigError` cannot leave + # the previous answer standing behind a key it no longer belongs to. + # Both call sites pass the same arguments, so in a running deployment + # this rebuilds nothing; what it removes is the case where they stop + # being the same and one of them silently gets the other's + # configuration. + _runtime = None + _built = False + _built_from = None + + runtime = build_composio_runtime(env, default_user_id=default_user_id) + _runtime = runtime + _built = True + _built_from = key + return _runtime + + +def reset_composio_runtime() -> None: + """Drop the cached runtime. For tests, which vary the environment.""" + global _runtime, _built, _built_from + with _lock: + _runtime = None + _built = False + _built_from = None diff --git a/agent/composio_tools/scopes.py b/agent/composio_tools/scopes.py new file mode 100644 index 00000000..8018ee7d --- /dev/null +++ b/agent/composio_tools/scopes.py @@ -0,0 +1,121 @@ +"""Which Composio identities a turn acts as, and what to say at startup. + +The actor here is the one the Channel forwarded with the run — the platform's own +word for who spoke. It is never a value the model produced, which is the whole +reason this code can live in the agent at all. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Mapping +from dataclasses import dataclass + +from composio_tools.config import ComposioConfig + +logger = logging.getLogger(__name__) + +#: Apps whose data is one person's, not a team's. +PERSONAL_TOOLKITS = frozenset({"gmail", "googlecalendar", "outlook", "googledrive"}) + +#: Composio toolkit slug -> the variable that enables the same app over MCP. +MCP_EQUIVALENTS = { + "linear": "LINEAR_API_KEY", + "notion": "NOTION_MCP_AUTH_TOKEN", + "posthog": "POSTHOG_PERSONAL_API_KEY", + "github": "GITHUB_PERSONAL_ACCESS_TOKEN", +} + + +@dataclass(frozen=True) +class ResolvedScope: + user_id: str + toolkits: tuple[str, ...] + #: True when this scope acts as the person who spoke rather than as the + #: shared team identity. Only that person may approve one of its calls. + personal: bool + + +def resolve_scopes( + config: ComposioConfig, + actor_id: str | None, +) -> tuple[ResolvedScope, ...]: + """ + Every applicable scope, not the first match — one turn can be both the + shared team identity and the person who sent the message. + + A toolkit named in both lists resolves to the personal scope only. Routing + by slug is ambiguous when a slug lives in two sessions, and picking whichever + loaded first would attribute an action to a person or to a shared account + depending on restart order. + + That de-duplication is unconditional: it does not depend on the personal + scope actually resolving. Naming a toolkit in `COMPOSIO_USER_TOOLKITS` is the + operator saying it must run as the person, so an unidentified turn gets no + access to it rather than quietly falling through to the shared account. + """ + scopes: list[ResolvedScope] = [] + + # The single place a personal identity is admitted. Blank is not an identity: + # an empty or whitespace-only id is as unverified as no actor at all. + actor = (actor_id or "").strip() or None + + workspace_toolkits = tuple( + slug for slug in config.workspace_toolkits if slug not in config.user_toolkits + ) + + if workspace_toolkits: + scopes.append( + ResolvedScope( + user_id=config.workspace_user_id, + toolkits=workspace_toolkits, + personal=False, + ) + ) + if actor is not None and config.user_toolkits: + scopes.append( + ResolvedScope( + user_id=actor, + toolkits=tuple(config.user_toolkits), + personal=True, + ) + ) + return tuple(scopes) + + +def startup_warnings( + config: ComposioConfig, + env: Mapping[str, str] | None = None, +) -> tuple[str, ...]: + """Misconfigurations worth saying out loud once, at boot rather than per turn.""" + source = os.environ if env is None else env + warnings: list[str] = [] + + for slug in dict.fromkeys(config.workspace_toolkits): + if slug in config.user_toolkits: + warnings.append( + f'"{slug}" is in both COMPOSIO_TOOLKITS and COMPOSIO_USER_TOOLKITS. ' + "Using each person's own account; the shared one is ignored for " + "this app." + ) + continue + if slug in PERSONAL_TOOLKITS: + warnings.append( + f'"{slug}" is in COMPOSIO_TOOLKITS (shared). Every Slack user will ' + "act through ONE account. If you meant each person to use their " + "own, move it to COMPOSIO_USER_TOOLKITS." + ) + + for slug in dict.fromkeys((*config.workspace_toolkits, *config.user_toolkits)): + mcp_var = MCP_EQUIVALENTS.get(slug) + if not mcp_var or not (source.get(mcp_var) or "").strip(): + continue + warnings.append( + f'"{slug}" is configured twice: via Composio and via {mcp_var}. The ' + "agent will see two sets of tools for it and may pick either, so " + "whether an action asks for approval will vary. Remove one to make " + "this predictable." + ) + + return tuple(warnings) diff --git a/agent/composio_tools/sessions.py b/agent/composio_tools/sessions.py new file mode 100644 index 00000000..669202ee --- /dev/null +++ b/agent/composio_tools/sessions.py @@ -0,0 +1,285 @@ +"""Composio sessions, cached per identity for the life of the process. + +Composio stores connected accounts on its own side, keyed by user id, so this +cache holds no credential and losing it costs one round trip rather than a +re-authentication. A restart is invisible to everyone who has already connected. +""" + +from __future__ import annotations + +import logging +import threading +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any, Protocol + +from composio import Composio + +from composio_tools.config import ComposioConfig +from composio_tools.scopes import ResolvedScope + +logger = logging.getLogger(__name__) + +#: How many sessions one process keeps at once. +#: +#: A session holds no credential and costs one round trip to rebuild, but a +#: deployment serving a whole workspace mints one per person and the process +#: outlives every conversation — so this is bounded, and the least recently used +#: identity is the one that pays for the next arrival. +MAX_SESSIONS = 256 + + +class Session(Protocol): + """The part of a Composio session this package uses.""" + + def search(self, *, query: str) -> Any: ... + + def execute(self, slug: str, *, arguments: dict[str, Any]) -> Any: ... + + def authorize(self, toolkit: str) -> Any: ... + + def toolkits(self) -> Any: ... + + +@dataclass(frozen=True) +class ScopedSession: + """One live session, plus the scope that decides who may approve its calls.""" + + session: Session + scope: ResolvedScope + + +@dataclass(frozen=True) +class DroppedScope: + """A scope that could not produce a session, and the provider's reason.""" + + scope: ResolvedScope + reason: str + + +@dataclass(frozen=True) +class ResolvedSessions: + """Both halves of resolving a turn's scopes. + + `dropped` exists because a caller holding only `sessions` cannot tell a + person with no personal toolkits from a person whose account could not be + reached this turn — and it tells them "not configured for you", which is a + settled fact about their setup rather than the outage it actually is. + """ + + sessions: tuple[ScopedSession, ...] + dropped: tuple[DroppedScope, ...] + + +class SessionCache: + """ + Sessions keyed by identity and toolkit set. + + An instance rather than module state so a test gets a clean cache without + reaching into globals, and so two configurations cannot share entries. + """ + + def __init__(self, config: ComposioConfig, *, client: Any | None = None) -> None: + self._config = config + self._client = client + self._sessions: OrderedDict[tuple[str, tuple[str, ...]], Session] = ( + OrderedDict() + ) + # This cache is read from more than one thread. LangChain runs a sync + # `@tool` in a threadpool, so a turn's tool calls resolve their scopes + # concurrently, and FastAPI runs a sync route the same way, so two + # connect clicks arrive together. `_guard` covers the bookkeeping below + # — the ordered dictionary, the client, and the per-key locks — and is + # never held across a network call. + self._guard = threading.Lock() + #: One lock per cache key, so two threads asking for the *same* identity + #: take turns while two asking for different ones do not wait on each + #: other. Session creation is a round trip; a single lock over it would + #: put every first-time identity in a workspace behind one queue. + self._building: dict[ + tuple[str, tuple[str, ...]], tuple[threading.Lock, int] + ] = {} + + @property + def size(self) -> int: + """How many sessions are held. For tests and for a health check.""" + return len(self._sessions) + + def client(self) -> Any: + """The SDK client, constructed on first use. + + Shared with the effect map so one process holds one client, and so the + api key is read in exactly one place. Both of those stop being true if + two threads construct one at the same time: the loser's client is the + one already handed to a caller, and the api key has then been read twice + for two connection pools that outlive the request that made them. + """ + with self._guard: + if self._client is None: + self._client = Composio(api_key=self._config.api_key) + return self._client + + def _key(self, scope: ResolvedScope) -> tuple[str, tuple[str, ...]]: + return (scope.user_id, scope.toolkits) + + def _pinned_auth_configs(self, scope: ResolvedScope) -> dict[str, str]: + """The operator's auth-config choices that apply to this scope. + + Keyed by toolkit, and narrowed to the scope's own toolkits so a session + is never told about a pin for a toolkit it does not carry. + """ + pinned = self._config.auth_configs + return { + toolkit: pinned[toolkit] for toolkit in scope.toolkits if toolkit in pinned + } + + def invalidate(self, scope: ResolvedScope) -> None: + """Forget one scope's session so the next use builds a fresh one. + + A session that has started failing goes on failing for as long as it is + cached, so without this one stale session takes an identity out of + service until the process restarts. Dropping it costs a single round + trip, and nothing is lost: the connected accounts live on Composio's + side, not in here. + """ + with self._guard: + self._sessions.pop(self._key(scope), None) + + def _cached(self, key: tuple[str, tuple[str, ...]]) -> Session | None: + """The live session for one key, marked most recently used.""" + with self._guard: + session = self._sessions.get(key) + if session is not None: + # Most recently used, so eviction takes an identity that has + # gone quiet rather than one in the middle of a conversation. + self._sessions.move_to_end(key) + return session + + def _build_lock(self, key: tuple[str, tuple[str, ...]]) -> threading.Lock: + with self._guard: + lock, users = self._building.get(key, (threading.Lock(), 0)) + # Count waiting callers before they acquire the lock. A failed + # builder must not remove the lock while a waiter is about to retry. + self._building[key] = (lock, users + 1) + return lock + + def _remember(self, key: tuple[str, tuple[str, ...]], session: Session) -> None: + with self._guard: + self._sessions[key] = session + while len(self._sessions) > MAX_SESSIONS: + self._sessions.popitem(last=False) + + def _release_build_lock(self, key: tuple[str, tuple[str, ...]]) -> None: + """Forget a build lock only after its last holder or waiter leaves.""" + with self._guard: + current, users = self._building[key] + if users == 1: + del self._building[key] + else: + self._building[key] = (current, users - 1) + + def for_scope(self, scope: ResolvedScope) -> ScopedSession: + """The session for one scope, created on first use and reused after. + + A session is a remote object, so two threads creating one for the same + identity do not merely duplicate a dictionary entry: one of the two is + orphaned on Composio's side, held by nothing and closed by nobody. The + second thread waits for the first here instead, and then finds its + answer in the cache. + """ + key = self._key(scope) + session = self._cached(key) + if session is not None: + return ScopedSession(session=session, scope=scope) + + lock = self._build_lock(key) + with lock: + try: + # Whoever held this lock has published their session by now. + session = self._cached(key) + if session is not None: + return ScopedSession(session=session, scope=scope) + session = self._create(scope) + self._remember(key, session) + finally: + self._release_build_lock(key) + return ScopedSession(session=session, scope=scope) + + def _create(self, scope: ResolvedScope) -> Session: + """One new remote session for one scope.""" + return self.client().sessions.create( + user_id=scope.user_id, + toolkits=list(scope.toolkits), + # Explicit, and not optional. A default session hands back a + # remote shell and a remote Python tool with no opt-in, and the + # SDK only defaults them off under the direct-tools preset. The + # agent already has a sandbox behind its own credentials in + # `coding/`; a second ungated one arriving as a side effect of a + # toolkit list is a security surprise. + # + # `sandbox`, not `workbench`: the latter is a deprecated alias + # and passing both raises. + sandbox={"enable": False}, + # Also explicit, and also not optional: this defaults to True. + # Left on, the session carries tools that initiate and manage + # connected accounts — a second path to the thing the connect + # flow exists to control. That flow binds a connection to the + # actor the platform verified and delivers the link to that + # person alone; a model calling a connection tool inside a + # session binds whatever user id the session happens to hold, + # with no card, no approver and nobody verified. Nothing here + # needs it: `authorize()` mints links over the session's own + # REST endpoint and does not read this flag. + manage_connections=False, + # The same pinning the connect script applies, applied to the + # sessions that actually run the calls. Without it a toolkit + # could be *connected* through the auth config an operator + # named and then *used* through whichever one the project + # resolves on its own — the exact ambiguity + # `COMPOSIO_AUTH_CONFIGS` exists to settle, half-settled. + # `None` rather than `{}` when nothing is pinned: the SDK + # forwards the argument only when it is not None. + auth_configs=self._pinned_auth_configs(scope) or None, + ) + + def resolve(self, scopes: tuple[ResolvedScope, ...]) -> ResolvedSessions: + """ + Live sessions for every scope that can produce one, and the rest named. + + A scope whose session cannot be created is logged and dropped rather + than raising. One unreachable personal account must not take the team's + shared toolkits down for the turn, and a turn that runs with fewer tools + can still answer — while one that raises here answers nothing and + explains nothing. + + Dropped is not the same as absent, so the dropped scopes come back with + their reasons. A caller that sees only the survivors tells the person + "connected apps are not configured for you", which is a statement about + their setup and not about the lookup that just failed. + + The log names the scope so an operator can tell whose account went + missing, and the provider's reason so they can tell why. Neither is a + credential: the api key never leaves this module, and a failure to + create a session is not itself a capability. + """ + resolved: list[ScopedSession] = [] + dropped: list[DroppedScope] = [] + for scope in scopes: + try: + resolved.append(self.for_scope(scope)) + except (TypeError, AttributeError): + # The SDK no longer takes what this module passes it. That is a + # broken build, and every scope will fail the same way — read as + # an unreachable account it becomes a permanent, misleading + # "that person is not connected". + raise + except Exception as error: # noqa: BLE001 - provider errors vary + logger.warning( + "[composio] no session for user=%s toolkits=%s — " + "running the turn without it: %s", + scope.user_id, + ",".join(scope.toolkits), + error, + ) + dropped.append(DroppedScope(scope=scope, reason=str(error))) + return ResolvedSessions(sessions=tuple(resolved), dropped=tuple(dropped)) diff --git a/agent/composio_tools/state.py b/agent/composio_tools/state.py new file mode 100644 index 00000000..b999707c --- /dev/null +++ b/agent/composio_tools/state.py @@ -0,0 +1,247 @@ +"""Graph state carrying who is speaking, and the one place it is decided. + +The Channel forwards the verified actor with every run, and the AG-UI adapter +merges forwarded properties into the graph's input. A key only survives that +merge if the state schema declares it, which is what `ComposioAgentState` is +for. + +Two things the adapter does *not* do, and this module must: + +* The adapter merges caller-supplied `state` **over** the forwarded properties + (`{**forwarded_props, **payload_input}` in `prepare_stream`), so a request + body naming somebody else wins over the platform's own word for who spoke. +* The graph is checkpointed per thread, so `channel_actor` survives the turn + that set it. A later turn that forwards nobody inherits the last speaker and + runs in their connected accounts. + +`with_forwarded_actor` closes both: it rebuilds a run's state with +`channel_actor` taken from the forwarded properties and from nothing else, and +writes `None` when the run forwarded nobody so the previous speaker is cleared +rather than inherited. `agui.OpenTagAGUIAgent` applies it to every run, which is +the only point that can see the trusted and the untrusted value side by side. + +One exception, and it is why a resume carries its identity in the interrupt +payload rather than reading state. `@copilotkit/channels-core` does forward the +actor with a resume — `runAgentLoop` sends `{...forwardedIdentity(identity), +command: resume}` — but the adapter never merges it into the graph's input on +that path: a run carrying `command.resume` builds `Command(resume=...)` as the +whole stream input, and the merged state this module produces is computed and +then dropped. So a resume reaches the graph with no actor whatever the Channel +sent, and the approval it answers has to carry its own. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, NotRequired + +from deepagents import DeepAgentState + +#: Surfaces a turn can arrive from. +#: +#: Every platform label the installed `@copilotkit/channels` ships an adapter +#: for, copied from that package's own allow-list — `KNOWN_PLATFORMS` in +#: `@copilotkit/channels-core/dist/telemetry/sanitize-error.js`, which the +#: package keeps in step with its adapters through a coverage test of its own. +#: Read rather than guessed, because a surface missing from here is not a +#: degraded turn: it reads as *anonymous*, which is the exact failure this +#: module exists to prevent, waiting for whoever adds the next adapter. +#: +#: Still closed, and that is what makes the `platform:id` join injective: no +#: member contains a colon, so the first colon in a key is always the separator +#: and `(platform, id)` is recoverable from the key even when an id contains +#: one. An open set could not promise that — a blank platform used to namespace +#: people under `unknown:`, and a non-string one was coerced, so `{"x": 1}` and +#: `7` each minted a key of their own. A custom third-party adapter is still +#: anonymous here on purpose: its label is a free-form string its author +#: chooses, and admitting it would let two deployments' labels collide in one +#: namespace. +KNOWN_PLATFORMS = frozenset({"slack", "teams", "discord", "telegram", "whatsapp"}) + +#: The one actor kind that gets a personal identity. +#: +#: `ProviderActor.kind` is the provider's own word for what sent a message, and +#: the Channels SDK documents it as untrusted metadata rather than +#: authorization. That is exactly why it is read as a filter and never as a +#: grant: `bot`, `app`, `system` and `unknown` are refused, so a workflow or an +#: integration posting into a thread cannot spend a person's connected account. +PERSONAL_KINDS = frozenset({"human"}) + +#: The one state key this module decides. Named rather than spelled inline +#: because it is also the key the AG-UI adapter filters a run's input by, and +#: the two have to agree: a run whose input drops this key does not clear the +#: previous speaker, it inherits them. +ACTOR_STATE_KEY = "channel_actor" + +#: Every spelling of the actor a caller could put in a request's `state`. All of +#: them are dropped before the forwarded one is written. +_CALLER_ACTOR_KEYS = (ACTOR_STATE_KEY, "channelActor") + + +def _named_identity(actor: Any) -> tuple[str, str] | None: + """ + `(platform, id)` when this value names somebody, else `None`. + + The single type gate, shared by everything that reads an actor, so no two + callers can disagree about what a usable id is. `actor_of` rejecting a + non-string id while `actor_key` coerced one was such a disagreement: the + same actor was nobody to one function and a real Composio identity to the + other. + + The two halves are normalised differently on purpose. The platform is + case-folded because it is a label this repository chose the spelling of — + `KNOWN_PLATFORMS` is the whole vocabulary, and `Slack` and `slack` are the + same surface however a caller types them. The id is *not*, because it is a + provider's opaque handle and only that provider knows whether case is + significant: Slack member ids and Teams AAD object ids are compared exactly, + and `U1` and `u1` there are two people. Folding an id would merge two people + into one Composio identity and hand each other's connected accounts over, + which is worse than the cost of not folding — the same person arriving under + two spellings gets two identities and has to connect twice. One is a + security failure and the other is an inconvenience, so the id is left + exactly as the provider spelled it. + """ + if not isinstance(actor, Mapping): + return None + + identifier = actor.get("id") + if not isinstance(identifier, str): + return None + identifier = identifier.strip() + if not identifier: + return None + + platform = actor.get("platform") + if not isinstance(platform, str): + return None + platform = platform.strip().lower() + if platform not in KNOWN_PLATFORMS: + return None + + return platform, identifier + + +def is_personal_kind(actor: Any) -> bool: + """Whether this actor is a person, rather than something posting as one.""" + if not isinstance(actor, Mapping): + return False + kind = actor.get("kind") + return isinstance(kind, str) and kind.strip().lower() in PERSONAL_KINDS + + +def personal_actor(actor: Any) -> dict[str, Any] | None: + """ + The person this value names, reduced to what the agent acts on. + + `id`, `platform` and `kind` and nothing else. A `ProviderActor` also carries + `name`, `handle` and `email`, and none of them decide anything here — while + the whole of `channel_actor` is echoed back in every `StateSnapshotEvent` + and kept in the thread's checkpoint. The person's display name and work + address are already known to the surface that sent them, so carrying them + through the graph buys nothing and spreads them. + """ + named = _named_identity(actor) + if named is None or not is_personal_kind(actor): + return None + platform, identifier = named + return { + "id": identifier, + "platform": platform, + "kind": actor["kind"].strip().lower(), + } + + +def actor_of(state: Mapping[str, Any] | None) -> dict[str, Any] | None: + """ + The actor this turn may act as, or `None` when the turn named nobody. + + Defensive about shape because this value crosses a process boundary: an + actor that is malformed, from an unknown surface, or not a person reads as + an anonymous turn, which costs access to personal toolkits and never grants + it. + """ + if not isinstance(state, Mapping): + return None + return personal_actor(state.get(ACTOR_STATE_KEY)) + + +def actor_key(actor: Mapping[str, Any] | None) -> str | None: + """ + The stable per-person key, namespaced by platform. + + A provider id is unique only within its provider, so two platforms can hand + out the same string for different people. Everything keyed per person — + a connected account, a pending approval — keys on both parts. + + Naming only: it answers "how is this identity spelled", not "may this actor + act". `actor_of` and the connect route make that second decision, both + through `is_personal_kind`, and both on top of the same `_named_identity` + gate this uses — so there is no value one of them calls nobody and the other + turns into a Composio user id. + + An id or platform that does not pass that gate is nobody, and returns `None` + rather than a key ending in a colon or beginning with `unknown:`. Callers + reaching this through `actor_of` already had that filtered, but the connect + route does not: it builds an actor from a request body, and a live run + showed an empty `actor_id` minting a real link bound to an identity no turn + would ever look up again. + """ + named = _named_identity(actor) + if named is None: + return None + platform, identifier = named + return f"{platform}:{identifier}" + + +def forwarded_actor(forwarded_props: Any) -> dict[str, Any] | None: + """ + The actor the Channel forwarded with this run, or `None`. + + Read from `forwardedProps` alone. A Channel puts the platform's own word for + who spoke there; a request's `state` is whatever the caller typed, and the + two arrive in the same slot by the time the graph sees them. + + Both spellings are accepted because the key is snake-cased on its way + through the adapter, and this runs before that happens on one path and after + it on another. Both can therefore arrive in the same mapping, which is why + the search is for the first key that *names somebody* rather than the first + key that is present: a null or malformed `channel_actor` sitting beside a + real `channelActor` used to discard it, and the turn then ran anonymously — + no personal toolkits, for a person the Channel had identified. + """ + if not isinstance(forwarded_props, Mapping): + return None + for key in _CALLER_ACTOR_KEYS: + actor = personal_actor(forwarded_props.get(key)) + if actor is not None: + return actor + return None + + +def with_forwarded_actor( + state: Any, + forwarded_props: Any, +) -> dict[str, Any]: + """ + One run's state, with `channel_actor` decided by the forwarded actor alone. + + Always written, never merged. A caller's own `channel_actor` is dropped + whichever way it was spelled, and a run that forwarded nobody writes `None` + — an explicit key, because the graph is checkpointed per thread and leaving + it out lets the previous speaker's identity stand. An anonymous turn + inheriting the last speaker is how a second person in a Slack thread got a + Gmail call executed in the first person's account. + """ + merged = { + key: value + for key, value in (state.items() if isinstance(state, Mapping) else ()) + if key not in _CALLER_ACTOR_KEYS + } + merged[ACTOR_STATE_KEY] = forwarded_actor(forwarded_props) + return merged + + +class ComposioAgentState(DeepAgentState): + """`DeepAgentState` plus the forwarded actor.""" + + channel_actor: NotRequired[dict[str, Any] | None] diff --git a/agent/composio_tools/tools.py b/agent/composio_tools/tools.py new file mode 100644 index 00000000..9a8818ca --- /dev/null +++ b/agent/composio_tools/tools.py @@ -0,0 +1,654 @@ +"""The two tools the model sees: find an action, then run it. + +Registered once when the graph is built. Identity is read per call from the +forwarded actor in state, never captured at build time and never taken from a +model-supplied argument — the model chooses *what* to do, and the platform +decides *whose* account it happens in. + +Binding every tool of every connected toolkit is not an option: gmail alone +exposes 63, linear 47, googlecalendar 49. Composio's own session is a router, so +the model searches and then executes, and search returns the schemas inline — +which collapses search, fetch-schema, execute into two hops rather than three. +""" + +from __future__ import annotations + +import json +import logging +from typing import Annotated, Any + +from langchain_core.tools import tool +from langgraph.prebuilt import InjectedState + +from composio_tools.classify import needs_approval +from composio_tools.config import ComposioConfig +from composio_tools.effects import EffectMap +from composio_tools.scopes import ResolvedScope, resolve_scopes +from composio_tools.sessions import DroppedScope, ResolvedSessions, SessionCache +from composio_tools.state import actor_key, actor_of +from write_confirmation import ( + emit_write_failure, + require_write_confirmation, + summarize_args, +) + +logger = logging.getLogger(__name__) + +#: How many candidates the model sees. Tunable; not a principle. +MAX_RESULTS = 5 + +#: Longest provider-reported reason carried into a tool result. A structured +#: error can be arbitrarily large and the model reads every character of it. +_MAX_REASON = 300 + + +def _plain(value: Any) -> Any: + """ + One SDK response, as plain data. + + The Python SDK answers with Pydantic models — `SessionSearchResponse`, + `Result` — where the TypeScript one answered with plain objects. Reading them + as dictionaries returns nothing and raises nothing, so discovery came back + empty against a live project while every dict-shaped unit test passed. Tests + now build models too; this is the boundary that makes either work. + """ + dump = getattr(value, "model_dump", None) + if callable(dump): + try: + return dump() + except Exception as error: # noqa: BLE001 - not fatal, but never silent + # Falling through leaves an object no reader here understands, and + # both readers treat that as a failure rather than as empty data. + # Said out loud because it is a change in the SDK, and the symptom + # downstream ("nothing came back") points nowhere near it. + logger.warning( + "[composio] could not read a %s as data: %s", + type(value).__name__, + error, + ) + if isinstance(value, dict): + return {key: _plain(item) for key, item in value.items()} + if isinstance(value, list): + return [_plain(item) for item in value] + return value + + +def _as_list(value: Any) -> list[Any]: + plain = _plain(value) + return plain if isinstance(plain, list) else [] + + +def _as_dict(value: Any) -> dict[str, Any]: + plain = _plain(value) + return plain if isinstance(plain, dict) else {} + + +def _as_strings(value: Any) -> list[str]: + return [item for item in _as_list(value) if isinstance(item, str)] + + +def _field(fields: dict[str, Any], declared: str, camel: str) -> Any: + """ + One response field, read by the name the Python SDK actually declares. + + The models are `extra='allow'` and are built by `construct_type`, so a + response carrying the TypeScript SDK's camelCase spelling keeps *both* keys + and both are readable. Only one of them is the response's answer. + + Camel-first `or` picked the wrong one: a passthrough `toolSchemas` naming + other slugs beat the real `tool_schemas`, every candidate came back with + `inputSchema: null` — uncallable, by this module's own account — and the + model went on to guess arguments. Nothing anywhere said so. + + Presence decides rather than truthiness. A declared field that is + legitimately empty is still this response's answer, and falling through on + empty would hand the decision straight back to the undeclared key. + """ + return fields[declared] if declared in fields else fields.get(camel) + + +def _reason_text(value: Any) -> str: + """ + One provider-reported error as text, whatever shape it arrived in. + + `error` is declared `Optional[str]` and is not type-checked at runtime, so a + structured provider error arrives as a mapping. Tested with `isinstance` + alone it read as no error at all, and an outage reached the model as an + empty tool list — which the model reports to a person as "you have no tool + for that". + + Booleans are not a reason. `error: False` says nothing and `error: True` + says only what `success` already says, so both answer "" and the caller's + own default sentence stands. + """ + if value is None or isinstance(value, bool): + return "" + plain = _plain(value) + if isinstance(plain, str): + return plain.strip() + if isinstance(plain, (dict, list, tuple)): + if not plain: + return "" + text = json.dumps(plain, ensure_ascii=False, default=str) + else: + text = str(plain) + text = text.strip() + return text[:_MAX_REASON] + "…" if len(text) > _MAX_REASON else text + + +def _execute_fields(result: Any) -> dict[str, Any] | None: + """ + One execute result as fields, or `None` when nothing here can read it. + + `_as_dict` answered `{}` for every shape it did not recognise, and `{}` reads + downstream as no error and no data — a success carrying nothing. An + unrecognised result is not a success; it is a result nobody read, and the + caller has to be able to tell the difference. + + The attribute path goes through `_plain` exactly like the mapping one. It + did not, and the SDK nests models inside models, so `data` reached the model + as an object whose repr was all it could see. + """ + plain = _plain(result) + if isinstance(plain, dict): + return plain + fields = { + name: _plain(getattr(result, name)) + for name in ("data", "error", "successful", "log_id", "logId") + if hasattr(result, name) + } + return fields or None + + +def _candidates_of(response: Any) -> list[dict[str, Any]]: + """ + Every candidate one scope offers, in the order that scope ranked them. + + Primary slugs before related ones, because that ordering is the scope's own + judgement and there is nothing better to replace it with. + """ + payload = _as_dict(response) + schemas = _as_dict(_field(payload, "tool_schemas", "toolSchemas")) + candidates: list[dict[str, Any]] = [] + + for entry in _as_list(payload.get("results")): + result = _as_dict(entry) + slugs = [ + *_as_strings(_field(result, "primary_tool_slugs", "primaryToolSlugs")), + *_as_strings(_field(result, "related_tool_slugs", "relatedToolSlugs")), + ] + for slug in slugs: + schema = _as_dict(schemas.get(slug)) + description = schema.get("description") + candidates.append( + { + "slug": slug, + "description": description if isinstance(description, str) else "", + "inputSchema": _field(schema, "input_schema", "inputSchema"), + } + ) + return candidates + + +def _search_failure(response: Any) -> str | None: + """ + Why this search did not run, or `None` when it ran. + + Three fields say it and all three are read: `success` is the response's own + verdict, `error` carries the reason ("X out of Y searches failed, reasons: + …"), and `Result.error` reports the single query we send failing on its own. + + A response that carries no candidates *because* it failed must never reach + the model as an empty list. The model reports an empty list to a person as a + settled fact — "you have no tool for that" — and a server-side outage is not + a fact about anybody's connected apps. + """ + payload = _plain(response) + if not isinstance(payload, dict): + # `_as_dict` answers `{}` here, which is indistinguishable from a + # response that legitimately found nothing. + return ( + "the provider returned a response this agent cannot read " + f"({type(response).__name__})" + ) + + reason = _reason_text(payload.get("error")) + failed = payload.get("success") is False or bool(reason) + + for entry in _as_list(payload.get("results")): + per_query = _reason_text(_as_dict(entry).get("error")) + if per_query: + failed = True + reason = reason or per_query + + if not failed: + return None + return reason or "the provider reported the search as failed" + + +def _scope_name(scope: ResolvedScope) -> str: + """A scope named by what it reaches, not by whose id it holds. + + The failure list is read by the model, so it says "gmail (your account)" + rather than the Composio user id — which is the person's platform identity + and buys the model nothing. + """ + toolkits = ", ".join(scope.toolkits) or "no toolkits" + return f"{toolkits} ({'your account' if scope.personal else 'the shared account'})" + + +def _unreachable(dropped: tuple[DroppedScope, ...]) -> str: + """What to say when this turn resolved no session at all. + + "Not configured for you" is a statement about somebody's setup, and telling + a person to connect an app they already connected is the wrong instruction + — so it is said only when nothing was even attempted. + """ + if not dropped: + return "Connected apps are not configured for you." + reasons = "; ".join( + f"{_scope_name(entry.scope)}: {entry.reason}" for entry in dropped + ) + return ( + "Connected apps could not be reached on this turn. This is a lookup " + f"failure and not a missing setup: {reasons}" + ) + + +def _no_actor(user_toolkits: tuple[str, ...]) -> str: + """What to say when personal toolkits exist and the turn named nobody. + + Logged already, and until now logged *only* — so the model was left to + explain the absence with the two sentences it had: "not configured for you" + and "no connected app provides GMAIL_SEND_EMAIL". Both describe somebody's + setup, and neither is what happened. The turn arrived without the person on + it, which is this deployment's problem rather than theirs, and telling them + to connect an app they already connected is the one instruction that cannot + help. + """ + toolkits = ", ".join(user_toolkits) or "personal apps" + return ( + f"This turn did not carry who is speaking, so personal apps ({toolkits}) " + "were not available. Nothing is missing from anyone's setup and nobody " + "should be asked to connect an app; say the request could not be " + "attributed to a person on this turn." + ) + + +def _interleave(per_scope: list[list[dict[str, Any]]]) -> list[dict[str, Any]]: + """ + Round-robin across scopes rather than concatenating them. + + Scopes arrive shared-first and the cap is global, so concatenating would let + a chatty shared scope fill every slot and make the asking person's own apps + unreachable — "what's on my calendar" answering with only Linear tools. + Taking one candidate from each scope in turn keeps every scope represented. + + Deduplicated by slug, first occurrence wins. A linear scan on purpose: the + lists hold a handful of entries and a set would buy nothing. + """ + merged: list[dict[str, Any]] = [] + deepest = max((len(entries) for entries in per_scope), default=0) + + for rank in range(deepest): + for entries in per_scope: + if rank >= len(entries): + continue + candidate = entries[rank] + if any(existing["slug"] == candidate["slug"] for existing in merged): + continue + merged.append(candidate) + return merged + + +def owns_slug(scope_toolkits: tuple[str, ...], slug: str) -> bool: + """ + Whether a toolkit set contains the toolkit a slug belongs to. + + Composio slugs are `TOOLKIT_REST_OF_NAME` with the toolkit uppercased — + `GMAIL_SEND_EMAIL`, `GOOGLECALENDAR_EVENTS_LIST` — so the prefix is the only + thing needed to place a slug that discovery never returned. Which is the + case that matters: without this, an unplaced slug falls to the first scope, + the shared account, which does not carry the toolkit at all. + """ + upper = slug.upper() + return any(upper.startswith(f"{toolkit.upper()}_") for toolkit in scope_toolkits) + + +def humanize_slug(slug: str) -> str: + """`GMAIL_SEND_EMAIL` becomes `Send email (Gmail)`, for the approval card. + + The verb leads and the app follows in brackets. The card labels its confirm + button with the action's first word, and reads that same word to decide + whether the action looks dangerous — so leading with the toolkit gave every + Gmail action a button reading "Gmail", and hid "delete" from the one check + that cared about it. + """ + toolkit, _, rest = slug.partition("_") + if not rest: + return toolkit.capitalize() + words = rest.replace("_", " ").lower() + return f"{words[:1].upper()}{words[1:]} ({toolkit.capitalize()})" + + +def build_composio_tools( + config: ComposioConfig, + cache: SessionCache, + effects: EffectMap | None = None, +) -> list[Any]: + """The Composio tools for this deployment, or none at all.""" + effects = effects or EffectMap(cache.client) + + def resolve_turn( + state: dict[str, Any] | None, + ) -> tuple[ResolvedSessions, str | None]: + """This turn's sessions, and what to say if it named nobody.""" + # The platform-namespaced key, not the raw provider id. A provider id is + # unique only within its provider, so one deployment serving Slack and + # Teams would otherwise give `U1` on either platform the same Composio + # identity — and therefore each other's connected accounts. + identity = actor_key(actor_of(state)) + note = None + if identity is None and config.user_toolkits: + # The silent failure this feature is most likely to hit: an older + # `@copilotkit/channels` does not forward the actor, so every turn + # looks anonymous and personal toolkits quietly offer nothing while + # shared ones keep working. Said out loud in the log *and* carried + # back to the model, because the symptom otherwise reads to the + # person as "the app is not connected". + logger.warning( + "[composio] this turn carried no actor, so personal toolkits " + "(%s) are unavailable. A Channel forwards it as `channelActor`; " + "check the @copilotkit/channels version.", + ",".join(config.user_toolkits), + ) + note = _no_actor(config.user_toolkits) + scopes = resolve_scopes(config, identity) + return cache.resolve(scopes), note + + def nothing_reachable(resolved: ResolvedSessions, no_actor: str | None) -> str: + """Why this turn resolved no session at all. + + The anonymous note replaces `_unreachable` rather than following it when + nothing was even attempted: "connected apps are not configured for you" + is a settled fact about somebody's setup, and on a personal-only + deployment it is simply false — the apps are configured, the turn just + never said whose they are. + """ + if no_actor is not None and not resolved.dropped: + return no_actor + unreachable = _unreachable(resolved.dropped) + return unreachable if no_actor is None else f"{unreachable} {no_actor}" + + @tool + def search_my_tools( + query: str, + state: Annotated[dict[str, Any], InjectedState], + ) -> dict[str, Any] | str: + """Find actions available in the connected apps. Call this before run_my_tool. + + Args: + query: What you want to do, in plain words, e.g. 'send an email'. + """ + resolved, no_actor = resolve_turn(state) + scopes = resolved.sessions + if not scopes: + return nothing_reachable(resolved, no_actor) + + per_scope: list[list[dict[str, Any]]] = [] + needs_connection: list[str] = [] + # A scope that never produced a session is a scope that was not + # searched, and it is carried here for the same reason a failed search + # is: silence would make a partial answer look like a whole one. + failures: list[str] = [ + f"{_scope_name(entry.scope)}: {entry.reason}" for entry in resolved.dropped + ] + + for entry in scopes: + try: + response = entry.session.search(query=query) + except (TypeError, AttributeError): + # A call that no longer matches the SDK's signature is a broken + # build, not a scope having a bad day. Folded into the outage + # branch below it would read as "that app is unreachable" on + # every turn and forever, which is the one diagnosis that leads + # nobody to the actual cause. + raise + except Exception as error: # noqa: BLE001 - provider errors vary + # One scope's failure costs its own candidates and nothing else + # — but it is still carried back, because "we did not look" and + # "we looked and found nothing" are different answers. + logger.warning( + "[composio] search failed for user=%s: %s", + entry.scope.user_id, + error, + ) + cache.invalidate(entry.scope) + failures.append(f"{_scope_name(entry.scope)}: {error}") + continue + + failure = _search_failure(response) + if failure is not None: + logger.warning( + "[composio] search reported a failure for user=%s: %s", + entry.scope.user_id, + failure, + ) + # Same session, same state as one that raised: it goes on + # reporting failures for as long as it is cached, so the next + # turn would reuse the bad one. Dropping it costs one round trip. + cache.invalidate(entry.scope) + failures.append(f"{_scope_name(entry.scope)}: {failure}") + continue + + per_scope.append(_candidates_of(response)) + + for status in _as_list( + _field( + _as_dict(response), + "toolkit_connection_statuses", + "toolkitConnectionStatuses", + ) + ): + fields = _as_dict(status) + active = _field( + fields, "has_active_connection", "hasActiveConnection" + ) + # Only an explicit False means "not connected". An absent status + # is silence, not something to prompt a person about. + if active is not False: + continue + toolkit = fields.get("toolkit") + if isinstance(toolkit, str) and toolkit not in needs_connection: + needs_connection.append(toolkit) + + if failures and not per_scope: + # Nothing was searched. Returning `{"tools": []}` here is the + # failure this whole function most has to avoid: it is a lookup + # outage wearing the words "no tools found". + return ( + "Searching connected apps failed, so this is not an empty " + "result — nothing was searched. " + "; ".join(failures) + ) + + merged = _interleave(per_scope) + # A candidate with no schema cannot be called, so it must never displace + # one that can — but it still ships, so the model can see it exists. + ordered = [item for item in merged if item["inputSchema"] is not None] + [ + item for item in merged if item["inputSchema"] is None + ] + payload: dict[str, Any] = { + "tools": ordered[:MAX_RESULTS], + "needsConnection": needs_connection, + } + if failures: + # Present only when there were failures, so an absent key means + # every scope answered and an empty `tools` really is empty. + payload["searchFailures"] = failures + if no_actor is not None: + # Its own key rather than folded into `searchFailures`: nothing + # failed here. The personal scopes were never resolved, because the + # turn did not say who to resolve them for. + payload["personalAppsUnavailable"] = no_actor + return payload + + @tool + def run_my_tool( + slug: str, + arguments: dict[str, Any], + state: Annotated[dict[str, Any], InjectedState], + ) -> Any: + """Run one action found by search_my_tools. + + Args: + slug: The tool slug from search_my_tools, e.g. 'GMAIL_SEND_EMAIL'. + arguments: Arguments matching that tool's input schema. + """ + resolved, no_actor = resolve_turn(state) + scopes = resolved.sessions + if not scopes: + return nothing_reachable(resolved, no_actor) + + owning = next( + (entry for entry in scopes if owns_slug(entry.scope.toolkits, slug)), + None, + ) + if owning is None: + # The app may be configured and simply unreachable this turn. Saying + # "no connected app provides it" would send the model, and then the + # person, to fix a setup that is not broken. + lost = next( + ( + entry + for entry in resolved.dropped + if owns_slug(entry.scope.toolkits, slug) + ), + None, + ) + if lost is not None: + return ( + f"{slug} belongs to {_scope_name(lost.scope)}, which could " + f"not be reached on this turn: {lost.reason}" + ) + if no_actor is not None and owns_slug(config.user_toolkits, slug): + # The toolkit is configured and very probably connected. What is + # missing is the person, so saying "no connected app provides + # it" would send them to fix something that is not broken. + return f"{slug} did not run. {no_actor}" + return ( + f"No connected app here provides {slug}. " + "Call search_my_tools and use a slug it returned." + ) + + effect = effects.effect_for(slug) + # The label the card carried, and whether there was a card at all. Both + # decide what a later failure is allowed to say, and to whom. + label = humanize_slug(slug) + gated = needs_approval(effect, config.approvals) + if gated: + # The same card, and the same pause, that already gate a Linear or + # Notion write. One gate for every action a person has to sign off + # on, rather than a second mechanism that behaves almost the same. + # + # The graph resumes after the decision, so unlike the channel-side + # version the model sees the result of an approved call. + approved = require_write_confirmation( + action=label, + fields=summarize_args(arguments), + extra_args={ + # Who may answer this card. A personal call runs in one + # person's account, so a colleague approving it would spend + # somebody else's access. The surface knows who clicked and + # enforces it; the agent can only say whose call it is. + "approver": actor_key(actor_of(state)) + if owning.scope.personal + else None, + "effect": effect, + }, + ) + if not approved: + return f"{label} was declined, so nothing ran." + + def failed(reason: Any, *, log_id: Any = None) -> str: + """One failure, told to everyone who is waiting on it. + + The model hears it as a tool result, by slug — the handle it calls + things by. The thread hears it under the label the card carried, + and only when there *was* a card: an approver whose last sight of + this action was "running" has no other way to learn it did not. + """ + logger.warning( + "[composio] %s failed for user=%s (log=%s): %s", + slug, + owning.scope.user_id, + log_id, + reason, + ) + if gated: + emit_write_failure(label, str(reason)) + return f"{slug} failed: {reason}" + + # `arguments` is keyword-only in the Python SDK. The TypeScript one took + # it positionally, and a hand-written fake happily accepted either. + try: + result = owning.session.execute(slug, arguments=arguments) + except (TypeError, AttributeError) as error: + # A broken build rather than a failed tool, so it is not turned into + # a result the model will read as "try again". Reported to the + # thread on the way out all the same: the approval was already + # spent, and this raise is the end of the turn. + logger.warning( + "[composio] %s could not be called for user=%s — the SDK does " + "not accept this call: %s", + slug, + owning.scope.user_id, + error, + ) + if gated: + emit_write_failure(label, f"{type(error).__name__}: {error}") + raise + except Exception as error: # noqa: BLE001 - provider errors vary + # The one provider call that used to run unguarded, and the only one + # that runs *after* a person has approved something. Escaping here + # ends the turn with the card still reading "running". + cache.invalidate(owning.scope) + return failed(error) + + fields = _execute_fields(result) + if fields is None: + return failed( + "the provider returned a result this agent cannot read " + f"({type(result).__name__})" + ) + + error = fields.get("error") + data = fields.get("data") + log_id = _field(fields, "log_id", "logId") + + # Mandatory, not defensive: execute reports a failed tool in `error` and + # does not raise, so a try/except alone reads every failed write as a + # success. + if error: + return failed(_reason_text(error) or error, log_id=log_id) + + # And it does not always fill `error` in. `successful` is the execution + # envelope's own verdict — declared on `ToolExecuteResponse`, and + # carried through the `extra='allow'` session response — so a failure + # reported in the flag with a null message used to hand `data` back as a + # success, skipping the failure path and the thread notice with it. + # + # Only an explicit `False` counts. `SessionExecuteResponse` does not + # declare the field at all, so absent is silence rather than an answer. + if fields.get("successful") is False: + return failed( + "the provider reported the call as failed", log_id=log_id + ) + + return data + + # Asking somebody to connect an account is not here. Posting a card is the + # surface's work, and it is a channel tool (`connect_app`) for a concrete + # reason: an interrupt cannot be resumed from an interrupt handler, only from + # a button click, so the agent-side version could only ever fail. The agent + # still decides *when* to ask — discovery tells it which app is unconnected. + return [search_my_tools, run_my_tool] diff --git a/agent/main.py b/agent/main.py index 3eeba37d..7dd39cd7 100644 --- a/agent/main.py +++ b/agent/main.py @@ -3,13 +3,21 @@ from collections.abc import Mapping import os import sys +from typing import Any from ag_ui_langgraph import add_langgraph_fastapi_endpoint -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from pydantic import BaseModel from agent import build_agent +from agent_auth import authorizes_capability, configured_secret, is_authorized from agui import AGENT_DESCRIPTION, AGENT_NAME, build_agui_agent +from composio_tools.config import DEFAULT_WORKSPACE_USER_ID +from composio_tools.connect import ConnectRefused, connect_link +from composio_tools.runtime import composio_runtime +from composio_tools.state import actor_key, is_personal_kind app = FastAPI( title="OpenTag Agent", @@ -17,6 +25,38 @@ version="0.1.0", ) +# Registration order is load-bearing, and it reads backwards: Starlette builds +# the stack so that the middleware added *last* sits outermost. CORS must be the +# outer one. Added first — the way this file used to have it — the secret check +# wraps CORS, and then a browser preflight, which carries no `Authorization` +# because asking whether it may send one is the entire point of a preflight, is +# refused before CORS ever runs. Every 401 also loses its CORS headers, so a +# browser reports an opaque CORS failure instead of the status, and +# `CORS_ALLOW_ORIGINS` is inert exactly where an operator with a wrong secret +# needs to read it. + + +@app.middleware("http") +async def require_shared_secret(request: Request, call_next): + """Check the runtime's shared secret, when one is configured. + + Only when configured: a local run has no secret, and enforcing + unconditionally would take every existing deployment down on upgrade. The + connect route does not rely on this — it requires a secret of its own + accord, because handing out a bearer capability to an unauthenticated caller + has no correct configuration. + + `BaseHTTPMiddleware` in front of an SSE endpoint was measured rather than + assumed: against a real uvicorn socket, chunks arrive at the same moments + with it and without it, and a client disconnect still cancels the generator + at the same chunk. Nothing is buffered and nothing leaks (starlette 1.3.1, + uvicorn 0.51.0, anyio 4.14.2). + """ + if not is_authorized(request.url.path, request.headers.get("authorization")): + return JSONResponse({"error": "unauthorized"}, status_code=401) + return await call_next(request) + + # Allow all origins locally, or set CORS_ALLOW_ORIGINS to restrict access. _cors_origins = [ o.strip() @@ -32,15 +72,162 @@ ) -@app.get("/health") +# HEAD as well as GET: a platform probe that sends HEAD is ordinary, and this +# route answering GET alone made it a 405 that reads like an outage. +@app.api_route("/health", methods=["GET", "HEAD"]) def health(): """Return service health.""" return {"status": "ok", "service": "opentag-agent", "version": "0.1.0"} +class ConnectRequest(BaseModel): + """One person, one app. No link comes in; exactly one goes out.""" + + actor_id: str + platform: str + toolkit: str + #: The clicker's `ProviderActor.kind`. Optional on the wire and refused when + #: absent: a runtime too old to send it cannot say whether a person clicked, + #: and "I could not tell" is not a reason to mint a bearer capability. The + #: failure is a readable 400 rather than a schema rejection, because the + #: person on the other end sees this sentence. + kind: str | None = None + + +@app.post("/composio/connect") +def composio_connect(body: ConnectRequest, request: Request): + """Mint a connect link for one person's own account. + + The response is a bearer capability, so this route is deliberately stricter + than the rest of the service: with no shared secret configured it reports + itself unavailable rather than serving. + + The surface calls it because the surface is what knows who clicked, and the + surface delivers the link privately because that is the one thing an agent + cannot do. The model never sees the URL. + """ + # Asked before the comparison, because "there is no secret" and "that is + # not the secret" are two different problems and 401 says the second one. + # The TypeScript caller renders 401 as "the agent rejected the one this app + # sent", so an operator reading it goes hunting for a mismatch between two + # values when only one of them exists — and the old branch logged nothing + # here, which left no other place for them to find out. The route docstring + # above has promised "reports itself unavailable" since it was written. + if configured_secret() is None: + print( + "[ERROR] /composio/connect refused: no AGENT_AUTH_HEADER is set on " + "the agent, so it has no secret to check and will mint nothing", + file=sys.stderr, + ) + return JSONResponse( + # Read by whoever clicked, so it names no variable and no + # credential. It mirrors the sentence the Channel shows when the + # missing half is its own. + { + "error": "Connecting your own account needs a shared secret set " + "on both this app and its agent, and the agent has not set one. " + "Ask whoever runs this deployment." + }, + status_code=503, + ) + if not authorizes_capability(request.headers.get("authorization")): + return JSONResponse({"error": "unauthorized"}, status_code=401) + + runtime = composio_runtime( + # The default spelled once, in the module that resolves it. A present + # but empty `INTELLIGENCE_CHANNEL_NAME` reaches here as the empty + # string rather than as this default, and `read_composio_config` falls + # through to the same constant for either. + default_user_id=os.environ.get( + "INTELLIGENCE_CHANNEL_NAME", DEFAULT_WORKSPACE_USER_ID + ) + ) + if runtime is None: + return JSONResponse( + {"error": "Composio is not configured on this deployment."}, + status_code=503, + ) + + actor = { + "id": body.actor_id, + "platform": body.platform, + "kind": body.kind, + } + # Both halves of the same question the graph asks before it runs a personal + # tool, asked through the same two functions: is this spelled like somebody, + # and is that somebody a person. A link minted for a bot or an app binds a + # real account to an identity no turn will ever act as. + identity = actor_key(actor) if is_personal_kind(actor) else None + if identity is None: + return JSONResponse({"error": actor_refusal(actor)}, status_code=400) + + result = connect_link(runtime, identity=identity, toolkit=body.toolkit) + if isinstance(result, ConnectRefused): + return JSONResponse({"error": result.reason}, status_code=400) + return {"redirectUrl": result.url} + + +def actor_refusal(actor: Mapping[str, Any]) -> str: + """Why the gate above refused this actor, in words for whoever clicked. + + Only the sentence. The decision stays where it was, in the two functions + the graph asks the same question through, so this cannot answer "yes" to + something they refused or disagree with them about why. + + Three unrelated causes used to share one sentence, and that sentence is + shown to the person who pressed the button. "No person was named." is true + of a blank `actor_id` and false of everything else it was answering: told + to a Discord human, it says they did not identify themselves, and they go + looking for a name they gave. A person who cannot act on what they are told + asks the operator instead, and the operator is told nothing either. + + Nothing the request said comes back out. This string is rendered into a + card posted publicly in a Slack thread, as mrkdwn, where + `` is a live hyperlink — the same reason the + Channel refuses a toolkit slug that is not an identifier. Echoing an + `actor_id` or a `platform` back would put the model's or a caller's text + into that card. + """ + kind = actor.get("kind") + if not (isinstance(kind, str) and kind.strip()): + # A runtime too old to send `kind` cannot say whether a person clicked, + # and "I could not tell" is not a reason to mint a bearer capability. + # This one is addressed past the clicker, because only an upgrade fixes + # it and nothing they do will. + return ( + "This app could not tell whether a person clicked, so it will not " + "connect an account. Ask whoever runs this deployment." + ) + if not is_personal_kind(actor): + return ( + "Only a person can connect their own account, and this did not come " + "from one." + ) + identifier = actor.get("id") + if not (isinstance(identifier, str) and identifier.strip()): + return "No person was named." + # Everything else held, so the surface is what `_named_identity` refused. + # Reached by a real platform this deployment does not serve as much as by a + # malformed one, and it is the only branch that is nobody's mistake. + return ( + "Connecting your own account is not available from the app this message " + "came from." + ) + + def local_server_port(env: Mapping[str, str] = os.environ) -> int: - """Resolve the local agent port without consuming the Channel's `PORT`.""" - raw_port = env.get("SERVER_PORT", "8123") + """Resolve the local agent port without consuming the Channel's `PORT`. + + A blank value is an unset one. `SERVER_PORT=` is routine in `.env` files + and in compose passthrough, and it used to reach `int("")` and abort boot + with "Invalid SERVER_PORT" naming a variable the operator had not set to + anything. Every other environment reader in this file already says the same + thing — `SERVER_HOST` and `AGENT_RELOAD` fall back on falsiness, + `CORS_ALLOW_ORIGINS` on `or "*"` — so this was the one that disagreed. + Stripped for the same reason: a value pasted with a newline is the number + that was meant. + """ + raw_port = env.get("SERVER_PORT", "").strip() or "8123" try: port = int(raw_port) if not (1 <= port <= 65535): diff --git a/agent/prompts/__init__.py b/agent/prompts/__init__.py index 64ac61fd..7d1a2f9e 100644 --- a/agent/prompts/__init__.py +++ b/agent/prompts/__init__.py @@ -1,5 +1,7 @@ """OpenTag agent prompts.""" +from collections.abc import Collection + from .current_date import current_date_context, current_date_prompt from .system import ( DEFAULT_AGENT_DISPLAY_NAME, @@ -9,6 +11,9 @@ ) from .tools import ( TOOLS_PROMPT, + DEFAULT_INTERNAL_SOURCES, + composio_addendum, + tools_prompt, CODING_ON_ADDENDUM, CODING_OFF_ADDENDUM, ) @@ -19,17 +24,38 @@ def build_base_system_prompt( agent_display_name: str = DEFAULT_AGENT_DISPLAY_NAME, + internal_sources: Collection[str] = DEFAULT_INTERNAL_SOURCES, ) -> str: - """Build the complete system prompt for a deployment identity.""" - return build_system_prompt(agent_display_name) + TOOLS_PROMPT + WORKFLOW_PROMPT + """Build the complete system prompt for a deployment identity. + + The agent passes source names only when their tools actually loaded. + The default describes a fully configured deployment for static consumers. + """ + return ( + build_system_prompt(agent_display_name) + + tools_prompt(internal_sources) + + WORKFLOW_PROMPT + ) BASE_SYSTEM_PROMPT = build_base_system_prompt() +#: Every name this package re-exports, whether or not it is used here. +#: +#: `composio_addendum` and `tools_prompt` are imported for `agent.py` and +#: referenced nowhere in this module. Left out of `__all__` they read as dead +#: imports: any automated unused-import pass deletes them, and the agent stops +#: building at boot. A re-export that is not declared is not a re-export. __all__ = [ "BASE_SYSTEM_PROMPT", "DEFAULT_AGENT_DISPLAY_NAME", + "SYSTEM_PROMPT", + "WORKFLOW_PROMPT", + "TOOLS_PROMPT", "build_base_system_prompt", + "build_system_prompt", + "tools_prompt", + "composio_addendum", "current_date_context", "current_date_prompt", "NO_WEB_SEARCH_TOOL_ADDENDUM", diff --git a/agent/prompts/tools.py b/agent/prompts/tools.py index 19f2f9fc..7635a9e0 100644 --- a/agent/prompts/tools.py +++ b/agent/prompts/tools.py @@ -1,15 +1,69 @@ """Guidance for internal sources and write tools.""" -TOOLS_PROMPT = """- For internal or company-specific questions, prefer the team's Notion/Linear - and GitHub sources first; use the web for external questions -- Use GitHub tools to read repositories, code, pull requests, Actions runs, and - job logs. The GitHub integration is read-only -- CRITICAL: Every Linear or Notion mutation tool automatically pauses with its - exact action and draft details. Call the mutation once; it runs only after - the user grants approval, and otherwise no write occurs -- Reads and rendering never require confirmation +from collections.abc import Collection + +from composio_tools.scopes import resolve_scopes + +#: True of the agent whatever is configured: a property of the approval gate +#: rather than of any one integration. +#: +#: The approval sentence lives here and not in the internal-sources block +#: because `run_my_tool` raises the same interrupt a Linear or Notion mutation +#: does. When it sat in that block, a Composio-only deployment — the shape +#: running in production — was told only that reads never need confirmation: +#: the pause was real, the model was never told it existed, and so it had no +#: reason to expect a write to reach a person at all. It is worded in terms of +#: the gate so it can be said to a deployment holding none of those tools. +ALWAYS_TRUE_OF_TOOLS = """- Reads and rendering never require confirmation +- CRITICAL: Any tool call that needs approval automatically pauses and shows + its exact action and draft details. Call the tool once; it runs only after + the user grants approval, and otherwise nothing is written. Never ask for + permission in prose instead of calling the tool """ +DEFAULT_INTERNAL_SOURCES = ("notion", "linear", "github", "posthog") + + +def tools_prompt(internal_sources: Collection[str] = DEFAULT_INTERNAL_SOURCES) -> str: + """Describe the integrations whose tools actually loaded for this agent.""" + names = { + "notion": "Notion", + "linear": "Linear", + "github": "GitHub", + "posthog": "PostHog", + } + available = [name for key, name in names.items() if key in internal_sources] + lines = [ALWAYS_TRUE_OF_TOOLS] + if available: + lines.append( + "- For internal or company-specific questions, prefer the team's " + + ", ".join(available) + + " sources first; use the web for external questions\n" + ) + if "github" in internal_sources: + lines.append( + "- Use GitHub tools to read repositories, code, pull requests, Actions runs, and\n" + " job logs. The GitHub integration is read-only\n" + ) + if "posthog" in internal_sources: + lines.append( + "- Use PostHog tools for product analytics. " + "The PostHog integration is read-only\n" + ) + writable = [ + name for key, name in (("linear", "Linear"), ("notion", "Notion")) + if key in internal_sources + ] + if writable: + lines.append( + "- Every " + " or ".join(writable) + " mutation tool is gated that way. " + "Call the mutation once and let the pause do the asking\n" + ) + return "".join(lines) + + +TOOLS_PROMPT = tools_prompt() + CODING_ON_ADDENDUM = """ - Coding is available. For fix-tests, merge-main, fix-ci, or implement-issue, read the available Linear or read-only GitHub context, write a focused brief, then call @@ -36,3 +90,93 @@ - Coding is unavailable in this deployment. Do not claim you can open a pull request, run tests in a sandbox, or merge main """ + + +#: How many toolkit names are worth spending context on. +#: +#: The list exists to tell the model what KIND of thing it can reach, not to be +#: an inventory. A deployment naming thirty apps would spend the budget on +#: nouns the search tool can find anyway. +MAX_NAMED_TOOLKITS = 12 + + +def _named(toolkits: tuple[str, ...]) -> str: + """`toolkits`, capped, and honest about the cap. + + A truncated list that does not admit it was truncated is a list the model + will quote back as complete. + """ + shown = list(toolkits[:MAX_NAMED_TOOLKITS]) + rest = len(toolkits) - len(shown) + joined = ", ".join(shown) + return f"{joined}, and {rest} more" if rest > 0 else joined + + +def _shared_and_personal(config) -> tuple[tuple[str, ...], tuple[str, ...]]: + """The two lists as the runtime will actually route them. + + Not `config.workspace_toolkits` raw. `resolve_scopes` de-duplicates — "A + toolkit named in both lists resolves to the personal scope only" — so a + toolkit in both was advertised here as "shared with everyone" while every + call to it would run as the person who spoke, or, for a turn carrying no + actor, not run at all. + + Asked with no actor, `resolve_scopes` yields the shared scope alone, which + is precisely the shared list after de-duplication. Deriving it from that + function rather than restating its rule is the point: the rule cannot drift + out of agreement with what the tools do. + """ + personal = tuple(getattr(config, "user_toolkits", ()) or ()) + shared = tuple( + slug + for scope in resolve_scopes(config, None) + if not scope.personal + for slug in scope.toolkits + ) + return shared, personal + + +def composio_addendum(config) -> str: + """What the model is told about the connected apps it can reach. + + It was told nothing, and nothing is what it answered from: asked whether it + could see Linear — which was configured — it said no without ever calling + `search_my_tools`. A model cannot search for an app it does not know is + there, and the app names are the one part of this the agent already knows + at build time. + + Names apps, never actions. Which actions a toolkit exposes is the search + tool's answer, and a model left holding an app name will otherwise invent + plausible action names from it. + + Shared and personal are separated because they fail differently: a shared + toolkit is connected once by an operator, while a personal one does nothing + until that person connects it themselves — the difference between "try + again later" and "press the Connect button". + """ + if config is None: + # Other integrations can still be registered through MCP. + return "" + + shared, personal = _shared_and_personal(config) + if not shared and not personal: + return "" + + lines = ["\n- Connected apps are available. Call search_my_tools to find an" + " action in them before answering whether you can do something"] + if shared: + lines.append( + f"- Shared with everyone here: {_named(shared)}. These are connected" + " once for the whole workspace" + ) + if personal: + lines.append( + f"- Each person's own: {_named(personal)}. These run in the account" + " of whoever is speaking, and do nothing until that person connects" + " them" + ) + lines.append( + "- This names apps, not actions. Never claim a specific action exists" + " until search_my_tools has returned it" + ) + return "\n".join(lines) + "\n" diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 85d4362b..3eda9b20 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -5,6 +5,11 @@ description = "OpenTag general-purpose team knowledge-work agent — CopilotKit requires-python = ">=3.12" dependencies = [ "ag-ui-langgraph>=0.0.23", + # 0.17.0 is the first release whose client exposes `.sessions`, and everything + # in `composio_tools/sessions.py` goes through it. Below that the SDK offers + # `tool_router` and no alias, so a lower resolution installs, imports, and + # raises on the first turn that touches a toolkit. + "composio>=0.17.0", "copilotkit>=0.1.76", "deepagents>=0.6.12", "fastapi>=0.115.14", @@ -16,17 +21,21 @@ dependencies = [ "pyjwt[crypto]>=2.10.1", "tavily-python>=0.3.0", "uvicorn[standard]>=0.40.0", - "daytona", - "langchain-daytona", + # Floors, not bare names: a bare requirement resolves to whatever the index + # offers on the day the image is built, and the lockfile hides that until + # somebody regenerates it. These are the releases the lock resolves today. + "daytona>=0.204.0", + "langchain-daytona>=0.0.7", ] [dependency-groups] dev = ["pytest>=8.0.0"] [tool.setuptools] -packages = ["prompts", "coding"] +packages = ["prompts", "coding", "composio_tools"] py-modules = [ "agent", + "agent_auth", "agui", "internal_sources", "main", @@ -39,3 +48,11 @@ coding = ["skills/*/SKILL.md"] [tool.pytest.ini_options] pythonpath = ["."] + +# Without this table uv treats the project as virtual: it is never built, and +# every `[tool.setuptools]` line above describes a wheel that nothing produces. +# The agent image's second `uv sync --frozen --no-dev`, the one that runs after +# the source COPYs, is the step that builds it. +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" diff --git a/agent/tests/conftest.py b/agent/tests/conftest.py new file mode 100644 index 00000000..7a0cbdd6 --- /dev/null +++ b/agent/tests/conftest.py @@ -0,0 +1,171 @@ +"""Keep the machine's own configuration out of the tests. + +`agent.py` calls `load_dotenv()` at import, so importing anything that reaches +it copies the repo's real `.env` into `os.environ` — API keys, Slack tokens and +the toolkit lists. Every test in this suite then runs against whatever that +developer happens to have configured today, and an exported shell variable does +the same thing without any `.env` at all. + +That is not hypothetical, and it has now happened twice. `COMPOSIO_USER_TOOLKITS` +gained `linear` while testing a live Slack workspace and +`test_the_process_environment_still_wins_over_the_env_file` began failing — +correctly, because the connect script refuses to mint a shared link for a +toolkit configured as personal. Then `CORS_ALLOW_ORIGINS`, which this fixture +did not cover because it cleared the `COMPOSIO_` prefix only, made +`test_a_browser_preflight_is_answered_rather_than_refused` fail with +`assert 400 == 200`: the app under test had a CORS policy the test never asked +for. + +So the scrub is the whole class, not one prefix: + +* every variable this agent's own code reads — the list below, grouped by what + it configures. Each one decides which tools exist, whose account a call runs + in, which credentials are found, or whether a feature is constructed at all. + A test that wants one sets it, which is the only way its intent is visible in + the test itself; +* plus every name defined in the repo's `.env`, read from the very file + `agent.py` loads. That half needs no maintenance: a variable added to a + developer's `.env` tomorrow is cleared tomorrow, including one this agent + does not read yet. + +Still a deny-list rather than a wholesale scrub of `os.environ`: PATH, HOME and +the virtualenv are what make the suite runnable at all. + +One variable cannot be cleared per test at all. `CORS_ALLOW_ORIGINS` is read +once, when `main` is imported, so by the time a test runs the policy is already +built. It is pinned for the session below rather than deleted: `load_dotenv()` +fills in any name absent from `os.environ`, so deleting it hands the decision +straight back to the developer's `.env`. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from dotenv import dotenv_values + +#: The file `agent.py` loads, by the same path it computes: `agent/tests/` -> +#: repo root. If that ever moves, the file simply is not found and the curated +#: list below is all that clears — the fixture never guesses at a location. +ENV_FILE = Path(__file__).resolve().parents[2] / ".env" + +#: Which tools exist and whose account they run in. +_INTEGRATION_VARS = ( + "COMPOSIO_API_KEY", + "COMPOSIO_TOOLKITS", + "COMPOSIO_USER_TOOLKITS", + "COMPOSIO_APPROVALS", + "COMPOSIO_WORKSPACE_USER_ID", + "COMPOSIO_AUTH_CONFIGS", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "GITHUB_APP_ID", + "GITHUB_APP_INSTALLATION_ID", + "GITHUB_APP_PRIVATE_KEY_BASE64", + "GITHUB_CODER_TOKEN", + "GITHUB_ALLOWED_REPOS", + "GITHUB_MCP_URL", + "LINEAR_API_KEY", + "LINEAR_MCP_URL", + "NOTION_MCP_AUTH_TOKEN", + "NOTION_MCP_URL", + "POSTHOG_PERSONAL_API_KEY", + "POSTHOG_MCP_URL", + "TAVILY_API_KEY", + "DAYTONA_API_KEY", + "DAYTONA_SNAPSHOT", + "DAYTONA_TTL_MINUTES", +) + +#: Which model answers, and as whom. +_MODEL_AND_IDENTITY_VARS = ( + "OPENAI_API_KEY", + "OPENAI_MODEL", + "OPENAI_REASONING_EFFORT", + "OPENAI_VERBOSITY", + "AGENT_DISPLAY_NAME", + "INTELLIGENCE_CHANNEL_NAME", +) + +#: How the server is exposed. `CORS_ALLOW_ORIGINS` is the one this fixture was +#: widened for: set in the environment, it gave the app a policy the CORS tests +#: never configured, and they failed on the deployment's setting rather than on +#: anything the suite had asked for. +_SERVER_VARS = ( + "CORS_ALLOW_ORIGINS", + "AGENT_AUTH_HEADER", + "SERVER_HOST", + "SERVER_PORT", + "AGENT_RELOAD", +) + +DEPLOYMENT_VARS = (*_INTEGRATION_VARS, *_MODEL_AND_IDENTITY_VARS, *_SERVER_VARS) + + +def _env_file_names() -> tuple[str, ...]: + """Every name the repo's `.env` defines; nothing at all when there is none. + + Only the names are kept. The values are the developer's secrets and this + process has no use for them. + """ + if not ENV_FILE.is_file(): + return () + return tuple(dotenv_values(ENV_FILE)) + + +#: Read once, when `main` is imported, and never read again — so a test that +#: sets one has no effect and a `.env` that sets one changes what the suite +#: asserts. Pinned rather than deleted: `agent.py` calls `load_dotenv()` at +#: import, and that fills in any name it does not find in `os.environ`, so +#: deleting one hands the decision straight back to the `.env`. The empty +#: string is what an unset `CORS_ALLOW_ORIGINS` already means to `main`. +IMPORT_TIME_VARS = {"CORS_ALLOW_ORIGINS": ""} + +#: Needed *by* that import rather than cleared for it: `main` builds the agent +#: at module scope and `build_agent` refuses without a key. Whichever test +#: imports `main` first would otherwise decide whether the suite can run, +#: which is the import-order coupling the `client` fixtures used to carry. +IMPORT_TIME_REQUIRED = {"OPENAI_API_KEY": "sk-test"} + +#: Ordered and de-duplicated so a name in both halves is cleared once, and +#: minus the names the session fixture below owns. Those are read at `main` +#: import, before any test runs, so the session fixture pins them; clearing +#: them again per test would delete the pin and leave the next test that +#: imports `main` without the key `build_agent` refuses to start without. +SCRUBBED_VARS = tuple( + name + for name in dict.fromkeys((*DEPLOYMENT_VARS, *_env_file_names())) + if name not in IMPORT_TIME_VARS and name not in IMPORT_TIME_REQUIRED +) + + +#: Cleared before every test for the same reason, one layer out. These decide +#: whether a request is authorized and where the server listens, and a +#: developer's `.env` routinely sets the first of them — after which a test that +#: means "no secret is configured" is asserting against theirs. +SERVER_VARS = ( + "AGENT_AUTH_HEADER", + "SERVER_PORT", + "SERVER_HOST", + "AGENT_RELOAD", +) + + + +@pytest.fixture(autouse=True, scope="session") +def _server_import_env_is_the_suite_s_own(): + """Pin what `import main` reads, before any test can reach that import.""" + with pytest.MonkeyPatch.context() as patch: + for name, value in IMPORT_TIME_VARS.items(): + patch.setenv(name, value) + for name, fallback in IMPORT_TIME_REQUIRED.items(): + patch.setenv(name, os.environ.get(name) or fallback) + yield + + +@pytest.fixture(autouse=True) +def _the_configuration_is_the_test_s_own(monkeypatch: pytest.MonkeyPatch) -> None: + for name in SCRUBBED_VARS: + monkeypatch.delenv(name, raising=False) + diff --git a/agent/tests/test_agent_auth.py b/agent/tests/test_agent_auth.py new file mode 100644 index 00000000..67e4bc1d --- /dev/null +++ b/agent/tests/test_agent_auth.py @@ -0,0 +1,537 @@ +"""The shared secret between the runtime and this agent.""" + +from __future__ import annotations + +import asyncio +import hmac +import os + +import pytest +from fastapi.testclient import TestClient + +import agent_auth +from agent_auth import authorizes_capability, header_matches, is_authorized + + +def test_ordinary_traffic_is_open_when_no_secret_is_configured(): + # A local run has no secret, and enforcing unconditionally would take every + # existing deployment down on upgrade. + assert is_authorized("/", None, env={}) is True + assert is_authorized("/", "anything", env={}) is True + + +def test_ordinary_traffic_needs_the_secret_once_one_is_configured(): + env = {"AGENT_AUTH_HEADER": "Bearer s3cret"} + assert is_authorized("/", "Bearer s3cret", env=env) is True + assert is_authorized("/", "Bearer wrong", env=env) is False + assert is_authorized("/", None, env=env) is False + + +def test_health_stays_open_so_the_platform_probe_keeps_working(): + env = {"AGENT_AUTH_HEADER": "Bearer s3cret"} + assert is_authorized("/health", None, env=env) is True + + +def test_health_stays_open_however_the_probe_spells_the_path(): + # `/health/` is the same endpoint — the router redirects it to `/health` — + # but the redirect runs after this check, so an exactly-matched public path + # refuses the probe before routing ever sees it. + env = {"AGENT_AUTH_HEADER": "Bearer s3cret"} + assert is_authorized("/health/", None, env=env) is True + + +def test_a_capability_is_refused_when_no_secret_is_configured(): + # Unlike ordinary traffic, an absent secret is a refusal here: there is no + # configuration in which handing connect links to unauthenticated callers is + # the intended behaviour. + assert authorizes_capability("anything", env={}) is False + assert authorizes_capability(None, env={}) is False + + +def test_a_capability_needs_the_exact_secret(): + env = {"AGENT_AUTH_HEADER": "Bearer s3cret"} + assert authorizes_capability("Bearer s3cret", env=env) is True + assert authorizes_capability("Bearer s3cre", env=env) is False + assert authorizes_capability("bearer s3cret", env=env) is False + + +def test_a_blank_or_whitespace_secret_counts_as_unconfigured(): + # `AGENT_AUTH_HEADER=` is routine in .env files and compose passthrough, and + # must not become a secret that equals the empty string. + for raw in ("", " "): + assert is_authorized("/", None, env={"AGENT_AUTH_HEADER": raw}) is True + assert authorizes_capability(None, env={"AGENT_AUTH_HEADER": raw}) is False + + +def test_surrounding_whitespace_does_not_change_a_match(): + assert header_matches(" Bearer s3cret ", "Bearer s3cret") is True + assert header_matches("", "Bearer s3cret") is False + assert header_matches(None, "Bearer s3cret") is False + + +def spy_on_compare_digest(monkeypatch) -> list[tuple[bytes, bytes]]: + """Record every comparison the module makes, without touching `hmac`. + + Patched on `agent_auth`, not on `hmac`. `agent_auth.hmac` used to be the + stdlib module object itself, so `setattr` on it installed the spy + process-wide: every other caller of `hmac.compare_digest` for the duration + of the test — this suite's own, and anything a dependency does — appended + to that test's list. The module binds the function by name so it can be + replaced in one namespace and nowhere else, which the caller asserts. + """ + calls: list[tuple[bytes, bytes]] = [] + real = hmac.compare_digest + + def spy(left, right): + calls.append((left, right)) + return real(left, right) + + monkeypatch.setattr(agent_auth, "compare_digest", spy) + return calls + + +def test_the_secret_is_compared_in_constant_time(monkeypatch): + # `==` and `compare_digest` agree on every answer, so no assertion on a + # return value can tell them apart. The calls are asserted instead: + # swapping in `==` leaves `calls` empty. + real = hmac.compare_digest + calls = spy_on_compare_digest(monkeypatch) + + assert header_matches("Bearer s3cret", "Bearer s3cret") is True + assert header_matches("Bearer wrong!", "Bearer s3cret") is False + + # Two comparisons per call, always. Both readings of the presented value + # are made whichever one matched, so neither the count nor the order says + # which convention the client used. + assert calls == [ + (b"Bearer s3cret", b"Bearer s3cret"), + (b"Bearer s3cret", b"Bearer s3cret"), + (b"Bearer wrong!", b"Bearer s3cret"), + (b"Bearer wrong!", b"Bearer s3cret"), + ] + # The spy went into this module and nowhere else. + assert hmac.compare_digest is real + + +def test_a_header_that_differs_only_past_ascii_is_refused(): + # The vector that can tell the shipped comparison from a broken one. Every + # other non-ASCII case here also differs in its ASCII characters, so an + # implementation that dropped what it could not encode — `encode("ascii", + # "ignore")` — would refuse them for the wrong reason and look correct. + # This one is the configured secret plus one accented character: drop the + # character and it matches, keep it and it must not. + env = {"AGENT_AUTH_HEADER": "Bearer s3cret"} + suffixed = "Bearer s3cret\xe9" + + assert suffixed.encode("ascii", "ignore") == b"Bearer s3cret" + assert header_matches(suffixed, "Bearer s3cret") is False + assert is_authorized("/", suffixed, env=env) is False + assert authorizes_capability(suffixed, env=env) is False + + +def test_a_non_ascii_header_is_a_refusal_and_not_a_crash(): + # Headers arrive latin-1 decoded and `compare_digest` raises `TypeError` on + # non-ASCII `str` rather than returning False, so an accent in a wrong + # secret used to become a 500. Refusing is the only correct answer. + accented = "Bearer caf\xe9" + env = {"AGENT_AUTH_HEADER": "Bearer s3cret"} + + assert header_matches(accented, "Bearer s3cret") is False + assert is_authorized("/", accented, env=env) is False + assert authorizes_capability(accented, env=env) is False + + +# --- The wire, and what a client puts on it ---------------------------------- +# +# HTTP gives a header value no encoding. RFC 9110 §5.5 says a recipient should +# treat anything past US-ASCII as opaque octets, so clients disagree about how +# a non-ASCII secret is transmitted, and both conventions below are in +# production use: +# +# * Isomorphic — one code unit, one byte. What the Fetch standard specifies, +# so it is what Node's `undici` does and therefore what this deployment's +# own callers put on the wire: the Channel's `fetch` for the connect route +# and the runtime's `HttpAgent` for the graph. Measured against a raw +# socket rather than assumed. It also refuses a code point above U+00FF +# client-side, so a `€` in the secret never leaves the caller at all. +# * UTF-8 — what curl, httpx, requests, Go and Java send, and therefore what +# an operator reproducing a 401 by hand sends. +# +# Starlette decodes whichever bytes arrive with latin-1, so one configured +# secret reaches the check as two different strings depending on the caller. + +#: `Bearer café`, spelled the way the OS holds it after a UTF-8 shell exported +#: it and `os.environ` decoded it back. +NON_ASCII_SECRET = os.fsdecode(b"Bearer caf\xc3\xa9") + +#: The same secret, as each convention puts it on the wire. +ISOMORPHIC_WIRE = b"Bearer caf\xe9" +UTF8_WIRE = b"Bearer caf\xc3\xa9" + + +def as_starlette_decodes(wire: bytes) -> str: + """What `request.headers.get("authorization")` returns for these bytes.""" + return wire.decode("latin-1") + + +@pytest.mark.parametrize( + "wire", [ISOMORPHIC_WIRE, UTF8_WIRE], ids=["isomorphic", "utf-8"] +) +def test_a_non_ascii_secret_authenticates_however_it_was_transmitted(wire): + # The direction nothing here used to assert. Every other non-ASCII test + # checks that a WRONG value is refused, and that stayed true while a + # deployment whose `AGENT_AUTH_HEADER` held one accented character rejected + # its own correct secret forever — with a 401 that reads like a wrong one. + env = {"AGENT_AUTH_HEADER": NON_ASCII_SECRET} + presented = as_starlette_decodes(wire) + + assert header_matches(presented, NON_ASCII_SECRET) is True + assert is_authorized("/", presented, env=env) is True + assert authorizes_capability(presented, env=env) is True + + +@pytest.mark.parametrize( + "wire", + [ + b"Bearer caf\xe8", + b"Bearer caf\xc3\xa8", + b"Bearer caf", + b"Bearer caf\xc3\xa9\xc3\xa9", + ], + ids=["isomorphic-e-grave", "utf-8-e-grave", "ascii-prefix", "doubled"], +) +def test_a_wrong_secret_is_still_refused_when_the_right_one_is_non_ascii(wire): + # Reading both conventions must widen what authenticates to the two + # transmissions of the configured secret and to nothing else. `è` is not + # `é`, and the ASCII prefix the two encodings share is not the secret. + env = {"AGENT_AUTH_HEADER": NON_ASCII_SECRET} + presented = as_starlette_decodes(wire) + + assert header_matches(presented, NON_ASCII_SECRET) is False + assert is_authorized("/", presented, env=env) is False + assert authorizes_capability(presented, env=env) is False + + +def test_both_readings_are_compared_even_when_the_first_one_already_matched( + monkeypatch, +): + # The pair is the whole mechanism, so it is asserted rather than described: + # the mangled latin-1 spelling is compared first and cannot match, and the + # transcoded one is what authenticates. A short-circuiting `or` would make + # the number of comparisons depend on which client sent the request. + calls = spy_on_compare_digest(monkeypatch) + + assert header_matches(as_starlette_decodes(UTF8_WIRE), NON_ASCII_SECRET) is True + + assert calls == [ + (b"Bearer caf\xc3\x83\xc2\xa9", b"Bearer caf\xc3\xa9"), + (b"Bearer caf\xc3\xa9", b"Bearer caf\xc3\xa9"), + ] + + +def test_a_secret_past_latin_1_still_authenticates_and_never_raises(): + # `AGENT_AUTH_HEADER="Bearer €"` has no isomorphic transmission at all — + # `fetch` throws "greater than 255" before sending — so only a UTF-8 client + # can ever present it. Refusing it would be defensible; raising would not, + # and `str.encode("latin-1")` on the expected value raises. + secret = "Bearer \u20ac" + env = {"AGENT_AUTH_HEADER": secret} + presented = as_starlette_decodes("Bearer \u20ac".encode("utf-8")) + + assert header_matches(presented, secret) is True + assert authorizes_capability(presented, env=env) is True + assert header_matches(as_starlette_decodes(b"Bearer \xe2\x82\xab"), secret) is False + + +@pytest.fixture +def client(): + # No environment setup in here, deliberately. `main` builds the agent at + # module scope, so the import happens exactly once per session — in + # whichever test reaches it first. A `setenv` on this line therefore + # decided nothing at all when another module got there first, and was + # load-bearing when it did not, which made the fixture's correctness a + # question about collection order. `conftest.py` pins what that import + # reads, for the session, before any test can trigger it. + import main + + # Server errors are surfaced as 500s rather than re-raised, so a crash in + # the middleware reads as the wrong status code instead of an error that + # could be mistaken for an unrelated failure. + return TestClient(main.app, raise_server_exceptions=False) + + +def test_the_middleware_refuses_traffic_that_carries_no_secret(client, monkeypatch): + # `/nope` is routed by nothing, so 401 can only have come from the + # middleware. Asserting on a real route cannot tell "the middleware + # refused" from "the route refused", which is how deleting the middleware + # outright went unnoticed. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + assert client.get("/nope").status_code == 401 + wrong = client.get("/nope", headers={"Authorization": "Bearer wrong"}) + assert wrong.status_code == 401 + + +def test_the_middleware_lets_the_configured_secret_reach_routing( + client, monkeypatch +): + # 404, not 401: the request got past the middleware and found no route. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + response = client.get("/nope", headers={"Authorization": "Bearer s3cret"}) + + assert response.status_code == 404 + + +def test_the_middleware_stays_open_when_no_secret_is_configured(client, monkeypatch): + # A local `pnpm dev` has no secret, and enforcing unconditionally would take + # every existing deployment down on upgrade. + monkeypatch.delenv("AGENT_AUTH_HEADER", raising=False) + + assert client.get("/nope").status_code == 404 + + +def test_the_middleware_keeps_health_open_for_the_platform_probe(client, monkeypatch): + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + assert client.get("/health").status_code == 200 + + +def test_the_middleware_guards_the_agent_endpoint_itself(client, monkeypatch): + # The point of the middleware. Unauthenticated it is 401; let it through and + # the AG-UI endpoint answers 422 for this body, so the two are distinct. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + assert client.post("/", json={}).status_code == 401 + allowed = client.post( + "/", json={}, headers={"Authorization": "Bearer s3cret"} + ) + assert allowed.status_code == 422 + + +@pytest.mark.parametrize( + "wire", [b"Bearer caf\xe9", b"Bearer caf\xc3\xa9", b"Bearer \xff\xfe"], + ids=["isomorphic", "utf-8", "undecodable"], +) +def test_the_middleware_refuses_a_non_ascii_header_without_erroring( + client, monkeypatch, wire +): + # A 500 here is the crash this guards against: `compare_digest` raises + # `TypeError` on non-ASCII `str` rather than returning False, so an accent + # in a wrong secret used to be a server error. Sent through the raw probe + # because it used to claim to send latin-1 bytes while httpx quietly + # re-encoded them as UTF-8 — which meant the isomorphic case, the one this + # deployment's own callers produce, was never sent at all. The last vector + # is not valid UTF-8 in either direction and must also be a refusal. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + assert status_for_raw_authorization(client.app, wire) == 401 + + +def status_for_raw_authorization(app, wire: bytes, path: str = "/nope") -> int: + """The status `app` answers when exactly these bytes are the header. + + Not `TestClient`, because httpx will not carry them. Measured: a value of + `b"Bearer caf\xe9"` handed to `client.get(headers=...)` reaches the ASGI + scope as `b"Bearer caf\xc3\xa9"` — httpx re-encodes it as UTF-8 — so every + request the test client can make arrives under one of the two conventions + and the other is untestable through it. That is not a detail: the + convention it cannot send is the one this deployment's own Node callers + use. The scope is built by hand so the bytes on the wire are the bytes the + middleware decodes. + + `/nope` is routed by nothing, so 401 can only have come from the middleware + and 404 means the request got past it — the same distinction the + `TestClient` tests above rely on. + """ + scope = { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": path, + "raw_path": path.encode("ascii"), + "query_string": b"", + "root_path": "", + "headers": [(b"host", b"testserver"), (b"authorization", wire)], + "client": ("testclient", 50000), + "server": ("testserver", 80), + } + statuses: list[int] = [] + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message): + if message["type"] == "http.response.start": + statuses.append(message["status"]) + + asyncio.run(app(scope, receive, send)) + return statuses[0] + + +@pytest.mark.parametrize( + "wire", [ISOMORPHIC_WIRE, UTF8_WIRE], ids=["isomorphic", "utf-8"] +) +def test_the_middleware_admits_the_configured_non_ascii_secret( + client, monkeypatch, wire +): + # The positive direction, end to end and through the real header decode + # rather than the unit tests' model of it. A deployment whose + # `AGENT_AUTH_HEADER` holds one accented character used to answer 401 to + # its own correct secret, from either kind of client, forever. + monkeypatch.setenv("AGENT_AUTH_HEADER", NON_ASCII_SECRET) + + assert status_for_raw_authorization(client.app, wire) == 404 + + +@pytest.mark.parametrize( + "wire", + [b"Bearer caf\xe8", b"Bearer caf\xc3\xa8", b"Bearer caf"], + ids=["isomorphic-e-grave", "utf-8-e-grave", "ascii-prefix"], +) +def test_the_middleware_still_refuses_a_wrong_non_ascii_secret( + client, monkeypatch, wire +): + monkeypatch.setenv("AGENT_AUTH_HEADER", NON_ASCII_SECRET) + + assert status_for_raw_authorization(client.app, wire) == 401 + + +def test_a_browser_preflight_is_answered_rather_than_refused(client, monkeypatch): + # A browser sends no `Authorization` on a preflight — it cannot, the whole + # point of the preflight is to ask whether it may. So a secret check in + # front of CORS refuses every preflight, and the browser never sends the + # real request. Nothing downstream of this ever sees the traffic. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + response = client.options( + "/", + headers={ + "Origin": "https://ui.example", + "Access-Control-Request-Method": "POST", + }, + ) + + assert response.status_code == 200 + assert response.headers.get("access-control-allow-origin") is not None + + +def test_a_refusal_carries_the_cors_headers_so_a_browser_can_read_it( + client, monkeypatch +): + # Without them the browser reports a CORS failure instead of the 401, and + # `CORS_ALLOW_ORIGINS` is inert for exactly the responses an operator + # debugging a wrong secret needs to see. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + response = client.post("/", json={}, headers={"Origin": "https://ui.example"}) + + assert response.status_code == 401 + assert response.headers.get("access-control-allow-origin") is not None + + +def test_the_probe_reaches_the_agent_endpoint_s_own_health_route( + client, monkeypatch +): + # `add_langgraph_fastapi_endpoint(path="/")` builds its health route as + # `f"{path}/health"`, which at this path is the literal `//health`. It is a + # health route, it is registered, and the secret check answered 401 to it — + # so a probe pointed at it reported the service down for as long as a + # secret was configured. Asserted against the routes the library actually + # registers, so that if it ever stops registering that path this stops + # claiming to cover it. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + assert "//health" in {getattr(r, "path", None) for r in client.app.routes} + # Through the raw probe, because httpx reads a leading `//` as a + # protocol-relative URL and sends the request to a host called `health`. + # A wrong secret rather than none, so this says "the path is public" and + # not merely "the header happened to be right". + assert ( + status_for_raw_authorization(client.app, b"Bearer wrong", path="//health") + == 200 + ) + + +def test_a_capability_route_says_unavailable_rather_than_unauthorized( + client, monkeypatch +): + # 401 and 503 send an operator to two different places, and the difference + # is the whole value of the status here: the TypeScript caller renders 401 + # as "the agent rejected the one this app sent", which is a wrong-secret + # hunt for a secret that does not exist. The route's own docstring has + # promised "reports itself unavailable" since it was written. + monkeypatch.delenv("AGENT_AUTH_HEADER", raising=False) + + response = client.post( + "/composio/connect", + json={ + "actor_id": "U1", + "kind": "human", + "platform": "slack", + "toolkit": "gmail", + }, + headers={"Authorization": "Bearer anything"}, + ) + + assert response.status_code == 503 + # Read by whoever clicked, so it names no variable and no credential; the + # variable name goes to the log below. + detail = response.json()["error"] + assert "AGENT_AUTH_HEADER" not in detail + assert "shared secret" in detail + + +def test_a_capability_route_logs_the_variable_it_is_missing( + client, monkeypatch, capsys +): + # The old behaviour logged nothing at all, so the one place that could have + # said which half of the pair was unset said nothing. + monkeypatch.delenv("AGENT_AUTH_HEADER", raising=False) + + client.post( + "/composio/connect", + json={ + "actor_id": "U1", + "kind": "human", + "platform": "slack", + "toolkit": "gmail", + }, + ) + + assert "AGENT_AUTH_HEADER" in capsys.readouterr().err + + +def test_a_capability_route_still_answers_401_to_a_wrong_secret(client, monkeypatch): + # The other half of the same distinction: a secret IS configured here, so + # "unauthorized" is the true answer and 503 would be the misleading one. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + response = client.post( + "/composio/connect", + json={ + "actor_id": "U1", + "kind": "human", + "platform": "slack", + "toolkit": "gmail", + }, + headers={"Authorization": "Bearer wrong"}, + ) + + assert response.status_code == 401 + + +def test_the_probe_reaches_health_with_a_trailing_slash(client, monkeypatch): + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + assert client.get("/health/").status_code == 200 + + +def test_the_probe_may_ask_for_health_with_head(client, monkeypatch): + # A platform health check that sends HEAD is ordinary. It used to get 405, + # because the route answered GET alone. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + assert client.head("/health").status_code == 200 diff --git a/agent/tests/test_agent_configuration.py b/agent/tests/test_agent_configuration.py index e83747a1..f2032b5d 100644 --- a/agent/tests/test_agent_configuration.py +++ b/agent/tests/test_agent_configuration.py @@ -34,7 +34,7 @@ def with_config(self, config): return self -def build_with_captured_configuration(monkeypatch): +def build_with_captured_configuration(monkeypatch, source_toolsets=None): captured = {} monkeypatch.setenv("OPENAI_API_KEY", "sk-test") @@ -48,7 +48,9 @@ def build_with_captured_configuration(monkeypatch): monkeypatch.delenv("GITHUB_APP_ID", raising=False) monkeypatch.delenv("GITHUB_APP_INSTALLATION_ID", raising=False) monkeypatch.delenv("GITHUB_APP_PRIVATE_KEY_BASE64", raising=False) - monkeypatch.setattr(agent_mod, "internal_source_toolsets", lambda _provider: {}) + monkeypatch.setattr( + agent_mod, "internal_source_toolsets", lambda _provider: source_toolsets or {} + ) def fake_chat_openai(**kwargs): captured["model"] = kwargs @@ -65,6 +67,31 @@ def fake_create_deep_agent(**kwargs): return graph, captured +@pytest.mark.parametrize( + "source,label", + [("linear", "Linear"), ("notion", "Notion"), ("github", "GitHub"), ("posthog", "PostHog")], +) +def test_prompt_describes_only_loaded_integrations_without_composio( + monkeypatch, source, label +): + from types import SimpleNamespace + + loaded_tool = SimpleNamespace(name=f"{source}_test_tool") + _, captured = build_with_captured_configuration( + monkeypatch, {source: [loaded_tool], "unavailable": []} + ) + prompt = captured["agent"]["system_prompt"] + + assert loaded_tool in captured["agent"]["tools"] + assert label in prompt + assert "no connected apps" not in prompt.lower() + assert "search_my_tools" not in prompt + assert f"prefer the team's {label} sources first" in prompt + for other in {"Linear", "Notion", "GitHub", "PostHog"} - {label}: + assert f"{other} tools" not in prompt + assert f"{other} mutation tool" not in prompt + + def test_build_agent_defaults_to_low_reasoning_and_verbosity(monkeypatch): monkeypatch.delenv("OPENAI_REASONING_EFFORT", raising=False) monkeypatch.delenv("OPENAI_VERBOSITY", raising=False) diff --git a/agent/tests/test_agui_approval_correlation.py b/agent/tests/test_agui_approval_correlation.py new file mode 100644 index 00000000..b95508ff --- /dev/null +++ b/agent/tests/test_agui_approval_correlation.py @@ -0,0 +1,132 @@ +"""Approval resumes name the graph interrupt whose card the person answered.""" + +import asyncio +import json +from types import SimpleNamespace + +import pytest +from ag_ui.core import CustomEvent, EventType, RunAgentInput +from copilotkit.langgraph import copilotkit_interrupt +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import END, START, StateGraph +from langgraph.types import Interrupt + +from agui import build_agui_agent +from composio_tools.state import ComposioAgentState + + +class ApprovalState(ComposioAgentState): + first_decision: bool + second_decision: bool + + +def interrupt_payload(events): + [event] = [event for event in events if getattr(event, "name", None) == "on_interrupt"] + return json.loads(event.value) if isinstance(event.value, str) else event.value + + +@pytest.mark.parametrize("confirmed", [True, False]) +def test_stale_resume_does_not_answer_the_next_graph_interrupt(confirmed): + def first(_state): + _, response = copilotkit_interrupt( + action="confirm_write", args={"action": "First write"} + ) + return {"first_decision": response["confirmed"]} + + def second(_state): + _, response = copilotkit_interrupt( + action="confirm_write", args={"action": "Second write"} + ) + return {"second_decision": response["confirmed"]} + + builder = StateGraph(ApprovalState) + builder.add_node("first", first) + builder.add_node("second", second) + builder.add_edge(START, "first") + builder.add_edge("first", "second") + builder.add_edge("second", END) + graph = builder.compile(checkpointer=MemorySaver()) + adapter = build_agui_agent(graph) + config = {"configurable": {"thread_id": "correlated-approvals"}} + + async def collect(run_id, resume=None): + request = RunAgentInput( + runId=run_id, + threadId="correlated-approvals", + state={}, + messages=[{"id": "u1", "role": "user", "content": "Make both changes"}], + tools=[], + context=[], + forwardedProps={} if resume is None else {"command": {"resume": resume}}, + ) + return [event async for event in adapter.run(request)] + + events = asyncio.run(collect("initial")) + first_id = interrupt_payload(events)["__opentag_interrupt_id__"] + assert first_id == graph.get_state(config).tasks[0].interrupts[0].id + + events = asyncio.run(collect("first-answer", {first_id: {"confirmed": confirmed}})) + second_id = interrupt_payload(events)["__opentag_interrupt_id__"] + pending = graph.get_state(config) + assert first_id != second_id + assert second_id == pending.tasks[0].interrupts[0].id + assert pending.values["first_decision"] is confirmed + assert "second_decision" not in pending.values + + events = asyncio.run(collect("stale-answer", {first_id: {"confirmed": True}})) + assert interrupt_payload(events)["__opentag_interrupt_id__"] == second_id + assert "second_decision" not in graph.get_state(config).values + + events = asyncio.run(collect("second-answer", {second_id: {"confirmed": confirmed}})) + finished = graph.get_state(config) + assert finished.values["second_decision"] is confirmed + assert not finished.next + assert events[-1].type == EventType.RUN_FINISHED + + +@pytest.mark.parametrize("serialized", [True, False]) +@pytest.mark.parametrize("raw_as_dict", [True, False]) +def test_correlation_comes_from_graph_metadata_without_mutating_payload( + serialized, raw_as_dict +): + from agui import with_interrupt_id + + identifier = "0123456789abcdef0123456789abcdef" + payload = { + "__copilotkit_interrupt_value__": { + "action": "confirm_write", "args": {"action": "Send email"} + }, + "__opentag_interrupt_id__": "forged-payload-id", + } + original = json.loads(json.dumps(payload)) + event = CustomEvent( + type=EventType.CUSTOM, + name="on_interrupt", + value=json.dumps(payload) if serialized else payload, + raw_event={"id": identifier} if raw_as_dict else Interrupt(value=payload, id=identifier), + ) + + result = with_interrupt_id(event) + enriched = json.loads(result.value) if serialized else result.value + + assert enriched["__opentag_interrupt_id__"] == identifier + assert enriched["__copilotkit_interrupt_value__"] == payload["__copilotkit_interrupt_value__"] + assert payload == original + assert event.value == (json.dumps(original) if serialized else original) + + +@pytest.mark.parametrize("identifier", [None, "", "a" * 31, "a" * 33, "A" * 32, "x" * 32, 42]) +def test_missing_or_invalid_graph_id_cannot_reuse_a_payload_supplied_id(identifier): + from agui import with_interrupt_id + + event = CustomEvent( + type=EventType.CUSTOM, + name="on_interrupt", + value={ + "__copilotkit_interrupt_value__": {"action": "confirm_write", "args": {}}, + "__opentag_interrupt_id__": "a" * 32, + }, + raw_event=SimpleNamespace(id=identifier), + ) + + assert "__opentag_interrupt_id__" not in with_interrupt_id(event).value diff --git a/agent/tests/test_agui_recursion.py b/agent/tests/test_agui_recursion.py index c298b45f..d735543d 100644 --- a/agent/tests/test_agui_recursion.py +++ b/agent/tests/test_agui_recursion.py @@ -1,11 +1,17 @@ import asyncio +import logging +import uuid from types import SimpleNamespace import pytest -from ag_ui.core import EventType +from ag_ui.core import EventType, RunAgentInput, RunStartedEvent +from langchain_core.messages import AIMessage +from langgraph.checkpoint.memory import MemorySaver from langgraph.errors import GraphRecursionError +from langgraph.graph import START, StateGraph from agui import build_agui_agent, iter_agent_events +from composio_tools.state import ComposioAgentState def test_agui_agent_receives_the_main_graph_recursion_limit(): @@ -62,3 +68,105 @@ async def boom(_input): async def _collect(stream): return [event async for event in stream] + + +def test_the_recursion_reply_finishes_the_run_the_adapter_started(): + # `thread_id` is not the caller's to decide. The adapter mints one when a + # request arrives without it and starts the run under that id, so echoing + # the request's own value closes a run nobody opened and leaves the started + # one open forever. + started = RunStartedEvent( + type=EventType.RUN_STARTED, thread_id="minted-by-the-adapter", run_id="run-1" + ) + + async def boom(_input): + yield started + raise GraphRecursionError("Recursion limit of 25 reached") + + events = asyncio.run( + _collect( + iter_agent_events(boom, SimpleNamespace(thread_id="", run_id="run-1")) + ) + ) + + assert events[-1].type == EventType.RUN_FINISHED + assert events[-1].thread_id == started.thread_id + assert events[-1].run_id == started.run_id + + +def test_the_step_limit_is_logged_rather_than_printed(capsys, caplog): + async def boom(_input): + raise GraphRecursionError("Recursion limit of 25 reached") + yield # pragma: no cover + + with caplog.at_level(logging.WARNING, logger="agui"): + asyncio.run( + _collect( + iter_agent_events(boom, SimpleNamespace(thread_id="t", run_id="r")) + ) + ) + + assert "Recursion limit" in caplog.text + assert capsys.readouterr().out == "" + + +def looping_agent(recursion_limit: int = 4): + """A graph that never stops, wired through the real adapter.""" + + def step(state): + del state + return {"messages": [AIMessage(content="thinking", id=str(uuid.uuid4()))]} + + graph = StateGraph(ComposioAgentState) + graph.add_node("step", step) + graph.add_edge(START, "step") + graph.add_edge("step", "step") + return build_agui_agent( + graph.compile(checkpointer=MemorySaver()), recursion_limit=recursion_limit + ) + + +def recursion_run(agent, thread: str = "loop"): + return asyncio.run( + _collect( + agent.run( + RunAgentInput( + threadId=thread, + runId="run-1", + state={}, + messages=[{"id": "u1", "role": "user", "content": "go"}], + tools=[], + context=[], + forwardedProps={}, + ) + ) + ) + ) + + +def test_the_recursion_exit_snapshots_state_and_messages_like_the_normal_one(): + # The ordinary end of a run emits STATE_SNAPSHOT + MESSAGES_SNAPSHOT before + # RUN_FINISHED. Skipping them on this path leaves the client holding fewer + # messages than the checkpoint, and the adapter reads that difference on the + # next turn as a time-travel edit — the one entry point where identity is + # decided by a checkpoint rather than by this turn. + events = recursion_run(looping_agent()) + + types = [event.type for event in events] + + assert EventType.STATE_SNAPSHOT in types + assert EventType.MESSAGES_SNAPSHOT in types + assert types.index(EventType.MESSAGES_SNAPSHOT) < types.index( + EventType.TEXT_MESSAGE_START + ), "the snapshot must not land after the reply and erase it" + assert types[-1] == EventType.RUN_FINISHED + + +def test_the_recursion_snapshot_keeps_the_messages_the_run_committed(): + events = recursion_run(looping_agent()) + + snapshot = next( + event for event in events if event.type == EventType.MESSAGES_SNAPSHOT + ) + + assert [message.content for message in snapshot.messages].count("thinking") >= 1 diff --git a/agent/tests/test_composio_approval_resume.py b/agent/tests/test_composio_approval_resume.py new file mode 100644 index 00000000..2e8cdc1f --- /dev/null +++ b/agent/tests/test_composio_approval_resume.py @@ -0,0 +1,323 @@ +"""A gated Composio call, approved after the original run has finished. + +The interesting part is not the pause. It is that a resume never brings an actor +into the graph, so identity has to survive the checkpoint. If it did not, an +approval clicked twenty minutes later would either fail or — much worse — run in +the wrong account. + +Note what that is *not*. `@copilotkit/channels-core` does send the actor with a +resume: `runAgentLoop` posts `{...forwardedIdentity(identity), command: resume}`, +so the clicker travels on the request. What stops it deciding anything is the +adapter — a run carrying `command.resume` streams `Command(resume=...)` and the +merged state is dropped on the floor. Both halves are pinned below, because the +first is a property of the installed Channel and the second of the installed +adapter, and either could move. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from ag_ui.core import RunAgentInput +from copilotkit import CopilotKitMiddleware +from deepagents import create_deep_agent +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langgraph.checkpoint.memory import MemorySaver + +from agui import build_agui_agent +from composio_tools.config import ComposioConfig +from composio_tools.sessions import SessionCache +from composio_tools.state import ComposioAgentState +from composio_tools.tools import build_composio_tools + +SLUG = "GMAIL_SEND_EMAIL" + + +class SendOnceModel(BaseChatModel): + """Calls the gated tool once, then stops.""" + + @property + def _llm_type(self): + return "composio-approval-resume" + + def bind_tools(self, tools, **_kwargs): + return self + + def _generate( + self, + messages: list[BaseMessage], + stop=None, + run_manager=None, + **_kwargs: Any, + ): + del stop, run_manager + already_ran = any(isinstance(message, ToolMessage) for message in messages) + message = ( + AIMessage(content="sent") + if already_ran + else AIMessage( + content="", + tool_calls=[ + { + "id": "send-1", + "name": "run_my_tool", + "args": {"slug": SLUG, "arguments": {"to": "a@b.c"}}, + } + ], + ) + ) + return ChatResult(generations=[ChatGeneration(message=message)]) + + +class RecordingSession: + def __init__(self, user_id: str) -> None: + self.user_id = user_id + self.executed: list[tuple[str, dict]] = [] + + def search(self, *, query): + raise AssertionError("this test does not search") + + def execute(self, slug, *, arguments): + self.executed.append((slug, arguments)) + return {"data": {"id": "msg-1"}, "error": None} + + def authorize(self, toolkit): + raise NotImplementedError + + def toolkits(self): + raise NotImplementedError + + +class RecordingComposio: + def __init__(self, sessions_by_user): + self.sessions = self + self._by_user = sessions_by_user + + def create(self, *, user_id, **_kwargs): + return self._by_user[user_id] + + +class AlwaysDestructive: + def effect_for(self, _slug): + return "destructive" + + +async def _collect(stream): + return [event async for event in stream] + + +def test_an_approval_after_the_run_ends_still_runs_in_the_asking_person_s_account(): + personal = RecordingSession("slack:U1") + shared = RecordingSession("open-tag") + config = ComposioConfig( + api_key="ak_test", + workspace_toolkits=("linear",), + user_toolkits=("gmail",), + approvals="on", + workspace_user_id="open-tag", + ) + cache = SessionCache( + config, client=RecordingComposio({"slack:U1": personal, "open-tag": shared}) + ) + checkpointer = MemorySaver() + graph = create_deep_agent( + model=SendOnceModel(), + tools=build_composio_tools(config, cache, AlwaysDestructive()), + middleware=[CopilotKitMiddleware()], + state_schema=ComposioAgentState, + checkpointer=checkpointer, + ) + agent = build_agui_agent(graph, recursion_limit=40) + + request = { + "threadId": "composio-approval-thread", + "state": {}, + "messages": [{"id": "user-1", "role": "user", "content": "email them"}], + "tools": [], + "context": [], + } + + first = asyncio.run( + _collect( + agent.run( + RunAgentInput( + runId="run-1", + forwardedProps={ + "channelActor": { + "id": "U1", + "kind": "human", + "platform": "slack", + } + }, + **request, + ) + ) + ) + ) + + assert any(getattr(event, "name", None) == "on_interrupt" for event in first) + assert personal.executed == [], "nothing may run before the person answers" + + # The decision alone, which is what a resume looked like before the Channel + # began forwarding the actor with it. Kept as its own case: it is still what + # any other caller sends, and it is the one that proves the identity came + # from the checkpoint rather than from the request. + asyncio.run( + _collect( + agent.run( + RunAgentInput( + runId="run-2", + forwardedProps={"command": {"resume": {"confirmed": True}}}, + **request, + ) + ) + ) + ) + + assert personal.executed == [(SLUG, {"to": "a@b.c"})] + assert shared.executed == [], "a personal call must not fall to the shared account" + + +def test_a_resume_that_forwards_somebody_else_does_not_move_the_account(): + # A real resume is not empty. `@copilotkit/channels-core` forwards the + # clicker with it, and in a Slack thread the person who clicks Approve is + # routinely not the person who asked — so the request carries one identity + # while the pending call belongs to another. + # + # Nothing may read the clicker as the owner. The AG-UI adapter drops the + # merged state on the resume path, so the checkpointed actor stands; if that + # ever changes, this turn spends the approver's Gmail account instead of the + # asker's, on an action the asker asked for. + asker = RecordingSession("slack:U1") + approver = RecordingSession("slack:U2") + shared = RecordingSession("open-tag") + config = ComposioConfig( + api_key="ak_test", + workspace_toolkits=("linear",), + user_toolkits=("gmail",), + approvals="on", + workspace_user_id="open-tag", + ) + cache = SessionCache( + config, + client=RecordingComposio( + {"slack:U1": asker, "slack:U2": approver, "open-tag": shared} + ), + ) + graph = create_deep_agent( + model=SendOnceModel(), + tools=build_composio_tools(config, cache, AlwaysDestructive()), + middleware=[CopilotKitMiddleware()], + state_schema=ComposioAgentState, + checkpointer=MemorySaver(), + ) + agent = build_agui_agent(graph, recursion_limit=40) + request = { + "threadId": "composio-approval-other-clicker", + "state": {}, + "messages": [{"id": "user-1", "role": "user", "content": "email them"}], + "tools": [], + "context": [], + } + + asyncio.run( + _collect( + agent.run( + RunAgentInput( + runId="run-1", + forwardedProps={ + "channelActor": { + "id": "U1", + "kind": "human", + "platform": "slack", + } + }, + **request, + ) + ) + ) + ) + asyncio.run( + _collect( + agent.run( + RunAgentInput( + runId="run-2", + forwardedProps={ + "channelActor": { + "id": "U2", + "kind": "human", + "platform": "slack", + }, + "command": {"resume": {"confirmed": True}}, + }, + **request, + ) + ) + ) + ) + + assert asker.executed == [(SLUG, {"to": "a@b.c"})] + assert approver.executed == [], "the clicker's account must not run the call" + assert shared.executed == [] + + +def test_a_declined_approval_runs_nothing(): + personal = RecordingSession("slack:U1") + config = ComposioConfig( + api_key="ak_test", + workspace_toolkits=(), + user_toolkits=("gmail",), + approvals="on", + workspace_user_id="open-tag", + ) + cache = SessionCache(config, client=RecordingComposio({"slack:U1": personal})) + checkpointer = MemorySaver() + graph = create_deep_agent( + model=SendOnceModel(), + tools=build_composio_tools(config, cache, AlwaysDestructive()), + middleware=[CopilotKitMiddleware()], + state_schema=ComposioAgentState, + checkpointer=checkpointer, + ) + agent = build_agui_agent(graph, recursion_limit=40) + request = { + "threadId": "composio-decline-thread", + "state": {}, + "messages": [{"id": "user-1", "role": "user", "content": "email them"}], + "tools": [], + "context": [], + } + + asyncio.run( + _collect( + agent.run( + RunAgentInput( + runId="run-1", + forwardedProps={ + "channelActor": { + "id": "U1", + "kind": "human", + "platform": "slack", + } + }, + **request, + ) + ) + ) + ) + asyncio.run( + _collect( + agent.run( + RunAgentInput( + runId="run-2", + forwardedProps={"command": {"resume": {"confirmed": False}}}, + **request, + ) + ) + ) + ) + + assert personal.executed == [] diff --git a/agent/tests/test_composio_classify.py b/agent/tests/test_composio_classify.py new file mode 100644 index 00000000..95125a76 --- /dev/null +++ b/agent/tests/test_composio_classify.py @@ -0,0 +1,172 @@ +"""Effect classification and the approval decision it feeds.""" + +from __future__ import annotations + +import pytest + +from composio_tools.classify import effect_of, needs_approval +from composio_tools.effects import EffectMap + + +@pytest.mark.parametrize( + ("tags", "expected"), + [ + (["readOnlyHint"], "read"), + (["destructiveHint"], "destructive"), + # Both present: the dangerous claim wins. + (["readOnlyHint", "destructiveHint"], "destructive"), + # Nothing positively claimed. Deliberately not "write": the tags cannot + # express a write that is not destructive, so answering "write" here + # would be a guess dressed as a classification. + (["somethingElse"], None), + ([], None), + (None, None), + ], +) +def test_effect_of_tags(tags, expected): + assert effect_of(tags) == expected + + +@pytest.mark.parametrize( + ("tags", "expected"), + [ + ({"readOnlyHint": True}, "read"), + ({"destructiveHint": True}, "destructive"), + # The hint's value, not the hint's name. A tool that says "I am not + # read-only" must not read as read-only because the word is present. + ({"readOnlyHint": False}, None), + ({"readOnlyHint": False, "destructiveHint": True}, "destructive"), + ({"destructiveHint": False}, None), + # MCP hints are booleans; a truthy string is not a claim. + ({"readOnlyHint": "no"}, None), + ], +) +def test_a_hint_is_read_by_value_not_by_presence(tags, expected): + assert effect_of(tags) == expected + + +@pytest.mark.parametrize("tags", ["readOnlyHint", object(), [1, 2], 7]) +def test_a_shape_that_is_not_a_tag_list_claims_nothing(tags): + assert effect_of(tags) is None + + +@pytest.mark.parametrize( + ("effect", "mode", "expected"), + [ + ("destructive", "off", False), + ("write", "off", False), + ("read", "off", False), + ("destructive", "on", True), + ("write", "on", True), + ("read", "on", False), + ], +) +def test_needs_approval(effect, mode, expected): + assert needs_approval(effect, mode) is expected + + +def test_a_read_is_the_only_thing_that_goes_through_unasked(): + # The collapse of `writes` and `destructive` into `on` is only safe because + # nothing but a read escapes the gate. An effect this test has never heard + # of must still be asked about, or a new classification would ship ungated. + for effect in ("destructive", "write", "unclassified", "", "something new"): + assert needs_approval(effect, "on") is True, effect + assert needs_approval("read", "on") is False + + +class FakeTool: + def __init__(self, tags) -> None: + self.tags = tags + + +class FakeTools: + def __init__(self, by_slug) -> None: + self._by_slug = by_slug + self.asked: list[str] = [] + + def get_raw_composio_tool_by_slug(self, slug): + self.asked.append(slug) + return self._by_slug[slug] + + +class FakeClient: + def __init__(self, by_slug) -> None: + self.tools = FakeTools(by_slug) + + +def test_a_found_but_untagged_tool_is_destructive_not_a_write(): + # The whole gate rests on this. `write` is a value the tags cannot express + # and `needs_approval` lets nothing but a classified read through, so an + # untagged tool called a write is a write nobody is asked about. + client = FakeClient({"GMAIL_SEND_EMAIL": FakeTool([])}) + + assert EffectMap(lambda: client).effect_for("GMAIL_SEND_EMAIL") == "destructive" + + +def test_the_fail_safe_answer_is_never_cached_as_a_verdict(): + # A tool nobody classified is gated because nothing is known about it, not + # because something dangerous is known. Caching that would freeze a guess + # into a permanent answer and hide the day Composio does classify it. + tool = FakeTool([]) + client = FakeClient({"SLACK_DO_THING": tool}) + effects = EffectMap(lambda: client) + + assert effects.effect_for("SLACK_DO_THING") == "destructive" + tool.tags = ["readOnlyHint"] + + assert effects.effect_for("SLACK_DO_THING") == "read" + assert client.tools.asked == ["SLACK_DO_THING", "SLACK_DO_THING"] + + +def test_a_classified_tool_costs_one_lookup(): + tool = FakeTool(["readOnlyHint"]) + client = FakeClient({"LINEAR_LIST_ISSUES": tool}) + effects = EffectMap(lambda: client) + + assert effects.effect_for("LINEAR_LIST_ISSUES") == "read" + assert effects.effect_for("LINEAR_LIST_ISSUES") == "read" + assert client.tools.asked == ["LINEAR_LIST_ISSUES"] + + +def test_a_lookup_that_fails_is_destructive_and_gets_another_chance(): + class Failing: + def __init__(self) -> None: + self.tools = self + self.asked: list[str] = [] + + def get_raw_composio_tool_by_slug(self, slug): + self.asked.append(slug) + raise RuntimeError("provider down") + + client = Failing() + effects = EffectMap(lambda: client) + + assert effects.effect_for("GMAIL_SEND_EMAIL") == "destructive" + assert effects.effect_for("GMAIL_SEND_EMAIL") == "destructive" + assert client.asked == ["GMAIL_SEND_EMAIL", "GMAIL_SEND_EMAIL"] + + +def test_a_lookup_signature_break_is_not_read_as_a_provider_outage(): + # "Could not look it up, treating it as destructive" is the right thing to + # say about a provider having a bad day. Said about an SDK that renamed a + # parameter it is a diagnosis that leads nobody to the cause, and every call + # for the rest of the process is gated for a reason nobody can find. + class Breaking: + def __init__(self) -> None: + self.tools = self + + def get_raw_composio_tool_by_slug(self, slug, **kwargs): + raise TypeError( + "get_raw_composio_tool_by_slug() missing 1 required argument" + ) + + with pytest.raises(TypeError): + EffectMap(lambda: Breaking()).effect_for("GMAIL_SEND_EMAIL") + + +def test_a_client_that_lost_its_tools_collection_is_not_an_outage_either(): + class NoTools: + pass + + with pytest.raises(AttributeError): + EffectMap(lambda: NoTools()).effect_for("GMAIL_SEND_EMAIL") diff --git a/agent/tests/test_composio_config.py b/agent/tests/test_composio_config.py new file mode 100644 index 00000000..68501219 --- /dev/null +++ b/agent/tests/test_composio_config.py @@ -0,0 +1,195 @@ +"""The Composio environment contract.""" + +from __future__ import annotations + +import logging + +import pytest + +from composio_tools.config import ( + DEFAULT_WORKSPACE_USER_ID, + ComposioConfigError, + read_composio_config, +) + + +def test_no_api_key_reports_unconfigured(): + assert read_composio_config({}, default_user_id="open-tag") is None + + +def test_api_key_without_toolkits_reports_unconfigured(): + # A key naming no toolkit can reach nothing, so the agent must not advertise + # tools whose only possible answer is "nothing is set up". + config = read_composio_config( + {"COMPOSIO_API_KEY": "ak_test"}, default_user_id="open-tag" + ) + assert config is None + + +def test_toolkit_lists_are_split_trimmed_and_lowercased(): + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": " Linear , JIRA ,, ", + "COMPOSIO_USER_TOOLKITS": "Gmail", + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.workspace_toolkits == ("linear", "jira") + assert config.user_toolkits == ("gmail",) + + +def test_the_old_two_spellings_still_parse_and_mean_the_same_thing(): + # `destructive` and `writes` gated an identical set, so they collapsed to + # `on`. Refusing them now would fail an existing deployment at boot over a + # value that always meant what it still means. + for raw in ("destructive", "writes", "WRITES", " Destructive "): + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_APPROVALS": raw, + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.approvals == "on", raw + + +def test_approvals_defaults_to_on_when_blank(): + # `COMPOSIO_APPROVALS=` is routine in .env files and compose passthrough. + # Unset is not invalid, and must not take the agent down at boot. + for raw in ("", " "): + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_APPROVALS": raw, + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.approvals == "on" + + +def test_unknown_approval_mode_is_refused_by_name(): + with pytest.raises(ComposioConfigError) as error: + read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_APPROVALS": "sometimes", + }, + default_user_id="open-tag", + ) + assert "sometimes" in str(error.value) + + +def test_workspace_user_id_falls_back_to_the_channel_name(): + config = read_composio_config( + {"COMPOSIO_API_KEY": "ak_test", "COMPOSIO_TOOLKITS": "linear"}, + default_user_id="open-tag", + ) + assert config is not None + assert config.workspace_user_id == "open-tag" + + +def test_auth_configs_keep_id_case_and_split_on_the_first_colon_only(): + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + # Real ids are mixed case and can contain a colon; a lowercased or + # truncated id does not resolve against the project. + "COMPOSIO_AUTH_CONFIGS": "Linear:ac_ExAmPle1:aB, broken, :x, y:", + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.auth_configs == {"linear": "ac_ExAmPle1:aB"} + + +def test_the_workspace_user_id_override_is_what_wins(): + # No test set this variable, so deleting the line that reads it left the + # suite green while every shared call ran as the wrong Composio identity. + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_WORKSPACE_USER_ID": "shared-account", + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.workspace_user_id == "shared-account" + + +def test_a_blank_channel_name_never_becomes_an_empty_user_id(): + # `INTELLIGENCE_CHANNEL_NAME=` is routine, and `.get(name, "open-tag")` + # returns the empty string for it rather than the default. An empty + # Composio user id is a real identity that nothing else ever resolves to, + # so the shared connection lands somewhere no turn looks. + for blank in ("", " "): + config = read_composio_config( + {"COMPOSIO_API_KEY": "ak_test", "COMPOSIO_TOOLKITS": "linear"}, + default_user_id=blank, + ) + assert config is not None + assert config.workspace_user_id == DEFAULT_WORKSPACE_USER_ID + + # A blank override falls through to the default too, rather than winning. + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_WORKSPACE_USER_ID": " ", + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.workspace_user_id == "open-tag" + + +def test_a_key_with_no_toolkits_says_why_the_feature_is_off(caplog): + # Configuring a key and nothing else is a plausible half-finished setup, and + # it used to disable the whole integration in silence: no tools, no error, + # nothing in the log to read. + with caplog.at_level(logging.WARNING): + assert ( + read_composio_config( + {"COMPOSIO_API_KEY": "ak_test"}, default_user_id="open-tag" + ) + is None + ) + + assert "COMPOSIO_TOOLKITS" in caplog.text + assert "COMPOSIO_USER_TOOLKITS" in caplog.text + + +def test_an_absent_key_says_nothing_at_all(caplog): + # Not configuring the feature is not a misconfiguration, and a deployment + # that never wanted Composio must not be told about it once per read. + with caplog.at_level(logging.WARNING): + assert read_composio_config({}, default_user_id="open-tag") is None + + assert caplog.text == "" + + +def test_the_config_is_hashable_the_way_a_frozen_dataclass_promises(): + # `frozen=True` generates `__hash__`, and a dict field made it raise — so + # anything ordinary that hashes a frozen value (a set, a dict key, an + # `lru_cache` argument) crashed on a config that named an auth config, and + # only on that one. + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_AUTH_CONFIGS": "linear:ac_ExAmPle1", + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.auth_configs == {"linear": "ac_ExAmPle1"} + assert isinstance(hash(config), int) + assert {config} diff --git a/agent/tests/test_composio_connect.py b/agent/tests/test_composio_connect.py new file mode 100644 index 00000000..46596226 --- /dev/null +++ b/agent/tests/test_composio_connect.py @@ -0,0 +1,305 @@ +"""Minting a connect link, and the route that serves one.""" + +from __future__ import annotations + +import logging + +import pytest +from fastapi.testclient import TestClient + +import composio_tools.runtime as runtime_mod +from composio_tools.config import ComposioConfig +from composio_tools.connect import ConnectLink, ConnectRefused, connect_link +from composio_tools.runtime import ComposioRuntime, reset_composio_runtime +from composio_tools.sessions import SessionCache + +LINK = "https://backend.composio.dev/connect/abc123" + + +class FakeAuthorization: + def __init__(self, url=LINK): + self.redirect_url = url + + +class FakeSession: + def __init__(self, user_id, *, fail=False, url=LINK): + self.user_id = user_id + self._fail = fail + self._url = url + self.authorized: list[str] = [] + + def search(self, *, query): + raise NotImplementedError + + def execute(self, slug, arguments): + raise NotImplementedError + + def authorize(self, toolkit): + self.authorized.append(toolkit) + if self._fail: + raise RuntimeError("provider said no") + return FakeAuthorization(self._url) + + def toolkits(self): + raise NotImplementedError + + +class FakeComposio: + def __init__(self, sessions_by_user): + self.sessions = self + self._by_user = sessions_by_user + self.created: list[str] = [] + + def create(self, *, user_id, **_kwargs): + self.created.append(user_id) + return self._by_user.setdefault(user_id, FakeSession(user_id)) + + +class FakeEffects: + """Nothing here gates, but the answer still matches production's fail-safe: + an unclassified slug is destructive, not read-only.""" + + def effect_for(self, _slug): + return "destructive" + + +def runtime_for(sessions_by_user, **config_overrides): + defaults = { + "api_key": "ak_test", + "workspace_toolkits": ("linear",), + "user_toolkits": ("gmail",), + "approvals": "destructive", + "workspace_user_id": "open-tag", + } + config = ComposioConfig(**{**defaults, **config_overrides}) + client = FakeComposio(sessions_by_user) + return ( + ComposioRuntime( + config=config, + cache=SessionCache(config, client=client), + effects=FakeEffects(), + ), + client, + ) + + +def test_a_personal_app_gets_a_link_minted_for_that_person(): + sessions = {} + runtime, client = runtime_for(sessions) + + result = connect_link(runtime, identity="slack:U1", toolkit="gmail") + + assert result.url == LINK + assert client.created == ["slack:U1"] + assert sessions["slack:U1"].authorized == ["gmail"] + + +def test_a_shared_app_is_refused_rather_than_connected_by_a_clicker(): + # A shared toolkit runs as one workspace identity, so a link minted for a + # clicker connects an account no shared call ever uses. + runtime, client = runtime_for({}) + + result = connect_link(runtime, identity="slack:U1", toolkit="linear") + + assert isinstance(result, ConnectRefused) + assert "not one of the apps people connect for themselves" in result.reason + assert client.created == [] + + +def test_an_unknown_app_is_refused(): + runtime, _client = runtime_for({}) + assert isinstance(connect_link(runtime, identity="slack:U1", toolkit="dropbox"), ConnectRefused) + assert isinstance(connect_link(runtime, identity="slack:U1", toolkit=" "), ConnectRefused) + + +def test_a_provider_failure_becomes_a_reason_not_an_exception(caplog): + sessions = {"slack:U1": FakeSession("slack:U1", fail=True)} + runtime, _client = runtime_for(sessions) + + with caplog.at_level(logging.WARNING): + result = connect_link(runtime, identity="slack:U1", toolkit="gmail") + + assert isinstance(result, ConnectRefused) + assert "provider said no" in caplog.text + + +def test_an_authorization_with_no_link_is_a_refusal(): + sessions = {"slack:U1": FakeSession("slack:U1", url="")} + runtime, _client = runtime_for(sessions) + + assert isinstance(connect_link(runtime, identity="slack:U1", toolkit="gmail"), ConnectRefused) + + +def test_the_link_is_never_logged(caplog): + sessions = {} + runtime, _client = runtime_for(sessions) + + with caplog.at_level(logging.DEBUG): + result = connect_link(runtime, identity="slack:U1", toolkit="gmail") + + # A refused mint logs no link either, so the assertion below holds for the + # one case this test is not about. Establish that a link was minted first, + # or the test passes for the wrong reason. + assert isinstance(result, ConnectLink) + assert result.url == LINK + assert LINK not in caplog.text + + +@pytest.fixture +def client(monkeypatch): + reset_composio_runtime() + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + import main + + yield TestClient(main.app) + reset_composio_runtime() + + +def install_runtime(monkeypatch, sessions_by_user, **overrides): + runtime, client = runtime_for(sessions_by_user, **overrides) + monkeypatch.setattr(runtime_mod, "build_composio_runtime", lambda *a, **k: runtime) + reset_composio_runtime() + return runtime, client + + +def test_the_route_refuses_without_a_configured_secret(client, monkeypatch): + # The response is a bearer capability. With no secret there is no + # configuration in which serving it is right, so it fails closed. + # + # 503 rather than 401, which is what this used to assert: nothing the + # caller sent could have been accepted, and 401 is rendered by the + # TypeScript caller as "the agent rejected the one this app sent" — a + # wrong-secret hunt for a secret that does not exist. `test_agent_auth.py` + # covers the distinction and the log that goes with it. + monkeypatch.delenv("AGENT_AUTH_HEADER", raising=False) + install_runtime(monkeypatch, {}) + + response = client.post( + "/composio/connect", + json={"actor_id": "U1", "kind": "human", "platform": "slack", "toolkit": "gmail"}, + ) + + assert response.status_code == 503 + + +def test_the_route_refuses_a_wrong_secret(client, monkeypatch): + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + install_runtime(monkeypatch, {}) + + response = client.post( + "/composio/connect", + json={"actor_id": "U1", "kind": "human", "platform": "slack", "toolkit": "gmail"}, + headers={"Authorization": "Bearer wrong"}, + ) + + assert response.status_code == 401 + + +def test_the_route_returns_a_link_for_the_named_person(client, monkeypatch): + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + sessions = {} + _runtime, composio = install_runtime(monkeypatch, sessions) + + response = client.post( + "/composio/connect", + json={"actor_id": "U1", "kind": "human", "platform": "slack", "toolkit": "gmail"}, + headers={"Authorization": "Bearer s3cret"}, + ) + + assert response.status_code == 200 + assert response.json() == {"redirectUrl": LINK} + assert composio.created == ["slack:U1"] + + +def test_the_route_reports_an_unconfigured_deployment(client, monkeypatch): + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + monkeypatch.setattr(runtime_mod, "build_composio_runtime", lambda *a, **k: None) + reset_composio_runtime() + + response = client.post( + "/composio/connect", + json={"actor_id": "U1", "kind": "human", "platform": "slack", "toolkit": "gmail"}, + headers={"Authorization": "Bearer s3cret"}, + ) + + assert response.status_code == 503 + + +def test_health_stays_reachable_without_the_secret(client, monkeypatch): + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + assert client.get("/health").status_code == 200 + + +def test_the_route_refuses_a_request_naming_nobody(client, monkeypatch): + # Found by a live run, not by a unit test: the route builds an actor from a + # request body, so a blank id reached `actor_key` without passing through the + # state reader that would have filtered it — and minted a real link bound to + # an identity no turn would ever look up again. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + _runtime, composio = install_runtime(monkeypatch, {}) + + for actor_id in ("", " "): + response = client.post( + "/composio/connect", + json={ + "actor_id": actor_id, + "kind": "human", + "platform": "slack", + "toolkit": "gmail", + }, + headers={"Authorization": "Bearer s3cret"}, + ) + assert response.status_code == 400 + + assert composio.created == [] + + +@pytest.mark.parametrize("kind", ["bot", "app", "system", "unknown", None]) +def test_the_route_mints_nothing_for_something_posting_as_a_person( + client, monkeypatch, kind +): + # A connect link is a bearer capability, and whoever opens it binds a real + # account to the id it was minted for. Minting one for a bot, an app, or a + # caller that could not say, binds an account to an identity no turn will + # ever act as — the same broken end state a blank `actor_id` produced. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + _runtime, composio = install_runtime(monkeypatch, {}) + + body = {"actor_id": "U1", "platform": "slack", "toolkit": "gmail"} + if kind is not None: + body["kind"] = kind + + response = client.post( + "/composio/connect", + json=body, + headers={"Authorization": "Bearer s3cret"}, + ) + + assert response.status_code == 400 + assert composio.created == [] + + +@pytest.mark.parametrize("platform", ["", "unknown", "matrix"]) +def test_the_route_mints_nothing_for_a_surface_no_turn_arrives_from( + client, monkeypatch, platform +): + # A link minted under `unknown:U1` connects an account nothing looks up, + # and `unknown` was a namespace anyone could reach. `matrix` is a surface + # `@copilotkit/channels` ships no adapter for; the ones it does ship are all + # in `KNOWN_PLATFORMS` and mint links here. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + _runtime, composio = install_runtime(monkeypatch, {}) + + response = client.post( + "/composio/connect", + json={ + "actor_id": "U1", + "kind": "human", + "platform": platform, + "toolkit": "gmail", + }, + headers={"Authorization": "Bearer s3cret"}, + ) + + assert response.status_code == 400 + assert composio.created == [] diff --git a/agent/tests/test_composio_connect_cli.py b/agent/tests/test_composio_connect_cli.py new file mode 100644 index 00000000..3233c3d0 --- /dev/null +++ b/agent/tests/test_composio_connect_cli.py @@ -0,0 +1,281 @@ +"""The operator path for connecting a shared toolkit.""" + +from __future__ import annotations + +from composio_tools.config import ComposioConfig +import composio_tools.connect_cli as connect_cli +from composio_tools.connect_cli import resolve_shared_toolkit + + +def config(**overrides) -> ComposioConfig: + defaults = { + "api_key": "ak_test", + "workspace_toolkits": ("linear", "jira"), + "user_toolkits": ("gmail",), + "approvals": "destructive", + "workspace_user_id": "open-tag", + } + return ComposioConfig(**{**defaults, **overrides}) + + +def test_a_shared_toolkit_resolves(): + slug, message = resolve_shared_toolkit(config(), "Linear") + assert (slug, message) == ("linear", None) + + +def test_no_argument_lists_what_could_be_connected(): + slug, message = resolve_shared_toolkit(config(), None) + assert slug is None + assert "linear, jira" in message + + +def test_a_personal_toolkit_is_refused_with_the_reason(): + # Minting a shared link for a personal toolkit connects one account that + # every personal call then ignores — the exact broken end state this script + # exists to prevent. + slug, message = resolve_shared_toolkit(config(), "gmail") + assert slug is None + assert "COMPOSIO_USER_TOOLKITS" in message + + +def test_an_unconfigured_toolkit_is_refused(): + slug, message = resolve_shared_toolkit(config(), "salesforce") + assert slug is None + assert "not in COMPOSIO_TOOLKITS" in message + + +def test_a_toolkit_in_both_lists_is_treated_as_personal(): + # Matching `resolve_scopes`, which resolves a doubly-listed toolkit to the + # personal scope only. The two must not disagree about which it is. + slug, message = resolve_shared_toolkit( + config(workspace_toolkits=("gmail",), user_toolkits=("gmail",)), "gmail" + ) + assert slug is None + assert "COMPOSIO_USER_TOOLKITS" in message + + +LINK = "https://backend.composio.dev/connect/abc123" + + +class FakeRequest: + """One SDK connection request. `spelling` picks the attribute it carries.""" + + def __init__(self, url=LINK, spelling="redirect_url"): + if url is not None: + setattr(self, spelling, url) + + +class FakeSessions: + def __init__(self, request): + self.created: list[dict] = [] + self._request = request + + def create(self, **kwargs): + self.created.append(kwargs) + return self + + def authorize(self, toolkit): + self.authorized = toolkit + return self._request + + +class FakeComposio: + instances: list["FakeComposio"] = [] + + def __init__(self, api_key=None, request=None): + self.api_key = api_key + self.sessions = FakeSessions(request or FakeRequest()) + FakeComposio.instances.append(self) + + +def install_sdk(monkeypatch, request=None): + """Replace the SDK client, and hand back the one the CLI constructs.""" + FakeComposio.instances.clear() + monkeypatch.setattr( + connect_cli, "Composio", lambda api_key: FakeComposio(api_key, request) + ) + return FakeComposio.instances + + +ENV = { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_USER_TOOLKITS": "gmail", +} + + +def test_the_operator_path_reads_the_repo_env_file(monkeypatch, tmp_path, capsys): + # Nothing this module imports loads the repo `.env`, and an operator running + # the script has no reason to have exported the variables into their shell. + # Without this the only correct way to connect a shared toolkit exits 1 + # saying Composio is not configured — on a deployment where it is. + env_file = tmp_path / ".env" + env_file.write_text( + "COMPOSIO_API_KEY=ak_from_env_file\n" + "COMPOSIO_TOOLKITS=linear\n" + "COMPOSIO_USER_TOOLKITS=gmail\n" + ) + monkeypatch.setattr(connect_cli, "ENV_FILE", env_file) + for name in ( + "COMPOSIO_API_KEY", + "COMPOSIO_TOOLKITS", + "COMPOSIO_USER_TOOLKITS", + "COMPOSIO_AUTH_CONFIGS", + "COMPOSIO_WORKSPACE_USER_ID", + ): + monkeypatch.delenv(name, raising=False) + instances = install_sdk(monkeypatch) + + assert connect_cli.main(["linear"]) == 0 + + assert instances[0].api_key == "ak_from_env_file" + assert LINK in capsys.readouterr().out + + +def test_the_process_environment_still_wins_over_the_env_file( + monkeypatch, tmp_path +): + # The file is a fallback, not an override: an operator who exports a key for + # one run must get that key, which is how `load_dotenv` already behaves for + # the agent itself. + env_file = tmp_path / ".env" + env_file.write_text("COMPOSIO_API_KEY=ak_from_env_file\nCOMPOSIO_TOOLKITS=linear\n") + monkeypatch.setattr(connect_cli, "ENV_FILE", env_file) + monkeypatch.setenv("COMPOSIO_API_KEY", "ak_exported") + monkeypatch.delenv("COMPOSIO_AUTH_CONFIGS", raising=False) + instances = install_sdk(monkeypatch) + + assert connect_cli.main(["linear"]) == 0 + assert instances[0].api_key == "ak_exported" + + +def test_a_missing_env_file_is_not_an_error(monkeypatch, tmp_path): + # Exported rather than passed as `env=`, because `operator_environment` + # returns a supplied mapping before it ever opens the file: with `env=` this + # named the missing-file path and never entered it, and a reader that + # assumed the file was there would have taken down every operator who has + # no `.env` with all 13 tests still green. + monkeypatch.setattr(connect_cli, "ENV_FILE", tmp_path / "absent.env") + for name, value in ENV.items(): + monkeypatch.setenv(name, value) + instances = install_sdk(monkeypatch) + + assert connect_cli.main(["linear"]) == 0 + assert instances[0].api_key == "ak_test" + + +def test_the_session_is_opened_for_the_shared_identity_and_that_toolkit_alone( + monkeypatch, capsys +): + # What a connect link binds, and the only thing this script decides. A link + # binds whoever opens it to the id it was minted for, for the toolkit it was + # minted for, and neither is visible in the URL — so an operator who opens a + # link minted for the wrong id connects the team's shared account where no + # turn ever looks, and nothing says so. + # + # All three, together: the id on the session, the single toolkit the session + # is scoped to, and the toolkit handed to `authorize()`. The channel name is + # deliberately not `open-tag`, so a session that fell back to + # `DEFAULT_WORKSPACE_USER_ID` instead of reading the deployment's own + # identity fails here too. + instances = install_sdk(monkeypatch) + + assert ( + connect_cli.main( + ["linear"], + env={ + **ENV, + "COMPOSIO_TOOLKITS": "linear,jira", + "INTELLIGENCE_CHANNEL_NAME": "opentag-support", + }, + ) + == 0 + ) + + sessions = instances[0].sessions + assert sessions.created[0]["user_id"] == "opentag-support" + # This toolkit, not every shared toolkit the deployment has: a session + # scoped wider mints a link that connects `jira` as a side effect of + # connecting `linear`. + assert sessions.created[0]["toolkits"] == ["linear"] + assert sessions.authorized == "linear" + # The sentence the operator reads before clicking has to name the same id. + assert "opentag-support" in capsys.readouterr().out + + +def test_an_explicit_workspace_user_id_is_the_identity_that_is_bound(monkeypatch): + # `COMPOSIO_WORKSPACE_USER_ID` is how a deployment names a shared identity + # that is not the channel name, and every shared turn runs as that id. A + # link minted for anything else connects an account no turn resolves. + instances = install_sdk(monkeypatch) + + assert ( + connect_cli.main( + ["linear"], + env={ + **ENV, + "INTELLIGENCE_CHANNEL_NAME": "opentag-support", + "COMPOSIO_WORKSPACE_USER_ID": "shared-account", + }, + ) + == 0 + ) + + assert instances[0].sessions.created[0]["user_id"] == "shared-account" + + +def test_the_link_is_read_whichever_way_the_sdk_spells_it(monkeypatch, capsys): + # The connect route reads both spellings; this path read one, so a camelCase + # response became "Composio returned no link" on a request that worked. + install_sdk(monkeypatch, request=FakeRequest(spelling="redirectUrl")) + + assert connect_cli.main(["linear"], env=ENV) == 0 + assert LINK in capsys.readouterr().out + + +def test_no_link_at_all_is_still_reported(monkeypatch, capsys): + install_sdk(monkeypatch, request=FakeRequest(url=None)) + + assert connect_cli.main(["linear"], env=ENV) == 1 + assert "no link" in capsys.readouterr().err + + +def test_the_session_pins_the_auth_config_the_operator_named(monkeypatch, capsys): + # `COMPOSIO_AUTH_CONFIGS` was parsed, documented and never sent, so a + # toolkit with more than one auth config connected through whichever one the + # project happened to resolve — the case the variable exists for. + instances = install_sdk(monkeypatch) + + assert ( + connect_cli.main( + ["linear"], env={**ENV, "COMPOSIO_AUTH_CONFIGS": "linear:ac_ExAmPle1"} + ) + == 0 + ) + + assert instances[0].sessions.created[0]["auth_configs"] == { + "linear": "ac_ExAmPle1" + } + assert "ac_ExAmPle1" in capsys.readouterr().out + + +def test_an_unpinned_toolkit_sends_no_auth_config(monkeypatch): + instances = install_sdk(monkeypatch) + + assert connect_cli.main(["linear"], env=ENV) == 0 + + assert instances[0].sessions.created[0]["auth_configs"] is None + + +def test_the_operator_session_carries_no_connection_management_tools(monkeypatch): + # The SDK defaults this to True. Left on, the session the operator opens + # carries tools that initiate and manage connected accounts — the second + # path the connect flow exists to close. Set in the runtime's session cache + # already; this path builds its own session and missed it. + instances = install_sdk(monkeypatch) + + assert connect_cli.main(["linear"], env=ENV) == 0 + + created = instances[0].sessions.created[0] + assert created["manage_connections"] is False + assert created["sandbox"] == {"enable": False} diff --git a/agent/tests/test_composio_identity.py b/agent/tests/test_composio_identity.py new file mode 100644 index 00000000..081c5104 --- /dev/null +++ b/agent/tests/test_composio_identity.py @@ -0,0 +1,519 @@ +"""Who a turn acts as, decided at the boundary and nowhere else. + +The integration cases here drive the real AG-UI adapter over a real checkpointed +graph rather than asserting on a helper. Both defects they pin were invisible to +a helper-level test: one lives in how the adapter merges a request's `state` +over its forwarded properties, the other in the fact that the graph is +checkpointed per thread and a turn that says nothing leaves the last answer +standing. +""" + +from __future__ import annotations + +import asyncio +import re +import uuid +from pathlib import Path + +import pytest +from ag_ui.core import AssistantMessage, RunAgentInput, UserMessage +from langchain_core.messages import AIMessage +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import END, START, StateGraph + +from agui import build_agui_agent +from composio_tools.state import ( + KNOWN_PLATFORMS, + ComposioAgentState, + actor_key, + actor_of, + forwarded_actor, + is_personal_kind, + with_forwarded_actor, +) + +SLACK_U1 = {"id": "U1", "kind": "human", "platform": "slack"} +SLACK_U2 = {"id": "U2", "kind": "human", "platform": "slack"} + + +class Turns: + """Every actor the graph saw, in order. + + The node also appends messages, because the adapter decides between a + normal run and a time-travel regeneration by comparing the checkpoint's + message count with the run's — a node that writes nothing can never reach + the second path. + """ + + def __init__(self) -> None: + self.actors: list[dict | None] = [] + + def record(self, state) -> dict: + self.actors.append(state.get("channel_actor")) + index = len(self.actors) + return { + "messages": [ + AIMessage(content="ok", id=f"a{index}-1"), + AIMessage(content="done", id=f"a{index}-2"), + ] + } + + +def agent_over(turns: Turns): + graph = StateGraph(ComposioAgentState) + graph.add_node("record", turns.record) + graph.add_edge(START, "record") + graph.add_edge("record", END) + return build_agui_agent(graph.compile(checkpointer=MemorySaver())) + + +def run_input( + thread: str, + text: str, + *, + forwarded=None, + state=None, + message_id=None, + extra_messages=(), +) -> RunAgentInput: + return RunAgentInput( + thread_id=thread, + run_id=str(uuid.uuid4()), + state={} if state is None else state, + messages=[ + UserMessage( + id=str(uuid.uuid4()) if message_id is None else message_id, + role="user", + content=text, + ), + *extra_messages, + ], + tools=[], + context=[], + forwarded_props={} if forwarded is None else forwarded, + ) + + +class Regenerations: + """How many runs took the adapter's time-travel entry point. + + Without this the two regeneration cases below assert nothing about the path + they exist for: `prepare_regenerate_stream` is reached on a message-shape + heuristic inside the adapter, so a change there — or a change to the ids + this file happens to send — silently moves both cases onto the ordinary + path, where they pass for a reason that has nothing to do with the defect. + """ + + def __init__(self, agent) -> None: + self.count = 0 + original = agent.prepare_regenerate_stream + + async def counted(*args, **kwargs): + self.count += 1 + return await original(*args, **kwargs) + + agent.prepare_regenerate_stream = counted + + +def drive(agent, *inputs) -> None: + async def _drive() -> None: + for one in inputs: + async for _event in agent.run(one): + pass + + asyncio.run(_drive()) + + +def test_the_forwarded_actor_reaches_the_graph(): + # The control. Without it the two cases below could both pass on a build + # that never resolves anybody. + turns = Turns() + + drive( + agent_over(turns), + run_input("t", "hi", forwarded={"channelActor": SLACK_U1}), + ) + + assert turns.actors == [{"id": "U1", "platform": "slack", "kind": "human"}] + + +def test_an_anonymous_turn_does_not_inherit_the_previous_speaker(): + # The graph is checkpointed per thread, so `channel_actor` outlives the turn + # that set it. A second person speaking in the same Slack thread — or the + # same person on a build whose Channel does not forward — used to run in the + # first person's connected accounts. + turns = Turns() + + drive( + agent_over(turns), + run_input("t", "hi", forwarded={"channelActor": SLACK_U1}), + run_input("t", "and again", forwarded={}), + ) + + assert turns.actors[1] is None + + +def test_caller_supplied_state_cannot_name_a_different_person(): + # The adapter merges a request's `state` *over* its forwarded properties, so + # the untrusted value used to win the slot the trusted one arrives in. + turns = Turns() + + drive( + agent_over(turns), + run_input( + "t", + "hi", + forwarded={"channelActor": SLACK_U1}, + state={"channel_actor": SLACK_U2}, + ), + ) + + assert turns.actors == [{"id": "U1", "platform": "slack", "kind": "human"}] + + +def test_a_regenerated_turn_does_not_replay_the_checkpointed_actor(): + # The adapter has a second entry point. `prepare_regenerate_stream` forks + # from the checkpoint's own values and never reads `input.state`, so the + # rewrite that stamps the trusted actor on every run is a no-op there and + # the fork carries whoever spoke when that checkpoint was written. + # + # Reachable on the managed adapter, which keeps one LangGraph thread per + # conversation: from the second turn on, the transcript arrives with ids the + # checkpoint has never seen and the heuristic below fires. + turns = Turns() + agent = agent_over(turns) + regenerations = Regenerations(agent) + + drive( + agent, + run_input("t", "hi", forwarded={"channelActor": SLACK_U1}, message_id="m1"), + run_input( + "t", + "hi", + forwarded={"channelActor": SLACK_U2}, + message_id="m1", + extra_messages=[ + AssistantMessage(id="unseen-1", role="assistant", content="ok") + ], + ), + ) + + assert regenerations.count == 1, "the second turn never reached the fork" + assert len(turns.actors) == 2, turns.actors + assert turns.actors[1] == {"id": "U2", "platform": "slack", "kind": "human"} + + +def test_a_regenerated_turn_that_forwards_nobody_clears_the_actor(): + turns = Turns() + agent = agent_over(turns) + regenerations = Regenerations(agent) + + drive( + agent, + run_input("t", "hi", forwarded={"channelActor": SLACK_U1}, message_id="m1"), + run_input( + "t", + "hi", + forwarded={}, + message_id="m1", + extra_messages=[ + AssistantMessage(id="unseen-1", role="assistant", content="ok") + ], + ), + ) + + assert regenerations.count == 1, "the second turn never reached the fork" + assert len(turns.actors) == 2, turns.actors + assert turns.actors[1] is None + + +def test_caller_supplied_state_alone_names_nobody(): + turns = Turns() + + drive( + agent_over(turns), + run_input("t", "hi", forwarded={}, state={"channel_actor": SLACK_U2}), + ) + + assert turns.actors == [None] + + +def test_the_camelcase_spelling_in_state_is_dropped_too(): + # `state` is not key-converted on its way through the adapter, so a caller + # can spell the key either way. + assert with_forwarded_actor({"channelActor": SLACK_U2}, {}) == { + "channel_actor": None + } + + +def test_unrelated_state_survives_the_rewrite(): + assert with_forwarded_actor({"todos": ["a"]}, {"channelActor": SLACK_U1}) == { + "todos": ["a"], + "channel_actor": {"id": "U1", "platform": "slack", "kind": "human"}, + } + + +@pytest.mark.parametrize("state", [None, [], "nope", 7]) +def test_a_state_that_is_not_a_mapping_still_yields_a_cleared_actor(state): + assert with_forwarded_actor(state, {}) == {"channel_actor": None} + + +@pytest.mark.parametrize( + "platform", + ["", " ", "unknown", "matrix", {"x": 1}, 7, None, ["slack"]], +) +def test_an_unusable_platform_mints_no_identity(platform): + # A blank platform used to namespace people under `unknown:`, and a + # non-string one was coerced — `{'x': 1}:U1`, `7:U1`. Each was a namespace of + # its own, reachable by anyone who could put that value in the slot. + actor = {"id": "U1", "kind": "human", "platform": platform} + + assert actor_key(actor) is None + assert actor_of({"channel_actor": actor}) is None + + +def test_a_known_platform_is_matched_case_insensitively(): + assert actor_key({"id": "U1", "kind": "human", "platform": " Slack "}) == "slack:U1" + + +@pytest.mark.parametrize("kind", ["bot", "app", "system", "unknown", "", None, 7]) +def test_only_a_person_gets_a_personal_identity(kind): + # `ProviderActor.kind` is the provider's own word for what posted, and the + # SDK calls it untrusted metadata. Read as a filter it costs a bot access; + # read as a grant it would spend a person's connected account. + actor = {"id": "U1", "kind": kind, "platform": "slack"} + + assert is_personal_kind(actor) is False + assert actor_of({"channel_actor": actor}) is None + assert forwarded_actor({"channelActor": actor}) is None + + +@pytest.mark.parametrize("identifier", [7, None, b"U1", ["U1"], {"id": "U1"}, "", " "]) +def test_actor_of_and_actor_key_agree_on_an_unusable_id(identifier): + # They disagreed: `actor_of` required a string and `actor_key` coerced one, + # so the same actor was nobody to the turn and a real Composio user id to + # everything keyed per person. + actor = {"id": identifier, "kind": "human", "platform": "slack"} + + assert actor_of({"channel_actor": actor}) is None + assert actor_key(actor) is None + + +def test_no_known_platform_contains_the_separator(): + # What makes `platform:id` injective. The platform half comes from a closed, + # colon-free set, so the first colon in a key is always the separator and the + # pair is recoverable even from an id that contains one. + assert all(":" not in platform for platform in KNOWN_PLATFORMS) + + +def test_the_platform_id_join_is_injective(): + pairs = [ + *((platform, "U1") for platform in sorted(KNOWN_PLATFORMS)), + ("slack", "teams:U1"), + ("teams", "slack:U1"), + ("slack", "U1:"), + ("teams", ":U1"), + ] + keys = [actor_key({"id": i, "kind": "human", "platform": p}) for p, i in pairs] + + assert len(set(keys)) == len(pairs) + for key, (platform, identifier) in zip(keys, pairs, strict=True): + assert key.split(":", 1) == [platform, identifier] + + +def test_the_actor_kept_in_state_carries_no_name_or_email(): + # The whole of `channel_actor` is echoed in every StateSnapshotEvent and + # kept in the thread's checkpoint. Nothing here decides anything on a display + # name or a work address, and the surface that sent them already has them. + kept = forwarded_actor( + { + "channelActor": { + **SLACK_U1, + "name": "Ada Lovelace", + "handle": "ada", + "email": "ada@example.com", + } + } + ) + + assert kept == {"id": "U1", "platform": "slack", "kind": "human"} + + +def test_a_forwarded_actor_of_the_wrong_shape_is_nobody(): + for value in (None, "U1", 7, [], {"kind": "human"}, {"id": "U1"}): + assert forwarded_actor({"channelActor": value}) is None + assert forwarded_actor({}) is None + assert forwarded_actor(None) is None + + +def test_a_present_but_unusable_spelling_does_not_discard_a_usable_one(): + # Both spellings arrive in the same dictionary — one path snake-cases the + # forwarded keys and one does not. Returning on the first key that is + # *present* rather than the first that names somebody threw away a real + # actor sitting beside a null, and the turn ran anonymously: no personal + # toolkits, for a person the Channel did identify. + assert forwarded_actor({"channel_actor": None, "channelActor": SLACK_U1}) == { + "id": "U1", + "platform": "slack", + "kind": "human", + } + assert forwarded_actor({"channel_actor": {"kind": "human"}, "channelActor": SLACK_U1}) == { + "id": "U1", + "platform": "slack", + "kind": "human", + } + + +def test_a_usable_actor_wins_whichever_spelling_carries_it(): + for props in ( + {"channel_actor": SLACK_U1, "channelActor": None}, + {"channel_actor": None, "channelActor": SLACK_U1}, + ): + assert forwarded_actor(props) == { + "id": "U1", + "platform": "slack", + "kind": "human", + } + + +def test_neither_spelling_naming_anybody_is_still_nobody(): + assert forwarded_actor({"channel_actor": None, "channelActor": {"id": ""}}) is None + + +def test_the_snake_cased_spelling_is_read_too(): + # The adapter snake-cases forwarded keys on the way down; this runs above + # that on one path and below it on another. + assert forwarded_actor({"channel_actor": SLACK_U1}) == { + "id": "U1", + "platform": "slack", + "kind": "human", + } + + +def test_two_different_actors_never_share_a_key(): + # `unknown` was a real namespace, not a placeholder: a blank platform and a + # literal "unknown" both keyed to `unknown:U1`, and a differently-cased one + # opened a second namespace for the same person. Two people sharing a key + # share a Composio identity, and therefore each other's connected accounts. + collided = [ + {"id": "U1", "kind": "human", "platform": ""}, + {"id": "U1", "kind": "human", "platform": "unknown"}, + {"id": "U1", "kind": "human", "platform": {"x": 1}}, + ] + + assert [actor_key(actor) for actor in collided] == [None, None, None] + + +def without_schema_introspection(agent): + """The same agent, on a build whose graph cannot describe its own schema. + + `LangGraphAgent.get_schema_keys` catches `NotImplementedError` (among + others) and falls back to `constant_schema_keys` with a warning. Raising + from the graph drives the adapter's own documented fallback rather than + simulating it. + """ + + def no_introspection(*_args, **_kwargs): + raise NotImplementedError("this build cannot describe its own schema") + + agent.graph.get_input_jsonschema = no_introspection + return agent + + +def test_the_fallback_schema_path_still_clears_an_anonymous_turn(): + turns = Turns() + + drive( + without_schema_introspection(agent_over(turns)), + run_input("t", "hi", forwarded={"channelActor": SLACK_U1}), + run_input("t", "and again", forwarded={}), + ) + + assert turns.actors[0] == {"id": "U1", "platform": "slack", "kind": "human"} + assert turns.actors[1] is None + + +def test_the_fallback_schema_path_keeps_the_name_and_email_out_of_state(): + turns = Turns() + + drive( + without_schema_introspection(agent_over(turns)), + run_input( + "t", + "hi", + forwarded={ + "channelActor": { + **SLACK_U1, + "name": "Ada Lovelace", + "email": "ada@example.com", + } + }, + ), + ) + + assert turns.actors == [{"id": "U1", "platform": "slack", "kind": "human"}] + + +#: Every platform an official `@copilotkit/channels` adapter reports, as of the +#: pinned 0.9.2. Spelled out so the assertion below is a statement about this +#: repository, not a restatement of `KNOWN_PLATFORMS` against itself. +SHIPPED_PLATFORMS = ("slack", "teams", "discord", "telegram", "whatsapp") + + +@pytest.mark.parametrize("platform", SHIPPED_PLATFORMS) +def test_every_shipped_channel_adapter_names_somebody(platform): + # A surface outside the set is not a degraded turn, it is an anonymous one: + # no personal toolkits, no connect link, and a warning that blames the + # `@copilotkit/channels` version for something the version had nothing to do + # with. `slack` and `teams` alone left Discord, Telegram and WhatsApp turns + # anonymous on a package that ships adapters for all three. + actor = {"id": "U1", "kind": "human", "platform": platform} + + assert actor_key(actor) == f"{platform}:U1" + assert actor_of({"channel_actor": actor}) == { + "id": "U1", + "platform": platform, + "kind": "human", + } + + +def installed_channels_platforms() -> frozenset[str] | None: + """The allow-list the installed `@copilotkit/channels` keeps for itself. + + `@copilotkit/channels-core` bounds telemetry to the platforms it ships + adapters for, and holds that list to its own adapters with a coverage test. + So it is the one place in the tree that answers "which surfaces exist" + without anybody having to remember to update it. + """ + root = Path(__file__).resolve().parents[2] / "node_modules" / ".pnpm" + if not root.is_dir(): + return None + matches = sorted( + root.glob("@copilotkit+channels-core@*/**/telemetry/sanitize-error.js") + ) + if not matches: + return None + source = matches[-1].read_text(encoding="utf-8") + listed = re.search(r"KNOWN_PLATFORMS\s*=\s*new Set\(\[(.*?)\]\)", source, re.S) + assert listed, f"no KNOWN_PLATFORMS set found in {matches[-1]}" + return frozenset(re.findall(r'"([^"]+)"', listed.group(1))) + + +def test_the_shipped_platforms_are_what_the_installed_package_ships(): + # Read, not remembered. `SHIPPED_PLATFORMS` above is this repository's copy + # of a list that lives in the package; this is the only thing that notices + # when the pin moves and the copy does not. + # + # Skipped rather than assumed when the JS dependencies are absent: the CI + # `agent` job runs pytest without `pnpm install`, so there is nothing to + # read there. The `runtime` job installs them, and so does any local + # checkout that has run the setup in setup.md. + installed = installed_channels_platforms() + if installed is None: + pytest.skip("node_modules is not installed; nothing to read") + + assert set(SHIPPED_PLATFORMS) == installed + + +def test_known_platforms_covers_every_shipped_surface(): + assert set(SHIPPED_PLATFORMS) <= set(KNOWN_PLATFORMS) diff --git a/agent/tests/test_composio_prompt.py b/agent/tests/test_composio_prompt.py new file mode 100644 index 00000000..f2627025 --- /dev/null +++ b/agent/tests/test_composio_prompt.py @@ -0,0 +1,149 @@ +"""What the model is told about the connected apps it can reach.""" + +from __future__ import annotations + +import prompts +from composio_tools.config import ComposioConfig +from prompts import composio_addendum +from prompts.tools import MAX_NAMED_TOOLKITS + + +def config(**overrides) -> ComposioConfig: + defaults = { + "api_key": "ak_test", + "workspace_toolkits": ("linear",), + "user_toolkits": ("gmail",), + "approvals": "on", + "workspace_user_id": "open-tag", + } + return ComposioConfig(**{**defaults, **overrides}) + + +def test_no_composio_does_not_deny_other_integrations(): + assert composio_addendum(None) == "" + assert composio_addendum(config(workspace_toolkits=(), user_toolkits=())) == "" + + +def test_the_toolkits_are_named(): + # The defect this exists for: nothing told the model which apps were + # configured, so the only way to discover Linear was to guess a search term + # that happened to match it. Asked directly, it guessed wrong. + text = composio_addendum(config()) + + assert "linear" in text + assert "gmail" in text + + +def test_shared_and_personal_are_distinguished(): + # They fail differently. A shared toolkit is connected once by an operator; + # a personal one is unavailable until that person connects it themselves, + # and saying so is the difference between "ask again later" and "press + # this button". + text = composio_addendum(config(workspace_toolkits=("linear",), user_toolkits=("gmail",))) + + shared_line = next(line for line in text.splitlines() if "linear" in line) + personal_line = next(line for line in text.splitlines() if "gmail" in line) + assert shared_line != personal_line + assert "everyone" in shared_line.lower() or "shared" in shared_line.lower() + assert "own" in personal_line.lower() or "personal" in personal_line.lower() + + +def test_a_long_list_is_capped_and_says_so(): + # A deployment can name dozens. The point is to tell the model what kind of + # thing it can reach, not to spend the context window on an inventory — and + # a truncated list that does not admit it is truncated is a lie the model + # will repeat. + many = tuple(f"app{n}" for n in range(30)) + text = composio_addendum(config(workspace_toolkits=many, user_toolkits=())) + + named = sum(1 for n in range(30) if f"app{n}" in text) + # Exactly the cap, not "at most" it: `<=` passed when the cap was lowered, + # and passed just as well when no name was emitted at all, which is the one + # thing this list exists to do. The constant is imported rather than + # restated so the test moves with it. + assert named == MAX_NAMED_TOOLKITS + assert f"app{MAX_NAMED_TOOLKITS}" not in text + assert f"and {30 - MAX_NAMED_TOOLKITS} more" in text + + +def test_a_realistic_deployment_has_every_one_of_its_apps_named(): + # The other end of the cap. `named == MAX_NAMED_TOOLKITS` catches a list + # that stopped being capped or stopped emitting names, but it moves with + # the constant, so a cap quietly lowered to two still satisfies it. Eight + # apps is an ordinary deployment and all eight have to be named: below + # that, the model is guessing about apps it actually has. + eight = tuple(f"app{n}" for n in range(8)) + text = composio_addendum(config(workspace_toolkits=eight, user_toolkits=())) + + assert all(f"app{n}" in text for n in range(8)) + assert "more" not in text.lower() + + +def test_only_one_kind_configured_names_that_kind_and_not_the_other(): + # Both halves are a real deployment, and the personal-only one is the shape + # running in production. Asserting only that the other kind is absent was + # satisfied by the "there are no connected apps" text, which names neither: + # narrowing the emptiness check to `if not shared` would have told a + # personal-only deployment it could reach nothing, and no test would have + # moved. + shared_only = composio_addendum(config(user_toolkits=())) + assert "linear" in shared_only + assert "gmail" not in shared_only + assert "no connected apps" not in shared_only.lower() + assert "search_my_tools" in shared_only + + personal_only = composio_addendum(config(workspace_toolkits=())) + assert "gmail" in personal_only + assert "linear" not in personal_only + assert "no connected apps" not in personal_only.lower() + assert "search_my_tools" in personal_only + + +def test_the_model_is_told_to_search_rather_than_answer_from_this_list(): + # The list names apps, never actions. Left there, the model would invent + # action names from an app name; the search tool is the only thing that + # knows what a toolkit actually exposes. + text = composio_addendum(config()) + + # Not merely that the tool is named somewhere: the first line names it too, + # so deleting this sentence outright left the assertion green. It is the + # sentence that stops an app name being read as a licence to invent action + # names from it. + claim_line = next(line for line in text.splitlines() if "Never claim" in line) + assert "search_my_tools" in claim_line + assert "not actions" in claim_line + + +def test_a_toolkit_in_both_lists_is_named_as_personal_only(): + # `resolve_scopes` de-duplicates, and says why in its own docstring: "A + # toolkit named in both lists resolves to the personal scope only." Reading + # `workspace_toolkits` raw advertised such a toolkit as shared with + # everyone, so the model would tell a person their Linear action runs in + # the team account when the runtime will only ever run it as them — and, + # for a turn carrying no actor, will not run it at all. + text = composio_addendum( + config(workspace_toolkits=("linear", "notion"), user_toolkits=("linear",)) + ) + + shared_line = next(line for line in text.splitlines() if "everyone" in line) + personal_line = next(line for line in text.splitlines() if "own:" in line) + assert "linear" not in shared_line + assert "notion" in shared_line + assert "linear" in personal_line + + +def test_the_shared_line_disappears_when_every_shared_toolkit_is_also_personal(): + # The same rule taken to its end: the shared scope is empty, so there is + # nothing shared to name. It is still a deployment with a connected app. + text = composio_addendum(config(workspace_toolkits=("gmail",), user_toolkits=("gmail",))) + + assert "everyone" not in text + assert "no connected apps" not in text.lower() + assert "gmail" in text + + +def test_composio_addendum_is_a_declared_export(): + # `prompts/__init__` re-exports it and `agent.py` imports it from there. + # Missing from `__all__`, an automated unused-import pass reads the import + # as dead, removes it, and the agent stops building at boot. + assert "composio_addendum" in prompts.__all__ diff --git a/agent/tests/test_composio_scopes.py b/agent/tests/test_composio_scopes.py new file mode 100644 index 00000000..d440b42a --- /dev/null +++ b/agent/tests/test_composio_scopes.py @@ -0,0 +1,78 @@ +"""Which identities a turn acts as, and what the agent says at boot.""" + +from __future__ import annotations + +from composio_tools.config import ComposioConfig +from composio_tools.scopes import resolve_scopes, startup_warnings + + +def config(**overrides) -> ComposioConfig: + defaults = { + "api_key": "ak_test", + "workspace_toolkits": ("linear",), + "user_toolkits": (), + "approvals": "destructive", + "workspace_user_id": "open-tag", + } + return ComposioConfig(**{**defaults, **overrides}) + + +def test_shared_toolkits_run_as_the_workspace_identity(): + scopes = resolve_scopes(config(), actor_id="U1") + assert [(s.user_id, s.toolkits, s.personal) for s in scopes] == [ + ("open-tag", ("linear",), False) + ] + + +def test_a_personal_toolkit_runs_as_the_person_who_spoke(): + scopes = resolve_scopes( + config(workspace_toolkits=("linear",), user_toolkits=("gmail",)), + actor_id="U1", + ) + assert [(s.user_id, s.toolkits, s.personal) for s in scopes] == [ + ("open-tag", ("linear",), False), + ("U1", ("gmail",), True), + ] + + +def test_a_toolkit_in_both_lists_runs_only_as_the_person(): + # Routing by slug is ambiguous when a slug lives in two sessions, and + # picking whichever loaded first would attribute an action to a person or to + # the shared account depending on restart order. + scopes = resolve_scopes( + config(workspace_toolkits=("linear", "gmail"), user_toolkits=("gmail",)), + actor_id="U1", + ) + assert [(s.user_id, s.toolkits) for s in scopes] == [ + ("open-tag", ("linear",)), + ("U1", ("gmail",)), + ] + + +def test_an_unidentified_turn_gets_no_access_to_a_personal_toolkit(): + # The de-duplication above is unconditional. Naming a toolkit in + # COMPOSIO_USER_TOOLKITS is the operator saying it must run as the person, + # so an anonymous turn must not fall through to the shared account. + for actor in (None, "", " "): + scopes = resolve_scopes( + config(workspace_toolkits=("gmail",), user_toolkits=("gmail",)), + actor_id=actor, + ) + assert scopes == () + + +def test_a_shared_personal_app_warns_that_everyone_shares_one_account(): + warnings = startup_warnings(config(workspace_toolkits=("gmail",)), env={}) + assert any("Every Slack user will act through ONE account" in w for w in warnings) + + +def test_a_toolkit_configured_twice_warns_that_approvals_will_vary(): + warnings = startup_warnings( + config(workspace_toolkits=("linear",)), + env={"LINEAR_API_KEY": "lin_test"}, + ) + assert any("configured twice" in w for w in warnings) + + +def test_a_quiet_configuration_says_nothing(): + assert startup_warnings(config(workspace_toolkits=("jira",)), env={}) == () diff --git a/agent/tests/test_composio_sdk_contract.py b/agent/tests/test_composio_sdk_contract.py new file mode 100644 index 00000000..1fa7080c --- /dev/null +++ b/agent/tests/test_composio_sdk_contract.py @@ -0,0 +1,100 @@ +"""Do we call the installed SDK the way it is actually shaped? + +Three bugs in this feature came from the same place: the port carried the +TypeScript SDK's call shape, and hand-written fakes agreed with the port instead +of with Python. Every unit test passed while nothing worked against a live +project — a session response read as a dict returned nothing silently, and +`execute` took its arguments positionally where Python wants a keyword. + +A fake can only ever assert what its author believed. These tests read the real +installed classes, so an SDK upgrade that moves a parameter fails here rather +than in a thread. +""" + +from __future__ import annotations + +import inspect + +import pytest + +from composio.core.models.tool_router import ToolRouter +from composio.core.models.tool_router_session import ( + SessionSearchResponse, + ToolRouterSession, +) + +from composio_tools.sessions import Session + + +def parameters(method) -> dict[str, inspect.Parameter]: + return dict(inspect.signature(method).parameters) + + +def test_execute_takes_its_arguments_by_keyword(): + # The bug: `execute(slug, arguments)` raised "takes 2 positional arguments + # but 3 were given" only once a real call happened. + argument = parameters(ToolRouterSession.execute)["arguments"] + assert argument.kind is inspect.Parameter.KEYWORD_ONLY + + +def test_execute_names_the_slug_positionally(): + names = list(parameters(ToolRouterSession.execute)) + assert names[1] == "tool_slug" + assert ( + parameters(ToolRouterSession.execute)["tool_slug"].kind + is inspect.Parameter.POSITIONAL_OR_KEYWORD + ) + + +def test_search_takes_its_query_by_keyword(): + assert ( + parameters(ToolRouterSession.search)["query"].kind + is inspect.Parameter.KEYWORD_ONLY + ) + + +def test_authorize_names_the_toolkit_positionally(): + assert ( + parameters(ToolRouterSession.authorize)["toolkit"].kind + is inspect.Parameter.POSITIONAL_OR_KEYWORD + ) + + +def test_session_creation_accepts_what_we_pass_it(): + # `sandbox` disables the remote shell and remote Python tools, and `workbench` + # is its deprecated alias — passing both raises, so this must not silently + # become the wrong one. + names = parameters(ToolRouter.create) + assert names["user_id"].kind is inspect.Parameter.KEYWORD_ONLY + assert "sandbox" in names + assert "toolkits" in names + + +def test_our_protocol_matches_the_real_session(): + # The structural type our code is written against, checked member by member + # rather than trusted. + for name in ("search", "execute", "authorize", "toolkits"): + ours = parameters(getattr(Session, name)) + theirs = parameters(getattr(ToolRouterSession, name)) + for argument, declared in ours.items(): + if argument == "self": + continue + assert argument in theirs or argument == "slug", ( + f"Session.{name} declares {argument!r}, which " + f"ToolRouterSession.{name} does not accept" + ) + if argument in theirs: + assert declared.kind is theirs[argument].kind, ( + f"Session.{name}({argument}) is {declared.kind}, but the SDK " + f"wants {theirs[argument].kind}" + ) + + +@pytest.mark.parametrize( + "field", + ["results", "tool_schemas", "toolkit_connection_statuses"], +) +def test_the_search_response_still_carries_the_fields_we_read(field): + fields = getattr(SessionSearchResponse, "model_fields", None) + assert fields is not None, "the response stopped being a Pydantic model" + assert field in fields diff --git a/agent/tests/test_composio_sessions.py b/agent/tests/test_composio_sessions.py new file mode 100644 index 00000000..c3421243 --- /dev/null +++ b/agent/tests/test_composio_sessions.py @@ -0,0 +1,524 @@ +"""Session creation, caching, and what happens when one identity is unreachable.""" + +from __future__ import annotations + +import logging +import threading +import time + +import pytest + +import composio_tools.runtime as runtime_mod +import composio_tools.sessions as sessions_mod +from composio_tools.config import ComposioConfig, ComposioConfigError +from composio_tools.runtime import composio_runtime, reset_composio_runtime +from composio_tools.scopes import ResolvedScope +from composio_tools.sessions import MAX_SESSIONS, SessionCache + + +class FakeSession: + def __init__(self, user_id: str) -> None: + self.user_id = user_id + + +class FakeSessions: + def __init__(self, *, fail_for: set[str] | None = None) -> None: + self.calls: list[dict] = [] + self._fail_for = fail_for or set() + + def create(self, **kwargs): + self.calls.append(kwargs) + user_id = kwargs["user_id"] + if user_id in self._fail_for: + raise RuntimeError("no connected account") + return FakeSession(user_id) + + +class FakeComposio: + def __init__(self, **kwargs) -> None: + self.sessions = FakeSessions(**kwargs) + + +def config() -> ComposioConfig: + return ComposioConfig( + api_key="ak_test", + workspace_toolkits=("linear",), + user_toolkits=("gmail",), + approvals="on", + workspace_user_id="open-tag", + ) + + +def scope(user_id: str, *toolkits: str, personal: bool = False) -> ResolvedScope: + return ResolvedScope(user_id=user_id, toolkits=toolkits, personal=personal) + + +def test_a_session_disables_the_sandbox_explicitly(): + # A default session hands back a remote shell and a remote Python tool with + # no opt-in, and the SDK only defaults them off under one preset we do not + # use. + client = FakeComposio() + SessionCache(config(), client=client).for_scope(scope("open-tag", "linear")) + + assert client.sessions.calls[0]["sandbox"] == {"enable": False} + # `workbench` is a deprecated alias and passing both raises. + assert "workbench" not in client.sessions.calls[0] + + +def test_a_session_pins_the_auth_config_the_operator_named(): + # `COMPOSIO_AUTH_CONFIGS` exists to settle which credential a shared toolkit + # connects against when it has several. The connect script pinned it and the + # runtime did not, so a toolkit could be *connected* through the named + # config and then *used* through whichever one the project resolved on its + # own — the ambiguity, half-settled. + client = FakeComposio() + cfg = ComposioConfig( + api_key="ak_test", + workspace_toolkits=("linear", "notion"), + user_toolkits=(), + approvals="on", + workspace_user_id="open-tag", + auth_configs={"linear": "ac_ExAmPle1"}, + ) + + SessionCache(cfg, client=client).for_scope(scope("open-tag", "linear", "notion")) + + # Narrowed to the scope's own toolkits, and only the pinned one appears: + # a session is never told about a config for a toolkit it does not carry. + assert client.sessions.calls[0]["auth_configs"] == {"linear": "ac_ExAmPle1"} + + +def test_a_session_with_nothing_pinned_sends_no_auth_configs(): + # `None`, not `{}` — the SDK forwards the argument only when it is not None, + # and an empty mapping is a different thing to say than "no preference". + client = FakeComposio() + SessionCache(config(), client=client).for_scope(scope("open-tag", "linear")) + + assert client.sessions.calls[0]["auth_configs"] is None + + +def test_a_session_is_created_once_per_identity_and_toolkit_set(): + client = FakeComposio() + cache = SessionCache(config(), client=client) + + first = cache.for_scope(scope("U1", "gmail", personal=True)) + again = cache.for_scope(scope("U1", "gmail", personal=True)) + other = cache.for_scope(scope("U2", "gmail", personal=True)) + + assert first.session is again.session + assert other.session is not first.session + assert [call["user_id"] for call in client.sessions.calls] == ["U1", "U2"] + + +def test_a_different_toolkit_set_is_a_different_session(): + client = FakeComposio() + cache = SessionCache(config(), client=client) + + cache.for_scope(scope("U1", "gmail", personal=True)) + cache.for_scope(scope("U1", "gmail", "googlecalendar", personal=True)) + + assert len(client.sessions.calls) == 2 + + +def test_one_unreachable_identity_does_not_cost_the_others(caplog): + # A broken personal account must not take the team's shared toolkits down + # for the turn: fewer tools can still answer, an exception answers nothing. + client = FakeComposio(fail_for={"U1"}) + cache = SessionCache(config(), client=client) + + with caplog.at_level(logging.WARNING): + resolved = cache.resolve( + ( + scope("open-tag", "linear"), + scope("U1", "gmail", personal=True), + ) + ) + + assert [entry.scope.user_id for entry in resolved.sessions] == ["open-tag"] + assert "U1" in caplog.text + assert "gmail" in caplog.text + assert "no connected account" in caplog.text + + +def test_the_api_key_stays_out_of_the_failure_log(caplog): + client = FakeComposio(fail_for={"U1"}) + cache = SessionCache(config(), client=client) + + with caplog.at_level(logging.WARNING): + cache.resolve((scope("U1", "gmail", personal=True),)) + + assert "ak_test" not in caplog.text + + +def test_a_scope_that_was_dropped_is_reported_rather_than_silently_missing(): + # The caller's two answers are "you have no personal toolkits" and "your + # personal toolkits could not be reached this turn". Dropping the second + # into silence turns an outage into a settled fact about somebody's setup. + client = FakeComposio(fail_for={"U1"}) + cache = SessionCache(config(), client=client) + + resolved = cache.resolve( + (scope("open-tag", "linear"), scope("U1", "gmail", personal=True)) + ) + + assert [entry.scope.user_id for entry in resolved.sessions] == ["open-tag"] + assert [entry.scope.user_id for entry in resolved.dropped] == ["U1"] + assert "no connected account" in resolved.dropped[0].reason + + +def test_an_invalidated_session_is_rebuilt_on_the_next_use(): + # A session that has started failing keeps failing for as long as it is + # cached, so one stale session takes an identity out until the process + # restarts. Dropping it costs one round trip. + client = FakeComposio() + cache = SessionCache(config(), client=client) + + first = cache.for_scope(scope("U1", "gmail", personal=True)) + cache.invalidate(scope("U1", "gmail", personal=True)) + second = cache.for_scope(scope("U1", "gmail", personal=True)) + + assert second.session is not first.session + assert len(client.sessions.calls) == 2 + + +def test_invalidating_one_identity_leaves_the_others_alone(): + client = FakeComposio() + cache = SessionCache(config(), client=client) + + kept = cache.for_scope(scope("U2", "gmail", personal=True)) + cache.for_scope(scope("U1", "gmail", personal=True)) + cache.invalidate(scope("U1", "gmail", personal=True)) + + assert cache.for_scope(scope("U2", "gmail", personal=True)).session is kept.session + + +def test_invalidating_a_scope_that_was_never_cached_is_not_an_error(): + cache = SessionCache(config(), client=FakeComposio()) + + cache.invalidate(scope("nobody", "gmail", personal=True)) + + +def test_the_cache_is_bounded(): + # One session per person, and the process outlives every conversation. An + # unbounded map is a slow leak in any workspace bigger than a team. + client = FakeComposio() + cache = SessionCache(config(), client=client) + + for index in range(MAX_SESSIONS + 5): + cache.for_scope(scope(f"U{index}", "gmail", personal=True)) + + assert cache.size == MAX_SESSIONS + + +def test_the_least_recently_used_session_is_the_one_evicted(): + client = FakeComposio() + cache = SessionCache(config(), client=client) + + first = cache.for_scope(scope("U0", "gmail", personal=True)).session + for index in range(1, MAX_SESSIONS): + cache.for_scope(scope(f"U{index}", "gmail", personal=True)) + # Touching U0 makes it the most recent, so the next insert must evict U1. + assert cache.for_scope(scope("U0", "gmail", personal=True)).session is first + cache.for_scope(scope("LAST", "gmail", personal=True)) + + assert cache.for_scope(scope("U0", "gmail", personal=True)).session is first + assert cache.for_scope(scope("U1", "gmail", personal=True)).session is not None + assert [call["user_id"] for call in client.sessions.calls].count("U1") == 2 + + +def test_a_session_signature_break_is_not_reported_as_an_unreachable_account(caplog): + # `create` losing a keyword is a broken build. Logged as "no session for + # this user, running the turn without it" it reads as one person's account + # being unreachable, on every turn, forever. + class Breaking: + def __init__(self) -> None: + self.sessions = self + + def create(self, **kwargs): + raise TypeError("create() got an unexpected keyword argument 'sandbox'") + + cache = SessionCache(config(), client=Breaking()) + + with pytest.raises(TypeError): + cache.resolve((scope("open-tag", "linear"),)) + + +# The process-wide runtime that hands the graph and the connect route the *same* +# session cache. Two caches would mean two sessions per identity, so what this +# function answers — and when it answers from cache — is part of the same story +# as the cache itself. + + +@pytest.fixture(autouse=True) +def _clean_runtime(): + reset_composio_runtime() + yield + reset_composio_runtime() + + +def env(**overrides) -> dict[str, str]: + return { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + **overrides, + } + + +def test_the_runtime_is_built_once_for_the_same_arguments(): + first = composio_runtime(env(), default_user_id="open-tag") + again = composio_runtime(env(), default_user_id="open-tag") + + assert first is again + + +def test_a_different_environment_is_not_answered_from_the_first_one(): + # The arguments are not decoration. Answering the second call from the + # first one's environment hands back a runtime configured for toolkits the + # caller did not ask for — and the reason it is hard to see is that it is + # right the first time. + first = composio_runtime(env(), default_user_id="open-tag") + second = composio_runtime( + env(COMPOSIO_TOOLKITS="notion"), default_user_id="open-tag" + ) + + assert first.config.workspace_toolkits == ("linear",) + assert second.config.workspace_toolkits == ("notion",) + + +def test_a_different_default_user_id_is_not_answered_from_the_first_one(): + composio_runtime(env(), default_user_id="open-tag") + second = composio_runtime(env(), default_user_id="other-channel") + + assert second.config.workspace_user_id == "other-channel" + + +def test_an_unconfigured_deployment_is_still_answered_from_cache(monkeypatch): + # The `None` answer is cached too, so a deployment without Composio does not + # re-read the environment on every request to the connect route. + reads: list[int] = [] + real = runtime_mod.read_composio_config + + def counting(*args, **kwargs): + reads.append(1) + return real(*args, **kwargs) + + monkeypatch.setattr(runtime_mod, "read_composio_config", counting) + + assert composio_runtime({}, default_user_id="open-tag") is None + assert composio_runtime({}, default_user_id="open-tag") is None + assert len(reads) == 1 + + +def test_a_configuration_error_leaves_nothing_cached(): + broken = env(COMPOSIO_APPROVALS="sometimes") + + with pytest.raises(ComposioConfigError): + composio_runtime(broken, default_user_id="open-tag") + # Raised again rather than answered from a half-built cache, and a fixed + # environment is read rather than refused for the life of the process. + with pytest.raises(ComposioConfigError): + composio_runtime(broken, default_user_id="open-tag") + + assert composio_runtime(env(), default_user_id="open-tag") is not None + + +# Everything below runs the cache from more than one thread, because that is how +# it is actually used: LangChain runs a sync `@tool` in a threadpool, so two of a +# turn's tool calls resolve scopes at the same time, and FastAPI runs a sync +# route the same way, so two connect clicks land together. + + +def in_parallel(first, second, *, entered, release): + """Run `first`, wait until it is inside the guarded region, then `second`. + + Deterministic rather than timed: the second thread is not started until the + first is provably in the middle of building, so a build that is not guarded + always produces two, and one that is always produces one. + """ + results: dict[str, object] = {} + errors: list[BaseException] = [] + + def capture(name, work): + def run(): + try: + results[name] = work() + except BaseException as error: # noqa: BLE001 - reported below + errors.append(error) + + return run + + one = threading.Thread(target=capture("first", first)) + one.start() + assert entered.wait(5), "the first thread never reached the build" + + two = threading.Thread(target=capture("second", second)) + two.start() + # Long enough for an unguarded second thread to get past the check and into + # the build; a guarded one is still blocked and cannot use it. + time.sleep(0.1) + release.set() + + one.join(5) + two.join(5) + assert not errors, errors + return results + + +def test_two_threads_racing_for_the_runtime_get_one_runtime(monkeypatch): + # The graph builds this at import and the connect route builds it per + # request, and the module's own docstring says both must hold the *same* + # object: two session caches mean two sessions per identity. + entered = threading.Event() + release = threading.Event() + builds: list[int] = [] + real = runtime_mod.read_composio_config + + def slow(*args, **kwargs): + builds.append(1) + entered.set() + release.wait(5) + return real(*args, **kwargs) + + monkeypatch.setattr(runtime_mod, "read_composio_config", slow) + + def call(): + return composio_runtime(env(), default_user_id="open-tag") + + results = in_parallel(call, call, entered=entered, release=release) + + assert len(builds) == 1 + assert results["first"] is results["second"] + + +def test_two_threads_racing_for_one_scope_create_one_session(): + # A session is a remote object. Creating two and keeping one does not + # produce a duplicate cache entry — it produces a session on Composio's side + # that nothing will ever use or close. + entered = threading.Event() + release = threading.Event() + + class SlowSessions(FakeSessions): + def create(self, **kwargs): + entered.set() + release.wait(5) + return super().create(**kwargs) + + client = FakeComposio() + client.sessions = SlowSessions() + cache = SessionCache(config(), client=client) + + def call(): + return cache.for_scope(scope("U1", "gmail", personal=True)).session + + results = in_parallel(call, call, entered=entered, release=release) + + assert len(client.sessions.calls) == 1 + assert results["first"] is results["second"] + assert cache.size == 1 + + +def test_two_threads_racing_for_the_client_build_one_client(monkeypatch): + # One process, one client: the api key is read in one place and the effect + # map is handed the same object the sessions are made from. + entered = threading.Event() + release = threading.Event() + built: list[object] = [] + + def slow_client(**kwargs): + del kwargs + entered.set() + release.wait(5) + client = FakeComposio() + built.append(client) + return client + + monkeypatch.setattr(sessions_mod, "Composio", slow_client) + cache = SessionCache(config()) + call = cache.client + + results = in_parallel(call, call, entered=entered, release=release) + + assert len(built) == 1 + assert results["first"] is results["second"] + + +def test_a_failed_build_leaves_no_lock_behind(): + # The per-key locks are keyed by identity, so a process serving a whole + # workspace mints one per person. Kept after a failed build they are a slow + # leak of the one thing in here that is never evicted. + cache = SessionCache(config(), client=FakeComposio(fail_for={"U1"})) + + for _ in range(3): + with pytest.raises(RuntimeError): + cache.for_scope(scope("U1", "gmail", personal=True)) + + assert cache._building == {} + + +def test_a_failed_build_can_be_retried(): + cache = SessionCache(config(), client=FakeComposio(fail_for={"U1"})) + + with pytest.raises(RuntimeError): + cache.for_scope(scope("U1", "gmail", personal=True)) + cache._client.sessions._fail_for = set() + + assert cache.for_scope(scope("U1", "gmail", personal=True)).session is not None + + +def test_retry_waiters_keep_one_build_lock_after_a_failure(monkeypatch): + from concurrent.futures import ThreadPoolExecutor + + first_started = threading.Event() + fail_first = threading.Event() + retry_started = threading.Event() + release_retry = threading.Event() + waiting = [threading.Event() for _ in range(3)] + build_locks = [] + attempts = [] + + class RetryingSessions: + def create(self, **kwargs): + attempts.append(kwargs) + if len(attempts) == 1: + first_started.set() + assert fail_first.wait(5) + raise RuntimeError("temporary outage") + retry_started.set() + assert release_retry.wait(5) + return FakeSession(kwargs["user_id"]) + + client = FakeComposio() + client.sessions = RetryingSessions() + cache = SessionCache(config(), client=client) + real_build_lock = cache._build_lock + + def observe_waiter(key): + lock = real_build_lock(key) + build_locks.append(lock) + waiting[len(build_locks) - 1].set() + return lock + + monkeypatch.setattr(cache, "_build_lock", observe_waiter) + target = scope("U1", "gmail", personal=True) + with ThreadPoolExecutor(max_workers=3) as pool: + try: + first = pool.submit(cache.for_scope, target) + assert first_started.wait(5) + second = pool.submit(cache.for_scope, target) + assert waiting[1].wait(5) + fail_first.set() + with pytest.raises(RuntimeError, match="temporary outage"): + first.result(timeout=5) + assert retry_started.wait(5) + third = pool.submit(cache.for_scope, target) + assert waiting[2].wait(5) + # The newcomer must queue behind the retry already in progress. + # A new lock here lets two remote sessions be built concurrently. + assert build_locks[0] is build_locks[1] is build_locks[2] + finally: + fail_first.set() + release_retry.set() + + assert second.result(timeout=5).session is third.result(timeout=5).session + assert len(attempts) == 2 + assert cache._building == {} diff --git a/agent/tests/test_composio_tools.py b/agent/tests/test_composio_tools.py new file mode 100644 index 00000000..a60c2ba0 --- /dev/null +++ b/agent/tests/test_composio_tools.py @@ -0,0 +1,1302 @@ +"""Discovery and execution, and whose account each one happens in.""" + +from __future__ import annotations + +import asyncio +import logging + +import pytest +from ag_ui.core import EventType, RunAgentInput +from copilotkit import CopilotKitMiddleware +from deepagents import create_deep_agent +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langgraph.checkpoint.memory import MemorySaver + +import composio_tools.tools as tools_mod +from agui import build_agui_agent +from composio_tools.config import ComposioConfig, read_composio_config +from composio_tools.effects import EffectMap +from composio_tools.scopes import ResolvedScope +from composio_tools.sessions import SessionCache +from composio_tools.state import ComposioAgentState +from composio_tools.tools import build_composio_tools, humanize_slug, owns_slug + +SCHEMA = {"type": "object", "properties": {}} + + +class Model: + """Stands in for an SDK response. + + A plain dict would not have caught the bug this exists for: the Python SDK + answers with Pydantic models, reading one as a dict returns nothing and + raises nothing, and discovery came back empty against a live project while + every dict-shaped test passed. + """ + + def __init__(self, payload): + self._payload = payload + + def model_dump(self): + return self._payload + + +def search_response( + *slugs, + schema=SCHEMA, + statuses=None, + success=True, + error=None, + result_error=None, +): + """A search response shaped like `SessionSearchResponse`. + + `success` and `error` are top-level fields of the real model and + `result_error` is `Result.error`; all three say a search failed, and a + response that carries no candidates *because* it failed must never read as + "no tools found". + """ + return Model( + { + # snake_case, as the Python SDK emits. + "success": success, + "error": error, + "results": [ + {"primary_tool_slugs": list(slugs), "error": result_error} + ], + "tool_schemas": { + slug: {"description": f"{slug} does a thing", "input_schema": schema} + for slug in slugs + }, + **({"toolkit_connection_statuses": statuses} if statuses else {}), + } + ) + + +class FakeSession: + def __init__( + self, + user_id, + response=None, + result=None, + fail_search=False, + search_error=None, + execute_error=None, + ): + self.user_id = user_id + self._response = response if response is not None else search_response() + self._result = result if result is not None else {"data": {"ok": True}} + self._fail_search = fail_search + self._search_error = search_error + self._execute_error = execute_error + self.executed: list[tuple[str, dict]] = [] + + def search(self, *, query): + if self._search_error is not None: + raise self._search_error + if self._fail_search: + raise RuntimeError("scope unreachable") + return self._response + + def execute(self, slug, *, arguments): + self.executed.append((slug, arguments)) + if self._execute_error is not None: + raise self._execute_error + return self._result + + def authorize(self, toolkit): + raise NotImplementedError + + def toolkits(self): + raise NotImplementedError + + +class FakeComposio: + def __init__(self, sessions_by_user): + self.sessions = self + self._by_user = sessions_by_user + self.created: list[str] = [] + self.kwargs: list[dict] = [] + + def create(self, *, user_id, **kwargs): + self.created.append(user_id) + self.kwargs.append(kwargs) + return self._by_user[user_id] + + +def config(**overrides) -> ComposioConfig: + """A config as `read_composio_config` would return it. + + `approvals` is `"on"` because that is the only gating mode the parser can + now produce; `destructive` and `writes` are spellings it folds into it. A + fixture writing a folded spelling straight into the dataclass tests a value + no deployment can hold, and it goes on passing after the parser stops + producing it. + """ + defaults = { + "api_key": "ak_test", + "workspace_toolkits": ("linear",), + "user_toolkits": ("gmail",), + "approvals": "on", + "workspace_user_id": "open-tag", + } + return ComposioConfig(**{**defaults, **overrides}) + + +def parsed_config(approvals: str) -> ComposioConfig: + """A config built the way a deployment builds one — through the parser.""" + parsed = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_USER_TOOLKITS": "gmail", + "COMPOSIO_APPROVALS": approvals, + }, + default_user_id="open-tag", + ) + assert parsed is not None + return parsed + + +def test_the_fixture_matches_what_the_parser_produces(): + # The guard on the fixture above. Pinned by hand, it drifted once already: + # it held `destructive` for a while after `destructive` stopped being a + # value any deployment could have. + assert config() == parsed_config("") + + +class FakeEffects: + """Effects without a lookup. + + Destructive by default, because that is what production answers for a slug + nobody classified. A fake that defaults to `read` inverts the fail-safe and + lets a test walk straight past a gate the real thing would have closed — a + test asserting a call ran would then pass whether or not the gate worked. + """ + + def __init__(self, effects=None, default="destructive"): + self._effects = effects or {} + self._default = default + self.asked: list[str] = [] + + def effect_for(self, slug): + self.asked.append(slug) + return self._effects.get(slug, self._default) + + +def tools_for(sessions_by_user, cfg=None, effects=None): + cfg = cfg or config() + client = FakeComposio(sessions_by_user) + built = { + tool.name: tool + for tool in build_composio_tools( + cfg, SessionCache(cfg, client=client), effects or FakeEffects() + ) + } + return built["search_my_tools"], built["run_my_tool"], client + + +def all_tools(cfg, sessions_by_user=None, effects=None): + client = FakeComposio(sessions_by_user or {}) + return [ + tool.name + for tool in build_composio_tools( + cfg, SessionCache(cfg, client=client), effects or FakeEffects() + ) + ] + + +def state(actor_id=None, platform="slack"): + if actor_id is None: + return {} + return {"channel_actor": {"id": actor_id, "kind": "human", "platform": platform}} + + +def test_an_anonymous_turn_reaches_only_the_shared_account(): + shared = FakeSession("open-tag", search_response("LINEAR_CREATE_ISSUE")) + search, _run, client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "file a bug", "state": state()}) + + assert client.created == ["open-tag"] + assert [entry["slug"] for entry in result["tools"]] == ["LINEAR_CREATE_ISSUE"] + + +def test_an_identified_turn_also_reaches_that_person(): + shared = FakeSession("open-tag", search_response("LINEAR_CREATE_ISSUE")) + personal = FakeSession("slack:U1", search_response("GMAIL_SEND_EMAIL")) + search, _run, client = tools_for({"open-tag": shared, "slack:U1": personal}) + + result = search.invoke({"query": "email the team", "state": state("U1")}) + + assert client.created == ["open-tag", "slack:U1"] + assert {entry["slug"] for entry in result["tools"]} == { + "LINEAR_CREATE_ISSUE", + "GMAIL_SEND_EMAIL", + } + + +@pytest.mark.parametrize( + "actor", ["U1", {"kind": "human"}, {"id": ""}, {"id": 7}, None] +) +def test_a_malformed_actor_is_treated_as_anonymous(actor): + # The value crosses a process boundary. Refusing personal access is the safe + # failure; granting it on a shape we do not recognise is not. + # + # One cache per actor, on purpose. Sharing one across the whole set left + # `created` empty from the second actor on — the session was cached, not the + # turn dropped — and the `in ([], ["open-tag"])` that papered over that + # could no longer tell an ignored actor from a turn that resolved nothing. + shared = FakeSession("open-tag", search_response("LINEAR_CREATE_ISSUE")) + search, _run, client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "x", "state": {"channel_actor": actor}}) + + # The shared account, exactly once, and a turn that really did search it. + assert client.created == ["open-tag"] + assert [entry["slug"] for entry in result["tools"]] == ["LINEAR_CREATE_ISSUE"] + + +def test_a_chatty_shared_scope_cannot_crowd_out_the_person_asking(): + # Scopes arrive shared-first and the cap is global, so concatenating would + # answer "what's on my calendar" with five Linear tools. + shared = FakeSession( + "open-tag", + search_response(*[f"LINEAR_TOOL_{index}" for index in range(8)]), + ) + personal = FakeSession("slack:U1", search_response("GMAIL_SEND_EMAIL")) + search, _run, _client = tools_for({"open-tag": shared, "slack:U1": personal}) + + result = search.invoke({"query": "email", "state": state("U1")}) + + assert "GMAIL_SEND_EMAIL" in [entry["slug"] for entry in result["tools"]] + + +def test_a_schemaless_candidate_never_displaces_a_callable_one(): + shared = FakeSession( + "open-tag", + Model( + { + "results": [ + {"primary_tool_slugs": ["LINEAR_NO_SCHEMA", "LINEAR_OK"]} + ], + "tool_schemas": { + "LINEAR_NO_SCHEMA": {"description": "unusable"}, + "LINEAR_OK": {"description": "usable", "input_schema": SCHEMA}, + }, + } + ), + ) + search, _run, _client = tools_for({"open-tag": shared}) + + slugs = [entry["slug"] for entry in search.invoke({"query": "x", "state": state()})["tools"]] + + assert slugs == ["LINEAR_OK", "LINEAR_NO_SCHEMA"] + + +def test_only_an_explicit_false_asks_someone_to_connect(): + shared = FakeSession( + "open-tag", + search_response( + "LINEAR_OK", + statuses=[ + {"toolkit": "linear", "has_active_connection": False}, + {"toolkit": "jira"}, + ], + ), + ) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "x", "state": state()}) + + assert result["needsConnection"] == ["linear"] + + +def test_one_unreachable_scope_costs_only_its_own_candidates(caplog): + shared = FakeSession("open-tag", search_response("LINEAR_OK")) + personal = FakeSession("slack:U1", fail_search=True) + search, _run, _client = tools_for({"open-tag": shared, "slack:U1": personal}) + + with caplog.at_level(logging.WARNING): + result = search.invoke({"query": "x", "state": state("U1")}) + + assert [entry["slug"] for entry in result["tools"]] == ["LINEAR_OK"] + assert "scope unreachable" in caplog.text + + +def test_a_failed_search_is_not_reported_as_no_tools_found(): + # `success: False` is the response saying the search itself did not run. + # Answering "no tools found" tells the model the apps have nothing to offer, + # and the model then explains that to a person as a settled fact. + shared = FakeSession( + "open-tag", + search_response(success=False, error="1 out of 1 searches failed: upstream 500"), + ) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "x", "state": state()}) + + assert isinstance(result, str), result + assert "upstream 500" in result + assert "failed" in result.lower() + + +def test_a_top_level_search_error_is_a_failure(): + shared = FakeSession("open-tag", search_response("LINEAR_OK", error="quota exceeded")) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "x", "state": state()}) + + assert isinstance(result, str), result + assert "quota exceeded" in result + + +def test_a_per_query_search_error_is_a_failure(): + # `Result.error` is per query and we send exactly one, so a query that + # failed is the whole search failing for that scope. + shared = FakeSession( + "open-tag", search_response(result_error="index unavailable") + ) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "x", "state": state()}) + + assert isinstance(result, str), result + assert "index unavailable" in result + + +def test_an_unreadable_search_response_is_a_failure(): + # Neither a dict nor a model that dumps to one. `_as_dict` answers `{}` for + # this, which is indistinguishable from a response that found nothing. + shared = FakeSession("open-tag", "not a response at all") + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "x", "state": state()}) + + assert isinstance(result, str), result + assert "failed" in result.lower() or "could not" in result.lower() + + +def test_every_scope_failing_is_not_an_empty_success(): + shared = FakeSession("open-tag", fail_search=True) + personal = FakeSession("slack:U1", fail_search=True) + search, _run, _client = tools_for({"open-tag": shared, "slack:U1": personal}) + + result = search.invoke({"query": "x", "state": state("U1")}) + + assert isinstance(result, str), result + assert "scope unreachable" in result + + +def test_a_partial_search_failure_is_named_alongside_what_did_come_back(): + # One scope answering is not the same as every scope answering, and the + # difference is exactly "your Gmail was not searched". + shared = FakeSession("open-tag", search_response("LINEAR_OK")) + personal = FakeSession("slack:U1", fail_search=True) + search, _run, _client = tools_for({"open-tag": shared, "slack:U1": personal}) + + result = search.invoke({"query": "x", "state": state("U1")}) + + assert [entry["slug"] for entry in result["tools"]] == ["LINEAR_OK"] + assert result["searchFailures"], result + + +def test_a_search_signature_break_is_not_swallowed_as_an_outage(): + # An SDK that renamed a parameter is a broken deployment, not one scope + # having a bad day. Logged as an outage and skipped, it reads as "that app + # is down" forever. + shared = FakeSession( + "open-tag", + search_error=TypeError("search() got an unexpected keyword argument 'query'"), + ) + search, _run, _client = tools_for({"open-tag": shared}) + + with pytest.raises(TypeError): + search.invoke({"query": "x", "state": state()}) + + +def test_a_scope_that_could_not_be_reached_is_not_called_a_missing_setup(): + # "Connected apps are not configured for you" is a statement about somebody's + # setup. A session that failed to build is an outage, and telling a person to + # go and connect an app they already connected is the wrong instruction. + search, _run, _client = tools_for({}) # every `create` raises KeyError + + result = search.invoke({"query": "x", "state": state()}) + + assert isinstance(result, str), result + assert "not configured" not in result + assert "could not" in result.lower() or "failed" in result.lower() + + +def test_running_a_tool_when_no_scope_could_be_reached_says_so(): + _search, run, _client = tools_for({}) + + result = run.invoke({"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()}) + + assert isinstance(result, str), result + assert "not configured" not in result + + +def test_a_failed_search_drops_the_session_so_the_next_turn_gets_a_fresh_one(): + # A session that has started failing keeps failing while it is cached, so + # one bad session takes an identity out until the process restarts. + shared = FakeSession("open-tag", fail_search=True) + search, _run, client = tools_for({"open-tag": shared}) + + search.invoke({"query": "x", "state": state()}) + search.invoke({"query": "x", "state": state()}) + + assert client.created == ["open-tag", "open-tag"] + + +def test_a_call_runs_in_the_account_that_owns_its_toolkit(): + shared = FakeSession("open-tag") + personal = FakeSession("slack:U1") + # Classified read on purpose: this test is about whose account runs the + # call, and an ungated one keeps the gate out of the way of that question. + _search, run, _client = tools_for( + {"open-tag": shared, "slack:U1": personal}, + effects=FakeEffects(default="read"), + ) + + run.invoke( + {"slug": "GMAIL_SEND_EMAIL", "arguments": {"to": "a@b.c"}, "state": state("U1")} + ) + + assert personal.executed == [("GMAIL_SEND_EMAIL", {"to": "a@b.c"})] + assert shared.executed == [] + + +def test_an_unplaceable_slug_is_refused_rather_than_run_as_the_shared_account(): + # Without prefix matching this falls to the first scope, which does not + # carry the toolkit at all. + shared = FakeSession("open-tag") + _search, run, _client = tools_for({"open-tag": shared}) + + result = run.invoke({"slug": "DROPBOX_DELETE", "arguments": {}, "state": state()}) + + assert "No connected app here provides DROPBOX_DELETE" in result + assert shared.executed == [] + + +def test_a_personal_slug_is_refused_on_an_anonymous_turn(): + shared = FakeSession("open-tag") + _search, run, _client = tools_for({"open-tag": shared}) + + result = run.invoke({"slug": "GMAIL_SEND_EMAIL", "arguments": {}, "state": state()}) + + # Refused, and named for what it is. "No connected app provides it" would be + # a claim about a setup that is very likely fine — see + # `test_a_personal_slug_on_an_anonymous_turn_is_not_called_a_missing_app`. + assert "GMAIL_SEND_EMAIL" in result + assert shared.executed == [] + + +def test_a_reported_failure_is_a_failure(caplog): + # `execute` reports a failed tool in `error` and does not raise, so a + # try/except alone reads every failed write as a success. + shared = FakeSession( + "open-tag", + result={"data": None, "error": "Invalid request data provided", "logId": "log_1"}, + ) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) + + with caplog.at_level(logging.WARNING): + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + assert "failed" in result + assert "Invalid request data provided" in result + assert "log_1" in caplog.text + + +def test_a_successful_call_returns_its_data(): + shared = FakeSession("open-tag", result={"data": {"id": "ISS-1"}, "error": None}) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) + + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + assert result == {"id": "ISS-1"} + + +class Reports: + """Stands in for the message the thread gets when a confirmed write fails.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + + def __call__(self, action, error): + self.calls.append((action, error)) + + +def approved(monkeypatch): + """Approve every card, and record what the thread was told afterwards.""" + monkeypatch.setattr(tools_mod, "require_write_confirmation", Recorder(approve=True)) + reports = Reports() + monkeypatch.setattr(tools_mod, "emit_write_failure", reports) + return reports + + +def test_a_raising_execute_does_not_escape_after_the_approval_is_spent(monkeypatch): + # The only unguarded provider call, and it runs *after* the person has + # approved. A raise here ends the turn with the card's last word still + # "running", so the approver cannot tell an outage from a completed action. + shared = FakeSession("open-tag", execute_error=RuntimeError("gateway timeout")) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}) + ) + reports = approved(monkeypatch) + + result = run.invoke( + {"slug": "LINEAR_DELETE_ISSUE", "arguments": {}, "state": state()} + ) + + assert "gateway timeout" in result + assert reports.calls == [("Delete issue (Linear)", "gateway timeout")] + + +def test_an_approved_call_that_reports_a_failure_tells_the_thread(monkeypatch): + # The card is the last thing the person saw. Told nothing, they read it as + # done — and the label has to be the one the card carried, not the slug, + # because the slug is not what they approved. + shared = FakeSession( + "open-tag", result={"data": None, "error": "Invalid request", "log_id": "l1"} + ) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}) + ) + reports = approved(monkeypatch) + + result = run.invoke( + {"slug": "LINEAR_DELETE_ISSUE", "arguments": {}, "state": state()} + ) + + assert reports.calls == [("Delete issue (Linear)", "Invalid request")] + # The model keeps the slug, which is the handle it calls things by. + assert "LINEAR_DELETE_ISSUE" in result + + +def test_a_failure_nobody_approved_is_not_announced_in_the_thread(monkeypatch): + # An ungated read that fails is the model's problem to explain. Announcing + # it would put a warning in the thread for something nobody was asked about. + shared = FakeSession("open-tag", result={"data": None, "error": "nope"}) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) + reports = approved(monkeypatch) + + result = run.invoke( + {"slug": "LINEAR_LIST_ISSUES", "arguments": {}, "state": state()} + ) + + assert "nope" in result + assert reports.calls == [] + + +def test_an_unreadable_execute_result_is_not_a_success(): + # `_as_dict` answers `{}` for a shape it does not know, and `{}` reads as + # "no error, no data" — a success carrying nothing. + shared = FakeSession("open-tag", result="the tool ran, probably") + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) + + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + assert isinstance(result, str), result + assert "LINEAR_CREATE_ISSUE" in result + assert "failed" in result.lower() or "cannot read" in result.lower() + + +class AttributeResult: + """A result that answers by attribute rather than by `model_dump`.""" + + def __init__(self, data=None, error=None): + self.data = data + self.error = error + self.log_id = "log_7" + + +def test_an_attribute_shaped_result_is_read_as_plain_data(): + # The attribute branch used to hand `data` back untouched, so a nested SDK + # model reached the model as an object whose repr was all it could see. + shared = FakeSession("open-tag", result=AttributeResult(data=Model({"id": "ISS-1"}))) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) + + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + assert result == {"id": "ISS-1"} + + +def test_an_attribute_shaped_failure_is_still_a_failure(caplog): + shared = FakeSession("open-tag", result=AttributeResult(error="Invalid request")) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) + + with caplog.at_level(logging.WARNING): + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + assert "Invalid request" in result + assert "log_7" in caplog.text + + +def test_an_execute_signature_break_is_not_reported_as_a_failed_tool(monkeypatch): + # A renamed parameter is a broken build. Reported to the model as "the tool + # failed" it becomes something the model retries, forever. + shared = FakeSession( + "open-tag", + execute_error=TypeError("execute() got an unexpected keyword argument"), + ) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}) + ) + reports = approved(monkeypatch) + + with pytest.raises(TypeError): + run.invoke({"slug": "LINEAR_DELETE_ISSUE", "arguments": {}, "state": state()}) + + # The person is still looking at a card that says the action is running. + assert reports.calls and reports.calls[0][0] == "Delete issue (Linear)" + + +def test_a_result_that_cannot_be_dumped_says_so(caplog): + # An empty `except: pass` here turned a model that refused to dump into an + # empty result, which is the same silence this whole path exists to remove. + class Refuses: + def model_dump(self): + raise ValueError("cannot serialise") + + with caplog.at_level(logging.WARNING): + plain = tools_mod._plain(Refuses()) + + assert isinstance(plain, Refuses) + assert "cannot serialise" in caplog.text + + +@pytest.mark.parametrize( + ("toolkits", "slug", "expected"), + [ + (("gmail",), "GMAIL_SEND_EMAIL", True), + (("googlecalendar",), "GOOGLECALENDAR_EVENTS_LIST", True), + (("gmail",), "GMAILX_SEND", False), + (("gmail",), "LINEAR_CREATE_ISSUE", False), + ((), "GMAIL_SEND_EMAIL", False), + ], +) +def test_owns_slug(toolkits, slug, expected): + assert owns_slug(toolkits, slug) is expected + + +class Recorder: + """Stands in for the approval pause, recording what the card was asked.""" + + def __init__(self, approve: bool) -> None: + self.approve = approve + self.calls: list[dict] = [] + + def __call__(self, *, action, fields, extra_args=None): + self.calls.append( + {"action": action, "fields": fields, "extra_args": extra_args or {}} + ) + return self.approve + + +def test_a_destructive_call_waits_for_approval_before_running(monkeypatch): + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}), + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "LINEAR_DELETE_ISSUE", "arguments": {"id": "ISS-1"}, "state": state()}) + + assert len(recorder.calls) == 1 + assert recorder.calls[0]["action"] == "Delete issue (Linear)" + assert shared.executed == [("LINEAR_DELETE_ISSUE", {"id": "ISS-1"})] + + +def test_a_declined_call_does_not_run(monkeypatch): + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}), + ) + monkeypatch.setattr(tools_mod, "require_write_confirmation", Recorder(approve=False)) + + result = run.invoke( + {"slug": "LINEAR_DELETE_ISSUE", "arguments": {}, "state": state()} + ) + + assert "declined" in result + assert shared.executed == [] + + +def test_a_read_is_never_gated(monkeypatch): + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects({"LINEAR_LIST_ISSUES": "read"}) + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "LINEAR_LIST_ISSUES", "arguments": {}, "state": state()}) + + assert recorder.calls == [] + assert shared.executed == [("LINEAR_LIST_ISSUES", {})] + + +def test_the_approval_mode_decides_whether_a_write_is_gated(monkeypatch): + # `destructive` and `writes` are the old spellings; both now mean `on`, so + # the same write is gated under all three and only `off` lets it through. + # + # Built through the parser, because that is the only place the old + # spellings survive — writing one into the dataclass would assert on a + # value no deployment can hold. + for mode, gated in ( + ("off", False), + ("on", True), + ("destructive", True), + ("writes", True), + ): + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + cfg=parsed_config(mode), + effects=FakeEffects({"LINEAR_CREATE_ISSUE": "write"}), + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()}) + + assert bool(recorder.calls) is gated, mode + + +def test_only_the_person_whose_account_it_is_may_approve(monkeypatch): + # A personal call spends one person's access, so a colleague clicking + # approve would spend somebody else's. The agent names the approver; the + # surface, which knows who clicked, enforces it. + personal = FakeSession("slack:U1") + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared, "slack:U1": personal}, + effects=FakeEffects({"GMAIL_SEND_EMAIL": "write"}), + cfg=parsed_config("writes"), + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "GMAIL_SEND_EMAIL", "arguments": {}, "state": state("U1")}) + + assert recorder.calls[0]["extra_args"]["approver"] == "slack:U1" + + +def test_a_shared_call_names_no_particular_approver(monkeypatch): + # A shared account is the team's, so anyone who can see the card may answer. + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + effects=FakeEffects({"LINEAR_CREATE_ISSUE": "write"}), + cfg=parsed_config("writes"), + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state("U1")}) + + assert recorder.calls[0]["extra_args"]["approver"] is None + + +def test_an_unplaceable_slug_is_refused_before_anything_is_classified(): + # Refusing first keeps a hallucinated slug from costing a lookup, and keeps + # the person from being asked to approve a call that could never run. + effects = FakeEffects() + shared = FakeSession("open-tag") + _search, run, _client = tools_for({"open-tag": shared}, effects=effects) + + run.invoke({"slug": "DROPBOX_DELETE", "arguments": {}, "state": state()}) + + assert effects.asked == [] + + +@pytest.mark.parametrize( + ("slug", "expected"), + [ + # Verb first: the approval card labels its confirm button with the + # leading word, so leading with the toolkit gives every Gmail action a + # button reading "Gmail". + ("GMAIL_SEND_EMAIL", "Send email (Gmail)"), + ("GOOGLECALENDAR_EVENTS_LIST", "Events list (Googlecalendar)"), + ("LINEAR", "Linear"), + ], +) +def test_humanize_slug(slug, expected): + assert humanize_slug(slug) == expected + + +def test_the_composio_identity_is_namespaced_by_platform(): + # A provider id is unique only within its provider. Without the namespace, + # `U1` on Slack and `U1` on Teams share one Composio identity, and therefore + # each other's connected accounts. + slack_person = FakeSession("slack:U1", search_response("GMAIL_SEND_EMAIL")) + teams_person = FakeSession("teams:U1", search_response("GMAIL_SEND_EMAIL")) + shared = FakeSession("open-tag", search_response()) + search, _run, client = tools_for( + {"open-tag": shared, "slack:U1": slack_person, "teams:U1": teams_person} + ) + + search.invoke({"query": "x", "state": state("U1", platform="slack")}) + search.invoke({"query": "x", "state": state("U1", platform="teams")}) + + assert "slack:U1" in client.created + assert "teams:U1" in client.created + + + + +class UntaggedTool: + """A tool the SDK found, carrying the empty tag list it defaults to.""" + + def __init__(self, slug: str) -> None: + self.slug = slug + self.tags: list[str] = [] + + +class UntaggedTools: + """A live-shaped client whose tools exist and carry no behaviour tag.""" + + def __init__(self) -> None: + self.tools = self + self.asked: list[str] = [] + + def get_raw_composio_tool_by_slug(self, slug): + self.asked.append(slug) + return UntaggedTool(slug) + + +def test_a_found_but_untagged_call_is_gated_in_the_default_mode(monkeypatch): + # The gate's whole point. Composio returned the tool and said nothing about + # what it does, and the default mode gates everything that is not a + # classified read — so an untagged tool called anything less than + # destructive is an unapproved write against somebody's real account. + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + cfg=parsed_config(""), + effects=EffectMap(lambda: UntaggedTools()), + ) + recorder = Recorder(approve=False) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {"title": "x"}, "state": state()} + ) + + assert len(recorder.calls) == 1, "an untagged tool must not run unapproved" + assert shared.executed == [] + assert "declined" in result + + +def test_the_card_carries_the_classified_effect(monkeypatch): + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}), + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "LINEAR_DELETE_ISSUE", "arguments": {}, "state": state()}) + + assert recorder.calls[0]["extra_args"]["effect"] == "destructive" + + +def test_the_card_names_the_action_verb_first_not_the_app(monkeypatch): + # The card labels its confirm button with the action's leading word. Leading + # with the toolkit gives every Gmail action a button reading "Gmail", and + # hides the verb that decides whether the action is destructive. + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}), + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "LINEAR_DELETE_ISSUE", "arguments": {}, "state": state()}) + + assert recorder.calls[0]["action"].split()[0] == "Delete" + + +def test_a_session_carries_no_connection_management_tools(): + # The agent has its own connect flow, which binds a connection to the actor + # the platform verified. A session that can manage connections hands the + # model a second, unverified path to the same thing. + shared = FakeSession("open-tag") + client = FakeComposio({"open-tag": shared}) + cache = SessionCache(config(), client=client) + + cache.for_scope( + ResolvedScope(user_id="open-tag", toolkits=("linear",), personal=False) + ) + + assert client.kwargs[0]["manage_connections"] is False + + +def with_extra_keys(response, **extra): + """The same response, plus keys the SDK never declared. + + `composio_client` models set `extra='allow'` and are built by + `construct_type`, so a payload carrying the TypeScript SDK's camelCase + spellings keeps those *and* the declared snake_case fields. Both are then + readable, and only one of them is the response's real answer. + """ + return Model({**response.model_dump(), **extra}) + + +def test_a_passthrough_camel_case_key_does_not_strip_the_schemas(): + # A camelCase twin that is not the response's answer — it names another + # slug entirely. Read first, it answered `inputSchema: null` for every real + # candidate: uncallable by this function's own account, shipped anyway, and + # the model then guesses arguments. + shared = FakeSession( + "open-tag", + with_extra_keys( + search_response("LINEAR_CREATE_ISSUE"), + toolSchemas={"SOMETHING_ELSE": {"description": "camel twin"}}, + ), + ) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "file a bug", "state": state()}) + + assert [entry["slug"] for entry in result["tools"]] == ["LINEAR_CREATE_ISSUE"] + assert [entry["inputSchema"] for entry in result["tools"]] == [SCHEMA] + + +def test_a_passthrough_camel_case_key_does_not_strip_the_candidates(): + shared = FakeSession( + "open-tag", + with_extra_keys( + search_response("LINEAR_CREATE_ISSUE"), + results=[ + { + "primaryToolSlugs": ["SOMETHING_ELSE"], + "primary_tool_slugs": ["LINEAR_CREATE_ISSUE"], + } + ], + ), + ) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "file a bug", "state": state()}) + + assert [entry["slug"] for entry in result["tools"]] == ["LINEAR_CREATE_ISSUE"] + + +def test_a_passthrough_camel_case_key_does_not_hide_a_missing_connection(): + shared = FakeSession( + "open-tag", + with_extra_keys( + search_response( + "LINEAR_CREATE_ISSUE", + statuses=[{"toolkit": "linear", "has_active_connection": False}], + ), + toolkitConnectionStatuses=[{"toolkit": "linear"}], + ), + ) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "file a bug", "state": state()}) + + assert result["needsConnection"] == ["linear"] + + +def test_a_structured_search_error_is_still_a_failure(): + # `error` is declared `Optional[str]` and is not type-checked at runtime, so + # a structured provider error arrives as a mapping. Read with `isinstance` + # alone it counts as no error at all, and an outage reaches the model as an + # empty tool list — which it reports to a person as "you have no tool for + # that". + shared = FakeSession( + "open-tag", + search_response(error={"message": "upstream 500", "code": 502}), + ) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "x", "state": state()}) + + assert isinstance(result, str), result + assert "upstream 500" in result + + +def test_a_structured_per_query_search_error_is_still_a_failure(): + shared = FakeSession( + "open-tag", + search_response("LINEAR_OK", result_error={"message": "index unavailable"}), + ) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "x", "state": state()}) + + assert isinstance(result, str), result + assert "index unavailable" in result + + +def test_a_reported_search_failure_drops_the_session_too(): + # A session that reports failures keeps reporting them while it is cached, + # so the bad session is reused for every later turn. A raised failure + # already drops it; a reported one is the same session in the same state. + shared = FakeSession( + "open-tag", search_response(success=False, error="upstream 500") + ) + search, _run, client = tools_for({"open-tag": shared}) + + search.invoke({"query": "x", "state": state()}) + search.invoke({"query": "x", "state": state()}) + + assert client.created == ["open-tag", "open-tag"] + + +def test_an_execution_that_says_it_failed_is_a_failure_without_an_error(): + # `successful` is the execution envelope's own verdict. Branching on `error` + # alone hands `data` back as a success whenever the provider reports the + # failure in the flag and leaves the message null. + shared = FakeSession( + "open-tag", + result={"data": {"partial": True}, "error": None, "successful": False}, + ) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) + + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + assert isinstance(result, str), result + assert "failed" in result.lower() + + +def test_a_successful_execution_is_not_turned_into_a_failure(): + shared = FakeSession( + "open-tag", result={"data": {"id": "ISS-1"}, "error": None, "successful": True} + ) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) + + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + assert result == {"id": "ISS-1"} + + +def test_an_execution_that_never_mentions_the_flag_is_still_a_success(): + # `SessionExecuteResponse` does not declare `successful`, so absent is + # silence and not a failure. + shared = FakeSession("open-tag", result={"data": {"id": "ISS-1"}, "error": None}) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) + + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + assert result == {"id": "ISS-1"} + + +def test_a_turn_that_carried_nobody_says_so_rather_than_blaming_the_setup(): + # The one failure this feature is most likely to hit: an older + # `@copilotkit/channels` does not forward the actor. Logged and nothing + # else, the model is left with "not configured for you" — a settled fact + # about somebody's setup, and the wrong instruction to give them. + shared = FakeSession("open-tag", search_response("LINEAR_CREATE_ISSUE")) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "email the team", "state": state()}) + + said = str(result) + assert "gmail" in said + # Named as what it is, and explicitly not as a setup anybody has to fix. + assert "did not carry who is speaking" in said + assert "nobody should be asked to connect an app" in said + + +def test_a_personal_slug_on_an_anonymous_turn_is_not_called_a_missing_app(): + shared = FakeSession("open-tag") + _search, run, _client = tools_for({"open-tag": shared}) + + result = run.invoke({"slug": "GMAIL_SEND_EMAIL", "arguments": {}, "state": state()}) + + assert "No connected app here provides" not in result + assert "gmail" in result + assert shared.executed == [] + + +def test_an_identified_turn_says_nothing_about_a_missing_actor(): + shared = FakeSession("open-tag", search_response("LINEAR_CREATE_ISSUE")) + personal = FakeSession("slack:U1", search_response("GMAIL_SEND_EMAIL")) + search, _run, _client = tools_for({"open-tag": shared, "slack:U1": personal}) + + result = search.invoke({"query": "email", "state": state("U1")}) + + assert "did not carry" not in str(result) + + +def test_a_personal_only_deployment_on_an_anonymous_turn_is_not_told_it_is_unconfigured(): + # No shared toolkits, so an anonymous turn resolves no scope at all and + # `_unreachable` answers "Connected apps are not configured for you." That + # is a settled fact about somebody's setup, and it is false: the apps are + # configured, the turn just did not say who is asking. + search, run, _client = tools_for({}, cfg=config(workspace_toolkits=())) + + searched = search.invoke({"query": "email the team", "state": state()}) + ran = run.invoke({"slug": "GMAIL_SEND_EMAIL", "arguments": {}, "state": state()}) + + for said in (searched, ran): + assert isinstance(said, str), said + assert "not configured for you" not in said + assert "did not carry who is speaking" in said + + +# --- The whole path: an approved Composio write fails, and the thread hears --- + + +class SendOnceModel(BaseChatModel): + """Calls `run_my_tool` once, then stops.""" + + @property + def _llm_type(self): + return "composio-send-once" + + def bind_tools(self, tools, **_kwargs): + return self + + def _generate(self, messages, stop=None, run_manager=None, **_kwargs): + del stop, run_manager + already_ran = any(isinstance(message, ToolMessage) for message in messages) + message = ( + AIMessage(content="done") + if already_ran + else AIMessage( + content="", + tool_calls=[ + { + "id": "send-1", + "name": "run_my_tool", + "args": { + "slug": "GMAIL_SEND_EMAIL", + "arguments": {"to": "a@b.c"}, + }, + } + ], + ) + ) + return ChatResult(generations=[ChatGeneration(message=message)]) + + +def test_an_approved_write_that_fails_is_rendered_into_the_thread(): + """Measured on the wire, not at the call site. + + "We called `emit_write_failure`" was green for the whole of this feature's + life while the thread heard nothing: the notice went out as a lone CUSTOM + event, and both production renderers drop any custom event that is not + `on_interrupt`. TEXT_MESSAGE_* is what they post, so that is the assertion. + """ + personal = FakeSession( + "slack:U1", result={"data": None, "error": "Gmail said no"} + ) + cfg = config(workspace_toolkits=()) + cache = SessionCache(cfg, client=FakeComposio({"slack:U1": personal})) + graph = create_deep_agent( + model=SendOnceModel(), + tools=build_composio_tools(cfg, cache, FakeEffects()), + middleware=[CopilotKitMiddleware()], + state_schema=ComposioAgentState, + checkpointer=MemorySaver(), + ) + agent = build_agui_agent(graph, recursion_limit=40) + request = { + "threadId": "composio-failure-thread", + "state": {}, + "messages": [{"id": "user-1", "role": "user", "content": "email them"}], + "tools": [], + "context": [], + } + + async def collect(stream): + return [event async for event in stream] + + first = asyncio.run( + collect( + agent.run( + RunAgentInput( + runId="run-1", + forwardedProps={ + "channelActor": { + "id": "U1", + "kind": "human", + "platform": "slack", + } + }, + **request, + ) + ) + ) + ) + assert any(getattr(event, "name", None) == "on_interrupt" for event in first) + + resumed = asyncio.run( + collect( + agent.run( + RunAgentInput( + runId="run-2", + forwardedProps={"command": {"resume": {"confirmed": True}}}, + **request, + ) + ) + ) + ) + + started = { + event.message_id + for event in resumed + if event.type == EventType.TEXT_MESSAGE_START + } + ended = { + event.message_id + for event in resumed + if event.type == EventType.TEXT_MESSAGE_END + } + rendered = [ + event.delta + for event in resumed + if event.type == EventType.TEXT_MESSAGE_CONTENT + and event.message_id in started + and event.message_id in ended + ] + + assert personal.executed == [("GMAIL_SEND_EMAIL", {"to": "a@b.c"})] + assert "⚠️ **Send email (Gmail)** failed — Gmail said no" in rendered diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index ffb962c2..68dabb10 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -1,7 +1,8 @@ from fastapi.testclient import TestClient # Import before tests mutate environment variables. -import agent as agent_mod # noqa: E402 +import agent as agent_mod +from composio_tools.runtime import reset_composio_runtime # noqa: E402 def test_health_ok(monkeypatch): @@ -49,8 +50,66 @@ def test_local_agent_port_rejects_invalid_server_port(): import main import pytest - with pytest.raises(ValueError, match="SERVER_PORT"): - main.local_server_port({"SERVER_PORT": "70000"}) + for raw in ("70000", "0", "-1", "8123.5", "eight"): + with pytest.raises(ValueError, match="SERVER_PORT"): + main.local_server_port({"SERVER_PORT": raw}) + + +def test_a_blank_server_port_falls_back_like_an_unset_one(): + # `SERVER_PORT=` is routine in `.env` files and in compose passthrough, and + # every other environment reader in `main` treats blank as unset — + # `SERVER_HOST`, `AGENT_RELOAD` and `CORS_ALLOW_ORIGINS` all do. This one + # reached `int("")` and aborted boot with "Invalid SERVER_PORT" for a + # variable the operator had not set to anything. + import main + + for raw in ("", " ", "\n"): + assert main.local_server_port({"SERVER_PORT": raw}) == 8123 + + +def test_local_agent_port_tolerates_a_pasted_newline(): + # The other half of the same mistake: a value pasted with surrounding + # whitespace is the number that was meant. + import main + + assert main.local_server_port({"SERVER_PORT": " 8124\n"}) == 8124 + + +def test_the_refusal_names_what_was_actually_wrong(monkeypatch): + # One sentence used to answer three unrelated causes, and the person who + # clicked reads it. "No person was named." shown to a Discord human is + # simply false — they were named, and they go looking for a name they gave. + import main + + person = {"id": "U1", "platform": "slack", "kind": "human"} + + nobody = main.actor_refusal({**person, "id": " "}) + not_a_person = main.actor_refusal({**person, "kind": "bot"}) + could_not_tell = main.actor_refusal({**person, "kind": None}) + elsewhere = main.actor_refusal({**person, "platform": "discord"}) + + assert nobody == "No person was named." + # Three distinct sentences, and none of them is the one above. + assert len({nobody, not_a_person, could_not_tell, elsewhere}) == 4 + assert "person" in not_a_person + assert "discord" not in elsewhere.lower() + + +def test_the_refusal_never_repeats_what_the_request_said(monkeypatch): + # The sentence is rendered into a card posted in a Slack thread, as mrkdwn, + # so anything echoed from the request body is an injection vector — the + # same reason `normalizeToolkit` exists on the other side. Nothing the + # caller typed comes back out. + import main + + hostile = { + "id": "", + "platform": "", + "kind": "", + } + + for actor in (hostile, {**hostile, "kind": "human"}, {**hostile, "id": ""}): + assert "evil.example" not in main.actor_refusal(actor) def test_build_agent_without_tavily(monkeypatch, capsys): @@ -101,6 +160,12 @@ def with_config(self, config): monkeypatch.delenv("DAYTONA_API_KEY", raising=False) monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False) monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) + # The repo `.env` is loaded at import, so an optional feature configured on + # the developer's machine otherwise leaks into this assertion. + monkeypatch.delenv("COMPOSIO_API_KEY", raising=False) + # The Composio runtime is cached per process, so a test that varies the + # environment has to drop it first or it reads the previous test's answer. + reset_composio_runtime() monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: object()) monkeypatch.setattr(agent_mod, "internal_source_toolsets", lambda _provider: {}) @@ -115,6 +180,51 @@ def fake_create_deep_agent(**kwargs): assert captured["tools"] == [] +def test_build_agent_registers_composio_tools_only_when_configured(monkeypatch): + captured = {} + + class FakeGraph: + def with_config(self, config): + return self + + def build(env): + for name in ( + "TAVILY_API_KEY", + "DAYTONA_API_KEY", + "GITHUB_CODER_TOKEN", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "COMPOSIO_API_KEY", + "COMPOSIO_TOOLKITS", + "COMPOSIO_USER_TOOLKITS", + ): + monkeypatch.delenv(name, raising=False) + for name, value in env.items(): + monkeypatch.setenv(name, value) + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + reset_composio_runtime() + monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: object()) + monkeypatch.setattr( + agent_mod, "internal_source_toolsets", lambda _provider: {} + ) + monkeypatch.setattr( + agent_mod, + "create_deep_agent", + lambda **kwargs: (captured.update(kwargs), FakeGraph())[1], + ) + agent_mod.build_agent() + return [tool.name for tool in captured["tools"]] + + assert build({}) == [] + assert build( + {"COMPOSIO_API_KEY": "ak_test", "COMPOSIO_TOOLKITS": "linear"} + ) == ["search_my_tools", "run_my_tool"] + + # The actor key must be declared whichever way that went: the AG-UI adapter + # drops a forwarded key the state schema does not name, so "who spoke" must + # not depend on whether an unrelated feature is switched on. + assert "channel_actor" in captured["state_schema"].__annotations__ + + def test_system_prompt_requires_confirmation_only_for_writes(): prompt = agent_mod.BASE_SYSTEM_PROMPT diff --git a/agent/tests/test_log_tool_calls.py b/agent/tests/test_log_tool_calls.py index da807568..ed720e57 100644 --- a/agent/tests/test_log_tool_calls.py +++ b/agent/tests/test_log_tool_calls.py @@ -1,11 +1,18 @@ +"""Coder progress messages must survive the real AG-UI adapter.""" + import asyncio from types import SimpleNamespace import pytest +from ag_ui.core import EventType, RunAgentInput from langchain_core.messages import ToolMessage +from langgraph.checkpoint.memory import MemorySaver from langgraph.errors import GraphRecursionError +from langgraph.graph import END, START, MessagesState, StateGraph +import agent as agent_module from agent import LogToolCalls +from agui import build_agui_agent def _request(name="task", tool_call_id="tc-1"): @@ -53,3 +60,90 @@ def handler(_request): with pytest.raises(RuntimeError, match="boom"): middleware.wrap_tool_call(_request(), handler) + + +def test_coder_start_notices_render_and_the_run_finishes(): + middleware = LogToolCalls() + calls = [] + request = _request() + + async def handler(received): + calls.append(received) + return "coder finished" + + async def node(_state): + for _ in range(2): + result = await middleware.awrap_tool_call(request, handler) + assert result == "coder finished" + return {} + + builder = StateGraph(MessagesState) + builder.add_node("coder", node) + builder.add_edge(START, "coder") + builder.add_edge("coder", END) + adapter = build_agui_agent(builder.compile(checkpointer=MemorySaver())) + + async def collect(): + return [ + event + async for event in adapter.run( + RunAgentInput( + runId="coder-notice-run", + threadId="coder-notice-thread", + state={}, + messages=[{"id": "u1", "role": "user", "content": "Fix it"}], + tools=[], + context=[], + forwardedProps={}, + ) + ) + ] + + events = asyncio.run(collect()) + text_events = [ + event + for event in events + if event.type in { + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + } + ] + assert [event.type for event in text_events] == [ + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + ] * 2 + message_ids = set() + for start, content, end in (text_events[:3], text_events[3:]): + assert start.message_id == content.message_id == end.message_id + assert content.delta == ( + "Starting the coder in a Daytona sandbox. This can take a few minutes." + ) + message_ids.add(start.message_id) + assert len(message_ids) == 2 + assert EventType.RUN_ERROR not in [event.type for event in events] + assert events[-1].type == EventType.RUN_FINISHED + assert calls == [request, request] + + +def test_a_failed_coder_notice_is_logged_without_stopping_the_tool( + monkeypatch, caplog +): + async def failed_dispatch(*_args, **_kwargs): + raise RuntimeError("progress stream unavailable") + + monkeypatch.setattr(agent_module, "adispatch_custom_event", failed_dispatch) + request = _request() + calls = [] + + async def handler(received): + calls.append(received) + return "coder finished" + + result = asyncio.run(LogToolCalls().awrap_tool_call(request, handler)) + + assert result == "coder finished" + assert calls == [request] + assert "could not tell the thread the coder was starting" in caplog.text + assert "progress stream unavailable" in caplog.text diff --git a/agent/tests/test_packaging.py b/agent/tests/test_packaging.py index 5640c338..27fe7b80 100644 --- a/agent/tests/test_packaging.py +++ b/agent/tests/test_packaging.py @@ -1,34 +1,249 @@ +import re import tomllib +from collections.abc import Iterable from pathlib import Path +AGENT_ROOT = Path(__file__).resolve().parent.parent +REPO_ROOT = Path(__file__).resolve().parents[2] -def test_wheel_includes_every_runtime_module(): - agent_root = Path(__file__).resolve().parent.parent - project = tomllib.loads((agent_root / "pyproject.toml").read_text()) - packaged_modules = set(project["tool"]["setuptools"]["py-modules"]) - runtime_modules = { +# Directories that sit beside the runtime code and must never reach the wheel or +# the image. Named, rather than left to a denylist that happened to be right: +# the derivation below reads "every package on disk ships", so a `scripts` or a +# `tests` package would otherwise make this file demand that developer tooling +# be installed into site-packages. +NOT_SHIPPED_PACKAGES = frozenset({".venv", "scripts", "tests"}) + +# Same, one level up. A `conftest.py` at the agent root is pytest scaffolding, +# not a runtime module, and the wheel has no business carrying it. +NOT_SHIPPED_MODULES = frozenset({"conftest"}) + +# A floor under every derived set below. Deriving from disk is what keeps these +# assertions honest for the next person to add a module, but a derived set only +# asserts something while it has something in it: a glob that matches nothing — +# a moved test file, a renamed layout — turns every comparison here into +# `set() == set()`. These names must appear whatever the glob does. +KNOWN_MODULES = frozenset({"agent", "agent_auth", "main"}) +KNOWN_PACKAGE_ROOTS = frozenset({"coding", "composio_tools", "prompts"}) + +#: The first `composio` release whose client exposes `.sessions`. Everything in +#: `composio_tools/sessions.py` goes through it, and below this release the SDK +#: offers `tool_router` and no alias — so a resolver that picked a lower version +#: satisfies the floor, installs, imports, and raises `AttributeError` on the +#: first turn that touches a toolkit. Verified against the published wheels: +#: 0.16.0 has no `def sessions`, 0.17.0 does, and 0.17.0 already accepts the +#: `sandbox` and `manage_connections` arguments this repository passes. +COMPOSIO_SESSIONS_FLOOR = (0, 17, 0) + +#: Floors that are not a matter of taste: the earliest release carrying an API +#: this repository actually calls. +REQUIRED_FLOORS = {"composio": COMPOSIO_SESSIONS_FLOOR} + + +def runtime_modules() -> set[str]: + """Every top-level module on disk that the wheel has to carry.""" + modules = { path.stem - for path in agent_root.glob("*.py") - if path.name != "__init__.py" + for path in AGENT_ROOT.glob("*.py") + if path.name != "__init__.py" and path.stem not in NOT_SHIPPED_MODULES + } + assert KNOWN_MODULES <= modules, f"module discovery is broken: {modules}" + return modules + + +def package_roots() -> set[str]: + """The top-level packages. What the image copies, one directory at a time.""" + roots = { + path.parent.name + for path in AGENT_ROOT.glob("*/__init__.py") + if path.parent.name not in NOT_SHIPPED_PACKAGES } + assert KNOWN_PACKAGE_ROOTS <= roots, f"package discovery is broken: {roots}" + return roots + + +def package_names(init_paths: Iterable[Path], root: Path) -> set[str]: + """ + The dotted names of the packages `init_paths` describe, minus what never ships. + + Split out and given its root so the exclusion can be tested at a depth the + checkout does not currently have. `NOT_SHIPPED_PACKAGES` is matched against + every path segment rather than only the first: the version that looked at + the top-level name alone let a `composio_tools/tests/__init__.py` through, + and this file would then have demanded that a test package be listed in the + wheel — a derived assertion arguing for the opposite of what it exists for. + """ + names = set() + for path in init_paths: + parts = path.parent.relative_to(root).parts + if NOT_SHIPPED_PACKAGES.intersection(parts): + continue + names.add(".".join(parts)) + return names + + +def runtime_packages() -> set[str]: + """ + Every package setuptools has to be named, nested ones included. + + `packages` is an explicit list and setuptools does not walk it: naming + `composio_tools` does not carry `composio_tools.adapters`, which then + imports fine from a source checkout and is missing from the wheel. A + depth-one scan is that exact failure, so this one goes all the way down. + + Down from the package roots, not from the agent directory: a build leaves + `build/lib//__init__.py` behind, and a sweep of the whole tree + would then ask setuptools to package its own output. + """ + return package_names( + ( + path + for root in package_roots() + for path in (AGENT_ROOT / root).rglob("__init__.py") + ), + AGENT_ROOT, + ) + + +def lower_bound(requirement: str) -> tuple[int, ...] | None: + """The `>=` floor in a requirement, as a comparable tuple, or `None`.""" + match = re.search(r">=\s*(\d+(?:\.\d+)*)", requirement) + return tuple(int(part) for part in match.group(1).split(".")) if match else None + + +def declared_dependencies() -> dict[str, str]: + """Each declared dependency's distribution name mapped to its full requirement.""" + project = tomllib.loads((AGENT_ROOT / "pyproject.toml").read_text()) + declared = {} + for requirement in project["project"]["dependencies"]: + name = re.split(r"[\s\[<>=!~;(]", requirement, maxsplit=1)[0] + declared[name.strip().lower().replace("_", "-")] = requirement + return declared + + +def test_wheel_includes_every_runtime_module(): + project = tomllib.loads((AGENT_ROOT / "pyproject.toml").read_text()) - assert packaged_modules == runtime_modules - assert project["tool"]["setuptools"]["packages"] == ["prompts", "coding"] + assert set(project["tool"]["setuptools"]["py-modules"]) == runtime_modules() + # Derived, not listed. A hardcoded list passes for whoever wrote it and + # fails the next person to add a package, which is backwards: the point is + # to catch a package that exists on disk and never reaches the wheel. + assert set(project["tool"]["setuptools"]["packages"]) == runtime_packages() -def test_agent_image_copies_the_coding_package(): - repo_root = Path(__file__).resolve().parents[2] + +def test_nested_test_packages_never_reach_the_wheel(): + # At a depth the checkout does not currently have, which is the whole point: + # the exclusion used to read the first path segment only, so the day someone + # adds `composio_tools/tests/` this file starts demanding the test package + # ship in the wheel. + root = Path("/agent") + + assert package_names( + [ + root / "composio_tools" / "__init__.py", + root / "composio_tools" / "adapters" / "__init__.py", + root / "composio_tools" / "tests" / "__init__.py", + root / "composio_tools" / "tests" / "fixtures" / "__init__.py", + root / "tests" / "__init__.py", + root / ".venv" / "lib" / "site-packages" / "anything" / "__init__.py", + ], + root, + ) == {"composio_tools", "composio_tools.adapters"} + + +def test_agent_image_copies_every_runtime_module(): + # Deleting `COPY agent/*.py ./` leaves an image with no `main.py`, which is + # the file its own CMD runs: the container cannot boot at all. The package + # assertion below never looked at it, so that deletion passed the suite. + # + # Each COPY's source is expanded against the checkout rather than compared + # as text, so the assertion holds however the line is written — one glob or + # seven explicit paths — and fails when it stops covering a module. + dockerfile = ( + REPO_ROOT / "deployment" / "docker" / "agent.Dockerfile" + ).read_text(encoding="utf-8") + + copied = set() + for source, target in re.findall(r"^COPY agent/(\S+) (\S+)$", dockerfile, re.M): + if not source.endswith(".py") or target not in ("./", "."): + continue + copied |= {path.stem for path in AGENT_ROOT.glob(source)} + + assert copied - NOT_SHIPPED_MODULES == runtime_modules() + + +def test_agent_image_copies_every_runtime_package(): + # The image copies packages one line at a time, so a new package imports + # fine locally and crashes the container on first import. Derived from disk + # for the same reason as the wheel assertion above. Nested packages come + # along with their root's directory, so only the roots are checked here. dockerfile = ( - repo_root / "deployment" / "docker" / "agent.Dockerfile" + REPO_ROOT / "deployment" / "docker" / "agent.Dockerfile" ).read_text(encoding="utf-8") - assert "COPY agent/coding ./coding" in dockerfile + + # Anchored and matched as a whole line, because `"COPY agent/x ./x" in text` + # is satisfied by a commented-out COPY. Compared as a set rather than one + # membership check at a time, because equality also catches a COPY left + # behind for a directory that no longer exists — which fails the build. + copied = set( + re.findall(r"^COPY agent/(\S+) \./\1$", dockerfile, flags=re.MULTILINE) + ) + + assert copied == package_roots() def test_coding_dependencies_are_declared(): - agent_root = Path(__file__).resolve().parent.parent - project = tomllib.loads((agent_root / "pyproject.toml").read_text()) - deps = project["project"]["dependencies"] - assert any(dep.startswith("daytona") for dep in deps) - assert any(dep.startswith("langchain-daytona") for dep in deps) - assert any(dep.startswith("httpx") for dep in deps) - assert any(dep.startswith("pyjwt[crypto]") for dep in deps) + declared = declared_dependencies() + + # Whole names, not prefixes: `dep.startswith("httpx")` was satisfied by + # `httpx-sse`, a different distribution that does not provide `httpx`. + # `composio` is in the list because the agent imports it unconditionally + # from `composio_tools/sessions.py`, and nothing here asserted it was + # declared at all. + assert {"composio", "daytona", "langchain-daytona", "httpx", "pyjwt"} <= set( + declared + ) + + # And the extra, not merely the distribution: the coder signs GitHub App + # tokens with `cryptography`, which only the `crypto` extra pulls in. + assert "[crypto]" in declared["pyjwt"] + + +def test_every_dependency_declares_a_lower_bound(): + # A bare `daytona` resolves to whatever the index offers on the day the + # image is built, including a release that renamed the API underneath us, + # and the lockfile hides that until someone regenerates it. A floor is the + # only part of this that survives a re-resolve. + unbounded = sorted( + requirement + for requirement in declared_dependencies().values() + if lower_bound(requirement) is None + ) + + assert unbounded == [] + + +def test_pinned_apis_declare_a_floor_that_has_them(): + declared = declared_dependencies() + + for name, floor in REQUIRED_FLOORS.items(): + assert lower_bound(declared[name]) >= floor, ( + f"{declared[name]} admits a release without the API this repo calls" + ) + + +def test_the_project_is_actually_built(): + # Without `[build-system]` the whole `[tool.setuptools]` table above is + # inert: uv treats the project as virtual, never builds it, and the wheel + # the assertions in this file describe is never produced by anything. The + # image's `uv sync --frozen --no-dev` after the source COPYs is the step + # that builds it, and it only builds a project that names a backend. + project = tomllib.loads((AGENT_ROOT / "pyproject.toml").read_text()) + + assert project["build-system"]["build-backend"] == "setuptools.build_meta" + # And the backend the `[tool.setuptools]` config is written for has to be + # in the build requirements, or the build reaches for whatever is around. + assert any( + requirement.startswith("setuptools") + for requirement in project["build-system"]["requires"] + ) diff --git a/agent/tests/test_tools_prompt.py b/agent/tests/test_tools_prompt.py new file mode 100644 index 00000000..28e89cd1 --- /dev/null +++ b/agent/tests/test_tools_prompt.py @@ -0,0 +1,86 @@ +"""The prompt may only claim tools this deployment actually registered.""" + +from __future__ import annotations + +import prompts +from prompts import BASE_SYSTEM_PROMPT, build_base_system_prompt, tools_prompt + + +def test_internal_source_guidance_is_absent_when_there_are_no_internal_sources(): + # The defect this exists for. With no internal sources configured the agent + # holds exactly two tools — `search_my_tools` and `run_my_tool` — and the + # prompt still told it to "prefer the team's Notion/Linear and GitHub + # sources" and to "use GitHub tools". Believing it already had them, it + # answered questions about Linear without ever searching, and was wrong. + text = tools_prompt(internal_sources=()) + + assert "GitHub tools" not in text + assert "Notion, Linear" not in text + assert "mutation tool" not in text + + +def test_internal_source_guidance_is_present_when_they_exist(): + text = tools_prompt(internal_sources=("notion", "linear", "github")) + + assert "GitHub tools" in text + assert "Notion, Linear" in text + assert "mutation tool" in text + + +def test_what_is_always_true_is_always_said(): + # Reads never needing confirmation is a property of the approval gate, not + # of any particular integration, so it holds either way. + for sources in (("notion", "linear", "github"), ()): + assert "Reads and rendering never require confirmation" in tools_prompt( + internal_sources=sources + ) + + +def test_the_base_prompt_carries_the_choice_through(): + without = build_base_system_prompt("Kite", internal_sources=()) + with_them = build_base_system_prompt("Kite", internal_sources=("notion", "linear", "github")) + + assert "GitHub tools" not in without + assert "GitHub tools" in with_them + # The identity and the workflow guidance are unaffected either way. + assert "Kite" in without and "Kite" in with_them + + +def test_the_default_still_describes_a_fully_configured_deployment(): + # `agent.py` and the health tests import `BASE_SYSTEM_PROMPT` as a + # constant, so the assertion has to be on the constant. Calling the builder + # again tested the default argument and left the constant free to regress: + # anything could have been appended to it and this stayed green. + assert "GitHub tools" in BASE_SYSTEM_PROMPT + assert BASE_SYSTEM_PROMPT == build_base_system_prompt() + + +def test_the_approval_gate_is_described_however_this_is_configured(): + # The regression this exists for. Splitting the prompt took the approval + # sentence with the Notion/Linear block, so a Composio-only deployment — + # the shape running in production — was told only that reads never need + # confirmation, and nothing at all about what a write does. `run_my_tool` + # pauses on the very same interrupt a Linear mutation does. The behaviour + # was real and the model was never told it existed. + for sources in (("notion", "linear", "github"), ()): + text = tools_prompt(internal_sources=sources) + + assert "automatically pauses" in text + assert "grants approval" in text + + +def test_the_approval_gate_is_described_without_naming_an_integration(): + # It has to be said to a deployment holding no Notion, Linear or GitHub + # tools, so it cannot be said in terms of them: naming a tool the agent + # does not have is the defect the split was made to fix. + text = tools_prompt(internal_sources=()) + + for absent in ("Linear", "Notion", "GitHub"): + assert absent not in text + + +def test_the_tool_guidance_names_are_declared_exports(): + # Both are imported into `prompts/__init__` for callers outside it. An + # undeclared re-export reads as a dead import to any automated cleanup. + assert "tools_prompt" in prompts.__all__ + assert "TOOLS_PROMPT" in prompts.__all__ diff --git a/agent/tests/test_write_confirmation.py b/agent/tests/test_write_confirmation.py index 368ba883..8dbc0e0b 100644 --- a/agent/tests/test_write_confirmation.py +++ b/agent/tests/test_write_confirmation.py @@ -1,11 +1,16 @@ import asyncio +import logging import copilotkit.langgraph import pytest import write_confirmation +from ag_ui.core import EventType, RunAgentInput +from agui import build_agui_agent from langchain_core.messages import ToolMessage from langchain_core.tools import StructuredTool from langchain_mcp_adapters.interceptors import MCPToolCallRequest +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import END, START, MessagesState, StateGraph from mcp.types import CallToolResult, TextContent from write_confirmation import failure_text, summarize_args @@ -24,13 +29,21 @@ def approve(**kwargs): def capture_reports(monkeypatch): - """Record what the interceptor reports back to the thread.""" + """Record what the interceptor reports back to the thread. + + Taken at the dispatch, and only for the event name the AG-UI adapter + renders. The name is the whole of the bug this fake stands in for: the + notice was dispatched under one nothing translates, so recording the text + alone would go on passing while the thread heard nothing. + """ reported = [] - async def emit(_config, message): - reported.append(message) + async def dispatch(name, data, *, config=None): + del config + if name == write_confirmation._EMIT_MESSAGE_EVENT: + reported.append(data["message"]) - monkeypatch.setattr(write_confirmation, "copilotkit_emit_message", emit) + monkeypatch.setattr(write_confirmation, "adispatch_custom_event", dispatch) monkeypatch.setattr(write_confirmation, "ensure_config", lambda: {}) return reported @@ -146,6 +159,9 @@ async def handler(request): "args": { "action": "Create issue", "fields": [{"label": "Title", "value": "Checkout 500s"}], + # Nobody annotated `create_issue`, so the card is told to render it + # as dangerous rather than left to guess from the verb. + "effect": "destructive", }, } @@ -167,11 +183,18 @@ async def read_issue(issue_id: str): ) interceptor = write_confirmation.WriteConfirmationInterceptor() interceptor.register_tools([read_tool]) - monkeypatch.setattr( - write_confirmation, - "copilotkit_interrupt", - lambda **kwargs: interrupt_calls.append(kwargs), - ) + def refuse(**kwargs): + """A card nobody should be shown — and a well-formed one all the same. + + Returning `None` here (what `list.append` answers) makes a regression + that gates this read blow up unpacking the resume, so the test dies of + a `TypeError` in the source instead of failing `interrupt_calls == []`, + which is the thing it was written to say. + """ + interrupt_calls.append(kwargs) + return '{"confirmed": false}', {"confirmed": False} + + monkeypatch.setattr(write_confirmation, "copilotkit_interrupt", refuse) async def handler(request): handler_calls.append(request) @@ -226,6 +249,7 @@ async def handler(request): "args": { "action": "Create issue", "fields": [{"label": "Title", "value": "Checkout 500s"}], + "effect": "destructive", }, } ] @@ -477,10 +501,10 @@ async def failing(_request): def test_a_broken_failure_report_does_not_break_the_write(monkeypatch): approve_and_track(monkeypatch) - async def emit(_config, _message): + async def dispatch(_name, _data, *, config=None): raise RuntimeError("no stream") - monkeypatch.setattr(write_confirmation, "copilotkit_emit_message", emit) + monkeypatch.setattr(write_confirmation, "adispatch_custom_event", dispatch) monkeypatch.setattr(write_confirmation, "ensure_config", lambda: {}) failed = error_result("nope") @@ -569,3 +593,355 @@ def test_require_write_confirmation_rejects_a_bad_resume(monkeypatch): action="Open draft pull request", fields=[], ) + + +def read_tool(name, **metadata): + """An MCP-shaped tool carrying exactly the annotations a server sent.""" + + async def run(**kwargs): + return kwargs + + return StructuredTool.from_function( + coroutine=run, + name=name, + description=name, + metadata=dict(metadata), + ) + + +def card_for(monkeypatch, request, tools=()): + """The interrupt args of the card the interceptor raises for `request`.""" + cards = approve_and_track(monkeypatch) + interceptor = write_confirmation.WriteConfirmationInterceptor() + if tools: + interceptor.register_tools(list(tools)) + + async def handler(_request): + return "write-result" + + asyncio.run(interceptor(request, handler)) + return cards + + +def capture_card(monkeypatch): + """Record the args of every card `require_write_confirmation` raises.""" + cards = [] + + def approve(**kwargs): + cards.append(kwargs["args"]) + return '{"confirmed": true}', {"confirmed": True} + + monkeypatch.setattr(write_confirmation, "copilotkit_interrupt", approve) + return cards + + +def test_a_card_for_an_unregistered_tool_says_destructive(monkeypatch): + cards = card_for(monkeypatch, save_project(name="OpenTag")) + + assert cards[0]["effect"] == "destructive" + + +def test_a_tool_that_declares_it_is_not_read_only_gets_a_write_card(monkeypatch): + # `readOnlyHint: False` is a tool asserting it is *not* a read. Reading the + # key's presence instead of its value would call this unclassified. + cards = card_for( + monkeypatch, + save_project(name="OpenTag"), + tools=[read_tool("save_project", readOnlyHint=False)], + ) + + assert cards[0]["effect"] == "write" + + +def test_a_tool_that_declares_itself_destructive_gets_a_destructive_card( + monkeypatch, +): + cards = card_for( + monkeypatch, + save_project(name="OpenTag"), + tools=[ + read_tool("save_project", readOnlyHint=False, destructiveHint=True) + ], + ) + + assert cards[0]["effect"] == "destructive" + + +def test_a_tool_whose_annotations_say_nothing_gets_a_destructive_card( + monkeypatch, +): + cards = card_for( + monkeypatch, + save_project(name="OpenTag"), + tools=[read_tool("save_project", title="Save project")], + ) + + assert cards[0]["effect"] == "destructive" + + +def test_a_non_boolean_read_only_hint_is_not_an_assertion(monkeypatch): + # MCP hints are booleans. A string is a shape nobody meant to send, and it + # must not be able to talk the card down to a calmer styling. + cards = card_for( + monkeypatch, + save_project(name="OpenTag"), + tools=[read_tool("save_project", readOnlyHint="false")], + ) + + assert cards[0]["effect"] == "destructive" + + +def test_a_read_only_tool_produces_no_card_at_all(monkeypatch): + cards = approve_and_track(monkeypatch) + interceptor = write_confirmation.WriteConfirmationInterceptor() + interceptor.register_tools([read_tool("get_issue", readOnlyHint=True)]) + handled = [] + + async def handler(request): + handled.append(request) + return "read-result" + + request = MCPToolCallRequest( + name="get_issue", args={"issue_id": "CPK-9"}, server_name="linear" + ) + result = asyncio.run(interceptor(request, handler)) + + # The gate returns before any card exists, so `read` is never a value the + # card has to render — it is the reason there is no card. + assert result == "read-result" + assert handled == [request] + assert cards == [] + + +def test_a_known_read_only_notion_search_stays_a_read(monkeypatch): + # These POST endpoints are read-only despite what their own annotations + # look like, so a later registration must not gate them. + cards = approve_and_track(monkeypatch) + interceptor = write_confirmation.WriteConfirmationInterceptor() + interceptor.register_tools([read_tool("API-post-search", readOnlyHint=False)]) + + async def handler(_request): + return "read-result" + + result = asyncio.run( + interceptor( + MCPToolCallRequest( + name="API-post-search", args={"query": "x"}, server_name="notion" + ), + handler, + ) + ) + + assert result == "read-result" + assert cards == [] + + +def test_require_write_confirmation_defaults_to_a_destructive_card(monkeypatch): + cards = capture_card(monkeypatch) + + write_confirmation.require_write_confirmation( + action="Open draft pull request", fields=[] + ) + + assert cards[0]["effect"] == "destructive" + + +def test_require_write_confirmation_carries_a_classified_effect(monkeypatch): + cards = capture_card(monkeypatch) + + write_confirmation.require_write_confirmation( + action="Save project", fields=[], effect="write" + ) + + assert cards[0]["effect"] == "write" + + +@pytest.mark.parametrize("effect", ["mostly harmless", "", None, "READ", 1]) +def test_require_write_confirmation_fails_safe_on_an_unknown_effect( + monkeypatch, effect +): + cards = capture_card(monkeypatch) + + write_confirmation.require_write_confirmation( + action="Save project", fields=[], effect=effect + ) + + assert cards[0]["effect"] == "destructive" + + +def test_an_effect_from_extra_args_lands_in_the_card_once(monkeypatch): + # How the Composio path spells it. It must land in the same slot rather + # than beside a default that contradicts it. + cards = capture_card(monkeypatch) + + write_confirmation.require_write_confirmation( + action="Gmail send email", + fields=[], + extra_args={"approver": "U1", "effect": "read"}, + ) + + assert cards[0]["effect"] == "read" + assert cards[0]["approver"] == "U1" + + +def test_an_unclassified_effect_from_extra_args_is_destructive(monkeypatch): + cards = capture_card(monkeypatch) + + write_confirmation.require_write_confirmation( + action="Gmail send email", fields=[], extra_args={"effect": None} + ) + + assert cards[0]["effect"] == "destructive" + + +# --- The failure notice, measured on the wire rather than at the call site --- +# +# A test that asserts "we called emit" is exactly what was green while nothing +# was delivered: the old path put the notice on the wire as a single CUSTOM +# `copilotkit_manually_emit_message` event, and both production renderers +# (`@copilotkit/channels-slack`, `-teams`) return immediately from +# `onCustomEvent` for any name that is not `on_interrupt`. TEXT_MESSAGE_* is +# what those renderers post into a thread, so that is what these tests assert on. + + +def wire_events(node): + """Every AG-UI event one graph node puts on the wire. + + Real graph, real `OpenTagAGUIAgent`, real adapter — the three layers between + a tool and a Slack or Teams renderer, none of them stubbed. + """ + builder = StateGraph(MessagesState) + builder.add_node("emit", node) + builder.add_edge(START, "emit") + builder.add_edge("emit", END) + agent = build_agui_agent( + builder.compile(checkpointer=MemorySaver()), recursion_limit=10 + ) + + async def collect(): + return [ + event + async for event in agent.run( + RunAgentInput( + runId="run-1", + threadId=f"wire-{id(node)}", + state={}, + messages=[{"id": "u1", "role": "user", "content": "go"}], + tools=[], + context=[], + forwardedProps={}, + ) + ) + ] + + return asyncio.run(collect()) + + +def rendered_messages(events): + """The assistant messages a renderer would post, as `(id, text)` pairs. + + Only a START/CONTENT/END triple counts: the Slack renderer opens a message + on START, streams into it on CONTENT and closes it on END, so content + without the bracketing events is not something anybody reads. + """ + started = { + event.message_id + for event in events + if event.type == EventType.TEXT_MESSAGE_START + } + ended = { + event.message_id + for event in events + if event.type == EventType.TEXT_MESSAGE_END + } + return [ + (event.message_id, event.delta) + for event in events + if event.type == EventType.TEXT_MESSAGE_CONTENT + and event.message_id in started + and event.message_id in ended + ] + + +def test_an_async_failure_report_is_rendered_as_a_message(caplog): + async def node(_state): + await write_confirmation.report_write_failure( + "Send email (Gmail)", "Gmail said no" + ) + return {} + + with caplog.at_level(logging.WARNING): + messages = rendered_messages(wire_events(node)) + + assert [text for _id, text in messages] == [ + "⚠️ **Send email (Gmail)** failed — Gmail said no" + ] + assert "could not report" not in caplog.text + + +def test_a_sync_failure_report_is_rendered_as_a_message(caplog): + # The entry point `run_my_tool` uses. LangGraph runs a sync tool in a worker + # thread, so this is also the path where a lost context would leave the + # dispatch with no run to attach to and the notice would go nowhere. + def node(_state): + write_confirmation.emit_write_failure("Delete issue (Linear)", "nope") + return {} + + with caplog.at_level(logging.WARNING): + messages = rendered_messages(wire_events(node)) + + assert [text for _id, text in messages] == [ + "⚠️ **Delete issue (Linear)** failed — nope" + ] + assert "could not report" not in caplog.text + + +def test_an_unreadable_config_says_the_retry_memory_was_lost(monkeypatch, caplog): + # Swallowed silently, this costs every card in the process its retry banner + # and nothing anywhere would ever mention it. + def boom(): + raise RuntimeError("no ambient config") + + monkeypatch.setattr(write_confirmation, "ensure_config", boom) + + with caplog.at_level(logging.WARNING): + assert write_confirmation._thread_key() is None + + assert "no ambient config" in caplog.text + + +def test_a_broken_failure_report_names_the_write_and_the_cause( + monkeypatch, caplog +): + # "RuntimeError" on its own names neither the cause nor the write it + # belonged to, which is everything somebody reading this line needs. + approve_and_track(monkeypatch) + + async def dispatch(_name, _data, *, config=None): + raise RuntimeError("no stream") + + monkeypatch.setattr(write_confirmation, "adispatch_custom_event", dispatch) + monkeypatch.setattr(write_confirmation, "ensure_config", lambda: {}) + + async def failing(_request): + return error_result("nope") + + with caplog.at_level(logging.WARNING): + asyncio.run( + write_confirmation.WriteConfirmationInterceptor()( + save_project(name="x"), failing + ) + ) + + assert "Save project" in caplog.text + assert "no stream" in caplog.text + + +def test_a_broken_sync_failure_report_names_the_write_and_the_cause(caplog): + # No graph here, so the dispatch has no run to attach to and raises. That is + # the shape of the real failure, and it must arrive named. + with caplog.at_level(logging.WARNING): + write_confirmation.emit_write_failure("Send email (Gmail)", "nope") + + assert "Send email (Gmail)" in caplog.text + assert "RuntimeError" in caplog.text diff --git a/agent/uv.lock b/agent/uv.lock index 8252a975..57faa3f6 100644 --- a/agent/uv.lock +++ b/agent/uv.lock @@ -445,6 +445,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "composio" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "composio-client" }, + { name = "json-schema-to-pydantic" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pysher" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/2c/169aa85a8d42edf7e18032285beff001f8b492d253fa428eaf62dc75ee85/composio-0.21.0.tar.gz", hash = "sha256:334fbcc2358467a2eed7e04133fdd9080cf63007e53caffa50555023724e956a", size = 309944, upload-time = "2026-08-27T18:27:00.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/e7/28a2d0f4e63cd98e5a6d491496b9c7939996eb1823b0021b0b71e3bc8ce6/composio-0.21.0-py3-none-any.whl", hash = "sha256:8c26d8248b6f01c0b9e2453035291756f6c17d4f4971aaf073230206f8ec39f5", size = 187352, upload-time = "2026-08-27T18:26:48.764Z" }, +] + +[[package]] +name = "composio-client" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/4b/3789f4c1347fd01349b66ecaeffb5ef623434c3b1fa4f5c5993fddb69c68/composio_client-1.43.0.tar.gz", hash = "sha256:bb96700da0c2aabc394cebc954be0ebf419557cc42b40f5d148aac52a5aff6f9", size = 246767, upload-time = "2026-07-08T09:06:02.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/a2/3ae1f5471a52189ac558de6b0e088dc3575d695a0f91278cddf464274694/composio_client-1.43.0-py3-none-any.whl", hash = "sha256:3274d965b9efb6be90a51977f4c068ed24e2cad9160de4d40d3176d8bb4ce2d9", size = 277716, upload-time = "2026-07-08T09:06:01.007Z" }, +] + [[package]] name = "copilotkit" version = "0.1.94" @@ -1037,6 +1074,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, ] +[[package]] +name = "json-schema-to-pydantic" +version = "0.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/d8/423895b918706c80db1cee679c13fbe810200b9a9d9a9442c7a58d35c3f2/json_schema_to_pydantic-0.4.11.tar.gz", hash = "sha256:35448ed711a28dd33396b095c8492939b4925aa30eb31942e9b8e08d04279465", size = 56597, upload-time = "2026-03-09T20:53:55.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/64/7cfeb8c6d2a5e73e0f8d732032aa62be9a7724c04beb461d677de0b4beb3/json_schema_to_pydantic-0.4.11-py3-none-any.whl", hash = "sha256:da2ccc39d070ee03dbcf0517d16720e3e33f7aa8d61257ace09af8c51bd46348", size = 17842, upload-time = "2026-03-09T20:53:54.576Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -1448,7 +1497,7 @@ wheels = [ [[package]] name = "openai" -version = "2.45.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1460,17 +1509,18 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/60/d4219875289b11d2c2f7da93c36283da224a2e55865ed865ab64e0ce9217/openai-2.45.0.tar.gz", hash = "sha256:10d34ca9c5643bce775852fddbfc172505cb1d4de1ccd101696c3ecff358765d", size = 1109653, upload-time = "2026-07-09T18:02:44.091Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/9a/8c75e8c8a5b407a0586faeb2afac91674ff955c191ecc1d6d3b6669f6788/openai-2.54.0.tar.gz", hash = "sha256:e3e6f8bc1ba30ddf381ace1a14340eed381cb984a1a59bd0f34b5be3b5d49cfa", size = 1100285, upload-time = "2026-08-11T18:46:59.035Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/b0/2291689e3ec4723fbf5bbf3b54afcd7b160f9ddc98ca7aedfd0132af5677/openai-2.45.0-py3-none-any.whl", hash = "sha256:5df105f5f8c9b711fcb9d06d2d3888cebc82506db216484c14a4e53cdf651777", size = 1629470, upload-time = "2026-07-09T18:02:42.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/a8/bb76c7356de8ad57f59d5ff993d434df0607f07f08bcc9c9a5c275e399c0/openai-2.54.0-py3-none-any.whl", hash = "sha256:89089789197ccdb87f173a03145ed1598d00795220c93e96cf712b1cbf5e5f2b", size = 1660351, upload-time = "2026-08-11T18:46:56.684Z" }, ] [[package]] name = "opentag-agent" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "ag-ui-langgraph" }, + { name = "composio" }, { name = "copilotkit" }, { name = "daytona" }, { name = "deepagents" }, @@ -1494,13 +1544,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "ag-ui-langgraph", specifier = ">=0.0.23" }, + { name = "composio", specifier = ">=0.17.0" }, { name = "copilotkit", specifier = ">=0.1.76" }, - { name = "daytona" }, + { name = "daytona", specifier = ">=0.204.0" }, { name = "deepagents", specifier = ">=0.6.12" }, { name = "fastapi", specifier = ">=0.115.14" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "langchain", specifier = ">=1.2.4" }, - { name = "langchain-daytona" }, + { name = "langchain-daytona", specifier = ">=0.0.7" }, { name = "langchain-mcp-adapters", specifier = ">=0.3.0" }, { name = "langchain-openai", specifier = ">=1.1.7" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, @@ -2018,6 +2069,16 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pysher" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/a0/d0638470df605ce266991fb04f74c69ab1bed3b90ac3838e9c3c8b69b66a/Pysher-1.0.8.tar.gz", hash = "sha256:7849c56032b208e49df67d7bd8d49029a69042ab0bb45b2ed59fa08f11ac5988", size = 9071, upload-time = "2022-10-10T13:41:09.936Z" } + [[package]] name = "pytest" version = "9.1.1" diff --git a/agent/write_confirmation.py b/agent/write_confirmation.py index b8a3feaf..a2305b9d 100644 --- a/agent/write_confirmation.py +++ b/agent/write_confirmation.py @@ -1,13 +1,24 @@ """Approval enforcement for mutating MCP tools.""" -import asyncio import json import logging import re +import uuid from collections import OrderedDict -from concurrent.futures import ThreadPoolExecutor -from copilotkit.langgraph import copilotkit_emit_message, copilotkit_interrupt +from ag_ui_langgraph import CustomEventNames +from composio_tools.classify import ( + DESTRUCTIVE, + READ, + READ_ONLY_HINT, + WRITE, + effect_of, +) +from copilotkit.langgraph import copilotkit_interrupt +from langchain_core.callbacks import ( + adispatch_custom_event, + dispatch_custom_event, +) from langchain_core.messages import ToolMessage from langchain_core.runnables.config import ensure_config from langchain_core.tools import BaseTool @@ -27,10 +38,32 @@ # Longest failure text carried into the thread and onto the next card. _MAX_ERROR = 240 +# Everything the confirmation card understands. It renders danger for anything +# else, including a missing value, so a card leaves here carrying one of these +# three words and never a fourth. +_CARD_EFFECTS = frozenset({READ, WRITE, DESTRUCTIVE}) + # How many (thread, tool) failures are remembered at once. The interceptor # outlives every conversation, so this memory is bounded rather than unbounded. _MAX_TRACKED_FAILURES = 64 +# The custom event the AG-UI adapter turns into TEXT_MESSAGE_START / CONTENT / +# END, which is what a Slack or Teams renderer actually posts into a thread. +# +# Not `copilotkit.langgraph.copilotkit_emit_message`, which this used to call. +# That helper dispatches `copilotkit_manually_emit_message`, and nothing between +# here and a renderer turns that into a message: `ag_ui_langgraph` matches only +# its own `manually_emit_message`, and `copilotkit`'s +# `LangGraphAGUIAgent._dispatch_event` builds the three text events for it and +# then throws them away, returning the CUSTOM event alone. Both production +# renderers return immediately from `onCustomEvent` for any name that is not +# `on_interrupt` — so every failure notice this module sent reached nobody, and +# an approver's last word on a dead write stayed "running". +# +# Read off the adapter's own enum rather than spelled here, so a rename that +# would silently stop rendering fails at import instead. +_EMIT_MESSAGE_EVENT = CustomEventNames.ManuallyEmitMessage.value + logger = logging.getLogger(__name__) @@ -141,7 +174,14 @@ def _thread_key() -> str | None: try: configurable = ensure_config().get("configurable") or {} except Exception: - # Reading the ambient config must never be what stops a write. + # Reading the ambient config must never be what stops a write — but + # silence here costs the retry memory for every card in the process, + # and nothing else would ever say so. + logger.warning( + "[WRITE] could not read the running thread id; this write's " + "failures will not be remembered for the next card", + exc_info=True, + ) return None thread_id = configurable.get("thread_id") return str(thread_id) if thread_id else None @@ -165,71 +205,139 @@ def parse_confirm_write_response(response) -> bool: return response["confirmed"] is True +def _card_effect(value) -> str: + """One of the three words the card knows, erring towards the dangerous one. + + The card renders destructive styling unless something positively said + otherwise, so a value it cannot read is not a neutral card — it is a + dangerous-looking one. Saying `destructive` here rather than passing an + unreadable value on keeps the payload honest about which of the two it is, + and means no caller can quietly widen the vocabulary. + """ + # `isinstance` first because an unhashable value must answer `destructive` + # rather than raise: a malformed classification cannot be what stops a + # confirmation from being asked for. + if isinstance(value, str) and value in _CARD_EFFECTS: + return value + return DESTRUCTIVE + + +def _tool_effect(metadata) -> str | None: + """What an MCP tool's annotations say it does, or `None` when they don't. + + `effect_of` answers for the two hints that speak for themselves, reading + them by value: `{"readOnlyHint": False}` is a tool asserting it is **not** + a read, and the key being present claims nothing on its own. + + The third reading is this caller's alone, and it is the one `classify.WRITE` + exists for. Tags cannot express a write that is not destructive, but MCP + annotations can: a tool that denied being read-only has said more than + "unclassified" — it has said it changes something. Anything else is + unclassified, and `None` here is not a safe answer, it is no answer. + """ + metadata = metadata or {} + claimed = effect_of(metadata) + if claimed is not None: + return claimed + try: + denied_read_only = metadata.get(READ_ONLY_HINT) is False + except AttributeError: + # Not a mapping. Same answer as no annotations: nothing was claimed. + return None + return WRITE if denied_read_only else None + + def require_write_confirmation( *, action: str, fields: list[dict], + effect: str = DESTRUCTIVE, extra_args: dict | None = None, ) -> bool: - """Pause on the existing confirm_write card. Return True if approved.""" + """Pause on the existing confirm_write card. Return True if approved. + + `effect` is what the caller classified the action as, and it is the card's + only defence against styling a delete like a rename. It defaults to + `destructive` rather than to nothing: a caller that did not classify has + not established that the action is safe, and the card would fail safe + anyway — saying so here makes every card this module produces carry the + answer instead of relying on the reader to fail safe. + """ + extra = dict(extra_args or {}) + # The Composio path spells its classification as an `extra_args` entry. + # Popping it means the two spellings land in one slot rather than side by + # side, where whichever the dict merged last would silently win. + classified = extra.pop("effect", effect) _answer, response = copilotkit_interrupt( action="confirm_write", args={ "action": action, "fields": fields, - **(extra_args or {}), + "effect": _card_effect(classified), + **extra, }, ) return parse_confirm_write_response(response) +def _failure_notice(action: str, error: str) -> dict: + """One failed write, as the message payload the adapter renders.""" + text = _flatten_text(error) or error + return { + # Markdown bold, matching the cards — the platform renderers + # convert `**x**` to each surface's own bold. + "message": f"⚠️ **{action}** failed — {text}", + "message_id": str(uuid.uuid4()), + "role": "assistant", + } + + async def report_write_failure(action: str, error: str) -> None: """Tell the thread the confirmed write failed. Without this the approval card is the last word the user sees, and a rejected write is indistinguishable from a completed one. """ - text = _flatten_text(error) or error try: - # Markdown bold, matching the cards — the platform renderers - # convert `**x**` to each surface's own bold. - await copilotkit_emit_message( - ensure_config(), - f"⚠️ **{action}** failed — {text}", + await adispatch_custom_event( + _EMIT_MESSAGE_EVENT, + _failure_notice(action, error), + config=ensure_config(), ) - except Exception as emit_error: + except Exception: # The tool result still reaches the agent, which can retry or - # explain; a failed report must not also fail the turn. + # explain; a failed report must not also fail the turn. Logged with the + # exception rather than just its class name, because "RuntimeError" on + # its own names neither the cause nor the write it belonged to. logger.warning( - "[WRITE] could not report a failed write to the thread: %s", - type(emit_error).__name__, + "[WRITE] could not report a failed %s to the thread", + action, + exc_info=True, ) def emit_write_failure(action: str, error: str) -> None: - """Synchronous entry point for graph tools that cannot await.""" - - def _run() -> None: - asyncio.run(report_write_failure(action, error)) - - try: - asyncio.get_running_loop() - except RuntimeError: - try: - _run() - except Exception as emit_error: - logger.warning( - "[WRITE] could not report a failed write to the thread: %s", - type(emit_error).__name__, - ) - return + """Synchronous entry point for graph tools that cannot await. + + Dispatched from this thread, not handed to another one. LangGraph runs a + sync tool in a worker seeded with a copy of the calling context, so the + ambient config — and the callback manager the dispatch has to attach to — + is already here. The previous version hopped to a second thread and ran + `asyncio.run` inside it, which left `ensure_config()` empty because + contextvars do not cross a bare `ThreadPoolExecutor`, and blocked the + calling event loop on `.result()` whenever there was one. + """ try: - with ThreadPoolExecutor(max_workers=1) as pool: - pool.submit(_run).result() - except Exception as emit_error: + dispatch_custom_event( + _EMIT_MESSAGE_EVENT, + _failure_notice(action, error), + config=ensure_config(), + ) + except Exception: logger.warning( - "[WRITE] could not report a failed write to the thread: %s", - type(emit_error).__name__, + "[WRITE] could not report a failed %s to the thread", + action, + exc_info=True, ) @@ -243,7 +351,13 @@ class WriteConfirmationInterceptor: } def __init__(self): - self._read_only_tools = set(self._KNOWN_READ_ONLY_TOOLS) + # Tool name -> what it does, in the card's vocabulary. A name missing + # from here is one this interceptor could not classify, which is not + # the same as a harmless one: `_effect_for` answers `destructive`, so + # an unannotated tool is both gated and shown as dangerous. + self._effects: dict[str, str] = dict.fromkeys( + self._KNOWN_READ_ONLY_TOOLS, READ + ) # (thread id, tool name) -> (attempts so far, last failure text). self._failures: OrderedDict[tuple[str, str], tuple[int, str]] = ( OrderedDict() @@ -251,9 +365,23 @@ def __init__(self): def register_tools(self, tools: list[BaseTool]) -> None: for source_tool in tools: - metadata = source_tool.metadata or {} - if metadata.get("readOnlyHint") is True: - self._read_only_tools.add(source_tool.name) + if self._effects.get(source_tool.name) == READ: + # Already established as a read, and it stays one. The seeded + # Notion searches are here precisely because their own + # annotations are not what got them classified. + continue + effect = _tool_effect(source_tool.metadata) + if effect is not None: + self._effects[source_tool.name] = effect + + def _effect_for(self, name: str) -> str: + """What the card should say this tool does. + + Unclassified is `destructive`, never neutral. A tool nobody annotated + is exactly the case that must not look calm, and it is also the case + the gate below refuses to let through unasked. + """ + return self._effects.get(name, DESTRUCTIVE) def _remember_failure(self, key, error: str) -> None: if key is None: @@ -282,7 +410,11 @@ async def __call__( request: MCPToolCallRequest, handler, ) -> MCPToolCallResult: - if request.name in self._read_only_tools: + effect = self._effect_for(request.name) + if effect == READ: + # The only effect that never reaches a card: a read is not gated, + # so `read` is the reason there is no card rather than a value one + # ever renders. return await handler(request) action = request.name.replace("_", " ").replace("-", " ").strip() @@ -292,6 +424,7 @@ async def __call__( confirmed = require_write_confirmation( action=action, fields=summarize_args(request.args), + effect=effect, extra_args=self._retry_args(key), ) diff --git a/app/channel.test.ts b/app/channel.test.ts index 5c1ca2e1..d2154223 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -107,11 +107,13 @@ const channels: Channel[] = []; function confirmWriteEnvelope( action = "Create Linear issue", detail: string | null = "CPK-9: Checkout 500s", + extraArgs: Record = {}, ) { return { + __opentag_interrupt_id__: crypto.randomUUID().replaceAll("-", ""), __copilotkit_interrupt_value__: { action: "confirm_write", - args: { action, detail }, + args: { action, detail, ...extraArgs }, }, __copilotkit_messages__: [ { @@ -151,6 +153,27 @@ function findButton( return undefined; } +/** The Connect button carrying one toolkit, anywhere in a posted card. */ +function findButtonByToolkit( + nodes: ChannelNode[], + toolkit: string, +): ChannelNode | undefined { + for (const node of nodes) { + if ( + node.type === "button" && + (node.props.value as { toolkit?: string } | undefined)?.toolkit === toolkit + ) { + return node; + } + const children = node.props.children; + if (Array.isArray(children)) { + const found = findButtonByToolkit(children as ChannelNode[], toolkit); + if (found) return found; + } + } + return undefined; +} + function findIncidentButton( nodes: ChannelNode[], action: "ack" | "escalate", @@ -176,6 +199,11 @@ afterEach(async () => { channels.splice(0).map((channel) => channel.ɵruntime.stop()), ); vi.restoreAllMocks(); + // Here rather than at the end of the one test that stubs, because the end of + // a test is exactly where a failing test does not reach: a stubbed + // `AGENT_URL` would then leak into every test after it, and the suite would + // report the leak as a second failure somewhere unrelated. + vi.unstubAllEnvs(); }); function makeChannel( @@ -233,6 +261,49 @@ describe("createOpenTagChannel", () => { ).toContain("Show Kite's interactive identity"); }); + it("does not answer a revision of a message it already answered", async () => { + // The reply loop. Posting an answer counts as a change to the message that + // asked, so Slack re-announces that message with a fresh revision id and + // the ORIGINAL author still on it — indistinguishable from the person + // asking again, unless the revision itself is read. One mention here + // produced about fifty answers in a live workspace. + const { adapter, agent, channel } = makeChannel(); + await channel.ɵruntime.start(); + + const ask = { + conversationKey: "loop-thread", + replyTarget: {}, + userText: "@OpenTag say hi", + platform: "slack" as const, + actor: { id: "U1", kind: "human" as const }, + }; + + await adapter.getSink().onTurn({ + ...ask, + operation: { + kind: "created" as const, + logicalMessageId: "m1", + revisionId: "m1", + mentioned: true, + }, + }); + const afterFirstAnswer = (agent as CapturingAgent).calls.length; + expect(afterFirstAnswer).toBe(1); + + // Same message, new revision, because the answer landed in its thread. + await adapter.getSink().onTurn({ + ...ask, + operation: { + kind: "updated" as const, + logicalMessageId: "m1", + revisionId: "m1-r2", + mentioned: true, + }, + }); + + expect((agent as CapturingAgent).calls.length).toBe(afterFirstAnswer); + }); + it("subscribes a new mentioned conversation and handles a later managed delivery", async () => { const { adapter, agent, channel, stateStore } = makeChannel(); @@ -336,7 +407,12 @@ describe("createOpenTagChannel", () => { ); }); - it("falls back to a new conversation when history loading fails", async () => { + it("answers a mention without following the thread when the history is unreadable", async () => { + // Following a thread is a standing commitment to answer everything said in + // it from here on. Taking it because a history read failed is deciding on + // evidence nobody has — and it is not the cheap direction: not following + // costs the user one sentence, and the run is still offered the tool to + // act on it. const consoleError = vi .spyOn(console, "error") .mockImplementation(() => undefined); @@ -360,16 +436,21 @@ describe("createOpenTagChannel", () => { }, }); - expect(await stateStore.kv.get("sub:history-failure")).toBe(true); + // The mention is answered either way. That is the part that must not + // depend on a history read. + expect((agent as CapturingAgent).calls).toHaveLength(1); + expect( + await stateStore.kv.get("sub:history-failure"), + ).toBeUndefined(); expect(toolNames((agent as CapturingAgent).calls[0])).toContain( - unsubscribeThreadTool.name, + subscribeThreadTool.name, ); expect(consoleError).toHaveBeenCalledWith( "[channel] recoverable error", expect.objectContaining({ context: { operation: "get_thread_history", - recovery: "treat_as_new_conversation", + recovery: "answered_without_following", }, }), ); @@ -454,7 +535,11 @@ describe("createOpenTagChannel", () => { expect(toolNames(agent.calls[4])).toContain(unsubscribeThreadTool.name); }); - it.each(["bot", "app"] as const)( + // `composio_tools.state.PERSONAL_KINDS` admits `human` and nothing else, on + // the grounds that `ProviderActor.kind` is the provider's own untrusted word + // for what sent a message. A surface-side filter that stops at `bot`/`app` + // hands the other two a turn the agent would never have granted an identity. + it.each(["bot", "app", "system", "unknown"] as const)( "ignores %s-authored messages in a subscribed thread", async (actorKind) => { const { adapter, agent, channel } = makeChannel(); @@ -540,6 +625,7 @@ describe("createOpenTagChannel", () => { "triage", ]); expect(appTools.map(({ name }) => name).sort()).toEqual([ + "connect_app", "issue_card", "issue_list", "page_list", @@ -736,6 +822,86 @@ describe("createOpenTagChannel", () => { ); }); + it("says so when the surface cannot take suggested prompts at all", async () => { + // `Thread.setSuggestedPrompts` answers `{ ok: false }` on a surface with no + // such pane — it does not throw. A `try`/`catch` around it is watching the + // one door this failure never comes through, and the prompts are simply + // missing with nothing anywhere saying why. + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const adapter = new FakeAdapter({ + platform: "intelligence", + paneMethods: false, + }); + adapter.stateStore = new MemoryStore(); + const channel = createOpenTagChannel("opentag", new FakeAgent()); + channel.ɵruntime.addAdapter(adapter); + channels.push(channel); + + await channel.ɵruntime.start(); + await adapter.emitThreadStarted({ + conversationKey: "c1", + replyTarget: {}, + actor: { id: "U1", kind: "human", name: "Ada" }, + }); + + expect(adapter.suggestedPromptsCalls).toHaveLength(0); + expect(JSON.stringify(consoleError.mock.calls)).toContain( + "set_suggested_prompts", + ); + consoleError.mockRestore(); + }); + + it("logs the pair when the agent run and its error reply both fail", async () => { + // Thrown out of an unguarded handler, this is the one failure in the file + // that leaves no trace of its own: the Channel takes the throw, and the two + // errors inside it — why the run failed, and why the user was never told — + // go with it. + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { adapter, channel } = makeChannel({ + agent: new FakeAgent([ + () => { + throw new Error("agent unavailable"); + }, + ]), + }); + adapter.post = vi.fn(async () => { + throw new Error("thread is archived"); + }); + + await channel.ɵruntime.start(); + // The pair is re-raised as well as logged, and this ingress reports it + // however the runtime chooses to; the assertion below is about the log. + await Promise.resolve( + adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "hello", + platform: "slack", + actor: { id: "U1", kind: "human" }, + }), + ).catch(() => undefined); + + // Read off the logged object rather than its JSON: an Error stringifies to + // `{}`, so a JSON assertion here would pass on a log carrying no reason at + // all. + const entry = consoleError.mock.calls.find( + ([, payload]) => + (payload as { context?: { operation?: string } } | undefined)?.context + ?.operation === "run_agent_error_reply", + ); + expect(entry).toBeDefined(); + const logged = (entry![1] as { error: unknown }).error; + expect(logged).toBeInstanceOf(AggregateError); + expect( + (logged as AggregateError).errors.map((e: Error) => e.message), + ).toEqual(["agent unavailable", "thread is archived"]); + consoleError.mockRestore(); + }); + it("posts a user-facing error when the agent run fails", async () => { const error = new Error("agent unavailable"); const consoleError = vi @@ -834,7 +1000,8 @@ describe("createOpenTagChannel", () => { it("renders the interrupt's fields as a table on the posted card", async () => { const envelope = { - __copilotkit_interrupt_value__: { + __opentag_interrupt_id__: crypto.randomUUID().replaceAll("-", ""), + __copilotkit_interrupt_value__: { action: "confirm_write", args: { action: "Save project", @@ -865,6 +1032,7 @@ describe("createOpenTagChannel", () => { replyTarget: {}, userText: "save it", platform: "slack", + actor: { id: "U1", kind: "human" }, }); expect(adapter.posted).toHaveLength(1); @@ -877,6 +1045,294 @@ describe("createOpenTagChannel", () => { ]); }); + it("styles the posted card from the effect the agent classified", async () => { + // The agent looks the slug up, decides it is destructive, and sends that on + // the interrupt. Dropping it between the schema and the card leaves the red + // on Cancel and the irreversible button looking like the safe one. + const envelope = { + __opentag_interrupt_id__: crypto.randomUUID().replaceAll("-", ""), + __copilotkit_interrupt_value__: { + action: "confirm_write", + args: { + action: "Trash message (Gmail)", + fields: null, + attempt: null, + approver: null, + effect: "destructive", + }, + }, + __copilotkit_messages__: [], + }; + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: JSON.stringify(envelope), + }, + } as never); + }, + ]); + const { adapter, channel } = makeChannel({ agent }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "bin that mail", + platform: "slack", + actor: { id: "U1", kind: "human" }, + }); + + expect(adapter.posted).toHaveLength(1); + const { blocks } = renderSlackMessage(adapter.posted[0]!); + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { style?: string }[] } + | undefined; + expect(actions?.elements[0]?.style).toBe("danger"); + expect(actions?.elements[1]?.style).toBeUndefined(); + }); + + it("carries a plain write onto the card as a plain write", async () => { + // The classification is the only thing separating "the agent looked this + // up and it changes something" from "nobody could say what this does" — + // and the second is styled as a delete. Dropping `effect` between the + // schema and the card left every test in this suite green, because every + // card it checked was one the fail-safe would have reddened anyway. + const { adapter } = await postConfirmWrite({ + action: "Send email (Gmail)", + fields: null, + approver: null, + effect: "write", + }); + + const { blocks } = renderSlackMessage(adapter.posted[0]!); + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { text: { text: string }; style?: string }[] } + | undefined; + expect( + actions?.elements.map((element) => ({ + text: element.text.text, + style: element.style, + })), + ).toEqual([ + { text: "Send", style: undefined }, + { text: "Cancel", style: undefined }, + ]); + }); + + it("still reddens a write whose own words say delete", async () => { + // `NOTION_API_DELETE_A_BLOCK`, humanised, classified from + // `readOnlyHint: false`, all the way through the real schema and handler. + const { adapter } = await postConfirmWrite({ + action: "API delete a block", + effect: "write", + }); + + const { blocks } = renderSlackMessage(adapter.posted[0]!); + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { text: { text: string }; style?: string }[] } + | undefined; + expect(actions?.elements[0]).toMatchObject({ + text: { text: "Confirm" }, + style: "danger", + }); + }); + + it("names the approver on the posted card, so a colleague's click is refused", async () => { + // Whose call it is travels from the agent, through the schema, onto the + // card, and into the click. Dropping it anywhere on that path costs nothing + // visible and quietly lets anybody in the thread spend somebody else's + // connected account. + const { adapter } = await postConfirmWrite({ + action: "Send email (Gmail)", + approver: "slack:U1", + effect: "write", + }); + + await adapter.getSink().onInteraction({ + id: confirmActionId(adapter), + conversationKey: "c1", + replyTarget: {}, + // The Intelligence adapter is the transport; the provider that delivered + // the click is stamped per delivery and is what the Channel reports as + // `interaction.platform`. Leaving it off makes the click look like it + // came from the transport itself, which is a surface nobody clicks on. + platform: "slack", + messageRef: { id: "msg-1" }, + actor: { id: "U2", kind: "human", name: "Someone else" }, + value: { confirmed: true }, + }); + + expect(adapter.updated).toHaveLength(0); + expect(JSON.stringify(adapter.ephemeralPosts)).toMatch( + /only they can approve/i, + ); + }); + + it("lets the named approver answer the posted card", async () => { + const { adapter } = await postConfirmWrite({ + action: "Send email (Gmail)", + approver: "slack:U1", + effect: "write", + }); + + await adapter.getSink().onInteraction({ + id: confirmActionId(adapter), + conversationKey: "c1", + replyTarget: {}, + // The Intelligence adapter is the transport; the provider that delivered + // the click is stamped per delivery and is what the Channel reports as + // `interaction.platform`. Leaving it off makes the click look like it + // came from the transport itself, which is a surface nobody clicks on. + platform: "slack", + messageRef: { id: "msg-1" }, + actor: { id: "U1", kind: "human", name: "The owner" }, + value: { confirmed: true }, + }); + + expect(adapter.updated).toHaveLength(1); + expect(JSON.stringify(adapter.updated)).toContain("Approved"); + expect(adapter.ephemeralPosts).toHaveLength(0); + }); + + it("refuses the same id arriving from a platform the approver does not name", async () => { + // A provider id is unique only within its provider. `teams:U1` and the + // `U1` who clicked from Slack are two people, and the id alone cannot tell + // them apart — which is the whole reason the approver carries a platform. + const { adapter } = await postConfirmWrite({ + action: "Send email (Gmail)", + approver: "teams:U1", + effect: "destructive", + }); + + await adapter.getSink().onInteraction({ + id: confirmActionId(adapter), + conversationKey: "c1", + replyTarget: {}, + platform: "slack", + messageRef: { id: "msg-1" }, + actor: { id: "U1", kind: "human", name: "A different U1" }, + value: { confirmed: true }, + }); + + expect(adapter.updated).toHaveLength(0); + expect(JSON.stringify(adapter.ephemeralPosts)).toMatch( + /only they can approve/i, + ); + }); + + it("carries the retry context from the interrupt onto the posted card", async () => { + const { adapter } = await postConfirmWrite({ + action: "Save project", + attempt: 2, + previous_error: 'Team "Growth" not found', + }); + + const { blocks } = renderSlackMessage(adapter.posted[0]!); + expect(JSON.stringify(blocks)).toContain("Attempt 2"); + expect(JSON.stringify(blocks)).toContain("Growth"); + }); + + it("does not report an interrupt it never claimed to render as a broken approval card", async () => { + // "I could not show the approval card for that action" sends the reader + // looking for a card, and for the write behind it. Neither exists: the + // agent asked for something this surface has no handler for at all, and + // saying so is the difference between a bug report and a wild goose chase. + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: JSON.stringify({ + __opentag_interrupt_id__: crypto.randomUUID().replaceAll("-", ""), + __copilotkit_interrupt_value__: { + action: "connect_account", + args: { action: "Injected write", secret: "s3cret" }, + }, + __copilotkit_messages__: [], + }), + }, + } as never); + }, + ]); + const { adapter, channel } = makeChannel({ agent }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "do it", + platform: "slack", + actor: { id: "U1", kind: "human" }, + }); + + const posted = JSON.stringify(adapter.posted); + expect(posted).toMatch(/connect_account/); + expect(posted).not.toMatch(/approval card/i); + // The name of the request is all that is echoed; its arguments are the + // agent's own words about a request nobody here can read. + expect(posted).not.toContain("Injected write"); + expect(posted).not.toContain("s3cret"); + const logged = JSON.stringify(consoleError.mock.calls); + expect(logged).toContain("unsupported_interrupt"); + expect(logged).not.toContain("posted_user_facing_error"); + consoleError.mockRestore(); + }); + + it("does not claim nothing has been changed when it cannot know", async () => { + // The card gates one tool call. The turn that reached it may have written + // three other things already, and this handler saw none of them — so the + // notice speaks for the action it could not ask about, and for nothing + // else. + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: JSON.stringify({ + __opentag_interrupt_id__: crypto.randomUUID().replaceAll("-", ""), + __copilotkit_interrupt_value__: { + action: "confirm_write", + args: { fields: [{ label: "Name" }] }, + }, + __copilotkit_messages__: [], + }), + }, + } as never); + }, + ]); + const { adapter, channel } = makeChannel({ agent }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "do it", + platform: "slack", + actor: { id: "U1", kind: "human" }, + }); + + const posted = JSON.stringify(adapter.posted); + expect(posted).toMatch(/could not show the approval card/i); + expect(posted).not.toMatch(/nothing has been changed/i); + // What it can say: the action it was gating was never approved. + expect(posted).toMatch(/not approved|was not approved/i); + expect(JSON.stringify(consoleError.mock.calls)).toContain( + "confirm_write_interrupt", + ); + consoleError.mockRestore(); + }); + it("rejects malformed confirm_write interrupt payloads", async () => { const consoleError = vi .spyOn(console, "error") @@ -889,9 +1345,13 @@ describe("createOpenTagChannel", () => { name: "on_interrupt", value: JSON.stringify({ ...confirmWriteEnvelope("Injected write"), - __copilotkit_interrupt_value__: { - action: "unexpected_action", - args: { action: "Injected write" }, + __opentag_interrupt_id__: crypto.randomUUID().replaceAll("-", ""), + __copilotkit_interrupt_value__: { + action: "confirm_write", + // A confirm_write the card cannot be built from: `attempt` is + // 1-based, so this one fails the schema after the envelope has + // already been read as an approval. + args: { action: "Injected write", attempt: 0 }, }, }), }, @@ -911,8 +1371,14 @@ describe("createOpenTagChannel", () => { actor: { id: "U1", kind: "human" }, }); - expect(JSON.stringify(adapter.posted)).toMatch(/error/i); - expect(JSON.stringify(adapter.posted)).not.toContain("Injected write"); + const posted = JSON.stringify(adapter.posted); + expect(posted).toMatch(/could not show the approval card/i); + expect(posted).not.toContain("Injected write"); + const logged = JSON.stringify(consoleError.mock.calls); + expect(logged).toContain("confirm_write_interrupt"); + // Reported as an interrupt that could not be rendered, not as a run that + // recovered — the graph is still paused on a question nobody was asked. + expect(logged).not.toContain("posted_user_facing_error"); consoleError.mockRestore(); }); @@ -926,7 +1392,16 @@ describe("createOpenTagChannel", () => { event: { type: EventType.CUSTOM, name: "on_interrupt", - value: confirmWriteEnvelope("Create Linear issue", "CPK-9"), + // Stringified, as `ag_ui_langgraph` sends it, and naming an + // approver: the point of this test is that a click served by + // re-rendering the card from the store is served with the props + // the card was posted with, the approver among them. + value: JSON.stringify( + confirmWriteEnvelope("Create Linear issue", "CPK-9", { + approver: "slack:U1", + effect: "destructive", + }), + ), }, } as never); }, @@ -962,7 +1437,27 @@ describe("createOpenTagChannel", () => { id: actionId!, conversationKey: "c1", replyTarget: {}, + platform: "slack", + messageRef: { id: "msg-1" }, + actor: { id: "U2", kind: "human", name: "Someone else" }, + value: { confirmed: true }, + }); + + // A card re-rendered from the store that forgot whose call it was would + // let this through, and spend the first person's connected account. + expect(secondAdapter.updated).toHaveLength(0); + expect(secondAgent.calls).toHaveLength(0); + expect(JSON.stringify(secondAdapter.ephemeralPosts)).toMatch( + /only they can approve/i, + ); + + await secondAdapter.getSink().onInteraction({ + id: actionId!, + conversationKey: "c1", + replyTarget: {}, + platform: "slack", messageRef: { id: "msg-1" }, + actor: { id: "U1", kind: "human", name: "The owner" }, value: { confirmed: true }, }); @@ -971,6 +1466,71 @@ describe("createOpenTagChannel", () => { expect(secondAgent.calls).toHaveLength(1); }); + it("re-registers the Connect button when a new Channel uses the same store", async () => { + // `ConnectAccount` is in the component list for exactly this: the button is + // posted publicly and pressed minutes later, by several different people, + // and a click after a restart is served by re-rendering the named component + // from that list. Unregistered, the dispatch raises an expired-action error + // the Channel swallows — the person presses it and nothing happens at all. + const sharedState = new MemoryStore(); + const firstAdapter = new FakeAdapter({ platform: "intelligence" }); + firstAdapter.stateStore = sharedState; + const firstAgent = new FakeAgent([ + (subscriber) => { + subscriber.onToolCallEndEvent?.({ + event: { toolCallId: "connect-app-1" }, + toolCallName: "connect_app", + toolCallArgs: { toolkit: "gmail" }, + } as never); + subscriber.onRunFinishedEvent?.({ event: {} } as never); + }, + ]); + const firstChannel = createOpenTagChannel("opentag", firstAgent); + firstChannel.ɵruntime.addAdapter(firstAdapter); + channels.push(firstChannel); + await firstChannel.ɵruntime.start(); + await firstAdapter.getSink().onTurn({ + conversationKey: "connect-thread", + replyTarget: {}, + userText: "connect my gmail", + platform: "slack", + actor: { id: "U1", kind: "human" }, + }); + + const connectButton = findButtonByToolkit(firstAdapter.posted[0]!, "gmail"); + const actionId = (connectButton?.props.onClick as { id?: string })?.id; + expect(actionId).toMatch(/^ck:/); + await firstChannel.ɵruntime.stop(); + + const secondAdapter = new FakeAdapter({ platform: "intelligence" }); + secondAdapter.stateStore = sharedState; + const secondChannel = createOpenTagChannel("opentag", new FakeAgent()); + secondChannel.ɵruntime.addAdapter(secondAdapter); + channels.push(secondChannel); + await secondChannel.ɵruntime.start(); + // The click handler reads the environment before it reads the clicker. + vi.stubEnv("AGENT_URL", "http://agent.test"); + vi.stubEnv("INTELLIGENCE_API_KEY", "test-key"); + // Clicked by nobody the surface could name, so the handler answers from its + // own first guard and no connect link is minted or requested. + await secondAdapter.getSink().onInteraction({ + id: actionId!, + conversationKey: "connect-thread", + replyTarget: {}, + platform: "slack", + messageRef: { id: "connect-message" }, + value: { toolkit: "gmail" }, + }); + + // The notice goes to the THREAD, not to an ephemeral message: with no + // identifiable clicker there is no user id to address one to, and the old + // `postEphemeral("unknown", …)` addressed a user that does not exist. + expect(JSON.stringify(secondAdapter.posted)).toMatch( + /could not tell who clicked/i, + ); + expect(secondAdapter.ephemeralPosts).toHaveLength(0); + }); + it("re-registers incident actions when a new Channel uses the same store", async () => { const sharedState = new MemoryStore(); const firstAdapter = new FakeAdapter({ platform: "intelligence" }); @@ -1019,6 +1579,7 @@ describe("createOpenTagChannel", () => { id: actionId!, conversationKey: "incident-thread", replyTarget: {}, + platform: "slack", messageRef: { id: "incident-message" }, actor: { id: "U2", kind: "human", name: "Ada" }, value: { action: "ack", id: "INC-42" }, @@ -1031,3 +1592,264 @@ describe("createOpenTagChannel", () => { expect(JSON.stringify(secondAdapter.updated)).toContain("Ack'd by Ada"); }); }); + +/** Post one `confirm_write` card through the real interrupt handler. */ +async function postConfirmWrite(args: Record) { + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: JSON.stringify({ + __opentag_interrupt_id__: crypto.randomUUID().replaceAll("-", ""), + __copilotkit_interrupt_value__: { action: "confirm_write", args }, + __copilotkit_messages__: [], + }), + }, + } as never); + }, + ]); + const made = makeChannel({ agent }); + + await made.channel.ɵruntime.start(); + await made.adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "do it", + platform: "slack", + actor: { id: "U1", kind: "human" }, + }); + + expect(made.adapter.posted).toHaveLength(1); + return made; +} + +/** The registered action id behind the posted card's confirm button. */ +function confirmActionId(adapter: FakeAdapter): string { + const button = findButton(adapter.posted[0]!, true); + const id = (button?.props.onClick as { id?: string } | undefined)?.id; + expect(id).toMatch(/^ck:/); + return id!; +} + +describe("createOpenTagChannel error paths", () => { + it("ignores a turn the platform could not attribute to anybody", async () => { + // An ingress with no actor is normalized to `{ id: "", kind: "unknown" }`. + // Running on it is running on input nobody can be held to — and the card + // that gates the resulting writes names no approver, so anyone can answer. + const { adapter, agent, channel } = makeChannel(); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "anonymous-thread", + replyTarget: {}, + userText: "@Kite do the thing", + platform: "slack", + operation: { + kind: "created", + logicalMessageId: "m1", + revisionId: "m1", + mentioned: true, + }, + }); + + expect((agent as CapturingAgent).calls).toHaveLength(0); + }); + + it("answers the mention when the subscription lookup fails", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { adapter, agent, channel, stateStore } = makeChannel(); + vi.spyOn(stateStore.kv, "get").mockRejectedValue( + new Error("state store unavailable"), + ); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "unreadable-subscription", + replyTarget: {}, + userText: "@Kite are you there", + platform: "slack", + actor: { id: "U1", kind: "human" }, + operation: { + kind: "created", + logicalMessageId: "m1", + revisionId: "m1", + mentioned: true, + }, + }); + + // Whether the thread is subscribed decides which tool the run offers, not + // whether the person gets an answer. Dropping the mention because a lookup + // failed is silence the user has no way to tell from being ignored. + expect((agent as CapturingAgent).calls).toHaveLength(1); + expect(JSON.stringify(consoleError.mock.calls)).toContain( + "read_thread_subscription", + ); + consoleError.mockRestore(); + }); + + it("answers the mention when recording the subscription fails", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { adapter, agent, channel, stateStore } = makeChannel(); + vi.spyOn(stateStore.kv, "set").mockRejectedValue( + new Error("state store unavailable"), + ); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "unwritable-subscription", + replyTarget: {}, + userText: "@Kite follow this thread", + platform: "slack", + actor: { id: "U1", kind: "human" }, + operation: { + kind: "created", + logicalMessageId: "m1", + revisionId: "m1", + mentioned: true, + }, + }); + + expect((agent as CapturingAgent).calls).toHaveLength(1); + expect(JSON.stringify(consoleError.mock.calls)).toContain( + "record_thread_subscription", + ); + consoleError.mockRestore(); + }); + + it("answers a follow-up in a thread it has been talking in when the subscription is unreadable", async () => { + // The whole gate on an unmentioned turn. Answered `false` for a store that + // could not answer at all, a blip drops the next thing somebody says in a + // thread the bot is following — and drops it silently, which from the + // user's side is indistinguishable from being ignored. + // + // The thread itself is the second source: a conversation this bot has been + // posting into is one it belongs in, and that is a fact the store outage + // cannot take away. + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { adapter, agent, channel, stateStore } = makeChannel(); + adapter.messages = [ + { text: "@Kite triage this", ts: "1" }, + { text: "On it — here is what I found.", ts: "2", isBot: true }, + ]; + vi.spyOn(stateStore.kv, "get").mockRejectedValue( + new Error("state store unavailable"), + ); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "unreadable-follow-up", + replyTarget: {}, + userText: "and what about the second one?", + platform: "slack", + actor: { id: "U1", kind: "human" }, + operation: { + kind: "created", + logicalMessageId: "m3", + revisionId: "m3", + mentioned: false, + }, + }); + + expect((agent as CapturingAgent).calls).toHaveLength(1); + // Neither subscription tool is offered: the store cannot say what the + // thread's subscription is, so the run must not offer to change it. + const offered = toolNames((agent as CapturingAgent).calls[0]); + expect(offered).not.toContain(subscribeThreadTool.name); + expect(offered).not.toContain(unsubscribeThreadTool.name); + expect(JSON.stringify(consoleError.mock.calls)).toContain( + "read_thread_subscription", + ); + consoleError.mockRestore(); + }); + + it("stays out of a thread it has never spoken in when the subscription is unreadable", async () => { + // The other side of the same coin. Answering every message in every thread + // the bot can see, for as long as the store is down, is a failure the + // people in those threads cannot opt out of. + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { adapter, agent, channel, stateStore } = makeChannel(); + adapter.messages = [ + { text: "shipping tomorrow", ts: "1" }, + { text: "nice", ts: "2" }, + ]; + vi.spyOn(stateStore.kv, "get").mockRejectedValue( + new Error("state store unavailable"), + ); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "unreadable-bystander", + replyTarget: {}, + userText: "anyone got the link?", + platform: "slack", + actor: { id: "U1", kind: "human" }, + operation: { + kind: "created", + logicalMessageId: "m3", + revisionId: "m3", + mentioned: false, + }, + }); + + expect((agent as CapturingAgent).calls).toHaveLength(0); + // Skipped, but not silently: a turn dropped on a guess nobody can see is + // the same failure one layer down. + expect(JSON.stringify(consoleError.mock.calls)).toContain( + "skipped_unmentioned_turn", + ); + consoleError.mockRestore(); + }); + + it("says the card could not be shown, rather than quoting a ZodError", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: "{broken", + }, + } as never); + }, + ]); + const { adapter, channel } = makeChannel({ agent }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "file this", + platform: "slack", + actor: { id: "U1", kind: "human" }, + operation: { + kind: "created", + logicalMessageId: "m1", + revisionId: "m1", + mentioned: true, + }, + }); + + const posted = JSON.stringify(adapter.posted); + expect(posted).toMatch(/approval/i); + // A parser's own vocabulary is not a message to a person, and it is what + // the thread showed: "I hit an error: ZodError: [.". + expect(posted).not.toMatch(/ZodError|SyntaxError/); + expect(JSON.stringify(consoleError.mock.calls)).toContain( + "confirm_write_interrupt", + ); + consoleError.mockRestore(); + }); +}); diff --git a/app/channel.tsx b/app/channel.tsx index 311a2c38..99bd573e 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -3,6 +3,8 @@ import { type Channel, type ChannelTool, type CreateChannelOptions, + type ProviderActor, + type Thread, } from "@copilotkit/channels"; import { managedRunInput, @@ -13,8 +15,14 @@ import { appCommands } from "./commands/index.js"; import { IssueCard, IssueList, PageList } from "./components/index.js"; import { createAppContext } from "./context/app-context.js"; import { DEFAULT_AGENT_DISPLAY_NAME } from "./env.js"; -import { ConfirmWrite } from "./human-in-the-loop/index.js"; -import { parseConfirmWriteInterrupt } from "./interrupt.js"; +import { ConnectAccount } from "./human-in-the-loop/index.js"; +import { createConfirmWrite } from "./human-in-the-loop/confirm-write.js"; +import { createApprovalDecisions } from "./human-in-the-loop/approval-decisions.js"; +import { + parseInterrupt, + type ConfirmWriteArgs, + type ParsedInterrupt, +} from "./interrupt.js"; import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; import { IncidentCard } from "./tools/showcase-tools.js"; import { RenderChart } from "./tools/render-chart.js"; @@ -26,12 +34,103 @@ import { type ChannelAgent = NonNullable; +/** + * Whether a message is a person asking for something. + * + * `ProviderActor.kind` is the provider's own word for what sent a message, and + * the Channels SDK documents it as untrusted metadata rather than + * authorization — which is why it is read here as a filter and never as a + * grant. `human` and nothing else, the same set the agent's + * `composio_tools.state.PERSONAL_KINDS` admits, so both ends of the wire agree + * about who is speaking: `bot` and `app` are the reply loop, `system` is the + * platform talking about the channel rather than into it, and `unknown` is a + * message the surface could not attribute to anybody — which is also what an + * ingress carrying no actor at all is normalized to. + * + * Optional-chained like every other read of `actor` in this app. The Channel + * normalizes one in, so this only ever fires for something that bypassed it — + * and that should be refused, not turned into a `TypeError` where a decision + * belongs. + */ +function isFromAPerson(message: { actor?: ProviderActor }): boolean { + return message.actor?.kind === "human"; +} + +/** + * Whether this is somebody asking, rather than the same ask arriving again. + * + * Posting an answer counts as a change to the message that asked for it, so + * Slack re-announces that message with a fresh revision id and the original + * author still on it. Read as a message it is indistinguishable from the person + * asking a second time — same thread, same text, same human — so the answer + * triggers the next question and the bot talks to itself. One mention produced + * about fifty answers in a live workspace. + * + * The revision is the only thing that tells them apart, so it is what gets read. + * A `created` message is a new ask. An `updated` one is not, and neither is a + * `deleted` tombstone. + * + * The cost, stated rather than hidden: somebody who edits a message to add the + * mention is not answered, and has to say it again. That is worth one loop. + * CopilotKit#6717 fixes this properly, one layer down, by comparing the text + * against the previous revision — evidence this side cannot see. Delete this + * once the pinned `@copilotkit/channels` carries that fix. + */ +function isAFreshAsk(message: { operation?: { kind?: string } }): boolean { + return (message.operation?.kind ?? "created") === "created"; +} + +/** + * What the thread is told when its approval card could not be rendered. + * + * It speaks for the action it was gating and for nothing else. The card sits in + * front of one tool call, and the turn that reached it may have written three + * other things first — none of which this handler saw. "Nothing has been + * changed" was a claim about the whole turn made by the one part of it that + * cannot see any of that. + * + * What it can say is exact: no answer was ever collected, so the write behind + * this card was never approved, so it did not run. + */ +const APPROVAL_CARD_FAILED = + "⚠️ I could not show the approval card for that action, so it was not approved and has not run. Anything else in this turn may already have happened. Please ask again."; + +/** Interrupt names safe to quote back into a thread verbatim. */ +const INTERRUPT_NAME = /^[a-z0-9_.:-]{1,64}$/i; + +/** + * What the thread is told when the agent pauses for something this surface has + * no handler for. + * + * The name is quoted because it is the only thing that makes the notice + * actionable — and only the name, because the arguments belong to a request + * nothing here could read, and a thread is not the place to paste them. A name + * that is not a plain identifier is not quoted at all. + */ +function unsupportedInterruptNotice(action: string): string { + const named = INTERRUPT_NAME.test(action) + ? `\`${action}\`` + : "an unrecognised request"; + return ( + `⚠️ The agent paused for ${named}, which I do not know how to show, ` + + "so I could not put it in front of you. Nothing was approved. Please ask again." + ); +} + /** Build the managed OpenTag Channel; Intelligence owns its platform adapters. */ export function createOpenTagChannel( name: string, agent: ChannelAgent, agentDisplayName = DEFAULT_AGENT_DISPLAY_NAME, ): Channel { + const decisions = createApprovalDecisions(() => { + const store = channel.adapters.find( + (adapter) => adapter.stateStore, + )?.stateStore; + if (!store) throw new Error("The Channel has no durable approval store"); + return store; + }); + const ConfirmWrite = createConfirmWrite(decisions.claim); const channel = createChannel({ name, agent, @@ -45,6 +144,12 @@ export function createOpenTagChannel( PageList, IncidentCard, ConfirmWrite, + // Load-bearing, not bookkeeping: once the in-process cache is gone, a + // click is served by re-rendering the named component from here. An + // unregistered card's buttons raise an error the Channel swallows, so the + // person clicks and nothing happens at all. `ConnectAccount` exists to be + // pressed minutes later, by several different people. + ConnectAccount, RenderChart, ], }); @@ -65,10 +170,19 @@ export function createOpenTagChannel( userFacingRunError(error, { sourceText: message.text }), ); } catch (postError) { - throw new AggregateError( + const bothFailed = new AggregateError( [error, postError], "The agent run and its user-facing error reply both failed", ); + // Recorded here because there is nowhere else. This handler is + // unguarded, the Channel takes the throw and keeps no copy of it, and + // the user was never told either — so without this line the turn + // simply stops, and both reasons stop with it. + reportRecoverableError(bothFailed, { + operation: "run_agent_error_reply", + recovery: "none_the_user_was_never_told", + }); + throw bothFailed; } // A failed turn is isolated from future turns. Once the user receives an @@ -80,27 +194,126 @@ export function createOpenTagChannel( } }; + /** + * Whether this thread is one the bot follows — with `unknown` as an answer. + * + * Three values rather than two because the two call sites below want + * different things from a store that cannot answer. On a mention it decides + * which subscription tool the run offers, and the mention is answered either + * way. On an unmentioned turn it is the WHOLE gate, and a `false` invented + * for an unreadable store drops the next thing somebody says in a thread the + * bot is following — silently, which from their side looks like being + * ignored. Collapsing the two into one boolean is what made a store blip + * cost a turn. + */ + const readSubscription = async ( + thread: MessageHandlerInput["thread"], + ): Promise<"following" | "not-following" | "unknown"> => { + try { + return (await thread.isSubscribed()) ? "following" : "not-following"; + } catch (error) { + reportRecoverableError(error, { + operation: "read_thread_subscription", + recovery: "asked_the_thread_instead", + }); + return "unknown"; + } + }; + + /** + * Whether this Channel has been posting in this thread. + * + * The second source, consulted only when the store cannot say. A thread the + * bot has been answering in is one it belongs in, and that is a fact the + * conversation itself carries — no store required. It is a weaker signal + * than a subscription (a one-off mention answered here also leaves a bot + * message behind) and it is deliberately the weaker mistake: over-answering + * inside a conversation the bot is already part of, rather than either + * dropping a follow-up or barging into every thread on the workspace for as + * long as the outage lasts. + */ + const hasSpokenHere = async ( + thread: MessageHandlerInput["thread"], + ): Promise => { + try { + const history = await thread.getMessages(); + return Array.isArray(history) && history.some((m) => m.isBot === true); + } catch (error) { + reportRecoverableError(error, { + operation: "read_thread_participation", + recovery: "treat_as_a_thread_we_are_not_in", + }); + return false; + } + }; + + /** + * Say something in the thread, and log it when the thread will not take it. + * + * The last step of every failure path here, so the failure of the last step + * is the one place a failure cannot be reported anywhere else. + */ + const tellThread = async ( + thread: Pick, + text: string, + operation: string, + ): Promise => { + try { + await thread.post(text); + } catch (postError) { + reportRecoverableError(postError, { + operation, + recovery: "none_the_thread_shows_nothing", + }); + } + }; + channel.onMention(async ({ thread, message }) => { - if (message.actor.kind === "bot" || message.actor.kind === "app") return; + if (!isFromAPerson(message) || !isAFreshAsk(message)) return; - if (await thread.isSubscribed()) { + const subscription = await readSubscription(thread); + if (subscription === "following") { await runAgentSafely({ thread, message }, [unsubscribeThreadTool]); return; } + if (subscription === "unknown") { + // A mention is answered whatever the store is doing. No subscription + // tool is offered with it: the run would be acting on a state nobody + // could read, and "unsubscribe" from a thread that was never subscribed + // is an answer to a question the user did not ask. + await runAgentSafely({ thread, message }, []); + return; + } - let isNewConversation = true; + // Only a history that positively says so. Following a thread is a standing + // commitment to answer everything said in it from here on, and it is taken + // on the strength of one read: an empty history on the managed adapter, + // whose transcript excludes the in-flight turn, or a single message on a + // local one, where the mention itself is that message. A read that failed + // says nothing about either, and used to say "new". + let isNewConversation = false; try { const history = await thread.getMessages(); - isNewConversation = !history || history.length <= 1; + isNewConversation = Array.isArray(history) && history.length <= 1; } catch (error) { reportRecoverableError(error, { operation: "get_thread_history", - recovery: "treat_as_new_conversation", + recovery: "answered_without_following", }); } if (isNewConversation) { - await thread.subscribe(); + try { + await thread.subscribe(); + } catch (error) { + // Following the thread is an affordance for later turns. This turn is + // an answered mention either way, and a failed write here used to + // throw past the run that had not happened yet. + reportRecoverableError(error, { + operation: "record_thread_subscription", + recovery: "answered_without_subscribing", + }); + } await runAgentSafely({ thread, message }, [unsubscribeThreadTool]); return; } @@ -109,33 +322,117 @@ export function createOpenTagChannel( }); channel.onMessage(async ({ thread, message }) => { - if (message.actor.kind === "bot" || message.actor.kind === "app") return; + if (!isFromAPerson(message) || !isAFreshAsk(message)) return; - if (await thread.isSubscribed()) { + const subscription = await readSubscription(thread); + if (subscription === "following") { await runAgentSafely({ thread, message }, [unsubscribeThreadTool]); + return; + } + if (subscription === "not-following") return; + + if (await hasSpokenHere(thread)) { + await runAgentSafely({ thread, message }, []); + return; } + // Nothing said the bot belongs in this thread, so it stays out — but a + // dropped turn that nobody can see is the same defect one layer down, so + // it says so where an operator can find it. + reportRecoverableError( + new Error( + "Unreadable subscription in a thread this Channel has not posted in", + ), + { + operation: "skipped_unmentioned_turn", + recovery: "answer_by_mention", + }, + ); }); channel.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); channel.onInterrupt("on_interrupt", async ({ payload, thread }) => { - const { args } = parseConfirmWriteInterrupt(payload); - await thread.post( - , - ); + let interrupt: ParsedInterrupt; + try { + interrupt = parseInterrupt(payload); + } catch (error) { + // The only handler here that had no guard, and the one whose failure is + // least visible: `parseInterrupt` throws on a payload it cannot read, + // the graph stays paused on a question nobody was asked, and the thread + // showed the parser's own words — "I hit an error: ZodError: [." — + // logged as a run that recovered. + reportRecoverableError(error, { + operation: "confirm_write_interrupt", + recovery: "posted_card_failure_notice", + }); + await tellThread( + thread, + APPROVAL_CARD_FAILED, + "confirm_write_interrupt_notice", + ); + return; + } + + if (interrupt.kind !== "confirm_write") { + // Not this surface's card to render, and not a failure of it. Reported + // under its own name so the log says which handler is missing rather + // than implicating the one that works. + // + // The graph stays paused either way, here and below. `Thread.resume` + // needs the one-use continuation a button click carries, and this Thread + // came from a turn — so answering the interrupt from inside the handler + // that failed to ask the question rejects with + // `ChannelContinuationRequiredError` before it sends anything. Which is + // why the notice tells the person what to do instead of implying the + // agent has moved on. + reportRecoverableError( + new Error(`No handler for interrupt "${interrupt.action}"`), + { + operation: "unsupported_interrupt", + recovery: "posted_unsupported_notice", + }, + ); + await tellThread( + thread, + unsupportedInterruptNotice(interrupt.action), + "unsupported_interrupt_notice", + ); + return; + } + + if (!interrupt.interruptId) { + await tellThread( + thread, + "I could not safely identify this approval, so I did not show an actionable card. Update the agent and ask again for a fresh approval; this action has not run.", + "confirm_write_missing_interrupt_id", + ); + return; + } + + try { + const decisionId = await decisions.register(thread.conversationKey); + await postConfirmWriteCard(thread, interrupt.args, ConfirmWrite, decisionId, interrupt.interruptId); + } catch (error) { + reportRecoverableError(error, { + operation: "confirm_write_interrupt", + recovery: "posted_card_failure_notice", + }); + await tellThread( + thread, + APPROVAL_CARD_FAILED, + "confirm_write_interrupt_notice", + ); + } }); channel.onThreadStarted(async ({ thread, user }) => { if (!user?.name) return; try { - await thread.setSuggestedPrompts([ + // `{ ok: false }`, not a throw, is how a surface with no prompt pane + // answers — and how the adapter reports a call it rejected. A `catch` + // alone watches the one door this failure does not come through. + const result = await thread.setSuggestedPrompts([ { title: "Synthesize this discussion", message: @@ -147,6 +444,15 @@ export function createOpenTagChannel( "Compare the options in this thread and recommend a path forward", }, ]); + if (!result?.ok) { + reportRecoverableError( + new Error(result?.error ?? "suggested prompts were not set"), + { + operation: "set_suggested_prompts", + recovery: "continue_without_suggested_prompts", + }, + ); + } } catch (error) { // Suggested prompts are an optional affordance; their absence does not // affect message delivery, agent execution, or later thread turns. @@ -159,3 +465,34 @@ export function createOpenTagChannel( return channel; } + +/** + * Post the approval card this interrupt is asking for. + * + * Extracted so the handler above is a guard and nothing else. + */ +async function postConfirmWriteCard( + thread: Pick, + args: ConfirmWriteArgs, + ConfirmWrite: ReturnType, + decisionId: string, + interruptId: string, +): Promise { + await thread.post( + , + ); +} diff --git a/app/env.test.ts b/app/env.test.ts index a4de71b7..3312d0da 100644 --- a/app/env.test.ts +++ b/app/env.test.ts @@ -25,6 +25,33 @@ describe("readEnvironment", () => { ).toThrow("Missing required env var: INTELLIGENCE_API_KEY"); }); + it.each(["", " ", "\n"])( + "treats a required variable set to %j as missing", + (blank) => { + // Same reasoning as the optional ones: a declared-but-empty variable is + // how a deploy platform's UI says "not set", and an all-whitespace + // AGENT_URL fails much later, inside `new URL()`, with no name attached. + expect(() => + readEnvironment({ ...requiredEnvironment, AGENT_URL: blank }), + ).toThrow("Missing required env var: AGENT_URL"); + expect(() => + readEnvironment({ ...requiredEnvironment, INTELLIGENCE_API_KEY: blank }), + ).toThrow("Missing required env var: INTELLIGENCE_API_KEY"); + }, + ); + + it("trims the required variables it does accept", () => { + expect( + readEnvironment({ + AGENT_URL: " http://localhost:8123/ ", + INTELLIGENCE_API_KEY: " cpk_test ", + }), + ).toMatchObject({ + agentUrl: "http://localhost:8123/", + intelligenceApiKey: "cpk_test", + }); + }); + it("uses the Intelligence, channel-name, and port defaults", () => { expect(readEnvironment(requiredEnvironment)).toMatchObject({ agentDisplayName: "OpenTag", @@ -82,16 +109,124 @@ describe("readEnvironment", () => { ).toMatchObject({ agentDisplayName: "Kite" }); }); - it("does not expose platform credentials owned by Intelligence", () => { + it("reads the shared secret the runtime presents to the agent", () => { + // `AGENT_AUTH_HEADER` unread here is `AGENT_AUTH_HEADER` never sent: the + // agent then answers 401 and nothing in this suite noticed. + expect( + readEnvironment({ + ...requiredEnvironment, + AGENT_AUTH_HEADER: "Bearer s3cret", + }), + ).toMatchObject({ agentAuthHeader: "Bearer s3cret" }); + expect(readEnvironment(requiredEnvironment).agentAuthHeader).toBeUndefined(); + }); + + it.each(["", " ", "\n"])( + "treats an AGENT_AUTH_HEADER of %j as unset rather than as a secret", + (AGENT_AUTH_HEADER) => { + // A blank value is truthy everywhere it is checked and authorizes + // nothing, so it reads as "configured" while every request comes back + // 401. + expect( + readEnvironment({ ...requiredEnvironment, AGENT_AUTH_HEADER }) + .agentAuthHeader, + ).toBeUndefined(); + }, + ); + + it("trims AGENT_AUTH_HEADER, because a trailing newline is not a header value", () => { + // Every neighbouring variable is trimmed and this one was not. A value + // pasted with a newline makes `fetch` reject the request outright, so all + // agent traffic fails at once with nothing pointing at the cause. + expect( + readEnvironment({ + ...requiredEnvironment, + AGENT_AUTH_HEADER: " Bearer s3cret\n", + }).agentAuthHeader, + ).toBe("Bearer s3cret"); + }); + + it.each(["", " "])( + "falls back to the Intelligence defaults when the overrides are %j", + (blank) => { + // `??` only replaces `undefined`, so a variable declared and left empty — + // the normal shape of an unset value in a deploy platform's UI — became + // an empty URL and an empty channel name. Every other variable here uses + // `||` and treats blank as unset. + expect( + readEnvironment({ + ...requiredEnvironment, + INTELLIGENCE_API_URL: blank, + INTELLIGENCE_GATEWAY_WS_URL: blank, + INTELLIGENCE_CHANNEL_NAME: blank, + }), + ).toMatchObject({ + intelligenceApiUrl: DEFAULT_INTELLIGENCE_API_URL, + intelligenceGatewayWsUrl: DEFAULT_INTELLIGENCE_GATEWAY_WS_URL, + channelName: DEFAULT_INTELLIGENCE_CHANNEL_NAME, + }); + }, + ); + + it("trims the Intelligence overrides it does keep", () => { + expect( + readEnvironment({ + ...requiredEnvironment, + INTELLIGENCE_API_URL: " https://intelligence.example.test ", + INTELLIGENCE_GATEWAY_WS_URL: " wss://realtime.example.test ", + INTELLIGENCE_CHANNEL_NAME: " custom-channel ", + }), + ).toMatchObject({ + intelligenceApiUrl: "https://intelligence.example.test", + intelligenceGatewayWsUrl: "wss://realtime.example.test", + channelName: "custom-channel", + }); + }); + + it("reads PORT, the port the process is told to listen on", () => { + // `parsePort` is exercised directly below, but nothing passed `PORT` + // through `readEnvironment` itself: renaming the key it reads left all 29 + // tests green while the container bound 3000 and the platform routed every + // request to the port it had assigned. + expect(readEnvironment({ ...requiredEnvironment, PORT: "4242" })).toMatchObject( + { port: 4242 }, + ); + // And the rejection reaches the caller from here too, rather than the + // process falling back to a default port nothing routes to. + expect(() => + readEnvironment({ ...requiredEnvironment, PORT: "0" }), + ).toThrow('Invalid PORT: "0"'); + }); + + it("reads no platform credential, Slack tokens included", () => { + // Intelligence owns the Slack and Teams edges, and no platform token + // belongs in this repository. This app once read the Slack pair to attach + // its own adapter beside the managed one; that hatch is gone, so the + // variables have to stop reaching the environment at all. const environment = readEnvironment({ ...requiredEnvironment, SLACK_BOT_TOKEN: "xoxb-unused", + SLACK_APP_TOKEN: "xapp-unused", TEAMS_CLIENT_ID: "teams-unused", }); - expect(environment).not.toHaveProperty("slackBotToken"); - expect(environment).not.toHaveProperty("teamsClientId"); - expect(environment).not.toHaveProperty("teamsPort"); + // The whole key set, deliberately. A `not.toHaveProperty("slackDirect")` + // cannot fail once the field is gone and would say nothing about a field + // that replaced it under another name. + expect(Object.keys(environment).sort()).toEqual([ + "agentAuthHeader", + "agentDisplayName", + "agentUrl", + "channelName", + "intelligenceApiKey", + "intelligenceApiUrl", + "intelligenceGatewayWsUrl", + "learningContainerId", + "port", + ]); + // Nothing carried the values through under a different shape either. + expect(JSON.stringify(environment)).not.toContain("xoxb-unused"); + expect(JSON.stringify(environment)).not.toContain("xapp-unused"); }); }); diff --git a/app/env.ts b/app/env.ts index 4faa03ef..b3d70e8b 100644 --- a/app/env.ts +++ b/app/env.ts @@ -17,8 +17,9 @@ export interface AppEnvironment { port: number; } +/** Trimmed, and blank counts as missing — a deploy UI's "unset" is an empty string. */ function required(env: NodeJS.ProcessEnv, name: string): string { - const value = env[name]; + const value = env[name]?.trim(); if (!value) { throw new Error(`Missing required env var: ${name}`); } @@ -46,17 +47,31 @@ export function readEnvironment( agentDisplayName: env.AGENT_DISPLAY_NAME?.trim() || DEFAULT_AGENT_DISPLAY_NAME, agentUrl: required(env, "AGENT_URL"), - agentAuthHeader: env.AGENT_AUTH_HEADER, + // Trimmed like every neighbour, and blank means unset. This one value goes + // out as an HTTP header: a trailing newline is not a legal header value and + // makes `fetch` reject every request to the agent, and a whitespace-only + // value reads as "a secret is configured" everywhere it is checked while + // authorizing nothing. + agentAuthHeader: env.AGENT_AUTH_HEADER?.trim() || undefined, intelligenceApiKey: required(env, "INTELLIGENCE_API_KEY"), + // `||` rather than `??`: a variable declared and left empty is how a deploy + // platform's UI represents "not set", and `??` let that empty string defeat + // the default and become an empty URL. intelligenceApiUrl: - env.INTELLIGENCE_API_URL ?? DEFAULT_INTELLIGENCE_API_URL, + env.INTELLIGENCE_API_URL?.trim() || DEFAULT_INTELLIGENCE_API_URL, intelligenceGatewayWsUrl: - env.INTELLIGENCE_GATEWAY_WS_URL ?? + env.INTELLIGENCE_GATEWAY_WS_URL?.trim() || DEFAULT_INTELLIGENCE_GATEWAY_WS_URL, learningContainerId: env.INTELLIGENCE_LEARNING_CONTAINER_ID?.trim() || undefined, channelName: - env.INTELLIGENCE_CHANNEL_NAME ?? DEFAULT_INTELLIGENCE_CHANNEL_NAME, - port: parsePort(env.PORT), + env.INTELLIGENCE_CHANNEL_NAME?.trim() || DEFAULT_INTELLIGENCE_CHANNEL_NAME, + // `?.trim() || undefined` like every neighbour, and for the same reason: + // this module's rule is that a blank value counts as unset, because a + // deploy platform's UI represents "not set" as a declared empty string. + // `PORT` alone did not follow it — `parsePort("")` throws, so a `PORT` row + // left empty in a deploy UI aborted boot rather than falling back to the + // default every other variable here falls back to. + port: parsePort(env.PORT?.trim() || undefined), }; } diff --git a/app/human-in-the-loop/__tests__/approval-decisions.test.tsx b/app/human-in-the-loop/__tests__/approval-decisions.test.tsx new file mode 100644 index 00000000..24128a5b --- /dev/null +++ b/app/human-in-the-loop/__tests__/approval-decisions.test.tsx @@ -0,0 +1,251 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentSubscriber, RunAgentParameters, RunAgentResult } from "@ag-ui/client"; +import { createChannel, FakeAdapter, FakeAgent, MemoryStore, type Channel, type ChannelNode } from "@copilotkit/channels"; +import { createOpenTagChannel } from "../../channel.js"; +import { createApprovalDecisions } from "../approval-decisions.js"; +import { createConfirmWrite } from "../confirm-write.js"; + +const ConfirmWrite = createConfirmWrite(async () => false); + +type Step = (subscriber: AgentSubscriber) => void; +class RecordingAgent extends FakeAgent { + constructor(readonly steps: Step[], readonly calls: Array = []) { + super(); + } + override clone() { + const clone = new RecordingAgent(this.steps, this.calls); + clone.threadId = this.threadId; + clone.agentId = this.agentId; + clone.messages = structuredClone(this.messages); + clone.state = structuredClone(this.state); + return clone; + } + override async runAgent(parameters?: RunAgentParameters, subscriber?: AgentSubscriber): Promise { + this.calls.push(parameters); + const step = this.steps.shift(); + if (step && subscriber) step(subscriber); + return { result: undefined, newMessages: [] }; + } +} + +const channels: Channel[] = []; +afterEach(async () => { + await Promise.all(channels.splice(0).map((channel) => channel.ɵruntime.stop())); + vi.restoreAllMocks(); +}); + +let interruptSequence = 0; +const interrupt: Step = (subscriber) => { + subscriber.onCustomEvent?.({ event: { + name: "on_interrupt", + value: { __opentag_interrupt_id__: (++interruptSequence).toString(16).padStart(32, "0"), __copilotkit_interrupt_value__: { + action: "confirm_write", + args: { action: "Gmail send email", approver: "slack:alice", effect: "destructive" }, + } }, + } } as never); +}; + +function buttons(nodes: ChannelNode[]): string[] { + return nodes.flatMap((node) => [ + ...(node.type === "button" ? [String((node.props.onClick as { id: string }).id)] : []), + ...buttons((node.props.children ?? []) as ChannelNode[]), + ]); +} + +async function runtime(store: MemoryStore, steps: Step[], legacy = false) { + const adapter = new FakeAdapter({ platform: "slack" }); + adapter.stateStore = store; + const agent = new RecordingAgent(steps); + const channel = legacy + ? createChannel({ name: "approval-tests", identifyUser: "platform", agent, adapters: [adapter], components: [ConfirmWrite] }) + : createOpenTagChannel("approval-tests", agent); + if (legacy) { + channel.onMention(async ({ thread }) => { await thread.runAgent(); }); + channel.onInterrupt("on_interrupt", async ({ thread }) => { + await thread.post(); + }); + } else { + channel.ɵruntime.addAdapter(adapter); + } + channels.push(channel); + await channel.ɵruntime.start(); + let event = 0; + return { + adapter, agent, channel, + async turn() { + await adapter.getSink().onTurn({ + conversationKey: "thread-1", replyTarget: {}, userText: "@OpenTag send email", platform: "slack", + actor: { id: "alice", kind: "human" }, + operation: { kind: "created", logicalMessageId: `m${++event}`, revisionId: `r${event}`, mentioned: true }, + }); + }, + async click(id: string, conversationKey = "thread-1") { + await adapter.getSink().onInteraction({ + id, conversationKey, replyTarget: {}, messageRef: { id: "card-1" }, + eventId: `click-${crypto.randomUUID()}`, actor: { id: "alice", kind: "human" }, + }); + }, + }; +} + +describe("approval decisions through the SDK", () => { + it("targets the original interrupt when a newer turn pauses during the old card's update", async () => { + const current = await runtime(new MemoryStore(), [interrupt, interrupt]); + await current.turn(); + const oldId = interruptSequence.toString(16).padStart(32, "0"); + const [approve] = buttons(current.adapter.posted[0]!); + let unblock!: () => void; + let entered!: () => void; + const updating = new Promise((resolve) => { entered = resolve; }); + const blocked = new Promise((resolve) => { unblock = resolve; }); + const update = current.adapter.update.bind(current.adapter); + vi.spyOn(current.adapter, "update").mockImplementationOnce(async (ref, nodes) => { + entered(); + await blocked; + return update(ref, nodes); + }); + const click = current.click(approve!); + await updating; + await current.turn(); + const newId = interruptSequence.toString(16).padStart(32, "0"); + expect(newId).not.toBe(oldId); + unblock(); + await click; + expect(current.agent.calls).toHaveLength(3); + expect(current.agent.calls[2]?.forwardedProps?.command).toMatchObject({ + resume: { [oldId]: { confirmed: true } }, + }); + expect(current.agent.calls[2]?.forwardedProps?.command?.resume).not.toHaveProperty(newId); + expect(current.agent.calls[2]?.forwardedProps?.command?.resume).not.toHaveProperty("confirmed"); + }); + + it("refuses a wrong conversation before consuming the original card", async () => { + const current = await runtime(new MemoryStore(), [interrupt]); + await current.turn(); + const [approve] = buttons(current.adapter.posted[0]!); + await current.click(approve!, "different-thread"); + expect(current.agent.calls).toHaveLength(1); + await current.click(approve!); + expect(current.agent.calls).toHaveLength(2); + }); + + it("posts no actionable card for an agent that sends no interrupt ID", async () => { + const uncorrelated: Step = (subscriber) => { + subscriber.onCustomEvent?.({ event: { + name: "on_interrupt", + value: { __copilotkit_interrupt_value__: { + action: "confirm_write", args: { action: "Send email" }, + } }, + } } as never); + }; + const current = await runtime(new MemoryStore(), [uncorrelated]); + await current.turn(); + expect(buttons(current.adapter.posted.flat())).toHaveLength(0); + expect(JSON.stringify(current.adapter.posted)).toContain("Update the agent"); + }); + + it("cannot consume a new card's token when separate runtimes interleave registration and a stale claim", async () => { + const shared = new MemoryStore(); + const firstStore = new MemoryStore(); + const otherStore = new MemoryStore(); + firstStore.kv = shared.kv; + otherStore.kv = shared.kv; + const first = createApprovalDecisions(() => firstStore); + const other = createApprovalDecisions(() => otherStore); + const oldId = await first.register("thread"); + const consume = shared.kv.consume.bind(shared.kv); + let newId = ""; + vi.spyOn(shared.kv, "consume").mockImplementationOnce(async (key) => { + newId = await other.register("thread"); + return consume(key); + }); + expect(await first.claim("thread", oldId)).toBe(false); + expect(await other.claim("thread", newId)).toBe(true); + expect(await first.claim("thread", newId)).toBe(false); + }); + + it.each([true, false])("keeps both restored buttons on one decision (first confirmed=%s)", async (confirmed) => { + const store = new MemoryStore(); + const first = await runtime(store, [interrupt]); + await first.turn(); + const [approve, cancel] = buttons(first.adapter.posted[0]!); + const firstId = interruptSequence.toString(16).padStart(32, "0"); + await first.channel.ɵruntime.stop(); + + const restored = await runtime(store, [interrupt]); + await restored.click(confirmed ? approve! : cancel!); + expect(restored.agent.calls).toHaveLength(1); + expect(restored.agent.calls[0]?.forwardedProps?.command).toMatchObject({ resume: { [firstId]: { confirmed } } }); + await restored.click(confirmed ? cancel! : approve!); + expect(restored.agent.calls).toHaveLength(1); + expect(JSON.stringify(restored.adapter.ephemeralPosts)).toContain("already been answered"); + const [nextApprove] = buttons(restored.adapter.posted[0]!); + const nextId = interruptSequence.toString(16).padStart(32, "0"); + await restored.click(nextApprove!); + expect(restored.agent.calls).toHaveLength(2); + expect(restored.agent.calls[1]?.forwardedProps?.command).toMatchObject({ resume: { [nextId]: { confirmed: true } } }); + }); + + it("allows only one simultaneous restored button to resume", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const store = new MemoryStore(); + const first = await runtime(store, [interrupt]); + await first.turn(); + const ids = buttons(first.adapter.posted[0]!); + await first.channel.ɵruntime.stop(); + const restored = await runtime(store, []); + await Promise.all(ids.map((id) => restored.click(id))); + expect(restored.agent.calls).toHaveLength(1); + }); + + it("keeps the decision spent when updating the card fails, including after another restart", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const store = new MemoryStore(); + const first = await runtime(store, [interrupt]); + await first.turn(); + const [approve, cancel] = buttons(first.adapter.posted[0]!); + vi.spyOn(first.adapter, "update").mockRejectedValue(new Error("Slack unavailable")); + await first.click(approve!); + expect(first.agent.calls).toHaveLength(2); + await first.channel.ɵruntime.stop(); + const restored = await runtime(store, []); + await restored.click(cancel!); + expect(restored.agent.calls).toHaveLength(0); + }); + + it("does not let a superseded card consume the current decision", async () => { + const current = await runtime(new MemoryStore(), [interrupt, interrupt]); + await current.turn(); + const [stale] = buttons(current.adapter.posted[0]!); + await current.turn(); + const [latest] = buttons(current.adapter.posted[1]!); + await current.click(stale!); + expect(current.agent.calls).toHaveLength(2); + await current.click(latest!); + expect(current.agent.calls).toHaveLength(3); + }); + + it("refuses legacy saved cards that have no durable decision ID", async () => { + const store = new MemoryStore(); + const first = await runtime(store, [interrupt], true); + await first.turn(); + const [approve] = buttons(first.adapter.posted[0]!); + await first.channel.ɵruntime.stop(); + const restored = await runtime(store, []); + await restored.click(approve!); + expect(restored.agent.calls).toHaveLength(0); + expect(JSON.stringify(restored.adapter.posted)).toContain("fresh card"); + }); + + it("fails closed when claiming the persisted decision is unavailable", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const store = new MemoryStore(); + const current = await runtime(store, [interrupt]); + await current.turn(); + const [approve] = buttons(current.adapter.posted[0]!); + vi.spyOn(store.kv, "consume").mockRejectedValue(new Error("store unavailable")); + await current.click(approve!); + expect(current.agent.calls).toHaveLength(1); + expect(JSON.stringify(current.adapter.posted)).toContain("did not send your answer"); + }); +}); diff --git a/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx b/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx new file mode 100644 index 00000000..9db3abbb --- /dev/null +++ b/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx @@ -0,0 +1,329 @@ +/** + * Who may answer an approval card. + * + * A call that runs in one person's own connected account spends that person's + * access, so a colleague pressing approve would spend somebody else's. The agent + * can only say whose call it is; the surface knows who clicked, so the rule is + * enforced here. + */ +import { describe, expect, it, vi } from "vitest"; +import type { + ClickHandler, + EphemeralResult, + InteractionContext, + MessageRef, + Renderable, +} from "@copilotkit/channels"; +import { createConfirmWrite } from "../confirm-write.js"; + +const INTERRUPT_ID = "0123456789abcdef0123456789abcdef"; +const unitCard = createConfirmWrite(async () => true); +const ConfirmWrite = (props: Parameters[0]) => unitCard({ interruptId: INTERRUPT_ID, decisionId: "unit-test", conversationKey: "unit-thread", ...props }); + +/** + * The card's buttons, as click handlers: confirm first, decline second. + * + * The count is asserted rather than assumed. Indexing positionally into a list + * whose length nobody checks is how a test goes on passing while pressing + * something else — or, once the buttons are gone, nothing at all. + */ +function cardButtons(node: unknown): { + confirm: ClickHandler; + decline: ClickHandler; +} { + const found: ClickHandler[] = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + if (!value || typeof value !== "object") return; + const element = value as { + props?: Record; + children?: unknown; + }; + const onClick = element.props?.onClick; + if (typeof onClick === "function") { + found.push(onClick as ClickHandler); + } + if (element.props?.children) visit(element.props.children); + if (element.children) visit(element.children); + }; + visit(node); + expect(found).toHaveLength(2); + return { confirm: found[0]!, decline: found[1]! }; +} + +/** + * The part of an interaction a `ConfirmWrite` click reads. + * + * Narrowed on purpose, and checked with `satisfies` rather than cast away with + * `as never`: the mock's method signatures are then held to the real ones, so a + * fake that resolves to the wrong shape — the `postEphemeral` that answers + * `null` on a surface with no ephemeral message, say — cannot quietly drift out + * of step with the interface the card is written against. + */ +type ClickContext = Pick< + InteractionContext, + "actor" | "platform" | "message" +> & { + thread: { conversationKey: string } & Pick< + InteractionContext["thread"], + "update" | "resume" | "post" | "postEphemeral" + >; +}; + +function interaction( + actorId: string, + overrides: { + platform?: string; + postEphemeral?: ClickContext["thread"]["postEphemeral"]; + } = {}, +) { + const update = vi.fn( + async (_ref: MessageRef, _ui: Renderable): Promise => ({ + id: "m1", + }), + ); + const resume = vi.fn( + async (_value: unknown): Promise => undefined, + ); + const post = vi.fn(async (_ui: Renderable): Promise => ({ + id: "m2", + })); + const postEphemeral = vi.fn( + overrides.postEphemeral ?? + (async (): Promise => ({ + ok: true, + usedFallback: false, + })), + ); + const actor = { id: actorId, kind: "human" } as const; + const platform = overrides.platform ?? "slack"; + const ctx = { + actor, + platform, + thread: { conversationKey: "unit-thread", update, resume, postEphemeral, post }, + message: { + text: "", + user: null, + actor, + ref: { id: "m1" }, + platform, + }, + } satisfies ClickContext; + + return { + ctx: ctx as unknown as InteractionContext, + update, + resume, + post, + postEphemeral, + }; +} + +describe("ConfirmWrite approver", () => { + it("lets the named person answer", async () => { + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, postEphemeral } = interaction("U1"); + + await confirm(ctx); + + expect(update).toHaveBeenCalled(); + expect(postEphemeral).not.toHaveBeenCalled(); + }); + + it("refuses anybody else, and leaves the card for the right person", async () => { + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, resume, postEphemeral } = interaction("U2"); + + await confirm(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + // Told privately where the surface can, and by DM where it cannot. The + // notice names nobody, so it is not a secret that has to stay undelivered + // — unlike a connect link, which is a bearer capability and does not fall + // back to a DM. + expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: true }); + }); + + it("refuses the decline button too, not only approve", async () => { + const { decline } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, postEphemeral } = interaction("U2"); + + await decline(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); + + it("lets anyone answer a workspace action, which names no approver", async () => { + const { confirm } = cardButtons( + ConfirmWrite({ action: "Create issue" }), + ); + const { ctx, update, postEphemeral } = interaction("U2"); + + await confirm(ctx); + + expect(update).toHaveBeenCalled(); + expect(postEphemeral).not.toHaveBeenCalled(); + }); + + it("says so in the thread when the surface cannot deliver privately", async () => { + // `postEphemeral` resolves to `null` on a surface with no ephemeral + // message — the managed adapter reports exactly that. Ignoring the answer + // makes the refusal invisible: the person clicks, nothing happens, and the + // card sits there looking unclicked. + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, resume, post, postEphemeral } = interaction("U2", { + postEphemeral: async () => null, + }); + + await confirm(ctx); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledTimes(1); + expect(resume).not.toHaveBeenCalled(); + // The notice names nobody, so a public fallback leaks no account. + expect(JSON.stringify(post.mock.calls[0])).toMatch(/only they can approve/i); + }); + + it("still refuses, and says so, when the private message throws", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, resume, post } = interaction("U2", { + postEphemeral: async () => { + throw new Error("ephemeral unavailable"); + }, + }); + + await confirm(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(post).toHaveBeenCalledTimes(1); + consoleError.mockRestore(); + }); + + it("has no platform it waves through, not even `unknown`", async () => { + // `composio_tools.state.KNOWN_PLATFORMS` is closed, and `_named_identity` + // refuses anything outside it, so `actor_key` cannot spell an approver + // `unknown:`. A prefix this card matched on trust would be a platform check + // that any producer could opt out of by naming a platform nobody serves. + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "unknown:U1" }), + ); + const { ctx, update, resume, postEphemeral } = interaction("U1"); + + await confirm(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); + + it("refuses an approver carrying no platform at all", async () => { + // Not reachable from the agent — `actor_key` writes `platform:id` or + // nothing — which is exactly why it is asserted here rather than assumed. + // Read as a bare id, `U1` would match its own id and let this card be + // answered by whoever shares it on any surface. + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "U1" }), + ); + const { ctx, update, resume, postEphemeral } = interaction("U1"); + + await confirm(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); + + it("refuses an approver whose id half is empty", async () => { + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:" }), + ); + const { ctx, update, resume, postEphemeral } = interaction("U1"); + + await confirm(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); + + it("refuses a click nobody can be identified with", async () => { + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, resume } = interaction(""); + + await confirm(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + }); + + it("does not match a person on another platform who shares an id", async () => { + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "teams:U1" }), + ); + const { ctx, update, postEphemeral } = interaction("U1"); + + await confirm(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); + + it("does not tell a colleague the card is waiting once it has been answered", async () => { + // One card, one answer — and the approver already gave it. "The card is + // still waiting for them" is then a statement about a card that is waiting + // for nobody, sent to the one person it misleads. The check that costs a + // message has to come after the check that says there is nothing to say. + const { confirm, decline } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const approver = interaction("U1"); + const colleague = interaction("U2"); + + await confirm(approver.ctx); + await decline(colleague.ctx); + + expect(approver.resume).toHaveBeenCalledTimes(1); + expect(colleague.resume).not.toHaveBeenCalled(); + expect(colleague.postEphemeral).not.toHaveBeenCalled(); + expect(colleague.post).not.toHaveBeenCalled(); + }); + + it("spells the platform the way the agent does, so casing cannot refuse the right person", async () => { + // The approver string is built by `actor_key` in the agent, which lowercases + // the platform. A surface reporting "Slack" would otherwise never match the + // `slack:U1` the card names, and the one person entitled to answer could not. + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, postEphemeral } = interaction("U1", { + platform: "Slack", + }); + + await confirm(ctx); + + expect(update).toHaveBeenCalled(); + expect(postEphemeral).not.toHaveBeenCalled(); + }); +}); diff --git a/app/human-in-the-loop/__tests__/confirm-write.test.tsx b/app/human-in-the-loop/__tests__/confirm-write.test.tsx index e1aedc99..b43768c6 100644 --- a/app/human-in-the-loop/__tests__/confirm-write.test.tsx +++ b/app/human-in-the-loop/__tests__/confirm-write.test.tsx @@ -1,5 +1,8 @@ import { describe, it, expect, vi } from "vitest"; import { + ActionExpiredError, + ActionContinuationMismatchError, + ChannelContinuationRequiredError, renderToIR, type ChannelNode, type InteractionContext, @@ -7,7 +10,34 @@ import { } from "@copilotkit/channels"; import { renderSlackMessage } from "@copilotkit/channels/slack"; import { renderAdaptiveCard } from "@copilotkit/channels/teams"; -import { ConfirmWrite } from "../confirm-write.js"; +import { + createConfirmWrite, + type ConfirmWriteEffect, +} from "../confirm-write.js"; + +const INTERRUPT_ID = "0123456789abcdef0123456789abcdef"; +const unitCard = createConfirmWrite(async () => true); +const ConfirmWrite = (props: Parameters[0]) => unitCard({ interruptId: INTERRUPT_ID, decisionId: "unit-test", conversationKey: "unit-thread", ...props }); + +it.each([undefined, "invalid-id"])("refuses a saved card without a usable interrupt ID: %s", async (interruptId) => { + const claim = vi.fn(async () => true); + const BoundCard = createConfirmWrite(claim); + const button = buttonByText(renderToIR(BoundCard({ + action: "Delete customer", + decisionId: "saved-decision", + conversationKey: "unit-thread", + interruptId, + })), "Delete"); + const post = vi.fn(async () => ({ id: "notice" })); + const resume = vi.fn(); + await (button.props.onClick as ClickHandler)({ + thread: { conversationKey: "unit-thread", post, resume }, + message: { ref: { id: "saved-card" } }, + } as unknown as InteractionContext); + expect(claim).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(post).toHaveBeenCalledWith(expect.stringContaining("fresh card")); +}); /** Children of an IR node as an array (empty if none). */ function childNodes(node: ChannelNode): ChannelNode[] { @@ -336,14 +366,14 @@ describe("ConfirmWrite", () => { const update = vi.fn(async () => ({ id: "m1" })); const resume = vi.fn(async () => ({ id: "m2" })); const ctx = { - thread: { update, resume }, + thread: { conversationKey: "unit-thread", update, resume }, message: { ref: { id: "m1" } }, } as unknown as InteractionContext; await (create.props.onClick as ClickHandler)(ctx); expect(update).toHaveBeenCalledTimes(1); - expect(resume).toHaveBeenCalledWith({ confirmed: true }); + expect(resume).toHaveBeenCalledWith({ [INTERRUPT_ID]: { confirmed: true } }); expect(update.mock.invocationCallOrder[0]).toBeLessThan( resume.mock.invocationCallOrder[0]!, ); @@ -370,7 +400,7 @@ describe("ConfirmWrite", () => { const save = buttonByText(ir, "Save"); const update = vi.fn(async () => ({ id: "m1" })); const ctx = { - thread: { update, resume: vi.fn(async () => ({ id: "m2" })) }, + thread: { conversationKey: "unit-thread", update, resume: vi.fn(async () => ({ id: "m2" })) }, message: { ref: { id: "m1" } }, } as unknown as InteractionContext; @@ -392,23 +422,105 @@ describe("ConfirmWrite", () => { expect(context?.elements[0]?.text).not.toMatch(/written|wrote|saved|done/i); }); - it("does not resume approval when the status update fails", async () => { + it("does not lose an approved decision when the card update fails", async () => { + // The card is the receipt, not the decision. The graph is paused on the + // answer the person already gave; dropping it because Slack would not + // repaint a message leaves that graph paused for good. + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const ir = renderToIR(); const create = buttonByText(ir, "Create"); - const failure = new Error("status update unavailable"); const update = vi.fn(async () => { - throw failure; + throw new Error("status update unavailable"); }); const resume = vi.fn(async () => ({ id: "m2" })); const ctx = { - thread: { update, resume }, + thread: { conversationKey: "unit-thread", update, resume }, message: { ref: { id: "m1" } }, } as unknown as InteractionContext; - await expect( - (create.props.onClick as ClickHandler)(ctx), - ).rejects.toBe(failure); - expect(resume).not.toHaveBeenCalled(); + await (create.props.onClick as ClickHandler)(ctx); + + expect(resume).toHaveBeenCalledWith({ [INTERRUPT_ID]: { confirmed: true } }); + consoleError.mockRestore(); + }); + + it("answers once when the same button is pressed twice", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Create"); + const update = vi.fn(async () => ({ id: "m1" })); + const resume = vi.fn(async () => ({ id: "m2" })); + const ctx = { + thread: { conversationKey: "unit-thread", update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + await (create.props.onClick as ClickHandler)(ctx); + await (create.props.onClick as ClickHandler)(ctx); + + // The second press lands on a graph that is no longer paused. + expect(resume).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("does not let a later Cancel overturn an approval already resumed", async () => { + const ir = renderToIR(); + const update = vi.fn(async () => ({ id: "m1" })); + const resume = vi.fn(async () => ({ id: "m2" })); + const ctx = { + thread: { conversationKey: "unit-thread", update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + await (buttonByText(ir, "Create").props.onClick as ClickHandler)(ctx); + await (buttonByText(ir, "Cancel").props.onClick as ClickHandler)(ctx); + + expect(resume).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledWith({ [INTERRUPT_ID]: { confirmed: true } }); + }); + + it("takes the classified effect over the verb when styling the confirm button", () => { + // "Trash message (Gmail)" leads with a verb no local list calls dangerous. + // The agent classified it and the card must use that, or the red sits on + // Cancel while the irreversible button looks like the inviting one. + const ir = renderToIR( + , + ); + const { blocks } = renderSlackMessage(ir); + + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { style?: string }[] } + | undefined; + expect(actions?.elements[0]?.style).toBe("danger"); + expect(actions?.elements[1]?.style).toBeUndefined(); + }); + + it("keeps a destructive verb dangerous even when the effect says otherwise", () => { + const ir = renderToIR(); + const { blocks } = renderSlackMessage(ir); + + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { style?: string }[] } + | undefined; + expect(actions?.elements[0]?.style).toBe("danger"); + }); + + it("leaves a classified write unwarned, and unendorsed", () => { + const ir = renderToIR( + , + ); + const { blocks } = renderSlackMessage(ir); + + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { style?: string }[] } + | undefined; + // No warning colour, because the agent classified it and did not call it + // destructive. No endorsement colour either: `write` is what + // `readOnlyHint: false` becomes, and "I am not read-only" is not a claim + // that clicking is safe. + expect(actions?.elements[0]?.style).toBeUndefined(); + expect(actions?.elements[1]?.style).toBeUndefined(); }); it("cancel onClick updates the picker and resumes the interrupted agent", async () => { @@ -422,14 +534,14 @@ describe("ConfirmWrite", () => { const update = vi.fn(async () => ({ id: "m1" })); const resume = vi.fn(async () => ({ id: "m2" })); const ctx = { - thread: { update, resume }, + thread: { conversationKey: "unit-thread", update, resume }, message: { ref: { id: "m1" } }, } as unknown as InteractionContext; await (cancel.props.onClick as ClickHandler)(ctx); expect(update).toHaveBeenCalledTimes(1); - expect(resume).toHaveBeenCalledWith({ confirmed: false }); + expect(resume).toHaveBeenCalledWith({ [INTERRUPT_ID]: { confirmed: false } }); expect(update.mock.invocationCallOrder[0]).toBeLessThan( resume.mock.invocationCallOrder[0]!, ); @@ -451,26 +563,28 @@ describe("ConfirmWrite", () => { expect(context?.elements[0]?.text).toContain("Declined"); }); - it("does not resume a decline when the status update fails", async () => { + it("does not lose a decline when the card update fails", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const ir = renderToIR(); const cancel = buttonByText(ir, "Cancel"); - const failure = new Error("status update unavailable"); const update = vi.fn(async () => { - throw failure; + throw new Error("status update unavailable"); }); const resume = vi.fn(async () => ({ id: "m2" })); const ctx = { - thread: { update, resume }, + thread: { conversationKey: "unit-thread", update, resume }, message: { ref: { id: "m1" } }, } as unknown as InteractionContext; - await expect( - (cancel.props.onClick as ClickHandler)(ctx), - ).rejects.toBe(failure); - expect(resume).not.toHaveBeenCalled(); + await (cancel.props.onClick as ClickHandler)(ctx); + + expect(resume).toHaveBeenCalledWith({ [INTERRUPT_ID]: { confirmed: false } }); + consoleError.mockRestore(); }); - it("replaces the optimistic card with a retry state when resume fails", async () => { + it("replaces the optimistic card with an unknown outcome when resume fails", async () => { const ir = renderToIR( , ); @@ -481,7 +595,7 @@ describe("ConfirmWrite", () => { throw failure; }); const ctx = { - thread: { update, resume }, + thread: { conversationKey: "unit-thread", update, resume }, message: { ref: { id: "m1" } }, } as unknown as InteractionContext; @@ -498,10 +612,13 @@ describe("ConfirmWrite", () => { renderToIR(failedRenderable), ); expect(accent).toBe("#EB5757"); - expect(JSON.stringify(blocks)).toMatch(/couldn.t resume|retry/i); + expect(JSON.stringify(blocks)).toMatch(/cannot say whether it ran/i); }); - it("surfaces both resume and retry-card failures", async () => { + it("surfaces both resume and correction-card failures", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const ir = renderToIR(); const create = buttonByText(ir, "Create"); const resumeFailure = new Error("resume unavailable"); @@ -514,7 +631,7 @@ describe("ConfirmWrite", () => { throw resumeFailure; }); const ctx = { - thread: { update, resume }, + thread: { conversationKey: "unit-thread", update, resume }, message: { ref: { id: "m1" } }, } as unknown as InteractionContext; @@ -530,5 +647,456 @@ describe("ConfirmWrite", () => { resumeFailure, updateFailure, ]); + consoleError.mockRestore(); + }); +}); + +/** + * The agent fails safe: `EffectMap.effect_for` answers `destructive` for a slug + * it could not classify, and for one whose lookup failed. A card that renders + * anything it does not recognise as neutral inverts that decision on the far + * side of the wire — the one place where the person deciding can see it. + */ +describe("ConfirmWrite effect fail-safe", () => { + const confirmStyle = (node: Parameters[0]) => { + const { blocks } = renderSlackMessage(renderToIR(node)); + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { style?: string }[] } + | undefined; + return actions?.elements[0]?.style; + }; + + it("treats an unclassified action as destructive, not as safe", () => { + // A verb the card's own list does not know, and no classification at all. + // Neutral here says "this is fine" about an action nobody has vouched for. + expect(confirmStyle()).toBe( + "danger", + ); + }); + + it("treats an effect outside the agent's vocabulary as destructive", () => { + expect( + confirmStyle( + , + ), + ).toBe("danger"); + }); + + it("still renders a classified write without the warning", () => { + // The fail-safe must not swallow the distinction it exists to protect: an + // action the agent looked up and called a plain write still reads + // differently from one nobody could classify. + expect( + confirmStyle(), + ).toBeUndefined(); + expect(confirmStyle()).toBe( + "danger", + ); + }); +}); + +/** + * What happens after `thread.resume` throws. + * + * The failure is not evidence that nothing ran: `resume` fails on the way out + * as readily as on the way in, and a destructive write whose approval landed + * before the connection dropped has already happened. + */ +describe("ConfirmWrite after a failed resume", () => { + const failingResumeCtx = () => { + const update = vi.fn(async () => ({ id: "m1" })); + const failure = new Error("resume unavailable"); + const resume = vi.fn(async () => { + throw failure; + }); + return { + failure, + update, + resume, + ctx: { + thread: { conversationKey: "unit-thread", update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext, + }; + }; + + it("does not resume twice when the first resume may already have landed", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Delete"); + const { ctx, resume, failure } = failingResumeCtx(); + + await expect((create.props.onClick as ClickHandler)(ctx)).rejects.toBe( + failure, + ); + await expect((create.props.onClick as ClickHandler)(ctx)).resolves.toBe( + undefined, + ); + + // One press, one answer. The card is already replaced by a button-less one, + // so a second `resume` cannot be a retry of anything — it is the same + // approval applied twice. + expect(resume).toHaveBeenCalledTimes(1); + }); + + it("does not let a failed approve be answered again as a decline", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Delete"); + const cancel = buttonByText(ir, "Cancel"); + const { ctx, resume, failure } = failingResumeCtx(); + + await expect((create.props.onClick as ClickHandler)(ctx)).rejects.toBe( + failure, + ); + await (cancel.props.onClick as ClickHandler)(ctx); + + expect(resume).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledWith({ [INTERRUPT_ID]: { confirmed: true } }); + }); + + it("does not claim the write never ran, and does not invite a retry", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Delete"); + const { ctx, update, failure } = failingResumeCtx(); + + await expect((create.props.onClick as ClickHandler)(ctx)).rejects.toBe( + failure, + ); + + const [, failedRenderable] = update.mock.calls[1] as unknown as [ + { id: string }, + Parameters[0], + ]; + const { blocks } = renderSlackMessage(renderToIR(failedRenderable)); + const text = JSON.stringify(blocks); + + // The approval may have been applied before the failure. Saying it was not + // is the one thing this card must never do. + expect(text).toMatch(/may already have been applied/i); + // And the card it replaces has no buttons, so "retry" points at nothing. + expect(text).not.toMatch(/retry/i); + }); + + it("reports the receipt it could not correct", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const ir = renderToIR(); + const create = buttonByText(ir, "Delete"); + const updateFailure = new Error("retry card unavailable"); + const update = vi + .fn() + .mockResolvedValueOnce({ id: "m1" }) + .mockRejectedValueOnce(updateFailure); + const resume = vi.fn(async () => { + throw new Error("resume unavailable"); + }); + const ctx = { + thread: { conversationKey: "unit-thread", update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + await expect( + (create.props.onClick as ClickHandler)(ctx), + ).rejects.toBeInstanceOf(AggregateError); + + // The thread is left showing "✅ Approved" for a write nobody can vouch + // for. Throwing alone leaves no trace naming that card. + expect(JSON.stringify(consoleError.mock.calls)).toContain( + "confirm_write_outcome_unknown", + ); + consoleError.mockRestore(); + }); +}); + +/** + * One card, one answer. + * + * The guard lives in the closure both buttons share, so it has to be pressed + * from both to be tested at all: a suite that only ever presses the same button + * twice cannot tell a shared flag from two independent ones. + */ +describe("ConfirmWrite one-answer guard", () => { + const clicked = () => { + const update = vi.fn(async () => ({ id: "m1" })); + const resume = vi.fn(async () => ({ id: "m2" })); + return { + update, + resume, + ctx: { + thread: { conversationKey: "unit-thread", update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext, + }; + }; + + it("resumes once when the same button is pressed twice", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Create"); + const { ctx, resume, update } = clicked(); + + await (create.props.onClick as ClickHandler)(ctx); + await (create.props.onClick as ClickHandler)(ctx); + + expect(resume).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("resumes once when an approve is followed by a decline", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Create"); + const cancel = buttonByText(ir, "Cancel"); + const { ctx, resume } = clicked(); + + await (create.props.onClick as ClickHandler)(ctx); + await (cancel.props.onClick as ClickHandler)(ctx); + + // Both buttons close over one flag. Two flags would let the second press + // resume a graph that is no longer paused — with the opposite answer. + expect(resume).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledWith({ [INTERRUPT_ID]: { confirmed: true } }); + }); + + it("resumes once when a decline is followed by an approve", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Create"); + const cancel = buttonByText(ir, "Cancel"); + const { ctx, resume } = clicked(); + + await (cancel.props.onClick as ClickHandler)(ctx); + await (create.props.onClick as ClickHandler)(ctx); + + expect(resume).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledWith({ [INTERRUPT_ID]: { confirmed: false } }); + }); +}); + +/** + * The danger the leading word does not show. + * + * `readOnlyHint: false` is a tool saying "I am not read-only". It is not a + * claim of safety, and the MCP interceptor turns it into `write` — so `write` + * is the effect under which a delete arrives whenever nobody classified it any + * further. `NOTION_API_DELETE_A_BLOCK` reaches this card as + * "API delete a block", whose leading word is the name of a protocol. + * + * Asserted on what Slack is handed, text and style together. A suite that + * checked the classification instead of the button watched a delete render as + * `{"text": "API", "style": "primary"}` and stayed green. + */ +describe("ConfirmWrite danger the leading word hides", () => { + const buttons = (node: Parameters[0]) => { + const { blocks } = renderSlackMessage(renderToIR(node)); + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { text: { text: string }; style?: string }[] } + | undefined; + return (actions?.elements ?? []).map((element) => ({ + text: element.text.text, + style: element.style, + })); + }; + + it("renders a delete as dangerous when the delete is not the leading word", () => { + // Measured on this exact input before this test existed: + // {"text": "API", "style": "primary"} — a green button labelled after a + // protocol, on a card that deletes a block. + expect( + buttons()[0], + ).toEqual({ text: "Confirm", style: "danger" }); + }); + + it("reads every word of the action, not only the first", () => { + for (const action of [ + "API delete a block", + "Notion archive a page", + "Gmail remove a label", + "Slack revoke a token", + ]) { + expect(buttons()[0]).toEqual( + { text: "Confirm", style: "danger" }, + ); + } + }); + + it("never offers the confirm button as the inviting one", () => { + // `primary` is Slack's endorsement colour. This card is only ever posted + // for an action that changes something, and `write` says which of the two + // dangerous readings applies — not that the change is safe to wave through. + expect( + buttons()[0], + ).toEqual({ text: "Send", style: undefined }); + expect( + JSON.stringify( + renderSlackMessage( + renderToIR(), + ).blocks, + ), + ).not.toContain("primary"); + }); + + it("never paints Cancel as the dangerous choice", () => { + // The file's own rule: red marks the irreversible choice. On a card whose + // confirm button is not red, painting the escape hatch red inverts it. + for (const node of [ + , + , + , + ]) { + expect(buttons(node)[1]).toEqual({ text: "Cancel", style: undefined }); + } + }); + + it("keeps naming the verb when the verb is what makes the action dangerous", () => { + // Dropping to "Confirm" everywhere would cost the one label that tells an + // approver what the button does. It is only spent where the leading word + // would misdescribe the click. + expect(buttons()[0]).toEqual({ + text: "Delete", + style: "danger", + }); + expect(buttons()[0]).toEqual({ + text: "Sync", + style: "danger", + }); + }); +}); + +/** + * What the card says when the answer never left it. + * + * `thread.resume` rejects with `ChannelContinuationRequiredError` before it + * sends anything: the one-use continuation behind this card's button is gone — + * expired, or already spent by an earlier press. Nothing reached the graph, so + * the write it gates cannot have run. "It may already have been applied" is + * then a warning about something that provably did not happen, and it sends + * the reader to check a system that never heard from us. + */ +describe("ConfirmWrite when the answer never left the card", () => { + const clickWith = async (failure: unknown) => { + const ir = renderToIR(); + const del = buttonByText(ir, "Delete"); + const update = vi.fn(async () => ({ id: "m1" })); + const resume = vi.fn(async () => { + throw failure; + }); + const ctx = { + thread: { conversationKey: "unit-thread", update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + await expect((del.props.onClick as ClickHandler)(ctx)).rejects.toBe(failure); + + const [, renderable] = update.mock.calls[1] as unknown as [ + { id: string }, + Parameters[0], + ]; + return JSON.stringify(renderSlackMessage(renderToIR(renderable)).blocks); + }; + + it("does not warn about a write that could not have happened", async () => { + const text = await clickWith(new ChannelContinuationRequiredError()); + + expect(text).not.toMatch(/may already have been applied/i); + expect(text).toMatch(/never reached|was not sent/i); + }); + + it("says the card was already answered or has expired, and what to do", async () => { + const text = await clickWith(new ChannelContinuationRequiredError()); + + expect(text).toMatch(/already answered|expired/i); + expect(text).toMatch(/asking again/i); + }); + + it("reads the code as well as the class, across two copies of the SDK", async () => { + // `instanceof` is one `node_modules` layout away from being false for the + // very error it names. The code is the contract the SDK documents. + const text = await clickWith( + Object.assign(new Error("Channel resume requires a valid interaction continuation"), { + code: "channel_continuation_required", + }), + ); + + expect(text).not.toMatch(/may already have been applied/i); + }); + + it.each([ + new ActionExpiredError("expired-action"), + new ActionContinuationMismatchError(), + Object.assign(new Error("expired in another SDK copy"), { code: "channel_action_expired" }), + Object.assign(new Error("wrong binding in another SDK copy"), { code: "channel_continuation_mismatch" }), + ])("recognizes SDK continuation failures before the agent run: %s", async (error) => { + const text = await clickWith(error); + expect(text).toMatch(/was not sent/); + expect(text).not.toContain("outcome unknown"); + expect(text).toContain("earlier answer may already have run"); + }); + + it("still says the outcome is unknown when the send itself failed", async () => { + // A dropped connection is not evidence that nothing ran: an approval whose + // request reached the graph before the socket died has been applied. + const text = await clickWith(new Error("socket hang up")); + + expect(text).toMatch(/may already have been applied/i); + }); +}); + +/** + * The single-answer guarantee where the closure cannot reach. + * + * A card whose replacement failed keeps its buttons, and a press after a + * restart is served by re-rendering the component — a new closure, with + * `answered` back to false. What stops that press writing a second time is the + * one-use continuation the SDK claims on the first resume. + */ +describe("ConfirmWrite pressed again after its buttons could not be removed", () => { + it("does not write twice, and does not report the second press as an unknown outcome", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + // First press: the approval lands, the repaint does not, so the card in + // the thread still has both buttons on it. + const firstRender = renderToIR(); + const failingUpdate = vi.fn(async () => { + throw new Error("status update unavailable"); + }); + const firstResume = vi.fn(async () => ({ id: "m2" })); + await (buttonByText(firstRender, "Delete").props.onClick as ClickHandler)({ + thread: { conversationKey: "unit-thread", update: failingUpdate, resume: firstResume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext); + expect(firstResume).toHaveBeenCalledTimes(1); + + // Second press, minutes and one restart later: the click is served by + // re-rendering the registered component, and this closure has never seen + // the first answer. The continuation has. + const secondRender = renderToIR(); + const update = vi.fn(async () => ({ id: "m1" })); + const spent = new ChannelContinuationRequiredError(); + const resume = vi.fn(async () => { + throw spent; + }); + + await expect( + (buttonByText(secondRender, "Delete").props.onClick as ClickHandler)({ + thread: { conversationKey: "unit-thread", update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext), + ).rejects.toBe(spent); + + const [, renderable] = update.mock.calls[1] as unknown as [ + { id: string }, + Parameters[0], + ]; + const text = JSON.stringify( + renderSlackMessage(renderToIR(renderable)).blocks, + ); + // Nothing was sent by this press, and the person who made it is told that + // rather than sent to look for a second delete. + expect(text).toMatch(/already answered|expired/i); + expect(text).not.toMatch(/may already have been applied/i); + consoleError.mockRestore(); }); }); diff --git a/app/human-in-the-loop/__tests__/connect-account.test.tsx b/app/human-in-the-loop/__tests__/connect-account.test.tsx new file mode 100644 index 00000000..f93f0220 --- /dev/null +++ b/app/human-in-the-loop/__tests__/connect-account.test.tsx @@ -0,0 +1,249 @@ +/** + * The Connect button's click path. + * + * The card is posted publicly and pressed minutes later, so its handler is + * re-derived rather than remembered — and a throw on that path is the failure + * this whole card was shaped to avoid: the person presses it and nothing + * happens, with nothing anywhere to explain it. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToIR } from "@copilotkit/channels"; +import type { + ClickHandler, + EphemeralResult, + InteractionContext, + MessageRef, + ProviderActor, + Renderable, +} from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; +import { renderAdaptiveCard } from "@copilotkit/channels/teams"; +import { ConnectAccount, ConnectLink } from "../connect-account.js"; + +/** + * The click handler's own module, with a switch on the one failure the card's + * catch exists for. Kept delegating by default so the test below that exercises + * the real click path still does. + */ +const boot = vi.hoisted(() => ({ fails: false })); +vi.mock("../../tools/connect-click.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + handleConnectClick: async ( + ...args: Parameters + ) => { + if (boot.fails) throw new Error("connect-click could not be loaded"); + return actual.handleConnectClick(...args); + }, + }; +}); + +/** The card's single button, as a click handler. */ +function connectButton(node: unknown): ClickHandler { + const found: ClickHandler[] = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + if (!value || typeof value !== "object") return; + const element = value as { + props?: Record; + children?: unknown; + }; + if (typeof element.props?.onClick === "function") { + found.push(element.props.onClick as ClickHandler); + } + if (element.props?.children) visit(element.props.children); + if (element.children) visit(element.children); + }; + visit(node); + expect(found).toHaveLength(1); + return found[0]!; +} + +function interaction( + actor: ProviderActor | undefined, + ephemeral: EphemeralResult | null = { ok: true, usedFallback: false }, +) { + const postEphemeral = vi.fn( + async ( + _user: ProviderActor | string, + _ui: Renderable, + _opts: { fallbackToDM: boolean }, + ): Promise => ephemeral, + ); + const post = vi.fn(async (_ui: Renderable): Promise => ({ + id: "m2", + })); + return { + ctx: { + actor, + platform: "slack", + thread: { postEphemeral, post }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext, + postEphemeral, + post, + }; +} + +describe("ConnectAccount", () => { + it("tells the clicker when the connection could not even be started", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + // `handleConnectClick` reads the environment before anything else, so an + // incomplete deployment throws out of the click. Unguarded, that throw is + // the dead button this card's whole design exists to prevent. + vi.stubEnv("AGENT_URL", ""); + const press = connectButton(ConnectAccount({ toolkit: "gmail" })); + const { ctx, postEphemeral } = interaction({ id: "U1", kind: "human" }); + + await press(ctx); + + // The click is answered by `handleConnectClick`'s configuration guard, which + // says what is wrong and who can fix it rather than "could not start". + expect(postEphemeral).toHaveBeenCalledTimes(1); + const notice = JSON.stringify(postEphemeral.mock.calls[0]); + expect(notice).toMatch(/not configured to connect accounts/i); + expect(notice).toMatch(/ask whoever runs it/i); + // The notice carries no credential and no variable name. A connect failure + // is read by whoever pressed the button, not by whoever operates the + // deployment, and `AGENT_URL` in a thread teaches nobody anything useful. + expect(notice).not.toMatch(/AGENT_URL|AGENT_AUTH_HEADER|INTELLIGENCE_API_KEY/); + // Private either way: DM fallback is scoped to the clicker exactly as an + // ephemeral message is, which is why the link path asks for it too. + expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: true }); + expect(JSON.stringify(consoleError.mock.calls)).toMatch( + /connect_click_environment/, + ); + vi.unstubAllEnvs(); + consoleError.mockRestore(); + }); +}); + +describe("the notice shown when the click could not even be handed over", () => { + /** Runs one press with the click handler's module failing to load. */ + async function pressWithABrokenHandler( + actor: ProviderActor | undefined, + ephemeral: EphemeralResult | null = { ok: true, usedFallback: false }, + ) { + boot.fails = true; + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const press = connectButton(ConnectAccount({ toolkit: "gmail" })); + const surface = interaction(actor, ephemeral); + try { + await press(surface.ctx); + // Snapshotted before the restore below, which clears the record: read + // afterwards it is always `[]`, and every `not.toContain` on it passes + // for the wrong reason. + return { ...surface, logged: JSON.stringify(consoleError.mock.calls) }; + } finally { + boot.fails = false; + consoleError.mockRestore(); + } + } + + it("says it in the thread when the surface delivered no private message", async () => { + // The managed adapter reports non-delivery by returning `null` — it does + // not throw. The return value was discarded, so the notice vanished while + // the log recorded `told_the_clicker_privately`. + const { post } = await pressWithABrokenHandler( + { id: "U1", kind: "human" }, + null, + ); + + expect(post).toHaveBeenCalledTimes(1); + expect(JSON.stringify(post.mock.calls)).toMatch(/could not start/i); + }); + + it("treats an ok:false ephemeral result as undelivered too", async () => { + const { post } = await pressWithABrokenHandler({ id: "U1", kind: "human" }, { + ok: false, + error: "this surface has no ephemeral message", + } as EphemeralResult); + + expect(post).toHaveBeenCalledTimes(1); + }); + + it("does not log a delivery it did not make", async () => { + // `told_the_clicker_privately` beside a dropped message is a log line that + // lies to whoever reads it looking for why nobody was told. + const { logged } = await pressWithABrokenHandler( + { id: "U1", kind: "human" }, + null, + ); + + expect(logged).toContain("connect_account_click"); + expect(logged).not.toContain("told_the_clicker_privately"); + expect(logged).toContain("posted_the_notice_in_the_thread"); + }); + + it("still records the private delivery when one actually happened", async () => { + const { post, logged } = await pressWithABrokenHandler({ + id: "U1", + kind: "human", + }); + + expect(post).not.toHaveBeenCalled(); + expect(logged).toContain("told_the_clicker_privately"); + }); + + it("never addresses the notice to a literal \"unknown\" user id", async () => { + // There is no such user, so the notice went nowhere. With no identifiable + // clicker the thread is the only surface left, and the notice carries no + // capability. + const { postEphemeral, post } = await pressWithABrokenHandler(undefined); + + expect(postEphemeral).not.toHaveBeenCalled(); + expect(post).toHaveBeenCalledTimes(1); + expect(JSON.stringify(post.mock.calls)).not.toContain("unknown"); + }); + + it("asks for the DM fallback, exactly as the link path does", async () => { + const { postEphemeral } = await pressWithABrokenHandler({ + id: "U1", + kind: "human", + }); + + expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: true }); + }); +}); + +describe("ConnectLink", () => { + const URL_WITH_PARAMS = + "https://backend.composio.dev/connect?state=abc&redirect=xyz"; + + it("carries the url in a link button rather than Slack-only markup", () => { + // `` is a Slack mrkdwn construct: on Teams it renders as that + // literal string, and on Slack an unescaped `&` inside the url half ends + // the link early, so a connect url with query parameters arrived broken. + const { blocks } = renderSlackMessage( + renderToIR(), + ); + + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { type: string; url?: string }[] } + | undefined; + expect(actions?.elements[0]?.url).toBe(URL_WITH_PARAMS); + // The url is in a url field, not in any rendered text. + const sections = blocks.filter((b) => b.type !== "actions"); + expect(JSON.stringify(sections)).not.toContain("http"); + expect(JSON.stringify(blocks)).not.toMatch(/<[^<>]*\|[^<>]*>/); + }); + + it("opens the same url on a surface that is not Slack", () => { + const card = renderAdaptiveCard( + renderToIR(), + ) as { actions?: { type: string; url?: string }[]; body?: unknown }; + + expect(card.actions?.[0]?.type).toBe("Action.OpenUrl"); + expect(card.actions?.[0]?.url).toBe(URL_WITH_PARAMS); + expect(JSON.stringify(card.body)).not.toContain("http"); + }); +}); diff --git a/app/human-in-the-loop/approval-decisions.ts b/app/human-in-the-loop/approval-decisions.ts new file mode 100644 index 00000000..f91dd0eb --- /dev/null +++ b/app/human-in-the-loop/approval-decisions.ts @@ -0,0 +1,34 @@ +import type { StateStore } from "@copilotkit/channels"; + +/** LangGraph assigns each interrupt a 128-bit lowercase hexadecimal ID. */ +export const INTERRUPT_ID_PATTERN = /^[0-9a-f]{32}$/; + +// Match the Channel's default action retention. Saved renders never recreate +// tokens; expired and consumed cards stay unusable after a runtime restart. +const RETENTION_MS = 7 * 24 * 60 * 60 * 1_000; + +export function createApprovalDecisions(getStore: () => StateStore) { + const currentKey = (conversation: string) => `opentag:approval:${conversation}`; + const tokenKey = (conversation: string, id: string) => + `${currentKey(conversation)}:${id}`; + + return { + async register(conversation: string): Promise { + const id = crypto.randomUUID(); + const store = getStore(); + await store.kv.set(tokenKey(conversation, id), true, RETENTION_MS); + await store.kv.set(currentKey(conversation), id, RETENTION_MS); + return id; + }, + async claim(conversation: string, id: string): Promise { + const store = getStore(); + // The managed store's locks are process-local. Only consume is atomic + // across runtimes, and its key must belong to this card: a stale click + // must never consume a newer decision between a read and a delete. + if ((await store.kv.consume(tokenKey(conversation, id))) !== true) { + return false; + } + return (await store.kv.get(currentKey(conversation))) === id; + }, + }; +} diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx index 7a14f63f..25225604 100644 --- a/app/human-in-the-loop/confirm-write.tsx +++ b/app/human-in-the-loop/confirm-write.tsx @@ -3,17 +3,24 @@ * Notion write. The Python MCP interceptor pauses before invoking a mutating * tool. The Channel interrupt handler posts this card and returns immediately. * A click updates the card, then resumes the paused graph with - * `{ confirmed: true | false }`. + * `{ [interruptId]: { confirmed: true | false } }`. * * Each button also carries an `onClick` that updates the picker in place to a * resolved / declined state — so the card reflects the decision the moment it's * clicked, even minutes later (the "approve the action 20 minutes later" * durability story). * + * The Channel binds this component to a durable decision shared by both + * buttons. SDK continuations are one-use per button, so they alone cannot + * prevent the sibling button resuming a later interrupt after a restart. + * * The Slack-side equivalent of React's `useHumanInTheLoop`, expressed as a * plain JSX component over the cross-platform bot-ui vocabulary. */ import { + ActionExpiredError, + ActionContinuationMismatchError, + ChannelContinuationRequiredError, Message, Header, Section, @@ -25,6 +32,8 @@ import { Cell, } from "@copilotkit/channels"; import type { InteractionContext } from "@copilotkit/channels"; +import { reportRecoverableError } from "../channel-helpers.js"; +import { INTERRUPT_ID_PATTERN } from "./approval-decisions.js"; /** One argument of the pending write, already labelled and stringified. */ export interface ConfirmWriteField { @@ -32,9 +41,61 @@ export interface ConfirmWriteField { value: string; } +/** + * Everything the agent can say an action does. Closed on purpose: these are the + * three literals `composio_tools.classify` defines, and nothing else crosses + * the wire. Naming them here rather than accepting any string is what lets the + * card treat a word it does not recognise as the dangerous reading instead of + * silently sorting it with the safe ones. + * + * `read` is unreachable on this card — a read is never gated, so no card is + * posted for one — and `write` is unreachable from the Composio path, whose + * tags cannot express a write that is not destructive. Both stay in the + * vocabulary because the MCP interceptor classifies by `readOnlyHint` metadata + * instead, and because a schema's job here is to reject a typo, not to prove + * which of its members production happens to use this month. + */ +export const CONFIRM_WRITE_EFFECTS = ["read", "write", "destructive"] as const; + +export type ConfirmWriteEffect = (typeof CONFIRM_WRITE_EFFECTS)[number]; + +/** + * The effects that may render without the warning colour. Everything else + * carries it, and that includes both a missing classification and one this card + * cannot read. + * + * The agent decides the same way: `EffectMap.effect_for` answers `destructive` + * for a slug whose lookup failed and for one carrying no behaviour tag, on the + * grounds that an unclassified tool and a dangerous one are indistinguishable + * from here. Rendering the unclassified case unwarned would invert that at the + * one point where the person deciding can see it. + * + * Unwarned is as far as `write` gets: see {@link confirmStyle}. `write` is what + * the MCP interceptor makes of `readOnlyHint: false`, which is a tool saying "I + * am not read-only" — the absence of a safety claim, not the presence of one. + */ +const NEUTRAL_EFFECTS: ReadonlySet = new Set([ + "read", + "write", +]); + interface ConfirmWriteProps { + /** The specific LangGraph interrupt this answer may resolve. */ + interruptId?: string; + /** Surface-generated identifier shared by both buttons and saved with the card. */ + decisionId?: string; + /** Conversation bound to this card by the Channel when it posts the interrupt. */ + conversationKey?: string; /** Short imperative title of the write, e.g. 'Create Linear issue'. */ action: string; + /** + * Who may answer, as `platform:id`. Set only when the pending action runs in + * one person's own connected account: approving it spends that person's + * access, so a colleague pressing the button would spend somebody else's. + * Absent means the action belongs to the workspace and anyone who can see the + * card may answer. + */ + approver?: string; /** * The write's arguments as approver-readable rows, rendered as a table. The * agent decides which fields are worth showing (see `summarize_args`); this @@ -54,31 +115,118 @@ interface ConfirmWriteProps { attempt?: number; /** Why the previous attempt failed, quoted from the tool that rejected it. */ previousError?: string; + /** + * What the agent classified the action as. The card also checks the action's + * words for destructive verbs. + * + * Both the MCP interceptor and Composio send a classification. An older or + * malformed producer may omit it, which renders with the danger style. + */ + effect?: ConfirmWriteEffect; +} + +/** + * Whether this failure is the SDK refusing to send, rather than a send that + * failed. + * + * `Thread.resume` validates the interaction, loads its continuation, and claims + * it before starting the agent. A missing action, expired action, or mismatched + * binding therefore proves that this click never started an agent run. + * + * The code is read as well as the class. `instanceof` is one `node_modules` + * layout away from being false for the very error it names, and being wrong + * here means telling somebody a delete "may already have been applied" when it + * provably was not — sending them to check a system that never heard from us. + */ +function nothingWasSent(error: unknown): boolean { + if ( + error instanceof ChannelContinuationRequiredError || + error instanceof ActionExpiredError || + error instanceof ActionContinuationMismatchError + ) { + return true; + } + return ( + typeof error === "object" && + error !== null && + [ + "channel_continuation_required", + "channel_action_expired", + "channel_continuation_mismatch", + ].includes(String((error as { code?: unknown }).code)) + ); +} + +/** What the card is replaced with when the answer never left it. */ +function notSentCard(action: string) { + return ( + +
{`⚠️ ${action} — not sent`}
+ + {"This answer was not sent: the card is expired, already answered, or no longer valid here. An earlier answer may already have run the action. Check its result before asking again."} + +
+ ); +} + +/** What the card is replaced with when the answer may or may not have landed. */ +function unknownOutcomeCard(action: string) { + return ( + +
{`⚠️ ${action} — outcome unknown`}
+ + {"I lost contact with the agent after sending your answer, so I cannot say whether it ran. It may already have been applied — check before asking again."} + +
+ ); } +/** + * Send the answer, and say what is true when sending it fails. + * + * A `resume` that throws is usually not evidence that nothing happened. It + * fails on the way out as readily as on the way in, so an approval whose + * request reached the graph before the connection dropped has already been + * applied — and the write with it. The card therefore reports an unknown + * outcome rather than a paused one, and the answer is not re-armed: the same + * approval sent twice is a second destructive write, not a retry, and the card + * this replaces has no buttons left to retry from anyway. + * + * The one exception is the failure that happens before anything is sent (see + * {@link nothingWasSent}). Warning that a write "may already have been applied" + * when the SDK refused to send the approval at all is a false alarm about a + * destructive action — the same defect as the false calm, pointed the other + * way, and it costs somebody a hunt through Linear for a write nobody made. + */ async function resumeOrShowFailure( thread: InteractionContext["thread"], messageRef: InteractionContext["message"]["ref"], action: string, confirmed: boolean, + interruptId: string, ): Promise { try { - await thread.resume({ confirmed }); + // A newer turn may pause while updating the receipt. A scalar resume would + // answer that newer interrupt; LangGraph's ID map only answers this one. + await thread.resume({ [interruptId]: { confirmed } }); } catch (error) { try { await thread.update( messageRef, - -
{`⚠️ ${action} paused`}
- - {"I couldn't resume the agent. Please retry the action."} - -
, + nothingWasSent(error) ? notSentCard(action) : unknownOutcomeCard(action), ); } catch (updateError) { + // Both the answer and the correction failed, so the thread is left + // showing the optimistic receipt — "✅ Approved" — for a write nobody can + // vouch for. Throwing says the click failed; it does not say that, and a + // wrong receipt nobody logged is a wrong receipt nobody can find. + reportRecoverableError(updateError, { + operation: "confirm_write_outcome_unknown", + recovery: "none_receipt_overstates_the_outcome", + }); throw new AggregateError( [error, updateError], - `Failed to resume "${action}" and show its retry state`, + `Failed to resume "${action}" and correct its receipt`, ); } throw error; @@ -105,15 +253,62 @@ function fieldTable(fields: ConfirmWriteField[]) { } /** - * Verbs whose confirmation must not look inviting. A destructive action gets the - * warning colour on its confirm button, and Cancel drops to neutral so the red - * on the card marks the irreversible choice rather than the safe one. + * Words whose presence makes an action irreversible. Matched against every word + * of the action, not only the leading one: `NOTION_API_DELETE_A_BLOCK` reaches + * this card as "API delete a block", and a list consulted with the first word + * alone answers "API" — a protocol, not a verb, and not on any list of things + * that destroy data. + * + * A word appearing anywhere is enough, which will occasionally warn about a + * create whose arguments happen to mention an archive. That direction is the + * cheap one: an unnecessary warning costs a moment's hesitation, and a missing + * one costs the block. */ -const DESTRUCTIVE = new Set(["delete", "remove", "archive", "cancel", "revoke"]); +const DESTRUCTIVE = new Set([ + "delete", + "deletes", + "remove", + "removes", + "archive", + "archives", + "cancel", + "cancels", + "revoke", + "revokes", + "destroy", + "drop", + "purge", + "trash", + "wipe", + "erase", + "terminate", + "deactivate", + "uninstall", + "unpublish", +]); /** The label of the button that declines the write. */ const DECLINE_LABEL = "Cancel"; +/** Every word of the action, lowercased, with punctuation and casing dropped. */ +function wordsOf(action: string): string[] { + return action + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); +} + +/** + * The word that makes this action irreversible, if any of them do. + * + * Returned rather than answered yes/no, because where it sits decides how the + * button may be labelled: a delete named by the leading word can say so, and + * one buried further in cannot be summarised by the word in front of it. + */ +function destructiveWordOf(action: string): string | undefined { + return wordsOf(action).find((word) => DESTRUCTIVE.has(word)); +} + /** The action's own leading verb, e.g. `Delete customer` -> `Delete`. */ function verbOf(action: string): string { const word = action.trim().split(/\s+/)[0] ?? ""; @@ -125,14 +320,43 @@ function verbOf(action: string): string { * How the confirm button reads. Normally the action's own verb, so the button * never claims a delete is a create. * - * Falls back to `Confirm` when the verb would collide with the decline button: - * `Cancel subscription` otherwise renders two buttons both reading "Cancel", - * one cancelling the subscription and one cancelling the request. Derived - * separately from `destructive` below, so relabelling never costs the styling. + * Falls back to `Confirm` twice over. Once when the verb would collide with the + * decline button: `Cancel subscription` otherwise renders two buttons both + * reading "Cancel", one cancelling the subscription and one cancelling the + * request. And once when the action is destructive somewhere other than its + * leading word — "API delete a block" would otherwise put `API` on the button + * that deletes the block, and "Create a page in the archive database" would put + * `Create` on a button this card is calling irreversible. Neither word is a + * summary of the click; the header carries the action verbatim either way. + * + * Derived separately from the styling below, so relabelling never costs the + * warning colour and the colour never costs the verb. */ -function confirmLabel(verb: string): string { +function confirmLabel(verb: string, destructiveWord?: string): string { if (!verb) return "Confirm"; - return verb.toLowerCase() === DECLINE_LABEL.toLowerCase() ? "Confirm" : verb; + if (verb.toLowerCase() === DECLINE_LABEL.toLowerCase()) return "Confirm"; + const namedByTheVerb = destructiveWord === verb.toLowerCase(); + return destructiveWord && !namedByTheVerb ? "Confirm" : verb; +} + +/** + * How the confirm button looks — and there are two answers, not three. + * + * `danger` for anything irreversible, and for anything nobody classified: an + * unreadable or absent effect is an action nobody vouched for, styled the way + * the agent's own `EffectMap.effect_for` reads it. + * + * No style at all for a classified `write`. Slack's `primary` is an + * endorsement, and this card is never posted for anything but a change to + * somebody's data — there is no reading of `write` under which the right thing + * to show is a green button meaning "go ahead". `write` earns the absence of a + * warning, which is what distinguishes it from `destructive`; it does not earn + * a recommendation. Rendering it red instead would paint every card on the + * surface red and teach approvers to click through the colour, which costs the + * distinction this card exists to draw. + */ +function confirmStyle(dangerous: boolean): "danger" | undefined { + return dangerous ? "danger" : undefined; } /** @@ -149,13 +373,155 @@ function retryNotice(attempt: number, previousError?: string) { ); } -export function ConfirmWrite({ - action, - fields, - detail, - attempt, - previousError, -}: ConfirmWriteProps) { +/** The refusal itself. Names nobody, so it is safe anywhere in the thread. */ +const WRONG_APPROVER_NOTICE = ( + +
+ {"This one runs in someone else's connected account, so only they can approve it. The card is still waiting for them."} +
+
+); + +/** + * Whether the person who clicked is the one the agent named. + * + * The agent writes `platform:id`. Both halves must agree, because a provider id + * is unique only within its provider and one deployment can serve two. + * + * There is no exception, and none is needed. `composio_tools.state` keeps a + * closed `KNOWN_PLATFORMS`, and `_named_identity` refuses anything outside it, + * so `actor_key` writes `slack:` or `teams:` or names nobody at all — never + * `unknown:`, and never a bare id. Anything else reaching here is a shape this + * side of the wire cannot account for, and a platform check with a prefix it + * waves through is a platform check any producer can opt out of. + */ +function isNamedApprover( + interaction: InteractionContext, + approver: string, +): boolean { + const clickedBy = (interaction.actor?.id ?? "").trim(); + // Nobody verified pressed this. Refusing costs a click; accepting spends + // somebody's account on an unattributed press. + // + // Half of a pair with the `!namedId` check below, and each is redundant while + // the other stands: two empty strings only compare equal when both sides are + // empty. Deleting either leaves the suite green and the behaviour intact — + // and leaves the remaining one load-bearing on its own, which is why both + // stay. The equality is what must never be the whole of the test. + if (!clickedBy) return false; + + const separator = approver.indexOf(":"); + // No separator, no platform half. Read as a bare id it would match on the id + // alone, which is the whole of what this function exists to refuse. + if (separator === -1) return false; + const namedPlatform = approver.slice(0, separator).trim(); + const namedId = approver.slice(separator + 1).trim(); + // See the `!clickedBy` note above: `!namedId` is the other half of that pair. + if (!namedId || namedId !== clickedBy) return false; + + // `composio_tools.state.actor_key` lowercases the platform before it writes + // `approver`, so the surface's spelling has to be folded the same way. + // Comparing raw, a surface reporting "Slack" missed `slack:U1` — and the only + // person entitled to answer the card was the one person refused by it. + return namedPlatform === (interaction.platform ?? "").trim().toLowerCase(); +} + +/** + * Tell one person something only they need to hear, and never silently fail to. + * + * `postEphemeral` resolves to `null` on a surface with no ephemeral message — + * the managed adapter reports exactly that — so an unchecked call is a message + * that was never delivered and never reported. The refusal names nobody, so + * when the private path cannot carry it the thread can. + */ +async function tellOrPost(interaction: InteractionContext): Promise { + try { + const delivered = await interaction.thread.postEphemeral( + interaction.actor, + WRONG_APPROVER_NOTICE, + { fallbackToDM: true }, + ); + if (delivered?.ok) return; + } catch (error) { + reportRecoverableError(error, { + operation: "confirm_write_refusal_ephemeral", + recovery: "post_refusal_in_thread", + }); + } + await interaction.thread.post(WRONG_APPROVER_NOTICE); +} + +/** + * Whether this click came from somebody other than the named approver. + * + * Enforced here rather than in the agent because only the surface knows who + * pressed the button; the agent can say whose action it is and nothing more. + * The wrong person is told and the graph is left paused, so the right person + * can still answer. + */ +async function refuseWrongApprover( + interaction: InteractionContext, + approver: string | undefined, +): Promise { + if (!approver) return false; + if (isNamedApprover(interaction, approver)) return false; + + try { + await tellOrPost(interaction); + } catch (error) { + // The refusal stands whether or not it could be delivered. Falling through + // to the click would hand somebody else's account to whoever pressed. + reportRecoverableError(error, { + operation: "confirm_write_refusal", + recovery: "refused_without_telling_the_clicker", + }); + } + return true; +} + +/** A saved sibling button may outlive the card it came from. */ +async function tellStaleApprover(interaction: InteractionContext): Promise { + const notice = "This card has already been answered, expired, or been replaced. This click did not run anything; use the latest card or check the previous result."; + try { + const result = await interaction.thread.postEphemeral( + interaction.actor, + notice, + { fallbackToDM: true }, + ); + if (result?.ok) return; + } catch (error) { + reportRecoverableError(error, { + operation: "confirm_write_stale_notice", + recovery: "post_notice_in_thread", + }); + } + await interaction.thread.post(notice); +} + +type ClaimDecision = (conversation: string, id: string) => Promise; + +/** Dependencies live in the registered renderer, never in serialized card props. */ +export function createConfirmWrite(claim: ClaimDecision) { + return function ConfirmWrite(props: ConfirmWriteProps) { + return renderConfirmWrite(props, claim); + }; +} + +function renderConfirmWrite( + { + decisionId, + interruptId, + conversationKey, + action, + approver, + fields, + detail, + attempt, + previousError, + effect, + }: ConfirmWriteProps, + claim: ClaimDecision, +) { const body = fields?.length ? fieldTable(fields) : detail @@ -167,10 +533,92 @@ export function ConfirmWrite({ : null; const verb = verbOf(action); - const label = confirmLabel(verb); - // Read from the action's real verb, never from `label` — a relabelled - // destructive action is still destructive. - const destructive = DESTRUCTIVE.has(verb.toLowerCase()); + const destructiveWord = destructiveWordOf(action); + const label = confirmLabel(verb, destructiveWord); + // Either signal is enough, and neither can talk the other down. Only an + // effect the card recognises as unalarming buys an unwarned button, so an + // absent or unreadable classification is styled as destructive rather than + // assumed safe. The action words still count: `readOnlyHint: false` alone + // produces `write`, which a delete satisfies as readily as a rename. Never + // derive this from `label`: relabeling an action cannot make it safe. + const destructive = + !NEUTRAL_EFFECTS.has(effect ?? "") || destructiveWord !== undefined; + + // The local flag avoids repeated work; the durable claim covers restored + // renders, sibling buttons, and other runtime processes. + let answered = false; + + const answer = async ( + interaction: InteractionContext, + confirmed: boolean, + resolvedCard: Parameters[1], + ): Promise => { + // Cheapest first, and the only one that costs nothing to ask: an answered + // card has nothing to say to anybody, including the wrong person. Refusing + // first told a colleague "the card is still waiting for them" about a card + // that was waiting for nobody. + if (answered) return; + if (await refuseWrongApprover(interaction, approver)) return; + // Read again on the far side of that await. The refusal path is I/O, and a + // second press can land while it is in flight. Nothing between this check + // and the assignment yields, so the pair cannot be interleaved. + if (answered) return; + const { thread, message } = interaction; + if ( + !decisionId || + !conversationKey || + !interruptId || + !INTERRUPT_ID_PATTERN.test(interruptId) + ) { + await thread.post( + "This approval card cannot identify the action it would answer. Update the agent and ask again for a fresh card; this click did not run anything.", + ); + return; + } + // The UI Thread type omits this field, but the concrete SDK Thread copies + // the ingress conversationKey unchanged. Check before consuming a token: + // the SDK's later continuation binding rejection must leave it usable. + if ( + !("conversationKey" in thread) || + thread.conversationKey !== conversationKey + ) { + await thread.post( + "This approval belongs to a different conversation. Please use its original card; this click did not run anything.", + ); + return; + } + answered = true; + try { + if (!(await claim(conversationKey, decisionId))) { + await tellStaleApprover(interaction); + return; + } + } catch (error) { + reportRecoverableError(error, { + operation: "confirm_write_decision", + recovery: "refused_without_resuming", + }); + await thread.post( + "I could not safely record this approval, so I did not send your answer. Please ask again for a fresh card.", + ); + return; + } + try { + await thread.update(message.ref, resolvedCard); + } catch (error) { + // The card is the receipt, not the decision. A graph is paused on the + // answer this person already gave, and throwing away an approval because + // Slack would not repaint a message leaves it paused for good. + // + // The durable decision was already consumed, so neither visible button + // can send a second answer even if this process restarts. + reportRecoverableError(error, { + operation: "confirm_write_card_update", + recovery: "resumed_the_agent_anyway", + }); + } + await resumeOrShowFailure(thread, message.ref, action, confirmed, interruptId); + }; return ( @@ -183,10 +631,11 @@ export function ConfirmWrite({ + {/* + Never styled. Cancel is the one button on this card that changes + nothing, and the red belongs on the irreversible choice — putting it + here says the safe answer is the alarming one. + */} + + + {`Anyone else in this thread can click to connect their own account.`} + + + ); +} + +/** + * What one person sees after clicking, and nobody else does. + * + * A link button rather than `` in a section. That syntax is Slack + * mrkdwn: this component is rendered on whatever surface the person is on, and + * on Teams it arrived as that literal string. It was wrong on Slack too — an + * `&` inside the url half ends the link there, and a minted connect url is all + * query parameters, so the one thing this card exists to deliver came through + * truncated. A ` + + + ); +} + +/** What one person sees when no link could be minted. */ +export function ConnectFailed({ message }: { message: string }) { + return ( + +
{`⚠️ ${message}`}
+
+ ); +} diff --git a/app/human-in-the-loop/index.ts b/app/human-in-the-loop/index.ts index d20645f1..0949ba85 100644 --- a/app/human-in-the-loop/index.ts +++ b/app/human-in-the-loop/index.ts @@ -6,4 +6,14 @@ * The backend MCP write interceptor emits `confirm_write`. Its `on_interrupt` * event posts `ConfirmWrite`; the card's buttons call `thread.resume(...)`. */ -export { ConfirmWrite } from "./confirm-write.js"; +export { createConfirmWrite, CONFIRM_WRITE_EFFECTS } from "./confirm-write.js"; +export type { + ConfirmWriteEffect, + ConfirmWriteField, +} from "./confirm-write.js"; +export { + ConnectAccount, + ConnectFailed, + ConnectLink, +} from "./connect-account.js"; +export type { ConnectRequest } from "./connect-account.js"; diff --git a/app/index.ts b/app/index.ts index df2a7351..5a52af11 100644 --- a/app/index.ts +++ b/app/index.ts @@ -3,20 +3,55 @@ import { createOpenTagChannel } from "./channel.js"; import { readEnvironment, type AppEnvironment } from "./env.js"; import { createOpenTagRuntime } from "./runtime-host.js"; -export function createOpenTagApplication( - environment: AppEnvironment = readEnvironment(), -) { - // Channels agents are stateful, so each conversation gets its own SDK agent. - const agent = (threadId: string) => { +/** + * One SDK agent per conversation, because Channels agents are stateful. + * + * Exported so the `Authorization` header can be asserted. It is the runtime's + * half of the shared secret — the agent refuses traffic that arrives without it + * — and once the agent is inside a Channel nothing in this process can see what + * was put on the wire, so dropping the header here is otherwise invisible. + */ +export function createAgentFactory(environment: AppEnvironment) { + // Truthiness alone decided this: `""` dropped the header with no sign, and + // `" "` — or a value pasted with a trailing newline — went out as if it were + // a secret. Both read as "configured" to whoever set them, and the agent + // answers 401 to both. `readEnvironment` already normalizes blank to + // undefined, so reaching this throw means a caller built an `AppEnvironment` + // by hand with a value that cannot work. + const secret = environment.agentAuthHeader?.trim(); + if (environment.agentAuthHeader !== undefined && !secret) { + throw new Error( + "AGENT_AUTH_HEADER is set but blank. Unset it to talk to an " + + "unauthenticated agent, or set it to the secret the agent checks.", + ); + } + + return (threadId: string) => { const instance = new SanitizingHttpAgent({ url: environment.agentUrl, - headers: environment.agentAuthHeader - ? { Authorization: environment.agentAuthHeader } - : undefined, + headers: secret ? { Authorization: secret } : undefined, }); instance.threadId = threadId; return instance; }; +} + +export function createOpenTagApplication( + environment: AppEnvironment = readEnvironment(), +) { + const agent = createAgentFactory(environment); + + // A one-sided shared secret is invisible from either end: an agent that + // requires one answers 401 to every request, and once the agent is inside a + // Channel nothing in this process sees the response. This line at boot is the + // only place the operator can notice which half is missing. + if (!environment.agentAuthHeader) { + console.warn( + "[opentag] no AGENT_AUTH_HEADER is set, so agent requests go out " + + "unauthenticated. If the agent has one set, every request will be " + + "rejected with 401.", + ); + } // Intelligence owns the Slack and Teams adapters for this logical Channel. const channels = [ diff --git a/app/interrupt.test.ts b/app/interrupt.test.ts index b14ed77a..f994ba3e 100644 --- a/app/interrupt.test.ts +++ b/app/interrupt.test.ts @@ -1,7 +1,8 @@ import { EventType } from "@ag-ui/client"; import { createRunRenderer } from "@copilotkit/channels/slack/render"; import { describe, expect, it, vi } from "vitest"; -import { parseConfirmWriteInterrupt } from "./interrupt.js"; +import { ZodError } from "zod"; +import { parseInterrupt } from "./interrupt.js"; const realEnvelope = { __copilotkit_interrupt_value__: { @@ -30,11 +31,30 @@ const realEnvelope = { ], }; -describe("parseConfirmWriteInterrupt", () => { - it("parses ag_ui_langgraph's JSON-stringified interrupt envelope", () => { - expect(parseConfirmWriteInterrupt(JSON.stringify(realEnvelope))).toEqual( - realEnvelope.__copilotkit_interrupt_value__, +/** + * Parse one payload and insist it was the approval card's own interrupt. + * + * `parseInterrupt` answers with a kind, because a graph pausing for something + * else is a different request rather than a broken approval. Everything below + * that is about the card asserts the kind first, so a test cannot go on reading + * `args` off a result that never was one. + */ +function confirmWrite(payload: unknown) { + const parsed = parseInterrupt(payload); + if (parsed.kind !== "confirm_write") { + throw new Error( + `expected a confirm_write interrupt, got "${parsed.action}"`, ); + } + return parsed; +} + +describe("parseInterrupt", () => { + it("parses ag_ui_langgraph's JSON-stringified interrupt envelope", () => { + expect(confirmWrite(JSON.stringify(realEnvelope))).toEqual({ + kind: "confirm_write", + args: realEnvelope.__copilotkit_interrupt_value__.args, + }); }); it("parses the object produced by the canary Slack renderer", () => { @@ -56,9 +76,10 @@ describe("parseConfirmWriteInterrupt", () => { const renderedPayload = renderer.getPendingInterrupt()?.value; expect(renderedPayload).toEqual(realEnvelope); - expect(parseConfirmWriteInterrupt(renderedPayload)).toEqual( - realEnvelope.__copilotkit_interrupt_value__, - ); + expect(confirmWrite(renderedPayload)).toEqual({ + kind: "confirm_write", + args: realEnvelope.__copilotkit_interrupt_value__.args, + }); }); it("parses the fields the agent sends for the confirmation table", () => { @@ -68,7 +89,7 @@ describe("parseConfirmWriteInterrupt", () => { ]; expect( - parseConfirmWriteInterrupt( + confirmWrite( JSON.stringify({ ...realEnvelope, __copilotkit_interrupt_value__: { @@ -82,7 +103,7 @@ describe("parseConfirmWriteInterrupt", () => { it("rejects fields that are not label/value pairs", () => { expect(() => - parseConfirmWriteInterrupt( + confirmWrite( JSON.stringify({ ...realEnvelope, __copilotkit_interrupt_value__: { @@ -95,7 +116,7 @@ describe("parseConfirmWriteInterrupt", () => { }); it("parses the retry context the agent adds to a re-asked write", () => { - const args = parseConfirmWriteInterrupt( + const args = confirmWrite( JSON.stringify({ ...realEnvelope, __copilotkit_interrupt_value__: { @@ -115,7 +136,7 @@ describe("parseConfirmWriteInterrupt", () => { }); it("accepts a first attempt with no retry context", () => { - const args = parseConfirmWriteInterrupt( + const args = confirmWrite( JSON.stringify(realEnvelope), ).args; @@ -126,7 +147,7 @@ describe("parseConfirmWriteInterrupt", () => { it("rejects a nonsensical attempt number", () => { for (const attempt of [0, -1, 1.5]) { expect(() => - parseConfirmWriteInterrupt( + confirmWrite( JSON.stringify({ ...realEnvelope, __copilotkit_interrupt_value__: { @@ -139,9 +160,13 @@ describe("parseConfirmWriteInterrupt", () => { } }); - it("rejects malformed envelopes and other actions", () => { - expect(() => - parseConfirmWriteInterrupt( + it("reads an interrupt that is not an approval as unsupported, not as broken", () => { + // The card is not the only thing a graph can pause for. Reported as a + // failed approval, a request this surface has no handler for sends the + // reader looking for a card that was never asked for — and for the write + // they think it was gating. + expect( + parseInterrupt( JSON.stringify({ ...realEnvelope, __copilotkit_interrupt_value__: { @@ -150,7 +175,158 @@ describe("parseConfirmWriteInterrupt", () => { }, }), ), - ).toThrow(/confirm_write/); - expect(() => parseConfirmWriteInterrupt("{broken")).toThrow(); + ).toEqual({ kind: "unsupported", action: "delete_without_confirmation" }); + }); + + it("does not read the arguments of a request it has no handler for", () => { + // Nothing here can say what those arguments mean, and the caller quotes + // only what this returns. An `args` shape nobody validated is not a thing + // to hand a thread. + expect( + parseInterrupt({ + __copilotkit_interrupt_value__: { + action: "connect_account", + args: { anything: ["at", "all"] }, + }, + }), + ).toEqual({ kind: "unsupported", action: "connect_account" }); + }); + + it("still throws on an envelope it cannot read at all", () => { + expect(() => parseInterrupt("{broken")).toThrow(); + expect(() => parseInterrupt({ nothing: "here" })).toThrow(); + }); +}); + +function interruptPayload(action: string, args: unknown) { + return { + __copilotkit_interrupt_value__: { action, args }, + __copilotkit_messages__: [], + }; +} + +describe("parseInterrupt approver", () => { + it("carries the approver through when one is named", () => { + const { args } = confirmWrite( + interruptPayload("confirm_write", { + action: "Gmail send email", + approver: "slack:U1", + effect: "write", + }), + ); + expect(args.approver).toBe("slack:U1"); + }); + + it("accepts a null approver, which is how a workspace action arrives", () => { + const { args } = confirmWrite( + interruptPayload("confirm_write", { + action: "Create issue", + approver: null, + }), + ); + expect(args.approver ?? undefined).toBeUndefined(); + }); + + it("accepts an explicitly null fields, which is how the agent says none", () => { + // Every other optional key on this card is nullish, and the producer sends + // explicit nulls. A schema that only tolerates `undefined` throws inside + // the interrupt handler, and the card is never posted at all — the graph + // waits for an answer to a question nobody was ever asked. + const { args } = confirmWrite( + interruptPayload("confirm_write", { + action: "Save project", + fields: null, + attempt: null, + previous_error: null, + }), + ); + expect(args.fields ?? undefined).toBeUndefined(); + expect(args.attempt ?? undefined).toBeUndefined(); + }); + + it("carries the classified effect through to the card", () => { + const { args } = confirmWrite( + interruptPayload("confirm_write", { + action: "Gmail delete draft", + effect: "destructive", + }), + ); + expect(args.effect).toBe("destructive"); + }); + + it("still accepts a payload from an agent revision predating the approver", () => { + const { args } = confirmWrite( + interruptPayload("confirm_write", { action: "Create issue" }), + ); + expect(args.action).toBe("Create issue"); + }); +}); + +describe("parseInterrupt fail-safe", () => { + it("reads the three effects the agent classifies", () => { + for (const effect of ["read", "write", "destructive"] as const) { + expect( + confirmWrite( + interruptPayload("confirm_write", { action: "Do it", effect }), + ).args.effect, + ).toBe(effect); + } + }); + + it("reads an effect outside that vocabulary as destructive", () => { + // `EffectMap.effect_for` answers `destructive` for anything it cannot + // classify. A word this schema does not know is the same situation one hop + // later, and the card must not be handed a value it will render neutral. + expect( + confirmWrite( + interruptPayload("confirm_write", { + action: "Do it", + effect: "purge", + }), + ).args.effect, + ).toBe("destructive"); + }); + + it("throws one shape for bad JSON, not two for the same contract", () => { + // The renderer's contract is a ZodError. A raw SyntaxError from an + // unguarded `JSON.parse` is a second throw shape for the same failure, and + // the handler that has to tell them apart cannot. + let thrown: unknown; + try { + parseInterrupt("{broken"); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(ZodError); + expect(thrown).not.toBeInstanceOf(SyntaxError); + }); + + it("posts the card when the agent sends no message history", () => { + // `__copilotkit_messages__` is never read here. Requiring it means a + // producer that omits it kills the card, and the graph waits on a question + // nobody was asked. + const { args } = confirmWrite({ + __copilotkit_interrupt_value__: { + action: "confirm_write", + args: { action: "Create issue" }, + }, + }); + expect(args.action).toBe("Create issue"); + }); +}); + + +describe("interrupt correlation", () => { + it("preserves the trusted outer LangGraph ID", () => { + const id = "0123456789abcdef0123456789abcdef"; + expect(confirmWrite({ ...realEnvelope, __opentag_interrupt_id__: id }).interruptId).toBe(id); + }); + + it("leaves an older agent's missing ID absent for the Channel to refuse", () => { + expect(confirmWrite(realEnvelope).interruptId).toBeUndefined(); + }); + + it.each([null, "", "abc", "0123456789ABCDEF0123456789ABCDEF", "confirmed", 5])("rejects malformed ID %s", (id) => { + expect(() => confirmWrite({ ...realEnvelope, __opentag_interrupt_id__: id })).toThrow(ZodError); }); }); diff --git a/app/interrupt.ts b/app/interrupt.ts index 420edef5..889446c3 100644 --- a/app/interrupt.ts +++ b/app/interrupt.ts @@ -1,31 +1,146 @@ import { z } from "zod"; +import { INTERRUPT_ID_PATTERN } from "./human-in-the-loop/approval-decisions.js"; +import { + CONFIRM_WRITE_EFFECTS, + type ConfirmWriteEffect, +} from "./human-in-the-loop/confirm-write.js"; -const confirmWriteInterruptSchema = z.object({ +/** What an unreadable classification is treated as, on both sides of the wire. */ +const DANGEROUS_READING: ConfirmWriteEffect = "destructive"; + +/** + * The classification the agent sends, or the dangerous reading when it sends + * something this side does not know. + * + * `.catch` rather than a bare enum, because the two ways of being strict here + * both fail in the wrong direction. Accepting any string lets an unreadable + * word render as a harmless one — the agent's fail-safe inverted on this side + * of the wire. Throwing on it kills the whole card, and a graph paused on a + * question nobody was asked is worse than one asked in red. Falling back to + * `destructive` is the same answer `EffectMap.effect_for` gives when it cannot + * classify a slug. + */ +const effectSchema = z + .enum(CONFIRM_WRITE_EFFECTS) + .nullish() + .catch(DANGEROUS_READING); + +const confirmWriteArgsSchema = z.object({ + action: z.string().min(1), + /** + * Approver-readable rows built by the agent's `summarize_args`. + * + * Nullish, not optional. The agent sends explicit nulls for the extras a + * card does not carry, and a schema that only tolerates `undefined` + * throws inside the interrupt handler — which posts no card at all and + * leaves the graph paused on a question nobody was ever asked. + */ + fields: z + .array(z.object({ label: z.string(), value: z.string() })) + .nullish(), + /** Legacy pre-`fields` summary; still accepted across a deploy skew. */ + detail: z.string().nullish(), + /** + * Which attempt at this write the card is asking about. Absent on a + * first attempt; `2` and up mean an earlier approved attempt failed. + * Nullish for the same reason `fields` is. + */ + attempt: z.number().int().min(1).nullish(), + /** Why the previous attempt at this same write failed. */ + previous_error: z.string().nullish(), + /** + * Who may answer this card, as `platform:id`. Present only when the call + * runs in one person's own account: approving it spends that person's + * access, so a colleague pressing the button would spend somebody else's. + * Absent means the action belongs to the workspace and anyone who can see + * the card may answer. + */ + approver: z.string().min(1).nullish(), + /** `read`, `write`, or `destructive` — what the agent classified it as. */ + effect: effectSchema, +}); + +/** The `confirm_write` arguments, as the card receives them. */ +export type ConfirmWriteArgs = z.infer; + +/** + * The envelope every interrupt arrives in, read only as far as its name. + * + * `action` is a string rather than the `confirm_write` literal, because an + * interrupt naming something else is not a malformed approval — it is a + * different request, and collapsing the two made the surface report a graph + * asking for `connect_account` as an approval card it had failed to render. + * The reader then goes looking for a card, and for the write behind it, and + * neither exists. + * + * `__copilotkit_messages__` is deliberately absent. The envelope carries the + * run's message history and nothing here reads it, so requiring it only gave a + * producer that omits it a way to kill the card. Unknown keys pass. + */ +const interruptEnvelopeSchema = z.object({ + __opentag_interrupt_id__: z.string().regex(INTERRUPT_ID_PATTERN).optional(), __copilotkit_interrupt_value__: z.object({ - action: z.literal("confirm_write"), - args: z.object({ - action: z.string().min(1), - /** Approver-readable rows built by the agent's `summarize_args`. */ - fields: z - .array(z.object({ label: z.string(), value: z.string() })) - .optional(), - /** Legacy pre-`fields` summary; still accepted across a deploy skew. */ - detail: z.string().nullish(), - /** - * Which attempt at this write the card is asking about. Absent on a - * first attempt; `2` and up mean an earlier approved attempt failed. - */ - attempt: z.number().int().min(1).optional(), - /** Why the previous attempt at this same write failed. */ - previous_error: z.string().nullish(), - }), + action: z.string().min(1), + args: z.unknown(), }), - __copilotkit_messages__: z.array(z.unknown()), }); -export function parseConfirmWriteInterrupt(payload: unknown) { - const normalized = - typeof payload === "string" ? JSON.parse(payload) : payload; - return confirmWriteInterruptSchema.parse(normalized) - .__copilotkit_interrupt_value__; +/** + * The envelope as an object, whichever way it arrived. + * + * The JSON parse is folded into the schema rather than run ahead of it so this + * module has exactly one throw shape. An unguarded `JSON.parse` threw a raw + * `SyntaxError` for a truncated payload and a `ZodError` for a structurally + * wrong one — the same failure, in two shapes, for the handler that has to + * report it. + */ +const envelopeSchema = z + .unknown() + .transform((payload, ctx) => { + if (typeof payload !== "string") return payload; + try { + return JSON.parse(payload) as unknown; + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `interrupt payload is not JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + return z.NEVER; + } + }) + .pipe(interruptEnvelopeSchema); + +/** + * What an interrupt turned out to be. + * + * `unsupported` is a value rather than a throw because the two failures need + * different words in the thread: an approval this surface could not render is a + * bug in the card, and a request it has no handler for is a bug somewhere else + * entirely. A caller that cannot tell them apart has to guess, and it guessed + * wrong in the direction that costs the reader a search. + */ +export type ParsedInterrupt = + | { kind: "confirm_write"; args: ConfirmWriteArgs; interruptId?: string } + | { kind: "unsupported"; action: string }; + +/** + * Read one interrupt envelope. + * + * Throws only when the payload cannot be read at all, or when a `confirm_write` + * carries arguments the card cannot be built from — the two cases where there + * is genuinely no card to post. + */ +export function parseInterrupt(payload: unknown): ParsedInterrupt { + const envelope = envelopeSchema.parse(payload); + const { action, args } = envelope.__copilotkit_interrupt_value__; + if (action !== "confirm_write") { + return { kind: "unsupported", action }; + } + return { + kind: "confirm_write", + args: confirmWriteArgsSchema.parse(args), + interruptId: envelope.__opentag_interrupt_id__, + }; } diff --git a/app/railway.test.ts b/app/railway.test.ts index 02fb5f49..6fa2dd13 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -1,71 +1,119 @@ -import { execFileSync } from "node:child_process"; +import { + createRailwayContext, + project, + projectDefinitionToGraph, + validateGraph, + type RailwayGraph, + type ServiceNode, +} from "railway/iac"; +import { existsSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import railwayProgram from "../.railway/railway.js"; -interface RailwayVariable { - type: "literal" | "preserve"; - value?: string; -} +/** The scripts `pnpm ` can actually resolve at the repository root. */ +const packageScripts: Record = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +).scripts; -interface RailwayResource { - name: string; - source?: { - repo?: string; - branch?: string; - rootDirectory?: string; - }; - build?: { - builder?: string; - buildCommand?: string; - watchPatterns?: string[] | null; - }; - deploy?: { - startCommand?: string; - healthcheckPath?: string; - }; - variables?: Record; +/** + * The compiled deployment graph. + * + * Evaluated in this process rather than by shelling out to `railway`'s bin. + * The subprocess booted a second Node, loaded the whole `railway` bundle again + * and re-compiled the config through `tsx` — between 0.3s and 6.2s depending on + * what else the machine was doing, which straddles vitest's default timeout and + * went red on an unmodified config. Raising the timeout only moves the number + * the flake has to beat; removing the second process removes the variance. This + * is the same sequence the bin runs (`resolveDefinition` then + * `projectDefinitionToGraph`), against the compiler vitest has already warmed. + */ +async function railwayGraph(): Promise { + const graph = projectDefinitionToGraph( + await railwayProgram(createRailwayContext({}), project), + ); + // What the bin reports as `ok: false` with diagnostics attached. + expect(validateGraph(graph)).toEqual([]); + return graph; } -function evaluateRailwayGraph(): RailwayResource[] { - const stdout = execFileSync( - process.execPath, - ["node_modules/railway/dist/iac/bin.js"], - { - cwd: process.cwd(), - encoding: "utf8", - }, +/** The one service called `name`, or a failure that says which one is missing. */ +function serviceNamed(graph: RailwayGraph, name: string): ServiceNode { + const service = graph.resources.find( + (candidate): candidate is ServiceNode => + candidate.type === "service" && candidate.name === name, ); - const result = JSON.parse(stdout) as { - ok: boolean; - diagnostics: unknown[]; - graph: { resources: RailwayResource[] }; - }; - expect(result.ok).toBe(true); - expect(result.diagnostics).toEqual([]); - return result.graph.resources; + if (!service) { + throw new Error( + `no service called ${name}; the graph has ${graph.resources + .map((resource) => resource.name) + .join(", ")}`, + ); + } + return service; } +/** + * Every variable name a service carries, sorted. + * + * `toMatchObject` only reads the keys it is handed, so it is blind to a + * variable that should not be there at all — a credential belonging to one + * service quietly added to the other passes it without complaint. The whole + * name list is compared instead. + */ +function variableNames(service: ServiceNode): string[] { + return Object.keys(service.variables ?? {}).sort(); +} + +/** + * What both services must say about restarts and health checks. + * + * Its own constant because the previous version of this file typed `deploy` as + * `{ startCommand, healthcheckPath }` and asserted nothing else: deleting the + * restart policy from both services, or setting the health-check timeout to a + * second, left the suite green. A service that never restarts after a crash is + * the failure this deployment config exists to prevent. + */ +const RESILIENCE = { + // Five minutes: the agent installs nothing at boot but does import the model + // and MCP clients, and the runtime waits on the agent. + healthcheckTimeout: 300, + // Restart a crashed container, and stop after five so a container that + // cannot start does not restart forever without anyone noticing. + restartPolicyType: "ON_FAILURE", + restartPolicyMaxRetries: 5, +} as const; + describe("Railway deployment graph", () => { - it("ships the Python agent and Chromium-capable runtime services", () => { - const resources = evaluateRailwayGraph(); - expect(resources.map(({ name }) => name).sort()).toEqual(["agent", "runtime"]); + it("ships the Python agent and Chromium-capable runtime services", async () => { + const graph = await railwayGraph(); + expect(graph.resources.map(({ name }) => name).sort()).toEqual([ + "agent", + "runtime", + ]); - const agent = resources.find(({ name }) => name === "agent"); + const agent = serviceNamed(graph, "agent"); + // The whole source object, not a subset: `rootDirectory` is what decides + // which tree Railpack builds, and `toMatchObject` reads only the keys it is + // handed — so it is blind to the one that should not be there. The runtime + // builds the repository root and the agent builds `agent/`; swapping either + // gives a service that builds and then cannot start. + expect(agent.source).toEqual({ + type: "github", + repo: "CopilotKit/OpenTag", + branch: "main", + rootDirectory: "agent", + }); expect(agent).toMatchObject({ - source: { - repo: "CopilotKit/OpenTag", - branch: "main", - rootDirectory: "agent", - }, build: { builder: "RAILPACK", }, deploy: { - startCommand: - 'uvicorn main:app --host "" --port ${PORT:-8123}', + startCommand: 'uvicorn main:app --host "" --port ${PORT:-8123}', healthcheckPath: "/health", + ...RESILIENCE, }, }); - expect(agent?.variables).toMatchObject({ + expect(agent.variables).toMatchObject({ AGENT_DISPLAY_NAME: { type: "preserve" }, OPENAI_API_KEY: { type: "preserve" }, TAVILY_API_KEY: { type: "preserve" }, @@ -80,14 +128,64 @@ describe("Railway deployment graph", () => { LINEAR_API_KEY: { type: "preserve" }, NOTION_MCP_URL: { type: "preserve" }, NOTION_MCP_AUTH_TOKEN: { type: "preserve" }, + COMPOSIO_API_KEY: { type: "preserve" }, + COMPOSIO_TOOLKITS: { type: "preserve" }, + COMPOSIO_USER_TOOLKITS: { type: "preserve" }, + COMPOSIO_APPROVALS: { type: "preserve" }, + COMPOSIO_WORKSPACE_USER_ID: { type: "preserve" }, + COMPOSIO_AUTH_CONFIGS: { type: "preserve" }, + AGENT_AUTH_HEADER: { type: "preserve" }, + // The agent derives the default Composio workspace user id from this, so + // it has to reach the agent and not only the runtime. + INTELLIGENCE_CHANNEL_NAME: { type: "literal", value: "open-tag" }, + // The port the start command falls back to and the port the runtime is + // told to reach it on. + PORT: { type: "literal", value: "8123" }, }); - const runtime = resources.find(({ name }) => name === "runtime"); + // The agent holds the Composio key and every source credential; the + // runtime must not. Named exhaustively so a credential added to the wrong + // service is a failure rather than an unread key. + expect(variableNames(agent)).toEqual([ + "AGENT_AUTH_HEADER", + "AGENT_DISPLAY_NAME", + "COMPOSIO_API_KEY", + "COMPOSIO_APPROVALS", + "COMPOSIO_AUTH_CONFIGS", + "COMPOSIO_TOOLKITS", + "COMPOSIO_USER_TOOLKITS", + "COMPOSIO_WORKSPACE_USER_ID", + "DAYTONA_API_KEY", + "DAYTONA_SNAPSHOT", + "DAYTONA_TTL_MINUTES", + "GITHUB_APP_ID", + "GITHUB_APP_INSTALLATION_ID", + "GITHUB_APP_PRIVATE_KEY_BASE64", + "GITHUB_CODER_TOKEN", + "GITHUB_MCP_URL", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "INTELLIGENCE_CHANNEL_NAME", + "LINEAR_API_KEY", + "NOTION_MCP_AUTH_TOKEN", + "NOTION_MCP_URL", + "OPENAI_API_KEY", + "PORT", + "POSTHOG_MCP_URL", + "POSTHOG_PERSONAL_API_KEY", + "TAVILY_API_KEY", + ]); + + const runtime = serviceNamed(graph, "runtime"); + // No `rootDirectory`, asserted by absence: the runtime is the repository + // root, and a subset match passes just as happily with it pointed at + // `agent/` — where `pnpm runtime` is not a script and the deploy restarts + // until it gives up. + expect(runtime.source).toEqual({ + type: "github", + repo: "CopilotKit/OpenTag", + branch: "main", + }); expect(runtime).toMatchObject({ - source: { - repo: "CopilotKit/OpenTag", - branch: "main", - }, build: { builder: "RAILPACK", buildCommand: "pnpm exec playwright install chromium", @@ -96,13 +194,13 @@ describe("Railway deployment graph", () => { deploy: { startCommand: "pnpm runtime", healthcheckPath: "/api/copilotkit/info", + ...RESILIENCE, }, variables: { AGENT_DISPLAY_NAME: { type: "preserve" }, AGENT_URL: { type: "literal", - value: - "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", + value: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", }, INTELLIGENCE_API_KEY: { type: "preserve" }, INTELLIGENCE_API_URL: { @@ -118,6 +216,7 @@ describe("Railway deployment graph", () => { type: "literal", value: "open-tag", }, + AGENT_AUTH_HEADER: { type: "preserve" }, PLAYWRIGHT_BROWSERS_PATH: { type: "literal", value: "0", @@ -128,5 +227,56 @@ describe("Railway deployment graph", () => { }, }, }); + + // The runtime carries the shared secret it presents to the agent, and no + // platform or Composio credential: Intelligence owns the Slack and Teams + // edges, and the toolkits live on the agent. Asserted as the complete set + // so a token added back here fails rather than passes unnoticed. + expect(variableNames(runtime)).toEqual([ + "AGENT_AUTH_HEADER", + "AGENT_DISPLAY_NAME", + "AGENT_URL", + "INTELLIGENCE_API_KEY", + "INTELLIGENCE_API_URL", + "INTELLIGENCE_CHANNEL_NAME", + "INTELLIGENCE_GATEWAY_WS_URL", + "INTELLIGENCE_LEARNING_CONTAINER_ID", + "PLAYWRIGHT_BROWSERS_PATH", + "PORT", + "RAILPACK_DEPLOY_APT_PACKAGES", + ]); + }); + + it("starts each service with an entry point this repository actually has", async () => { + // Replaces a cross-check that compared the two services' + // `INTELLIGENCE_CHANNEL_NAME` values. Both are pinned to the literal + // `open-tag` by the exhaustive assertions above, so the comparison could + // not fail unless one of those failed first — it read as a drift guard and + // was arithmetic on two constants. + // + // This is the part of the config nothing else can see: the start commands + // are strings here and names elsewhere. Renaming the `runtime` script in + // `package.json`, or moving the agent's ASGI module, leaves every + // assertion above green and every deploy exiting at boot — which Railway + // then restarts five times and stops. + const graph = await railwayGraph(); + + const runtimeStart = serviceNamed(graph, "runtime").deploy?.startCommand; + const scriptName = /^pnpm (?:run )?([\w:-]+)$/.exec(runtimeStart ?? "")?.[1]; + expect({ runtimeStart, scriptName }).toMatchObject({ + scriptName: expect.any(String), + }); + expect(Object.keys(packageScripts)).toContain(scriptName); + + // `uvicorn :`, resolved from the agent's own + // `rootDirectory`, so the module is a file in `agent/`. + const agentStart = serviceNamed(graph, "agent").deploy?.startCommand ?? ""; + const moduleName = /^uvicorn ([\w.]+):(\w+)/.exec(agentStart)?.[1]; + expect({ agentStart, moduleName }).toMatchObject({ + moduleName: expect.any(String), + }); + expect( + existsSync(new URL(`../agent/${moduleName}.py`, import.meta.url)), + ).toBe(true); }); }); diff --git a/app/server.test.ts b/app/server.test.ts index 54776794..d7bbf189 100644 --- a/app/server.test.ts +++ b/app/server.test.ts @@ -1,19 +1,21 @@ import { EventEmitter } from "node:events"; import type { RequestListener } from "node:http"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { ChannelsControl } from "@copilotkit/runtime/v2"; import { startOpenTagServer, type HttpServerLike, type RuntimeListener, } from "../server.js"; -import type { AppEnvironment } from "./env.js"; -import { createOpenTagApplication } from "./index.js"; +import { readEnvironment, type AppEnvironment } from "./env.js"; +import { createAgentFactory, createOpenTagApplication } from "./index.js"; class FakeServer extends EventEmitter implements HttpServerLike { listening = false; listenCalls: Array<{ port: number; host: string }> = []; closeCalls = 0; + /** When set, `close` reports this the way `http.Server` does. */ + closeError: Error | undefined; listen(port: number, host: string, callback: () => void): this { this.listenCalls.push({ port, host }); @@ -25,7 +27,24 @@ class FakeServer extends EventEmitter implements HttpServerLike { close(callback: (error?: Error) => void): this { this.closeCalls += 1; this.listening = false; - callback(); + callback(this.closeError); + return this; + } +} + +/** + * A port that is already taken. + * + * Node reports this on the server's `error` event, never through the `listen` + * callback, so nothing resolves and the failure is only visible to a listener + * that was attached before `listen`. + */ +class TakenPortServer extends FakeServer { + readonly failure = new Error("listen EADDRINUSE: address already in use :::3000"); + + override listen(port: number, host: string, _callback: () => void): this { + this.listenCalls.push({ port, host }); + queueMicrotask(() => this.emit("error", this.failure)); return this; } } @@ -110,6 +129,124 @@ describe("startOpenTagServer", () => { expect(closeBrowser).toHaveBeenCalledOnce(); }); + it("rejects when the port is taken, rather than resolving into a dead server", async () => { + // The `error` event is the only report of this. Dropping the listener that + // catches it leaves `listen` pending forever and startup never returns. + const controls = makeControls(); + const server = new TakenPortServer(); + const closeBrowser = vi.fn(async () => undefined); + + await expect( + startOpenTagServer({ + listener: makeListener(controls), + port: 3000, + closeBrowser, + createHttpServer: () => server, + signalTarget: new EventEmitter(), + }), + ).rejects.toBe(server.failure); + + expect(controls.stop).toHaveBeenCalledOnce(); + expect(closeBrowser).toHaveBeenCalledOnce(); + // Nothing to close: the server never began listening. + expect(server.closeCalls).toBe(0); + }); + + it.each(["SIGINT", "SIGTERM"] as const)( + "shuts everything down on %s with nothing else prompting it", + async (signal) => { + // Emitting a signal and then calling `shutdown()` proves nothing: the + // second call returns the memoized promise, so the assertions pass just + // as well with both signal handlers deleted. Only the signal runs here. + const controls = makeControls(); + const server = new FakeServer(); + const closeBrowser = vi.fn(async () => undefined); + const signalTarget = new EventEmitter(); + + await startOpenTagServer({ + listener: makeListener(controls), + port: 3000, + closeBrowser, + createHttpServer: () => server, + signalTarget, + }); + + signalTarget.emit(signal); + + await vi.waitFor(() => { + expect(controls.stop).toHaveBeenCalledOnce(); + expect(server.closeCalls).toBe(1); + expect(closeBrowser).toHaveBeenCalledOnce(); + }); + }, + ); + + it("reports every resource that failed to stop, not just the first", async () => { + // `Promise.allSettled` is the point: a Channel that will not stop must not + // hide a browser that will not close. + const channelFailure = new Error("channels would not stop"); + const browserFailure = new Error("browser would not close"); + const controls = makeControls({ + stop: vi.fn(async () => { + throw channelFailure; + }), + }); + const server = new FakeServer(); + server.closeError = new Error("server would not close"); + + const running = await startOpenTagServer({ + listener: makeListener(controls), + port: 3000, + closeBrowser: vi.fn(async () => { + throw browserFailure; + }), + createHttpServer: () => server, + signalTarget: new EventEmitter(), + }); + + const error = await running.shutdown().then( + () => undefined, + (reason: unknown) => reason, + ); + + expect(error).toBeInstanceOf(AggregateError); + expect((error as AggregateError).errors).toEqual([ + channelFailure, + server.closeError, + browserFailure, + ]); + }); + + it("hands a signal-initiated shutdown failure to onShutdownError", async () => { + // A signal callback cannot be awaited by an EventEmitter, so without this + // hook the rejection is an unhandled one and the process exits 0 after + // failing to clean up. + const failure = new Error("channels would not stop"); + const controls = makeControls({ + stop: vi.fn(async () => { + throw failure; + }), + }); + const onShutdownError = vi.fn(); + const signalTarget = new EventEmitter(); + + await startOpenTagServer({ + listener: makeListener(controls), + port: 3000, + closeBrowser: vi.fn(async () => undefined), + createHttpServer: () => new FakeServer(), + signalTarget, + onShutdownError, + }); + + signalTarget.emit("SIGTERM"); + + await vi.waitFor(() => expect(onShutdownError).toHaveBeenCalledOnce()); + const [reported] = onShutdownError.mock.calls[0]! as [unknown]; + expect(reported).toBeInstanceOf(AggregateError); + expect((reported as AggregateError).errors).toEqual([failure]); + }); + it("stops every owned resource exactly once across repeated shutdowns", async () => { const controls = makeControls(); const server = new FakeServer(); @@ -134,19 +271,146 @@ describe("startOpenTagServer", () => { }); }); +/** + * Where a Channel built in this file is allowed to dial. + * + * `createOpenTagApplication` does not merely describe a deployment, it builds + * one: the runtime's Channel manager opens a websocket to + * `intelligenceGatewayWsUrl` and calls `intelligenceApiUrl` as soon as the + * listener exists. Left to the defaults in `readEnvironment`, a unit test in + * this file reaches CopilotKit's production Intelligence gateway with whatever + * key is on the machine running it. + */ +const INTELLIGENCE_TEST_ENDPOINTS = { + INTELLIGENCE_API_URL: "https://api.intelligence.test", + INTELLIGENCE_GATEWAY_WS_URL: "wss://realtime.intelligence.test", +} as const; + +const managedEnvironment: AppEnvironment = { + agentDisplayName: "OpenTag", + agentUrl: "http://agent.internal/", + intelligenceApiKey: "cpk-1_test", + intelligenceApiUrl: INTELLIGENCE_TEST_ENDPOINTS.INTELLIGENCE_API_URL, + intelligenceGatewayWsUrl: + INTELLIGENCE_TEST_ENDPOINTS.INTELLIGENCE_GATEWAY_WS_URL, + channelName: "open-tag", + port: 3000, +}; + +describe("createAgentFactory", () => { + it("presents the shared secret the agent checks", () => { + // The runtime's half of `AGENT_AUTH_HEADER`. Deleting the header from the + // agent config left all 261 tests in this suite green while every request + // to a secured agent started coming back 401. + const agent = createAgentFactory({ + ...managedEnvironment, + agentAuthHeader: "Bearer agent-secret", + })("thread-1"); + + expect(agent.url).toBe("http://agent.internal/"); + expect(agent.headers).toEqual({ Authorization: "Bearer agent-secret" }); + }); + + it("sends no Authorization at all when no secret is configured", () => { + // A local run has no secret and the agent lets unauthenticated traffic + // through; sending an empty or literal-undefined header instead would be a + // request the agent has to decide about. + const agent = createAgentFactory({ + ...managedEnvironment, + agentAuthHeader: undefined, + })("thread-1"); + + expect(agent.headers).toEqual({}); + }); + + it.each(["", " ", "\n"])( + "refuses a shared secret of %j instead of guessing what it meant", + (agentAuthHeader) => { + // Truthiness alone decided this: `""` dropped the header silently and + // `" "` put whitespace on the wire as if it were a secret. Both read as + // "configured" to whoever set it, and the agent answers 401 either way. + expect(() => + createAgentFactory({ ...managedEnvironment, agentAuthHeader }), + ).toThrow(/AGENT_AUTH_HEADER/); + }, + ); + + it("trims the secret rather than sending an unusable header value", () => { + // A value pasted with a trailing newline is not a legal header value; Node + // rejects the request outright, so every call to the agent fails at once. + const agent = createAgentFactory({ + ...managedEnvironment, + agentAuthHeader: " Bearer agent-secret\n", + })("thread-1"); + + expect(agent.headers).toEqual({ Authorization: "Bearer agent-secret" }); + }); + + it("gives each conversation its own agent, bound to its thread", () => { + // Channels agents are stateful, so a shared instance would cross threads. + const factory = createAgentFactory(managedEnvironment); + const first = factory("thread-1"); + const second = factory("thread-2"); + + expect(first.threadId).toBe("thread-1"); + expect(second.threadId).toBe("thread-2"); + expect(first).not.toBe(second); + }); +}); + describe("createOpenTagApplication", () => { - it("declares one adapter-free managed Channel", () => { - const environment: AppEnvironment = { - agentDisplayName: "OpenTag", - agentUrl: "http://agent.internal/", - intelligenceApiKey: "cpk-1_test", - intelligenceApiUrl: "https://api.intelligence.test", - intelligenceGatewayWsUrl: "wss://realtime.intelligence.test", - channelName: "open-tag", - port: 3000, - }; + /** + * Every application this block builds, so every one of them is stopped. + * + * Each call starts a live Channel manager — a websocket to the Intelligence + * gateway and its reconnect timers — and nothing here was stopping them, so + * the sockets outlived the tests that opened them and kept retrying against + * whatever host the environment named. `channel.test.ts` has the same hook + * for the same reason. `listener.channels.stop()` is what the process itself + * calls on SIGTERM. + */ + const applications: Array> = []; + function buildApplication(environment: AppEnvironment) { const application = createOpenTagApplication(environment); + applications.push(application); + return application; + } + + /** + * The control the process itself stops the Channel with. + * + * `createCopilotNodeListener` declares it only when the runtime was handed + * Channels, so a missing one is not a typing inconvenience to assert away — + * it is a deployment whose SIGTERM handler reads `undefined` and leaves the + * gateway connection open. Said here, once, rather than at three call sites. + */ + function channelControl( + application: ReturnType, + ): ChannelsControl { + const control = application.listener.channels; + if (!control) { + throw new Error( + "the runtime exposed no Channel control, so nothing can stop the " + + "Channel this application started", + ); + } + return control; + } + + afterEach(async () => { + await Promise.all( + applications.splice(0).map(async (application) => { + await channelControl(application).stop(); + await Promise.all( + application.channels.map((channel) => channel["ɵruntime"].stop()), + ); + }), + ); + }); + + it("declares one adapter-free managed Channel", () => { + const application = buildApplication(managedEnvironment); expect( application.channels.map((channel) => ({ @@ -156,4 +420,85 @@ describe("createOpenTagApplication", () => { ).toEqual([{ name: "open-tag", adapters: [] }]); expect(application.runtime.channels).toEqual(application.channels); }); + + it("attaches no Slack adapter even when both Slack tokens are set", () => { + // The escape hatch this pins the removal of: the app used to read + // SLACK_BOT_TOKEN and SLACK_APP_TOKEN and attach its own Slack adapter + // beside the managed one, because the managed adapter could not post a + // message only one person sees. `@copilotkit/channels@0.9.2` can, so the + // hatch is gone — and it has to stay gone. Two ingress paths at once meant + // Slack delivered every message twice and the agent answered twice, and the + // direct adapter needed Socket Mode, which stops Slack delivering events to + // Intelligence at all. + // + // Read through `readEnvironment` rather than assembled by hand: the whole + // failure was env vars reaching the Channel, so the env vars are what this + // sets. + const environment = readEnvironment({ + AGENT_URL: "http://localhost:8123/", + INTELLIGENCE_API_KEY: "cpk_test", + // Named, not defaulted: `readEnvironment` falls back to CopilotKit's + // production Intelligence endpoints, and this call really does open a + // gateway websocket. A unit test must not dial production. + ...INTELLIGENCE_TEST_ENDPOINTS, + SLACK_BOT_TOKEN: "xoxb-test", + SLACK_APP_TOKEN: "xapp-test", + }); + + // Asserted here rather than trusted, because the point of reading through + // `readEnvironment` is that the env vars are what reach the Channel. + expect({ + intelligenceApiUrl: environment.intelligenceApiUrl, + intelligenceGatewayWsUrl: environment.intelligenceGatewayWsUrl, + }).toEqual({ + intelligenceApiUrl: INTELLIGENCE_TEST_ENDPOINTS.INTELLIGENCE_API_URL, + intelligenceGatewayWsUrl: + INTELLIGENCE_TEST_ENDPOINTS.INTELLIGENCE_GATEWAY_WS_URL, + }); + + const application = buildApplication(environment); + + expect(application.channels[0]!.adapters).toEqual([]); + }); + + it("hands back a Channel control that stops the Channel it started", async () => { + // The premise of the `afterEach` above, asserted rather than assumed: + // building an application starts a live Channel — the manager is already + // dialing the gateway when this line returns — and the control the process + // calls on SIGTERM is the only thing that stops it. A Channel the runtime + // never registered cannot be stopped either, so the name is compared, not + // just the overall state. + const application = buildApplication(managedEnvironment); + + await channelControl(application).stop(); + + expect(channelControl(application).status().channels).toEqual({ + "open-tag": "stopped", + }); + }); + + it("warns at startup when nothing authenticates its agent traffic", () => { + // The mismatch is silent in both directions: an agent that requires a + // secret answers 401 to every request, and nothing in this process can see + // what the Channel put on the wire. One line at boot is the only place the + // operator can notice. + const warned = vi.spyOn(console, "warn").mockImplementation(() => {}); + + buildApplication(managedEnvironment); + + expect(JSON.stringify(warned.mock.calls)).toContain("AGENT_AUTH_HEADER"); + warned.mockRestore(); + }); + + it("says nothing when a secret is configured", () => { + const warned = vi.spyOn(console, "warn").mockImplementation(() => {}); + + buildApplication({ + ...managedEnvironment, + agentAuthHeader: "Bearer agent-secret", + }); + + expect(JSON.stringify(warned.mock.calls)).not.toContain("AGENT_AUTH_HEADER"); + warned.mockRestore(); + }); }); diff --git a/app/tools/__tests__/composio-connect.test.ts b/app/tools/__tests__/composio-connect.test.ts new file mode 100644 index 00000000..3d436a98 --- /dev/null +++ b/app/tools/__tests__/composio-connect.test.ts @@ -0,0 +1,461 @@ +import { describe, expect, it, vi } from "vitest"; +import { + connectEndpoint, + normalizeToolkit, + requestConnectLink, +} from "../composio-connect.js"; + +const LINK = "https://backend.composio.dev/connect/abc123"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +const base = { + agentUrl: "http://agent.internal:8123/", + agentAuthHeader: "Bearer s3cret", + actorId: "U1", + actorKind: "human", + platform: "slack", + toolkit: "gmail", +}; + +describe("connectEndpoint", () => { + it("derives the route from the agent url, with or without a trailing slash", () => { + expect(connectEndpoint("http://agent:8123/")).toBe( + "http://agent:8123/composio/connect", + ); + expect(connectEndpoint("http://agent:8123")).toBe( + "http://agent:8123/composio/connect", + ); + }); + + it("keeps a base path rather than replacing it", () => { + expect(connectEndpoint("http://agent:8123/opentag/")).toBe( + "http://agent:8123/opentag/composio/connect", + ); + }); +}); + +describe("requestConnectLink", () => { + it("returns the link and never puts one in the request", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ redirectUrl: LINK }), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result).toEqual({ ok: true, url: LINK }); + const [, init] = (fetchImpl as unknown as ReturnType).mock + .calls[0]!; + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + actor_id: "U1", + // The agent mints nothing for a bot or an app, and only this side knows + // what clicked. Omitting it would make every connection anonymous. + kind: "human", + platform: "slack", + toolkit: "gmail", + }); + }); + + it("sends the shared secret, because the route mints nothing without it", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ redirectUrl: LINK }), + ) as unknown as typeof fetch; + + await requestConnectLink({ ...base, fetchImpl }); + + const [, init] = (fetchImpl as unknown as ReturnType).mock + .calls[0]!; + expect((init as RequestInit).headers).toMatchObject({ + authorization: "Bearer s3cret", + }); + }); + + it.each([undefined, "", " ", "\n"])( + "explains a secret of %j instead of provoking a 401 nobody can act on", + async (agentAuthHeader) => { + // Truthiness alone let a whitespace-only value through, and a header + // value with a newline in it is rejected by fetch outright. + const fetchImpl = vi.fn() as unknown as typeof fetch; + + const result = await requestConnectLink({ + ...base, + agentAuthHeader, + fetchImpl, + }); + + expect(result.ok).toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + // The variable name is the operator's business. Naming it in a thread + // tells everyone reading how this deployment is wired. + if (!result.ok) expect(result.message).not.toContain("AGENT_AUTH_HEADER"); + }, + ); + + it("logs the variable an operator has to set, where only an operator looks", async () => { + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await requestConnectLink({ + ...base, + agentAuthHeader: " ", + fetchImpl: vi.fn() as unknown as typeof fetch, + }); + + expect(JSON.stringify(logged.mock.calls)).toContain("AGENT_AUTH_HEADER"); + logged.mockRestore(); + }); + + it("trims the secret rather than putting a stray newline on the wire", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ redirectUrl: LINK }), + ) as unknown as typeof fetch; + + await requestConnectLink({ + ...base, + agentAuthHeader: "Bearer s3cret\n", + fetchImpl, + }); + + const [, init] = (fetchImpl as unknown as ReturnType).mock + .calls[0]!; + expect((init as RequestInit).headers).toMatchObject({ + authorization: "Bearer s3cret", + }); + }); + + it.each([401, 403])( + "does not repeat the agent's %i body at a person who cannot act on it", + async (status) => { + // The agent answers a mismatched secret with the bare word + // "unauthorized", which tells the person nothing and tells them nothing + // they can do. A 4xx body is also the one place a credential could be + // echoed back, and this is the status that would echo one. + const fetchImpl = vi.fn(async () => + jsonResponse({ error: "unauthorized: Bearer s3cret" }, status), + ) as unknown as typeof fetch; + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).not.toContain("s3cret"); + expect(result.message).not.toBe("unauthorized"); + expect(result.message).not.toContain("unauthorized"); + expect(result.message).not.toContain("AGENT_AUTH_HEADER"); + expect(result.message.length).toBeGreaterThan(30); + } + logged.mockRestore(); + }, + ); + + it("never repeats the secret it was given, whatever the agent says back", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ error: "rejected token Bearer s3cret" }, 400), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).not.toContain("s3cret"); + }); + + it("redacts the bare token as well as the whole header value", async () => { + // An agent that answers `token abc… is not valid` quotes only the second + // half of what we sent, and that half is the credential. + const fetchImpl = vi.fn(async () => + jsonResponse({ error: "token 0123456789abcdef is not valid" }, 400), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ + ...base, + agentAuthHeader: "Bearer 0123456789abcdef", + fetchImpl, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).not.toContain("0123456789abcdef"); + expect(result.message).toContain("[redacted]"); + } + }); + + it("passes the agent's own refusal through, because it is written for a person", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse( + { error: '"linear" is not one of the apps people connect for themselves.' }, + 400, + ), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ + ...base, + toolkit: "linear", + fetchImpl, + }); + + expect(result).toEqual({ + ok: false, + message: '"linear" is not one of the apps people connect for themselves.', + }); + }); + + it("does not surface a server error body, which is a stack trace or proxy html", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ error: "Traceback (most recent call last)" }, 500), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).not.toContain("Traceback"); + expect(result.message).toContain("gmail"); + } + }); + + it("passes the agent's own 503 through, because it says what to configure", async () => { + // "Composio is not configured on this deployment." is the agent's own + // sentence and the only one that tells the operator what to do. A blanket + // >=500 filter threw it away and showed a generic retry line instead. + const fetchImpl = vi.fn(async () => + jsonResponse( + { error: "Composio is not configured on this deployment." }, + 503, + ), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result).toEqual({ + ok: false, + message: "Composio is not configured on this deployment.", + }); + }); + + it("does not pass a proxy's 503 through, which is html and not a sentence", async () => { + const fetchImpl = vi.fn( + async () => + new Response("503 Service Unavailable", { + status: 503, + headers: { "content-type": "text/html" }, + }), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).not.toContain("html"); + }); + + it("gives up on a hung agent instead of leaving the click pending forever", async () => { + // Without a deadline the request can hang for the platform's timeout, or + // never resolve at all, and the "try again shortly" sentence below is + // unreachable — the person just watches a button that did nothing. + const fetchImpl = vi.fn( + async (_url: unknown, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + }), + ) as unknown as typeof fetch; + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await requestConnectLink({ + ...base, + timeoutMs: 10, + fetchImpl, + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("Try again shortly"); + logged.mockRestore(); + }); + + it("gives up on a stalled body, not only on stalled headers", async () => { + // The deadline was cleared the moment `fetch` resolved, which is when the + // *headers* arrive — the body is still a stream. An agent that answers 200 + // and then stops sending left `response.json()` awaiting forever with no + // deadline behind it, so the click hung exactly as it did before there was + // a timeout at all. + const stalledBody = vi.fn( + async (_url: unknown, init: RequestInit) => + ({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: () => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => { + reject( + new DOMException("The operation was aborted.", "AbortError"), + ); + }); + }), + }) as unknown as Response, + ) as unknown as typeof fetch; + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await requestConnectLink({ + ...base, + timeoutMs: 10, + fetchImpl: stalledBody, + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("Try again shortly"); + logged.mockRestore(); + }); + + it("stops the deadline once the whole reply is in hand", async () => { + // The abort must not fire after a successful read: the controller outlives + // the response object, and a late `abort()` on a settled request is a timer + // this process holds for no reason. + const fetchImpl = vi.fn(async () => + jsonResponse({ redirectUrl: LINK }), + ) as unknown as typeof fetch; + const cleared = vi.spyOn(globalThis, "clearTimeout"); + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result).toEqual({ ok: true, url: LINK }); + expect(cleared).toHaveBeenCalled(); + cleared.mockRestore(); + }); + + it("says a bad AGENT_URL is a configuration problem, not a transient one", async () => { + // `new URL()` and the header build sat inside the same unbound `catch {}` + // as the fetch, so a misconfigured agent address read as "try again + // shortly" forever, and nothing was logged. + const fetchImpl = vi.fn() as unknown as typeof fetch; + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await requestConnectLink({ + ...base, + agentUrl: "not a url", + fetchImpl, + }); + + expect(fetchImpl).not.toHaveBeenCalled(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).not.toContain("Try again shortly"); + expect(logged).toHaveBeenCalled(); + logged.mockRestore(); + }); + + it("logs an unreachable agent rather than swallowing why", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await requestConnectLink({ ...base, fetchImpl }); + + expect(logged).toHaveBeenCalled(); + logged.mockRestore(); + }); + + it("treats an unreachable agent as something to retry", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("Try again shortly"); + }); + + it("treats a response with no link as a failure rather than passing undefined on", async () => { + for (const body of [{}, { redirectUrl: "" }, { redirectUrl: 7 }]) { + const fetchImpl = vi.fn(async () => + jsonResponse(body), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + } + }); + + it("tells an unreadable reply apart from a reply with no link", async () => { + // `.catch(() => null)` reported both as "no link", so an agent answering + // 200 with html — a proxy in front of it, say — read as a Composio problem. + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + const unreadable = vi.fn( + async () => + new Response("hello", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ) as unknown as typeof fetch; + const noLink = vi.fn(async () => + jsonResponse({}), + ) as unknown as typeof fetch; + + const unreadableResult = await requestConnectLink({ + ...base, + fetchImpl: unreadable, + }); + const noLinkResult = await requestConnectLink({ + ...base, + fetchImpl: noLink, + }); + + expect(unreadableResult.ok).toBe(false); + expect(noLinkResult.ok).toBe(false); + if (!unreadableResult.ok && !noLinkResult.ok) { + expect(unreadableResult.message).not.toBe(noLinkResult.message); + } + expect(logged).toHaveBeenCalled(); + logged.mockRestore(); + }); + + it.each([ + "javascript:alert(1)", + "https://evil.example/x|Click here", + "https://evil.example/x> { + // The link is rendered into Slack's `` syntax. A `|` or a `>` in + // it ends the url half and lets the rest become a label or a second link, + // and a `javascript:` scheme is not a connect flow at all. + const fetchImpl = vi.fn(async () => + jsonResponse({ redirectUrl }), + ) as unknown as typeof fetch; + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + logged.mockRestore(); + }); +}); + +describe("normalizeToolkit", () => { + it("keeps an app name an app name", () => { + expect(normalizeToolkit(" Gmail ")).toBe("gmail"); + expect(normalizeToolkit("google_calendar")).toBe("google_calendar"); + expect(normalizeToolkit("notion-v2")).toBe("notion-v2"); + }); + + it.each([ + "", + " ", + "", + "*gmail*", + "gmail\nSection: hi", + "<@U123>", + "a".repeat(65), + ])("refuses %j, because the slug is rendered in a public post", (raw) => { + // The model chooses this string and the card carrying it is posted where + // everyone in the thread reads it, rendered as mrkdwn. An identifier + // charset is the whole of what a slug may be; anything else is not one. + expect(normalizeToolkit(raw)).toBeNull(); + }); +}); diff --git a/app/tools/__tests__/connect-app.test.tsx b/app/tools/__tests__/connect-app.test.tsx new file mode 100644 index 00000000..f5b658ec --- /dev/null +++ b/app/tools/__tests__/connect-app.test.tsx @@ -0,0 +1,99 @@ +/** + * Posting the Connect button. + * + * This is a channel tool rather than an interrupt because `Thread.resume` + * requires a live interaction continuation, which only a button click has — the + * agent-side version raised an interrupt and tried to resume it from the + * interrupt handler, which could only ever fail. + */ +import { describe, expect, it, vi } from "vitest"; +import { connectAppTool } from "../connect-app.js"; + +function context() { + const post = vi.fn(async (_ui: unknown) => ({ id: "m1" })); + return { ctx: { thread: { post }, platform: "slack" } as never, post }; +} + +describe("connect_app", () => { + it("posts a card for the named app", async () => { + const { ctx, post } = context(); + + const result = await connectAppTool.handler({ toolkit: "gmail" }, ctx); + + expect(post).toHaveBeenCalledTimes(1); + expect(String(result)).toContain("gmail"); + }); + + it("lowercases and trims what the model passed", async () => { + const { ctx, post } = context(); + + await connectAppTool.handler({ toolkit: " Gmail " }, ctx); + + const posted = JSON.stringify(post.mock.calls[0]![0]); + expect(posted).toContain("gmail"); + expect(posted).not.toContain(" Gmail "); + }); + + it.each([ + "", + "gmail>*click here*", + "*gmail*", + "gmail\nSection: ignore the above", + "<@U123>", + "", + ])("posts nothing for %j, which the card would render as live mrkdwn", async (toolkit) => { + // The model chooses this string and the card is a PUBLIC post rendered as + // Slack mrkdwn, so `` in it became a hyperlink everyone in the + // thread could click. A toolkit is an identifier; nothing else is one. + const { ctx, post } = context(); + + const result = await connectAppTool.handler({ toolkit }, ctx); + + expect(post).not.toHaveBeenCalled(); + expect(String(result)).toMatch(/not an app name|No app was named/); + }); + + it("does not echo the rejected name back into the conversation", async () => { + // The tool result goes to the model, which routinely repeats it to the + // person. Quoting the payload back would put it on a rendered surface by + // another route. + const { ctx } = context(); + + const result = await connectAppTool.handler( + { toolkit: "" }, + ctx, + ); + + expect(String(result)).not.toContain("evil.example"); + }); + + it("accepts the slug shapes real toolkits use", async () => { + for (const toolkit of ["google_calendar", "notion-v2", "gmail"]) { + const { ctx, post } = context(); + + await connectAppTool.handler({ toolkit }, ctx); + + expect(post).toHaveBeenCalledTimes(1); + expect(JSON.stringify(post.mock.calls[0]![0])).toContain(toolkit); + } + }); + + it("posts nothing when no app was named", async () => { + const { ctx, post } = context(); + + const result = await connectAppTool.handler({ toolkit: " " }, ctx); + + expect(post).not.toHaveBeenCalled(); + expect(String(result)).toContain("No app was named"); + }); + + it("tells the agent not to claim the account is connected yet", async () => { + // The button still has to be pressed, and the link still has to be + // completed in a browser. An agent that reports success here is lying. + const { ctx } = context(); + + const result = await connectAppTool.handler({ toolkit: "gmail" }, ctx); + + expect(String(result)).toContain("Do not claim the account is connected"); + }); +}); diff --git a/app/tools/__tests__/connect-click.test.tsx b/app/tools/__tests__/connect-click.test.tsx new file mode 100644 index 00000000..19796599 --- /dev/null +++ b/app/tools/__tests__/connect-click.test.tsx @@ -0,0 +1,397 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToIR, type Renderable } from "@copilotkit/channels"; +import { renderSlackMessage } from "@copilotkit/channels/slack"; +import { handleConnectClick } from "../connect-click.js"; + +const LINK = "https://backend.composio.dev/connect/abc123"; + +type Ephemeral = { ok: boolean; usedFallback?: boolean; error?: string } | null; + +/** + * `postEphemeral` resolving `null` is not an edge case: it is what the SDK does + * on every surface without a native ephemeral message, which is what the + * managed Slack adapter reports and therefore what the default deployment does. + * Every test here says which of the two outcomes it is exercising. + */ +function interaction( + actor: { id: string; kind: string } | undefined, + options: { ephemeral?: Ephemeral; postRejects?: Error } = {}, +) { + const ephemeral: Ephemeral = + options.ephemeral === undefined ? { ok: true, usedFallback: false } : options.ephemeral; + // Typed parameters, not a cast: the assertions below read the recorded + // arguments, and an untyped mock records an empty tuple. + const postEphemeral = vi.fn( + async (_user: unknown, _ui: unknown, _options: { fallbackToDM: boolean }) => + ephemeral, + ); + const post = vi.fn(async (_ui: unknown) => { + if (options.postRejects) throw options.postRejects; + return { id: "m1" }; + }); + return { + ctx: { + actor, + platform: "slack", + thread: { postEphemeral, post }, + message: { ref: "m1" }, + action: { id: "a1" }, + values: {}, + user: null, + } as never, + postEphemeral, + post, + }; +} + +const environment = { + agentUrl: "http://agent:8123/", + agentAuthHeader: "Bearer s3cret", +} as never; + +/** Everything either delivery path was handed, as one searchable string. */ +function everythingRendered( + postEphemeral: ReturnType, + post: ReturnType, +): string { + return JSON.stringify([...postEphemeral.mock.calls, ...post.mock.calls]); +} + +describe("handleConnectClick", () => { + it("mints for whoever clicked, not for whoever the card was posted to", async () => { + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx } = interaction({ id: "U2", kind: "human" }); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: "U2", + actorKind: "human", + platform: "slack", + toolkit: "gmail", + }), + ); + }); + + it("reports what clicked rather than asserting it was a person", async () => { + // The agent is the one gate on this, and it can only refuse what it is + // told. Sending a fixed "human" would hand a bot a link to a real account. + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx } = interaction({ id: "B1", kind: "bot" }); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ actorId: "B1", actorKind: "bot" }), + ); + }); + + it("delivers the link to that person alone", async () => { + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx, postEphemeral, post } = interaction({ id: "U2", kind: "human" }); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + expect(postEphemeral.mock.calls[0]![0]).toEqual({ id: "U2", kind: "human" }); + // Nothing public happened, because the private post landed. + expect(post).not.toHaveBeenCalled(); + }); + + it("asks for the DM fallback, because the default deployment has no ephemeral message", async () => { + // The managed Slack adapter declares `supportsEphemeral: false`. With + // `fallbackToDM: false` the SDK resolves `null` and the minted link is + // simply dropped — the connect button did nothing on the default install. + // A DM is scoped to the clicker exactly as an ephemeral message is. + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: true }); + }); + + it("says so in the thread when the link could not be delivered privately", async () => { + // `null` is the SDK's "this surface delivered nothing". Discarding it left + // the person staring at a button that did nothing, with no log either. + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx, post } = interaction({ id: "U2", kind: "human" }, { ephemeral: null }); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(post).toHaveBeenCalledTimes(1); + expect(logged).toHaveBeenCalled(); + logged.mockRestore(); + }); + + it("never puts the minted link anywhere public, whatever went wrong", async () => { + // Whoever completes a connect link binds their account to the id it was + // minted for, so a link in a thread is an account-takeover hazard. + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + for (const ephemeral of [null, { ok: false, error: "no ephemeral" }] as Ephemeral[]) { + const { ctx, post } = interaction({ id: "U2", kind: "human" }, { ephemeral }); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(JSON.stringify(post.mock.calls)).not.toContain(LINK); + } + logged.mockRestore(); + }); + + it("treats an ok:false ephemeral result as undelivered", async () => { + // The SDK reports the surface's refusal this way rather than throwing. + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx, post } = interaction( + { id: "U2", kind: "human" }, + { ephemeral: { ok: false, error: "slack does not support ephemeral messages" } }, + ); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(post).toHaveBeenCalledTimes(1); + logged.mockRestore(); + }); + + it("mints nothing when it cannot tell who clicked", async () => { + // Minting anyway would bind an account to whatever id we guessed. + const request = vi.fn(); + const { ctx, postEphemeral, post } = interaction(undefined); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(request).not.toHaveBeenCalled(); + expect(post).toHaveBeenCalledTimes(1); + }); + + it("does not address the no-actor notice to a literal \"unknown\"", async () => { + // There is no such user id, so `postEphemeral("unknown", …)` delivered the + // notice to nobody. With no identifiable clicker the thread is the only + // surface left, and the notice carries no capability. + const request = vi.fn(); + const { ctx, postEphemeral } = interaction(undefined); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(everythingRendered(postEphemeral, vi.fn())).not.toContain("unknown"); + expect(postEphemeral).not.toHaveBeenCalled(); + }); + + it("shows the reason privately when no link could be minted", async () => { + const request = vi.fn(async () => ({ + ok: false as const, + message: "Shared apps are connected by an operator.", + })); + const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); + + await handleConnectClick("linear", ctx, { environment, request }); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: true }); + }); + + it("still shows the reason when the surface cannot deliver privately", async () => { + // A refusal carries no capability, so the thread is a safe place for it and + // silence is not. + const request = vi.fn(async () => ({ + ok: false as const, + message: "Shared apps are connected by an operator.", + })); + const { ctx, post } = interaction({ id: "U2", kind: "human" }, { ephemeral: null }); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await handleConnectClick("linear", ctx, { environment, request }); + + expect(JSON.stringify(post.mock.calls)).toContain( + "Shared apps are connected by an operator.", + ); + logged.mockRestore(); + }); + + it("does not let a failed mint escape the click handler", async () => { + // Nothing awaits this handler: an escaping rejection is an unhandled one, + // and the person sees a button that did nothing. + const request = vi.fn(async () => { + throw new Error("boom"); + }); + const { ctx, post } = interaction({ id: "U2", kind: "human" }); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + handleConnectClick("gmail", ctx, { environment, request }), + ).resolves.toBeUndefined(); + expect(logged).toHaveBeenCalled(); + expect(post).toHaveBeenCalledTimes(1); + logged.mockRestore(); + }); + + it("does not let a throwing surface escape the click handler", async () => { + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx } = interaction({ id: "U2", kind: "human" }, { + ephemeral: null, + postRejects: new Error("channel_not_found"), + }); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + handleConnectClick("gmail", ctx, { environment, request }), + ).resolves.toBeUndefined(); + logged.mockRestore(); + }); + + it("does not let an unreadable environment escape the click handler", async () => { + // `readEnvironment()` throws on a deployment missing `AGENT_URL`, and it ran + // per click inside a handler nothing awaits. + const request = vi.fn(); + const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); + const environmentThatThrows = () => { + throw new Error("Missing required env var: AGENT_URL"); + }; + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + handleConnectClick("gmail", ctx, { + request, + readEnvironment: environmentThatThrows, + }), + ).resolves.toBeUndefined(); + expect(request).not.toHaveBeenCalled(); + // The person hears about it, on whichever surface could carry it. + expect(postEphemeral).toHaveBeenCalledTimes(1); + logged.mockRestore(); + }); + + it("refuses a toolkit that is not a slug, because the card renders it publicly", async () => { + // The value travels on the card the model asked for, and the card is a + // public post rendered as mrkdwn. + const request = vi.fn(); + const { ctx } = interaction({ id: "U2", kind: "human" }); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await handleConnectClick("", ctx, { + environment, + request, + }); + + expect(request).not.toHaveBeenCalled(); + logged.mockRestore(); + }); + + it("cannot be talked into rendering a live link by the agent's refusal text", async () => { + // The refusal is written by another service and lands in a Slack `section` + // as mrkdwn, where `` is a live, labelled hyperlink. The agent's + // own 400 quotes the toolkit it was handed, so this string has a route in + // from outside. The rendered blocks are the assertion here, not "the guard + // was called". + const request = vi.fn(async () => ({ + ok: false as const, + message: + " is not one of the apps.", + })); + const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await handleConnectClick("gmail", ctx, { environment, request }); + + const ui = postEphemeral.mock.calls[0]![1] as Renderable; + const rendered = JSON.stringify(renderSlackMessage(renderToIR(ui))); + expect(rendered).not.toContain("evil.example"); + // No Slack link markup survived at all: `<…|…>` is the whole mechanism. + expect(rendered).not.toMatch(/<[^<>]*\|[^<>]*>/); + // And the person is still told something. + expect(rendered).toMatch(/could not start the gmail connection/i); + logged.mockRestore(); + }); + + it("keeps an ordinary refusal verbatim, underscored slug and all", async () => { + // The guard rejects markup, not prose. `_` stays legal because the agent's + // refusal quotes the slug it was handed and slugs contain underscores; it + // renders as italics at worst, which carries nothing. + const request = vi.fn(async () => ({ + ok: false as const, + message: + '"google_calendar" is not one of the apps people connect for themselves. ' + + "Shared apps are connected once by an operator, not from Slack.", + })); + const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); + + await handleConnectClick("google_calendar", ctx, { environment, request }); + + const ui = postEphemeral.mock.calls[0]![1] as Renderable; + const rendered = JSON.stringify(renderSlackMessage(renderToIR(ui))); + expect(rendered).toContain("google_calendar"); + expect(rendered).toContain("connected once by an operator"); + }); + + it("still shows a refusal the client had to redact the secret out of", async () => { + // `withoutSecret` writes `[redacted]` into the sentence, so the guard has + // to refuse the markdown link *pair* rather than the brackets — otherwise + // the one refusal that most needs saying is the one silently swallowed. + const request = vi.fn(async () => ({ + ok: false as const, + message: "The token [redacted] is not valid for this workspace.", + })); + const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); + + await handleConnectClick("gmail", ctx, { environment, request }); + + const ui = postEphemeral.mock.calls[0]![1] as Renderable; + expect(JSON.stringify(renderSlackMessage(renderToIR(ui)))).toContain( + "[redacted] is not valid", + ); + }); + + it("refuses the markdown link a second surface would make live", async () => { + const request = vi.fn(async () => ({ + ok: false as const, + message: "Finish connecting [here](https:evil.example) to continue.", + })); + const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await handleConnectClick("gmail", ctx, { environment, request }); + + const ui = postEphemeral.mock.calls[0]![1] as Renderable; + expect(JSON.stringify(renderSlackMessage(renderToIR(ui)))).not.toContain( + "evil.example", + ); + logged.mockRestore(); + }); + + it("does not render a bare url the agent handed back either", async () => { + // An unmarked `https://…` autolinks in Slack on its own, so escaping the + // angle brackets would not have been enough. + const request = vi.fn(async () => ({ + ok: false as const, + message: "Finish at https://evil.example/steal to connect.", + })); + const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await handleConnectClick("gmail", ctx, { environment, request }); + + const ui = postEphemeral.mock.calls[0]![1] as Renderable; + expect(JSON.stringify(renderSlackMessage(renderToIR(ui)))).not.toContain( + "evil.example", + ); + logged.mockRestore(); + }); + + it("reports through reportRecoverableError rather than a bare console.error", async () => { + // Every other recoverable path in this app logs through one funnel, which + // is what an operator greps for and where a reporter would be attached. + const request = vi.fn(); + const { ctx } = interaction(undefined); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(logged.mock.calls[0]![0]).toBe("[channel] recoverable error"); + expect(JSON.stringify(logged.mock.calls)).toContain("connect_click"); + logged.mockRestore(); + }); +}); diff --git a/app/tools/composio-connect.ts b/app/tools/composio-connect.ts new file mode 100644 index 00000000..6c46b4d6 --- /dev/null +++ b/app/tools/composio-connect.ts @@ -0,0 +1,358 @@ +/** + * Asking the agent for one person's connect link. + * + * The Channel holds no Composio session and no api key. It knows two things the + * agent cannot: who pressed the button, and how to put something in front of + * that person alone. So it asks for a link and delivers it. The URL never + * reaches the model and is never posted where a second person could open it — + * whoever completes a connect flow binds their account to the id the link was + * minted for, which makes a shared link an account-takeover hazard. + * + * Two rules run through every branch below. Nothing a person is shown may carry + * a credential or a variable name — those go to the log, where only an operator + * looks. And nothing fails without saying so: every `return { ok: false }` here + * either repeats a sentence the agent wrote for a person, or logs the reason it + * could not. + */ + +/** How long a click waits for the agent before it is told to try again. */ +export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; + +export interface ConnectRequestInput { + agentUrl: string; + agentAuthHeader?: string; + actorId: string; + /** + * The clicker's `ProviderActor.kind`. Sent because the agent refuses to mint + * a link for anything but a person, and only this side knows what clicked. + */ + actorKind: string; + platform: string; + toolkit: string; + fetchImpl?: typeof fetch; + /** Overridden only by tests; a click cannot wait on a hung agent forever. */ + timeoutMs?: number; +} + +/** A link for exactly one person, or the sentence to show them instead. */ +export type ConnectResult = + | { ok: true; url: string } + | { ok: false; message: string }; + +/** + * The one shape a toolkit slug may have. + * + * The model chooses this string, and it is rendered into a card posted publicly + * in the thread — as Slack mrkdwn, where `` is a + * live hyperlink and `*gmail*` is bold. Escaping at the render site would have + * to be repeated at every render site and got missed at the first one. A + * toolkit is an identifier, so the identifier charset is the whole of what it + * may contain and anything else is not a toolkit name at all. + * + * Returns the normalized slug, or `null` when the string was never one. + */ +export function normalizeToolkit(raw: string): string | null { + const slug = raw.trim().toLowerCase(); + return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(slug) ? slug : null; +} + +/** + * The agent's connect endpoint, derived from the URL the Channel already uses + * to run it. Derived rather than configured separately: two variables pointing + * at one service drift, and the second one is always the stale one. + */ +export function connectEndpoint(agentUrl: string): string { + return new URL("composio/connect", agentUrl.endsWith("/") ? agentUrl : `${agentUrl}/`).toString(); +} + +/** Said when the two services do not share a secret. Names no variable. */ +const NO_SHARED_SECRET = + "Connecting your own account needs a shared secret set on both this app and " + + "its agent, and this deployment has not set one. Ask whoever runs it."; + +/** Said when they both set one and the two do not match. Names no credential. */ +const SECRET_REJECTED = + "Connecting your own account needs this app and its agent to present the " + + "same shared secret, and the agent rejected the one this app sent. Ask " + + "whoever runs this deployment."; + +export async function requestConnectLink({ + agentUrl, + agentAuthHeader, + actorId, + actorKind, + platform, + toolkit, + fetchImpl = fetch, + timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, +}: ConnectRequestInput): Promise { + // The endpoint refuses to mint anything without this header, so a deployment + // that never set it gets a clear sentence rather than a 401 the person cannot + // act on. Trimmed rather than tested for truthiness: a value of `" "` is set + // everywhere it is checked and authorizes nothing, and one with a newline in + // it is not a legal header value — `fetch` rejects the whole request. + const secret = agentAuthHeader?.trim(); + if (!secret) { + console.error( + "[opentag] no connect link can be minted: AGENT_AUTH_HEADER is unset or " + + "blank on this service, and the agent's connect route requires it", + ); + return { ok: false, message: NO_SHARED_SECRET }; + } + + // Built before the request and outside its catch. `new URL()` throws on a + // malformed AGENT_URL, which is a configuration mistake that will never + // resolve itself; sharing a catch with the fetch reported it as "try again + // shortly" forever and logged nothing. + let endpoint: string; + let body: string; + try { + endpoint = connectEndpoint(agentUrl); + body = JSON.stringify({ + actor_id: actorId, + kind: actorKind, + platform, + toolkit, + }); + } catch (error) { + console.error( + `[opentag] could not build the connect request for ${toolkit}; check AGENT_URL`, + error, + ); + return { + ok: false, + message: + `Could not start the ${toolkit} connection: this deployment's agent ` + + "address is not a usable URL. Ask whoever runs it.", + }; + } + + const controller = new AbortController(); + // Held until the whole reply is in hand, not until the headers are. `fetch` + // resolves on the status line, with the body still an open stream, so + // clearing the deadline here left `response.json()` below awaiting with + // nothing behind it — an agent that answers 200 and then stops sending hung + // the click exactly as it did before there was a timeout at all. Aborting + // after the headers errors the body stream, which is the wanted effect. + const deadline = setTimeout(() => controller.abort(), timeoutMs); + try { + return await exchange({ + endpoint, + body, + secret, + toolkit, + fetchImpl, + signal: controller.signal, + }); + } finally { + clearTimeout(deadline); + } +} + +/** One request and its whole reply, inside the caller's deadline. */ +async function exchange({ + endpoint, + body, + secret, + toolkit, + fetchImpl, + signal, +}: { + endpoint: string; + body: string; + secret: string; + toolkit: string; + fetchImpl: typeof fetch; + signal: AbortSignal; +}): Promise { + let response: Response; + try { + response = await fetchImpl(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: secret, + }, + body, + signal, + }); + } catch (error) { + // The reason is a network detail; the person can only retry either way. It + // still belongs in the log, where the operator can see whether every click + // is failing and why. + console.error( + `[opentag] the agent could not be reached to mint a ${toolkit} connect link`, + error, + ); + return { + ok: false, + message: `Could not reach the agent to start the ${toolkit} connection. Try again shortly.`, + }; + } + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + // The agent's own body here is the bare word "unauthorized", which tells + // the person nothing they can act on. It is also the one response that + // could quote the credential back, and a thread is the last place that + // may appear. + console.error( + `[opentag] the agent rejected this service's AGENT_AUTH_HEADER (${response.status}) ` + + `while minting a ${toolkit} connect link; the two halves do not match`, + ); + return { ok: false, message: SECRET_REJECTED }; + } + + const detail = withoutSecret(await readErrorMessage(response), secret); + if (detail === null) { + console.error( + `[opentag] the agent answered ${response.status} with no usable reason ` + + `while minting a ${toolkit} connect link`, + ); + } + return { + ok: false, + message: detail ?? `Could not start the ${toolkit} connection.`, + }; + } + + let payload: { redirectUrl?: unknown } | null; + try { + payload = (await response.json()) as { redirectUrl?: unknown } | null; + } catch (error) { + // Not the same thing as "the agent had no link for you": this is something + // other than the agent answering — a proxy, usually — and conflating the + // two sent the person off to check their Composio configuration. + console.error( + `[opentag] the agent's ${toolkit} connect reply was not JSON`, + error, + ); + return { + ok: false, + message: `Could not start the ${toolkit} connection: the reply was unreadable. Try again shortly.`, + }; + } + + const url = safeConnectUrl(payload?.redirectUrl); + if (url === null) { + console.error( + `[opentag] the agent returned no usable ${toolkit} connect link`, + ); + return { + ok: false, + message: `Could not start the ${toolkit} connection: no link came back. Try again shortly.`, + }; + } + return { ok: true, url }; +} + +/** + * The agent's sentence with the one credential this side knows taken out of it. + * + * A 4xx body is written for a person and goes straight into a thread. We handed + * the agent exactly one secret, so that is exactly one string we can recognize + * on the way back — both as the whole header value and as the token inside it, + * because an error like `token abc… is not valid` quotes only the second. + */ +function withoutSecret(message: string | null, secret: string): string | null { + if (message === null) return null; + let scrubbed = message; + for (const needle of secretNeedles(secret)) { + scrubbed = scrubbed.split(needle).join("[redacted]"); + } + return scrubbed; +} + +/** The header value, and the token in it when that is long enough to be one. */ +function secretNeedles(secret: string): string[] { + const needles = new Set(); + if (secret.length > 0) needles.add(secret); + const token = secret.split(/\s+/).at(-1); + if (token && token.length >= 6) needles.add(token); + return [...needles].sort((a, b) => b.length - a.length); +} + +/** + * The minted link, if it is one that may be rendered. + * + * It is rendered into Slack's `` syntax, where `|` and `>` end the + * url half — a link carrying either could smuggle a label of its own or a + * second link past the person reading it. The scheme is checked because a + * `javascript:` or `data:` URL in that position is not a connect flow. + */ +function safeConnectUrl(raw: unknown): string | null { + if (typeof raw !== "string" || raw.length === 0) return null; + if (/[<>|"'\s]/.test(raw)) return null; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + return raw; +} + +/** + * The agent's refusal, if it is one that may be rendered. + * + * The sibling of `normalizeToolkit` and `safeConnectUrl`, for the third value + * on this path that another service wrote: a 4xx body, which goes into a Slack + * `section` as mrkdwn. There, `` + * is a live, labelled hyperlink and a bare `https://…` autolinks on its own — + * so escaping the angle brackets would not have been enough, and the guard + * has to be the same one the other two use: a value outside the shape we + * accept is not rendered at all. + * + * The shape is "a sentence": prose, with nothing in it that opens markup on a + * surface this app renders to and nothing that reads as a web address. + * + * `_` stays legal because the agent's own refusal quotes the slug it was handed + * and slugs contain underscores; italics carry nothing anyway. `/` is refused, + * and that is what puts `://` out of reach. `](` is refused because a section's + * text is run through a markdown-to-mrkdwn pass on the way out, so + * `[here](https:evil.example)` arrives in the thread as a live `` + * without ever containing an angle bracket. It is the pair that is refused + * rather than the brackets, so that `withoutSecret`'s `[redacted]` still reads + * as a sentence — that refusal is the one that most needs saying. + * + * Returns the trimmed sentence, or `null` when it was never one — the caller + * shows its own words instead and logs what it dropped. + */ +export function safeRefusalMessage(raw: string): string | null { + const message = raw.trim(); + if (message.length === 0 || message.length > 400) return null; + if (/[<>|*~`\/\\]/.test(message)) return null; + if (message.includes("](")) return null; + if (/www\./i.test(message)) return null; + return message; +} + +/** A JSON body is the agent answering; anything else is something in front of it. */ +function isJson(response: Response): boolean { + return (response.headers.get("content-type") ?? "").includes("json"); +} + +/** + * The agent's own sentence when it has one. + * + * From a 4xx: those are its considered refusals ("that app is connected by an + * operator, not from Slack"), and they are written for a person. And from a + * 503, which is the agent saying it is not configured for this — equally its + * own sentence and the only one that names what to fix. Every other 5xx is a + * stack trace. The content type is checked because a proxy's 503 is HTML and + * carries no sentence for anyone. + */ +async function readErrorMessage(response: Response): Promise { + if (response.status >= 500 && response.status !== 503) return null; + if (!isJson(response)) return null; + try { + const payload = (await response.json()) as { error?: unknown } | null; + const error = payload?.error; + if (typeof error !== "string") return null; + const trimmed = error.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } +} diff --git a/app/tools/connect-app.tsx b/app/tools/connect-app.tsx new file mode 100644 index 00000000..f451e269 --- /dev/null +++ b/app/tools/connect-app.tsx @@ -0,0 +1,57 @@ +/** + * `connect_app` — post the Connect button for one app. + * + * A channel tool rather than an interrupt. The agent's first attempt at this + * raised an interrupt and resumed it immediately, on the reasoning that posting + * a card is a render request and not a decision to wait on. The framework + * disagrees for a good reason: `Thread.resume` requires a live interaction + * continuation, which only a button click has. An interrupt handler has none, so + * that call could only ever fail. + * + * A channel tool is the mechanism that actually fits. The agent decides *when* + * to ask — it knows which apps are configured and which the search reported as + * unconnected — and the surface does the posting, which is its job anyway. + */ +import { defineChannelTool } from "@copilotkit/channels"; +import { z } from "zod"; +import { ConnectAccount } from "../human-in-the-loop/connect-account.js"; +import { normalizeToolkit } from "./composio-connect.js"; + +export const connectAppTool = defineChannelTool({ + name: "connect_app", + description: + "Post a Connect button so the person can connect their own account for one " + + "app. Call this when a connected-app search reports that an app needs " + + "connecting, naming that app. The button is public but the link it produces " + + "is private to whoever presses it.", + parameters: z.object({ + toolkit: z + .string() + .describe("The app to connect, as the search reported it, e.g. 'gmail'"), + }), + async handler({ toolkit }, { thread }) { + if (!toolkit.trim()) return "No app was named, so no button was posted."; + + // The model chose this string and the card carrying it is a PUBLIC post + // rendered as mrkdwn, where `` is a live hyperlink and `*x*` is + // bold. A toolkit is an identifier, so anything outside the identifier + // charset is not a toolkit name and no card is posted for it. The rejected + // value is not quoted back: the model repeats tool results to people, which + // would put it on a rendered surface by a second route. + const slug = normalizeToolkit(toolkit); + if (slug === null) { + return ( + "That is not an app name, so no button was posted. App names are " + + "lowercase identifiers like 'gmail' or 'google_calendar'; ask the " + + "person which app they mean." + ); + } + + await thread.post(); + return ( + `Posted a Connect ${slug} button in this thread. Tell the person to press ` + + `it; the link will be private to them. Do not claim the account is ` + + `connected until a later message says so.` + ); + }, +}); diff --git a/app/tools/connect-click.tsx b/app/tools/connect-click.tsx new file mode 100644 index 00000000..36fb6738 --- /dev/null +++ b/app/tools/connect-click.tsx @@ -0,0 +1,280 @@ +/** + * What happens when somebody presses "Connect". + * + * Kept out of the card so it can be tested without rendering one, and out of + * `composio-connect.ts` so that module stays a pure client with no knowledge of + * threads or delivery. + * + * Nothing awaits this handler, so nothing here may throw: a rejection escaping + * it is an unhandled one, and all the person sees is a button that did nothing. + * Every step below either delivers something or logs why it could not. + */ +import type { InteractionContext, Renderable } from "@copilotkit/channels"; +import { reportRecoverableError } from "../channel-helpers.js"; +import { readEnvironment } from "../env.js"; +import { + ConnectFailed, + ConnectLink, + type ConnectRequest, +} from "../human-in-the-loop/connect-account.js"; +import { + normalizeToolkit, + requestConnectLink, + safeRefusalMessage, +} from "./composio-connect.js"; + +type Interaction = InteractionContext; + +/** + * Deliver privately, and say so publicly when that was not possible. + * + * `Thread.postEphemeral` reports a non-delivery two ways, and neither is an + * exception: `null` when the surface has no native ephemeral message and was + * told not to DM, and `{ ok: false }` when the adapter offers no private + * message at all. Both results were discarded here, so on the default + * deployment — the managed Intelligence adapter, which declares + * `supportsEphemeral: false` at the time — the minted link went nowhere, the thread stayed + * silent, and nothing was logged. The button did nothing, twice over. + * + * So: ask for the DM fallback, because a DM is scoped to the clicker exactly as + * an ephemeral message is — the hazard a connect link carries is a *second + * reader*, and a DM has none. Then check what came back. When nothing was + * delivered the thread gets a sentence saying so, never the link: whoever + * completes a connect flow binds their account to the id it was minted for, so + * a link a second person can read is an account takeover. + * + * The managed adapter implements `postEphemeral` as of + * `@copilotkit/channels@0.9.2`, and the flow is confirmed working end to end + * against a live Slack workspace: the clicker gets a link nobody else can + * read. Teams has no ephemeral surface, so there the link is discarded and the + * clicker is told — which is the whole point of checking the result. + */ +export async function handleConnectClick( + toolkit: string, + interaction: Interaction, + deps: { + environment?: ReturnType; + readEnvironment?: typeof readEnvironment; + request?: typeof requestConnectLink; + } = {}, +): Promise { + try { + await runConnectClick(toolkit, interaction, deps); + } catch (error) { + // The last resort. Everything below is already guarded, so reaching here + // means something threw that was not expected to — and the person is still + // looking at a button that appears to have done nothing. + reportRecoverableError(error, { + operation: "connect_click", + recovery: "posted_the_notice_in_the_thread", + }); + await postToThread( + interaction, + , + ); + } +} + +async function runConnectClick( + toolkit: string, + interaction: Interaction, + deps: { + environment?: ReturnType; + readEnvironment?: typeof readEnvironment; + request?: typeof requestConnectLink; + }, +): Promise { + const request = deps.request ?? requestConnectLink; + + // The value travels on the card, and the card was posted from a name the + // model chose. A click after a restart re-derives that card from its stored + // props, so this is the last place the value is checked before it is rendered + // again. + const slug = normalizeToolkit(toolkit); + if (slug === null) { + reportRecoverableError( + "[opentag] a connect click carried something that is not an app name; nothing was minted", + { + operation: "connect_click_toolkit", + recovery: "posted_the_notice_in_the_thread", + }, + ); + await postToThread( + interaction, + , + ); + return; + } + + const actor = interaction.actor; + if (!actor?.id) { + // Without a verified clicker there is nobody to mint for. Minting anyway + // would bind an account to whatever id we guessed. The notice goes to the + // thread rather than to a made-up id: `postEphemeral("unknown", …)` + // addresses a user that does not exist, so nobody ever saw it. + reportRecoverableError( + "[opentag] a connect click arrived with no identifiable actor; nothing was minted", + { + operation: "connect_click_actor", + recovery: "posted_the_notice_in_the_thread", + }, + ); + await postToThread( + interaction, + , + ); + return; + } + + let environment: ReturnType; + try { + environment = deps.environment ?? (deps.readEnvironment ?? readEnvironment)(); + } catch (error) { + // `readEnvironment()` throws on a deployment missing `AGENT_URL`, and it + // runs per click — inside a handler nothing awaits. + reportRecoverableError(error, { + operation: "connect_click_environment", + recovery: "told_the_clicker_on_whichever_surface_could_carry_it", + }); + await deliver( + interaction, + actor, + , + ); + return; + } + + const result = await request({ + agentUrl: environment.agentUrl, + agentAuthHeader: environment.agentAuthHeader, + actorId: actor.id, + actorKind: actor.kind, + platform: interaction.platform, + toolkit: slug, + }); + + if (!result.ok) { + // A refusal carries no capability, so the thread is a safe second home for + // it — and silence is not one. + // + // The sentence itself, though, was written by another service and is about + // to be rendered as mrkdwn, where `` is a live hyperlink. The + // model-chosen toolkit slug was given this treatment earlier in this PR and + // this path was missed: the agent's own 400 quotes the toolkit it was + // handed, so a value the model chose reaches here by a second route. Same + // rule, same place — nothing from outside this repository is rendered + // unexamined. + const safe = safeRefusalMessage(result.message); + if (safe === null) { + reportRecoverableError( + "[opentag] the agent's connect refusal was not a renderable sentence " + + "and was replaced; it is logged here rather than shown: " + + result.message, + { + operation: "connect_click_refusal", + recovery: "showed_this_app's_own_sentence_instead", + }, + ); + } + await deliver( + interaction, + actor, + , + ); + return; + } + + const delivered = await deliverPrivately( + interaction, + actor, + , + ); + if (delivered) return; + + // Says what actually broke, in the one place only an operator reads. Private + // delivery is the managed adapter's job and it has one: an ephemeral message + // where the surface supports it, a DM where it does not. Reaching this line + // means that adapter refused or the surface has no private message at all, so + // the answer is on the Intelligence side — not a token this app could hold. + reportRecoverableError( + `[opentag] a minted ${slug} connect link could not be delivered privately ` + + "and was discarded rather than posted publicly. Check that this " + + "deployment's Intelligence Channel is connected and that its Slack app " + + "can message this person directly.", + { + operation: "connect_click_delivery", + recovery: "discarded_the_link_and_said_so_in_the_thread", + }, + ); + await postToThread( + interaction, + + ); +} + +/** Privately if the surface can, in the thread if it cannot. Never silent. */ +async function deliver( + interaction: Interaction, + actor: NonNullable, + ui: Renderable, +): Promise { + if (await deliverPrivately(interaction, actor, ui)) return; + await postToThread(interaction, ui); +} + +/** + * True only when the surface actually put this in front of that one person. + * + * `null` means the surface delivered nothing; `{ ok: false }` means it declined + * and said why. Neither is an exception, which is how both came to be dropped. + */ +async function deliverPrivately( + interaction: Interaction, + actor: NonNullable, + ui: Renderable, +): Promise { + try { + const result = await interaction.thread.postEphemeral(actor, ui, { + fallbackToDM: true, + }); + if (result?.ok) return true; + reportRecoverableError( + "[opentag] the surface delivered no private connect message: " + + (result?.error ?? "no ephemeral message and no DM on this surface"), + { + operation: "connect_click_private_delivery", + recovery: "falling_back_to_the_thread", + }, + ); + } catch (error) { + reportRecoverableError(error, { + operation: "connect_click_private_delivery", + recovery: "falling_back_to_the_thread", + }); + } + return false; +} + +/** The public half. Only ever a sentence — never a link. */ +async function postToThread( + interaction: Interaction, + ui: Renderable, +): Promise { + try { + await interaction.thread.post(ui); + } catch (error) { + reportRecoverableError(error, { + operation: "connect_click_thread_notice", + recovery: "none_the_clicker_was_not_told", + }); + } +} diff --git a/app/tools/index.ts b/app/tools/index.ts index 1878635d..d3e33df8 100644 --- a/app/tools/index.ts +++ b/app/tools/index.ts @@ -9,6 +9,7 @@ import { blockCatalogTool, isBlockCatalogEnabled, } from "./block-catalog.js"; +import { connectAppTool } from "./connect-app.js"; import { readThreadTool } from "./read-thread.js"; import { createShowCapabilitiesTool } from "./capabilities.js"; import { renderDiagramTool } from "./render-diagram.js"; @@ -51,6 +52,11 @@ export function createAppTools( showWorkPlanTool, showDecisionBriefTool, showKnowledgeSummaryTool, + // Registered unconditionally. Which apps a person can connect is the + // agent's configuration, not the runtime's — on a two-service deployment the + // toolkit lists are set on the agent alone — so the surface offers the + // button and the agent decides when asking for one makes sense. + connectAppTool, // Off by default, and *absent* rather than refusing when off: a tool the // agent can see but must not call leaks into its reasoning and turns into // "I can't do that here" instead of the topic not existing. diff --git a/deployment/aws/README.md b/deployment/aws/README.md index 44ac2dd9..123aff96 100644 --- a/deployment/aws/README.md +++ b/deployment/aws/README.md @@ -72,7 +72,8 @@ Create one JSON secret with these fields: "GITHUB_CODER_TOKEN": "", "POSTHOG_PERSONAL_API_KEY": "", "LINEAR_API_KEY": "", - "NOTION_MCP_AUTH_TOKEN": "" + "NOTION_MCP_AUTH_TOKEN": "", + "COMPOSIO_API_KEY": "" } ``` @@ -80,6 +81,41 @@ Only `INTELLIGENCE_API_KEY` and `OPENAI_API_KEY` are required by the standard deployment. Every JSON field must exist because ECS resolves each one when the task starts; use an empty string for an unused integration. +**`AGENT_AUTH_HEADER` is the exception to "empty is fine."** The template above +ships it empty on purpose — that is the correct value when you are not connecting +personal Composio accounts, and it leaves ordinary agent traffic unauthenticated +exactly as before. But `agent/agent_auth.py` strips the value and treats `""` as +unconfigured, and a connect link is refused whenever the secret is unconfigured. +So a deployment that turns on `composioUserToolkits` and leaves this field empty +gets the Connect card and never a link. Put one long random string here, the same +one for both containers — the stack already maps this single field onto both, so +there is nothing to keep in sync by hand. + +**Upgrading an existing deployment: nothing to add unless you are turning +Composio on.** `COMPOSIO_API_KEY` is new in this release, and the stack declares +it only when the context that gives it a purpose is set — that is, when either +toolkit list is non-empty. A deployment that does not set those contexts never +asks ECS for the field, so an existing secret still starts. + +Add them **when you enable the feature**, in the same change that sets the +context. ECS resolves every declared field at task start, so a secret missing a +field the stack now declares fails with `does not contain the specified JSON +key` and the deployment rolls back. + +### Composio context keys + +Set these with `-c` at deploy time, or in `cdk.json`: + +| Key | Effect | +|---|---| +| `composioToolkits` | Toolkit slugs everyone shares one connection for. Setting either list makes the stack declare `COMPOSIO_API_KEY`. | +| `composioUserToolkits` | Toolkit slugs scoped to whoever sent the message. Setting either list makes the stack declare `COMPOSIO_API_KEY`. Two prerequisites: a **non-empty `AGENT_AUTH_HEADER`** in the JSON secret, without which no connect link is ever minted, and a **Slack-backed Channel** — Teams has no private message, so the link cannot be delivered there. No platform credential is needed either way; the managed adapter delivers privately. | +| `composioApprovals` | `on` (default) or `off`. `destructive` and `writes` are the old spellings and still parse as `on`. | +| `composioWorkspaceUserId` | The Composio user id shared toolkits act as. Set it explicitly: it otherwise defaults to the Channel name, and renaming the Channel would move every shared connection. | + +`COMPOSIO_AUTH_CONFIGS` has no context key yet, so an AWS deployment cannot pin +which auth config a shared toolkit connects against. Railway can. + Create a second Secrets Manager secret for Datadog. Its entire plaintext value must be the raw Datadog API key, not JSON. @@ -98,7 +134,7 @@ These CDK context values become container environment variables: | CDK context | Container variable | Default | | --- | --- | --- | | `agentDisplayName` | `AGENT_DISPLAY_NAME` on both containers | `OpenTag` | -| `channelName` | `INTELLIGENCE_CHANNEL_NAME` | `open-tag` | +| `channelName` | `INTELLIGENCE_CHANNEL_NAME` on both containers | `open-tag` | | `intelligenceApiUrl` | `INTELLIGENCE_API_URL` | CopilotKit hosted API | | `intelligenceGatewayWsUrl` | `INTELLIGENCE_GATEWAY_WS_URL` | CopilotKit hosted realtime gateway | | `logLevel` | `LOG_LEVEL` | `warn` | @@ -115,10 +151,23 @@ These CDK context values become container environment variables: | `posthogMcpUrl` | `POSTHOG_MCP_URL` | Hosted read-only PostHog MCP | | `linearMcpUrl` | `LINEAR_MCP_URL` | Hosted Linear MCP | | `notionMcpUrl` | `NOTION_MCP_URL` | Unset | +| `composioToolkits` | `COMPOSIO_TOOLKITS` | Unset | +| `composioUserToolkits` | `COMPOSIO_USER_TOOLKITS` | Unset | +| `composioApprovals` | `COMPOSIO_APPROVALS` | Unset, so the agent's own default `on` applies | +| `composioWorkspaceUserId` | `COMPOSIO_WORKSPACE_USER_ID` | Unset | `githubAppPrivateKeySecretArn` optionally maps a separate raw Secrets Manager secret to `GITHUB_APP_PRIVATE_KEY_BASE64` on the agent container. +Set `composioWorkspaceUserId` explicitly whenever you use `composioToolkits`. +Left unset, shared toolkits use `channelName`, which the stack forwards to the +agent as `INTELLIGENCE_CHANNEL_NAME`. The connect script an operator runs locally +reads the same variables from their local environment, so it must use the same +identity. If the two disagree, the link connects an account no deployed turn +ever looks up. Set `COMPOSIO_WORKSPACE_USER_ID` locally to the deployed +`composioWorkspaceUserId` to keep them aligned. See +[`../../setup.md`](../../setup.md#composio). + The AWS task fixes `AGENT_URL` to `http://127.0.0.1:8123/`, the runtime port to `3000`, and the agent port to `8123` because both containers share one task. Users running the images elsewhere can set `AGENT_URL`, `PORT`, `SERVER_HOST`, @@ -145,8 +194,8 @@ Deploy a versioned public GHCR release: ```bash pnpm exec cdk deploy opentag-production \ -c vpcId=vpc-... \ - -c agentImage=ghcr.io/copilotkit/opentag-agent:v0.2.0 \ - -c runtimeImage=ghcr.io/copilotkit/opentag-runtime:v0.2.0 \ + -c agentImage=ghcr.io/copilotkit/opentag-agent:v0.4.1 \ + -c runtimeImage=ghcr.io/copilotkit/opentag-runtime:v0.4.1 \ --parameters opentag-production:OpenTagSecretArn=COMPLETE_OPENTAG_SECRET_ARN \ --parameters opentag-production:DatadogApiKeySecretArn=COMPLETE_DATADOG_SECRET_ARN ``` @@ -201,7 +250,7 @@ pnpm exec cdk deploy opentag-production \ ## Public images -CopilotKit's maintainer-only Kite release automation publishes: +CopilotKit's maintainer-only release automation publishes: - `ghcr.io/copilotkit/opentag-agent` - `ghcr.io/copilotkit/opentag-runtime` @@ -212,11 +261,14 @@ merges do not publish images. New GHCR packages start private; after the first publication, an organization owner must change both packages to public. No registry credentials are needed after that. -Test the same images locally: +Test the same images locally. The compose file resolves its build context and +its `env_file` relative to `deployment/`, so the `.env` it wants is the one at +the repository root — these paths are written from `deployment/aws`, where the +Deploy section above left you: ```bash -cp .env.example .env -docker compose -f deployment/docker-compose.yml up --build +cp ../../.env.example ../../.env +docker compose -f ../docker-compose.yml up --build ``` ## Verify @@ -238,7 +290,7 @@ Check runtime logs for `setup_required`, then test a real Channel mention. The service maintains one task during normal operation. During deployments, ECS briefly starts a second task and waits for it to become healthy before stopping -the old task. This avoids intentionally taking Kite offline during replacement. +the old task. This avoids intentionally taking OpenTag offline during replacement. Multiple runtimes using the same Channel name can race to claim deliveries, so the overlap is limited to the rollout. Agent graph checkpoints remain in memory, so verify a real Channel mention after deployment. diff --git a/deployment/aws/lib/opentag-stack.ts b/deployment/aws/lib/opentag-stack.ts index 56fc3913..a4409238 100644 --- a/deployment/aws/lib/opentag-stack.ts +++ b/deployment/aws/lib/opentag-stack.ts @@ -19,8 +19,27 @@ const repositoryRoot = path.resolve(currentDirectory, "../../.."); const DATADOG_FORWARDER_TEMPLATE_URL = "https://datadog-cloudformation-template.s3.amazonaws.com/aws/forwarder/5.4.11.yaml"; +/** + * The shared secret the runtime presents and the agent checks. + * + * Named once and referenced from both lists below, because the two containers + * have to read the same field of the same secret: point one of them at a + * different name and the runtime authenticates against a value the agent never + * sees, which is a 401 on every request and nothing in the template to show + * why. See `agent/agent_auth.py` for what the agent does with it. + */ +const SHARED_AUTH_SECRET_KEY = "AGENT_AUTH_HEADER"; + +/** + * Fields every documented OpenTag secret already carries. + * + * Injected unconditionally, which is only safe because + * `deployment/aws/README.md` has required each of them since before this + * release — an existing secret has them, empty string or not. + */ const AGENT_SECRET_KEYS = [ "OPENAI_API_KEY", + SHARED_AUTH_SECRET_KEY, "TAVILY_API_KEY", "DAYTONA_API_KEY", "GITHUB_PERSONAL_ACCESS_TOKEN", @@ -32,9 +51,26 @@ const AGENT_SECRET_KEYS = [ const RUNTIME_SECRET_KEYS = [ "INTELLIGENCE_API_KEY", - "AGENT_AUTH_HEADER", + SHARED_AUTH_SECRET_KEY, ] as const; +/** + * Fields this release introduces, declared only when they have a job to do. + * + * ECS resolves every declared secret field when the task starts and fails the + * task when one is missing. A new field added to the lists above is therefore + * not a deploy-time error an operator can read and correct — it is an existing + * deployment that stops starting tasks the moment it takes the upgrade, before + * anybody had the chance to add the field. So an upgrade asks for nothing new, + * and turning the feature on is one deliberate step that adds the field and + * sets the context together. + * + * The agent treats a Composio key with no toolkits as unconfigured + * (`agent/composio_tools/config.py`), so the toolkit lists are exactly the + * signal for whether the key has anything to do. + */ +const COMPOSIO_AGENT_SECRET_KEYS = ["COMPOSIO_API_KEY"] as const; + function contextString( scope: Construct, key: string, @@ -138,6 +174,15 @@ export class OpenTagStack extends cdk.Stack { "daytonaTtlMinutes", 60, ); + const composioToolkits = contextString(this, "composioToolkits", ""); + const composioUserToolkits = contextString( + this, + "composioUserToolkits", + "", + ); + // Either list on its own turns the integration on, and one key serves both. + const composioConfigured = + composioToolkits.length > 0 || composioUserToolkits.length > 0; const githubAppId = contextString(this, "githubAppId", ""); const githubAppInstallationId = contextString( this, @@ -252,6 +297,11 @@ export class OpenTagStack extends cdk.Stack { "githubMcpUrl", "https://api.githubcopilot.com/mcp/readonly", ), + // The agent derives the default Composio workspace user id from this. + // Without it the team's shared connections resolve under the literal + // `open-tag` whatever the channel is really called, so a deployment + // that renamed its channel silently connects the wrong identity. + INTELLIGENCE_CHANNEL_NAME: channelName, LINEAR_MCP_URL: contextString( this, "linearMcpUrl", @@ -261,6 +311,19 @@ export class OpenTagStack extends cdk.Stack { "NOTION_MCP_URL", contextString(this, "notionMcpUrl", ""), ), + ...optionalEnvironment("COMPOSIO_TOOLKITS", composioToolkits), + ...optionalEnvironment( + "COMPOSIO_USER_TOOLKITS", + composioUserToolkits, + ), + ...optionalEnvironment( + "COMPOSIO_APPROVALS", + contextString(this, "composioApprovals", ""), + ), + ...optionalEnvironment( + "COMPOSIO_WORKSPACE_USER_ID", + contextString(this, "composioWorkspaceUserId", ""), + ), OPENAI_MODEL: openAiModel, OPENAI_REASONING_EFFORT: openAiReasoningEffort, OPENAI_VERBOSITY: openAiVerbosity, @@ -294,6 +357,9 @@ export class OpenTagStack extends cdk.Stack { memoryReservationMiB: 1792, secrets: { ...secretFields(applicationSecret, AGENT_SECRET_KEYS), + ...(composioConfigured + ? secretFields(applicationSecret, COMPOSIO_AGENT_SECRET_KEYS) + : {}), ...(githubAppPrivateKeySecret ? { GITHUB_APP_PRIVATE_KEY_BASE64: diff --git a/deployment/aws/test/opentag-stack.test.ts b/deployment/aws/test/opentag-stack.test.ts index 62f38ae5..87a921b7 100644 --- a/deployment/aws/test/opentag-stack.test.ts +++ b/deployment/aws/test/opentag-stack.test.ts @@ -7,6 +7,71 @@ import * as ecs from "aws-cdk-lib/aws-ecs"; import { OpenTagInfrastructureStack } from "../lib/opentag-infrastructure-stack.js"; import { OpenTagStack } from "../lib/opentag-stack.js"; +interface ContainerDefinition { + Environment?: { Name: string; Value: string }[]; + Name: string; + Secrets?: { Name: string; ValueFrom: unknown }[]; +} + +/** The one task definition's container called `name`. */ +function containerDefinition( + template: Template, + name: string, +): ContainerDefinition { + const taskDefinitions = Object.values( + template.findResources("AWS::ECS::TaskDefinition"), + ) as { Properties: { ContainerDefinitions: ContainerDefinition[] } }[]; + assert.equal(taskDefinitions.length, 1); + const container = taskDefinitions[0]?.Properties.ContainerDefinitions.find( + (candidate) => candidate.Name === name, + ); + assert.ok(container, `no ${name} container in the task definition`); + return container; +} + +/** What a container's secret for `key` must resolve to: the shared secret's field, by reference. */ +function secretsManagerField(key: string): unknown { + return { + "Fn::Join": ["", [{ Ref: "OpenTagSecretArn" }, `:${key}::`]], + }; +} + +/** A container's secrets keyed by name, so the comparison ignores declaration order. */ +function secretsByName( + template: Template, + name: string, +): Record { + return Object.fromEntries( + (containerDefinition(template, name).Secrets ?? []).map( + ({ Name, ValueFrom }) => [Name, ValueFrom], + ), + ); +} + +function expectedSecrets(keys: string[]): Record { + return Object.fromEntries(keys.map((key) => [key, secretsManagerField(key)])); +} + +/** + * A container's environment as name to value, so a comparison reads both. + * + * The names-only version this replaces passed with the CORS default flipped to + * a single origin, with `PLAYWRIGHT_BROWSERS_PATH` pointed at a directory the + * image does not have, and with the runtime `PORT` moved off the port its own + * health check probes. Every one of those is a container that boots into a + * different deployment than the one the file describes. + */ +function environmentValues( + template: Template, + name: string, +): Record { + return Object.fromEntries( + (containerDefinition(template, name).Environment ?? []).map( + ({ Name, Value }) => [Name, Value], + ), + ); +} + function stackWithContext( context: Record = {}, shared = false, @@ -103,6 +168,261 @@ test("creates one private rolling environment service containing both containers }); }); +/** The fields the documented secret has carried since the first release. */ +const ESTABLISHED_AGENT_SECRETS = [ + "OPENAI_API_KEY", + // Presented by the runtime; checked by the agent. Both containers read the + // same field of the same secret or the runtime cannot reach the agent at + // all. Already documented as a required field before this container read it, + // so an existing secret carries it. + "AGENT_AUTH_HEADER", + "TAVILY_API_KEY", + "DAYTONA_API_KEY", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "GITHUB_CODER_TOKEN", + "POSTHOG_PERSONAL_API_KEY", + "LINEAR_API_KEY", + "NOTION_MCP_AUTH_TOKEN", +]; + +const ESTABLISHED_RUNTIME_SECRETS = [ + "INTELLIGENCE_API_KEY", + // The other half of the pair above. + "AGENT_AUTH_HEADER", +]; + +test("injects each container's secrets from the shared secret, and no others", () => { + // Asserted as the whole set rather than one membership check at a time. The + // suite already had a `assert.match(json, /OPENAI_API_KEY/)` style check, and + // it passes just as happily with the entire Composio, shared-secret and Slack + // wiring deleted — which is how that wiring shipped with no coverage at all. + const template = Template.fromStack(stackWithContext()); + + assert.deepEqual( + secretsByName(template, "agent"), + expectedSecrets(ESTABLISHED_AGENT_SECRETS), + ); + assert.deepEqual( + secretsByName(template, "runtime"), + expectedSecrets(ESTABLISHED_RUNTIME_SECRETS), + ); +}); + +test("asks an upgrading deployment for no secret field it does not already have", () => { + // ECS resolves every declared secret field when the task starts and fails the + // task when one is missing — so a field added here unconditionally is not a + // deploy-time error an operator can read, it is an existing deployment that + // stops starting tasks after the upgrade. Nothing this release introduced may + // appear until the context that gives it a purpose is set. + // + // The empty context is asserted above, as a complete set. What is asserted + // here is every *other* way an upgrade can arrive without a connected + // account: a Composio setting configured while the toolkit lists are still + // empty. Each one of these is a context key the stack reads, and reading one + // of them as "Composio is configured" adds `COMPOSIO_API_KEY` to a secret + // that does not carry it yet — an upgrade whose tasks stop starting, with the + // default-context assertion above still green. + const settingsThatDoNotConfigureComposio: Record[] = [ + { composioApprovals: "on" }, + { composioWorkspaceUserId: "acme" }, + // No context key reads this one yet (see `deployment/aws/README.md`), so + // today it is inert. It is listed because the moment it is wired, the + // question of whether it turns the integration on is exactly this test's. + { composioAuthConfigs: "linear:ac_ExAmPle1" }, + { composioToolkits: "" }, + { composioUserToolkits: "" }, + { + composioApprovals: "on", + composioWorkspaceUserId: "acme", + composioAuthConfigs: "linear:ac_ExAmPle1", + }, + ]; + + for (const context of settingsThatDoNotConfigureComposio) { + const template = Template.fromStack(stackWithContext(context)); + const label = JSON.stringify(context); + + assert.deepEqual( + secretsByName(template, "agent"), + expectedSecrets(ESTABLISHED_AGENT_SECRETS), + `agent secrets with ${label}`, + ); + assert.deepEqual( + secretsByName(template, "runtime"), + expectedSecrets(ESTABLISHED_RUNTIME_SECRETS), + `runtime secrets with ${label}`, + ); + } +}); + +test("adds the Composio key to the agent once a toolkit is configured", () => { + // The agent treats a key with no toolkits as unconfigured, so the toolkit + // lists are what decides whether the key has anything to do. Both lists, + // separately: either one on its own turns the integration on. + const contexts: Record[] = [ + { composioToolkits: "linear" }, + { composioUserToolkits: "gmail" }, + ]; + for (const context of contexts) { + const template = Template.fromStack(stackWithContext(context)); + + assert.deepEqual( + secretsByName(template, "agent"), + expectedSecrets([...ESTABLISHED_AGENT_SECRETS, "COMPOSIO_API_KEY"]), + `agent secrets with ${JSON.stringify(context)}`, + ); + } +}); + +test("puts no Slack token on the runtime, personal toolkits or not", () => { + // The runtime once held the Slack pair so a personal connect link could reach + // one named person, which the managed adapter could not do. It can now, and + // holding the pair was actively harmful: a second Slack ingress answered + // every message twice, and it needed Socket Mode, which stops Slack + // delivering events to Intelligence at all. Both contexts are asserted as + // complete secret sets, so re-adding either token fails here. + // Annotated, because inferring a union of three differently-shaped object + // literals is not a `Record` and `pnpm build` (`tsc + // --noEmit`) has been failing on this line. `pnpm test` strips types rather + // than checking them, so the suite stayed green over a package that does not + // compile. + const contexts: Record[] = [ + { composioToolkits: "linear" }, + { composioUserToolkits: "gmail" }, + { composioToolkits: "linear", composioUserToolkits: "gmail" }, + ]; + for (const context of contexts) { + const template = Template.fromStack(stackWithContext(context)); + assert.deepEqual( + secretsByName(template, "runtime"), + expectedSecrets(ESTABLISHED_RUNTIME_SECRETS), + `runtime secrets with ${JSON.stringify(context)}`, + ); + } +}); + +test("never puts a Composio setting or credential on the internet-facing runtime", () => { + // The runtime is the service the platform reaches. The Composio key mints + // sessions against every connected account in the project, and nothing in the + // runtime reads it. + // + // Environment as well as secrets, which is what the neighbouring assertions + // cannot see: they compare secret sets, so a Composio setting added to the + // runtime's plaintext environment — the toolkit lists, the shared identity, + // the auth-config pins — passes all of them. Every Composio context key the + // stack reads is set here, so anything that forwards one to the runtime under + // any name fails. + const template = Template.fromStack( + stackWithContext({ + composioToolkits: "linear,jira", + composioUserToolkits: "gmail", + composioApprovals: "on", + composioWorkspaceUserId: "acme", + composioAuthConfigs: "linear:ac_ExAmPle1", + }), + ); + + assert.deepEqual( + Object.keys(secretsByName(template, "runtime")).filter((key) => + key.startsWith("COMPOSIO_"), + ), + [], + ); + assert.deepEqual( + Object.keys(environmentValues(template, "runtime")).filter((key) => + key.startsWith("COMPOSIO_"), + ), + [], + ); + // Not under another name either: no runtime value carries the toolkit list or + // the identity shared toolkits act as. A key-prefix filter cannot see a + // Composio value forwarded as, say, `CHANNEL_OWNER`. + const runtimeValues = Object.values(environmentValues(template, "runtime")); + assert.deepEqual( + runtimeValues.filter( + (value) => value === "acme" || value.includes("linear,jira"), + ), + [], + ); + + // And the agent, which is the service that reads them, still has them — so + // this test passing does not mean the wiring was removed from both. + // + // `COMPOSIO_AUTH_CONFIGS` is absent on purpose: it has no context key on this + // stack, which `deployment/aws/README.md` says out loud. Listed as a complete + // set so that closing that gap has to come here and say so, rather than + // arriving as an unread key. + assert.deepEqual( + Object.keys(environmentValues(template, "agent")) + .filter((key) => key.startsWith("COMPOSIO_")) + .sort(), + [ + "COMPOSIO_APPROVALS", + "COMPOSIO_TOOLKITS", + "COMPOSIO_USER_TOOLKITS", + "COMPOSIO_WORKSPACE_USER_ID", + ], + ); +}); + +test("leaves optional settings out of the container until context supplies them", () => { + // The whole map, name and value. Two separate failures are in scope here: an + // `optionalEnvironment` that stops being optional (`COMPOSIO_APPROVALS=""` + // reaching the agent is not the same as it being absent, and every + // `arrayWith` assertion in this file is blind to a key that should not + // exist), and a default quietly changing under a name that still looks + // right. + const template = Template.fromStack(stackWithContext()); + + assert.deepEqual(environmentValues(template, "agent"), { + AGENT_DISPLAY_NAME: "OpenTag", + // Wide open by default because the agent sits on a private subnet with no + // ingress; narrowing it is the operator's call, not a silent edit here. + CORS_ALLOW_ORIGINS: "*", + DAYTONA_TTL_MINUTES: "60", + GITHUB_MCP_URL: "https://api.githubcopilot.com/mcp/readonly", + // The agent derives the default Composio workspace user id from this, so + // an agent that never receives it runs the team's shared connections under + // the literal `open-tag` whatever the channel is really called. + INTELLIGENCE_CHANNEL_NAME: "open-tag", + LINEAR_MCP_URL: "https://mcp.linear.app/mcp", + OPENAI_MODEL: "gpt-5.5", + OPENAI_REASONING_EFFORT: "low", + OPENAI_VERBOSITY: "low", + POSTHOG_MCP_URL: "https://mcp.posthog.com/mcp?mode=cli&readonly=true", + SERVER_HOST: "0.0.0.0", + // The port the agent's own health check probes, and the port the runtime + // is told to reach it on. + SERVER_PORT: "8123", + }); + assert.deepEqual(environmentValues(template, "runtime"), { + AGENT_DISPLAY_NAME: "OpenTag", + AGENT_URL: "http://127.0.0.1:8123/", + INTELLIGENCE_API_URL: "https://api.intelligence.copilotkit.ai", + INTELLIGENCE_CHANNEL_NAME: "open-tag", + INTELLIGENCE_GATEWAY_WS_URL: "wss://realtime.intelligence.copilotkit.ai", + LOG_LEVEL: "warn", + // Where the runtime image installs Chromium. Point it elsewhere and the + // browser is missing at run time, not at build time. + PLAYWRIGHT_BROWSERS_PATH: "/ms-playwright", + // The port the runtime's own health check probes. + PORT: "3000", + }); +}); + +test("carries the configured channel name to both containers", () => { + const template = Template.fromStack(stackWithContext({ channelName: "kite" })); + + assert.equal( + environmentValues(template, "agent").INTELLIGENCE_CHANNEL_NAME, + "kite", + ); + assert.equal( + environmentValues(template, "runtime").INTELLIGENCE_CHANNEL_NAME, + "kite", + ); +}); + test("allows supported non-secret environment overrides through context", () => { const template = Template.fromStack( stackWithContext({ @@ -117,6 +437,10 @@ test("allows supported non-secret environment overrides through context", () => openAiModel: "gpt-test", openAiReasoningEffort: "high", openAiVerbosity: "medium", + composioToolkits: "linear,jira", + composioUserToolkits: "gmail", + composioApprovals: "writes", + composioWorkspaceUserId: "acme", }), ); @@ -129,6 +453,13 @@ test("allows supported non-secret environment overrides through context", () => { Name: "DAYTONA_TTL_MINUTES", Value: "45" }, { Name: "GITHUB_APP_ID", Value: "12345" }, { Name: "GITHUB_APP_INSTALLATION_ID", Value: "67890" }, + // Composio is read by the agent container, which is where the + // toolkits live. Listed in the order the stack builds them, because + // `arrayWith` matches in sequence and CDK preserves insertion order. + { Name: "COMPOSIO_TOOLKITS", Value: "linear,jira" }, + { Name: "COMPOSIO_USER_TOOLKITS", Value: "gmail" }, + { Name: "COMPOSIO_APPROVALS", Value: "writes" }, + { Name: "COMPOSIO_WORKSPACE_USER_ID", Value: "acme" }, { Name: "OPENAI_MODEL", Value: "gpt-test" }, { Name: "OPENAI_REASONING_EFFORT", Value: "high" }, { Name: "OPENAI_VERBOSITY", Value: "medium" }, diff --git a/deployment/docker/agent.Dockerfile b/deployment/docker/agent.Dockerfile index 5c184333..280866b9 100644 --- a/deployment/docker/agent.Dockerfile +++ b/deployment/docker/agent.Dockerfile @@ -17,6 +17,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ COPY agent/*.py ./ COPY agent/prompts ./prompts COPY agent/coding ./coding +COPY agent/composio_tools ./composio_tools RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev \ && useradd --uid 10001 --create-home --home-dir /home/opentag opentag diff --git a/package.json b/package.json index 9a04d600..dff46feb 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,9 @@ "e2e": "tsx e2e/run.ts" }, "dependencies": { - "@ag-ui/client": "^0.0.57", - "@copilotkit/channels": "0.9.0", - "@copilotkit/runtime": "1.68.1", + "@ag-ui/client": "0.0.59", + "@copilotkit/channels": "0.9.2", + "@copilotkit/runtime": "1.70.1", "dotenv": "^16.4.5", "playwright": "^1.49.0", "tsx": "^4.19.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52d88b18..3ceec499 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: .: dependencies: '@ag-ui/client': - specifier: ^0.0.57 - version: 0.0.57 + specifier: 0.0.59 + version: 0.0.59 '@copilotkit/channels': - specifier: 0.9.0 - version: 0.9.0(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + specifier: 0.9.2 + version: 0.9.2(@ag-ui/core@0.0.59)(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) '@copilotkit/runtime': - specifier: 1.68.1 - version: 1.68.1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + specifier: 1.70.1 + version: 1.70.1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -66,23 +66,23 @@ packages: '@ag-ui/client@0.0.54': resolution: {integrity: sha512-N5UVXEBV5gPHqTuMoR/21brconRn42URf+MB4L8OniCJKqLcl/qUJb5kMamK0nnfBhDfPs/uq7LxDn6bsDJzJg==} - '@ag-ui/client@0.0.57': - resolution: {integrity: sha512-Xap2alG9Z0/j5kb3x4D7oTpe2sw1dfrC9rgJJr2NZu5vKcm8dzIPNd31mF2B4zS3BKqYIu245yxKPhEtT30MHw==} + '@ag-ui/client@0.0.59': + resolution: {integrity: sha512-d7++r8sBAq6z9G/D/WKrMznQ1Mdvew14pCqOVATReFIvl06mCi1BPqTwmos3zCDGAIiSgcvxc/8m472rYQgFFQ==} '@ag-ui/core@0.0.54': resolution: {integrity: sha512-Ilx31OvRQaZfU7jSArGqz06JZKOsAt8zWiCPJljyp9zR6Tzl18oyfx8o6FsuGfAktGRe50GI9SCCxNXXysZwtA==} - '@ag-ui/core@0.0.57': - resolution: {integrity: sha512-gho1OWjNE6E3Rl7ZEZ1wr2CEpUHjLFU0FqzCZZk439TicLu+BfLCMkMokB07bMGlRmbJ60hM6LW60iOVauCx+Q==} + '@ag-ui/core@0.0.59': + resolution: {integrity: sha512-hDgy4ipTqXieT8YG8Mr917Y+FD/f11VK1GefZ5CwTDCuNqS/oTwjJ5l/DZkicThgS8hQW/Y7wPylPBMBJ8BkUg==} '@ag-ui/encoder@0.0.54': resolution: {integrity: sha512-0dPuE/eAeBRBDj/OOj5AW8SoP1r0dufmoOdrtKgmf+dlbVXKSNkDDHGrrvIWFPxwvPTWhHeN6wnsVUayWpUsGg==} - '@ag-ui/encoder@0.0.57': - resolution: {integrity: sha512-ifD9NctR4xyPDR58xF9GK1bj/S8oECFkTeDfuYD8tXdbcOstIJ2TOqU2zhiCKnw7Vw+zR9Qv3TbsM9E7Gi9X3Q==} + '@ag-ui/encoder@0.0.59': + resolution: {integrity: sha512-wQCzBsStyZMm8nzhTdTx1G0B1C8yv5rbzZLnz1ka/l5LcJEsK3KTounU8CR9l+7QtcpOUUDJKd0xAbyI6gNcSw==} - '@ag-ui/langgraph@0.0.42': - resolution: {integrity: sha512-dXasEbGQFJcasdoy5khYyDHZHYoD1/i7hioaP8cejfw+Dss4tLvN8ndzD6c5j0hmANaKscekl+zkF7UQq3sI9w==} + '@ag-ui/langgraph@0.0.43': + resolution: {integrity: sha512-eG8FBd7jQeo7lfraAz9fbuYM/sFJILnO7yFioCa/++cFygd5CpqpvVoUI1d/WEOzHYTwiuXYM5fPOQ8ZzQuYog==} peerDependencies: '@ag-ui/client': '>=0.0.42' '@ag-ui/core': '>=0.0.42' @@ -100,8 +100,8 @@ packages: '@ag-ui/proto@0.0.54': resolution: {integrity: sha512-IPF+xeFaBAKKP2FO74MaVTkKUP8VaGGkbPzORCvC5TLDdGs+oQgQFqz+XoBeksQGE14+jgLWiAr9EPXdhqr1NA==} - '@ag-ui/proto@0.0.57': - resolution: {integrity: sha512-pPENOZt0P6ibH8sCTgq05wLYXi5t3P9B5r/1bWYehXjUxtyOdnukSlWM++SsCIwUXsQdm/b3aBgGjEeTF7RenA==} + '@ag-ui/proto@0.0.59': + resolution: {integrity: sha512-X+uvDaegLEHw5kJu8tv2eSqHH8ouat+JCfFokGV1uuMquY8qCEQSlGsM7xzexwX9fujuxgHOWumg5kJDqa0+RA==} '@ai-sdk/anthropic@2.0.86': resolution: {integrity: sha512-Zwh6GgGmR1u/Gyv1Q+atapY+BZ/RwYULLu7hSxR3QcXwte2MbxVMywI/HI/rMw3ucA5h1RfSqJejx07BvbPgrA==} @@ -203,52 +203,52 @@ packages: '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} - '@copilotkit/channels-core@0.9.0': - resolution: {integrity: sha512-bCWLb/jb9j8O+JvOvf/jkJ/Nb8B09U/tAdTCQ221mE2AEHS2dn5PMlQih9gqyF6kbJFO0Nmf+IDTritWswmfaA==} + '@copilotkit/channels-core@0.9.2': + resolution: {integrity: sha512-YfWoLlnH2Vm1m6zj27BCOI0STLvvQPQP4q0b+JppRVYsLJglphGcg4miyr6Kzcx/CN3JeTp0mEP7zjLnyOJB1Q==} peerDependencies: vitest: ^4.0.0 peerDependenciesMeta: vitest: optional: true - '@copilotkit/channels-discord@0.9.0': - resolution: {integrity: sha512-tiVRvl/6gJ9yyJsZCGtmWNaNZ1bCNMAKNh4PpL7MbiQYKmOowHhEHVcWFT4DUeV6lxxIVRthIaJzpqDby+IhUQ==} + '@copilotkit/channels-discord@0.9.2': + resolution: {integrity: sha512-nqdSwcuraGLwrxrEaINdmdvy5Ks9VK7EjwlQqOHkIeTLP6SaphReVu2ByuLK/t8iMxAc5NJRLvLkoSJ8/ir9ew==} - '@copilotkit/channels-intelligence@0.9.0': - resolution: {integrity: sha512-w+ARYm+i30buMGJB+E3QFBze41s464MKkXGikgLYg3k9WEFHE3BgTRKz6MbHxv0yN9L6mOAjwMhR8LkTSufMcw==} + '@copilotkit/channels-intelligence@0.9.2': + resolution: {integrity: sha512-0wi1u1OER1LSZmA3xq1j9HT/juxedIW715ogY//aZViyT2kmfmKABOMY0JhQEzgKUFbThyFggfKqPzE+xZtT+A==} - '@copilotkit/channels-slack@0.9.0': - resolution: {integrity: sha512-3QkCInbQmruSP875x02YG1Ez59jPzQMDUlWRlNKPvzcAvnPfOmHxaXVVmBnS+fwcWccd4AAaeRvslTWBwU9s6g==} + '@copilotkit/channels-slack@0.9.2': + resolution: {integrity: sha512-Au/1et1KrdftsfyTquG5sAhl/BJe1eejanbVmX21u7k6+DjrL+No+GSIQncBmnSL/0YmPSHvGRts8ykRL0S3pA==} - '@copilotkit/channels-teams@0.9.0': - resolution: {integrity: sha512-OI1xaIg9Ho7vMUgPpxM2h4LZQRvQKRt+1yy2Er8nMBy1IJXQaNP4of4XI5pQA/WnskL/07xcGPrFiXrTRs4Hyg==} + '@copilotkit/channels-teams@0.9.2': + resolution: {integrity: sha512-2q/j+Pw3phTby/BpSri0wkQ1vy0HxsJmLjqMZHZeo9hdx5N2ibqhx3IWXUf26yDPz5zzaHIMNaZ374CJJgrquQ==} - '@copilotkit/channels-telegram@0.9.0': - resolution: {integrity: sha512-+qrts4W8VWEMiKVBnYTnXXsTEWid2SKMozcFSJ9rg116rhcDGw2ymXVC3u+HpF05Xlwc3p1ELmygDq3igpsdUQ==} + '@copilotkit/channels-telegram@0.9.2': + resolution: {integrity: sha512-DKf+iNelAMQyVtd5CQZLJaHd1Y1eyc4s1mS99UQEJN+/+0phCntcw4YyODXa1D5oe8c3zLNYombMfgfHfGnUIQ==} - '@copilotkit/channels-ui@0.9.0': - resolution: {integrity: sha512-6nhmIuexyW+fOBp3BE3ZWk4Mco5NOG4sr//BZ46RynSYYD36klTQQbTKA0ntSR6nIGJ/luAGc8WbVdPH8srYOA==} + '@copilotkit/channels-ui@0.9.2': + resolution: {integrity: sha512-i6AgVPN8xgOSceXrAfWuda/LwAU3Cm1YmViz4irix+HtWgiBDVi573EYw15CnDqzip9XkrnDYTECDwMQ9LM/Hg==} - '@copilotkit/channels-whatsapp@0.9.0': - resolution: {integrity: sha512-nantNMFpRqWXs+58G+2RGATmcStD7w8k+UOffTsp1h1kt1GLyR1rm4552Pl0GJvovakYeZy3nBVwiV/MPUQIpA==} + '@copilotkit/channels-whatsapp@0.9.2': + resolution: {integrity: sha512-ZuA2siIpLr6DCO7oHt3vKd3koFfrxPvy1HxeJEk8ZLQ3EAFxpbp17tBNhyaLmukJXjOiDFSX/71JC0JQCWJDWg==} - '@copilotkit/channels@0.9.0': - resolution: {integrity: sha512-phPsXReoBYIaZdUvzJX/2ngP5wX3rU7tv2y8UMlnjOqWyMUCatyIWtFwmf+ooVDMobFfnGYYiM1qq7SUL8olHQ==} + '@copilotkit/channels@0.9.2': + resolution: {integrity: sha512-76hEwjssSUb/MBIEmK9xBznXa2JGK+3q/AAmMCTXH0cjdmOpOSivyTkDr4WDALC8T1IszZ7jR1L1depnGJjzAA==} peerDependencies: vitest: ^4.0.0 peerDependenciesMeta: vitest: optional: true - '@copilotkit/core@1.68.0': - resolution: {integrity: sha512-WMG8hIPgmSbZSLmnRITCUaR9Q/029I6lnfreO97jc6yPi/1TLhfDcTkuiBSn0/PjbZ3cTs7H1WfF1DzzuJOuOw==} + '@copilotkit/core@1.70.1': + resolution: {integrity: sha512-/Ci3367THKfGm6qNGXuzNa/UEtQADS3rgxiRCWX4HIAv0DZmPAEHeHHUCu1/e/LDd9wSpNBr0LxOsivWQoxu9Q==} engines: {node: '>=18'} '@copilotkit/license-verifier@0.5.0': resolution: {integrity: sha512-vrwKtIpYwF0FT9ZoYASH8owa2cGV0dhDvJGaCRaRMStwDxpc6DRdydKkhx8cWZXyBRxEYcq/Vygv4JvevhQQdQ==} - '@copilotkit/runtime@1.68.1': - resolution: {integrity: sha512-jEzy6a6Hkh1ssQLyfNpQA3TMZg1gnPmaEd6lLmXSlz76byUC1zFlQSFLTgTUZapqvHVA7CDM87zHSu3BAHl1Zg==} + '@copilotkit/runtime@1.70.1': + resolution: {integrity: sha512-/aZX4amHYdjHr4KDvLMmpMw2P8C7Sw6s0+7oPnnDPJ0qu/XSGRPqKw4a50UsND2VgSCINxLhICUR5krd9JgBbw==} peerDependencies: '@anthropic-ai/sdk': '>=0.57.0' '@langchain/aws': '>=0.1.9' @@ -280,13 +280,8 @@ packages: openai: optional: true - '@copilotkit/shared@1.68.0': - resolution: {integrity: sha512-JF9BqrOokjkPXxLblrqjrYH56Z1+aFE5MeEZkbl2/VlCj/OZd4/UQQqoV3S7otVew5ZKH0SzOylxwrgo4oFR2g==} - peerDependencies: - '@ag-ui/core': '>=0.0.48' - - '@copilotkit/shared@1.68.1': - resolution: {integrity: sha512-RCDGd+va5QU8rza2/TJXanrP+AukuzPp0TZ1+Zc1e+GX476KBQN2K/CrR8GRXvwkhLIyfZXjzxjR+hKDtJySyg==} + '@copilotkit/shared@1.70.1': + resolution: {integrity: sha512-sx7VdE+KEE648LjEa5OevIJgFQLlQFhENZi+h2/Nol5GrXv58UgSSm969GzNCIypuWckASg2aALHGnV9s+/0Cw==} peerDependencies: '@ag-ui/core': '>=0.0.48' @@ -842,15 +837,24 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@4.19.9': + resolution: {integrity: sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==} + '@types/express-serve-static-core@5.1.2': resolution: {integrity: sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==} + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} @@ -863,6 +867,9 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -881,9 +888,15 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} @@ -2376,10 +2389,10 @@ packages: snapshots: - '@ag-ui/a2ui-middleware@0.0.10(@ag-ui/client@0.0.57)(rxjs@7.8.2)': + '@ag-ui/a2ui-middleware@0.0.10(@ag-ui/client@0.0.59)(rxjs@7.8.2)': dependencies: '@ag-ui/a2ui-toolkit': 0.0.4 - '@ag-ui/client': 0.0.57 + '@ag-ui/client': 0.0.59 clarinet: 0.12.6 rxjs: 7.8.2 @@ -2398,11 +2411,11 @@ snapshots: uuid: 11.1.1 zod: 3.25.76 - '@ag-ui/client@0.0.57': + '@ag-ui/client@0.0.59': dependencies: - '@ag-ui/core': 0.0.57 - '@ag-ui/encoder': 0.0.57 - '@ag-ui/proto': 0.0.57 + '@ag-ui/core': 0.0.59 + '@ag-ui/encoder': 0.0.59 + '@ag-ui/proto': 0.0.59 '@types/uuid': 10.0.0 compare-versions: 6.1.1 fast-json-patch: 3.1.1 @@ -2415,7 +2428,7 @@ snapshots: dependencies: zod: 3.25.76 - '@ag-ui/core@0.0.57': + '@ag-ui/core@0.0.59': dependencies: zod: 3.25.76 @@ -2424,16 +2437,16 @@ snapshots: '@ag-ui/core': 0.0.54 '@ag-ui/proto': 0.0.54 - '@ag-ui/encoder@0.0.57': + '@ag-ui/encoder@0.0.59': dependencies: - '@ag-ui/core': 0.0.57 - '@ag-ui/proto': 0.0.57 + '@ag-ui/core': 0.0.59 + '@ag-ui/proto': 0.0.59 - '@ag-ui/langgraph@0.0.42(@ag-ui/client@0.0.57)(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)': + '@ag-ui/langgraph@0.0.43(@ag-ui/client@0.0.59)(@ag-ui/core@0.0.59)(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)': dependencies: '@ag-ui/a2ui-toolkit': 0.0.4 - '@ag-ui/client': 0.0.57 - '@ag-ui/core': 0.0.57 + '@ag-ui/client': 0.0.59 + '@ag-ui/core': 0.0.59 '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) '@langchain/langgraph-sdk': 1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)) langchain: 1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) @@ -2450,9 +2463,9 @@ snapshots: - vue - ws - '@ag-ui/mcp-apps-middleware@0.0.3(@ag-ui/client@0.0.57)(@cfworker/json-schema@4.1.1)(zod@3.25.76)': + '@ag-ui/mcp-apps-middleware@0.0.3(@ag-ui/client@0.0.59)(@cfworker/json-schema@4.1.1)(zod@3.25.76)': dependencies: - '@ag-ui/client': 0.0.57 + '@ag-ui/client': 0.0.59 '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) rxjs: 7.8.2 transitivePeerDependencies: @@ -2476,9 +2489,9 @@ snapshots: '@bufbuild/protobuf': 2.12.1 '@protobuf-ts/protoc': 2.11.1 - '@ag-ui/proto@0.0.57': + '@ag-ui/proto@0.0.59': dependencies: - '@ag-ui/core': 0.0.57 + '@ag-ui/core': 0.0.59 '@bufbuild/protobuf': 2.12.1 '@protobuf-ts/protoc': 2.11.1 @@ -2597,13 +2610,13 @@ snapshots: '@cfworker/json-schema@4.1.1': {} - '@copilotkit/channels-core@0.9.0(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': + '@copilotkit/channels-core@0.9.2(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': dependencies: - '@ag-ui/client': 0.0.57 - '@ag-ui/core': 0.0.57 - '@copilotkit/channels-ui': 0.9.0(@ag-ui/core@0.0.57) - '@copilotkit/core': 1.68.0(@ag-ui/core@0.0.57)(zod@3.25.76) - '@copilotkit/shared': 1.68.0(@ag-ui/core@0.0.57) + '@ag-ui/client': 0.0.59 + '@ag-ui/core': 0.0.59 + '@copilotkit/channels-ui': 0.9.2(@ag-ui/core@0.0.59) + '@copilotkit/core': 1.70.1(@ag-ui/core@0.0.59)(zod@3.25.76) + '@copilotkit/shared': 1.70.1(@ag-ui/core@0.0.59) zod-to-json-schema: 3.25.2(zod@3.25.76) optionalDependencies: vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)) @@ -2611,11 +2624,11 @@ snapshots: - encoding - zod - '@copilotkit/channels-discord@0.9.0(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + '@copilotkit/channels-discord@0.9.2(@ag-ui/core@0.0.59)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: - '@ag-ui/client': 0.0.57 - '@copilotkit/channels-core': 0.9.0(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.9.0(@ag-ui/core@0.0.57) + '@ag-ui/client': 0.0.59 + '@copilotkit/channels-core': 0.9.2(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.9.2(@ag-ui/core@0.0.59) discord.js: 14.27.0 zod: 3.25.76 transitivePeerDependencies: @@ -2625,13 +2638,13 @@ snapshots: - utf-8-validate - vitest - '@copilotkit/channels-intelligence@0.9.0(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': + '@copilotkit/channels-intelligence@0.9.2(@ag-ui/core@0.0.59)(@opentelemetry/api@1.9.1)(@types/express@4.17.25)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': dependencies: - '@ag-ui/client': 0.0.57 - '@copilotkit/channels-core': 0.9.0(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-slack': 0.9.0(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) - '@copilotkit/channels-teams': 0.9.0(@opentelemetry/api@1.9.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) - '@copilotkit/channels-ui': 0.9.0(@ag-ui/core@0.0.57) + '@ag-ui/client': 0.0.59 + '@copilotkit/channels-core': 0.9.2(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-slack': 0.9.2(@types/express@4.17.25)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-teams': 0.9.2(@opentelemetry/api@1.9.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-ui': 0.9.2(@ag-ui/core@0.0.59) phoenix: 1.8.9 transitivePeerDependencies: - '@ag-ui/core' @@ -2646,14 +2659,37 @@ snapshots: - vitest - zod - '@copilotkit/channels-slack@0.9.0(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + '@copilotkit/channels-slack@0.9.2(@types/express@4.17.25)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + dependencies: + '@ag-ui/client': 0.0.59 + '@ag-ui/core': 0.0.59 + '@copilotkit/channels-core': 0.9.2(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.9.2(@ag-ui/core@0.0.59) + '@copilotkit/core': 1.70.1(@ag-ui/core@0.0.59)(zod@3.25.76) + '@copilotkit/shared': 1.70.1(@ag-ui/core@0.0.59) + '@slack/bolt': 4.7.3(@types/express@4.17.25) + '@slack/types': 2.22.0 + '@slack/web-api': 7.19.0 + rxjs: 7.8.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@types/express' + - bufferutil + - debug + - encoding + - supports-color + - utf-8-validate + - vitest + + '@copilotkit/channels-slack@0.9.2(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: - '@ag-ui/client': 0.0.57 - '@ag-ui/core': 0.0.57 - '@copilotkit/channels-core': 0.9.0(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.9.0(@ag-ui/core@0.0.57) - '@copilotkit/core': 1.68.0(@ag-ui/core@0.0.57)(zod@3.25.76) - '@copilotkit/shared': 1.68.0(@ag-ui/core@0.0.57) + '@ag-ui/client': 0.0.59 + '@ag-ui/core': 0.0.59 + '@copilotkit/channels-core': 0.9.2(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.9.2(@ag-ui/core@0.0.59) + '@copilotkit/core': 1.70.1(@ag-ui/core@0.0.59)(zod@3.25.76) + '@copilotkit/shared': 1.70.1(@ag-ui/core@0.0.59) '@slack/bolt': 4.7.3(@types/express@5.0.6) '@slack/types': 2.22.0 '@slack/web-api': 7.19.0 @@ -2669,14 +2705,14 @@ snapshots: - utf-8-validate - vitest - '@copilotkit/channels-teams@0.9.0(@opentelemetry/api@1.9.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + '@copilotkit/channels-teams@0.9.2(@opentelemetry/api@1.9.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: - '@ag-ui/client': 0.0.57 - '@ag-ui/core': 0.0.57 - '@copilotkit/channels-core': 0.9.0(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.9.0(@ag-ui/core@0.0.57) - '@copilotkit/core': 1.68.0(@ag-ui/core@0.0.57)(zod@3.25.76) - '@copilotkit/shared': 1.68.0(@ag-ui/core@0.0.57) + '@ag-ui/client': 0.0.59 + '@ag-ui/core': 0.0.59 + '@copilotkit/channels-core': 0.9.2(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.9.2(@ag-ui/core@0.0.59) + '@copilotkit/core': 1.70.1(@ag-ui/core@0.0.59)(zod@3.25.76) + '@copilotkit/shared': 1.70.1(@ag-ui/core@0.0.59) '@microsoft/agents-activity': 1.7.1 '@microsoft/agents-hosting': 1.7.1(@opentelemetry/api@1.9.1) express: 4.22.2 @@ -2690,11 +2726,11 @@ snapshots: - supports-color - vitest - '@copilotkit/channels-telegram@0.9.0(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + '@copilotkit/channels-telegram@0.9.2(@ag-ui/core@0.0.59)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: - '@ag-ui/client': 0.0.57 - '@copilotkit/channels-core': 0.9.0(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.9.0(@ag-ui/core@0.0.57) + '@ag-ui/client': 0.0.59 + '@copilotkit/channels-core': 0.9.2(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.9.2(@ag-ui/core@0.0.59) grammy: 1.44.0 zod: 3.25.76 transitivePeerDependencies: @@ -2703,33 +2739,33 @@ snapshots: - supports-color - vitest - '@copilotkit/channels-ui@0.9.0(@ag-ui/core@0.0.57)': + '@copilotkit/channels-ui@0.9.2(@ag-ui/core@0.0.59)': dependencies: - '@copilotkit/shared': 1.68.0(@ag-ui/core@0.0.57) + '@copilotkit/shared': 1.70.1(@ag-ui/core@0.0.59) transitivePeerDependencies: - '@ag-ui/core' - encoding - '@copilotkit/channels-whatsapp@0.9.0(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': + '@copilotkit/channels-whatsapp@0.9.2(@ag-ui/core@0.0.59)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': dependencies: - '@ag-ui/client': 0.0.57 - '@copilotkit/channels-core': 0.9.0(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-ui': 0.9.0(@ag-ui/core@0.0.57) + '@ag-ui/client': 0.0.59 + '@copilotkit/channels-core': 0.9.2(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-ui': 0.9.2(@ag-ui/core@0.0.59) transitivePeerDependencies: - '@ag-ui/core' - encoding - vitest - zod - '@copilotkit/channels@0.9.0(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': + '@copilotkit/channels@0.9.2(@ag-ui/core@0.0.59)(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76)': dependencies: - '@copilotkit/channels-core': 0.9.0(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-discord': 0.9.0(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) - '@copilotkit/channels-slack': 0.9.0(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) - '@copilotkit/channels-teams': 0.9.0(@opentelemetry/api@1.9.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) - '@copilotkit/channels-telegram': 0.9.0(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) - '@copilotkit/channels-ui': 0.9.0(@ag-ui/core@0.0.57) - '@copilotkit/channels-whatsapp': 0.9.0(@ag-ui/core@0.0.57)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-core': 0.9.2(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-discord': 0.9.2(@ag-ui/core@0.0.59)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-slack': 0.9.2(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-teams': 0.9.2(@opentelemetry/api@1.9.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-telegram': 0.9.2(@ag-ui/core@0.0.59)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1))) + '@copilotkit/channels-ui': 0.9.2(@ag-ui/core@0.0.59) + '@copilotkit/channels-whatsapp': 0.9.2(@ag-ui/core@0.0.59)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) optionalDependencies: vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)) transitivePeerDependencies: @@ -2744,10 +2780,10 @@ snapshots: - utf-8-validate - zod - '@copilotkit/core@1.68.0(@ag-ui/core@0.0.57)(zod@3.25.76)': + '@copilotkit/core@1.70.1(@ag-ui/core@0.0.59)(zod@3.25.76)': dependencies: - '@ag-ui/client': 0.0.57 - '@copilotkit/shared': 1.68.0(@ag-ui/core@0.0.57) + '@ag-ui/client': 0.0.59 + '@copilotkit/shared': 1.70.1(@ag-ui/core@0.0.59) '@tanstack/pacer': 0.20.1 phoenix: 1.8.9 rxjs: 7.8.2 @@ -2759,24 +2795,24 @@ snapshots: '@copilotkit/license-verifier@0.5.0': {} - '@copilotkit/runtime@1.68.1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': + '@copilotkit/runtime@1.70.1(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))': dependencies: - '@ag-ui/a2ui-middleware': 0.0.10(@ag-ui/client@0.0.57)(rxjs@7.8.2) - '@ag-ui/client': 0.0.57 - '@ag-ui/core': 0.0.57 - '@ag-ui/encoder': 0.0.57 - '@ag-ui/langgraph': 0.0.42(@ag-ui/client@0.0.57)(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) - '@ag-ui/mcp-apps-middleware': 0.0.3(@ag-ui/client@0.0.57)(@cfworker/json-schema@4.1.1)(zod@3.25.76) + '@ag-ui/a2ui-middleware': 0.0.10(@ag-ui/client@0.0.59)(rxjs@7.8.2) + '@ag-ui/client': 0.0.59 + '@ag-ui/core': 0.0.59 + '@ag-ui/encoder': 0.0.59 + '@ag-ui/langgraph': 0.0.43(@ag-ui/client@0.0.59)(@ag-ui/core@0.0.59)(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) + '@ag-ui/mcp-apps-middleware': 0.0.3(@ag-ui/client@0.0.59)(@cfworker/json-schema@4.1.1)(zod@3.25.76) '@ag-ui/mcp-middleware': 0.0.1(@cfworker/json-schema@4.1.1)(rxjs@7.8.2)(zod@3.25.76) '@ai-sdk/anthropic': 3.0.97(zod@3.25.76) '@ai-sdk/google': 3.0.94(zod@3.25.76) '@ai-sdk/google-vertex': 3.0.150(zod@3.25.76) '@ai-sdk/mcp': 1.0.62(zod@3.25.76) '@ai-sdk/openai': 3.0.85(zod@3.25.76) - '@copilotkit/channels-core': 0.9.0(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) - '@copilotkit/channels-intelligence': 0.9.0(@ag-ui/core@0.0.57)(@opentelemetry/api@1.9.1)(@types/express@5.0.6)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-core': 0.9.2(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) + '@copilotkit/channels-intelligence': 0.9.2(@ag-ui/core@0.0.59)(@opentelemetry/api@1.9.1)(@types/express@4.17.25)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(vite@8.1.4(@types/node@22.20.1)(esbuild@0.28.1)(tsx@4.23.1)))(zod@3.25.76) '@copilotkit/license-verifier': 0.5.0 - '@copilotkit/shared': 1.68.1(@ag-ui/core@0.0.57) + '@copilotkit/shared': 1.70.1(@ag-ui/core@0.0.59) '@graphql-yoga/plugin-defer-stream': 3.21.2(graphql-yoga@5.21.2(graphql@16.14.2))(graphql@16.14.2) '@hono/node-server': 1.19.14(hono@4.12.30) '@langchain/core': 1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1) @@ -2784,6 +2820,8 @@ snapshots: '@remix-run/node-fetch-server': 0.13.3 '@scarf/scarf': 1.4.0 '@segment/analytics-node': 2.3.0 + '@types/cors': 2.8.19 + '@types/express': 4.17.25 ai: 6.0.228(zod@3.25.76) clarinet: 0.12.6 class-transformer: 0.5.1 @@ -2814,7 +2852,6 @@ snapshots: - '@opentelemetry/api-logs' - '@opentelemetry/exporter-trace-otlp-proto' - '@opentelemetry/sdk-trace-base' - - '@types/express' - bufferutil - debug - encoding @@ -2826,26 +2863,10 @@ snapshots: - vitest - vue - '@copilotkit/shared@1.68.0(@ag-ui/core@0.0.57)': - dependencies: - '@ag-ui/client': 0.0.57 - '@ag-ui/core': 0.0.57 - '@copilotkit/license-verifier': 0.5.0 - '@segment/analytics-node': 2.3.0 - '@standard-schema/spec': 1.1.0 - chalk: 4.1.2 - graphql: 16.14.2 - partial-json: 0.1.7 - uuid: 11.1.1 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - transitivePeerDependencies: - - encoding - - '@copilotkit/shared@1.68.1(@ag-ui/core@0.0.57)': + '@copilotkit/shared@1.70.1(@ag-ui/core@0.0.59)': dependencies: - '@ag-ui/client': 0.0.57 - '@ag-ui/core': 0.0.57 + '@ag-ui/client': 0.0.59 + '@ag-ui/core': 0.0.59 '@copilotkit/license-verifier': 0.5.0 '@segment/analytics-node': 2.3.0 '@standard-schema/spec': 1.1.0 @@ -3298,6 +3319,25 @@ snapshots: transitivePeerDependencies: - encoding + '@slack/bolt@4.7.3(@types/express@4.17.25)': + dependencies: + '@slack/logger': 4.0.1 + '@slack/oauth': 3.0.5 + '@slack/socket-mode': 2.0.7 + '@slack/types': 2.22.0 + '@slack/web-api': 7.19.0 + '@types/express': 4.17.25 + axios: 1.18.1 + express: 5.2.1 + path-to-regexp: 8.4.2 + raw-body: 3.0.2 + tsscmp: 1.0.6 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + '@slack/bolt@4.7.3(@types/express@5.0.6)': dependencies: '@slack/logger': 4.0.1 @@ -3396,10 +3436,21 @@ snapshots: dependencies: '@types/node': 22.20.1 + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.20.1 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} + '@types/express-serve-static-core@4.19.9': + dependencies: + '@types/node': 22.20.1 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + '@types/express-serve-static-core@5.1.2': dependencies: '@types/node': 22.20.1 @@ -3407,6 +3458,13 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 1.2.1 + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.9 + '@types/qs': 6.15.1 + '@types/serve-static': 1.15.10 + '@types/express@5.0.6': dependencies: '@types/body-parser': 1.19.6 @@ -3422,6 +3480,8 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 22.20.1 + '@types/mime@1.3.5': {} + '@types/ms@2.1.0': {} '@types/node@22.20.1': @@ -3436,10 +3496,21 @@ snapshots: '@types/semver@7.7.1': {} + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.20.1 + '@types/send@1.2.1': dependencies: '@types/node': 22.20.1 + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.20.1 + '@types/send': 0.17.6 + '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 diff --git a/setup.md b/setup.md index 78264427..c0187a4f 100644 --- a/setup.md +++ b/setup.md @@ -23,9 +23,9 @@ supported; Discord, Telegram, and WhatsApp are coming soon. | AWS topology | [`deployment/aws/`](./deployment/aws) | One private Fargate task, images, secrets, and Datadog log forwarding | The host always uses the Intelligence-owned runtime. It declares one -adapter-free Channel using the configured name. The Slack and Microsoft Teams -adapters, their credentials, and attachments are configured only in -Intelligence — never here. +adapter-free Channel using the configured name, with no exceptions: the Slack +and Microsoft Teams adapters, their credentials, and attachments are configured +only in Intelligence, and no platform token is read here. ## Install @@ -82,6 +82,14 @@ or Channel slug. | `OPENAI_REASONING_EFFORT` | No | Defaults to `low` | | `OPENAI_VERBOSITY` | No | Defaults to `low` | | `TAVILY_API_KEY` | No | Enables live web research | +| `COMPOSIO_API_KEY` | No | Master switch for Composio toolkits. Absent means the feature is never constructed | +| `COMPOSIO_TOOLKITS` | No | Toolkit slugs everyone shares one connection for | +| `COMPOSIO_USER_TOOLKITS` | No | Toolkit slugs scoped to whoever sent the message. Each person connects their own account from a Slack thread; a non-empty `AGENT_AUTH_HEADER` is required before a link is minted | +| `COMPOSIO_APPROVALS` | No | `on` (default) or `off`. `destructive` and `writes` are the old spellings and still parse as `on`. An unrecognized value fails startup, but only once Composio is configured — with no API key or no toolkit list the variable is never read | +| `COMPOSIO_WORKSPACE_USER_ID` | No | Composio `user_id` the shared toolkits run as. Defaults to this service's `INTELLIGENCE_CHANNEL_NAME`, and to `open-tag` when that variable is not set on the agent | +| `INTELLIGENCE_CHANNEL_NAME` | No | Also read here, not only by the runtime: it is the default shared-toolkit `user_id` above. The agent's own fallback is `open-tag`, so an overridden Channel name has to be set on **both** services or the shared identity differs between them | +| `COMPOSIO_AUTH_CONFIGS` | No | `toolkit:auth_config_id` pairs, ids case-sensitive. Pins which auth config a toolkit connects against when it has several. Unset, Composio picks one from the project | +| `AGENT_AUTH_HEADER` | No | The runtime's shared secret. Checked when set to a non-empty value, and **required** — non-empty — before a Composio connect link is minted; `""` reads as unconfigured | | `GITHUB_PERSONAL_ACCESS_TOKEN` | No | Enables read-only GitHub repository, code, PR, Actions-run, and job-log search. It remains the legacy coding fallback | | `GITHUB_MCP_URL` | No | Overrides the hosted GitHub MCP URL; OpenTag still sends read-only headers | | `DAYTONA_API_KEY` | No | Enables the coding subagent (Daytona sandbox) | @@ -142,9 +150,9 @@ The AG-UI endpoint is `http://localhost:8123/`; `/health` reports the | `INTELLIGENCE_LEARNING_CONTAINER_ID` | No | Assigns OpenTag Threads to this existing Learning Container | | `INTELLIGENCE_API_URL` | No | Defaults to `https://api.intelligence.copilotkit.ai` | | `INTELLIGENCE_GATEWAY_WS_URL` | No | Defaults to `wss://realtime.intelligence.copilotkit.ai` | -| `AGENT_AUTH_HEADER` | No | Authorization header forwarded to the agent | +| `AGENT_AUTH_HEADER` | No | Shared secret between runtime and agent. Sent as `Authorization`; the agent checks it when set to a non-empty value, and **requires** a non-empty one before minting a Composio connect link | | `PORT` | No | Channel HTTP port; defaults to `3000` | -| `LOG_LEVEL` | No | Defaults to `error`; use `debug` to see Channel lifecycle breadcrumbs | +| `LOG_LEVEL` | No | Defaults to `error`. Channel lifecycle breadcrumbs are emitted at `warn`, so set `warn` or lower to see them | | `MERMAID_URL` | No | Overrides the Mermaid browser bundle URL used by diagram rendering | The API key selects a project; the Channel name selects a Channel inside it. @@ -224,17 +232,31 @@ has both already. Its Slack handoff never asks anyone to paste a secret into cha | `slack` | `channelToken` — Bot User OAuth Token (`xoxb-`), from **OAuth & Permissions**; `signingSecret`, from **Basic Information → App Credentials** | | `teams` | `clientId` and `tenantId`, from the Entra app registration **Overview**; `clientSecret` — the secret **Value**, not the Secret ID | -There is no app-level `xapp-` token on the managed path. Slack reaches -Intelligence over HTTPS at an Intelligence-hosted Request URL, authenticated by -the signing secret Intelligence holds, and Intelligence reaches your runtime -over a websocket your process opens outbound. Nothing here uses Socket Mode, and -a Slack app configured for Socket Mode installs green and delivers nothing. +There is no app-level `xapp-` token on the managed path, and nothing in OpenTag +reads one. Slack reaches Intelligence over HTTPS at an Intelligence-hosted +Request URL, authenticated by the signing secret Intelligence holds, and +Intelligence reaches your runtime over a websocket your process opens outbound. `copilotkit channels add --adapter teams --provision` can create the provider-side Teams app for you. Two Teams gates stay user-owned regardless: granting tenant admin consent, and uploading the app package through **Apps → Manage your apps → Upload an app**. +### Leave Socket Mode off + +**Socket Mode stays off on the Slack app, permanently.** Managed delivery never +uses it, and a Slack app with Socket Mode enabled installs green and delivers +nothing to the Request URL — which is to say, nothing to Intelligence and +nothing to your runtime. It fails silently and it looks like success. + +This is not hypothetical. OpenTag once attached its own Socket Mode adapter so a +Composio connect link could reach one person privately; from 24 August until +that adapter was turned off, Slack delivered events over the socket and stopped +posting them to Intelligence, so the managed path was dead. That adapter and its +two tokens have since been removed from this repository outright — private +delivery is the managed adapter's job with the SDK pair pinned in +[`package.json`](./package.json), and nothing here reads a Slack token any more. + ### Channel names claim deliveries Managed delivery is claim-based. Two runtimes declaring the same Channel name in @@ -269,10 +291,10 @@ Mentions, messages, and button and select clicks are the proven managed-path triggers — interactivity is enabled deliberately, which is what makes human-in-the-loop fire. **Slash commands and modals are registered in code but their managed-path delivery depends on the Channel's generated Slack manifest -declaring them.** As of the last verification against `@copilotkit/channels` -0.7.0 the generated manifest declared no `slash_commands` and `view_submission` -was not handled, so those handlers compiled, started, reported online, and never -fired. Send a real command and submit a real modal before relying on either. +declaring them**, which is decided server-side by Intelligence rather than by +anything in this repository. A handler that is never delivered still compiles, +starts, and reports online, so send a real command and submit a real modal +against your own Channel before relying on either. Before a Linear or Notion mutation reaches MCP, a Python interceptor emits `confirm_write`. The Channel posts an approval card, and the button resumes the @@ -333,6 +355,184 @@ Notion is optional and remote-only, not a separate Railway service. Set both discovers the tools. If either value is absent OpenTag skips Notion without blocking startup. +### Composio + +Composio adds a toolkit — Gmail, Linear, Jira, Google Calendar, Salesforce — +without a new MCP block, a `preserve()` line, or a matching test assertion. It +lives in the Python agent, alongside every other capability, and is gated by the +same `confirm_write` card that already guards a Linear or Notion write. There is +one approval mechanism in this product, not two. + +Setup is **three steps per app**, not one: + +1. Add the toolkit at . That creates its auth config. +2. Add its slug to `COMPOSIO_TOOLKITS` or `COMPOSIO_USER_TOOLKITS`. A **shared** + toolkit also needs connecting once: + + ```bash + cd agent && uv run python -m composio_tools.connect_cli + ``` + + Open the link it prints, signed in as the account the team should share. That + needs no running agent, so do it before you restart. Personal toolkits skip + this — each person connects their own from a thread. +3. Restart the agent, once. + +**The slug is the tricky part.** It is Composio's own, lowercase and unspaced: +Google Calendar is `googlecalendar`, not `google-calendar` or `gcal`. Take it +from the toolkit's page URL at (`/toolkit/gmail`), or +from the Toolkits list in their docs. A typo is **silent** — OpenTag does not +validate slugs against Composio at startup, so a misspelled toolkit is simply one +that never appears: the agent has no tools for it and `search_my_tools` never +mentions it. If an app you configured seems absent, check the spelling first. + +`COMPOSIO_API_KEY` is the master switch. Without it nothing is constructed — no +client, no session, no tool the model can see but must not call. A key with both +toolkit lists empty is equally inert. + +#### Shared team accounts versus personal ones + +`COMPOSIO_TOOLKITS` runs every Slack user through **one** connection, under the +Composio `user_id` in `COMPOSIO_WORKSPACE_USER_ID` — defaulting to the agent's +own `INTELLIGENCE_CHANNEL_NAME`, and to `open-tag` when that is unset there. +That is right for the team's Linear or Jira. The connect script below reads the +same two variables from wherever you run it, so an id that differs between your +shell and the deployment connects an account no turn will look up. + +`COMPOSIO_USER_TOOLKITS` scopes to whoever spoke, keyed by their verified +platform actor **and** the platform it came from — a provider id is unique only +within its provider, so `U1` on Slack and `U1` on Teams are different people. You +ask about "my calendar" and get yours; your colleague gets theirs. A turn with no +resolvable actor gets no personal tools at all and never falls back to the shared +identity. + +Both lists may be set at once, and one turn can use both. A toolkit named in both +resolves to the personal scope only. + +How an account gets connected differs by list, and this is where the surprises +are: + +- **Personal.** The agent calls `connect_app`, which posts a public **Connect** + card carrying no link. On Slack, whoever clicks receives a one-time link + privately, minted for them; somebody else clicking the same card connects + their own account. A pre-minted link posted in a channel would be an + account-takeover hazard, because whoever completes the flow binds their + account to the id the link was minted for. +- **Shared.** Nobody in Slack can connect it, and neither can the dashboard — a + connection made there binds to the dashboard's own user id, which this + deployment never passes. It is a test button. The connect script above is the + only correct path. + +**Personal toolkits work, and Slack is the surface they work on.** The agent +learns who spoke from `forwardedProps.channelActor`, which the pinned +`@copilotkit/channels` forwards on every run of a turn. A clean install of this +repository resolves the speaker with nothing configured for it. + +Delivery is the other half, and it is where the surfaces differ. A connect link +has to reach one person alone. On Slack it does: the Connect card is public in +the thread, the link is minted when somebody clicks and goes to that person +privately, and the whole flow is proven end to end against a live workspace. +Teams has no ephemeral message, so a Teams-backed Channel posts the card and +then tells the clicker the link could not be sent privately — it is discarded +rather than posted where the thread can read it. + +The pin in [`package.json`](./package.json) says what should be installed. This +says what is: + +```bash +grep -rl channelActor node_modules/.pnpm +``` + +Output names the packages that carry the field. Silence means the installed +Channels predates it and every turn will read as anonymous — reinstall, and if +it is still silent, check the pin. + +**Upgrading an older installation.** Older SDKs did not forward the speaker, +so `COMPOSIO_USER_TOOLKITS` could be configured and silent — `search_my_tools` +listed no personal tool and no Connect card was ever posted. Install the SDK +pair pinned in [`package.json`](./package.json) to enable forwarding; no +environment variable enables it. Two things then have to be true before a link +is minted: the Slack-backed Channel above, and a non-empty +`AGENT_AUTH_HEADER` on both services, described below. + +That forwarded value is the only thing the agent will treat as an identity, and +four rules follow from it. They fail closed — each one costs access to a +personal toolkit and none of them grants it: + +- A `channelActor` in a request's own `state` is discarded. The AG-UI adapter + merges caller state *over* forwarded properties, so without this the body + would decide whose account a turn runs in. +- A turn that forwards nobody is anonymous, and clears whoever spoke last. The + graph is checkpointed per thread, so an inherited actor would let a second + person in a Slack thread act as the first. +- Only `slack` and `teams` are recognised surfaces. Adding one means adding it + to `KNOWN_PLATFORMS` in `agent/composio_tools/state.py`; until then its turns + read as anonymous rather than sharing a namespace with everybody else's. +- Only `kind: "human"` gets a personal identity. A `bot`, `app` or `system` + actor — a workflow posting on somebody's behalf — reaches the shared toolkits + and no personal one, and cannot be minted a connect link. The Channels SDK + documents `kind` as the provider's own metadata rather than an authorization + claim, which is exactly why it is read as a filter and never as a grant: it + can only take a personal toolkit away, never hand one over. + +Beyond a Slack-backed Channel, personal toolkits need one thing configured: + +- **`AGENT_AUTH_HEADER`, non-empty, on both services.** The runtime asks the + agent to mint each link, and the agent refuses to mint one without this secret. + A link is a bearer capability; there is no configuration in which handing one + to an unauthenticated caller is right. Empty is not configured: `configured_secret` + in [`agent/agent_auth.py`](./agent/agent_auth.py) strips the value and treats + `""` as absent, so a deployment that ships the variable set to an empty string + refuses every mint. Ordinary agent traffic is checked only when the variable + holds a value, so an existing deployment is unaffected until it opts in. + +Delivery itself needs no configuration here, and no platform token — this app +holds no Slack credential to deliver with. The managed adapter puts the link in +front of the clicker alone. Where a surface offers no private message the Connect +button discards the minted link rather than posting it in the thread, says so to +the person who clicked, and logs what an operator should check. See +[`app/tools/connect-click.tsx`](./app/tools/connect-click.tsx). + +#### Approvals + +`COMPOSIO_APPROVALS` is `on` (the default) or `off`. A gated call posts the same +card as a Linear or Notion write and pauses the graph, so the answer can arrive +twenty minutes later and the model still sees the result. + +A tool's effect comes from Composio's own behaviour tags, looked up per slug. +`readOnlyHint` is the only thing that takes a call out of the gate. Everything +else is gated, including a slug that cannot be classified — which covers both a +lookup that failed and a tool the lookup found carrying no behaviour tag. Only +the lookup's own answer is remembered, never the fail-safe one, so a tool is not +permanently mislabelled by one bad lookup. + +**`destructive` and `writes` were separate modes and are now one.** They gated an +identical set and always would have. The tags can say exactly two things — +`readOnlyHint` and `destructiveHint` — so there is no way to express "a write +that is definitely not destructive", and `idempotentHint` cannot stand in for +one, because DELETE is idempotent. Since an unclassified tool is gated as +destructive rather than guessed at, every call is either a read or destructive, +and choosing between the two modes was choosing between two spellings of one +behaviour. Both still parse as `on`, so an existing deployment does not fail at +boot on upgrade; there is nothing to change unless you want the new name. + +A call that runs in one person's own account names that person as its approver, +and only they can answer the card — approving it spends their access and nobody +else's. Somebody else pressing it is told so, privately where the surface allows +one and in the thread where it does not, and the card stays up for its owner. +Both buttons share one durable decision, so only one click can resume the +agent, including after a runtime restart. Each answer also names its original +graph interrupt; a delayed click cannot approve a newer pending action. + +Upgrade the runtime and agent together for this approval protocol. Cards +created before the upgrade cannot be resumed safely and require a fresh +request. If the agent cannot provide an interrupt ID, the runtime refuses the +approval and asks the operator to update the agent. + +Sessions are created with connection management off. The connect flow above is +the only way an account is linked, because it is the only one that binds the +connection to an actor the platform verified. + ## Railway The IaC file declares exactly: @@ -344,10 +544,25 @@ The IaC file declares exactly: `runtime.AGENT_URL` references the agent's Railway private domain and port. Production Intelligence URLs are literal configuration, the API key is -preserved, and the Channel name is `open-tag`. `AGENT_DISPLAY_NAME` is preserved -independently on both services and must match when overridden. `OPENAI_API_KEY` -is required on `agent`; Tavily, Daytona/coder, GitHub, PostHog, Linear, and the -paired remote Notion variables are optional preserved settings. +preserved, and the Channel name is the literal `open-tag` on **both** services — +the agent's copy is what shared Composio toolkits default their `user_id` to. +`AGENT_DISPLAY_NAME` is preserved independently on both services and must match +when overridden. `OPENAI_API_KEY` is required on `agent`; Tavily, Daytona/coder, +GitHub, PostHog, Linear, and the paired remote Notion variables are optional +preserved settings. + +The variables this change added are preserved too, and which service carries +them is the whole design: + +- On `agent`: `COMPOSIO_API_KEY`, `COMPOSIO_TOOLKITS`, `COMPOSIO_USER_TOOLKITS`, + `COMPOSIO_APPROVALS`, `COMPOSIO_WORKSPACE_USER_ID`, `COMPOSIO_AUTH_CONFIGS`. + The toolkits live in the agent, so the Composio credential never reaches the + runtime. +- On `runtime`: nothing. Delivery is the managed adapter's job, so the runtime + carries no platform credential. +- On both: `AGENT_AUTH_HEADER`. It is a shared secret, so the two values have to + match; they are preserved independently and Railway will not reconcile them + for you. Evaluate the configuration locally without applying it: diff --git a/tsconfig.json b/tsconfig.json index 90cd3aa7..0aea9397 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ "app/**/*.ts", "app/**/*.tsx", "server.ts", - "scripts/**/*.ts" + "scripts/**/*.ts", + ".railway/railway.ts" ], "exclude": ["node_modules", "e2e"] }