diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index e75d6f41b33..e700f79f562 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1906,3 +1906,15 @@ unavailable. Preserve current ownership and newer-work fences. See endpoints delegate to sources while retaining response shapes. - GitHub.com, manual refresh only. No upstream editing, polling, webhook sync, commits, or pull-request creation in this milestone. + +### Connection instructions + +Connections can store optional, versioned agent instructions independently of +provider and transport. Catalog templates control editor visibility; saved +settings and runtime delivery also support connections without a template. +The server includes instructions only when the connection and at least one +action are available to the run's agent and responsible identity. An immutable +per-turn snapshot participates in session compatibility, so subsequent turns +remove stale instructions after edits or access revocation. See +[Connection instructions](connections/CONNECTION-INSTRUCTIONS.md) for contracts, +UI conventions, custom adapter integration, and initial memory templates. diff --git a/doc/connections/CONNECTION-INSTRUCTIONS.md b/doc/connections/CONNECTION-INSTRUCTIONS.md new file mode 100644 index 00000000000..5afca12342a --- /dev/null +++ b/doc/connections/CONNECTION-INSTRUCTIONS.md @@ -0,0 +1,120 @@ +# Connection instructions + +Connection instructions are an optional, provider-independent capability. A saved +connection can contribute a short paragraph to an authorized agent’s execution +prompt. Memory providers are the first catalog templates; custom and non-memory +connections use exactly the same settings, authorization, and delivery path. + +## Catalog and saved settings + +A connector can declare `agentInstructions: { id, version, text }` in its +`AppDefinition`. Author reviewed defaults directly in that app's +`packages/shared/src/app-definitions/.json`, alongside its configuration +and supporting provider `docsUrl`. This field is the source of truth; there is +no separate template registry. Catalog ingestion validates and preserves it, +including edits or removal, when regenerating the other definition fields. Run +`node scripts/ingest-app-definitions.mjs --definitions-only` to verify. Template +text is limited to 2,000 characters. Remote MCP initialization prose is never +adopted as trusted instructions automatically. + +Every `ToolConnection` has nullable `agentInstructions`: + +```json +{ + "enabled": true, + "text": "Use the release handbook before changing a deployment. Cite the checklist.", + "template": { "id": "handbook.usage", "version": 1 } +} +``` + +The template provenance is optional. Authorized custom instructions can be saved +without a catalog template. `null` means no instructions. Disabling retains text +and tool access. New catalog connections persist the current default enabled; +the initial migration backfills existing connections for the five participating +providers. A reconnect, OAuth return, or catalog refresh retains saved settings. +Catalog version changes do not replace custom text or opt-outs. **Reset to +default** explicitly adopts the current template and preserves the toggle. + +Existing connect, finish, and connection PATCH requests accept the same nullable +setting. Mutations use connection configuration permissions and company checks, +and write activity records. Required provider configuration lives in +`config.methodConfig`, independently of instruction text and enabled state. + +## UI convention + +The editor appears only when explicit template metadata exists. Setup (including +inline setup and OAuth) uses the current flow, without another wizard step. The +Permissions page places **Agent instructions** after agent access and before +Actions. It shows **Tell agents to use {provider}**, visible text, editing, +**Reset to default** after changes, and an amber notice when off. Failed saves +retain the draft. Agent Instructions includes a read-only **From connections** +section with source links and an explanation of task-dependent availability. +Disabled instructions remain inspectable there with an amber notice. + +## Runtime delivery and compatibility + +The gateway resolves instructions using the stored run’s company, agent, task, +project, active responsible identity, connection grants, and effective action +policy. At least one tool must be available (including tools requiring approval). +The resolver uses persisted settings and the cached catalog; it does not contact +providers. It does not gate delivery on a provider name, memory category, or the +presence of a catalog template. +Prompt assembly checks access to candidate connections’ cached tools directly, +using the same task restrictions and policy decisions as discovery. It does not +enter the discovery queue, so queue saturation neither drops guidance nor replaces +an otherwise compatible session. Revocation is still checked on every turn. + +Eligible blocks are sorted by connection ID. Each block identifies its +connection, selected grant, optional template provenance, and declared public +configuration. Secret/password fields are excluded. Missing required public +configuration withholds standing instructions. The snapshot is `{ text, digest }`, +where `digest` is SHA-256 of the complete UTF-8 text. Declared public configuration and +source changes therefore change the digest as well as text edits. + +The server replaces caller-supplied runtime instruction fields before executing. +Native Runner receives the snapshot in `runtimeContext.connectionInstructions` +and composes it alongside agent instructions. Legacy adapters receive it through +shared wake-prompt composition on fresh and resumed turns. Session compatibility +includes the snapshot: changing or removing it replaces an incompatible session, +including when live tool refresh would otherwise retain that session. Active +turns retain the snapshot with which they started. Older native snapshots without +this field remain valid. No AGENTS.md or shared harness home is modified. + +Custom integration contracts: + +- **HTTP adapter:** the JSON request contains top-level `connectionInstructions` + (`{ text, digest }` or `null`), also available on invocation `context`. + This server-owned field overrides `payloadTemplate`. Append its text alongside + agent instructions before invoking a model; do not reuse a session with a + different digest. +- **Process adapter:** `PAPERCLIP_CONNECTION_INSTRUCTIONS_FILE` points to a private + JSON file containing the same snapshot. Read it during the invocation and + append `.text` to your model prompt. It is empty when no instructions apply. + Configured or inherited paths cannot override it. The temporary file is removed + when the process exits, including failure paths. + +Instruction guidance grants no new permissions. Approval requirements remain +attached to tool calls, and remembered content does not override the current task. +Provider memory scope follows the provider’s real configuration; Paperclip does +not claim isolation from invented agent IDs or tags. + +## Honcho configuration + +Honcho requires `config.methodConfig.workspaceId` for new setup. The field is +expanded above instructions, with validation beside it. For tools declaring a +`workspace_id` argument, the existing managed-argument mechanism projects the +configured workspace after caller arguments and removes the field from the +agent-visible schema. Pending approvals compare against the current projected +arguments, so changing the workspace invalidates the old approved invocation. +Existing connections missing a workspace retain their tools and manual workspace +arguments, but receive no standing guidance until configured. + +## Verification + +`server/src/__tests__/connection-instructions.test.ts` persists a non-memory +fixture, resolves effective access, captures a real process invocation, and checks +shared/native prompt composition. Route tests cover permissions and OAuth draft +preservation. Session tests cover edits/removal and incompatible resumes. +Storybook uses production components backed by stateful mock APIs, including +Notion with and without explicit templates. Model behavior evaluations and new +agent/company memory scope controls are deferred. diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 6c94bd6124f..6c5f7913b2f 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -122,6 +122,7 @@ read/write proof separately and never describe metadata discovery as live proof. - [Current access defaults](#current-default-access-policy) - [Golden-path agent tutorial](#golden-path-agent-tutorial) - [Connection UX and user journeys](#connection-ux-and-user-journeys) +- [Optional agent instructions](#optional-agent-instructions) - [Chat and email connector UX](./CHAT-CONNECTOR-UX.md) - [Production validation evidence](#step-9-align-with-production-validation) - [AppDefinition field reference](#appdefinition-field-reference) @@ -147,6 +148,59 @@ A complete connector proposal produces: denied/quarantined call when the method declares one, revoke, and audit evidence. +## Optional agent instructions + +Some connections need standing guidance: tools tell an agent what it *can* do, +while a short paragraph explains when it should use them. Memory is the first +use case. See [Connection instructions](CONNECTION-INSTRUCTIONS.md) for the +generic catalog metadata, saved settings, API, runtime, and integration contracts. +**Design explorations → Connections → Agent instructions** in Storybook uses +production components with mocked APIs. + +When adding a template: + +- Show the section only when the connector explicitly provides a nonempty + instruction template. Use **Agent instructions** and the checkbox + **Tell agents to use {provider}**, with the exact paragraph visible. No template + means no instructions UI, including no blank editor. + Keep setup in the existing flow and its single footer; add no required test or + extra wizard step. Use the current shared access defaults/disclosure. +- Use a reviewed, versioned provider-specific suggestion. Explain when to recall, + when to save, the allowed context, and what to do if a call fails. Prefer one + short paragraph over a full skill or a pasted tool catalog. Avoid unsupported + promises about hooks, automatic transcript capture, or private memory. +- Default provided instructions on for every participating connector. Let users + edit, **Reset to default** after changes, or disable guidance while retaining + tools. Show a compact amber notice when off. Reconnect and catalog refresh + preserve explicit opt-outs and custom text. Omit suggestion attribution badges + and routine lifecycle/storage helper copy. +- Edit in an optional **Agent instructions** section of the existing **Permissions** + page, between agent access and Actions. Use the actual connection page shell, + credential form, and footers in review stories; include a baseline story for + comparison rather than constructing parallel page chrome. Show recipients + from current assignments and a read-only, source-linked copy on the agent. + Keep action controls and tests in the existing **Permissions** screen. +- Resolve runtime guidance with the same company, agent, task, responsible-user, + and grant checks used for tools. Compose a revisioned block alongside agent + instructions; never permanently edit AGENTS.md or shared harness home files. + Verify fresh and resumed turns, removal, revoked access, and unsupported adapters. +- Keep required provider settings, such as Honcho workspace, expanded near the + top of setup and configuration, above instructions. Put validation beside the + field and block completion while required values are missing. Memory stays an + ordinary connection; do not add a default-memory chooser or agent preference. + Keep provider binding identifiers separate from editable prose. Agent IDs, + tags, and query filters alone are not access-control boundaries. Any promised + isolation needs server enforcement across all relevant tool paths. +- Treat MCP initialization instructions as external provider content. Review + them as template source; do not silently inject arbitrary remote text or let + a provider update overwrite an operator's instructions. Guidance never grants + additional tool permissions. +- Prove both delivery and behavior: deterministic assignment/resume checks, then + a bounded save → **fresh session** recall with synthetic data through the real + run gateway. Record exact model/template/catalog versions, tool receipts, + costs, ingestion timeout, and cleanup. Discovery and a model's “saved” reply + do not prove memory works. See the linked plan for the minimal matrix. + ## Use This Document As The Checklist An agent implementing a connection should be able to begin with only a provider @@ -158,7 +212,7 @@ The shortest valid implementation usually changes these files: ```text scripts/ingest-app-definitions.mjs # human-authored definition source -packages/shared/src/app-definitions/.json # generated definition +packages/shared/src/app-definitions/.json # definition with app-owned instruction template packages/shared/src/app-definitions.generated.ts # generated registry ui/public/brands/apps/.svg # official, sanitized mark ui/public/brands/apps/manifest.json # runtime branding paths @@ -708,7 +762,13 @@ definitions and visible manifest entries to match exactly. ### Phase 5: Author the definition at the durable source -The checked-in provider JSON files are generated. Do not edit one and stop. +Transport/auth fields in the checked-in provider JSON files are generated. Do +not edit those fields and stop. Optional `agentInstructions: { id, version, text }` +templates are authored directly in each app's JSON definition, alongside its +supporting `docsUrl`. Ingestion validates and preserves that field; edit or +remove it there, and increment its version when changing the default. There is +no central instruction-template registry. See +[Connection instructions](./CONNECTION-INSTRUCTIONS.md) for the runtime contract. 1. Add or update the provider in `scripts/ingest-app-definitions.mjs`. 2. Update `packages/shared/src/self-serve-mcp-research.json` when it belongs to diff --git a/doc/connections/MEMORY.md b/doc/connections/MEMORY.md index f2153e618c0..2b661c3184e 100644 --- a/doc/connections/MEMORY.md +++ b/doc/connections/MEMORY.md @@ -8,7 +8,9 @@ rotate credentials without re-enabling the toggle. Generic custom MCP connections retain their existing behavior. These are ordinary company-scoped tool connections. There is no separate memory -page, automatic conversation upload, prompt injection, or background memory sync. +page, automatic conversation upload, or background memory sync. Reviewed defaults +use the generic [connection instructions](CONNECTION-INSTRUCTIONS.md) capability +to explain when to recall and save; operators can edit or disable them. Agents explicitly invoke the provider's tools through the existing gateway, and operators manage access and actions on the regular Permissions screen. @@ -22,7 +24,7 @@ Official documentation and public endpoint discovery checked September 24, 2026. | [Zep](https://help.getzep.com/memory-mcp-server) | Remote MCP, `https://api.getzep.com/mcp`, OAuth | Configure Memory MCP and the identity provider in Zep first. A project administrator assigns MCP seats and shared graphs. Sign in with the assigned work identity. An ordinary Zep API key is not the credential for this endpoint. | | [Supermemory](https://supermemory.ai/docs/supermemory-mcp/mcp) | Remote MCP, `https://mcp.supermemory.ai/mcp`, OAuth | Sign in and select the workspace, read/write access, and optional container tags offered by Supermemory. Developer API keys are separate from hosted MCP sign-in. | | [Cognee](https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp) | Bundled Cloud API bridge, Cloud API key | Copy the tenant API Base URL and key from Cognee Cloud → API Keys. Requires an active Cloud workspace. The bundled bridge works in public deployments without a local MCP runtime host. | -| [Honcho](https://honcho.dev/docs/v3/guides/integrations/mcp) | Remote MCP, `https://mcp.honcho.dev`, Bearer API key | Create an organization and API key in the Honcho dashboard. Workspace, peer, and session selectors remain explicit provider tool arguments. | +| [Honcho](https://honcho.dev/docs/v3/guides/integrations/mcp) | Remote MCP, `https://mcp.honcho.dev`, Bearer API key | Create an organization and API key in the Honcho dashboard. Configure the required workspace in Paperclip; it is projected into workspace-scoped tool arguments. Peer and session selectors follow provider/task context. | Zep and Supermemory use user grants, the existing PKCE OAuth broker, and automatic client registration/discovery. Zep advertises its authorization server at diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index 1a7f7852115..efd9e1c31b2 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -1105,8 +1105,7 @@ "https://honcho.dev/docs/v3/guides/integrations/mcp" ], "reviewedAt": "2026-09-30", - "liveProof": "not-run", - "keyPermissions": "Create peers/sessions and save memory under the API key project. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + "liveProof": "not-run" }, { "app": "hugging-face", diff --git a/doc/plans/2026-10-03-connection-instructions-and-memory-evals.md b/doc/plans/2026-10-03-connection-instructions-and-memory-evals.md new file mode 100644 index 00000000000..0352b79f720 --- /dev/null +++ b/doc/plans/2026-10-03-connection-instructions-and-memory-evals.md @@ -0,0 +1,363 @@ +# Connection instructions and a small memory smoke suite + +Implementation update (2026-10-05): the generic capability is specified in +[Connection instructions](../connections/CONNECTION-INSTRUCTIONS.md). The +Storybooks now use production components. Model evaluations in this original +design plan remain deferred. + +Status: original UX proposal. The production implementation now follows the +linked capability contract. Live model evaluations remain deferred. + +## Decision + +Make **Agent instructions** an optional connection capability. A connection can +provide a short paragraph that tells assigned agents when and how to use it. +Memory is the first use case. Use the same pattern for other services whose +purpose needs standing guidance, without requiring users to install a skill. + +The checkbox says **Tell agents to use Honcho** (using the provider name). Show +this section only when the connection explicitly provides a nonempty instruction +template. Default it on, show the exact paragraph, and offer **Reset to default** +only after edits. Turning it off preserves the paragraph and shows an amber notice. On the agent, show a read-only copy +with its source and a link back to the connection. + +Compose this into runtime instructions beside the agent's own instructions. +Do not append persistent text to AGENTS.md: that would create duplicate, stale +instructions after editing, reconnecting, changing assignments, or disconnecting. +An optional generated run-local file can be a delivery mechanism for a harness, +but the canonical agent files remain untouched. + +## What exists, and what is still unproven + +The five experimental catalog entries are ordinary remote MCP connections behind +`enableMemoryConnectors`. `doc/connections/memory-tool-inventory.json` records +authenticated discovery on **2026-09-24**, explicitly not execution of every tool. + +| Provider | Captured tools | Smallest useful path in that snapshot | +| --- | ---: | --- | +| Mem0 | 11 | `add_memory` → `get_event_status` if asynchronous → `search_memories` / `get_memory` | +| Honcho | 40 | Resolve workspace/peer/session → `add_messages_to_session` → `search` or `get_session_messages` | +| Supermemory | 16 | `add_memory` → `search_memory`, scoped to the approved space/tag | +| Zep | 12 | `add_memory` / `add_memory_to_graph` → `search_graph` | +| Cognee | 3 | `remember` → `recall`; `forget` for supported cleanup | + +These are observed names, not a cross-provider API or a claim that every +operation works. Capture fresh schemas before writing live fixtures. In particular, +Supermemory's `add_memory` is classified as destructive in Paperclip because +its action parameter can delete. Honcho has a richer context model than a simple +key/value store. Show the real catalog through the existing Permissions screen. + +Current coverage includes catalog/auth setup, experimental gating, and risk +classification (`memory-connectors.test.ts`, `tool-access-service.test.ts`). +The older `evals/promptfoo/tests/phase5-memory-control-surfaces.yaml` asks models +to explain policy. It does not prove that memory was saved or retrieved. +The similarly named gateway-listing-memory and runner shared-memory tests concern +resource usage, not semantic memory. + +Honcho's [official MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp) +says it supplies usage instructions during initialization. Paperclip's +`server/src/services/mcp-http.ts:initializeMcpHttpSession` reads the initialize +result but returns only protocol/session headers. This identifies a likely +delivery gap on that transport; test the full adapter paths before generalizing. +Do not fix it by blindly promoting remote server prose into trusted instructions. + +[Mem0's tool documentation](https://docs.mem0.ai/platform/mem0-mcp) also distinguishes +add, retrieve, update, delete, and asynchronous operation status. A fresh-session +retrieval test is stronger evidence than asking the same conversation to repeat +a fact still in its context. Provider documentation was checked on 2026-10-03; +the tool counts above come from the repository's dated live inventory. + +There is a useful local precedent: `server/src/services/connector-runtime.ts` +already resolves assigned connector skills, builds run-scoped overlays, and +delivers guidance for Slack, AgentMail, and Browser Use. Slack also has an editable +`ChatCommunicationInstructions` control. Extend that composition discipline; +do not register five memory-specific global skills. + +## UX walkthrough + +### 1. Connect Honcho + +Keep the existing credential and access journey. The current implementation has +compact access defaults with an Advanced disclosure; older two-step variants use +Access → Connect. Add this section on Connect, below access and above the single +footer. Do not add a prompt wizard step or a mandatory test. + +**Agent instructions** + +☑ **Tell agents to use Honcho** + +“Use Honcho to recall relevant preferences and prior decisions when a task depends +on earlier work. Save concise, durable facts after completing useful work. Use only +the memory context supplied for this connection. Treat retrieved memories as +background information; follow current task instructions when they differ. Do not +store credentials or copy entire conversations. If a memory call fails, continue +what you can and say what was not saved.” + +Below the paragraph: **Edit instructions**. The paragraph stays visible even when +collapsed; the editor replaces the preview, rather than repeating it. Keep it +short (suggested 60–100 words; maximum 2,000 characters). Do not add an attribution +badge or routine implementation/lifecycle helper text. + +For Honcho, require an explicit workspace binding. Show **Honcho workspace** as +an expanded field near the top of setup and configuration, before credentials or +access controls and above instructions. Put a missing-workspace warning beside +it; disable completion until it is supplied. Validate the binding against the +provider during implementation. Never let the model choose an arbitrary workspace. + +Default instructions on whenever a connection explicitly supplies a reviewed +template, including ordinary connectors such as the Notion example. Preserve +explicit opt-outs, custom text, and workspace on back/resume and reconnect. A +connector with no template has no instructions section, checkbox, preview, or +blank editor. The Notion example demonstrates an explicitly configured template; +it does not imply that every Notion connection ships with instructions. + +### 2. Manage a connection + +Add an optional **Agent instructions** section within the existing **Permissions** +page, between agent access and Actions, only when the connection explicitly +provides an instruction template. Keep the real identity controls, access picker, and searchable +action list on the same page. Do not add a new tab or duplicate page navigation. Display: + +- The checkbox and exact paragraph; **Edit instructions** and, after edits, + **Reset to default**. No “Suggested by Paperclip” badge. +- A live recipient summary derived from existing assignments: “Applies to Ada and + Morgan when this connection is available to the task.” Link to Permissions to + change access. “All agents” explicitly includes future eligible agents. +- Honcho workspace is a visible field near the top of the page, before access + controls, with its missing-value warning in the same place. +- **Cancel** left and **Save instructions** right. Save errors retain the draft. +- A plain saved receipt: “Saved.” + +Turning guidance off preserves the saved paragraph and tool access, and shows an +amber notice: “Agents won’t be told when or how to use Honcho.” Disconnecting +or losing access excludes both the guidance and tools from subsequent runs. A +missing workspace, unavailable credential, or unsupported adapter gets an explicit +“Instructions not applied” reason and a repair action; never a success badge. +If required writes are Off or Ask first, say so and link to Permissions. Saving +guidance must not change those policies or imply that writing is already allowed. + +### 3. Inspect an agent + +In the existing agent instructions area, add **From connections** beneath the +agent's own files. Each row shows provider, short use summary, On/Off/unavailable, +and its source. Expanding a row shows exactly what will be supplied and the +effective binding for the viewed task context. **Manage in Honcho** returns to the +connection editor. It is read-only here so the source of truth is unmistakable. + +Distinguish configured guidance from effective guidance: a personal credential +may be usable only for the responsible user's tasks. Without a task context, +state this condition rather than promising unconditional application. + +Memory remains an ordinary connector capability. Do not introduce a default-memory +chooser, memory-specific agent preference, or first-class memory settings area. +Connection instructions compose through the general connector instruction path; +users manage each connection’s instructions and assignments in its configuration. + +## Runtime and storage proposal + +Implement only after the UX has been reviewed. Suggested contracts: + +1. Add optional, versioned `agentGuidance` metadata to the AppDefinition source + and schema: template ID/version, default text, and required context/capabilities. A nonempty + template opts the connector into the UI; instructions start enabled. Regenerate + the catalog from its authoring source; do not hand-edit generated JSON. +2. Store company-scoped connection guidance with `enabled`, text, reviewed + template version, revision, editor identity, and update time. Store provider + context in a typed binding separate from prose; validate access on save and + resolution. Keep public API/shared validators/db/UI contracts synchronized. +3. Reuse the same effective access resolver as tool delivery: company, agent, + task, responsible human, grant, connection health, and action policies all + matter. Agent assignment alone must not imply access to a personal grant. +4. Resolve a stable ordered list of instruction blocks per turn. Include source + connection and revision, the short paragraph, and structured provider context. + Use actual runtime tool aliases and current schemas for any detailed recipes. + Do not paste the entire 40-tool Honcho catalog into the paragraph. +5. Include a guidance digest in session compatibility/input composition. Native + and resumed sessions must observe updates on the next turn. If a harness + cannot replace old context, start a fresh session or display the limitation; + never keep supplying conflicting old and new blocks indefinitely. Active + calls remain governed by current gateway authorization on every invocation. +6. Log edits and assignment changes as normal company activity. Record revision + and binding references in local run evidence. No new first-party Telemetry is + needed. Preview and run composition must share one resolver so they agree. + +An enabled paragraph is user-controlled operating guidance, not authorization to +bypass budgets, approvals, current user intent, or company boundaries. Templates +are maintained by Paperclip; provider initialize instructions are external data +that can inform a reviewed template, not an automatic template update. Pin saved +versions; show “Suggested instructions updated” with a diff and deliberate apply. +Never replace custom text during catalog refresh or reconnect. + +### Memory context is not an access-control boundary + +For the first Honcho slice, bind a dedicated workspace to the connection. Derive +stable peer IDs from company + agent and session IDs from company + task; retries +must reuse them. Decide these values in server code, not in editable prose. Show +the workspace openly near the top of configuration; users should not need to +invent peer IDs. Treat the task's responsible human as a separate subject when needed, +not as interchangeable with the agent's peer. + +A peer ID, tag, or query filter may partition retrieval without preventing an +agent holding broad tools from reading another scope. Do not label that “private +memory.” If private +agent memory is promised later, enforce scope arguments server-side, disallow +unscoped/list/bulk operations that bypass the boundary, and test alternate tool +paths. Until then, use isolated provider credentials/workspaces for confidential +scopes. Paperclip company access checks still apply to every connection. + +## The smallest useful evaluation + +Two separate questions: **does the integration work?** and **does the instruction +make a small model use it appropriately?** Do not ask an LLM judge to score either. +Assert tool calls, arguments, returned records, and a random fact in the answer. + +Start with Honcho and Mem0. Each has a different memory shape, and both are already +in the catalog. Expand to the other three once those fixtures are useful. + +| Case | Setup / task | Pass evidence | +| --- | --- | --- | +| Discover (no model) | Authenticate disposable test connection and list tools through the actual run gateway | Required read/write tools visible; record schema hashes and policies | +| Save → fresh recall | A: “Remember: release codename is ``.” B: fresh task/session, same memory subject: “What is the release codename?” | Confirmed write receipt; B performs retrieval before answering and includes the nonce from the returned record | +| Implicit recall | Seed a synthetic formatting preference, then ask “Draft my weekly update” without mentioning memory | Guidance-on run retrieves and follows the seeded preference; record guidance-off control separately | +| Skip irrelevant work | “What is 19 × 7?” with memory available | Correct answer, no memory calls or unnecessary writes | +| Failed write / denied access | Return write failure; separately deny the grant | No “saved” claim; useful task continues; no fallback to another user's/company's memory | +| Updated / disabled guidance (no model) | Change text, resume; turn off; unassign; revoke | Correct revision supplied once or absent as applicable; no agent file changes | + +The fresh-recall reader receives neither the writer transcript nor its answer, +nonce, runtime session, scratch files, or task summary. Disable other memory +providers and fixture workspace-memory carryover. Give reader a fresh execution +workspace. Only the provider contains the answer; the assertion runner knows the +nonce. Same model instance/conversation is not sufficient isolation. + +Run a deterministic gateway round trip before spending model tokens. For async +ingestion, poll a documented status/read path with a bounded deadline (proposed +60 seconds), record “indexing timeout” separately, and never sleep an arbitrary +amount then declare the model wrong. For Honcho, first prove raw message retrieval; +inferred conclusions are a separate, slower capability. + +Use the existing Product E2E runner for through-Paperclip evidence; follow +`add-product-e2e-eval` and `paperclip-evals` when implementing. Use a fixture MCP +server in ordinary CI. Account-bound runs are opt-in; the fixture suite proves +Paperclip behavior, not provider availability. Existing promptfoo response-text +tests cannot substitute for the tool loop. + +**Model candidates:** start with GPT-5 nano for the baseline, then Gemini 3.1 +Flash-Lite as the independent confirmation, if the configured runner provider +supports them. They are inexpensive candidates, not models already proven on +these connectors. Check the [OpenAI model page](https://developers.openai.com/api/docs/models/gpt-5-nano) +and [Google pricing](https://ai.google.dev/gemini-api/docs/pricing) at execution +time, including reasoning-token charges and any routing markup. If unavailable, +select another small tool-capable model from the installed roster and record the +substitution; do not expand this UX work into a new model adapter. + +**Cost envelope:** begin with one small tool-capable API model supported +by the runner, then one from another provider for confirmation. Select from the +current installed roster/pricing at execution time, pin exact IDs in the report, +and use a configurable `MEMORY_EVAL_MODEL` rather than “latest.” Do not treat +“free” endpoints as reproducible CI. Initial matrix: 2 providers × 1 model × 4 +behavior cases; save/recall uses two isolated sessions per case. One run per cell, +max 6 tool calls/session, 1,000 output tokens/session, concurrency 1. Proposed +hard ceiling: **$1 model spend per matrix**, and **$0.10 per cell**; stop before +dispatch if the reserved worst-case cost exceeds the remaining budget. Count +input tokens/tool schemas too. No automatic retry of failed model cells. + +Provider memory/derivation charges are separate: report them when available and +otherwise mark them unknown, never $0. Use designated test accounts, synthetic +facts, and a unique prefix. Cleanup only returned IDs or the known disposable +session/workspace; do not run account-wide delete tools. If cleanup is unsupported +or fails, report retained IDs and the required manual cleanup. + +A report needs provider, endpoint/catalog hash, model ID, instruction revision, +effective scope, per-case result, tool transcript/receipt IDs, latency, model +usage/cost, provider cost availability, and cleanup status. Classify configuration, +auth, policy, schema drift, ingestion, model behavior, and cleanup failures +separately. A connected badge or model saying “I remembered” is not evidence. + +## Delivery slices and acceptance + +1. **This proposal:** interactive Storybook placement, editing/off/error states, + agent preview, ordinary-connector examples with and without instructions, mobile/light views, + and the connector playbook convention. All mutations are local simulation. +2. **First runtime slice:** shared guidance contract, persistence, Honcho binding, + optional UI within existing configuration, one prompt composer for native + local harnesses, meaningful + assignment/revocation/resume tests. Provided templates default on while preserving + explicit opt-outs; missing templates add no UI or runtime text. +3. **Smoke slice:** deterministic gateway checks plus the bounded Honcho/Mem0 + model matrix above. Keep Experimental until both have fresh-session evidence; + the other providers stay unverified until individually exercised. + +Review in Storybook: **Design explorations → Connections → Agent instructions**. +The specimens render the production app shell, setup flow, and connection detail +page. The instructions block is composed into those pages through optional layout +slots. Credential handling, persistence, bindings, and delivery receipts are simulated. No credentials +or real memory providers are contacted. The plan intentionally does not ship a +new memory service, transcript mirroring, memory browser, or automatic hooks. + +## Prototype verification (2026-10-03) + +- UI TypeScript check passed after building the shared and plugin SDK type + dependencies in this fresh worktree. +- Full Storybook static build passed. Existing CSS highlight/font-resolution and + chunk-size warnings remain; no build errors. +- All 14 new stories rendered in Chromium without page errors. Browser checks + exercised edit/save/agent preview, disabling while retaining text, failed save + and retry, and setup draft exit/resume. +- Inspected desktop and 390-pixel mobile captures; no horizontal overflow. +- Token gates, palette/character sync checks, and `git diff --check` passed. + +In the initial proposal, no production code was changed. Repo-wide server tests/builds and account-bound +memory/model evals were not run for this design-only change. Run the full PR +checks before a PR-ready implementation handoff. This local Codex chat has no +Paperclip issue/run identity, so issue artifact upload and work-product creation +could not be associated with a destination. The plan and stories are retained in +the repository; the built review is available through the local Storybook server. + +## Revision: use the actual configuration pages (2026-10-04) + +The initial specimens invented a header, tabs, and a wider setup form. Replaced +them with the real `Layout`, `ConnectionSetupFlow`, and `AppDetail`, backed by +Storybook API fixtures. Credential instructions, requirements links, the compact +form width, Back/Connect footer, identity controls, agent access, and the searchable +Actions list now come directly from production components. The new section lives +under Permissions after agent access. Setup adds only the instruction section +above the existing footer. No extra workspace input is added to setup. + +`Current connect page` and `Current configuration page` render the same production +pages without the proposed section, for direct comparison. The agent preview is +a dialog over the connection page; it does not impersonate an implemented agent +configuration page. The only production changes are optional composition slots; +normal callers supply no new content and keep their current behavior. + +Verification for this revision: UI TypeScript and the full Storybook build pass. +All 242 tests across `AppDetail.test.tsx`, `AppsConnect.test.tsx`, and +`ConnectionSetupFlow.architecture.test.ts` pass. Token gates and whitespace +checks pass. Browser verification covered the actual setup and Permissions pages, +edit/save/preview, disabling while preserving text, failed save and retry, and +the 390-pixel mobile setup/editor (no horizontal overflow). Instruction saves +remain Storybook-only; no runtime memory behavior or provider eval was run. + +## Revision: concise provider-owned instructions (2026-10-05) + +Apply the same instruction component across setup, settings, reconnect, and agent +preview. Use “Tell agents to use {provider}”, default on for explicitly supplied +templates, an amber off state, and “Reset to default” after changes. Remove the +suggestion badge, storage/lifecycle explanation, and preview disclaimers from +product UI; prototype limitations stay in Storybook documentation. Honcho’s +required workspace stays expanded at the top, with validation beside the field. +Remove the proposed default-memory chooser from both the stories and runtime plan. + +The shared setup header no longer shows the generic “Connect now” subtitle. +Remove the Honcho key-permission paragraph from its catalog authoring source and +regenerate the definition so all credential forms pick up the same change. The +shared credential form renders only provider-supplied helper text, without +substituting the removed generic key-permission paragraph. + +Verification: 296 targeted tests pass across connection setup/detail, setup +architecture, app definitions, and memory connector risk coverage. After removing +the fallback credential paragraph, all 174 setup/architecture tests pass again. +Browser checks confirm template-present/on and template-absent/hidden states, +reset after saving a custom paragraph, off-state warning and preserved text, +failed save/retry, workspace-required blocking, baseline copy, and the mobile form +at 390 pixels without horizontal overflow. Final UI typecheck, full Storybook +build, token gates, and whitespace checks pass. These remain simulated instruction +and workspace settings; no live provider or model calls were made. diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 6c9b2a2b138..d07a634e825 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -2245,6 +2245,7 @@ export function renderPaperclipWakePrompt( return joinPromptSections([ renderPaperclipWakePromptBody(value, options), instructions ? `## Assigned connector skills\n\n${instructions}` : "", + asString(parseObject(parseObject(value).connectionInstructions).text, "").trim(), ]); } diff --git a/packages/db/src/migrations/0298_connection_agent_instructions.sql b/packages/db/src/migrations/0298_connection_agent_instructions.sql new file mode 100644 index 00000000000..bc798564739 --- /dev/null +++ b/packages/db/src/migrations/0298_connection_agent_instructions.sql @@ -0,0 +1,16 @@ +ALTER TABLE "tool_connections" ADD COLUMN IF NOT EXISTS "agent_instructions" jsonb; +--> statement-breakpoint +UPDATE "tool_connections" SET "agent_instructions" = '{"enabled": true, "text": "Use Honcho to recall relevant preferences and prior decisions when work depends on earlier context. Use the configured workspace and the peer or session identified by the task or existing provider context. Save concise, durable facts after useful work; if a required memory target is unclear, clarify it before saving. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable.", "template": {"id": "honcho.usage", "version": 1}}'::jsonb +WHERE "agent_instructions" IS NULL AND COALESCE("config"->>'sourceTemplateKey', "transport_config"->>'sourceTemplateKey') = 'honcho'; +--> statement-breakpoint +UPDATE "tool_connections" SET "agent_instructions" = '{"enabled": true, "text": "Use Mem0 to search for relevant preferences, decisions, and prior work before relying on remembered context. Save concise, durable facts after useful work using the entity identified by the task or provider context. Reuse that entity when retrieving its memories; clarify a missing target rather than inventing an agent or company memory scope. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable.", "template": {"id": "mem0.usage", "version": 1}}'::jsonb +WHERE "agent_instructions" IS NULL AND COALESCE("config"->>'sourceTemplateKey', "transport_config"->>'sourceTemplateKey') = 'mem0'; +--> statement-breakpoint +UPDATE "tool_connections" SET "agent_instructions" = '{"enabled": true, "text": "Use Zep to search the connected identity’s memory when earlier decisions or preferences matter. Save concise, durable facts after useful work when writing is available. Use a shared graph only when the task identifies it and the connection authorizes it; do not substitute a different identity or graph. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable.", "template": {"id": "zep.usage", "version": 1}}'::jsonb +WHERE "agent_instructions" IS NULL AND COALESCE("config"->>'sourceTemplateKey', "transport_config"->>'sourceTemplateKey') = 'zep'; +--> statement-breakpoint +UPDATE "tool_connections" SET "agent_instructions" = '{"enabled": true, "text": "Use Supermemory to recall relevant preferences, decisions, and prior work from the workspace and tags authorized for this connection. Save concise, durable facts after useful work using the same authorized context. Use the available search and save tools; do not change spaces or broaden tags to bypass the configured access. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable.", "template": {"id": "supermemory.usage", "version": 1}}'::jsonb +WHERE "agent_instructions" IS NULL AND COALESCE("config"->>'sourceTemplateKey', "transport_config"->>'sourceTemplateKey') = 'supermemory'; +--> statement-breakpoint +UPDATE "tool_connections" SET "agent_instructions" = '{"enabled": true, "text": "Use Cognee recall to retrieve relevant facts and decisions when work depends on earlier context. Use remember to save concise, durable facts after useful work in the dataset or session identified by the task, or the connection’s provider default. Use forget only when the task calls for deletion. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable.", "template": {"id": "cognee.usage", "version": 1}}'::jsonb +WHERE "agent_instructions" IS NULL AND COALESCE("config"->>'sourceTemplateKey', "transport_config"->>'sourceTemplateKey') = 'cognee'; diff --git a/packages/db/src/migrations/meta/0293_snapshot.json b/packages/db/src/migrations/meta/0298_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0293_snapshot.json rename to packages/db/src/migrations/meta/0298_snapshot.json index 7dee20fa0e9..017e197f09e 100644 --- a/packages/db/src/migrations/meta/0293_snapshot.json +++ b/packages/db/src/migrations/meta/0298_snapshot.json @@ -1,6 +1,6 @@ { - "id": "d0c40f4e-c2c5-4efd-bc15-c6ae5632047d", - "prevId": "239dfc30-3531-48b5-a06f-72608de479da", + "id": "792dfb38-10bf-4147-a541-5c52ce0e80b6", + "prevId": "ed75c75c-748b-4420-8a68-ec5b34394e75", "version": "7", "dialect": "postgresql", "tables": { @@ -38918,6 +38918,140 @@ }, "isRLSEnabled": false }, + "public.resource_lifecycle_events": { + "name": "resource_lifecycle_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "resource_lifecycle_events_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_lifecycle_events_creation_idx": { + "name": "resource_lifecycle_events_creation_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"resource_lifecycle_events\".\"action\" = 'create'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_lifecycle_events_company_idx": { + "name": "resource_lifecycle_events_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_lifecycle_events_company_id_companies_id_fk": { + "name": "resource_lifecycle_events_company_id_companies_id_fk", + "tableFrom": "resource_lifecycle_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "resource_lifecycle_events_resource_type_check": { + "name": "resource_lifecycle_events_resource_type_check", + "value": "\"resource_lifecycle_events\".\"resource_type\" IN ('agent', 'project')" + }, + "resource_lifecycle_events_action_check": { + "name": "resource_lifecycle_events_action_check", + "value": "\"resource_lifecycle_events\".\"action\" = 'create' OR (\"resource_lifecycle_events\".\"resource_type\" = 'agent' AND \"resource_lifecycle_events\".\"action\" IN ('pause', 'resume', 'terminate'))" + } + }, + "isRLSEnabled": false + }, "public.routine_documents": { "name": "routine_documents", "schema": "", @@ -44497,6 +44631,298 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.tool_connection_app_snapshots": { + "name": "tool_connection_app_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_key": { + "name": "credential_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "toolkit": { + "name": "toolkit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accounts": { + "name": "accounts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_at": { + "name": "error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tool_connection_app_snapshots_owner_toolkit_uq": { + "name": "tool_connection_app_snapshots_owner_toolkit_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "toolkit", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_connection_app_snapshots_company_id_companies_id_fk": { + "name": "tool_connection_app_snapshots_company_id_companies_id_fk", + "tableFrom": "tool_connection_app_snapshots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_connection_app_snapshots_company_connection_fk": { + "name": "tool_connection_app_snapshots_company_connection_fk", + "tableFrom": "tool_connection_app_snapshots", + "tableTo": "tool_connections", + "columnsFrom": [ + "company_id", + "connection_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_connection_app_syncs": { + "name": "tool_connection_app_syncs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_key": { + "name": "credential_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_id": { + "name": "lease_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checked": { + "name": "checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed": { + "name": "failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_completed_at": { + "name": "last_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tool_connection_app_syncs_owner_key_uq": { + "name": "tool_connection_app_syncs_owner_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tool_connection_app_syncs_company_id_companies_id_fk": { + "name": "tool_connection_app_syncs_company_id_companies_id_fk", + "tableFrom": "tool_connection_app_syncs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tool_connection_app_syncs_company_connection_fk": { + "name": "tool_connection_app_syncs_company_connection_fk", + "tableFrom": "tool_connection_app_syncs", + "tableTo": "tool_connections", + "columnsFrom": [ + "company_id", + "connection_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.tool_connection_installs": { "name": "tool_connection_installs", "schema": "", @@ -44790,6 +45216,12 @@ "notNull": true, "default": false }, + "agent_instructions": { + "name": "agent_instructions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "config": { "name": "config", "type": "jsonb", @@ -46209,6 +46641,27 @@ "method": "btree", "with": {} }, + "tool_mcp_gateway_tokens_expiry_idx": { + "name": "tool_mcp_gateway_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "tool_mcp_gateway_tokens_gateway_idx": { "name": "tool_mcp_gateway_tokens_gateway_idx", "columns": [ diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 4b91019db5e..3a305202425 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2073,6 +2073,13 @@ "when": 1791233758328, "tag": "0297_foamy_swordsman", "breakpoints": true + }, + { + "idx": 298, + "version": "7", + "when": 1791241297512, + "tag": "0298_connection_agent_instructions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/tool_access.ts b/packages/db/src/schema/tool_access.ts index 19b93987e03..e82255f2b09 100644 --- a/packages/db/src/schema/tool_access.ts +++ b/packages/db/src/schema/tool_access.ts @@ -16,6 +16,7 @@ import { uuid, } from "drizzle-orm/pg-core"; import type { + ConnectionAgentInstructions, ConnectionTokenIssuanceOutcome, ConnectionTokenIssuancePath, McpConnectionCredentialRef, @@ -129,6 +130,7 @@ export const toolConnections = pgTable( credentialPolicy: text("credential_policy").$type().notNull().default("shared"), status: text("status").$type().notNull().default("draft"), enabled: boolean("enabled").notNull().default(false), + agentInstructions: jsonb("agent_instructions").$type(), config: jsonb("config").$type>().notNull().default({}), transportConfig: jsonb("transport_config").$type>().notNull().default({}), credentialRefs: jsonb("credential_refs").$type().notNull().default([]), diff --git a/packages/paperclip-runner/src/contracts/runtime-context.ts b/packages/paperclip-runner/src/contracts/runtime-context.ts index bbf7e89ab5e..850825c29b1 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context.ts @@ -23,6 +23,7 @@ export interface NativeRuntimeContextSnapshot { }; skills: Array<{ key: string; runtimeName: string; versionId: string | null; bundle: NativeRuntimeAssetReference }>; mcp: { assignmentSetId: string; digest: string; bindingId: string | null }; + connectionInstructions?: { text: string; digest: string }; aggregateDigest: string; } @@ -87,6 +88,7 @@ function aggregatePayload(value: Omit; const aggregateDigest = digest(context.aggregateDigest, "input.runtimeContext.aggregateDigest"); if (aggregateDigest !== canonicalNativeRuntimeContextDigest(parsed)) { @@ -157,6 +169,7 @@ export function composeNativeSystemInstructions(context: NativeRuntimeContextSna return [ context.prompt.text, entryContent.trim(), + context.connectionInstructions?.text, context.instructions.workingCopy?.kind === "agent_files" ? `Your persistent agent directory (AGENT_HOME) is ${context.instructions.workingCopy.rootPath}. Your instruction entry is ${context.instructions.workingCopy.entryPath}, relative to that directory. All supported files and subfolders there are restored across tasks and sessions, and collected after this provider stops. Write task deliverables in the task working directory. Only changed or deleted files synchronize; the last sync wins for the same file. Temporary copies are cleaned up without retaining file history. Check the save receipt before claiming persistence.` : context.instructions.workingCopy diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index aa038c32f61..0970e05d064 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -1026,6 +1026,7 @@ describe("AppDefinition catalog", () => { ).sort(); expect(required).toEqual([ "clickhouse:mcp-oauth:serviceId", + "honcho:mcp-api-key:workspaceId", "shopify:storefront-mcp:storeDomain", "shopify:ucp-commerce:storeDomain", "supabase:mcp-api-key:projectRef", diff --git a/packages/shared/src/app-definitions/cognee.json b/packages/shared/src/app-definitions/cognee.json index 287beb3acf5..2019b44563f 100644 --- a/packages/shared/src/app-definitions/cognee.json +++ b/packages/shared/src/app-definitions/cognee.json @@ -67,5 +67,10 @@ "docs": "https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp" } } - ] + ], + "agentInstructions": { + "id": "cognee.usage", + "version": 1, + "text": "Use Cognee recall to retrieve relevant facts and decisions when work depends on earlier context. Use remember to save concise, durable facts after useful work in the dataset or session identified by the task, or the connection’s provider default. Use forget only when the task calls for deletion. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable." + } } diff --git a/packages/shared/src/app-definitions/honcho.json b/packages/shared/src/app-definitions/honcho.json index ad5a94c78d9..17185eaf518 100644 --- a/packages/shared/src/app-definitions/honcho.json +++ b/packages/shared/src/app-definitions/honcho.json @@ -36,8 +36,7 @@ "type": "password", "required": true, "placeholder": "Paste your Honcho API key", - "secret": true, - "helperMd": "Create peers/sessions and save memory under the API key project. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." + "secret": true } ], "keyPlacement": { @@ -51,7 +50,24 @@ }, "warnings": [ "An API key from the Honcho dashboard. Memory is stored in your Honcho account; select workspace and peer identifiers when calling tools." + ], + "tenantFields": [ + { + "key": "workspaceId", + "label": "Honcho workspace", + "type": "text", + "required": true, + "placeholder": "Workspace ID", + "validation": { + "maxLength": 512 + } + } ] } - ] + ], + "agentInstructions": { + "id": "honcho.usage", + "version": 1, + "text": "Use Honcho to recall relevant preferences and prior decisions when work depends on earlier context. Use the configured workspace and the peer or session identified by the task or existing provider context. Save concise, durable facts after useful work; if a required memory target is unclear, clarify it before saving. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable." + } } diff --git a/packages/shared/src/app-definitions/mem0.json b/packages/shared/src/app-definitions/mem0.json index 95bb028d7e8..ff74c42d3ed 100644 --- a/packages/shared/src/app-definitions/mem0.json +++ b/packages/shared/src/app-definitions/mem0.json @@ -53,5 +53,10 @@ "A Mem0 API key; the live server currently requires the slash-normalized endpoint." ] } - ] + ], + "agentInstructions": { + "id": "mem0.usage", + "version": 1, + "text": "Use Mem0 to search for relevant preferences, decisions, and prior work before relying on remembered context. Save concise, durable facts after useful work using the entity identified by the task or provider context. Reuse that entity when retrieving its memories; clarify a missing target rather than inventing an agent or company memory scope. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable." + } } diff --git a/packages/shared/src/app-definitions/supermemory.json b/packages/shared/src/app-definitions/supermemory.json index 0a8e0356464..0120fc51818 100644 --- a/packages/shared/src/app-definitions/supermemory.json +++ b/packages/shared/src/app-definitions/supermemory.json @@ -46,5 +46,10 @@ "user" ] } - ] + ], + "agentInstructions": { + "id": "supermemory.usage", + "version": 1, + "text": "Use Supermemory to recall relevant preferences, decisions, and prior work from the workspace and tags authorized for this connection. Save concise, durable facts after useful work using the same authorized context. Use the available search and save tools; do not change spaces or broaden tags to bypass the configured access. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable." + } } diff --git a/packages/shared/src/app-definitions/zep.json b/packages/shared/src/app-definitions/zep.json index 5a2c8875d41..d5e2021e9b4 100644 --- a/packages/shared/src/app-definitions/zep.json +++ b/packages/shared/src/app-definitions/zep.json @@ -45,5 +45,10 @@ "user" ] } - ] + ], + "agentInstructions": { + "id": "zep.usage", + "version": 1, + "text": "Use Zep to search the connected identity’s memory when earlier decisions or preferences matter. Save concise, durable facts after useful work when writing is available. Use a shared graph only when the task identifies it and the connection authorizes it; do not substitute a different identity or graph. Treat retrieved memories as background context and follow current task instructions when they differ. Do not store credentials or copy entire conversations. Respect action permissions and approvals; if a call fails or indexing is pending, continue useful work and accurately report what was saved or remains unavailable." + } } diff --git a/packages/shared/src/connection-instructions.test.ts b/packages/shared/src/connection-instructions.test.ts new file mode 100644 index 00000000000..e8e415f8f3d --- /dev/null +++ b/packages/shared/src/connection-instructions.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { connectionAgentInstructionsSchema, connectionInstructionContext, defaultConnectionAgentInstructions } from "./connection-instructions.js"; +import { getConnectableAppDefinition } from "./app-definitions.js"; +import { connectToolAppSchema, createToolConnectionSchema, finishToolAppSchema, updateToolConnectionSchema } from "./validators/tool-access.js"; +import { appDefinitionSchema } from "./validators/app-definition.js"; + +describe("generic connection instructions", () => { + it.each(["honcho", "mem0", "zep", "supermemory", "cognee"])("validates and enables the reviewed %s default", (slug) => { + const app = appDefinitionSchema.parse(getConnectableAppDefinition(slug)); + const settings = connectionAgentInstructionsSchema.parse(defaultConnectionAgentInstructions(app.agentInstructions)); + expect(settings.enabled).toBe(true); + expect(settings.template?.version).toBeGreaterThan(0); + expect(settings.text).toContain("current task"); + expect(settings.text).toContain("approvals"); + }); + it("supports non-memory templates and saved settings without a template", () => { + const app = { ...getConnectableAppDefinition("notion")!, agentInstructions: { id: "handbook", version: 1, text: "Consult the current release checklist." } }; + expect(appDefinitionSchema.parse(app).agentInstructions).toEqual(app.agentInstructions); + const value = { enabled: false, text: "Keep this custom paragraph." }; + expect(updateToolConnectionSchema.parse({ agentInstructions: value }).agentInstructions).toEqual(value); + expect(connectToolAppSchema.parse({ galleryKey: "notion", agentInstructions: value }).agentInstructions).toEqual(value); + expect(finishToolAppSchema.parse({ enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: "all_agents", agentInstructions: value }).agentInstructions).toEqual(value); + expect(createToolConnectionSchema.parse({ name: "Handbook", transport: "mcp_remote", agentInstructions: value }).agentInstructions).toEqual(value); + expect(defaultConnectionAgentInstructions(getConnectableAppDefinition("notion")?.agentInstructions)).toBeNull(); + }); + it("withholds unconfigured instructions and never includes secret fields", () => { + const app = getConnectableAppDefinition("honcho")!; + expect(connectionInstructionContext(app, { methodConfig: { workspaceId: " " } })).toBeNull(); + expect(connectionInstructionContext(app, { methodConfig: { workspaceId: " team ", apiKey: "secret" } })).toEqual({ workspaceId: "team" }); + }); + it("allows explicit clearing and rejects blank or unbounded text", () => { + expect(updateToolConnectionSchema.parse({ agentInstructions: null }).agentInstructions).toBeNull(); + expect(connectionAgentInstructionsSchema.safeParse({ enabled: true, text: " " }).success).toBe(false); + expect(connectionAgentInstructionsSchema.safeParse({ enabled: true, text: "a".repeat(2001) }).success).toBe(false); + }); + + it("regenerates templates from each app definition, including non-memory edits and removal", () => { + const root = fileURLToPath(new URL("../../../", import.meta.url)); + const fixture = mkdtempSync(join(tmpdir(), "paperclip-app-instructions-")); + const definitions = join(fixture, "packages/shared/src/app-definitions"); + const readApp = (slug: string) => JSON.parse(readFileSync(join(definitions, `${slug}.json`), "utf8")); + const writeApp = (slug: string, app: unknown) => writeFileSync(join(definitions, `${slug}.json`), JSON.stringify(app)); + const generate = () => execFileSync(process.execPath, [join(root, "scripts/ingest-app-definitions.mjs"), "--definitions-only"], { cwd: fixture, stdio: "pipe" }); + try { + for (const source of [ + "ui/public/brands/apps/manifest.json", + "packages/shared/src/self-serve-mcp-research.json", + "doc/connections/tool-method-permission-reviews.json", + "packages/shared/src/app-definitions", + ]) { + mkdirSync(dirname(join(fixture, source)), { recursive: true }); + cpSync(join(root, source), join(fixture, source), { recursive: true }); + } + const notion = readApp("notion"); + notion.agentInstructions = { id: "notion.handbook", version: 2, text: "Consult the current release checklist." }; + writeApp("notion", notion); + generate(); + expect(readApp("notion").agentInstructions).toEqual(notion.agentInstructions); + for (const slug of ["honcho", "mem0", "zep", "supermemory", "cognee"]) { + expect(readApp(slug).agentInstructions).toEqual(getConnectableAppDefinition(slug)?.agentInstructions); + } + expect(readApp("linear").agentInstructions).toBeUndefined(); + + notion.agentInstructions.text = "Check the updated handbook before a release."; + notion.agentInstructions.version++; + writeApp("notion", notion); + const mem0 = readApp("mem0"); + delete mem0.agentInstructions; + writeApp("mem0", mem0); + generate(); + expect(readApp("notion").agentInstructions).toEqual(notion.agentInstructions); + expect(readApp("mem0").agentInstructions).toBeUndefined(); + + notion.agentInstructions.text = " "; + writeApp("notion", notion); + expect(generate).toThrow("notion: invalid agent instruction template"); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/shared/src/connection-instructions.ts b/packages/shared/src/connection-instructions.ts new file mode 100644 index 00000000000..61b3e102838 --- /dev/null +++ b/packages/shared/src/connection-instructions.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; +import type { AppDefinition } from "./types/app-definition.js"; + +export const CONNECTION_INSTRUCTIONS_MAX_LENGTH = 2000; + +/** Optional catalog capability. It is independent of provider and transport. */ +export const connectionInstructionTemplateSchema = z.object({ + id: z.string().trim().min(1).max(160), + version: z.number().int().positive(), + text: z.string().trim().min(1).max(CONNECTION_INSTRUCTIONS_MAX_LENGTH), +}); +export type ConnectionInstructionTemplate = z.infer; + +/** Connection-owned settings; custom connections need not have a template. */ +export const connectionAgentInstructionsSchema = z.object({ + enabled: z.boolean(), + text: z.string().trim().min(1).max(CONNECTION_INSTRUCTIONS_MAX_LENGTH), + template: z.object({ + id: z.string().trim().min(1).max(160), + version: z.number().int().positive(), + }).optional(), +}); +export type ConnectionAgentInstructions = z.infer; + +export function defaultConnectionAgentInstructions( + template: ConnectionInstructionTemplate | null | undefined, +): ConnectionAgentInstructions | null { + return template ? { + enabled: true, + text: template.text, + template: { id: template.id, version: template.version }, + } : null; +} + +/** The server supplies this immutable snapshot, never the wake request author. */ +export interface ConnectionInstructionsSnapshot { + text: string; + digest: string; +} + +/** Older connections can retain catalog metadata only in transportConfig. */ +export function connectionInstructionsConfig(connection: { + config?: Record | null; + transportConfig?: Record | null; +}): Record { + return { ...connection.transportConfig, ...connection.config }; +} + +/** Only public configuration fields declared by the connector enter prompts. */ +export function connectionInstructionContext( + app: AppDefinition | null | undefined, + config: Record, +): Record | null { + const method = app?.methods.find((entry) => entry.key === config.connectionMethodKey) ?? app?.methods[0]; + const values = config.methodConfig && typeof config.methodConfig === "object" && !Array.isArray(config.methodConfig) + ? config.methodConfig as Record : {}; + const context: Record = {}; + for (const field of [...(method?.tenantFields ?? []), ...(method?.extensionFields ?? [])]) { + if (field.secret || field.type === "password") continue; + const value = values[field.key] ?? field.defaultValue; + if (field.required && (value === undefined || value === null || (typeof value === "string" && !value.trim()))) return null; + if (typeof value === "string" && value.trim()) context[field.key] = value.trim(); + else if (typeof value === "boolean") context[field.key] = value; + } + return context; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f4a7e0ab28f..5486a3c58fe 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2821,3 +2821,5 @@ export * from "./types/skill-source.js"; export * from "./validators/skill-source.js"; export * from "./github-skill-repository.js"; export { isAppAggregator, aggregatorManagementUrl, aggregatorAppsSyncSchema, aggregatorAppsRefreshSchema, arcadeDiscoverySetupSchema, type AggregatorAppSnapshot, type AggregatorAppsResponse, type ArcadeDiscoverySetupInput } from "./aggregator-apps.js"; + +export * from "./connection-instructions.js"; diff --git a/packages/shared/src/types/app-definition.ts b/packages/shared/src/types/app-definition.ts index de6cc3d98fe..dc04bbabd7a 100644 --- a/packages/shared/src/types/app-definition.ts +++ b/packages/shared/src/types/app-definition.ts @@ -3,7 +3,7 @@ export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"| export type OAuthRedirectConstraints = "https-or-loopback-http"; export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; hidden?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} } export interface ConnectionMethodDef { ai?: import("../ai-connections.js").AiConnectionMetadata; key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"|"agentmail"|"imessage-photon"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; oauthClientSecretRequired?:boolean; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] } -export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> } +export interface AppDefinition { agentInstructions?: import("../connection-instructions.js").ConnectionInstructionTemplate; schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> } export type SelfServeMcpAuthMode = | "dcr" diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts index 5abb3a64f66..756e6f4708f 100644 --- a/packages/shared/src/types/tool-access.ts +++ b/packages/shared/src/types/tool-access.ts @@ -165,6 +165,7 @@ export interface ToolApplication { } export interface ToolConnection { + agentInstructions?: import("../connection-instructions.js").ConnectionAgentInstructions | null; id: string; companyId: string; applicationId: string; diff --git a/packages/shared/src/validators/app-definition.ts b/packages/shared/src/validators/app-definition.ts index d55b839e1f8..6cf59f19f92 100644 --- a/packages/shared/src/validators/app-definition.ts +++ b/packages/shared/src/validators/app-definition.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { connectionInstructionTemplateSchema } from "../connection-instructions.js"; import { AI_CONNECTION_CAPABILITIES, aiConnectionMetadataSchema } from "../ai-connections.js"; import { connectionGrantKindSchema, toolConnectionOwnershipSchema, toolConnectionPurposeSchema, toolConnectionTransportSchema } from "./tool-access.js"; const appBrandAssetUrlSchema=z.string().refine((value)=>{ @@ -7,5 +8,5 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{ },{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"}); const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})}); export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),oauthClientSecretRequired:z.boolean().optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})}); -export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}); +export const appDefinitionSchema=z.object({agentInstructions:connectionInstructionTemplateSchema.optional(),schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}); export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})}); diff --git a/packages/shared/src/validators/tool-access.ts b/packages/shared/src/validators/tool-access.ts index 3933aa34959..e0a0b3561e7 100644 --- a/packages/shared/src/validators/tool-access.ts +++ b/packages/shared/src/validators/tool-access.ts @@ -1,3 +1,4 @@ +import { connectionAgentInstructionsSchema } from "../connection-instructions.js"; import { isRemoteMcpConnectorMethod } from "../remote-mcp-connectors.js"; import { z } from "zod"; import { @@ -178,6 +179,7 @@ export const updateToolApplicationSchema = createToolApplicationSchema.partial() export type UpdateToolApplication = z.infer; export const createToolConnectionSchema = z.object({ + agentInstructions: connectionAgentInstructionsSchema.nullable().optional(), applicationId: z.string().guid().optional(), applicationName: z.string().trim().min(1).max(160).optional(), name: z.string().trim().min(1).max(160), @@ -407,6 +409,7 @@ function rejectUnsafeHeaderCredentials( } export const connectToolAppSchema = z.object({ + agentInstructions: connectionAgentInstructionsSchema.nullable().optional(), galleryKey: z.string().trim().min(1).max(120).optional(), connectionMethodKey: z.string().trim().min(1).max(120).optional(), link: z.string().trim().url().max(2000).optional(), @@ -488,6 +491,7 @@ export const reconnectToolAppSchema = z.object({ export type ReconnectToolApp = z.infer; export const finishToolAppSchema = z.object({ + agentInstructions: connectionAgentInstructionsSchema.nullable().optional(), /** Task setup adds access while preserving existing assignments and action policies. */ preserveExistingAccess: z.boolean().optional(), enabledCatalogEntryIds: z.array(z.string().guid()).max(500).default([]), diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 928085347b3..acb746dee91 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -1130,6 +1130,10 @@ const specialMethodsFor = (entry) => { apiKeyMethodFor(entry, "mcp-api-key", entry.serverUrl, { guidanceMd: `Open the ${entry.name} dashboard, create an API key for the account agents should use, and paste it below.`, consoleLinks: { keys: entry.slug === "mem0" ? "https://app.mem0.ai/dashboard/api-keys" : "https://app.honcho.dev", docs: entry.docsUrl }, + ...(entry.slug === "honcho" ? { tenantFields: [{ + key: "workspaceId", label: "Honcho workspace", type: "text", required: true, + placeholder: "Workspace ID", validation: { maxLength: 512 }, + }] } : {}), }), ]; if (entry.slug === "zep") return [oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, { @@ -1755,6 +1759,21 @@ for (const app of apps) { } } +// Reviewed instruction templates are authored in each app's definition. Keep +// that optional capability intact when regenerating its transport/auth fields. +for (const app of apps) { + const definitionPath = path.join(out, `${app.slug}.json`); + if (!fs.existsSync(definitionPath)) continue; + const { agentInstructions: template } = JSON.parse(fs.readFileSync(definitionPath, "utf8")); + if (template === undefined) continue; + if (!template || typeof template.id !== "string" || !template.id.trim() || template.id.length > 160 + || !Number.isInteger(template.version) || template.version < 1 + || typeof template.text !== "string" || !template.text.trim() || template.text.length > 2000) { + throw new Error(`${app.slug}: invalid agent instruction template`); + } + app.agentInstructions = template; +} + const validateApp = (app) => { if ( app.schemaVersion !== 1 || diff --git a/server/src/__tests__/connection-instructions.test.ts b/server/src/__tests__/connection-instructions.test.ts new file mode 100644 index 00000000000..9b1fcabe0db --- /dev/null +++ b/server/src/__tests__/connection-instructions.test.ts @@ -0,0 +1,262 @@ +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { nativeSystemInstructions } from "../../../packages/paperclip-runner/src/backends/runtime-context.js"; +import type { NativeExecutionInput } from "../vendor/paperclip-runner/index.js"; +import { randomUUID } from "node:crypto"; +import { access, mkdtemp, writeFile, rm, readFile } from "node:fs/promises"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, companyMemberships, connectionGrants, createDb, heartbeatRuns, issues, toolApplications, toolCatalogEntries, toolConnections, toolPolicies, toolProfileBindings, toolActionRequests } from "@paperclipai/db"; +import { createToolGatewayService } from "../services/tool-gateway.js"; +import { toolDiscoveryScheduler } from "../services/tool-discovery-scheduler.js"; +import { toolAccessService, projectedConnectionToolArguments, projectedConnectionToolInputSchema } from "../services/tool-access.js"; +import { composeConnectionInstructions, prepareConnectionInstructionDelivery } from "../services/connection-instructions.js"; +import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils"; +import { canonicalNativeRuntimeContextDigest, parseNativeRuntimeContext } from "../vendor/paperclip-runner/index.js"; +import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js"; +import { execute as executeProcess } from "../adapters/process/execute.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +const support = await getEmbeddedPostgresTestSupport(); +const paragraph = "Use the release handbook before changing a deployment. Cite the relevant release checklist."; +async function createRunFixture(db: ReturnType) { + const company = await db.insert(companies).values({ + name: `Gateway ${randomUUID()}`, + issuePrefix: `TG${randomUUID().slice(0, 6).toUpperCase()}`, + }).returning().then((rows) => rows[0]!); + const agent = await db.insert(agents).values({ + companyId: company.id, + name: `Gateway Agent ${randomUUID()}`, + role: "engineer", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }).returning().then((rows) => rows[0]!); + const issue = await db.insert(issues).values({ + companyId: company.id, + title: "Gateway approval work", + status: "in_progress", + assigneeAgentId: agent.id, + }).returning().then((rows) => rows[0]!); + const run = await db.insert(heartbeatRuns).values({ + companyId: company.id, + agentId: agent.id, + invocationSource: "assignment", + status: "running", + contextSnapshot: { issueId: issue.id }, + }).returning().then((rows) => rows[0]!); + return { company, agent, issue, run }; +} + +async function createRemoteMcpToolFixture(db: ReturnType, companyId: string) { + const application = await db.insert(toolApplications).values({ + companyId, + applicationKey: `remote-${randomUUID().slice(0, 8)}`, + name: "Remote MCP", + type: "mcp_http", + status: "active", + }).returning().then((rows) => rows[0]!); + const connection = await db.insert(toolConnections).values({ + companyId, + applicationId: application.id, + name: "Remote connection", + uid: `test/${randomUUID()}`, + transport: "mcp_remote", + status: "active", + enabled: true, + healthStatus: "ok", + // Use a public IP literal so protocol tests remain independent of DNS while + // still exercising the production egress guard and their global fetch stub. + credentialPolicy: "shared", + config: { url: "https://8.8.8.8/mcp" }, + }).returning().then((rows) => rows[0]!); + await db.insert(connectionGrants).values({ + companyId, + connectionId: connection.id, + kind: "organization", + credentialSecretRefs: [], + status: "active", + isDefault: true, + }); + const catalogEntry = await db.insert(toolCatalogEntries).values({ + companyId, + applicationId: application.id, + connectionId: connection.id, + entryKind: "tool", + name: "needs_input", + toolName: "needs_input", + title: "Needs input", + riskLevel: "read", + isReadOnly: true, + status: "active", + versionHash: randomUUID(), + schemaHash: randomUUID(), + }).returning().then((rows) => rows[0]!); + return { application, connection, catalogEntry }; +} + + +(support.supported ? describe : describe.skip)("generic connection instruction delivery", () => { + let db: ReturnType; + let temp: Awaited>; + beforeAll(async () => { temp = await startEmbeddedPostgresTestDatabase("paperclip-connection-instructions-"); db = createDb(temp.connectionString); }, 30000); + afterAll(async () => { await temp?.cleanup(); }); + async function fixture() { + const run = await createRunFixture(db); + const connection = await createRemoteMcpToolFixture(db, run.company.id); + const service = toolAccessService(db); + await service.updateConnection(connection.connection.id, { agentInstructions: { enabled: true, text: paragraph } }); + await service.finishGalleryAppConnection(run.company.id, connection.connection.id, { + enabledCatalogEntryIds: [connection.catalogEntry.id], askFirstCatalogEntryIds: [], access: { agentIds: [run.agent.id] }, + }); + const gateway = createToolGatewayService(db); + const binding = { companyId: run.company.id, agentId: run.agent.id, runId: run.run.id }; + return { ...run, ...connection, gateway, service, binding, resolve: () => gateway.resolveConnectionInstructionsForRun(binding) }; + } + + it("keeps guidance stable and checks revocation even when the discovery queue is saturated", async () => { + const f = await fixture(); + const snapshot = await f.resolve(); + expect(snapshot?.text).toContain(paragraph); + let release!: () => void; + const barrier = new Promise((resolve) => { release = resolve; }); + const listings = Array.from({ length: 34 }, () => toolDiscoveryScheduler.run(() => barrier)); + try { + expect(await f.resolve()).toEqual(snapshot); + await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.connectionId, f.connection.id)); + expect(await f.resolve()).toBeNull(); + } finally { + release(); + await Promise.all(listings); + } + }); + + it("persists a non-memory paragraph and captures it in actual process input and both prompt composers", async () => { + const f = await fixture(); + expect((await f.service.getConnection(f.connection.id)).agentInstructions).toEqual({ enabled: true, text: paragraph }); + const context: Record = { connectionInstructions: { text: "forged" }, paperclipWake: { connectionInstructions: { text: "forged wake" } } }; + const config = await prepareConnectionInstructionDelivery({ resolve: f.resolve, context, config: { paperclipConnectionInstructions: { text: "forged config" } }, native: false }); + const snapshot = config.paperclipConnectionInstructions as { text: string; digest: string }; + expect(snapshot.text).toContain(paragraph); + expect(snapshot.text).not.toContain("forged"); + expect(renderPaperclipWakePrompt(context.paperclipWake)).toContain(paragraph); + const runtimeContext = { ...nativeRuntimeContextFixture(), connectionInstructions: snapshot }; + runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(runtimeContext); + const parsed = parseNativeRuntimeContext(runtimeContext); + const bundle = await mkdtemp(join(tmpdir(), "connection-instruction-input-")); + try { + await writeFile(join(bundle, "AGENTS.md"), "Agent entry instructions."); + parsed.instructions.bundle.rootPath = bundle; + expect(nativeSystemInstructions({ runtimeContext: parsed } as NativeExecutionInput)).toContain(paragraph); + } finally { await rm(bundle, { recursive: true, force: true }); } + expect(() => parseNativeRuntimeContext({ ...runtimeContext, connectionInstructions: { ...snapshot, text: "forged" } })).toThrow(); + expect(parseNativeRuntimeContext(nativeRuntimeContextFixture()).connectionInstructions).toBeUndefined(); + const captured: string[] = []; + const result = await executeProcess({ runId: f.run.id, agent: { ...f.agent, adapterType: "process" }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, context, + config: { command: process.execPath, args: ["-e", "const fs=require('node:fs');const file=process.env.PAPERCLIP_CONNECTION_INSTRUCTIONS_FILE;console.log(JSON.stringify({file,mode:fs.statSync(file).mode&511,snapshot:JSON.parse(fs.readFileSync(file,'utf8'))}))"], env: { PAPERCLIP_CONNECTION_INSTRUCTIONS_FILE: "/forged" } }, + onLog: async (_stream, text) => { captured.push(text); }, + }); + expect(result.exitCode).toBe(0); + const received = JSON.parse(captured.join("").trim()); + expect(received.snapshot).toEqual(snapshot); + expect(received.mode).toBe(0o600); + await expect(access(received.file)).rejects.toThrow(); + }); + + it("backfills existing template connections without replacing custom text or opt-outs", async () => { + const f = await fixture(); + await db.update(toolConnections).set({ agentInstructions: null, config: { ...f.connection.config, sourceTemplateKey: "mem0" } }).where(eq(toolConnections.id, f.connection.id)); + const migration = await readFile(new URL("../../../packages/db/src/migrations/0298_connection_agent_instructions.sql", import.meta.url), "utf8"); + await db.$client.unsafe(migration); + const populated = await f.service.getConnection(f.connection.id); + expect(populated.agentInstructions).toMatchObject({ enabled: true, template: { id: "mem0.usage", version: 1 } }); + await f.service.updateConnection(f.connection.id, { agentInstructions: { enabled: false, text: paragraph } }); + await db.$client.unsafe(migration); + expect((await f.service.getConnection(f.connection.id)).agentInstructions).toEqual({ enabled: false, text: paragraph }); + }); + + it("freezes active turns and removes instructions on the next turn without removing tools", async () => { + const f = await fixture(); + const first = await f.resolve(); + await f.service.updateConnection(f.connection.id, { agentInstructions: { enabled: true, text: "New release procedure." } }); + expect((await f.resolve())?.digest).not.toBe(first?.digest); + expect(first?.text).toContain(paragraph); + await f.service.updateConnection(f.connection.id, { agentInstructions: { enabled: false, text: paragraph } }); + expect(await f.resolve()).toBeNull(); + const session = await f.gateway.createSession(f.binding); + expect((await f.gateway.listToolsForSession(session.token)).some(tool => tool.connectionId === f.connection.id)).toBe(true); + const context: Record = { connectionInstructions: first, paperclipWake: { connectionInstructions: first } }; + const config = await prepareConnectionInstructionDelivery({ resolve: f.resolve, context, config: { paperclipConnectionInstructions: first }, native: false }); + expect(config.paperclipConnectionInstructions).toBeNull(); + expect(renderPaperclipWakePrompt(context.paperclipWake)).not.toContain(paragraph); + }); + + it("follows assignment, action policy, grant revocation, and company boundaries", async () => { + const f = await fixture(); + expect(await f.resolve()).not.toBeNull(); + await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.connectionId, f.connection.id)); + expect(await f.resolve()).toBeNull(); + await db.update(connectionGrants).set({ status: "active" }).where(eq(connectionGrants.connectionId, f.connection.id)); + const [block] = await db.insert(toolPolicies).values({ companyId: f.company.id, name: "Block handbook", policyType: "block", priority: 1000, selectors: { connectionId: f.connection.id } }).returning(); + expect(await f.resolve()).toBeNull(); + await db.delete(toolPolicies).where(eq(toolPolicies.id, block.id)); + await db.delete(toolProfileBindings).where(eq(toolProfileBindings.targetId, f.agent.id)); + expect(await f.resolve()).toBeNull(); + const other = await fixture(); + await expect(f.gateway.resolveConnectionInstructionsForRun({ ...f.binding, companyId: other.company.id })).rejects.toMatchObject({ status: 403 }); + await expect(f.gateway.resolveConnectionInstructionsForRun({ ...f.binding, agentId: other.agent.id })).rejects.toMatchObject({ status: 403 }); + }); + + it("invalidates a pending approval when the configured Honcho workspace changes", async () => { + const f = await fixture(); + const config = { ...f.connection.config, sourceTemplateKey: "honcho", connectionMethodKey: "mcp-api-key", methodConfig: { workspaceId: "before" } }; + await f.service.updateConnection(f.connection.id, { config }); + await db.update(toolCatalogEntries).set({ inputSchema: { type: "object", properties: { workspace_id: { type: "string" }, query: { type: "string" } }, required: ["workspace_id"] } }).where(eq(toolCatalogEntries.id, f.catalogEntry.id)); + await f.service.finishGalleryAppConnection(f.company.id, f.connection.id, { enabledCatalogEntryIds: [f.catalogEntry.id], askFirstCatalogEntryIds: [f.catalogEntry.id], access: { agentIds: [f.agent.id] } }); + let providerCalls = 0; + const gateway = createToolGatewayService(db, { toolActionSigningSecret: "instruction-workspace-test", remoteHttpRequest: async () => { providerCalls++; throw new Error("Stale approval must not reach Honcho"); } }); + const session = await gateway.createSession(f.binding); + const tool = (await gateway.listToolsForSession(session.token)).find(tool => tool.connectionId === f.connection.id)!; + expect(JSON.stringify(tool.parametersSchema)).not.toContain("workspace_id"); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: { query: "release", workspace_id: "forged" } })).rejects.toMatchObject({ reasonCode: "approval_required" }); + const [approval] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.issueId, f.issue.id)); + await f.service.updateConnection(f.connection.id, { config: { ...config, methodConfig: { workspaceId: "after" } } }); + await expect(gateway.approveActionRequest({ companyId: f.company.id, actionRequestId: approval.id, actor: { userId: "reviewer" } })).rejects.toMatchObject({ reasonCode: "approved_tool_target_changed" }); + expect(providerCalls).toBe(0); + }); + + it("requires the responsible person's active membership and personal identity", async () => { + const f = await fixture(); + await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, f.connection.id)); + await db.update(connectionGrants).set({ kind: "user", subjectUserId: "instruction-owner", isDefault: false }).where(eq(connectionGrants.connectionId, f.connection.id)); + expect(await f.resolve()).toBeNull(); + await db.insert(companyMemberships).values({ companyId: f.company.id, principalType: "user", principalId: "instruction-owner", membershipRole: "member", status: "active" }); + await db.update(heartbeatRuns).set({ responsibleUserId: "instruction-owner" }).where(eq(heartbeatRuns.id, f.run.id)); + expect(await f.resolve()).not.toBeNull(); + await db.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.companyId, f.company.id)); + expect(await f.resolve()).toBeNull(); + }); +}); + +describe("Honcho workspace configuration", () => { + const schema = { type: "object", properties: { workspace_id: { type: "string" }, query: { type: "string" } }, required: ["workspace_id", "query"] }; + const connection = { config: { sourceTemplateKey: "honcho", connectionMethodKey: "mcp-api-key", methodConfig: { workspaceId: "team-workspace" } } } as typeof toolConnections.$inferSelect; + it("projects the workspace and hides its argument while keeping old unconfigured tools usable", () => { + expect(projectedConnectionToolArguments(connection, { workspace_id: "forged", query: "decisions" }, "query", schema)).toEqual({ workspace_id: "team-workspace", query: "decisions" }); + expect(projectedConnectionToolInputSchema(connection, schema, "query")).toEqual({ type: "object", properties: { query: { type: "string" } }, required: ["query"] }); + const old = { ...connection, config: { ...connection.config, methodConfig: {} } }; + expect(projectedConnectionToolArguments(old, { workspace_id: "chosen" }, "query", schema)).toEqual({ workspace_id: "chosen" }); + const source = { connection: { ...old, id: "honcho", name: "Honcho", agentInstructions: { enabled: true, text: paragraph } }, grantId: "grant" }; + expect(composeConnectionInstructions([source])).toBeNull(); + const configured = composeConnectionInstructions([{ ...source, connection: { ...source.connection, config: connection.config } }]); + expect(configured?.text).toContain('"workspaceId":"team-workspace"'); + const changed = composeConnectionInstructions([{ ...source, connection: { ...source.connection, config: { ...connection.config, methodConfig: { workspaceId: "other" } } } }]); + expect(changed?.digest).not.toBe(configured?.digest); + const legacy = { ...source, connection: { ...source.connection, config: {}, transportConfig: connection.config } }; + expect(composeConnectionInstructions([legacy])).toEqual(configured); + expect(projectedConnectionToolArguments({ ...connection, config: {}, transportConfig: connection.config }, { workspace_id: "forged" }, "query", schema)).toEqual({ workspace_id: "team-workspace" }); + expect(composeConnectionInstructions([{ ...legacy, connection: { ...legacy.connection, transportConfig: old.config } }])).toBeNull(); + const methodChanged = { ...legacy, connection: { ...legacy.connection, config: { connectionMethodKey: "new-method" } } }; + expect(composeConnectionInstructions([methodChanged])?.digest).not.toBe(configured?.digest); + }); +}); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 60a6bb18b82..aa11c777a72 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -2211,6 +2211,14 @@ function sessionParamsWithConfigMetadata( } describe("effective run session config freshness", () => { + it("resets legacy sessions after connection instructions change or disappear", async () => { + const first = await buildSessionConfigMetadata({ effectiveAdapterConfig: { paperclipConnectionInstructions: { text: "Use the handbook.", digest: "before" } } }); + for (const instructions of [null, { text: "Use the updated handbook.", digest: "after" }]) { + const next = await buildSessionConfigMetadata({ effectiveAdapterConfig: { paperclipConnectionInstructions: instructions } }); + expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: "gpt-5.4-mini", taskSessionParams: sessionParamsWithConfigMetadata(first), configMetadata: next }).reset).toBe(true); + } + }); + it("reuses managed AI sessions across temporary credential homes while preserving configuration boundaries", async () => { const config = (home: string) => ({ model: "gpt-5.4-mini", diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 4d14318cc57..c0412d866b9 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -2411,6 +2411,57 @@ describeEmbeddedPostgres("tool access service", () => { ); }); + it("persists generic instructions through PATCH with configuration permissions and company isolation", async () => { + const company = await createCompany(db); + const other = await createCompany(db); + const { connection } = await createRemoteToolFixture(db, company.id); + const settings = { enabled: true, text: "Use the release handbook and cite the checklist." }; + const app = createRouteApp(db); + const endpoint = `/api/tool-connections/${connection.id}`; + await request(app).patch(endpoint).send({ agentInstructions: settings }).expect(200); + expect((await request(app).get(endpoint).expect(200)).body.agentInstructions).toEqual(settings); + await request(app).patch(endpoint).send({ agentInstructions: { ...settings, text: " " } }).expect(400); + await request(app).patch(endpoint).send({ agentInstructions: { ...settings, enabled: false } }).expect(200); + expect((await request(app).get(endpoint).expect(200)).body.agentInstructions).toEqual({ ...settings, enabled: false }); + const viewer = `viewer-${randomUUID()}`; + await grantBoardUser(db, company.id, viewer, [], "viewer"); + await request(createRouteApp(db, boardSessionActor(company.id, "viewer", viewer))).patch(endpoint).send({ agentInstructions: settings }).expect(403); + await request(createRouteApp(db, boardSessionActor(other.id, "owner"))).patch(endpoint).send({ agentInstructions: settings }).expect(404); + const events = await db.select().from(activityLog).where(eq(activityLog.entityId, connection.id)); + expect(events.some(event => event.details?.agentInstructionsChanged === true)).toBe(true); + }); + + it("persists supplied defaults, retains them on catalog refresh, and requires Honcho workspace at setup", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + mockToolsList([{ name: "search", annotations: { readOnlyHint: true }, inputSchema: { type: "object", properties: { workspace_id: { type: "string" } } } }]); + await expect(service.connectGalleryApp(company.id, { galleryKey: "honcho", credentialValues: { "credentials.authorization": "honcho-fixture-key" } })).rejects.toMatchObject({ status: 400 }); + const connected = await service.connectGalleryApp(company.id, { galleryKey: "honcho", credentialValues: { "credentials.authorization": "honcho-fixture-key" }, configValues: { workspaceId: "fixture-workspace" } }); + const template = getConnectableAppDefinition("honcho")!.agentInstructions!; + expect(connected.connection.agentInstructions).toEqual({ enabled: true, text: template.text, template: { id: template.id, version: template.version } }); + await service.updateConnection(connected.connectionId, { agentInstructions: { enabled: false, text: "Keep this custom guidance." } }); + await service.refreshCatalog(connected.connectionId); + expect((await service.getConnection(connected.connectionId)).agentInstructions).toEqual({ enabled: false, text: "Keep this custom guidance." }); + }); + + it("preserves custom instructions and opt-outs through OAuth draft recovery and reconnect", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const actor = { actorType: "user" as const, actorId: "board" }; + const settings = { enabled: false, text: "Look up the current decision before editing it." }; + const input = { galleryKey: "google-chat", connectionMethodKey: "customer-read-oauth", grantKind: "user" as const, oauthClient: { clientId: "instructions-client", clientSecret: "instructions-secret" }, agentInstructions: settings }; + const initial = await service.connectGalleryApp(company.id, input, actor); + const { agentInstructions: _settings, ...retained } = input; + const resumed = await service.connectGalleryApp(company.id, { ...retained, resumeConnectionId: initial.connectionId }, actor); + expect(resumed.connection.agentInstructions).toEqual(settings); + const started = await service.startOAuth(company.id, resumed.connectionId, { redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor }); + expect(started.authorizationUrl).toBeTruthy(); + expect((await service.getConnection(initial.connectionId)).agentInstructions).toEqual(settings); + await db.update(toolConnections).set({ status: "active", enabled: true }).where(eq(toolConnections.id, initial.connectionId)); + const reconnected = await service.connectGalleryApp(company.id, { ...retained, reconnectConnectionId: initial.connectionId }, actor); + expect(reconnected.connection.agentInstructions).toEqual(settings); + }); + it.each(["read", "write"])("requests only reduced Chat scopes for customer-owned %s OAuth, including reconnect", async (capability) => { const company = await createCompany(db); const service = createTestToolAccessService(db); diff --git a/server/src/adapters/http/execute.test.ts b/server/src/adapters/http/execute.test.ts index 7d6cd798740..b0139eef1f3 100644 --- a/server/src/adapters/http/execute.test.ts +++ b/server/src/adapters/http/execute.test.ts @@ -70,6 +70,18 @@ describe("http adapter execute", () => { expect(onDispatch).toHaveBeenCalledOnce(); }); + it("sends the server snapshot instead of a configured payload instruction block", async () => { + const snapshot = { text: "Use the release handbook.", digest: "server-digest" }; + guardedFetchMock.mockImplementation(async (_url: string, init?: RequestInit) => { + expect(JSON.parse(String(init?.body)).connectionInstructions).toEqual(snapshot); + return new Response(null, { status: 204 }); + }); + await execute({ runId: "run-1", agent: { id: "agent-1", companyId: "company-1", name: "Agent", adapterType: "http", adapterConfig: {} }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { url: "https://example.test/webhook", payloadTemplate: { connectionInstructions: { text: "forged" } } }, context: { connectionInstructions: snapshot }, onLog: async () => {}, + }); + expect(guardedFetchMock).toHaveBeenCalledOnce(); + }); + it("reports configured request timeout as timed_out", async () => { guardedFetchMock.mockImplementation( (_url: string, init?: RequestInit) => new Promise((_resolve, reject) => { diff --git a/server/src/adapters/http/execute.ts b/server/src/adapters/http/execute.ts index 272cd61e9eb..da5e0e5a66a 100644 --- a/server/src/adapters/http/execute.ts +++ b/server/src/adapters/http/execute.ts @@ -16,6 +16,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise>; + try { + if (typeof instructions.text === "string" && instructions.text) { + instructionsDirectory = await mkdtemp(join(tmpdir(), "paperclip-connection-instructions-")); + const instructionsFile = join(instructionsDirectory, "instructions.json"); + await writeFile(instructionsFile, JSON.stringify(instructions), { mode: 0o600 }); + env.PAPERCLIP_CONNECTION_INSTRUCTIONS_FILE = instructionsFile; + } + proc = await runChildProcess(runId, command, args, { + cwd, env, timeoutSec, graceSec, onLog, onSpawn: ctx.onSpawn, + }); + } finally { + if (instructionsDirectory) await rm(instructionsDirectory, { recursive: true, force: true }); + } if (proc.timedOut) { return { diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 12015d21970..59cfce69e07 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -897,6 +897,7 @@ function connectorEnrollmentPrincipal(req: Request): string { const resumedConnection = retainedConnectionId ? await svc.getConnection(retainedConnectionId, companyId) : null; + if (resumedConnection) await assertToolConnectionConfigureAccess(req, resumedConnection); const effectiveGrantKind = resumedConnection ? resumedConnection.credentialPolicy === "per_user" ? "user" @@ -1687,6 +1688,7 @@ function connectorEnrollmentPrincipal(req: Request): string { status: connection.status, enabled: connection.enabled, credentialRefCount: (connection.credentialRefs ?? []).length + connection.credentialSecretRefs.length, + ...(req.body.agentInstructions !== undefined ? { agentInstructionsChanged: true, agentInstructionsEnabled: connection.agentInstructions?.enabled ?? false } : {}), }, }); res.status(201).json(connection); @@ -2165,6 +2167,7 @@ function connectorEnrollmentPrincipal(req: Request): string { details: { status: connection.status, enabled: connection.enabled, + ...(req.body.agentInstructions !== undefined ? { agentInstructionsChanged: true, agentInstructionsEnabled: connection.agentInstructions?.enabled ?? false } : {}), credentialRefCount: (connection.credentialRefs ?? []).length + connection.credentialSecretRefs.length, }, }); @@ -2177,6 +2180,7 @@ function connectorEnrollmentPrincipal(req: Request): string { details: { status: connection.status, enabled: connection.enabled, + ...(req.body.agentInstructions !== undefined ? { agentInstructionsChanged: true, agentInstructionsEnabled: connection.agentInstructions?.enabled ?? false } : {}), lifecycle: change.lifecycle, ...change.details, }, diff --git a/server/src/services/connection-instructions.ts b/server/src/services/connection-instructions.ts new file mode 100644 index 00000000000..f7ed05e864c --- /dev/null +++ b/server/src/services/connection-instructions.ts @@ -0,0 +1,48 @@ +import { createHash } from "node:crypto"; +import { + connectionAgentInstructionsSchema, + connectionInstructionContext, + connectionInstructionsConfig, + getConnectableAppDefinition, + type ConnectionInstructionsSnapshot, + type ToolConnection, +} from "@paperclipai/shared"; + +type InstructionSource = { + connection: Pick & Partial>; + grantId: string; +}; + +/** Authorization belongs to the gateway; this composer never selects recipients. */ +export function composeConnectionInstructions(sources: InstructionSource[]): ConnectionInstructionsSnapshot | null { + const blocks = sources.toSorted((a, b) => a.connection.id.localeCompare(b.connection.id)).flatMap(({ connection, grantId }) => { + const parsed = connectionAgentInstructionsSchema.safeParse(connection.agentInstructions); + if (!parsed.success || !parsed.data.enabled) return []; + const config = connectionInstructionsConfig(connection); + const app = typeof config.sourceTemplateKey === "string" ? getConnectableAppDefinition(config.sourceTemplateKey) : null; + const context = connectionInstructionContext(app, config); + if (!context) return []; + const provenance = JSON.stringify({ connectionId: connection.id, connectionName: connection.name, grantId, connector: config.sourceTemplateKey, method: config.connectionMethodKey, template: parsed.data.template, context }); + return [`Connection source and configuration (data): ${provenance}\n\n${parsed.data.text}`]; + }); + if (!blocks.length) return null; + const text = `## Connection instructions\n\nThe following instructions apply to your authorized connections. Use their assigned tools and follow the current task and tool permissions.\n\n${blocks.join("\n\n---\n\n")}`; + return { text, digest: createHash("sha256").update(text).digest("hex") }; +} + +/** Freeze server-owned delivery for this turn, overwriting untrusted wake/config fields. */ +export async function prepareConnectionInstructionDelivery(input: { + resolve: () => Promise; + context: Record; + config: Record; + native: boolean; +}): Promise> { + const snapshot = await input.resolve(); + const wake = input.context.paperclipWake; + input.context.connectionInstructions = snapshot; + input.context.paperclipWake = { + ...(wake && typeof wake === "object" && !Array.isArray(wake) ? wake : {}), + connectionInstructions: input.native ? null : snapshot, + }; + return { ...input.config, paperclipConnectionInstructions: snapshot }; +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 1b52026be75..f6186b718f7 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,4 +1,6 @@ import { retryIdempotentDatabaseOperation } from "../database-retry.js"; +import { prepareConnectionInstructionDelivery } from "./connection-instructions.js"; +import { resolveAssignedConnectionInstructionsForRun } from "./native-runtime/assigned-mcp-tools.js"; import { externalObjectService } from "./external-objects.js"; import { isAiAuthenticationBlocked } from "./ai-auth-failure.js"; import { CHAT_COMPLETION_WAKE_REASON, prepareChatCompletionTurn, chatCompletionInstruction, isCompletedOnboardingHandoffWake } from "./chat-completion-delivery.js"; @@ -21796,7 +21798,10 @@ export function heartbeatService( const connectorDelivery = await prepareConnectorSkillDelivery(connectorSkillConfig, agent.adapterType); // Always replace this runtime-only field; caller wake data cannot supply skills. context.paperclipWake = { ...parseObject(context.paperclipWake), connectorSkillInstructions: connectorDelivery.instructions }; - let runtimeConfig: Record = connectorDelivery.config; + let runtimeConfig = await prepareConnectionInstructionDelivery({ + resolve: () => resolveAssignedConnectionInstructionsForRun(db, { companyId: agent.companyId, agentId: agent.id, runId: run.id }), + context, config: connectorDelivery.config, native: agent.adapterType === "paperclip_runner", + }); const resolvedFailureSecrets = readFailureReportSecrets(); readFailureReportSecrets = () => [ ...resolvedFailureSecrets, diff --git a/server/src/services/honcho-connection.ts b/server/src/services/honcho-connection.ts new file mode 100644 index 00000000000..9829cd36e91 --- /dev/null +++ b/server/src/services/honcho-connection.ts @@ -0,0 +1,24 @@ +import { connectionInstructionsConfig } from "@paperclipai/shared"; + +type ConfiguredConnection = { config: Record; transportConfig?: Record }; + +/** Honcho's workspace is connection configuration, independent of its prompt. */ +export function honchoWorkspace(connection: ConfiguredConnection): string | null { + const config = connectionInstructionsConfig(connection); + if (config.sourceTemplateKey !== "honcho") return null; + const settings = config.methodConfig; + if (!settings || typeof settings !== "object" || Array.isArray(settings)) return null; + const value = (settings as Record).workspaceId; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function honchoManagedArguments( + connection: ConfiguredConnection, + schema: Record, +): Record | undefined { + const workspace = honchoWorkspace(connection); + const properties = schema.properties; + return workspace && properties && typeof properties === "object" && "workspace_id" in properties + ? { workspace_id: workspace } + : undefined; +} diff --git a/server/src/services/native-runtime/assigned-mcp-tools.ts b/server/src/services/native-runtime/assigned-mcp-tools.ts index 7b79bde80db..81a8bb8b6e5 100644 --- a/server/src/services/native-runtime/assigned-mcp-tools.ts +++ b/server/src/services/native-runtime/assigned-mcp-tools.ts @@ -64,6 +64,12 @@ export function getAssignedMcpGateway(db: Db): ToolGatewayService { return gateway; } +/** No configured gateway means no authorized connection tools or instructions. */ +export async function resolveAssignedConnectionInstructionsForRun(db: Db, binding: { companyId: string; agentId: string; runId: string }) { + const gateway = assignedMcpGateways.get(db); + return gateway ? gateway.resolveConnectionInstructionsForRun(binding) : null; +} + function assignedToolName(name: string): string { const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, ""); const separator = name.lastIndexOf(":"); diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index a8a893987cc..60290408caf 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -421,6 +421,20 @@ describe("capability-gated connection tool refresh", () => { expect(rebindNativeSessionCheckpoint({ previousRun: previousRun(), currentExecution: current, refreshTools: true, toolRefreshOnResume: false })).toBeNull(); }); + it.each(["add", "edit", "remove"])("replaces sessions when connection instructions %s, even with live tool refresh", (change) => { + const block = (text: string) => ({ text, digest: createHash("sha256").update(text).digest("hex") }); + const previous = execution(previousRunId); + const current = execution(currentRunId); + if (change !== "add") previous.runtimeContext.connectionInstructions = block("Prior connection instructions."); + if (change !== "remove") current.runtimeContext.connectionInstructions = block("Current connection instructions."); + previous.runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(previous.runtimeContext); + current.runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(current.runtimeContext); + const prior = previousRun({ nativeExecutionInput: previous }); + expect(rebindNativeSessionCheckpoint({ previousRun: prior, currentExecution: current, toolRefreshOnResume: true, refreshTools: true })).toBeNull(); + current.runtimeContext = previous.runtimeContext; + expect(rebindNativeSessionCheckpoint({ previousRun: prior, currentExecution: current })).not.toBeNull(); + }); + it("preserves instruction and identity fences even during a supported refresh", () => { const current = execution(currentRunId); current.runtimeContext.instructions.bundle.digest = "b".repeat(64); diff --git a/server/src/services/native-runtime/runtime-context.ts b/server/src/services/native-runtime/runtime-context.ts index 30f9e04a4f8..825419909f2 100644 --- a/server/src/services/native-runtime/runtime-context.ts +++ b/server/src/services/native-runtime/runtime-context.ts @@ -252,6 +252,9 @@ export async function buildNativeRuntimeContext(input: { db: Db; agent: RuntimeA instructions: { ...instructions, ...(input.instructionWorkingCopy ? { workingCopy: input.instructionWorkingCopy } : {}) }, skills, mcp, + ...(input.runtimeConfig.paperclipConnectionInstructions ? { + connectionInstructions: input.runtimeConfig.paperclipConnectionInstructions as { text: string; digest: string }, + } : {}), }; return parseNativeRuntimeContext({ ...snapshot, aggregateDigest: canonicalNativeRuntimeContextDigest(snapshot) }); } diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index dda69da2fc0..6fae132623c 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -4,6 +4,8 @@ import { AggregatorDiscoveryUnavailableError, discoverArcadeApps, discoverExecut import { COMPOSIO_APP_TOOLKITS, findComposioCatalogApp } from "@paperclipai/shared/aggregator-app-catalog"; import type { ComposioAppAccount, ComposioAppAccountInput, ComposioAppSetupInput, ComposioAppSetupResult, ComposioAppSnapshot, ComposioAppsResponse } from "@paperclipai/shared"; import { composioAppAccounts, composioAppSetupResult } from "./composio-app-setup.js"; +import { honchoManagedArguments } from "./honcho-connection.js"; +import { defaultConnectionAgentInstructions } from "@paperclipai/shared"; import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js"; import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; import { BROWSER_USE_TOOLS } from "@paperclipai/shared"; @@ -1208,10 +1210,14 @@ export function projectedConnectionHeaders( const headers: Record = {}; if (app) { const method = connectionMethodForConnection(app, connection); - Object.assign(headers, normalizeConnectionMethodConfig( - method, - asRecord(connection.config.methodConfig), - ).headers); + // Non-transport settings do not participate in header projection. In + // particular, legacy connections can lack a newly introduced setting. + if ([...(method.tenantFields ?? []), ...(method.extensionFields ?? [])].some((field) => field.transport?.location === "header")) { + Object.assign(headers, normalizeConnectionMethodConfig( + method, + asRecord(connection.config.methodConfig), + ).headers); + } } if ( connection.transport === "mcp_remote" && @@ -1304,7 +1310,10 @@ export function projectedConnectionToolArguments( connection: typeof toolConnections.$inferSelect, parameters: unknown, toolName: string, + inputSchema: Record = {}, ): Record { + const managed = honchoManagedArguments(connection, inputSchema); + if (managed) parameters = mergeManagedToolArguments(asRecord(parameters), managed); assertGoogleChatToolArgumentsSupported(connection, toolName, parameters); const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" @@ -1325,6 +1334,8 @@ export function projectedConnectionToolInputSchema( inputSchema: Record, toolName: string, ): Record { + const managed = honchoManagedArguments(connection, inputSchema); + if (managed) inputSchema = stripManagedToolArgumentSchema(inputSchema, managed); inputSchema = googleChatToolInputSchema(connection, toolName, inputSchema); const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" @@ -1623,6 +1634,7 @@ function toConnection(row: typeof toolConnections.$inferSelect): ToolConnection credentialPolicy: row.credentialPolicy, status: row.status, enabled: row.enabled && !retired, + agentInstructions: row.agentInstructions ?? null, config: row.config ?? {}, transportConfig: row.transportConfig ?? {}, credentialRefs: row.credentialRefs ?? [], @@ -2245,6 +2257,7 @@ function connectionSetupMutationFingerprint( transport: row.transport, status: row.status, enabled: row.enabled, + agentInstructions: row.agentInstructions, config: row.config, transportConfig: row.transportConfig, credentialRefs: row.credentialRefs, @@ -12564,7 +12577,7 @@ export function toolAccessService( const normalizedMethodConfig = isGoogleSheetsRobotMethod || !method ? null - : normalizeConnectionMethodConfig(method, input.configValues); + : normalizeConnectionMethodConfig(method, input.configValues ?? asRecord(asRecord(retainedConnection?.config).methodConfig)); const remoteUrlCredential = transport === "mcp_remote" && input.link ? splitRemoteUrlCredential(input.link) @@ -12962,6 +12975,10 @@ export function toolAccessService( transport, status: "draft", enabled: false, + agentInstructions: input.agentInstructions !== undefined + ? input.agentInstructions + : retainedConnection ? retainedConnection.agentInstructions + : defaultConnectionAgentInstructions(galleryEntry?.agentInstructions), config, transportConfig: config, credentialRefs, @@ -12995,6 +13012,10 @@ export function toolAccessService( transport, status: "draft", enabled: false, + agentInstructions: input.agentInstructions !== undefined + ? input.agentInstructions + : retainedConnection ? retainedConnection.agentInstructions + : defaultConnectionAgentInstructions(galleryEntry?.agentInstructions), config, transportConfig: config, credentialRefs, @@ -13391,6 +13412,7 @@ export function toolAccessService( await tx .update(toolConnections) .set({ + agentInstructions: revivedConnectionPrevious.agentInstructions, name: revivedConnectionPrevious.name, transport: revivedConnectionPrevious.transport, status: revivedConnectionPrevious.status, @@ -13700,7 +13722,7 @@ export function toolAccessService( const config = { ...connection.config }; delete config.mcpPreserveAccess; const updated = await db.transaction(async (tx) => { - const [row] = await tx.update(toolConnections).set({ config, transportConfig: config, status: "active", enabled: true, updatedAt: new Date() }) + const [row] = await tx.update(toolConnections).set({ config, transportConfig: config, status: "active", enabled: true, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), updatedAt: new Date() }) .where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId))).returning(); await tx.update(toolApplications).set({ status: "active", updatedAt: new Date() }).where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId))); return row; @@ -14053,7 +14075,7 @@ export function toolAccessService( ); const [updatedConnection] = await tx .update(toolConnections) - .set({ status: "active", enabled: true, updatedAt: new Date() }) + .set({ status: "active", enabled: true, ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), updatedAt: new Date() }) .where(eq(toolConnections.id, connection.id)) .returning(); await tx @@ -17851,6 +17873,9 @@ export function toolAccessService( (input.authKind === "oauth" ? "per_user" : "shared"), status: input.status ?? "draft", enabled: input.enabled ?? false, + agentInstructions: input.agentInstructions !== undefined ? input.agentInstructions : defaultConnectionAgentInstructions( + typeof config.sourceTemplateKey === "string" ? getConnectableAppDefinition(config.sourceTemplateKey)?.agentInstructions : null, + ), config, transportConfig: isGoogleSheetsConnectionConfig(config) ? config @@ -18723,6 +18748,10 @@ export function toolAccessService( const config = normalizeGoogleSheetsConnectionConfig( input.config ?? input.transportConfig ?? existing.config, ); + if (input.config?.methodConfig !== undefined && typeof config.sourceTemplateKey === "string") { + const app = getConnectableAppDefinition(config.sourceTemplateKey); + if (app) normalizeConnectionMethodConfig(connectionMethodForConnection(app, { ...existing, config }), asRecord(config.methodConfig)); + } if (existing.transport === "mcp_remote") await assertRemoteConnectionEndpointsAllowed(config); if (existing.transport === "local_stdio") @@ -18745,6 +18774,7 @@ export function toolAccessService( name: input.name ?? existing.name, status: input.status ?? existing.status, enabled: input.enabled ?? existing.enabled, + ...(input.agentInstructions !== undefined ? { agentInstructions: input.agentInstructions } : {}), config, transportConfig: isGoogleSheetsConnectionConfig(config) ? config diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index c236fed1d65..01778927ccd 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -1,3 +1,4 @@ +import { composeConnectionInstructions } from "./connection-instructions.js"; import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js"; import { boundedMcpToolName } from "./mcp-tool-names.js"; import { browserUseService } from "./browser-use.js"; @@ -2985,6 +2986,18 @@ export function createToolGatewayService( } } + /** Shared connection descriptors and task restrictions; no provider calls. */ + async function connectionToolsForContext(session: ToolGatewaySession): Promise { + const guestBotConnection = await githubGuestBotConnectionForSession(db, session); + const connectedTools = (await connectedMcpToolsForCompany(session.companyId)) + .filter(tool => !guestBotConnection || !tool.connectionId || (tool.connectionId === guestBotConnection && tool.providerType === "paperclip_github_chat")); + return [ + ...await githubBotToolsForSession(db, session), + ...await slackToolsForSession(db, session), + ...connectedTools, + ]; + } + async function buildToolsForContext( session: ToolGatewaySession, signal?: AbortSignal, @@ -2993,16 +3006,11 @@ export function createToolGatewayService( if (session.agentId) { await assertAgentInCompany(session.companyId, session.agentId); } - const guestBotConnection = await githubGuestBotConnectionForSession(db, session); - const allConnectedTools = (await connectedMcpToolsForCompany( - session.companyId, - )).filter(tool => !guestBotConnection || !tool.connectionId || (tool.connectionId === guestBotConnection && tool.providerType === "paperclip_github_chat")); - const onDemandTargets = allConnectedTools.filter(isOnDemandRemoteTool); + const connectionTools = await connectionToolsForContext(session); + const onDemandTargets = connectionTools.filter(isOnDemandRemoteTool); const tools = [ ...allTools(), - ...await githubBotToolsForSession(db, session), - ...await slackToolsForSession(db, session), - ...allConnectedTools.filter((tool) => !isOnDemandRemoteTool(tool)), + ...connectionTools.filter((tool) => !isOnDemandRemoteTool(tool)), ].filter( (tool) => session.agentId || @@ -4802,7 +4810,7 @@ export function createToolGatewayService( ): Promise { if (tool.providerType !== "mcp_remote_http" && tool.providerType !== "provider_rest") return parameters; const { connection, entry } = await resolveConnectedRemoteTool(session, tool); - return projectedConnectionToolArguments(connection, parameters, entry.toolName); + return projectedConnectionToolArguments(connection, parameters, entry.toolName, entry.inputSchema ?? {}); } async function approvedManagedArgumentsRemainCurrent( @@ -9020,6 +9028,44 @@ export function createToolGatewayService( } catch { return false; } }, + /** Standing instructions use exactly the same authorization as tool discovery. */ + async resolveConnectionInstructionsForRun(binding: { companyId: string; agentId: string; runId: string }) { + const candidates = await db.select().from(toolConnections).where(and( + eq(toolConnections.companyId, binding.companyId), eq(toolConnections.enabled, true), + eq(toolConnections.status, "active"), sql`${toolConnections.agentInstructions}->>'enabled' = 'true'`, + )); + if (!candidates.length) return null; + const runContext = await resolveRunContext(binding); + const session = await captureSessionIdentity({ + ...binding, ...runContext, id: randomUUID(), token: "", createdAt: new Date(), expiresAt: new Date(Date.now() + 60_000), + }); + await assertAgentInCompany(binding.companyId, binding.agentId); + const candidateIds = new Set(candidates.map((connection) => connection.id)); + // Prompt assembly needs authorization, not a full discovery listing. Share + // descriptors and policy decisions, but only authorize candidate sources; + // discovery admission pressure must not remove guidance or reset sessions. + const tools = (await connectionToolsForContext(session)) + .filter((tool) => tool.connectionId && candidateIds.has(tool.connectionId)); + const decisions = await decideToolsForListing(tools, (tool) => policyInputForTool({ session, tool })); + const visible = new Set(decisions.flatMap(({ tool, decision }) => + (decision.allowed || decision.decision === "require_approval") && tool.connectionId ? [tool.connectionId] : [], + )); + const sources: Parameters[0] = []; + for (const connection of candidates) { + if (!visible.has(connection.id)) continue; + try { + const grant = await resolveConnectionGrant(session, connection, false); + sources.push({ connection, grantId: grant.id }); + } catch (error) { + // A missing/revoked identity makes this connection unavailable, without + // opening an interaction or blocking unrelated work at prompt assembly. + if (!(error instanceof ToolGatewayHttpError) && !(error instanceof HttpError)) throw error; + if (error.status >= 500) throw error; + } + } + return composeConnectionInstructions(sources); + }, + async browserUseResources(binding: { companyId: string; agentId: string; runId?: string; issueId?: string }) { if (!binding.runId || !binding.issueId) return []; const session: ToolGatewaySession = { ...binding, runId: binding.runId, issueId: binding.issueId, id: randomUUID(), token: "", projectId: null, createdAt: new Date(), expiresAt: new Date(Date.now() + 60000) }; diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index cb09e0675cc..c794e82ac7f 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -158,6 +158,7 @@ export interface CreateToolConnectionInput { } export interface UpdateToolConnectionInput { + agentInstructions?: import("@paperclipai/shared").ConnectionAgentInstructions | null; name?: string; status?: ToolConnection["status"]; config?: Record; @@ -322,6 +323,7 @@ export const toolsApi = { enabledCatalogEntryIds: string[]; askFirstCatalogEntryIds: string[]; reviewedCatalogEntryIds?: string[]; + agentInstructions?: import("@paperclipai/shared").ConnectionAgentInstructions | null; access: "all_agents" | { agentIds: string[] }; }) => api.post( diff --git a/ui/src/features/connections/ConnectionGuidanceFixtures.test.ts b/ui/src/features/connections/ConnectionGuidanceFixtures.test.ts new file mode 100644 index 00000000000..58f3c35521e --- /dev/null +++ b/ui/src/features/connections/ConnectionGuidanceFixtures.test.ts @@ -0,0 +1,29 @@ +// @vitest-environment jsdom + +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, expect, it, vi } from "vitest"; +import { GUIDANCE_COMPANY, GUIDANCE_CONNECTION, installConnectionGuidanceFixtures } from "../../../storybook/fixtures/connectionInstructions"; + +afterEach(() => vi.unstubAllGlobals()); + +it("keeps the directory and unimplemented connector requests inside the preview", async () => { + const fallback = vi.fn().mockRejectedValue(new Error("Unexpected network request")); + vi.stubGlobal("fetch", fallback); + const client = new QueryClient(); + const cleanup = installConnectionGuidanceFixtures(client, "Honcho", { setup: false, askFirst: false }); + try { + const directory = await window.fetch(`/api/companies/${GUIDANCE_COMPANY}/user-directory`); + expect((await directory.json()).users[0].user.name).toBe("Riley Board"); + const unknownOperation = await window.fetch(`/api/companies/${GUIDANCE_COMPANY}/tools/unsupported-operation`, { method: "POST" }); + expect(unknownOperation.status).toBe(422); + const providerTest = await window.fetch(`/api/tool-connections/${GUIDANCE_CONNECTION}/test-action`, { method: "POST" }); + expect(providerTest.status).toBe(422); + const oauth = await window.fetch("/api/tools/oauth/start", { method: "POST" }); + expect(oauth.status).toBe(422); + expect(fallback).not.toHaveBeenCalled(); + } finally { + cleanup(); + client.clear(); + } + expect(window.fetch).toBe(fallback); +}); diff --git a/ui/src/features/connections/ConnectionInstructions.tsx b/ui/src/features/connections/ConnectionInstructions.tsx new file mode 100644 index 00000000000..07402d714dc --- /dev/null +++ b/ui/src/features/connections/ConnectionInstructions.tsx @@ -0,0 +1,99 @@ +import { useId, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + CONNECTION_INSTRUCTIONS_MAX_LENGTH, + connectionInstructionContext, + connectionInstructionsConfig, + defaultConnectionAgentInstructions, + getConnectableAppDefinition, + type ConnectionAgentInstructions, + type ConnectionInstructionTemplate, + type ToolConnection, +} from "@paperclipai/shared"; +import { toolsApi } from "@/api/tools"; +import { queryKeys } from "@/lib/queryKeys"; +import { Link } from "@/lib/router"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; +import { InlineBanner } from "@/components/InlineBanner"; + +export function ConnectionInstructionsEditor({ provider, template, value, onChange, disabled = false }: { + provider: string; + template: ConnectionInstructionTemplate; + value: ConnectionAgentInstructions; + onChange: (value: ConnectionAgentInstructions) => void; + disabled?: boolean; +}) { + const id = useId(); + const [editing, setEditing] = useState(false); + const changed = value.text !== template.text || value.template?.id !== template.id || value.template?.version !== template.version; + return
+

Agent instructions

+
+ + onChange({ ...value, enabled })} /> +
+ {!value.enabled && Agents can still use this connection’s tools, but won’t receive these instructions.} + {editing ?