diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index e7ceeb1a537..461d88b599d 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -39,6 +39,7 @@ on: - bot - bot-discord - bot-slack + - bot-teams - bot-telegram - bot-whatsapp suffix: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 179a86c9e3c..743b5e43e7f 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -42,6 +42,7 @@ on: - bot - bot-discord - bot-slack + - bot-teams - bot-telegram - bot-whatsapp mode: diff --git a/.github/workflows/showcase_promote.yml b/.github/workflows/showcase_promote.yml index 7a271051182..6cc98e3899f 100644 --- a/.github/workflows/showcase_promote.yml +++ b/.github/workflows/showcase_promote.yml @@ -72,6 +72,7 @@ on: - starter-pydantic-ai - starter-strands-python - strands + - strands-typescript - webhooks # <<< END GENERATED service options digest: diff --git a/.github/workflows/stable-release.yml b/.github/workflows/stable-release.yml index 5d1d5f5ea38..dfa543fb2a1 100644 --- a/.github/workflows/stable-release.yml +++ b/.github/workflows/stable-release.yml @@ -13,6 +13,7 @@ on: - bot - bot-discord - bot-slack + - bot-teams - bot-telegram - bot-whatsapp bump: diff --git a/.gitignore b/.gitignore index da4568cffb5..21ef4016a9b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,10 @@ __pycache__/ .venv .vercel +# Host-specific deploy config — lives in your platform's dashboard, not the repo. +# (e.g. a local `railway up` upload filter generated when deploying from a worktree) +.railwayignore + docs/next-env.d.ts showcase/shell-docs/next-env.d.ts diff --git a/examples/showcases/banking/docs/DESIGN.md b/examples/showcases/banking/docs/DESIGN.md index 39c18779e19..6de4f09b560 100644 --- a/examples/showcases/banking/docs/DESIGN.md +++ b/examples/showcases/banking/docs/DESIGN.md @@ -36,33 +36,33 @@ colors (`brand`, `brand-violet`, `brand-indigo`, `brand-soft`, `surface`, `surface-muted`, `canvas`, `ink`, `ink-muted`, `positive`, `positive-soft`, `negative`, `negative-soft`, `hairline`). -| Token | Light | Dark | Role | -| ------------------ | -------------------- | --------------------- | -------------------------------------- | -| `--canvas` | `255 60% 97%` | `252 30% 7%` | App background (lavender / deep indigo)| -| `--surface` | `0 0% 100%` | `252 24% 11%` | Cards, sidebar, menus | -| `--surface-muted` | `252 40% 98%` | `252 22% 14%` | Row hover, inset blocks | -| `--ink` | `252 30% 14%` | `250 30% 96%` | Primary text / headings | -| `--ink-muted` | `250 12% 46%` | `250 12% 66%` | Secondary / label text | -| `--hairline` | `252 30% 92%` | `252 20% 22%` | Borders / dividers | -| `--brand`/`-violet`| `252 83% 67%` | (shared) | Primary violet | -| `--brand-indigo` | `248 84% 60%` | (shared) | Gradient end / heading color | -| `--brand-soft` | `252 90% 96%` | `252 50% 18%` | Lilac chips, hover wash, avatar bg | -| `--positive` | `152 62% 40%` | `152 56% 50%` | Income / available credit | -| `--positive-soft` | `152 70% 95%` | `152 40% 16%` | Income chip background | -| `--negative` | `349 78% 56%` | `349 80% 64%` | Expense / destructive | -| `--negative-soft` | `349 90% 96%` | `349 40% 18%` | Expense chip background | +| Token | Light | Dark | Role | +| ------------------- | ------------- | ------------- | --------------------------------------- | +| `--canvas` | `255 60% 97%` | `252 30% 7%` | App background (lavender / deep indigo) | +| `--surface` | `0 0% 100%` | `252 24% 11%` | Cards, sidebar, menus | +| `--surface-muted` | `252 40% 98%` | `252 22% 14%` | Row hover, inset blocks | +| `--ink` | `252 30% 14%` | `250 30% 96%` | Primary text / headings | +| `--ink-muted` | `250 12% 46%` | `250 12% 66%` | Secondary / label text | +| `--hairline` | `252 30% 92%` | `252 20% 22%` | Borders / dividers | +| `--brand`/`-violet` | `252 83% 67%` | (shared) | Primary violet | +| `--brand-indigo` | `248 84% 60%` | (shared) | Gradient end / heading color | +| `--brand-soft` | `252 90% 96%` | `252 50% 18%` | Lilac chips, hover wash, avatar bg | +| `--positive` | `152 62% 40%` | `152 56% 50%` | Income / available credit | +| `--positive-soft` | `152 70% 95%` | `152 40% 16%` | Income chip background | +| `--negative` | `349 78% 56%` | `349 80% 64%` | Expense / destructive | +| `--negative-soft` | `349 90% 96%` | `349 40% 18%` | Expense chip background | ## Radius, shadow, font scale -| Token | Value | -| ---------------- | ---------------------------------------------- | -| `--radius` (lg) | `1.375rem` (~22px) — card baseline | -| `radius-xl/2xl` | `+6px` / `+12px` — sidebar, hero panels | -| `radius-md/sm` | `-6px` / `-10px` | -| `--shadow-soft` | resting card shadow (low, violet-tinted) | -| `--shadow-lift` | hover / floating panels / menus | -| `--shadow-glow` | violet glow under gradient CTAs | -| `--font-sans` | `var(--font-inter), ui-sans-serif, system-ui…` | +| Token | Value | +| --------------- | ---------------------------------------------- | +| `--radius` (lg) | `1.375rem` (~22px) — card baseline | +| `radius-xl/2xl` | `+6px` / `+12px` — sidebar, hero panels | +| `radius-md/sm` | `-6px` / `-10px` | +| `--shadow-soft` | resting card shadow (low, violet-tinted) | +| `--shadow-lift` | hover / floating panels / menus | +| `--shadow-glow` | violet glow under gradient CTAs | +| `--font-sans` | `var(--font-inter), ui-sans-serif, system-ui…` | ## Helper classes (in `@layer components`) diff --git a/examples/showcases/banking/docs/teach-mode/LEARNING-TRACK-PLAN.md b/examples/showcases/banking/docs/teach-mode/LEARNING-TRACK-PLAN.md index fa13dcc2bd5..56f38559157 100644 --- a/examples/showcases/banking/docs/teach-mode/LEARNING-TRACK-PLAN.md +++ b/examples/showcases/banking/docs/teach-mode/LEARNING-TRACK-PLAN.md @@ -9,6 +9,7 @@ > **Owner:** jerel@copilotkit.ai · **Date:** 2026-06-05 · **Status:** Draft for review > > **Repos in play (read both):** +> > - **Banking demo (canonical, OSS):** `CopilotKit/examples/showcases/banking` — Next.js App > Router, CopilotKit v2 hooks, `workspace:*` packages (react-core 1.59.2, **no recording > hook**). This is where the gate/unlock/framing/UX live and verify today. @@ -52,20 +53,20 @@ backend (FOR-147)**; then **pin the hook + swap the import (FOR-146)**; then the ## 1. The loop, end to end — concretely, against THIS demo -The banking entities are: `transaction` (the gated write is *approve*), `expense-policy` +The banking entities are: `transaction` (the gated write is _approve_), `expense-policy` (the limit that blocks it), `policy-exception` (the unlock record), and a `policy-exception-code` catalogue (justifying vs decoy vs invalid). -| Step | What happens | THIS demo's concrete entities + file | -|------|--------------|--------------------------------------| -| **1. Agent A tries the obvious write** | A fresh agent is asked to approve an over-limit transaction. It calls the approve write. | The agent prompt in `src/app/api/copilotkit/[[...slug]]/route.ts` (`bankingAgent`) lists the tools but withholds the unlock recipe. The approve path is the `showAndApproveTransactions` HITL in `src/app/page.tsx` → `changeTransactionStatus` (`src/app/actions.ts`) → `PUT /api/v1/transactions/[id]`. | -| **2. Gate fails, symptom-only** | The PUT returns **422 `OVER_POLICY_LIMIT`**, message `" policy limit exceeded"`. It names the *problem* (over limit), never the *fix* (policy exception). | `src/app/api/v1/transactions/[id]/route.ts` (the `patch.status === "approved" && !isWithinPolicyLimit && !hasApprovedException` branch). Rules in `src/lib/store.ts`: `isWithinPolicyLimit` / `hasApprovedException` / `canApprove`. Seed: `t-1` (Google Ads, −5000, Marketing limit 5000/spent 500) ⇒ over limit. | -| **3. Agent stops (framing holds)** | Per the **ACTION DISCIPLINE** clause, the agent does not improvise. It reports the failure and asks the human how to proceed. It does NOT fire a distractor (`sendSpendAlert` / `requestCardReplacement` / `flagForReview`). | Prompt + distractor tools in the runtime route and `src/app/page.tsx` (the three `useFrontendTool` no-op distractors). This is the **control**: pre-learning, a correctly-framed agent cannot pass. | -| **4. Human demonstrates the unlock** | A human opens a policy exception under a **justifying** code (e.g. `EXC-BOARD-APPROVED`), finalizes it (auto-approves + links `activeExceptionId`), then re-approves — now **201**. | Today: `PolicyExceptionModal` (`src/components/policy-exception-modal.tsx`) opened from the over-limit row in `src/components/transactions-list.tsx`. Catalogue: `src/app/api/v1/policy-exception-codes.ts` (`JUSTIFYING_EXCEPTION_CODES` = BOARD-APPROVED / CONTRACTUAL-COMMITMENT / EMERGENCY-SPEND; decoys = WILL-REIMBURSE / ONE-TIME). REST: `exceptions/route.ts` + `exceptions/[id]/finalize/route.ts`. **This plan moves that flow inline into the chat — see §3.2.** | -| **5. Each mutation is recorded on the thread** | After `open()` and after `finalize()`, the UI calls `recordUserAction({title, description, previousData, newData, metadata})`. `previousData` carries the gated flags (`approvePermitted: false`); `newData` the unlocked effect (flipped flags + the linking exception id); `metadata` the `transactionId`. | Two existing calls in `policy-exception-modal.tsx` (`policy_exception.opened`, `policy_exception.finalized`) + two in `transactions-list.tsx` (`transaction.approved`, `transaction.denied`). The import is the no-op shim `@/lib/record-user-action` **today** — see Blocker 2a. | -| **6. Events stream to the gateway** | With the real hook + Intelligence runtime, every AG-UI event of the run (including the recorded user actions) streams over the Phoenix WebSocket to the Intelligence gateway, scoped to the current user + thread. | The env-gated `CopilotKitIntelligence({apiUrl,wsUrl,apiKey})` branch of `createRuntime()` in the runtime route; `identifyUser` maps `properties.userRole` → a stable `northwind-` id so threads + knowledge are scoped consistently. Reference: `cpk-intelligence-banking/demos/e-commerce/bff/.../main.ts`. | -| **7. Distilled into `/knowledge`** | The `sl-worker` sweeps the recorded actions and an LLM writer distills a reusable procedure: *"to approve an over-policy-limit transaction, open a policy exception under a justifying code (board-approved / contractual / emergency), finalize it, then approve."* It lands in `/knowledge` (shared per org+project). | `apps/sl-worker` in the Intelligence repo (gated on `SL_ENABLED=true`); writes `cpki.knowledge_base_files` exposed to agents as `/knowledge`. | -| **8. Agent B (fresh) learns + succeeds** | In a NEW thread with no memory of the human, the agent is asked the same over-limit approval. It greps `/knowledge` (via the `copilotkit_knowledge_base_shell` tool), discovers the procedure, files a *justifying* exception, finalizes it, approves → **201** — no human help, nothing added to the prompt. | Same `bankingAgent` prompt (still recipe-free) reading `/knowledge`. This is the **proof of learning** — see §5. | +| Step | What happens | THIS demo's concrete entities + file | +| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **1. Agent A tries the obvious write** | A fresh agent is asked to approve an over-limit transaction. It calls the approve write. | The agent prompt in `src/app/api/copilotkit/[[...slug]]/route.ts` (`bankingAgent`) lists the tools but withholds the unlock recipe. The approve path is the `showAndApproveTransactions` HITL in `src/app/page.tsx` → `changeTransactionStatus` (`src/app/actions.ts`) → `PUT /api/v1/transactions/[id]`. | +| **2. Gate fails, symptom-only** | The PUT returns **422 `OVER_POLICY_LIMIT`**, message `" policy limit exceeded"`. It names the _problem_ (over limit), never the _fix_ (policy exception). | `src/app/api/v1/transactions/[id]/route.ts` (the `patch.status === "approved" && !isWithinPolicyLimit && !hasApprovedException` branch). Rules in `src/lib/store.ts`: `isWithinPolicyLimit` / `hasApprovedException` / `canApprove`. Seed: `t-1` (Google Ads, −5000, Marketing limit 5000/spent 500) ⇒ over limit. | +| **3. Agent stops (framing holds)** | Per the **ACTION DISCIPLINE** clause, the agent does not improvise. It reports the failure and asks the human how to proceed. It does NOT fire a distractor (`sendSpendAlert` / `requestCardReplacement` / `flagForReview`). | Prompt + distractor tools in the runtime route and `src/app/page.tsx` (the three `useFrontendTool` no-op distractors). This is the **control**: pre-learning, a correctly-framed agent cannot pass. | +| **4. Human demonstrates the unlock** | A human opens a policy exception under a **justifying** code (e.g. `EXC-BOARD-APPROVED`), finalizes it (auto-approves + links `activeExceptionId`), then re-approves — now **201**. | Today: `PolicyExceptionModal` (`src/components/policy-exception-modal.tsx`) opened from the over-limit row in `src/components/transactions-list.tsx`. Catalogue: `src/app/api/v1/policy-exception-codes.ts` (`JUSTIFYING_EXCEPTION_CODES` = BOARD-APPROVED / CONTRACTUAL-COMMITMENT / EMERGENCY-SPEND; decoys = WILL-REIMBURSE / ONE-TIME). REST: `exceptions/route.ts` + `exceptions/[id]/finalize/route.ts`. **This plan moves that flow inline into the chat — see §3.2.** | +| **5. Each mutation is recorded on the thread** | After `open()` and after `finalize()`, the UI calls `recordUserAction({title, description, previousData, newData, metadata})`. `previousData` carries the gated flags (`approvePermitted: false`); `newData` the unlocked effect (flipped flags + the linking exception id); `metadata` the `transactionId`. | Two existing calls in `policy-exception-modal.tsx` (`policy_exception.opened`, `policy_exception.finalized`) + two in `transactions-list.tsx` (`transaction.approved`, `transaction.denied`). The import is the no-op shim `@/lib/record-user-action` **today** — see Blocker 2a. | +| **6. Events stream to the gateway** | With the real hook + Intelligence runtime, every AG-UI event of the run (including the recorded user actions) streams over the Phoenix WebSocket to the Intelligence gateway, scoped to the current user + thread. | The env-gated `CopilotKitIntelligence({apiUrl,wsUrl,apiKey})` branch of `createRuntime()` in the runtime route; `identifyUser` maps `properties.userRole` → a stable `northwind-` id so threads + knowledge are scoped consistently. Reference: `cpk-intelligence-banking/demos/e-commerce/bff/.../main.ts`. | +| **7. Distilled into `/knowledge`** | The `sl-worker` sweeps the recorded actions and an LLM writer distills a reusable procedure: _"to approve an over-policy-limit transaction, open a policy exception under a justifying code (board-approved / contractual / emergency), finalize it, then approve."_ It lands in `/knowledge` (shared per org+project). | `apps/sl-worker` in the Intelligence repo (gated on `SL_ENABLED=true`); writes `cpki.knowledge_base_files` exposed to agents as `/knowledge`. | +| **8. Agent B (fresh) learns + succeeds** | In a NEW thread with no memory of the human, the agent is asked the same over-limit approval. It greps `/knowledge` (via the `copilotkit_knowledge_base_shell` tool), discovers the procedure, files a _justifying_ exception, finalizes it, approves → **201** — no human help, nothing added to the prompt. | Same `bankingAgent` prompt (still recipe-free) reading `/knowledge`. This is the **proof of learning** — see §5. | The contrast in step 5 (`previousData` gated flags vs `newData` unlocked flags) is the signal the distiller turns into the procedure — which is why the flag names must stay stable across @@ -78,6 +79,7 @@ the distiller turns into the procedure — which is why the flag names must stay ### 2a. The recording hook (FOR-146) — pin the build, swap one import **Current state (verified).** + - Banking demo `package.json` pins CopilotKit packages as `workspace:*`; the installed `@copilotkit/react-core` is **1.59.2**, whose `v2` hooks index exports `useFrontendTool` / `useHumanInTheLoop` / `useAgent` / `useThreads` / `useComponent` / @@ -100,11 +102,11 @@ the distiller turns into the procedure — which is why the flag names must stay `e103a19` pins (at minimum `@copilotkit/react-core`, plus `@copilotkit/core`, `@copilotkit/runtime`, `@copilotkit/shared` to keep the runtime route's `CopilotKitIntelligence` / `BuiltInAgent` imports on the same line). Re-install. - *Cost:* the demo leaves the OSS monorepo `workspace:*` graph; lockfile churn. Best when + _Cost:_ the demo leaves the OSS monorepo `workspace:*` graph; lockfile churn. Best when the demo is being **vendored into the Intelligence repo** (where these pins already exist — see §6). - **(B) Land the recording hook into the OSS `react-core/v2` build** the demo's - `workspace:*` already resolves, then bump. *Cost:* a real OSS change; out of scope for + `workspace:*` already resolves, then bump. _Cost:_ a real OSS change; out of scope for this demo plan but the cleaner long-term home. Treat as a CopilotKit-core ticket. 2. **The one-line import swap** at each of the **four** call sites. Change ONLY the import; every `recordUserAction({...})` body and the `UserActionRecord` type stay identical: @@ -145,16 +147,16 @@ const intelligenceEnabled = Boolean( // missing → new CopilotRuntime({ agents:{default:bankingAgent}, runner: new InMemoryAgentRunner() }) ``` -So no route code changes to *turn on* the backend — it is purely a deploy + env exercise. +So no route code changes to _turn on_ the backend — it is purely a deploy + env exercise. What each var points at: -| Env var | Points at | Local-dev value | Notes | -|---------|-----------|-----------------|-------| -| `INTELLIGENCE_API_URL` | `apps/app-api` HTTP — the `/knowledge` + threads + user_actions store | `http://localhost:7050` (`APP_API_PORT` default in `scripts/local-dev.sh`) | The durable backend the gateway writes to and `/knowledge` is read from. | -| `INTELLIGENCE_GATEWAY_WS_URL` | `apps/realtime-gateway` Phoenix WebSocket — where AG-UI run events (incl. recorded actions) stream | `ws://localhost:7053` (`REALTIME_GATEWAY_PORT` default) | The live ingestion seam. | -| `INTELLIGENCE_API_KEY` | the org/project key (scopes threads + `/knowledge`) | the seeded `cpk_…` key for `casa-de-erlang` (e-commerce uses `cpk_sPRVSEED_seed0privat0longtoken00`) | Must belong to an org whose `cpki.users` includes the demo identities `identifyUser` mints (`northwind-`), or those users must be seeded. | -| `COPILOTKIT_LICENSE_TOKEN` | optional, read automatically by the runtime | — | Only if the build requires it. | -| `SL_ENABLED` | gate on the **`sl-worker`** distillation sweep (Intelligence side) | `true` | Without it the worker won't distill — recording streams but `/knowledge` never fills. | +| Env var | Points at | Local-dev value | Notes | +| ----------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `INTELLIGENCE_API_URL` | `apps/app-api` HTTP — the `/knowledge` + threads + user_actions store | `http://localhost:7050` (`APP_API_PORT` default in `scripts/local-dev.sh`) | The durable backend the gateway writes to and `/knowledge` is read from. | +| `INTELLIGENCE_GATEWAY_WS_URL` | `apps/realtime-gateway` Phoenix WebSocket — where AG-UI run events (incl. recorded actions) stream | `ws://localhost:7053` (`REALTIME_GATEWAY_PORT` default) | The live ingestion seam. | +| `INTELLIGENCE_API_KEY` | the org/project key (scopes threads + `/knowledge`) | the seeded `cpk_…` key for `casa-de-erlang` (e-commerce uses `cpk_sPRVSEED_seed0privat0longtoken00`) | Must belong to an org whose `cpki.users` includes the demo identities `identifyUser` mints (`northwind-`), or those users must be seeded. | +| `COPILOTKIT_LICENSE_TOKEN` | optional, read automatically by the runtime | — | Only if the build requires it. | +| `SL_ENABLED` | gate on the **`sl-worker`** distillation sweep (Intelligence side) | `true` | Without it the worker won't distill — recording streams but `/knowledge` never fills. | **Standing it up — two paths.** @@ -177,7 +179,7 @@ What each var points at: distilled procedure appearing in `/knowledge` (verifiable by grepping `/knowledge` via the agent or inspecting `cpki.knowledge_base_files`). -**Ordering.** 2a and 2b are independent to *build* but both required for the live loop. Do +**Ordering.** 2a and 2b are independent to _build_ but both required for the live loop. Do 2b's standup in parallel with the UX shell; 2a is the final flip. The cleanest single move is §6 (vendor into the Intelligence repo), which clears 2a and 2b together because the pins and services already live there. @@ -209,11 +211,15 @@ useConfigureSuggestions({ suggestions: [ { title: "Approve the $5,000 Marketing transaction", - message: "Approve the $5,000 Google Ads transaction on the Marketing policy.", + message: + "Approve the $5,000 Google Ads transaction on the Marketing policy.", }, { title: "View transactions", message: "Show me my recent transactions" }, { title: "Add a card", message: "Add a new credit card" }, - { title: "Assign a policy", message: "Assign a spending policy to one of my cards" }, + { + title: "Assign a policy", + message: "Assign a spending policy to one of my cards", + }, ], }); ``` @@ -230,11 +236,12 @@ useConfigureSuggestions({ ### 3.2 Inline HITL — approve/deny + file-exception rendered IN the chat **Goal.** The human's whole demonstration — see the over-limit symptom, approve/deny, and -*file a policy exception* — happens **inline in the chat as a tool-call card**, not in a +_file a policy exception_ — happens **inline in the chat as a tool-call card**, not in a separate page modal. That's what makes the recorded demonstration feel like "the agent watched me do it right here." **What exists today.** + - The approve/deny inline card is **already** an inline HITL: `showAndApproveTransactions` (`src/app/page.tsx`, ~line 405) renders `` inside the chat via `useHumanInTheLoop`'s `render`. That list shows the **over-limit symptom** @@ -256,21 +263,21 @@ watched me do it right here." - **File exception** (calls `openPolicyException` → `finalizePolicyException`, the same REST callers threaded from `useCreditCards` in `actions.ts`), - and on success a confirmation + the approve affordance. - It carries the **same two `recordUserAction` calls** (`policy_exception.opened` → - `policy_exception.finalized`) verbatim from the modal — the recording payloads do not - change. + It carries the **same two `recordUserAction` calls** (`policy_exception.opened` → + `policy_exception.finalized`) verbatim from the modal — the recording payloads do not + change. 2. **A new inline HITL tool `fileAndApproveOverLimit`** (a `useHumanInTheLoop` in `src/app/page.tsx`) whose `render` mounts `PolicyExceptionInline`. Description stays **neutral** (does not name the exception path or which codes justify — preserves the - learning invariant), e.g. *"Resolve a blocked over-limit approval. Requires human - approval."* This becomes the inline surface the human uses to teach, and later the surface + learning invariant), e.g. _"Resolve a blocked over-limit approval. Requires human + approval."_ This becomes the inline surface the human uses to teach, and later the surface the learned agent's `openPolicyException` / `finalizePolicyException` calls render into. 3. **Reuse vs replace.** - **Reuse** `approval-buttons.tsx` unchanged for approve/deny. - **Reuse** the existing `openPolicyException` / `finalizePolicyException` HITL tools (`src/app/page.tsx`, ~lines 492 / 549) — they already render inline approve cards; the learned agent drives the unlock through these. `PolicyExceptionInline` is the - *human-initiated* twin. + _human-initiated_ twin. - **Replace** the page-modal entrypoint: drop the `setExceptionTxnId` → `` branch in `transactions-list.tsx` in favor of rendering `PolicyExceptionInline` within the chat card. Keep `policy-exception-modal.tsx` only if a non-chat entry is still wanted; @@ -293,6 +300,7 @@ exposing `isRecording` + `beginRecording()` / `endRecording()` (or a ref-counted `withRecording()` wrapper). Wire it around the **`recordUserAction` calls**: each call site calls `beginRecording()` immediately before firing the record(s) and `endRecording()` when the demonstration step settles. Concretely: + - In `PolicyExceptionInline` (§3.2): `beginRecording()` at the start of `handleSubmit`, and `endRecording()` after the second (`finalized`) record resolves (or in `finally`). - In `transactions-list.tsx` approve/deny: wrap the `recordUserAction(...)` call the same way. @@ -305,6 +313,7 @@ demonstration step settles. Concretely: > (FOR-148) and stays correct once the real hook streams (FOR-146). **Visual treatment.** + - An **edge vignette**: a full-viewport overlay with `box-shadow: inset 0 0 0 …` / `radial-gradient` mask so color concentrates at the **edges** and fades to transparent in the center (content stays unobscured). @@ -338,15 +347,15 @@ wrapping in `policy-exception-inline.tsx` and `transactions-list.tsx`. ## 4. Buildable NOW vs blocked — the explicit split -| Piece | Ticket | Needs the real hook? | Needs the Intelligence backend? | Buildable today? | -|-------|--------|----------------------|---------------------------------|------------------| -| Suggested prompt (§3.1) | FOR-148 | No | No | **Yes** — pure `useConfigureSuggestions` copy. | -| Inline HITL card (§3.2) | FOR-148 | No (renders the demonstration UI; recording payloads already present via the shim) | No | **Yes** — renders + drives REST unlock; records via the shim. | -| Recording vignette (§3.3) | FOR-148 | No (reads a local `recording` flag set around the record calls) | No | **Yes** — flag is true even against the no-op shim. | -| Recording actually streams (role #3 beyond no-op) | FOR-146 | **Yes** (pin `e103a19` + import swap) | Indirectly (events have somewhere to go) | No — blocked on 2a. | -| Distill → `/knowledge` (role #5) | FOR-147 | — | **Yes** (`app-api` + gateway + `sl-worker`, `SL_ENABLED=true`) | No — blocked on 2b. | -| Fresh-agent learns + succeeds | FOR-149 | Yes | Yes | No — needs 2a + 2b. | -| Fresh-agent verification harness | FOR-145 | The script half works today (§5); the learning half needs 2a+2b | Partial | The REST proof: **yes**. The learning proof: blocked. | +| Piece | Ticket | Needs the real hook? | Needs the Intelligence backend? | Buildable today? | +| ------------------------------------------------- | ------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------- | +| Suggested prompt (§3.1) | FOR-148 | No | No | **Yes** — pure `useConfigureSuggestions` copy. | +| Inline HITL card (§3.2) | FOR-148 | No (renders the demonstration UI; recording payloads already present via the shim) | No | **Yes** — renders + drives REST unlock; records via the shim. | +| Recording vignette (§3.3) | FOR-148 | No (reads a local `recording` flag set around the record calls) | No | **Yes** — flag is true even against the no-op shim. | +| Recording actually streams (role #3 beyond no-op) | FOR-146 | **Yes** (pin `e103a19` + import swap) | Indirectly (events have somewhere to go) | No — blocked on 2a. | +| Distill → `/knowledge` (role #5) | FOR-147 | — | **Yes** (`app-api` + gateway + `sl-worker`, `SL_ENABLED=true`) | No — blocked on 2b. | +| Fresh-agent learns + succeeds | FOR-149 | Yes | Yes | No — needs 2a + 2b. | +| Fresh-agent verification harness | FOR-145 | The script half works today (§5); the learning half needs 2a+2b | Partial | The REST proof: **yes**. The learning proof: blocked. | **Why the UX is safe to build first.** The three UX pieces only depend on (a) the v2 chat + HITL APIs the demo already uses, and (b) a local `recording` boolean. The no-op shim already @@ -385,6 +394,7 @@ BASE_URL=http://localhost:3000 ./verify-teachable-gate.sh # next dev defaults ``` It asserts, in order: + - **A. GATE** — `PUT /api/v1/transactions/t-1 {"status":"approved"}` → **422 `OVER_POLICY_LIMIT`**, and the body does **not** mention the exception/unlock path (symptom-only invariant). @@ -396,7 +406,7 @@ It asserts, in order: enumerate the catalogue (non-enumeration invariant). This proves the gate is real and the unlock is discriminating — i.e. there is genuinely -*something to learn* — without any Intelligence backend. It is the control that the demo isn't +_something to learn_ — without any Intelligence backend. It is the control that the demo isn't faked. Re-run from a fresh server to reseed (in-memory store). ### 5.2 The fresh-agent proof (activates after 2a + 2b) — roles #3 + #5 — FOR-149 @@ -404,19 +414,19 @@ faked. Re-run from a fresh server to reseed (in-memory store). This is the proof the loop **learned**, not that REST works. Requires the real hook (2a) and the env-gated `CopilotKitIntelligence` backend with `SL_ENABLED=true` (2b). -1. **Baseline (control).** Fresh thread, ask: *"Approve the $5,000 Google Ads transaction on - the Marketing policy."* With the recipe-free prompt + ACTION DISCIPLINE intact, the agent +1. **Baseline (control).** Fresh thread, ask: _"Approve the $5,000 Google Ads transaction on + the Marketing policy."_ With the recipe-free prompt + ACTION DISCIPLINE intact, the agent hits the gate, has no procedure, and **reports the failure** instead of firing a distractor. - *This failure is the control — record it.* + _This failure is the control — record it._ 2. **Human teaches.** Open the inline policy-exception card (§3.2), pick a **justifying** code, file + finalize. Each step fires `recordUserAction(...)` on the current thread — now a real stream (2a), and the vignette (§3.3) confirms recording is live on screen. 3. **Distill.** The `sl-worker` (2b, `SL_ENABLED=true`) distills the recorded actions into a - reusable procedure in `/knowledge`. *Spot-check:* grep `/knowledge` (via the agent or + reusable procedure in `/knowledge`. _Spot-check:_ grep `/knowledge` (via the agent or `cpki.knowledge_base_files`) and confirm the over-limit/policy-exception procedure exists. 4. **Fresh agent succeeds unaided.** A **new** thread (and ideally a different seeded user, to prove cross-thread/cross-user transfer), same approval request. The agent greps `/knowledge`, - files a *justifying* exception, finalizes, approves → **201** — **no human help, nothing + files a _justifying_ exception, finalizes, approves → **201** — **no human help, nothing added to the prompt.** **Pass criteria:** step 1 fails, step 4 succeeds, and the **only** thing that changed between @@ -425,9 +435,10 @@ the REST proof (§5.1) gates "is there something to learn," the fresh-agent run learn it." **Anti-cheat checks (keep the proof honest):** + - The agent prompt at step 4 is **byte-identical** to step 1 (recipe still withheld — diff the runtime route prompt). -- A run that files a **decoy** code must still fail (the agent must learn *which* codes justify, +- A run that files a **decoy** code must still fail (the agent must learn _which_ codes justify, not just "file an exception"). - Distractor tools must remain harmless no-ops (a "success" from `sendSpendAlert` must not be mistaken for clearing the gate). @@ -436,8 +447,9 @@ learn it." ## 6. Recommended path: vendor into the Intelligence repo (clears 2a + 2b together) -The standalone Next.js banking demo can *render* the full UX today, but the **live learning +The standalone Next.js banking demo can _render_ the full UX today, but the **live learning loop's natural home is the Intelligence repo**, because: + - it already pins `@copilotkit/react-core@e103a19` (the hook-bearing build) — **2a is free there**; - it already runs `app-api` + `realtime-gateway` + `sl-worker` via `scripts/local-dev.sh`, and @@ -464,8 +476,9 @@ gives for free. ## 7. File / component touch-point summary **Edit (UX shell, FOR-148 — buildable now):** + - `src/app/wrapper.tsx` — add the teachable suggestion pill (§3.1); mount `RecordingProvider` - + `` (§3.3). + - `` (§3.3). - `src/app/page.tsx` — add the `fileAndApproveOverLimit` inline HITL tool whose `render` mounts `PolicyExceptionInline` (§3.2). - `src/components/transactions-list.tsx` — swap the page-modal entry for the inline card; wrap @@ -473,6 +486,7 @@ gives for free. - `src/app/globals.css` — `.recording-vignette` + `@keyframes` + reduced-motion variant (§3.3). **Create (UX shell, FOR-148):** + - `src/components/policy-exception-inline.tsx` — inline version of the file-exception flow (carries the two existing recording payloads verbatim). - `src/components/recording-context.tsx` — `isRecording` + `begin/endRecording` (ref-counted, @@ -480,6 +494,7 @@ gives for free. - `src/components/recording-vignette.tsx` — the edge-glow overlay. **Edit (unblock the loop):** + - `package.json` — pin `@copilotkit/react-core` (+ core/runtime/shared) to `e103a19` (FOR-146, option A) — or do this via vendoring (§6). - `src/lib/record-user-action.ts` — turn into a re-export of the real hook (FOR-146) so no call @@ -501,7 +516,7 @@ react/.../order-actions-bar.tsx, react/.../incident-create-modal.tsx}` and ## 8. Risks / open questions - **Hook parity at `e103a19`.** Confirm the published `react-core@e103a19` `v2` index exports - `useRecordUserActionInCurrentThread` *and* the `UserActionRecord` type (e-commerce imports the + `useRecordUserActionInCurrentThread` _and_ the `UserActionRecord` type (e-commerce imports the hook; verify the type export before relying on it in the re-export shim — otherwise keep the local type). - **Pin drift vs `workspace:*`.** Pinning the demo to `e103a19` takes it off the OSS monorepo @@ -512,8 +527,7 @@ react/.../order-actions-bar.tsx, react/.../incident-create-modal.tsx}` and org must have those users seeded (e-commerce seeds four users in `casa-de-erlang`). Mismatch = threads/knowledge land under an unexpected scope and the fresh-agent retrieval misses. - **Writer non-determinism.** The LLM distiller may phrase `/knowledge` differently run to run; - keep a deterministic fallback note for scripted live demos, and assert on *behavior* (the - 201) not on the knowledge text. + keep a deterministic fallback note for scripted live demos, and assert on _behavior_ (the 201) not on the knowledge text. - **Vignette over modals.** Ensure the overlay's `z-index` sits above page content but **below** HITL cards/toasts, and `pointer-events: none` everywhere, so it never blocks the approve buttons the human needs during recording. @@ -525,10 +539,10 @@ react/.../order-actions-bar.tsx, react/.../incident-create-modal.tsx}` and ## 9. Ticket map (suggested) -| Ticket | Scope | -|--------|-------| -| **FOR-145** | Verification harness: REST gate proof (works today) + fresh-agent learning proof (activates post-146/147). | -| **FOR-146** | Recording hook unblock: pin `e103a19` (or re-export shim) + one-line import swap. | +| Ticket | Scope | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| **FOR-145** | Verification harness: REST gate proof (works today) + fresh-agent learning proof (activates post-146/147). | +| **FOR-146** | Recording hook unblock: pin `e103a19` (or re-export shim) + one-line import swap. | | **FOR-147** | Intelligence backend standup: `app-api` + gateway + `sl-worker` (`SL_ENABLED=true`); wire the three env vars (local-dev or hosted). | -| **FOR-148** | Teachable-demo UX shell (buildable now): suggested prompt + inline HITL card + recording vignette. | -| **FOR-149** | Close the loop: with 146+147 live, demonstrate fresh-agent success unaided and capture it in the harness. | +| **FOR-148** | Teachable-demo UX shell (buildable now): suggested prompt + inline HITL card + recording vignette. | +| **FOR-149** | Close the loop: with 146+147 live, demonstrate fresh-agent success unaided and capture it in the harness. | diff --git a/examples/showcases/banking/src/app/api/v1/exceptions/route.ts b/examples/showcases/banking/src/app/api/v1/exceptions/route.ts index da1430b401c..8b00742bb18 100644 --- a/examples/showcases/banking/src/app/api/v1/exceptions/route.ts +++ b/examples/showcases/banking/src/app/api/v1/exceptions/route.ts @@ -7,7 +7,10 @@ export const POST = async (req: NextRequest) => { try { const body = await req.json(); code = body?.code; - const exception = store.openPolicyException(body?.transactionId, code as string); + const exception = store.openPolicyException( + body?.transactionId, + code as string, + ); return new Response(JSON.stringify(exception), { status: 201 }); } catch (error) { const message = error instanceof Error ? error.message : ""; diff --git a/examples/showcases/banking/src/components/change-pin-dialog.tsx b/examples/showcases/banking/src/components/change-pin-dialog.tsx index cffbdf75dc5..19de6c9b7c9 100644 --- a/examples/showcases/banking/src/components/change-pin-dialog.tsx +++ b/examples/showcases/banking/src/components/change-pin-dialog.tsx @@ -17,7 +17,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Card } from "@/app/api/v1/data"; +import type { Card } from "@/app/api/v1/data"; export function ChangePinDialog({ onDialogOpenChange, @@ -48,7 +48,10 @@ export function ChangePinDialog({
- diff --git a/examples/showcases/banking/src/components/chat/chat-panel-header.tsx b/examples/showcases/banking/src/components/chat/chat-panel-header.tsx index 65e80d21546..b89fff5b04c 100644 --- a/examples/showcases/banking/src/components/chat/chat-panel-header.tsx +++ b/examples/showcases/banking/src/components/chat/chat-panel-header.tsx @@ -22,8 +22,7 @@ export function ChatPanelHeader() { const configuration = useCopilotChatConfiguration(); const { isInboxOpen, toggleInbox, startNewConversation } = useChatInbox(); - const title = - configuration?.labels.modalHeaderTitle ?? IDENTITY.assistant; + const title = configuration?.labels.modalHeaderTitle ?? IDENTITY.assistant; const closePanel = () => configuration?.setModalOpen?.(false); diff --git a/examples/showcases/banking/src/components/credit-card-details.tsx b/examples/showcases/banking/src/components/credit-card-details.tsx index a6cb45f535b..f430d9d92b6 100644 --- a/examples/showcases/banking/src/components/credit-card-details.tsx +++ b/examples/showcases/banking/src/components/credit-card-details.tsx @@ -48,7 +48,9 @@ export function CreditCardDetails({
) : ( -

No expense policy assigned

+

+ No expense policy assigned +

)} diff --git a/examples/showcases/banking/src/components/statistics-chart.tsx b/examples/showcases/banking/src/components/statistics-chart.tsx index 51b2429dbb8..17a9b43b0d6 100644 --- a/examples/showcases/banking/src/components/statistics-chart.tsx +++ b/examples/showcases/banking/src/components/statistics-chart.tsx @@ -132,8 +132,8 @@ export function StatisticsChart({ ))}

- Latest {formatCurrency(last?.value ?? 0)}; range{" "} - {formatCurrency(min)}–{formatCurrency(max)}. + Latest {formatCurrency(last?.value ?? 0)}; range {formatCurrency(min)}– + {formatCurrency(max)}.

); diff --git a/examples/showcases/banking/src/components/ui/button.tsx b/examples/showcases/banking/src/components/ui/button.tsx index 7b169a10807..c2a1b36a1d6 100644 --- a/examples/showcases/banking/src/components/ui/button.tsx +++ b/examples/showcases/banking/src/components/ui/button.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; +import { cva } from "class-variance-authority"; +import type { VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; @@ -12,8 +13,7 @@ const buttonVariants = cva( // Signature violet→indigo gradient pill with a soft glow. default: "brand-gradient text-brand-foreground shadow-[0_8px_20px_hsl(252_83%_60%/0.28)] hover:shadow-[0_12px_28px_hsl(252_83%_60%/0.4)] hover:brightness-[1.05]", - destructive: - "bg-negative text-white shadow-sm hover:bg-negative/90", + destructive: "bg-negative text-white shadow-sm hover:bg-negative/90", outline: "border border-hairline bg-surface text-ink shadow-soft hover:bg-brand-soft hover:text-brand-indigo hover:border-brand/40", secondary: diff --git a/examples/showcases/banking/src/components/ui/card.tsx b/examples/showcases/banking/src/components/ui/card.tsx index fdccedb7b64..ababd623ed5 100644 --- a/examples/showcases/banking/src/components/ui/card.tsx +++ b/examples/showcases/banking/src/components/ui/card.tsx @@ -45,11 +45,7 @@ const CardDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -

+

)); CardDescription.displayName = "CardDescription"; diff --git a/examples/showcases/banking/src/components/ui/dropdown-menu.tsx b/examples/showcases/banking/src/components/ui/dropdown-menu.tsx index a01b6decf72..8c3cc4bb496 100644 --- a/examples/showcases/banking/src/components/ui/dropdown-menu.tsx +++ b/examples/showcases/banking/src/components/ui/dropdown-menu.tsx @@ -167,10 +167,7 @@ const DropdownMenuSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/examples/showcases/banking/src/components/ui/select.tsx b/examples/showcases/banking/src/components/ui/select.tsx index ef9a8172791..4ca7007df4f 100644 --- a/examples/showcases/banking/src/components/ui/select.tsx +++ b/examples/showcases/banking/src/components/ui/select.tsx @@ -144,10 +144,7 @@ const SelectSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/examples/showcases/banking/tsconfig.json b/examples/showcases/banking/tsconfig.json index 877b650fcf5..67494258ac6 100644 --- a/examples/showcases/banking/tsconfig.json +++ b/examples/showcases/banking/tsconfig.json @@ -1,10 +1,6 @@ { "compilerOptions": { - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -22,9 +18,7 @@ } ], "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] }, "target": "ES2017" }, @@ -35,7 +29,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } diff --git a/examples/teams/.env.example b/examples/teams/.env.example new file mode 100644 index 00000000000..ee7b201ce49 --- /dev/null +++ b/examples/teams/.env.example @@ -0,0 +1,16 @@ +# Required: the bot runs a CopilotKit BuiltInAgent and exits without this. +OPENAI_API_KEY= +# Optional: defaults to openai/gpt-5.5. +# OPENAI_MODEL= + +# The Microsoft 365 Agents Playground connects to the bot anonymously, so NO +# Microsoft credentials are needed for local development. Just run `pnpm start`. + +# Port for the bot's POST /api/messages endpoint (the Playground default). +PORT=3978 + +# --- Only needed to talk to REAL Microsoft Teams via Azure Bot Service --- +# (lowercase names: the M365 Agents SDK reads these via loadAuthConfigFromEnv) +# clientId= +# clientSecret= +# tenantId= diff --git a/examples/teams/.gitignore b/examples/teams/.gitignore new file mode 100644 index 00000000000..bc94e839090 --- /dev/null +++ b/examples/teams/.gitignore @@ -0,0 +1,14 @@ +node_modules +dist +.env* +!.env.example +*.log +devTools + +# Built Teams app package (rezip from manifest.json + icons) +appPackage/*.zip + +# Host-specific deploy config lives in your platform's dashboard, not the repo. +# Keep a local copy here for `railway up` if you like; it won't be committed. +railway.json +railway.toml diff --git a/examples/teams/README.md b/examples/teams/README.md new file mode 100644 index 00000000000..5d91430705f --- /dev/null +++ b/examples/teams/README.md @@ -0,0 +1,173 @@ +# Teams example: demo bot + +A runnable demo of [`@copilotkit/bot-teams`](../../packages/bot-teams): a +Microsoft Teams bot backed by a CopilotKit `BuiltInAgent` that shows +**streamed-by-edit replies**, **agent-rendered Adaptive Cards**, and a +**human-in-the-loop approval gate**, testable locally in the **Microsoft 365 +Agents Playground** with **no Microsoft credentials**. It needs an +`OPENAI_API_KEY`. + +## Run it + +From this directory (after `pnpm install` at the repo root): + +```sh +export OPENAI_API_KEY=sk-... # or add it to .env (see .env.example) +pnpm start # starts the bot on http://localhost:3978/api/messages +``` + +In a second terminal: + +```sh +pnpm playground # opens the M365 Agents Playground at http://localhost:56150 +``` + +Then, in the Playground: + +- Ask anything → the agent replies, **streaming in by message edit** (a typing + indicator first, then text that fills in as it's edited, following Teams' + baseline post-then-`updateActivity` streaming model). +- Ask for a **summary**, **status**, or any structured data → the agent calls + the `show_card` tool and posts an **Adaptive Card** (header, facts, table). +- Ask it to **"announce X to the team"** → it drafts the message, posts an + **Approve/Reject card**, and only sends after you approve (the card updates in + place to ✅/🚫). + +That exercises the CopilotKit bot engine and the Teams adapter end-to-end: +streaming, agent-rendered Adaptive Cards, and human-in-the-loop. + +## What's in here + +- `app/index.tsx`: the whole bot, covering an in-process `BuiltInAgent` runtime, + the `createBot({ adapters: [teams()] })` wiring, an `onMessage` handler that + runs the agent, and the agent-facing `show_card` tool. +- `app/human-in-the-loop/`: the `confirm_write` approval gate and the Adaptive + Card it posts. This is user-land code, not SDK code. + +## Use a remote agent + +By default the example serves an in-process `BuiltInAgent`. To point the bot at +a remote AG-UI endpoint (a deployed CopilotKit runtime, LangGraph, and so on) +instead, swap the `agent` factory to read a URL from the environment: + +```ts +agent: (threadId) => { + const a = new SanitizingHttpAgent({ url: process.env.AGENT_URL! }); + a.threadId = threadId; + return a; +}, +``` + +## Connect to Microsoft Teams + +The Playground needs no credentials; real Teams does. The high-level path: + +1. **Register the bot with Microsoft.** Create an [Entra app + registration](https://learn.microsoft.com/entra/identity-platform/quickstart-register-app) + and note its Application (client) ID, Directory (tenant) ID, and a client + secret. Create an [Azure Bot + resource](https://learn.microsoft.com/azure/bot-service/bot-service-quickstart-registration) + that uses that app, enable the **Microsoft Teams** channel, and set its + **messaging endpoint** to `https:///api/messages`. +2. **Give the bot the credentials.** Set `clientId` / `clientSecret` / + `tenantId` (the names the M365 Agents SDK reads) in the bot's environment. + With them set, the bot acks each turn and runs the agent on a detached + context, so HITL approvals can resume minutes later. +3. **Build and upload the app package** (below), then in Teams: **Apps → Manage + your apps → Upload a custom app**. + +The full step-by-step walkthrough is in the [Microsoft Teams +guide](../../showcase/shell-docs/src/content/docs/frontends/teams.mdx). + +## Build the Teams app package + +The app package is the manifest + icons you sideload into Teams. Build it with: + +```sh +pnpm package # -> appPackage/appPackage.zip +``` + +The script (`appPackage/package.mjs`, dependency-free) reads your bot id from +`MICROSOFT_APP_ID` / `CLIENT_ID` / `clientId` (env or `.env`) and injects it into +the manifest, validates the manifest, and auto-generates placeholder icons if +they're missing, so the committed `manifest.json` stays a placeholder and you +never hardcode your id. See [`appPackage/README.md`](./appPackage/README.md) for +details. + +## Files and charts (upload a CSV, get a chart) + +The agent can read uploaded files and render charts. Upload a CSV and ask for a +pie/bar chart: the bot parses the data and calls `render_chart`, which posts a +**native Teams chart** (an Adaptive Card chart element, no image generation, no +headless browser). How the file reaches the bot depends on where it's uploaded, +because of a Teams limitation: + +- **1:1 (personal) chat** — the file is delivered to the bot inline (requires + `supportsFiles: true` in the manifest, already set). Works with no extra setup. +- **Channel / group chat** — Teams does **not** send the file to bots here, so + the bot fetches it through Microsoft Graph. That needs two **application** + permissions on the bot's Entra app, consented once by a tenant admin: + - `Files.Read.All` — download the file from SharePoint. + - `Group.Read.All` (or the manifest's RSC `ChannelMessage.Read.Group`, which a + team owner can consent without a tenant admin) — read the channel message + that references the file. + + Without that consent the bot still works — it asks the user to paste the data + inline (which also renders a chart). To verify the Graph chain in a tenant + where you control consent before requesting it org-wide, run + `scripts/verify-graph-channel.ts` (see its header). + +Charts render natively in the Teams client, so there's nothing extra to install +(no Chromium, no headless browser). Native charts need a Teams app manifest at +version 1.25+ (already set in `appPackage/manifest.json`). + +## Deploy + +The bot is a plain HTTP service: it serves `POST /api/messages` (plus a +`/healthz` liveness probe) and binds `PORT`, so it runs anywhere a Node process +does. Teams is an **inbound webhook**, so the service needs a public URL: point +your Azure Bot resource's messaging endpoint at `https:///api/messages`. + +### Deploy as a workspace member (built from source) + +This example consumes the `@copilotkit/*` packages via the **`workspace:*`** +protocol, so it always builds from the in-repo source — **not** the npm +registry. That decouples the deploy from publishing: a change to `packages/**` +redeploys with the new code immediately. + +Because it's a workspace member, the deploy must run from the **repo root** so +the workspace and `packages/**` are visible. The bot runs its `BuiltInAgent` +runtime in-process (on `RUNTIME_PORT`, localhost-only), so it's a **single +service** — no separate runtime process. On Railway (or any host), set: + +| Setting | Value | +| ------------------ | -------------------------------------------------------------------- | +| **Root Directory** | repo root (`/`) | +| **Build Command** | `pnpm install && pnpm --filter teams-example build` | +| **Start Command** | `pnpm --filter teams-example start` | +| **Watch Paths** | `packages/**`, `examples/teams/**`, `pnpm-lock.yaml`, `package.json` | + +`pnpm --filter teams-example build` builds the workspace libs the example +imports (`@copilotkit/bot`, `bot-teams`, `bot-ui`, `runtime`) and everything +they depend on, via the Nx project graph — so `tsx` runs against fresh `dist`. The **Watch Paths** are +what make a `packages/**`-only change trigger a redeploy. On Railway, generate a +public domain on the service (Settings → Networking); it routes to `$PORT`, +which the bot listens on for `/api/messages`. + +> **Copying this example out of the monorepo?** Replace the `workspace:*` ranges +> in `package.json` with the published versions (e.g. +> `@copilotkit/bot-teams: ^0.0.1`) — `workspace:*` only resolves inside this +> monorepo. + +Set the environment for wherever you deploy: + +- `OPENAI_API_KEY` _(required)_: the bot runs a `BuiltInAgent` and exits at + startup without it. +- `OPENAI_MODEL` _(optional)_: defaults to `openai/gpt-5.5`. +- `clientId` / `clientSecret` / `tenantId`: needed to reach real Teams (see + above). The in-process `BuiltInAgent` runtime stays on `RUNTIME_PORT` + (localhost-only, default 8200). + +Note: the conversation store and pending HITL approvals are **in-memory**, so +they do not survive a restart. Swap in a durable store before relying on +long-lived approvals in production. diff --git a/examples/teams/app/human-in-the-loop/__tests__/confirm-action.test.tsx b/examples/teams/app/human-in-the-loop/__tests__/confirm-action.test.tsx new file mode 100644 index 00000000000..8d0179fa0f5 --- /dev/null +++ b/examples/teams/app/human-in-the-loop/__tests__/confirm-action.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { renderToIR } from "@copilotkit/bot-ui"; +import type { BotNode } from "@copilotkit/bot-ui"; +import { renderAdaptiveCard } from "@copilotkit/bot-teams"; +import { confirmWriteTool } from "../index.js"; + +/** A fake thread whose `awaitChoice` records the posted UI and returns a fixed choice. */ +function fakeThread(choice: unknown) { + const awaited: unknown[] = []; + const thread = { + async awaitChoice(ui: unknown) { + awaited.push(ui); + return choice; + }, + }; + return { thread, awaited }; +} + +describe("confirm_write tool (Teams)", () => { + it("posts a ConfirmAction card and returns approval when the user approves", async () => { + const { thread, awaited } = fakeThread({ confirmed: true }); + + const result = await confirmWriteTool.handler( + { action: "Send announcement", detail: "Deploy v1.4.2 is live." }, + { thread, platform: "teams" } as never, + ); + + expect(result).toBe("The user APPROVED. Proceed with send_announcement."); + + // The posted UI renders to an Adaptive Card whose header carries the action. + expect(awaited).toHaveLength(1); + const card = renderAdaptiveCard(renderToIR(awaited[0] as BotNode)); + expect(card.type).toBe("AdaptiveCard"); + const header = card.body[0] as { type: string; text: string } | undefined; + expect(header?.type).toBe("TextBlock"); + expect(header?.text).toContain("Send announcement"); + }); + + it("returns a decline message when the user rejects", async () => { + const { thread } = fakeThread({ confirmed: false }); + + const result = await confirmWriteTool.handler( + { action: "Send announcement" }, + { thread, platform: "teams" } as never, + ); + + expect(result).toBe( + "The user DECLINED. Do not send; acknowledge and stop.", + ); + }); +}); diff --git a/examples/teams/app/human-in-the-loop/confirm-action.tsx b/examples/teams/app/human-in-the-loop/confirm-action.tsx new file mode 100644 index 00000000000..e21c00fe552 --- /dev/null +++ b/examples/teams/app/human-in-the-loop/confirm-action.tsx @@ -0,0 +1,70 @@ +/** + * The human-in-the-loop approval card. A tool handler calls + * `await thread.awaitChoice()`, which posts this interactive + * Adaptive Card and **blocks until the user clicks Approve or Reject** (even + * minutes later), resolving to the clicked button's `value` (`{confirmed}`). + * + * Each button also carries an `onClick` that updates the card in place to a + * resolved (green) or declined (red) state, so the card reflects the decision the + * moment it's clicked. This is the cross-platform `bot-ui` equivalent of React's + * `useHumanInTheLoop`: the same JSX renders as an Adaptive Card on Teams and a + * Block Kit message on Slack. + */ +import { + Message, + Header, + Section, + Context, + Actions, + Button, +} from "@copilotkit/bot-ui"; +import type { InteractionContext } from "@copilotkit/bot-ui"; + +export interface ConfirmActionProps { + /** Short imperative title of the action, e.g. 'Send announcement'. */ + action: string; + /** The specifics being approved: the drafted announcement text, etc. */ + detail?: string; +} + +export function ConfirmAction({ action, detail }: ConfirmActionProps) { + return ( + +

{`📣 ${action}?`}
+ {detail ?
{detail}
: null} + {"🔒 Nothing is sent until you click **Approve**."} + + + + + + ); +} diff --git a/examples/teams/app/human-in-the-loop/index.tsx b/examples/teams/app/human-in-the-loop/index.tsx new file mode 100644 index 00000000000..7b1a17a6e84 --- /dev/null +++ b/examples/teams/app/human-in-the-loop/index.tsx @@ -0,0 +1,59 @@ +/** + * Human-in-the-loop demo tools. + * + * HITL here is a **blocking frontend tool**: `confirm_write`'s handler calls + * `await thread.awaitChoice()`, which posts the approval card and + * blocks until the user clicks (resolving to `{confirmed}`). The agent is + * instructed (system prompt in `app/index.tsx`) to call `confirm_write` BEFORE + * `send_announcement`, so a consequential action is always gated on a human. + */ +import { z } from "zod"; +import { defineBotTool } from "@copilotkit/bot"; +import { ConfirmAction } from "./confirm-action.js"; + +/** The HITL gate: ask the user to approve before a consequential action. */ +export const confirmWriteTool = defineBotTool({ + name: "confirm_write", + description: + "Ask the user to approve a consequential action before you perform it. " + + "Posts an approve/reject card and BLOCKS until the user clicks; returns " + + "{confirmed: boolean}. You MUST call this before send_announcement. Reads " + + "and chit-chat never need confirmation.", + parameters: z.object({ + action: z + .string() + .describe( + "One-line summary of what you're about to do, e.g. 'Send announcement to the team'", + ), + detail: z + .string() + .optional() + .describe("The specifics being approved: the drafted announcement text"), + }), + async handler({ action, detail }, { thread }) { + const choice = await thread.awaitChoice<{ confirmed?: boolean }>( + , + ); + return choice?.confirmed + ? "The user APPROVED. Proceed with send_announcement." + : "The user DECLINED. Do not send; acknowledge and stop."; + }, +}); + +/** The gated action. Self-contained (mock), with no external API. */ +export const sendAnnouncementTool = defineBotTool({ + name: "send_announcement", + description: + "Send a team announcement. You MUST have called confirm_write and received " + + "approval first. Returns a confirmation with a mock message id.", + parameters: z.object({ + message: z.string().describe("The announcement body to send"), + }), + async handler({ message }) { + // Mock send. A real bot would post to a channel or call an API here. + const id = `ann_${message.length}_${message.trim().split(/\s+/).length}`; + return `Announcement sent (id: ${id}). Give the user a one-line confirmation.`; + }, +}); + +export const hitlTools = [confirmWriteTool, sendAnnouncementTool]; diff --git a/examples/teams/app/index.tsx b/examples/teams/app/index.tsx new file mode 100644 index 00000000000..e6886edeb33 --- /dev/null +++ b/examples/teams/app/index.tsx @@ -0,0 +1,214 @@ +/** + * Microsoft Teams demo bot for `@copilotkit/bot-teams`. + * + * Every message runs a real CopilotKit `BuiltInAgent`. Replies stream by + * message-edit, and the agent renders **Adaptive Cards automatically** by + * calling the `show_card` tool whenever structured data (a summary, status, + * table, list of facts) is clearer as a card than as prose. Consequential + * actions go through a human-in-the-loop approval gate (`confirm_write`). + * + * Requires `OPENAI_API_KEY`. No Microsoft credentials are needed to test in the + * M365 Agents Playground: + * + * pnpm start # bot on http://localhost:3978/api/messages + * pnpm playground # M365 Agents Playground UI (http://localhost:56150) + */ +import "dotenv/config"; +import { createServer } from "node:http"; +import { createBot, defineBotTool } from "@copilotkit/bot"; +import { teams, SanitizingHttpAgent } from "@copilotkit/bot-teams"; +import { BuiltInAgent, CopilotSseRuntime } from "@copilotkit/runtime/v2"; +import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node"; +import { z } from "zod"; +import { hitlTools } from "./human-in-the-loop/index.js"; +import { renderChartTool } from "./tools/render-chart.js"; +import { + Message, + Header, + Section, + Fields, + Field, + Table, + Row, + Cell, +} from "@copilotkit/bot-ui"; + +// This demo drives a real agent, so an LLM key is required. Fail fast with a +// clear message rather than booting a bot that errors on the first message. +if (!process.env.OPENAI_API_KEY) { + console.error( + "Missing OPENAI_API_KEY.\n" + + "This demo runs a CopilotKit BuiltInAgent, which needs an LLM API key.\n" + + " export OPENAI_API_KEY=sk-... (or add it to examples/teams/.env)\n" + + "Optional: OPENAI_MODEL (defaults to openai/gpt-5.5).", + ); + process.exit(1); +} + +const port = Number(process.env.PORT ?? 3978); + +const SYSTEM_PROMPT = + "You are a helpful Microsoft Teams assistant powered by CopilotKit. Keep " + + "replies concise. When the user asks for a summary, status, list, " + + "comparison, or any structured/tabular data, call the show_card tool to " + + "render it as a rich Adaptive Card instead of writing it out as plain text.\n\n" + + "Charts: when you have tabular/numeric data and the user wants it " + + "visualized, parse it and call render_chart. Pass a chartType (one of " + + "verticalBar, horizontalBar, line, pie, donut; pick what fits, defaults to " + + "verticalBar), a short title, and a data array of {label, value} points with " + + "the actual numbers inlined. Add xAxisTitle/yAxisTitle for bar and line " + + "charts. render_chart posts a native chart in the conversation itself, so do " + + "NOT restate the data as text or claim you can't make charts; you can. After " + + "it posts, reply with at most one short line.\n\n" + + "Where the data comes from: in a 1:1 chat, an uploaded file (CSV/JSON/text) " + + "arrives as readable content and you can chart it directly. In a CHANNEL or " + + "group chat, Microsoft Teams does NOT deliver uploaded files to bots — you " + + "will only see the user's text, never the file's contents, even if Teams " + + "shows a file card. So if the user references an attached file in a channel " + + "but you received no file content, do NOT guess: briefly tell them Teams " + + "doesn't share channel file uploads with bots, and ask them to paste the " + + "data here (or send the file in a 1:1 chat with you). When they paste it, " + + "chart it.\n\n" + + "When the user asks to send, post, or announce something to the team, FIRST " + + "draft the announcement, then call confirm_write with a one-line action " + + "summary and the drafted text to get the user's approval. Only call " + + "send_announcement after confirm_write returns approval; if it is declined, " + + "acknowledge and do not send."; + +// The agent is a CopilotKit `BuiltInAgent` served over a local +// `CopilotSseRuntime`, and the bot connects to it with a `SanitizingHttpAgent` +// (the re-runnable `HttpAgent` this package exports, as bot-slack does). A +// `BuiltInAgent` can't be handed to `createBot` directly: the bot's run loop +// re-invokes the agent once per tool round (call → result → respond), and a +// single `BuiltInAgent` instance rejects a second concurrent run. An +// `HttpAgent` is re-runnable, so it drives the multi-step + HITL loops cleanly. +const agentId = "assistant"; +const runtimePort = Number(process.env.RUNTIME_PORT ?? 8200); +const runtimeAgentUrl = `http://localhost:${runtimePort}/api/copilotkit/agent/${agentId}/run`; + +const runtime = new CopilotSseRuntime({ + agents: { + [agentId]: new BuiltInAgent({ + model: process.env.OPENAI_MODEL ?? "openai/gpt-5.5", + prompt: SYSTEM_PROMPT, + }), + }, +}); +// Bind to loopback only: this internal runtime is unauthenticated (it wraps the +// BuiltInAgent that holds the OpenAI key) and is consumed in-process via +// `runtimeAgentUrl` (localhost). Omitting the host would bind all interfaces and +// expose it on a deployed host. +createServer( + createCopilotNodeListener({ runtime, basePath: "/api/copilotkit" }), +).listen(runtimePort, "127.0.0.1", () => { + console.log(`Runtime (BuiltInAgent) listening on 127.0.0.1:${runtimePort}`); +}); + +/** + * The card the **agent** renders on demand. The LLM calls this tool with + * structured args; the handler turns them into an Adaptive Card via CopilotKit's + * platform-agnostic JSX, then returns a short ack so the model doesn't restate + * the card in prose. + */ +const showCard = defineBotTool({ + name: "show_card", + description: + "Render a rich Adaptive Card in Teams. Call this whenever a summary, " + + "status report, comparison, set of facts, or tabular data would be clearer " + + "as a card than as plain prose. Prefer a card for anything structured.", + parameters: z.object({ + title: z.string().describe("Card header text"), + body: z.string().describe("A short intro paragraph (markdown allowed)"), + facts: z + .array(z.object({ label: z.string(), value: z.string() })) + .optional() + .describe("Key/value facts rendered as a list"), + table: z + .object({ + columns: z.array(z.string()), + rows: z.array(z.array(z.string())), + }) + .optional() + .describe("Optional simple table; each row is an array of cell strings"), + }), + async handler({ title, body, facts, table }, { thread }) { + await thread.post( + +
{title}
+
{body}
+ {facts && facts.length > 0 ? ( + + {facts.map((f, i) => ( + {`${f.label}: ${f.value}`} + ))} + + ) : null} + {table ? ( + ({ header }))}> + {table.rows.map((row, i) => ( + + {row.map((cell, j) => ( + {cell} + ))} + + ))} +
+ ) : null} +
, + ); + return "Displayed the card to the user. Give a one-line confirmation; do not restate the card's contents."; + }, +}); + +const bot = createBot({ + adapters: [teams({ port })], + agent: (threadId: string) => { + const agent = new SanitizingHttpAgent({ url: runtimeAgentUrl }); + agent.threadId = threadId; + return agent; + }, + tools: [showCard, renderChartTool, ...hitlTools], +}); + +// Run the agent on every message. It streams text by edit and renders Adaptive +// Cards on its own via the show_card tool. Uploaded files (e.g. a CSV) are +// recorded into the conversation transcript by the adapter — including their +// decoded contents — so `runAgent()` picks them up from the seeded history with +// no extra wiring, and they persist for follow-up turns. +bot.onMessage(async ({ thread, message }) => { + // A bare file upload with no accompanying text should still do something + // useful. The adapter only sets `contentParts` when it actually read file + // content, so this nudges the agent to act on a dropped-in CSV instead of + // running on an empty prompt and asking "what would you like me to do?". + const hasFile = (message.contentParts?.length ?? 0) > 0; + if (hasFile && message.text.trim().length === 0) { + await thread.runAgent({ + prompt: + "I uploaded a file with no other instructions. If it contains " + + "tabular or numeric data, chart it with render_chart (pick a sensible " + + "chart type); otherwise give me a short summary of what's in it.", + }); + return; + } + await thread.runAgent(); +}); + +await bot.start(); + +console.log( + `Teams demo bot listening at http://localhost:${port}/api/messages`, +); +console.log( + 'Run `pnpm playground`, then ask for a "summary" or "status" to see an ' + + "auto-rendered card, upload a CSV and ask for a chart to see render_chart, " + + 'or "announce X to the team" to see the HITL approval.', +); + +// Stop the bot cleanly on exit. +const shutdown = async (signal: string): Promise => { + console.log(`\nReceived ${signal}, stopping…`); + await bot.stop().catch(() => {}); + process.exit(0); +}; +process.on("SIGINT", () => void shutdown("SIGINT")); +process.on("SIGTERM", () => void shutdown("SIGTERM")); diff --git a/examples/teams/app/tools/render-chart.tsx b/examples/teams/app/tools/render-chart.tsx new file mode 100644 index 00000000000..112709efbf8 --- /dev/null +++ b/examples/teams/app/tools/render-chart.tsx @@ -0,0 +1,68 @@ +/** + * `render_chart` — the agent describes the data it wants to visualize and we + * render it as a **native Teams chart** (an Adaptive Card chart element), no + * image generation involved. This is the "upload a CSV → get a chart" payoff: + * the agent parses the data (the CSV arrives as readable text via the adapter's + * inbound-file handling), then calls this with a simple `{label, value}` series. + * + * Native charts render right inside the card, so there's no headless browser, + * no Chromium, and no PNG upload, so the bot stays a pure Node service. + */ +import { z } from "zod"; +import { defineBotTool } from "@copilotkit/bot"; +import { Message, Chart } from "@copilotkit/bot-ui"; + +const schema = z.object({ + chartType: z + .enum(["verticalBar", "horizontalBar", "line", "pie", "donut"]) + .optional() + .describe( + "Chart kind. Defaults to 'verticalBar'. Use 'line' for trends over " + + "time, 'pie'/'donut' for parts of a whole, 'horizontalBar' for ranked " + + "categories with long labels.", + ), + title: z.string().describe("Short title shown above the chart."), + xAxisTitle: z + .string() + .optional() + .describe("X-axis label (bar/line charts only)."), + yAxisTitle: z + .string() + .optional() + .describe("Y-axis label (bar/line charts only)."), + data: z + .array( + z.object({ + label: z + .string() + .describe("Category / x value, e.g. '2026-01' or 'Sev1'."), + value: z.number().describe("Numeric value for this category."), + }), + ) + .min(1) + .describe("The data to plot — one entry per category."), +}); + +export const renderChartTool = defineBotTool({ + name: "render_chart", + description: + "Render a native chart in the conversation. Provide a chartType, a title, " + + "and a `data` array of {label, value} points (inline the actual numbers). " + + "Use this to visualize data — e.g. after analyzing an uploaded CSV. The " + + "chart renders inline in Teams.", + parameters: schema, + async handler({ chartType, title, xAxisTitle, yAxisTitle, data }, ctx) { + await ctx.thread.post( + + + , + ); + return "Rendered and posted the chart. Give a one-line confirmation; do not restate the chart's data in prose."; + }, +}); diff --git a/examples/teams/appPackage/README.md b/examples/teams/appPackage/README.md new file mode 100644 index 00000000000..fb784aa96fb --- /dev/null +++ b/examples/teams/appPackage/README.md @@ -0,0 +1,38 @@ +# Teams app package + +The Teams app manifest + icons you sideload into Microsoft Teams to install the +bot. Build the `.zip` with one command, then upload it in **Teams → Apps → +Manage your apps → Upload a custom app**. + +## Build it + +From `examples/teams`: + +```sh +pnpm package +``` + +This validates everything Teams needs and writes `appPackage/appPackage.zip`: + +- **Bot id:** read from `MICROSOFT_APP_ID` / `CLIENT_ID` / `clientId` (env or + `examples/teams/.env`) and injected into `manifest.json`'s `bots[0].botId`, so + the committed manifest stays a placeholder and you never hardcode your id. Must + be the **Application (client) ID** (a GUID) of the Entra app bound to your + Azure Bot. +- **Icons:** `color.png` (192×192) and `outline.png` (32×32). Auto-generated as + CopilotKit-purple placeholders if missing; drop in your own PNGs of those exact + sizes to brand it. +- **Manifest:** checked for valid JSON and the required bot fields. + +If something's missing the script tells you exactly what and how to fix it. + +## What's here + +- `manifest.json`: the app manifest template (`botId` is a placeholder; the + build injects the real one). Edit `developer` / `name` / `description` to taste. +- `color.png` / `outline.png`: placeholder icons (regenerated if deleted). +- `package.mjs`: the dependency-free build script (`pnpm package`). +- `appPackage.zip`: the build output (gitignored). + +The bot only **replies** once your hosted endpoint is set as the Azure Bot +**messaging endpoint**. Installing the package just registers the bot in Teams. diff --git a/examples/teams/appPackage/color.png b/examples/teams/appPackage/color.png new file mode 100644 index 00000000000..25a443a246a Binary files /dev/null and b/examples/teams/appPackage/color.png differ diff --git a/examples/teams/appPackage/manifest.json b/examples/teams/appPackage/manifest.json new file mode 100644 index 00000000000..d7e5deed406 --- /dev/null +++ b/examples/teams/appPackage/manifest.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.25/MicrosoftTeams.schema.json", + "manifestVersion": "1.25", + "version": "1.0.0", + "id": "c3da40e5-a328-46aa-bef2-cbbaa61496e3", + "developer": { + "name": "CopilotKit", + "websiteUrl": "https://copilotkit.ai", + "privacyUrl": "https://copilotkit.ai/privacy", + "termsOfUseUrl": "https://copilotkit.ai/terms" + }, + "name": { + "short": "CopilotKit Bot", + "full": "CopilotKit Teams Bot" + }, + "description": { + "short": "A CopilotKit assistant for Microsoft Teams.", + "full": "A Microsoft Teams bot powered by CopilotKit (@copilotkit/bot-teams)." + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "accentColor": "#5B5FC7", + "supportsChannelFeatures": "tier1", + "bots": [ + { + "botId": "REPLACE_WITH_MICROSOFT_APP_ID", + "scopes": ["personal", "team", "groupChat"], + "supportsFiles": true, + "isNotificationOnly": false + } + ], + "permissions": ["identity", "messageTeamMembers"], + "validDomains": [], + "webApplicationInfo": { + "id": "REPLACE_WITH_MICROSOFT_APP_ID", + "resource": "https://graph.microsoft.com" + }, + "authorization": { + "permissions": { + "resourceSpecific": [ + { + "name": "ChannelMessage.Read.Group", + "type": "Application" + } + ] + } + } +} diff --git a/examples/teams/appPackage/outline.png b/examples/teams/appPackage/outline.png new file mode 100644 index 00000000000..53f6eb824cc Binary files /dev/null and b/examples/teams/appPackage/outline.png differ diff --git a/examples/teams/appPackage/package.mjs b/examples/teams/appPackage/package.mjs new file mode 100644 index 00000000000..e05e8ed4f2a --- /dev/null +++ b/examples/teams/appPackage/package.mjs @@ -0,0 +1,256 @@ +#!/usr/bin/env node +/** + * Build a sideload-ready Teams app package (`appPackage.zip`). + * + * Validates everything Teams needs, then zips `manifest.json` + icons: + * • Microsoft App (bot) id. Read from env (MICROSOFT_APP_ID / CLIENT_ID / + * clientId) or `.env`, and injected into the manifest's `bots[0].botId`, so + * the committed manifest stays a placeholder and nobody hardcodes their id. + * • Icons: `color.png` (192×192) and `outline.png` (32×32). Auto-generated as + * CopilotKit-purple placeholders if missing, so `pnpm package` always works. + * • Manifest: valid JSON with the required bot fields. + * + * Dependency-free (Node ≥ 18): pure-JS PNG writer + ZIP builder, no devDeps. + * + * pnpm package # -> examples/teams/appPackage/appPackage.zip + */ +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { deflateSync } from "node:zlib"; + +const here = dirname(fileURLToPath(import.meta.url)); +const p = (name) => join(here, name); + +const PURPLE = [91, 95, 199, 255]; // #5B5FC7 + +const GUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const fail = (msg, hint) => { + console.error(`\n❌ ${msg}`); + if (hint) console.error(` ${hint}`); + process.exit(1); +}; + +// ---------------------------------------------------------------- CRC-32 +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); +function crc32(buf) { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) + c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +// ------------------------------------------------------------ PNG writer +function pngChunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const typeBuf = Buffer.from(type, "ascii"); + const body = Buffer.concat([typeBuf, data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body), 0); + return Buffer.concat([len, body, crc]); +} +/** Write an 8-bit RGBA PNG. `pixel(x,y) -> [r,g,b,a]`. */ +function writePng(path, w, h, pixel) { + const raw = Buffer.alloc((w * 4 + 1) * h); + let o = 0; + for (let y = 0; y < h; y++) { + raw[o++] = 0; // filter: none + for (let x = 0; x < w; x++) { + const [r, g, b, a] = pixel(x, y); + raw[o++] = r; + raw[o++] = g; + raw[o++] = b; + raw[o++] = a; + } + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(w, 0); + ihdr.writeUInt32BE(h, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type RGBA + const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", ihdr), + pngChunk("IDAT", deflateSync(raw, { level: 9 })), + pngChunk("IEND", Buffer.alloc(0)), + ]); + writeFileSync(path, png); +} +/** Read a PNG's pixel dimensions from its IHDR (no decode). */ +function pngDimensions(buf) { + const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + if (buf.length < 24 || !sig.every((b, i) => buf[i] === b)) return undefined; + return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) }; +} + +// -------------------------------------------------------------- ZIP (stored) +function zip(entries) { + // Fixed 1980-01-01 timestamp (deterministic output). + const dosTime = 0; + const dosDate = 0x21; + const locals = []; + const centrals = []; + let offset = 0; + for (const { name, data } of entries) { + const nameBuf = Buffer.from(name, "utf8"); + const crc = crc32(data); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); // version needed + local.writeUInt16LE(0, 6); // flags + local.writeUInt16LE(0, 8); // method: stored + local.writeUInt16LE(dosTime, 10); + local.writeUInt16LE(dosDate, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); // compressed size + local.writeUInt32LE(data.length, 22); // uncompressed size + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); + locals.push(local, nameBuf, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(dosTime, 12); + central.writeUInt16LE(dosDate, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt32LE(offset, 42); + centrals.push(central, nameBuf); + + offset += local.length + nameBuf.length + data.length; + } + const centralStart = offset; + const centralBuf = Buffer.concat(centrals); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(entries.length, 8); + eocd.writeUInt16LE(entries.length, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(centralStart, 16); + return Buffer.concat([...locals, centralBuf, eocd]); +} + +// ----------------------------------------------------------- env resolution +function parseEnv(path) { + const out = {}; + if (!existsSync(path)) return out; + for (const line of readFileSync(path, "utf8").split("\n")) { + const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (m && !line.trimStart().startsWith("#")) out[m[1]] = m[2].trim(); + } + return out; +} + +function main() { + const env = { ...parseEnv(p("../.env")), ...process.env }; + const manifestPath = p("manifest.json"); + + if (!existsSync(manifestPath)) + fail("manifest.json not found next to this script."); + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch (e) { + return fail(`manifest.json is not valid JSON: ${e.message}`); + } + if (!manifest.bots?.[0]) { + fail( + "manifest.json has no bots[0] entry.", + "A Teams bot manifest needs a `bots` array.", + ); + } + + // Resolve the Microsoft App (bot) id: env wins; else a real id already in the + // manifest; else fail with guidance. + const placeholder = "REPLACE_WITH_MICROSOFT_APP_ID"; + const fromEnv = env.MICROSOFT_APP_ID || env.CLIENT_ID || env.clientId || ""; + const fromManifest = + manifest.bots[0].botId !== placeholder ? manifest.bots[0].botId : ""; + const botId = (fromEnv || fromManifest).trim(); + + if (!botId) { + fail( + "No Microsoft App id found.", + "Set MICROSOFT_APP_ID (or clientId) in examples/teams/.env. It's the\n" + + " Application (client) ID of the Entra app bound to your Azure Bot.", + ); + } + if (!GUID_RE.test(botId)) { + fail( + `Microsoft App id "${botId}" is not a GUID.`, + "Use the Application (client) ID (a GUID), not the secret or its id.", + ); + } + manifest.bots[0].botId = botId; + // RSC: webApplicationInfo.id must be the same Entra app (client) id so Teams + // can grant the resource-specific ChannelMessage.Read.Group permission to it. + if (manifest.webApplicationInfo) manifest.webApplicationInfo.id = botId; + + // Icons: validate, auto-generating CopilotKit-purple placeholders if missing. + const requireIcon = (name, size, make) => { + const path = p(name); + if (!existsSync(path)) { + make(path); + console.log(`• generated placeholder ${name} (${size}×${size})`); + return; + } + const dims = pngDimensions(readFileSync(path)); + if (!dims) fail(`${name} is not a valid PNG.`); + if (dims.w !== size || dims.h !== size) { + fail( + `${name} must be ${size}×${size}, found ${dims.w}×${dims.h}.`, + "Replace it with a correctly-sized PNG, or delete it to auto-generate one.", + ); + } + }; + requireIcon("color.png", 192, (path) => + writePng(path, 192, 192, () => PURPLE), + ); + requireIcon("outline.png", 32, (path) => { + const cx = 15.5; + const cy = 15.5; + const r = 14; + writePng(path, 32, 32, (x, y) => + (x - cx) ** 2 + (y - cy) ** 2 <= r * r + ? [255, 255, 255, 255] + : [0, 0, 0, 0], + ); + }); + + // Build the zip with the resolved manifest (manifest.json at the root). + const entries = [ + { + name: "manifest.json", + data: Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf8"), + }, + { name: "color.png", data: readFileSync(p("color.png")) }, + { name: "outline.png", data: readFileSync(p("outline.png")) }, + ]; + const outPath = p("appPackage.zip"); + writeFileSync(outPath, zip(entries)); + + console.log(`\n✅ Built appPackage.zip (botId ${botId})`); + console.log( + " Upload in Teams → Apps → Manage your apps → Upload a custom app.", + ); + console.log(` ${outPath}\n`); +} + +main(); diff --git a/examples/teams/package.json b/examples/teams/package.json new file mode 100644 index 00000000000..8a7ad6c48ac --- /dev/null +++ b/examples/teams/package.json @@ -0,0 +1,32 @@ +{ + "name": "teams-example", + "version": "0.0.1", + "private": true, + "description": "Runnable demo for @copilotkit/bot-teams: streamed replies, Adaptive Cards, and a human-in-the-loop approval gate, testable in the Microsoft 365 Agents Playground with no Microsoft credentials.", + "license": "MIT", + "type": "module", + "scripts": { + "dev": "tsx watch app/index.tsx", + "start": "tsx app/index.tsx", + "build": "pnpm exec nx run-many -t build -p @copilotkit/bot @copilotkit/bot-teams @copilotkit/bot-ui @copilotkit/runtime", + "playground": "agentsplayground", + "package": "node appPackage/package.mjs", + "check-types": "tsc --noEmit -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "@copilotkit/bot": "workspace:*", + "@copilotkit/bot-teams": "workspace:*", + "@copilotkit/bot-ui": "workspace:*", + "@copilotkit/runtime": "workspace:*", + "zod": "^3.25.76" + }, + "devDependencies": { + "@microsoft/m365agentsplayground": "^0.2.27", + "@types/node": "^22.10.0", + "dotenv": "^16.4.5", + "tsx": "^4.19.2", + "typescript": "^5.6.3", + "vitest": "^4.1.3" + } +} diff --git a/examples/teams/scripts/verify-graph-channel.ts b/examples/teams/scripts/verify-graph-channel.ts new file mode 100644 index 00000000000..29c85a04694 --- /dev/null +++ b/examples/teams/scripts/verify-graph-channel.ts @@ -0,0 +1,145 @@ +/** + * Standalone verification of the app-only Graph channel-file chain — the exact + * sequence `@copilotkit/bot-teams` runs in a channel, WITHOUT the bot, a tunnel, + * an Azure Bot resource, or Chromium. Use it to prove the permission model in a + * tenant where YOU are the admin (e.g. a free Microsoft 365 Developer sandbox) + * before asking your real org's admin to consent. + * + * Setup (in the sandbox, where you're Global Admin): + * 1. Entra → App registrations → New registration (single tenant is fine). + * 2. API permissions → add Microsoft Graph APPLICATION permissions + * - ChannelMessage.Read.All (read the channel message) + * - Files.Read.All (download the SharePoint file) + * → Grant admin consent (you can — you're the admin). + * 3. Certificates & secrets → new client secret. + * 4. Create a team + channel, upload a CSV to the channel. + * 5. Get the team + channel ids: open the channel → ••• → "Get link to + * channel". The link has groupId= and the channel id is the + * "19:....@thread.tacv2" segment (URL-decode %3a → :, %40 → @). + * + * Run: + * CLIENT_ID=... CLIENT_SECRET=... TENANT_ID=... \ + * TEAM_ID=... CHANNEL_ID='19:xxxx@thread.tacv2' \ + * pnpm --filter teams-example exec tsx scripts/verify-graph-channel.ts + */ +const GRAPH = "https://graph.microsoft.com/v1.0"; + +function need(name: string): string { + const v = process.env[name]; + if (!v) { + console.error(`Missing env var ${name} (see the header of this file).`); + process.exit(1); + } + return v; +} + +function shareIdFor(url: string): string { + const b64 = Buffer.from(url, "utf8").toString("base64"); + return "u!" + b64.replace(/=+$/, "").replace(/\//g, "_").replace(/\+/g, "-"); +} + +async function main(): Promise { + const clientId = need("CLIENT_ID"); + const clientSecret = need("CLIENT_SECRET"); + const tenantId = need("TENANT_ID"); + const teamId = need("TEAM_ID"); + const channelId = need("CHANNEL_ID"); + + // Step 1 — app-only token (client credentials), exactly like the bot. + console.log("[1/3] Acquiring app-only Graph token…"); + const tokenRes = await fetch( + `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + scope: "https://graph.microsoft.com/.default", + grant_type: "client_credentials", + }), + }, + ); + if (!tokenRes.ok) { + console.error( + ` ✗ token failed (HTTP ${tokenRes.status}): ${await tokenRes.text()}`, + ); + process.exit(1); + } + const token = ((await tokenRes.json()) as { access_token: string }) + .access_token; + console.log(" ✓ got a token"); + + // Step 2 — read the channel's recent messages, find file attachments. + console.log( + "[2/3] Reading channel messages (needs ChannelMessage.Read.All)…", + ); + const msgsRes = await fetch( + `${GRAPH}/teams/${teamId}/channels/${encodeURIComponent(channelId)}/messages?$top=20`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!msgsRes.ok) { + console.error( + ` ✗ read messages failed (HTTP ${msgsRes.status}): ${await msgsRes.text()}`, + ); + console.error( + " → ChannelMessage.Read.All probably isn't consented yet.", + ); + process.exit(1); + } + const msgs = (await msgsRes.json()) as { + value?: Array<{ + id?: string; + attachments?: Array<{ + contentType?: string; + contentUrl?: string; + name?: string; + }>; + }>; + }; + const files = (msgs.value ?? []) + .flatMap((m) => m.attachments ?? []) + .filter((a) => a.contentType === "reference" && a.contentUrl); + console.log( + ` ✓ read ${msgs.value?.length ?? 0} messages; found ${files.length} file attachment(s)`, + ); + if (files.length === 0) { + console.error( + " → No file attachments in the last 20 messages. Upload a CSV to this channel and rerun.", + ); + process.exit(1); + } + for (const f of files) console.log(` • ${f.name} ${f.contentUrl}`); + + // Step 3 — download each file from SharePoint via /shares. + console.log("[3/3] Downloading from SharePoint (needs Files.Read.All)…"); + for (const f of files) { + const dlRes = await fetch( + `${GRAPH}/shares/${shareIdFor(f.contentUrl!)}/driveItem/content`, + { headers: { Authorization: `Bearer ${token}` }, redirect: "follow" }, + ); + if (!dlRes.ok) { + console.error( + ` ✗ download "${f.name}" failed (HTTP ${dlRes.status}): ${await dlRes.text()}`, + ); + console.error(" → Files.Read.All probably isn't consented yet."); + process.exit(1); + } + const bytes = Buffer.from(await dlRes.arrayBuffer()); + const preview = bytes.toString("utf8").slice(0, 120).replace(/\n/g, "\\n"); + console.log( + ` ✓ "${f.name}" — ${bytes.byteLength} bytes; starts: "${preview}"`, + ); + } + + console.log( + "\n✅ Verified: app-only token → read channel message → download file all work.\n" + + " This is exactly what the bot does; granting the same two permissions in\n" + + " your real tenant makes the channel CSV→chart flow work there too.", + ); +} + +main().catch((err) => { + console.error("Unexpected failure:", err); + process.exit(1); +}); diff --git a/examples/teams/tsconfig.json b/examples/teams/tsconfig.json new file mode 100644 index 00000000000..889068cb833 --- /dev/null +++ b/examples/teams/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "isolatedModules": true, + "module": "NodeNext", + "moduleDetection": "force", + "moduleResolution": "NodeNext", + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "noEmit": true, + "lib": ["es2022", "dom"], + "types": ["node"], + "jsx": "react-jsx", + "jsxImportSource": "@copilotkit/bot-ui" + }, + "include": ["app/**/*.ts", "app/**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/examples/teams/vitest.config.ts b/examples/teams/vitest.config.ts new file mode 100644 index 00000000000..2d73ffdd564 --- /dev/null +++ b/examples/teams/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + esbuild: { + jsx: "automatic", + jsxImportSource: "@copilotkit/bot-ui", + }, + test: { + include: ["app/**/*.test.ts", "app/**/*.test.tsx"], + }, +}); diff --git a/packages/bot-teams/ARCHITECTURE.md b/packages/bot-teams/ARCHITECTURE.md new file mode 100644 index 00000000000..3f32b52ef7e --- /dev/null +++ b/packages/bot-teams/ARCHITECTURE.md @@ -0,0 +1,131 @@ +# Architecture + +`@copilotkit/bot-teams` is a concrete `PlatformAdapter` for +[`@copilotkit/bot`](../bot): it plugs Microsoft Teams into the +platform-agnostic bot engine, exactly as [`@copilotkit/bot-slack`](../bot-slack) +does for Slack. You write the bot once (handlers, JSX, tools, context) and this +package translates between the engine and Teams via the **Microsoft 365 Agents +SDK** (`@microsoft/agents-hosting`). + +## Design goals + +- **The agent is ignorant of Teams.** Tool/handler code uses the engine's + platform-agnostic surface (`thread.post`, `thread.stream`, + `thread.awaitChoice`, bot-ui JSX). Nothing Teams-specific leaks up. +- **Teams mechanics are contained.** Adaptive Card rendering, streamed-by-edit + updates, card-action decoding, and proactive auth all live behind the + `PlatformAdapter` boundary. +- **Failure isolation.** One bad turn (e.g. a Bot Connector error) is logged and + contained, so it never crashes the process or takes down other conversations. + +## The boundary: `PlatformAdapter` + +`TeamsAdapter` (in `adapter.ts`) implements the engine's `PlatformAdapter`: +ingress normalization, egress (`post` / `update` / `delete` / streamed edits), +IR→native rendering, capability flags, and the conversation store. `teams(opts)` +is the thin factory most callers use. + +## Request lifecycle + +``` +Teams ──HTTP──▶ POST /api/messages (listener.ts, express) + │ CloudAdapter.process ── authenticates, builds TurnContext + ▼ + handleActivity (adapter.ts) + │ message? → sink.onTurn(...) → engine runs handlers / agent + │ card submit? → sink.onInteraction(...) → engine resolves awaitChoice + ▼ + egress: render IR → Adaptive Card | Markdown text, sent on a + TurnContext (proactive when credentialed; see below) +``` + +### Ingress + +`createTeamsServer` (`listener.ts`) stands up `POST /api/messages` (+ a +`/healthz` liveness probe) and hands each inbound activity to +`CloudAdapter.process`, which authenticates the request and invokes +`handleActivity`. The `process` promise is `.catch`-contained so a failed turn +returns 500 instead of crashing the process. + +### Proactive vs in-turn (the credentialed split) + +How the bot replies depends on whether it has Microsoft credentials: + +- **Credentialed (real Teams):** ingress acks the inbound turn immediately and + runs the work on a **detached `continueConversation` context** authenticated + by the app id. This lets an `awaitChoice` suspend outlive the ~15s Teams turn + window (an approval can land minutes later), and (critically) it is the + _authenticated_ context. The inbound turn's own connector client is created + with an **anonymous identity**, so using it for outbound calls + (`sendActivity`/`updateActivity`) is rejected `401`. **Both** ordinary replies + **and** card interactions therefore run on the proactive context. +- **Anonymous (local M365 Agents Playground):** `continueConversation` needs an + app id we don't have, so work runs on the inbound turn context. localhost + holds that connection open across an `awaitChoice` suspend, and the Playground + doesn't enforce connector auth, so the anonymous context is fine there. + +### Run / render + +`createRunRenderer` (`event-renderer.ts`) subscribes to the agent's AG-UI event +stream and bridges it to Teams: each text message is **streamed by edit**. It posts +once (after a typing indicator), then `updateActivity` edits it as the buffer grows, +throttled and serialised by `TeamsMessageStream` (`message-stream.ts`). Mid-stream +buffers are balanced by `autoCloseOpenMarkdown` (`render/auto-close.ts`) so an +in-flight `**`/code-fence never renders broken; the finalized message commits the +agent's exact (balanced) text. Tool calls and interrupts are captured for the +run-loop to read after `runAgent` resolves. + +### Rendering + +`render(ir)` chooses the surface: a reply that collapses to plain text +(`isPlainText`) is sent as a normal **Markdown** text activity (a bare `Echo: hi` +shouldn't be a card); anything structured/interactive becomes an **Adaptive Card +1.5** attachment (`render/adaptive-card.ts`). Both renderers clamp to +`TEAMS_LIMITS` (`render/budget.ts`) to stay within Teams' payload ceilings. + +### HITL & interrupts + +A tool handler that calls `await thread.awaitChoice()` posts an approval +Adaptive Card and suspends the run. The card's buttons are `Action.Submit`s +carrying an opaque `ckActionId` + tiny value in their `data`. The click arrives +as a Message activity; `parseCardAction` / `decodeInteraction` (`interaction.ts`) +recognise it and route it to `sink.onInteraction`, which resolves the waiter and +runs the button's `onClick` (e.g. editing the card in place). Ingress and +interaction decoding derive the conversation key from one shared helper +(`conversationKeyOf`) so the waiter always resolves. + +### Conversation store + +Teams does not hand the bot a queryable transcript (unlike Slack's +`conversations.history`), so `TeamsConversationStore` (`conversation-store.ts`) +keeps an **in-memory** transcript per conversation and seeds each agent run with +it. It implements the engine's `ConversationStore` interface, so a durable +backend can be swapped in for production (today the store and any pending +`awaitChoice` waiters do not survive a restart). + +## SDK files at a glance + +| File | Role | +| -------------------------- | -------------------------------------------------------------------- | +| `adapter.ts` | `PlatformAdapter`: ingress, egress, proactive auth, rendering | +| `listener.ts` | express server: `POST /api/messages` + `/healthz`, error containment | +| `event-renderer.ts` | AG-UI → streamed-by-edit + tool/interrupt capture | +| `message-stream.ts` | throttled, serialised post-then-edit state machine | +| `render/adaptive-card.ts` | bot-ui IR → Adaptive Card 1.5 (+ HITL action ids) | +| `render/markdown.ts` | bot-ui IR → Markdown (plain-text path) | +| `render/auto-close.ts` | balances mid-stream markdown for clean edits | +| `render/budget.ts` | per-element limits, truncation/clamping | +| `interaction.ts` | decode `Action.Submit` → engine `InteractionEvent` | +| `conversation-store.ts` | in-memory transcript (pluggable for durability) | +| `sanitizing-http-agent.ts` | `HttpAgent` tolerant of `@ag-ui/langgraph` event quirks | + +## What's intentionally _not_ done yet + +The architecture leaves room for each; none is required for the core loop: + +- **Native token streaming:** replies stream by post-then-edit, not via the + SDK's `StreamingResponse` (`queueTextChunk`/`endStream`). +- **Durable conversation store + HITL waiters:** in-memory today. +- **File upload/download** and **Microsoft Graph user lookup:** not wired. + +These mirror the deferred items in the README's roadmap. diff --git a/packages/bot-teams/README.md b/packages/bot-teams/README.md new file mode 100644 index 00000000000..0eb7bc233e0 --- /dev/null +++ b/packages/bot-teams/README.md @@ -0,0 +1,141 @@ +# @copilotkit/bot-teams + +The **Microsoft Teams platform adapter** for [`@copilotkit/bot`](../bot). It's a +concrete `PlatformAdapter` that plugs Teams into the platform-agnostic bot +engine, exactly like [`@copilotkit/bot-slack`](../bot-slack) does for Slack. You +write your bot once with `createBot` (handlers, JSX, tools, context) and run it +on Teams by adding this adapter. + +It is built on the **Microsoft 365 Agents SDK** (`@microsoft/agents-hosting`), +the successor to the Bot Framework SDK. + +## Install + +```sh +pnpm add @copilotkit/bot @copilotkit/bot-ui @copilotkit/bot-teams +``` + +## Quickstart + +```ts +import { createBot } from "@copilotkit/bot"; +import { teams } from "@copilotkit/bot-teams"; + +const bot = createBot({ + adapters: [teams({ port: 3978 })], +}); + +bot.onMessage(({ thread, message }) => thread.post(`Echo: ${message.text}`)); + +await bot.start(); // POST /api/messages now listening on :3978 +``` + +Then point the **Microsoft 365 Agents Playground** at it. No Microsoft +credentials are required for local development: + +```sh +npx @microsoft/m365agentsplayground # opens http://localhost:56150 +``` + +The Playground connects to `http://127.0.0.1:3978/api/messages` and gives you a +Teams-like chat UI to test against. See [`examples/teams`](../../examples/teams) +for a complete, runnable echo bot, and the +[Microsoft Teams guide](../../showcase/shell-docs/src/content/docs/frontends/teams.mdx) +for sideloading into real Teams via Azure Bot Service. + +## How it maps onto the `PlatformAdapter` contract + +- **Ingress:** a `CloudAdapter` receives Teams activities at + `POST /api/messages` (stood up by an Express server). Each `message` activity + is normalized into `sink.onTurn(...)`. Uploaded files ride along as + attachments: `buildFileContentParts` downloads them (a `file.download.info` + URL, or a `data:`/https media URL) and hands the agent multimodal content + parts — CSV/JSON/text as decoded text, images and PDFs as binary. That's what + makes "upload a CSV → get a chart" work. Note Teams only delivers uploaded + files to a bot in **1:1 (personal) chat** (requires `supportsFiles: true` in + the app manifest); in a channel or group chat Teams does NOT send the file to + the bot at all, so chart-from-data there means pasting the data inline. +- **Egress:** structured/interactive UI is rendered to an **Adaptive Card** + (1.5) and sent as an attachment; a reply that collapses to plain text is sent + as a normal text activity (a bare `Echo: hi` shouldn't be a card). Both go out + on the live `TurnContext` _within the originating turn_. The engine awaits the + whole turn handler, so a reply (or a full `runAgent()` loop) completes before + the HTTP response closes. (Out-of-turn / proactive sends fall back to + `CloudAdapter.continueConversation` via the captured conversation reference.) +- **Files out:** `postFile` posts a file to the conversation. An image (e.g. a + rendered chart PNG) is sent as an inline attachment via a `data:` URI, so it + renders directly in the thread — the bot-slack `postFile` parallel. +- **Streaming:** text replies stream **by message edit** (Teams' baseline + model). It posts the first content, then `updateActivity` edits the same + message as the buffer grows (throttled and serialised; see + `TeamsMessageStream`), after a typing indicator. Native token streaming is a + later enhancement. +- **Agent runs:** `createRunRenderer` bridges AG-UI events to Teams. Each text + message is streamed by edit, and tool calls plus interrupts are captured for + the run loop. +- **History:** Teams does not hand the bot a queryable transcript, so an + in-memory `TeamsConversationStore` keeps one per conversation and seeds each + agent run with it. Swap in a durable `ConversationStore` for production. + +## Options + +```ts +teams({ + port: 3978, // POST /api/messages port (Playground default) + clientId, // Microsoft app id; omit for anonymous local dev + clientSecret, // omit for anonymous local dev + tenantId, // omit for multi-tenant / anonymous + interruptEventNames, // custom-event names treated as agent interrupts +}); +``` + +Credentials also resolve from the `clientId` / `clientSecret` / `tenantId` +environment variables (the names the M365 Agents SDK reads). + +## Status & roadmap + +Implemented: message ingress; **Adaptive Card rendering** of the bot-ui +vocabulary (`
`, `
`/``, ``, ``, +``, ``/`
as a native Table with a header row", () => { + const card = renderAdaptiveCard([ + el( + "table", + [el("row", [el("cell", [text("a1")]), el("cell", [text("b1")])])], + { + columns: [{ header: "A" }, { header: "B", align: "right" }], + }, + ), + ]); + const table = card.body[0] as Record; + expect(table.type).toBe("Table"); + expect(table.firstRowAsHeader).toBe(true); + const rows = table.rows as Array>; + expect(rows).toHaveLength(2); // header + 1 data row + expect(rows[0]!.type).toBe("TableRow"); + }); + + it("clamps top-level actions to the Teams ceiling", () => { + const buttons = Array.from({ length: 10 }, (_, i) => + el("button", [text(`b${i}`)], { onClick: { id: `ck:${i}` } }), + ); + const card = renderAdaptiveCard([el("actions", buttons)]); + expect(card.actions).toHaveLength(6); + }); + + it("skips unknown intrinsics without throwing", () => { + const card = renderAdaptiveCard([el("mystery", [text("x")])]); + expect(card.body).toHaveLength(0); + }); + + it("renders a vertical-bar with title and axis titles", () => { + const card = renderAdaptiveCard([ + chart({ + title: "Tickets", + xAxisTitle: "Month", + yAxisTitle: "Count", + data: [ + { label: "Jan", value: 3 }, + { label: "Feb", value: 7 }, + ], + }), + ]); + expect(card.body[0]).toMatchObject({ + type: "Chart.VerticalBar", + title: "Tickets", + showTitle: true, + showBarValues: true, + maxWidth: "520px", + xAxisTitle: "Month", + yAxisTitle: "Count", + data: [ + { x: "Jan", y: 3 }, + { x: "Feb", y: 7 }, + ], + }); + }); + + it("defaults an absent chart type to a vertical bar", () => { + const card = renderAdaptiveCard([ + chart({ title: "T", data: [{ label: "a", value: 1 }] }), + ]); + expect((card.body[0] as Record).type).toBe( + "Chart.VerticalBar", + ); + }); + + it("maps pie/donut to legend+value slices (no axes)", () => { + const data = [ + { label: "Open", value: 4 }, + { label: "Closed", value: 6 }, + ]; + const pie = renderAdaptiveCard([chart({ type: "pie", data })]); + expect(pie.body[0]).toMatchObject({ + type: "Chart.Pie", + data: [ + { legend: "Open", value: 4 }, + { legend: "Closed", value: 6 }, + ], + }); + expect((pie.body[0] as Record).xAxisTitle).toBeUndefined(); + const donut = renderAdaptiveCard([chart({ type: "donut", data })]); + expect((donut.body[0] as Record).type).toBe("Chart.Donut"); + }); + + it("maps line to a single legended series of values", () => { + const card = renderAdaptiveCard([ + chart({ + type: "line", + title: "Revenue", + data: [{ label: "Q1", value: 100 }], + }), + ]); + expect(card.body[0]).toMatchObject({ + type: "Chart.Line", + data: [{ legend: "Revenue", values: [{ x: "Q1", y: 100 }] }], + }); + }); + + it("maps horizontalBar to an x/y series", () => { + const card = renderAdaptiveCard([ + chart({ type: "horizontalBar", data: [{ label: "A", value: 5 }] }), + ]); + expect(card.body[0]).toMatchObject({ + type: "Chart.HorizontalBar", + data: [{ x: "A", y: 5 }], + }); + }); + + it("clamps chart data points to the Teams ceiling", () => { + const data = Array.from({ length: 80 }, (_, i) => ({ + label: `p${i}`, + value: i, + })); + const card = renderAdaptiveCard([chart({ data })]); + const points = (card.body[0] as { data: unknown[] }).data; + expect(points).toHaveLength(50); + }); +}); + +describe("isPlainText", () => { + it("is true for text-only trees", () => { + expect(isPlainText([text("hi")])).toBe(true); + expect(isPlainText([el("message", [el("section", [text("hi")])])])).toBe( + true, + ); + }); + + it("is false once any rich element appears", () => { + expect(isPlainText([el("header", [text("hi")])])).toBe(false); + expect(isPlainText([el("actions", [el("button", [text("x")])])])).toBe( + false, + ); + expect(isPlainText([el("message", [chart({ data: [] })])])).toBe(false); + }); +}); + +describe("collectPlainText", () => { + it("joins block text depth-first", () => { + const ir = [el("message", [el("section", [text("a")]), text("b")])]; + expect(collectPlainText(ir)).toBe("ab"); + }); +}); diff --git a/packages/bot-teams/src/render/adaptive-card.ts b/packages/bot-teams/src/render/adaptive-card.ts new file mode 100644 index 00000000000..5e1f7c51b9c --- /dev/null +++ b/packages/bot-teams/src/render/adaptive-card.ts @@ -0,0 +1,423 @@ +import type { BotNode } from "@copilotkit/bot-ui"; +import { TEAMS_LIMITS, truncateText, clampArray } from "./budget.js"; + +/** Teams attachment content type for an Adaptive Card. */ +export const ADAPTIVE_CARD_CONTENT_TYPE = + "application/vnd.microsoft.card.adaptive"; + +/** A minimally-typed Adaptive Card (1.5). Elements/actions are open bags: the + * schema is large and we only emit a curated subset. */ +export interface AdaptiveCard { + type: "AdaptiveCard"; + $schema: string; + version: string; + body: CardElement[]; + actions?: CardAction[]; +} +type CardElement = Record; +type CardAction = Record; + +const SCHEMA = "http://adaptivecards.io/schemas/adaptive-card.json"; +const VERSION = "1.5"; + +/** + * Render a cross-platform component IR tree (already expanded by `renderToIR` + * and pre-bound by the action registry, so event props are `{ id }`) into a + * Teams **Adaptive Card** (1.5). + * + * Structural nodes map to body elements (`
`→bold `TextBlock`, + * `
`/``→wrapped `TextBlock`, ``→`FactSet`, + * `
`→native `Table`, ``→`Image`). Interactive nodes split by + * Adaptive Card shape: `
` → a native Adaptive Cards `Table` (1.5). */ +function renderTable(node: BotNode): CardElement { + const props = node.props ?? {}; + const cell = (text: string, header = false): Record => ({ + type: "TableCell", + items: [ + { + type: "TextBlock", + text: truncateText(text, TEAMS_LIMITS.cellText), + wrap: true, + ...(header ? { weight: "Bolder" } : {}), + }, + ], + }); + + const columnsProp = props.columns as + | { header: string; align?: "left" | "center" | "right" }[] + | undefined; + const columns = columnsProp + ? clampArray(columnsProp, TEAMS_LIMITS.tableColumns).items + : undefined; + + const rows: Record[] = []; + if (columns && columns.length > 0) { + rows.push({ + type: "TableRow", + cells: columns.map((c) => cell(c.header, true)), + }); + } + const rowNodes = childNodes(node).filter((c) => c.type === "row"); + const { items: dataRows } = clampArray(rowNodes, TEAMS_LIMITS.tableRows); + for (const rowNode of dataRows) { + const cells = childNodes(rowNode).filter((c) => c.type === "cell"); + rows.push({ + type: "TableRow", + cells: cells.map((c) => cell(collectText(c))), + }); + } + + const table: CardElement = { + type: "Table", + columns: (columns ?? inferColumns(rowNodes)).map((c) => ({ + width: 1, + ...(typeof c === "object" && "align" in c && c.align + ? { horizontalCellContentAlignment: capitalize(c.align) } + : {}), + })), + rows, + firstRowAsHeader: !!(columns && columns.length > 0), + gridStyle: "default", + }; + return table; +} + +/** + * A `` → a native Teams chart element (`Chart.VerticalBar` / + * `Chart.HorizontalBar` / `Chart.Line` / `Chart.Pie` / `Chart.Donut`). These + * are a Teams host extension: they render in Teams clients whose app manifest + * opts into chart support; other Adaptive Card hosts ignore the unknown + * element. Data points clamp and labels/title truncate to the budget. + */ +function renderChart(node: BotNode): CardElement { + const props = node.props ?? {}; + const type = String(props.type ?? "verticalBar"); + const title = + props.title != null && String(props.title).length > 0 + ? truncateText(String(props.title), TEAMS_LIMITS.chartTitle) + : undefined; + + const rawData = Array.isArray(props.data) + ? (props.data as { label?: unknown; value?: unknown }[]) + : []; + const { items } = clampArray(rawData, TEAMS_LIMITS.chartDataPoints); + const points = items.map((p) => ({ + label: truncateText(String(p?.label ?? ""), TEAMS_LIMITS.chartLabel), + value: Number.isFinite(Number(p?.value)) ? Number(p?.value) : 0, + })); + + // Fields shared by every chart kind. `showTitle` is meaningless without a + // title; `maxWidth` keeps the chart from stretching the whole card. + const common: CardElement = { maxWidth: "520px" }; + if (title !== undefined) { + common.title = title; + common.showTitle = true; + } + // Axis titles apply to the cartesian charts (bar/line), not pie/donut. + const withAxes = (el: CardElement): CardElement => { + if (props.xAxisTitle != null) el.xAxisTitle = String(props.xAxisTitle); + if (props.yAxisTitle != null) el.yAxisTitle = String(props.yAxisTitle); + return el; + }; + const xy = points.map((p) => ({ x: p.label, y: p.value })); + const slices = points.map((p) => ({ legend: p.label, value: p.value })); + + switch (type) { + case "horizontalBar": + return withAxes({ ...common, type: "Chart.HorizontalBar", data: xy }); + case "line": + return withAxes({ + ...common, + type: "Chart.Line", + data: [{ legend: title ?? "", values: xy }], + }); + case "pie": + return { ...common, type: "Chart.Pie", data: slices }; + case "donut": + return { ...common, type: "Chart.Donut", data: slices }; + default: + // verticalBar — also the fallback for any unrecognized type. + return withAxes({ + ...common, + type: "Chart.VerticalBar", + showBarValues: true, + data: xy, + }); + } +} + +/** When no explicit `columns` are given, size the grid to the widest row. */ +function inferColumns(rowNodes: BotNode[]): { align?: undefined }[] { + let widest = 0; + for (const r of rowNodes) { + const n = childNodes(r).filter((c) => c.type === "cell").length; + if (n > widest) widest = n; + } + return Array.from( + { length: Math.min(widest, TEAMS_LIMITS.tableColumns) }, + () => ({}), + ); +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** Extract `{ id }` stamped onto an event prop by the action registry, if present. */ +function idFromHandler(handler: unknown): string | undefined { + if (handler && typeof handler === "object" && "id" in handler) { + const id = (handler as { id?: unknown }).id; + if (typeof id === "string") return id; + } + return undefined; +} + +/** The expanded `children` of an IR node as a `BotNode[]` (empty if none). */ +function childNodes(node: BotNode): BotNode[] { + const children = node.props?.children; + if (Array.isArray(children)) return children as BotNode[]; + if ( + children && + typeof children === "object" && + "type" in (children as object) + ) { + return [children as BotNode]; + } + return []; +} + +/** Concatenate the `value` of all descendant `text` nodes (depth-first). */ +function collectText(node: BotNode): string { + if (typeof node.type === "string" && node.type === "text") { + return String(node.props?.value ?? ""); + } + let acc = ""; + for (const child of childNodes(node)) acc += collectText(child); + return acc; +} + +/** + * Does this IR collapse to plain text (no structural or interactive elements)? + * Such replies are sent as a normal Teams text activity rather than wrapped in + * an Adaptive Card. A bare `Echo: hi` shouldn't render as a card. + */ +export function isPlainText(ir: BotNode[]): boolean { + const RICH = new Set([ + "header", + "fields", + "field", + "table", + "row", + "cell", + "chart", + "image", + "actions", + "button", + "select", + "input", + "divider", + "context", + ]); + const visit = (node: BotNode): boolean => { + if (typeof node.type === "string" && RICH.has(node.type)) return false; + return childNodes(node).every(visit); + }; + return ir.every(visit); +} + +/** Plain-text projection of an IR tree (depth-first text, blocks joined). */ +export function collectPlainText(ir: BotNode[]): string { + return ir + .map((n) => collectText(n)) + .filter((s) => s.length > 0) + .join("\n\n") + .trim(); +} diff --git a/packages/bot-teams/src/render/auto-close.test.ts b/packages/bot-teams/src/render/auto-close.test.ts new file mode 100644 index 00000000000..425c11b4538 --- /dev/null +++ b/packages/bot-teams/src/render/auto-close.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { autoCloseOpenMarkdown as ac } from "./auto-close.js"; + +describe("autoCloseOpenMarkdown", () => { + it("leaves empty and already-balanced text untouched (no synthetic closers)", () => { + expect(ac("")).toBe(""); + expect(ac("plain text")).toBe("plain text"); + expect(ac("**bold** and _italic_")).toBe("**bold** and _italic_"); + expect(ac("```js\nconst x = 1;\n```")).toBe("```js\nconst x = 1;\n```"); + }); + + it("closes an unclosed bold/italic/strike that has content", () => { + expect(ac("**bold")).toBe("**bold**"); + expect(ac("_italic")).toBe("_italic_"); + expect(ac("~~strike")).toBe("~~strike~~"); + }); + + it("closes nested markers innermost-first", () => { + expect(ac("**bold _italic")).toBe("**bold _italic_**"); + }); + + it("inserts closers before trailing whitespace", () => { + expect(ac("**bold ")).toBe("**bold** "); + }); + + it("does NOT close a marker with no content after it (avoids transient ****)", () => { + expect(ac("hello **")).toBe("hello **"); + expect(ac("text _")).toBe("text _"); + }); + + it("closes an open inline code span with content, but not a bare opener", () => { + expect(ac("run `npm test")).toBe("run `npm test`"); + expect(ac("a paired `span` then more")).toBe("a paired `span` then more"); + }); + + it("closes an open fence only once there's code past the language line", () => { + expect(ac("```js")).toBe("```js"); // still on the language line + expect(ac("```js\n")).toBe("```js\n"); // language line, no code yet + expect(ac("```js\nconst x = 1;")).toBe("```js\nconst x = 1;\n```"); + expect(ac("```\nplain code")).toBe("```\nplain code\n```"); + }); + + it("does not corrupt digits adjacent to balanced regions (sentinel safety)", () => { + // Regression guard: the placeholder sentinels must be real PUA codepoints, + // not empty strings, otherwise the restore regex would eat bare digits. + expect(ac("```\ncode\n```\n12345 items")).toBe( + "```\ncode\n```\n12345 items", + ); + expect(ac("see `x` then 2024 and 99")).toBe("see `x` then 2024 and 99"); + }); + + it("is idempotent once the real closer arrives", () => { + // mid-stream we'd have closed it; the finalized balanced text adds nothing. + expect(ac("**done**")).toBe("**done**"); + expect(ac(ac("**partial"))).toBe(ac("**partial")); + }); +}); diff --git a/packages/bot-teams/src/render/auto-close.ts b/packages/bot-teams/src/render/auto-close.ts new file mode 100644 index 00000000000..805b92d5d97 --- /dev/null +++ b/packages/bot-teams/src/render/auto-close.ts @@ -0,0 +1,163 @@ +/** + * Streaming-polish layer for Teams' post-then-edit streaming. + * + * While an agent is mid-stream the buffer is usually an *unfinished* markdown + * document: an open code fence, an unclosed `**`, etc. Teams renders message + * text as markdown, so editing the message with unbalanced text makes the rest + * of the bubble render broken (a stray ``` turns the remainder into a code + * block until the closer arrives, a dangling `**` bolds the rest, and so on). + * + * `autoCloseOpenMarkdown` returns a balanced copy of the buffer by appending the + * minimum set of closers needed for well-formed display. When the agent later + * emits the real closer the buffer balances on its own and this adds nothing, + * so the *committed* (finalized) message has no synthetic closers. + * + * Handled, in priority order: + * 1. Unclosed fenced code blocks ``` (most severe: leaks code styling) + * 2. Unclosed inline code + * 3. Unclosed bold `**` / `__`, italic `*` / `_`, strike `~~` + * + * Heuristics: + * - A marker with no content after it (trailing `**`) is NOT closed. The + * agent may just be opening it; closing would flash a transient `****`. + * - Markers inside code/fence regions don't count. + * - Closers are emitted innermost-first so the structure nests correctly. + */ + +// Private-use codepoints that will never appear in agent markdown, used to +// stash already-balanced regions while we scan for the dangling opener. +const SENTINEL_FENCE = "\uE000"; +const SENTINEL_INLINE = "\uE001"; + +export function autoCloseOpenMarkdown(text: string): string { + if (!text) return text; + + // ── 1. Fenced code blocks ────────────────────────────────────────── + const fences: string[] = []; + let work = text.replace(/```[\s\S]*?```/g, (m) => { + fences.push(m); + return `${SENTINEL_FENCE}${fences.length - 1}${SENTINEL_FENCE}`; + }); + + const openFenceIdx = work.indexOf("```"); + let openFenceTail = ""; + if (openFenceIdx >= 0) { + openFenceTail = work.slice(openFenceIdx); + work = work.slice(0, openFenceIdx); + } + + // ── 2. Inline code regions ───────────────────────────────────────── + const inlines: string[] = []; + work = work.replace(/`[^`\n]+`/g, (m) => { + inlines.push(m); + return `${SENTINEL_INLINE}${inlines.length - 1}${SENTINEL_INLINE}`; + }); + + const openBacktickIdx = work.indexOf("`"); + let openBacktickTail = ""; + if (openBacktickIdx >= 0) { + openBacktickTail = work.slice(openBacktickIdx); + work = work.slice(0, openBacktickIdx); + } + + // ── 3. Bold / italic / strike via stack scan ────────────────────── + const stack = scanBracketStack(work); + const closers = stack.slice().toReversed().join(""); + + // ── 4. Reassemble ───────────────────────────────────────────────── + // Insert closers BEFORE trailing whitespace so "**bold " → "**bold**". + let output = work; + if (closers) { + const trail = output.match(/\s*$/)?.[0] ?? ""; + output = output.slice(0, output.length - trail.length) + closers + trail; + } + + if (openBacktickTail) { + output += openBacktickTail; + if (hasContentAfterMarker(openBacktickTail, "`")) output += "`"; + } + + output = output.replace( + new RegExp(`${SENTINEL_INLINE}(\\d+)${SENTINEL_INLINE}`, "g"), + (_, idx) => inlines[Number(idx)] ?? "", + ); + + if (openFenceTail) { + output += openFenceTail; + if (hasFenceCodeContent(openFenceTail)) { + output += openFenceTail.endsWith("\n") ? "```" : "\n```"; + } + } + + output = output.replace( + new RegExp(`${SENTINEL_FENCE}(\\d+)${SENTINEL_FENCE}`, "g"), + (_, idx) => fences[Number(idx)] ?? "", + ); + + return output; +} + +/** + * Walk `text` and return the unbalanced bracket stack. Recognises (longest + * first) `**`, `__`, `~~`, then `*`, `_`, so `**` is one bold marker, not two + * italics. A trailing marker with no content after it is dropped (closing it + * would flash a transient `****`). + */ +function scanBracketStack(text: string): string[] { + const stack: string[] = []; + const tryToggle = (m: string) => { + if (stack[stack.length - 1] === m) stack.pop(); + else stack.push(m); + }; + + let i = 0; + while (i < text.length) { + if (text.startsWith("**", i)) { + tryToggle("**"); + i += 2; + } else if (text.startsWith("__", i)) { + tryToggle("__"); + i += 2; + } else if (text.startsWith("~~", i)) { + tryToggle("~~"); + i += 2; + } else if (text[i] === "*") { + tryToggle("*"); + i += 1; + } else if (text[i] === "_") { + tryToggle("_"); + i += 1; + } else { + i += 1; + } + } + + while (stack.length > 0) { + const last = stack[stack.length - 1]!; + const lastIdx = text.lastIndexOf(last); + if (lastIdx < 0) break; + const after = text.slice(lastIdx + last.length); + if (/^\s*$/.test(after)) stack.pop(); + else break; + } + + return stack; +} + +function hasContentAfterMarker(tail: string, marker: string): boolean { + if (!tail.startsWith(marker)) return false; + return /\S/.test(tail.slice(marker.length)); +} + +/** + * True if a ```` ```?\n ```` buffer has actual code past the + * optional language line (the first newline separates the language tag from the + * code body). A buffer still on the language line has nothing to close yet. + */ +function hasFenceCodeContent(tail: string): boolean { + if (!tail.startsWith("```")) return false; + const after = tail.slice(3); + const nl = after.indexOf("\n"); + if (nl < 0) return false; + return /\S/.test(after.slice(nl + 1)); +} diff --git a/packages/bot-teams/src/render/budget.test.ts b/packages/bot-teams/src/render/budget.test.ts new file mode 100644 index 00000000000..35b68202bea --- /dev/null +++ b/packages/bot-teams/src/render/budget.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { truncateText, clampArray, TEAMS_LIMITS } from "./budget.js"; + +describe("truncateText", () => { + it("leaves short text unchanged", () => { + expect(truncateText("hi", 10)).toBe("hi"); + }); + it("truncates with an ellipsis and never exceeds max", () => { + const out = truncateText("abcdef", 4); + expect(out).toBe("abc…"); + expect(out.length).toBe(4); + }); +}); + +describe("clampArray", () => { + it("keeps everything under the cap", () => { + expect(clampArray([1, 2], 5)).toEqual({ items: [1, 2], overflow: 0 }); + }); + it("clamps and reports overflow", () => { + expect(clampArray([1, 2, 3, 4], 2)).toEqual({ items: [1, 2], overflow: 2 }); + }); +}); + +describe("TEAMS_LIMITS", () => { + it("caps top-level actions to a Teams-friendly count", () => { + expect(TEAMS_LIMITS.actions).toBe(6); + }); +}); diff --git a/packages/bot-teams/src/render/budget.ts b/packages/bot-teams/src/render/budget.ts new file mode 100644 index 00000000000..5144d167341 --- /dev/null +++ b/packages/bot-teams/src/render/budget.ts @@ -0,0 +1,46 @@ +/** + * Adaptive Card payload limits for Teams. + * + * Teams caps an Adaptive Card attachment at ~28 KB of JSON and renders only a + * handful of top-level actions comfortably. These ceilings keep a rendered card + * inside those bounds; the renderer clamps collections and truncates text to + * them rather than emitting an oversized card Teams would reject. + */ +export const TEAMS_LIMITS = { + /** Top-level body elements (TextBlocks, FactSets, Tables, etc.) per card. */ + bodyElements: 100, + /** Top-level `Action.Submit`s. Teams shows ~6 before overflowing. */ + actions: 6, + /** Characters of text in a single TextBlock. */ + textBlock: 12000, + factTitle: 200, + factValue: 2000, + factsPerSet: 50, + buttonText: 256, + tableColumns: 12, + tableRows: 100, + cellText: 2000, + choices: 100, + choiceLabel: 256, + /** Data points (categories / slices) in a single chart. */ + chartDataPoints: 50, + /** Characters of a chart title or a data point's label. */ + chartTitle: 200, + chartLabel: 200, +} as const; + +/** Truncate to `max` chars, appending an ellipsis if the input was longer. Never returns >max. */ +export function truncateText(text: string, max: number): string { + if (text.length <= max) return text; + if (max <= 1) return text.slice(0, max); + return text.slice(0, max - 1) + "…"; +} + +/** Clamp an array to `max` items; return the kept items plus how many overflowed. */ +export function clampArray( + items: readonly T[], + max: number, +): { items: T[]; overflow: number } { + if (items.length <= max) return { items: [...items], overflow: 0 }; + return { items: items.slice(0, max), overflow: items.length - max }; +} diff --git a/packages/bot-teams/src/render/markdown.test.ts b/packages/bot-teams/src/render/markdown.test.ts new file mode 100644 index 00000000000..c897d9ad556 --- /dev/null +++ b/packages/bot-teams/src/render/markdown.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import type { BotNode } from "@copilotkit/bot-ui"; +import { renderTeamsMarkdown } from "./markdown.js"; + +const text = (value: string): BotNode => ({ type: "text", props: { value } }); + +describe("renderTeamsMarkdown", () => { + it("renders a bare text node", () => { + expect(renderTeamsMarkdown([text("Echo: hi")])).toBe("Echo: hi"); + }); + + it("renders a header as bold", () => { + const ir: BotNode[] = [ + { type: "header", props: { children: [text("Title")] } }, + ]; + expect(renderTeamsMarkdown(ir)).toBe("**Title**"); + }); + + it("renders a divider as a rule", () => { + expect(renderTeamsMarkdown([{ type: "divider", props: {} }])).toBe("---"); + }); + + it("joins a message container's children with blank lines", () => { + const ir: BotNode[] = [ + { + type: "message", + props: { + children: [ + { type: "header", props: { children: [text("Status")] } }, + { type: "section", props: { children: [text("All good.")] } }, + ], + }, + }, + ]; + expect(renderTeamsMarkdown(ir)).toBe("**Status**\n\nAll good."); + }); + + it("renders context children as emphasized lines", () => { + const ir: BotNode[] = [ + { type: "context", props: { children: [text("fyi")] } }, + ]; + expect(renderTeamsMarkdown(ir)).toBe("_fyi_"); + }); + + it("returns an empty string for an empty tree", () => { + expect(renderTeamsMarkdown([])).toBe(""); + }); + + it("renders a
as a GFM pipe-table fallback", () => { + const cell = (v: string): BotNode => ({ + type: "cell", + props: { children: [text(v)] }, + }); + const ir: BotNode[] = [ + { + type: "table", + props: { + columns: [{ header: "Name" }, { header: "Count", align: "right" }], + children: [ + { type: "row", props: { children: [cell("Bugs"), cell("3")] } }, + ], + }, + }, + ]; + expect(renderTeamsMarkdown(ir)).toBe( + "| Name | Count |\n| --- | ---: |\n| Bugs | 3 |", + ); + }); +}); diff --git a/packages/bot-teams/src/render/markdown.ts b/packages/bot-teams/src/render/markdown.ts new file mode 100644 index 00000000000..2f816b64382 --- /dev/null +++ b/packages/bot-teams/src/render/markdown.ts @@ -0,0 +1,134 @@ +import type { BotNode } from "@copilotkit/bot-ui"; + +/** + * Render the bot-ui IR tree to a Teams message string. + * + * Teams message activities render Markdown, so the structural vocabulary maps + * cleanly onto it: a `
` becomes a bold line, `
`/`` + * pass their text through, `` becomes a rule, and so on. This is the + * thin, text-first renderer that covers plain replies and the common card + * shapes; richer interactive surfaces (buttons, inputs) will render to + * Adaptive Cards in a follow-up (see the package README). + */ +export function renderTeamsMarkdown(ir: BotNode[]): string { + return ir + .map((node) => renderNode(node)) + .filter((s) => s.length > 0) + .join("\n\n") + .trim(); +} + +function renderNode(node: BotNode): string { + if (typeof node.type !== "string") { + // Components are expanded to intrinsic nodes before render(); a stray + // function/symbol node carries no renderable text. + return collectText(node); + } + + switch (node.type) { + case "text": + return String(node.props.value ?? ""); + case "header": + return `**${collectText(node)}**`; + case "divider": + return "---"; + case "context": + // Supplementary, lower-emphasis text. + return collectText(node) + .split("\n") + .map((line) => (line ? `_${line}_` : line)) + .join("\n"); + case "field": + return collectText(node); + case "fields": + return childNodes(node) + .map((c) => renderNode(c)) + .filter(Boolean) + .join("\n"); + case "button": + // No interactive surface yet, so render the label so the intent is visible. + return `\`${collectText(node)}\``; + case "table": + return renderTable(node); + case "message": + case "section": + case "markdown": + case "actions": + default: + // Containers and unknown nodes: render children, falling back to any + // direct text. + return renderChildren(node) || collectText(node); + } +} + +/** + * Markdown-table fallback for `
` (the Adaptive Card renderer emits a + * native Table; this is the text-surface fallback). Teams renders GFM pipe + * tables in message text. + */ +function renderTable(node: BotNode): string { + const columns = node.props?.columns as + | { header: string; align?: "left" | "center" | "right" }[] + | undefined; + const rowNodes = childNodes(node).filter((c) => c.type === "row"); + const dataRows = rowNodes.map((r) => + childNodes(r) + .filter((c) => c.type === "cell") + .map((c) => collectText(c).replace(/\|/g, "\\|")), + ); + + const width = + columns?.length ?? dataRows.reduce((m, r) => Math.max(m, r.length), 0); + if (width === 0) return ""; + + const headers = columns + ? columns.map((c) => c.header) + : Array.from({ length: width }, () => " "); + const sep = (columns ?? Array.from({ length: width })).map((c) => { + const align = (c as { align?: string } | undefined)?.align; + if (align === "center") return ":---:"; + if (align === "right") return "---:"; + return "---"; + }); + + const line = (cells: string[]): string => + `| ${Array.from({ length: width }, (_, i) => cells[i] ?? "").join(" | ")} |`; + + return [line(headers), `| ${sep.join(" | ")} |`, ...dataRows.map(line)].join( + "\n", + ); +} + +function renderChildren(node: BotNode): string { + const kids = childNodes(node); + if (kids.length === 0) return ""; + return kids + .map((c) => renderNode(c)) + .filter((s) => s.length > 0) + .join("\n\n"); +} + +/** Normalize a node's `children` prop to an array of child nodes. */ +function childNodes(node: BotNode): BotNode[] { + const children = node.props?.children; + if (Array.isArray(children)) return children as BotNode[]; + if (children && typeof children === "object" && "type" in children) { + return [children as BotNode]; + } + return []; +} + +/** Depth-first collection of a node's descendant text. */ +function collectText(node: BotNode): string { + const out: string[] = []; + const visit = (n: BotNode): void => { + if (typeof n.type === "string" && n.type === "text") { + const value = n.props?.value; + if (value != null) out.push(String(value)); + return; + } + for (const child of childNodes(n)) visit(child); + }; + visit(node); + return out.join(" ").replace(/\s+/g, " ").trim(); +} diff --git a/packages/bot-teams/src/sanitizing-http-agent.ts b/packages/bot-teams/src/sanitizing-http-agent.ts new file mode 100644 index 00000000000..8b6eeb27f5a --- /dev/null +++ b/packages/bot-teams/src/sanitizing-http-agent.ts @@ -0,0 +1,67 @@ +import { HttpAgent, parseSSEStream, runHttpRequest } from "@ag-ui/client"; +import type { BaseEvent, RunAgentInput } from "@ag-ui/core"; +import { map } from "rxjs"; +import type { Observable } from "rxjs"; + +/** + * An `HttpAgent` that tolerates the AG-UI event streams real agents emit. + * + * `@ag-ui/client`'s stock transform re-validates every streamed event against a + * strict Zod schema. Some events that `@ag-ui/langgraph` legitimately emits fail + * it (notably a `TOOL_CALL_START` whose `parentMessageId` is `null`: "Expected + * string, received null"), and a single rejected event aborts the entire run. + * That breaks LangGraph interrupts / human-in-the-loop on Teams, where the tool + * call that triggers the interrupt carries exactly that shape. + * + * The bridge talks to a trusted runtime, so rather than re-validate its output + * we use the same SSE parse the stock path wraps (`parseSSEStream`) and coerce + * the known nullable-string fields. This deliberately drops the stock + * transform's *entire* strict Zod re-validation step (not just the offending + * field). This is acceptable only because the runtime is trusted and `runHttpRequest` + * still throws on transport/HTTP errors. The first coercion logs a one-time + * breadcrumb so the workaround is visible in production. Revert to the stock + * transform (`transformHttpEventStream`) once upstream makes the fields + * nullable. + * + * Use it in place of `HttpAgent` when pointing the Teams bot at a LangGraph + * (or other AG-UI) agent: + * + * ```ts + * import { SanitizingHttpAgent } from "@copilotkit/bot-teams"; + * const agent = new SanitizingHttpAgent({ url: process.env.AGENT_URL! }); + * ``` + */ +export class SanitizingHttpAgent extends HttpAgent { + run(input: RunAgentInput): Observable { + return parseSSEStream( + runHttpRequest(() => this.fetch(this.url, this.requestInit(input))), + this.debugLogger, + ).pipe(map((event: unknown) => coerceNullStrings(event) as BaseEvent)); + } +} + +/** One-time breadcrumb so the workaround's use is visible in production. */ +let coercionWarned = false; + +/** + * Coerce known nullable-string event fields to `""`. Targeted on purpose: we + * only touch fields where a `null` is known to come through from + * `@ag-ui/langgraph` and would otherwise trip a downstream string check. + */ +function coerceNullStrings(event: unknown): unknown { + if (!event || typeof event !== "object") return event; + const e = event as Record; + if (e["parentMessageId"] === null) { + e["parentMessageId"] = ""; + if (!coercionWarned) { + coercionWarned = true; + console.warn( + '[SanitizingHttpAgent] coerced a null `parentMessageId` to "" and ' + + "bypassed @ag-ui/client strict event re-validation for this stream " + + "(known @ag-ui/langgraph quirk). Remove this agent once upstream " + + "makes the field nullable.", + ); + } + } + return e; +} diff --git a/packages/bot-teams/src/types.ts b/packages/bot-teams/src/types.ts new file mode 100644 index 00000000000..92b4b4e1033 --- /dev/null +++ b/packages/bot-teams/src/types.ts @@ -0,0 +1,55 @@ +import type { TurnContext } from "@microsoft/agents-hosting"; +import type { ConversationReference } from "@microsoft/agents-activity"; +import type { FileDeliveryConfig } from "./download-files.js"; + +/** + * Stable per-conversation key. Teams gives every conversation (1:1 chat, group + * chat, or channel) a durable `conversation.id`, which is exactly the grain we + * want: one agent session per conversation. + */ +export type ConversationKey = string; + +/** + * Where a reply goes. + * + * Replies sent *inside* the originating turn use the live {@link TurnContext} + * (the simplest path, and the one the M365 Agents Playground exercises). The + * {@link ConversationReference} is captured alongside it so the same target can + * later drive proactive (out-of-turn) sends via + * `CloudAdapter.continueConversation`. + */ +export interface TeamsReplyTarget { + conversationKey: ConversationKey; + reference: Partial; + /** Live turn context, present while replying within the originating activity. */ + context?: TurnContext; +} + +export interface TeamsAdapterOptions { + /** + * Port for the bot's `POST /api/messages` endpoint. Defaults to `3978`, the + * endpoint the M365 Agents Playground connects to. + */ + port?: number; + /** + * Microsoft app (client) id. Omit for anonymous local development with the + * M365 Agents Playground; required to talk to real Teams via Azure Bot + * Service. Falls back to the `clientId` env var. + */ + clientId?: string; + /** Microsoft client secret. Omit for anonymous local dev. Falls back to `clientSecret`. */ + clientSecret?: string; + /** Microsoft tenant (directory) id. Omit for multi-tenant / anonymous. Falls back to `tenantId`. */ + tenantId?: string; + /** + * Custom-event names treated as interrupts by the run renderer (captured for + * an `onInterrupt` handler). Defaults to `on_interrupt`, the name + * LangGraph's AG-UI adapter emits. + */ + interruptEventNames?: ReadonlySet; + /** + * Tunables for inbound file handling (size/count caps applied when a user + * uploads files). Defaults are sane; override only to widen or tighten them. + */ + files?: FileDeliveryConfig; +} diff --git a/packages/bot-teams/tsconfig.check.json b/packages/bot-teams/tsconfig.check.json new file mode 100644 index 00000000000..c4cbc877d12 --- /dev/null +++ b/packages/bot-teams/tsconfig.check.json @@ -0,0 +1,10 @@ +{ + "extends": "@copilotkit/typescript-config/base.json", + "compilerOptions": { + "noEmit": true, + "lib": ["es2023", "dom"], + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/bot-teams/tsconfig.json b/packages/bot-teams/tsconfig.json new file mode 100644 index 00000000000..2555b7388da --- /dev/null +++ b/packages/bot-teams/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@copilotkit/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["es2023"] + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "examples"] +} diff --git a/packages/bot-teams/vitest.config.ts b/packages/bot-teams/vitest.config.ts new file mode 100644 index 00000000000..ae847ff6d9c --- /dev/null +++ b/packages/bot-teams/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/bot-ui/src/components.test.tsx b/packages/bot-ui/src/components.test.tsx index 344814e0402..5888ae9428b 100644 --- a/packages/bot-ui/src/components.test.tsx +++ b/packages/bot-ui/src/components.test.tsx @@ -13,6 +13,7 @@ import { Cell, Image, Select, + Chart, } from "./components.js"; /** @@ -36,6 +37,10 @@ const __typeGuards = () => { ; // @ts-expect-error Select.options is required