From f69a0be9f5619ea577ecd54b073d998444b5ead6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 00:28:28 +0000 Subject: [PATCH 1/2] Initial plan From 61fc5ba0d4792bc9cf0f4696a3ab1fcbc9e9b490 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 00:34:01 +0000 Subject: [PATCH 2/2] Fix Repo Policy Check: Add Trademark section to onboarding README and sort package.json Agent-Logs-Url: https://github.com/microsoft/TypeAgent/sessions/038f7f2f-4aba-4463-9150-ef9e660bdd4d Co-authored-by: robgruen <25374553+robgruen@users.noreply.github.com> --- ts/packages/agents/onboarding/AGENTS.md | 121 ++ ts/packages/agents/onboarding/README.md | 161 ++ ts/packages/agents/onboarding/USER_GUIDE.md | 532 +++++++ ts/packages/agents/onboarding/package.json | 61 + .../src/discovery/discoveryHandler.ts | 641 ++++++++ .../src/discovery/discoverySchema.agr | 71 + .../src/discovery/discoverySchema.ts | 50 + .../src/grammarGen/grammarGenHandler.ts | 297 ++++ .../src/grammarGen/grammarGenSchema.agr | 49 + .../src/grammarGen/grammarGenSchema.ts | 31 + ts/packages/agents/onboarding/src/lib/llm.ts | 34 + .../agents/onboarding/src/lib/workspace.ts | 214 +++ .../onboarding/src/onboardingActionHandler.ts | 290 ++++ .../onboarding/src/onboardingManifest.json | 77 + .../onboarding/src/onboardingSchema.agr | 79 + .../agents/onboarding/src/onboardingSchema.ts | 54 + .../src/packaging/packagingHandler.ts | 602 ++++++++ .../src/packaging/packagingSchema.agr | 63 + .../src/packaging/packagingSchema.ts | 46 + .../src/phraseGen/phraseGenHandler.ts | 299 ++++ .../src/phraseGen/phraseGenSchema.agr | 66 + .../src/phraseGen/phraseGenSchema.ts | 52 + .../src/scaffolder/scaffolderHandler.ts | 1372 +++++++++++++++++ .../src/scaffolder/scaffolderSchema.agr | 97 ++ .../src/scaffolder/scaffolderSchema.ts | 59 + .../src/schemaGen/schemaGenHandler.ts | 278 ++++ .../src/schemaGen/schemaGenSchema.agr | 54 + .../src/schemaGen/schemaGenSchema.ts | 34 + .../onboarding/src/testing/testingHandler.ts | 649 ++++++++ .../onboarding/src/testing/testingSchema.agr | 77 + .../onboarding/src/testing/testingSchema.ts | 57 + .../agents/onboarding/src/tsconfig.json | 12 + ts/packages/agents/onboarding/tsconfig.json | 11 + .../defaultAgentProvider/data/config.json | 3 + ts/pnpm-lock.yaml | 43 + 35 files changed, 6636 insertions(+) create mode 100644 ts/packages/agents/onboarding/AGENTS.md create mode 100644 ts/packages/agents/onboarding/README.md create mode 100644 ts/packages/agents/onboarding/USER_GUIDE.md create mode 100644 ts/packages/agents/onboarding/package.json create mode 100644 ts/packages/agents/onboarding/src/discovery/discoveryHandler.ts create mode 100644 ts/packages/agents/onboarding/src/discovery/discoverySchema.agr create mode 100644 ts/packages/agents/onboarding/src/discovery/discoverySchema.ts create mode 100644 ts/packages/agents/onboarding/src/grammarGen/grammarGenHandler.ts create mode 100644 ts/packages/agents/onboarding/src/grammarGen/grammarGenSchema.agr create mode 100644 ts/packages/agents/onboarding/src/grammarGen/grammarGenSchema.ts create mode 100644 ts/packages/agents/onboarding/src/lib/llm.ts create mode 100644 ts/packages/agents/onboarding/src/lib/workspace.ts create mode 100644 ts/packages/agents/onboarding/src/onboardingActionHandler.ts create mode 100644 ts/packages/agents/onboarding/src/onboardingManifest.json create mode 100644 ts/packages/agents/onboarding/src/onboardingSchema.agr create mode 100644 ts/packages/agents/onboarding/src/onboardingSchema.ts create mode 100644 ts/packages/agents/onboarding/src/packaging/packagingHandler.ts create mode 100644 ts/packages/agents/onboarding/src/packaging/packagingSchema.agr create mode 100644 ts/packages/agents/onboarding/src/packaging/packagingSchema.ts create mode 100644 ts/packages/agents/onboarding/src/phraseGen/phraseGenHandler.ts create mode 100644 ts/packages/agents/onboarding/src/phraseGen/phraseGenSchema.agr create mode 100644 ts/packages/agents/onboarding/src/phraseGen/phraseGenSchema.ts create mode 100644 ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts create mode 100644 ts/packages/agents/onboarding/src/scaffolder/scaffolderSchema.agr create mode 100644 ts/packages/agents/onboarding/src/scaffolder/scaffolderSchema.ts create mode 100644 ts/packages/agents/onboarding/src/schemaGen/schemaGenHandler.ts create mode 100644 ts/packages/agents/onboarding/src/schemaGen/schemaGenSchema.agr create mode 100644 ts/packages/agents/onboarding/src/schemaGen/schemaGenSchema.ts create mode 100644 ts/packages/agents/onboarding/src/testing/testingHandler.ts create mode 100644 ts/packages/agents/onboarding/src/testing/testingSchema.agr create mode 100644 ts/packages/agents/onboarding/src/testing/testingSchema.ts create mode 100644 ts/packages/agents/onboarding/src/tsconfig.json create mode 100644 ts/packages/agents/onboarding/tsconfig.json diff --git a/ts/packages/agents/onboarding/AGENTS.md b/ts/packages/agents/onboarding/AGENTS.md new file mode 100644 index 0000000000..1b8da5da67 --- /dev/null +++ b/ts/packages/agents/onboarding/AGENTS.md @@ -0,0 +1,121 @@ +# AGENTS.md — Onboarding Agent + +This document is for AI agents (Claude Code, GitHub Copilot, etc.) working with the onboarding agent codebase. + +## What this agent does + +The onboarding agent automates integrating a new application or API into TypeAgent. It is itself a TypeAgent agent, so its actions are available to AI orchestrators via TypeAgent's MCP interface using `list_commands`. + +## Agent structure + +``` +src/ + onboardingManifest.json ← main manifest, declares 7 sub-action manifests + onboardingSchema.ts ← top-level coordination actions + onboardingSchema.agr ← grammar for top-level actions + onboardingActionHandler.ts ← instantiate(); routes all actions to phase handlers + lib/ + workspace.ts ← read/write per-integration state on disk + llm.ts ← aiclient ChatModel factories per phase + discovery/ ← Phase 1: API surface enumeration + phraseGen/ ← Phase 2: natural language phrase generation + schemaGen/ ← Phase 3: TypeScript action schema generation + grammarGen/ ← Phase 4: .agr grammar generation + scaffolder/ ← Phase 5: agent package scaffolding + testing/ ← Phase 6: phrase→action test loop + packaging/ ← Phase 7: packaging and distribution +``` + +## How actions are routed + +`onboardingActionHandler.ts` exports `instantiate()` which returns a single `AppAgent`. The `executeAction` method receives all actions (from main schema and all sub-schemas) and dispatches by `action.actionName` to the appropriate phase handler module. + +## Workspace state + +All artifacts are persisted at `~/.typeagent/onboarding//`. The `workspace.ts` lib provides: + +- `createWorkspace(config)` — initialize a new integration workspace +- `loadState(name)` — load current phase state +- `saveState(state)` — persist state +- `updatePhase(name, phase, update)` — update phase status; automatically advances `currentPhase` on approval +- `readArtifact(name, phase, filename)` — read a phase artifact +- `writeArtifact(name, phase, filename, content)` — write a phase artifact +- `listIntegrations()` — list all integration workspaces + +## LLM usage + +Each phase that requires LLM calls uses `aiclient`'s `createChatModelDefault(tag)`. Tags are namespaced as `onboarding:` (e.g. `onboarding:schemagen`). This follows the standard TypeAgent pattern — credentials come from `ts/.env`. + +## Phase approval model + +Each phase has a status: `pending → in-progress → approved`. An `approve*` action locks artifacts and advances to the next phase. The AI orchestrator is expected to review artifacts before calling approve — this is the human-in-the-loop checkpoint. + +## Adding a new phase + +1. Create `src//` with `*Schema.ts`, `*Schema.agr`, `*Handler.ts` +2. Add the sub-action manifest entry to `onboardingManifest.json` +3. Add `asc:*` and `agc:*` build scripts to `package.json` +4. Import and wire up the handler in `onboardingActionHandler.ts` +5. Add the phase to the `OnboardingPhase` type and `phases` object in `workspace.ts` + +## Adding a new tool to an existing phase + +1. Add the action type to the phase's `*Schema.ts` +2. Add grammar patterns to the phase's `*Schema.agr` +3. Implement the handler case in the phase's `*Handler.ts` + +## Key dependencies + +- `@typeagent/agent-sdk` — `AppAgent`, `ActionContext`, `TypeAgentAction`, `ActionResult` +- `@typeagent/agent-sdk/helpers/action` — `createActionResultFromTextDisplay`, `createActionResultFromMarkdownDisplay` +- `aiclient` — `createChatModelDefault`, `ChatModel` +- `typechat` — `createJsonTranslator` for structured LLM output + +## Scaffolder — choosing a pattern + +The scaffolder (Phase 5) generates pattern-appropriate boilerplate. Before calling `scaffoldAgent`, determine which pattern fits the integration being onboarded. The discovery phase artifacts should give you enough information to decide. + +**Decision guide** + +| Signal from discovery | Pattern to use | +| ------------------------------------------------------------------------ | -------------------------- | +| Integration streams text (chat, code gen, summarization) | `llm-streaming` | +| Integration is a desktop/browser/Electron app with a JS runtime | `websocket-bridge` | +| Integration is a long-running, multi-step process needing human sign-off | `state-machine` | +| API surface has 5+ distinct domains (e.g., files + calendar + mail) | `sub-agent-orchestrator` | +| Integration needs a custom interactive UI | `view-ui` | +| Integration has an authenticated REST or OAuth API | `external-api` | +| Integration is a CLI tool, mobile device, or OS service | `native-platform` | +| Integration has only a few toggle/config actions | `command-handler` | +| None of the above | `schema-grammar` (default) | + +**Scaffold with a pattern** + +``` +scaffold the agent using the pattern +``` + +**List all patterns** + +``` +list agent patterns +``` + +Full pattern reference (file layouts, manifest flags, example code) is in +[docs/architecture/agent-patterns.md](../../../../docs/architecture/agent-patterns.md). + +**What the scaffolder generates per pattern** + +- `schema-grammar` — manifest, handler, schema, grammar, tsconfigs, package.json +- `external-api` — above + `*Bridge.ts` with an API client class stub; adds `aiclient` dependency +- `llm-streaming` — above + `injected: true / cached: false / streamingActions` in manifest; adds `aiclient` + `typechat` dependencies +- `sub-agent-orchestrator` — above + `actions/` directory with per-group schema and grammar stubs; `subActionManifests` in manifest +- `websocket-bridge` — above + `*Bridge.ts` with a `WebSocketServer` + pending-request map; adds `ws` dependency +- `state-machine` — above + state type definitions and `loadState` / `saveState` helpers +- `native-platform` — above + `child_process` / platform-branching boilerplate +- `view-ui` — above + `openLocalView` / `closeLocalView` lifecycle; `localView: true` in manifest +- `command-handler` — replaces `executeAction` dispatch with a named `handlers` map + +## Testing + +Run phrase→action tests with the `runTests` action after completing the testing phase setup. Results are saved to `~/.typeagent/onboarding//testing/results.json`. The `proposeRepair` action uses an LLM to suggest schema/grammar fixes for failing tests. diff --git a/ts/packages/agents/onboarding/README.md b/ts/packages/agents/onboarding/README.md new file mode 100644 index 0000000000..1cd09843a4 --- /dev/null +++ b/ts/packages/agents/onboarding/README.md @@ -0,0 +1,161 @@ +# Onboarding Agent + +A TypeAgent agent that automates the end-to-end process of integrating a new application or API into TypeAgent. Each phase of the onboarding pipeline is a sub-agent with typed actions, enabling AI orchestrators (Claude Code, GitHub Copilot) to drive the process via TypeAgent's MCP interface. + +## Overview + +Integrating a new application into TypeAgent involves 7 phases: + +| Phase | Sub-agent | What it does | +| ----- | ----------------------- | ------------------------------------------------------------------ | +| 1 | `onboarding-discovery` | Crawls docs or parses an OpenAPI spec to enumerate the API surface | +| 2 | `onboarding-phrasegen` | Generates natural language sample phrases for each action | +| 3 | `onboarding-schemagen` | Generates TypeScript action schemas from the API surface | +| 4 | `onboarding-grammargen` | Generates `.agr` grammar files from schemas and phrases | +| 5 | `onboarding-scaffolder` | Stamps out the agent package infrastructure | +| 6 | `onboarding-testing` | Generates test cases and runs a phrase→action validation loop | +| 7 | `onboarding-packaging` | Packages the agent for distribution and registration | + +Each phase produces **artifacts saved to disk** at `~/.typeagent/onboarding//`, so work can be resumed across sessions. + +## Usage + +### Starting a new integration + +``` +start onboarding for slack +``` + +### Checking status + +``` +what's the status of the slack onboarding +``` + +### Resuming an in-progress integration + +``` +resume onboarding for slack +``` + +### Running a specific phase + +``` +crawl docs at https://api.slack.com/docs for slack +generate phrases for slack +generate schema for slack +run tests for slack +``` + +## Workspace layout + +``` +~/.typeagent/onboarding/ + / + state.json ← phase status, config, timestamps + discovery/ + api-surface.json ← enumerated actions from docs/spec + phraseGen/ + phrases.json ← sample phrases per action + schemaGen/ + schema.ts ← generated TypeScript action schema + grammarGen/ + schema.agr ← generated grammar file + scaffolder/ + agent/ ← stamped-out agent package files + testing/ + test-cases.json ← phrase → expected action test pairs + results.json ← latest test run results + packaging/ + dist/ ← final packaged output +``` + +Each phase must be **approved** before the next phase begins. Approval locks the phase's artifacts and advances the current phase pointer in `state.json`. + +## For Best Results + +The onboarding agent is designed to be driven by an AI orchestrator (Claude Code, GitHub Copilot) that can call TypeAgent actions iteratively, inspect artifacts, and guide each phase to completion. For the best experience, set up TypeAgent as an MCP server so your AI client can communicate with it directly. + +### Set up TypeAgent as an MCP server + +TypeAgent exposes a **Command Executor MCP server** that bridges any MCP-compatible client (Claude Code, GitHub Copilot) to the TypeAgent dispatcher. Full setup instructions are in [packages/commandExecutor/README.md](../../commandExecutor/README.md). The short version: + +1. **Build** the workspace (from `ts/`): + + ```bash + pnpm run build + ``` + +2. **Add the MCP server** to `.mcp.json` at the repo root (create it if it doesn't exist): + + ```json + { + "mcpServers": { + "command-executor": { + "command": "node", + "args": ["packages/commandExecutor/dist/server.js"] + } + } + } + ``` + +3. **Start the TypeAgent dispatcher** (in a separate terminal): + + ```bash + pnpm run start:agent-server + ``` + +4. **Restart your AI client** (Claude Code or Copilot) to pick up the new MCP configuration. + +Once connected, your AI client can drive onboarding phases end-to-end using natural language — e.g. _"start onboarding for Slack"_ — without any manual copy-paste between tools. + +## Agent Patterns + +The scaffolder supports nine architectural patterns. Use `list agent patterns` at runtime for the full table, or see [docs/architecture/agent-patterns.md](../../../../docs/architecture/agent-patterns.md) for the complete reference including when-to-use guidance, file layouts, and manifest flags. + +| Pattern | When to use | Examples | +| ------------------------ | ---------------------------------------- | ------------------------------- | +| `schema-grammar` | Bounded set of typed actions (default) | `weather`, `photo`, `list` | +| `external-api` | Authenticated REST / cloud API | `calendar`, `email`, `player` | +| `llm-streaming` | Agent calls an LLM, streams results | `chat`, `greeting` | +| `sub-agent-orchestrator` | API surface too large for one schema | `desktop`, `code`, `browser` | +| `websocket-bridge` | Automate a host app via a plugin | `browser`, `code` | +| `state-machine` | Multi-phase workflow with approval gates | `onboarding`, `scriptflow` | +| `native-platform` | OS / device APIs, no cloud | `androidMobile`, `playerLocal` | +| `view-ui` | Rich interactive web-view UI | `turtle`, `montage`, `markdown` | +| `command-handler` | Simple settings-style direct dispatch | `settings`, `test` | + +## Building + +```bash +pnpm install +pnpm run build +``` + +## TODO + +### Additional discovery crawlers + +The discovery phase currently supports web docs and OpenAPI specs. Planned crawlers: + +- **CLI `--help` scraping** — invoke a command-line tool with `--help` / `--help ` and parse the output to enumerate commands, flags, and arguments +- **`dumpbin` / PE inspection** — extract exported function names and signatures from Windows DLLs for native library integration +- **.NET reflection** — load a managed assembly and enumerate public types, methods, and parameters via `System.Reflection` +- **Man pages** — parse `man` output for POSIX CLI tools +- **Python `inspect` / `pydoc`** — introspect Python modules and their docstrings +- **GraphQL introspection** — query a GraphQL endpoint's introspection schema to enumerate types and operations +- **gRPC / Protobuf** — parse `.proto` files or use server reflection to enumerate services and RPC methods + +Each new crawler should implement the same `DiscoveryResult` contract so downstream phases (phrase gen, schema gen) remain crawler-agnostic. + +## Architecture + +See [AGENTS.md](./AGENTS.md) for details on the agent structure, how to extend it, and how each phase's LLM prompting works. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/ts/packages/agents/onboarding/USER_GUIDE.md b/ts/packages/agents/onboarding/USER_GUIDE.md new file mode 100644 index 0000000000..f6146e5992 --- /dev/null +++ b/ts/packages/agents/onboarding/USER_GUIDE.md @@ -0,0 +1,532 @@ +# TypeAgent Onboarding — User Guide + +This guide shows how to use an AI assistant (Claude Code, GitHub Copilot, or any MCP client) to onboard a new application or API into TypeAgent from start to finish. + +The onboarding agent is itself a TypeAgent agent. Its actions are available in your AI assistant automatically via `discover_agents` — no extra registration required beyond the one-time MCP setup below. + +--- + +## Step 0 — Register TypeAgent as an MCP server + +Before you can use the onboarding agent from your AI assistant, you need to register TypeAgent's MCP server (`command-executor`) once. This is a one-time setup per machine. + +### What it is + +TypeAgent exposes a stdio MCP server at `ts/packages/commandExecutor/dist/server.js`. It provides three tools to your AI assistant: + +| Tool | What it does | +| ----------------- | ------------------------------------------------------------- | +| `discover_agents` | Lists all TypeAgent agents and their actions | +| `execute_action` | Calls any agent action directly by name with typed parameters | +| `execute_command` | Passes a natural language request to the TypeAgent dispatcher | + +The onboarding agent's actions (`startOnboarding`, `crawlDocUrl`, `generateSchema`, etc.) are discovered and called via these tools. + +### Prerequisites + +- Node.js ≥ 20 installed +- The TypeAgent repo cloned and built: `cd ts && pnpm install && pnpm run build` +- The TypeAgent agent-server running (started automatically on first use, or via `node packages/agentServer/server/dist/server.js` from `ts/`) +- `ts/.env` configured with your Azure OpenAI or OpenAI API keys + +--- + +### Claude Code + +Claude Code reads MCP server config from `.mcp.json` in your project root (or `~/.claude/mcp.json` for global config). + +The repo already includes `ts/.mcp.json` with the `command-executor` server. **If you open Claude Code from the `ts/` directory, it will be picked up automatically.** + +To verify it is active, run inside Claude Code: + +``` +/mcp +``` + +You should see `command-executor` listed as connected. + +**If you need to register it manually** (e.g. you're working from a different directory), add this to your `.mcp.json`: + +```json +{ + "mcpServers": { + "typeagent": { + "command": "node", + "args": [ + "/ts/packages/commandExecutor/dist/server.js" + ], + "env": {} + } + } +} +``` + +Replace `` with the full path to your TypeAgent clone, for example: + +- Windows: `C:/repos/TypeAgent/ts/packages/commandExecutor/dist/server.js` +- Mac/Linux: `/home/you/repos/TypeAgent/ts/packages/commandExecutor/dist/server.js` + +Then restart Claude Code. + +--- + +### GitHub Copilot (VS Code) + +GitHub Copilot uses VS Code's MCP configuration. Add the TypeAgent server via the VS Code settings UI or directly in `settings.json`. + +**Via settings.json** — open your VS Code `settings.json` (`Ctrl+Shift+P` → "Open User Settings (JSON)") and add: + +```json +{ + "github.copilot.chat.mcpServers": { + "typeagent": { + "command": "node", + "args": [ + "/ts/packages/commandExecutor/dist/server.js" + ], + "type": "stdio" + } + } +} +``` + +**Via the VS Code UI** — open the Command Palette (`Ctrl+Shift+P`), run **"MCP: Add MCP Server"**, choose **"Command (stdio)"**, and enter: + +- Command: `node` +- Args: `/ts/packages/commandExecutor/dist/server.js` +- Name: `typeagent` + +After saving, open a Copilot Chat panel. You should see the TypeAgent tools listed under the MCP tools icon (the plug icon in the chat input bar). + +--- + +### Verify the connection + +Once registered, ask your AI assistant: + +``` +> Discover TypeAgent agents +``` + +or + +``` +> What TypeAgent agents are available? +``` + +The assistant will call `discover_agents` and return a list that includes `onboarding` (among others). If you see the list, you're ready to start onboarding. + +**Troubleshooting:** + +- If the server isn't found, check that `ts/packages/commandExecutor/dist/server.js` exists — run `pnpm run build` from `ts/` if not +- If tools don't appear, restart your AI assistant or reload the VS Code window +- Logs are written to `~/.tmp/typeagent-mcp/` — check there for connection errors + +--- + +## Prerequisites (after MCP setup) + +- TypeAgent MCP server registered with your AI assistant (see above) +- Your `ts/.env` configured with API keys (the same ones TypeAgent already uses) +- The application you want to integrate is either documented online or has an OpenAPI spec + +--- + +## How it works + +You talk to your AI assistant in plain English. The assistant calls the onboarding agent's actions to do the work. Each phase produces artifacts saved to `~/.typeagent/onboarding//` so you can pause and come back anytime. + +``` +You (natural language) + ↓ +AI assistant (Claude Code / Copilot) + ↓ MCP → list_commands → TypeAgent +Onboarding agent actions + ↓ +Artifacts on disk (schemas, phrases, grammar, agent package) +``` + +--- + +## Complete walkthrough: onboarding a REST API + +Below is a realistic session. Lines starting with `>` are things you'd say to your AI assistant. + +--- + +### Step 1 — Start the onboarding + +``` +> Start onboarding for Slack +``` + +The assistant calls `startOnboarding` and creates a workspace at `~/.typeagent/onboarding/slack/`. + +--- + +### Step 2 — Discover the API surface + +**From documentation URL:** + +``` +> Crawl the Slack API docs at https://api.slack.com/methods for slack +``` + +**From an OpenAPI spec file:** + +``` +> Parse the OpenAPI spec at C:\specs\slack-openapi.json for slack +``` + +**From an OpenAPI spec URL:** + +``` +> Parse the OpenAPI spec at https://raw.githubusercontent.com/slackapi/slack-api-specs/master/web-api/slack_web_openapi_v2.json for slack +``` + +After crawling, review what was found: + +``` +> List the discovered actions for slack +``` + +You'll see a table of all API actions with names and descriptions. Trim down to what you actually want: + +``` +> Approve the API surface for slack, excluding: listAllUsers, adminCreateWorkspace, deleteTeam +``` + +Or include only specific actions: + +``` +> Approve the API surface for slack, including only: postMessage, listChannels, getUserInfo, addReaction, uploadFile +``` + +--- + +### Step 3 — Generate sample phrases + +``` +> Generate phrases for slack +``` + +The assistant calls `generatePhrases` and asks the LLM to produce 5 natural language samples per action. You can tune the count: + +``` +> Generate 8 phrases per action for slack +``` + +Review the output. Add or remove specific phrases: + +``` +> Add phrase "DM John about the meeting" for action postMessage in slack +> Remove phrase "send a slack" from action postMessage in slack +``` + +When satisfied: + +``` +> Approve phrases for slack +``` + +--- + +### Step 4 — Generate the TypeScript action schema + +``` +> Generate the action schema for slack +``` + +The LLM produces a TypeScript file with union types and JSDoc comments mapping each action to the Slack API. Review the output in the response. + +If you want changes: + +``` +> Refine the slack schema to make the channelId parameter optional and add a threadTs parameter to postMessage +``` + +``` +> Refine the slack schema to split postMessage into postChannelMessage and postDirectMessage +``` + +When happy: + +``` +> Approve the slack schema +``` + +--- + +### Step 5 — Generate the grammar + +``` +> Generate the grammar for slack +``` + +The LLM produces a `.agr` file with natural language patterns for each action. Then validate it compiles: + +``` +> Compile the slack grammar +``` + +If compilation fails, the error message will tell you which rule is invalid. You can ask: + +``` +> Generate the grammar for slack +``` + +again after the schema is adjusted, or manually edit the grammar file at `~/.typeagent/onboarding/slack/grammarGen/schema.agr`. + +When the grammar compiles cleanly: + +``` +> Approve the slack grammar +``` + +--- + +### Step 6 — Scaffold the agent package + +``` +> Scaffold the slack agent +``` + +This stamps out a complete TypeAgent agent package at `ts/packages/agents/slack/` with: + +- `slackManifest.json` +- `slackSchema.ts` (the approved schema) +- `slackSchema.agr` (the approved grammar) +- `slackActionHandler.ts` (stub — ready for your implementation) +- `package.json`, `tsconfig.json`, `src/tsconfig.json` + +If your integration talks to Slack over REST, scaffold the bridge too: + +``` +> Scaffold the slack rest-client plugin +``` + +For a WebSocket-based integration (like Excel or VS Code agents): + +``` +> Scaffold the slack websocket-bridge plugin +``` + +For an Office add-in: + +``` +> Scaffold the slack office-addin plugin +``` + +See what templates are available: + +``` +> List templates +``` + +--- + +### Step 7 — Package and register + +``` +> Package the slack agent +``` + +This runs `pnpm install` and `pnpm run build` in the agent directory. + +To also register it with the local TypeAgent dispatcher immediately: + +``` +> Package the slack agent and register it +``` + +Then restart TypeAgent so it picks up the new agent. + +--- + +### Step 8 — Run the tests + +After TypeAgent has restarted with the agent registered: + +``` +> Generate tests for slack +> Run tests for slack +``` + +You'll get a pass/fail table. If tests fail: + +``` +> Get the failing test results for slack +> Propose a repair for slack +``` + +The LLM analyzes the failures and suggests specific changes to the schema and/or grammar. Review the proposal, then: + +``` +> Approve the repair for slack +``` + +Then re-run: + +``` +> Run tests for slack +``` + +Repeat until pass rate is satisfactory. A common target is >90% before handing off to users. + +--- + +## Checking in on progress + +At any point: + +``` +> What's the status of the slack onboarding? +``` + +You'll see a phase-by-phase table like: + +``` +| Phase | Status | +|-------------|------------| +| discovery | ✅ approved | +| phraseGen | ✅ approved | +| schemaGen | ✅ approved | +| grammarGen | 🔄 in-progress | +| scaffolder | ⏳ pending | +| testing | ⏳ pending | +| packaging | ⏳ pending | +``` + +--- + +## Resuming an interrupted onboarding + +If you close your session and come back later: + +``` +> Resume onboarding for slack +``` + +This tells you the current phase and what to do next. All artifacts are on disk — nothing is lost. + +List all integrations in progress: + +``` +> List my integrations +> List integrations in progress +``` + +--- + +## Implementing the action handler + +After scaffolding, you'll have a stub handler at `ts/packages/agents/slack/src/slackActionHandler.ts`. The stub looks like: + +```typescript +async function executeAction( + action: TypeAgentAction, + context: ActionContext, +): Promise { + // TODO: implement action handlers + return createActionResultFromTextDisplay( + `Executing ${action.actionName} — not yet implemented.`, + ); +} +``` + +Fill in the cases using the Slack Web API client. For example: + +```typescript +switch (action.actionName) { + case "postMessage": { + const result = await slackClient.chat.postMessage({ + channel: action.parameters.channelId, + text: action.parameters.message, + }); + return createActionResultFromTextDisplay( + `Message sent to ${action.parameters.channelId}`, + ); + } + // ... +} +``` + +After implementing, rebuild: + +```bash +cd ts/packages/agents/slack +pnpm run build +``` + +Then re-run tests: + +``` +> Run tests for slack +``` + +--- + +## Sample session: rapid onboarding via AI assistant + +Here's a compressed example of what a full session looks like from the AI assistant's perspective: + +``` +You: I want to add a Jira integration to TypeAgent. It should support creating + issues, searching issues, updating issue status, and adding comments. + +AI: I'll kick off the Jira onboarding. Let me start by discovering the API surface. + + [calls startOnboarding for jira] + [calls parseOpenApiSpec from https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json] + Found 147 API actions. Here are a few: + - createIssue: Creates a new issue + - searchIssues: Searches for issues using JQL + - transitionIssue: Transitions an issue to a new status + - addComment: Adds a comment to an issue + ... + + I'll approve just the 4 you specified. + [calls approveApiSurface with includeActions: [createIssue, searchIssues, transitionIssue, addComment]] + [calls generatePhrases] + [calls approvePhrases] + [calls generateSchema] + + Here's the generated schema — looks good, I'll approve it. + [calls approveSchema] + [calls generateGrammar] + [calls compileGrammar] ✓ Compiled successfully + [calls approveGrammar] + [calls scaffoldAgent] + + The Jira agent is scaffolded at ts/packages/agents/jira/. Now I'll package + and register it so we can run tests. + + [calls packageAgent with register: true] + + TypeAgent needs a restart to pick up the new agent. Once you've restarted: + + [calls generateTests] + [calls runTests] + + Pass rate: 87% (35/40). Proposing repair for the 5 failing tests... + [calls proposeRepair] + [calls approveRepair] + [calls runTests] + + Pass rate: 97% (39/40). One edge case remaining in searchIssues — acceptable. + Onboarding complete! Implement slackActionHandler.ts to wire up the Jira API calls. +``` + +--- + +## Tips + +**Start narrow.** Onboard 4–6 actions first, get them working end-to-end, then add more. You can re-run `generatePhrases`, `generateSchema`, and `generateGrammar` incrementally. + +**Inspect artifacts directly.** All generated files are in `~/.typeagent/onboarding//`. You can edit them by hand before approving if the LLM output isn't quite right. + +**Grammar failures are normal.** The `.agr` compiler is strict. If `compileGrammar` fails, ask the AI to regenerate the grammar, or read the error and fix the specific rule. Common issues are ambiguous wildcards and missing required words before captures. + +**Test failures drive improvement.** A 70% pass rate on first run is typical. Two rounds of `proposeRepair` → `runTests` usually gets to 90%+. The LLM is good at diagnosing pattern mismatches. + +**Re-use grows over time.** The second integration you onboard will reuse the doc crawler, phrase generator, and schema generator — only the integration-specific configuration changes. diff --git a/ts/packages/agents/onboarding/package.json b/ts/packages/agents/onboarding/package.json new file mode 100644 index 0000000000..d7a09a6d90 --- /dev/null +++ b/ts/packages/agents/onboarding/package.json @@ -0,0 +1,61 @@ +{ + "name": "onboarding-agent", + "version": "0.0.1", + "private": true, + "description": "TypeAgent onboarding agent — automates integrating new applications into TypeAgent", + "homepage": "https://github.com/microsoft/TypeAgent#readme", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/TypeAgent.git", + "directory": "ts/packages/agents/onboarding" + }, + "license": "MIT", + "author": "Microsoft", + "type": "module", + "exports": { + "./agent/manifest": "./src/onboardingManifest.json", + "./agent/handlers": "./dist/onboardingActionHandler.js" + }, + "scripts": { + "agc:discovery": "agc -i ./src/discovery/discoverySchema.agr -o ./dist/discoverySchema.ag.json", + "agc:grammargen": "agc -i ./src/grammarGen/grammarGenSchema.agr -o ./dist/grammarGenSchema.ag.json", + "agc:main": "agc -i ./src/onboardingSchema.agr -o ./dist/onboardingSchema.ag.json", + "agc:packaging": "agc -i ./src/packaging/packagingSchema.agr -o ./dist/packagingSchema.ag.json", + "agc:phrasegen": "agc -i ./src/phraseGen/phraseGenSchema.agr -o ./dist/phraseGenSchema.ag.json", + "agc:scaffolder": "agc -i ./src/scaffolder/scaffolderSchema.agr -o ./dist/scaffolderSchema.ag.json", + "agc:schemagen": "agc -i ./src/schemaGen/schemaGenSchema.agr -o ./dist/schemaGenSchema.ag.json", + "agc:testing": "agc -i ./src/testing/testingSchema.agr -o ./dist/testingSchema.ag.json", + "asc:discovery": "asc -i ./src/discovery/discoverySchema.ts -o ./dist/discoverySchema.pas.json -t DiscoveryActions", + "asc:grammargen": "asc -i ./src/grammarGen/grammarGenSchema.ts -o ./dist/grammarGenSchema.pas.json -t GrammarGenActions", + "asc:main": "asc -i ./src/onboardingSchema.ts -o ./dist/onboardingSchema.pas.json -t OnboardingActions", + "asc:packaging": "asc -i ./src/packaging/packagingSchema.ts -o ./dist/packagingSchema.pas.json -t PackagingActions", + "asc:phrasegen": "asc -i ./src/phraseGen/phraseGenSchema.ts -o ./dist/phraseGenSchema.pas.json -t PhraseGenActions", + "asc:scaffolder": "asc -i ./src/scaffolder/scaffolderSchema.ts -o ./dist/scaffolderSchema.pas.json -t ScaffolderActions", + "asc:schemagen": "asc -i ./src/schemaGen/schemaGenSchema.ts -o ./dist/schemaGenSchema.pas.json -t SchemaGenActions", + "asc:testing": "asc -i ./src/testing/testingSchema.ts -o ./dist/testingSchema.pas.json -t TestingActions", + "build": "concurrently npm:tsc npm:asc:* npm:agc:*", + "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", + "prettier": "prettier --check . --ignore-path ../../../.prettierignore", + "prettier:fix": "prettier --write . --ignore-path ../../../.prettierignore", + "tsc": "tsc -b" + }, + "dependencies": { + "@typeagent/agent-sdk": "workspace:*", + "@typeagent/dispatcher-types": "workspace:*", + "agent-dispatcher": "workspace:*", + "aiclient": "workspace:*", + "dispatcher-node-providers": "workspace:*", + "typechat": "^0.1.1" + }, + "devDependencies": { + "@typeagent/action-schema-compiler": "workspace:*", + "action-grammar-compiler": "workspace:*", + "concurrently": "^9.1.2", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "typescript": "~5.4.5" + }, + "engines": { + "node": ">=20" + } +} diff --git a/ts/packages/agents/onboarding/src/discovery/discoveryHandler.ts b/ts/packages/agents/onboarding/src/discovery/discoveryHandler.ts new file mode 100644 index 0000000000..79faa7ccf7 --- /dev/null +++ b/ts/packages/agents/onboarding/src/discovery/discoveryHandler.ts @@ -0,0 +1,641 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Phase 1 — Discovery handler. +// Enumerates the API surface of the target application from documentation +// or an OpenAPI spec, saving results to the workspace for the next phase. + +import { + ActionContext, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromMarkdownDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { DiscoveryActions } from "./discoverySchema.js"; +import { + loadState, + updatePhase, + writeArtifactJson, + readArtifactJson, +} from "../lib/workspace.js"; +import { getDiscoveryModel } from "../lib/llm.js"; + +// Represents a single discovered API action +export type DiscoveredAction = { + name: string; + description: string; + // HTTP method if REST, or operation type + method?: string; + // Endpoint path or function signature + path?: string; + // Discovered parameters + parameters?: DiscoveredParameter[]; + // Source URL where this was found + sourceUrl?: string; +}; + +export type DiscoveredParameter = { + name: string; + type: string; + description?: string; + required?: boolean; +}; + +export type ApiSurface = { + integrationName: string; + discoveredAt: string; + source: string; + actions: DiscoveredAction[]; + approved?: boolean; + approvedAt?: string; + approvedActions?: string[]; +}; + +export async function executeDiscoveryAction( + action: TypeAgentAction, + _context: ActionContext, +): Promise { + switch (action.actionName) { + case "crawlDocUrl": + return handleCrawlDocUrl( + action.parameters.integrationName, + action.parameters.url, + action.parameters.maxDepth ?? 2, + ); + + case "parseOpenApiSpec": + return handleParseOpenApiSpec( + action.parameters.integrationName, + action.parameters.specSource, + ); + + case "listDiscoveredActions": + return handleListDiscoveredActions( + action.parameters.integrationName, + ); + + case "approveApiSurface": + return handleApproveApiSurface( + action.parameters.integrationName, + action.parameters.includeActions, + action.parameters.excludeActions, + ); + } +} + +async function handleCrawlDocUrl( + integrationName: string, + url: string, + maxDepth: number, +): Promise { + const state = await loadState(integrationName); + if (!state) { + return { + error: `Integration "${integrationName}" not found. Run startOnboarding first.`, + }; + } + + await updatePhase(integrationName, "discovery", { status: "in-progress" }); + + const model = getDiscoveryModel(); + + // Fetch and parse the documentation page + let pageContent: string; + try { + const response = await fetch(url); + if (!response.ok) { + return { + error: `Failed to fetch ${url}: ${response.status} ${response.statusText}`, + }; + } + pageContent = await response.text(); + } catch (err: any) { + return { error: `Failed to fetch ${url}: ${err?.message ?? err}` }; + } + + // Strip HTML tags and collapse whitespace to get readable text content + const textContent = stripHtml(pageContent); + + // Follow links up to maxDepth levels + const linkedContent = await crawlLinks( + url, + pageContent, + maxDepth, + integrationName, + ); + + // Use LLM to extract API actions from the page content + const prompt = [ + { + role: "system" as const, + content: + "You are an API documentation analyzer. Extract a list of user-facing API actions/operations from the provided documentation. " + + "For each action, identify: name (camelCase), description, HTTP method (if applicable), endpoint path (if applicable), and parameters. " + + "IMPORTANT: Only include actions that represent real operations a user would invoke. " + + "Exclude internal/infrastructure methods like: load, sync, toJSON, context, track, untrack, set, get (bare getters/setters without a domain concept). " + + "Return a JSON array of actions with shape: { name, description, method?, path?, parameters?: [{name, type, description?, required?}] }[]", + }, + { + role: "user" as const, + content: + `Extract all user-facing API actions from this documentation for the "${integrationName}" integration.\n\n` + + `Primary URL: ${url}\n\n` + + `Content:\n${(textContent + "\n\n" + linkedContent).slice(0, 16000)}`, + }, + ]; + + const result = await model.complete(prompt); + if (!result.success) { + return { error: `LLM extraction failed: ${result.message}` }; + } + + let actions: DiscoveredAction[] = []; + try { + // Extract JSON from LLM response + const jsonMatch = result.data.match(/\[[\s\S]*\]/); + if (jsonMatch) { + actions = JSON.parse(jsonMatch[0]); + } + } catch { + return { error: "Failed to parse LLM response as JSON action list." }; + } + + // Add source URL to each action; filter out internal framework methods + actions = actions + .map((a) => ({ ...a, sourceUrl: url })) + .filter((a) => !isInternalAction(a.name)); + + // Merge with any existing discovered actions + const existing = await readArtifactJson( + integrationName, + "discovery", + "api-surface.json", + ); + const merged: ApiSurface = { + integrationName, + discoveredAt: new Date().toISOString(), + source: url, + actions: [ + ...(existing?.actions ?? []).filter( + (a) => !actions.find((n) => n.name === a.name), + ), + ...actions, + ], + }; + + await writeArtifactJson( + integrationName, + "discovery", + "api-surface.json", + merged, + ); + + return createActionResultFromMarkdownDisplay( + `## Discovery complete: ${integrationName}\n\n` + + `**Source:** ${url}\n` + + `**Actions found:** ${actions.length}\n\n` + + actions + .slice(0, 20) + .map((a) => `- **${a.name}**: ${a.description}`) + .join("\n") + + (actions.length > 20 + ? `\n\n_...and ${actions.length - 20} more_` + : "") + + `\n\nReview with \`listDiscoveredActions\`, then \`approveApiSurface\` to proceed.`, + ); +} + +// ── HTML helpers ───────────────────────────────────────────────────────────── + +// Strip HTML tags and collapse whitespace to extract readable text. +function stripHtml(html: string): string { + // Repeatedly remove multi-character patterns until stable to avoid + // incomplete sanitization from overlapping/re-formed substrings. + let sanitized = html; + let previous: string; + do { + previous = sanitized; + sanitized = sanitized + .replace(//gi, "") + .replace(//gi, ""); + } while (sanitized !== previous); + + return sanitized + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/&/g, "&") + .replace(/\s{2,}/g, " ") + .trim(); +} + +// Extract same-origin links from an HTML page. +function extractLinks(baseUrl: string, html: string): string[] { + const base = new URL(baseUrl); + const links: string[] = []; + const hrefRe = /href=["']([^"'#?]+)["']/gi; + let m: RegExpExecArray | null; + while ((m = hrefRe.exec(html)) !== null) { + try { + const resolved = new URL(m[1], baseUrl); + // Only follow links on the same hostname and path prefix + if ( + resolved.hostname === base.hostname && + resolved.pathname.startsWith( + base.pathname.split("/").slice(0, -1).join("/"), + ) + ) { + links.push(resolved.href); + } + } catch { + // skip malformed URLs + } + } + // Deduplicate + return [...new Set(links)].slice(0, 30); // cap at 30 links +} + +// Crawl linked pages up to maxDepth and return combined text (capped to 8000 chars per page). +async function crawlLinks( + baseUrl: string, + baseHtml: string, + maxDepth: number, + _integrationName: string, +): Promise { + if (maxDepth <= 1) return ""; + + const links = extractLinks(baseUrl, baseHtml); + const visited = new Set([baseUrl]); + const chunks: string[] = []; + + for (const link of links.slice(0, 15)) { + if (visited.has(link)) continue; + visited.add(link); + try { + const resp = await fetch(link); + if (!resp.ok) continue; + const html = await resp.text(); + const text = stripHtml(html).slice(0, 8000); + chunks.push(`\n--- ${link} ---\n${text}`); + } catch { + // skip unreachable pages + } + } + + return chunks.join("\n").slice(0, 40000); +} + +// Names that are internal Office.js / API framework infrastructure, not user-facing operations. +const INTERNAL_ACTION_NAMES = new Set([ + "load", + "sync", + "toJSON", + "track", + "untrack", + "context", + "getItem", + "getCount", + "getItemOrNullObject", + "getFirstOrNullObject", + "getLastOrNullObject", + "getLast", + "getFirst", + "items", +]); + +function isInternalAction(name: string): boolean { + if (INTERNAL_ACTION_NAMES.has(name)) return true; + // Bare getters/setters with no domain concept (e.g. "get", "set", "load") + if (/^(get|set|load|read|fetch)$/.test(name)) return true; + return false; +} + +async function handleParseOpenApiSpec( + integrationName: string, + specSource: string, +): Promise { + const state = await loadState(integrationName); + if (!state) { + return { + error: `Integration "${integrationName}" not found. Run startOnboarding first.`, + }; + } + + await updatePhase(integrationName, "discovery", { status: "in-progress" }); + + // Fetch the spec (URL or file path) + let specContent: string; + try { + if ( + specSource.startsWith("http://") || + specSource.startsWith("https://") + ) { + const response = await fetch(specSource); + if (!response.ok) { + return { + error: `Failed to fetch spec: ${response.status} ${response.statusText}`, + }; + } + specContent = await response.text(); + } else { + const fs = await import("fs/promises"); + specContent = await fs.readFile(specSource, "utf-8"); + } + } catch (err: any) { + return { + error: `Failed to read spec from ${specSource}: ${err?.message ?? err}`, + }; + } + + let spec: any; + try { + spec = JSON.parse(specContent); + } catch { + try { + // Try YAML if JSON fails (basic line parsing) + return { + error: "YAML specs not yet supported — please provide a JSON OpenAPI spec.", + }; + } catch { + return { error: "Could not parse spec as JSON or YAML." }; + } + } + + // Extract actions from OpenAPI paths + const actions: DiscoveredAction[] = []; + const paths = spec.paths ?? {}; + for (const [pathStr, pathItem] of Object.entries(paths) as [ + string, + any, + ][]) { + for (const method of [ + "get", + "post", + "put", + "patch", + "delete", + ] as const) { + const op = pathItem?.[method]; + if (!op) continue; + + const name = + op.operationId ?? + `${method}${pathStr.replace(/[^a-zA-Z0-9]/g, "_")}`; + const camelName = name.replace( + /_([a-z])/g, + (_: string, c: string) => c.toUpperCase(), + ); + + const parameters: DiscoveredParameter[] = (op.parameters ?? []).map( + (p: any) => ({ + name: p.name, + type: p.schema?.type ?? "string", + description: p.description, + required: p.required ?? false, + }), + ); + + // Also include request body fields as parameters + const requestBody = + op.requestBody?.content?.["application/json"]?.schema; + if (requestBody?.properties) { + for (const [propName, propSchema] of Object.entries( + requestBody.properties, + ) as [string, any][]) { + parameters.push({ + name: propName, + type: propSchema.type ?? "string", + description: propSchema.description, + required: + requestBody.required?.includes(propName) ?? false, + }); + } + } + + actions.push({ + name: camelName, + description: + op.summary ?? + op.description ?? + `${method.toUpperCase()} ${pathStr}`, + method: method.toUpperCase(), + path: pathStr, + parameters, + sourceUrl: specSource, + }); + } + } + + const surface: ApiSurface = { + integrationName, + discoveredAt: new Date().toISOString(), + source: specSource, + actions, + }; + + await writeArtifactJson( + integrationName, + "discovery", + "api-surface.json", + surface, + ); + + return createActionResultFromMarkdownDisplay( + `## OpenAPI spec parsed: ${integrationName}\n\n` + + `**Source:** ${specSource}\n` + + `**OpenAPI version:** ${spec.openapi ?? spec.swagger ?? "unknown"}\n` + + `**Actions found:** ${actions.length}\n\n` + + actions + .slice(0, 20) + .map( + (a) => + `- **${a.name}** (\`${a.method} ${a.path}\`): ${a.description}`, + ) + .join("\n") + + (actions.length > 20 + ? `\n\n_...and ${actions.length - 20} more_` + : "") + + `\n\nReview with \`listDiscoveredActions\`, then \`approveApiSurface\` to proceed.`, + ); +} + +async function handleListDiscoveredActions( + integrationName: string, +): Promise { + const surface = await readArtifactJson( + integrationName, + "discovery", + "api-surface.json", + ); + if (!surface) { + return { + error: `No discovered actions found for "${integrationName}". Run crawlDocUrl or parseOpenApiSpec first.`, + }; + } + + const lines = [ + `## Discovered actions: ${integrationName}`, + ``, + `**Source:** ${surface.source}`, + `**Discovered:** ${surface.discoveredAt}`, + `**Total actions:** ${surface.actions.length}`, + `**Status:** ${surface.approved ? "✅ Approved" : "⏳ Pending approval"}`, + ``, + `| # | Name | Description |`, + `|---|---|---|`, + ...surface.actions.map( + (a, i) => `| ${i + 1} | \`${a.name}\` | ${a.description} |`, + ), + ]; + + return createActionResultFromMarkdownDisplay(lines.join("\n")); +} + +async function handleApproveApiSurface( + integrationName: string, + includeActions?: string[], + excludeActions?: string[], +): Promise { + const surface = await readArtifactJson( + integrationName, + "discovery", + "api-surface.json", + ); + if (!surface) { + return { + error: `No discovered actions found for "${integrationName}".`, + }; + } + + let approved = surface.actions; + if (includeActions && includeActions.length > 0) { + approved = approved.filter((a) => includeActions.includes(a.name)); + } + if (excludeActions && excludeActions.length > 0) { + approved = approved.filter((a) => !excludeActions.includes(a.name)); + } + + const updated: ApiSurface = { + ...surface, + approved: true, + approvedAt: new Date().toISOString(), + approvedActions: approved.map((a) => a.name), + actions: approved, + }; + + await writeArtifactJson( + integrationName, + "discovery", + "api-surface.json", + updated, + ); + await updatePhase(integrationName, "discovery", { status: "approved" }); + + // If many actions, recommend sub-schema categorization + let subSchemaNote = ""; + if (approved.length > 20) { + subSchemaNote = await generateSubSchemaRecommendation( + integrationName, + approved, + ); + } + + return createActionResultFromMarkdownDisplay( + `## API surface approved: ${integrationName}\n\n` + + `**Approved actions:** ${approved.length}\n\n` + + approved + .map((a) => `- \`${a.name}\`: ${a.description}`) + .join("\n") + + subSchemaNote + + `\n\n**Next step:** Phase 2 — use \`generatePhrases\` to create natural language samples.`, + ); +} + +// When the approved action count exceeds 20, ask the LLM to categorize them +// into logical groups and save a sub-schema-groups.json artifact so that the +// scaffolder phase can generate sub-action manifests. +type SubSchemaGroup = { + name: string; + description: string; + actions: string[]; +}; + +type SubSchemaSuggestion = { + recommended: boolean; + groups: SubSchemaGroup[]; +}; + +async function generateSubSchemaRecommendation( + integrationName: string, + approved: DiscoveredAction[], +): Promise { + const model = getDiscoveryModel(); + const actionList = approved + .map((a) => `- ${a.name}: ${a.description}`) + .join("\n"); + + const prompt = [ + { + role: "system" as const, + content: + "You are an API architect. Given a list of API actions, categorize them " + + "into logical groups suitable for sub-schema separation in a TypeAgent agent. " + + "Each group should have a short camelCase name, a description, and the list of action names belonging to it. " + + "Every action must appear in exactly one group. Aim for 3-7 groups. " + + "Return ONLY a JSON array of objects with keys: name, description, actions.", + }, + { + role: "user" as const, + content: `Categorize these ${approved.length} actions for the "${integrationName}" integration into logical sub-schema groups:\n\n${actionList}`, + }, + ]; + + const result = await model.complete(prompt); + if (!result.success) { + // Non-fatal — just skip the recommendation + return "\n\n> **Note:** Could not generate sub-schema recommendation (LLM error). You can still proceed."; + } + + let groups: SubSchemaGroup[] = []; + try { + const jsonMatch = result.data.match(/\[[\s\S]*\]/); + if (jsonMatch) { + groups = JSON.parse(jsonMatch[0]); + } + } catch { + return "\n\n> **Note:** Could not parse sub-schema recommendation. You can still proceed."; + } + + if (groups.length === 0) { + return ""; + } + + const suggestion: SubSchemaSuggestion = { + recommended: true, + groups, + }; + + await writeArtifactJson( + integrationName, + "discovery", + "sub-schema-groups.json", + suggestion, + ); + + const groupSummary = groups + .map( + (g) => + `- **${g.name}** (${g.actions.length} actions): ${g.description}`, + ) + .join("\n"); + + return ( + `\n\n---\n### Sub-schema recommendation\n\n` + + `With **${approved.length} actions**, we recommend splitting into sub-schemas for better organization:\n\n` + + groupSummary + + `\n\nThis grouping has been saved to \`discovery/sub-schema-groups.json\`. ` + + `The scaffolder will use it to generate separate schema and grammar files per group.` + ); +} diff --git a/ts/packages/agents/onboarding/src/discovery/discoverySchema.agr b/ts/packages/agents/onboarding/src/discovery/discoverySchema.agr new file mode 100644 index 0000000000..f130d13d93 --- /dev/null +++ b/ts/packages/agents/onboarding/src/discovery/discoverySchema.agr @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Grammar for Phase 1 — Discovery actions. + +// crawlDocUrl - crawl API documentation at a URL + = crawl (docs | documentation | api) (at | from)? $(url:wildcard) for $(integrationName:wildcard) -> { + actionName: "crawlDocUrl", + parameters: { + url, + integrationName + } +} + | (fetch | scrape | read) (the)? $(integrationName:wildcard) (api)? (docs | documentation) (at | from)? $(url:wildcard) -> { + actionName: "crawlDocUrl", + parameters: { + integrationName, + url + } +}; + +// parseOpenApiSpec - parse an OpenAPI or Swagger spec + = parse (the)? (openapi | swagger | api) spec (at | from)? $(specSource:wildcard) for $(integrationName:wildcard) -> { + actionName: "parseOpenApiSpec", + parameters: { + specSource, + integrationName + } +} + | (load | ingest) (the)? $(integrationName:wildcard) (openapi | swagger | api) spec (from | at)? $(specSource:wildcard) -> { + actionName: "parseOpenApiSpec", + parameters: { + integrationName, + specSource + } +}; + +// listDiscoveredActions - show what was found + = list (discovered | found)? actions for $(integrationName:wildcard) -> { + actionName: "listDiscoveredActions", + parameters: { + integrationName + } +} + | (show | what are) (the)? (discovered | available)? actions (for | in)? $(integrationName:wildcard) -> { + actionName: "listDiscoveredActions", + parameters: { + integrationName + } +}; + +// approveApiSurface - lock in the discovered action set + = approve (the)? (api)? (surface | actions) for $(integrationName:wildcard) -> { + actionName: "approveApiSurface", + parameters: { + integrationName + } +} + | (confirm | finalize) (the)? $(integrationName:wildcard) (api)? surface -> { + actionName: "approveApiSurface", + parameters: { + integrationName + } +}; + +import { DiscoveryActions } from "./discoverySchema.ts"; + + : DiscoveryActions = + | + | + | ; diff --git a/ts/packages/agents/onboarding/src/discovery/discoverySchema.ts b/ts/packages/agents/onboarding/src/discovery/discoverySchema.ts new file mode 100644 index 0000000000..44d27c9bcc --- /dev/null +++ b/ts/packages/agents/onboarding/src/discovery/discoverySchema.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type DiscoveryActions = + | CrawlDocUrlAction + | ParseOpenApiSpecAction + | ListDiscoveredActionsAction + | ApproveApiSurfaceAction; + +export type CrawlDocUrlAction = { + actionName: "crawlDocUrl"; + parameters: { + // Name of the integration being onboarded + integrationName: string; + // URL of the API documentation page to crawl (e.g. "https://api.slack.com/methods") + url: string; + // Maximum link-follow depth (default: 2) + maxDepth?: number; + }; +}; + +export type ParseOpenApiSpecAction = { + actionName: "parseOpenApiSpec"; + parameters: { + // Name of the integration being onboarded + integrationName: string; + // URL or absolute file path to the OpenAPI 3.x or Swagger 2.x spec + specSource: string; + }; +}; + +export type ListDiscoveredActionsAction = { + actionName: "listDiscoveredActions"; + parameters: { + // Integration name to list discovered actions for + integrationName: string; + }; +}; + +export type ApproveApiSurfaceAction = { + actionName: "approveApiSurface"; + parameters: { + // Integration name to approve + integrationName: string; + // If provided, only these action names are included in the approved surface (excludes all others) + includeActions?: string[]; + // Action names to exclude from the approved surface + excludeActions?: string[]; + }; +}; diff --git a/ts/packages/agents/onboarding/src/grammarGen/grammarGenHandler.ts b/ts/packages/agents/onboarding/src/grammarGen/grammarGenHandler.ts new file mode 100644 index 0000000000..898074830b --- /dev/null +++ b/ts/packages/agents/onboarding/src/grammarGen/grammarGenHandler.ts @@ -0,0 +1,297 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Phase 4 — Grammar Generation handler. +// Generates a .agr grammar file from the approved schema and phrase set, +// then compiles it via the action-grammar-compiler (agc) to validate. + +import { + ActionContext, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromMarkdownDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { GrammarGenActions } from "./grammarGenSchema.js"; +import { + loadState, + updatePhase, + writeArtifact, + readArtifact, + readArtifactJson, + getPhasePath, +} from "../lib/workspace.js"; +import { getGrammarGenModel } from "../lib/llm.js"; +import { ApiSurface } from "../discovery/discoveryHandler.js"; +import { PhraseSet } from "../phraseGen/phraseGenHandler.js"; +import { spawn } from "child_process"; +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; + +export async function executeGrammarGenAction( + action: TypeAgentAction, + _context: ActionContext, +): Promise { + switch (action.actionName) { + case "generateGrammar": + return handleGenerateGrammar(action.parameters.integrationName); + case "compileGrammar": + return handleCompileGrammar(action.parameters.integrationName); + case "approveGrammar": + return handleApproveGrammar(action.parameters.integrationName); + } +} + +async function handleGenerateGrammar( + integrationName: string, +): Promise { + const state = await loadState(integrationName); + if (!state) return { error: `Integration "${integrationName}" not found.` }; + if (state.phases.schemaGen.status !== "approved") { + return { + error: `Schema phase must be approved first. Run approveSchema.`, + }; + } + + const surface = await readArtifactJson( + integrationName, + "discovery", + "api-surface.json", + ); + const phraseSet = await readArtifactJson( + integrationName, + "phraseGen", + "phrases.json", + ); + const schemaTs = await readArtifact( + integrationName, + "schemaGen", + "schema.ts", + ); + if (!surface || !phraseSet || !schemaTs) { + return { + error: `Missing required artifacts for "${integrationName}".`, + }; + } + + await updatePhase(integrationName, "grammarGen", { status: "in-progress" }); + + const model = getGrammarGenModel(); + const prompt = buildGrammarPrompt( + integrationName, + surface, + phraseSet, + schemaTs, + ); + const result = await model.complete(prompt); + if (!result.success) { + return { error: `Grammar generation failed: ${result.message}` }; + } + + const grammarContent = extractGrammarContent(result.data); + await writeArtifact( + integrationName, + "grammarGen", + "schema.agr", + grammarContent, + ); + + return createActionResultFromMarkdownDisplay( + `## Grammar generated: ${integrationName}\n\n` + + "```\n" + + grammarContent.slice(0, 2000) + + (grammarContent.length > 2000 ? "\n// ... (truncated)" : "") + + "\n```\n\n" + + `Use \`compileGrammar\` to validate, or \`approveGrammar\` if it looks correct.`, + ); +} + +async function handleCompileGrammar( + integrationName: string, +): Promise { + const grammarPath = path.join( + getPhasePath(integrationName, "grammarGen"), + "schema.agr", + ); + const outputPath = path.join( + getPhasePath(integrationName, "grammarGen"), + "schema.ag.json", + ); + + const grammarContent = await readArtifact( + integrationName, + "grammarGen", + "schema.agr", + ); + if (!grammarContent) { + return { + error: `No grammar file found for "${integrationName}". Run generateGrammar first.`, + }; + } + + // Copy the schema .ts file into grammarGen/ so the agr import resolves + const schemaSrc = path.join( + getPhasePath(integrationName, "schemaGen"), + "schema.ts", + ); + const schemaDst = path.join( + getPhasePath(integrationName, "grammarGen"), + "schema.ts", + ); + try { + await fs.copyFile(schemaSrc, schemaDst); + } catch { + return { + error: `Could not copy schema.ts into grammarGen/ for compilation. Ensure schema is approved.`, + }; + } + + return new Promise((resolve) => { + // Resolve agc from the package's own node_modules/.bin + const pkgDir = path.resolve( + fileURLToPath(import.meta.url), + "..", + "..", + "..", + ); + const binDir = path.join(pkgDir, "node_modules", ".bin"); + const env = { + ...process.env, + PATH: binDir + path.delimiter + (process.env.PATH ?? ""), + }; + + const proc = spawn("agc", ["-i", grammarPath, "-o", outputPath], { + stdio: ["ignore", "pipe", "pipe"], + env, + shell: true, + }); + + let stdout = ""; + let stderr = ""; + proc.stdout?.on("data", (d: Buffer) => { + stdout += d.toString(); + }); + proc.stderr?.on("data", (d: Buffer) => { + stderr += d.toString(); + }); + + proc.on("close", (code) => { + if (code === 0) { + resolve( + createActionResultFromMarkdownDisplay( + `## Grammar compiled successfully: ${integrationName}\n\n` + + `Output: \`schema.ag.json\`\n\n` + + (stdout + ? `Compiler output:\n\`\`\`\n${stdout}\n\`\`\`` + : "") + + `\n\nUse \`approveGrammar\` to proceed to scaffolding.`, + ), + ); + } else { + resolve({ + error: + `Grammar compilation failed (exit code ${code}).\n\n` + + (stderr || stdout || "No output from compiler.") + + `\n\nUse \`generateGrammar\` or \`refineSchema\` to fix the grammar.`, + }); + } + }); + + proc.on("error", (err) => { + resolve({ + error: `Failed to run agc: ${err.message}. Is action-grammar-compiler installed?`, + }); + }); + }); +} + +async function handleApproveGrammar( + integrationName: string, +): Promise { + const grammar = await readArtifact( + integrationName, + "grammarGen", + "schema.agr", + ); + if (!grammar) { + return { + error: `No grammar found for "${integrationName}". Run generateGrammar first.`, + }; + } + + await updatePhase(integrationName, "grammarGen", { status: "approved" }); + + return createActionResultFromMarkdownDisplay( + `## Grammar approved: ${integrationName}\n\n` + + `**Next step:** Phase 5 — use \`scaffoldAgent\` to create the agent package.`, + ); +} + +function buildGrammarPrompt( + integrationName: string, + surface: ApiSurface, + phraseSet: PhraseSet, + schemaTs: string, +): { role: "system" | "user"; content: string }[] { + const actionExamples = surface.actions + .map((a) => { + const phrases = phraseSet.phrases[a.name] ?? []; + return `Action: ${a.name}\nPhrases:\n${phrases + .slice(0, 4) + .map((p) => ` - "${p}"`) + .join("\n")}`; + }) + .join("\n\n"); + + return [ + { + role: "system", + content: + "You are an expert in TypeAgent grammar files (.agr format). " + + "Grammar rules use this syntax:\n" + + ' = pattern -> { actionName: "name", parameters: { ... } }\n' + + " | alternative -> { ... };\n\n" + + "Pattern syntax:\n" + + " - $(paramName:wildcard) captures 1+ words into a variable\n" + + " - $(paramName:word) captures exactly 1 word into a variable\n" + + " - (optional)? makes tokens optional\n" + + " - word matches a literal word\n" + + " - | separates alternatives\n\n" + + "IMPORTANT: In the action output object after ->, reference captured parameters by BARE NAME only, NOT with $() syntax.\n" + + "Example:\n" + + " = add $(item:wildcard) to (the)? $(listName:wildcard) list -> {\n" + + ' actionName: "addItems",\n' + + " parameters: {\n" + + " items: [item],\n" + + " listName\n" + + " }\n" + + " };\n\n" + + "The action output must use multi-line format with proper indentation as shown above.\n" + + "The file must start with a copyright header comment and end with:\n" + + ' import { ActionType } from "./schemaFile.ts";\n' + + " : ActionType = | | ...;\n\n" + + "Respond in JSON format. Return a JSON object with a single `grammar` key containing the .agr file content as a string.", + }, + { + role: "user", + content: + `Generate a TypeAgent .agr grammar file for the "${integrationName}" integration.\n\n` + + `TypeScript schema:\n\`\`\`typescript\n${schemaTs.slice(0, 3000)}\n\`\`\`\n\n` + + `Sample phrases for each action:\n${actionExamples}\n\n` + + `The schema file will be imported as "./schema.ts". The entry type is the main union type from the schema.`, + }, + ]; +} + +function extractGrammarContent(llmResponse: string): string { + // Try to parse as JSON first (when using json_object response format) + try { + const parsed = JSON.parse(llmResponse); + if (parsed.grammar) return parsed.grammar.trim(); + } catch { + // Not JSON, fall through to other extraction methods + } + const fenceMatch = llmResponse.match(/```(?:agr)?\n([\s\S]*?)```/); + if (fenceMatch) return fenceMatch[1].trim(); + return llmResponse.trim(); +} diff --git a/ts/packages/agents/onboarding/src/grammarGen/grammarGenSchema.agr b/ts/packages/agents/onboarding/src/grammarGen/grammarGenSchema.agr new file mode 100644 index 0000000000..49b3b55a76 --- /dev/null +++ b/ts/packages/agents/onboarding/src/grammarGen/grammarGenSchema.agr @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Grammar for Phase 4 — Grammar Generation actions. + + = generate (the)? (agr)? grammar for $(integrationName:wildcard) -> { + actionName: "generateGrammar", + parameters: { + integrationName + } +} + | (create | produce | write) (the)? $(integrationName:wildcard) (agr)? grammar (file)? -> { + actionName: "generateGrammar", + parameters: { + integrationName + } +}; + + = compile (the)? $(integrationName:wildcard) grammar -> { + actionName: "compileGrammar", + parameters: { + integrationName + } +} + | (validate | build | check) (the)? $(integrationName:wildcard) grammar -> { + actionName: "compileGrammar", + parameters: { + integrationName + } +}; + + = approve (the)? $(integrationName:wildcard) grammar -> { + actionName: "approveGrammar", + parameters: { + integrationName + } +} + | (confirm | finalize) (the)? $(integrationName:wildcard) (agr)? grammar -> { + actionName: "approveGrammar", + parameters: { + integrationName + } +}; + +import { GrammarGenActions } from "./grammarGenSchema.ts"; + + : GrammarGenActions = + | + | ; diff --git a/ts/packages/agents/onboarding/src/grammarGen/grammarGenSchema.ts b/ts/packages/agents/onboarding/src/grammarGen/grammarGenSchema.ts new file mode 100644 index 0000000000..4f5f258734 --- /dev/null +++ b/ts/packages/agents/onboarding/src/grammarGen/grammarGenSchema.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type GrammarGenActions = + | GenerateGrammarAction + | CompileGrammarAction + | ApproveGrammarAction; + +export type GenerateGrammarAction = { + actionName: "generateGrammar"; + parameters: { + // Integration name to generate grammar for + integrationName: string; + }; +}; + +export type CompileGrammarAction = { + actionName: "compileGrammar"; + parameters: { + // Integration name whose grammar to compile and validate + integrationName: string; + }; +}; + +export type ApproveGrammarAction = { + actionName: "approveGrammar"; + parameters: { + // Integration name to approve grammar for + integrationName: string; + }; +}; diff --git a/ts/packages/agents/onboarding/src/lib/llm.ts b/ts/packages/agents/onboarding/src/lib/llm.ts new file mode 100644 index 0000000000..e79e6b6d06 --- /dev/null +++ b/ts/packages/agents/onboarding/src/lib/llm.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// aiclient ChatModel factories for each onboarding phase. +// Each phase gets a distinct debug tag so LLM calls are easy to trace +// with DEBUG=typeagent:openai:* environment variable. +// +// Credentials are read from ts/.env via the standard TypeAgent mechanism. + +import { ChatModel, openai } from "aiclient"; + +export function getDiscoveryModel(): ChatModel { + return openai.createChatModelDefault("onboarding:discovery"); +} + +export function getPhraseGenModel(): ChatModel { + return openai.createChatModelDefault("onboarding:phrasegen"); +} + +export function getSchemaGenModel(): ChatModel { + return openai.createChatModelDefault("onboarding:schemagen"); +} + +export function getGrammarGenModel(): ChatModel { + return openai.createChatModelDefault("onboarding:grammargen"); +} + +export function getTestingModel(): ChatModel { + return openai.createChatModelDefault("onboarding:testing"); +} + +export function getPackagingModel(): ChatModel { + return openai.createChatModelDefault("onboarding:packaging"); +} diff --git a/ts/packages/agents/onboarding/src/lib/workspace.ts b/ts/packages/agents/onboarding/src/lib/workspace.ts new file mode 100644 index 0000000000..1b76879e8c --- /dev/null +++ b/ts/packages/agents/onboarding/src/lib/workspace.ts @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Manages per-integration workspace state persisted to disk. +// Each integration gets a folder at ~/.typeagent/onboarding// +// containing state.json and phase-specific artifact subdirectories. + +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +export type PhaseStatus = "pending" | "in-progress" | "approved" | "skipped"; + +export type PhaseState = { + status: PhaseStatus; + startedAt?: string; + completedAt?: string; +}; + +export type OnboardingPhase = + | "discovery" + | "phraseGen" + | "schemaGen" + | "grammarGen" + | "scaffolder" + | "testing" + | "packaging"; + +export const PHASE_ORDER: OnboardingPhase[] = [ + "discovery", + "phraseGen", + "schemaGen", + "grammarGen", + "scaffolder", + "testing", + "packaging", +]; + +export type OnboardingConfig = { + integrationName: string; + description?: string; + apiType?: "rest" | "graphql" | "websocket" | "ipc" | "sdk"; + docSources?: string[]; +}; + +export type OnboardingState = { + integrationName: string; + createdAt: string; + updatedAt: string; + // "complete" when all phases are approved + currentPhase: OnboardingPhase | "complete"; + config: OnboardingConfig; + phases: Record; +}; + +const BASE_DIR = path.join(os.homedir(), ".typeagent", "onboarding"); + +export function getWorkspacePath(integrationName: string): string { + return path.join(BASE_DIR, integrationName); +} + +export function getPhasePath( + integrationName: string, + phase: OnboardingPhase, +): string { + return path.join(getWorkspacePath(integrationName), phase); +} + +export async function createWorkspace( + config: OnboardingConfig, +): Promise { + const workspacePath = getWorkspacePath(config.integrationName); + await fs.mkdir(workspacePath, { recursive: true }); + + const emptyPhase = (): PhaseState => ({ status: "pending" }); + + const state: OnboardingState = { + integrationName: config.integrationName, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + currentPhase: "discovery", + config, + phases: { + discovery: emptyPhase(), + phraseGen: emptyPhase(), + schemaGen: emptyPhase(), + grammarGen: emptyPhase(), + scaffolder: emptyPhase(), + testing: emptyPhase(), + packaging: emptyPhase(), + }, + }; + + // Create phase subdirectories up front + for (const phase of PHASE_ORDER) { + await fs.mkdir(path.join(workspacePath, phase), { recursive: true }); + } + + await saveState(state); + return state; +} + +export async function loadState( + integrationName: string, +): Promise { + const statePath = path.join( + getWorkspacePath(integrationName), + "state.json", + ); + try { + const content = await fs.readFile(statePath, "utf-8"); + return JSON.parse(content) as OnboardingState; + } catch { + return undefined; + } +} + +export async function saveState(state: OnboardingState): Promise { + state.updatedAt = new Date().toISOString(); + const statePath = path.join( + getWorkspacePath(state.integrationName), + "state.json", + ); + await fs.writeFile(statePath, JSON.stringify(state, null, 2), "utf-8"); +} + +export async function updatePhase( + integrationName: string, + phase: OnboardingPhase, + update: Partial, +): Promise { + const state = await loadState(integrationName); + if (!state) { + throw new Error(`Integration "${integrationName}" not found`); + } + state.phases[phase] = { ...state.phases[phase], ...update }; + + // When approved, advance currentPhase to the next phase + if (update.status === "approved") { + state.phases[phase].completedAt = new Date().toISOString(); + const idx = PHASE_ORDER.indexOf(phase); + if (idx >= 0 && idx < PHASE_ORDER.length - 1) { + state.currentPhase = PHASE_ORDER[idx + 1]; + } else if (idx === PHASE_ORDER.length - 1) { + state.currentPhase = "complete"; + } + } + + if (update.status === "in-progress" && !state.phases[phase].startedAt) { + state.phases[phase].startedAt = new Date().toISOString(); + } + + await saveState(state); + return state; +} + +export async function readArtifact( + integrationName: string, + phase: OnboardingPhase, + filename: string, +): Promise { + const filePath = path.join(getPhasePath(integrationName, phase), filename); + try { + return await fs.readFile(filePath, "utf-8"); + } catch { + return undefined; + } +} + +export async function writeArtifact( + integrationName: string, + phase: OnboardingPhase, + filename: string, + content: string, +): Promise { + const dirPath = getPhasePath(integrationName, phase); + await fs.mkdir(dirPath, { recursive: true }); + const filePath = path.join(dirPath, filename); + await fs.writeFile(filePath, content, "utf-8"); + return filePath; +} + +export async function readArtifactJson( + integrationName: string, + phase: OnboardingPhase, + filename: string, +): Promise { + const content = await readArtifact(integrationName, phase, filename); + if (!content) return undefined; + return JSON.parse(content) as T; +} + +export async function writeArtifactJson( + integrationName: string, + phase: OnboardingPhase, + filename: string, + data: unknown, +): Promise { + return writeArtifact( + integrationName, + phase, + filename, + JSON.stringify(data, null, 2), + ); +} + +export async function listIntegrations(): Promise { + try { + const entries = await fs.readdir(BASE_DIR, { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => e.name); + } catch { + return []; + } +} diff --git a/ts/packages/agents/onboarding/src/onboardingActionHandler.ts b/ts/packages/agents/onboarding/src/onboardingActionHandler.ts new file mode 100644 index 0000000000..b329a56f00 --- /dev/null +++ b/ts/packages/agents/onboarding/src/onboardingActionHandler.ts @@ -0,0 +1,290 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { + createActionResultFromTextDisplay, + createActionResultFromMarkdownDisplay, +} from "@typeagent/agent-sdk/helpers/action"; +import { OnboardingActions } from "./onboardingSchema.js"; +import { DiscoveryActions } from "./discovery/discoverySchema.js"; +import { PhraseGenActions } from "./phraseGen/phraseGenSchema.js"; +import { SchemaGenActions } from "./schemaGen/schemaGenSchema.js"; +import { GrammarGenActions } from "./grammarGen/grammarGenSchema.js"; +import { ScaffolderActions } from "./scaffolder/scaffolderSchema.js"; +import { TestingActions } from "./testing/testingSchema.js"; +import { PackagingActions } from "./packaging/packagingSchema.js"; +import { executeDiscoveryAction } from "./discovery/discoveryHandler.js"; +import { executePhraseGenAction } from "./phraseGen/phraseGenHandler.js"; +import { executeSchemaGenAction } from "./schemaGen/schemaGenHandler.js"; +import { executeGrammarGenAction } from "./grammarGen/grammarGenHandler.js"; +import { executeScaffolderAction } from "./scaffolder/scaffolderHandler.js"; +import { executeTestingAction } from "./testing/testingHandler.js"; +import { executePackagingAction } from "./packaging/packagingHandler.js"; +import { + createWorkspace, + loadState, + listIntegrations, +} from "./lib/workspace.js"; + +type AllActions = + | OnboardingActions + | DiscoveryActions + | PhraseGenActions + | SchemaGenActions + | GrammarGenActions + | ScaffolderActions + | TestingActions + | PackagingActions; + +export function instantiate(): AppAgent { + return { + executeAction, + }; +} + +async function executeAction( + action: TypeAgentAction, + context: ActionContext, +): Promise { + const { actionName } = action as TypeAgentAction; + + // Top-level coordination actions + if ( + actionName === "startOnboarding" || + actionName === "resumeOnboarding" || + actionName === "getOnboardingStatus" || + actionName === "listIntegrations" + ) { + return executeOnboardingAction( + action as TypeAgentAction, + context, + ); + } + + // Discovery phase + if ( + actionName === "crawlDocUrl" || + actionName === "parseOpenApiSpec" || + actionName === "listDiscoveredActions" || + actionName === "approveApiSurface" + ) { + return executeDiscoveryAction( + action as TypeAgentAction, + context, + ); + } + + // Phrase generation phase + if ( + actionName === "generatePhrases" || + actionName === "addPhrase" || + actionName === "removePhrase" || + actionName === "approvePhrases" + ) { + return executePhraseGenAction( + action as TypeAgentAction, + context, + ); + } + + // Schema generation phase + if ( + actionName === "generateSchema" || + actionName === "refineSchema" || + actionName === "approveSchema" + ) { + return executeSchemaGenAction( + action as TypeAgentAction, + context, + ); + } + + // Grammar generation phase + if ( + actionName === "generateGrammar" || + actionName === "compileGrammar" || + actionName === "approveGrammar" + ) { + return executeGrammarGenAction( + action as TypeAgentAction, + context, + ); + } + + // Scaffolder phase + if ( + actionName === "scaffoldAgent" || + actionName === "scaffoldPlugin" || + actionName === "listTemplates" + ) { + return executeScaffolderAction( + action as TypeAgentAction, + context, + ); + } + + // Testing phase + if ( + actionName === "generateTests" || + actionName === "runTests" || + actionName === "getTestResults" || + actionName === "proposeRepair" || + actionName === "approveRepair" + ) { + return executeTestingAction( + action as TypeAgentAction, + context, + ); + } + + // Packaging phase + if ( + actionName === "packageAgent" || + actionName === "validatePackage" || + actionName === "generateDemo" || + actionName === "generateReadme" + ) { + return executePackagingAction( + action as TypeAgentAction, + context, + ); + } + + return { error: `Unknown action: ${actionName}` }; +} + +async function executeOnboardingAction( + action: TypeAgentAction, + _context: ActionContext, +): Promise { + switch (action.actionName) { + case "startOnboarding": { + const { integrationName, description, apiType } = action.parameters; + const existing = await loadState(integrationName); + if (existing) { + return createActionResultFromTextDisplay( + `Integration "${integrationName}" already exists (current phase: ${existing.currentPhase}). Use resumeOnboarding to continue.`, + ); + } + await createWorkspace({ + integrationName, + ...(description !== undefined ? { description } : undefined), + ...(apiType !== undefined ? { apiType } : undefined), + }); + return createActionResultFromMarkdownDisplay( + `## Onboarding started: ${integrationName}\n\n` + + `**Next step:** Phase 1 — Discovery\n\n` + + `Use \`crawlDocUrl\` or \`parseOpenApiSpec\` to enumerate the API surface.\n\n` + + `Workspace: \`~/.typeagent/onboarding/${integrationName}/\``, + ); + } + + case "resumeOnboarding": { + const { integrationName, fromPhase } = action.parameters; + const state = await loadState(integrationName); + if (!state) { + return { + error: `Integration "${integrationName}" not found. Use startOnboarding to create it.`, + }; + } + const phase = fromPhase ?? state.currentPhase; + return createActionResultFromMarkdownDisplay( + `## Resuming: ${integrationName}\n\n` + + `**Current phase:** ${phase}\n\n` + + `${phaseNextStepHint(phase)}`, + ); + } + + case "getOnboardingStatus": { + const { integrationName } = action.parameters; + const state = await loadState(integrationName); + if (!state) { + return { + error: `Integration "${integrationName}" not found.`, + }; + } + const lines = [ + `## ${integrationName} — Onboarding Status`, + ``, + `**Current phase:** ${state.currentPhase}`, + `**Started:** ${state.createdAt}`, + `**Updated:** ${state.updatedAt}`, + ``, + `| Phase | Status |`, + `|---|---|`, + ...Object.entries(state.phases).map( + ([phase, ps]) => + `| ${phase} | ${statusIcon(ps.status)} ${ps.status} |`, + ), + ]; + return createActionResultFromMarkdownDisplay(lines.join("\n")); + } + + case "listIntegrations": { + const { status } = action.parameters; + const names = await listIntegrations(); + if (names.length === 0) { + return createActionResultFromTextDisplay( + "No integrations found. Use startOnboarding to begin.", + ); + } + const lines = [`## Integrations`, ``]; + for (const name of names) { + const state = await loadState(name); + if (!state) continue; + if (status === "complete" && state.currentPhase !== "complete") + continue; + if ( + status === "in-progress" && + state.currentPhase === "complete" + ) + continue; + lines.push( + `- **${name}** — ${state.currentPhase} (updated ${state.updatedAt})`, + ); + } + return createActionResultFromMarkdownDisplay(lines.join("\n")); + } + } +} + +function phaseNextStepHint(phase: string): string { + const hints: Record = { + discovery: + "Use `crawlDocUrl` or `parseOpenApiSpec` to enumerate the API surface.", + phraseGen: + "Use `generatePhrases` to create natural language samples for each action.", + schemaGen: + "Use `generateSchema` to produce the TypeScript action schema.", + grammarGen: + "Use `generateGrammar` to produce the .agr grammar file, then `compileGrammar` to validate.", + scaffolder: + "Use `scaffoldAgent` to stamp out the agent package infrastructure.", + testing: + "Use `generateTests` then `runTests` to validate phrase-to-action mapping.", + packaging: "Use `packageAgent` to prepare the agent for distribution.", + complete: "Onboarding is complete.", + }; + return hints[phase] ?? ""; +} + +function statusIcon(status: string): string { + switch (status) { + case "pending": + return "⏳"; + case "in-progress": + return "🔄"; + case "approved": + return "✅"; + case "skipped": + return "⏭️"; + default: + return "❓"; + } +} diff --git a/ts/packages/agents/onboarding/src/onboardingManifest.json b/ts/packages/agents/onboarding/src/onboardingManifest.json new file mode 100644 index 0000000000..22a5d7381d --- /dev/null +++ b/ts/packages/agents/onboarding/src/onboardingManifest.json @@ -0,0 +1,77 @@ +{ + "emojiChar": "🛠️", + "description": "Agent for onboarding new applications and APIs into TypeAgent", + "defaultEnabled": true, + "schema": { + "description": "Top-level onboarding coordination: start, resume, and check status of integration onboarding workflows", + "originalSchemaFile": "./onboardingSchema.ts", + "schemaFile": "../dist/onboardingSchema.pas.json", + "grammarFile": "../dist/onboardingSchema.ag.json", + "schemaType": "OnboardingActions" + }, + "subActionManifests": { + "onboarding-discovery": { + "schema": { + "description": "Phase 1: Enumerate the API surface of the target application by crawling documentation or parsing an OpenAPI spec", + "originalSchemaFile": "./discovery/discoverySchema.ts", + "schemaFile": "../dist/discoverySchema.pas.json", + "grammarFile": "../dist/discoverySchema.ag.json", + "schemaType": "DiscoveryActions" + } + }, + "onboarding-phrasegen": { + "schema": { + "description": "Phase 2: Generate natural language sample phrases that users would say to invoke each discovered action", + "originalSchemaFile": "./phraseGen/phraseGenSchema.ts", + "schemaFile": "../dist/phraseGenSchema.pas.json", + "grammarFile": "../dist/phraseGenSchema.ag.json", + "schemaType": "PhraseGenActions" + } + }, + "onboarding-schemagen": { + "schema": { + "description": "Phase 3: Generate TypeScript action schema types with comments that map user requests to the target API surface", + "originalSchemaFile": "./schemaGen/schemaGenSchema.ts", + "schemaFile": "../dist/schemaGenSchema.pas.json", + "grammarFile": "../dist/schemaGenSchema.ag.json", + "schemaType": "SchemaGenActions" + } + }, + "onboarding-grammargen": { + "schema": { + "description": "Phase 4: Generate .agr grammar files from action schemas and sample phrases, then compile and validate them", + "originalSchemaFile": "./grammarGen/grammarGenSchema.ts", + "schemaFile": "../dist/grammarGenSchema.pas.json", + "grammarFile": "../dist/grammarGenSchema.ag.json", + "schemaType": "GrammarGenActions" + } + }, + "onboarding-scaffolder": { + "schema": { + "description": "Phase 5: Scaffold the complete TypeAgent agent package infrastructure including manifest, handler, package.json, and any required plugins", + "originalSchemaFile": "./scaffolder/scaffolderSchema.ts", + "schemaFile": "../dist/scaffolderSchema.pas.json", + "grammarFile": "../dist/scaffolderSchema.ag.json", + "schemaType": "ScaffolderActions" + } + }, + "onboarding-testing": { + "schema": { + "description": "Phase 6: Generate test cases from sample phrases and run a phrase-to-action validation loop, proposing schema and grammar repairs for failures", + "originalSchemaFile": "./testing/testingSchema.ts", + "schemaFile": "../dist/testingSchema.pas.json", + "grammarFile": "../dist/testingSchema.ag.json", + "schemaType": "TestingActions" + } + }, + "onboarding-packaging": { + "schema": { + "description": "Phase 7: Package the completed agent for distribution and register it with the TypeAgent dispatcher", + "originalSchemaFile": "./packaging/packagingSchema.ts", + "schemaFile": "../dist/packagingSchema.pas.json", + "grammarFile": "../dist/packagingSchema.ag.json", + "schemaType": "PackagingActions" + } + } + } +} diff --git a/ts/packages/agents/onboarding/src/onboardingSchema.agr b/ts/packages/agents/onboarding/src/onboardingSchema.agr new file mode 100644 index 0000000000..a18f480ecc --- /dev/null +++ b/ts/packages/agents/onboarding/src/onboardingSchema.agr @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Grammar for top-level onboarding coordination actions. + +// startOnboarding - begin a new integration onboarding workflow + = start onboarding (for)? $(integrationName:wildcard) -> { + actionName: "startOnboarding", + parameters: { + integrationName + } +} + | onboard $(integrationName:wildcard) (into TypeAgent)? -> { + actionName: "startOnboarding", + parameters: { + integrationName + } +} + | (begin | create) (a)? (new)? $(integrationName:wildcard) integration -> { + actionName: "startOnboarding", + parameters: { + integrationName + } +}; + +// resumeOnboarding - continue an in-progress onboarding + = resume onboarding (for)? $(integrationName:wildcard) -> { + actionName: "resumeOnboarding", + parameters: { + integrationName + } +} + | continue (the)? $(integrationName:wildcard) onboarding -> { + actionName: "resumeOnboarding", + parameters: { + integrationName + } +}; + +// getOnboardingStatus - check the current phase and status + = (what's | what is) (the)? status (of)? (the)? $(integrationName:wildcard) onboarding -> { + actionName: "getOnboardingStatus", + parameters: { + integrationName + } +} + | show (me)? (the)? $(integrationName:wildcard) onboarding status -> { + actionName: "getOnboardingStatus", + parameters: { + integrationName + } +} + | how far along is (the)? $(integrationName:wildcard) (onboarding)? -> { + actionName: "getOnboardingStatus", + parameters: { + integrationName + } +}; + +// listIntegrations - list all known integrations + = list (all)? (my)? integrations -> { + actionName: "listIntegrations", + parameters: {} +} + | show (me)? (all)? (my)? integrations -> { + actionName: "listIntegrations", + parameters: {} +} + | what integrations (do I have | are there)? -> { + actionName: "listIntegrations", + parameters: {} +}; + +import { OnboardingActions } from "./onboardingSchema.ts"; + + : OnboardingActions = + | + | + | ; diff --git a/ts/packages/agents/onboarding/src/onboardingSchema.ts b/ts/packages/agents/onboarding/src/onboardingSchema.ts new file mode 100644 index 0000000000..6980d3cfc2 --- /dev/null +++ b/ts/packages/agents/onboarding/src/onboardingSchema.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type OnboardingActions = + | StartOnboardingAction + | ResumeOnboardingAction + | GetOnboardingStatusAction + | ListIntegrationsAction; + +export type StartOnboardingAction = { + actionName: "startOnboarding"; + parameters: { + // Unique name for this integration (e.g. "slack", "jira", "my-rest-api"). + // Used as the workspace folder name — lowercase, no spaces. + integrationName: string; + // Human-readable description of what the integration does + description?: string; + // The type of API being integrated; helps select appropriate templates and bridge patterns + apiType?: "rest" | "graphql" | "websocket" | "ipc" | "sdk"; + }; +}; + +export type ResumeOnboardingAction = { + actionName: "resumeOnboarding"; + parameters: { + // Name of the integration to resume + integrationName: string; + // Optional: override which phase to start from (defaults to current phase in state.json) + fromPhase?: + | "discovery" + | "phraseGen" + | "schemaGen" + | "grammarGen" + | "scaffolder" + | "testing" + | "packaging"; + }; +}; + +export type GetOnboardingStatusAction = { + actionName: "getOnboardingStatus"; + parameters: { + // Integration name to check status for + integrationName: string; + }; +}; + +export type ListIntegrationsAction = { + actionName: "listIntegrations"; + parameters: { + // Filter by phase status; omit to list all + status?: "in-progress" | "complete"; + }; +}; diff --git a/ts/packages/agents/onboarding/src/packaging/packagingHandler.ts b/ts/packages/agents/onboarding/src/packaging/packagingHandler.ts new file mode 100644 index 0000000000..7f902eada2 --- /dev/null +++ b/ts/packages/agents/onboarding/src/packaging/packagingHandler.ts @@ -0,0 +1,602 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Phase 7 — Packaging handler. +// Builds the scaffolded agent package and optionally registers it +// with the local TypeAgent dispatcher configuration. + +import { + ActionContext, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromMarkdownDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { PackagingActions } from "./packagingSchema.js"; +import { + loadState, + updatePhase, + readArtifact, + readArtifactJson, + writeArtifact, +} from "../lib/workspace.js"; +import { getPackagingModel } from "../lib/llm.js"; +import { spawn } from "child_process"; +import path from "path"; +import fs from "fs/promises"; + +export async function executePackagingAction( + action: TypeAgentAction, + _context: ActionContext, +): Promise { + switch (action.actionName) { + case "packageAgent": + return handlePackageAgent( + action.parameters.integrationName, + action.parameters.register ?? false, + ); + case "validatePackage": + return handleValidatePackage(action.parameters.integrationName); + case "generateDemo": + return handleGenerateDemo( + action.parameters.integrationName, + action.parameters.durationMinutes, + ); + case "generateReadme": + return handleGenerateReadme(action.parameters.integrationName); + } +} + +async function handlePackageAgent( + integrationName: string, + register: boolean, +): Promise { + const state = await loadState(integrationName); + if (!state) return { error: `Integration "${integrationName}" not found.` }; + if (state.phases.testing.status !== "approved") { + return { error: `Testing phase must be approved before packaging.` }; + } + + // Find where the scaffolded agent lives + const scaffoldedTo = await readArtifact( + integrationName, + "scaffolder", + "scaffolded-to.txt", + ); + if (!scaffoldedTo) { + return { error: `No scaffolded agent found. Run scaffoldAgent first.` }; + } + + const agentDir = scaffoldedTo.trim(); + + await updatePhase(integrationName, "packaging", { status: "in-progress" }); + + // Run pnpm install + build in the agent directory + const installResult = await runCommand("pnpm", ["install"], agentDir); + if (!installResult.success) { + return { + error: `pnpm install failed:\n${installResult.output}`, + }; + } + + const buildResult = await runCommand("pnpm", ["run", "build"], agentDir); + if (!buildResult.success) { + return { + error: `Build failed:\n${buildResult.output}`, + }; + } + + const summary = [ + `## Package built: ${integrationName}`, + ``, + `**Agent directory:** \`${agentDir}\``, + `**Build output:** \`${path.join(agentDir, "dist")}\``, + ``, + buildResult.output + ? `\`\`\`\n${buildResult.output.slice(0, 500)}\n\`\`\`` + : "", + ]; + + if (register) { + const registerResult = await registerWithDispatcher( + integrationName, + agentDir, + ); + summary.push(``, registerResult); + } + + await updatePhase(integrationName, "packaging", { status: "approved" }); + + summary.push( + ``, + `**Onboarding complete!** 🎉`, + ``, + `The \`${integrationName}\` agent is ready for end-user testing.`, + register + ? `It has been registered with the local TypeAgent dispatcher.` + : `Run with \`register: true\` to register with the local dispatcher, or add it manually to \`ts/packages/defaultAgentProvider/data/config.json\`.`, + ); + + return createActionResultFromMarkdownDisplay(summary.join("\n")); +} + +async function handleValidatePackage( + integrationName: string, +): Promise { + const scaffoldedTo = await readArtifact( + integrationName, + "scaffolder", + "scaffolded-to.txt", + ); + if (!scaffoldedTo) { + return { error: `No scaffolded agent found. Run scaffoldAgent first.` }; + } + + const agentDir = scaffoldedTo.trim(); + const checks: { name: string; passed: boolean; detail?: string }[] = []; + + // Check required files exist + const requiredFiles = [ + "package.json", + "tsconfig.json", + "src/tsconfig.json", + ]; + for (const file of requiredFiles) { + const exists = await fileExists(path.join(agentDir, file)); + checks.push({ name: `File: ${file}`, passed: exists }); + } + + // Check package.json exports + try { + const pkgJson = JSON.parse( + await fs.readFile(path.join(agentDir, "package.json"), "utf-8"), + ); + const hasManifestExport = !!pkgJson.exports?.["./agent/manifest"]; + const hasHandlerExport = !!pkgJson.exports?.["./agent/handlers"]; + checks.push({ + name: "package.json: exports ./agent/manifest", + passed: hasManifestExport, + }); + checks.push({ + name: "package.json: exports ./agent/handlers", + passed: hasHandlerExport, + }); + } catch { + checks.push({ + name: "package.json: parse", + passed: false, + detail: "Could not read package.json", + }); + } + + // Check dist exists (agent has been built) + const distExists = await fileExists(path.join(agentDir, "dist")); + checks.push({ name: "dist/ directory exists (built)", passed: distExists }); + + const passed = checks.filter((c) => c.passed).length; + const failed = checks.filter((c) => !c.passed).length; + + const lines = [ + `## Package validation: ${integrationName}`, + ``, + `**Passed:** ${passed} / ${checks.length}`, + ``, + ...checks.map( + (c) => + `${c.passed ? "✅" : "❌"} ${c.name}${c.detail ? ` — ${c.detail}` : ""}`, + ), + ]; + + if (failed === 0) { + lines.push(``, `Package is valid and ready for distribution.`); + } else { + lines.push(``, `Fix the failing checks above before packaging.`); + } + + return createActionResultFromMarkdownDisplay(lines.join("\n")); +} + +async function handleGenerateDemo( + integrationName: string, + durationMinutes?: string, +): Promise { + const state = await loadState(integrationName); + if (!state) return { error: `Integration "${integrationName}" not found.` }; + if (state.phases.testing.status !== "approved") { + return { + error: `Testing phase must be approved before generating a demo.`, + }; + } + + // Load discovery artifacts + const apiSurface = await readArtifactJson<{ + actions: { name: string; description: string; category?: string }[]; + }>(integrationName, "discovery", "api-surface.json"); + if (!apiSurface) { + return { + error: `No approved API surface found. Complete discovery first.`, + }; + } + + const subSchemaGroups = await readArtifactJson>( + integrationName, + "discovery", + "sub-schema-groups.json", + ); + + // Load the generated schema + const schemaTs = await readArtifact( + integrationName, + "schemaGen", + "schema.ts", + ); + + const duration = durationMinutes ?? "3-5"; + const description = state.config.description ?? integrationName; + + // Build action listing — grouped by sub-schema if available + let actionListing: string; + if (subSchemaGroups) { + const groupLines: string[] = []; + for (const [group, actionNames] of Object.entries(subSchemaGroups)) { + groupLines.push(`### ${group}`); + for (const actionName of actionNames) { + const action = apiSurface.actions.find( + (a) => a.name === actionName, + ); + groupLines.push( + `- **${actionName}**: ${action?.description ?? "(no description)"}`, + ); + } + groupLines.push(""); + } + actionListing = groupLines.join("\n"); + } else { + actionListing = apiSurface.actions + .map((a) => `- **${a.name}**: ${a.description}`) + .join("\n"); + } + + const model = getPackagingModel(); + const prompt = buildDemoPrompt( + integrationName, + description, + actionListing, + schemaTs ?? "", + duration, + ); + + const result = await model.complete(prompt); + if (!result.success) { + return { error: `Demo generation failed: ${result.message}` }; + } + + // Parse the LLM response — expect two fenced blocks: + // ```demo ... ``` and ```narration ... ``` + const responseText = result.data; + const demoScript = + extractFencedBlock(responseText, "demo") ?? + extractFirstFencedBlock(responseText) ?? + responseText; + const narrationScript = + extractFencedBlock(responseText, "narration") ?? + extractSecondFencedBlock(responseText) ?? + ""; + + // Find the scaffolded agent directory + const scaffoldedTo = await readArtifact( + integrationName, + "scaffolder", + "scaffolded-to.txt", + ); + + // Write to the shell demo directory alongside other demo scripts + const shellDemoDir = path.resolve( + scaffoldedTo?.trim() ?? ".", + "../../shell/demo", + ); + await fs.mkdir(shellDemoDir, { recursive: true }); + + const demoFilename = `${integrationName}_agent.txt`; + const narrationFilename = `${integrationName}_agent_narration.md`; + + const demoPath = path.join(shellDemoDir, demoFilename); + const narrationPath = path.join(shellDemoDir, narrationFilename); + + await fs.writeFile(demoPath, demoScript, "utf-8"); + await fs.writeFile(narrationPath, narrationScript, "utf-8"); + + // Also save as artifacts in the onboarding workspace + await writeArtifact(integrationName, "packaging", demoFilename, demoScript); + await writeArtifact( + integrationName, + "packaging", + narrationFilename, + narrationScript, + ); + + const lines = [ + `## Demo scripts generated: ${integrationName}`, + ``, + `**Demo script:** \`${demoPath}\``, + `**Narration script:** \`${narrationPath}\``, + ``, + `**Target duration:** ${duration} minutes`, + ``, + `### Demo script preview`, + `\`\`\``, + demoScript.split("\n").slice(0, 20).join("\n"), + demoScript.split("\n").length > 20 ? "..." : "", + `\`\`\``, + ``, + `### Narration preview`, + narrationScript.split("\n").slice(0, 15).join("\n"), + narrationScript.split("\n").length > 15 ? "\n..." : "", + ]; + + return createActionResultFromMarkdownDisplay(lines.join("\n")); +} + +function buildDemoPrompt( + integrationName: string, + description: string, + actionListing: string, + schemaTs: string, + duration: string, +): string { + return `You are generating a demo script for a TypeAgent integration called "${integrationName}". + +**Integration description:** ${description} + +**Available actions (grouped by category if applicable):** +${actionListing} + +${schemaTs ? `**TypeScript action schema:**\n\`\`\`typescript\n${schemaTs}\n\`\`\`` : ""} + +Generate TWO outputs: + +## 1. Demo script (shell format) + +Create a demo script with 5-8 acts that showcase each action category. The demo should be ${duration} minutes long (approximately 50-80 natural language commands). + +Format rules: +- One natural language command per line (what a user would type, NOT @action syntax) +- Use \`# Section Title\` comments for section headers +- Use \`@pauseForInput\` between acts/sections +- Commands should be realistic, conversational requests a user would make +- Progress from simple to complex usage +- Show off different capabilities in each act +- Include some multi-step scenarios + +Wrap the entire demo script in a fenced code block with the label \`demo\`: +\`\`\`demo +# Act 1: Getting Started +... +\`\`\` + +## 2. Narration script (markdown) + +Create a matching narration script with timestamped sections that correspond to each act. Include: +- Approximate timestamp for each section (e.g., [0:00], [0:30]) +- Voice-over text explaining what is being demonstrated +- Key talking points for each act +- Transition text between acts + +Wrap the narration in a fenced code block with the label \`narration\`: +\`\`\`narration +# Demo Narration: ${integrationName} Agent +... +\`\`\``; +} + +function extractFencedBlock(text: string, label: string): string | undefined { + const regex = new RegExp("```" + label + "\\s*\\n([\\s\\S]*?)\\n```", "i"); + const match = text.match(regex); + return match?.[1]?.trim(); +} + +function extractFirstFencedBlock(text: string): string | undefined { + const match = text.match(/```[\w]*\s*\n([\s\S]*?)\n```/); + return match?.[1]?.trim(); +} + +function extractSecondFencedBlock(text: string): string | undefined { + const blocks = [...text.matchAll(/```[\w]*\s*\n([\s\S]*?)\n```/g)]; + if (blocks.length >= 2) { + return blocks[1][1]?.trim(); + } + return undefined; +} + +async function registerWithDispatcher( + integrationName: string, + agentDir: string, +): Promise { + // Add agent to defaultAgentProvider config.json + const configPath = path.resolve( + agentDir, + "../../../../defaultAgentProvider/data/config.json", + ); + + try { + const configRaw = await fs.readFile(configPath, "utf-8"); + const config = JSON.parse(configRaw); + + if (!config.agents) config.agents = {}; + if (config.agents[integrationName]) { + return `Agent "${integrationName}" is already registered in the dispatcher config.`; + } + + config.agents[integrationName] = { + name: `${integrationName}-agent`, + }; + + await fs.writeFile( + configPath, + JSON.stringify(config, null, 2), + "utf-8", + ); + return `✅ Registered "${integrationName}" in dispatcher config at \`${configPath}\`\n\nRestart TypeAgent to load the new agent.`; + } catch (err: any) { + return `⚠️ Could not auto-register — update dispatcher config manually.\n${err?.message ?? err}`; + } +} + +async function runCommand( + cmd: string, + args: string[], + cwd: string, +): Promise<{ success: boolean; output: string }> { + return new Promise((resolve) => { + const proc = spawn(cmd, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + }); + + let output = ""; + proc.stdout?.on("data", (d: Buffer) => { + output += d.toString(); + }); + proc.stderr?.on("data", (d: Buffer) => { + output += d.toString(); + }); + + proc.on("close", (code) => { + resolve({ success: code === 0, output }); + }); + + proc.on("error", (err) => { + resolve({ success: false, output: err.message }); + }); + }); +} + +async function handleGenerateReadme( + integrationName: string, +): Promise { + const state = await loadState(integrationName); + if (!state) return { error: `Integration "${integrationName}" not found.` }; + + // Read artifacts for context + const surface = await readArtifactJson<{ + actions: { name: string; description: string }[]; + }>(integrationName, "discovery", "api-surface.json"); + const subGroups = await readArtifactJson<{ + recommended: boolean; + groups: { name: string; description: string; actions: string[] }[]; + }>(integrationName, "discovery", "sub-schema-groups.json"); + const scaffoldedTo = await readArtifact( + integrationName, + "scaffolder", + "scaffolded-to.txt", + ); + + const description = + state.config.description ?? `Agent for ${integrationName}`; + const totalActions = surface?.actions.length ?? 0; + + // Build action listing for the LLM + let actionListing: string; + if (subGroups?.recommended && subGroups.groups.length > 0) { + actionListing = subGroups.groups + .map( + (g) => + `**${g.name}** (${g.actions.length} actions) — ${g.description}\n` + + g.actions.map((a) => ` - ${a}`).join("\n"), + ) + .join("\n\n"); + } else { + actionListing = + surface?.actions + .map((a) => `- **${a.name}** — ${a.description}`) + .join("\n") ?? "No actions discovered."; + } + + const model = getPackagingModel(); + const prompt = [ + { + role: "system" as const, + content: + "You are a technical writer generating a README.md for a TypeAgent agent package. " + + "Write clear, concise documentation in GitHub-flavored Markdown. " + + "Include: overview, architecture diagram (ASCII), action categories table, " + + "prerequisites, quick start, manual setup, project structure, " + + "API limitations (if any actions report limitations), and troubleshooting. " + + "Respond in JSON format with a single `readme` key containing the full Markdown content.", + }, + { + role: "user" as const, + content: + `Generate a README.md for the "${integrationName}" TypeAgent agent.\n\n` + + `Description: ${description}\n\n` + + `Total actions: ${totalActions}\n\n` + + `Actions:\n${actionListing}\n\n` + + `The agent uses a WebSocket bridge pattern where a Node.js bridge server ` + + `connects to an Office Add-in running inside the application. ` + + `The bridge port is 5680. The add-in dev server runs on port 3003.\n\n` + + `The agent was created using the TypeAgent onboarding pipeline.\n\n` + + (subGroups?.recommended + ? `The agent uses ${subGroups.groups.length} sub-schemas: ${subGroups.groups.map((g) => g.name).join(", ")}.\n\n` + : "") + + `Include a quick start section that references:\n` + + ` pnpm run build packages/agents/${integrationName}\n` + + ` npx office-addin-dev-certs install\n` + + ` pnpm run ${integrationName}:addin\n`, + }, + ]; + + const result = await model.complete(prompt); + if (!result.success) { + return { error: `README generation failed: ${result.message}` }; + } + + // Extract README content + let readmeContent: string; + try { + const parsed = JSON.parse(result.data); + readmeContent = parsed.readme ?? result.data; + } catch { + readmeContent = result.data; + } + + // Write to the agent directory + const agentDir = scaffoldedTo?.trim(); + if (agentDir) { + try { + await fs.writeFile( + path.join(agentDir, "README.md"), + readmeContent, + "utf-8", + ); + } catch { + // Fall through — still save as artifact + } + } + + // Save as artifact + await writeArtifact( + integrationName, + "packaging", + "README.md", + readmeContent, + ); + + return createActionResultFromMarkdownDisplay( + `## README generated: ${integrationName}\n\n` + + (agentDir + ? `Written to \`${path.join(agentDir, "README.md")}\`\n\n` + : "") + + `**Preview (first 2000 chars):**\n\n` + + readmeContent.slice(0, 2000) + + (readmeContent.length > 2000 ? "\n\n_...truncated_" : ""), + ); +} + +async function fileExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} diff --git a/ts/packages/agents/onboarding/src/packaging/packagingSchema.agr b/ts/packages/agents/onboarding/src/packaging/packagingSchema.agr new file mode 100644 index 0000000000..a306643cc6 --- /dev/null +++ b/ts/packages/agents/onboarding/src/packaging/packagingSchema.agr @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Grammar for Phase 7 — Packaging actions. + + = package (the)? $(integrationName:wildcard) agent -> { + actionName: "packageAgent", + parameters: { + integrationName + } +} + | (build | bundle | prepare) (the)? $(integrationName:wildcard) (agent)? (package)? (for distribution)? -> { + actionName: "packageAgent", + parameters: { + integrationName + } +}; + + = validate (the)? $(integrationName:wildcard) (agent)? package -> { + actionName: "validatePackage", + parameters: { + integrationName + } +} + | (check | verify) (the)? $(integrationName:wildcard) (agent)? package -> { + actionName: "validatePackage", + parameters: { + integrationName + } +}; + + = generate (a)? demo (script)? for $(integrationName:wildcard) -> { + actionName: "generateDemo", + parameters: { + integrationName + } +} + | create (a)? demo for $(integrationName:wildcard) -> { + actionName: "generateDemo", + parameters: { + integrationName + } +}; + + = generate (a)? readme for $(integrationName:wildcard) -> { + actionName: "generateReadme", + parameters: { + integrationName + } +} + | create (a)? readme for (the)? $(integrationName:wildcard) (agent)? -> { + actionName: "generateReadme", + parameters: { + integrationName + } +}; + +import { PackagingActions } from "./packagingSchema.ts"; + + : PackagingActions = + | + | + | ; diff --git a/ts/packages/agents/onboarding/src/packaging/packagingSchema.ts b/ts/packages/agents/onboarding/src/packaging/packagingSchema.ts new file mode 100644 index 0000000000..de3a68ae04 --- /dev/null +++ b/ts/packages/agents/onboarding/src/packaging/packagingSchema.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type PackagingActions = + | PackageAgentAction + | ValidatePackageAction + | GenerateDemoAction + | GenerateReadmeAction; + +export type PackageAgentAction = { + actionName: "packageAgent"; + parameters: { + // Integration name to package + integrationName: string; + // If true, also register the agent with the local TypeAgent dispatcher config + register?: boolean; + }; +}; + +export type ValidatePackageAction = { + actionName: "validatePackage"; + parameters: { + // Integration name whose package to validate + integrationName: string; + }; +}; + +// Generates a README.md for the onboarded agent +export type GenerateReadmeAction = { + actionName: "generateReadme"; + parameters: { + // Name of the integration + integrationName: string; + }; +}; + +// Generates a demo script and narration for the onboarded agent +export type GenerateDemoAction = { + actionName: "generateDemo"; + parameters: { + // Name of the integration + integrationName: string; + // Duration target in minutes (default: 3-5) + durationMinutes?: string; + }; +}; diff --git a/ts/packages/agents/onboarding/src/phraseGen/phraseGenHandler.ts b/ts/packages/agents/onboarding/src/phraseGen/phraseGenHandler.ts new file mode 100644 index 0000000000..a98a934217 --- /dev/null +++ b/ts/packages/agents/onboarding/src/phraseGen/phraseGenHandler.ts @@ -0,0 +1,299 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Phase 2 — Phrase Generation handler. +// Generates natural language sample phrases for each discovered action +// using an LLM, saved to ~/.typeagent/onboarding//phraseGen/phrases.json + +import { + ActionContext, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { + createActionResultFromTextDisplay, + createActionResultFromMarkdownDisplay, +} from "@typeagent/agent-sdk/helpers/action"; +import { PhraseGenActions } from "./phraseGenSchema.js"; +import { + loadState, + updatePhase, + writeArtifactJson, + readArtifactJson, +} from "../lib/workspace.js"; +import { getPhraseGenModel } from "../lib/llm.js"; +import { ApiSurface, DiscoveredAction } from "../discovery/discoveryHandler.js"; + +export type PhraseSet = { + integrationName: string; + generatedAt: string; + // Map from actionName to array of sample phrases + phrases: Record; + approved?: boolean; + approvedAt?: string; +}; + +export async function executePhraseGenAction( + action: TypeAgentAction, + _context: ActionContext, +): Promise { + switch (action.actionName) { + case "generatePhrases": + return handleGeneratePhrases( + action.parameters.integrationName, + action.parameters.phrasesPerAction ?? 5, + action.parameters.forActions, + ); + + case "addPhrase": + return handleAddPhrase( + action.parameters.integrationName, + action.parameters.actionName, + action.parameters.phrase, + ); + + case "removePhrase": + return handleRemovePhrase( + action.parameters.integrationName, + action.parameters.actionName, + action.parameters.phrase, + ); + + case "approvePhrases": + return handleApprovePhrases(action.parameters.integrationName); + } +} + +async function handleGeneratePhrases( + integrationName: string, + phrasesPerAction: number, + forActions?: string[], +): Promise { + const state = await loadState(integrationName); + if (!state) { + return { error: `Integration "${integrationName}" not found.` }; + } + if (state.phases.discovery.status !== "approved") { + return { + error: `Discovery phase must be approved before generating phrases. Run approveApiSurface first.`, + }; + } + + const surface = await readArtifactJson( + integrationName, + "discovery", + "api-surface.json", + ); + if (!surface) { + return { error: `No API surface found for "${integrationName}".` }; + } + + await updatePhase(integrationName, "phraseGen", { status: "in-progress" }); + + const model = getPhraseGenModel(); + const existing = await readArtifactJson( + integrationName, + "phraseGen", + "phrases.json", + ); + const phraseMap: Record = existing?.phrases ?? {}; + + const actionsToProcess = forActions + ? surface.actions.filter((a) => forActions.includes(a.name)) + : surface.actions; + + for (const discoveredAction of actionsToProcess) { + const prompt = buildPhrasePrompt( + integrationName, + discoveredAction, + phrasesPerAction, + state.config.description, + ); + const result = await model.complete(prompt); + if (!result.success) continue; + + const phrases = extractPhraseList(result.data); + phraseMap[discoveredAction.name] = [ + ...(phraseMap[discoveredAction.name] ?? []), + ...phrases, + ]; + } + + const phraseSet: PhraseSet = { + integrationName, + generatedAt: new Date().toISOString(), + phrases: phraseMap, + }; + + await writeArtifactJson( + integrationName, + "phraseGen", + "phrases.json", + phraseSet, + ); + + const totalPhrases = Object.values(phraseMap).reduce( + (sum, p) => sum + p.length, + 0, + ); + + return createActionResultFromMarkdownDisplay( + `## Phrases generated: ${integrationName}\n\n` + + `**Actions covered:** ${Object.keys(phraseMap).length}\n` + + `**Total phrases:** ${totalPhrases}\n\n` + + Object.entries(phraseMap) + .slice(0, 10) + .map( + ([name, phrases]) => + `**${name}:**\n` + + phrases.map((p) => ` - "${p}"`).join("\n"), + ) + .join("\n\n") + + (Object.keys(phraseMap).length > 10 + ? `\n\n_...and ${Object.keys(phraseMap).length - 10} more actions_` + : "") + + `\n\nReview, add/remove phrases as needed, then \`approvePhrases\` to proceed.`, + ); +} + +async function handleAddPhrase( + integrationName: string, + actionName: string, + phrase: string, +): Promise { + const existing = await readArtifactJson( + integrationName, + "phraseGen", + "phrases.json", + ); + const phraseMap = existing?.phrases ?? {}; + if (!phraseMap[actionName]) phraseMap[actionName] = []; + if (!phraseMap[actionName].includes(phrase)) { + phraseMap[actionName].push(phrase); + } + + await writeArtifactJson(integrationName, "phraseGen", "phrases.json", { + ...(existing ?? { + integrationName, + generatedAt: new Date().toISOString(), + }), + phrases: phraseMap, + }); + + return createActionResultFromTextDisplay( + `Added phrase "${phrase}" to action "${actionName}" for ${integrationName}.`, + ); +} + +async function handleRemovePhrase( + integrationName: string, + actionName: string, + phrase: string, +): Promise { + const existing = await readArtifactJson( + integrationName, + "phraseGen", + "phrases.json", + ); + if (!existing) { + return { error: `No phrases found for "${integrationName}".` }; + } + + const phrases = existing.phrases[actionName] ?? []; + existing.phrases[actionName] = phrases.filter((p) => p !== phrase); + await writeArtifactJson( + integrationName, + "phraseGen", + "phrases.json", + existing, + ); + + return createActionResultFromTextDisplay( + `Removed phrase "${phrase}" from action "${actionName}" for ${integrationName}.`, + ); +} + +async function handleApprovePhrases( + integrationName: string, +): Promise { + const phraseSet = await readArtifactJson( + integrationName, + "phraseGen", + "phrases.json", + ); + if (!phraseSet) { + return { + error: `No phrases found for "${integrationName}". Run generatePhrases first.`, + }; + } + + const updated: PhraseSet = { + ...phraseSet, + approved: true, + approvedAt: new Date().toISOString(), + }; + + await writeArtifactJson( + integrationName, + "phraseGen", + "phrases.json", + updated, + ); + await updatePhase(integrationName, "phraseGen", { status: "approved" }); + + const totalPhrases = Object.values(phraseSet.phrases).reduce( + (sum, p) => sum + p.length, + 0, + ); + + return createActionResultFromMarkdownDisplay( + `## Phrases approved: ${integrationName}\n\n` + + `**Actions:** ${Object.keys(phraseSet.phrases).length}\n` + + `**Total phrases:** ${totalPhrases}\n\n` + + `**Next step:** Phase 3 — use \`generateSchema\` to produce the TypeScript action schema.`, + ); +} + +function buildPhrasePrompt( + integrationName: string, + action: DiscoveredAction, + count: number, + appDescription?: string, +): { role: "system" | "user"; content: string }[] { + return [ + { + role: "system", + content: + "You are a UX writer generating natural language phrases that users would say to an AI assistant to perform an API action. " + + "Produce varied, conversational phrases — include different phrasings, politeness levels, and levels of specificity. " + + "Return a JSON array of strings.", + }, + { + role: "user", + content: + `Generate ${count} distinct natural language phrases a user would say to perform this action in ${integrationName}` + + (appDescription ? ` (${appDescription})` : "") + + `.\n\n` + + `Action: ${action.name}\n` + + `Description: ${action.description}\n` + + (action.parameters?.length + ? `Parameters: ${action.parameters.map((p) => `${p.name} (${p.type})`).join(", ")}` + : "") + + `\n\nReturn only a JSON array of strings.`, + }, + ]; +} + +function extractPhraseList(llmResponse: string): string[] { + try { + const jsonMatch = llmResponse.match(/\[[\s\S]*\]/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + if (Array.isArray(parsed)) { + return parsed.filter((p) => typeof p === "string"); + } + } + } catch {} + // Fallback: extract quoted strings + return [...llmResponse.matchAll(/"([^"]+)"/g)].map((m) => m[1]); +} diff --git a/ts/packages/agents/onboarding/src/phraseGen/phraseGenSchema.agr b/ts/packages/agents/onboarding/src/phraseGen/phraseGenSchema.agr new file mode 100644 index 0000000000..ecfb22bde7 --- /dev/null +++ b/ts/packages/agents/onboarding/src/phraseGen/phraseGenSchema.agr @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Grammar for Phase 2 — Phrase Generation actions. + +// generatePhrases - generate natural language phrases for all or specific actions + = generate phrases for $(integrationName:wildcard) -> { + actionName: "generatePhrases", + parameters: { + integrationName + } +} + | (create | produce | write) (sample | example | natural language)? phrases for $(integrationName:wildcard) -> { + actionName: "generatePhrases", + parameters: { + integrationName + } +} + | generate $(phrasesPerAction:number) phrases (per action)? for $(integrationName:wildcard) -> { + actionName: "generatePhrases", + parameters: { + integrationName, + phrasesPerAction: phrasesPerAction + } +}; + +// addPhrase - manually add a phrase for a specific action + = add phrase $(phrase:wildcard) for (action)? $(actionName:wildcard) in $(integrationName:wildcard) -> { + actionName: "addPhrase", + parameters: { + phrase, + actionName, + integrationName + } +}; + +// removePhrase - remove a phrase from an action + = remove phrase $(phrase:wildcard) from (action)? $(actionName:wildcard) in $(integrationName:wildcard) -> { + actionName: "removePhrase", + parameters: { + phrase, + actionName, + integrationName + } +}; + +// approvePhrases - lock in the phrase set + = approve phrases for $(integrationName:wildcard) -> { + actionName: "approvePhrases", + parameters: { + integrationName + } +} + | (confirm | finalize) (the)? $(integrationName:wildcard) phrases -> { + actionName: "approvePhrases", + parameters: { + integrationName + } +}; + +import { PhraseGenActions } from "./phraseGenSchema.ts"; + + : PhraseGenActions = + | + | + | ; diff --git a/ts/packages/agents/onboarding/src/phraseGen/phraseGenSchema.ts b/ts/packages/agents/onboarding/src/phraseGen/phraseGenSchema.ts new file mode 100644 index 0000000000..f33b7def18 --- /dev/null +++ b/ts/packages/agents/onboarding/src/phraseGen/phraseGenSchema.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type PhraseGenActions = + | GeneratePhrasesAction + | AddPhraseAction + | RemovePhraseAction + | ApprovePhrasesAction; + +export type GeneratePhrasesAction = { + actionName: "generatePhrases"; + parameters: { + // Integration name to generate phrases for + integrationName: string; + // Number of phrases to generate per action (default: 5) + phrasesPerAction?: number; + // Generate phrases only for these specific action names (generates for all if omitted) + forActions?: string[]; + }; +}; + +export type AddPhraseAction = { + actionName: "addPhrase"; + parameters: { + // Integration name + integrationName: string; + // The action name this phrase should map to + actionName: string; + // The natural language phrase to add + phrase: string; + }; +}; + +export type RemovePhraseAction = { + actionName: "removePhrase"; + parameters: { + // Integration name + integrationName: string; + // The action name to remove the phrase from + actionName: string; + // The exact phrase to remove + phrase: string; + }; +}; + +export type ApprovePhrasesAction = { + actionName: "approvePhrases"; + parameters: { + // Integration name to approve phrases for + integrationName: string; + }; +}; diff --git a/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts b/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts new file mode 100644 index 0000000000..96bb718f72 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/scaffolderHandler.ts @@ -0,0 +1,1372 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Phase 5 — Scaffolder handler. +// Stamps out a complete TypeAgent agent package from approved artifacts. +// Templates cover manifest, handler, schema, grammar, package.json, tsconfigs. + +import { + ActionContext, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromMarkdownDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { AgentPattern, ScaffolderActions } from "./scaffolderSchema.js"; +import { + loadState, + updatePhase, + writeArtifact, + readArtifact, + readArtifactJson, +} from "../lib/workspace.js"; +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; + +// Sub-schema group type matching discovery/sub-schema-groups.json +type SubSchemaGroup = { + name: string; + description: string; + actions: string[]; +}; + +type SubSchemaSuggestion = { + recommended: boolean; + groups: SubSchemaGroup[]; +}; + +// Default output root within the TypeAgent repo +const AGENTS_DIR = path.resolve( + fileURLToPath(import.meta.url), + "../../../../../../packages/agents", +); + +export async function executeScaffolderAction( + action: TypeAgentAction, + _context: ActionContext, +): Promise { + switch (action.actionName) { + case "scaffoldAgent": + return handleScaffoldAgent( + action.parameters.integrationName, + action.parameters.pattern, + action.parameters.outputDir, + ); + case "scaffoldPlugin": + return handleScaffoldPlugin( + action.parameters.integrationName, + action.parameters.template, + action.parameters.outputDir, + ); + case "listTemplates": + return handleListTemplates(); + case "listPatterns": + return handleListPatterns(); + } +} + +async function handleScaffoldAgent( + integrationName: string, + pattern: AgentPattern = "schema-grammar", + outputDir?: string, +): Promise { + const state = await loadState(integrationName); + if (!state) return { error: `Integration "${integrationName}" not found.` }; + if (state.phases.grammarGen.status !== "approved") { + return { + error: `Grammar phase must be approved first. Run approveGrammar.`, + }; + } + + const schemaTs = await readArtifact( + integrationName, + "schemaGen", + "schema.ts", + ); + const grammarAgr = await readArtifact( + integrationName, + "grammarGen", + "schema.agr", + ); + if (!schemaTs || !grammarAgr) { + return { + error: `Missing schema or grammar artifacts for "${integrationName}".`, + }; + } + + await updatePhase(integrationName, "scaffolder", { status: "in-progress" }); + + // Determine package name and Pascal-case type name + const packageName = `${integrationName}-agent`; + const pascalName = toPascalCase(integrationName); + const targetDir = outputDir ?? path.join(AGENTS_DIR, integrationName); + const srcDir = path.join(targetDir, "src"); + + await fs.mkdir(srcDir, { recursive: true }); + + // Check if sub-schema groups exist from the discovery phase + const subSchemaSuggestion = await readArtifactJson( + integrationName, + "discovery", + "sub-schema-groups.json", + ); + const subGroups = + subSchemaSuggestion?.recommended && + subSchemaSuggestion.groups.length > 0 + ? subSchemaSuggestion.groups + : undefined; + + // Write core schema and grammar + await writeFile(path.join(srcDir, `${integrationName}Schema.ts`), schemaTs); + await writeFile( + path.join(srcDir, `${integrationName}Schema.agr`), + grammarAgr.replace( + /from "\.\/schema\.ts"/g, + `from "./${integrationName}Schema.ts"`, + ), + ); + + // Track all files created for the output summary + const files: string[] = [ + `src/${integrationName}Schema.ts`, + `src/${integrationName}Schema.agr`, + ]; + + // If sub-schema groups exist, generate per-group schema and grammar files + if (subGroups) { + const actionsDir = path.join(srcDir, "actions"); + await fs.mkdir(actionsDir, { recursive: true }); + + for (const group of subGroups) { + const groupPascal = toPascalCase(group.name); + + // Generate a filtered schema file for this group + const groupSchemaContent = buildSubSchemaTs( + integrationName, + pascalName, + group, + groupPascal, + schemaTs, + ); + await writeFile( + path.join(actionsDir, `${group.name}ActionsSchema.ts`), + groupSchemaContent, + ); + files.push(`src/actions/${group.name}ActionsSchema.ts`); + + // Generate a filtered grammar file for this group + const groupGrammarContent = buildSubSchemaAgr( + integrationName, + group, + groupPascal, + grammarAgr, + ); + await writeFile( + path.join(actionsDir, `${group.name}ActionsSchema.agr`), + groupGrammarContent, + ); + files.push(`src/actions/${group.name}ActionsSchema.agr`); + } + } + + // Stamp out manifest (with sub-action manifests if groups exist) + await writeFile( + path.join(srcDir, `${integrationName}Manifest.json`), + JSON.stringify( + buildManifest( + integrationName, + pascalName, + state.config.description ?? "", + pattern, + subGroups, + ), + null, + 2, + ), + ); + files.push(`src/${integrationName}Manifest.json`); + + // Stamp out handler + await writeFile( + path.join(srcDir, `${integrationName}ActionHandler.ts`), + buildHandler(integrationName, pascalName, pattern), + ); + files.push(`src/${integrationName}ActionHandler.ts`); + + // Stamp out package.json (with sub-schema build scripts if groups exist) + const subSchemaNames = subGroups?.map((g) => g.name); + await writeFile( + path.join(targetDir, "package.json"), + JSON.stringify( + buildPackageJson( + integrationName, + packageName, + pascalName, + pattern, + subSchemaNames, + ), + null, + 2, + ), + ); + files.push(`package.json`); + + // Stamp out tsconfigs + await writeFile( + path.join(targetDir, "tsconfig.json"), + JSON.stringify(ROOT_TSCONFIG, null, 2), + ); + await writeFile( + path.join(srcDir, "tsconfig.json"), + JSON.stringify(SRC_TSCONFIG, null, 2), + ); + files.push(`tsconfig.json`, `src/tsconfig.json`); + + // Also copy to workspace scaffolder dir for reference + await writeArtifact( + integrationName, + "scaffolder", + "scaffolded-to.txt", + targetDir, + ); + + await updatePhase(integrationName, "scaffolder", { status: "approved" }); + + let subSchemaNote = ""; + if (subGroups) { + subSchemaNote = + `\n\n**Sub-schemas generated:** ${subGroups.length} groups\n` + + subGroups + .map( + (g) => + `- **${g.name}** (${g.actions.length} actions): ${g.description}`, + ) + .join("\n"); + } + + return createActionResultFromMarkdownDisplay( + `## Agent scaffolded: ${integrationName}\n\n` + + `**Output directory:** \`${targetDir}\`\n\n` + + `**Files created:**\n` + + files.map((f) => `- \`${f}\``).join("\n") + + subSchemaNote + + `\n\n**Next step:** Phase 6 — use \`generateTests\` and \`runTests\` to validate.`, + ); +} + +// Build a sub-schema TypeScript file that re-exports only the actions belonging +// to this group. It imports from the main schema and creates a union type. +function buildSubSchemaTs( + _integrationName: string, + _pascalName: string, + group: SubSchemaGroup, + groupPascal: string, + fullSchemaTs: string, +): string { + // Extract individual action type names from the full schema that match the + // group's action list. TypeAgent schema files define types like: + // export type BoldAction = { actionName: "bold"; parameters: {...} }; + // and then a union: + // export type FooActions = BoldAction | ItalicAction | ...; + // + // We emit a new file that re-exports only the relevant action types and + // creates a new union type for this sub-schema group. + + const actionTypeNames = group.actions.map( + (a) => `${a.charAt(0).toUpperCase()}${a.slice(1)}Action`, + ); + + // Find action type blocks in the full schema that belong to this group + const actionBlocks: string[] = []; + for (const actionName of group.actions) { + // Match "export type XxxAction = ..." blocks + const typeName = `${actionName.charAt(0).toUpperCase()}${actionName.slice(1)}Action`; + const regex = new RegExp( + `(export\\s+type\\s+${typeName}\\s*=\\s*\\{[\\s\\S]*?\\};)`, + ); + const match = fullSchemaTs.match(regex); + if (match) { + actionBlocks.push(match[1]); + } + } + + const unionType = `export type ${groupPascal}Actions =\n | ${actionTypeNames.join("\n | ")};`; + + return `// Copyright (c) Microsoft Corporation.\n// Licensed under the MIT License.\n\n// Sub-schema: ${group.name} — ${group.description}\n// Auto-generated by the onboarding scaffolder.\n\n${actionBlocks.join("\n\n")}\n\n${unionType}\n`; +} + +// Build a sub-schema grammar file that includes only the rules relevant to +// this group's actions. +function buildSubSchemaAgr( + integrationName: string, + group: SubSchemaGroup, + groupPascal: string, + fullGrammarAgr: string, +): string { + // Grammar files contain rule blocks that typically start with the action name. + // We extract lines that reference actions in this group and build a new .agr. + const lines = fullGrammarAgr.split("\n"); + const relevantLines: string[] = []; + let inRelevantBlock = false; + const actionSet = new Set(group.actions); + + for (const line of lines) { + // Check if line starts a new action rule (e.g., "actionName:" or + // a line that contains an action name as an identifier) + const ruleMatch = line.match(/^(\w+)\s*:/); + if (ruleMatch) { + inRelevantBlock = actionSet.has(ruleMatch[1]); + } + + // Also include header/import lines (lines starting with '#' or 'from') + const isHeader = + line.startsWith("#") || + line.startsWith("from ") || + line.startsWith("//") || + line.trim() === ""; + + if (inRelevantBlock || isHeader) { + relevantLines.push(line); + } + } + + // Fix the schema file reference to point to the sub-schema + let content = relevantLines.join("\n"); + content = content.replace( + /from "\.\/[^"]*Schema\.ts"/g, + `from "./actions/${group.name}ActionsSchema.ts"`, + ); + // Update the schema type reference + content = content.replace( + /from "\.\/[^"]*"/g, + `from "./actions/${group.name}ActionsSchema.ts"`, + ); + + return `// Sub-schema grammar: ${group.name} — ${group.description}\n// Auto-generated by the onboarding scaffolder.\n\n${content}\n`; +} + +async function handleScaffoldPlugin( + integrationName: string, + template: string, + outputDir?: string, +): Promise { + const state = await loadState(integrationName); + if (!state) return { error: `Integration "${integrationName}" not found.` }; + + const templateInfo = PLUGIN_TEMPLATES[template]; + if (!templateInfo) { + return { + error: `Unknown template "${template}". Use listTemplates to see available templates.`, + }; + } + + const targetDir = + outputDir ?? + path.join(AGENTS_DIR, integrationName, templateInfo.defaultSubdir); + await fs.mkdir(targetDir, { recursive: true }); + + for (const [filename, content] of Object.entries( + templateInfo.files(integrationName), + )) { + await writeFile(path.join(targetDir, filename), content); + } + + return createActionResultFromMarkdownDisplay( + `## Plugin scaffolded: ${integrationName} (${template})\n\n` + + `**Output:** \`${targetDir}\`\n\n` + + `**Files created:**\n` + + Object.keys(templateInfo.files(integrationName)) + .map((f) => `- \`${f}\``) + .join("\n") + + `\n\n${templateInfo.nextSteps}`, + ); +} + +async function handleListTemplates(): Promise { + const lines = [ + `## Available scaffolding templates`, + ``, + `### Agent templates`, + `- **default** — TypeAgent agent package (manifest, handler, schema, grammar)`, + ``, + `### Plugin templates (use with \`scaffoldPlugin\`)`, + ...Object.entries(PLUGIN_TEMPLATES).map( + ([key, t]) => `- **${key}** — ${t.description}`, + ), + ]; + return createActionResultFromMarkdownDisplay(lines.join("\n")); +} + +// ─── Template helpers ──────────────────────────────────────────────────────── + +function toPascalCase(str: string): string { + return str + .split(/[-_\s]+/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(""); +} + +function buildManifest( + name: string, + pascalName: string, + description: string, + pattern: AgentPattern = "schema-grammar", + subGroups?: SubSchemaGroup[], +) { + const manifest: Record = { + emojiChar: "🔌", + description: description || `Agent for ${name}`, + defaultEnabled: false, + schema: { + description: `${pascalName} agent actions`, + originalSchemaFile: `./${name}Schema.ts`, + schemaFile: `../dist/${name}Schema.pas.json`, + grammarFile: `../dist/${name}Schema.ag.json`, + schemaType: `${pascalName}Actions`, + }, + }; + + // Pattern-specific manifest flags + if (pattern === "llm-streaming") { + manifest.injected = true; + manifest.cached = false; + manifest.streamingActions = ["generateResponse"]; + } else if (pattern === "view-ui") { + manifest.localView = true; + } + + if (subGroups && subGroups.length > 0) { + const subActionManifests: Record = {}; + for (const group of subGroups) { + const groupPascal = toPascalCase(group.name); + subActionManifests[group.name] = { + schema: { + description: group.description, + originalSchemaFile: `./actions/${group.name}ActionsSchema.ts`, + schemaFile: `../dist/actions/${group.name}ActionsSchema.pas.json`, + grammarFile: `../dist/actions/${group.name}ActionsSchema.ag.json`, + schemaType: `${groupPascal}Actions`, + }, + }; + } + manifest.subActionManifests = subActionManifests; + } + + return manifest; +} + +function buildHandler( + name: string, + pascalName: string, + pattern: AgentPattern = "schema-grammar", +): string { + switch (pattern) { + case "external-api": + return buildExternalApiHandler(name, pascalName); + case "llm-streaming": + return buildLlmStreamingHandler(name, pascalName); + case "sub-agent-orchestrator": + return buildSubAgentOrchestratorHandler(name, pascalName); + case "websocket-bridge": + return buildWebSocketBridgeHandler(name, pascalName); + case "state-machine": + return buildStateMachineHandler(name, pascalName); + case "native-platform": + return buildNativePlatformHandler(name, pascalName); + case "view-ui": + return buildViewUiHandler(name, pascalName); + case "command-handler": + return buildCommandHandlerTemplate(name, pascalName); + default: + return buildSchemaGrammarHandler(name, pascalName); + } +} + +function buildSchemaGrammarHandler(name: string, pascalName: string): string { + return `// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { ${pascalName}Actions } from "./${name}Schema.js"; + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + return {}; +} + +async function executeAction( + action: TypeAgentAction<${pascalName}Actions>, + context: ActionContext, +): Promise { + // TODO: implement action handlers + return createActionResultFromTextDisplay( + \`Executing \${action.actionName} — not yet implemented.\`, + ); +} +`; +} + +function buildPackageJson( + name: string, + packageName: string, + pascalName: string, + pattern: AgentPattern = "schema-grammar", + subSchemaNames?: string[], +) { + const scripts: Record = { + asc: `asc -i ./src/${name}Schema.ts -o ./dist/${name}Schema.pas.json -t ${pascalName}Actions`, + agc: `agc -i ./src/${name}Schema.agr -o ./dist/${name}Schema.ag.json`, + tsc: "tsc -b", + clean: "rimraf --glob dist *.tsbuildinfo *.done.build.log", + }; + + // Generate asc: and agc: scripts for each sub-schema + const buildTargets = ["npm:tsc", "npm:asc", "npm:agc"]; + if (subSchemaNames && subSchemaNames.length > 0) { + for (const groupName of subSchemaNames) { + const groupPascal = toPascalCase(groupName); + scripts[`asc:${groupName}`] = + `asc -i ./src/actions/${groupName}ActionsSchema.ts -o ./dist/actions/${groupName}ActionsSchema.pas.json -t ${groupPascal}Actions`; + scripts[`agc:${groupName}`] = + `agc -i ./src/actions/${groupName}ActionsSchema.agr -o ./dist/actions/${groupName}ActionsSchema.ag.json`; + buildTargets.push(`npm:asc:${groupName}`, `npm:agc:${groupName}`); + } + } + + scripts.build = `concurrently ${buildTargets.join(" ")}`; + + return { + name: packageName, + version: "0.0.1", + private: true, + description: `TypeAgent agent for ${name}`, + license: "MIT", + author: "Microsoft", + type: "module", + exports: { + "./agent/manifest": `./src/${name}Manifest.json`, + "./agent/handlers": `./dist/${name}ActionHandler.js`, + }, + scripts, + dependencies: { + "@typeagent/agent-sdk": "workspace:*", + ...(pattern === "llm-streaming" + ? { aiclient: "workspace:*", typechat: "workspace:*" } + : pattern === "external-api" + ? { aiclient: "workspace:*" } + : pattern === "websocket-bridge" + ? { ws: "^8.18.0" } + : {}), + }, + devDependencies: { + "@typeagent/action-schema-compiler": "workspace:*", + "action-grammar-compiler": "workspace:*", + concurrently: "^9.1.2", + rimraf: "^6.0.1", + typescript: "~5.4.5", + }, + }; +} + +const ROOT_TSCONFIG = { + extends: "../../../tsconfig.base.json", + compilerOptions: { composite: true }, + include: [], + references: [{ path: "./src" }], + "ts-node": { esm: true }, +}; + +const SRC_TSCONFIG = { + extends: "../../../../tsconfig.base.json", + compilerOptions: { composite: true, rootDir: ".", outDir: "../dist" }, + include: ["./**/*"], + "ts-node": { esm: true }, +}; + +const PLUGIN_TEMPLATES: Record< + string, + { + description: string; + defaultSubdir: string; + nextSteps: string; + files: (name: string) => Record; + } +> = { + "rest-client": { + description: "Simple REST API client bridge", + defaultSubdir: "src", + nextSteps: + "Implement `executeCommand(action, params)` to call your REST API endpoints.", + files: (name) => ({ + [`${name}Bridge.ts`]: buildRestClientTemplate(name), + }), + }, + "websocket-bridge": { + description: + "WebSocket bridge (bidirectional RPC, used by Excel, VS Code agents)", + defaultSubdir: "src", + nextSteps: + "Start the bridge with `new WebSocketBridge(port).start()` and connect your plugin.", + files: (name) => ({ + [`${name}Bridge.ts`]: buildWebSocketBridgeTemplate(name), + }), + }, + "office-addin": { + description: "Office.js task pane add-in skeleton", + defaultSubdir: "addin", + nextSteps: + "Load the add-in in Excel/Word/Outlook and configure the manifest URL.", + files: (name) => ({ + "taskpane.html": buildOfficeAddinHtml(name), + "taskpane.ts": buildOfficeAddinTs(name), + "manifest.xml": buildOfficeManifestXml(name), + }), + }, +}; + +function buildRestClientTemplate(name: string): string { + return `// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// REST client bridge for ${name}. +// Calls the target API and returns results to the TypeAgent handler. + +export class ${toPascalCase(name)}Bridge { + constructor(private readonly baseUrl: string, private readonly apiKey?: string) {} + + async executeCommand(actionName: string, parameters: Record): Promise { + // TODO: map actionName to HTTP endpoint and method + throw new Error(\`Not implemented: \${actionName}\`); + } + + private get headers(): Record { + const h: Record = { "Content-Type": "application/json" }; + if (this.apiKey) h["Authorization"] = \`Bearer \${this.apiKey}\`; + return h; + } +} +`; +} + +function buildWebSocketBridgeTemplate(name: string): string { + return `// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// WebSocket bridge for ${name}. +// Manages a WebSocket connection to the host application plugin. +// Pattern matches the Excel/VS Code agent bridge implementations. + +import { WebSocketServer, WebSocket } from "ws"; + +type BridgeCommand = { + id: string; + actionName: string; + parameters: Record; +}; + +type BridgeResponse = { + id: string; + success: boolean; + result?: unknown; + error?: string; +}; + +export class ${toPascalCase(name)}Bridge { + private wss: WebSocketServer | undefined; + private client: WebSocket | undefined; + private pending = new Map void>(); + + constructor(private readonly port: number) {} + + start(): void { + this.wss = new WebSocketServer({ port: this.port }); + this.wss.on("connection", (ws) => { + this.client = ws; + ws.on("message", (data) => { + const response = JSON.parse(data.toString()) as BridgeResponse; + this.pending.get(response.id)?.(response); + this.pending.delete(response.id); + }); + }); + } + + async sendCommand(actionName: string, parameters: Record): Promise { + if (!this.client) throw new Error("No client connected"); + const id = \`cmd-\${Date.now()}-\${Math.random().toString(36).slice(2)}\`; + return new Promise((resolve, reject) => { + this.pending.set(id, (res) => { + if (res.success) resolve(res.result); + else reject(new Error(res.error)); + }); + this.client!.send(JSON.stringify({ id, actionName, parameters } satisfies BridgeCommand)); + }); + } +} +`; +} + +function buildOfficeAddinHtml(name: string): string { + return ` + + + + ${toPascalCase(name)} TypeAgent Add-in + + + + +

${toPascalCase(name)} TypeAgent

+
Connecting...
+ + +`; +} + +function buildOfficeAddinTs(name: string): string { + return `// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Office.js task pane add-in for ${name} TypeAgent integration. +// Connects to the TypeAgent bridge via WebSocket and forwards commands +// to the Office.js API. + +const BRIDGE_PORT = 5678; + +Office.onReady(async () => { + document.getElementById("status")!.textContent = "Connecting to TypeAgent..."; + const ws = new WebSocket(\`ws://localhost:\${BRIDGE_PORT}\`); + + ws.onopen = () => { + document.getElementById("status")!.textContent = "Connected"; + ws.send(JSON.stringify({ type: "hello", addinName: "${name}" })); + }; + + ws.onmessage = async (event) => { + const command = JSON.parse(event.data); + try { + const result = await executeCommand(command.actionName, command.parameters); + ws.send(JSON.stringify({ id: command.id, success: true, result })); + } catch (err: any) { + ws.send(JSON.stringify({ id: command.id, success: false, error: err?.message ?? String(err) })); + } + }; +}); + +async function executeCommand(actionName: string, parameters: Record): Promise { + // TODO: map actionName to Office.js API calls + throw new Error(\`Not implemented: \${actionName}\`); +} +`; +} + +function buildOfficeManifestXml(name: string): string { + const pascal = toPascalCase(name); + return ` + + + 1.0.0.0 + Microsoft + en-US + + + + + + + + + ReadWriteDocument + +`; +} + +async function writeFile(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, "utf-8"); +} + +// ─── Pattern listing ───────────────────────────────────────────────────────── + +async function handleListPatterns(): Promise { + const lines = [ + `## Agent architectural patterns`, + ``, + `Pass \`pattern\` to \`scaffoldAgent\` to generate pattern-appropriate boilerplate.`, + ``, + `| Pattern | When to use | Examples |`, + `|---------|-------------|----------|`, + `| \`schema-grammar\` | Standard: bounded set of typed actions (default) | weather, photo, list |`, + `| \`external-api\` | REST/OAuth cloud API (MS Graph, Spotify, GitHub…) | calendar, email, player |`, + `| \`llm-streaming\` | Agent calls an LLM and streams partial results | chat, greeting |`, + `| \`sub-agent-orchestrator\` | API surface too large for one schema; split into groups | desktop, code, browser |`, + `| \`websocket-bridge\` | Automate an app via a host-side plugin over WebSocket | browser, code |`, + `| \`state-machine\` | Multi-phase workflow with approval gates and disk persistence | onboarding, scriptflow |`, + `| \`native-platform\` | OS/device APIs via child_process or SDK; no cloud | androidMobile, playerLocal |`, + `| \`view-ui\` | Rich interactive UI rendered in a local web view | turtle, montage, markdown |`, + `| \`command-handler\` | Simple settings-style agent; direct dispatch, no schema | settings, test |`, + ]; + return createActionResultFromMarkdownDisplay(lines.join("\n")); +} + +// ─── Pattern-specific handler builders ─────────────────────────────────────── + +function buildExternalApiHandler(name: string, pascalName: string): string { + return `// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: external-api — REST/OAuth cloud API bridge. +// Implement ${pascalName}Client with your API's authentication and endpoints. + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { ${pascalName}Actions } from "./${name}Schema.js"; + +// ---- API client -------------------------------------------------------- + +class ${pascalName}Client { + private token: string | undefined; + + /** Authenticate and store the access token. */ + async authenticate(): Promise { + // TODO: implement OAuth flow or API key loading. + // Store token in: ~/.typeagent/profiles//${name}/token.json + throw new Error("authenticate() not yet implemented"); + } + + async callApi(endpoint: string, params: Record): Promise { + if (!this.token) await this.authenticate(); + // TODO: implement HTTP call using this.token + throw new Error(\`callApi(\${endpoint}) not yet implemented\`); + } +} + +// ---- Agent lifecycle --------------------------------------------------- + +type Context = { client: ${pascalName}Client }; + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + updateAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + return { client: new ${pascalName}Client() }; +} + +async function updateAgentContext( + enable: boolean, + _context: ActionContext, +): Promise { + // Optionally authenticate eagerly when the agent is enabled. +} + +async function executeAction( + action: TypeAgentAction<${pascalName}Actions>, + context: ActionContext, +): Promise { + const { client } = context.agentContext; + // TODO: map each action to a client.callApi() call. + return createActionResultFromTextDisplay( + \`Executing \${action.actionName} — not yet implemented.\`, + ); +} +`; +} + +function buildLlmStreamingHandler(name: string, pascalName: string): string { + return `// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: llm-streaming — LLM-injected agent with streaming responses. +// Runs inside the dispatcher process (injected: true in manifest). +// Uses aiclient + typechat; streams partial results via streamingActionContext. + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromMarkdownDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { ${pascalName}Actions } from "./${name}Schema.js"; + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + return {}; +} + +async function executeAction( + action: TypeAgentAction<${pascalName}Actions>, + context: ActionContext, +): Promise { + switch (action.actionName) { + case "generateResponse": { + // TODO: call your LLM and stream chunks via: + // context.streamingActionContext?.appendDisplay(chunk) + return createActionResultFromMarkdownDisplay( + "Streaming response not yet implemented.", + ); + } + default: + return createActionResultFromMarkdownDisplay( + \`Unknown action: \${(action as any).actionName}\`, + ); + } +} +`; +} + +function buildSubAgentOrchestratorHandler( + name: string, + pascalName: string, +): string { + return `// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: sub-agent-orchestrator — root agent routing to N typed sub-schemas. +// Add one executeXxxAction() per sub-schema group defined in subActionManifests. +// The root executeAction routes by action name (each group owns disjoint names). + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { ${pascalName}Actions } from "./${name}Schema.js"; + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + return {}; +} + +async function executeAction( + action: TypeAgentAction<${pascalName}Actions>, + context: ActionContext, +): Promise { + // TODO: route to sub-schema handlers, e.g.: + // if (isGroupOneAction(action)) return executeGroupOneAction(action, context); + // if (isGroupTwoAction(action)) return executeGroupTwoAction(action, context); + return createActionResultFromTextDisplay( + \`Executing \${action.actionName} — not yet implemented.\`, + ); +} + +// ---- Sub-schema handlers (one per subActionManifests group) ------------ + +// async function executeGroupOneAction( +// action: TypeAgentAction, +// context: ActionContext, +// ): Promise { ... } +`; +} + +function buildWebSocketBridgeHandler(name: string, pascalName: string): string { + return `// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: websocket-bridge — bidirectional RPC to a host-side plugin. +// The agent owns a WebSocketServer; the host plugin connects as the client. +// Commands flow TypeAgent → WebSocket → plugin → response. + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { WebSocketServer, WebSocket } from "ws"; +import { ${pascalName}Actions } from "./${name}Schema.js"; + +const BRIDGE_PORT = 5678; // TODO: choose an unused port + +// ---- WebSocket bridge -------------------------------------------------- + +type BridgeRequest = { id: string; actionName: string; parameters: unknown }; +type BridgeResponse = { id: string; success: boolean; result?: unknown; error?: string }; + +class ${pascalName}Bridge { + private wss: WebSocketServer | undefined; + private client: WebSocket | undefined; + private pending = new Map void>(); + + start(): void { + this.wss = new WebSocketServer({ port: BRIDGE_PORT }); + this.wss.on("connection", (ws) => { + this.client = ws; + ws.on("message", (data) => { + const response = JSON.parse(data.toString()) as BridgeResponse; + this.pending.get(response.id)?.(response); + this.pending.delete(response.id); + }); + ws.on("close", () => { this.client = undefined; }); + }); + } + + async stop(): Promise { + return new Promise((resolve) => this.wss?.close(() => resolve())); + } + + async send(actionName: string, parameters: unknown): Promise { + if (!this.client) { + throw new Error("No host plugin connected on port " + BRIDGE_PORT); + } + const id = \`\${Date.now()}-\${Math.random().toString(36).slice(2)}\`; + return new Promise((resolve, reject) => { + this.pending.set(id, (res) => + res.success ? resolve(res.result) : reject(new Error(res.error)), + ); + this.client!.send( + JSON.stringify({ id, actionName, parameters } satisfies BridgeRequest), + ); + }); + } + + get connected(): boolean { return this.client !== undefined; } +} + +// ---- Agent lifecycle --------------------------------------------------- + +type Context = { bridge: ${pascalName}Bridge }; + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + updateAgentContext, + closeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + const bridge = new ${pascalName}Bridge(); + bridge.start(); + return { bridge }; +} + +async function updateAgentContext( + _enable: boolean, + _context: ActionContext, +): Promise {} + +async function closeAgentContext(context: ActionContext): Promise { + await context.agentContext.bridge.stop(); +} + +async function executeAction( + action: TypeAgentAction<${pascalName}Actions>, + context: ActionContext, +): Promise { + const { bridge } = context.agentContext; + if (!bridge.connected) { + return { + error: \`Host plugin not connected. Make sure the ${name} plugin is running on port \${BRIDGE_PORT}.\`, + }; + } + try { + const result = await bridge.send(action.actionName, action.parameters); + return createActionResultFromTextDisplay(JSON.stringify(result, null, 2)); + } catch (err: any) { + return { error: err?.message ?? String(err) }; + } +} +`; +} + +function buildStateMachineHandler(name: string, pascalName: string): string { + return `// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: state-machine — multi-phase disk-persisted workflow. +// State is stored in ~/.typeagent/${name}//state.json. +// Each phase must be approved before the next begins. + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromMarkdownDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { ${pascalName}Actions } from "./${name}Schema.js"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +const STATE_ROOT = path.join(os.homedir(), ".typeagent", "${name}"); + +// ---- State types ------------------------------------------------------- + +type PhaseStatus = "pending" | "in-progress" | "approved"; + +type WorkflowState = { + workflowId: string; + currentPhase: string; + phases: Record; + config: Record; + createdAt: string; + updatedAt: string; +}; + +// ---- State I/O --------------------------------------------------------- + +async function loadState(workflowId: string): Promise { + const statePath = path.join(STATE_ROOT, workflowId, "state.json"); + try { + return JSON.parse(await fs.readFile(statePath, "utf-8")) as WorkflowState; + } catch { + return undefined; + } +} + +async function saveState(state: WorkflowState): Promise { + const stateDir = path.join(STATE_ROOT, state.workflowId); + await fs.mkdir(stateDir, { recursive: true }); + state.updatedAt = new Date().toISOString(); + await fs.writeFile( + path.join(stateDir, "state.json"), + JSON.stringify(state, null, 2), + "utf-8", + ); +} + +// ---- Agent lifecycle --------------------------------------------------- + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + await fs.mkdir(STATE_ROOT, { recursive: true }); + return {}; +} + +async function executeAction( + action: TypeAgentAction<${pascalName}Actions>, + _context: ActionContext, +): Promise { + // TODO: map actions to phase handlers, e.g.: + // case "startWorkflow": return handleStart(action.parameters.workflowId); + // case "runPhaseOne": return handlePhaseOne(action.parameters.workflowId); + // case "approvePhase": return handleApprove(action.parameters.workflowId, action.parameters.phase); + // case "getStatus": return handleStatus(action.parameters.workflowId); + return createActionResultFromMarkdownDisplay( + \`Executing \${action.actionName} — not yet implemented.\`, + ); +} +`; +} + +function buildNativePlatformHandler(name: string, pascalName: string): string { + return `// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: native-platform — OS/device APIs via child_process or SDK. +// No cloud dependency. Handle platform differences in executeCommand(). + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { ${pascalName}Actions } from "./${name}Schema.js"; + +const execAsync = promisify(exec); +const platform = process.platform; // "win32" | "darwin" | "linux" + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + return {}; +} + +async function executeAction( + action: TypeAgentAction<${pascalName}Actions>, + _context: ActionContext, +): Promise { + try { + const output = await executeCommand( + action.actionName, + action.parameters as Record, + ); + return createActionResultFromTextDisplay(output ?? "Done."); + } catch (err: any) { + return { error: err?.message ?? String(err) }; + } +} + +/** + * Map a typed action to a platform-specific shell command or SDK call. + * Add one case per action defined in ${pascalName}Actions. + */ +async function executeCommand( + actionName: string, + parameters: Record, +): Promise { + switch (actionName) { + // TODO: add cases for each action. Example: + // case "openFile": { + // const cmd = platform === "win32" ? \`start "" "\${parameters.path}"\` + // : platform === "darwin" ? \`open "\${parameters.path}"\` + // : \`xdg-open "\${parameters.path}"\`; + // return (await execAsync(cmd)).stdout; + // } + default: + throw new Error(\`Not implemented: \${actionName}\`); + } +} +`; +} + +function buildViewUiHandler(name: string, pascalName: string): string { + return `// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: view-ui — web view renderer with IPC handler. +// Opens a local HTTP server serving site/ and communicates via display APIs. +// The actual UX lives in the site/ directory. + +import { + ActionContext, + AppAgent, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromHtmlDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { ${pascalName}Actions } from "./${name}Schema.js"; + +const VIEW_PORT = 3456; // TODO: choose an unused port + +export function instantiate(): AppAgent { + return { + initializeAgentContext, + updateAgentContext, + closeAgentContext, + executeAction, + }; +} + +async function initializeAgentContext(): Promise { + // TODO: start the local HTTP server that serves site/ + return {}; +} + +async function updateAgentContext( + enable: boolean, + context: ActionContext, +): Promise { + if (enable) { + await context.sessionContext.agentIO.openLocalView( + context.sessionContext.requestId, + VIEW_PORT, + ); + } else { + await context.sessionContext.agentIO.closeLocalView( + context.sessionContext.requestId, + VIEW_PORT, + ); + } +} + +async function closeAgentContext(_context: ActionContext): Promise { + // TODO: stop the local HTTP server +} + +async function executeAction( + action: TypeAgentAction<${pascalName}Actions>, + _context: ActionContext, +): Promise { + // Push state changes to the view via HTML display updates. + return createActionResultFromHtmlDisplay( + \`

Executing \${action.actionName} — not yet implemented.

\`, + ); +} +`; +} + +function buildCommandHandlerTemplate(name: string, pascalName: string): string { + return `// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Pattern: command-handler — direct dispatch via a handlers map. +// Suited for settings-style agents with a small number of well-known commands. + +import { AppAgent, ActionResult } from "@typeagent/agent-sdk"; +import { createActionResultFromTextDisplay } from "@typeagent/agent-sdk/helpers/action"; + +export function instantiate(): AppAgent { + return getCommandInterface(handlers); +} + +// ---- Handlers ---------------------------------------------------------- +// Add one entry per action name defined in ${pascalName}Actions. + +const handlers: Record Promise> = { + // exampleAction: async (params) => { + // return createActionResultFromTextDisplay("Done."); + // }, +}; + +function getCommandInterface( + handlerMap: Record Promise>, +): AppAgent { + return { + async executeAction(action: any): Promise { + const handler = handlerMap[action.actionName]; + if (!handler) { + return { error: \`Unknown action: \${action.actionName}\` }; + } + return handler(action.parameters); + }, + }; +} +`; +} diff --git a/ts/packages/agents/onboarding/src/scaffolder/scaffolderSchema.agr b/ts/packages/agents/onboarding/src/scaffolder/scaffolderSchema.agr new file mode 100644 index 0000000000..c355076f59 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/scaffolderSchema.agr @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Grammar for Phase 5 — Scaffolder actions. + + = scaffold (the)? $(integrationName:wildcard) agent -> { + actionName: "scaffoldAgent", + parameters: { + integrationName + } +} + | (create | generate | stamp out) (the)? $(integrationName:wildcard) (agent)? (package)? -> { + actionName: "scaffoldAgent", + parameters: { + integrationName + } +} + | scaffold (the)? $(integrationName:wildcard) agent (using | with | as) (a | the)? schema grammar pattern -> { + actionName: "scaffoldAgent", + parameters: { integrationName, pattern: "schema-grammar" } +} + | scaffold (the)? $(integrationName:wildcard) agent (using | with | as) (a | the)? external api pattern -> { + actionName: "scaffoldAgent", + parameters: { integrationName, pattern: "external-api" } +} + | scaffold (the)? $(integrationName:wildcard) agent (using | with | as) (a | the)? (llm)? streaming pattern -> { + actionName: "scaffoldAgent", + parameters: { integrationName, pattern: "llm-streaming" } +} + | scaffold (the)? $(integrationName:wildcard) agent (using | with | as) (a | the)? (sub agent)? orchestrator pattern -> { + actionName: "scaffoldAgent", + parameters: { integrationName, pattern: "sub-agent-orchestrator" } +} + | scaffold (the)? $(integrationName:wildcard) agent (using | with | as) (a | the)? websocket (bridge)? pattern -> { + actionName: "scaffoldAgent", + parameters: { integrationName, pattern: "websocket-bridge" } +} + | scaffold (the)? $(integrationName:wildcard) agent (using | with | as) (a | the)? state machine pattern -> { + actionName: "scaffoldAgent", + parameters: { integrationName, pattern: "state-machine" } +} + | scaffold (the)? $(integrationName:wildcard) agent (using | with | as) (a | the)? native platform pattern -> { + actionName: "scaffoldAgent", + parameters: { integrationName, pattern: "native-platform" } +} + | scaffold (the)? $(integrationName:wildcard) agent (using | with | as) (a | the)? view (ui)? pattern -> { + actionName: "scaffoldAgent", + parameters: { integrationName, pattern: "view-ui" } +} + | scaffold (the)? $(integrationName:wildcard) agent (using | with | as) (a | the)? command handler pattern -> { + actionName: "scaffoldAgent", + parameters: { integrationName, pattern: "command-handler" } +}; + + = scaffold (the)? $(integrationName:wildcard) (plugin | extension) -> { + actionName: "scaffoldPlugin", + parameters: { + integrationName, + template: "rest-client" + } +} + | (create | generate) (the)? $(integrationName:wildcard) (plugin | extension) -> { + actionName: "scaffoldPlugin", + parameters: { + integrationName, + template: "rest-client" + } +}; + + = list (available)? templates -> { + actionName: "listTemplates", + parameters: {} +} + | (show | what are) (the)? (available)? (scaffolding)? templates -> { + actionName: "listTemplates", + parameters: {} +}; + + = list (available | agent)? patterns -> { + actionName: "listPatterns", + parameters: {} +} + | (show | what are) (the)? (available | agent)? (agent)? patterns -> { + actionName: "listPatterns", + parameters: {} +} + | what patterns (are)? (available | supported)? -> { + actionName: "listPatterns", + parameters: {} +}; + +import { ScaffolderActions } from "./scaffolderSchema.ts"; + + : ScaffolderActions = + | + | + | ; diff --git a/ts/packages/agents/onboarding/src/scaffolder/scaffolderSchema.ts b/ts/packages/agents/onboarding/src/scaffolder/scaffolderSchema.ts new file mode 100644 index 0000000000..6de7584c34 --- /dev/null +++ b/ts/packages/agents/onboarding/src/scaffolder/scaffolderSchema.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Agent architectural patterns supported by the scaffolder. +export type AgentPattern = + | "schema-grammar" // Standard: schema + grammar + dispatch handler (default) + | "external-api" // REST/OAuth cloud API bridge (MS Graph, Spotify, etc.) + | "llm-streaming" // LLM-injected agent with streaming responses + | "sub-agent-orchestrator" // Root agent routing to N typed sub-schemas + | "websocket-bridge" // Bidirectional WebSocket to a host-side plugin + | "state-machine" // Multi-phase disk-persisted workflow + | "native-platform" // OS/device APIs via child_process or SDK + | "view-ui" // Web view renderer with IPC handler + | "command-handler"; // CommandHandler (direct dispatch, no typed schema) + +export type ScaffolderActions = + | ScaffoldAgentAction + | ScaffoldPluginAction + | ListTemplatesAction + | ListPatternsAction; + +export type ScaffoldAgentAction = { + actionName: "scaffoldAgent"; + parameters: { + // Integration name to scaffold agent for + integrationName: string; + // Architectural pattern to use (defaults to "schema-grammar") + pattern?: AgentPattern; + // Target directory for the agent package (defaults to ts/packages/agents/) + outputDir?: string; + }; +}; + +export type ScaffoldPluginAction = { + actionName: "scaffoldPlugin"; + parameters: { + // Integration name to scaffold the host-side plugin for + integrationName: string; + // Template to use for the plugin side + template: + | "office-addin" + | "vscode-extension" + | "electron-app" + | "browser-extension" + | "rest-client"; + // Target directory for the plugin (defaults to ts/packages/agents//plugin) + outputDir?: string; + }; +}; + +export type ListTemplatesAction = { + actionName: "listTemplates"; + parameters: {}; +}; + +export type ListPatternsAction = { + actionName: "listPatterns"; + parameters: {}; +}; diff --git a/ts/packages/agents/onboarding/src/schemaGen/schemaGenHandler.ts b/ts/packages/agents/onboarding/src/schemaGen/schemaGenHandler.ts new file mode 100644 index 0000000000..db58175b44 --- /dev/null +++ b/ts/packages/agents/onboarding/src/schemaGen/schemaGenHandler.ts @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Phase 3 — Schema Generation handler. +// Uses the approved API surface and generated phrases to produce a +// TypeScript action schema file with appropriate comments. + +import { + ActionContext, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { createActionResultFromMarkdownDisplay } from "@typeagent/agent-sdk/helpers/action"; +import { SchemaGenActions } from "./schemaGenSchema.js"; +import { + loadState, + updatePhase, + writeArtifact, + readArtifact, + readArtifactJson, +} from "../lib/workspace.js"; +import { getSchemaGenModel } from "../lib/llm.js"; +import { ApiSurface } from "../discovery/discoveryHandler.js"; +import { PhraseSet } from "../phraseGen/phraseGenHandler.js"; + +// Shared schema authoring guidelines injected into every schema gen/refine prompt. +const SCHEMA_GUIDELINES = ` +COMMENT STRUCTURE RULES: +1. Action-level block (above the action type declaration): use only for a short "what it does" description and example user/agent phrase pairs. No rules or constraints here. +2. Property-level comments (inside the parameters object, above each property declaration): ALL guidance lives here, co-located with the property it constrains. Do NOT put constraints at the action level. +3. No inline end-of-line comments on property declarations. All commentary goes in the line(s) above the property. + +PROPERTY COMMENT ORDERING (top = least important, bottom = most important — the LLM reads top-to-bottom, so put the critical constraint last, immediately before the property): +// General description of what this parameter is. +// Supplementary guidance / common aliases / optional tips. +// NOTE: or IMPORTANT: The hard constraint the model must not violate. +propertyName: type; + +CRITICAL CONSTRAINT FORMAT — embed a concrete WRONG/RIGHT example for any hard constraint; the WRONG case should be the exact failure mode you have observed: +// The data range in A1 notation. +// NOTE: Must be a literal cell range — do NOT use named ranges or structured references. +// WRONG: "SalesData[ActualSales]" ← structured table reference, will fail +// WRONG: "ActualSales" ← column name, will fail +// RIGHT: "C1:C7" ← literal A1 range +dataRange: string; + +BEST PRACTICES: +- Enum-like properties: always define the type as an explicit union of string literals instead of \`string\`. The comment above the property should name the underlying API enum it maps to and explain the default value and why. + Example: + // Label position relative to the data point. Maps to Office.js ChartDataLabelPosition enum. + // Default is "BestFit" — Office.js automatically chooses the best placement. + position?: "Top" | "Bottom" | "Center" | "InsideEnd" | "InsideBase" | "OutsideEnd" | "Left" | "Right" | "BestFit" | "Callout" | "None"; +`; + +export async function executeSchemaGenAction( + action: TypeAgentAction, + _context: ActionContext, +): Promise { + switch (action.actionName) { + case "generateSchema": + return handleGenerateSchema(action.parameters.integrationName); + + case "refineSchema": + return handleRefineSchema( + action.parameters.integrationName, + action.parameters.instructions, + ); + + case "approveSchema": + return handleApproveSchema(action.parameters.integrationName); + } +} + +async function handleGenerateSchema( + integrationName: string, +): Promise { + const state = await loadState(integrationName); + if (!state) return { error: `Integration "${integrationName}" not found.` }; + if (state.phases.discovery.status !== "approved") { + return { + error: `Discovery phase must be approved first. Run approveApiSurface.`, + }; + } + + const surface = await readArtifactJson( + integrationName, + "discovery", + "api-surface.json", + ); + if (!surface) { + return { + error: `Missing discovery artifact for "${integrationName}".`, + }; + } + // phraseSet is optional — we can still generate a schema without sample phrases + const phraseSet = await readArtifactJson( + integrationName, + "phraseGen", + "phrases.json", + ); + + await updatePhase(integrationName, "schemaGen", { status: "in-progress" }); + + const model = getSchemaGenModel(); + const prompt = buildSchemaPrompt( + integrationName, + surface, + phraseSet ?? null, + state.config.description, + ); + const result = await model.complete(prompt); + if (!result.success) { + return { error: `Schema generation failed: ${result.message}` }; + } + + const schemaTs = extractTypeScript(result.data); + await writeArtifact(integrationName, "schemaGen", "schema.ts", schemaTs); + + return createActionResultFromMarkdownDisplay( + `## Schema generated: ${integrationName}\n\n` + + "```typescript\n" + + schemaTs.slice(0, 2000) + + (schemaTs.length > 2000 ? "\n// ... (truncated)" : "") + + "\n```\n\n" + + `Use \`refineSchema\` to adjust, or \`approveSchema\` to proceed to grammar generation.`, + ); +} + +async function handleRefineSchema( + integrationName: string, + instructions: string, +): Promise { + const existing = await readArtifact( + integrationName, + "schemaGen", + "schema.ts", + ); + if (!existing) { + return { + error: `No schema found for "${integrationName}". Run generateSchema first.`, + }; + } + + const model = getSchemaGenModel(); + const prompt = [ + { + role: "system" as const, + content: + "You are a TypeScript expert. Modify the given TypeAgent action schema according to the instructions. " + + "Preserve all copyright headers and existing structure.\n" + + SCHEMA_GUIDELINES + + "Respond in JSON format. Return a JSON object with a single `schema` key containing the updated TypeScript file content as a string.", + }, + { + role: "user" as const, + content: + `Refine this TypeAgent action schema for "${integrationName}".\n\n` + + `Instructions: ${instructions}\n\n` + + `Current schema:\n\`\`\`typescript\n${existing}\n\`\`\``, + }, + ]; + + const result = await model.complete(prompt); + if (!result.success) { + return { error: `Schema refinement failed: ${result.message}` }; + } + + const refined = extractTypeScript(result.data); + // Archive the previous version + const version = Date.now(); + await writeArtifact( + integrationName, + "schemaGen", + `schema.v${version}.ts`, + existing, + ); + await writeArtifact(integrationName, "schemaGen", "schema.ts", refined); + + return createActionResultFromMarkdownDisplay( + `## Schema refined: ${integrationName}\n\n` + + `Previous version archived as \`schema.v${version}.ts\`\n\n` + + "```typescript\n" + + refined.slice(0, 2000) + + (refined.length > 2000 ? "\n// ... (truncated)" : "") + + "\n```", + ); +} + +async function handleApproveSchema( + integrationName: string, +): Promise { + const schema = await readArtifact( + integrationName, + "schemaGen", + "schema.ts", + ); + if (!schema) { + return { + error: `No schema found for "${integrationName}". Run generateSchema first.`, + }; + } + + await updatePhase(integrationName, "schemaGen", { status: "approved" }); + + return createActionResultFromMarkdownDisplay( + `## Schema approved: ${integrationName}\n\n` + + `Schema saved to \`~/.typeagent/onboarding/${integrationName}/schemaGen/schema.ts\`\n\n` + + `**Next step:** Phase 4 — use \`generateGrammar\` to produce the .agr grammar file.`, + ); +} + +function buildSchemaPrompt( + integrationName: string, + surface: ApiSurface, + phraseSet: PhraseSet | null, + description?: string, +): { role: "system" | "user"; content: string }[] { + const actionSummary = surface.actions + .map((a) => { + const phrases = phraseSet?.phrases[a.name] ?? []; + return ( + `Action: ${a.name}\n` + + `Description: ${a.description}\n` + + (a.parameters?.length + ? `Parameters: ${a.parameters.map((p) => `${p.name}: ${p.type}${p.required ? "" : "?"}`).join(", ")}\n` + : "") + + (phrases.length + ? `Sample phrases:\n${phrases + .slice(0, 3) + .map((p) => ` - "${p}"`) + .join("\n")}` + : "") + ); + }) + .join("\n\n"); + + return [ + { + role: "system", + content: + "You are a TypeScript expert generating TypeAgent action schemas. " + + "TypeAgent action schemas are TypeScript union types where each member has an `actionName` discriminant and a `parameters` object. " + + "Follow these file-level conventions:\n" + + "- Start the file with:\n // Copyright (c) Microsoft Corporation.\n // Licensed under the MIT License.\n" + + "- Export a top-level union type named `Actions`\n" + + "- Each action type is named `Action`\n" + + '- Use `actionName: "camelCaseName"` as a string literal type\n' + + "- Parameters use camelCase names\n" + + "- Optional parameters use `?: type` syntax\n" + + SCHEMA_GUIDELINES + + "Respond in JSON format. Return a JSON object with a single `schema` key containing the TypeScript file content as a string.", + }, + { + role: "user", + content: + `Generate a TypeAgent action schema for the "${integrationName}" integration` + + (description ? ` (${description})` : "") + + `.\n\n` + + `Actions to include:\n\n${actionSummary}`, + }, + ]; +} + +function extractTypeScript(llmResponse: string): string { + // Try to parse as JSON first (when using json_object response format) + try { + const parsed = JSON.parse(llmResponse); + if (parsed.schema) return parsed.schema.trim(); + } catch { + // Not JSON, fall through to other extraction methods + } + // Strip markdown code fences if present + const fenceMatch = llmResponse.match( + /```(?:typescript|ts)?\n([\s\S]*?)```/, + ); + if (fenceMatch) return fenceMatch[1].trim(); + return llmResponse.trim(); +} diff --git a/ts/packages/agents/onboarding/src/schemaGen/schemaGenSchema.agr b/ts/packages/agents/onboarding/src/schemaGen/schemaGenSchema.agr new file mode 100644 index 0000000000..e5987f97c5 --- /dev/null +++ b/ts/packages/agents/onboarding/src/schemaGen/schemaGenSchema.agr @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Grammar for Phase 3 — Schema Generation actions. + +// generateSchema - produce TypeScript action schema from discovered actions + phrases + = generate (the)? (action)? schema for $(integrationName:wildcard) -> { + actionName: "generateSchema", + parameters: { + integrationName + } +} + | (create | produce | write) (the)? (typescript)? (action)? schema for $(integrationName:wildcard) -> { + actionName: "generateSchema", + parameters: { + integrationName + } +}; + +// refineSchema - update the schema based on instructions + = refine (the)? $(integrationName:wildcard) schema (to)? $(instructions:wildcard) -> { + actionName: "refineSchema", + parameters: { + integrationName, + instructions + } +} + | update (the)? $(integrationName:wildcard) schema (to)? $(instructions:wildcard) -> { + actionName: "refineSchema", + parameters: { + integrationName, + instructions + } +}; + +// approveSchema - lock in the schema + = approve (the)? $(integrationName:wildcard) schema -> { + actionName: "approveSchema", + parameters: { + integrationName + } +} + | (confirm | finalize) (the)? $(integrationName:wildcard) (action)? schema -> { + actionName: "approveSchema", + parameters: { + integrationName + } +}; + +import { SchemaGenActions } from "./schemaGenSchema.ts"; + + : SchemaGenActions = + | + | ; diff --git a/ts/packages/agents/onboarding/src/schemaGen/schemaGenSchema.ts b/ts/packages/agents/onboarding/src/schemaGen/schemaGenSchema.ts new file mode 100644 index 0000000000..397f9c17cc --- /dev/null +++ b/ts/packages/agents/onboarding/src/schemaGen/schemaGenSchema.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type SchemaGenActions = + | GenerateSchemaAction + | RefineSchemaAction + | ApproveSchemaAction; + +export type GenerateSchemaAction = { + actionName: "generateSchema"; + parameters: { + // Integration name to generate schema for + integrationName: string; + }; +}; + +export type RefineSchemaAction = { + actionName: "refineSchema"; + parameters: { + // Integration name + integrationName: string; + // Specific instructions for the LLM about what to change + // e.g. "make the listName parameter optional" or "add a sortOrder parameter to sortAction" + instructions: string; + }; +}; + +export type ApproveSchemaAction = { + actionName: "approveSchema"; + parameters: { + // Integration name to approve schema for + integrationName: string; + }; +}; diff --git a/ts/packages/agents/onboarding/src/testing/testingHandler.ts b/ts/packages/agents/onboarding/src/testing/testingHandler.ts new file mode 100644 index 0000000000..f182d7d80f --- /dev/null +++ b/ts/packages/agents/onboarding/src/testing/testingHandler.ts @@ -0,0 +1,649 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Phase 6 — Testing handler. +// Generates phrase→action test cases from the approved phrase set, +// runs them against the dispatcher using createDispatcher (same pattern +// as evalHarness.ts), and uses an LLM to propose repairs for failures. + +import { + ActionContext, + TypeAgentAction, + ActionResult, +} from "@typeagent/agent-sdk"; +import { + createActionResultFromTextDisplay, + createActionResultFromMarkdownDisplay, +} from "@typeagent/agent-sdk/helpers/action"; +import { TestingActions } from "./testingSchema.js"; +import { + loadState, + updatePhase, + writeArtifactJson, + readArtifactJson, + readArtifact, +} from "../lib/workspace.js"; +import { getTestingModel } from "../lib/llm.js"; +import { PhraseSet } from "../phraseGen/phraseGenHandler.js"; +import { createDispatcher } from "agent-dispatcher"; +import { + createNpmAppAgentProvider, + getFsStorageProvider, +} from "dispatcher-node-providers"; +import fs from "node:fs"; +import path from "node:path"; +import { getInstanceDir } from "agent-dispatcher/helpers/data"; +import type { + ClientIO, + IAgentMessage, + RequestId, + CommandResult, +} from "@typeagent/dispatcher-types"; +import type { + DisplayAppendMode, + DisplayContent, + MessageContent, +} from "@typeagent/agent-sdk"; + +export type TestCase = { + phrase: string; + expectedActionName: string; + // Expected parameter values (partial match is acceptable) + expectedParameters?: Record; +}; + +export type TestResult = { + phrase: string; + expectedActionName: string; + actualActionName?: string; + passed: boolean; + error?: string; +}; + +export type TestRun = { + integrationName: string; + ranAt: string; + total: number; + passed: number; + failed: number; + results: TestResult[]; +}; + +export type ProposedRepair = { + integrationName: string; + proposedAt: string; + // Suggested changes to the schema file + schemaChanges?: string; + // Suggested changes to the grammar file + grammarChanges?: string; + // Explanation of what was wrong and why these changes fix it + rationale: string; + applied?: boolean; + appliedAt?: string; +}; + +export async function executeTestingAction( + action: TypeAgentAction, + context: ActionContext, +): Promise { + switch (action.actionName) { + case "generateTests": + return handleGenerateTests(action.parameters.integrationName); + + case "runTests": + return handleRunTests( + action.parameters.integrationName, + context, // passed through for future session context use + action.parameters.forActions, + action.parameters.limit, + ); + + case "getTestResults": + return handleGetTestResults( + action.parameters.integrationName, + action.parameters.filter, + ); + + case "proposeRepair": + return handleProposeRepair( + action.parameters.integrationName, + action.parameters.forActions, + ); + + case "approveRepair": + return handleApproveRepair(action.parameters.integrationName); + } +} + +async function handleGenerateTests( + integrationName: string, +): Promise { + const state = await loadState(integrationName); + if (!state) return { error: `Integration "${integrationName}" not found.` }; + if (state.phases.scaffolder.status !== "approved") { + return { + error: `Scaffolder phase must be approved first. Run scaffoldAgent.`, + }; + } + + const phraseSet = await readArtifactJson( + integrationName, + "phraseGen", + "phrases.json", + ); + if (!phraseSet) { + return { error: `No phrases found for "${integrationName}".` }; + } + + await updatePhase(integrationName, "testing", { status: "in-progress" }); + + // Convert phrase set to test cases + const testCases: TestCase[] = []; + for (const [actionName, phrases] of Object.entries(phraseSet.phrases)) { + for (const phrase of phrases) { + testCases.push({ + phrase, + expectedActionName: actionName, + }); + } + } + + await writeArtifactJson( + integrationName, + "testing", + "test-cases.json", + testCases, + ); + + return createActionResultFromMarkdownDisplay( + `## Test cases generated: ${integrationName}\n\n` + + `**Total test cases:** ${testCases.length}\n` + + `**Actions covered:** ${Object.keys(phraseSet.phrases).length}\n\n` + + `Use \`runTests\` to execute them against the dispatcher.`, + ); +} + +async function handleRunTests( + integrationName: string, + _context: ActionContext, + forActions?: string[], + limit?: number, +): Promise { + const testCases = await readArtifactJson( + integrationName, + "testing", + "test-cases.json", + ); + if (!testCases || testCases.length === 0) { + return { + error: `No test cases found for "${integrationName}". Run generateTests first.`, + }; + } + + let toRun = forActions + ? testCases.filter((tc) => forActions.includes(tc.expectedActionName)) + : testCases; + if (limit) toRun = toRun.slice(0, limit); + + // Create a dispatcher and run each phrase through it. + // The scaffolded agent must be registered in config.json before running tests. + // Use `packageAgent --register` (phase 7) or add manually and restart TypeAgent. + let dispatcherSession: + | Awaited> + | undefined; + try { + dispatcherSession = await createTestDispatcher(); + } catch (err: any) { + return { + error: + `Failed to create dispatcher: ${err?.message ?? err}\n\n` + + `Make sure the "${integrationName}" agent is registered in config.json ` + + `and TypeAgent has been restarted. Run \`packageAgent --register\` first.`, + }; + } + + const results: TestResult[] = []; + for (const tc of toRun) { + const result = await runSingleTest( + tc, + integrationName, + dispatcherSession.dispatcher, + ); + results.push(result); + } + + await dispatcherSession.dispatcher.close(); + + const passed = results.filter((r) => r.passed).length; + const failed = results.length - passed; + + const testRun: TestRun = { + integrationName, + ranAt: new Date().toISOString(), + total: results.length, + passed, + failed, + results, + }; + + await writeArtifactJson( + integrationName, + "testing", + "results.json", + testRun, + ); + + const passRate = Math.round((passed / results.length) * 100); + + const failingSummary = results + .filter((r) => !r.passed) + .slice(0, 10) + .map( + (r) => + `- ❌ "${r.phrase}" → expected \`${r.expectedActionName}\`, got \`${r.actualActionName ?? "error"}\`${r.error ? ` (${r.error})` : ""}`, + ) + .join("\n"); + + return createActionResultFromMarkdownDisplay( + `## Test results: ${integrationName}\n\n` + + `**Pass rate:** ${passRate}% (${passed}/${results.length})\n\n` + + (failed > 0 + ? `**Failing tests (first 10):**\n${failingSummary}\n\n` + + `Use \`proposeRepair\` to get LLM-suggested schema/grammar fixes.` + : `All tests passed! Use \`approveRepair\` to finalize or proceed to packaging.`), + ); +} + +async function handleGetTestResults( + integrationName: string, + filter?: "passing" | "failing", +): Promise { + const testRun = await readArtifactJson( + integrationName, + "testing", + "results.json", + ); + if (!testRun) { + return { + error: `No test results found for "${integrationName}". Run runTests first.`, + }; + } + + const results = filter + ? testRun.results.filter((r) => + filter === "passing" ? r.passed : !r.passed, + ) + : testRun.results; + + const lines = [ + `## Test results: ${integrationName}`, + ``, + `**Run at:** ${testRun.ranAt}`, + `**Total:** ${testRun.total} | **Passed:** ${testRun.passed} | **Failed:** ${testRun.failed}`, + ``, + `| Result | Phrase | Expected | Actual |`, + `|---|---|---|---|`, + ...results + .slice(0, 50) + .map( + (r) => + `| ${r.passed ? "✅" : "❌"} | "${r.phrase}" | \`${r.expectedActionName}\` | \`${r.actualActionName ?? r.error ?? "—"}\` |`, + ), + ]; + if (results.length > 50) { + lines.push(``, `_...and ${results.length - 50} more_`); + } + + return createActionResultFromMarkdownDisplay(lines.join("\n")); +} + +async function handleProposeRepair( + integrationName: string, + forActions?: string[], +): Promise { + const testRun = await readArtifactJson( + integrationName, + "testing", + "results.json", + ); + if (!testRun) { + return { error: `No test results found. Run runTests first.` }; + } + + const failing = testRun.results.filter((r) => !r.passed); + if (failing.length === 0) { + return createActionResultFromTextDisplay( + "All tests are passing — no repairs needed.", + ); + } + + const schemaTs = await readArtifact( + integrationName, + "schemaGen", + "schema.ts", + ); + const grammarAgr = await readArtifact( + integrationName, + "grammarGen", + "schema.agr", + ); + + const filteredFailing = forActions + ? failing.filter((r) => forActions.includes(r.expectedActionName)) + : failing; + + const model = getTestingModel(); + const prompt = buildRepairPrompt( + integrationName, + filteredFailing, + schemaTs ?? "", + grammarAgr ?? "", + ); + + const result = await model.complete(prompt); + if (!result.success) { + return { error: `Repair proposal failed: ${result.message}` }; + } + + // Try to parse as JSON first (when using json_object response format) + let responseText = result.data; + let schemaFromJson: string | undefined; + let grammarFromJson: string | undefined; + try { + const parsed = JSON.parse(result.data); + responseText = parsed.explanation || result.data; + schemaFromJson = parsed.schema; + grammarFromJson = parsed.grammar; + } catch { + // Not JSON, fall through to regex extraction + } + + const repair: ProposedRepair = { + integrationName, + proposedAt: new Date().toISOString(), + rationale: responseText, + }; + + // Extract suggested schema and grammar changes from the response + const schemaMatch = schemaFromJson + ? null + : result.data.match(/```typescript([\s\S]*?)```/); + const grammarMatch = grammarFromJson + ? null + : result.data.match(/```(?:agr)?([\s\S]*?)```/); + if (schemaFromJson) repair.schemaChanges = schemaFromJson.trim(); + else if (schemaMatch) repair.schemaChanges = schemaMatch[1].trim(); + if (grammarFromJson) repair.grammarChanges = grammarFromJson.trim(); + else if (grammarMatch) repair.grammarChanges = grammarMatch[1].trim(); + + await writeArtifactJson( + integrationName, + "testing", + "proposed-repair.json", + repair, + ); + + return createActionResultFromMarkdownDisplay( + `## Proposed repair: ${integrationName}\n\n` + + `**Failing tests addressed:** ${filteredFailing.length}\n\n` + + result.data.slice(0, 3000) + + (result.data.length > 3000 ? "\n\n_...truncated_" : "") + + `\n\nReview the proposed changes, then use \`approveRepair\` to apply them.`, + ); +} + +async function handleApproveRepair( + integrationName: string, +): Promise { + const repair = await readArtifactJson( + integrationName, + "testing", + "proposed-repair.json", + ); + if (!repair) { + return { error: `No proposed repair found. Run proposeRepair first.` }; + } + if (repair.applied) { + return createActionResultFromTextDisplay("Repair was already applied."); + } + + // Apply schema changes if present + if (repair.schemaChanges) { + const version = Date.now(); + const existing = await readArtifact( + integrationName, + "schemaGen", + "schema.ts", + ); + if (existing) { + await writeArtifactJson( + integrationName, + "testing", + `schema.backup.v${version}.ts`, + existing, + ); + } + const { writeArtifact } = await import("../lib/workspace.js"); + await writeArtifact( + integrationName, + "schemaGen", + "schema.ts", + repair.schemaChanges, + ); + } + + // Apply grammar changes if present + if (repair.grammarChanges) { + const version = Date.now(); + const existing = await readArtifact( + integrationName, + "grammarGen", + "schema.agr", + ); + if (existing) { + await writeArtifactJson( + integrationName, + "testing", + `grammar.backup.v${version}.agr`, + existing, + ); + } + const { writeArtifact } = await import("../lib/workspace.js"); + await writeArtifact( + integrationName, + "grammarGen", + "schema.agr", + repair.grammarChanges, + ); + } + + repair.applied = true; + repair.appliedAt = new Date().toISOString(); + await writeArtifactJson( + integrationName, + "testing", + "proposed-repair.json", + repair, + ); + + await updatePhase(integrationName, "testing", { status: "approved" }); + + return createActionResultFromMarkdownDisplay( + `## Repair applied: ${integrationName}\n\n` + + (repair.schemaChanges ? "- Schema updated\n" : "") + + (repair.grammarChanges ? "- Grammar updated\n" : "") + + `\nRe-run \`runTests\` to verify fixes, or \`packageAgent\` to proceed.`, + ); +} + +// ─── Dispatcher helpers ─────────────────────────────────────────────────────── + +// Minimal ClientIO that silently captures display output into a buffer. +// Mirrors the createCapturingClientIO pattern from evalHarness.ts. +function createCapturingClientIO(buffer: string[]): ClientIO { + const noop = (() => {}) as (...args: any[]) => any; + + function contentToText(content: DisplayContent): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + if (content.length === 0) return ""; + if (typeof content[0] === "string") + return (content as string[]).join("\n"); + return (content as string[][]).map((r) => r.join(" | ")).join("\n"); + } + // TypedDisplayContent + const msg = (content as any).content as MessageContent; + if (typeof msg === "string") return msg; + if (Array.isArray(msg)) return (msg as string[]).join("\n"); + return String(msg); + } + + return { + clear: noop, + exit: () => process.exit(0), + setUserRequest: noop, + setDisplayInfo: noop, + setDisplay(message: IAgentMessage) { + const text = contentToText(message.message); + if (text) buffer.push(text); + }, + appendDisplay(message: IAgentMessage, _mode: DisplayAppendMode) { + const text = contentToText(message.message); + if (text) buffer.push(text); + }, + appendDiagnosticData: noop, + setDynamicDisplay: noop, + askYesNo: async (_id: RequestId, _msg: string, def = false) => def, + proposeAction: async () => undefined, + popupQuestion: async () => { + throw new Error("popupQuestion not supported in test runner"); + }, + notify: noop, + openLocalView: async () => {}, + closeLocalView: async () => {}, + requestChoice: noop, + requestInteraction: noop, + interactionResolved: noop, + interactionCancelled: noop, + takeAction: noop, + } satisfies ClientIO; +} + +// Build a provider containing only the externally-registered agents. +// The scaffolded agent must be registered in the TypeAgent config before +// running tests (use `packageAgent --register` or add manually to config.json). +function getExternalAppAgentProviders(instanceDir: string) { + const configPath = path.join(instanceDir, "externalAgentsConfig.json"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const agents = fs.existsSync(configPath) + ? ((JSON.parse(fs.readFileSync(configPath, "utf8")) as any).agents ?? + {}) + : {}; + return [ + createNpmAppAgentProvider( + agents, + path.join(instanceDir, "externalagents/package.json"), + ), + ]; +} + +async function createTestDispatcher() { + const instanceDir = getInstanceDir(); + const appAgentProviders = getExternalAppAgentProviders(instanceDir); + const buffer: string[] = []; + const clientIO = createCapturingClientIO(buffer); + + const dispatcher = await createDispatcher("onboarding-test-runner", { + appAgentProviders, + agents: { commands: ["dispatcher"] }, + explainer: { enabled: false }, + cache: { enabled: false }, + collectCommandResult: true, + persistDir: instanceDir, + storageProvider: getFsStorageProvider(), + clientIO, + dblogging: false, + }); + + return { dispatcher, buffer }; +} + +async function runSingleTest( + tc: TestCase, + integrationName: string, + dispatcher: Awaited>["dispatcher"], +): Promise { + // Route to the specific integration agent: "@ " + const command = `@${integrationName} ${tc.phrase}`; + + let result: CommandResult | undefined; + try { + result = await dispatcher.processCommand(command); + } catch (err: any) { + return { + phrase: tc.phrase, + expectedActionName: tc.expectedActionName, + passed: false, + error: err?.message ?? String(err), + }; + } + + if (result?.lastError) { + return { + phrase: tc.phrase, + expectedActionName: tc.expectedActionName, + passed: false, + error: result.lastError, + }; + } + + // Check the first dispatched action's name against expected + const actualActionName = result?.actions?.[0]?.actionName; + const passed = actualActionName === tc.expectedActionName; + + return { + phrase: tc.phrase, + expectedActionName: tc.expectedActionName, + ...(actualActionName !== undefined ? { actualActionName } : undefined), + passed, + ...(passed + ? undefined + : { + error: `Expected "${tc.expectedActionName}", got "${actualActionName ?? "no action"}"`, + }), + }; +} + +function buildRepairPrompt( + integrationName: string, + failing: TestResult[], + schemaTs: string, + grammarAgr: string, +): { role: "system" | "user"; content: string }[] { + const failuresSummary = failing + .slice(0, 20) + .map( + (r) => + `Phrase: "${r.phrase}"\nExpected: ${r.expectedActionName}\nGot: ${r.actualActionName ?? r.error ?? "no match"}`, + ) + .join("\n\n"); + + return [ + { + role: "system", + content: + "You are a TypeAgent grammar and schema expert. Analyze failing phrase-to-action test cases " + + "and propose specific fixes to the TypeScript schema and/or .agr grammar file. " + + "Explain what is wrong and why your changes will fix it. " + + "Respond in JSON format. Return a JSON object with optional `schema` and `grammar` keys containing the updated file contents as strings, and an `explanation` key describing the fixes.", + }, + { + role: "user", + content: + `Fix the TypeAgent schema and grammar for "${integrationName}" to make these failing tests pass.\n\n` + + `Failing tests (${failing.length} total, showing first 20):\n\n${failuresSummary}\n\n` + + `Current schema:\n\`\`\`typescript\n${schemaTs.slice(0, 3000)}\n\`\`\`\n\n` + + `Current grammar:\n\`\`\`agr\n${grammarAgr.slice(0, 3000)}\n\`\`\``, + }, + ]; +} diff --git a/ts/packages/agents/onboarding/src/testing/testingSchema.agr b/ts/packages/agents/onboarding/src/testing/testingSchema.agr new file mode 100644 index 0000000000..5c6f4ed2ec --- /dev/null +++ b/ts/packages/agents/onboarding/src/testing/testingSchema.agr @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Grammar for Phase 6 — Testing actions. + + = generate tests for $(integrationName:wildcard) -> { + actionName: "generateTests", + parameters: { + integrationName + } +} + | (create | produce) (test cases | tests) for $(integrationName:wildcard) -> { + actionName: "generateTests", + parameters: { + integrationName + } +}; + + = run tests for $(integrationName:wildcard) -> { + actionName: "runTests", + parameters: { + integrationName + } +} + | (execute | run) (the)? $(integrationName:wildcard) tests -> { + actionName: "runTests", + parameters: { + integrationName + } +}; + + = (get | show | display) (the)? $(integrationName:wildcard) test results -> { + actionName: "getTestResults", + parameters: { + integrationName + } +} + | (what are | show me) (the)? (test)? results for $(integrationName:wildcard) -> { + actionName: "getTestResults", + parameters: { + integrationName + } +}; + + = propose (a)? repair for $(integrationName:wildcard) -> { + actionName: "proposeRepair", + parameters: { + integrationName + } +} + | (suggest | find) (a)? fix for (the)? $(integrationName:wildcard) (test)? failures -> { + actionName: "proposeRepair", + parameters: { + integrationName + } +}; + + = approve (the)? $(integrationName:wildcard) repair -> { + actionName: "approveRepair", + parameters: { + integrationName + } +} + | (apply | accept) (the)? $(integrationName:wildcard) (proposed)? (repair | fix) -> { + actionName: "approveRepair", + parameters: { + integrationName + } +}; + +import { TestingActions } from "./testingSchema.ts"; + + : TestingActions = + | + | + | + | ; diff --git a/ts/packages/agents/onboarding/src/testing/testingSchema.ts b/ts/packages/agents/onboarding/src/testing/testingSchema.ts new file mode 100644 index 0000000000..1b9e6e3788 --- /dev/null +++ b/ts/packages/agents/onboarding/src/testing/testingSchema.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type TestingActions = + | GenerateTestsAction + | RunTestsAction + | GetTestResultsAction + | ProposeRepairAction + | ApproveRepairAction; + +export type GenerateTestsAction = { + actionName: "generateTests"; + parameters: { + // Integration name to generate tests for + integrationName: string; + }; +}; + +export type RunTestsAction = { + actionName: "runTests"; + parameters: { + // Integration name to run tests for + integrationName: string; + // Run only tests for these specific action names + forActions?: string[]; + // Maximum number of tests to run (runs all if omitted) + limit?: number; + }; +}; + +export type GetTestResultsAction = { + actionName: "getTestResults"; + parameters: { + // Integration name to get test results for + integrationName: string; + // Filter to show only passing or failing tests + filter?: "passing" | "failing"; + }; +}; + +export type ProposeRepairAction = { + actionName: "proposeRepair"; + parameters: { + // Integration name to propose repairs for + integrationName: string; + // If provided, propose repairs only for these specific failing action names + forActions?: string[]; + }; +}; + +export type ApproveRepairAction = { + actionName: "approveRepair"; + parameters: { + // Integration name to approve the proposed repair for + integrationName: string; + }; +}; diff --git a/ts/packages/agents/onboarding/src/tsconfig.json b/ts/packages/agents/onboarding/src/tsconfig.json new file mode 100644 index 0000000000..85efcd566d --- /dev/null +++ b/ts/packages/agents/onboarding/src/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist" + }, + "include": ["./**/*"], + "ts-node": { + "esm": true + } +} diff --git a/ts/packages/agents/onboarding/tsconfig.json b/ts/packages/agents/onboarding/tsconfig.json new file mode 100644 index 0000000000..acb9cb4a91 --- /dev/null +++ b/ts/packages/agents/onboarding/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "include": [], + "references": [{ "path": "./src" }], + "ts-node": { + "esm": true + } +} diff --git a/ts/packages/defaultAgentProvider/data/config.json b/ts/packages/defaultAgentProvider/data/config.json index faea7bb6cc..b4ada610f7 100644 --- a/ts/packages/defaultAgentProvider/data/config.json +++ b/ts/packages/defaultAgentProvider/data/config.json @@ -67,6 +67,9 @@ }, "utility": { "name": "utility-typeagent" + }, + "onboarding": { + "name": "onboarding-agent" } }, "mcpServers": { diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index a368486bf4..6e62a19304 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -2232,6 +2232,46 @@ importers: specifier: ^5.2.0 version: 5.2.1(debug@4.4.1)(webpack-cli@5.1.4)(webpack@5.105.0) + packages/agents/onboarding: + dependencies: + '@typeagent/agent-sdk': + specifier: workspace:* + version: link:../../agentSdk + '@typeagent/dispatcher-types': + specifier: workspace:* + version: link:../../dispatcher/types + agent-dispatcher: + specifier: workspace:* + version: link:../../dispatcher/dispatcher + aiclient: + specifier: workspace:* + version: link:../../aiclient + dispatcher-node-providers: + specifier: workspace:* + version: link:../../dispatcher/nodeProviders + typechat: + specifier: ^0.1.1 + version: 0.1.1(typescript@5.4.5)(zod@3.25.76) + devDependencies: + '@typeagent/action-schema-compiler': + specifier: workspace:* + version: link:../../actionSchemaCompiler + action-grammar-compiler: + specifier: workspace:* + version: link:../../actionGrammarCompiler + concurrently: + specifier: ^9.1.2 + version: 9.1.2 + prettier: + specifier: ^3.5.3 + version: 3.5.3 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + typescript: + specifier: ~5.4.5 + version: 5.4.5 + packages/agents/photo: dependencies: '@typeagent/agent-sdk': @@ -3364,6 +3404,9 @@ importers: music-local: specifier: workspace:* version: link:../agents/playerLocal + onboarding-agent: + specifier: workspace:* + version: link:../agents/onboarding photo-agent: specifier: workspace:* version: link:../agents/photo