diff --git a/.changeset/clean-core-refactor.md b/.changeset/clean-core-refactor.md new file mode 100644 index 00000000..a862633d --- /dev/null +++ b/.changeset/clean-core-refactor.md @@ -0,0 +1,5 @@ +--- +"@anvia/core": patch +--- + +Refactor core internals for improved maintainability while preserving public API and behavior. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 00000000..424278dd --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["docs", "cookbook", "anvia-cli-agent"] +} diff --git a/.env.example b/.env.example index fde3d97b..16d28f1c 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ OPENAI_API_KEY= +OPENAI_BASEURL= ANTHROPIC_API_KEY= ANTHROPIC_MODEL=claude-sonnet-4-20250514 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c27a049a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + packages: + name: Packages + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.0.4 + run_install: false + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build core package + run: pnpm --filter @anvia/core build + + - name: Build remaining packages + run: pnpm --filter './packages/**' --filter '!@anvia/core' build + + - name: Typecheck packages + run: pnpm --filter './packages/**' typecheck + + - name: Test packages + run: pnpm --filter './packages/**' test + + docs: + name: Docs + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.0.4 + run_install: false + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck docs + run: pnpm --filter docs typecheck + + - name: Build docs + run: pnpm --filter docs build diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 00000000..85b486ac --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,51 @@ +name: Deploy Docs + +on: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: docs-production + cancel-in-progress: false + +jobs: + deploy: + name: Deploy docs + runs-on: ubuntu-latest + environment: production + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.0.4 + run_install: false + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck docs + run: pnpm --filter docs typecheck + + - name: Build docs + run: pnpm --filter docs build + + - name: Deploy docs + run: pnpm --filter docs run deploy + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..347c40ef --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,104 @@ +name: Release Packages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: package-release + cancel-in-progress: false + +jobs: + version: + name: Create release pull request + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.0.4 + run_install: false + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Create release pull request + uses: changesets/action@v1 + with: + version: pnpm version-packages + title: Version Packages + commit: Version Packages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish: + name: Publish packages to npm + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Require main branch + run: | + if [ "${GITHUB_REF_NAME}" != "main" ]; then + echo "Manual package publishing must run from the main branch." + exit 1 + fi + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.0.4 + run_install: false + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Publish packages + run: pnpm release + env: + NPM_CONFIG_PROVENANCE: true + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Push release tags + run: git push --follow-tags + + - name: Create GitHub Releases + run: pnpm github-releases + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGESET_GUIDE.md b/CHANGESET_GUIDE.md new file mode 100644 index 00000000..fe288098 --- /dev/null +++ b/CHANGESET_GUIDE.md @@ -0,0 +1,116 @@ +# Changeset Guide + +Use Changesets whenever a change should publish one or more packages from `packages/` to npm. + +## When To Add A Changeset + +Add a changeset for any user-facing package change: + +- `patch`: bug fixes, small behavior fixes, documentation fixes that affect the published package, or internal changes that should ship. +- `minor`: new backwards-compatible APIs, features, options, exports, or capabilities. +- `major`: breaking API, behavior, package export, runtime, or type changes. + +Do not add a changeset for docs-only changes, CI changes, examples-only changes, or changes to private workspaces. + +## Create A Changeset + +From the repository root: + +```sh +pnpm changeset +``` + +Then: + +1. Select every package that should be released. +2. Choose the bump type for each selected package. +3. Write a short release note that explains the user-visible change. +4. Commit the generated file under `.changeset/` with your code changes. + +Example changeset text: + +```md +--- +"@anvia/core": minor +"@anvia/openai": patch +--- + +Add streaming metadata support to core runs and align the OpenAI adapter output. +``` + +## Before Opening A PR + +Run the relevant checks: + +```sh +pnpm install --frozen-lockfile +pnpm --filter './packages/**' typecheck +pnpm --filter './packages/**' test +pnpm --filter './packages/**' build +``` + +If docs changed, also run: + +```sh +pnpm --filter docs typecheck +pnpm --filter docs build +``` + +## Release Flow + +After a PR with one or more changesets is merged into `main`, the GitHub Actions release workflow runs Changesets. + +The workflow will: + +1. Open or update a `Version Packages` release PR. +2. Apply version bumps to changed packages. +3. Update package changelogs. +4. Remove consumed `.changeset/*.md` files. + +Review the release PR before merging it. Make sure package versions and changelog entries are correct. + +## Publish To npm Manually + +Merging the `Version Packages` release PR into `main` does not publish to npm. Publishing is manual dispatch only. + +Publishing requires the repository secret: + +```txt +NPM_TOKEN +``` + +To publish: + +1. Merge the `Version Packages` release PR into `main`. +2. Open GitHub Actions. +3. Select the `Release Packages` workflow. +4. Click `Run workflow`. +5. Run it from the `main` branch. + +The manual publish job builds packages before publishing: + +```sh +pnpm release +``` + +That command runs: + +```sh +pnpm --filter './packages/**' build && changeset publish +``` + +## Manual Version Check + +To preview what Changesets sees locally: + +```sh +pnpm exec changeset status --since main +``` + +To apply version bumps locally for inspection only: + +```sh +pnpm version-packages +``` + +Only commit local version bumps if you are intentionally preparing the release PR by hand. In normal development, let GitHub Actions create the release PR. diff --git a/README.md b/README.md index 39679391..ae6d4d0c 100644 --- a/README.md +++ b/README.md @@ -5,236 +5,106 @@

Anvia

- Provider-agnostic agents, tool workflows, and structured extraction for TypeScript applications. + Build provider-agnostic AI agents and workflows in TypeScript.

MIT license - @anvia/core v0.1.0 TypeScript 5.9 - pnpm 11.0.4 Node.js runtime - Cookbook docs

-Anvia is a TypeScript runtime for building provider-agnostic agents, tool workflows, and structured extraction inside your application code. +Anvia is a TypeScript runtime for agents, tools, structured extraction, retrieval, pipelines, and observability inside your application code. -It is designed for teams that want more structure than raw model calls, but less framework weight than a full orchestration stack. Provider clients create models. Builders configure behavior. Your application still owns data, permissions, persistence, and side effects. +It gives teams more structure than raw model calls without forcing a heavyweight orchestration stack. You bring the product, data, permissions, persistence, deployment, and side effects. Anvia gives you typed AI workflow primitives that fit around them. -## Product Shape +## Why Anvia -Anvia gives you a compact set of primitives for production-adjacent AI workflows: +- Provider-neutral clients for OpenAI-compatible APIs, Anthropic, Gemini, and Mistral. +- Agent and tool APIs that keep application behavior explicit and typed. +- Structured extraction and output schemas for turning model responses into usable data. +- Pipeline primitives for composing functions, agents, extractors, batches, and parallel branches. +- Retrieval adapters for in-memory search, local embeddings, ChromaDB, Qdrant, and pgvector. +- Optional Studio, MCP, local skills, Langfuse, and OpenTelemetry integrations. -- Agents, tools, extractors, pipelines, streaming, and typed output schemas. -- Provider-backed completion and embedding models across OpenAI, Anthropic, Gemini, Mistral, and compatible APIs. -- RAG primitives with in-memory search, local embeddings, ChromaDB, Qdrant, and pgvector adapters. -- Runtime integrations for MCP, local skills, multimodal attachments, observability, and Anvia Studio. +## Quick Start -## When To Choose Anvia +Install the core runtime and a provider adapter: -Choose Anvia when you are building AI features inside a TypeScript product and want provider-agnostic agents, extractors, tools, pipelines, and Studio workflows without giving up control of your application's data, permissions, persistence, and side effects. - -For deeper positioning, see [Comparison](apps/docs/content/docs/guides/comparison.mdx) and [Design Philosophy](apps/docs/content/docs/guides/design-philosophy.mdx). +```sh +pnpm add @anvia/core @anvia/openai +``` -## API Shape +Create a provider client, build an agent, and run it from your app: ```ts -import { AgentBuilder, ExtractorBuilder, PipelineBuilder } from "@anvia/core"; +import { AgentBuilder } from "@anvia/core"; import { OpenAIClient } from "@anvia/openai"; -const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey, -}); -const model = client.completionModel("qwen/qwen3.6-35b-a3b"); +const client = new OpenAIClient({ apiKey }); +const model = client.completionModel("gpt-5.5"); -const agent = new AgentBuilder("support", model) - .instructions("Answer support questions clearly.") +const supportAgent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly. Ask for missing details.") .build(); -const response = await agent.prompt("How do I reset my password?").send(); - -const extractor = new ExtractorBuilder(model, ticketSchema).build(); -const ticket = await extractor.extract(response.output); - -const workflow = new PipelineBuilder() - .step((input) => `Summarize this support ticket:\n\n${input}`) - .prompt(agent) - .extract(extractor) - .build(); - -const normalizedTicket = await workflow.run( - "Acme Co. reports checkout failures. Priority is high.", -); -``` - -## Packages - -| Package | Path | Purpose | -| --- | --- | --- | -| `@anvia/core` | `packages/core` | Core runtime for agents, tools, streaming, extraction, RAG primitives, MCP, skills, attachments, and observability interfaces. | -| `@anvia/openai` | `packages/providers/openai` | OpenAI and OpenAI-compatible provider adapter. | -| `@anvia/anthropic` | `packages/providers/anthropic` | Anthropic and Anthropic-compatible provider adapter. | -| `@anvia/gemini` | `packages/providers/gemini` | Gemini and Vertex AI provider adapter. | -| `@anvia/mistral` | `packages/providers/mistral` | Mistral completion and embedding provider adapter. | -| `@anvia/chroma` | `packages/vector-stores/chroma` | ChromaDB vector store adapter for Anvia embeddings and RAG. | -| `@anvia/qdrant` | `packages/vector-stores/qdrant` | Qdrant vector store adapter for Anvia embeddings and RAG. | -| `@anvia/pgvector` | `packages/vector-stores/pgvector` | Postgres pgvector store adapter for Anvia embeddings and RAG. | -| `@anvia/langfuse` | `packages/observability/langfuse` | Langfuse tracing adapter for Anvia observers. | -| `@anvia/otel` | `packages/observability/otel` | OpenTelemetry tracing adapter for Anvia observers. | -| `@anvia/transformers` | `packages/embeddings/transformers` | Transformers.js embedding model adapter, defaulting to local All-MiniLM. | -| `@anvia/studio` | `packages/tools/studio` | HTTP runtime and browser UI for serving agents, sessions, traces, and approvals. | -| `docs` | `apps/docs` | Private documentation app. | -| `cookbook` | `examples/cookbook` | Runnable examples that document the public learning path. | - -## Getting Started - -Install dependencies: - -```sh -pnpm install -``` - -Create a local `.env` file for cookbook runs: - -```sh -OPENROUTER_API_KEY=... -OPENAI_API_KEY=... -GEMINI_API_KEY=... -MISTRAL_API_KEY=... -``` - -Run the first basic text call: - -```sh -pnpm cookbook:basics:01 -``` - -Run Studio locally: +const response = await supportAgent + .prompt("A customer cannot reset their password. What should I check first?") + .send(); -```sh -pnpm cookbook:studio:01 +console.log(response.output); ``` -Start the cookbook ChromaDB service before running the Chroma-backed RAG examples: +Use the same runtime shape with other providers: ```sh -docker compose -f examples/cookbook/compose.cookbook.yml up -d -pnpm cookbook:retrieval:05 -pnpm cookbook:retrieval:06 -pnpm cookbook:retrieval:07 -pnpm cookbook:retrieval:08 +pnpm add @anvia/anthropic @anvia/gemini @anvia/mistral ``` -## Cookbook +Anvia clients take explicit constructor values and do not read environment variables on their own, so credentials stay in your existing configuration layer. -The cookbook is a product learning path. It starts with a plain text call, then adds history, streaming, tools, extraction, providers and multimodal APIs, pipelines, retrieval, multi-agent workflows, evals, Studio, and integrations one concept at a time. +## What You Can Build -| Level | Focus | +| Capability | Use it for | | --- | --- | -| Basics | Text calls, chat history, context, streaming, and `ReadableStream` output. | -| Tools | Tool calls, streamed tool events, hooks, concurrency, application state, guarded tools, and dynamic tool selection. | -| Structured output | Schema-first extraction, output schemas, context, retries, and extraction with history. | -| Providers and multimodal | Provider adapters, model capabilities, reasoning streams, attachments, image generation, audio generation, and transcription. | -| Pipelines | Step transforms, composition, named parallel branches, batching, agents, extraction, and richer workflows. | -| Retrieval | Local embeddings, vector search, metadata filters, RAG context, document loaders, ChromaDB, Qdrant, pgvector, FastEmbed, and Mistral embeddings. | -| Multi-agent | Agents as tools and pipeline-backed parallel specialists. | -| Evals | Deterministic metrics, semantic similarity, custom metrics, agent eval targets, and LLM judge/score. | -| Studio | Served agents, browser sessions, traces, multi-agent runners, tool approvals, human feedback, and Knowledge. | -| Integrations | MCP tools, local skills, Langfuse tracing, Langfuse eval reporting, and OpenTelemetry tracing. | - -Run the default example for a level: +| Agents | Promptable workflows with instructions, context, tools, hooks, history, streaming, and typed outputs. | +| Tools | Safe, typed access to application-owned actions such as lookup, search, mutation, approval, or dispatch. | +| Extractors | Schema-shaped data from text, tickets, documents, messages, and model responses. | +| Pipelines | Explicit multi-step workflows that combine functions, agents, extraction, branching, and batching. | +| Retrieval | Embeddings, vector search, document context, metadata filters, and RAG workflows. | +| Observability | Run, generation, tool, usage, trace, and eval events for production visibility. | +| Studio | A local browser UI for inspecting agents, sessions, traces, pipelines, tools, approvals, and knowledge. | -```sh -pnpm cookbook:basics -pnpm cookbook:tools -pnpm cookbook:structured-output -pnpm cookbook:providers -pnpm cookbook:pipelines -pnpm cookbook:retrieval -pnpm cookbook:multi-agent -pnpm cookbook:evals -pnpm cookbook:studio -pnpm cookbook:integrations -``` - -Numbered scripts are available for each level when you want to step through the path in order, for example `pnpm cookbook:basics:01`. Existing `basic`, `intermediate`, `pipeline`, `rag`, and `multimodal` cookbook scripts remain as compatibility aliases. +## Cookbook -## Development +The [cookbook](examples/cookbook/README.md) is the fastest way to see Anvia in motion. It walks from a first text call through tools, structured output, providers, multimodal inputs, pipelines, retrieval, multi-agent workflows, evals, Studio, and integrations. -Install dependencies before running workspace tasks: +Run the first example from the repository root: ```sh pnpm install +pnpm cookbook:basics:01 ``` -Common commands: - -```sh -pnpm typecheck -pnpm test -pnpm build -pnpm check -``` - -Package-scoped commands: +Run Studio locally: ```sh -pnpm --filter @anvia/core typecheck -pnpm --filter @anvia/core test -pnpm --filter @anvia/core build - -pnpm --filter @anvia/studio typecheck -pnpm --filter @anvia/studio test -pnpm --filter @anvia/studio build - -pnpm --filter cookbook typecheck -``` - -## Repository Layout - -```txt -. -├── packages/ -│ ├── core/ # @anvia/core -│ ├── providers/ -│ │ ├── openai/ # @anvia/openai -│ │ ├── anthropic/ # @anvia/anthropic -│ │ ├── gemini/ # @anvia/gemini -│ │ └── mistral/ # @anvia/mistral -│ ├── vector-stores/ -│ │ ├── chroma/ # @anvia/chroma -│ │ ├── qdrant/ # @anvia/qdrant -│ │ └── pgvector/ # @anvia/pgvector -│ ├── observability/ -│ │ └── langfuse/ # @anvia/langfuse -│ ├── embeddings/ -│ │ └── transformers/ # @anvia/transformers -│ └── tools/ -│ └── studio/ # @anvia/studio -├── apps/ -│ └── docs/ # documentation app -├── examples/ -│ ├── cli-agent/ # runnable CLI agent example -│ └── cookbook/ # runnable examples -├── biome.json -├── pnpm-workspace.yaml -└── tsconfig.base.json +pnpm cookbook:studio:01 ``` -## Contributing +## Learn More -Keep changes small and covered by the relevant package tests. For API changes, add or update cookbook coverage so the behavior is easy to verify from the command line. +- [Introduction](apps/docs/content/docs/guides/index.mdx) +- [Getting Started](apps/docs/content/docs/guides/getting-started.mdx) +- [Cookbook Guide](apps/docs/content/docs/guides/cookbook.mdx) +- [Comparison](apps/docs/content/docs/guides/comparison.mdx) +- [Design Philosophy](apps/docs/content/docs/guides/design-philosophy.mdx) +- [Contributing](CONTRIBUTING.md) -Before opening a change, run: +## Project Activity -```sh -pnpm typecheck -pnpm test -pnpm build -pnpm check -``` +![Repobeats analytics image](https://repobeats.axiom.co/api/embed/a63db5f32641718a48cb706d9957e94fa413871d.svg "Repobeats analytics image") ## License -MIT. +MIT diff --git a/apps/docs/content/docs/best-practices/common-patterns/agent-structure.mdx b/apps/docs/content/docs/best-practices/common-patterns/agent-structure.mdx new file mode 100644 index 00000000..9d9b5eb1 --- /dev/null +++ b/apps/docs/content/docs/best-practices/common-patterns/agent-structure.mdx @@ -0,0 +1,140 @@ +--- +title: Agent Structure +description: Keep stable runtime objects separate from request-local state. +--- + +Build agents from explicit TypeScript values. A built agent is an immutable runtime configuration: id, model, instructions, static context, tools, hooks, observers, memory, and defaults. It should be safe to import from routes, jobs, tests, and Studio when its tools do not capture the current request. + +When tools or context need the current user, tenant, request, transaction, or permission state, create a scoped agent in a factory or runner. Do not hide request state in module-level variables. + +## Stable Agent Module + +Use a shared built agent when every registered tool is context-free, read-only, or already enforces its own context through injected services. + +```ts +// src/ai/support-agent.ts +import { AgentBuilder } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; +import { supportTools } from "./support-tools"; + +const client = new OpenAIClient({ apiKey: process.env.OPENAI_API_KEY }); +const model = client.completionModel("gpt-5.5"); + +export const supportAgent = new AgentBuilder("support", model) + .name("Support Agent") + .description("Answers support questions and uses support tools.") + .instructions( + "Answer support questions clearly. Ask for missing details before guessing.", + ) + .tools(supportTools) + .defaultMaxTurns(3) + .build(); +``` + +The stable agent id is part of the runtime contract. It appears in traces, Studio, sessions, nested-agent tools, and workflows that reference the agent. + +## Request Boundary + +Keep user input, auth state, tenant data, message history, trace metadata, route-specific limits, and application error mapping at the call site or runner. + +```ts +import { Message } from "@anvia/core"; +import { supportAgent } from "./support-agent"; + +export async function runSupportTurn(input: SupportTurnInput) { + const history = await input.conversations.loadMessages(input.conversationId); + + const response = await supportAgent + .prompt([...history, Message.user(input.message)]) + .withTrace({ + name: "support-chat", + userId: input.user.id, + metadata: { + tenantId: input.user.tenantId, + conversationId: input.conversationId, + }, + }) + .maxTurns(2) + .send(); + + await input.conversations.append(input.conversationId, response.messages); + + return response.output; +} +``` + +This keeps the agent reusable. A background job can call the same agent with a different trace name, a test can replace history with fixtures, and Studio can run the built agent without product-route state. + +## Scoped Agent Factory + +Use a factory when tools or context need current user data, tenant data, feature flags, service handles, or a transaction. + +```ts +import type { CompletionModel } from "@anvia/core"; +import { AgentBuilder } from "@anvia/core"; +import { createSupportTools } from "./support-tools"; + +type SupportAgentScope = { + userId: string; + tenantId: string; + plan: "free" | "pro" | "enterprise"; + services: { + orders: OrdersService; + tickets: TicketsService; + }; +}; + +export function createSupportAgent(model: CompletionModel, scope: SupportAgentScope) { + return new AgentBuilder("support", model) + .instructions("Answer support questions clearly. Use tools for account data.") + .tools( + createSupportTools({ + userId: scope.userId, + tenantId: scope.tenantId, + orders: scope.services.orders, + tickets: scope.services.tickets, + }), + ) + .context(`Current customer plan: ${scope.plan}`, "customer-plan") + .defaultMaxTurns(3) + .build(); +} +``` + +The factory should receive explicit dependencies. Avoid reading the current user, request, tenant, or transaction from global state inside the agent module. + +## Shared Configuration Helper + +If stable and scoped agents share most behavior, extract a small builder helper. + +```ts +import type { AgentBuilder, CompletionModel } from "@anvia/core"; + +function configureSupportBehavior(builder: AgentBuilder) { + return builder + .name("Support Agent") + .description("Answers support questions and performs safe support actions.") + .instructions("Answer clearly. Ask before guessing. Use tools for account data.") + .defaultMaxTurns(3); +} + +export function createScopedSupportAgent(model: CompletionModel, scope: SupportAgentScope) { + return configureSupportBehavior(new AgentBuilder("support", model)) + .tools(createSupportTools(scope)) + .context(`Current customer plan: ${scope.plan}`, "customer-plan") + .build(); +} +``` + +Prefer factories and helpers over mutating a built agent. Build once for stable configuration, or build per request when the tool set captures request-local state. + +## Decision Table + +| Put it here | When | +| --- | --- | +| `AgentBuilder` | stable identity, instructions, static context, context-free tools, default limits, observers | +| scoped agent factory | request-scoped tools, tenant-aware context, feature-flagged tools, transaction-bound services | +| runner | validation, auth, history, trace metadata, persistence, error mapping | +| prompt request | current input, one-off max turns, tool concurrency, request trace metadata | +| tool factory | user id, tenant id, service handles, permission scope | +| route or job | transport parsing, response shape, job retry policy, caller-specific timeouts | diff --git a/apps/docs/content/docs/best-practices/common-patterns/context-and-memory.mdx b/apps/docs/content/docs/best-practices/common-patterns/context-and-memory.mdx new file mode 100644 index 00000000..33424edb --- /dev/null +++ b/apps/docs/content/docs/best-practices/common-patterns/context-and-memory.mdx @@ -0,0 +1,136 @@ +--- +title: Context and Memory +description: Assemble instructions, facts, retrieval, history, and sessions deliberately. +--- + +Context works best when each layer has a clear job. Keep durable behavior in instructions, small stable facts in static context, request facts in the scoped agent or prompt text, prompt-dependent knowledge in retrieval, and conversation continuity in history or sessions. + +Do not treat context as permission. Filter records by tenant, user, and access level before they can be included in a prompt or retrieval index. + +## Choose the Right Layer + +| Layer | Use it for | Owner | +| --- | --- | --- | +| instructions | behavior rules that apply to every run | stable agent | +| static context | short stable facts always relevant to the agent | stable agent | +| request facts | current user, tenant, plan, locale, feature flags, route state | runner or scoped factory | +| dynamic context | retrieved documents or records selected for the prompt | index plus runner filtering | +| message history | previous turns needed to continue a conversation | application storage | +| sessions | durable memory managed through an agent memory store | Anvia memory plus app-chosen store | +| tools | data that should be fetched or changed only when needed | model decision, app execution | + +## Instructions vs Facts + +Put behavior in instructions. Put facts in context or loaded prompt data. + +```ts +const agent = new AgentBuilder("billing", model) + .instructions(` +Answer billing questions clearly. +Use billing tools for account-specific data. +Do not guess invoice status. + `) + .context("Invoices are generated on the first day of each month.", "invoice-cycle") + .build(); +``` + +Avoid hiding changing account facts in instructions. If a value depends on the current request, load it in the runner and attach it to the scoped agent or prompt. + +```ts +const agent = new AgentBuilder("billing", model) + .instructions("Answer billing questions clearly.") + .context(`Current account plan: ${account.plan}`, "account-plan") + .context(`Billing country: ${account.country}`, "billing-country") + .build(); +``` + +## Request Context + +Use request context for small facts the application already knows are relevant. + +```ts +const prompt = [ + ...history, + Message.user(` +Current route: support chat +Current plan: ${account.plan} +Open ticket count: ${openTickets.length} + +User message: +${message} + `), +]; + +const response = await agent.prompt(prompt).send(); +``` + +Keep request context compact. If the agent only needs the current plan, do not paste the whole account record. If the model may need additional fields conditionally, expose a tool instead. + +## Dynamic Context + +Use dynamic context when relevant facts depend on the prompt. + +```ts +const agent = new AgentBuilder("support", model) + .instructions("Use retrieved support context when it is relevant.") + .dynamicContext(supportKnowledgeIndex, { + topK: 5, + threshold: 0.72, + format: (result) => ({ + id: result.id, + text: `${result.document.title}\n${result.document.body}`, + }), + }) + .build(); +``` + +Dynamic context should be tenant-safe before search. Use separate indexes, metadata filters, or a prefiltered index handle so records from another tenant cannot be retrieved. + +## History and Sessions + +Use explicit `Message[]` history when your application already owns conversation storage. + +```ts +import { Message } from "@anvia/core"; + +const history = await conversations.loadMessages(conversationId); + +const response = await agent + .prompt([...history, Message.user(message)]) + .send(); + +await conversations.append(conversationId, response.messages); +``` + +Use `agent.session(...)` when the agent has a memory store configured and you want Anvia to manage durable conversation state for that session. + +```ts +const response = await agent + .session(sessionId) + .prompt("What did we decide last time?") + .send(); +``` + +Use one approach per workflow unless there is a clear reason to combine them. Mixing explicit history and sessions casually makes prompts harder to inspect. + +## Context Assembly Checklist + +| Question | Default | +| --- | --- | +| Is it a rule for every run? | instruction | +| Is it a short stable fact? | static `.context(...)` | +| Is it current user or tenant data? | runner or scoped agent context | +| Is it large or frequently changing knowledge? | retrieval | +| Is it previous conversation state? | explicit history or session memory | +| Does access need to be checked at call time? | tool or service | +| Could it leak another tenant's data? | filter before context assembly | + +## Practical Rule + +Context should explain the current run, not become a data dump. If a fact is needed every time, make it stable. If a fact changes per request, load it in the runner. If a fact is large, search for it. If a fact requires permission, put it behind a tool or prefilter it before retrieval. + +## Related Patterns + +- Use [Support Agent](/docs/best-practices/real-cases/support-agent) for account-aware chat with history and retrieval. +- Use [Research Agent](/docs/best-practices/real-cases/research-agent) for retrieval-heavy workflows with many read-only tools. +- Use [Dynamic Tool Catalogs](/docs/best-practices/tool-patterns/dynamic-tool-catalogs) when searchable tools are a better fit than broad context. diff --git a/apps/docs/content/docs/best-practices/common-patterns/harness-blueprint.mdx b/apps/docs/content/docs/best-practices/common-patterns/harness-blueprint.mdx new file mode 100644 index 00000000..5fe324d3 --- /dev/null +++ b/apps/docs/content/docs/best-practices/common-patterns/harness-blueprint.mdx @@ -0,0 +1,120 @@ +--- +title: Harness Blueprint +description: The standard production shape around an Anvia agent run. +--- + +An agent harness is the application-owned code around the Anvia runtime. It accepts a product request, prepares the agent run, controls access to application behavior, records what happened, and returns a product response. + +The harness is not a new framework layer. It is ordinary TypeScript that makes the boundary explicit: Anvia runs the prompt, tools, hooks, retrieval, memory, streaming, and observers; your application owns auth, data, permissions, transactions, storage, retries, deployment, and side effects. + +## Ownership Map + +| Layer | Owns | +| --- | --- | +| route, server action, job, or queue worker | transport parsing, auth entrypoint, response shape, job retry policy | +| harness runner | one product workflow, request validation, scoped dependencies, trace metadata, error mapping | +| stable agent factory | agent id, instructions, model behavior, static defaults, reusable observers | +| request-scoped tools | product service calls, permissions, tenant filtering, side-effect safety | +| context assembly | static facts, request facts, retrieval results, history, session id | +| storage and audit | conversation history, event stream, memory store, idempotency records, approval decisions | +| Anvia runtime | model-tool loop, schemas, hooks, turn limits, tool calls, streaming events, observer events | +| model provider | completion behavior and provider usage accounting | + +Keep the ownership split boring. If a decision affects product correctness or security, it belongs in app code or a tool. If it affects how the model is prompted, constrained, or observed, configure it on the agent or prompt request. + +## Recommended Modules + +```txt +src/ + ai/ + model.ts # provider clients and reusable models + support-agent.ts # stable agent factory or shared built agent + support-tools.ts # request-scoped tool factories + support-runner.ts # harness runner for one product workflow + services/ + orders.ts # product services and permission-aware data access + tickets.ts + storage/ + conversations.ts # history, sessions, events, and audit records + routes/ + support.ts # thin transport boundary +``` + +This layout is a convention, not a requirement. The important part is the direction of dependencies: routes call runners, runners create scoped tools and agents, tools call services, and services own product data. + +## Request Lifecycle + +| Step | Harness responsibility | Anvia responsibility | +| --- | --- | --- | +| validate input | parse payload, reject missing fields, normalize product ids | none | +| resolve app context | authenticate, load user, tenant, feature flags, services, transaction | none | +| load state | read history, session state, idempotency record, relevant product records | read memory only if configured | +| create runtime | build or select agent, create request-scoped tools, add context | hold agent configuration | +| run prompt | attach trace metadata, set turn limit, call `.send()` or `.stream()` | run model-tool loop | +| handle effects | tools enforce permissions, services run transactions and audit writes | call tools and return tool results to model | +| persist result | append messages, event logs, summaries, metrics, user-visible output | return response messages and usage | +| map response | return HTTP body, job result, UI stream, or domain object | none | + +## Minimal Harness + +```ts +import { AgentBuilder, Message } from "@anvia/core"; +import { model } from "./model"; +import { createSupportTools } from "./support-tools"; + +export async function runSupportHarness(input: SupportHarnessInput) { + const user = await input.auth.requireUser(); + const history = await input.conversations.loadMessages(input.conversationId); + + const agent = new AgentBuilder("support", model) + .name("Support Agent") + .instructions("Answer support questions clearly. Use tools for account data.") + .tools( + createSupportTools({ + userId: user.id, + tenantId: user.tenantId, + orders: input.services.orders, + tickets: input.services.tickets, + }), + ) + .context(`Current customer plan: ${user.plan}`, "customer-plan") + .defaultMaxTurns(3) + .build(); + + const response = await agent + .prompt([...history, Message.user(input.message)]) + .withTrace({ + name: "support-chat", + userId: user.id, + metadata: { + tenantId: user.tenantId, + conversationId: input.conversationId, + }, + }) + .send(); + + await input.conversations.append(input.conversationId, response.messages); + + return { + output: response.output, + usage: response.usage, + }; +} +``` + +The runner can be called from a route, background job, CLI script, test, or Studio setup. Keep transport details outside it unless the workflow itself is transport-specific. + +## Harness Checklist + +| Concern | Production default | +| --- | --- | +| identity | stable agent id and trace name | +| input | validate before constructing the prompt | +| permissions | enforce inside tools and services, not in prompt text | +| context | attach only scoped, relevant facts | +| side effects | use idempotency keys or transactions in service code | +| persistence | save `response.messages` and any app-owned audit records | +| observability | use stable traces and safe metadata | +| failures | map known errors to product responses at the runner boundary | + +Start with this shape before adding more agents, nested agents, or pipelines. Most harness problems are easier to fix when the single-agent boundary is explicit. diff --git a/apps/docs/content/docs/best-practices/common-patterns/meta.json b/apps/docs/content/docs/best-practices/common-patterns/meta.json new file mode 100644 index 00000000..d03b27cb --- /dev/null +++ b/apps/docs/content/docs/best-practices/common-patterns/meta.json @@ -0,0 +1,15 @@ +{ + "title": "Common Patterns", + "defaultOpen": true, + "collapsible": true, + "pages": [ + "harness-blueprint", + "agent-structure", + "request-runners", + "tools-and-services", + "context-and-memory", + "pipeline", + "production-guardrails", + "testing-and-observability" + ] +} diff --git a/apps/docs/content/docs/best-practices/common-patterns/pipeline.mdx b/apps/docs/content/docs/best-practices/common-patterns/pipeline.mdx new file mode 100644 index 00000000..4f8b4951 --- /dev/null +++ b/apps/docs/content/docs/best-practices/common-patterns/pipeline.mdx @@ -0,0 +1,104 @@ +--- +title: Pipeline +description: Choose one agent, a runner, nested agents, or a pipeline based on the job shape. +--- + +Start with one runner around one agent. Add orchestration only when the job has a real boundary: a deterministic step, a specialist capability, a parallel branch, a typed extraction stage, batching, or a separately testable process. + +Many production workflows do not need a pipeline. They need a clear harness runner. + +## One Agent in a Runner + +Use one agent when one promptable runtime can own the task and a small set of tools is enough. + +```ts +const agent = new AgentBuilder("support", model) + .instructions("Answer support questions and use tools when account data is needed.") + .tools(createSupportTools(scope)) + .defaultMaxTurns(3) + .build(); + +const response = await agent.prompt(message).send(); +``` + +This is the simplest shape to trace, test, and run in Studio. Prefer it until the workflow has a boundary that should be named and tested separately. + +## Runner Before Pipeline + +Use a runner when the workflow is mostly application wiring: auth, input validation, history, scoped tools, context, trace metadata, persistence, and error mapping. + +```ts +export async function runSupportTurn(input: SupportRunnerInput) { + const user = await input.auth.requireUser(); + const history = await input.conversations.loadMessages(input.conversationId); + const agent = createSupportAgent(model, { user, services: input.services }); + + const response = await agent + .prompt([...history, Message.user(input.message)]) + .withTrace({ name: "support-chat", userId: user.id }) + .send(); + + await input.conversations.append(input.conversationId, response.messages); + return response.output; +} +``` + +Do not add a pipeline just to hide application setup. If the steps are not reusable or independently testable, the runner is the right abstraction. + +## Agent as a Tool + +Use an agent tool when a subdomain has stable instructions and should be callable by another agent. + +```ts +const refundsAgent = new AgentBuilder("refunds", model) + .description("Answers refund-policy questions.") + .instructions("Answer only refund-related questions.") + .build(); + +const triageAgent = new AgentBuilder("triage", model) + .instructions("Route refund questions to the refund specialist.") + .tool( + refundsAgent.asTool({ + name: "ask_refunds_agent", + maxTurns: 2, + }), + ) + .defaultMaxTurns(3) + .build(); +``` + +Keep nested agents narrow. A broad agent calling another broad agent usually makes traces harder to understand and quality harder to evaluate. + +## Pipeline + +Use a pipeline when the job has explicit typed stages that should be tested, reused, or observed independently. + +```ts +const pipeline = new PipelineBuilder() + .step((input) => input.trim()) + .prompt(triageAgent) + .extract(ticketExtractor) + .step((ticket) => ({ + ...ticket, + needsEscalation: ticket.priority === "high", + })) + .build(); +``` + +Pipelines are a good fit for preprocessing, enrichment, extraction, branching, batching, and post-processing. They are a poor fit for hiding route logic or permission policy. + +## Decision Table + +| Use | When | +| --- | --- | +| one agent | one conversational runtime can own the task | +| runner | the complexity is app context, persistence, tracing, or error mapping | +| agent tool | another agent owns a narrow specialist boundary | +| pipeline | the job has explicit typed stages or deterministic steps | +| direct service call | the step does not need a model | +| extractor | model output must match a schema for downstream code | +| parallel branches | independent model or service work can run at the same time | + +## Practical Rule + +Reach for the smallest named boundary that matches the job. A runner names a product workflow. An agent names model behavior. A tool names product capability. A pipeline names typed process stages. diff --git a/apps/docs/content/docs/best-practices/common-patterns/production-guardrails.mdx b/apps/docs/content/docs/best-practices/common-patterns/production-guardrails.mdx new file mode 100644 index 00000000..0d373442 --- /dev/null +++ b/apps/docs/content/docs/best-practices/common-patterns/production-guardrails.mdx @@ -0,0 +1,168 @@ +--- +title: Production Guardrails +description: Put limits, approvals, retries, timeouts, and idempotency at the boundary they protect. +--- + +Production guardrails should live at the boundary they protect. Agents can own default runtime limits and hooks. Tools should own side-effect safety. The application should own retries, timeouts, idempotency records, persistence, audit logs, and response shape. + +Do not try to solve product safety with prompt text alone. Prompt instructions help the model choose behavior, but product code must enforce the boundary. + +## Turn Limits + +Keep tool-call loops bounded. Set a conservative default on the agent and override it per request only when the workflow needs more room. + +```ts +const agent = new AgentBuilder("support", model) + .instructions("Use tools only when account data is required.") + .tools(supportTools) + .defaultMaxTurns(3) + .build(); + +const response = await agent.prompt(message).maxTurns(2).send(); +``` + +Low limits make failures faster and traces easier to inspect. If a workflow often needs a high turn limit, check whether it should be split into deterministic steps, a pipeline, or a narrower tool. + +## Permission Checks + +Use tool or service code for normal permission checks. The model should never be the authority for whether a user can read data or perform a side effect. + +```ts +async execute({ orderId }) { + await orders.requireAccess({ + userId: scope.userId, + tenantId: scope.tenantId, + orderId, + }); + + return orders.find(orderId); +} +``` + +Permission failures can either return a typed state when the model can continue, or throw when the product request should fail. + +## Hooks and Approvals + +Use hooks when the run should decide whether to run, skip, or cancel a tool call. Use application code for reviewer identity, storage, notification, timeout, and audit policy. + +```ts +import { createHook } from "@anvia/core"; + +const approvalHook = createHook({ + async onToolCall({ toolName, tool }) { + if (toolName !== "issue_refund") { + return tool.run(); + } + + const approved = await approvals.waitForDecision({ + toolName, + reason: "Refunds require reviewer approval.", + }); + + return approved ? tool.run() : tool.cancel("Refund was not approved."); + }, +}); + +const agent = new AgentBuilder("support", model) + .instructions("Use refund tools only when policy allows it.") + .tools(refundTools) + .hook(approvalHook) + .build(); +``` + +Studio is useful for local approval iteration. Production approval storage, notifications, reviewer identity, and audit logs stay in your application. + +## Timeouts and Cancellation + +Use hooks to cancel unsafe prompt runs. Use app-level timeout wrappers when the caller needs bounded latency. + +```ts +import { createHook } from "@anvia/core"; + +const policyHook = createHook({ + async onCompletionCall({ prompt, run }) { + if (containsSensitiveExportRequest(prompt)) { + return run.cancel("Sensitive exports are not allowed in this workflow."); + } + + return run.continue(); + }, +}); +``` + +```ts +const result = await withTimeout(runSupportTurn(input), 30_000); +``` + +Use cancellation for policy decisions and user-denied workflows. Use timeouts for caller latency. Use retries for transient infrastructure failures only when the operation is safe to retry. + +## Idempotent Tools + +For side effects, pass an application-generated operation id into the service layer and let the service deduplicate. + +```ts +async execute({ orderId, amount }) { + return billing.issueRefund({ + userId: scope.userId, + tenantId: scope.tenantId, + orderId, + amount, + operationId: `refund:${scope.tenantId}:${orderId}:${amount}`, + }); +} +``` + +The model should not invent the idempotency boundary. Your application should. + +## Retries + +Retry at the runner or job boundary, not inside individual tools by default. + +```ts +export async function runSupportJob(job: SupportJob) { + return retryTransient( + () => runSupportTurn(job.input), + { + attempts: 2, + retryIf: (error) => isProviderTimeout(error) || isTemporaryStorageError(error), + }, + ); +} +``` + +Do not retry non-idempotent write tools unless the service method has an idempotency key, transaction boundary, or durable operation record. + +## Audit Records + +Audit product decisions in application storage. Traces help debug agent behavior, but they should not be the only record of a sensitive product operation. + +```ts +await audit.write({ + actorId: scope.userId, + tenantId: scope.tenantId, + action: "refund.issued", + targetId: orderId, + operationId, +}); +``` + +Use traces for runtime inspection. Use audit records for product accountability. + +## Decision Table + +| Risk | Guardrail | +| --- | --- | +| endless tool loop | `.defaultMaxTurns(...)` and request `.maxTurns(...)` | +| restricted read | permission check in tool or service code | +| restricted write | permission check, hook, approval runtime, audit record | +| human approval required | application-owned approval runtime called from a hook | +| caller needs bounded latency | app-level timeout around the runner | +| duplicate side effect | idempotency key or transaction in the service layer | +| provider or transport failure | retry at the application boundary when safe | +| sensitive operation | product audit record plus trace metadata | + +## Related Patterns + +- Use [Side Effect Tools](/docs/best-practices/tool-patterns/side-effect-tools) for writes, approvals, idempotency, and audit records. +- Use [Backoffice Agent](/docs/best-practices/real-cases/backoffice-agent) for admin workflows with approval-heavy operations. +- Use [Production Readiness Checklist](/docs/best-practices/operations/production-readiness-checklist) before deploying a harness. diff --git a/apps/docs/content/docs/best-practices/common-patterns/request-runners.mdx b/apps/docs/content/docs/best-practices/common-patterns/request-runners.mdx new file mode 100644 index 00000000..a8fe2f06 --- /dev/null +++ b/apps/docs/content/docs/best-practices/common-patterns/request-runners.mdx @@ -0,0 +1,184 @@ +--- +title: Request Runners +description: Wrap one product request in a testable agent harness function. +--- + +A request runner is the function your route, job, queue worker, or test calls. It owns the product workflow around one agent run. Keep it small enough to test directly and explicit enough that auth, context, tools, persistence, and error mapping are visible. + +## Runner Responsibilities + +| Responsibility | Why it belongs in the runner | +| --- | --- | +| validate input | reject bad product requests before model calls | +| resolve app context | auth, tenant, feature flags, service handles, and transactions are app-owned | +| load state | conversation history, memory ids, idempotency records, and product records are storage-owned | +| create scoped runtime | request-scoped tools and context should not leak into globals | +| call the agent | the runner controls trace metadata, limits, and streaming vs final response | +| persist output | `response.messages`, events, audit records, and summaries are product state | +| map failures | routes and jobs need predictable product errors | + +## Standard Runner Shape + +```ts +import { Message, PromptCancelledError } from "@anvia/core"; +import { model } from "./model"; +import { createSupportAgent } from "./support-agent"; + +type SupportRunnerInput = { + conversationId: string; + message: string; + auth: AuthService; + conversations: ConversationStore; + services: { + orders: OrdersService; + tickets: TicketsService; + }; +}; + +export async function runSupportTurn(input: SupportRunnerInput) { + const message = input.message.trim(); + + if (message.length === 0) { + return { ok: false as const, error: "message_required" }; + } + + const user = await input.auth.requireUser(); + const history = await input.conversations.loadMessages(input.conversationId); + + const agent = createSupportAgent(model, { + userId: user.id, + tenantId: user.tenantId, + plan: user.plan, + services: input.services, + }); + + try { + const response = await agent + .prompt([...history, Message.user(message)]) + .withTrace({ + name: "support-chat", + userId: user.id, + metadata: { + tenantId: user.tenantId, + conversationId: input.conversationId, + }, + }) + .send(); + + await input.conversations.append(input.conversationId, response.messages); + + return { + ok: true as const, + output: response.output, + usage: response.usage, + }; + } catch (error) { + if (error instanceof PromptCancelledError) { + return { ok: false as const, error: "cancelled" }; + } + + throw error; + } +} +``` + +The route can stay thin: + +```ts +export async function POST(request: Request) { + const body = await request.json(); + + const result = await runSupportTurn({ + conversationId: body.conversationId, + message: body.message, + auth, + conversations, + services: { orders, tickets }, + }); + + if (!result.ok) { + return Response.json({ error: result.error }, { status: 400 }); + } + + return Response.json({ output: result.output }); +} +``` + +## Load Context Before the Prompt + +Do not ask the model to discover product context that the application can resolve deterministically. Load the known values first and attach only the facts the agent needs. + +```ts +const account = await accounts.findForUser(user.id); +const openTickets = await tickets.listOpen({ userId: user.id, limit: 5 }); + +const agent = createSupportAgent(model, { + userId: user.id, + tenantId: user.tenantId, + plan: account.plan, + services: input.services, +}); + +const prompt = [ + ...history, + Message.user(` +Current account plan: ${account.plan} +Open ticket count: ${openTickets.length} + +User message: +${message} + `), +]; + +const response = await agent.prompt(prompt).send(); +``` + +Use tools when the model needs to decide whether to fetch or change state. Use preloaded context when the application already knows the fact is required. + +## Timeouts and Retries + +Anvia prompt requests do not replace product-level timeout or retry policy. Put bounded-latency behavior around the runner. + +```ts +export async function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timeout: ReturnType | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error("support_agent_timeout")); + }, timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } +} + +const result = await withTimeout(runSupportTurn(input), 30_000); +``` + +Retry only when the whole operation is safe to retry. If tools can create side effects, the service layer should use idempotency keys or transactions before the runner retries the prompt. + +## Testing Boundary + +A runner is easy to test because every product dependency is passed in. + +```ts +it("rejects empty messages before calling the agent", async () => { + const result = await runSupportTurn({ + conversationId: "conv_123", + message: " ", + auth: fakeAuth(), + conversations: fakeConversations(), + services: fakeServices(), + }); + + expect(result).toEqual({ ok: false, error: "message_required" }); +}); +``` + +Test validation, auth failures, storage calls, known cancellations, and persistence without a provider call. Use provider-backed tests only for the model behavior the workflow depends on. diff --git a/apps/docs/content/docs/best-practices/common-patterns/testing-and-observability.mdx b/apps/docs/content/docs/best-practices/common-patterns/testing-and-observability.mdx new file mode 100644 index 00000000..af5f4c8f --- /dev/null +++ b/apps/docs/content/docs/best-practices/common-patterns/testing-and-observability.mdx @@ -0,0 +1,160 @@ +--- +title: Testing and Observability +description: Test deterministic harness boundaries before inspecting model behavior. +--- + +Most correctness should be tested before a provider call is made. Tools, service wrappers, retrieval filters, pipeline steps, prompt wrappers, runners, and error mapping can all be checked with fakes. + +Use provider-backed tests, Studio, traces, and evals after the application boundaries are covered. + +## Test Tools Directly + +Tools have schemas and executable handlers. Call them directly or through a `ToolSet` to verify validation, permissions, expected states, and output shape. + +```ts +const tools = createSupportToolSet({ + userId: "user_123", + tenantId: "tenant_123", + orders: fakeOrders, + tickets: fakeTickets, +}); + +const result = await tools.call( + "lookup_order", + JSON.stringify({ orderId: "A-100" }), +); + +expect(JSON.parse(result)).toEqual({ + status: "found", + orderId: "A-100", + fulfillmentStatus: "shipped", +}); +``` + +This keeps product policy testable without asking a model to choose the right branch. + +## Test Runners With Fakes + +Runner tests should verify application behavior: validation, auth, history loading, scoped tool creation, trace metadata, persistence, and known errors. + +```ts +it("persists new messages after a successful support turn", async () => { + const conversations = fakeConversations({ + history: [Message.user("Earlier question")], + }); + + const result = await runSupportTurn({ + conversationId: "conv_123", + message: "Where is order A-100?", + auth: fakeAuth({ userId: "user_123", tenantId: "tenant_123" }), + conversations, + services: fakeServices(), + }); + + expect(result.ok).toBe(true); + expect(conversations.append).toHaveBeenCalledWith( + "conv_123", + expect.any(Array), + ); +}); +``` + +If the runner currently creates the real agent internally, extract a factory dependency for tests or keep provider-backed tests narrow. Do not make every route test depend on a live model. + +## Test Retrieval Filters + +Retrieval bugs are often permission bugs. Test the filter or index handle before testing answer quality. + +```ts +const results = await tenantKnowledge.search("refund policy", { + tenantId: "tenant_123", + topK: 5, +}); + +expect(results.every((result) => result.metadata.tenantId === "tenant_123")).toBe(true); +``` + +Then use Studio, traces, or evals to inspect whether the retrieved context produces the answer you expect. + +## Observe Runs + +Attach observers when you need traces, usage records, external reporting, or debugging evidence. + +```ts +const agent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly.") + .observe(observer) + .build(); +``` + +Use stable trace names such as `support-chat`, `ticket-summary`, or `retrieval-answer`. Include application identifiers in trace metadata when they are safe to store. + +```ts +const response = await agent + .prompt(message) + .withTrace({ + name: "support-chat", + userId, + metadata: { + tenantId, + conversationId, + channel: "web", + }, + }) + .send(); +``` + +Trace metadata should help connect an agent run to application state without leaking secrets or large records. + +## Use Studio During Iteration + +Use Studio to inspect messages, tool calls, approvals, sessions, traces, and quick prompts while the workflow is still changing. + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "./ai/support-agent"; + +new Studio([supportAgent]).start({ port: 3000 }); +``` + +For request-scoped agents, create a safe development scope with fake or sandbox services and register that built agent in Studio. Do not wire Studio directly to production credentials just to test prompt behavior. + +## Use Evals for Repeatable Behavior + +Use evals when the workflow has known prompts, regression cases, or output expectations that should be checked repeatedly. + +Keep eval targets narrow: + +| Eval target | Good use | +| --- | --- | +| tool output | permission and state contracts | +| runner output | product response shape and known error mapping | +| agent output | answer quality for stable prompts | +| extractor output | schema adherence and downstream contract | +| pipeline output | stage composition and typed workflow behavior | + +Evals should complement unit tests, not replace them. Unit tests prove app-owned boundaries. Evals watch model-dependent behavior. + +## Harness Test Matrix + +| Area | Test without provider | Inspect with provider | +| --- | --- | --- | +| input validation | empty, malformed, unsupported payloads | not needed | +| auth and permissions | fake users, tenants, denied reads and writes | trace denied tool calls | +| tools | direct `ToolSet.call(...)` with fake services | observe model tool choice | +| context assembly | snapshot prompt messages or scoped facts | inspect retrieved documents in traces | +| history and persistence | fake conversation store calls | verify multi-turn behavior | +| guardrails | hook cancellation, approval decisions, idempotency | inspect approval and cancellation traces | +| final output | runner response shape | eval answer quality | + +## Practical Rule + +Test product policy with fakes and direct calls. Use provider tests, Studio, traces, and evals to inspect model behavior after the application-owned boundaries are already covered. + +## Related Patterns + +- Use [Tool Validation and Contracts](/docs/best-practices/tool-patterns/tool-validation-and-contracts) for schema and direct-call tests. +- Use [MCP Tool Inspection](/docs/best-practices/mcp-patterns/mcp-tool-inspection) for server capability checks. +- Use [Eval Strategy](/docs/best-practices/quality-observability/eval-strategy) for repeatable model-dependent checks. +- Use [Tracing and Debugging](/docs/best-practices/quality-observability/tracing-and-debugging) to connect traces, logs, and eval outcomes. +- Use [Production Readiness Checklist](/docs/best-practices/operations/production-readiness-checklist) for deployment validation. diff --git a/apps/docs/content/docs/best-practices/common-patterns/tools-and-services.mdx b/apps/docs/content/docs/best-practices/common-patterns/tools-and-services.mdx new file mode 100644 index 00000000..a9e1abff --- /dev/null +++ b/apps/docs/content/docs/best-practices/common-patterns/tools-and-services.mdx @@ -0,0 +1,180 @@ +--- +title: Tools and Services +description: Wrap application services as typed tools without moving product policy into the model. +--- + +Tools are the boundary between model decisions and product behavior. Keep tool names, descriptions, schemas, and result shapes model-friendly. Keep permissions, database access, side effects, transactions, and audit policy in application code. + +A good tool is a narrow adapter over a product capability. It should not be a second service layer, and it should not trust the model to enforce product policy. + +## Tool Factory Pattern + +Use factories when a tool needs the current user, tenant, database transaction, feature flags, or service client. + +```ts +import { createTool } from "@anvia/core"; +import { z } from "zod"; + +type OrderToolScope = { + userId: string; + tenantId: string; + orders: OrdersService; +}; + +export function createOrderTools(scope: OrderToolScope) { + const lookupOrder = createTool({ + name: "lookup_order", + description: "Look up one order for the current customer.", + input: z.object({ + orderId: z.string().min(1), + }), + output: z.object({ + status: z.enum(["found", "not_found"]), + orderId: z.string(), + fulfillmentStatus: z.string().optional(), + }), + async execute({ orderId }) { + await scope.orders.requireAccess({ + userId: scope.userId, + tenantId: scope.tenantId, + orderId, + }); + + const order = await scope.orders.find(orderId); + + if (!order) { + return { status: "not_found" as const, orderId }; + } + + return { + status: "found" as const, + orderId, + fulfillmentStatus: order.fulfillmentStatus, + }; + }, + }); + + return [lookupOrder]; +} +``` + +The model sees a small action. Your application still owns access checks, tenant scoping, service calls, and product states. + +## Compose Tool Groups + +Create groups by domain, then combine them in the scoped agent factory or runner. + +```ts +import { ToolSet } from "@anvia/core/tool"; + +export function createSupportToolSet(scope: SupportToolScope) { + return ToolSet.fromTools([ + ...createOrderTools(scope), + ...createTicketTools(scope), + ...createPolicyTools(scope), + ]); +} +``` + +Use `.tools([...])` for a request-scoped list. Use `.useToolSet(...)` when you need a shared mutable catalog that can be inspected or updated at runtime. + +```ts +const agent = new AgentBuilder("support", model) + .instructions("Use tools when account-specific data is required.") + .useToolSet(createSupportToolSet(scope)) + .defaultMaxTurns(3) + .build(); +``` + +## Return Expected States + +Return structured expected states when the model can continue productively. Throw only when the workflow should fail, be retried, or be handled by the application error boundary. + +```ts +async execute({ ticketId }) { + const ticket = await tickets.findForUser(scope.userId, ticketId); + + if (!ticket) { + return { status: "not_found" as const, ticketId }; + } + + if (ticket.locked) { + return { + status: "blocked" as const, + reason: "ticket_locked", + ticketId, + }; + } + + return { + status: "ready" as const, + ticketId, + subject: ticket.subject, + priority: ticket.priority, + }; +} +``` + +Expected states make the model's next step easier to inspect. Unexpected errors should still throw so the runner can log, retry, or return a stable product error. + +## Keep Side Effects Behind Services + +For write tools, the tool validates model input and calls a product service. The service owns permissions, transactions, idempotency, and audit records. + +```ts +export function createRefundTools(scope: RefundToolScope) { + return [ + createTool({ + name: "issue_refund", + description: "Issue an approved refund for a paid order.", + input: z.object({ + orderId: z.string(), + amount: z.number().positive(), + reason: z.string().min(1), + }), + async execute({ orderId, amount, reason }) { + return scope.billing.issueRefund({ + userId: scope.userId, + tenantId: scope.tenantId, + orderId, + amount, + reason, + operationId: `refund:${scope.tenantId}:${orderId}:${amount}`, + }); + }, + }), + ]; +} +``` + +The model can request the operation. It should not invent the permission check, transaction boundary, or idempotency key policy. + +## Tool Contract Checklist + +| Concern | Good default | +| --- | --- | +| name | verb-noun, stable, specific, such as `lookup_order` | +| description | tell the model when to use it, not how the service works internally | +| input schema | require product ids, enums, and bounded strings where possible | +| output schema | return compact states the model can reason about | +| permissions | enforce in service or tool code before reading or writing data | +| side effects | route through service methods with idempotency and audit behavior | +| errors | return expected states, throw unexpected failures | +| tests | call tools directly or through `ToolSet.call(...)` with fake services | + +## Decision Table + +| Situation | Pattern | +| --- | --- | +| tool needs current user or tenant | create tools inside a scoped factory | +| tool reads product data | filter and authorize in the service call | +| product state is recoverable | return `not_found`, `blocked`, `ready`, or another typed state | +| product operation failed unexpectedly | throw and let the runner boundary handle it | +| write operation can be retried | require an idempotency key or transaction in the service layer | +| many tools need reuse or direct tests | group them with `ToolSet` | + +## Related Patterns + +- Use [Dynamic Tool Catalogs](/docs/best-practices/tool-patterns/dynamic-tool-catalogs) when the catalog is too large to send every turn. +- Use [Tool Validation and Contracts](/docs/best-practices/tool-patterns/tool-validation-and-contracts) when tool output drives downstream code. +- Use [Side Effect Tools](/docs/best-practices/tool-patterns/side-effect-tools) when a tool writes data or calls an external write API. diff --git a/apps/docs/content/docs/best-practices/index.mdx b/apps/docs/content/docs/best-practices/index.mdx new file mode 100644 index 00000000..ffbe25b2 --- /dev/null +++ b/apps/docs/content/docs/best-practices/index.mdx @@ -0,0 +1,140 @@ +--- +title: Best Practices +description: A production pattern library for Anvia agent harnesses. +--- + +Use these patterns when an agent is moving from a demo into product code. The goal is to keep model behavior explicit while your application keeps ownership of users, permissions, data, side effects, storage, deployment, and audit trails. + +This section is organized as a pattern library. Start with the common harness shape, then jump to the real case that matches the system you are building. + +## Pattern Map + +| Need | Pattern | +| --- | --- | +| Understand the standard agent harness boundary | [Harness Blueprint](/docs/best-practices/common-patterns/harness-blueprint) | +| Keep stable agent config separate from request state | [Agent Structure](/docs/best-practices/common-patterns/agent-structure) | +| Wrap one route, job, or queue request | [Request Runners](/docs/best-practices/common-patterns/request-runners) | +| Wrap product services as safe model-callable actions | [Tools and Services](/docs/best-practices/common-patterns/tools-and-services) | +| Assemble instructions, facts, retrieval, history, and sessions | [Context and Memory](/docs/best-practices/common-patterns/context-and-memory) | +| Choose one agent, nested agents, or a typed pipeline | [Pipeline](/docs/best-practices/common-patterns/pipeline) | +| Add limits, approvals, idempotency, retries, and audit records | [Production Guardrails](/docs/best-practices/common-patterns/production-guardrails) | +| Test deterministic boundaries and inspect model behavior | [Testing and Observability](/docs/best-practices/common-patterns/testing-and-observability) | + +## Tool Patterns + +| Real problem | Pattern | +| --- | --- | +| The agent has dozens or hundreds of tools | [Dynamic Tool Catalogs](/docs/best-practices/tool-patterns/dynamic-tool-catalogs) | +| Tool arguments, outputs, and product states need contracts | [Tool Validation and Contracts](/docs/best-practices/tool-patterns/tool-validation-and-contracts) | +| A tool writes data, sends messages, or changes external state | [Side Effect Tools](/docs/best-practices/tool-patterns/side-effect-tools) | + +## Long Process Pipelines + +| Need | Pattern | +| --- | --- | +| Run Anvia pipelines outside the request path with BullMQ and Redis | [Overview](/docs/best-practices/long-process-pipelines) | +| Enqueue validated pipeline work from an API boundary | [Enqueue Pipeline Jobs](/docs/best-practices/long-process-pipelines/enqueue-pipeline-jobs) | +| Run pipeline jobs from a separate BullMQ worker | [Run Pipeline Workers](/docs/best-practices/long-process-pipelines/run-pipeline-workers) | +| Persist status, handle retries, and test long-running jobs | [Status, Retries, and Testing](/docs/best-practices/long-process-pipelines/status-retries-testing) | + +## Multi-agent Patterns + +| Need | Pattern | +| --- | --- | +| Stream coordinator output and nested specialist progress | [Overview](/docs/best-practices/multi-agent-patterns) | +| Build specialist agents and expose them as streaming tools | [Build Streaming Specialists](/docs/best-practices/multi-agent-patterns/build-streaming-specialists) | +| Group parent and child stream events in a UI | [Consume Nested Events](/docs/best-practices/multi-agent-patterns/consume-nested-events) | +| Persist, replay, and test multi-agent streams | [Persistence and Testing](/docs/best-practices/multi-agent-patterns/persistence-and-testing) | + +## MCP Patterns + +| Real problem | Pattern | +| --- | --- | +| You need to connect and reconnect external MCP servers | [MCP Server Lifecycle](/docs/best-practices/mcp-patterns/mcp-server-lifecycle) | +| You need to inspect, validate, filter, or wrap MCP tools | [MCP Tool Inspection](/docs/best-practices/mcp-patterns/mcp-tool-inspection) | +| You need an agent harness that combines app tools and MCP tools | [MCP Agent Harness](/docs/best-practices/mcp-patterns/mcp-agent-harness) | + +## Knowledge Patterns + +| Real problem | Pattern | +| --- | --- | +| You need to build or refresh a retrieval index safely | [RAG Ingestion](/docs/best-practices/knowledge-patterns/rag-ingestion) | +| You need to decide how retrieval enters an agent run | [RAG Agent Context](/docs/best-practices/knowledge-patterns/rag-agent-context) | + +## Real Cases + +| System | Pattern | +| --- | --- | +| Customer support chat with account tools, retrieval, and history | [Support Agent](/docs/best-practices/real-cases/support-agent) | +| Backoffice workflow with writes, approvals, audit, and idempotency | [Backoffice Agent](/docs/best-practices/real-cases/backoffice-agent) | +| Research workflow with many read-only tools and extraction | [Research Agent](/docs/best-practices/real-cases/research-agent) | +| Coding assistant over a codebase with file, command, patch, and git boundaries | [Coding Agent](/docs/best-practices/real-cases/coding-agent) | + +## Quality and Observability + +| Need | Pattern | +| --- | --- | +| Build regression checks for prompts, tools, and workflows | [Eval Strategy](/docs/best-practices/quality-observability/eval-strategy) | +| Connect traces, tool calls, retrieval evidence, logs, and eval scores | [Tracing and Debugging](/docs/best-practices/quality-observability/tracing-and-debugging) | + +## Operations + +| Need | Pattern | +| --- | --- | +| Check whether a harness is ready for production | [Production Readiness Checklist](/docs/best-practices/operations/production-readiness-checklist) | + +## Baseline Shape + +Most production agents follow the same boundaries: + +- stable provider clients, models, reusable agents, static tool catalogs, observers, and default limits are created at startup +- request-local user, tenant, conversation, permission, feature flag, MCP availability, and caller timeout policy are resolved at the application boundary +- tools wrap product services and enforce permissions before reading or changing data +- context is assembled from instructions, static facts, request facts, retrieval, history, and memory +- persistence, retries, idempotency, audit logs, and product response shapes stay in application code + +```ts +import { AgentBuilder, Message } from "@anvia/core"; +import { model } from "./ai/model"; +import { createSupportTools } from "./ai/support-tools"; + +export async function runSupportTurn(input: SupportTurnInput) { + const user = await input.auth.requireUser(); + const conversation = await input.conversations.load(input.conversationId); + + const agent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly. Use tools for account data.") + .tools( + createSupportTools({ + userId: user.id, + tenantId: user.tenantId, + orders: input.services.orders, + tickets: input.services.tickets, + }), + ) + .context(`Current plan: ${user.plan}`, "current-plan") + .defaultMaxTurns(3) + .build(); + + const response = await agent + .prompt([...conversation.history, Message.user(input.message)]) + .withTrace({ + name: "support-chat", + userId: user.id, + metadata: { + tenantId: user.tenantId, + conversationId: input.conversationId, + }, + }) + .send(); + + await input.conversations.append(input.conversationId, response.messages); + + return { + output: response.output, + messages: response.messages, + }; +} +``` + +The exact route, worker, queue, or UI framework can vary. The ownership boundary should not: Anvia owns the model-tool loop, and your application owns product state and side effects. diff --git a/apps/docs/content/docs/best-practices/knowledge-patterns/meta.json b/apps/docs/content/docs/best-practices/knowledge-patterns/meta.json new file mode 100644 index 00000000..dc379ffb --- /dev/null +++ b/apps/docs/content/docs/best-practices/knowledge-patterns/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Knowledge Patterns", + "defaultOpen": false, + "collapsible": true, + "pages": ["rag-ingestion", "rag-agent-context"] +} diff --git a/apps/docs/content/docs/best-practices/knowledge-patterns/rag-agent-context.mdx b/apps/docs/content/docs/best-practices/knowledge-patterns/rag-agent-context.mdx new file mode 100644 index 00000000..4894d2ba --- /dev/null +++ b/apps/docs/content/docs/best-practices/knowledge-patterns/rag-agent-context.mdx @@ -0,0 +1,140 @@ +--- +title: RAG Agent Context +description: Choose how retrieval enters an agent run and how to test the retrieved evidence. +--- + +RAG context is the runtime side of retrieval. The harness decides whether retrieval should happen automatically with `.dynamicContext(...)`, through a search tool the model can call, or through preloaded context from application code. + +Retrieval is input, not permission. Filter documents before they can enter the model request. + +## Scenario + +A support agent needs policy docs on every answer, while a research agent should decide when to search. Both use the same indexed knowledge, but retrieval enters the run differently. + +## When to Use It + +Use this pattern when: + +- the agent needs facts that are too large or change too often for static context +- answers should include source-backed evidence +- retrieved documents need formatting, thresholds, or metadata filters +- you need evals for retrieval quality and answer quality separately + +## Architecture Shape + +| Pattern | Use when | Tradeoff | +| --- | --- | --- | +| `.dynamicContext(...)` | every prompt should receive relevant knowledge | automatic, but search happens every run | +| search tool | model should decide whether and when to search | more flexible, but uses a tool turn | +| runner-preloaded context | app already knows required facts | deterministic, but less adaptive | +| `.dynamicTools(...)` | the retrieved object is a tool definition | solves capability selection, not knowledge | + +## Automatic Dynamic Context + +```ts +const agent = new AgentBuilder("support", model) + .instructions("Use retrieved support docs when answering policy questions.") + .dynamicContext(supportDocsIndex, { + topK: 4, + threshold: 0.72, + format: (result) => ({ + id: result.id, + text: [ + `Source: ${result.document.title}`, + `Product: ${result.metadata?.product ?? "unknown"}`, + result.document.body, + ].join("\n"), + }), + }) + .defaultMaxTurns(3) + .build(); +``` + +Use `format(...)` to control exactly what the model sees. Include source ids or titles when answers should be traceable. + +## Search Tool + +Use a tool when retrieval should be optional or iterative. + +```ts +const searchSupportDocs = supportDocsIndex.asTool({ + name: "search_support_docs", + description: "Search support documentation by query.", + topK: 5, + threshold: 0.7, +}); + +const agent = new AgentBuilder("support-research", model) + .instructions("Search docs when the answer depends on current support policy.") + .tool(searchSupportDocs) + .defaultMaxTurns(4) + .build(); +``` + +## Tenant and Access Filtering + +Filter before retrieval results become model input. Use separate indexes when strict isolation is simpler than filtering. + +```ts +const tenantDocsIndex = docsStore.indexForTenant(user.tenantId); + +const agent = new AgentBuilder("tenant-support", model) + .dynamicContext(tenantDocsIndex, { + topK: 4, + threshold: 0.75, + }) + .build(); +``` + +If a shared index is used, make the index handle enforce metadata filters so another tenant's document cannot be returned. + +## Tuning TopK and Threshold + +| Symptom | Adjustment | +| --- | --- | +| answers miss obvious docs | lower `threshold`, raise `topK`, improve titles/chunks | +| irrelevant docs enter prompts | raise `threshold`, lower `topK`, improve chunking | +| prompts are too large | lower `topK` or shorten formatted text | +| model ignores citations | include source ids in text and instruction | +| retrieval is slow | use a production vector store and avoid request-time ingestion | + +## Test Queries + +Test retrieval separately from answer generation. + +```ts +const matches = await supportDocsIndex.searchIds({ + query: "How long does a password reset link last?", + topK: 3, + threshold: 0.72, +}); + +expect(matches.map((match) => match.id)).toContain("password-reset-policy"); +``` + +Then test the full agent answer with evals. + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| source is stale | fix ingestion refresh policy | +| answer lacks evidence | include source ids in formatted context and traces | +| tenant leak | use tenant-specific index handles or enforced metadata filters | +| model overuses search tool | use `.dynamicContext(...)` for always-needed knowledge | +| retrieval hides missing data | return no context and make the model state uncertainty | + +## Test Checklist + +- Search representative queries and assert expected document ids. +- Test threshold behavior for weak matches. +- Test tenant and visibility filters. +- Inspect traces or Studio knowledge view for retrieved documents. +- Add evals that fail when required facts are missing. + +## Related Docs + +- [RAG Ingestion](/docs/best-practices/knowledge-patterns/rag-ingestion) +- [RAG Context](/docs/guides/retrieval/rag-context) +- [Eval Strategy](/docs/best-practices/quality-observability/eval-strategy) + diff --git a/apps/docs/content/docs/best-practices/knowledge-patterns/rag-ingestion.mdx b/apps/docs/content/docs/best-practices/knowledge-patterns/rag-ingestion.mdx new file mode 100644 index 00000000..1b454072 --- /dev/null +++ b/apps/docs/content/docs/best-practices/knowledge-patterns/rag-ingestion.mdx @@ -0,0 +1,138 @@ +--- +title: RAG Ingestion +description: Build retrieval indexes deliberately before agent runs need them. +--- + +RAG quality starts before the agent runs. Ingestion is the application-owned workflow that loads source material, normalizes it, chooses ids and metadata, embeds it, writes it to a vector store, and decides when indexes are refreshed. + +Do not rebuild a retrieval index inside a hot prompt path. Build it during deploy, startup, admin ingestion, or background work. + +## Scenario + +A support agent answers product questions from Markdown docs, PDFs, release notes, and internal policy pages. The application needs a repeatable ingestion job so every agent run sees fresh, filtered, traceable context. + +## When to Use It + +Use this pattern when: + +- knowledge is larger than static `.context(...)` +- documents change outside the request path +- documents need metadata filters such as tenant, product, visibility, version, or locale +- retrieval evidence should be debugged in traces and evals +- multiple agents share the same knowledge source + +## Architecture Shape + +| Layer | Responsibility | +| --- | --- | +| loader | read files, directories, PDFs, bytes, or application records | +| normalizer | clean text, split sections, remove boilerplate, attach source ids | +| metadata | tenant, product, visibility, version, locale, source path, updated time | +| embedding job | call `embedDocuments(...)` with bounded concurrency | +| vector store | store embedded documents and expose an index | +| refresh policy | rebuild or incrementally update at an explicit boundary | + +## Code Example + +```ts +import { InMemoryVectorStore } from "@anvia/core/vector-store"; +import { embedDocuments } from "@anvia/core/embeddings"; +import { FileLoader, fileLoaderToDocuments } from "@anvia/core/loaders"; +import { embeddings } from "./models"; + +const loaded = await fileLoaderToDocuments( + FileLoader.withGlob("content/support/**/*.md") + .readWithPath() + .ignoreErrors(), +); + +const documents = loaded.map((doc) => ({ + id: doc.id, + title: doc.metadata.path, + body: doc.text, + product: "support", + visibility: "public", +})); + +const embedded = await embedDocuments(embeddings, documents, { + id: (doc) => doc.id, + content: (doc) => `${doc.title}\n${doc.body}`, + metadata: (doc) => ({ + product: doc.product, + visibility: doc.visibility, + title: doc.title, + }), + concurrency: 2, +}); + +export const supportDocsIndex = InMemoryVectorStore.fromDocuments(embedded).index(embeddings); +``` + +For production storage, build the same embedded document shape and write it to the vector store your application owns. + +## Document Ids and Metadata + +Stable ids make refreshes and debugging easier. + +```ts +const document = { + id: `support:${locale}:${slug}:${sectionId}`, + title, + body, + locale, + product, + visibility, + sourceUrl, + updatedAt, +}; +``` + +Metadata should support the filters and trace evidence you need later. Do not rely on prompt instructions to prevent a tenant or visibility leak. + +## Chunking Ownership + +Anvia loaders convert source files into documents. Your application owns semantic chunking policy. + +| Source | Chunk default | +| --- | --- | +| Markdown docs | one document per page or heading section | +| PDFs | one document per page, then merge or split if needed | +| tickets or records | one document per record or summary | +| code files | one document per file or symbol-level section | +| long policies | one document per rule group with source id | + +## Refresh Strategy + +| Strategy | Use when | +| --- | --- | +| deploy-time rebuild | docs change with code deploys | +| startup build | small indexes and local development | +| background ingestion | docs change frequently or source data is remote | +| tenant-specific index | strict tenant isolation is required | +| metadata-filtered shared index | source store supports reliable filtering | + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| stale answers | define refresh triggers and store `updatedAt` metadata | +| weak retrieval | improve chunking, titles, content selector, and source text | +| tenant leakage | separate indexes or enforce metadata filters before search | +| slow ingestion | bound `concurrency` and move ingestion off request paths | +| hard-to-debug context | include stable ids, source paths, titles, and versions | + +## Test Checklist + +- Test loaders against representative files and PDFs. +- Snapshot normalized documents before embedding. +- Verify ids are stable across repeated ingestion. +- Verify metadata includes tenant, visibility, product, and source fields where needed. +- Run search probes and assert expected document ids appear. +- Add eval cases for known questions that depend on retrieved facts. + +## Related Docs + +- [Embed Documents](/docs/guides/retrieval/embed-documents) +- [Loaders](/docs/guides/retrieval/loaders) +- [RAG Agent Context](/docs/best-practices/knowledge-patterns/rag-agent-context) + diff --git a/apps/docs/content/docs/best-practices/long-process-pipelines/enqueue-pipeline-jobs.mdx b/apps/docs/content/docs/best-practices/long-process-pipelines/enqueue-pipeline-jobs.mdx new file mode 100644 index 00000000..e716e731 --- /dev/null +++ b/apps/docs/content/docs/best-practices/long-process-pipelines/enqueue-pipeline-jobs.mdx @@ -0,0 +1,115 @@ +--- +title: Enqueue Pipeline Jobs +description: Validate API input and add stable BullMQ jobs for later pipeline execution. +--- + +The enqueue boundary is product code. It should authorize the caller, validate input, create a durable pending record, and then call `queue.add(...)` with a stable job id. + +## Job Data + +```ts +import { z } from "zod"; + +export const ticketInputSchema = z.object({ + tenantId: z.string(), + ticketId: z.string(), + summary: z.string().min(1), +}); + +export type TicketJobData = z.infer; +``` + +Keep job data scoped and serializable. Do not enqueue request objects, database clients, provider clients, or unbounded conversation history. + +## Queue Setup + +```ts +import { Queue, type JobsOptions } from "bullmq"; +import IORedis from "ioredis"; +import type { TicketJobData } from "./schema"; + +export const queueName = "ticket-triage"; + +const producerConnection = new IORedis(process.env.REDIS_URL); + +export const triageQueue = new Queue(queueName, { + connection: producerConnection, +}); +``` + +Use separate producer and worker connections when API and worker processes have different runtime constraints. + +## Job Options + +```ts +export const jobOptions: JobsOptions = { + attempts: 3, + backoff: { + type: "exponential", + delay: 5_000, + }, + removeOnComplete: { + age: 60 * 60 * 24, + count: 1_000, + }, + removeOnFail: { + age: 60 * 60 * 24 * 7, + }, +}; +``` + +Retries are useful for transient provider, network, and database failures. They are dangerous when the worker performs non-idempotent side effects. + +## Enqueue Function + +```ts +import { ticketInputSchema } from "./schema"; +import { jobOptions, triageQueue } from "./queue"; +import { ticketJobs } from "./storage"; + +export async function enqueueTicketTriage(input: unknown) { + const data = ticketInputSchema.parse(input); + const jobId = `ticket-triage:${data.tenantId}:${data.ticketId}`; + + await ticketJobs.createPending({ + jobId, + tenantId: data.tenantId, + ticketId: data.ticketId, + }); + + const job = await triageQueue.add("triage", data, { + ...jobOptions, + jobId, + }); + + return { + jobId: job.id, + status: "queued", + }; +} +``` + +The stable `jobId` prevents duplicate queue jobs for the same product operation. Keep a matching idempotency constraint in app storage because the database is the source of product truth. + +## API Boundary + +```ts +import { enqueueTicketTriage } from "./enqueue"; + +export async function POST(request: Request) { + const body = await request.json(); + const result = await enqueueTicketTriage(body); + + return Response.json(result, { + status: 202, + }); +} +``` + +Do authentication, tenant scoping, quotas, and validation before `queue.add(...)`. The worker should still trust only scoped job data, but the API is the first product boundary. + +## Related Docs + +- [Request Runners](/docs/best-practices/common-patterns/request-runners) +- [Tool Validation and Contracts](/docs/best-practices/tool-patterns/tool-validation-and-contracts) +- [Run Pipeline Workers](/docs/best-practices/long-process-pipelines/run-pipeline-workers) diff --git a/apps/docs/content/docs/best-practices/long-process-pipelines/index.mdx b/apps/docs/content/docs/best-practices/long-process-pipelines/index.mdx new file mode 100644 index 00000000..c5dc88e2 --- /dev/null +++ b/apps/docs/content/docs/best-practices/long-process-pipelines/index.mdx @@ -0,0 +1,69 @@ +--- +title: Long Process Pipelines +description: Run Anvia pipelines outside the request path with a durable queue. +--- + +Use a long process pipeline when the work should not finish inside one HTTP request. The request validates input and creates a job. A worker process runs the Anvia pipeline, persists the result, and reports progress through app-owned status records. + +## What is BullMQ? + +[BullMQ](https://docs.bullmq.io/) is a Node.js queue library built on Redis. A [`Queue`](https://docs.bullmq.io/guide/queues) adds jobs, a [`Worker`](https://docs.bullmq.io/guide/workers) processes jobs, and [`QueueEvents`](https://docs.bullmq.io/guide/events) can observe queue lifecycle events. BullMQ uses Redis [connections](https://docs.bullmq.io/guide/connections) so API producers and worker processes can run separately and scale independently. + +Install BullMQ in the application that owns the queue and worker: + +```sh +pnpm add bullmq ioredis +``` + +The docs app does not need these dependencies unless it is also running a queue worker. + +## When to Use It + +Use this pattern when: + +- a pipeline can run longer than the caller should wait +- work should survive API process restarts +- jobs need retry, backoff, delayed execution, or worker concurrency +- pipeline results should be persisted for later polling or review +- API and worker processes scale independently + +## Architecture Shape + +| Layer | Responsibility | +| --- | --- | +| API route | authorize caller, validate input, create app record, enqueue job | +| BullMQ queue | store pending jobs, retry policy, backoff, job lifecycle | +| worker process | consume jobs, run the Anvia pipeline, update progress | +| pipeline | own typed stages, agents, extractors, and deterministic transforms | +| app storage | durable status, result, errors, audit metadata | +| observability | connect queue job ids, pipeline stage events, traces, and logs | + +## Baseline Flow + + B["Validate and authorize"] + B --> C["Create pending job record"] + C --> D["BullMQ Queue.add"] + D --> E["BullMQ Worker"] + E --> F["pipeline.run"] + F --> G["Persist result or error"] + G --> H["Status endpoint or review UI"] +`} +/> + +Keep the API response small. Return a job id and store user-facing status in your own database. BullMQ return values are useful for worker internals and queue inspection, but product status pages should read durable app records. + +## Pages + +- [Enqueue Pipeline Jobs](/docs/best-practices/long-process-pipelines/enqueue-pipeline-jobs) shows the API producer boundary. +- [Run Pipeline Workers](/docs/best-practices/long-process-pipelines/run-pipeline-workers) shows the worker and `pipeline.run(...)` boundary. +- [Status, Retries, and Testing](/docs/best-practices/long-process-pipelines/status-retries-testing) covers production behavior and tests. + +## Related Docs + +- [Pipeline](/docs/best-practices/common-patterns/pipeline) +- [Request Runners](/docs/best-practices/common-patterns/request-runners) +- [Production Guardrails](/docs/best-practices/common-patterns/production-guardrails) +- [Testing and Observability](/docs/best-practices/common-patterns/testing-and-observability) diff --git a/apps/docs/content/docs/best-practices/long-process-pipelines/meta.json b/apps/docs/content/docs/best-practices/long-process-pipelines/meta.json new file mode 100644 index 00000000..95ef938d --- /dev/null +++ b/apps/docs/content/docs/best-practices/long-process-pipelines/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Long Process Pipelines", + "defaultOpen": false, + "collapsible": true, + "pages": ["index", "enqueue-pipeline-jobs", "run-pipeline-workers", "status-retries-testing"] +} diff --git a/apps/docs/content/docs/best-practices/long-process-pipelines/run-pipeline-workers.mdx b/apps/docs/content/docs/best-practices/long-process-pipelines/run-pipeline-workers.mdx new file mode 100644 index 00000000..88f15ef4 --- /dev/null +++ b/apps/docs/content/docs/best-practices/long-process-pipelines/run-pipeline-workers.mdx @@ -0,0 +1,119 @@ +--- +title: Run Pipeline Workers +description: Consume BullMQ jobs and execute Anvia pipelines in a separate worker process. +--- + +The worker owns execution. It should mark jobs running, call `pipeline.run(...)`, update progress from pipeline stage events, persist the final result, and let BullMQ retry failures that should be retried. + +## Build the Pipeline + +```ts +import { PipelineBuilder } from "@anvia/core/pipeline"; +import { ticketAgent, ticketExtractor } from "./ai"; +import type { TicketJobData } from "./schema"; + +export const ticketPipeline = new PipelineBuilder({ + id: "ticket-triage-pipeline", + name: "Ticket triage pipeline", +}) + .step((input) => ({ + ...input, + summary: input.summary.trim(), + })) + .prompt(ticketAgent, { + name: "Draft triage", + }) + .extract(ticketExtractor, { + name: "Extract ticket result", + }) + .build(); +``` + +Build provider clients, agents, extractors, and reusable pipelines at worker startup. Keep request-local user and tenant data inside the job payload. + +## Worker Connection + +```ts +import IORedis from "ioredis"; + +export const workerConnection = new IORedis(process.env.REDIS_URL, { + maxRetriesPerRequest: null, +}); +``` + +BullMQ workers need a Redis connection suitable for blocking commands. Keep this separate from short-lived HTTP request behavior. + +## Worker Handler + +```ts +import type { PipelineRunEvent } from "@anvia/core/pipeline"; +import { Worker } from "bullmq"; +import { queueName } from "./queue"; +import { ticketPipeline } from "./pipeline"; +import type { TicketJobData } from "./schema"; +import { ticketJobs } from "./storage"; +import { workerConnection } from "./worker-connection"; + +export const triageWorker = new Worker( + queueName, + async (job) => { + await ticketJobs.markRunning(job.id!, { + startedAt: new Date(), + attempt: job.attemptsMade + 1, + }); + + const output = await ticketPipeline.run(job.data, { + observer: { + async onEvent(event: PipelineRunEvent) { + await job.updateProgress({ + type: event.type, + stage: event.node.label, + }); + }, + }, + }); + + await ticketJobs.markCompleted(job.id!, { + completedAt: new Date(), + output, + }); + + return { + ticketId: job.data.ticketId, + status: "completed", + }; + }, + { + connection: workerConnection, + concurrency: 4, + }, +); +``` + +Use the pipeline observer for metadata-only progress. Do not put sensitive model output, retrieved documents, or tool results into queue progress if those values should only live in app storage or traces. + +## Worker Events + +```ts +triageWorker.on("failed", async (job, error) => { + if (!job) return; + + await ticketJobs.markFailed(job.id!, { + failedAt: new Date(), + message: error.message, + attempt: job.attemptsMade, + }); +}); + +triageWorker.on("error", (error) => { + console.error("ticket triage worker error", error); +}); +``` + +The `failed` event is job-level state. The `error` event is worker-level runtime state. Handle both so job status and worker health are visible. + +## Related Docs + +- [Pipeline](/docs/best-practices/common-patterns/pipeline) +- [Tracing and Debugging](/docs/best-practices/quality-observability/tracing-and-debugging) +- [Inspect Pipelines](/docs/studio/pipelines/inspect-pipelines) diff --git a/apps/docs/content/docs/best-practices/long-process-pipelines/status-retries-testing.mdx b/apps/docs/content/docs/best-practices/long-process-pipelines/status-retries-testing.mdx new file mode 100644 index 00000000..84cbdcf2 --- /dev/null +++ b/apps/docs/content/docs/best-practices/long-process-pipelines/status-retries-testing.mdx @@ -0,0 +1,84 @@ +--- +title: Status, Retries, and Testing +description: Persist product status and test long-running BullMQ pipeline jobs. +--- + +BullMQ owns queue mechanics. Your application owns product status, permissions, idempotency, audit records, and user-facing results. + +## Durable Status + +```ts +export type TicketJobRecord = { + jobId: string; + tenantId: string; + ticketId: string; + status: "queued" | "running" | "completed" | "failed"; + output?: unknown; + errorMessage?: string; + updatedAt: Date; +}; +``` + +Store status in app storage before enqueueing. Update the same record from the worker. Use BullMQ job state for operations and debugging, not as the only user-facing product record. + +## Queue Events + +```ts +import { QueueEvents } from "bullmq"; +import { queueName } from "./queue"; +import { workerConnection } from "./worker-connection"; + +export const triageQueueEvents = new QueueEvents(queueName, { + connection: workerConnection, +}); + +triageQueueEvents.on("completed", ({ jobId }) => { + console.info("ticket triage job completed", { jobId }); +}); + +triageQueueEvents.on("failed", ({ jobId, failedReason }) => { + console.warn("ticket triage job failed", { jobId, failedReason }); +}); +``` + +Queue events are useful for operational logs and metrics. Product state should still be written by the API and worker paths that understand tenant, ticket, and result data. + +## Retry Policy + +| Case | Suggested behavior | +| --- | --- | +| provider timeout | retry with backoff | +| temporary Redis or database error | retry with backoff | +| invalid job data | fail without re-enqueueing | +| permission or tenant mismatch | fail and alert | +| non-idempotent side effect | protect with operation id before retrying | + +If the pipeline writes data, sends messages, or calls external write APIs, use service-layer idempotency keys. A retried worker must not create duplicate side effects. + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| duplicate jobs for one ticket | stable `jobId` plus app-level idempotency | +| Redis outage blocks API too long | tune producer connection retry behavior and return a retryable API error | +| worker retries repeat side effects | keep side effects idempotent and persist operation ids | +| user cannot see progress after completion cleanup | store status and result in app storage | +| worker process hides emitted errors | attach a worker `error` listener | +| pipeline stage is hard to debug | pass a pipeline observer and include job id in logs and traces | + +## Test Checklist + +- Test input validation before enqueueing. +- Test stable job ids for duplicate enqueue attempts. +- Test pending records are created before `queue.add(...)`. +- Test worker success writes completed status and output. +- Test worker failure writes failed status and leaves retry behavior to BullMQ. +- Test idempotent side effects when BullMQ retries the same job. +- Test progress updates from pipeline stage events. +- Run the pipeline directly in unit tests before testing BullMQ wiring. + +## Related Docs + +- [Production Guardrails](/docs/best-practices/common-patterns/production-guardrails) +- [Testing and Observability](/docs/best-practices/common-patterns/testing-and-observability) +- [Side Effect Tools](/docs/best-practices/tool-patterns/side-effect-tools) diff --git a/apps/docs/content/docs/best-practices/mcp-patterns/mcp-agent-harness.mdx b/apps/docs/content/docs/best-practices/mcp-patterns/mcp-agent-harness.mdx new file mode 100644 index 00000000..85a39747 --- /dev/null +++ b/apps/docs/content/docs/best-practices/mcp-patterns/mcp-agent-harness.mdx @@ -0,0 +1,151 @@ +--- +title: MCP Agent Harness +description: Combine local tools, MCP tools, registry state, and error handling in one runner. +--- + +An MCP agent harness is a normal request runner with one extra dependency: connected MCP servers. Keep MCP connection ownership outside prompt logic, then pass validated servers or filtered tools into the scoped agent. + +## Scenario + +A support agent uses local account tools plus a remote docs MCP server. If the docs server is unavailable, the agent can still answer from account tools and say it cannot search docs right now. + +## When to Use It + +Use this pattern when an agent combines application-owned tools with MCP tools, or when MCP availability changes independently from the rest of the app. + +## Architecture Shape + +| Layer | Responsibility | +| --- | --- | +| registry | owns connected MCP servers and health state | +| startup | connects required servers and optional servers | +| runner | selects servers or filtered MCP tools for this request | +| scoped agent | registers local tools plus MCP tools | +| tools | enforce local permissions and side-effect policy | +| Studio | inspects registered MCP metadata during development | + +## Code Example + +```ts +import { AgentBuilder } from "@anvia/core"; +import type { McpServer } from "@anvia/core/mcp"; + +type McpRegistry = { + get(name: string): McpServer | undefined; +}; + +export function createSupportAgent(input: { + docsServer?: McpServer; + scope: SupportToolScope; +}) { + const builder = new AgentBuilder("support", model) + .instructions(` +Answer support questions clearly. +Use account tools for customer-specific data. +Use docs tools when policy or product documentation is needed. + `) + .tools(createSupportTools(input.scope)) + .defaultMaxTurns(4); + + if (input.docsServer) { + builder.mcp([input.docsServer]); + } + + return builder.build(); +} +``` + +The runner chooses the MCP capability for the current request. + +```ts +export async function runSupportWithMcp(input: SupportRunnerInput) { + const user = await input.auth.requireUser(); + const history = await input.conversations.loadMessages(input.conversationId); + const docsServer = input.mcp.get("docs"); + + const agent = createSupportAgent({ + docsServer, + scope: { + userId: user.id, + tenantId: user.tenantId, + orders: input.services.orders, + tickets: input.services.tickets, + }, + }); + + const response = await agent + .prompt([...history, Message.user(input.message)]) + .withTrace({ + name: "support-chat", + userId: user.id, + metadata: { + tenantId: user.tenantId, + docsMcpAvailable: docsServer !== undefined, + }, + }) + .send(); + + await input.conversations.append(input.conversationId, response.messages); + + return response.output; +} +``` + +## Filter MCP Tools in the Harness + +If the docs server exposes more tools than support should use, filter before building the agent. + +```ts +const docsTools = docsServer?.tools.filter((tool) => + ["search_docs", "read_doc"].includes(tool.name), +); + +const agent = new AgentBuilder("support", model) + .tools(createSupportTools(scope)) + .tools(docsTools ?? []) + .defaultMaxTurns(4) + .build(); +``` + +## Error Handling + +MCP call errors are tool errors during an agent run. Keep the agent bounded and make server health visible in trace metadata. + +```ts +const response = await agent + .prompt(message) + .withTrace({ + name: "support-chat", + metadata: { + docsMcpAvailable: docsServer !== undefined, + }, + }) + .maxTurns(3) + .send(); +``` + +Use registry health checks and reconnects outside the prompt run. The model should not be responsible for repairing infrastructure. + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| optional MCP unavailable | build agent without it and include trace metadata | +| wrong MCP tools exposed | filter or wrap tools before registration | +| remote tool loops after errors | keep max turns low | +| credentials are request-scoped | connect in `try/finally` for that request | +| Studio does not show expected MCP tools | verify `.mcp([server])` or filtered tools are registered on the built agent | + +## Test Checklist + +- Test runner behavior with MCP available and unavailable. +- Test filtered MCP tool list includes only allowed names. +- Test required server validation during startup. +- Test traces include MCP availability metadata. +- Use Studio MCP inspection for local verification. + +## Related Docs + +- [MCP Server Lifecycle](/docs/best-practices/mcp-patterns/mcp-server-lifecycle) +- [MCP Tool Inspection](/docs/best-practices/mcp-patterns/mcp-tool-inspection) +- [Support Agent](/docs/best-practices/real-cases/support-agent) diff --git a/apps/docs/content/docs/best-practices/mcp-patterns/mcp-server-lifecycle.mdx b/apps/docs/content/docs/best-practices/mcp-patterns/mcp-server-lifecycle.mdx new file mode 100644 index 00000000..4954ce23 --- /dev/null +++ b/apps/docs/content/docs/best-practices/mcp-patterns/mcp-server-lifecycle.mdx @@ -0,0 +1,149 @@ +--- +title: MCP Server Lifecycle +description: Connect, close, and reconnect MCP servers at the right application boundary. +--- + +MCP servers expose external tools to an agent. Treat each MCP connection as infrastructure: decide whether it is required, when it connects, how it is closed, and what happens when it fails. + +## Scenario + +An agent needs tools from a filesystem MCP server, an internal docs MCP server, and a remote CRM MCP server. Some are required for startup. Others are optional and should degrade gracefully. + +## When to Use It + +Use this pattern whenever an agent registers tools with `.mcp(...)` or wraps MCP tools with local Anvia tools. + +## Architecture Shape + +| Boundary | Pattern | +| --- | --- | +| required startup dependency | connect once during application boot and fail fast on error | +| optional dependency | attempt connection, log failure, run agent without that server | +| request-scoped server | connect inside `try/finally` and always close | +| long-lived process | close all servers during shutdown | +| reconnect | close previous server, connect next server, rebuild agents or update tool sets | + +## Connect at Startup + +```ts +import { connectMcp, mcp, type McpConnection, type McpServer } from "@anvia/core/mcp"; + +export async function connectMcpServers() { + const filesystem = await connectMcp( + mcp.stdio({ + name: "filesystem", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + }), + ); + + const docs = await connectMcp( + mcp.http({ + name: "docs", + url: "https://mcp.example.com/mcp", + }), + ); + + return { filesystem, docs }; +} +``` + +Register connected servers on an agent. + +```ts +const { filesystem, docs } = await connectMcpServers(); + +const agent = new AgentBuilder("research", model) + .instructions("Use MCP tools when external context is needed.") + .mcp([filesystem, docs]) + .defaultMaxTurns(4) + .build(); +``` + +## Optional Servers + +Optional servers should not block the whole application. + +```ts +async function connectOptional(connection: McpConnection) { + try { + return await connectMcp(connection); + } catch (error) { + logger.warn({ error, server: connection.name }, "optional MCP server unavailable"); + return undefined; + } +} + +const docs = await connectOptional( + mcp.http({ name: "docs", url: "https://mcp.example.com/mcp" }), +); + +const mcpServers = [docs].filter((server): server is McpServer => server !== undefined); +``` + +## Request-Scoped Connections + +Use request scope only when the connection depends on request-local credentials or a short-lived resource. + +```ts +const server = await connectMcp(connection); + +try { + const agent = new AgentBuilder("tenant-docs", model) + .mcp([server]) + .defaultMaxTurns(3) + .build(); + + return await agent.prompt(message).send(); +} finally { + await server.close(); +} +``` + +Most application servers should prefer startup connections over request-scoped connections. + +## Registry and Reconnect + +```ts +type McpRegistry = { + get(name: string): McpServer | undefined; + set(server: McpServer): void; + closeAll(): Promise; +}; + +async function reconnect(connection: McpConnection, registry: McpRegistry) { + const previous = registry.get(connection.name); + await previous?.close(); + + const next = await connectMcp(connection); + registry.set(next); + + return next; +} +``` + +After reconnecting, create new agents or update the shared tool catalog used by future runs. A built agent keeps the tools it was built with unless it uses a shared mutable `ToolSet`. + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| app starts without required MCP tools | fail fast during startup | +| optional MCP outage breaks all agents | omit optional server and log degraded capability | +| server process leaks | close on shutdown or in `finally` | +| reconnected tools not visible | rebuild agents or update the shared tool set | +| tool call loops on remote errors | keep turn limits low and expose clear tool error text | + +## Test Checklist + +- Test required server connection failure at startup. +- Test optional server failure produces a reduced agent capability set. +- Test request-scoped connections close in `finally`. +- Test reconnect closes the previous server before replacing it. +- Use Studio or `/agents/:agentId/mcps` to inspect registered MCP servers. + +## Related Docs + +- [MCP Connections](/docs/guides/mcp/connections) +- [Errors and Reconnects](/docs/guides/mcp/errors-and-reconnects) +- [MCP Agent Harness](/docs/best-practices/mcp-patterns/mcp-agent-harness) diff --git a/apps/docs/content/docs/best-practices/mcp-patterns/mcp-tool-inspection.mdx b/apps/docs/content/docs/best-practices/mcp-patterns/mcp-tool-inspection.mdx new file mode 100644 index 00000000..a7d25f94 --- /dev/null +++ b/apps/docs/content/docs/best-practices/mcp-patterns/mcp-tool-inspection.mdx @@ -0,0 +1,148 @@ +--- +title: MCP Tool Inspection +description: Check, filter, validate, and wrap MCP tools before exposing them to an agent. +--- + +MCP tools are adapted automatically, but a production harness should still inspect what the server exposed. Tool names, descriptions, input schemas, and permission expectations decide whether a tool is safe to show to a model. + +## Scenario + +A remote MCP server exposes 40 tools. Your support agent should use only read-only documentation tools, while an internal admin agent can use a smaller set of write tools behind approval. + +## When to Use It + +Use this pattern when: + +- a server exposes more tools than one agent should see +- tool names are unclear or conflict with local tools +- a tool needs product permissions or audit records +- you need to verify required tools exist before startup succeeds + +## Architecture Shape + +| Step | Purpose | +| --- | --- | +| connect server | get adapted Anvia tools | +| inspect definitions | verify name, description, and schema | +| validate required tools | fail fast for missing required capabilities | +| filter safe tools | expose only the tools this agent should use | +| wrap tools | rename, audit, permission-check, or normalize output when needed | + +## Inspect Definitions + +```ts +import { connectMcp, mcp, type McpServer } from "@anvia/core/mcp"; + +const docsServer = await connectMcp( + mcp.http({ + name: "docs", + url: "https://mcp.example.com/mcp", + }), +); + +for (const tool of docsServer.tools) { + const definition = await tool.definition(""); + logger.info({ + server: docsServer.name, + tool: definition.name, + description: definition.description, + parameters: definition.parameters, + }); +} +``` + +## Validate Required Tools + +```ts +async function requireMcpTools(server: McpServer, names: string[]) { + const available = new Set(server.tools.map((tool) => tool.name)); + const missing = names.filter((name) => !available.has(name)); + + if (missing.length > 0) { + throw new Error(`MCP server ${server.name} is missing tools: ${missing.join(", ")}`); + } +} + +await requireMcpTools(docsServer, ["search_docs", "read_doc"]); +``` + +## Filter Tools + +```ts +function pickMcpTools(server: McpServer, allowedNames: string[]) { + const allowed = new Set(allowedNames); + return server.tools.filter((tool) => allowed.has(tool.name)); +} + +const safeDocsTools = pickMcpTools(docsServer, ["search_docs", "read_doc"]); + +const agent = new AgentBuilder("support", model) + .instructions("Use docs tools for public support documentation.") + .tools(safeDocsTools) + .defaultMaxTurns(3) + .build(); +``` + +Use `.mcp([server])` when the agent should receive every tool from that server. Use `.tools(filteredTools)` when the agent should receive only selected tools. + +## Wrap Tools + +Wrap an MCP tool when you need product-specific names, permissions, audit records, or normalized output. + +```ts +import { createTool } from "@anvia/core"; +import { z } from "zod"; + +const searchDocs = docsServer.tools.find((tool) => tool.name === "search_docs"); + +if (!searchDocs) { + throw new Error("docs MCP server is missing search_docs"); +} + +const searchInternalDocs = createTool({ + name: "search_internal_docs", + description: "Search internal support documentation for the current tenant.", + input: z.object({ + query: z.string().min(1), + }), + async execute({ query }) { + await permissions.require(scope.userId, "docs:read"); + + const result = await searchDocs.call({ + query: `${query}\ntenant:${scope.tenantId}`, + }); + + await audit.write({ + actorId: scope.userId, + action: "docs.search", + tenantId: scope.tenantId, + }); + + return result; + }, +}); +``` + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| server exposes unsafe tools | filter or wrap before registering | +| tool names conflict | wrap with product-specific names | +| schema is too loose | wrap with a stricter Zod schema | +| server changes tool names | validate required tools at startup | +| remote tool returns noisy output | wrap and normalize result shape | + +## Test Checklist + +- Assert required tools exist after connection. +- Snapshot provider-facing tool definitions for critical MCP tools. +- Test wrapped tools with fake permission and audit services. +- Verify agents receive only allowed MCP tools. +- Inspect MCP metadata in Studio when available. + +## Related Docs + +- [MCP Tool Adapters](/docs/guides/mcp/tool-adapters) +- [MCP Result Handling](/docs/guides/mcp/result-handling) +- [Tool Validation and Contracts](/docs/best-practices/tool-patterns/tool-validation-and-contracts) diff --git a/apps/docs/content/docs/best-practices/mcp-patterns/meta.json b/apps/docs/content/docs/best-practices/mcp-patterns/meta.json new file mode 100644 index 00000000..b869ef67 --- /dev/null +++ b/apps/docs/content/docs/best-practices/mcp-patterns/meta.json @@ -0,0 +1,6 @@ +{ + "title": "MCP Patterns", + "defaultOpen": false, + "collapsible": true, + "pages": ["mcp-server-lifecycle", "mcp-tool-inspection", "mcp-agent-harness"] +} diff --git a/apps/docs/content/docs/best-practices/meta.json b/apps/docs/content/docs/best-practices/meta.json new file mode 100644 index 00000000..d7803061 --- /dev/null +++ b/apps/docs/content/docs/best-practices/meta.json @@ -0,0 +1,18 @@ +{ + "title": "Best Practices", + "description": "Agent pattern library", + "icon": "BookCheck", + "root": true, + "pages": [ + "index", + "common-patterns", + "long-process-pipelines", + "multi-agent-patterns", + "tool-patterns", + "mcp-patterns", + "knowledge-patterns", + "real-cases", + "quality-observability", + "operations" + ] +} diff --git a/apps/docs/content/docs/best-practices/multi-agent-patterns/build-streaming-specialists.mdx b/apps/docs/content/docs/best-practices/multi-agent-patterns/build-streaming-specialists.mdx new file mode 100644 index 00000000..32cc0926 --- /dev/null +++ b/apps/docs/content/docs/best-practices/multi-agent-patterns/build-streaming-specialists.mdx @@ -0,0 +1,93 @@ +--- +title: Build Streaming Specialists +description: Create specialist agents and expose them as streaming tools on a coordinator. +--- + +Build multi-agent streaming around one coordinator and a small set of narrow specialists. Each specialist should have a clear role, stable agent id, and focused output. + +## Build Specialist Agents + +```ts +import { AgentBuilder } from "@anvia/core"; +import { model } from "./model"; + +export const supportAgent = new AgentBuilder("support", model) + .name("Support Specialist") + .description("Summarize customer impact and support next steps.") + .instructions("Return compact support triage bullets using only the provided facts.") + .build(); + +export const engineeringAgent = new AgentBuilder("engineering", model) + .name("Engineering Specialist") + .description("Summarize diagnostics and engineering next steps.") + .instructions("Return compact engineering triage bullets without unverified root-cause claims.") + .build(); + +export const commsAgent = new AgentBuilder("communications", model) + .name("Communications Specialist") + .description("Draft customer-facing incident communication guidance.") + .instructions("Return concise customer-safe communication notes.") + .build(); +``` + +Keep specialist instructions narrow. Each child agent should own one role and return a focused result that the coordinator can use. + +## Expose Specialists With Streaming + +```ts +import { AgentBuilder } from "@anvia/core"; +import { commsAgent, engineeringAgent, supportAgent } from "./specialists"; +import { model } from "./model"; + +export const coordinator = new AgentBuilder("incident-coordinator", model) + .name("Incident Coordinator") + .instructions( + [ + "Coordinate specialist agents through tools.", + "Call specialists only when their expertise is useful.", + "Combine specialist findings into one concise incident brief.", + "Do not expose internal-only notes in the final customer-facing summary.", + ].join("\n"), + ) + .tools([ + supportAgent.asTool({ name: "ask_support_agent", stream: true }), + engineeringAgent.asTool({ name: "ask_engineering_agent", stream: true }), + commsAgent.asTool({ name: "ask_comms_agent", stream: true }), + ]) + .defaultMaxTurns(4) + .build(); +``` + +The coordinator should decide which specialists to call. Do not attach every possible specialist if the model cannot choose reliably. + +## Run the Coordinator Stream + +```ts +const prompt = [ + "Acme Co. reports webhook retries fail for payloads larger than 512 KB.", + "They have missed several order updates in the last hour.", + "Prepare an incident brief for support, engineering, and communications.", +].join(" "); + +for await (const event of coordinator + .prompt(prompt) + .withToolConcurrency(3) + .withTrace({ + name: "incident-multi-agent-stream", + metadata: { + incidentId: "inc_123", + tenantId: "tenant_123", + }, + }) + .stream()) { + renderEvent(event); +} +``` + +Use `.withToolConcurrency(...)` only when specialist calls are independent. If one specialist must wait for another specialist's finding, keep concurrency at `1` or split the workflow into explicit stages. + +## Related Docs + +- [Multi-Agent Workflows](/docs/guides/agents/multi-agent-workflows) +- [Agent Structure](/docs/best-practices/common-patterns/agent-structure) +- [Consume Nested Events](/docs/best-practices/multi-agent-patterns/consume-nested-events) diff --git a/apps/docs/content/docs/best-practices/multi-agent-patterns/consume-nested-events.mdx b/apps/docs/content/docs/best-practices/multi-agent-patterns/consume-nested-events.mdx new file mode 100644 index 00000000..4d9c159f --- /dev/null +++ b/apps/docs/content/docs/best-practices/multi-agent-patterns/consume-nested-events.mdx @@ -0,0 +1,91 @@ +--- +title: Consume Nested Events +description: Group parent and child stream events from streaming agent tools. +--- + +When a coordinator runs with `.stream()`, normal parent events and nested child-agent events arrive in the same stream. Render coordinator text directly and group child progress by `internalCallId`. + +## Render Parent and Child Events + +```ts +import type { AgentStreamEvent } from "@anvia/core/agent"; + +function renderEvent(event: AgentStreamEvent): void { + switch (event.type) { + case "text_delta": + renderCoordinatorText(event.delta); + break; + case "tool_call": + renderDelegation(event.toolCall.function.name); + break; + case "agent_tool_event": + renderSpecialistEvent({ + groupId: event.internalCallId, + agentLabel: event.agentName ?? event.agentId, + toolName: event.toolName, + event: event.event, + }); + break; + case "final": + saveFinalResponse(event); + break; + case "error": + renderStreamError(event.error); + break; + } +} +``` + +Group nested progress by `internalCallId` when the same specialist can be called more than once. Use `agentId`, `agentName`, and `toolName` for labels. + +## Render Specialist Progress + +```ts +import type { AgentStreamEvent } from "@anvia/core/agent"; + +type SpecialistEvent = Extract; + +function renderSpecialistEvent(input: { + groupId: string; + agentLabel: string; + toolName: string; + event: SpecialistEvent["event"]; +}) { + if (input.event.type === "text_delta") { + appendSpecialistText(input.groupId, input.agentLabel, input.event.delta); + } + + if (input.event.type === "tool_call") { + showSpecialistToolCall(input.groupId, input.event.toolCall.function.name); + } + + if (input.event.type === "tool_result") { + markSpecialistToolDone(input.groupId, input.event.toolName); + } + + if (input.event.type === "final") { + markSpecialistComplete(input.groupId, input.event.output); + } +} +``` + +Do not render raw tool results, sensitive retrieved context, or internal reasoning in a user-facing UI unless the product intentionally exposes those details. Prefer status labels and safe summaries for customer-facing screens. + +## Event Mapping + +| Event | UI use | +| --- | --- | +| `tool_call` | coordinator is delegating to a specialist | +| `agent_tool_event.text_delta` | specialist is streaming visible progress | +| `agent_tool_event.tool_call` | specialist is using one of its tools | +| `agent_tool_event.tool_result` | specialist tool completed | +| `agent_tool_event.final` | specialist returned final output to the coordinator | +| `tool_result` | coordinator received the specialist's final output | +| `text_delta` | coordinator is streaming the final answer | +| `final` | persist final messages, usage, trace, and run id | + +## Related Docs + +- [Streaming Events](/docs/guides/streaming/streaming-events) +- [Readable Streams](/docs/guides/streaming/readable-streams) +- [Build Streaming Specialists](/docs/best-practices/multi-agent-patterns/build-streaming-specialists) diff --git a/apps/docs/content/docs/best-practices/multi-agent-patterns/index.mdx b/apps/docs/content/docs/best-practices/multi-agent-patterns/index.mdx new file mode 100644 index 00000000..9fc34f48 --- /dev/null +++ b/apps/docs/content/docs/best-practices/multi-agent-patterns/index.mdx @@ -0,0 +1,65 @@ +--- +title: Multi-agent Streaming +description: Stream coordinator output while surfacing nested specialist-agent progress. +--- + +Use multi-agent streaming when one coordinator agent delegates work to specialist agents and the caller should see progress while the specialists run. The coordinator still owns the final answer, but the UI can show nested child-agent activity through `agent_tool_event`. + +## Scenario + +An incident coordinator receives a customer-impact report. It delegates to support, engineering, and communications specialists. The user sees the coordinator's final brief stream in normally, while each specialist's progress appears in grouped status panels. + +## When to Use It + +Use this pattern when: + +- one coordinator should decide which specialists to call +- specialist work may take long enough that hidden progress feels stalled +- the UI should group nested progress by specialist or tool call +- the final answer should still come from one coordinator +- child-agent progress should be replayable or inspectable after the run + +Do not use this pattern just to run known independent work. Use parallel pipelines when every branch should run and the coordinator does not need to decide. + +## Architecture Shape + +| Layer | Responsibility | +| --- | --- | +| coordinator agent | decides which specialists to call and writes the final response | +| specialist agents | own narrow role instructions and return focused findings | +| streaming agent tools | expose specialists with `asTool({ stream: true })` | +| request runner | applies trace metadata, tool concurrency, and stream response shape | +| UI stream consumer | renders parent events and groups nested `agent_tool_event` values | +| event store | optionally persists parent and child events for replay and debugging | + +## Requirements + +Nested streaming requires: + +- the parent run uses `.stream()` +- specialist tools are created with `stream: true` +- the child model supports streaming + +If nested streaming is unavailable, the specialist still behaves like a normal opaque `asTool(...)` call. The parent model receives only the final specialist output as the normal `tool_result`; intermediate child events are for the caller, trace, or event store. + +## Pages + +- [Build Streaming Specialists](/docs/best-practices/multi-agent-patterns/build-streaming-specialists) shows how to build specialist agents, expose them with `asTool({ stream: true })`, and run the coordinator stream. +- [Consume Nested Events](/docs/best-practices/multi-agent-patterns/consume-nested-events) shows how to group and render parent and child events safely. +- [Persistence and Testing](/docs/best-practices/multi-agent-patterns/persistence-and-testing) covers event-store replay, failure modes, and test coverage. + +## Choose the Right Shape + +| Shape | Use it when | +| --- | --- | +| `agent.asTool(...)` | child progress can stay hidden | +| `agent.asTool({ stream: true })` | coordinator decides which specialists to call and the caller should see child progress | +| parallel pipelines | every branch should always run and return typed outputs | +| separate Studio agents | operators should run specialists manually during development | + +## Related Docs + +- [Multi-Agent Workflows](/docs/guides/agents/multi-agent-workflows) +- [Streaming Events](/docs/guides/streaming/streaming-events) +- [Readable Streams](/docs/guides/streaming/readable-streams) +- [Pipeline](/docs/best-practices/common-patterns/pipeline) diff --git a/apps/docs/content/docs/best-practices/multi-agent-patterns/meta.json b/apps/docs/content/docs/best-practices/multi-agent-patterns/meta.json new file mode 100644 index 00000000..17b5750c --- /dev/null +++ b/apps/docs/content/docs/best-practices/multi-agent-patterns/meta.json @@ -0,0 +1,11 @@ +{ + "title": "Multi-agent Patterns", + "defaultOpen": false, + "collapsible": true, + "pages": [ + "index", + "build-streaming-specialists", + "consume-nested-events", + "persistence-and-testing" + ] +} diff --git a/apps/docs/content/docs/best-practices/multi-agent-patterns/persistence-and-testing.mdx b/apps/docs/content/docs/best-practices/multi-agent-patterns/persistence-and-testing.mdx new file mode 100644 index 00000000..17ec7cc5 --- /dev/null +++ b/apps/docs/content/docs/best-practices/multi-agent-patterns/persistence-and-testing.mdx @@ -0,0 +1,55 @@ +--- +title: Persistence and Testing +description: Persist, replay, and test multi-agent streaming runs. +--- + +Multi-agent streams are useful live, but production systems often need replay, support inspection, and regression coverage. Persist events when users may refresh, support teams need a run history, or child-agent progress is part of the product record. + +## Persist Events for Replay + +Add an event store when nested progress must be replayed after the run, inspected in support tools, or attached to a product audit trail. + +```ts +import { AgentBuilder } from "@anvia/core"; +import type { AgentEventStore } from "@anvia/core/agent"; +import { supportAgent } from "./specialists"; +import { model } from "./model"; + +declare const eventStore: AgentEventStore; + +export const coordinatorWithEvents = new AgentBuilder("incident-coordinator", model) + .instructions("Delegate support triage, then produce a short final brief.") + .tool(supportAgent.asTool({ name: "ask_support_agent", stream: true })) + .eventStore(eventStore, { include: "all" }) + .defaultMaxTurns(3) + .build(); +``` + +The final stream event includes `runId`. Use that id to load saved parent and child events from the event store. + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| child progress never appears | check parent `.stream()`, `stream: true`, and child model streaming support | +| nested events are hard to group | group by `internalCallId`, then label with `agentName` or `toolName` | +| coordinator calls too many specialists | tighten instructions, lower max turns, or reduce available specialist tools | +| tool results leak sensitive data | render safe status labels and persist sensitive details only in protected storage | +| parallel calls overload services | lower `.withToolConcurrency(...)` or add service-level limits | +| progress disappears after refresh | attach an event store and replay by `runId` | + +## Test Checklist + +- Test specialist agents directly with representative prompts. +- Test coordinator tool registration includes `stream: true` for visible specialists. +- Test stream handling for `agent_tool_event`, `tool_result`, `text_delta`, `final`, and `error`. +- Test UI grouping when the same specialist is called more than once. +- Test event-store replay with saved parent and child events. +- Inspect a provider-backed run in Studio or traces before exposing nested progress to users. + +## Related Docs + +- [Event Store](/docs/guides/agents/event-store) +- [Testing and Observability](/docs/best-practices/common-patterns/testing-and-observability) +- [Tracing and Debugging](/docs/best-practices/quality-observability/tracing-and-debugging) +- [Consume Nested Events](/docs/best-practices/multi-agent-patterns/consume-nested-events) diff --git a/apps/docs/content/docs/best-practices/operations/meta.json b/apps/docs/content/docs/best-practices/operations/meta.json new file mode 100644 index 00000000..73d3d3cf --- /dev/null +++ b/apps/docs/content/docs/best-practices/operations/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Operations", + "defaultOpen": false, + "collapsible": true, + "pages": ["production-readiness-checklist"] +} diff --git a/apps/docs/content/docs/best-practices/operations/production-readiness-checklist.mdx b/apps/docs/content/docs/best-practices/operations/production-readiness-checklist.mdx new file mode 100644 index 00000000..62554da3 --- /dev/null +++ b/apps/docs/content/docs/best-practices/operations/production-readiness-checklist.mdx @@ -0,0 +1,111 @@ +--- +title: Production Readiness Checklist +description: Check an agent harness before it handles real users, data, and side effects. +--- + +Use this checklist before deploying an agent harness to production or expanding it from read-only behavior into product actions. + +## Runtime Boundaries + +| Check | Ready when | +| --- | --- | +| stable agent id | traces, sessions, Studio, and logs use a stable id | +| runner boundary | route or job calls a named runner instead of embedding all prompt logic inline | +| scoped tools | request-local user, tenant, services, and transactions are passed explicitly | +| turn limits | every agent has `.defaultMaxTurns(...)`, and high-risk runs override lower | +| timeouts | HTTP routes, jobs, and approval waits have app-owned timeout policy | + +## Tools + +| Check | Ready when | +| --- | --- | +| input schemas | every tool validates model arguments | +| output contracts | important tool results are typed states or validated output schemas | +| permission checks | every data-bearing tool checks actor and tenant scope | +| side effects | writes use service-layer transactions, idempotency, and audit records | +| direct tests | high-risk tools are tested through direct calls or `ToolSet.call(...)` | + +## Dynamic Tools + +| Check | Ready when | +| --- | --- | +| catalog ownership | a `ToolSet` owns the full catalog | +| index lifecycle | tool index is built at startup, deploy time, or another explicit boundary | +| search tests | representative prompts select expected tool ids | +| critical tools | required tools are either static or reliably selected | +| trace review | traces show which dynamic tools were available during runs | + +## MCP + +| Check | Ready when | +| --- | --- | +| lifecycle | required servers fail fast, optional servers degrade gracefully | +| tool inspection | required MCP tools are validated after connection | +| filtering | agents receive only the MCP tools they should use | +| reconnect | reconnect closes the previous server and updates future agents | +| cleanup | long-lived servers close during shutdown, scoped servers close in `finally` | + +## Context and Memory + +| Check | Ready when | +| --- | --- | +| instructions | durable behavior is in instructions | +| request facts | current user, tenant, plan, locale, or flags are loaded by the runner | +| retrieval filters | indexes or searches enforce tenant and access boundaries | +| history | conversation history is stored and replayed deliberately | +| session policy | workflows use explicit history or sessions deliberately, not casually mixed | + +## Observability + +| Check | Ready when | +| --- | --- | +| trace names | every workflow uses stable names such as `support-chat` | +| metadata | traces include safe ids such as tenant, conversation, ticket, or channel | +| usage | usage is available for cost and regression checks | +| external telemetry | observers or integrations are attached where needed | +| Studio | local Studio can inspect tools, MCPs, context, sessions, approvals, and traces | + +## Testing and Evals + +| Check | Ready when | +| --- | --- | +| unit tests | tools, services, runners, filters, and known errors are tested with fakes | +| integration tests | narrow provider-backed tests cover model-dependent behavior | +| evals | known prompts or regression cases have repeatable evals | +| approval tests | approval accepted, rejected, and timeout paths are covered | +| MCP tests | missing, unavailable, and filtered MCP tools are covered | + +## Deployment + +| Check | Ready when | +| --- | --- | +| secrets | provider and MCP credentials are server-only | +| storage | history, memory, traces, audit, approval, and idempotency stores are durable | +| retries | job retries are safe for side effects or disabled for non-idempotent runs | +| streaming | proxies and platforms do not buffer streams that need live events | +| rollback | agents can be rebuilt with a previous prompt, tool catalog, or MCP config | + +## Smoke Test Shape + +```ts +const result = await runSupportTurn({ + conversationId: "smoke_test", + message: "Say hello and do not call tools.", + auth: smokeAuth, + conversations: smokeConversations, + services: smokeServices, +}); + +expect(result.ok).toBe(true); +``` + +For tool workflows, add a smoke test that calls one read-only tool and verifies trace metadata. For side-effect workflows, run against sandbox services with explicit idempotency records. + +## Related Docs + +- [Testing and Observability](/docs/best-practices/common-patterns/testing-and-observability) +- [Eval Strategy](/docs/best-practices/quality-observability/eval-strategy) +- [Tracing and Debugging](/docs/best-practices/quality-observability/tracing-and-debugging) +- [RAG Ingestion](/docs/best-practices/knowledge-patterns/rag-ingestion) +- [Production Guardrails](/docs/best-practices/common-patterns/production-guardrails) +- [MCP Server Lifecycle](/docs/best-practices/mcp-patterns/mcp-server-lifecycle) diff --git a/apps/docs/content/docs/best-practices/quality-observability/eval-strategy.mdx b/apps/docs/content/docs/best-practices/quality-observability/eval-strategy.mdx new file mode 100644 index 00000000..ff20fa64 --- /dev/null +++ b/apps/docs/content/docs/best-practices/quality-observability/eval-strategy.mdx @@ -0,0 +1,151 @@ +--- +title: Eval Strategy +description: Build repeatable quality checks for tools, agents, RAG, and workflows. +--- + +Evals are for behavior that cannot be fully proven with unit tests. Use unit tests for deterministic boundaries, then use eval suites for model-dependent behavior, retrieval quality, answer quality, tool choice, and regressions. + +Do not make every route test call a provider. Keep provider-backed evals focused and repeatable. + +## Scenario + +A support agent should answer refund-policy questions correctly, use account tools only when needed, and avoid claiming a refund happened unless the refund tool confirms it. Unit tests cover tools and services. Evals cover model behavior. + +## When to Use It + +Use evals when: + +- prompts, instructions, tools, or retrieval change often +- failures are quality regressions rather than type errors +- you need a repeatable signal before deploying prompt changes +- outputs are judged by required facts, schema, similarity, or a rubric +- traces or Langfuse scores should connect eval outcomes to runs + +## Architecture Shape + +| Layer | Test with | +| --- | --- | +| service and permission policy | unit tests with fakes | +| tool contracts | direct `tool.call(...)` or `ToolSet.call(...)` | +| retrieval selection | search probes against indexes | +| runner response shape | unit tests with fake agents or sandbox agents | +| agent answer quality | eval suite with `agentEvalTarget(...)` or custom target | +| traced workflow | eval reporter plus trace metadata | + +## Golden Cases + +Store cases close to the workflow they protect. Keep them small and named by the behavior they assert. + +```ts +const supportCases = [ + { + id: "refund-window", + input: "How long do I have to request a refund?", + expected: "30 days", + }, + { + id: "password-reset-expiry", + input: "Can I use yesterday's password reset link?", + expected: "30 minutes", + }, +]; +``` + +## Agent Eval + +```ts +import { agentEvalTarget, contains, runEvalSuite } from "@anvia/core/evals"; + +const result = await runEvalSuite({ + name: "support-agent-regression", + cases: supportCases, + target: agentEvalTarget(supportAgent), + metrics: [ + contains(), + ], +}); + +console.log(result.passed, result.failed, result.invalid); +``` + +Use `agentEvalTarget(...)` when the target is exactly `agent.prompt(input).send()`. + +## Custom Harness Eval + +Use a custom target when the real behavior lives in your runner. + +```ts +const result = await runEvalSuite({ + name: "support-runner-regression", + cases: supportCases, + target: async (input) => { + const response = await runSupportTurn({ + conversationId: `eval:${input}`, + message: input, + auth: evalAuth, + conversations: evalConversations, + services: evalServices, + }); + + return response.output; + }, + metrics: [contains()], +}); +``` + +This covers the harness boundary: history, tools, retrieval, trace metadata, and final output. + +## Metric Choice + +| Metric | Use it for | +| --- | --- | +| `exactMatch(...)` | deterministic booleans, labels, JSON-shaped values | +| `contains(...)` | required phrases, facts, or regex matches | +| `semanticSimilarity(...)` | answer similarity when wording can vary | +| `llmJudge(...)` | schema-shaped rubric checks | +| `llmScore(...)` | scored quality feedback with a threshold | + +Start with deterministic metrics. Add LLM judges when the behavior cannot be checked with simple selectors. + +## Report to Langfuse + +```ts +import { createLangfuseEvalReporter, langfuse } from "@anvia/langfuse"; + +const tracing = langfuse.create({ publicKey, secretKey, baseUrl }); + +await runEvalSuite({ + name: "support-agent-regression", + cases: supportCases, + target: agentEvalTarget(supportAgent), + metrics: [contains()], + reporters: [createLangfuseEvalReporter(tracing)], +}); +``` + +When your target returns a prompt response with trace ids, reporters can connect eval scores back to traces. + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| evals are flaky | prefer deterministic metrics and sandbox services | +| cases are too broad | split into one behavior per case | +| evals duplicate unit tests | move deterministic policy checks back to unit tests | +| judges hide regressions | store judge feedback and add required fact metrics | +| no trace linkage | include trace metadata or return prompt response trace ids | + +## Test Checklist + +- Cover tool contracts with unit tests before provider evals. +- Add retrieval probe tests before answer-quality evals. +- Keep golden cases small, named, and versioned. +- Run evals on prompt, retrieval, tool, and model changes. +- Report eval scores to the same observability system used for traces when useful. + +## Related Docs + +- [Evals](/docs/guides/testing/evals) +- [Testing and Observability](/docs/best-practices/common-patterns/testing-and-observability) +- [Tracing and Debugging](/docs/best-practices/quality-observability/tracing-and-debugging) + diff --git a/apps/docs/content/docs/best-practices/quality-observability/meta.json b/apps/docs/content/docs/best-practices/quality-observability/meta.json new file mode 100644 index 00000000..d8949248 --- /dev/null +++ b/apps/docs/content/docs/best-practices/quality-observability/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Quality and Observability", + "defaultOpen": false, + "collapsible": true, + "pages": ["eval-strategy", "tracing-and-debugging"] +} diff --git a/apps/docs/content/docs/best-practices/quality-observability/tracing-and-debugging.mdx b/apps/docs/content/docs/best-practices/quality-observability/tracing-and-debugging.mdx new file mode 100644 index 00000000..e2ff808c --- /dev/null +++ b/apps/docs/content/docs/best-practices/quality-observability/tracing-and-debugging.mdx @@ -0,0 +1,137 @@ +--- +title: Tracing and Debugging +description: Connect agent runs, tool calls, retrieval evidence, product logs, and eval scores. +--- + +Tracing gives you the evidence needed to debug an agent harness: prompt runs, model generations, tool calls, errors, usage, retrieval evidence, trace metadata, and eval scores. + +Use traces for runtime debugging. Use product logs and audit records for product accountability. + +## Scenario + +A support answer was wrong. You need to know which prompt ran, which user and conversation it belonged to, which tools were available, which retrieval documents were injected, what the model returned, and whether the same case fails in evals. + +## When to Use It + +Use this pattern for every production harness that handles real users, private data, side effects, or recurring quality checks. + +## Architecture Shape + +| Layer | Responsibility | +| --- | --- | +| observer | records runs, tool calls, model calls, errors, and usage | +| `.withTrace(...)` | attaches workflow name, user id, session id, tags, version, and safe metadata | +| product logs | store trace ids next to product events | +| Studio | inspect local runs, tools, MCPs, context, approvals, and traces | +| external tracing | long-term search, metrics, dashboards, eval scores | +| shutdown | flush and close buffered telemetry | + +## Code Example + +```ts +import { AgentBuilder } from "@anvia/core"; +import { langfuse } from "@anvia/langfuse"; + +const tracing = langfuse.create({ + publicKey, + secretKey, + baseUrl, +}); + +const agent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly.") + .observe(tracing) + .defaultMaxTurns(3) + .build(); + +const response = await agent + .prompt(message) + .withTrace({ + name: "support-chat", + userId: user.id, + sessionId: conversationId, + tags: ["support", channel], + version: "2026-05-11", + metadata: { + tenantId: user.tenantId, + conversationId, + plan: user.plan, + }, + }) + .send(); + +logger.info({ + traceId: response.trace?.traceId, + observationId: response.trace?.observationId, + conversationId, +}, "support agent completed"); +``` + +## Trace Metadata Rules + +| Include | Avoid | +| --- | --- | +| stable workflow name | raw secrets or API keys | +| user id when safe | full prompts in app logs | +| tenant id | large records | +| conversation, ticket, or job id | private document bodies in metadata | +| model or prompt version | unbounded objects | +| channel, route, or feature flag | values your policy forbids storing | + +Trace metadata should connect systems. It should not become a second database. + +## Debugging Flow + +1. Find the product event, ticket, conversation, or eval case. +2. Open the linked trace id. +3. Check prompt input, instructions, retrieved context, and available tools. +4. Check tool calls, tool outputs, errors, and turn count. +5. Reproduce with the same case in Studio or an eval suite. +6. Fix the deterministic boundary first: tool, retrieval, runner, or prompt. +7. Add an eval case when the issue is model-dependent. + +## Local Studio vs External Tracing + +| Use Studio | Use external tracing | +| --- | --- | +| local development | production traffic | +| inspect agent registration | long-term trace search | +| exercise approvals and questions | aggregate cost and usage | +| debug context and MCP visibility | dashboards and alerting | +| iterate before product UI exists | eval score reporting | + +Studio and tracing are complementary. Studio shortens local iteration; tracing preserves production evidence. + +## Flush and Shutdown + +```ts +await tracing.flush?.(); +await tracing.shutdown?.(); +``` + +Call `flush()` before process exit when pending events matter. Call `shutdown()` during application shutdown for long-lived integrations. + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| traces cannot be tied to product events | log `traceId` and product ids together | +| trace metadata leaks sensitive data | restrict metadata to ids and small safe fields | +| eval scores are disconnected | report evals with trace ids or case metadata | +| tool failures are invisible | attach observer before building production agents | +| buffered traces are missing | flush or shutdown the observer on exit | + +## Test Checklist + +- Assert runners attach stable trace names. +- Assert safe metadata includes product correlation ids. +- Verify `response.trace` is logged or returned where needed. +- Inspect one local Studio run for context, tools, and trace evidence. +- Verify external observer flushes during shutdown. + +## Related Docs + +- [Tracing](/docs/guides/observability/tracing) +- [Langfuse](/docs/guides/observability/langfuse) +- [Eval Strategy](/docs/best-practices/quality-observability/eval-strategy) + diff --git a/apps/docs/content/docs/best-practices/real-cases/backoffice-agent.mdx b/apps/docs/content/docs/best-practices/real-cases/backoffice-agent.mdx new file mode 100644 index 00000000..692408c9 --- /dev/null +++ b/apps/docs/content/docs/best-practices/real-cases/backoffice-agent.mdx @@ -0,0 +1,160 @@ +--- +title: Backoffice Agent +description: An admin workflow with side effects, approvals, audit records, and idempotency. +--- + +This pattern is for internal operations agents that can change product state. The harness must treat every write as a product operation with permissions, approvals, idempotency, and audit records. + +## Scenario + +A support lead asks an agent to resolve a billing ticket, issue a refund, and notify the customer. The model can help coordinate the work, but application code owns every restricted operation. + +## When to Use It + +Use this pattern when the agent can: + +- issue refunds +- close or transition tickets +- update accounts +- send customer messages +- trigger jobs or webhooks +- access admin-only data + +## Architecture Shape + +| Layer | Responsibility | +| --- | --- | +| runner | resolve admin actor, ticket, tenant, approval run, trace metadata | +| agent | coordinate the workflow and call tools | +| write tools | validate inputs and call product services | +| approval runtime | store decisions, notify reviewers, wait or resume | +| service layer | permissions, transactions, idempotency, audit | +| storage | ticket state, operation records, audit records, traces | + +## Code Example + +```ts +import { AgentBuilder, Message, createHook } from "@anvia/core"; +import { model } from "./model"; +import { createBackofficeTools } from "./tools"; + +export async function runBackofficeResolution(input: BackofficeInput) { + const actor = await input.auth.requireAdmin(); + const ticket = await input.tickets.getForTenant(input.ticketId, actor.tenantId); + + const approvalHook = createHook({ + async onToolCall({ toolName, tool }) { + if (!["issue_refund", "send_customer_email", "close_account"].includes(toolName)) { + return tool.run(); + } + + const approved = await input.approvals.waitForDecision({ + actorId: actor.id, + tenantId: actor.tenantId, + ticketId: ticket.id, + toolName, + }); + + return approved ? tool.run() : tool.cancel("Operation was not approved."); + }, + }); + + const agent = new AgentBuilder("backoffice", model) + .instructions(` +Help resolve backoffice tickets. +Use tools for account data and product changes. +Never claim that a side effect happened unless the tool result confirms it. + `) + .tools( + createBackofficeTools({ + actorId: actor.id, + tenantId: actor.tenantId, + ticketId: ticket.id, + billing: input.services.billing, + tickets: input.services.tickets, + messages: input.services.messages, + audit: input.audit, + }), + ) + .hook(approvalHook) + .defaultMaxTurns(5) + .build(); + + const response = await agent + .prompt([ + Message.user(`Ticket ${ticket.id}: ${ticket.summary}`), + Message.user(input.instruction), + ]) + .withTrace({ + name: "backoffice-resolution", + userId: actor.id, + metadata: { + tenantId: actor.tenantId, + ticketId: ticket.id, + }, + }) + .send(); + + await input.audit.write({ + actorId: actor.id, + tenantId: actor.tenantId, + action: "backoffice.agent_run", + targetId: ticket.id, + }); + + return response.output; +} +``` + +## Write Tool Shape + +```ts +async execute({ orderId, amount, reason }) { + const operationId = `refund:${scope.tenantId}:${orderId}:${amount}`; + + const result = await scope.billing.issueRefund({ + actorId: scope.actorId, + tenantId: scope.tenantId, + orderId, + amount, + reason, + operationId, + }); + + await scope.audit.write({ + actorId: scope.actorId, + tenantId: scope.tenantId, + action: "refund.issued", + targetId: orderId, + operationId, + }); + + return result; +} +``` + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| duplicate refund | idempotency key in service layer | +| model performs write without approval | tool approval metadata or `onToolCall` hook | +| approval blocks HTTP request too long | store approval and resume asynchronously | +| audit only appears in traces | write product audit records | +| admin data leaks | enforce actor and tenant permissions in services | + +## Test Checklist + +- Test admin auth and tenant scoping. +- Test approval approved, rejected, and timed out paths. +- Test write tools call services with idempotency keys. +- Test audit records for each successful side effect. +- Test runner maps cancellation to a product-safe response. +- Use Studio streaming runs to inspect approval behavior locally. + +## Related Docs + +- [Side Effect Tools](/docs/best-practices/tool-patterns/side-effect-tools) +- [Production Guardrails](/docs/best-practices/common-patterns/production-guardrails) +- [Approval Handlers](/docs/guides/human-in-the-loop/approval-handlers) + diff --git a/apps/docs/content/docs/best-practices/real-cases/coding-agent.mdx b/apps/docs/content/docs/best-practices/real-cases/coding-agent.mdx new file mode 100644 index 00000000..d06d3232 --- /dev/null +++ b/apps/docs/content/docs/best-practices/real-cases/coding-agent.mdx @@ -0,0 +1,206 @@ +--- +title: Coding Agent +description: A codebase assistant harness with repo search, file tools, command guardrails, patches, and evals. +--- + +A coding agent is a high-risk harness because it can inspect source code, run commands, and propose or apply changes. Treat it as an application over a workspace: your app owns the repository boundary, allowed paths, command policy, patch approval, git behavior, audit records, and trace metadata. + +This pattern does not require new SDK primitives. It composes agents, tools, MCP, approvals, tracing, and evals around a codebase workflow. + +## Scenario + +A user asks, "Find why the checkout test fails and propose a fix." The agent should search files, read relevant code, optionally run allowed test commands, propose a patch, and wait for approval before any write. + +## When to Use It + +Use this pattern when: + +- an agent assists with code review, debugging, test triage, migration, or docs changes +- workspace access must be scoped to allowed repositories and paths +- command execution must be allow-listed +- writes require preview, approval, idempotency, and git diff inspection +- behavior should be checked with coding-task evals + +## Architecture Shape + +| Layer | Responsibility | +| --- | --- | +| runner | resolve user, repo, branch, task, allowed paths, trace metadata | +| read tools | search files, read files, inspect git diff, list tests | +| command tool | run only allow-listed commands with timeouts and sandbox policy | +| patch tool | propose or apply patches behind approval | +| MCP tools | optional filesystem or git server tools, filtered before registration | +| audit | record command runs, patch proposals, approvals, and applied changes | +| evals | regression tasks for search, diagnosis, patch proposal, and no-write behavior | + +## Code Example + +```ts +import { AgentBuilder, createHook } from "@anvia/core"; +import { model } from "./model"; +import { createCodebaseTools } from "./tools"; + +export async function runCodingAgent(input: CodingAgentInput) { + const user = await input.auth.requireUser(); + const workspace = await input.workspaces.open({ + repoId: input.repoId, + userId: user.id, + }); + + const approvalHook = createHook({ + async onToolCall({ toolName, tool }) { + if (!["apply_patch", "run_command"].includes(toolName)) { + return tool.run(); + } + + const approved = await input.approvals.waitForDecision({ + actorId: user.id, + repoId: input.repoId, + toolName, + reason: "Codebase mutation or command execution requires approval.", + }); + + return approved ? tool.run() : tool.cancel("Operation was not approved."); + }, + }); + + const agent = new AgentBuilder("coding", model) + .instructions(` +Help with codebase tasks. +Search and read files before proposing changes. +Prefer minimal patches. +Do not run commands unless a tool allows them. +Do not claim a patch was applied unless the tool confirms it. + `) + .tools( + createCodebaseTools({ + workspace, + allowedPaths: input.allowedPaths, + allowedCommands: ["pnpm test", "pnpm lint", "pnpm typecheck"], + audit: input.audit, + }), + ) + .hook(approvalHook) + .defaultMaxTurns(8) + .build(); + + const response = await agent + .prompt(input.task) + .withTrace({ + name: "coding-agent-task", + userId: user.id, + metadata: { + repoId: input.repoId, + branch: workspace.branch, + taskId: input.taskId, + }, + }) + .send(); + + return { + output: response.output, + trace: response.trace, + }; +} +``` + +## Tool Boundaries + +Keep read tools separate from mutation tools. + +```ts +export function createCodebaseTools(scope: CodebaseToolScope) { + return [ + createSearchFilesTool(scope), + createReadFileTool(scope), + createGitDiffTool(scope), + createRunCommandTool(scope), + createApplyPatchTool(scope), + ]; +} +``` + +Read tools should enforce allowed paths. + +```ts +async execute({ path }) { + scope.workspace.requireAllowedPath(path, scope.allowedPaths); + return scope.workspace.readFile(path); +} +``` + +Command tools should enforce exact allow lists, timeouts, and working directory policy. + +```ts +async execute({ command }) { + if (!scope.allowedCommands.includes(command)) { + return { status: "blocked" as const, reason: "command_not_allowed" }; + } + + return scope.workspace.run(command, { + timeoutMs: 60_000, + audit: scope.audit, + }); +} +``` + +Patch tools should support preview-first behavior. + +```ts +async execute({ patch, mode }) { + if (mode === "preview") { + return scope.workspace.previewPatch(patch); + } + + return scope.workspace.applyPatch({ + patch, + operationId: `patch:${scope.workspace.id}:${hashPatch(patch)}`, + }); +} +``` + +## MCP Filesystem Tools + +If a filesystem MCP server is used, filter or wrap its tools before the coding agent sees them. Prefer local wrapper tools when you need path allow lists, audit records, command policy, or patch approval. + +```ts +const filesystem = await connectMcp( + mcp.stdio({ + name: "filesystem", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", workspace.root], + }), +); + +const readOnlyTools = filesystem.tools.filter((tool) => + ["read_file", "list_directory"].includes(tool.name), +); +``` + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| agent reads outside repo | enforce allowed paths in every file tool | +| command is too broad | exact allow list and timeout in command tool | +| patch applies twice | idempotency key from patch hash | +| write happens without approval | hook or approval metadata on mutation tools | +| traces leak source code | keep trace metadata to ids, paths, and summaries | +| evals only check final prose | add task cases for tool choice, no-write mode, and patch preview | + +## Test Checklist + +- Test file reads inside and outside allowed paths. +- Test blocked commands and approved commands. +- Test patch preview without mutation. +- Test approved and rejected patch application. +- Test git diff inspection after a patch. +- Add eval cases for diagnosis, minimal patch proposal, and refusal to run disallowed commands. + +## Related Docs + +- [MCP Tool Inspection](/docs/best-practices/mcp-patterns/mcp-tool-inspection) +- [Side Effect Tools](/docs/best-practices/tool-patterns/side-effect-tools) +- [Eval Strategy](/docs/best-practices/quality-observability/eval-strategy) +- [Tracing and Debugging](/docs/best-practices/quality-observability/tracing-and-debugging) + diff --git a/apps/docs/content/docs/best-practices/real-cases/meta.json b/apps/docs/content/docs/best-practices/real-cases/meta.json new file mode 100644 index 00000000..9f5d39f2 --- /dev/null +++ b/apps/docs/content/docs/best-practices/real-cases/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Real Cases", + "defaultOpen": false, + "collapsible": true, + "pages": ["support-agent", "backoffice-agent", "research-agent", "coding-agent"] +} diff --git a/apps/docs/content/docs/best-practices/real-cases/research-agent.mdx b/apps/docs/content/docs/best-practices/real-cases/research-agent.mdx new file mode 100644 index 00000000..569458ec --- /dev/null +++ b/apps/docs/content/docs/best-practices/real-cases/research-agent.mdx @@ -0,0 +1,129 @@ +--- +title: Research Agent +description: A research workflow with many read-only tools, dynamic selection, retrieval, and extraction. +--- + +This pattern is for research, analysis, due diligence, competitive intelligence, and internal knowledge workflows. The agent often has many read-only tools, uses retrieval heavily, and returns structured output for downstream processing. + +## Scenario + +A research agent answers a market question by searching internal docs, querying external MCP tools, reading product notes, and extracting a structured brief. + +## When to Use It + +Use this pattern when: + +- the workflow is read-heavy +- the tool catalog is large +- sources need to be inspected in traces +- the final output should match a schema +- deterministic post-processing is useful + +## Architecture Shape + +| Layer | Responsibility | +| --- | --- | +| dynamic tool catalog | selects relevant read-only tools | +| retrieval | provides internal knowledge context | +| MCP tools | expose external research systems | +| agent | gathers and synthesizes evidence | +| extractor or output schema | produces typed downstream output | +| pipeline | separates normalize, research, extract, and enrich steps | + +## Code Example + +```ts +import { AgentBuilder } from "@anvia/core"; +import { ExtractorBuilder } from "@anvia/core/extractor"; +import { PipelineBuilder } from "@anvia/core/pipeline"; +import { ToolSet, createToolIndex } from "@anvia/core/tool"; +import { z } from "zod"; +import { embeddingModel, model } from "./models"; +import { internalDocsIndex } from "./retrieval"; +import { createResearchTools } from "./tools"; + +const researchTools = ToolSet.fromTools(createResearchTools()); + +const researchToolIndex = await createToolIndex(embeddingModel, researchTools, { + metadata: (tool) => ({ name: tool.name, domain: "research" }), +}); + +const researchAgent = new AgentBuilder("research", model) + .instructions(` +Research the question using available tools and retrieved context. +Prefer cited facts over guesses. +Call tools only when they are relevant to the question. + `) + .dynamicContext(internalDocsIndex, { + topK: 6, + threshold: 0.72, + }) + .dynamicTools(researchToolIndex, { + topK: 8, + threshold: 0.68, + }) + .defaultMaxTurns(6) + .build(); + +const briefSchema = z.object({ + summary: z.string(), + confidence: z.enum(["low", "medium", "high"]), + keyFindings: z.array(z.string()), + openQuestions: z.array(z.string()), +}); + +const briefExtractor = new ExtractorBuilder(model, briefSchema).build(); + +export const researchWorkflow = new PipelineBuilder() + .step((question) => question.trim()) + .prompt(researchAgent) + .extract(briefExtractor) + .build(); +``` + +## Add MCP Research Tools + +If external research systems are MCP servers, inspect and filter their tools before adding them to the catalog. + +```ts +import { connectMcp, mcp } from "@anvia/core/mcp"; + +const docsServer = await connectMcp(mcp.http({ + name: "external-docs", + url: "https://mcp.example.com/mcp", +})); + +const allowedMcpTools = docsServer.tools.filter((tool) => + ["search_docs", "read_doc"].includes(tool.name), +); + +researchTools.addTools(allowedMcpTools); +``` + +Rebuild the dynamic tool index after changing the catalog. + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| agent picks irrelevant tools | improve tool descriptions, lower `topK`, raise `threshold` | +| important tools are not selected | improve embedding text or lower `threshold` | +| final output is hard to consume | use an extractor or agent output schema | +| citations are weak | include source ids in retrieved context and tool outputs | +| tool catalog changes | rebuild the index before future runs | + +## Test Checklist + +- Test dynamic tool search for representative research prompts. +- Test retrieval filters and source formatting. +- Test pipeline output schema with known questions. +- Inspect traces for selected tools and retrieved context. +- Add evals for answer quality and missing-evidence behavior. + +## Related Docs + +- [Dynamic Tool Catalogs](/docs/best-practices/tool-patterns/dynamic-tool-catalogs) +- [RAG Agent Context](/docs/best-practices/knowledge-patterns/rag-agent-context) +- [Eval Strategy](/docs/best-practices/quality-observability/eval-strategy) +- [MCP Tool Inspection](/docs/best-practices/mcp-patterns/mcp-tool-inspection) +- [Pipeline](/docs/best-practices/common-patterns/pipeline) diff --git a/apps/docs/content/docs/best-practices/real-cases/support-agent.mdx b/apps/docs/content/docs/best-practices/real-cases/support-agent.mdx new file mode 100644 index 00000000..4055fa9f --- /dev/null +++ b/apps/docs/content/docs/best-practices/real-cases/support-agent.mdx @@ -0,0 +1,131 @@ +--- +title: Support Agent +description: A practical support chat harness with history, retrieval, account tools, and traces. +--- + +This pattern is for customer support chat, help center assistants, and account-aware product support. The agent answers user questions, retrieves support knowledge, reads customer state through tools, persists conversation history, and emits trace metadata. + +## Scenario + +A signed-in customer asks, "Where is my order A-100 and can I change the address?" The agent needs previous conversation history, support documentation, order tools, and tenant-safe account context. + +## When to Use It + +Use this pattern when: + +- the user is authenticated +- the agent needs conversation history +- account-specific data must be fetched through tools +- documentation or policy should come from retrieval +- the response is user-facing and should be traceable + +## Architecture Shape + +| Layer | Responsibility | +| --- | --- | +| route | parse request and return product response | +| runner | resolve user, load history, create scoped tools, persist messages | +| support agent | instructions, dynamic context, scoped account tools, turn limit | +| tools | order, ticket, account, and subscription service calls | +| retrieval | tenant-safe or public support docs | +| storage | conversation history and trace correlation ids | + +## Code Example + +```ts +import { AgentBuilder, Message } from "@anvia/core"; +import { model } from "./model"; +import { supportDocsIndex } from "./support-docs"; +import { createSupportTools } from "./support-tools"; + +export async function runSupportTurn(input: SupportTurnInput) { + const user = await input.auth.requireUser(); + const history = await input.conversations.loadMessages(input.conversationId); + + const agent = new AgentBuilder("support", model) + .instructions(` +Answer support questions clearly. +Use account tools for customer-specific data. +Use retrieved support docs for policy and product behavior. +Ask for missing details before guessing. + `) + .dynamicContext(supportDocsIndex, { + topK: 4, + threshold: 0.72, + }) + .tools( + createSupportTools({ + userId: user.id, + tenantId: user.tenantId, + orders: input.services.orders, + tickets: input.services.tickets, + subscriptions: input.services.subscriptions, + }), + ) + .context(`Current customer plan: ${user.plan}`, "customer-plan") + .defaultMaxTurns(4) + .build(); + + const response = await agent + .prompt([...history, Message.user(input.message)]) + .withTrace({ + name: "support-chat", + userId: user.id, + metadata: { + tenantId: user.tenantId, + conversationId: input.conversationId, + channel: input.channel, + }, + }) + .send(); + + await input.conversations.append(input.conversationId, response.messages); + + return { + output: response.output, + usage: response.usage, + }; +} +``` + +## Tool Scope + +Support tools should be scoped to the current user and tenant. + +```ts +export function createSupportTools(scope: SupportToolScope) { + return [ + createLookupOrderTool(scope), + createCreateTicketTool(scope), + createSubscriptionStatusTool(scope), + ]; +} +``` + +Do not give the model raw database clients. Give it narrow tools that call permission-aware services. + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| agent answers from stale docs | rebuild or refresh the retrieval index | +| account data leaks across tenants | enforce tenant filters in every tool and retrieval source | +| route tests call live providers | test runner and tools with fakes first | +| history grows too large | summarize, window, or use session memory policy | +| model calls too many tools | lower `defaultMaxTurns` or split tools by workflow | + +## Test Checklist + +- Test empty and malformed messages in the runner. +- Test order lookup allowed, denied, and not found paths. +- Test conversation history is appended with `response.messages`. +- Test retrieval filters only return allowed documents. +- Use Studio to inspect tool calls and retrieved context. +- Add evals for common support questions and known policy answers. + +## Related Docs + +- [Request Runners](/docs/best-practices/common-patterns/request-runners) +- [Context and Memory](/docs/best-practices/common-patterns/context-and-memory) +- [Tools and Services](/docs/best-practices/common-patterns/tools-and-services) + diff --git a/apps/docs/content/docs/best-practices/tool-patterns/dynamic-tool-catalogs.mdx b/apps/docs/content/docs/best-practices/tool-patterns/dynamic-tool-catalogs.mdx new file mode 100644 index 00000000..18226819 --- /dev/null +++ b/apps/docs/content/docs/best-practices/tool-patterns/dynamic-tool-catalogs.mdx @@ -0,0 +1,130 @@ +--- +title: Dynamic Tool Catalogs +description: Select relevant tools at runtime when a static tool list is too large. +--- + +Use dynamic tools when an agent has a large catalog of possible actions but only a small subset should be sent to the model on each turn. This is common for internal platforms, operations agents, research agents, and backoffice assistants with many read-only service tools. + +Do not use dynamic tools to hide permission checks. The selected tool still needs to enforce permissions in code. + +## Scenario + +A support operations agent can inspect orders, refunds, tickets, subscriptions, feature flags, warehouse records, fraud signals, and policy documents. Sending every tool definition on every turn is noisy and expensive. Dynamic tools let Anvia search a tool index with the current prompt and send only the matching definitions. + +## When to Use It + +| Use dynamic tools | Prefer static tools | +| --- | --- | +| many tools compete for the same agent | the agent has a small stable tool set | +| tools are discoverable by name, description, and schema | every tool is needed in almost every run | +| the prompt usually needs only a few tools | missing one tool would be more costly than sending all tools | +| tool catalog can be indexed at startup or deploy time | tools are created entirely per request | + +## Architecture Shape + +| Layer | Responsibility | +| --- | --- | +| `ToolSet` | owns the full catalog and direct test calls | +| embedding model | embeds provider-facing tool definitions | +| `createToolIndex(...)` | builds a searchable dynamic tool index | +| `AgentBuilder.dynamicTools(...)` | selects top matching tools per prompt | +| tool code | still enforces permissions, tenant scope, and side-effect policy | + +## Code Example + +```ts +import { AgentBuilder } from "@anvia/core"; +import { ToolSet, createToolIndex } from "@anvia/core/tool"; +import { embeddingModel, model } from "./models"; +import { createAdminTools, createBillingTools, createSupportTools } from "./tools"; + +const toolCatalog = ToolSet.fromTools([ + ...createSupportTools(), + ...createBillingTools(), + ...createAdminTools(), +]); + +const toolIndex = await createToolIndex(embeddingModel, toolCatalog, { + content: (tool, definition) => [ + definition.name, + definition.description, + JSON.stringify(definition.parameters), + ], + metadata: (tool) => ({ + name: tool.name, + domain: tool.name.split("_")[0], + }), +}); + +export const operationsAgent = new AgentBuilder("operations", model) + .instructions("Use the most relevant tool for the user's operational request.") + .dynamicTools(toolIndex, { + topK: 6, + threshold: 0.7, + }) + .defaultMaxTurns(4) + .build(); +``` + +Static tools can still be registered when they should always be available. + +```ts +const agent = new AgentBuilder("operations", model) + .tool(createThinkTool()) + .dynamicTools(toolIndex, { topK: 6, threshold: 0.7 }) + .build(); +``` + +## Validate Tool Selection + +Test the catalog before testing model behavior. Search the index with representative prompts and assert that the expected tool ids appear. + +```ts +const matches = await toolIndex.searchIds({ + query: "refund order A-100 because it was duplicated", + topK: 6, + threshold: 0.7, +}); + +expect(matches.map((match) => match.id)).toContain("issue_refund"); +``` + +Then test the selected tool directly through the catalog. + +```ts +const result = await toolCatalog.call( + "issue_refund", + JSON.stringify({ + orderId: "A-100", + amount: 25, + reason: "duplicate_charge", + }), +); + +expect(JSON.parse(result).status).toBe("refunded"); +``` + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| model cannot find the right tool | improve tool name, description, schema text, or `content` embedding text | +| too many unrelated tools are sent | raise `threshold`, lower `topK`, or split catalogs by domain | +| needed tools are missing | lower `threshold`, add domain words to descriptions, or keep critical tools static | +| permission leak | enforce user and tenant checks inside every tool | +| catalog changes at runtime | rebuild the index or use a new indexed catalog for future agents | + +## Test Checklist + +- Search the index with common prompts and assert expected tool ids. +- Call high-risk tools directly through `ToolSet.call(...)`. +- Verify each tool still enforces permissions with fake users and tenants. +- Use Studio traces to inspect which dynamic tools were sent during real runs. +- Add evals for prompts where tool selection is part of the expected behavior. + +## Related Docs + +- [Tool Sets](/docs/guides/tools/tool-sets) +- [Tools and Services](/docs/best-practices/common-patterns/tools-and-services) +- [Research Agent](/docs/best-practices/real-cases/research-agent) + diff --git a/apps/docs/content/docs/best-practices/tool-patterns/meta.json b/apps/docs/content/docs/best-practices/tool-patterns/meta.json new file mode 100644 index 00000000..737ffcda --- /dev/null +++ b/apps/docs/content/docs/best-practices/tool-patterns/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Tool Patterns", + "defaultOpen": false, + "collapsible": true, + "pages": ["dynamic-tool-catalogs", "tool-validation-and-contracts", "side-effect-tools"] +} diff --git a/apps/docs/content/docs/best-practices/tool-patterns/side-effect-tools.mdx b/apps/docs/content/docs/best-practices/tool-patterns/side-effect-tools.mdx new file mode 100644 index 00000000..759ac469 --- /dev/null +++ b/apps/docs/content/docs/best-practices/tool-patterns/side-effect-tools.mdx @@ -0,0 +1,135 @@ +--- +title: Side Effect Tools +description: Safely expose writes, external actions, approvals, idempotency, and audit records. +--- + +Side-effect tools change product state: refunds, deletes, emails, status transitions, webhooks, exports, or external API calls. Treat them as product operations first and model-callable tools second. + +## Scenario + +A backoffice agent can resolve tickets and issue refunds. The model can decide that a refund is appropriate, but product code must enforce permissions, approval policy, idempotency, transactions, and audit records. + +## When to Use It + +Use this pattern for any tool that: + +- writes to your database +- calls an external write API +- sends a message or notification +- changes a workflow state +- triggers a job +- exposes restricted data as a side effect + +## Architecture Shape + +| Layer | Responsibility | +| --- | --- | +| tool schema | require bounded, explicit operation inputs | +| approval metadata or hook | decide whether a human must approve | +| product service | enforce permissions, transaction, idempotency, and audit | +| runner | attach actor, tenant, trace, and operation context | +| storage | persist operation status and audit evidence | + +## Code Example + +```ts +import { createTool } from "@anvia/core"; +import { z } from "zod"; + +export function createRefundTool(scope: RefundToolScope) { + return createTool({ + name: "issue_refund", + description: "Issue an approved refund for a paid order.", + input: z.object({ + orderId: z.string().min(1), + amount: z.number().positive(), + reason: z.string().min(1), + }), + output: z.object({ + status: z.enum(["refunded", "blocked"]), + operationId: z.string().optional(), + reason: z.string().optional(), + }), + approval: { + when: ({ args }) => args.amount >= 100, + reason: "Refunds of 100 or more require reviewer approval.", + rejectMessage: "Refund was not approved.", + }, + async execute({ orderId, amount, reason }) { + const operationId = `refund:${scope.tenantId}:${orderId}:${amount}`; + + return scope.billing.issueRefund({ + actorId: scope.userId, + tenantId: scope.tenantId, + orderId, + amount, + reason, + operationId, + }); + }, + }); +} +``` + +Approval metadata is useful for Studio and approval-capable runtimes. Use a hook when approval depends on request-local state or spans multiple tools. + +```ts +import { createHook } from "@anvia/core"; + +const approvalHook = createHook({ + async onToolCall({ toolName, tool }) { + if (!["issue_refund", "close_account"].includes(toolName)) { + return tool.run(); + } + + const approved = await approvals.waitForDecision({ + actorId: scope.userId, + tenantId: scope.tenantId, + toolName, + }); + + return approved ? tool.run() : tool.cancel("Operation was not approved."); + }, +}); +``` + +## Idempotency and Audit + +The model should not invent idempotency keys. Generate them from product state or pass an operation id from the caller. + +```ts +await audit.write({ + actorId: scope.userId, + tenantId: scope.tenantId, + action: "refund.issued", + targetId: orderId, + operationId, + traceName: "backoffice-resolution", +}); +``` + +Use traces to debug agent behavior. Use audit records for product accountability. + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| duplicate write after retry | idempotency key or durable operation record | +| model bypasses policy | enforce permissions in service code | +| approval waits forever | app-owned approval timeout or asynchronous workflow | +| audit only exists in trace | write product audit records in service code | +| side effect is hard to test | split tool adapter from service method and fake the service | + +## Test Checklist + +- Test permission denied before the write executes. +- Test approval required and rejected paths. +- Test idempotency by calling the service twice with the same operation id. +- Test audit records are written for successful side effects. +- Test runner behavior when the tool throws a transient service error. + +## Related Docs + +- [Production Guardrails](/docs/best-practices/common-patterns/production-guardrails) +- [Human in the Loop](/docs/guides/human-in-the-loop) +- [Backoffice Agent](/docs/best-practices/real-cases/backoffice-agent) diff --git a/apps/docs/content/docs/best-practices/tool-patterns/tool-validation-and-contracts.mdx b/apps/docs/content/docs/best-practices/tool-patterns/tool-validation-and-contracts.mdx new file mode 100644 index 00000000..f262e06d --- /dev/null +++ b/apps/docs/content/docs/best-practices/tool-patterns/tool-validation-and-contracts.mdx @@ -0,0 +1,163 @@ +--- +title: Tool Validation and Contracts +description: Design tool schemas, outputs, and test boundaries that hold up in production. +--- + +Tool contracts are the strongest deterministic boundary in an agent harness. Use schemas to validate arguments and outputs, return typed product states for expected outcomes, and test tools directly before model runs. + +## Scenario + +A model can choose when to call a tool, but the tool owns the contract. The model should not be able to pass arbitrary unvalidated data into your service layer, and downstream code should not need to parse vague natural-language tool results. + +## When to Use It + +Use this pattern for every tool that reads product state, writes product state, or returns values used by downstream code. + +## Architecture Shape + +| Layer | Responsibility | +| --- | --- | +| Zod input schema | validate model arguments before execution | +| Zod output schema | validate tool result before it is serialized | +| tool `execute` | call product services and return typed states | +| runner | map expected states and thrown errors to product responses | +| tests | call tools directly with valid and invalid arguments | + +## Code Example + +```ts +import { createTool } from "@anvia/core"; +import { z } from "zod"; + +const lookupOrderOutput = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("found"), + orderId: z.string(), + fulfillmentStatus: z.enum(["processing", "shipped", "delivered"]), + }), + z.object({ + status: z.literal("not_found"), + orderId: z.string(), + }), + z.object({ + status: z.literal("blocked"), + reason: z.literal("access_denied"), + }), +]); + +export function createLookupOrderTool(scope: OrderToolScope) { + return createTool({ + name: "lookup_order", + description: "Look up one order owned by the current customer.", + input: z.object({ + orderId: z.string().min(1), + }), + output: lookupOrderOutput, + async execute({ orderId }) { + const allowed = await scope.orders.canRead({ + userId: scope.userId, + tenantId: scope.tenantId, + orderId, + }); + + if (!allowed) { + return { status: "blocked" as const, reason: "access_denied" as const }; + } + + const order = await scope.orders.find(orderId); + + if (!order) { + return { status: "not_found" as const, orderId }; + } + + return { + status: "found" as const, + orderId, + fulfillmentStatus: order.fulfillmentStatus, + }; + }, + }); +} +``` + +## Expected States vs Errors + +| Situation | Return state | Throw | +| --- | --- | --- | +| record not found | yes | no | +| user lacks access and the model can continue safely | yes | no | +| malformed model arguments | schema handles it | no | +| database unavailable | no | yes | +| invariant violated | no | yes | +| downstream service timeout | no | yes | + +Expected states are useful model input. Unexpected failures belong to the runner, logs, retries, or product error boundary. + +## Direct Tool Tests + +```ts +const tool = createLookupOrderTool({ + userId: "user_123", + tenantId: "tenant_123", + orders: fakeOrders, +}); + +const result = await tool.call({ orderId: "A-100" }); + +expect(result).toEqual({ + status: "found", + orderId: "A-100", + fulfillmentStatus: "shipped", +}); +``` + +Use `ToolSet.call(...)` when you want to exercise JSON parsing and serialized output. + +```ts +const tools = ToolSet.fromTools([tool]); + +await expect( + tools.call("lookup_order", JSON.stringify({ orderId: "" })), +).rejects.toThrow(); +``` + +## Runner Error Mapping + +The runner should decide which failures become user-facing product errors. + +```ts +try { + const response = await agent.prompt(message).send(); + return { ok: true as const, output: response.output }; +} catch (error) { + if (isTemporaryStorageError(error)) { + return { ok: false as const, error: "temporarily_unavailable" }; + } + + throw error; +} +``` + +## Failure Modes + +| Failure | Fix | +| --- | --- | +| model keeps passing invalid arguments | tighten description, schema descriptions, or ask for missing data first | +| downstream code parses prose | return structured tool output or agent output schema | +| permission errors leak details | return a compact `blocked` state or generic product error | +| tests only cover provider runs | add direct tool and runner tests with fakes | + +## Test Checklist + +- Test valid inputs, invalid inputs, and missing required fields. +- Test permission allowed and denied paths. +- Test expected states such as `not_found` and `blocked`. +- Test unexpected service failures at the runner boundary. +- Inspect tool result text in traces for readability. + +## Related Docs + +- [Creating Tools](/docs/guides/tools/creating-tools) +- [Tool Schemas](/docs/guides/tools/tool-schemas) +- [Testing and Observability](/docs/best-practices/common-patterns/testing-and-observability) + diff --git a/apps/docs/content/docs/changelog/anthropic.mdx b/apps/docs/content/docs/changelog/anthropic.mdx new file mode 100644 index 00000000..c5a351de --- /dev/null +++ b/apps/docs/content/docs/changelog/anthropic.mdx @@ -0,0 +1,63 @@ +--- +title: "@anvia/anthropic" +description: "Release notes for @anvia/anthropic." +--- + +# `@anvia/anthropic` + +Anthropic provider adapter for Anvia. + +Source: [`packages/providers/anthropic/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/providers/anthropic/CHANGELOG.md) + +## 0.3.1 + +### Patch Changes + +- c9728d4: Update upstream runtime dependencies to their latest compatible releases. + +## 0.3.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.2.0 + +### Minor Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +### Patch Changes + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.10 + +### Patch Changes + +- 49e43a3: Update upstream runtime dependencies for Anthropic, Gemini, OpenAI, and Studio. + +## 0.1.9 + +### Patch Changes + +- 896ae21: Update upstream provider and runtime dependencies. + +## 0.1.8 + +### Patch Changes + +- 1ad360d: Fix Anthropic-compatible streaming tool inputs and update provider dependencies. + diff --git a/apps/docs/content/docs/changelog/chroma.mdx b/apps/docs/content/docs/changelog/chroma.mdx new file mode 100644 index 00000000..9dd1a330 --- /dev/null +++ b/apps/docs/content/docs/changelog/chroma.mdx @@ -0,0 +1,43 @@ +--- +title: "@anvia/chroma" +description: "Release notes for @anvia/chroma." +--- + +# `@anvia/chroma` + +ChromaDB vector store adapter for Anvia. + +Source: [`packages/vector-stores/chroma/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/vector-stores/chroma/CHANGELOG.md) + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 + diff --git a/apps/docs/content/docs/changelog/core.mdx b/apps/docs/content/docs/changelog/core.mdx new file mode 100644 index 00000000..76f644be --- /dev/null +++ b/apps/docs/content/docs/changelog/core.mdx @@ -0,0 +1,43 @@ +--- +title: "@anvia/core" +description: "Release notes for @anvia/core." +--- + +# `@anvia/core` + +Core runtime primitives for context-aware Anvia agents. + +Source: [`packages/core/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/core/CHANGELOG.md) + +## 0.4.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +## 0.3.1 + +### Patch Changes + +- b12932d: Update upstream dependencies for PDF loading, globbing, Langfuse tracing, and pgvector support. + + The PDF loader now destroys the `pdfjs-dist` loading task after reading pages, matching the v6 cleanup API. + +## 0.3.0 + +### Minor Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +## 0.2.4 + +### Patch Changes + +- a0a5def: Preserve accumulated streamed tool arguments when a provider final response contains an empty tool input. + diff --git a/apps/docs/content/docs/changelog/fastembed.mdx b/apps/docs/content/docs/changelog/fastembed.mdx new file mode 100644 index 00000000..69f9b821 --- /dev/null +++ b/apps/docs/content/docs/changelog/fastembed.mdx @@ -0,0 +1,43 @@ +--- +title: "@anvia/fastembed" +description: "Release notes for @anvia/fastembed." +--- + +# `@anvia/fastembed` + +FastEmbed embedding model adapter for Anvia. + +Source: [`packages/embeddings/fastembed/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/embeddings/fastembed/CHANGELOG.md) + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 + diff --git a/apps/docs/content/docs/changelog/gemini.mdx b/apps/docs/content/docs/changelog/gemini.mdx new file mode 100644 index 00000000..7824fd66 --- /dev/null +++ b/apps/docs/content/docs/changelog/gemini.mdx @@ -0,0 +1,61 @@ +--- +title: "@anvia/gemini" +description: "Release notes for @anvia/gemini." +--- + +# `@anvia/gemini` + +Gemini provider adapter for Anvia. + +Source: [`packages/providers/gemini/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/providers/gemini/CHANGELOG.md) + +## 0.2.1 + +### Patch Changes + +- c9728d4: Update upstream runtime dependencies to their latest compatible releases. + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.10 + +### Patch Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.9 + +### Patch Changes + +- 49e43a3: Update upstream runtime dependencies for Anthropic, Gemini, OpenAI, and Studio. + +## 0.1.8 + +### Patch Changes + +- 896ae21: Update upstream provider and runtime dependencies. + +## 0.1.7 + +### Patch Changes + +- 1ad360d: Fix Anthropic-compatible streaming tool inputs and update provider dependencies. + diff --git a/apps/docs/content/docs/changelog/index.mdx b/apps/docs/content/docs/changelog/index.mdx new file mode 100644 index 00000000..c66b2e56 --- /dev/null +++ b/apps/docs/content/docs/changelog/index.mdx @@ -0,0 +1,73 @@ +--- +title: Package Changelog +description: Release history for Anvia packages. +--- + +Anvia package release notes are generated from the package changelog files maintained by Changesets. + +Developers should keep writing release notes with `pnpm changeset`. The docs pages in this section are generated from `packages/**/CHANGELOG.md`. + +
+ +## Core + +| Package | Current version | Release notes | +| --- | --- | --- | +| `@anvia/core` | `0.4.0` | [View changelog](/docs/changelog/core) | + +## Providers + +| Package | Current version | Release notes | +| --- | --- | --- | +| `@anvia/anthropic` | `0.3.1` | [View changelog](/docs/changelog/anthropic) | +| `@anvia/gemini` | `0.2.1` | [View changelog](/docs/changelog/gemini) | +| `@anvia/mistral` | `0.2.0` | [View changelog](/docs/changelog/mistral) | +| `@anvia/openai` | `0.3.1` | [View changelog](/docs/changelog/openai) | + +## Embeddings + +| Package | Current version | Release notes | +| --- | --- | --- | +| `@anvia/fastembed` | `0.2.0` | [View changelog](/docs/changelog/fastembed) | +| `@anvia/transformers` | `0.2.0` | [View changelog](/docs/changelog/transformers) | + +## Vector Stores + +| Package | Current version | Release notes | +| --- | --- | --- | +| `@anvia/chroma` | `0.2.0` | [View changelog](/docs/changelog/chroma) | +| `@anvia/pgvector` | `0.2.0` | [View changelog](/docs/changelog/pgvector) | +| `@anvia/qdrant` | `0.2.0` | [View changelog](/docs/changelog/qdrant) | + +## Logger + +| Package | Current version | Release notes | +| --- | --- | --- | +| `@anvia/logger` | `0.3.1` | [View changelog](/docs/changelog/logger) | + +## Observability + +| Package | Current version | Release notes | +| --- | --- | --- | +| `@anvia/langfuse` | `0.2.0` | [View changelog](/docs/changelog/langfuse) | +| `@anvia/otel` | `0.2.0` | [View changelog](/docs/changelog/otel) | + +## Tools + +| Package | Current version | Release notes | +| --- | --- | --- | +| `@anvia/studio` | `0.5.1` | [View changelog](/docs/changelog/studio) | + +## React + +| Package | Current version | Release notes | +| --- | --- | --- | +| `@anvia/react` | `0.3.0` | [View changelog](/docs/changelog/react) | + +## Server + +| Package | Current version | Release notes | +| --- | --- | --- | +| `@anvia/server` | `0.3.0` | [View changelog](/docs/changelog/server) | + +
diff --git a/apps/docs/content/docs/changelog/langfuse.mdx b/apps/docs/content/docs/changelog/langfuse.mdx new file mode 100644 index 00000000..6071e864 --- /dev/null +++ b/apps/docs/content/docs/changelog/langfuse.mdx @@ -0,0 +1,48 @@ +--- +title: "@anvia/langfuse" +description: "Release notes for @anvia/langfuse." +--- + +# `@anvia/langfuse` + +Langfuse tracing adapter for Anvia. + +Source: [`packages/observability/langfuse/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/observability/langfuse/CHANGELOG.md) + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.7 + +### Patch Changes + +- b12932d: Update upstream dependencies for PDF loading, globbing, Langfuse tracing, and pgvector support. + + The PDF loader now destroys the `pdfjs-dist` loading task after reading pages, matching the v6 cleanup API. + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.1.6 + +### Patch Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + diff --git a/apps/docs/content/docs/changelog/logger.mdx b/apps/docs/content/docs/changelog/logger.mdx new file mode 100644 index 00000000..53008a76 --- /dev/null +++ b/apps/docs/content/docs/changelog/logger.mdx @@ -0,0 +1,40 @@ +--- +title: "@anvia/logger" +description: "Release notes for @anvia/logger." +--- + +# `@anvia/logger` + +Structured logger adapters for Anvia. + +Source: [`packages/logger/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/logger/CHANGELOG.md) + +## 0.3.1 + +### Patch Changes + +- c9728d4: Update upstream runtime dependencies to their latest compatible releases. + +## 0.3.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.2.0 + +### Minor Changes + +- c55f5cd: Add the first `@anvia/logger` release with structured logger types, console and Pino logger factories, and an agent observer that logs Anvia run, generation, and tool lifecycle events. + +## 0.1.0 + +### Minor Changes + +- Initial release with structured logger types, console and Pino logger factories, and an agent observer that logs Anvia run, generation, and tool lifecycle events. + diff --git a/apps/docs/content/docs/changelog/meta.json b/apps/docs/content/docs/changelog/meta.json new file mode 100644 index 00000000..a218dfea --- /dev/null +++ b/apps/docs/content/docs/changelog/meta.json @@ -0,0 +1,36 @@ +{ + "title": "Changelog", + "description": "Package release notes", + "icon": "History", + "root": true, + "defaultOpen": false, + "collapsible": true, + "pages": [ + "index", + "---Core---", + "core", + "---Providers---", + "anthropic", + "gemini", + "mistral", + "openai", + "---Embeddings---", + "fastembed", + "transformers", + "---Vector Stores---", + "chroma", + "pgvector", + "qdrant", + "---Logger---", + "logger", + "---Observability---", + "langfuse", + "otel", + "---Tools---", + "studio", + "---React---", + "react", + "---Server---", + "server" + ] +} diff --git a/apps/docs/content/docs/changelog/mistral.mdx b/apps/docs/content/docs/changelog/mistral.mdx new file mode 100644 index 00000000..a45768ba --- /dev/null +++ b/apps/docs/content/docs/changelog/mistral.mdx @@ -0,0 +1,51 @@ +--- +title: "@anvia/mistral" +description: "Release notes for @anvia/mistral." +--- + +# `@anvia/mistral` + +Mistral provider adapter for Anvia. + +Source: [`packages/providers/mistral/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/providers/mistral/CHANGELOG.md) + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.6 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.1.5 + +### Patch Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 + diff --git a/apps/docs/content/docs/changelog/openai.mdx b/apps/docs/content/docs/changelog/openai.mdx new file mode 100644 index 00000000..afe3b907 --- /dev/null +++ b/apps/docs/content/docs/changelog/openai.mdx @@ -0,0 +1,77 @@ +--- +title: "@anvia/openai" +description: "Release notes for @anvia/openai." +--- + +# `@anvia/openai` + +OpenAI provider adapter for Anvia. + +Source: [`packages/providers/openai/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/providers/openai/CHANGELOG.md) + +## 0.3.1 + +### Patch Changes + +- c9728d4: Update upstream runtime dependencies to their latest compatible releases. + +## 0.3.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.2.0 + +### Minor Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +### Patch Changes + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.11 + +### Patch Changes + +- 49e43a3: Update upstream runtime dependencies for Anthropic, Gemini, OpenAI, and Studio. + +## 0.1.10 + +### Patch Changes + +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 + +## 0.1.9 + +### Patch Changes + +- 1f7d3aa: Republish packages with registry-safe dependency metadata. + +## 0.1.8 + +### Patch Changes + +- 1ad360d: Fix Anthropic-compatible streaming tool inputs and update provider dependencies. + diff --git a/apps/docs/content/docs/changelog/otel.mdx b/apps/docs/content/docs/changelog/otel.mdx new file mode 100644 index 00000000..bea826fc --- /dev/null +++ b/apps/docs/content/docs/changelog/otel.mdx @@ -0,0 +1,43 @@ +--- +title: "@anvia/otel" +description: "Release notes for @anvia/otel." +--- + +# `@anvia/otel` + +OpenTelemetry tracing adapter for Anvia. + +Source: [`packages/observability/otel/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/observability/otel/CHANGELOG.md) + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.5 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 + diff --git a/apps/docs/content/docs/changelog/pgvector.mdx b/apps/docs/content/docs/changelog/pgvector.mdx new file mode 100644 index 00000000..e7f62d16 --- /dev/null +++ b/apps/docs/content/docs/changelog/pgvector.mdx @@ -0,0 +1,59 @@ +--- +title: "@anvia/pgvector" +description: "Release notes for @anvia/pgvector." +--- + +# `@anvia/pgvector` + +Postgres pgvector store adapter for Anvia. + +Source: [`packages/vector-stores/pgvector/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/vector-stores/pgvector/CHANGELOG.md) + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.6 + +### Patch Changes + +- b12932d: Update upstream dependencies for PDF loading, globbing, Langfuse tracing, and pgvector support. + + The PDF loader now destroys the `pdfjs-dist` loading task after reading pages, matching the v6 cleanup API. + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.1.5 + +### Patch Changes + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 + +## 0.1.3 + +### Patch Changes + +- 1f7d3aa: Republish packages with registry-safe dependency metadata. + +## 0.1.2 + +### Patch Changes + +- 1ad360d: Fix Anthropic-compatible streaming tool inputs and update provider dependencies. + diff --git a/apps/docs/content/docs/changelog/qdrant.mdx b/apps/docs/content/docs/changelog/qdrant.mdx new file mode 100644 index 00000000..19a1654c --- /dev/null +++ b/apps/docs/content/docs/changelog/qdrant.mdx @@ -0,0 +1,43 @@ +--- +title: "@anvia/qdrant" +description: "Release notes for @anvia/qdrant." +--- + +# `@anvia/qdrant` + +Qdrant vector store adapter for Anvia. + +Source: [`packages/vector-stores/qdrant/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/vector-stores/qdrant/CHANGELOG.md) + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 + diff --git a/apps/docs/content/docs/changelog/react.mdx b/apps/docs/content/docs/changelog/react.mdx new file mode 100644 index 00000000..420ecc8f --- /dev/null +++ b/apps/docs/content/docs/changelog/react.mdx @@ -0,0 +1,23 @@ +--- +title: "@anvia/react" +description: "Release notes for @anvia/react." +--- + +# `@anvia/react` + +React hooks and client transports for Anvia applications. + +Source: [`packages/react/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/react/CHANGELOG.md) + +## 0.3.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +## 0.2.0 + +### Minor Changes + +- eb90638: Add server stream response helpers and React client transports for JSONL and Server-Sent Event agent streams. + diff --git a/apps/docs/content/docs/changelog/server.mdx b/apps/docs/content/docs/changelog/server.mdx new file mode 100644 index 00000000..e74ca21d --- /dev/null +++ b/apps/docs/content/docs/changelog/server.mdx @@ -0,0 +1,23 @@ +--- +title: "@anvia/server" +description: "Release notes for @anvia/server." +--- + +# `@anvia/server` + +Server-side event stream helpers for Anvia applications. + +Source: [`packages/server/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/server/CHANGELOG.md) + +## 0.3.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +## 0.2.0 + +### Minor Changes + +- eb90638: Add server stream response helpers and React client transports for JSONL and Server-Sent Event agent streams. + diff --git a/apps/docs/content/docs/changelog/studio.mdx b/apps/docs/content/docs/changelog/studio.mdx new file mode 100644 index 00000000..487c0968 --- /dev/null +++ b/apps/docs/content/docs/changelog/studio.mdx @@ -0,0 +1,110 @@ +--- +title: "@anvia/studio" +description: "Release notes for @anvia/studio." +--- + +# `@anvia/studio` + +Studio UI and HTTP runtime for Anvia agents. + +Source: [`packages/tools/studio/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/tools/studio/CHANGELOG.md) + +## 0.5.1 + +### Patch Changes + +- c9728d4: Update upstream runtime dependencies to their latest compatible releases. + +## 0.5.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.4.1 + +### Patch Changes + +- 6c53426: Make Studio UI routes consistently use the configured UI path, add the missing Evals shell route, restore the dynamic tools Knowledge tab, and make runtime JSON serialization safe for cyclic model metadata. + +## 0.4.0 + +### Minor Changes + +- b542b87: Add Studio inspection surfaces for memory, runtime status, richer agent metadata, direct tool invocation, pipeline replay controls, realtime observability events, and eval suite runs, with in-memory storage as the default and optional SQLite persistence. + +### Patch Changes + +- b542b87: Allow Studio to accept typed pipelines with arbitrary input and output types, and update the cookbook Studio inspection example to point at the correct UI routes. + +## 0.3.0 + +### Minor Changes + +- e74df22: Add Studio inspection surfaces for memory, runtime status, richer agent metadata, direct tool invocation, pipeline replay controls, realtime observability events, and eval suite runs, with in-memory storage as the default and optional SQLite persistence. + +## 0.2.11 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.2.10 + +### Patch Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.2.9 + +### Patch Changes + +- 49e43a3: Update upstream runtime dependencies for Anthropic, Gemini, OpenAI, and Studio. + +## 0.2.8 + +### Patch Changes + +- 896ae21: Update upstream provider and runtime dependencies. + +## 0.2.7 + +### Patch Changes + +- a0a5def: Lazy-load the default SQLite store so importing Studio does not require `node:sqlite` in Bun-compatible runtimes. +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 + +## 0.2.6 + +### Patch Changes + +- 1f7d3aa: Republish packages with registry-safe dependency metadata. + +## 0.2.5 + +### Patch Changes + +- 1ad360d: Fix Anthropic-compatible streaming tool inputs and update provider dependencies. + +## 0.2.4 + +### Patch Changes + +- 1e5b78d: Polish the Studio UI with updated sidebar, page surfaces, tracing views, playground logs, transcript auto-scroll, and full-width markdown tables. + diff --git a/apps/docs/content/docs/changelog/transformers.mdx b/apps/docs/content/docs/changelog/transformers.mdx new file mode 100644 index 00000000..dd8b6ebf --- /dev/null +++ b/apps/docs/content/docs/changelog/transformers.mdx @@ -0,0 +1,43 @@ +--- +title: "@anvia/transformers" +description: "Release notes for @anvia/transformers." +--- + +# `@anvia/transformers` + +Transformers.js embedding model adapter for Anvia. + +Source: [`packages/embeddings/transformers/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/embeddings/transformers/CHANGELOG.md) + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 + diff --git a/apps/docs/content/docs/frameworks/express/01-prep.mdx b/apps/docs/content/docs/frameworks/express/01-prep.mdx new file mode 100644 index 00000000..16f8d5ac --- /dev/null +++ b/apps/docs/content/docs/frameworks/express/01-prep.mdx @@ -0,0 +1,57 @@ +--- +title: 01 Prep +description: Prepare an Express app for Anvia routes. +--- + +Use this path when Anvia runs inside an existing Express server or a new Node API. + +## 1. Create An Express Project + +```sh +mkdir anvia-express +cd anvia-express +pnpm init +pnpm add express zod +pnpm add -D tsx typescript @types/node @types/express +``` + +## 2. Install Anvia + +```sh +pnpm add @anvia/core @anvia/openai @anvia/server +``` + +Install other provider packages when needed: + +```sh +pnpm add @anvia/anthropic @anvia/gemini @anvia/mistral +``` + +## 3. Add Environment Variables + +```txt +OPENAI_API_KEY=sk_... +``` + +Read the value in server code: + +```ts +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + throw new Error("OPENAI_API_KEY is required"); +} +``` + +## 4. Choose File Boundaries + +| File | Purpose | +| --- | --- | +| `src/ai/support-agent.ts` | Provider client, model, tools, and reusable agent | +| `src/routes/support.ts` | Express router for prompt and stream endpoints | +| `src/middleware/auth.ts` | Request auth and `req.user` enrichment | +| `src/app.ts` | Express app, JSON parser, routers, and error middleware | + +## Next + +Build the reusable agent in [Setup Anvia](/docs/frameworks/express/02-setup-anvia). Read [Runtime Boundaries](/docs/guides/sdk-fundamentals/runtime-boundaries) for where application code should own auth, storage, and side effects. diff --git a/apps/docs/content/docs/frameworks/express/02-setup-anvia.mdx b/apps/docs/content/docs/frameworks/express/02-setup-anvia.mdx new file mode 100644 index 00000000..143f0aee --- /dev/null +++ b/apps/docs/content/docs/frameworks/express/02-setup-anvia.mdx @@ -0,0 +1,66 @@ +--- +title: 02 Setup Anvia +description: Create a reusable Anvia agent module for Express. +--- + +Create provider clients and shared tools outside route handlers. Express routes should call an already configured agent. + +## 1. Create `src/ai/support-agent.ts` + +```ts +import { AgentBuilder, createTool } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; +import { z } from "zod"; + +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + throw new Error("OPENAI_API_KEY is required"); +} + +const client = new OpenAIClient({ apiKey }); +export const model = client.completionModel("gpt-5.5"); + +const lookupPolicy = createTool({ + name: "lookup_policy", + description: "Look up a support policy by key.", + input: z.object({ + key: z.enum(["password_reset", "priority_support"]), + }), + output: z.object({ + text: z.string(), + }), + async execute({ key }) { + const policies = { + password_reset: "Password reset links expire after 30 minutes.", + priority_support: "Enterprise customers receive priority support.", + }; + + return { text: policies[key] }; + }, +}); + +export const supportAgent = new AgentBuilder("support", model) + .name("Support Agent") + .instructions("Answer clearly. Use tools when policy detail is needed.") + .tool(lookupPolicy) + .defaultMaxTurns(3) + .build(); +``` + +## 2. Keep Route State Out Of The Agent + +The shared agent can hold provider configuration and static tools. Request-local auth, database records, and retrieval results should be attached inside routes or route-specific tool factories. + +## 3. Swap Providers Later + +```ts +import { MistralClient } from "@anvia/mistral"; + +const client = new MistralClient({ apiKey: process.env.MISTRAL_API_KEY }); +const model = client.completionModel("mistral-large-latest"); +``` + +## Next + +Expose the agent through an Express router in [Route Handler](/docs/frameworks/express/03-route-handler). Related guides: [Creating Agents](/docs/guides/agents/creating-agents) and [Tools](/docs/guides/tools/creating-tools). diff --git a/apps/docs/content/docs/frameworks/express/03-route-handler.mdx b/apps/docs/content/docs/frameworks/express/03-route-handler.mdx new file mode 100644 index 00000000..15e1bee7 --- /dev/null +++ b/apps/docs/content/docs/frameworks/express/03-route-handler.mdx @@ -0,0 +1,78 @@ +--- +title: 03 Route Handler +description: Return a non-streaming Anvia response from an Express route. +--- + +Use Express middleware for JSON parsing and route handlers for application-owned validation and error shapes. + +## 1. Create `src/routes/support.ts` + +```ts +import { Router } from "express"; +import { z } from "zod"; +import { supportAgent } from "../ai/support-agent"; + +const SupportRequest = z.object({ + message: z.string().trim().min(1, "message is required"), +}); + +export const supportRouter = Router(); + +supportRouter.post("/support", async (req, res, next) => { + try { + const parsed = SupportRequest.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: { code: "bad_request", message: parsed.error.issues[0]?.message }, + }); + } + + const response = await supportAgent.prompt(parsed.data.message).send(); + + return res.json({ + output: response.output, + usage: response.usage, + messages: response.messages, + }); + } catch (error) { + return next(error); + } +}); +``` + +## 2. Mount The Router + +```ts +import express from "express"; +import { supportRouter } from "./routes/support"; + +export const app = express(); + +app.use(express.json({ limit: "1mb" })); +app.use("/api", supportRouter); + +app.use((error: unknown, _req, res, _next) => { + console.error(error); + res.status(500).json({ + error: { code: "agent_failed", message: "The agent run failed." }, + }); +}); +``` + +## 3. Call The Route + +```ts +const response = await fetch("http://localhost:3000/api/support", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "How long does a reset link last?" }), +}); + +const data = await response.json(); +console.log(data.output); +``` + +## Next + +Return live run events in [Streaming](/docs/frameworks/express/04-streaming). For response fields, read [Prompt Responses](/docs/guides/sdk-fundamentals/prompt-responses). diff --git a/apps/docs/content/docs/frameworks/express/04-streaming.mdx b/apps/docs/content/docs/frameworks/express/04-streaming.mdx new file mode 100644 index 00000000..e0bdb864 --- /dev/null +++ b/apps/docs/content/docs/frameworks/express/04-streaming.mdx @@ -0,0 +1,81 @@ +--- +title: 04 Streaming +description: Stream Anvia run events from an Express route. +--- + +Express uses Node response objects. Use `@anvia/server` to create the event stream response, then bridge its Web stream into `res`. + +## 1. Add `/api/support/stream` + +```ts +import { Readable } from "node:stream"; +import { createEventStream } from "@anvia/server"; +import { Router } from "express"; +import { z } from "zod"; +import { supportAgent } from "../ai/support-agent"; + +const SupportStreamRequest = z.object({ + message: z.string().trim().min(1, "message is required"), +}); + +export const supportRouter = Router(); + +supportRouter.post("/support/stream", async (req, res, next) => { + try { + const parsed = SupportStreamRequest.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: { code: "bad_request", message: parsed.error.issues[0]?.message }, + }); + } + + const streamResponse = createEventStream(supportAgent.prompt(parsed.data.message).stream(), { + format: "jsonl", + }); + + streamResponse.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + if (streamResponse.body === null) { + res.end(); + return; + } + + Readable.fromWeb(streamResponse.body).on("error", next).pipe(res); + } catch (error) { + next(error); + } +}); +``` + +## 2. Consume The Stream + +```ts +const response = await fetch("http://localhost:3000/api/support/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "Draft a support reply." }), +}); + +const reader = response.body?.getReader(); +const decoder = new TextDecoder(); + +while (reader) { + const next = await reader.read(); + if (next.done) break; + + for (const line of decoder.decode(next.value).split("\n")) { + if (line.trim()) console.log(JSON.parse(line)); + } +} +``` + +## 3. Operational Notes + +Disable proxy buffering for this route and keep request timeouts long enough for model calls. Clients should handle both `final` and `error` stream events. + +## Next + +Add auth, request-local tools, and retrieval in [Tools and Context](/docs/frameworks/express/05-tools-and-context). Related guides: [Readable Streams](/docs/guides/streaming/readable-streams) and [Streaming Events](/docs/guides/streaming/streaming-events). diff --git a/apps/docs/content/docs/frameworks/express/05-tools-and-context.mdx b/apps/docs/content/docs/frameworks/express/05-tools-and-context.mdx new file mode 100644 index 00000000..29d69888 --- /dev/null +++ b/apps/docs/content/docs/frameworks/express/05-tools-and-context.mdx @@ -0,0 +1,91 @@ +--- +title: 05 Tools and Context +description: Pass Express auth, request data, and retrieval context into Anvia tools. +--- + +Express middleware should authenticate the request. Anvia tools should receive the smallest request-local context they need. + +## 1. Add Auth Middleware + +```ts +import type { NextFunction, Request, Response } from "express"; + +declare global { + namespace Express { + interface Request { + user?: { id: string }; + } + } +} + +export async function requireUser(req: Request, res: Response, next: NextFunction) { + const user = await auth.userFromRequest(req); + + if (!user) { + return res.status(401).json({ error: { code: "unauthorized" } }); + } + + req.user = { id: user.id }; + return next(); +} +``` + +## 2. Build Request-Local Tools + +```ts +import { createTool } from "@anvia/core"; +import { z } from "zod"; + +export function createAccountTool(input: { userId: string }) { + return createTool({ + name: "get_account_status", + description: "Read the authenticated user's account status.", + input: z.object({}), + output: z.object({ plan: z.string(), openTickets: z.number() }), + async execute() { + return db.account.findStatus({ userId: input.userId }); + }, + }); +} +``` + +## 3. Attach Context In The Route + +```ts +supportRouter.post("/support", requireUser, async (req, res, next) => { + try { + const { message } = SupportRequest.parse(req.body); + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ error: { code: "unauthorized" } }); + } + + const response = await supportAgent + .prompt(message) + .tool(createAccountTool({ userId })) + .context({ userId }) + .send(); + + return res.json({ output: response.output }); + } catch (error) { + return next(error); + } +}); +``` + +## 4. Add Retrieval Context + +```ts +const documents = await knowledge.search({ + query: message, + filter: { userId: req.user.id }, + limit: 5, +}); + +const response = await supportAgent.prompt(message).documents(documents).send(); +``` + +## Next + +Persist history in [Persistence](/docs/frameworks/express/06-persistence). Related guides: [Runtime Context](/docs/guides/agents/runtime-context), [Tool Handlers](/docs/guides/tools/tool-handlers), and [RAG Context](/docs/guides/retrieval/rag-context). diff --git a/apps/docs/content/docs/frameworks/express/06-persistence.mdx b/apps/docs/content/docs/frameworks/express/06-persistence.mdx new file mode 100644 index 00000000..106c1e54 --- /dev/null +++ b/apps/docs/content/docs/frameworks/express/06-persistence.mdx @@ -0,0 +1,49 @@ +--- +title: 06 Persistence +description: Persist Express chat history through your app storage. +--- + +Express does not prescribe storage. Load existing messages before the run and store `response.messages` after success. + +## 1. Load The Session + +```ts +const session = await db.chatSession.findUnique({ + where: { id: req.params.sessionId, userId: req.user.id }, + include: { messages: { orderBy: { createdAt: "asc" } } }, +}); + +if (!session) { + return res.status(404).json({ error: { code: "not_found" } }); +} +``` + +## 2. Send With History + +```ts +const response = await supportAgent + .prompt(message) + .messages(session.messages.map((item) => item.message)) + .send(); +``` + +## 3. Store New Messages + +```ts +await db.chatMessage.createMany({ + data: response.messages.map((message) => ({ + sessionId: session.id, + message, + })), +}); +``` + +Keep persistence in your transaction boundary when the app needs message history and related business records to commit together. + +## 4. Use Memory Deliberately + +Use chat history for turn-by-turn continuity. Use Anvia memory when the model should recall durable facts across future sessions. + +## Next + +Prepare runtime constraints in [Deploy](/docs/frameworks/express/07-deploy). Related guides: [Memory](/docs/guides/memory), [Memory and Sessions](/docs/guides/sdk-fundamentals/memory-and-sessions), and [Agent History](/docs/guides/agents/agent-history). diff --git a/apps/docs/content/docs/frameworks/express/07-deploy.mdx b/apps/docs/content/docs/frameworks/express/07-deploy.mdx new file mode 100644 index 00000000..54654ba3 --- /dev/null +++ b/apps/docs/content/docs/frameworks/express/07-deploy.mdx @@ -0,0 +1,45 @@ +--- +title: 07 Deploy +description: Deploy Express Anvia routes in a Node runtime. +--- + +Express runs in Node. Size timeouts, body limits, and proxy buffering for model calls and streams. + +## 1. Start The Server + +```ts +import { app } from "./app"; + +const port = Number(process.env.PORT ?? 3000); + +app.listen(port, () => { + console.log(`listening on :${port}`); +}); +``` + +## 2. Configure Environment Variables + +```txt +OPENAI_API_KEY=sk_... +DATABASE_URL=... +ANVIA_STUDIO_TOKEN=... +``` + +Keep provider keys server-side and inject them through your deployment platform. + +## 3. Streaming Checks + +Disable buffering in reverse proxies for `/api/support/stream`. Keep Node and proxy timeouts longer than expected agent runs. + +## 4. Production Checklist + +| Check | Why | +| --- | --- | +| `express.json` limit set | Avoid unbounded body parsing | +| Error middleware installed | Avoid leaking provider stack traces | +| Proxy buffering disabled | NDJSON streams must flush incrementally | +| Observability enabled | Tool and provider failures need traces | + +## Next + +Debug common failures in [Troubleshooting](/docs/frameworks/express/08-troubleshooting). Add telemetry with [Observability](/docs/guides/observability/tracing). diff --git a/apps/docs/content/docs/frameworks/express/08-troubleshooting.mdx b/apps/docs/content/docs/frameworks/express/08-troubleshooting.mdx new file mode 100644 index 00000000..7aeb7f30 --- /dev/null +++ b/apps/docs/content/docs/frameworks/express/08-troubleshooting.mdx @@ -0,0 +1,49 @@ +--- +title: 08 Troubleshooting +description: Fix common Express and Anvia integration failures. +--- + +Most Express failures come from missing middleware, untyped bodies, stream buffering, or errors bypassing `next(error)`. + +## `req.body` Is Undefined + +Mount JSON parsing before the router: + +```ts +app.use(express.json({ limit: "1mb" })); +app.use("/api", supportRouter); +``` + +## Validation Returns 500 + +Use `safeParse` for request validation. Reserve error middleware for unexpected failures. + +```ts +const parsed = SupportRequest.safeParse(req.body); + +if (!parsed.success) { + return res.status(400).json({ error: { code: "bad_request" } }); +} +``` + +## Stream Does Not Flush + +Use `createEventStream(...)`, pipe the response body to `res`, and check proxy buffering. + +```ts +const streamResponse = createEventStream(agent.prompt(message).stream()); +streamResponse.headers.forEach((value, key) => res.setHeader(key, value)); +Readable.fromWeb(streamResponse.body).pipe(res); +``` + +## Provider Failures Leak Details + +Route handlers should call `next(error)`, and centralized error middleware should return a stable error shape. + +## Request Times Out + +Raise Node, proxy, and platform timeouts for agent endpoints. For long approvals, use human-in-the-loop storage instead of holding an HTTP request open indefinitely. + +## Next + +Add reviewer workflows in [Human in the Loop](/docs/frameworks/express/09-human-in-the-loop). Related guides: [Tool Errors](/docs/guides/tools/tool-errors), [Readable Streams](/docs/guides/streaming/readable-streams), and [Tracing](/docs/guides/observability/tracing). diff --git a/apps/docs/content/docs/frameworks/express/09-human-in-the-loop.mdx b/apps/docs/content/docs/frameworks/express/09-human-in-the-loop.mdx new file mode 100644 index 00000000..21da49e5 --- /dev/null +++ b/apps/docs/content/docs/frameworks/express/09-human-in-the-loop.mdx @@ -0,0 +1,140 @@ +--- +title: 09 Human in the Loop +description: Add approvals and reviewer decisions to Express Anvia routes. +--- + +Express can expose agent routes and reviewer routes from the same server. Anvia provides hooks; your app provides approval storage and reviewer workflows. + +## 1. Use Studio During Development + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "../ai/support-agent"; + +new Studio([supportAgent]).start({ port: 4021 }); +``` + +Studio helps inspect pending approvals locally. Production reviewer permissions and notifications belong to your Express app. + +## 2. Create A Hook + +```ts +import { createHook } from "@anvia/core"; +import { approvalRuntime } from "../approvals/runtime"; + +export function createApprovalHook(input: { userId: string; approvalRunId: string }) { + return createHook({ + async onToolCall({ toolName, args, tool }) { + if (toolName !== "refund_order") { + return tool.run(); + } + + const approved = await approvalRuntime.waitForDecision({ + userId: input.userId, + approvalRunId: input.approvalRunId, + toolName, + args, + }); + + return approved ? tool.run() : tool.skip("Refund was not approved."); + }, + }); +} +``` + +`approvalRuntime` is not imported from Anvia. It is your module for database records, notifications, reviewer UI, and waiter resolution. + +## 3. Create The Approval Runtime + +```ts +type ApprovalRequest = { + userId: string; + approvalRunId: string; + toolName: string; + args: string; +}; + +type ApprovalDecision = { + approved: boolean; + reason?: string; +}; + +export function createApprovalRuntime() { + const waiters = new Map void>(); + + return { + async waitForDecision(request: ApprovalRequest): Promise { + const approval = await db.approval.create({ + data: { ...request, status: "pending" }, + }); + + await notifyReviewers({ approvalId: approval.id }); + + const decision = await new Promise((resolve) => { + waiters.set(approval.id, resolve); + }); + + waiters.delete(approval.id); + return decision.approved; + }, + + async listPendingForReviewer(reviewerId: string) { + return db.approval.findMany({ + where: { reviewerId, status: "pending" }, + orderBy: { createdAt: "asc" }, + }); + }, + + async decide(input: { approvalId: string; approved: boolean; reason?: string }) { + await db.approval.update({ + where: { id: input.approvalId }, + data: { + status: input.approved ? "approved" : "rejected", + decisionReason: input.reason, + resolvedAt: new Date(), + }, + }); + + waiters.get(input.approvalId)?.({ + approved: input.approved, + reason: input.reason, + }); + }, + }; +} + +export const approvalRuntime = createApprovalRuntime(); +``` + +Use durable storage plus queue, pub/sub, websocket, or polling workers for production. The `Map` only works inside one process. + +## 4. Add Reviewer Routes + +```ts +const DecisionRequest = z.object({ + approved: z.boolean(), + reason: z.string().optional(), +}); + +supportRouter.get("/approvals", requireUser, async (req, res, next) => { + try { + res.json(await approvalRuntime.listPendingForReviewer(req.user.id)); + } catch (error) { + next(error); + } +}); + +supportRouter.post("/approvals/:id/decision", requireUser, async (req, res, next) => { + try { + const decision = DecisionRequest.parse(req.body); + await approvalRuntime.decide({ approvalId: req.params.id, ...decision }); + res.json({ ok: true }); + } catch (error) { + next(error); + } +}); +``` + +## Next + +Add route tests in [Setup Tests](/docs/frameworks/express/10-setup-tests). Core concepts: [Human in the Loop](/docs/guides/human-in-the-loop), [Approval by Hooks](/docs/guides/human-in-the-loop/tool-approval), and [Approval Runtimes](/docs/guides/human-in-the-loop/approval-handlers). diff --git a/apps/docs/content/docs/frameworks/express/10-setup-tests.mdx b/apps/docs/content/docs/frameworks/express/10-setup-tests.mdx new file mode 100644 index 00000000..eb590e75 --- /dev/null +++ b/apps/docs/content/docs/frameworks/express/10-setup-tests.mdx @@ -0,0 +1,78 @@ +--- +title: 10 Setup Tests +description: Test Express Anvia routes, streams, and provider boundaries. +--- + +Use route tests with mocked agents. Keep provider calls behind explicit integration tests. + +## 1. Install Test Tools + +```sh +pnpm add -D vitest supertest @types/supertest +``` + +## 2. Test The JSON Route + +```ts +import request from "supertest"; +import { describe, expect, it, vi } from "vitest"; +import { app } from "../src/app"; + +vi.mock("../src/ai/support-agent", () => ({ + supportAgent: { + prompt: () => ({ + send: async () => ({ + output: "Reset links expire after 30 minutes.", + usage: { totalTokens: 12 }, + messages: [], + }), + }), + }, +})); + +describe("POST /api/support", () => { + it("returns the agent output", async () => { + const response = await request(app) + .post("/api/support") + .send({ message: "How long does a reset link last?" }) + .expect(200); + + expect(response.body.output).toBe("Reset links expire after 30 minutes."); + }); +}); +``` + +## 3. Test The Stream Route + +```ts +const response = await request(app) + .post("/api/support/stream") + .send({ message: "Hello" }) + .expect(200); + +expect(response.headers["content-type"]).toContain("application/x-ndjson"); +``` + +Mock `stream()` with a small async iterable that emits one `final` event. + +## 4. Test Studio Without A Port + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "../src/ai/support-agent"; + +const studio = new Studio([supportAgent]); +const response = await studio.fetch( + new Request("http://studio.test/agents/support/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "Hello" }), + }), +); + +expect(response.status).toBe(200); +``` + +## Next + +Related guides: [Testing](/docs/guides/testing), [Tools and Pipelines](/docs/guides/testing/tools-and-pipelines), and [Studio and Providers](/docs/guides/testing/studio-and-providers). diff --git a/apps/docs/content/docs/frameworks/express/meta.json b/apps/docs/content/docs/frameworks/express/meta.json new file mode 100644 index 00000000..6316ca17 --- /dev/null +++ b/apps/docs/content/docs/frameworks/express/meta.json @@ -0,0 +1,17 @@ +{ + "title": "Express", + "defaultOpen": false, + "collapsible": true, + "pages": [ + "01-prep", + "02-setup-anvia", + "03-route-handler", + "04-streaming", + "05-tools-and-context", + "06-persistence", + "07-deploy", + "08-troubleshooting", + "09-human-in-the-loop", + "10-setup-tests" + ] +} diff --git a/apps/docs/content/docs/frameworks/fastify/01-prep.mdx b/apps/docs/content/docs/frameworks/fastify/01-prep.mdx new file mode 100644 index 00000000..a1d6ec4e --- /dev/null +++ b/apps/docs/content/docs/frameworks/fastify/01-prep.mdx @@ -0,0 +1,57 @@ +--- +title: 01 Prep +description: Prepare a Fastify app for Anvia routes. +--- + +Use this path when Anvia runs inside a Fastify API or plugin-based Node service. + +## 1. Create A Fastify Project + +```sh +mkdir anvia-fastify +cd anvia-fastify +pnpm init +pnpm add fastify fastify-plugin zod +pnpm add -D tsx typescript @types/node +``` + +## 2. Install Anvia + +```sh +pnpm add @anvia/core @anvia/openai @anvia/server +``` + +Install other provider packages when needed: + +```sh +pnpm add @anvia/anthropic @anvia/gemini @anvia/mistral +``` + +## 3. Add Environment Variables + +```txt +OPENAI_API_KEY=sk_... +``` + +Read the value in server code: + +```ts +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + throw new Error("OPENAI_API_KEY is required"); +} +``` + +## 4. Choose File Boundaries + +| File | Purpose | +| --- | --- | +| `src/ai/support-agent.ts` | Provider client, model, tools, and reusable agent | +| `src/routes/support.ts` | Fastify plugin with prompt and stream routes | +| `src/plugins/auth.ts` | Auth decoration and hooks | +| `src/app.ts` | Fastify instance and plugin registration | + +## Next + +Build the reusable agent in [Setup Anvia](/docs/frameworks/fastify/02-setup-anvia). Read [Runtime Boundaries](/docs/guides/sdk-fundamentals/runtime-boundaries) for the SDK and application boundaries. diff --git a/apps/docs/content/docs/frameworks/fastify/02-setup-anvia.mdx b/apps/docs/content/docs/frameworks/fastify/02-setup-anvia.mdx new file mode 100644 index 00000000..48e52e8f --- /dev/null +++ b/apps/docs/content/docs/frameworks/fastify/02-setup-anvia.mdx @@ -0,0 +1,66 @@ +--- +title: 02 Setup Anvia +description: Create a reusable Anvia agent module for Fastify. +--- + +Create provider clients and shared agents outside Fastify route handlers. Register request-local tools inside routes. + +## 1. Create `src/ai/support-agent.ts` + +```ts +import { AgentBuilder, createTool } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; +import { z } from "zod"; + +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + throw new Error("OPENAI_API_KEY is required"); +} + +const client = new OpenAIClient({ apiKey }); +export const model = client.completionModel("gpt-5.5"); + +const lookupPolicy = createTool({ + name: "lookup_policy", + description: "Look up a support policy by key.", + input: z.object({ + key: z.enum(["password_reset", "priority_support"]), + }), + output: z.object({ + text: z.string(), + }), + async execute({ key }) { + const policies = { + password_reset: "Password reset links expire after 30 minutes.", + priority_support: "Enterprise customers receive priority support.", + }; + + return { text: policies[key] }; + }, +}); + +export const supportAgent = new AgentBuilder("support", model) + .name("Support Agent") + .instructions("Answer clearly. Use tools when policy detail is needed.") + .tool(lookupPolicy) + .defaultMaxTurns(3) + .build(); +``` + +## 2. Keep The Fastify Instance Separate + +The agent module should not import `FastifyInstance`. This keeps it reusable from routes, jobs, Studio, and tests. + +## 3. Swap Providers Later + +```ts +import { GeminiClient } from "@anvia/gemini"; + +const client = new GeminiClient({ apiKey: process.env.GEMINI_API_KEY }); +const model = client.completionModel("gemini-2.5-pro"); +``` + +## Next + +Expose the agent through a Fastify plugin in [Route Handler](/docs/frameworks/fastify/03-route-handler). Related guides: [Creating Agents](/docs/guides/agents/creating-agents) and [Tools](/docs/guides/tools/creating-tools). diff --git a/apps/docs/content/docs/frameworks/fastify/03-route-handler.mdx b/apps/docs/content/docs/frameworks/fastify/03-route-handler.mdx new file mode 100644 index 00000000..99b51b79 --- /dev/null +++ b/apps/docs/content/docs/frameworks/fastify/03-route-handler.mdx @@ -0,0 +1,66 @@ +--- +title: 03 Route Handler +description: Return a non-streaming Anvia response from a Fastify route. +--- + +Fastify routes can live in plugins. Validate unknown bodies with `zod` before calling the agent. + +## 1. Create `src/routes/support.ts` + +```ts +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { supportAgent } from "../ai/support-agent"; + +const SupportRequest = z.object({ + message: z.string().trim().min(1, "message is required"), +}); + +export async function supportRoutes(app: FastifyInstance) { + app.post("/support", async (request, reply) => { + const parsed = SupportRequest.safeParse(request.body); + + if (!parsed.success) { + return reply.status(400).send({ + error: { code: "bad_request", message: parsed.error.issues[0]?.message }, + }); + } + + const response = await supportAgent.prompt(parsed.data.message).send(); + + return reply.send({ + output: response.output, + usage: response.usage, + messages: response.messages, + }); + }); +} +``` + +## 2. Register The Plugin + +```ts +import Fastify from "fastify"; +import { supportRoutes } from "./routes/support"; + +export const app = Fastify({ logger: true }); + +await app.register(supportRoutes, { prefix: "/api" }); +``` + +## 3. Call The Route + +```ts +const response = await fetch("http://localhost:3000/api/support", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "How long does a reset link last?" }), +}); + +const data = await response.json(); +console.log(data.output); +``` + +## Next + +Return live run events in [Streaming](/docs/frameworks/fastify/04-streaming). For response fields, read [Prompt Responses](/docs/guides/sdk-fundamentals/prompt-responses). diff --git a/apps/docs/content/docs/frameworks/fastify/04-streaming.mdx b/apps/docs/content/docs/frameworks/fastify/04-streaming.mdx new file mode 100644 index 00000000..312ed0f6 --- /dev/null +++ b/apps/docs/content/docs/frameworks/fastify/04-streaming.mdx @@ -0,0 +1,76 @@ +--- +title: 04 Streaming +description: Stream Anvia run events from a Fastify route. +--- + +Fastify replies can send stream-like payloads. Use `@anvia/server` to serialize Anvia events and set the correct stream headers. + +## 1. Add `/api/support/stream` + +```ts +import { Readable } from "node:stream"; +import { createEventStream } from "@anvia/server"; +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { supportAgent } from "../ai/support-agent"; + +const SupportStreamRequest = z.object({ + message: z.string().trim().min(1, "message is required"), +}); + +export async function supportRoutes(app: FastifyInstance) { + app.post("/support/stream", async (request, reply) => { + const parsed = SupportStreamRequest.safeParse(request.body); + + if (!parsed.success) { + return reply.status(400).send({ + error: { code: "bad_request", message: parsed.error.issues[0]?.message }, + }); + } + + const streamResponse = createEventStream(supportAgent.prompt(parsed.data.message).stream(), { + format: "jsonl", + }); + + streamResponse.headers.forEach((value, key) => { + reply.header(key, value); + }); + + if (streamResponse.body === null) { + return reply.status(streamResponse.status).send(); + } + + return reply.status(streamResponse.status).send(Readable.fromWeb(streamResponse.body)); + }); +} +``` + +## 2. Consume The Stream + +```ts +const response = await fetch("http://localhost:3000/api/support/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "Draft a support reply." }), +}); + +const reader = response.body?.getReader(); +const decoder = new TextDecoder(); + +while (reader) { + const next = await reader.read(); + if (next.done) break; + + for (const line of decoder.decode(next.value).split("\n")) { + if (line.trim()) console.log(JSON.parse(line)); + } +} +``` + +## 3. Operational Notes + +Keep reverse proxies from buffering NDJSON responses. Clients should handle both `final` and `error` events. + +## Next + +Add auth, request-local tools, and retrieval in [Tools and Context](/docs/frameworks/fastify/05-tools-and-context). Related guides: [Readable Streams](/docs/guides/streaming/readable-streams) and [Streaming Events](/docs/guides/streaming/streaming-events). diff --git a/apps/docs/content/docs/frameworks/fastify/05-tools-and-context.mdx b/apps/docs/content/docs/frameworks/fastify/05-tools-and-context.mdx new file mode 100644 index 00000000..046b0be7 --- /dev/null +++ b/apps/docs/content/docs/frameworks/fastify/05-tools-and-context.mdx @@ -0,0 +1,86 @@ +--- +title: 05 Tools and Context +description: Pass Fastify auth, request data, and retrieval context into Anvia tools. +--- + +Use Fastify decorators or hooks for auth. Pass request-local data into Anvia at the route boundary. + +## 1. Add A User Decoration + +```ts +import fp from "fastify-plugin"; + +declare module "fastify" { + interface FastifyRequest { + user?: { id: string }; + } +} + +export const authPlugin = fp(async (app) => { + app.addHook("preHandler", async (request, reply) => { + const user = await auth.userFromRequest(request); + + if (!user) { + return reply.status(401).send({ error: { code: "unauthorized" } }); + } + + request.user = { id: user.id }; + }); +}); +``` + +## 2. Build Request-Local Tools + +```ts +import { createTool } from "@anvia/core"; +import { z } from "zod"; + +export function createAccountTool(input: { userId: string }) { + return createTool({ + name: "get_account_status", + description: "Read the authenticated user's account status.", + input: z.object({}), + output: z.object({ plan: z.string(), openTickets: z.number() }), + async execute() { + return db.account.findStatus({ userId: input.userId }); + }, + }); +} +``` + +## 3. Attach Context In The Route + +```ts +app.post("/support", async (request, reply) => { + const { message } = SupportRequest.parse(request.body); + const userId = request.user?.id; + + if (!userId) { + return reply.status(401).send({ error: { code: "unauthorized" } }); + } + + const response = await supportAgent + .prompt(message) + .tool(createAccountTool({ userId })) + .context({ userId }) + .send(); + + return reply.send({ output: response.output }); +}); +``` + +## 4. Add Retrieval Context + +```ts +const documents = await knowledge.search({ + query: message, + filter: { userId: request.user.id }, + limit: 5, +}); + +const response = await supportAgent.prompt(message).documents(documents).send(); +``` + +## Next + +Persist history in [Persistence](/docs/frameworks/fastify/06-persistence). Related guides: [Runtime Context](/docs/guides/agents/runtime-context), [Tool Handlers](/docs/guides/tools/tool-handlers), and [RAG Context](/docs/guides/retrieval/rag-context). diff --git a/apps/docs/content/docs/frameworks/fastify/06-persistence.mdx b/apps/docs/content/docs/frameworks/fastify/06-persistence.mdx new file mode 100644 index 00000000..614e454b --- /dev/null +++ b/apps/docs/content/docs/frameworks/fastify/06-persistence.mdx @@ -0,0 +1,49 @@ +--- +title: 06 Persistence +description: Persist Fastify chat history through your app storage. +--- + +Fastify gives you routing and lifecycle hooks. Your app owns session storage and message persistence. + +## 1. Load Existing Messages + +```ts +const session = await db.chatSession.findUnique({ + where: { id: request.params.sessionId, userId: request.user.id }, + include: { messages: { orderBy: { createdAt: "asc" } } }, +}); + +if (!session) { + return reply.status(404).send({ error: { code: "not_found" } }); +} +``` + +## 2. Send With History + +```ts +const response = await supportAgent + .prompt(message) + .messages(session.messages.map((item) => item.message)) + .send(); +``` + +## 3. Store New Messages + +```ts +await db.chatMessage.createMany({ + data: response.messages.map((message) => ({ + sessionId: session.id, + message, + })), +}); +``` + +Store messages only after the run succeeds. Use a transaction when message writes must commit with application state changes. + +## 4. Use Memory When The Model Should Remember + +Use chat history for conversation continuity. Use Anvia memory for durable facts that should be recalled across future runs. + +## Next + +Prepare runtime constraints in [Deploy](/docs/frameworks/fastify/07-deploy). Related guides: [Memory](/docs/guides/memory), [Memory and Sessions](/docs/guides/sdk-fundamentals/memory-and-sessions), and [Agent History](/docs/guides/agents/agent-history). diff --git a/apps/docs/content/docs/frameworks/fastify/07-deploy.mdx b/apps/docs/content/docs/frameworks/fastify/07-deploy.mdx new file mode 100644 index 00000000..ccc4a0f7 --- /dev/null +++ b/apps/docs/content/docs/frameworks/fastify/07-deploy.mdx @@ -0,0 +1,41 @@ +--- +title: 07 Deploy +description: Deploy Fastify Anvia routes in a Node runtime. +--- + +Fastify is a good fit for long-lived Node services. Configure timeouts and stream behavior explicitly. + +## 1. Start The Server + +```ts +import { app } from "./app"; + +const port = Number(process.env.PORT ?? 3000); + +await app.listen({ host: "0.0.0.0", port }); +``` + +## 2. Configure Environment Variables + +```txt +OPENAI_API_KEY=sk_... +DATABASE_URL=... +ANVIA_STUDIO_TOKEN=... +``` + +## 3. Streaming Checks + +Confirm `application/x-ndjson` responses flush through your proxy and hosting layer. + +## 4. Production Checklist + +| Check | Why | +| --- | --- | +| Body limits configured | Avoid unbounded JSON requests | +| Error handler installed | Keep provider errors out of response bodies | +| Stream proxy behavior tested | NDJSON needs incremental flushing | +| Tracing connected | Tool and provider runs need observability | + +## Next + +Debug common failures in [Troubleshooting](/docs/frameworks/fastify/08-troubleshooting). Add telemetry with [Observability](/docs/guides/observability/tracing). diff --git a/apps/docs/content/docs/frameworks/fastify/08-troubleshooting.mdx b/apps/docs/content/docs/frameworks/fastify/08-troubleshooting.mdx new file mode 100644 index 00000000..5ad9732d --- /dev/null +++ b/apps/docs/content/docs/frameworks/fastify/08-troubleshooting.mdx @@ -0,0 +1,49 @@ +--- +title: 08 Troubleshooting +description: Fix common Fastify and Anvia integration failures. +--- + +Most Fastify issues come from missing body validation, plugin ordering, or streams being buffered by infrastructure. + +## Auth Is Not Available In Routes + +Register auth plugins before support routes: + +```ts +await app.register(authPlugin); +await app.register(supportRoutes, { prefix: "/api" }); +``` + +## Validation Returns 500 + +Validate unknown bodies with `safeParse` and return a 400 from the route. + +```ts +const parsed = SupportRequest.safeParse(request.body); + +if (!parsed.success) { + return reply.status(400).send({ error: { code: "bad_request" } }); +} +``` + +## Stream Does Not Flush + +Set the NDJSON content type and check proxy buffering. + +```ts +const streamResponse = createEventStream(agent.prompt(message).stream()); +streamResponse.headers.forEach((value, key) => reply.header(key, value)); +return reply.send(Readable.fromWeb(streamResponse.body)); +``` + +## Provider Failures Leak Details + +Install a Fastify error handler that logs internal details and returns a stable application error shape. + +## Long Runs Time Out + +Increase application, proxy, and platform timeouts. For reviewer waits, store approvals and resume through a decision endpoint. + +## Next + +Add reviewer workflows in [Human in the Loop](/docs/frameworks/fastify/09-human-in-the-loop). Related guides: [Tool Errors](/docs/guides/tools/tool-errors), [Readable Streams](/docs/guides/streaming/readable-streams), and [Tracing](/docs/guides/observability/tracing). diff --git a/apps/docs/content/docs/frameworks/fastify/09-human-in-the-loop.mdx b/apps/docs/content/docs/frameworks/fastify/09-human-in-the-loop.mdx new file mode 100644 index 00000000..453332cd --- /dev/null +++ b/apps/docs/content/docs/frameworks/fastify/09-human-in-the-loop.mdx @@ -0,0 +1,127 @@ +--- +title: 09 Human in the Loop +description: Add approvals and reviewer decisions to Fastify Anvia routes. +--- + +Fastify plugins can expose agent routes and approval routes together. Anvia supplies hooks; your app supplies approval storage and reviewer permissions. + +## 1. Use Studio During Development + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "../ai/support-agent"; + +new Studio([supportAgent]).start({ port: 4021 }); +``` + +Studio helps inspect approvals locally. Production approval storage and reviewer workflow belong to your app. + +## 2. Create A Hook + +```ts +import { createHook } from "@anvia/core"; +import { approvalRuntime } from "../approvals/runtime"; + +export function createApprovalHook(input: { userId: string; approvalRunId: string }) { + return createHook({ + async onToolCall({ toolName, args, tool }) { + if (toolName !== "refund_order") { + return tool.run(); + } + + const approved = await approvalRuntime.waitForDecision({ + userId: input.userId, + approvalRunId: input.approvalRunId, + toolName, + args, + }); + + return approved ? tool.run() : tool.skip("Refund was not approved."); + }, + }); +} +``` + +`approvalRuntime` is user code. It is not exported by Anvia. + +## 3. Create The Approval Runtime + +```ts +type ApprovalRequest = { + userId: string; + approvalRunId: string; + toolName: string; + args: string; +}; + +type ApprovalDecision = { + approved: boolean; + reason?: string; +}; + +export function createApprovalRuntime() { + const waiters = new Map void>(); + + return { + async waitForDecision(request: ApprovalRequest): Promise { + const approval = await db.approval.create({ + data: { ...request, status: "pending" }, + }); + + await notifyReviewers({ approvalId: approval.id }); + + const decision = await new Promise((resolve) => { + waiters.set(approval.id, resolve); + }); + + waiters.delete(approval.id); + return decision.approved; + }, + + async decide(input: { approvalId: string; approved: boolean; reason?: string }) { + await db.approval.update({ + where: { id: input.approvalId }, + data: { + status: input.approved ? "approved" : "rejected", + decisionReason: input.reason, + resolvedAt: new Date(), + }, + }); + + waiters.get(input.approvalId)?.({ + approved: input.approved, + reason: input.reason, + }); + }, + }; +} + +export const approvalRuntime = createApprovalRuntime(); +``` + +Use durable storage and an external wakeup mechanism in production. + +## 4. Add Reviewer Routes + +```ts +const DecisionRequest = z.object({ + approved: z.boolean(), + reason: z.string().optional(), +}); + +app.post("/approvals/:id/decision", async (request, reply) => { + const decision = DecisionRequest.parse(request.body); + const params = request.params as { id: string }; + + await approvalRuntime.decide({ + approvalId: params.id, + ...decision, + }); + + return reply.send({ ok: true }); +}); +``` + +## Next + +Add route tests in [Setup Tests](/docs/frameworks/fastify/10-setup-tests). Core concepts: [Human in the Loop](/docs/guides/human-in-the-loop), [Approval by Hooks](/docs/guides/human-in-the-loop/tool-approval), and [Approval Runtimes](/docs/guides/human-in-the-loop/approval-handlers). diff --git a/apps/docs/content/docs/frameworks/fastify/10-setup-tests.mdx b/apps/docs/content/docs/frameworks/fastify/10-setup-tests.mdx new file mode 100644 index 00000000..04b448b7 --- /dev/null +++ b/apps/docs/content/docs/frameworks/fastify/10-setup-tests.mdx @@ -0,0 +1,80 @@ +--- +title: 10 Setup Tests +description: Test Fastify Anvia routes, streams, and provider boundaries. +--- + +Use Fastify `inject` for route tests. Mock the agent so unit tests do not call providers. + +## 1. Install Test Tools + +```sh +pnpm add -D vitest +``` + +## 2. Test The JSON Route + +```ts +import { describe, expect, it, vi } from "vitest"; +import { app } from "../src/app"; + +vi.mock("../src/ai/support-agent", () => ({ + supportAgent: { + prompt: () => ({ + send: async () => ({ + output: "Reset links expire after 30 minutes.", + usage: { totalTokens: 12 }, + messages: [], + }), + }), + }, +})); + +describe("POST /api/support", () => { + it("returns the agent output", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/support", + payload: { message: "How long does a reset link last?" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().output).toBe("Reset links expire after 30 minutes."); + }); +}); +``` + +## 3. Test The Stream Route + +```ts +const response = await app.inject({ + method: "POST", + url: "/api/support/stream", + payload: { message: "Hello" }, +}); + +expect(response.headers["content-type"]).toContain("application/x-ndjson"); +``` + +Mock `stream()` with a small async iterable that emits one `final` event. + +## 4. Test Studio Without A Port + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "../src/ai/support-agent"; + +const studio = new Studio([supportAgent]); +const response = await studio.fetch( + new Request("http://studio.test/agents/support/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "Hello" }), + }), +); + +expect(response.status).toBe(200); +``` + +## Next + +Related guides: [Testing](/docs/guides/testing), [Tools and Pipelines](/docs/guides/testing/tools-and-pipelines), and [Studio and Providers](/docs/guides/testing/studio-and-providers). diff --git a/apps/docs/content/docs/frameworks/fastify/meta.json b/apps/docs/content/docs/frameworks/fastify/meta.json new file mode 100644 index 00000000..a6462cdf --- /dev/null +++ b/apps/docs/content/docs/frameworks/fastify/meta.json @@ -0,0 +1,17 @@ +{ + "title": "Fastify", + "defaultOpen": false, + "collapsible": true, + "pages": [ + "01-prep", + "02-setup-anvia", + "03-route-handler", + "04-streaming", + "05-tools-and-context", + "06-persistence", + "07-deploy", + "08-troubleshooting", + "09-human-in-the-loop", + "10-setup-tests" + ] +} diff --git a/apps/docs/content/docs/frameworks/hono/01-prep.mdx b/apps/docs/content/docs/frameworks/hono/01-prep.mdx new file mode 100644 index 00000000..758e3591 --- /dev/null +++ b/apps/docs/content/docs/frameworks/hono/01-prep.mdx @@ -0,0 +1,60 @@ +--- +title: 01 Prep +description: Prepare a Hono app for Anvia HTTP routes. +--- + +Use this path when Anvia will run behind a plain Hono server or a framework that exposes Hono routes. + +## 1. Create a Hono Project + +```sh +mkdir anvia-hono +cd anvia-hono +pnpm init +pnpm add hono @hono/node-server +pnpm add -D tsx typescript @types/node +``` + +## 2. Install Anvia + +```sh +pnpm add @anvia/core @anvia/openai @anvia/server @hono/zod-validator zod +``` + +Install other provider packages when you need them: + +```sh +pnpm add @anvia/anthropic @anvia/gemini @anvia/mistral +``` + +## 3. Add Environment Variables + +Anvia clients use explicit constructor options. + +```txt +OPENAI_API_KEY=sk_... +``` + +Read the value in server code: + +```ts +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + throw new Error("OPENAI_API_KEY is required"); +} +``` + +## 4. Choose File Boundaries + +| File | Purpose | +| --- | --- | +| `src/ai/support-agent.ts` | Provider client, model, tools, and reusable agent | +| `src/app.ts` | Hono app and routes | +| `src/server.ts` | Node server entry point | + +Hono handlers receive `c.req`, but they can also return standard Web `Response` objects. That makes Anvia streaming direct. + +## Next + +Build the reusable agent in [Setup Anvia](/docs/frameworks/hono/02-setup-anvia). Read [How Anvia Works](/docs/guides/sdk-fundamentals/runtime-boundaries) for the SDK boundaries. diff --git a/apps/docs/content/docs/frameworks/hono/02-setup-anvia.mdx b/apps/docs/content/docs/frameworks/hono/02-setup-anvia.mdx new file mode 100644 index 00000000..1f69b328 --- /dev/null +++ b/apps/docs/content/docs/frameworks/hono/02-setup-anvia.mdx @@ -0,0 +1,80 @@ +--- +title: 02 Setup Anvia +description: Create a reusable Anvia agent module for Hono routes. +--- + +Create provider clients, models, and shared tools outside the Hono handler when their configuration is the same for every request. + +## 1. Create `src/ai/support-agent.ts` + +```ts +import { AgentBuilder, createTool } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; +import { z } from "zod"; + +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + throw new Error("OPENAI_API_KEY is required"); +} + +const client = new OpenAIClient({ apiKey }); +export const model = client.completionModel("gpt-5.5"); + +const lookupPolicy = createTool({ + name: "lookup_policy", + description: "Look up a short support policy by key.", + input: z.object({ + key: z.enum(["password_reset", "priority_support"]), + }), + output: z.object({ + text: z.string(), + }), + async execute({ key }) { + const policies = { + password_reset: "Password reset links expire after 30 minutes.", + priority_support: "Enterprise customers receive priority support.", + }; + + return { text: policies[key] }; + }, +}); + +export const supportAgent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly. Use tools for policy facts.") + .tool(lookupPolicy) + .defaultMaxTurns(3) + .build(); +``` + +## 2. Create `src/app.ts` + +```ts +import { Hono } from "hono"; + +export const app = new Hono(); + +app.get("/health", (c) => c.json({ ok: true })); +``` + +## 3. Create `src/server.ts` + +```ts +import { serve } from "@hono/node-server"; +import { app } from "./app"; + +serve({ + fetch: app.fetch, + port: 3000, +}); +``` + +Run it: + +```sh +pnpm exec tsx src/server.ts +``` + +## Next + +Expose the agent through a JSON route in [Route Handler](/docs/frameworks/hono/03-route-handler). Related guides: [Creating Agents](/docs/guides/agents/creating-agents), [Tools](/docs/guides/tools/creating-tools), and [Provider Clients](/docs/guides/sdk-fundamentals/clients-and-models). diff --git a/apps/docs/content/docs/frameworks/hono/03-route-handler.mdx b/apps/docs/content/docs/frameworks/hono/03-route-handler.mdx new file mode 100644 index 00000000..64944a7a --- /dev/null +++ b/apps/docs/content/docs/frameworks/hono/03-route-handler.mdx @@ -0,0 +1,59 @@ +--- +title: 03 Route Handler +description: Return a non-streaming Anvia response from a Hono route. +--- + +Validate JSON at the Hono boundary with `zValidator(...)`, then read the typed body with `c.req.valid("json")`. + +## 1. Add `/api/support` + +```ts +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { supportAgent } from "./ai/support-agent"; + +export const app = new Hono(); + +const SupportRequest = z.object({ + message: z.string().trim().min(1, "message is required"), +}); + +app.post("/api/support", zValidator("json", SupportRequest), async (c) => { + const { message } = c.req.valid("json"); + const response = await supportAgent.prompt(message).send(); + + return c.json({ + output: response.output, + usage: response.usage, + messages: response.messages, + }); +}); +``` + +## 2. Call The Route + +```sh +curl -X POST http://localhost:3000/api/support \ + -H "Content-Type: application/json" \ + -d '{"message":"How long does a reset link last?"}' +``` + +## 3. Return Structured Failures + +```ts +app.post("/api/support", zValidator("json", SupportRequest), async (c) => { + try { + const { message } = c.req.valid("json"); + const response = await supportAgent.prompt(message).send(); + return c.json({ output: response.output }); + } catch (error) { + console.error(error); + return c.json({ error: "agent_failed" }, 500); + } +}); +``` + +## Next + +Return live events in [Streaming](/docs/frameworks/hono/04-streaming). For prompt response fields, read [Prompt Responses](/docs/guides/sdk-fundamentals/prompt-responses). diff --git a/apps/docs/content/docs/frameworks/hono/04-streaming.mdx b/apps/docs/content/docs/frameworks/hono/04-streaming.mdx new file mode 100644 index 00000000..7c3d349f --- /dev/null +++ b/apps/docs/content/docs/frameworks/hono/04-streaming.mdx @@ -0,0 +1,54 @@ +--- +title: 04 Streaming +description: Stream Anvia run events from a Hono route. +--- + +Hono handlers can return a standard `Response`, so `@anvia/server` can serialize Anvia events directly. + +## 1. Add `/api/support/stream` + +```ts +import { zValidator } from "@hono/zod-validator"; +import { createEventStream } from "@anvia/server"; +import { z } from "zod"; +import { supportAgent } from "./ai/support-agent"; + +const SupportStreamRequest = z.object({ + message: z.string().trim().min(1, "message is required"), +}); + +app.post("/api/support/stream", zValidator("json", SupportStreamRequest), async (c) => { + const { message } = c.req.valid("json"); + return createEventStream(supportAgent.prompt(message).stream(), { format: "jsonl" }); +}); +``` + +## 2. Consume The Stream + +```ts +const response = await fetch("http://localhost:3000/api/support/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "Draft a support reply." }), +}); + +const reader = response.body?.getReader(); +const decoder = new TextDecoder(); + +while (reader) { + const next = await reader.read(); + if (next.done) break; + + for (const line of decoder.decode(next.value).split("\n")) { + if (line.trim()) console.log(JSON.parse(line)); + } +} +``` + +## 3. Handle Stream Errors + +Anvia writes a terminal `error` event if iteration fails. Clients should handle both `final` and `error`. + +## Next + +Add auth, request-local tools, and retrieval in [Tools and Context](/docs/frameworks/hono/05-tools-and-context). Related guides: [Readable Streams](/docs/guides/streaming/readable-streams) and [Streaming Events](/docs/guides/streaming/streaming-events). diff --git a/apps/docs/content/docs/frameworks/hono/05-tools-and-context.mdx b/apps/docs/content/docs/frameworks/hono/05-tools-and-context.mdx new file mode 100644 index 00000000..50641acf --- /dev/null +++ b/apps/docs/content/docs/frameworks/hono/05-tools-and-context.mdx @@ -0,0 +1,98 @@ +--- +title: 05 Tools and Context +description: Scope Hono request data before exposing tools and retrieval to Anvia. +--- + +Hono gives you the raw request. Resolve auth in middleware or the handler, then create scoped tools when a tool needs the current user. + +## 1. Add Auth Middleware + +```ts +type Variables = { + userId: string; +}; + +export const app = new Hono<{ Variables: Variables }>(); + +app.use("/api/*", async (c, next) => { + const userId = c.req.header("x-user-id"); + + if (!userId) { + return c.json({ error: "unauthorized" }, 401); + } + + c.set("userId", userId); + await next(); +}); +``` + +## 2. Create a Scoped Agent + +```ts +import { z } from "zod"; +import { AgentBuilder, createTool } from "@anvia/core"; +import { model } from "./ai/support-agent"; +import { orders } from "./db/orders"; + +export function createSupportAgent(scope: { userId: string }) { + const lookupOrder = createTool({ + name: "lookup_order", + description: "Look up one order owned by the current user.", + input: z.object({ + orderId: z.string(), + }), + output: z.object({ + status: z.string(), + }), + async execute({ orderId }) { + return orders.findForUser(scope.userId, orderId); + }, + }); + + return new AgentBuilder("support", model) + .instructions("Use tools for account-specific data.") + .tool(lookupOrder) + .defaultMaxTurns(3) + .build(); +} +``` + +## 3. Use Request State In The Handler + +```ts +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; + +const SupportRequest = z.object({ + message: z.string().trim().min(1, "message is required"), +}); + +app.post("/api/support", zValidator("json", SupportRequest), async (c) => { + const userId = c.get("userId"); + const { message } = c.req.valid("json"); + + const agent = createSupportAgent({ userId }); + const response = await agent.prompt(message).send(); + + return c.json({ output: response.output }); +}); +``` + +## 4. Add Retrieval Context + +```ts +const agent = new AgentBuilder("support", model) + .instructions("Use retrieved support docs when relevant.") + .dynamicContext(supportDocsIndex, { + topK: 3, + threshold: 0.7, + }) + .tool(lookupOrder) + .build(); +``` + +Use retrieval for knowledge. Use tools for authorization-sensitive application state. + +## Next + +Persist conversations in [Persistence](/docs/frameworks/hono/06-persistence). Related guides: [Runtime Context](/docs/guides/agents/runtime-context), [RAG Context](/docs/guides/retrieval/rag-context), and [Tool Handlers](/docs/guides/tools/tool-handlers). diff --git a/apps/docs/content/docs/frameworks/hono/06-persistence.mdx b/apps/docs/content/docs/frameworks/hono/06-persistence.mdx new file mode 100644 index 00000000..cc16f0cc --- /dev/null +++ b/apps/docs/content/docs/frameworks/hono/06-persistence.mdx @@ -0,0 +1,70 @@ +--- +title: 06 Persistence +description: Store Hono conversation history with app storage or Anvia memory. +--- + +Hono does not prescribe persistence. Keep storage in your application layer and pass messages into Anvia. + +## 1. Explicit Transcript Storage + +```ts +import { zValidator } from "@hono/zod-validator"; +import { Message } from "@anvia/core"; +import { z } from "zod"; +import { supportAgent } from "./ai/support-agent"; +import { conversations } from "./db/conversations"; + +const SupportRequest = z.object({ + conversationId: z.string().min(1), + message: z.string().trim().min(1, "message is required"), +}); + +app.post("/api/support", zValidator("json", SupportRequest), async (c) => { + const userId = c.get("userId"); + const { conversationId, message } = c.req.valid("json"); + + const history = await conversations.loadMessages(userId, conversationId); + const response = await supportAgent + .prompt([...history, Message.user(message)]) + .send(); + + await conversations.saveMessages(userId, conversationId, [ + ...history, + ...response.messages, + ]); + + return c.json({ output: response.output }); +}); +``` + +## 2. Agent Memory + +```ts +const agent = new AgentBuilder("support", model) + .memory(memoryStore, { savePolicy: "message" }) + .build(); + +const response = await agent + .session(conversationId, { userId }) + .prompt(message) + .send(); +``` + +Use memory when Anvia should load and append transcript messages through your store. + +## 3. Studio During Development + +You can inspect the same built agent in Studio without changing the Hono route: + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "./ai/support-agent"; + +new Studio([supportAgent]).start({ port: 4021 }); +``` + +Studio is for local inspection and internal tooling. Your Hono app still owns product auth, routes, and persistence. + +## Next + +Review deployment checks in [Deploy](/docs/frameworks/hono/07-deploy). Related guides: [Memory](/docs/guides/memory), [Studio](/docs/studio/overview), and [Event Store](/docs/guides/agents/event-store). diff --git a/apps/docs/content/docs/frameworks/hono/07-deploy.mdx b/apps/docs/content/docs/frameworks/hono/07-deploy.mdx new file mode 100644 index 00000000..fa13e543 --- /dev/null +++ b/apps/docs/content/docs/frameworks/hono/07-deploy.mdx @@ -0,0 +1,55 @@ +--- +title: 07 Deploy +description: Check Hono runtime, environment, and operations before deploying Anvia routes. +--- + +## Runtime Checklist + +| Area | Check | +| --- | --- | +| Runtime | Provider SDKs and storage clients work in your chosen Hono adapter | +| Secrets | Provider keys are server-only environment variables | +| Streaming | Host and proxy do not buffer `application/x-ndjson` responses | +| Timeouts | Request timeout covers model latency and tool calls | +| Storage | Conversations, memory, retrieval indexes, and traces are durable | + +## Node Server Example + +```ts +import { serve } from "@hono/node-server"; +import { app } from "./app"; + +serve({ + fetch: app.fetch, + hostname: "0.0.0.0", + port: Number(process.env.PORT ?? 3000), +}); +``` + +## Observability + +```ts +const response = await supportAgent + .prompt(message) + .withTrace({ + name: "hono-support-route", + userId, + sessionId: conversationId, + tags: ["hono"], + }) + .send(); +``` + +Attach observers for logs, metrics, Langfuse, or OpenTelemetry. + +## Deployment Smoke Test + +```sh +curl -X POST "$APP_URL/api/support" \ + -H "Content-Type: application/json" \ + -d '{"message":"Say hello"}' +``` + +## Next + +Use [Troubleshooting](/docs/frameworks/hono/08-troubleshooting) for common failures. Related guides: [Observers](/docs/guides/observability/observers), [Langfuse](/docs/guides/observability/langfuse), and [OpenTelemetry](/docs/guides/observability/otel). diff --git a/apps/docs/content/docs/frameworks/hono/08-troubleshooting.mdx b/apps/docs/content/docs/frameworks/hono/08-troubleshooting.mdx new file mode 100644 index 00000000..a92354c7 --- /dev/null +++ b/apps/docs/content/docs/frameworks/hono/08-troubleshooting.mdx @@ -0,0 +1,69 @@ +--- +title: 08 Troubleshooting +description: Fix common Hono and Anvia route issues. +--- + +## Validation Returns 400 + +The route uses `zValidator("json", schema)`, so the request body must match the Zod schema. + +```sh +curl -X POST http://localhost:3000/api/support \ + -H "Content-Type: application/json" \ + -d '{"message":"Hello"}' +``` + +## JSON Validation Fails + +Set the request header and send valid JSON: + +```txt +Content-Type: application/json +``` + +Then read validated data with `c.req.valid("json")` inside the handler: + +```ts +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; + +const SupportRequest = z.object({ + message: z.string().trim().min(1, "message is required"), +}); + +app.post("/api/support", zValidator("json", SupportRequest), async (c) => { + const { message } = c.req.valid("json"); + return c.json({ message }); +}); +``` + +## Provider Key Is Missing + +Create the provider client only after checking the environment: + +```ts +const apiKey = process.env.OPENAI_API_KEY; +if (!apiKey) throw new Error("OPENAI_API_KEY is required"); +``` + +## Streaming Does Not Flush + +Return the event stream response from `@anvia/server`: + +```ts +return createEventStream(agent.prompt(message).stream(), { format: "jsonl" }); +``` + +Check adapter support, reverse proxy buffering, and route timeouts. + +## Tool Authorization Is Wrong + +Resolve the current user in middleware or the handler. Build scoped tools that close over that user, and never trust model-supplied identifiers for access control. + +## Studio Works But Hono Route Does Not + +Studio runs the same agent runtime but different HTTP routes. Compare the prompt input, session history, tools, and provider environment used by your Hono handler. + +## Next + +Revisit [Route Handler](/docs/frameworks/hono/03-route-handler), [Tools and Context](/docs/frameworks/hono/05-tools-and-context), and [Tool Errors](/docs/guides/tools/tool-errors). diff --git a/apps/docs/content/docs/frameworks/hono/09-human-in-the-loop.mdx b/apps/docs/content/docs/frameworks/hono/09-human-in-the-loop.mdx new file mode 100644 index 00000000..a65a8a44 --- /dev/null +++ b/apps/docs/content/docs/frameworks/hono/09-human-in-the-loop.mdx @@ -0,0 +1,188 @@ +--- +title: 09 Human in the Loop +description: Add approvals and human feedback to Hono Anvia routes. +--- + +Hono is a good fit for human-in-the-loop routes because the same app can expose agent runs, approval lists, and decision endpoints. + +## 1. Use Studio During Development + +Add approval metadata to protected tools, then register the built agent in Studio: + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "./ai/support-agent"; + +new Studio([supportAgent]).start({ port: 4021 }); +``` + +Studio gives you a local approval UI. Your Hono app still owns production auth and reviewer permissions. + +## 2. Use A Request Hook In Hono + +```ts +import { createHook } from "@anvia/core"; +import { approvalRuntime } from "./approvals/runtime"; + +function createApprovalHook(input: { userId: string; approvalRunId: string }) { + return createHook({ + async onToolCall({ toolName, args, tool }) { + if (toolName !== "refund_order") { + return tool.run(); + } + + const approved = await approvalRuntime.waitForDecision({ + userId: input.userId, + approvalRunId: input.approvalRunId, + toolName, + args, + }); + + return approved ? tool.run() : tool.skip("Refund was not approved."); + }, + }); +} +``` + +`approvalRuntime` is your own module. It is not imported from `@anvia/core` or any Anvia package. + +Attach the hook inside the route: + +```ts +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; + +const SupportRequest = z.object({ + message: z.string().trim().min(1, "message is required"), +}); + +app.post("/api/support", zValidator("json", SupportRequest), async (c) => { + const userId = c.get("userId"); + const { message } = c.req.valid("json"); + const approvalRunId = crypto.randomUUID(); + + const response = await supportAgent + .prompt(message) + .requestHook(createApprovalHook({ userId, approvalRunId })) + .send(); + + return c.json({ output: response.output }); +}); +``` + +## 3. Create The Approval Runtime + +`approvalRuntime` is not provided by Anvia. It is your Hono application's approval service: create a pending record, notify reviewers, wait for a decision route to resolve the pending promise, then return the boolean to the hook. + +```ts +type ApprovalRequest = { + userId: string; + approvalRunId: string; + toolName: string; + args: string; +}; + +type ApprovalDecision = { + approved: boolean; + reason?: string; +}; + +export function createApprovalRuntime() { + const waiters = new Map void>(); + + return { + async waitForDecision(request: ApprovalRequest): Promise { + const approval = await db.approval.create({ + data: { + userId: request.userId, + approvalRunId: request.approvalRunId, + toolName: request.toolName, + args: request.args, + status: "pending", + }, + }); + + await notifyReviewers({ approvalId: approval.id }); + + const decision = await new Promise((resolve) => { + waiters.set(approval.id, resolve); + }); + + waiters.delete(approval.id); + return decision.approved; + }, + + async listPendingForReviewer(reviewerId: string) { + return db.approval.findMany({ + where: { + reviewerId, + status: "pending", + }, + orderBy: { createdAt: "asc" }, + }); + }, + + async decide(input: { + approvalId: string; + reviewerId: string; + approved: boolean; + reason?: string; + }): Promise { + await db.approval.update({ + where: { id: input.approvalId }, + data: { + status: input.approved ? "approved" : "rejected", + reviewerId: input.reviewerId, + decisionReason: input.reason, + resolvedAt: new Date(), + }, + }); + + waiters.get(input.approvalId)?.({ + approved: input.approved, + reason: input.reason, + }); + }, + }; +} + +export const approvalRuntime = createApprovalRuntime(); +``` + +The `Map` is only a simple waiter for one Node process. In production, keep records in durable storage and resolve waiters through your queue, pub/sub, websocket, or polling worker. + +## 4. Add Reviewer Routes + +```ts +const ApprovalDecisionRequest = z.object({ + approved: z.boolean(), +}); + +app.get("/api/approvals", async (c) => { + const userId = c.get("userId"); + return c.json(await approvalRuntime.listPendingForReviewer(userId)); +}); + +app.post( + "/api/approvals/:id/decision", + zValidator("json", ApprovalDecisionRequest), + async (c) => { + const userId = c.get("userId"); + const { approved } = c.req.valid("json"); + + await approvalRuntime.decide({ + approvalId: c.req.param("id"), + reviewerId: userId, + approved, + }); + + return c.json({ ok: true }); + }, +); +``` + +Use `zValidator("json", schema)` on the decision route the same way as prompt routes. + +## Next + +Add Hono route tests in [Setup Tests](/docs/frameworks/hono/10-setup-tests). Core concepts: [Human in the Loop](/docs/guides/human-in-the-loop), [Approval by Hooks](/docs/guides/human-in-the-loop/tool-approval), [Approval Runtimes](/docs/guides/human-in-the-loop/approval-handlers), and [Studio Tool Approvals](/docs/studio/human-in-the-loop/tool-approvals). diff --git a/apps/docs/content/docs/frameworks/hono/10-setup-tests.mdx b/apps/docs/content/docs/frameworks/hono/10-setup-tests.mdx new file mode 100644 index 00000000..c9e5bf0b --- /dev/null +++ b/apps/docs/content/docs/frameworks/hono/10-setup-tests.mdx @@ -0,0 +1,94 @@ +--- +title: 10 Setup Tests +description: Test Hono Anvia routes, validation, streams, and Studio wiring. +--- + +Hono apps are straightforward to test because `app.request(...)` exercises the same route stack without starting a server. + +## 1. Install Test Tools + +```sh +pnpm add -D vitest +``` + +## 2. Test JSON Validation + +```ts +import { describe, expect, it } from "vitest"; +import { app } from "../src/app"; + +describe("POST /api/support", () => { + it("rejects invalid JSON bodies", async () => { + const response = await app.request("/api/support", { + method: "POST", + headers: { + "content-type": "application/json", + "x-user-id": "user_123", + }, + body: JSON.stringify({ message: "" }), + }); + + expect(response.status).toBe(400); + }); +}); +``` + +`zValidator("json", schema)` handles the validation failure before the agent runs. + +## 3. Test The Happy Path + +Mock the agent module for route tests so provider calls stay out of unit tests. + +```ts +import { vi } from "vitest"; + +vi.mock("../src/ai/support-agent", () => ({ + supportAgent: { + prompt: () => ({ + send: async () => ({ output: "Hello", usage: { totalTokens: 4 }, messages: [] }), + }), + }, +})); +``` + +Then call the route with `app.request(...)` and assert the JSON body. + +## 4. Test Streaming Headers + +```ts +const response = await app.request("/api/support/stream", { + method: "POST", + headers: { + "content-type": "application/json", + "x-user-id": "user_123", + }, + body: JSON.stringify({ message: "Hello" }), +}); + +expect(response.headers.get("content-type")).toContain("application/x-ndjson"); +``` + +Mock `stream()` with a small async iterable that emits one final event. + +## 5. Test Studio Without a Port + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "../src/ai/support-agent"; + +const studio = new Studio([supportAgent]); + +const response = await studio.fetch( + new Request("http://studio.test/agents/support/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "Hello" }), + }), +); + +expect(response.status).toBe(200); +``` + +## Next + +Related guides: [Testing](/docs/guides/testing), [Tools and Pipelines](/docs/guides/testing/tools-and-pipelines), and [Studio and Providers](/docs/guides/testing/studio-and-providers). diff --git a/apps/docs/content/docs/frameworks/hono/meta.json b/apps/docs/content/docs/frameworks/hono/meta.json new file mode 100644 index 00000000..49736f62 --- /dev/null +++ b/apps/docs/content/docs/frameworks/hono/meta.json @@ -0,0 +1,17 @@ +{ + "title": "Hono", + "defaultOpen": false, + "collapsible": true, + "pages": [ + "01-prep", + "02-setup-anvia", + "03-route-handler", + "04-streaming", + "05-tools-and-context", + "06-persistence", + "07-deploy", + "08-troubleshooting", + "09-human-in-the-loop", + "10-setup-tests" + ] +} diff --git a/apps/docs/content/docs/frameworks/index.mdx b/apps/docs/content/docs/frameworks/index.mdx new file mode 100644 index 00000000..4387fc74 --- /dev/null +++ b/apps/docs/content/docs/frameworks/index.mdx @@ -0,0 +1,52 @@ +--- +title: Framework Guides +description: Add Anvia agents to application frameworks without giving up your app boundaries. +--- + +These guides show how to move Anvia from a local script into real HTTP runtimes. + +Use `@anvia/server` at the HTTP boundary to return JSONL or SSE event streams. In React-based apps, use `@anvia/react` for chat state and fetch-backed stream transports. + +Each framework follows the same path: + +| Step | Goal | +| --- | --- | +| 01 Prep | Create the project shape and install Anvia packages | +| 02 Setup Anvia | Build provider clients, models, tools, and agents in server-side modules | +| 03 Route Handler | Return a normal JSON response from a framework route | +| 04 Streaming | Return `@anvia/server` event streams from the same agent | +| 05 Tools and Context | Pass request-local auth, product data, and retrieval context safely | +| 06 Persistence | Store conversation history through your application storage | +| 07 Deploy | Check runtime, environment, and operational constraints | +| 08 Troubleshooting | Fix common framework and provider failures | +| 09 Human in the Loop | Add approvals, questions, and reviewer waits to framework routes | +| 10 Setup Tests | Test route handlers, streams, Studio wiring, and provider boundaries | + +Start with the framework that owns your HTTP routes: + +| Framework | Start here | Route shape | +| --- | --- | --- | +| Next.js | [Next.js Prep](/docs/frameworks/nextjs/01-prep) | App Router `route.ts` handlers | +| TanStack Start | [TanStack Start Prep](/docs/frameworks/tanstack-start/01-prep) | `createServerFn(...)` and server routes | +| SvelteKit | [SvelteKit Prep](/docs/frameworks/sveltekit/01-prep) | `+server.ts` endpoint modules | +| Hono | [Hono Prep](/docs/frameworks/hono/01-prep) | Plain Hono routes | +| Express | [Express Prep](/docs/frameworks/express/01-prep) | Router middleware and handlers | +| Fastify | [Fastify Prep](/docs/frameworks/fastify/01-prep) | Plugin routes and replies | +| NestJS | [NestJS Prep](/docs/frameworks/nestjs/01-prep) | Modules, services, and controllers | + +The Anvia runtime shape stays the same across every framework: + +```ts +import { AgentBuilder } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; + +const client = new OpenAIClient({ apiKey }); +const model = client.completionModel("gpt-5.5"); + +export const supportAgent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly.") + .defaultMaxTurns(3) + .build(); +``` + +For the underlying SDK concepts, read [How Anvia Works](/docs/guides/sdk-fundamentals/runtime-boundaries), [Creating Agents](/docs/guides/agents/creating-agents), [Tools](/docs/guides/tools/creating-tools), [Readable Streams](/docs/guides/streaming/readable-streams), and [Memory](/docs/guides/memory). diff --git a/apps/docs/content/docs/frameworks/meta.json b/apps/docs/content/docs/frameworks/meta.json new file mode 100644 index 00000000..7fb69033 --- /dev/null +++ b/apps/docs/content/docs/frameworks/meta.json @@ -0,0 +1,16 @@ +{ + "title": "Frameworks", + "description": "App integrations", + "icon": "Blocks", + "root": true, + "pages": [ + "index", + "nextjs", + "tanstack-start", + "sveltekit", + "hono", + "express", + "fastify", + "nestjs" + ] +} diff --git a/apps/docs/content/docs/frameworks/nestjs/01-prep.mdx b/apps/docs/content/docs/frameworks/nestjs/01-prep.mdx new file mode 100644 index 00000000..3313e02c --- /dev/null +++ b/apps/docs/content/docs/frameworks/nestjs/01-prep.mdx @@ -0,0 +1,57 @@ +--- +title: 01 Prep +description: Prepare a NestJS app for Anvia modules and controllers. +--- + +Use this path when Anvia runs inside a NestJS backend with modules, dependency injection, and controllers. + +## 1. Create A NestJS Project + +```sh +pnpm dlx @nestjs/cli new anvia-nestjs +cd anvia-nestjs +``` + +Use TypeScript and the default Express HTTP adapter unless your app already uses Fastify. + +## 2. Install Anvia + +```sh +pnpm add @anvia/core @anvia/openai @anvia/server zod +pnpm add -D @types/express +``` + +Install other providers when needed: + +```sh +pnpm add @anvia/anthropic @anvia/gemini @anvia/mistral +``` + +## 3. Add Environment Variables + +```txt +OPENAI_API_KEY=sk_... +``` + +Read secrets through your config layer or `process.env`: + +```ts +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + throw new Error("OPENAI_API_KEY is required"); +} +``` + +## 4. Choose Module Boundaries + +| File | Purpose | +| --- | --- | +| `src/ai/anvia.module.ts` | Nest module for Anvia providers | +| `src/ai/support-agent.service.ts` | Provider client, model, tools, and agent methods | +| `src/support/support.controller.ts` | HTTP prompt and stream endpoints | +| `src/approvals/approval-runtime.service.ts` | User-owned approval runtime | + +## Next + +Build the injectable service in [Setup Anvia](/docs/frameworks/nestjs/02-setup-anvia). Read [Runtime Boundaries](/docs/guides/sdk-fundamentals/runtime-boundaries) before wiring Anvia into Nest modules. diff --git a/apps/docs/content/docs/frameworks/nestjs/02-setup-anvia.mdx b/apps/docs/content/docs/frameworks/nestjs/02-setup-anvia.mdx new file mode 100644 index 00000000..e4480180 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nestjs/02-setup-anvia.mdx @@ -0,0 +1,99 @@ +--- +title: 02 Setup Anvia +description: Create injectable Anvia services for NestJS. +--- + +In NestJS, wrap Anvia in services. Controllers should depend on methods like `runSupport(...)`, not construct provider clients directly. + +## 1. Create `src/ai/support-agent.service.ts` + +```ts +import { Injectable } from "@nestjs/common"; +import { AgentBuilder, createTool } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; +import { z } from "zod"; + +type Agent = ReturnType; + +@Injectable() +export class SupportAgentService { + private readonly agent: Agent; + + constructor() { + const apiKey = process.env.OPENAI_API_KEY; + + if (!apiKey) { + throw new Error("OPENAI_API_KEY is required"); + } + + const client = new OpenAIClient({ apiKey }); + const model = client.completionModel("gpt-5.5"); + + const lookupPolicy = createTool({ + name: "lookup_policy", + description: "Look up a support policy by key.", + input: z.object({ + key: z.enum(["password_reset", "priority_support"]), + }), + output: z.object({ + text: z.string(), + }), + async execute({ key }) { + const policies = { + password_reset: "Password reset links expire after 30 minutes.", + priority_support: "Enterprise customers receive priority support.", + }; + + return { text: policies[key] }; + }, + }); + + this.agent = new AgentBuilder("support", model) + .name("Support Agent") + .instructions("Answer clearly. Use tools when policy detail is needed.") + .tool(lookupPolicy) + .defaultMaxTurns(3) + .build(); + } + + async runSupport(message: string) { + return this.agent.prompt(message).send(); + } + + streamSupport(message: string) { + return this.agent.prompt(message).stream(); + } + + getAgent() { + return this.agent; + } +} +``` + +## 2. Export The Service From A Module + +```ts +import { Module } from "@nestjs/common"; +import { SupportAgentService } from "./support-agent.service"; + +@Module({ + providers: [SupportAgentService], + exports: [SupportAgentService], +}) +export class AnviaModule {} +``` + +## 3. Swap Providers Later + +```ts +import { OpenAICompatibleClient } from "@anvia/openai"; + +const client = new OpenAICompatibleClient({ + apiKey: process.env.OPENROUTER_API_KEY, + baseURL: "https://openrouter.ai/api/v1", +}); +``` + +## Next + +Expose the service through a controller in [Route Handler](/docs/frameworks/nestjs/03-route-handler). Related guides: [Creating Agents](/docs/guides/agents/creating-agents) and [Tools](/docs/guides/tools/creating-tools). diff --git a/apps/docs/content/docs/frameworks/nestjs/03-route-handler.mdx b/apps/docs/content/docs/frameworks/nestjs/03-route-handler.mdx new file mode 100644 index 00000000..59df152f --- /dev/null +++ b/apps/docs/content/docs/frameworks/nestjs/03-route-handler.mdx @@ -0,0 +1,75 @@ +--- +title: 03 Route Handler +description: Return a non-streaming Anvia response from a NestJS controller. +--- + +NestJS controllers are the HTTP boundary. Validate request bodies before calling the Anvia service. + +## 1. Create `src/support/support.controller.ts` + +```ts +import { BadRequestException, Body, Controller, Post } from "@nestjs/common"; +import { z } from "zod"; +import { SupportAgentService } from "../ai/support-agent.service"; + +const SupportRequest = z.object({ + message: z.string().trim().min(1, "message is required"), +}); + +@Controller("api/support") +export class SupportController { + constructor(private readonly supportAgent: SupportAgentService) {} + + @Post() + async prompt(@Body() body: unknown) { + const parsed = SupportRequest.safeParse(body); + + if (!parsed.success) { + throw new BadRequestException(parsed.error.issues[0]?.message); + } + + const response = await this.supportAgent.runSupport(parsed.data.message); + + return { + output: response.output, + usage: response.usage, + messages: response.messages, + }; + } +} +``` + +## 2. Register The Controller + +```ts +import { Module } from "@nestjs/common"; +import { AnviaModule } from "../ai/anvia.module"; +import { SupportController } from "./support.controller"; + +@Module({ + imports: [AnviaModule], + controllers: [SupportController], +}) +export class SupportModule {} +``` + +## 3. Call The Route + +```ts +const response = await fetch("http://localhost:3000/api/support", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "How long does a reset link last?" }), +}); + +const data = await response.json(); +console.log(data.output); +``` + +## 4. Keep Errors Structured + +Use Nest exceptions or filters for application error shapes. Do not return raw provider stack traces. + +## Next + +Return live run events in [Streaming](/docs/frameworks/nestjs/04-streaming). For response fields, read [Prompt Responses](/docs/guides/sdk-fundamentals/prompt-responses). diff --git a/apps/docs/content/docs/frameworks/nestjs/04-streaming.mdx b/apps/docs/content/docs/frameworks/nestjs/04-streaming.mdx new file mode 100644 index 00000000..eb9911b6 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nestjs/04-streaming.mdx @@ -0,0 +1,80 @@ +--- +title: 04 Streaming +description: Stream Anvia run events from a NestJS controller. +--- + +With the default Express adapter, inject `@Res()`, create the Anvia event response with `@anvia/server`, and pipe its Web stream into the Node response. + +## 1. Add A Streaming Controller Method + +```ts +import { createEventStream } from "@anvia/server"; +import { BadRequestException, Body, Controller, Post, Res } from "@nestjs/common"; +import type { Response } from "express"; +import { Readable } from "node:stream"; +import { z } from "zod"; +import { SupportAgentService } from "../ai/support-agent.service"; + +const SupportStreamRequest = z.object({ + message: z.string().trim().min(1, "message is required"), +}); + +@Controller("api/support") +export class SupportController { + constructor(private readonly supportAgent: SupportAgentService) {} + + @Post("stream") + async stream(@Body() body: unknown, @Res() res: Response) { + const parsed = SupportStreamRequest.safeParse(body); + + if (!parsed.success) { + throw new BadRequestException(parsed.error.issues[0]?.message); + } + + const streamResponse = createEventStream(this.supportAgent.streamSupport(parsed.data.message), { + format: "jsonl", + }); + + streamResponse.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + if (streamResponse.body === null) { + res.end(); + return; + } + + Readable.fromWeb(streamResponse.body).pipe(res); + } +} +``` + +## 2. Consume The Stream + +```ts +const response = await fetch("http://localhost:3000/api/support/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "Draft a support reply." }), +}); + +const reader = response.body?.getReader(); +const decoder = new TextDecoder(); + +while (reader) { + const next = await reader.read(); + if (next.done) break; + + for (const line of decoder.decode(next.value).split("\n")) { + if (line.trim()) console.log(JSON.parse(line)); + } +} +``` + +## 3. Adapter Notes + +If your Nest app uses the Fastify adapter, use Fastify reply handling instead of Express `Response`. + +## Next + +Add auth, request-local tools, and retrieval in [Tools and Context](/docs/frameworks/nestjs/05-tools-and-context). Related guides: [Readable Streams](/docs/guides/streaming/readable-streams) and [Streaming Events](/docs/guides/streaming/streaming-events). diff --git a/apps/docs/content/docs/frameworks/nestjs/05-tools-and-context.mdx b/apps/docs/content/docs/frameworks/nestjs/05-tools-and-context.mdx new file mode 100644 index 00000000..447b99e8 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nestjs/05-tools-and-context.mdx @@ -0,0 +1,86 @@ +--- +title: 05 Tools and Context +description: Pass NestJS auth, request data, and retrieval context into Anvia tools. +--- + +In NestJS, guards and services should own auth and data access. Pass request-local values into Anvia from controller or service methods. + +## 1. Add A Request User Type + +```ts +import type { Request } from "express"; + +export type AuthenticatedRequest = Request & { + user: { id: string }; +}; +``` + +Use your existing guard to set `request.user`. + +## 2. Build Request-Local Tools + +```ts +import { createTool } from "@anvia/core"; +import { z } from "zod"; + +export function createAccountTool(input: { userId: string }) { + return createTool({ + name: "get_account_status", + description: "Read the authenticated user's account status.", + input: z.object({}), + output: z.object({ plan: z.string(), openTickets: z.number() }), + async execute() { + return db.account.findStatus({ userId: input.userId }); + }, + }); +} +``` + +## 3. Add A Request-Local Service Method + +```ts +async runSupportForUser(input: { userId: string; message: string }) { + return this.agent + .prompt(input.message) + .tool(createAccountTool({ userId: input.userId })) + .context({ userId: input.userId }) + .send(); +} +``` + +## 4. Call It From A Guarded Controller + +```ts +import { Controller, Post, Req, UseGuards } from "@nestjs/common"; +import type { AuthenticatedRequest } from "../auth/types"; + +@UseGuards(AuthGuard) +@Controller("api/support") +export class SupportController { + @Post() + async prompt(@Req() request: AuthenticatedRequest) { + const response = await this.supportAgent.runSupportForUser({ + userId: request.user.id, + message: request.body.message, + }); + + return { output: response.output }; + } +} +``` + +## 5. Add Retrieval Context + +```ts +const documents = await this.knowledge.search({ + query: input.message, + filter: { userId: input.userId }, + limit: 5, +}); + +return this.agent.prompt(input.message).documents(documents).send(); +``` + +## Next + +Persist history in [Persistence](/docs/frameworks/nestjs/06-persistence). Related guides: [Runtime Context](/docs/guides/agents/runtime-context), [Tool Handlers](/docs/guides/tools/tool-handlers), and [RAG Context](/docs/guides/retrieval/rag-context). diff --git a/apps/docs/content/docs/frameworks/nestjs/06-persistence.mdx b/apps/docs/content/docs/frameworks/nestjs/06-persistence.mdx new file mode 100644 index 00000000..f387e4bb --- /dev/null +++ b/apps/docs/content/docs/frameworks/nestjs/06-persistence.mdx @@ -0,0 +1,49 @@ +--- +title: 06 Persistence +description: Persist NestJS chat history through application services. +--- + +Use Nest services or repositories for session storage. Anvia should not own your database boundary. + +## 1. Load Existing Messages + +```ts +const session = await this.chatSessions.findForUser({ + sessionId: input.sessionId, + userId: input.userId, +}); + +if (!session) { + throw new NotFoundException("Session not found"); +} +``` + +## 2. Send With History + +```ts +const response = await this.agent + .prompt(input.message) + .messages(session.messages.map((item) => item.message)) + .send(); +``` + +## 3. Store New Messages + +```ts +await this.chatMessages.createMany( + response.messages.map((message) => ({ + sessionId: session.id, + message, + })), +); +``` + +Keep transaction ownership in your application services when message writes must commit with business records. + +## 4. Use Memory When The Model Should Remember + +Use chat history for the current conversation. Use Anvia memory for durable user or domain facts that should survive across sessions. + +## Next + +Prepare runtime constraints in [Deploy](/docs/frameworks/nestjs/07-deploy). Related guides: [Memory](/docs/guides/memory), [Memory and Sessions](/docs/guides/sdk-fundamentals/memory-and-sessions), and [Agent History](/docs/guides/agents/agent-history). diff --git a/apps/docs/content/docs/frameworks/nestjs/07-deploy.mdx b/apps/docs/content/docs/frameworks/nestjs/07-deploy.mdx new file mode 100644 index 00000000..36ba86d8 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nestjs/07-deploy.mdx @@ -0,0 +1,45 @@ +--- +title: 07 Deploy +description: Deploy NestJS Anvia modules in a Node runtime. +--- + +NestJS gives you a long-lived Node server by default. Size request timeouts and observability for model calls. + +## 1. Start The App + +```ts +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "./app.module"; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + await app.listen(process.env.PORT ?? 3000); +} + +void bootstrap(); +``` + +## 2. Configure Environment Variables + +```txt +OPENAI_API_KEY=sk_... +DATABASE_URL=... +ANVIA_STUDIO_TOKEN=... +``` + +## 3. Streaming Checks + +If you use the Express adapter, verify `res.setHeader(...)` and streaming through any reverse proxy. If you use the Fastify adapter, use Fastify-specific reply handling. + +## 4. Production Checklist + +| Check | Why | +| --- | --- | +| Config module validates secrets | Fail early when provider keys are missing | +| Exception filters installed | Keep provider stack traces out of responses | +| Route timeouts reviewed | Agent runs may take longer than CRUD routes | +| Observability module enabled | Tool calls and provider failures need traces | + +## Next + +Debug common failures in [Troubleshooting](/docs/frameworks/nestjs/08-troubleshooting). Add telemetry with [Observability](/docs/guides/observability/tracing). diff --git a/apps/docs/content/docs/frameworks/nestjs/08-troubleshooting.mdx b/apps/docs/content/docs/frameworks/nestjs/08-troubleshooting.mdx new file mode 100644 index 00000000..a3189f67 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nestjs/08-troubleshooting.mdx @@ -0,0 +1,46 @@ +--- +title: 08 Troubleshooting +description: Fix common NestJS and Anvia integration failures. +--- + +Most NestJS issues come from provider initialization, request validation, adapter-specific streaming, or dependency injection boundaries. + +## `OPENAI_API_KEY is required` + +Validate configuration before constructing provider clients. Prefer a config module for production apps. + +## Service Cannot Be Injected + +Export `SupportAgentService` from `AnviaModule` and import that module where your controller lives. + +```ts +@Module({ + providers: [SupportAgentService], + exports: [SupportAgentService], +}) +export class AnviaModule {} +``` + +## Validation Returns 500 + +Validate body input and throw `BadRequestException` for user errors. + +```ts +const parsed = SupportRequest.safeParse(body); + +if (!parsed.success) { + throw new BadRequestException(parsed.error.issues[0]?.message); +} +``` + +## Stream Does Not Flush + +Check which Nest HTTP adapter you use. Express examples use `@Res() res: Response`; Fastify apps need Fastify reply handling. + +## Long Runs Time Out + +Raise server, proxy, and platform timeouts. For reviewer waits, store approvals and resolve them from a decision endpoint. + +## Next + +Add reviewer workflows in [Human in the Loop](/docs/frameworks/nestjs/09-human-in-the-loop). Related guides: [Tool Errors](/docs/guides/tools/tool-errors), [Readable Streams](/docs/guides/streaming/readable-streams), and [Tracing](/docs/guides/observability/tracing). diff --git a/apps/docs/content/docs/frameworks/nestjs/09-human-in-the-loop.mdx b/apps/docs/content/docs/frameworks/nestjs/09-human-in-the-loop.mdx new file mode 100644 index 00000000..3940d948 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nestjs/09-human-in-the-loop.mdx @@ -0,0 +1,136 @@ +--- +title: 09 Human in the Loop +description: Add approvals and reviewer decisions to NestJS Anvia modules. +--- + +In NestJS, place approval storage and decision handling in injectable services. Anvia hooks call those services, but Anvia does not provide your approval runtime. + +## 1. Use Studio During Development + +```ts +import { Injectable, OnModuleInit } from "@nestjs/common"; +import { Studio } from "@anvia/studio"; +import { SupportAgentService } from "./support-agent.service"; + +@Injectable() +export class StudioService implements OnModuleInit { + constructor(private readonly supportAgent: SupportAgentService) {} + + onModuleInit() { + new Studio([this.supportAgent.getAgent()]).start({ port: 4021 }); + } +} +``` + +Studio helps locally. Production reviewer permissions, records, and notifications belong to your Nest app. + +## 2. Create An Approval Hook + +```ts +import { createHook } from "@anvia/core"; +import { ApprovalRuntimeService } from "../approvals/approval-runtime.service"; + +export function createApprovalHook(input: { + userId: string; + approvalRunId: string; + approvalRuntime: ApprovalRuntimeService; +}) { + return createHook({ + async onToolCall({ toolName, args, tool }) { + if (toolName !== "refund_order") { + return tool.run(); + } + + const approved = await input.approvalRuntime.waitForDecision({ + userId: input.userId, + approvalRunId: input.approvalRunId, + toolName, + args, + }); + + return approved ? tool.run() : tool.skip("Refund was not approved."); + }, + }); +} +``` + +`ApprovalRuntimeService` is your own Nest provider. It is not exported by Anvia. + +## 3. Create The Approval Runtime Service + +```ts +import { Injectable } from "@nestjs/common"; + +type ApprovalRequest = { + userId: string; + approvalRunId: string; + toolName: string; + args: string; +}; + +type ApprovalDecision = { + approved: boolean; + reason?: string; +}; + +@Injectable() +export class ApprovalRuntimeService { + private readonly waiters = new Map void>(); + + async waitForDecision(request: ApprovalRequest): Promise { + const approval = await this.approvals.create({ + ...request, + status: "pending", + }); + + await this.notifications.notifyReviewers({ approvalId: approval.id }); + + const decision = await new Promise((resolve) => { + this.waiters.set(approval.id, resolve); + }); + + this.waiters.delete(approval.id); + return decision.approved; + } + + async decide(input: { approvalId: string; approved: boolean; reason?: string }) { + await this.approvals.updateDecision(input); + + this.waiters.get(input.approvalId)?.({ + approved: input.approved, + reason: input.reason, + }); + } +} +``` + +The `Map` is only a single-process waiter. Use durable storage plus queue, pub/sub, websocket, or polling workers for production. + +## 4. Add A Decision Controller + +```ts +import { Body, Controller, Param, Post } from "@nestjs/common"; +import { z } from "zod"; +import { ApprovalRuntimeService } from "./approval-runtime.service"; + +const DecisionRequest = z.object({ + approved: z.boolean(), + reason: z.string().optional(), +}); + +@Controller("api/approvals") +export class ApprovalsController { + constructor(private readonly approvalRuntime: ApprovalRuntimeService) {} + + @Post(":id/decision") + async decide(@Param("id") approvalId: string, @Body() body: unknown) { + const decision = DecisionRequest.parse(body); + await this.approvalRuntime.decide({ approvalId, ...decision }); + return { ok: true }; + } +} +``` + +## Next + +Add NestJS tests in [Setup Tests](/docs/frameworks/nestjs/10-setup-tests). Core concepts: [Human in the Loop](/docs/guides/human-in-the-loop), [Approval by Hooks](/docs/guides/human-in-the-loop/tool-approval), and [Approval Runtimes](/docs/guides/human-in-the-loop/approval-handlers). diff --git a/apps/docs/content/docs/frameworks/nestjs/10-setup-tests.mdx b/apps/docs/content/docs/frameworks/nestjs/10-setup-tests.mdx new file mode 100644 index 00000000..62abb908 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nestjs/10-setup-tests.mdx @@ -0,0 +1,81 @@ +--- +title: 10 Setup Tests +description: Test NestJS Anvia controllers, services, streams, and provider boundaries. +--- + +Use Nest testing modules for controllers and services. Mock Anvia services in controller tests and provider clients in integration tests. + +## 1. Install Test Tools + +Nest projects usually include Jest. If you use Vitest, install the equivalent Nest test setup for your project. + +```sh +pnpm add -D @nestjs/testing supertest @types/supertest +``` + +## 2. Test The Controller + +```ts +import { Test } from "@nestjs/testing"; +import request from "supertest"; +import { SupportController } from "../src/support/support.controller"; +import { SupportAgentService } from "../src/ai/support-agent.service"; + +describe("SupportController", () => { + it("returns the agent output", async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [SupportController], + providers: [ + { + provide: SupportAgentService, + useValue: { + runSupport: async () => ({ + output: "Reset links expire after 30 minutes.", + usage: { totalTokens: 12 }, + messages: [], + }), + }, + }, + ], + }).compile(); + + const app = moduleRef.createNestApplication(); + await app.init(); + + await request(app.getHttpServer()) + .post("/api/support") + .send({ message: "How long does a reset link last?" }) + .expect(201) + .expect(({ body }) => { + expect(body.output).toBe("Reset links expire after 30 minutes."); + }); + }); +}); +``` + +Nest returns `201` for `POST` by default unless you set `@HttpCode(200)`. + +## 3. Test The Stream Method + +Mock `streamSupport()` with a small async iterable that emits one `final` event. Assert the controller sets `application/x-ndjson`. + +## 4. Test Studio Without A Port + +```ts +import { Studio } from "@anvia/studio"; + +const studio = new Studio([supportAgent]); +const response = await studio.fetch( + new Request("http://studio.test/agents/support/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "Hello" }), + }), +); + +expect(response.status).toBe(200); +``` + +## Next + +Related guides: [Testing](/docs/guides/testing), [Tools and Pipelines](/docs/guides/testing/tools-and-pipelines), and [Studio and Providers](/docs/guides/testing/studio-and-providers). diff --git a/apps/docs/content/docs/frameworks/nestjs/meta.json b/apps/docs/content/docs/frameworks/nestjs/meta.json new file mode 100644 index 00000000..f71cc72d --- /dev/null +++ b/apps/docs/content/docs/frameworks/nestjs/meta.json @@ -0,0 +1,17 @@ +{ + "title": "NestJS", + "defaultOpen": false, + "collapsible": true, + "pages": [ + "01-prep", + "02-setup-anvia", + "03-route-handler", + "04-streaming", + "05-tools-and-context", + "06-persistence", + "07-deploy", + "08-troubleshooting", + "09-human-in-the-loop", + "10-setup-tests" + ] +} diff --git a/apps/docs/content/docs/frameworks/nextjs/01-prep.mdx b/apps/docs/content/docs/frameworks/nextjs/01-prep.mdx new file mode 100644 index 00000000..7949e421 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nextjs/01-prep.mdx @@ -0,0 +1,61 @@ +--- +title: 01 Prep +description: Prepare a Next.js App Router project for Anvia server routes. +--- + +Use this path when Anvia will run behind Next.js App Router route handlers. + +## 1. Create or Open an App Router Project + +```sh +pnpm create next-app@latest anvia-next --ts --app +cd anvia-next +``` + +If you already have a Next.js app, use the App Router `app/` directory. The route examples in this guide use `app/api/.../route.ts`. + +## 2. Install Anvia + +```sh +pnpm add @anvia/core @anvia/openai @anvia/server @anvia/react zod +``` + +Install other provider packages when you need them: + +```sh +pnpm add @anvia/anthropic @anvia/gemini @anvia/mistral +``` + +## 3. Add Environment Variables + +Anvia clients use explicit constructor options and do not read environment variables by themselves. + +```txt +OPENAI_API_KEY=sk_... +``` + +Read the value only in server-side files: + +```ts +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + throw new Error("OPENAI_API_KEY is required"); +} +``` + +## 4. Choose Runtime Boundaries + +Keep these files server-only: + +| File | Purpose | +| --- | --- | +| `app/ai/support-agent.ts` | Provider client, model, tools, and reusable agent | +| `app/api/support/route.ts` | Non-streaming prompt endpoint | +| `app/api/support/stream/route.ts` | Streaming prompt endpoint | + +Next.js route handlers use the Web `Request` and `Response` APIs, so `@anvia/server` can return Anvia streams directly. React client components can consume those streams with `@anvia/react`. + +## Next + +Build the reusable agent in [Setup Anvia](/docs/frameworks/nextjs/02-setup-anvia). For the SDK model, read [How Anvia Works](/docs/guides/sdk-fundamentals/runtime-boundaries). diff --git a/apps/docs/content/docs/frameworks/nextjs/02-setup-anvia.mdx b/apps/docs/content/docs/frameworks/nextjs/02-setup-anvia.mdx new file mode 100644 index 00000000..355ef152 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nextjs/02-setup-anvia.mdx @@ -0,0 +1,69 @@ +--- +title: 02 Setup Anvia +description: Create a reusable Anvia agent module for Next.js routes. +--- + +Create provider clients, models, and ordinary tools outside the route handler when their configuration is shared by every request. + +## 1. Create `app/ai/support-agent.ts` + +```ts +import { AgentBuilder, createTool } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; +import { z } from "zod"; + +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + throw new Error("OPENAI_API_KEY is required"); +} + +const client = new OpenAIClient({ apiKey }); +export const model = client.completionModel("gpt-5.5"); + +const lookupPolicy = createTool({ + name: "lookup_policy", + description: "Look up a short support policy by key.", + input: z.object({ + key: z.enum(["password_reset", "priority_support"]), + }), + output: z.object({ + text: z.string(), + }), + async execute({ key }) { + const policies = { + password_reset: "Password reset links expire after 30 minutes.", + priority_support: "Enterprise customers receive priority support.", + }; + + return { text: policies[key] }; + }, +}); + +export const supportAgent = new AgentBuilder("support", model) + .name("Support Agent") + .description("Answers support questions from the product app.") + .instructions("Answer clearly. Use tools when a policy detail is needed.") + .tool(lookupPolicy) + .defaultMaxTurns(3) + .build(); +``` + +## 2. Keep The Agent Server-Side + +Import `supportAgent` from route handlers, server actions, background jobs, or tests. Do not import it into client components. + +## 3. Add More Providers Later + +The route code does not change when you swap the provider module: + +```ts +import { AnthropicClient } from "@anvia/anthropic"; + +const client = new AnthropicClient({ apiKey }); +const model = client.completionModel("claude-opus-4-6"); +``` + +## Next + +Expose the agent through a JSON endpoint in [Route Handler](/docs/frameworks/nextjs/03-route-handler). For deeper agent configuration, read [Creating Agents](/docs/guides/agents/creating-agents) and [Tools](/docs/guides/tools/creating-tools). diff --git a/apps/docs/content/docs/frameworks/nextjs/03-route-handler.mdx b/apps/docs/content/docs/frameworks/nextjs/03-route-handler.mdx new file mode 100644 index 00000000..c6411523 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nextjs/03-route-handler.mdx @@ -0,0 +1,74 @@ +--- +title: 03 Route Handler +description: Return a non-streaming Anvia response from a Next.js App Router route. +--- + +Use a route handler when your UI or another service should call the agent over HTTP. + +## 1. Create `app/api/support/route.ts` + +```ts +import { supportAgent } from "@/app/ai/support-agent"; + +export const runtime = "nodejs"; + +type SupportRequest = { + message?: string; +}; + +export async function POST(request: Request): Promise { + const body = (await request.json()) as SupportRequest; + const message = body.message?.trim(); + + if (!message) { + return Response.json( + { error: { code: "bad_request", message: "message is required" } }, + { status: 400 }, + ); + } + + const response = await supportAgent.prompt(message).send(); + + return Response.json({ + output: response.output, + usage: response.usage, + messages: response.messages, + }); +} +``` + +## 2. Call The Route + +```ts +const response = await fetch("/api/support", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "How long does a password reset link last?", + }), +}); + +const data = await response.json(); +console.log(data.output); +``` + +## 3. Keep Errors Structured + +Validate the request before calling the model. Provider and tool failures should return an application-owned error shape, not raw stack traces. + +```ts +try { + const response = await supportAgent.prompt(message).send(); + return Response.json({ output: response.output }); +} catch (error) { + console.error(error); + return Response.json( + { error: { code: "agent_failed", message: "The agent run failed." } }, + { status: 500 }, + ); +} +``` + +## Next + +Return live run events in [Streaming](/docs/frameworks/nextjs/04-streaming). For response fields, read [Prompt Responses](/docs/guides/sdk-fundamentals/prompt-responses). diff --git a/apps/docs/content/docs/frameworks/nextjs/04-streaming.mdx b/apps/docs/content/docs/frameworks/nextjs/04-streaming.mdx new file mode 100644 index 00000000..381ed230 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nextjs/04-streaming.mdx @@ -0,0 +1,66 @@ +--- +title: 04 Streaming +description: Stream Anvia run events from a Next.js route handler. +--- + +Next.js route handlers can return the streaming `Response` created by `@anvia/server`. + +## 1. Create `app/api/support/stream/route.ts` + +```ts +import { createEventStream } from "@anvia/server"; +import { supportAgent } from "@/app/ai/support-agent"; + +export const runtime = "nodejs"; + +type SupportStreamRequest = { + message?: string; +}; + +export async function POST(request: Request): Promise { + const body = (await request.json()) as SupportStreamRequest; + const message = body.message?.trim(); + + if (!message) { + return Response.json( + { error: { code: "bad_request", message: "message is required" } }, + { status: 400 }, + ); + } + + return createEventStream(supportAgent.prompt(message).stream(), { format: "jsonl" }); +} +``` + +## 2. Consume Events From React + +```tsx +"use client"; + +import { useChat } from "@anvia/react"; + +export function SupportChat() { + const chat = useChat({ endpoint: "/api/support/stream" }); + + return ( +
{ + event.preventDefault(); + void chat.send(); + }} + > +
{chat.text}
+ chat.setInput(event.target.value)} /> + +
+ ); +} +``` + +## 3. Handle Terminal Events + +The stream ends with either a `final` event or an `error` event. Store final output only after you receive the terminal event. + +## Next + +Add request-local authorization and retrieval in [Tools and Context](/docs/frameworks/nextjs/05-tools-and-context). For stream details, read [Readable Streams](/docs/guides/streaming/readable-streams) and [Streaming Events](/docs/guides/streaming/streaming-events). diff --git a/apps/docs/content/docs/frameworks/nextjs/05-tools-and-context.mdx b/apps/docs/content/docs/frameworks/nextjs/05-tools-and-context.mdx new file mode 100644 index 00000000..79264ccd --- /dev/null +++ b/apps/docs/content/docs/frameworks/nextjs/05-tools-and-context.mdx @@ -0,0 +1,81 @@ +--- +title: 05 Tools and Context +description: Scope Next.js request data before exposing tools and retrieval to Anvia. +--- + +If a tool needs the current user, build a request-scoped tool or agent factory. Keep provider clients and models reusable. + +## 1. Create a Scoped Agent Factory + +```ts +import { AgentBuilder, createTool } from "@anvia/core"; +import { z } from "zod"; +import { model } from "@/app/ai/support-agent"; +import { orders } from "@/app/db/orders"; + +type SupportScope = { + userId: string; +}; + +export function createSupportAgent(scope: SupportScope) { + const lookupOrder = createTool({ + name: "lookup_order", + description: "Look up one order owned by the current user.", + input: z.object({ + orderId: z.string(), + }), + output: z.object({ + status: z.string(), + }), + async execute({ orderId }) { + return orders.findForUser(scope.userId, orderId); + }, + }); + + return new AgentBuilder("support", model) + .instructions("Use tools for account-specific data.") + .tool(lookupOrder) + .defaultMaxTurns(3) + .build(); +} +``` + +## 2. Resolve Auth In The Route + +```ts +import { createSupportAgent } from "@/app/ai/create-support-agent"; +import { requireUser } from "@/app/auth"; + +export async function POST(request: Request): Promise { + const user = await requireUser(request); + const { message } = (await request.json()) as { message?: string }; + + if (!message?.trim()) { + return Response.json({ error: "message is required" }, { status: 400 }); + } + + const agent = createSupportAgent({ userId: user.id }); + const response = await agent.prompt(message).send(); + + return Response.json({ output: response.output }); +} +``` + +## 3. Add Retrieval Context + +```ts +const agent = new AgentBuilder("support", model) + .instructions("Use retrieved support docs when relevant.") + .dynamicContext(supportDocsIndex, { + topK: 3, + threshold: 0.7, + }) + .tool(lookupOrder) + .build(); +``` + +Build the retrieval index outside hot request paths. Use request-scoped tools for permissions and retrieval context for searchable knowledge. + +## Next + +Persist conversations in [Persistence](/docs/frameworks/nextjs/06-persistence). Related guides: [Runtime Context](/docs/guides/agents/runtime-context), [RAG Context](/docs/guides/retrieval/rag-context), and [Tool Handlers](/docs/guides/tools/tool-handlers). diff --git a/apps/docs/content/docs/frameworks/nextjs/06-persistence.mdx b/apps/docs/content/docs/frameworks/nextjs/06-persistence.mdx new file mode 100644 index 00000000..2ecc2db8 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nextjs/06-persistence.mdx @@ -0,0 +1,58 @@ +--- +title: 06 Persistence +description: Store Next.js conversation history with Anvia memory or app-owned transcripts. +--- + +Anvia gives you two practical persistence paths. + +## 1. Store Explicit History + +Use this when your app already owns chat transcripts. + +```ts +import { Message } from "@anvia/core"; +import { supportAgent } from "@/app/ai/support-agent"; +import { conversations } from "@/app/db/conversations"; + +export async function POST(request: Request): Promise { + const { conversationId, message } = (await request.json()) as { + conversationId: string; + message: string; + }; + + const history = await conversations.loadMessages(conversationId); + const response = await supportAgent + .prompt([...history, Message.user(message)]) + .send(); + + await conversations.saveMessages(conversationId, [ + ...history, + ...response.messages, + ]); + + return Response.json({ output: response.output }); +} +``` + +## 2. Use Agent Memory + +Use this when Anvia should load and append messages through your memory store. + +```ts +const agent = new AgentBuilder("support", model) + .memory(memoryStore, { savePolicy: "message" }) + .build(); + +const response = await agent + .session(conversationId, { userId }) + .prompt(message) + .send(); +``` + +## 3. Keep Runtime Events Separate + +Memory stores model transcript messages for future prompts. If your UI needs replayable stream events, also use an [Event Store](/docs/guides/agents/event-store). + +## Next + +Review production constraints in [Deploy](/docs/frameworks/nextjs/07-deploy). For memory adapters, read [Memory](/docs/guides/memory), [Prisma](/docs/guides/memory/prisma), and [Drizzle](/docs/guides/memory/drizzle). diff --git a/apps/docs/content/docs/frameworks/nextjs/07-deploy.mdx b/apps/docs/content/docs/frameworks/nextjs/07-deploy.mdx new file mode 100644 index 00000000..cf3064a4 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nextjs/07-deploy.mdx @@ -0,0 +1,55 @@ +--- +title: 07 Deploy +description: Check Next.js runtime, provider, and storage requirements before deploying Anvia routes. +--- + +## Runtime Checklist + +| Area | Check | +| --- | --- | +| Runtime | Use `export const runtime = "nodejs"` unless every dependency supports edge execution | +| Secrets | Set provider keys in the deployment environment, not in client bundles | +| Streaming | Disable buffering in proxies that sit in front of streaming routes | +| Timeouts | Keep route timeouts above your longest expected model/tool run | +| Storage | Use durable storage for history, memory, traces, and retrieval indexes | + +## Provider Clients + +Create clients and models in server modules: + +```ts +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + throw new Error("OPENAI_API_KEY is required"); +} + +const client = new OpenAIClient({ apiKey }); +const model = client.completionModel("gpt-5.5"); +``` + +Do not expose provider keys to browser code. + +## Observability + +Attach observers when you need logs, traces, or metrics: + +```ts +const agent = new AgentBuilder("support", model) + .observe(observer) + .build(); +``` + +Use `.withTrace(...)` per request for route, user, and session metadata. + +## Deployment Smoke Test + +```sh +curl -X POST "$APP_URL/api/support" \ + -H "Content-Type: application/json" \ + -d '{"message":"Say hello"}' +``` + +## Next + +Use [Troubleshooting](/docs/frameworks/nextjs/08-troubleshooting) when a deployed route behaves differently from local development. Related guides: [Observers](/docs/guides/observability/observers) and [Errors](/docs/guides/sdk-fundamentals/errors). diff --git a/apps/docs/content/docs/frameworks/nextjs/08-troubleshooting.mdx b/apps/docs/content/docs/frameworks/nextjs/08-troubleshooting.mdx new file mode 100644 index 00000000..834c92ea --- /dev/null +++ b/apps/docs/content/docs/frameworks/nextjs/08-troubleshooting.mdx @@ -0,0 +1,49 @@ +--- +title: 08 Troubleshooting +description: Fix common Next.js and Anvia route issues. +--- + +## `OPENAI_API_KEY is required` + +The provider client was created before the environment variable was available. + +```ts +const apiKey = process.env.OPENAI_API_KEY; +if (!apiKey) throw new Error("OPENAI_API_KEY is required"); +``` + +Set the variable in `.env.local` for development and in your deployment environment for production. + +## `message is required` + +The route expects JSON with a `message` field. + +```sh +curl -X POST http://localhost:3000/api/support \ + -H "Content-Type: application/json" \ + -d '{"message":"Hello"}' +``` + +## Client Bundle Contains Server Code + +The agent module was imported by a client component. Import it only from route handlers, server functions, server actions, or tests. + +## Streaming Works Locally But Not In Production + +Check proxy buffering, function timeouts, and response headers: + +```ts +return createEventStream(agent.prompt(message).stream(), { format: "jsonl" }); +``` + +## Tool Can Read The Wrong User + +Do not rely on model-supplied user ids for authorization. Resolve the user in the route and close over that user in scoped tools. + +## Provider Or Tool Fails Mid-Run + +Wrap non-streaming routes in `try/catch`. For streaming routes, handle terminal `error` events on the client. + +## Next + +Revisit [Tools and Context](/docs/frameworks/nextjs/05-tools-and-context), [Readable Streams](/docs/guides/streaming/readable-streams), and [Tool Errors](/docs/guides/tools/tool-errors). diff --git a/apps/docs/content/docs/frameworks/nextjs/09-human-in-the-loop.mdx b/apps/docs/content/docs/frameworks/nextjs/09-human-in-the-loop.mdx new file mode 100644 index 00000000..726ce6e8 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nextjs/09-human-in-the-loop.mdx @@ -0,0 +1,164 @@ +--- +title: 09 Human in the Loop +description: Add approvals and human feedback to Next.js Anvia routes. +--- + +Human-in-the-loop work can live in Studio during development or in your own Next.js routes when production users need to approve actions. + +## 1. Use Studio For Local Approval UI + +Add approval metadata to side-effect tools: + +```ts +const refundOrder = createTool({ + name: "refund_order", + description: "Issue a refund.", + input: z.object({ + orderId: z.string(), + amount: z.number().positive(), + }), + approval: { + when: ({ args }) => args.amount > 100, + reason: ({ args }) => `Review refund of $${args.amount} for ${args.orderId}.`, + rejectMessage: "Refund was not approved.", + }, + async execute({ orderId, amount }) { + return refunds.create({ orderId, amount }); + }, +}); +``` + +Run the same built agent in Studio from a server-only script: + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "@/app/ai/support-agent"; + +new Studio([supportAgent]).start({ port: 4021 }); +``` + +Studio reads the approval metadata and waits for a reviewer before running protected tools. + +## 2. Use A Request Hook For Product Approval + +Use a hook when your app owns the approval table, reviewer UI, notification, or timeout. + +```ts +import { createHook } from "@anvia/core"; +import { approvalRuntime } from "@/app/approvals/runtime"; + +function createApprovalHook(input: { userId: string; conversationId: string }) { + return createHook({ + async onToolCall({ toolName, args, tool }) { + if (toolName !== "refund_order") { + return tool.run(); + } + + const approved = await approvalRuntime.waitForDecision({ + userId: input.userId, + conversationId: input.conversationId, + toolName, + args, + }); + + return approved ? tool.run() : tool.skip("Refund was not approved."); + }, + }); +} +``` + +`approvalRuntime` is your own module. It is not imported from `@anvia/core` or any Anvia package. + +Attach the hook in the route: + +```ts +const response = await supportAgent + .prompt(message) + .requestHook(createApprovalHook({ userId, conversationId })) + .send(); +``` + +## 3. Create The Approval Runtime + +`approvalRuntime` is application code, not an Anvia export. It is the small runtime that creates a pending approval record, notifies reviewers, waits until one of your routes or jobs resolves it, then returns the decision to the hook. + +```ts +type ApprovalRequest = { + userId: string; + conversationId: string; + toolName: string; + args: string; +}; + +type ApprovalDecision = { + approved: boolean; + reason?: string; +}; + +export function createApprovalRuntime() { + const waiters = new Map void>(); + + return { + async waitForDecision(request: ApprovalRequest): Promise { + const approval = await db.approval.create({ + data: { + userId: request.userId, + conversationId: request.conversationId, + toolName: request.toolName, + args: request.args, + status: "pending", + }, + }); + + await notifyReviewers({ approvalId: approval.id }); + + const decision = await new Promise((resolve) => { + waiters.set(approval.id, resolve); + }); + + waiters.delete(approval.id); + return decision.approved; + }, + + async decide(input: { + approvalId: string; + reviewerId: string; + approved: boolean; + reason?: string; + }): Promise { + await db.approval.update({ + where: { id: input.approvalId }, + data: { + status: input.approved ? "approved" : "rejected", + reviewerId: input.reviewerId, + decisionReason: input.reason, + resolvedAt: new Date(), + }, + }); + + waiters.get(input.approvalId)?.({ + approved: input.approved, + reason: input.reason, + }); + }, + }; +} + +export const approvalRuntime = createApprovalRuntime(); +``` + +The `Map` is only the waiting mechanism for a single Node process. In production, keep approval records in durable storage and use your app's realtime channel, queue, pub/sub system, or polling worker to resolve waiters. + +## 4. Keep Approval State In Your App + +| State | Owner | +| --- | --- | +| Pending approval record | Your database | +| Reviewer authorization | Your app | +| Notification or websocket | Your app | +| Tool execution after approval | Anvia via `tool.run()` | +| Rejection message to model | Anvia via `tool.skip(...)` | + +## Next + +Add tests in [Setup Tests](/docs/frameworks/nextjs/10-setup-tests). Core concepts: [Human in the Loop](/docs/guides/human-in-the-loop), [Approval by Hooks](/docs/guides/human-in-the-loop/tool-approval), [Approval Runtimes](/docs/guides/human-in-the-loop/approval-handlers), and [Studio Tool Approvals](/docs/studio/human-in-the-loop/tool-approvals). diff --git a/apps/docs/content/docs/frameworks/nextjs/10-setup-tests.mdx b/apps/docs/content/docs/frameworks/nextjs/10-setup-tests.mdx new file mode 100644 index 00000000..be72bc02 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nextjs/10-setup-tests.mdx @@ -0,0 +1,88 @@ +--- +title: 10 Setup Tests +description: Test Next.js Anvia route handlers, streams, and Studio wiring. +--- + +Use tests to cover application routing and validation without turning every branch into a provider call. + +## 1. Install Test Tools + +```sh +pnpm add -D vitest +``` + +## 2. Test The JSON Route + +Next.js route handlers are normal exported functions. + +```ts +import { describe, expect, it, vi } from "vitest"; +import { POST } from "@/app/api/support/route"; + +vi.mock("@/app/ai/support-agent", () => ({ + supportAgent: { + prompt: () => ({ + send: async () => ({ + output: "Reset links expire after 30 minutes.", + usage: { totalTokens: 12 }, + messages: [], + }), + }), + }, +})); + +describe("POST /api/support", () => { + it("returns the agent output", async () => { + const response = await POST( + new Request("http://test.local/api/support", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "How long does a reset link last?" }), + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + output: "Reset links expire after 30 minutes.", + }); + }); +}); +``` + +## 3. Test The Streaming Route + +```ts +const response = await POST( + new Request("http://test.local/api/support/stream", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "Hello" }), + }), +); + +expect(response.headers.get("content-type")).toContain("application/x-ndjson"); +``` + +Mock `stream()` with a small async iterable that emits one `final` event. + +## 4. Test Studio Without a Port + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "@/app/ai/support-agent"; + +const studio = new Studio([supportAgent]); +const response = await studio.fetch( + new Request("http://studio.test/agents/support/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "Hello" }), + }), +); + +expect(response.status).toBe(200); +``` + +## Next + +Related guides: [Testing](/docs/guides/testing), [Tools and Pipelines](/docs/guides/testing/tools-and-pipelines), and [Studio and Providers](/docs/guides/testing/studio-and-providers). diff --git a/apps/docs/content/docs/frameworks/nextjs/meta.json b/apps/docs/content/docs/frameworks/nextjs/meta.json new file mode 100644 index 00000000..ccc70b1b --- /dev/null +++ b/apps/docs/content/docs/frameworks/nextjs/meta.json @@ -0,0 +1,17 @@ +{ + "title": "Next.js", + "defaultOpen": false, + "collapsible": true, + "pages": [ + "01-prep", + "02-setup-anvia", + "03-route-handler", + "04-streaming", + "05-tools-and-context", + "06-persistence", + "07-deploy", + "08-troubleshooting", + "09-human-in-the-loop", + "10-setup-tests" + ] +} diff --git a/apps/docs/content/docs/frameworks/sveltekit/01-prep.mdx b/apps/docs/content/docs/frameworks/sveltekit/01-prep.mdx new file mode 100644 index 00000000..757f604a --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/01-prep.mdx @@ -0,0 +1,57 @@ +--- +title: 01 Prep +description: Prepare a SvelteKit app for Anvia server endpoints. +--- + +Use this path when Anvia runs behind SvelteKit endpoints, form actions, or server-only modules. + +## 1. Create A SvelteKit Project + +```sh +pnpm create svelte@latest anvia-sveltekit +cd anvia-sveltekit +pnpm install +``` + +Choose TypeScript. Anvia code belongs in server-only files, not client components. + +## 2. Install Anvia + +```sh +pnpm add @anvia/core @anvia/openai @anvia/server zod +``` + +Install other providers when needed: + +```sh +pnpm add @anvia/anthropic @anvia/gemini @anvia/mistral +``` + +## 3. Add Environment Variables + +```txt +OPENAI_API_KEY=sk_... +``` + +Read secrets from `$env/static/private` inside server modules: + +```ts +import { OPENAI_API_KEY } from "$env/static/private"; + +if (!OPENAI_API_KEY) { + throw new Error("OPENAI_API_KEY is required"); +} +``` + +## 4. Choose File Boundaries + +| File | Purpose | +| --- | --- | +| `src/lib/server/ai/support-agent.ts` | Provider client, model, tools, and reusable agent | +| `src/routes/api/support/+server.ts` | JSON endpoint | +| `src/routes/api/support/stream/+server.ts` | NDJSON stream endpoint | +| `src/hooks.server.ts` | Auth and request-local `locals` | + +## Next + +Build the reusable agent in [Setup Anvia](/docs/frameworks/sveltekit/02-setup-anvia). Read [Runtime Boundaries](/docs/guides/sdk-fundamentals/runtime-boundaries) before importing Anvia into Svelte components. diff --git a/apps/docs/content/docs/frameworks/sveltekit/02-setup-anvia.mdx b/apps/docs/content/docs/frameworks/sveltekit/02-setup-anvia.mdx new file mode 100644 index 00000000..e165904e --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/02-setup-anvia.mdx @@ -0,0 +1,65 @@ +--- +title: 02 Setup Anvia +description: Create a reusable Anvia agent module for SvelteKit. +--- + +Create provider clients and shared agents in `src/lib/server`. SvelteKit keeps this code out of browser bundles. + +## 1. Create `src/lib/server/ai/support-agent.ts` + +```ts +import { OPENAI_API_KEY } from "$env/static/private"; +import { AgentBuilder, createTool } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; +import { z } from "zod"; + +if (!OPENAI_API_KEY) { + throw new Error("OPENAI_API_KEY is required"); +} + +const client = new OpenAIClient({ apiKey: OPENAI_API_KEY }); +export const model = client.completionModel("gpt-5.5"); + +const lookupPolicy = createTool({ + name: "lookup_policy", + description: "Look up a support policy by key.", + input: z.object({ + key: z.enum(["password_reset", "priority_support"]), + }), + output: z.object({ + text: z.string(), + }), + async execute({ key }) { + const policies = { + password_reset: "Password reset links expire after 30 minutes.", + priority_support: "Enterprise customers receive priority support.", + }; + + return { text: policies[key] }; + }, +}); + +export const supportAgent = new AgentBuilder("support", model) + .name("Support Agent") + .instructions("Answer clearly. Use tools when policy detail is needed.") + .tool(lookupPolicy) + .defaultMaxTurns(3) + .build(); +``` + +## 2. Keep Agents Server-Side + +Import `supportAgent` only from `+server.ts`, server actions, hooks, jobs, or tests. Do not import it from `+page.svelte`. + +## 3. Swap Providers Without Rewriting Routes + +```ts +import { AnthropicClient } from "@anvia/anthropic"; + +const client = new AnthropicClient({ apiKey: process.env.ANTHROPIC_API_KEY }); +const model = client.completionModel("claude-opus-4-6"); +``` + +## Next + +Expose the agent in [Route Handler](/docs/frameworks/sveltekit/03-route-handler). Related guides: [Creating Agents](/docs/guides/agents/creating-agents) and [Tools](/docs/guides/tools/creating-tools). diff --git a/apps/docs/content/docs/frameworks/sveltekit/03-route-handler.mdx b/apps/docs/content/docs/frameworks/sveltekit/03-route-handler.mdx new file mode 100644 index 00000000..21d537f5 --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/03-route-handler.mdx @@ -0,0 +1,71 @@ +--- +title: 03 Route Handler +description: Return a non-streaming Anvia response from a SvelteKit endpoint. +--- + +SvelteKit endpoint modules export HTTP method functions. Use them to keep validation and agent calls on the server. + +## 1. Create `src/routes/api/support/+server.ts` + +```ts +import { json, type RequestHandler } from "@sveltejs/kit"; +import { z } from "zod"; +import { supportAgent } from "$lib/server/ai/support-agent"; + +const SupportRequest = z.object({ + message: z.string().trim().min(1, "message is required"), +}); + +export const POST: RequestHandler = async ({ request }) => { + const parsed = SupportRequest.safeParse(await request.json()); + + if (!parsed.success) { + return json( + { error: { code: "bad_request", message: parsed.error.issues[0]?.message } }, + { status: 400 }, + ); + } + + const response = await supportAgent.prompt(parsed.data.message).send(); + + return json({ + output: response.output, + usage: response.usage, + messages: response.messages, + }); +}; +``` + +## 2. Call The Endpoint + +```ts +const response = await fetch("/api/support", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "How long does a reset link last?" }), +}); + +const data = await response.json(); +console.log(data.output); +``` + +## 3. Keep Errors Application-Owned + +Provider and tool errors should be logged server-side and returned as a stable app error shape. + +```ts +try { + const response = await supportAgent.prompt(parsed.data.message).send(); + return json({ output: response.output }); +} catch (error) { + console.error(error); + return json( + { error: { code: "agent_failed", message: "The agent run failed." } }, + { status: 500 }, + ); +} +``` + +## Next + +Return live run events in [Streaming](/docs/frameworks/sveltekit/04-streaming). For response fields, read [Prompt Responses](/docs/guides/sdk-fundamentals/prompt-responses). diff --git a/apps/docs/content/docs/frameworks/sveltekit/04-streaming.mdx b/apps/docs/content/docs/frameworks/sveltekit/04-streaming.mdx new file mode 100644 index 00000000..5b9ee04f --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/04-streaming.mdx @@ -0,0 +1,64 @@ +--- +title: 04 Streaming +description: Stream Anvia run events from a SvelteKit endpoint. +--- + +SvelteKit endpoints can return standard `Response` objects, so `@anvia/server` can serialize Anvia events directly. + +## 1. Create `src/routes/api/support/stream/+server.ts` + +```ts +import { createEventStream } from "@anvia/server"; +import type { RequestHandler } from "@sveltejs/kit"; +import { z } from "zod"; +import { supportAgent } from "$lib/server/ai/support-agent"; + +const SupportStreamRequest = z.object({ + message: z.string().trim().min(1, "message is required"), +}); + +export const POST: RequestHandler = async ({ request }) => { + const parsed = SupportStreamRequest.safeParse(await request.json()); + + if (!parsed.success) { + return Response.json( + { error: { code: "bad_request", message: parsed.error.issues[0]?.message } }, + { status: 400 }, + ); + } + + return createEventStream(supportAgent.prompt(parsed.data.message).stream(), { + format: "jsonl", + }); +}; +``` + +## 2. Consume The Stream + +```ts +const response = await fetch("/api/support/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "Draft a reply." }), +}); + +const reader = response.body?.getReader(); +const decoder = new TextDecoder(); + +while (reader) { + const next = await reader.read(); + if (next.done) break; + + for (const line of decoder.decode(next.value).split("\n")) { + if (line.trim()) console.log(JSON.parse(line)); + } +} +``` + +## 3. Runtime Notes + +Use a Node-compatible adapter when your provider client or retrieval package depends on Node APIs. Edge adapters need separate verification for each provider and integration. + +## Next + +Add auth, tools, and retrieval in [Tools and Context](/docs/frameworks/sveltekit/05-tools-and-context). Related guides: [Readable Streams](/docs/guides/streaming/readable-streams) and [Streaming Events](/docs/guides/streaming/streaming-events). diff --git a/apps/docs/content/docs/frameworks/sveltekit/05-tools-and-context.mdx b/apps/docs/content/docs/frameworks/sveltekit/05-tools-and-context.mdx new file mode 100644 index 00000000..0ebedfb4 --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/05-tools-and-context.mdx @@ -0,0 +1,90 @@ +--- +title: 05 Tools and Context +description: Pass SvelteKit locals, auth, and retrieval context into Anvia tools. +--- + +Your app should own auth, database access, and retrieval. Pass only the request-local values a run needs. + +## 1. Add `locals` In `hooks.server.ts` + +```ts +import type { Handle } from "@sveltejs/kit"; + +export const handle: Handle = async ({ event, resolve }) => { + const session = await auth.getSession(event.request); + event.locals.userId = session?.user.id; + return resolve(event); +}; +``` + +Add local types in `src/app.d.ts`: + +```ts +declare global { + namespace App { + interface Locals { + userId?: string; + } + } +} +``` + +## 2. Build Request-Local Tools + +```ts +import { createTool } from "@anvia/core"; +import { z } from "zod"; + +export function createAccountTool(input: { userId: string }) { + return createTool({ + name: "get_account_status", + description: "Read the authenticated user's account status.", + input: z.object({}), + output: z.object({ plan: z.string(), openTickets: z.number() }), + async execute() { + return db.account.findStatus({ userId: input.userId }); + }, + }); +} +``` + +## 3. Attach Context In The Endpoint + +```ts +import { json, type RequestHandler } from "@sveltejs/kit"; +import { supportAgent } from "$lib/server/ai/support-agent"; +import { createAccountTool } from "$lib/server/ai/tools"; + +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.userId) { + return json({ error: { code: "unauthorized" } }, { status: 401 }); + } + + const { message } = await request.json(); + const response = await supportAgent + .prompt(message) + .tool(createAccountTool({ userId: locals.userId })) + .context({ userId: locals.userId }) + .send(); + + return json({ output: response.output }); +}; +``` + +## 4. Add Retrieval Context + +Retrieve documents in application code, then pass them into the prompt or context. Keep vector-store credentials in server modules. + +```ts +const documents = await knowledge.search({ + query: message, + filter: { userId: locals.userId }, + limit: 5, +}); + +const response = await supportAgent.prompt(message).documents(documents).send(); +``` + +## Next + +Persist history in [Persistence](/docs/frameworks/sveltekit/06-persistence). Related guides: [Runtime Context](/docs/guides/agents/runtime-context), [Tool Handlers](/docs/guides/tools/tool-handlers), and [RAG Context](/docs/guides/retrieval/rag-context). diff --git a/apps/docs/content/docs/frameworks/sveltekit/06-persistence.mdx b/apps/docs/content/docs/frameworks/sveltekit/06-persistence.mdx new file mode 100644 index 00000000..9c5204e0 --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/06-persistence.mdx @@ -0,0 +1,49 @@ +--- +title: 06 Persistence +description: Persist SvelteKit chat history through your app storage. +--- + +Anvia returns the new messages from each run. Your SvelteKit app decides where sessions and history live. + +## 1. Load Existing Messages + +```ts +const session = await db.chatSession.findUnique({ + where: { id: sessionId, userId: locals.userId }, + include: { messages: { orderBy: { createdAt: "asc" } } }, +}); + +if (!session) { + return json({ error: { code: "not_found" } }, { status: 404 }); +} +``` + +## 2. Send With History + +```ts +const response = await supportAgent + .prompt(message) + .messages(session.messages.map((item) => item.message)) + .send(); +``` + +## 3. Store New Messages + +```ts +await db.chatMessage.createMany({ + data: response.messages.map((message) => ({ + sessionId, + message, + })), +}); +``` + +Only store messages after the run succeeds. If the run fails, record an application event instead of adding partial history. + +## 4. Use Memory When The Model Should Remember + +Use app storage for conversation history. Use Anvia memory when the model should recall durable facts in future sessions. + +## Next + +Prepare runtime constraints in [Deploy](/docs/frameworks/sveltekit/07-deploy). Related guides: [Memory](/docs/guides/memory), [Memory and Sessions](/docs/guides/sdk-fundamentals/memory-and-sessions), and [Agent History](/docs/guides/agents/agent-history). diff --git a/apps/docs/content/docs/frameworks/sveltekit/07-deploy.mdx b/apps/docs/content/docs/frameworks/sveltekit/07-deploy.mdx new file mode 100644 index 00000000..0dda8974 --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/07-deploy.mdx @@ -0,0 +1,43 @@ +--- +title: 07 Deploy +description: Deploy SvelteKit Anvia endpoints with the right runtime constraints. +--- + +Deployment depends on your SvelteKit adapter. Verify provider clients, vector stores, and observability packages against that runtime. + +## 1. Prefer Node For First Deploys + +Use a Node-compatible adapter when you need filesystem loaders, local embeddings, provider SDKs with Node dependencies, or long-running streams. + +```sh +pnpm add -D @sveltejs/adapter-node +``` + +## 2. Configure Environment Variables + +Set secrets in your deployment platform: + +```txt +OPENAI_API_KEY=sk_... +ANVIA_STUDIO_TOKEN=... +DATABASE_URL=... +``` + +Do not expose provider keys through public env prefixes. + +## 3. Streaming Checks + +Confirm your host supports unbuffered responses for `application/x-ndjson`. Some serverless platforms buffer responses unless streaming is explicitly enabled. + +## 4. Production Checklist + +| Check | Why | +| --- | --- | +| Node runtime verified | Provider and retrieval packages may require Node APIs | +| Request timeout configured | Agent runs can be longer than simple CRUD requests | +| Secrets scoped server-side | Provider keys must never reach the browser | +| Error logs connected | Provider and tool failures need operational visibility | + +## Next + +Debug common failures in [Troubleshooting](/docs/frameworks/sveltekit/08-troubleshooting). Add telemetry with [Observability](/docs/guides/observability/tracing). diff --git a/apps/docs/content/docs/frameworks/sveltekit/08-troubleshooting.mdx b/apps/docs/content/docs/frameworks/sveltekit/08-troubleshooting.mdx new file mode 100644 index 00000000..933c6fc2 --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/08-troubleshooting.mdx @@ -0,0 +1,46 @@ +--- +title: 08 Troubleshooting +description: Fix common SvelteKit and Anvia integration failures. +--- + +Most failures come from importing server code into the browser, missing env vars, or buffered streams. + +## `OPENAI_API_KEY is required` + +Use `$env/static/private` in server-only modules. Do not read provider keys in `+page.svelte` or `+page.ts`. + +```ts +import { OPENAI_API_KEY } from "$env/static/private"; +``` + +## `Cannot import server-only module into client code` + +Move Anvia imports into `src/lib/server/...` and call them from `+server.ts`. + +## Route Body Parsing Fails + +Validate unknown JSON before calling the agent: + +```ts +const parsed = SupportRequest.safeParse(await request.json()); + +if (!parsed.success) { + return Response.json({ error: { code: "bad_request" } }, { status: 400 }); +} +``` + +## Stream Does Not Flush + +Check the adapter and hosting platform. Return the event stream response from `@anvia/server` and avoid wrapping the stream in `json(...)`. + +```ts +return createEventStream(agent.prompt(message).stream(), { format: "jsonl" }); +``` + +## Provider Failures + +Catch provider errors at the route boundary, log the internal detail, and return a stable application error. + +## Next + +Add reviewer workflows in [Human in the Loop](/docs/frameworks/sveltekit/09-human-in-the-loop). Related guides: [Tool Errors](/docs/guides/tools/tool-errors), [Readable Streams](/docs/guides/streaming/readable-streams), and [Tracing](/docs/guides/observability/tracing). diff --git a/apps/docs/content/docs/frameworks/sveltekit/09-human-in-the-loop.mdx b/apps/docs/content/docs/frameworks/sveltekit/09-human-in-the-loop.mdx new file mode 100644 index 00000000..e6e8c9d5 --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/09-human-in-the-loop.mdx @@ -0,0 +1,134 @@ +--- +title: 09 Human in the Loop +description: Add approvals and reviewer decisions to SvelteKit Anvia endpoints. +--- + +SvelteKit can expose both the agent endpoint and reviewer decision endpoints. Anvia provides hooks; your app provides the approval runtime. + +## 1. Use Studio During Development + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "$lib/server/ai/support-agent"; + +new Studio([supportAgent]).start({ port: 4021 }); +``` + +Studio is useful locally. Production approval storage, reviewer permissions, and notifications belong to your app. + +## 2. Create A Hook + +```ts +import { createHook } from "@anvia/core"; +import { approvalRuntime } from "$lib/server/approvals/runtime"; + +export function createApprovalHook(input: { userId: string; approvalRunId: string }) { + return createHook({ + async onToolCall({ toolName, args, tool }) { + if (toolName !== "refund_order") { + return tool.run(); + } + + const approved = await approvalRuntime.waitForDecision({ + userId: input.userId, + approvalRunId: input.approvalRunId, + toolName, + args, + }); + + return approved ? tool.run() : tool.skip("Refund was not approved."); + }, + }); +} +``` + +`approvalRuntime` is not an Anvia API. Create it next to your database, queue, notification, and reviewer UI code. + +## 3. Create The Approval Runtime + +```ts +type ApprovalRequest = { + userId: string; + approvalRunId: string; + toolName: string; + args: string; +}; + +type ApprovalDecision = { + approved: boolean; + reason?: string; +}; + +export function createApprovalRuntime() { + const waiters = new Map void>(); + + return { + async waitForDecision(request: ApprovalRequest): Promise { + const approval = await db.approval.create({ + data: { ...request, status: "pending" }, + }); + + await notifyReviewers({ approvalId: approval.id }); + + const decision = await new Promise((resolve) => { + waiters.set(approval.id, resolve); + }); + + waiters.delete(approval.id); + return decision.approved; + }, + + async decide(input: { approvalId: string; approved: boolean; reason?: string }) { + await db.approval.update({ + where: { id: input.approvalId }, + data: { + status: input.approved ? "approved" : "rejected", + decisionReason: input.reason, + resolvedAt: new Date(), + }, + }); + + waiters.get(input.approvalId)?.({ + approved: input.approved, + reason: input.reason, + }); + }, + }; +} + +export const approvalRuntime = createApprovalRuntime(); +``` + +The `Map` is only a local waiter. Use durable storage plus queue, pub/sub, websocket, or polling workers for production. + +## 4. Add Reviewer Routes + +```ts +import { json, type RequestHandler } from "@sveltejs/kit"; +import { z } from "zod"; +import { approvalRuntime } from "$lib/server/approvals/runtime"; + +const DecisionRequest = z.object({ + approved: z.boolean(), + reason: z.string().optional(), +}); + +export const POST: RequestHandler = async ({ params, request, locals }) => { + if (!locals.userId) { + return json({ error: { code: "unauthorized" } }, { status: 401 }); + } + + const decision = DecisionRequest.parse(await request.json()); + + await approvalRuntime.decide({ + approvalId: params.id, + ...decision, + }); + + return json({ ok: true }); +}; +``` + +## Next + +Add SvelteKit tests in [Setup Tests](/docs/frameworks/sveltekit/10-setup-tests). Core concepts: [Human in the Loop](/docs/guides/human-in-the-loop), [Approval by Hooks](/docs/guides/human-in-the-loop/tool-approval), and [Approval Runtimes](/docs/guides/human-in-the-loop/approval-handlers). diff --git a/apps/docs/content/docs/frameworks/sveltekit/10-setup-tests.mdx b/apps/docs/content/docs/frameworks/sveltekit/10-setup-tests.mdx new file mode 100644 index 00000000..c44bd1f2 --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/10-setup-tests.mdx @@ -0,0 +1,91 @@ +--- +title: 10 Setup Tests +description: Test SvelteKit Anvia endpoints, streams, and provider boundaries. +--- + +Test endpoint behavior with mocked agents. Keep provider integration tests separate and opt-in. + +## 1. Install Test Tools + +```sh +pnpm add -D vitest +``` + +## 2. Test The JSON Endpoint + +```ts +import { describe, expect, it, vi } from "vitest"; +import { POST } from "./+server"; + +vi.mock("$lib/server/ai/support-agent", () => ({ + supportAgent: { + prompt: () => ({ + send: async () => ({ + output: "Reset links expire after 30 minutes.", + usage: { totalTokens: 12 }, + messages: [], + }), + }), + }, +})); + +describe("POST /api/support", () => { + it("returns the agent output", async () => { + const response = await POST({ + request: new Request("http://test.local/api/support", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "How long does a reset link last?" }), + }), + locals: {}, + params: {}, + url: new URL("http://test.local/api/support"), + } as never); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + output: "Reset links expire after 30 minutes.", + }); + }); +}); +``` + +## 3. Test The Stream Endpoint + +```ts +async function* stream() { + yield { type: "final", output: "Hello" }; +} + +vi.mock("$lib/server/ai/support-agent", () => ({ + supportAgent: { + prompt: () => ({ + stream, + }), + }, +})); +``` + +Assert the endpoint returns `application/x-ndjson` and a readable body. + +## 4. Test Studio Without A Port + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "$lib/server/ai/support-agent"; + +const studio = new Studio([supportAgent]); +const response = await studio.fetch( + new Request("http://studio.test/agents/support/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "Hello" }), + }), +); + +expect(response.status).toBe(200); +``` + +## Next + +Related guides: [Testing](/docs/guides/testing), [Tools and Pipelines](/docs/guides/testing/tools-and-pipelines), and [Studio and Providers](/docs/guides/testing/studio-and-providers). diff --git a/apps/docs/content/docs/frameworks/sveltekit/meta.json b/apps/docs/content/docs/frameworks/sveltekit/meta.json new file mode 100644 index 00000000..ba8f2959 --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/meta.json @@ -0,0 +1,17 @@ +{ + "title": "SvelteKit", + "defaultOpen": false, + "collapsible": true, + "pages": [ + "01-prep", + "02-setup-anvia", + "03-route-handler", + "04-streaming", + "05-tools-and-context", + "06-persistence", + "07-deploy", + "08-troubleshooting", + "09-human-in-the-loop", + "10-setup-tests" + ] +} diff --git a/apps/docs/content/docs/frameworks/tanstack-start/01-prep.mdx b/apps/docs/content/docs/frameworks/tanstack-start/01-prep.mdx new file mode 100644 index 00000000..0e7e8e5f --- /dev/null +++ b/apps/docs/content/docs/frameworks/tanstack-start/01-prep.mdx @@ -0,0 +1,61 @@ +--- +title: 01 Prep +description: Prepare a TanStack Start app for Anvia server functions and server routes. +--- + +Use this path when Anvia will run inside a TanStack Start application. + +## 1. Create or Open a Start Project + +Create a TanStack Start React project from the current TanStack starter, or use an existing app with `@tanstack/react-start` installed. + +```sh +pnpm create @tanstack/start@latest anvia-start +cd anvia-start +``` + +## 2. Install Anvia + +```sh +pnpm add @anvia/core @anvia/openai @anvia/server @anvia/react zod +``` + +Install other provider packages only when you need them: + +```sh +pnpm add @anvia/anthropic @anvia/gemini @anvia/mistral +``` + +## 3. Add Environment Variables + +Anvia clients require explicit configuration. + +```txt +OPENAI_API_KEY=sk_... +``` + +Read this value in server-side modules or server route handlers: + +```ts +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + throw new Error("OPENAI_API_KEY is required"); +} +``` + +## 4. Choose File Boundaries + +TanStack Start gives you two useful server shapes: + +| File | Purpose | +| --- | --- | +| `src/ai/support-agent.ts` | Provider client, model, tools, and reusable agent | +| `src/routes/api/support.ts` | Raw HTTP server route for JSON and streaming | +| `src/ai/support.functions.ts` | Optional `createServerFn(...)` wrapper callable from routes or components | + +Use server routes when you need raw `Request` and `Response` control. Use server functions when you want typed RPC-style calls inside the app. + +## Next + +Build the reusable agent in [Setup Anvia](/docs/frameworks/tanstack-start/02-setup-anvia). Read [How Anvia Works](/docs/guides/sdk-fundamentals/runtime-boundaries) for the SDK boundaries. diff --git a/apps/docs/content/docs/frameworks/tanstack-start/02-setup-anvia.mdx b/apps/docs/content/docs/frameworks/tanstack-start/02-setup-anvia.mdx new file mode 100644 index 00000000..3fe1704a --- /dev/null +++ b/apps/docs/content/docs/frameworks/tanstack-start/02-setup-anvia.mdx @@ -0,0 +1,76 @@ +--- +title: 02 Setup Anvia +description: Create a reusable Anvia agent module for TanStack Start. +--- + +Create clients, models, and shared tools in a server-side module. Import this module only from server routes, server functions, loaders, or tests. + +## 1. Create `src/ai/support-agent.ts` + +```ts +import { AgentBuilder, createTool } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; +import { z } from "zod"; + +const apiKey = process.env.OPENAI_API_KEY; + +if (!apiKey) { + throw new Error("OPENAI_API_KEY is required"); +} + +const client = new OpenAIClient({ apiKey }); +export const model = client.completionModel("gpt-5.5"); + +const lookupPolicy = createTool({ + name: "lookup_policy", + description: "Look up a short support policy by key.", + input: z.object({ + key: z.enum(["password_reset", "priority_support"]), + }), + output: z.object({ + text: z.string(), + }), + async execute({ key }) { + const policies = { + password_reset: "Password reset links expire after 30 minutes.", + priority_support: "Enterprise customers receive priority support.", + }; + + return { text: policies[key] }; + }, +}); + +export const supportAgent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly. Use tools for policy facts.") + .tool(lookupPolicy) + .defaultMaxTurns(3) + .build(); +``` + +## 2. Optional Server Function Wrapper + +Use `createServerFn(...)` when app code wants a typed server call instead of calling a raw HTTP route. + +```ts +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { supportAgent } from "./support-agent"; + +const SupportInput = z.object({ + message: z.string().min(1), +}); + +export const askSupport = createServerFn({ method: "POST" }) + .inputValidator(SupportInput) + .handler(async ({ data }) => { + const response = await supportAgent.prompt(data.message).send(); + return { + output: response.output, + usage: response.usage, + }; + }); +``` + +## Next + +Expose the agent through a server route in [Route Handler](/docs/frameworks/tanstack-start/03-route-handler). Related guides: [Creating Agents](/docs/guides/agents/creating-agents), [Tools](/docs/guides/tools/creating-tools), and [Provider Clients](/docs/guides/sdk-fundamentals/clients-and-models). diff --git a/apps/docs/content/docs/frameworks/tanstack-start/03-route-handler.mdx b/apps/docs/content/docs/frameworks/tanstack-start/03-route-handler.mdx new file mode 100644 index 00000000..95a5630d --- /dev/null +++ b/apps/docs/content/docs/frameworks/tanstack-start/03-route-handler.mdx @@ -0,0 +1,74 @@ +--- +title: 03 Route Handler +description: Return a non-streaming Anvia response from a TanStack Start server route. +--- + +TanStack Start server routes live in `src/routes` and can return Web `Response` objects. + +## 1. Create `src/routes/api/support.ts` + +```ts +import { createFileRoute } from "@tanstack/react-router"; +import { supportAgent } from "~/ai/support-agent"; + +type SupportRequest = { + message?: string; +}; + +export const Route = createFileRoute("/api/support")({ + server: { + handlers: { + POST: async ({ request }) => { + const body = (await request.json()) as SupportRequest; + const message = body.message?.trim(); + + if (!message) { + return Response.json( + { error: { code: "bad_request", message: "message is required" } }, + { status: 400 }, + ); + } + + const response = await supportAgent.prompt(message).send(); + + return Response.json({ + output: response.output, + usage: response.usage, + messages: response.messages, + }); + }, + }, + }, +}); +``` + +## 2. Call The Route + +```ts +const response = await fetch("/api/support", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "Can enterprise customers use priority support?", + }), +}); + +const data = await response.json(); +console.log(data.output); +``` + +## 3. Use Server Functions For App-Internal Calls + +If you created `askSupport`, app code can call the server function instead: + +```ts +const result = await askSupport({ + data: { message: "How long does a reset link last?" }, +}); +``` + +Use the raw server route for external clients, webhooks, and streaming. + +## Next + +Return live Anvia events in [Streaming](/docs/frameworks/tanstack-start/04-streaming). For prompt response fields, read [Prompt Responses](/docs/guides/sdk-fundamentals/prompt-responses). diff --git a/apps/docs/content/docs/frameworks/tanstack-start/04-streaming.mdx b/apps/docs/content/docs/frameworks/tanstack-start/04-streaming.mdx new file mode 100644 index 00000000..e13c0eb3 --- /dev/null +++ b/apps/docs/content/docs/frameworks/tanstack-start/04-streaming.mdx @@ -0,0 +1,69 @@ +--- +title: 04 Streaming +description: Stream Anvia run events from a TanStack Start server route. +--- + +Use a server route for streaming because it gives direct access to the `Response` created by `@anvia/server`. + +## 1. Add `POST` Streaming Handler + +```ts +import { createEventStream } from "@anvia/server"; +import { createFileRoute } from "@tanstack/react-router"; +import { supportAgent } from "~/ai/support-agent"; + +type SupportStreamRequest = { + message?: string; +}; + +export const Route = createFileRoute("/api/support/stream")({ + server: { + handlers: { + POST: async ({ request }) => { + const body = (await request.json()) as SupportStreamRequest; + const message = body.message?.trim(); + + if (!message) { + return Response.json( + { error: { code: "bad_request", message: "message is required" } }, + { status: 400 }, + ); + } + + return createEventStream(supportAgent.prompt(message).stream(), { format: "jsonl" }); + }, + }, + }, +}); +``` + +## 2. Consume Stream Events From React + +```tsx +import { useChat } from "@anvia/react"; + +export function SupportChat() { + const chat = useChat({ endpoint: "/api/support/stream" }); + + return ( +
{ + event.preventDefault(); + void chat.send(); + }} + > +
{chat.text}
+ chat.setInput(event.target.value)} /> + +
+ ); +} +``` + +## 3. Persist After `final` + +Do not save partial text deltas as conversation history. Save the final output or use Anvia memory/event storage. + +## Next + +Add authorization and retrieval in [Tools and Context](/docs/frameworks/tanstack-start/05-tools-and-context). Related guides: [Readable Streams](/docs/guides/streaming/readable-streams) and [Streaming Events](/docs/guides/streaming/streaming-events). diff --git a/apps/docs/content/docs/frameworks/tanstack-start/05-tools-and-context.mdx b/apps/docs/content/docs/frameworks/tanstack-start/05-tools-and-context.mdx new file mode 100644 index 00000000..022acfed --- /dev/null +++ b/apps/docs/content/docs/frameworks/tanstack-start/05-tools-and-context.mdx @@ -0,0 +1,88 @@ +--- +title: 05 Tools and Context +description: Scope TanStack Start request data before exposing tools and retrieval to Anvia. +--- + +Resolve auth and request data in the server route or server function. If a tool needs that data, create a scoped tool or agent for the request. + +## 1. Create a Scoped Agent Factory + +```ts +import { AgentBuilder, createTool } from "@anvia/core"; +import { z } from "zod"; +import { model } from "~/ai/support-agent"; +import { orders } from "~/db/orders"; + +type SupportScope = { + userId: string; +}; + +export function createSupportAgent(scope: SupportScope) { + const lookupOrder = createTool({ + name: "lookup_order", + description: "Look up one order owned by the current user.", + input: z.object({ + orderId: z.string(), + }), + output: z.object({ + status: z.string(), + }), + async execute({ orderId }) { + return orders.findForUser(scope.userId, orderId); + }, + }); + + return new AgentBuilder("support", model) + .instructions("Use tools for account-specific data.") + .tool(lookupOrder) + .defaultMaxTurns(3) + .build(); +} +``` + +## 2. Use It From a Server Route + +```ts +import { createFileRoute } from "@tanstack/react-router"; +import { requireUser } from "~/auth/server"; +import { createSupportAgent } from "~/ai/create-support-agent"; + +export const Route = createFileRoute("/api/support")({ + server: { + handlers: { + POST: async ({ request }) => { + const user = await requireUser(request); + const { message } = (await request.json()) as { message?: string }; + + if (!message?.trim()) { + return Response.json({ error: "message is required" }, { status: 400 }); + } + + const agent = createSupportAgent({ userId: user.id }); + const response = await agent.prompt(message).send(); + + return Response.json({ output: response.output }); + }, + }, + }, +}); +``` + +## 3. Add Retrieval Context + +```ts +const agent = new AgentBuilder("support", model) + .instructions("Use retrieved support docs when relevant.") + .dynamicContext(supportDocsIndex, { + topK: 3, + threshold: 0.7, + }) + .tool(lookupOrder) + .build(); +``` + +Build retrieval indexes during ingestion, startup, or background work, not inside every route call. + +## Next + +Persist history in [Persistence](/docs/frameworks/tanstack-start/06-persistence). Related guides: [Runtime Context](/docs/guides/agents/runtime-context), [RAG Context](/docs/guides/retrieval/rag-context), and [Tool Handlers](/docs/guides/tools/tool-handlers). diff --git a/apps/docs/content/docs/frameworks/tanstack-start/06-persistence.mdx b/apps/docs/content/docs/frameworks/tanstack-start/06-persistence.mdx new file mode 100644 index 00000000..054903cb --- /dev/null +++ b/apps/docs/content/docs/frameworks/tanstack-start/06-persistence.mdx @@ -0,0 +1,56 @@ +--- +title: 06 Persistence +description: Store TanStack Start conversation history with app storage or Anvia memory. +--- + +Persist conversation state in server code. Client components should call server routes or server functions. + +## 1. Explicit Transcript Storage + +```ts +import { Message } from "@anvia/core"; +import { supportAgent } from "~/ai/support-agent"; +import { conversations } from "~/db/conversations"; + +export async function runSupportTurn(input: { + conversationId: string; + message: string; +}) { + const history = await conversations.loadMessages(input.conversationId); + const response = await supportAgent + .prompt([...history, Message.user(input.message)]) + .send(); + + await conversations.saveMessages(input.conversationId, [ + ...history, + ...response.messages, + ]); + + return response; +} +``` + +Call this helper from a server route or `createServerFn(...)`. + +## 2. Agent Memory + +```ts +const agent = new AgentBuilder("support", model) + .memory(memoryStore, { savePolicy: "message" }) + .build(); + +const response = await agent + .session(conversationId, { userId }) + .prompt(message) + .send(); +``` + +Use memory when the route should not load and append messages manually. + +## 3. Streaming Persistence + +For streaming routes, wait for the terminal event in the client or use memory on the agent. Use an event store when you need replayable runtime events. + +## Next + +Review deployment checks in [Deploy](/docs/frameworks/tanstack-start/07-deploy). Related guides: [Memory](/docs/guides/memory), [Raw SQL](/docs/guides/memory/raw-sql), and [Event Store](/docs/guides/agents/event-store). diff --git a/apps/docs/content/docs/frameworks/tanstack-start/07-deploy.mdx b/apps/docs/content/docs/frameworks/tanstack-start/07-deploy.mdx new file mode 100644 index 00000000..7eef12ea --- /dev/null +++ b/apps/docs/content/docs/frameworks/tanstack-start/07-deploy.mdx @@ -0,0 +1,46 @@ +--- +title: 07 Deploy +description: Check TanStack Start runtime, environment, and streaming behavior before deployment. +--- + +## Runtime Checklist + +| Area | Check | +| --- | --- | +| Server placement | Provider clients, agents, and tools stay in server modules | +| Secrets | Provider keys are available only to server code | +| Streaming | Host and proxy support long-lived response bodies | +| Timeouts | Route timeouts exceed expected model and tool duration | +| Storage | Conversations, memory, retrieval indexes, and traces use durable stores | + +## Prefer Server Routes For HTTP Surfaces + +Server functions are useful inside the app. Server routes are the clearest contract for external clients and streaming endpoints because they return `Response` directly. + +## Add Trace Metadata + +```ts +const response = await supportAgent + .prompt(message) + .withTrace({ + name: "support-route", + userId, + sessionId: conversationId, + tags: ["tanstack-start"], + }) + .send(); +``` + +Attach observers for logs, metrics, Langfuse, or OpenTelemetry. + +## Deployment Smoke Test + +```sh +curl -X POST "$APP_URL/api/support" \ + -H "Content-Type: application/json" \ + -d '{"message":"Say hello"}' +``` + +## Next + +Use [Troubleshooting](/docs/frameworks/tanstack-start/08-troubleshooting) for common failures. Related guides: [Observers](/docs/guides/observability/observers), [Langfuse](/docs/guides/observability/langfuse), and [OpenTelemetry](/docs/guides/observability/otel). diff --git a/apps/docs/content/docs/frameworks/tanstack-start/08-troubleshooting.mdx b/apps/docs/content/docs/frameworks/tanstack-start/08-troubleshooting.mdx new file mode 100644 index 00000000..74d42651 --- /dev/null +++ b/apps/docs/content/docs/frameworks/tanstack-start/08-troubleshooting.mdx @@ -0,0 +1,49 @@ +--- +title: 08 Troubleshooting +description: Fix common TanStack Start and Anvia integration issues. +--- + +## Server Function Works But HTTP Client Cannot Call It + +Server functions are app-internal RPC-style calls. Use a server route under `src/routes` when external clients need a normal HTTP endpoint. + +## Route Returns `message is required` + +Send JSON with the expected field: + +```sh +curl -X POST http://localhost:3000/api/support \ + -H "Content-Type: application/json" \ + -d '{"message":"Hello"}' +``` + +## Provider Key Is Missing + +Create the provider client only where server environment variables are available: + +```ts +const apiKey = process.env.OPENAI_API_KEY; +if (!apiKey) throw new Error("OPENAI_API_KEY is required"); +``` + +## Server Code Reaches The Client Bundle + +Keep provider clients, agents, database clients, and scoped tool factories out of client components. Put client-safe schemas and types in separate files. + +## Streaming Does Not Flush + +Use a server route and return the event stream response from `@anvia/server`: + +```ts +return createEventStream(agent.prompt(message).stream(), { format: "jsonl" }); +``` + +Also check host buffering and route timeouts. + +## Tool Authorization Is Wrong + +Resolve the current user in the route or server function. Close over that user in request-scoped tools instead of trusting model-provided identifiers. + +## Next + +Revisit [Route Handler](/docs/frameworks/tanstack-start/03-route-handler), [Tools and Context](/docs/frameworks/tanstack-start/05-tools-and-context), and [Tool Errors](/docs/guides/tools/tool-errors). diff --git a/apps/docs/content/docs/frameworks/tanstack-start/09-human-in-the-loop.mdx b/apps/docs/content/docs/frameworks/tanstack-start/09-human-in-the-loop.mdx new file mode 100644 index 00000000..909fcfe2 --- /dev/null +++ b/apps/docs/content/docs/frameworks/tanstack-start/09-human-in-the-loop.mdx @@ -0,0 +1,143 @@ +--- +title: 09 Human in the Loop +description: Add approvals and human feedback to TanStack Start Anvia routes. +--- + +Human-in-the-loop work belongs in server routes or server functions. The agent can wait on your approval service before a protected tool runs. + +## 1. Use Studio During Development + +Add approval metadata to a tool and register the same built agent in Studio: + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "~/ai/support-agent"; + +new Studio([supportAgent]).start({ port: 4021 }); +``` + +Studio handles the approval UI for tools with `approval` metadata. Your TanStack Start app can still expose its own routes for production users. + +## 2. Use A Request Hook In Server Code + +```ts +import { createHook } from "@anvia/core"; +import { approvalRuntime } from "~/approvals/runtime"; + +function createApprovalHook(input: { userId: string; runId: string }) { + return createHook({ + async onToolCall({ toolName, args, tool }) { + if (toolName !== "refund_order") { + return tool.run(); + } + + const approved = await approvalRuntime.waitForDecision({ + userId: input.userId, + runId: input.runId, + toolName, + args, + }); + + return approved ? tool.run() : tool.skip("Refund was not approved."); + }, + }); +} +``` + +`approvalRuntime` is your own module. It is not imported from `@anvia/core` or any Anvia package. + +Attach the hook from a server route: + +```ts +const response = await supportAgent + .prompt(message) + .requestHook(createApprovalHook({ userId, runId })) + .send(); +``` + +## 3. Create The Approval Runtime + +`approvalRuntime` is your application runtime for reviewer state. Anvia only waits for the promise returned by `waitForDecision(...)`; your app creates the pending record, shows it to reviewers, accepts the decision, and resolves the waiting promise. + +```ts +type ApprovalRequest = { + userId: string; + runId: string; + toolName: string; + args: string; +}; + +type ApprovalDecision = { + approved: boolean; + reason?: string; +}; + +export function createApprovalRuntime() { + const waiters = new Map void>(); + + return { + async waitForDecision(request: ApprovalRequest): Promise { + const approval = await db.approval.create({ + data: { + userId: request.userId, + runId: request.runId, + toolName: request.toolName, + args: request.args, + status: "pending", + }, + }); + + await notifyReviewers({ approvalId: approval.id }); + + const decision = await new Promise((resolve) => { + waiters.set(approval.id, resolve); + }); + + waiters.delete(approval.id); + return decision.approved; + }, + + async decide(input: { + approvalId: string; + reviewerId: string; + approved: boolean; + reason?: string; + }): Promise { + await db.approval.update({ + where: { id: input.approvalId }, + data: { + status: input.approved ? "approved" : "rejected", + reviewerId: input.reviewerId, + decisionReason: input.reason, + resolvedAt: new Date(), + }, + }); + + waiters.get(input.approvalId)?.({ + approved: input.approved, + reason: input.reason, + }); + }, + }; +} + +export const approvalRuntime = createApprovalRuntime(); +``` + +This in-memory waiter works for a single running process. For production, store approvals durably and resolve waiters through your queue, pub/sub, websocket, or polling worker. + +## 4. Return Pending State From App Routes + +If a run can wait for approval longer than your HTTP timeout, split the workflow: + +| Route | Purpose | +| --- | --- | +| `POST /api/support/runs` | Create a run and pending approval record | +| `GET /api/approvals` | List pending approvals for the reviewer | +| `POST /api/approvals/:id/decision` | Resolve the waiting approval promise | + +For short internal workflows, the route can wait directly. For user-facing flows, store state and notify the client. + +## Next + +Add route tests in [Setup Tests](/docs/frameworks/tanstack-start/10-setup-tests). Core concepts: [Human in the Loop](/docs/guides/human-in-the-loop), [Approval by Hooks](/docs/guides/human-in-the-loop/tool-approval), [Approval Runtimes](/docs/guides/human-in-the-loop/approval-handlers), and [Studio Tool Approvals](/docs/studio/human-in-the-loop/tool-approvals). diff --git a/apps/docs/content/docs/frameworks/tanstack-start/10-setup-tests.mdx b/apps/docs/content/docs/frameworks/tanstack-start/10-setup-tests.mdx new file mode 100644 index 00000000..0c51615f --- /dev/null +++ b/apps/docs/content/docs/frameworks/tanstack-start/10-setup-tests.mdx @@ -0,0 +1,101 @@ +--- +title: 10 Setup Tests +description: Test TanStack Start server functions, route handlers, streams, and Studio wiring. +--- + +Keep tests close to the boundary you own: server functions, route helpers, and the agent wrapper. + +## 1. Install Test Tools + +```sh +pnpm add -D vitest +``` + +## 2. Extract Route Logic + +Put route behavior in a testable helper: + +```ts +import { supportAgent } from "~/ai/support-agent"; + +export async function handleSupportPost(request: Request): Promise { + const { message } = (await request.json()) as { message?: string }; + + if (!message?.trim()) { + return Response.json({ error: "message is required" }, { status: 400 }); + } + + const response = await supportAgent.prompt(message).send(); + return Response.json({ output: response.output }); +} +``` + +Use the helper from the route: + +```ts +export const Route = createFileRoute("/api/support")({ + server: { + handlers: { + POST: ({ request }) => handleSupportPost(request), + }, + }, +}); +``` + +## 3. Test The Helper + +```ts +import { describe, expect, it } from "vitest"; +import { handleSupportPost } from "~/routes/api/support"; + +describe("POST /api/support", () => { + it("rejects empty messages", async () => { + const response = await handleSupportPost( + new Request("http://test.local/api/support", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "" }), + }), + ); + + expect(response.status).toBe(400); + }); +}); +``` + +## 4. Test Server Functions + +```ts +import { askSupport } from "~/ai/support.functions"; + +const result = await askSupport({ + data: { message: "How long does a reset link last?" }, +}); + +expect(result.output).toContain("30 minutes"); +``` + +Use a mocked agent for route/unit tests. Reserve real provider calls for narrow integration tests. + +## 5. Test Studio Without a Port + +```ts +import { Studio } from "@anvia/studio"; +import { supportAgent } from "~/ai/support-agent"; + +const studio = new Studio([supportAgent]); + +const response = await studio.fetch( + new Request("http://studio.test/agents/support/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "Hello" }), + }), +); + +expect(response.status).toBe(200); +``` + +## Next + +Related guides: [Testing](/docs/guides/testing), [Tools and Pipelines](/docs/guides/testing/tools-and-pipelines), and [Studio and Providers](/docs/guides/testing/studio-and-providers). diff --git a/apps/docs/content/docs/frameworks/tanstack-start/meta.json b/apps/docs/content/docs/frameworks/tanstack-start/meta.json new file mode 100644 index 00000000..c807439e --- /dev/null +++ b/apps/docs/content/docs/frameworks/tanstack-start/meta.json @@ -0,0 +1,17 @@ +{ + "title": "TanStack Start", + "defaultOpen": false, + "collapsible": true, + "pages": [ + "01-prep", + "02-setup-anvia", + "03-route-handler", + "04-streaming", + "05-tools-and-context", + "06-persistence", + "07-deploy", + "08-troubleshooting", + "09-human-in-the-loop", + "10-setup-tests" + ] +} diff --git a/apps/docs/content/docs/guides/agents/agent-configuration.mdx b/apps/docs/content/docs/guides/agents/agent-configuration.mdx index d4602ca1..710259f3 100644 --- a/apps/docs/content/docs/guides/agents/agent-configuration.mdx +++ b/apps/docs/content/docs/guides/agents/agent-configuration.mdx @@ -58,9 +58,10 @@ const agent = new AgentBuilder("support", model) Use request-level methods when the setting belongs to one prompt. ```ts +import { Message } from "@anvia/core"; + const response = await agent - .prompt(userInput) - .withHistory(history) + .prompt([...history, Message.user(userInput)]) .maxTurns(1) .withTrace({ name: "support-chat", userId }) .send(); diff --git a/apps/docs/content/docs/guides/agents/agent-history.mdx b/apps/docs/content/docs/guides/agents/agent-history.mdx index a38aa4bd..3fb23b36 100644 --- a/apps/docs/content/docs/guides/agents/agent-history.mdx +++ b/apps/docs/content/docs/guides/agents/agent-history.mdx @@ -3,7 +3,7 @@ title: Agent History description: Work with chat history and multi-turn agent sessions. --- -Anvia history is a plain `Message[]`. You choose where to store it, then pass it into each new prompt with `.withHistory(...)`. +Anvia history is a plain `Message[]`. For explicit stateless history, pass the whole transcript to `agent.prompt([...])`; the last message is the active prompt and earlier messages are history. ## Basic Shape @@ -37,11 +37,9 @@ const history = [ ```ts const history = await conversations.loadMessages(conversationId); +const currentPrompt = Message.user(userInput); -const response = await agent - .prompt(userInput) - .withHistory(history) - .send(); +const response = await agent.prompt([...history, currentPrompt]).send(); await conversations.saveMessages(conversationId, [ ...history, diff --git a/apps/docs/content/docs/guides/agents/agent-tools.mdx b/apps/docs/content/docs/guides/agents/agent-tools.mdx index f33c5735..7408da7a 100644 --- a/apps/docs/content/docs/guides/agents/agent-tools.mdx +++ b/apps/docs/content/docs/guides/agents/agent-tools.mdx @@ -83,24 +83,21 @@ const response = await agent.prompt("Where is order A-100?").maxTurns(3).send(); ## Require Approval -Use hook-based tool approval when a tool should not run until your app approves it. +Use hook-based tool approval when a tool should not run until an approval-capable runtime approves it. ```ts import { createHook } from "@anvia/core"; const approvalHook = createHook({ - async onToolCall({ toolName, args, tool }) { + onToolCall({ toolName, tool }) { if (toolName !== "refund_order") { return tool.run(); } - const approved = await approvals.waitForDecision({ - toolName, - args, + return tool.requestApproval({ reason: "Refunds require staff approval.", + rejectMessage: "Refund was not approved.", }); - - return approved ? tool.run() : tool.skip("Refund was not approved."); }, }); @@ -110,4 +107,6 @@ const response = await agent .send(); ``` +Studio handles `tool.requestApproval(...)` automatically. Without an approval handler, core cancels clearly instead of running the guarded tool. + For the full approval flow, read [Human in the Loop](/docs/guides/human-in-the-loop). diff --git a/apps/docs/content/docs/guides/agents/creating-agents.mdx b/apps/docs/content/docs/guides/agents/creating-agents.mdx index 3e82ad94..19649f3c 100644 --- a/apps/docs/content/docs/guides/agents/creating-agents.mdx +++ b/apps/docs/content/docs/guides/agents/creating-agents.mdx @@ -66,11 +66,12 @@ const agent = new AgentBuilder("support", model) `.prompt(...)` creates a request. Chain request-level options before `.send()`. ```ts +import { Message } from "@anvia/core"; + const history = await conversations.loadMessages(conversationId); const response = await agent - .prompt("What did we decide earlier?") - .withHistory(history) + .prompt([...history, Message.user("What did we decide earlier?")]) .maxTurns(2) .withTrace({ name: "support-follow-up", userId: "user_123" }) .send(); diff --git a/apps/docs/content/docs/guides/agents/event-store.mdx b/apps/docs/content/docs/guides/agents/event-store.mdx new file mode 100644 index 00000000..984be099 --- /dev/null +++ b/apps/docs/content/docs/guides/agents/event-store.mdx @@ -0,0 +1,173 @@ +--- +title: Event Store +description: Persist agent stream events for replay and debugging. +--- + +An event store records runtime events from `PromptRequest.stream()`. Use it when you need to replay or inspect what happened during a run after the live stream has ended. + +Most applications start by rendering stream events directly to a UI: + +```ts +for await (const event of agent.prompt(prompt).stream()) { + render(event); +} +``` + +That is enough for live output, but the event data is gone once the stream is consumed. An event store gives you a durable runtime log for debugging, local inspection, Studio-like replay, audits, tests, or post-run analytics. + +## Why Event Store Exists + +Memory and event storage solve different problems. + +| API | Stores | Used as future model context | +| --- | --- | --- | +| `memory(...)` | Conversation messages: user prompts, assistant messages, final tool results | Yes | +| `eventStore(...)` | Runtime events: text deltas, tool progress, nested child-agent events | No | + +Memory is deliberately transcript-shaped because it is loaded back into future prompts. If Anvia stored every streamed text delta, tool progress event, nested child-agent turn, or UI-only runtime marker in memory, future model calls would receive noisy partial state instead of a clean conversation. + +The event store exists so you can keep that runtime detail without polluting future model context. + +Use memory when the model should remember something. Use event store when your application should remember how a run executed. + +## When To Use It + +Use an event store when you need any of these: + +- replay a run after the live stream is finished +- inspect nested `asTool({ stream: true })` child-agent progress +- debug which tool or child agent produced a bad result +- build a run timeline or Studio-style viewer +- calculate latency, tool usage, or agent activity after the run +- keep an audit log of runtime execution separate from conversation memory + +Skip it when you only need the final response, or when your UI consumes live stream events and does not need replay later. + +## Design Boundary + +Anvia treats model transcript, runtime events, and observability as separate surfaces: + +| Surface | Main question | Typical consumer | +| --- | --- | --- | +| Memory | What should the model know next time? | Future prompt runs | +| Event Store | What happened during this run? | Product UI, replay, debugging | +| Observers | What telemetry should be exported? | Tracing and monitoring systems | + +This separation keeps each storage layer small and predictable. Your application can store events in the same database as memory if you want, but the APIs stay separate so you can apply different retention, indexing, privacy, and replay policies. + +## Configure an Event Store + +```ts +const agent = new AgentBuilder("support", model) + .eventStore(eventStore, { include: "all" }) + .build(); +``` + +Anvia calls your store during streaming runs: + +```ts +interface AgentEventStore { + append(input: AgentEventAppendInput): Promise; + load(runId: string): Promise; + clear?(runId: string): Promise; +} + +type AgentEventStoreOptions = { + include?: "all" | "agent_tool_events"; +}; +``` + +`include: "all"` stores every parent stream event and every nested child-agent event. Choose this when you want a full run replay. + +`include: "agent_tool_events"` stores only nested child-agent events emitted by `asTool({ stream: true })`. Choose this when the parent stream is already easy to reconstruct from memory, but child-agent progress would otherwise be lost. + +Event storage is tied to streaming runs. A normal `.send()` call stays opaque and does not emit or persist runtime stream events. + +## Store Shape + +```ts +type AgentEventAppendInput = { + runId: string; + agentId: string; + agentName?: string; + turn?: number; + toolName?: string; + toolCallId?: string; + internalCallId?: string; + event: unknown; +}; +``` + +The `event` field is `unknown` because an event store is a durable log boundary. Store it as JSON or another format your application controls. Use `runId` to group one run, and use `toolName`, `internalCallId`, and `agentId` to group nested child-agent progress. + +The terminal `final` stream event also includes `runId`, so an application can load the saved runtime log after the live stream ends: + +```ts +let runId: string | undefined; + +for await (const event of agent.prompt(prompt).stream()) { + render(event); + + if (event.type === "final") { + runId = event.runId; + } +} + +const savedEvents = runId === undefined ? [] : await eventStore.load(runId); +``` + +## Streaming Agent Tools + +Event stores are especially useful with streaming multi-agent tools: + +```ts +const coordinator = new AgentBuilder("coordinator", model) + .tools([ + supportAgent.asTool({ name: "ask_support_agent", stream: true }), + engineeringAgent.asTool({ name: "ask_engineering_agent", stream: true }), + ]) + .eventStore(eventStore, { include: "all" }) + .build(); +``` + +During `.stream()`, callers receive live `agent_tool_event` values and the event store receives the same runtime history for replay. The parent agent and child agent models must both support streaming for nested progress to appear; otherwise the agent-tool still returns its final result normally. + +```ts +for await (const event of coordinator.prompt(prompt).stream()) { + if (event.type === "agent_tool_event") { + console.log(event.agentId, event.event.type); + } +} +``` + +The parent model still receives only the final child-agent output as the normal `tool_result`. Partial child deltas are for UI, debugging, replay, and inspection. + +## Production Notes + +`append(...)` runs in the streaming path before each event is yielded. Keep it fast: write to a local database, enqueue work, or batch outside the stream if your storage backend can add noticeable latency. + +Apply retention and redaction policies to event storage separately from memory. Event logs may contain partial deltas, tool arguments, intermediate tool results, and nested child-agent output that you might not want to keep as long as conversation memory. + +## Minimal In-Memory Store + +```ts +class InMemoryAgentEventStore implements AgentEventStore { + readonly records: AgentEventRecord[] = []; + + async append(input: AgentEventAppendInput): Promise { + this.records.push({ ...input, createdAt: new Date() }); + } + + async load(runId: string): Promise { + return this.records.filter((record) => record.runId === runId); + } + + async clear(runId: string): Promise { + const remaining = this.records.filter((record) => record.runId !== runId); + this.records.length = 0; + this.records.push(...remaining); + } +} +``` + +For a runnable example, see `examples/cookbook/07_multi_agent/04-agent-event-store.ts`. diff --git a/apps/docs/content/docs/guides/agents/meta.json b/apps/docs/content/docs/guides/agents/meta.json index e9006909..2ec9c9cd 100644 --- a/apps/docs/content/docs/guides/agents/meta.json +++ b/apps/docs/content/docs/guides/agents/meta.json @@ -10,6 +10,7 @@ "agent-tools", "agent-history", "multi-agent-workflows", + "event-store", "run-lifecycle", "runtime-hooks", "run-limits" diff --git a/apps/docs/content/docs/guides/agents/multi-agent-workflows.mdx b/apps/docs/content/docs/guides/agents/multi-agent-workflows.mdx index c0a9d999..b80e3e54 100644 --- a/apps/docs/content/docs/guides/agents/multi-agent-workflows.mdx +++ b/apps/docs/content/docs/guides/agents/multi-agent-workflows.mdx @@ -53,6 +53,27 @@ const coordinator = new AgentBuilder("coordinator", model) When the coordinator calls the tool, Anvia prompts the specialist agent and returns the specialist's final text as the tool result. +By default, a specialist exposed with `asTool(...)` is opaque while it runs. The parent stream shows the parent `tool_call` and final `tool_result`, but not the child agent's intermediate turns. + +Enable nested streaming when your UI should show specialist progress: + +```ts +const coordinator = new AgentBuilder("coordinator", model) + .tools([ + supportAgent.asTool({ name: "ask_support_agent", stream: true }), + engineeringAgent.asTool({ name: "ask_engineering_agent", stream: true }), + ]) + .build(); +``` + +When the parent runs with `.stream()`, child agent events arrive as `agent_tool_event` values. The parent model still receives only the final specialist output as the normal tool result. + +Nested progress is best-effort: the parent agent and child agent models must both support streaming. If nested streaming is unavailable, the agent-tool still behaves like a normal opaque `asTool(...)` call and returns the specialist's final output. + +Add an [event store](/docs/guides/agents/event-store) when you need to replay or inspect nested child-agent progress after the run. + +For memory boundaries in coordinator/specialist systems, see [Multi-Agent Memory](/docs/guides/memory/multi-agent). + ## Run With Tool Concurrency ```ts @@ -70,8 +91,9 @@ Use `.withToolConcurrency(...)` when independent specialist tools can run at the | Pattern | Use it when | | --- | --- | -| `agent.asTool(...)` | A coordinator should decide which specialists to call during a prompt run | +| `agent.asTool(...)` | A coordinator should decide which specialists to call during a prompt run and child progress can remain hidden | +| `agent.asTool({ stream: true })` | A coordinator should decide which specialists to call and the caller should see child progress during streaming | | Parallel pipelines | You already know every branch should run | | Studio multiple agents | You want several agents available in one local UI | -For runnable examples, see `examples/cookbook/07_multi_agent/01-agent-as-tool.ts` and `examples/cookbook/07_multi_agent/02-parallel-specialists.ts`. +For runnable examples, see `examples/cookbook/07_multi_agent/01-agent-as-tool.ts`, `examples/cookbook/07_multi_agent/03-streaming-agent-tools.ts`, and `examples/cookbook/07_multi_agent/04-agent-event-store.ts`. diff --git a/apps/docs/content/docs/guides/agents/run-lifecycle.mdx b/apps/docs/content/docs/guides/agents/run-lifecycle.mdx index f2c6d9e7..822803ff 100644 --- a/apps/docs/content/docs/guides/agents/run-lifecycle.mdx +++ b/apps/docs/content/docs/guides/agents/run-lifecycle.mdx @@ -15,9 +15,10 @@ A prompt run starts from `agent.prompt(...)` and ends with either a final assist 6. Repeat until the model returns final text or the turn limit is reached. ```ts +import { Message } from "@anvia/core"; + const response = await agent - .prompt("Where is order A-100?") - .withHistory(history) + .prompt([...history, Message.user("Where is order A-100?")]) .maxTurns(3) .send(); @@ -30,8 +31,7 @@ Use request-level methods for values that change per prompt. ```ts const response = await agent - .prompt(userInput) - .withHistory(history) + .prompt([...history, Message.user(userInput)]) .withToolConcurrency(2) .withTrace({ name: "support-message", diff --git a/apps/docs/content/docs/guides/agents/runtime-hooks.mdx b/apps/docs/content/docs/guides/agents/runtime-hooks.mdx index 1c23d7f5..b95f9eff 100644 --- a/apps/docs/content/docs/guides/agents/runtime-hooks.mdx +++ b/apps/docs/content/docs/guides/agents/runtime-hooks.mdx @@ -3,7 +3,7 @@ title: Runtime Hooks description: Observe and customize agent runtime behavior with hooks. --- -Hooks run inside a prompt request and can inspect or alter runtime behavior. Use observers for telemetry. Use hooks for guardrails, approval checks, skipped tools, and cancellation. +Hooks run inside a prompt request and can inspect or alter runtime behavior. Use observers for telemetry. Use hooks for guardrails, approval checks, skipped tools, and cancellation. Use [tool middleware](/docs/guides/tools/tool-middleware) when you need to transform tool result text before the model receives it. ## Create a Hook @@ -46,31 +46,30 @@ const hook = createHook({ }); ``` -`tool.run()` executes the tool. `tool.skip(...)` does not execute the tool; the message becomes the tool result sent back to the model. +`tool.run()` executes the tool. `tool.skip(...)` does not execute the tool; the message becomes the tool result sent back to the model. `tool.requestApproval(...)` asks an approval-capable runtime such as Studio to pause the tool call for a human decision. + +Tool result middleware runs after this decision produces a result string and before `onToolResult(...)` observes it. ## Await Human Approval -Approval is application code awaited inside the hook. +Approval can be requested from inside the hook. Studio handles this action automatically when the agent is run through Studio. ```ts const hook = createHook({ - async onToolCall({ toolName, args, tool }) { + onToolCall({ toolName, tool }) { if (toolName !== "refund_order") { return tool.run(); } - const approved = await approvals.waitForDecision({ - toolName, - args, + return tool.requestApproval({ reason: "Refunds require staff approval.", + rejectMessage: "Refund was not approved.", }); - - return approved ? tool.run() : tool.skip("Refund was not approved."); }, }); ``` -Anvia does not own the approval UI, database, queue, or notification system. Your hook can await any runtime your app provides. +Without Studio or another approval handler, `tool.requestApproval(...)` cancels clearly instead of running the tool. If your application owns a custom approval system, you can still await it in the hook and return `tool.run()` or `tool.skip(...)` yourself. Timeouts are optional. If the awaited approval never resolves, the agent run keeps waiting; add a timeout in your app code when the caller needs bounded latency. diff --git a/apps/docs/content/docs/guides/cookbook.mdx b/apps/docs/content/docs/guides/cookbook.mdx index 9bf9d045..490f3060 100644 --- a/apps/docs/content/docs/guides/cookbook.mdx +++ b/apps/docs/content/docs/guides/cookbook.mdx @@ -13,12 +13,12 @@ Each level introduces one layer at a time: | Basics | Text calls, chat history, static context, streaming, and `ReadableStream` output | | Tools | Tool calls, streamed tool events, hooks, concurrency, conditional tools, application state, guarded tools, and dynamic tool selection | | Structured output | Extraction, output schemas, context, retries, and extraction with history | -| Providers and multimodal | Provider adapters, model capabilities, reasoning streams, attachments, image generation, audio generation, and transcription | +| Providers and multimodal | Provider adapters, model capabilities, model listing, reasoning streams, attachments, image generation, audio generation, and transcription | | Pipelines | Step transforms, composition, named parallel branches, batching, agents, extraction, and richer workflows | | Retrieval | Embeddings, vector search, metadata filters, RAG context, document loaders, vector stores, and embedding provider variants | -| Multi-agent | Agents as tools and pipeline-backed parallel specialists | +| Multi-agent | Basic agent-tools, pipeline-backed parallel specialists, streaming agent-tools, and event stores | | Evals | Deterministic metrics, semantic similarity, custom metrics, agent eval targets, and LLM judge/score | -| Studio | Single-agent and multi-agent runners, tool approvals, questions, and Knowledge inspection | +| Studio | Single-agent, multi-agent, and subagent runners, tool approvals, questions, and Knowledge inspection | | Integrations | MCP tools, local skills, Langfuse tracing, and Langfuse eval reporting | ## 1. Install Dependencies @@ -32,8 +32,8 @@ pnpm install Create a local `.env` file for examples that call provider APIs: ```sh -OPENROUTER_API_KEY=... OPENAI_API_KEY=... +OPENAI_BASEURL=... ANTHROPIC_API_KEY=... GEMINI_API_KEY=... MISTRAL_API_KEY=... @@ -72,7 +72,7 @@ Numbered scripts are available when you want to step through a level in order: pnpm cookbook:tools:01 pnpm cookbook:pipelines:04 pnpm cookbook:retrieval:05 -pnpm cookbook:studio:05 +pnpm cookbook:studio:06 pnpm cookbook:integrations:04 ``` @@ -98,14 +98,16 @@ Use the in-memory and Transformers examples when you do not need a separate vect | --- | --- | --- | | Add a tool | `tools:01` | [Add Tools](/docs/guides/learning-paths/add-tools) | | Return structured data | `structured-output:01`, `structured-output:02` | [Structured Output](/docs/guides/structured-output/schemas) | -| Inspect model capabilities | `providers:03` | [Provider Clients and Models](/docs/guides/core-concepts/clients-and-models) | +| Inspect model capabilities | `providers:03` | [Provider Clients and Models](/docs/guides/sdk-fundamentals/clients-and-models) | +| List provider models | `providers:10` | [Model Listing](/docs/reference/core/model-listing) | | Stream agent events | `tools:02` | [Streaming Events](/docs/guides/streaming/streaming-events) | +| Stream over HTTP transports | `basics:07` | [Client Transports](/docs/guides/streaming/client-transports) | | Render reasoning summaries | `providers:04` | [Streaming Events](/docs/guides/streaming/streaming-events) | | Select dynamic tools | `tools:09` | [Tool Sets](/docs/guides/tools/tool-sets) | | Add approval behavior | `tools:08`, `studio:03` | [Human in the Loop](/docs/guides/human-in-the-loop) | | Add retrieval | `retrieval:01` through `retrieval:06` | [Add Retrieval](/docs/guides/learning-paths/add-retrieval) | | Run evals | `evals:01` through `evals:05`, `integrations:04` | [Evals](/docs/guides/testing/evals) | | Generate or transcribe media | `providers:07` through `providers:09` | [Image Generation](/docs/reference/core/image-generation) | -| Inspect locally in Studio | `studio:01`, `studio:05` | [Run Studio](/docs/studio/run-studio) | +| Inspect locally in Studio | `studio:01`, `studio:05`, `studio:06` | [Run Studio](/docs/studio/run-studio) | Before changing public APIs, add or update a cookbook example so behavior is easy to verify from the command line. diff --git a/apps/docs/content/docs/guides/design-philosophy.mdx b/apps/docs/content/docs/guides/design-philosophy.mdx index 564ad310..d732f537 100644 --- a/apps/docs/content/docs/guides/design-philosophy.mdx +++ b/apps/docs/content/docs/guides/design-philosophy.mdx @@ -136,7 +136,9 @@ Tools use Zod schemas. Extractors use schemas. Pipelines preserve TypeScript inp The goal is not to remove all runtime errors. The goal is to make boundaries visible, testable, and hard to misuse casually. ```ts -import { ExtractorBuilder, PipelineBuilder, createTool } from "@anvia/core"; +import { ExtractorBuilder } from "@anvia/core/extractor"; +import { PipelineBuilder } from "@anvia/core/pipeline"; +import { createTool } from "@anvia/core"; import { z } from "zod"; const ticketSchema = z.object({ diff --git a/apps/docs/content/docs/guides/getting-started.mdx b/apps/docs/content/docs/guides/getting-started.mdx index e9347e18..dc64545a 100644 --- a/apps/docs/content/docs/guides/getting-started.mdx +++ b/apps/docs/content/docs/guides/getting-started.mdx @@ -197,11 +197,12 @@ console.log(response.output); Anvia history is a plain `Message[]`. Store it wherever your application stores conversations. ```ts +import { Message } from "@anvia/core"; + const history = await conversations.loadMessages(conversationId); const response = await agent - .prompt(userInput) - .withHistory(history) + .prompt([...history, Message.user(userInput)]) .send(); await conversations.saveMessages(conversationId, [ diff --git a/apps/docs/content/docs/guides/human-in-the-loop/approval-handlers.mdx b/apps/docs/content/docs/guides/human-in-the-loop/approval-handlers.mdx index 4be853fb..6a1a522e 100644 --- a/apps/docs/content/docs/guides/human-in-the-loop/approval-handlers.mdx +++ b/apps/docs/content/docs/guides/human-in-the-loop/approval-handlers.mdx @@ -9,8 +9,10 @@ Studio gives you a local approval UI. Build your own runtime when approval needs ## Runtime Shape +`approvalRuntime` is a name for your own application object. Do not import it from Anvia; create it next to your database, queue, notification, or admin UI code. + ```ts -const approvals = { +const approvalRuntime = { async waitForDecision(request: { toolName: string; args: string; @@ -45,7 +47,7 @@ const approvalHook = createHook({ return tool.run(); } - const approved = await approvals.waitForDecision({ + const approved = await approvalRuntime.waitForDecision({ toolName, args, reason: "Refunds require staff approval.", @@ -86,18 +88,20 @@ Your runtime can notify one or more review surfaces. ```ts async function waitForDecision(request: ApprovalRequest): Promise { - const approval = await approvals.create(request); + const approval = await approvalStore.create(request); await Promise.all([ slack.sendApprovalMessage(approval), adminEvents.publish("approval.created", approval), ]); - const decision = await approvals.waitUntilResolved(approval.id); + const decision = await approvalStore.waitUntilResolved(approval.id); return decision.status === "approved"; } ``` +`approvalStore` is also your code. It can be a Prisma model wrapper, SQL repository, queue-backed service, or any persistence layer your application already uses. + The hook does not care whether the decision came from Studio, your app, Slack, email, or a queue worker. It only awaits a boolean or a richer decision object. ## Optional Timeouts @@ -106,7 +110,7 @@ Timeouts are not required by Anvia. If the approval promise never resolves, the ```ts const approved = await Promise.race([ - approvals.waitForDecision({ + approvalRuntime.waitForDecision({ toolName, args, reason: "Refunds require staff approval.", @@ -126,7 +130,7 @@ Use clear messages because the model sees them as the skipped tool result. Return more than a boolean when the final tool result should include reviewer context. ```ts -const decision = await approvals.waitForDecision({ +const decision = await approvalRuntime.waitForDecision({ toolName, args, reason: "Refunds require staff approval.", diff --git a/apps/docs/content/docs/guides/human-in-the-loop/approval-settings.mdx b/apps/docs/content/docs/guides/human-in-the-loop/approval-settings.mdx index 42157ffc..2a095169 100644 --- a/apps/docs/content/docs/guides/human-in-the-loop/approval-settings.mdx +++ b/apps/docs/content/docs/guides/human-in-the-loop/approval-settings.mdx @@ -94,6 +94,6 @@ approval: { ## When to Use Hooks Instead -Use [approval by hooks](/docs/guides/human-in-the-loop/tool-approval) when approval depends on request-local state, app permissions, external policy services, or a frontend/backend approval flow that is not tied to one tool definition. +Use [approval by hooks](/docs/guides/human-in-the-loop/tool-approval) when approval depends on request-local state, app permissions, external policy services, or policy that is not tied to one tool definition. -Agent hooks run before Studio approval metadata. If an agent hook returns `tool.skip(...)` or `tool.cancel(...)`, Studio does not ask for approval. +Agent hooks run before Studio approval metadata. If an agent hook returns `tool.skip(...)` or `tool.cancel(...)`, Studio does not ask for approval. If it returns `tool.requestApproval(...)`, Studio handles that approval request directly. diff --git a/apps/docs/content/docs/guides/human-in-the-loop/index.mdx b/apps/docs/content/docs/guides/human-in-the-loop/index.mdx index 461a6928..20ec3a42 100644 --- a/apps/docs/content/docs/guides/human-in-the-loop/index.mdx +++ b/apps/docs/content/docs/guides/human-in-the-loop/index.mdx @@ -5,14 +5,14 @@ description: Choose where human approval and feedback belong. Human-in-the-loop means the agent run waits for a person before continuing. Use it for approvals, operator feedback, missing context, escalation decisions, outbound messages, refunds, account changes, deletes, and other workflows where the model should not decide alone. -Anvia keeps the core execution model small: tools run, hooks can run/skip/cancel, and awaited promises pause the run. Studio adds a zero-config UI layer for common approval and question flows. +Anvia keeps the core execution model small: tools run, hooks can run/skip/cancel/request approval, and awaited promises pause the run. Studio adds a zero-config UI layer for common approval and question flows. ## Choose a Pattern | Pattern | Best for | Where it lives | | --- | --- | --- | | [Approval settings in tools](/docs/guides/human-in-the-loop/approval-settings) | Studio approval UI with no custom hook | Tool metadata | -| [Approval by hooks](/docs/guides/human-in-the-loop/tool-approval) | Custom policy, custom UI, or request-specific rules | `onToolCall(...)` | +| [Approval by hooks](/docs/guides/human-in-the-loop/tool-approval) | Dynamic policy or request-specific rules | `onToolCall(...)` | | [Ask question tools](/docs/guides/human-in-the-loop/ask-question) | Missing user input while a run is active | Normal tools | | [Approval runtimes](/docs/guides/human-in-the-loop/approval-handlers) | Databases, queues, notifications, audit logs, optional timeouts | Your app | @@ -45,22 +45,19 @@ Core stores this metadata but does not enforce it. Studio reads it and installs ## Hook Approval -Use a hook when approval is not a property of the tool itself, or when your app already owns the approval workflow. +Use a hook when approval is not a property of the tool itself. Studio handles `tool.requestApproval(...)` with the same approval UI and API used for tool metadata. ```ts const approvalHook = createHook({ - async onToolCall({ toolName, args, tool }) { + onToolCall({ toolName, tool }) { if (toolName !== "refund_order") { return tool.run(); } - const approved = await approvals.waitForDecision({ - toolName, - args, + return tool.requestApproval({ reason: "Refunds require staff approval.", + rejectMessage: "Refund was not approved.", }); - - return approved ? tool.run() : tool.skip("Refund was not approved."); }, }); diff --git a/apps/docs/content/docs/guides/human-in-the-loop/tool-approval.mdx b/apps/docs/content/docs/guides/human-in-the-loop/tool-approval.mdx index 95b10b03..006b080f 100644 --- a/apps/docs/content/docs/guides/human-in-the-loop/tool-approval.mdx +++ b/apps/docs/content/docs/guides/human-in-the-loop/tool-approval.mdx @@ -3,9 +3,9 @@ title: Approval by Hooks description: Await custom approval logic before selected tool calls execute. --- -Use `onToolCall(...)` when approval is runtime behavior rather than tool metadata. A hook can call your database, queue, websocket, permissions service, or internal admin UI before it returns `tool.run()`, `tool.skip(...)`, or `tool.cancel(...)`. +Use `onToolCall(...)` when approval is runtime behavior rather than tool metadata. A hook can request approval before it returns `tool.run()`, `tool.skip(...)`, or `tool.cancel(...)`. -This is the advanced escape hatch. For Studio-managed approval UI, start with [approval settings](/docs/guides/human-in-the-loop/approval-settings). +Studio handles `tool.requestApproval(...)` automatically. Without Studio or another approval handler, the run cancels clearly instead of executing the guarded tool. ## Require Approval for One Tool @@ -13,25 +13,20 @@ This is the advanced escape hatch. For Studio-managed approval UI, start with [a import { createHook } from "@anvia/core"; const approvalHook = createHook({ - async onToolCall({ toolName, args, tool }) { + onToolCall({ toolName, tool }) { if (toolName !== "refund_order") { return tool.run(); } - const approved = await approvals.waitForDecision({ - toolName, - args, - reason: `Review refund request: ${args}`, + return tool.requestApproval({ + reason: "Review this refund request.", + rejectMessage: "Refund was not approved.", }); - - return approved - ? tool.run() - : tool.skip("Refund was not approved."); }, }); ``` -The `args` value is the JSON string Anvia will send to the tool. Use it to show reviewers exactly what the model is trying to do. +Use the `args` value when the reviewer needs to see exactly what the model is trying to do. ## Attach the Hook @@ -62,20 +57,15 @@ Keep the approval rule explicit. const sensitiveTools = new Set(["refund_order", "cancel_subscription"]); const approvalHook = createHook({ - async onToolCall({ toolName, args, tool }) { + onToolCall({ toolName, tool }) { if (!sensitiveTools.has(toolName)) { return tool.run(); } - const approved = await approvals.waitForDecision({ - toolName, - args, + return tool.requestApproval({ reason: `${toolName} requires human review.`, + rejectMessage: `${toolName} was not approved.`, }); - - return approved - ? tool.run() - : tool.skip(`${toolName} was not approved.`); }, }); ``` @@ -87,10 +77,11 @@ Use normal tool execution for safe read-only tools such as lookup or search. Parse the pending tool arguments when only some calls need review. ```ts -import { createHook, parseToolArgs } from "@anvia/core"; +import { createHook } from "@anvia/core"; +import { parseToolArgs } from "@anvia/core/tool"; const approvalHook = createHook({ - async onToolCall({ toolName, args, tool }) { + onToolCall({ toolName, args, tool }) { if (toolName !== "issue_refund") { return tool.run(); } @@ -109,13 +100,10 @@ const approvalHook = createHook({ return tool.run(); } - const approved = await approvals.waitForDecision({ - toolName, - args, + return tool.requestApproval({ reason: `Refund amount is $${parsed.amount}.`, + rejectMessage: "Refund was not approved.", }); - - return approved ? tool.run() : tool.skip("Refund was not approved."); }, }); ``` @@ -144,21 +132,24 @@ const agent = new AgentBuilder("support", model) ## Rejected Approval -When approval is rejected, skip the tool with a clear message. +When approval is rejected, Studio sends the configured rejection message back to the model as the tool result. ```ts -return tool.skip("The reviewer rejected this action."); +return tool.requestApproval({ + reason: "Review this action.", + rejectMessage: "The reviewer rejected this action.", +}); ``` The model receives this text as the tool result, then it can produce a final answer. ## Timeout Policy -Anvia does not require a timeout. If the awaited approval promise never resolves, the run waits. Add a timeout in your app when the caller needs bounded latency. +Anvia does not require a timeout. Studio approval waits until a reviewer approves or rejects. If your application owns a custom approval system, add a timeout in that app code when the caller needs bounded latency. ```ts const approved = await Promise.race([ - approvals.waitForDecision({ toolName, args }), + approvalRuntime.waitForDecision({ toolName, args }), sleep(60_000).then(() => false), ]); diff --git a/apps/docs/content/docs/guides/index.mdx b/apps/docs/content/docs/guides/index.mdx index dbaf6679..c112159a 100644 --- a/apps/docs/content/docs/guides/index.mdx +++ b/apps/docs/content/docs/guides/index.mdx @@ -12,8 +12,8 @@ It is designed for teams that want more structure than raw model calls without g | Primitive | What it does | Start here | | --- | --- | --- | -| Client | Configures provider access, credentials, base URLs, and provider SDK wiring | [Provider Clients and Models](/docs/guides/core-concepts/clients-and-models) | -| Model | Provides a reusable completion or embedding capability | [Provider Clients and Models](/docs/guides/core-concepts/clients-and-models) | +| Client | Configures provider access, credentials, base URLs, and provider SDK wiring | [Provider Clients and Models](/docs/guides/sdk-fundamentals/clients-and-models) | +| Model | Provides a reusable completion or embedding capability | [Provider Clients and Models](/docs/guides/sdk-fundamentals/clients-and-models) | | Agent | Runs prompts with instructions, context, tools, hooks, turn limits, and output schemas | [Creating Agents](/docs/guides/agents/creating-agents) | | Tool | Exposes typed application-owned behavior to agents | [Creating Tools](/docs/guides/tools/creating-tools) | | Extractor | Converts unstructured text into schema-shaped data | [Extractors](/docs/guides/structured-output/extractors) | @@ -72,7 +72,7 @@ Read [Design Philosophy](/docs/guides/design-philosophy) to understand why Anvia | --- | --- | --- | | Agents | You need a promptable runtime with instructions, tools, context, and history | [Agents](/docs/guides/agents/creating-agents) | | Tools | The model needs to call application-owned behavior | [Tools](/docs/guides/tools/creating-tools) | -| History | You need multi-turn conversation state | [Messages and History](/docs/guides/core-concepts/messages-and-history) | +| History | You need multi-turn conversation state | [Messages and History](/docs/guides/sdk-fundamentals/messages-and-history) | | Structured Output | You need schema-shaped data instead of free-form text | [Structured Output](/docs/guides/structured-output/schemas) | | Pipelines | You need explicit workflow composition | [Pipelines](/docs/guides/pipelines/pipeline-builder) | | Retrieval | You need embeddings, vector search, or dynamic context | [Retrieval](/docs/guides/retrieval/embeddings) | @@ -88,15 +88,15 @@ Choose the path that matches what you are building: | --- | --- | --- | | Run your first agent | [Getting Started](/docs/guides/getting-started) | [Build an Agent](/docs/guides/learning-paths/build-an-agent) | | Run examples locally | [Cookbook](/docs/guides/cookbook) | [Testing](/docs/guides/testing) | -| Understand the SDK shape | [How Anvia Works](/docs/guides/core-concepts/runtime-boundaries) | [Provider Clients and Models](/docs/guides/core-concepts/clients-and-models) | +| Understand the SDK shape | [How Anvia Works](/docs/guides/sdk-fundamentals/runtime-boundaries) | [Provider Clients and Models](/docs/guides/sdk-fundamentals/clients-and-models) | | Persist conversations | [Persist Conversations](/docs/guides/learning-paths/persist-conversations) | [Agent History](/docs/guides/agents/agent-history) | -| Send images or documents | [Attachments](/docs/guides/core-concepts/attachments) | [Messages and History](/docs/guides/core-concepts/messages-and-history) | +| Send images or documents | [Attachments](/docs/guides/sdk-fundamentals/attachments) | [Messages and History](/docs/guides/sdk-fundamentals/messages-and-history) | | Add application actions | [Add Tools](/docs/guides/learning-paths/add-tools) | [Agent Tools](/docs/guides/agents/agent-tools) | | Return typed data | [Return Structured Output](/docs/guides/learning-paths/return-structured-output) | [Agent Output](/docs/guides/structured-output/agent-output) | | Compose multi-step workflows | [Build a Pipeline](/docs/guides/learning-paths/build-a-pipeline) | [Parallel Branches](/docs/guides/pipelines/parallel-branches) | | Add retrieval | [Add Retrieval](/docs/guides/learning-paths/add-retrieval) | [RAG Context](/docs/guides/retrieval/rag-context) | | Add traces | [Add Observability](/docs/guides/learning-paths/add-observability) | [Tracing](/docs/guides/observability/tracing) | | Inspect agents locally | [Studio](/docs/studio/overview) | [Run Studio](/docs/studio/run-studio) | -| Prepare to ship | [Prepare for Production](/docs/guides/learning-paths/prepare-for-production) | [Errors and Cancellation](/docs/guides/core-concepts/errors) | +| Prepare to ship | [Prepare for Production](/docs/guides/learning-paths/prepare-for-production) | [Errors and Cancellation](/docs/guides/sdk-fundamentals/errors) | Continue with [Getting Started](/docs/guides/getting-started) for a runnable first agent. diff --git a/apps/docs/content/docs/guides/learning-paths/add-observability.mdx b/apps/docs/content/docs/guides/learning-paths/add-observability.mdx index 40ea5b2a..673e8572 100644 --- a/apps/docs/content/docs/guides/learning-paths/add-observability.mdx +++ b/apps/docs/content/docs/guides/learning-paths/add-observability.mdx @@ -22,7 +22,7 @@ By the end, you should know how to observe: 3. Read [Tracing](/docs/guides/observability/tracing) for trace metadata and integrations. 4. Read [Langfuse](/docs/guides/observability/langfuse) to send Anvia traces to Langfuse. 5. Read [Streaming Events](/docs/guides/streaming/streaming-events) if your UI needs live events. -6. Read [Prompt Responses](/docs/guides/core-concepts/prompt-responses) to understand response usage and trace fields. +6. Read [Prompt Responses](/docs/guides/sdk-fundamentals/prompt-responses) to understand response usage and trace fields. ## What To Log First @@ -43,7 +43,8 @@ Do not log sensitive prompt, document, or tool data unless your product policy a Observers are plain TypeScript interfaces, so object literals work. For app code, prefer a class when the observer will keep state, share configuration, or be reused across agents. ```ts -import { AgentBuilder, type AgentObserver, type AgentRunObserver, type AgentRunStartArgs } from "@anvia/core"; +import { AgentBuilder } from "@anvia/core"; +import type { AgentObserver, AgentRunObserver, AgentRunStartArgs } from "@anvia/core/observability"; import { OpenAIClient } from "@anvia/openai"; class ConsoleObserver implements AgentObserver { diff --git a/apps/docs/content/docs/guides/learning-paths/add-retrieval.mdx b/apps/docs/content/docs/guides/learning-paths/add-retrieval.mdx index 5f19d398..0a2d0c72 100644 --- a/apps/docs/content/docs/guides/learning-paths/add-retrieval.mdx +++ b/apps/docs/content/docs/guides/learning-paths/add-retrieval.mdx @@ -46,7 +46,8 @@ Think about retrieval in two phases: Run preprocessing before the user prompt. In a real app, this usually belongs in a build step, startup task, admin action, or background ingestion job. ```ts -import { InMemoryVectorStore, embedDocuments } from "@anvia/core"; +import { InMemoryVectorStore } from "@anvia/core/vector-store"; +import { embedDocuments } from "@anvia/core/embeddings"; import { OpenAIClient } from "@anvia/openai"; const client = new OpenAIClient({ apiKey }); @@ -133,5 +134,5 @@ const agent = new AgentBuilder("support", model) | Need | Read | | --- | --- | | Local vector search | [LSH](/docs/guides/retrieval/lsh) | -| Provider embeddings | [Provider Clients and Models](/docs/guides/core-concepts/clients-and-models) | +| Provider embeddings | [Provider Clients and Models](/docs/guides/sdk-fundamentals/clients-and-models) | | Tracing retrieval workflows | [Add Observability](/docs/guides/learning-paths/add-observability) | diff --git a/apps/docs/content/docs/guides/learning-paths/build-a-pipeline.mdx b/apps/docs/content/docs/guides/learning-paths/build-a-pipeline.mdx index b3d4d63c..2a516a0d 100644 --- a/apps/docs/content/docs/guides/learning-paths/build-a-pipeline.mdx +++ b/apps/docs/content/docs/guides/learning-paths/build-a-pipeline.mdx @@ -40,7 +40,7 @@ Do not use a pipeline just to send one prompt. Start with an agent and add a pip ## Minimal Shape ```ts -import { PipelineBuilder } from "@anvia/core"; +import { PipelineBuilder } from "@anvia/core/pipeline"; type TicketInput = { customer: string; @@ -73,7 +73,9 @@ console.log(result.title); ## Agent and Extractor Shape ```ts -import { AgentBuilder, ExtractorBuilder, PipelineBuilder } from "@anvia/core"; +import { AgentBuilder } from "@anvia/core"; +import { ExtractorBuilder } from "@anvia/core/extractor"; +import { PipelineBuilder } from "@anvia/core/pipeline"; import { OpenAIClient } from "@anvia/openai"; import { z } from "zod"; diff --git a/apps/docs/content/docs/guides/learning-paths/build-an-agent.mdx b/apps/docs/content/docs/guides/learning-paths/build-an-agent.mdx index f2edd5d8..a12b567c 100644 --- a/apps/docs/content/docs/guides/learning-paths/build-an-agent.mdx +++ b/apps/docs/content/docs/guides/learning-paths/build-an-agent.mdx @@ -17,10 +17,10 @@ By the end, you should have: ## Path 1. Install Anvia and run the first agent in [Getting Started](/docs/guides/getting-started). -2. Read [How Anvia Works](/docs/guides/core-concepts/runtime-boundaries) to understand which object owns which responsibility. -3. Read [Provider Clients and Models](/docs/guides/core-concepts/clients-and-models) to choose the provider client and model. +2. Read [How Anvia Works](/docs/guides/sdk-fundamentals/runtime-boundaries) to understand which object owns which responsibility. +3. Read [Provider Clients and Models](/docs/guides/sdk-fundamentals/clients-and-models) to choose the provider client and model. 4. Read [Creating Agents](/docs/guides/agents/creating-agents) to configure the agent. -5. Read [Prompt Requests](/docs/guides/core-concepts/prompt-requests) to understand what happens when you call `agent.prompt(...).send()`. +5. Read [Prompt Requests](/docs/guides/sdk-fundamentals/prompt-requests) to understand what happens when you call `agent.prompt(...).send()`. ## Minimal Shape diff --git a/apps/docs/content/docs/guides/learning-paths/persist-conversations.mdx b/apps/docs/content/docs/guides/learning-paths/persist-conversations.mdx index c83186f7..04eef0bd 100644 --- a/apps/docs/content/docs/guides/learning-paths/persist-conversations.mdx +++ b/apps/docs/content/docs/guides/learning-paths/persist-conversations.mdx @@ -10,26 +10,29 @@ Use this path when an agent needs to remember previous turns. By the end, you should know: - the `Message[]` history shape -- how to pass history into a prompt +- how to pass an explicit transcript into a prompt +- how to use durable session memory - how to append `response.messages` - where tool calls and tool results appear in history ## Path -1. Read [Messages and History](/docs/guides/core-concepts/messages-and-history) to understand the raw `Message[]` shape. -2. Read [Prompt Responses](/docs/guides/core-concepts/prompt-responses) to understand `response.messages`. -3. Read [Agent History](/docs/guides/agents/agent-history) for agent-specific history examples. -4. Read [Messages and History](/docs/guides/core-concepts/messages-and-history) if your history includes attachments or rich content. +1. Read [Messages and History](/docs/guides/sdk-fundamentals/messages-and-history) to understand the raw `Message[]` shape. +2. Read [Prompt Responses](/docs/guides/sdk-fundamentals/prompt-responses) to understand `response.messages`. +3. Read [Memory and Sessions](/docs/guides/sdk-fundamentals/memory-and-sessions) for the core-managed durable conversation model. +4. Read [Memory](/docs/guides/memory) for raw SQL, Prisma, and Drizzle storage adapters. +5. Read [Agent History](/docs/guides/agents/agent-history) for agent-specific history examples. +6. Read [Messages and History](/docs/guides/sdk-fundamentals/messages-and-history) if your history includes attachments or rich content. ## Minimal Shape ```ts +import { Message } from "@anvia/core"; + const history = await conversations.loadMessages(conversationId); +const currentPrompt = Message.user(userInput); -const response = await agent - .prompt(userInput) - .withHistory(history) - .send(); +const response = await agent.prompt([...history, currentPrompt]).send(); await conversations.saveMessages(conversationId, [ ...history, @@ -41,10 +44,16 @@ await conversations.saveMessages(conversationId, [ `response.messages` is only the new part of the run. Append it to the history you loaded if you want a full transcript. +For core-managed durable conversations, configure memory and prompt through a session: + +```ts +const response = await agent.session(conversationId).prompt(userInput).send(); +``` + ## Add Next | Need | Read | | --- | --- | -| Tool-call history | [Messages and History](/docs/guides/core-concepts/messages-and-history) | +| Tool-call history | [Messages and History](/docs/guides/sdk-fundamentals/messages-and-history) | | Streaming conversation UI | [Readable Streams](/docs/guides/streaming/readable-streams) | | Long-term knowledge | [Add Retrieval](/docs/guides/learning-paths/add-retrieval) | diff --git a/apps/docs/content/docs/guides/learning-paths/prepare-for-production.mdx b/apps/docs/content/docs/guides/learning-paths/prepare-for-production.mdx index 57b9f15a..fe35e9b2 100644 --- a/apps/docs/content/docs/guides/learning-paths/prepare-for-production.mdx +++ b/apps/docs/content/docs/guides/learning-paths/prepare-for-production.mdx @@ -33,10 +33,10 @@ By the end, you should have reviewed: ## Path -1. Read [How Anvia Works](/docs/guides/core-concepts/runtime-boundaries) to confirm ownership is clear. -2. Read [Errors and Cancellation](/docs/guides/core-concepts/errors) to plan failure handling. +1. Read [How Anvia Works](/docs/guides/sdk-fundamentals/runtime-boundaries) to confirm ownership is clear. +2. Read [Errors and Cancellation](/docs/guides/sdk-fundamentals/errors) to plan failure handling. 3. Read [Human in the Loop](/docs/guides/human-in-the-loop) for guarded actions. -4. Read [Messages and History](/docs/guides/core-concepts/messages-and-history) for persistence shape. +4. Read [Messages and History](/docs/guides/sdk-fundamentals/messages-and-history) for persistence shape. 5. Read [Output Validation](/docs/guides/structured-output/output-validation) for typed workflows. 6. Read [Observers](/docs/guides/observability/observers) for runtime visibility. 7. Read [Testing](/docs/guides/testing) for verification boundaries. @@ -79,17 +79,20 @@ Put agent calls behind a small application-owned wrapper so logging, history, tr ```ts import { + type AgentBuilder, MaxTurnsError, + Message, PromptCancelledError, - type Agent, - type Message, + type Message as MessageType, } from "@anvia/core"; +type Agent = ReturnType; + type RunSupportAgentOptions = { userId: string; conversationId: string; input: string; - history: Message[]; + history: MessageType[]; }; export async function runSupportAgent( @@ -98,8 +101,7 @@ export async function runSupportAgent( ) { try { const response = await agent - .prompt(options.input) - .withHistory(options.history) + .prompt([...options.history, Message.user(options.input)]) .withTrace({ name: "support-agent", userId: options.userId, diff --git a/apps/docs/content/docs/guides/learning-paths/return-structured-output.mdx b/apps/docs/content/docs/guides/learning-paths/return-structured-output.mdx index 501cc2dc..b8465322 100644 --- a/apps/docs/content/docs/guides/learning-paths/return-structured-output.mdx +++ b/apps/docs/content/docs/guides/learning-paths/return-structured-output.mdx @@ -35,7 +35,7 @@ By the end, you should know when to use: Use an extractor when you have text and want validated data back. ```ts -import { ExtractorBuilder } from "@anvia/core"; +import { ExtractorBuilder } from "@anvia/core/extractor"; import { OpenAIClient } from "@anvia/openai"; import { z } from "zod"; @@ -87,4 +87,4 @@ const response = await agent.prompt("I cannot update my payment method.").send() | --- | --- | | Extraction inside workflows | [Extractor Steps](/docs/guides/pipelines/extractor-steps) | | Structured tool results | [Tool Results](/docs/guides/tools/tool-results) | -| Schema errors | [Errors and Cancellation](/docs/guides/core-concepts/errors) | +| Schema errors | [Errors and Cancellation](/docs/guides/sdk-fundamentals/errors) | diff --git a/apps/docs/content/docs/guides/mcp/connection-registry.mdx b/apps/docs/content/docs/guides/mcp/connection-registry.mdx index 36f867c5..0a79ad41 100644 --- a/apps/docs/content/docs/guides/mcp/connection-registry.mdx +++ b/apps/docs/content/docs/guides/mcp/connection-registry.mdx @@ -8,7 +8,7 @@ Anvia does not keep a global MCP registry. Your app should own connection storag ## App-Owned Registry ```ts -import type { McpServer } from "@anvia/core"; +import type { McpServer } from "@anvia/core/mcp"; class McpRegistry { private readonly servers = new Map(); diff --git a/apps/docs/content/docs/guides/mcp/connections.mdx b/apps/docs/content/docs/guides/mcp/connections.mdx index 4f28b79e..99c95df7 100644 --- a/apps/docs/content/docs/guides/mcp/connections.mdx +++ b/apps/docs/content/docs/guides/mcp/connections.mdx @@ -8,7 +8,7 @@ Anvia can connect to Model Context Protocol servers and expose their tools to ag ## Connect to a Stdio Server ```ts -import { connectMcp, mcp } from "@anvia/core"; +import { connectMcp, mcp } from "@anvia/core/mcp"; const filesystem = await connectMcp( mcp.stdio({ diff --git a/apps/docs/content/docs/guides/mcp/errors-and-reconnects.mdx b/apps/docs/content/docs/guides/mcp/errors-and-reconnects.mdx index 926eae67..7b3213c7 100644 --- a/apps/docs/content/docs/guides/mcp/errors-and-reconnects.mdx +++ b/apps/docs/content/docs/guides/mcp/errors-and-reconnects.mdx @@ -42,7 +42,7 @@ Keep turn limits low so a failing MCP tool cannot create an unbounded retry loop Anvia does not manage reconnects for you. Own that in your application registry. ```ts -import type { McpConnection, McpServer } from "@anvia/core"; +import type { McpConnection, McpServer } from "@anvia/core/mcp"; type McpRegistry = { get(name: string): McpServer | undefined; diff --git a/apps/docs/content/docs/guides/memory/drizzle.mdx b/apps/docs/content/docs/guides/memory/drizzle.mdx new file mode 100644 index 00000000..7f6842d4 --- /dev/null +++ b/apps/docs/content/docs/guides/memory/drizzle.mdx @@ -0,0 +1,137 @@ +--- +title: Drizzle +description: Implement MemoryStore with Drizzle ORM. +--- + +This example uses Drizzle with PostgreSQL. Store messages as `jsonb`, then load them by `sessionId` in insertion order. + +## Tables + +```ts +import { index, integer, jsonb, pgTable, text, timestamp, bigserial } from "drizzle-orm/pg-core"; +import type { Message } from "@anvia/core"; + +export const agentMemoryMessages = pgTable( + "agent_memory_messages", + { + id: bigserial("id", { mode: "number" }).primaryKey(), + sessionId: text("session_id").notNull(), + userId: text("user_id"), + runId: text("run_id").notNull(), + turn: integer("turn").notNull(), + message: jsonb("message").$type().notNull(), + metadata: jsonb("metadata"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + sessionOrderIdx: index("agent_memory_messages_session_id_id_idx").on( + table.sessionId, + table.id, + ), + }), +); + +export const agentMemoryErrors = pgTable( + "agent_memory_errors", + { + id: bigserial("id", { mode: "number" }).primaryKey(), + sessionId: text("session_id").notNull(), + userId: text("user_id"), + runId: text("run_id").notNull(), + error: jsonb("error").notNull(), + messages: jsonb("messages").$type().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + sessionCreatedIdx: index("agent_memory_errors_session_id_created_at_idx").on( + table.sessionId, + table.createdAt, + ), + }), +); +``` + +## Store + +```ts +import type { + MemoryAppendInput, + MemoryContext, + MemoryErrorInput, + MemoryStore, + Message, +} from "@anvia/core"; +import { asc, eq } from "drizzle-orm"; +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { agentMemoryErrors, agentMemoryMessages } from "./schema"; + +export class DrizzleMemoryStore implements MemoryStore { + constructor(private readonly db: NodePgDatabase) {} + + async load(context: MemoryContext): Promise { + const rows = await this.db + .select({ message: agentMemoryMessages.message }) + .from(agentMemoryMessages) + .where(eq(agentMemoryMessages.sessionId, context.sessionId)) + .orderBy(asc(agentMemoryMessages.id)); + + return rows.map((row) => row.message); + } + + async append(input: MemoryAppendInput): Promise { + if (input.messages.length === 0) { + return; + } + + await this.db.insert(agentMemoryMessages).values( + input.messages.map((message) => ({ + sessionId: input.context.sessionId, + userId: input.context.userId, + runId: input.runId, + turn: input.turn, + message, + metadata: input.context.metadata, + })), + ); + } + + async clear(context: MemoryContext): Promise { + await this.db + .delete(agentMemoryMessages) + .where(eq(agentMemoryMessages.sessionId, context.sessionId)); + } + + async recordError(input: MemoryErrorInput): Promise { + await this.db.insert(agentMemoryErrors).values({ + sessionId: input.context.sessionId, + userId: input.context.userId, + runId: input.runId, + error: serializeError(input.error), + messages: input.messages, + }); + } +} + +function serializeError(error: unknown): Record { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + return { message: String(error) }; +} +``` + +## Use It + +```ts +const memory = new DrizzleMemoryStore(db); + +const agent = new AgentBuilder("support", model) + .memory(memory) + .build(); + +await agent.session("thread_123", { userId: "user_456" }).prompt("Hello").send(); +``` diff --git a/apps/docs/content/docs/guides/memory/index.mdx b/apps/docs/content/docs/guides/memory/index.mdx new file mode 100644 index 00000000..a1dba60b --- /dev/null +++ b/apps/docs/content/docs/guides/memory/index.mdx @@ -0,0 +1,95 @@ +--- +title: Memory +description: Configure durable agent sessions with your own memory store. +--- + +Memory is Anvia's durable conversation API. Core owns when to load and append messages; your application owns where those messages are stored. + +```ts +const agent = new AgentBuilder("support", model) + .memory(memoryStore) + .build(); + +const response = await agent + .session("thread_123", { userId: "user_456" }) + .prompt("Continue from earlier.") + .send(); +``` + +## Mental Model + +Use `agent.prompt("...")` for stateless one-off requests. + +Use `agent.prompt([...messages])` when your application already owns an explicit transcript. The last message is the active prompt and earlier messages are temporary request history. + +Use `agent.session(id).prompt("...")` when Anvia should load and save durable conversation messages through the configured memory store. + +Memory stores model transcript messages for future context. It does not store runtime stream events such as text deltas, tool progress, or child-agent events from `asTool({ stream: true })`. Use [Event Store](/docs/guides/agents/event-store) when you need replay/debug history for runtime events. + +## Public API + +```ts +type MemorySavePolicy = "message" | "turn" | "run"; + +type MemoryContext = { + sessionId: string; + userId?: string; + metadata?: JsonObject; +}; + +interface MemoryStore { + load(context: MemoryContext): Promise; + + append(input: { + context: MemoryContext; + runId: string; + turn: number; + messages: Message[]; + }): Promise; + + clear(context: MemoryContext): Promise; + + recordError?(input: { + context: MemoryContext; + runId: string; + error: unknown; + messages: Message[]; + }): Promise; +} + +type MemoryOptions = { + savePolicy?: MemorySavePolicy; +}; +``` + +Configure the store and optional save policy on the agent: + +```ts +const agent = new AgentBuilder("support", model) + .memory(memoryStore, { savePolicy: "message" }) + .build(); +``` + +## Save Policy + +Memory defaults to `savePolicy: "message"`. + +| Policy | Behavior | +| --- | --- | +| `"message"` | Save the user prompt, completed assistant messages, and completed tool result messages immediately. | +| `"turn"` | Save completed messages after each model/tool turn. | +| `"run"` | Save only after a successful final response. | + +On failure, stores that implement `recordError(...)` receive the error and partial run messages. + +For delegation-specific behavior, read [Multi-Agent Memory](/docs/guides/memory/multi-agent). + +## Adapter Examples + +Choose the adapter style that matches your application: + +| Storage style | Guide | +| --- | --- | +| SQL client and hand-written queries | [Raw SQL](/docs/guides/memory/raw-sql) | +| Prisma ORM | [Prisma](/docs/guides/memory/prisma) | +| Drizzle ORM | [Drizzle](/docs/guides/memory/drizzle) | diff --git a/apps/docs/content/docs/guides/memory/meta.json b/apps/docs/content/docs/guides/memory/meta.json new file mode 100644 index 00000000..47247b86 --- /dev/null +++ b/apps/docs/content/docs/guides/memory/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Memory", + "defaultOpen": false, + "collapsible": true, + "pages": ["index", "multi-agent", "raw-sql", "prisma", "drizzle"] +} diff --git a/apps/docs/content/docs/guides/memory/multi-agent.mdx b/apps/docs/content/docs/guides/memory/multi-agent.mdx new file mode 100644 index 00000000..0b77d1d6 --- /dev/null +++ b/apps/docs/content/docs/guides/memory/multi-agent.mdx @@ -0,0 +1,61 @@ +--- +title: Multi-Agent Memory +description: Understand memory boundaries when agents delegate to other agents. +--- + +When an agent uses another agent through `asTool(...)`, memory still follows the active prompt request. + +```ts +const supportAgent = new AgentBuilder("support", model) + .instructions("Return support triage notes.") + .build(); + +const coordinator = new AgentBuilder("coordinator", model) + .memory(memoryStore) + .tool(supportAgent.asTool({ name: "ask_support_agent" })) + .build(); + +await coordinator.session("thread_123").prompt("Triage this incident.").send(); +``` + +In this setup, `memoryStore` saves the coordinator session transcript: + +| Message | Saved in coordinator memory | +| --- | --- | +| User prompt | Yes | +| Coordinator assistant tool call | Yes | +| Final `ask_support_agent` tool result | Yes | +| Coordinator final answer | Yes | +| Support agent internal text deltas or turns | No | + +The specialist result is saved as the parent tool result because that is the content the coordinator model receives on the next turn. The specialist's internal run is not appended to the coordinator session as separate user/assistant messages. + +## Streaming Agent Tools + +Streaming does not change memory behavior: + +```ts +const coordinator = new AgentBuilder("coordinator", model) + .memory(memoryStore) + .tool(supportAgent.asTool({ name: "ask_support_agent", stream: true })) + .build(); +``` + +With `stream: true`, the caller can see child-agent progress as `agent_tool_event` stream events, but memory still stores only transcript messages and final tool results. Use [Event Store](/docs/guides/agents/event-store) if you need to persist those nested runtime events. + +## Specialist Memory + +If a specialist needs its own durable history, configure memory on that specialist and run it through a session from application code: + +```ts +const supportAgent = new AgentBuilder("support", model) + .memory(memoryStore) + .build(); + +const response = await supportAgent + .session("support_thread_123") + .prompt("Continue support investigation.") + .send(); +``` + +`agent.asTool(...)` prompts the child agent directly, so it does not automatically create a child session. For manager/specialist workflows, keep the coordinator session as the durable user-facing conversation, and use explicit specialist sessions only when the specialist has its own long-lived thread. diff --git a/apps/docs/content/docs/guides/memory/prisma.mdx b/apps/docs/content/docs/guides/memory/prisma.mdx new file mode 100644 index 00000000..9bc7fa1f --- /dev/null +++ b/apps/docs/content/docs/guides/memory/prisma.mdx @@ -0,0 +1,124 @@ +--- +title: Prisma +description: Implement MemoryStore with Prisma ORM. +--- + +Use Prisma when your application already stores conversation data through a Prisma client. The key is to store each Anvia `Message` as JSON and load messages in insertion order. + +## Prisma Schema + +```prisma +model AgentMemoryMessage { + id BigInt @id @default(autoincrement()) + sessionId String + userId String? + runId String + turn Int + message Json + metadata Json? + createdAt DateTime @default(now()) + + @@index([sessionId, id]) +} + +model AgentMemoryError { + id BigInt @id @default(autoincrement()) + sessionId String + userId String? + runId String + error Json + messages Json + createdAt DateTime @default(now()) + + @@index([sessionId, createdAt]) +} +``` + +## Store + +```ts +import type { + MemoryAppendInput, + MemoryContext, + MemoryErrorInput, + MemoryStore, + Message, +} from "@anvia/core"; +import { Prisma, PrismaClient } from "@prisma/client"; + +export class PrismaMemoryStore implements MemoryStore { + constructor(private readonly prisma: PrismaClient) {} + + async load(context: MemoryContext): Promise { + const rows = await this.prisma.agentMemoryMessage.findMany({ + where: { sessionId: context.sessionId }, + orderBy: { id: "asc" }, + select: { message: true }, + }); + + return rows.map((row) => row.message as unknown as Message); + } + + async append(input: MemoryAppendInput): Promise { + await this.prisma.agentMemoryMessage.createMany({ + data: input.messages.map((message) => ({ + sessionId: input.context.sessionId, + userId: input.context.userId, + runId: input.runId, + turn: input.turn, + message: toPrismaJson(message), + metadata: + input.context.metadata === undefined + ? undefined + : toPrismaJson(input.context.metadata), + })), + }); + } + + async clear(context: MemoryContext): Promise { + await this.prisma.agentMemoryMessage.deleteMany({ + where: { sessionId: context.sessionId }, + }); + } + + async recordError(input: MemoryErrorInput): Promise { + await this.prisma.agentMemoryError.create({ + data: { + sessionId: input.context.sessionId, + userId: input.context.userId, + runId: input.runId, + error: toPrismaJson(serializeError(input.error)), + messages: toPrismaJson(input.messages), + }, + }); + } +} + +function toPrismaJson(value: unknown): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +} + +function serializeError(error: unknown): Record { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + return { message: String(error) }; +} +``` + +## Use It + +```ts +const prisma = new PrismaClient(); +const memory = new PrismaMemoryStore(prisma); + +const agent = new AgentBuilder("support", model) + .memory(memory, { savePolicy: "message" }) + .build(); + +await agent.session("thread_123", { userId: "user_456" }).prompt("Hello").send(); +``` diff --git a/apps/docs/content/docs/guides/memory/raw-sql.mdx b/apps/docs/content/docs/guides/memory/raw-sql.mdx new file mode 100644 index 00000000..53df18b2 --- /dev/null +++ b/apps/docs/content/docs/guides/memory/raw-sql.mdx @@ -0,0 +1,138 @@ +--- +title: Raw SQL +description: Implement MemoryStore with a SQL client and hand-written queries. +--- + +This example uses PostgreSQL and the `pg` client. Store Anvia messages as JSONB and order them by an auto-incrementing id. + +## Schema + +```sql +CREATE TABLE agent_memory_messages ( + id BIGSERIAL PRIMARY KEY, + session_id TEXT NOT NULL, + user_id TEXT, + run_id TEXT NOT NULL, + turn INTEGER NOT NULL, + message JSONB NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX agent_memory_messages_session_id_id_idx + ON agent_memory_messages (session_id, id); + +CREATE TABLE agent_memory_errors ( + id BIGSERIAL PRIMARY KEY, + session_id TEXT NOT NULL, + user_id TEXT, + run_id TEXT NOT NULL, + error JSONB NOT NULL, + messages JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +## Store + +```ts +import type { + MemoryAppendInput, + MemoryContext, + MemoryErrorInput, + MemoryStore, + Message, +} from "@anvia/core"; +import type { Pool } from "pg"; + +export class SqlMemoryStore implements MemoryStore { + constructor(private readonly pool: Pool) {} + + async load(context: MemoryContext): Promise { + const result = await this.pool.query<{ message: Message }>( + `SELECT message + FROM agent_memory_messages + WHERE session_id = $1 + ORDER BY id ASC`, + [context.sessionId], + ); + + return result.rows.map((row) => row.message); + } + + async append(input: MemoryAppendInput): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + + for (const message of input.messages) { + await client.query( + `INSERT INTO agent_memory_messages ( + session_id, user_id, run_id, turn, message, metadata + ) VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)`, + [ + input.context.sessionId, + input.context.userId ?? null, + input.runId, + input.turn, + JSON.stringify(message), + JSON.stringify(input.context.metadata ?? null), + ], + ); + } + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async clear(context: MemoryContext): Promise { + await this.pool.query( + "DELETE FROM agent_memory_messages WHERE session_id = $1", + [context.sessionId], + ); + } + + async recordError(input: MemoryErrorInput): Promise { + await this.pool.query( + `INSERT INTO agent_memory_errors ( + session_id, user_id, run_id, error, messages + ) VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)`, + [ + input.context.sessionId, + input.context.userId ?? null, + input.runId, + JSON.stringify(serializeError(input.error)), + JSON.stringify(input.messages), + ], + ); + } +} + +function serializeError(error: unknown): Record { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + return { message: String(error) }; +} +``` + +## Use It + +```ts +const memory = new SqlMemoryStore(pool); + +const agent = new AgentBuilder("support", model) + .memory(memory) + .build(); + +await agent.session("thread_123", { userId: "user_456" }).prompt("Hello").send(); +``` diff --git a/apps/docs/content/docs/guides/meta.json b/apps/docs/content/docs/guides/meta.json index 3931bc73..cd02bed0 100644 --- a/apps/docs/content/docs/guides/meta.json +++ b/apps/docs/content/docs/guides/meta.json @@ -12,8 +12,9 @@ "cookbook", "learning-paths", "---Guide---", - "core-concepts", + "sdk-fundamentals", "agents", + "memory", "tools", "human-in-the-loop", "mcp", diff --git a/apps/docs/content/docs/guides/observability/logging.mdx b/apps/docs/content/docs/guides/observability/logging.mdx new file mode 100644 index 00000000..d9a83f49 --- /dev/null +++ b/apps/docs/content/docs/guides/observability/logging.mdx @@ -0,0 +1,86 @@ +--- +title: Logging +description: Log Anvia agent lifecycle events with @anvia/logger. +--- + +`@anvia/logger` turns Anvia observer events into structured application logs. Use it when you want run, model generation, and tool lifecycle events in the same log stream as the rest of your app. + +## 1. Install the Package + +```sh +pnpm add @anvia/logger +``` + +## 2. Create a Logger + +Use Pino for production logging: + +```ts +import { createPinoLogger } from "@anvia/logger"; + +const logger = createPinoLogger({ + name: "support-app", + level: "info", +}); +``` + +Use the console logger for simple local output: + +```ts +import { createConsoleLogger } from "@anvia/logger"; + +const logger = createConsoleLogger({ + name: "support-app", + level: "debug", +}); +``` + +## 3. Attach the Logger Observer + +```ts +import { AgentBuilder } from "@anvia/core"; +import { createLoggerObserver } from "@anvia/logger"; + +const agent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly.") + .observe(createLoggerObserver(logger)) + .build(); +``` + +The logger observer writes events for: + +- agent run start, end, and error +- model generation start, end, and error +- tool start, stream event, end, and error + +## 4. Add Trace Metadata + +Trace metadata is included in run logs so application logs can line up with traces. + +```ts +const response = await agent + .prompt("Summarize ticket TICKET-1001.") + .withTrace({ + name: "support-ticket-summary", + userId: user.id, + sessionId: session.id, + metadata: { ticketId: "TICKET-1001" }, + tags: ["support"], + }) + .send(); +``` + +## 5. Control Verbose Payloads + +By default, the logger observer avoids logging final outputs, full model requests, responses, and tool results. Opt in when your data policy allows it: + +```ts +const observer = createLoggerObserver(logger, { + includeOutput: true, + includeRequest: true, + includeResponse: true, + includeToolResult: true, +}); +``` + +Keep prompts, responses, secrets, and customer data out of logs unless your application has an explicit policy for storing them. diff --git a/apps/docs/content/docs/guides/observability/meta.json b/apps/docs/content/docs/guides/observability/meta.json index 60f610a6..7efe7076 100644 --- a/apps/docs/content/docs/guides/observability/meta.json +++ b/apps/docs/content/docs/guides/observability/meta.json @@ -2,5 +2,5 @@ "title": "Observability", "defaultOpen": false, "collapsible": true, - "pages": ["observers", "trace-groups", "tracing", "langfuse", "otel"] + "pages": ["observers", "logging", "trace-groups", "tracing", "langfuse", "otel"] } diff --git a/apps/docs/content/docs/guides/observability/observers.mdx b/apps/docs/content/docs/guides/observability/observers.mdx index 43294e6f..e7add6b5 100644 --- a/apps/docs/content/docs/guides/observability/observers.mdx +++ b/apps/docs/content/docs/guides/observability/observers.mdx @@ -8,11 +8,7 @@ Observers are plain TypeScript objects or classes that receive runtime events fr ## 1. Create an Observer ```ts -import type { - AgentObserver, - AgentRunObserver, - AgentRunStartArgs, -} from "@anvia/core"; +import type { AgentObserver, AgentRunObserver, AgentRunStartArgs } from "@anvia/core/observability"; class ConsoleObserver implements AgentObserver { startRun(args: AgentRunStartArgs): AgentRunObserver { diff --git a/apps/docs/content/docs/guides/pipelines/extractor-steps.mdx b/apps/docs/content/docs/guides/pipelines/extractor-steps.mdx index 0e1f09bc..b49db583 100644 --- a/apps/docs/content/docs/guides/pipelines/extractor-steps.mdx +++ b/apps/docs/content/docs/guides/pipelines/extractor-steps.mdx @@ -8,7 +8,8 @@ Use `.extract(extractor)` when a pipeline stage should convert the current text ## 1. Build the Extractor ```ts -import { ExtractorBuilder, PipelineBuilder } from "@anvia/core"; +import { ExtractorBuilder } from "@anvia/core/extractor"; +import { PipelineBuilder } from "@anvia/core/pipeline"; import { z } from "zod"; const triageSchema = z.object({ diff --git a/apps/docs/content/docs/guides/pipelines/pipeline-builder.mdx b/apps/docs/content/docs/guides/pipelines/pipeline-builder.mdx index f8fcde92..753d5a1c 100644 --- a/apps/docs/content/docs/guides/pipelines/pipeline-builder.mdx +++ b/apps/docs/content/docs/guides/pipelines/pipeline-builder.mdx @@ -8,7 +8,7 @@ Use `PipelineBuilder` when a workflow should be explicit, testable, and made fro ## 1. Start With Input and Output Types ```ts -import { PipelineBuilder } from "@anvia/core"; +import { PipelineBuilder } from "@anvia/core/pipeline"; const pipeline = new PipelineBuilder(); ``` diff --git a/apps/docs/content/docs/guides/pipelines/research-example.mdx b/apps/docs/content/docs/guides/pipelines/research-example.mdx index afbf2da0..e33044e1 100644 --- a/apps/docs/content/docs/guides/pipelines/research-example.mdx +++ b/apps/docs/content/docs/guides/pipelines/research-example.mdx @@ -15,7 +15,9 @@ The internet access is ordinary application code. Anvia owns the workflow shape; ## 1. Define the Research Shape ```ts -import { AgentBuilder, ExtractorBuilder, PipelineBuilder } from "@anvia/core"; +import { AgentBuilder } from "@anvia/core"; +import { ExtractorBuilder } from "@anvia/core/extractor"; +import { PipelineBuilder } from "@anvia/core/pipeline"; import { OpenAIClient } from "@anvia/openai"; import { z } from "zod"; diff --git a/apps/docs/content/docs/guides/retrieval/embed-documents.mdx b/apps/docs/content/docs/guides/retrieval/embed-documents.mdx index 2b0bb101..875fadef 100644 --- a/apps/docs/content/docs/guides/retrieval/embed-documents.mdx +++ b/apps/docs/content/docs/guides/retrieval/embed-documents.mdx @@ -5,6 +5,8 @@ description: Prepare and embed text documents for retrieval. Use `embedDocuments(...)` during preprocessing. This step should usually run before user requests: in a build step, admin action, startup task, or background ingestion job. +If your source material starts as local files or PDFs, read [Loaders](/docs/guides/retrieval/loaders) first, then embed the loaded documents. + ## 1. Prepare Documents ```ts @@ -28,7 +30,7 @@ Normalize or chunk your source text before embedding it. ## 2. Load Local Files -Use `@anvia/core/loaders` when ingestion starts from local text files or PDFs. +Use `@anvia/core/loaders` when ingestion starts from local text files or PDFs. Loaders convert files, directories, globs, bytes, and PDFs into the `Document[]` shape that `embedDocuments(...)` expects. ```ts import { FileLoader, fileLoaderToDocuments } from "@anvia/core/loaders"; @@ -48,12 +50,12 @@ const pdfPages = await pdfPageLoaderToDocuments( ); ``` -Anvia loaders do ingestion only. For text chunking beyond PDF pages, preprocess text in application code before calling `embedDocuments(...)`. +Anvia loaders do ingestion only. For text chunking beyond PDF pages, preprocess text in application code before calling `embedDocuments(...)`. For the complete loader workflow, see [Loaders](/docs/guides/retrieval/loaders). ## 3. Embed With Selectors ```ts -import { embedDocuments } from "@anvia/core"; +import { embedDocuments } from "@anvia/core/embeddings"; const embedded = await embedDocuments(embeddings, documents, { id: (doc) => doc.id, diff --git a/apps/docs/content/docs/guides/retrieval/embeddings.mdx b/apps/docs/content/docs/guides/retrieval/embeddings.mdx index c9324a7f..555542f4 100644 --- a/apps/docs/content/docs/guides/retrieval/embeddings.mdx +++ b/apps/docs/content/docs/guides/retrieval/embeddings.mdx @@ -19,7 +19,7 @@ Use the same embedding model for indexing documents and searching with user quer ## 2. Embed One Text ```ts -import { embedText } from "@anvia/core"; +import { embedText } from "@anvia/core/embeddings"; const embedding = await embedText(embeddings, "Password reset links expire after 30 minutes."); @@ -29,7 +29,7 @@ console.log(embedding.vector.length); ## 3. Embed Many Texts ```ts -import { embedTexts } from "@anvia/core"; +import { embedTexts } from "@anvia/core/embeddings"; const results = await embedTexts(embeddings, [ "Password reset links expire after 30 minutes.", diff --git a/apps/docs/content/docs/guides/retrieval/loaders.mdx b/apps/docs/content/docs/guides/retrieval/loaders.mdx new file mode 100644 index 00000000..a9487c11 --- /dev/null +++ b/apps/docs/content/docs/guides/retrieval/loaders.mdx @@ -0,0 +1,109 @@ +--- +title: Loaders +description: Read local text files and PDFs before embedding documents. +--- + +Loaders are ingestion helpers for retrieval preprocessing. Use them when your source material starts as local files, directories, globs, bytes, or PDFs and you need to turn that material into Anvia `Document[]` values before calling `embedDocuments(...)`. + +Import loaders from `@anvia/core/loaders`, not from the root `@anvia/core` entry point. The loader subpath is separate because it depends on Node filesystem and PDF extraction packages. + +## 1. Load Text Files + +Use `FileLoader` for UTF-8 text files such as Markdown, plain text, exported docs, or generated knowledge files. + +```ts +import { FileLoader, fileLoaderToDocuments } from "@anvia/core/loaders"; + +const documents = await fileLoaderToDocuments( + FileLoader.withGlob("content/**/*.md").readWithPath().ignoreErrors(), +); +``` + +`readWithPath()` keeps the source path, and `fileLoaderToDocuments(...)` stores that path as the document id plus `source` metadata. + +## 2. Load a Directory + +```ts +const documents = await fileLoaderToDocuments( + FileLoader.withDir("content/articles").readWithPath().ignoreErrors(), +); +``` + +`withDir(...)` reads direct files only. Use `withGlob(...)` when you need recursive matching. + +## 3. Load Bytes + +```ts +const bytes = new TextEncoder().encode("Password reset links expire after 30 minutes."); + +const documents = await fileLoaderToDocuments( + FileLoader.fromBytes(bytes).readWithPath().ignoreErrors(), +); +``` + +Byte loaders are useful when files come from an upload, object store, or another runtime source instead of a local path. + +## 4. Load PDFs + +Use `PdfFileLoader` when source material is a PDF. + +```ts +import { PdfFileLoader, pdfLoaderToDocuments } from "@anvia/core/loaders"; + +const documents = await pdfLoaderToDocuments( + PdfFileLoader.withGlob("manuals/**/*.pdf").readWithPath().ignoreErrors(), +); +``` + +This creates one document per PDF with the extracted text and `mediaType: "application/pdf"` metadata. + +## 5. Split PDFs by Page + +```ts +import { PdfFileLoader, pdfPageLoaderToDocuments } from "@anvia/core/loaders"; + +const pages = await pdfPageLoaderToDocuments( + PdfFileLoader.withGlob("manuals/**/*.pdf").readWithPath().byPage().ignoreErrors(), +); +``` + +Use page splitting when a whole PDF is too broad for retrieval or when page-level source metadata matters. Page documents include `source`, `mediaType`, and `pageNumber` metadata. + +## 6. Handle Batch Errors + +Loader methods yield `LoaderResult` values by default so one unreadable file does not have to fail the whole batch. + +```ts +for await (const result of FileLoader.withGlob("content/**/*.md").readWithPath()) { + if (result.ok) { + console.log(result.value.path); + } else { + console.error(result.error); + } +} +``` + +Call `.ignoreErrors()` when your ingestion job should skip failed files and continue with successful records. + +## 7. Embed Loaded Documents + +After loading, pass the documents to `embedDocuments(...)`. + +```ts +import { embedDocuments } from "@anvia/core/embeddings"; + +const embedded = await embedDocuments(embeddings, documents, { + id: (document) => document.id, + content: (document) => document.text, + metadata: (document) => document.additionalProps, +}); +``` + +Loaders do ingestion only. For chunking beyond PDF pages, split text in application code before embedding or return multiple strings from the `content(...)` selector. + +## Related Reference + +| Topic | Reference | +| --- | --- | +| Loader API | [Loaders](/docs/reference/core/loaders) | +| Document embedding | [Embed Documents](/docs/guides/retrieval/embed-documents) | diff --git a/apps/docs/content/docs/guides/retrieval/meta.json b/apps/docs/content/docs/guides/retrieval/meta.json index e9205b04..bc4d68d5 100644 --- a/apps/docs/content/docs/guides/retrieval/meta.json +++ b/apps/docs/content/docs/guides/retrieval/meta.json @@ -4,6 +4,7 @@ "collapsible": true, "pages": [ "embeddings", + "loaders", "embed-documents", "vector-stores", "rag-context", diff --git a/apps/docs/content/docs/guides/retrieval/metadata-filters.mdx b/apps/docs/content/docs/guides/retrieval/metadata-filters.mdx index 4bf7da37..d64fa95c 100644 --- a/apps/docs/content/docs/guides/retrieval/metadata-filters.mdx +++ b/apps/docs/content/docs/guides/retrieval/metadata-filters.mdx @@ -24,7 +24,7 @@ Metadata values can be strings, numbers, booleans, or `null`. ## 2. Filter a Search ```ts -import { vectorFilter } from "@anvia/core"; +import { vectorFilter } from "@anvia/core/vector-store"; const results = await index.search({ query: "billing limits", diff --git a/apps/docs/content/docs/guides/retrieval/vector-stores.mdx b/apps/docs/content/docs/guides/retrieval/vector-stores.mdx index 8b21a6fe..c16c7e87 100644 --- a/apps/docs/content/docs/guides/retrieval/vector-stores.mdx +++ b/apps/docs/content/docs/guides/retrieval/vector-stores.mdx @@ -8,7 +8,7 @@ A vector store keeps embedded documents and exposes a searchable index. Anvia sh ## 1. Build a Store ```ts -import { InMemoryVectorStore } from "@anvia/core"; +import { InMemoryVectorStore } from "@anvia/core/vector-store"; const store = InMemoryVectorStore.fromDocuments(embedded); const index = store.index(embeddings); diff --git a/apps/docs/content/docs/guides/core-concepts/attachments.mdx b/apps/docs/content/docs/guides/sdk-fundamentals/attachments.mdx similarity index 97% rename from apps/docs/content/docs/guides/core-concepts/attachments.mdx rename to apps/docs/content/docs/guides/sdk-fundamentals/attachments.mdx index 8a11852a..3d8a6c57 100644 --- a/apps/docs/content/docs/guides/core-concepts/attachments.mdx +++ b/apps/docs/content/docs/guides/sdk-fundamentals/attachments.mdx @@ -63,10 +63,7 @@ Conversation history is still a plain `Message[]`: ```ts const history = await conversations.loadMessages(conversationId); -const response = await agent - .prompt(currentMessage) - .withHistory(history) - .send(); +const response = await agent.prompt([...history, currentMessage]).send(); await conversations.saveMessages(conversationId, [ ...history, diff --git a/apps/docs/content/docs/guides/core-concepts/clients-and-models.mdx b/apps/docs/content/docs/guides/sdk-fundamentals/clients-and-models.mdx similarity index 95% rename from apps/docs/content/docs/guides/core-concepts/clients-and-models.mdx rename to apps/docs/content/docs/guides/sdk-fundamentals/clients-and-models.mdx index 60031293..b4791693 100644 --- a/apps/docs/content/docs/guides/core-concepts/clients-and-models.mdx +++ b/apps/docs/content/docs/guides/sdk-fundamentals/clients-and-models.mdx @@ -135,4 +135,4 @@ Avoid creating a new provider client for every prompt unless your application sp ## Next -Read [Prompt Requests](/docs/guides/core-concepts/prompt-requests) to see how agents turn prompts, history, context, tools, and runtime options into normalized model requests. +Read [Prompt Requests](/docs/guides/sdk-fundamentals/prompt-requests) to see how agents turn prompts, history, context, tools, and runtime options into normalized model requests. diff --git a/apps/docs/content/docs/guides/core-concepts/errors.mdx b/apps/docs/content/docs/guides/sdk-fundamentals/errors.mdx similarity index 100% rename from apps/docs/content/docs/guides/core-concepts/errors.mdx rename to apps/docs/content/docs/guides/sdk-fundamentals/errors.mdx diff --git a/apps/docs/content/docs/guides/sdk-fundamentals/memory-and-sessions.mdx b/apps/docs/content/docs/guides/sdk-fundamentals/memory-and-sessions.mdx new file mode 100644 index 00000000..88d36a76 --- /dev/null +++ b/apps/docs/content/docs/guides/sdk-fundamentals/memory-and-sessions.mdx @@ -0,0 +1,89 @@ +--- +title: Memory and Sessions +description: Configure durable conversation memory and run session-backed prompts. +--- + +Memory is durable conversation state owned by an agent. Configure a memory store once, then choose a session id for each conversation. + +```ts +import { + AgentBuilder, + type MemoryAppendInput, + type MemoryContext, + type MemoryStore, + type Message, +} from "@anvia/core"; + +class AppMemoryStore implements MemoryStore { + private readonly sessions = new Map(); + + async load(context: MemoryContext): Promise { + return [...(this.sessions.get(context.sessionId) ?? [])]; + } + + async append(input: MemoryAppendInput): Promise { + const current = this.sessions.get(input.context.sessionId) ?? []; + this.sessions.set(input.context.sessionId, [...current, ...input.messages]); + } + + async clear(context: MemoryContext): Promise { + this.sessions.delete(context.sessionId); + } +} + +const memory = new AppMemoryStore(); + +const agent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly.") + .memory(memory) + .build(); + +await agent.session("thread_123", { userId: "user_456" }).prompt("Remember my plan.").send(); +await agent.session("thread_123", { userId: "user_456" }).prompt("What is my plan?").send(); +``` + +## Mental Model + +Use `agent.prompt("...")` for stateless one-off requests. + +Use `agent.prompt([...messages])` when you already have an explicit transcript. The last message is the active prompt and earlier messages are temporary request history. + +Use `agent.session(id).prompt("...")` when Anvia should load and save durable conversation messages through the configured memory store. + +## Save Policy + +Memory defaults to `savePolicy: "message"`. This saves the user prompt, completed assistant messages, and completed tool result messages as soon as they are ready. + +```ts +new AgentBuilder("support", model).memory(memory, { savePolicy: "turn" }); +``` + +Available policies are: + +| Policy | Behavior | +| --- | --- | +| `"message"` | Save each completed message immediately. Best recovery for long failed runs. | +| `"turn"` | Save completed messages after each model/tool turn. Fewer writes. | +| `"run"` | Save only after a successful final response. Simplest persistence behavior. | + +On failure, memory stores with `recordError(...)` receive the error and partial run messages. + +## Migration From Explicit History + +Old request history should become an explicit transcript: + +```ts +const history = await conversations.loadMessages(conversationId); + +const response = await agent + .prompt([...history, Message.user(userInput)]) + .send(); +``` + +Use sessions when you want core to own the load/save cycle: + +```ts +const response = await agent.session(conversationId).prompt(userInput).send(); +``` + +For storage adapter examples, see the dedicated [Memory](/docs/guides/memory) section. diff --git a/apps/docs/content/docs/guides/core-concepts/messages-and-history.mdx b/apps/docs/content/docs/guides/sdk-fundamentals/messages-and-history.mdx similarity index 90% rename from apps/docs/content/docs/guides/core-concepts/messages-and-history.mdx rename to apps/docs/content/docs/guides/sdk-fundamentals/messages-and-history.mdx index 124cc2b4..3cf43026 100644 --- a/apps/docs/content/docs/guides/core-concepts/messages-and-history.mdx +++ b/apps/docs/content/docs/guides/sdk-fundamentals/messages-and-history.mdx @@ -3,7 +3,7 @@ title: Messages and History description: Understand Anvia message objects and the history arrays built from them. --- -Anvia history is a plain `Message[]`. You choose where to store it, then pass it into each new prompt with `.withHistory(...)`. +Anvia history is a plain `Message[]`. For explicit stateless history, pass the whole transcript to `agent.prompt([...])`; the last message is the active prompt and earlier messages are history. ## Message Roles @@ -55,11 +55,9 @@ const history = [ ```ts const history = await conversations.loadMessages(conversationId); +const currentPrompt = Message.user(userInput); -const response = await agent - .prompt(userInput) - .withHistory(history) - .send(); +const response = await agent.prompt([...history, currentPrompt]).send(); await conversations.saveMessages(conversationId, [ ...history, @@ -150,4 +148,4 @@ Provider support for attachments varies. Check the provider and model before bui ## Next -Read [Prompt Responses](/docs/guides/core-concepts/prompt-responses) to see what an agent run returns. +Read [Prompt Responses](/docs/guides/sdk-fundamentals/prompt-responses) to see what an agent run returns. diff --git a/apps/docs/content/docs/guides/core-concepts/meta.json b/apps/docs/content/docs/guides/sdk-fundamentals/meta.json similarity index 91% rename from apps/docs/content/docs/guides/core-concepts/meta.json rename to apps/docs/content/docs/guides/sdk-fundamentals/meta.json index 44eb52fe..56d36693 100644 --- a/apps/docs/content/docs/guides/core-concepts/meta.json +++ b/apps/docs/content/docs/guides/sdk-fundamentals/meta.json @@ -6,6 +6,7 @@ "runtime-boundaries", "clients-and-models", "messages-and-history", + "memory-and-sessions", "attachments", "prompt-requests", "prompt-responses", diff --git a/apps/docs/content/docs/guides/core-concepts/package-exports.mdx b/apps/docs/content/docs/guides/sdk-fundamentals/package-exports.mdx similarity index 70% rename from apps/docs/content/docs/guides/core-concepts/package-exports.mdx rename to apps/docs/content/docs/guides/sdk-fundamentals/package-exports.mdx index ccb15f6e..82f17ba2 100644 --- a/apps/docs/content/docs/guides/core-concepts/package-exports.mdx +++ b/apps/docs/content/docs/guides/sdk-fundamentals/package-exports.mdx @@ -10,7 +10,7 @@ import { AgentBuilder, createTool } from "@anvia/core"; import { OpenAIClient } from "@anvia/openai"; ``` -The root export includes the core public SDK. Use it for most applications and examples. +The root export includes the common app-authoring SDK. Use it for most small applications and examples. Studio is a separate package: @@ -26,7 +26,7 @@ Anvia also exposes focused subpaths when you want narrower imports. | Export | Use for | | --- | --- | -| `@anvia/core/agent` | Agents, agent builders, prompt requests, hooks, and agent errors | +| `@anvia/core/agent` | Agent builders, stream event types, hooks, run controls, and agent errors | | `@anvia/core/completion` | Completion request types, messages, content helpers, and usage | | `@anvia/core/embeddings` | Embedding interfaces and shared embedding types | | `@anvia/core/evals` | Eval suites, built-in metrics, agent eval targets, and reporters | @@ -39,6 +39,14 @@ Anvia also exposes focused subpaths when you want narrower imports. | `@anvia/core/tool` | Tool creation, tool sets, tool registries, and tool errors | | `@anvia/core/vector-store` | Vector store interfaces, filters, and local indexes | +Internal entrypoints are intentionally separated from the public SDK: + +| Export | Use for | +| --- | --- | +| `@anvia/core/internal/agent` | Runtime agent internals used by Anvia integration packages | + +Internal entrypoints are unstable and should not be used by application code. + ## Root Import Example ```ts @@ -46,7 +54,7 @@ import { AgentBuilder, createTool } from "@anvia/core"; import { OpenAIClient } from "@anvia/openai"; ``` -Root imports are clear for small apps, examples, and docs. +Root imports are clear for small apps, examples, and docs when you only need common agent, tool, message, hook, skill, and error APIs. ## Subpath Import Example @@ -60,4 +68,4 @@ Subpath imports can make ownership clearer in larger codebases, especially when ## Recommendation -Start with root imports while learning. Move to subpath imports when a file has a narrow responsibility or when your project style prefers explicit module boundaries. +Start with root imports while learning. Move advanced APIs such as extractors, pipelines, MCP, evals, embeddings, vector stores, observability, and low-level tool sets to their focused subpaths. diff --git a/apps/docs/content/docs/guides/core-concepts/prompt-requests.mdx b/apps/docs/content/docs/guides/sdk-fundamentals/prompt-requests.mdx similarity index 87% rename from apps/docs/content/docs/guides/core-concepts/prompt-requests.mdx rename to apps/docs/content/docs/guides/sdk-fundamentals/prompt-requests.mdx index 78b3fa79..ba828871 100644 --- a/apps/docs/content/docs/guides/core-concepts/prompt-requests.mdx +++ b/apps/docs/content/docs/guides/sdk-fundamentals/prompt-requests.mdx @@ -46,12 +46,11 @@ Provider support for images and documents depends on the provider model you choo const history = await conversations.loadMessages(conversationId); const response = await agent - .prompt(userInput) - .withHistory(history) + .prompt([...history, Message.user(userInput)]) .send(); ``` -History is not global state inside Anvia. You load it from your application and pass it into the request. +History is not global state inside stateless prompts. You load it from your application and pass an explicit transcript into the request. ## 3. Override Runtime Options Per Prompt @@ -72,7 +71,7 @@ Use this when one request needs a tighter turn limit, different tool concurrency When you call `send()`, Anvia builds a normalized completion request in this order: 1. Start with the current prompt. -2. Add any history from `.withHistory(...)`. +2. Add any earlier messages from `prompt(Message[])`. 3. Add agent instructions. 4. Add static context from `.context(...)`. 5. Fetch dynamic context if retrieval is configured. @@ -102,4 +101,4 @@ Streaming emits normalized events for text deltas, reasoning deltas, tool calls, ## Next -Read [Messages and History](/docs/guides/core-concepts/messages-and-history) to understand the `Message[]` shape used by prompts, history, and responses. +Read [Messages and History](/docs/guides/sdk-fundamentals/messages-and-history) to understand the `Message[]` shape used by prompts, history, and responses. diff --git a/apps/docs/content/docs/guides/core-concepts/prompt-responses.mdx b/apps/docs/content/docs/guides/sdk-fundamentals/prompt-responses.mdx similarity index 90% rename from apps/docs/content/docs/guides/core-concepts/prompt-responses.mdx rename to apps/docs/content/docs/guides/sdk-fundamentals/prompt-responses.mdx index eeeebc7f..2cf41a75 100644 --- a/apps/docs/content/docs/guides/core-concepts/prompt-responses.mdx +++ b/apps/docs/content/docs/guides/sdk-fundamentals/prompt-responses.mdx @@ -51,11 +51,12 @@ Use usage data for logs, analytics, budgets, and rate-limit decisions. `messages` contains only the new messages created during this prompt run: ```ts +import { Message } from "@anvia/core"; + const history = await conversations.loadMessages(conversationId); const response = await agent - .prompt(userInput) - .withHistory(history) + .prompt([...history, Message.user(userInput)]) .send(); await conversations.saveMessages(conversationId, [ @@ -88,4 +89,4 @@ Use observers and tracing when you need to inspect runs, generations, tool calls ## Next -Read [Errors and Cancellation](/docs/guides/core-concepts/errors) to understand common failure modes and runtime limits. +Read [Errors and Cancellation](/docs/guides/sdk-fundamentals/errors) to understand common failure modes and runtime limits. diff --git a/apps/docs/content/docs/guides/core-concepts/runtime-boundaries.mdx b/apps/docs/content/docs/guides/sdk-fundamentals/runtime-boundaries.mdx similarity index 96% rename from apps/docs/content/docs/guides/core-concepts/runtime-boundaries.mdx rename to apps/docs/content/docs/guides/sdk-fundamentals/runtime-boundaries.mdx index 921dc77b..a19a8abd 100644 --- a/apps/docs/content/docs/guides/core-concepts/runtime-boundaries.mdx +++ b/apps/docs/content/docs/guides/sdk-fundamentals/runtime-boundaries.mdx @@ -114,4 +114,4 @@ If a decision affects product correctness, security, or data ownership, keep it ## Next -Read [Provider Clients and Models](/docs/guides/core-concepts/clients-and-models) to configure provider access and reusable model capabilities. +Read [Provider Clients and Models](/docs/guides/sdk-fundamentals/clients-and-models) to configure provider access and reusable model capabilities. diff --git a/apps/docs/content/docs/guides/skills/skill-tools.mdx b/apps/docs/content/docs/guides/skills/skill-tools.mdx index d6e8ca82..47df6bd8 100644 --- a/apps/docs/content/docs/guides/skills/skill-tools.mdx +++ b/apps/docs/content/docs/guides/skills/skill-tools.mdx @@ -30,7 +30,7 @@ This is the normal path. `.skills(skillSet)` adds both instructions and tools. Direct calls are useful in tests and admin workflows. ```ts -import { ToolSet } from "@anvia/core"; +import { ToolSet } from "@anvia/core/tool"; const toolSet = new ToolSet().addTools(skillSet.tools); diff --git a/apps/docs/content/docs/guides/streaming/client-transports.mdx b/apps/docs/content/docs/guides/streaming/client-transports.mdx new file mode 100644 index 00000000..ff075e6e --- /dev/null +++ b/apps/docs/content/docs/guides/streaming/client-transports.mdx @@ -0,0 +1,92 @@ +--- +title: Client Transports +description: Consume Anvia streams from React and custom clients. +--- + +Use `@anvia/react` when a browser or React UI needs to consume streaming agent events. + +The transport boundary is an async iterable: + +```ts +type EventTransport = { + send(request: TRequest, options?: TransportOptions): AsyncIterable; +}; +``` + +That lets UI state consume JSONL, SSE, WebSocket, local, or custom transports without changing the hook. + +## 1. Fetch Events Directly + +```ts +import { fetchEventStream } from "@anvia/react"; + +for await (const event of fetchEventStream("/api/chat", { + method: "POST", + body: JSON.stringify({ message: "Hello", stream: true }), + headers: { "content-type": "application/json" }, +})) { + if (event.type === "text_delta") { + renderDelta(event.delta); + } +} +``` + +`fetchEventStream(...)` returns `AsyncIterable` and supports `format: "jsonl"` or `format: "sse"`. If `format` is omitted, it infers SSE from `content-type: text/event-stream` and otherwise reads JSONL. + +## 2. Create a Fetch Transport + +```ts +import { createFetchTransport } from "@anvia/react"; + +const transport = createFetchTransport({ + endpoint: "/api/chat", + format: "jsonl", +}); + +for await (const event of transport.send({ message: "Hello", stream: true })) { + console.log(event); +} +``` + +The default transport uses `POST`, JSON request bodies, and JSONL response parsing. + +## 3. Use Chat State in React + +```tsx +import { useChat } from "@anvia/react"; + +export function Chat() { + const chat = useChat({ + endpoint: "/api/chat", + }); + + return ( +
{ + event.preventDefault(); + void chat.send(); + }} + > +
{chat.text}
+ chat.setInput(event.target.value)} /> + +
+ ); +} +``` + +Passing `endpoint` is shorthand for a default JSONL fetch transport. + +## 4. Pass a Custom Transport + +```ts +const chat = useChat({ + transport: { + async *send(request, options) { + yield* myWebSocketTransport(request, options); + }, + }, +}); +``` + +`useChat(...)` only consumes `AsyncIterable` events. It does not depend on `fetch`, JSONL, or SSE. diff --git a/apps/docs/content/docs/guides/streaming/meta.json b/apps/docs/content/docs/guides/streaming/meta.json index 75af8b57..9f67c9a8 100644 --- a/apps/docs/content/docs/guides/streaming/meta.json +++ b/apps/docs/content/docs/guides/streaming/meta.json @@ -2,5 +2,5 @@ "title": "Streaming", "defaultOpen": false, "collapsible": true, - "pages": ["streaming-events", "readable-streams", "stream-accumulation"] + "pages": ["streaming-events", "readable-streams", "client-transports", "stream-accumulation"] } diff --git a/apps/docs/content/docs/guides/streaming/readable-streams.mdx b/apps/docs/content/docs/guides/streaming/readable-streams.mdx index 4b6a220d..c1b72c32 100644 --- a/apps/docs/content/docs/guides/streaming/readable-streams.mdx +++ b/apps/docs/content/docs/guides/streaming/readable-streams.mdx @@ -1,51 +1,65 @@ --- title: Readable Streams -description: Expose streaming output through Web ReadableStream APIs. +description: Expose streaming output through HTTP stream responses. --- -Use `readableStream()` or `toReadableStream(...)` when an HTTP route should return newline-delimited JSON events. +Use `@anvia/server` when an HTTP route should return streaming agent events. The package can serialize any `AsyncIterable` as newline-delimited JSON or Server-Sent Events. ## 1. Stream an Agent Request ```ts -const stream = agent.prompt("Draft a reply.").readableStream(); +import { createEventStream } from "@anvia/server"; + +return createEventStream(agent.prompt("Draft a reply.").stream(), { + format: "jsonl", +}); ``` -`readableStream()` converts the agent stream into a `ReadableStream`. +`createEventStream(...)` returns a `Response` with streaming headers and an encoded body. -## 2. Return It From an HTTP Route +## 2. Choose JSONL or SSE ```ts -return new Response(agent.prompt("Draft a reply.").readableStream(), { - headers: { - "Content-Type": "application/x-ndjson", - }, +return createEventStream(agent.prompt("Draft a reply.").stream(), { + format: "sse", }); ``` -Each line is one JSON event. +JSONL is the default and is usually the best fit for chat APIs because it works naturally with `fetch`, `POST` bodies, auth headers, and cancellation. SSE is available when you need `text/event-stream` compatibility. + +JSONL writes one JSON event per line: ```jsonl {"type":"text_delta","turn":1,"delta":"Hello"} {"type":"final","output":"Hello","usage":{"totalTokens":12}} ``` -## 3. Convert Any Async Iterable +SSE writes JSON event payloads in `data:` fields: + +```txt +data: {"type":"text_delta","turn":1,"delta":"Hello"} + +``` + +## 3. Use Lower-Level Stream Helpers ```ts -import { toReadableStream } from "@anvia/core"; +import { createJsonlStream, createSseStream } from "@anvia/server"; -const stream = toReadableStream(agent.prompt("Draft a reply.").stream()); +const jsonl = createJsonlStream(agent.prompt("Draft a reply.").stream()); +const sse = createSseStream(agent.prompt("Draft a reply.").stream()); ``` -Use this helper when you already have an async iterable of events. +Use these helpers when your framework creates the `Response` object for you. ## 4. Handle Stream Errors -If iteration fails, Anvia emits one final JSON line with `type: "error"` and then closes the stream. +If iteration fails, `@anvia/server` emits one final event with `type: "error"` and then closes the stream. ```jsonl {"type":"error","error":{"name":"Error","message":"provider failed"}} ``` Clients should handle both `final` and `error` events. + +For React clients, see [Client Transports](/docs/guides/streaming/client-transports). diff --git a/apps/docs/content/docs/guides/streaming/streaming-events.mdx b/apps/docs/content/docs/guides/streaming/streaming-events.mdx index 158c8900..783a0f40 100644 --- a/apps/docs/content/docs/guides/streaming/streaming-events.mdx +++ b/apps/docs/content/docs/guides/streaming/streaming-events.mdx @@ -31,6 +31,9 @@ for await (const event of agent.prompt("Where is order A-100?").stream()) { case "tool_result": console.log("tool_result", event.toolName, event.result); break; + case "agent_tool_event": + console.log("child agent", event.agentId, event.event.type); + break; case "final": console.log("done", event.output); break; @@ -63,6 +66,24 @@ turn_end final ``` +Streaming agent-tools add nested child events between the parent `turn_end` and final parent `tool_result`: + +```txt +turn_start +tool_call +turn_end +agent_tool_event turn_start +agent_tool_event text_delta +agent_tool_event tool_call +agent_tool_event tool_result +agent_tool_event final +tool_result +turn_start +text_delta +turn_end +final +``` + ## 4. Event Types | Event | Meaning | @@ -72,6 +93,7 @@ final | `reasoning_delta` | Reasoning text or summary arrived from a provider that exposes it | | `tool_call` | The model requested a tool | | `tool_result` | Anvia ran a tool and produced a result | +| `agent_tool_event` | A child agent exposed through `asTool({ stream: true })` emitted a stream event | | `turn_end` | A model turn ended | | `final` | The agent run completed | | `error` | The stream failed | @@ -79,3 +101,7 @@ final The `final` event contains the same important data as `.send()`: `output`, `usage`, `messages`, and optional `trace`. `reasoning_delta` may include `contentType` and `signature` metadata. Render summaries or your own internal debug UI deliberately; encrypted and redacted blocks are opaque provider state for history continuity. + +`agent_tool_event` wraps the child event with `toolName`, `internalCallId`, optional provider `toolCallId`, and the child `agentId`/`agentName`. Use those fields to group nested progress in UIs. The parent model still receives only the final child output as the normal `tool_result`. + +The `final` event includes `runId`. Use it with [Event Store](/docs/guides/agents/event-store) when you need to load the persisted event log after the stream ends. diff --git a/apps/docs/content/docs/guides/structured-output/extractors.mdx b/apps/docs/content/docs/guides/structured-output/extractors.mdx index 1c35035b..5bbbd1cf 100644 --- a/apps/docs/content/docs/guides/structured-output/extractors.mdx +++ b/apps/docs/content/docs/guides/structured-output/extractors.mdx @@ -8,7 +8,7 @@ Extractors are the easiest path when you have text and want validated data back. ## 1. Create the Schema ```ts -import { ExtractorBuilder } from "@anvia/core"; +import { ExtractorBuilder } from "@anvia/core/extractor"; import { OpenAIClient } from "@anvia/openai"; import { z } from "zod"; diff --git a/apps/docs/content/docs/guides/testing/agents-and-retrieval.mdx b/apps/docs/content/docs/guides/testing/agents-and-retrieval.mdx index 73ee9d01..fbc22246 100644 --- a/apps/docs/content/docs/guides/testing/agents-and-retrieval.mdx +++ b/apps/docs/content/docs/guides/testing/agents-and-retrieval.mdx @@ -10,15 +10,18 @@ Agent tests should focus on the application boundary around the agent. Retrieval Put product policy around agent calls in a small wrapper. Then test that wrapper owns history, trace metadata, usage records, and error handling: ```ts +import { Message, type AgentBuilder, type Message as MessageType } from "@anvia/core"; + +type Agent = ReturnType; + export async function runSupportAgent( agent: Agent, conversationId: string, input: string, - history: Message[], + history: MessageType[], ) { const response = await agent - .prompt(input) - .withHistory(history) + .prompt([...history, Message.user(input)]) .withTrace({ name: "support-agent" }) .maxTurns(3) .send(); diff --git a/apps/docs/content/docs/guides/tools/meta.json b/apps/docs/content/docs/guides/tools/meta.json index 68e9ef06..03b04c8c 100644 --- a/apps/docs/content/docs/guides/tools/meta.json +++ b/apps/docs/content/docs/guides/tools/meta.json @@ -7,6 +7,7 @@ "tool-schemas", "tool-handlers", "tool-results", + "tool-middleware", "tool-sets", "think-tool", "tool-errors" diff --git a/apps/docs/content/docs/guides/tools/tool-errors.mdx b/apps/docs/content/docs/guides/tools/tool-errors.mdx index b5577bc5..7a81a151 100644 --- a/apps/docs/content/docs/guides/tools/tool-errors.mdx +++ b/apps/docs/content/docs/guides/tools/tool-errors.mdx @@ -18,7 +18,7 @@ Anvia distinguishes tool lookup, JSON parsing, and tool execution failures. When you call a `ToolSet` directly, catch tool errors at the call boundary. ```ts -import { ToolCallError, ToolJsonError, ToolNotFoundError } from "@anvia/core"; +import { ToolCallError, ToolJsonError, ToolNotFoundError } from "@anvia/core/tool"; try { const result = await toolSet.call("lookup_order", rawArgs); diff --git a/apps/docs/content/docs/guides/tools/tool-middleware.mdx b/apps/docs/content/docs/guides/tools/tool-middleware.mdx new file mode 100644 index 00000000..d07350da --- /dev/null +++ b/apps/docs/content/docs/guides/tools/tool-middleware.mdx @@ -0,0 +1,70 @@ +--- +title: Tool Middleware +description: Transform tool results before they are returned to the model. +--- + +Tool result middleware runs after a tool produces its serialized string result and before that result is sent back to the model. Use it for output gates, redaction, compression, or file references when a tool result is too large. + +## Create Middleware + +```ts +import { createToolMiddleware } from "@anvia/core/tool"; + +const outputGate = createToolMiddleware({ + async onResult({ toolName, result, internalCallId }) { + if (result.length <= 1_000) { + return undefined; + } + + const path = await files.write({ + name: `${toolName}-${internalCallId}.txt`, + content: result, + }); + + return JSON.stringify({ + type: "file_reference", + reason: "tool_output_too_large", + chars: result.length, + path, + }); + }, +}); +``` + +Return a string to replace the current tool result. Return `undefined` to keep the current result. + +## Register On An Agent + +```ts +const agent = new AgentBuilder("support", model) + .tools([lookupOrder, exportReport]) + .toolMiddleware(outputGate) + .build(); +``` + +Middleware applies to tool results from local tools, MCP tools, dynamic tools, vector search tools, and agents exposed with `agent.asTool(...)`. + +## Register For One Request + +```ts +const response = await agent + .prompt("Summarize the large report.") + .withToolMiddleware(outputGate) + .send(); +``` + +Use request middleware when one caller needs stricter output policy than the agent default. + +## Compose Middleware + +```ts +const agent = new AgentBuilder("support", model) + .toolMiddlewares([redactSecrets, outputGate]) + .build(); +``` + +Middleware runs in registration order. Agent middleware runs before request middleware. Each middleware receives the latest `result` and the unchanged `originalResult`. + +## Skill Tools + +Skill runtime tools are excluded from tool result middleware. Their behavior is owned by the skills runtime, including loading instructions, reading references, and running skill scripts. diff --git a/apps/docs/content/docs/guides/tools/tool-results.mdx b/apps/docs/content/docs/guides/tools/tool-results.mdx index 2c634a23..a5a492c6 100644 --- a/apps/docs/content/docs/guides/tools/tool-results.mdx +++ b/apps/docs/content/docs/guides/tools/tool-results.mdx @@ -5,6 +5,8 @@ description: Return tool output the model and application can consume. Tool results are serialized before they are sent back to the model. +Use [tool middleware](/docs/guides/tools/tool-middleware) when an agent should transform serialized tool results, such as replacing large outputs with file references. + ## String Results Strings are returned as-is. diff --git a/apps/docs/content/docs/guides/tools/tool-sets.mdx b/apps/docs/content/docs/guides/tools/tool-sets.mdx index 5233f6b5..74aec6a7 100644 --- a/apps/docs/content/docs/guides/tools/tool-sets.mdx +++ b/apps/docs/content/docs/guides/tools/tool-sets.mdx @@ -8,7 +8,7 @@ Use `ToolSet` when you want to group tools, inspect definitions, call tools dire ## Create a Tool Set ```ts -import { ToolSet } from "@anvia/core"; +import { ToolSet } from "@anvia/core/tool"; const supportTools = ToolSet.fromTools([ lookupOrder, diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 00c8194f..05aef9b5 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -1,4 +1,4 @@ { "title": "Anvia", - "pages": ["guides", "models", "studio", "reference"] + "pages": ["guides", "best-practices", "frameworks", "models", "studio", "reference", "changelog"] } diff --git a/apps/docs/content/docs/models/compatible-gateways/cloudflare-ai-gateway.mdx b/apps/docs/content/docs/models/compatible-gateways/cloudflare-ai-gateway.mdx new file mode 100644 index 00000000..8929ca1d --- /dev/null +++ b/apps/docs/content/docs/models/compatible-gateways/cloudflare-ai-gateway.mdx @@ -0,0 +1,51 @@ +--- +title: Cloudflare AI Gateway +description: Use Cloudflare AI Gateway's OpenAI-compatible endpoint with Anvia. +--- + +Cloudflare AI Gateway provides an OpenAI-compatible chat completions endpoint under `https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/compat`. + +## Create the Client + +```ts +import { AgentBuilder } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; + +const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; +const gatewayId = process.env.CLOUDFLARE_AI_GATEWAY_ID ?? "default"; + +const client = new OpenAIClient({ + baseUrl: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/compat`, + apiKey: process.env.CLOUDFLARE_AI_GATEWAY_API_KEY, +}); + +const model = client.completionModel("anthropic/claude-sonnet-4.5"); + +const agent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly.") + .build(); + +const response = await agent.prompt("Hello!").send(); + +console.log(response.output); +``` + +Use Cloudflare's gateway model format, usually `provider/model`. + +## Get the Model List + +If your Cloudflare AI Gateway configuration exposes the OpenAI-compatible models endpoint, `listModels()` reads it through the configured `baseUrl`. + +```ts +const models = await client.listModels(); + +console.table(models.data.map((model) => ({ id: model.id, owner: model.ownedBy }))); +``` + +## Notes + +- Cloudflare supports provider-native routes and an OpenAI-compatible unified route. This page uses the OpenAI-compatible route. +- Authentication depends on whether you use unified billing, stored keys, or request headers in Cloudflare. +- Model availability and feature support depend on the selected upstream provider. + +For current Cloudflare AI Gateway details, see the [getting started guide](https://developers.cloudflare.com/ai-gateway/get-started/) and [OpenAI-compatible endpoint documentation](https://developers.cloudflare.com/ai-gateway/chat-completion/). diff --git a/apps/docs/content/docs/models/compatible-gateways/helicone-ai-gateway.mdx b/apps/docs/content/docs/models/compatible-gateways/helicone-ai-gateway.mdx new file mode 100644 index 00000000..0f4ea27e --- /dev/null +++ b/apps/docs/content/docs/models/compatible-gateways/helicone-ai-gateway.mdx @@ -0,0 +1,48 @@ +--- +title: Helicone AI Gateway +description: Use Helicone AI Gateway's OpenAI-compatible API with Anvia. +--- + +Helicone AI Gateway exposes a unified OpenAI-compatible API at `https://ai-gateway.helicone.ai`. + +## Create the Client + +```ts +import { AgentBuilder } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; + +const client = new OpenAIClient({ + baseUrl: "https://ai-gateway.helicone.ai", + apiKey: process.env.HELICONE_API_KEY, +}); + +const model = client.completionModel("gpt-4o-mini"); + +const agent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly.") + .build(); + +const response = await agent.prompt("Hello!").send(); + +console.log(response.output); +``` + +Use the model ids and routing formats configured in Helicone. + +## Get the Model List + +If your Helicone AI Gateway route exposes a model registry through the OpenAI-compatible models endpoint, call `listModels()`. + +```ts +const models = await client.listModels(); + +console.table(models.data.map((model) => ({ id: model.id, owner: model.ownedBy }))); +``` + +## Notes + +- Helicone AI Gateway focuses on unified routing, fallbacks, and observability. Its gateway documentation currently describes the gateway as beta. +- Model ids, routing, and fallback behavior are controlled by Helicone and the upstream provider. +- Test specific model behavior before enabling tools, structured output, attachments, or multimodal features. + +For current Helicone details, see the [Helicone AI Gateway overview](https://docs.helicone.ai/gateway). diff --git a/apps/docs/content/docs/models/compatible-gateways/langdb-ai-gateway.mdx b/apps/docs/content/docs/models/compatible-gateways/langdb-ai-gateway.mdx new file mode 100644 index 00000000..c2334fa6 --- /dev/null +++ b/apps/docs/content/docs/models/compatible-gateways/langdb-ai-gateway.mdx @@ -0,0 +1,50 @@ +--- +title: LangDB AI Gateway +description: Use LangDB AI Gateway's OpenAI-compatible API with Anvia. +--- + +LangDB AI Gateway provides OpenAI-compatible APIs for routing to multiple LLM providers. The regional API base URL commonly includes your LangDB project id, such as `https://api.us-east-1.langdb.ai/{project_id}/v1`. + +## Create the Client + +```ts +import { AgentBuilder } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; + +const projectId = process.env.LANGDB_PROJECT_ID; + +const client = new OpenAIClient({ + baseUrl: `https://api.us-east-1.langdb.ai/${projectId}/v1`, + apiKey: process.env.LANGDB_API_KEY, +}); + +const model = client.completionModel("anthropic/claude-sonnet-4"); + +const agent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly.") + .build(); + +const response = await agent.prompt("Hello!").send(); + +console.log(response.output); +``` + +Use the model ids supported by your LangDB project and region. + +## Get the Model List + +If your LangDB project exposes model listing through its OpenAI-compatible API, `listModels()` calls the configured `/models` endpoint. + +```ts +const models = await client.listModels(); + +console.table(models.data.map((model) => ({ id: model.id, owner: model.ownedBy }))); +``` + +## Notes + +- LangDB can also require project metadata headers depending on the API path and account setup. Keep those values in configuration and pass them with `headers` if needed. +- Routing, tracing, guardrails, and model access are controlled by your LangDB project. +- Confirm the region-specific base URL before deploying. + +For current LangDB details, see the [LangDB API guide](https://docs.langdb.ai/getting-started/working-with-api) and [AI Gateway API reference](https://docs.langdb.ai/api-reference/ai-gateway-api). diff --git a/apps/docs/content/docs/models/compatible-gateways/litellm.mdx b/apps/docs/content/docs/models/compatible-gateways/litellm.mdx new file mode 100644 index 00000000..2c946c75 --- /dev/null +++ b/apps/docs/content/docs/models/compatible-gateways/litellm.mdx @@ -0,0 +1,48 @@ +--- +title: LiteLLM +description: Use a LiteLLM proxy as an OpenAI-compatible gateway with Anvia. +--- + +LiteLLM can run as a proxy server that exposes OpenAI-compatible endpoints for many upstream providers. The local proxy base URL is commonly `http://localhost:4000/v1`. + +## Create the Client + +```ts +import { AgentBuilder } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; + +const client = new OpenAIClient({ + baseUrl: process.env.LITELLM_BASE_URL ?? "http://localhost:4000/v1", + apiKey: process.env.LITELLM_API_KEY ?? "local", +}); + +const model = client.completionModel("gpt-5-mini"); + +const agent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly.") + .build(); + +const response = await agent.prompt("Hello!").send(); + +console.log(response.output); +``` + +The model id must match a model configured in your LiteLLM proxy. Depending on your proxy configuration, that might be a public provider model id or a local alias. + +## Get the Model List + +When the LiteLLM proxy exposes `/models`, `listModels()` reads the configured model list through the same base URL. + +```ts +const models = await client.listModels(); + +console.table(models.data.map((model) => ({ id: model.id, owner: model.ownedBy }))); +``` + +## Notes + +- Keep LiteLLM model aliases in your proxy configuration and pass those aliases to `completionModel(...)`. +- Advanced features such as tools, structured output, images, and reasoning metadata depend on the upstream provider and proxy configuration. +- If your proxy requires a master key or virtual key, pass it as `apiKey`. + +For current LiteLLM details, see the [LiteLLM documentation](https://docs.litellm.ai/). diff --git a/apps/docs/content/docs/models/compatible-gateways/meta.json b/apps/docs/content/docs/models/compatible-gateways/meta.json index 4f890937..cfa061c3 100644 --- a/apps/docs/content/docs/models/compatible-gateways/meta.json +++ b/apps/docs/content/docs/models/compatible-gateways/meta.json @@ -4,6 +4,12 @@ "collapsible": true, "pages": [ "openrouter", + "litellm", + "vercel-ai-gateway", + "cloudflare-ai-gateway", + "portkey", + "helicone-ai-gateway", + "langdb-ai-gateway", "minimax", "moonshot-ai", "novita-ai", diff --git a/apps/docs/content/docs/models/compatible-gateways/minimax.mdx b/apps/docs/content/docs/models/compatible-gateways/minimax.mdx index fefbb66a..561fd0dd 100644 --- a/apps/docs/content/docs/models/compatible-gateways/minimax.mdx +++ b/apps/docs/content/docs/models/compatible-gateways/minimax.mdx @@ -29,28 +29,12 @@ console.log(response.output); ## Get the Model List -MiniMax provides an OpenAI-compatible model list endpoint. +MiniMax provides an OpenAI-compatible model list endpoint. Because the client was created with `baseUrl`, `listModels()` calls MiniMax's `/models` endpoint. ```ts -const response = await fetch("https://api.minimax.io/v1/models", { - headers: { - Authorization: `Bearer ${process.env.MINIMAX_API_KEY}`, - }, -}); - -if (!response.ok) { - throw new Error(`MiniMax models request failed: ${response.status}`); -} - -const body = (await response.json()) as { - data: Array<{ - id: string; - object?: string; - owned_by?: string; - }>; -}; +const models = await client.listModels(); -console.table(body.data.map((model) => ({ id: model.id, owner: model.owned_by }))); +console.table(models.data.map((model) => ({ id: model.id, owner: model.ownedBy }))); ``` Use the `id` field directly with `completionModel(...)`. diff --git a/apps/docs/content/docs/models/compatible-gateways/moonshot-ai.mdx b/apps/docs/content/docs/models/compatible-gateways/moonshot-ai.mdx index cde8eb49..0134c4cd 100644 --- a/apps/docs/content/docs/models/compatible-gateways/moonshot-ai.mdx +++ b/apps/docs/content/docs/models/compatible-gateways/moonshot-ai.mdx @@ -29,28 +29,12 @@ console.log(response.output); ## Get the Model List -If your Moonshot account exposes the OpenAI-compatible models endpoint, you can read available model ids from `/models`. +If your Moonshot account exposes the OpenAI-compatible models endpoint, `listModels()` reads available model ids from `/models`. ```ts -const response = await fetch("https://api.moonshot.ai/v1/models", { - headers: { - Authorization: `Bearer ${process.env.MOONSHOT_API_KEY}`, - }, -}); - -if (!response.ok) { - throw new Error(`Moonshot models request failed: ${response.status}`); -} - -const body = (await response.json()) as { - data: Array<{ - id: string; - object?: string; - owned_by?: string; - }>; -}; +const models = await client.listModels(); -console.table(body.data.map((model) => ({ id: model.id, owner: model.owned_by }))); +console.table(models.data.map((model) => ({ id: model.id, owner: model.ownedBy }))); ``` ## Notes diff --git a/apps/docs/content/docs/models/compatible-gateways/novita-ai.mdx b/apps/docs/content/docs/models/compatible-gateways/novita-ai.mdx index 31b161ff..d8d392e6 100644 --- a/apps/docs/content/docs/models/compatible-gateways/novita-ai.mdx +++ b/apps/docs/content/docs/models/compatible-gateways/novita-ai.mdx @@ -29,28 +29,12 @@ console.log(response.output); ## Get the Model List -Novita AI documents model listing as part of its OpenAI-compatible LLM API. +Novita AI documents model listing as part of its OpenAI-compatible LLM API. Because the client was created with `baseUrl`, `listModels()` calls Novita AI's configured models endpoint. ```ts -const response = await fetch("https://api.novita.ai/openai/models", { - headers: { - Authorization: `Bearer ${process.env.NOVITA_API_KEY}`, - }, -}); - -if (!response.ok) { - throw new Error(`Novita AI models request failed: ${response.status}`); -} - -const body = (await response.json()) as { - data: Array<{ - id: string; - object?: string; - owned_by?: string; - }>; -}; +const models = await client.listModels(); -console.table(body.data.map((model) => ({ id: model.id, owner: model.owned_by }))); +console.table(models.data.map((model) => ({ id: model.id, owner: model.ownedBy }))); ``` ## Notes diff --git a/apps/docs/content/docs/models/compatible-gateways/nvidia-nim.mdx b/apps/docs/content/docs/models/compatible-gateways/nvidia-nim.mdx index 401bb121..a78a622a 100644 --- a/apps/docs/content/docs/models/compatible-gateways/nvidia-nim.mdx +++ b/apps/docs/content/docs/models/compatible-gateways/nvidia-nim.mdx @@ -31,30 +31,19 @@ For NVIDIA-hosted endpoints, use the base URL and credentials from your NVIDIA A ## Get the Model List -NIM exposes `GET /v1/models` for loaded and available inference models. +NIM exposes `GET /v1/models` for loaded and available inference models. Configure `OpenAIClient` with your NIM base URL, then call `listModels()`. ```ts const baseUrl = process.env.NVIDIA_NIM_BASE_URL ?? "http://localhost:8000/v1"; -const response = await fetch(`${baseUrl}/models`, { - headers: { - Authorization: `Bearer ${process.env.NVIDIA_NIM_API_KEY ?? "local"}`, - }, +const client = new OpenAIClient({ + baseUrl, + apiKey: process.env.NVIDIA_NIM_API_KEY ?? "local", }); -if (!response.ok) { - throw new Error(`NVIDIA NIM models request failed: ${response.status}`); -} - -const body = (await response.json()) as { - data: Array<{ - id: string; - object?: string; - owned_by?: string; - }>; -}; +const models = await client.listModels(); -console.table(body.data.map((model) => ({ id: model.id, owner: model.owned_by }))); +console.table(models.data.map((model) => ({ id: model.id, owner: model.ownedBy }))); ``` ## Notes diff --git a/apps/docs/content/docs/models/compatible-gateways/ollama-cloud.mdx b/apps/docs/content/docs/models/compatible-gateways/ollama-cloud.mdx index 56662959..fdad69f6 100644 --- a/apps/docs/content/docs/models/compatible-gateways/ollama-cloud.mdx +++ b/apps/docs/content/docs/models/compatible-gateways/ollama-cloud.mdx @@ -29,28 +29,12 @@ console.log(response.output); ## Get the Model List -If your Ollama Cloud account exposes the OpenAI-compatible models endpoint, use `/v1/models`. +If your Ollama Cloud account exposes the OpenAI-compatible models endpoint, use `listModels()`. ```ts -const response = await fetch("https://ollama.com/v1/models", { - headers: { - Authorization: `Bearer ${process.env.OLLAMA_API_KEY}`, - }, -}); - -if (!response.ok) { - throw new Error(`Ollama Cloud models request failed: ${response.status}`); -} - -const body = (await response.json()) as { - data: Array<{ - id: string; - object?: string; - owned_by?: string; - }>; -}; +const models = await client.listModels(); -console.table(body.data.map((model) => ({ id: model.id, owner: model.owned_by }))); +console.table(models.data.map((model) => ({ id: model.id, owner: model.ownedBy }))); ``` ## Notes diff --git a/apps/docs/content/docs/models/compatible-gateways/ollama.mdx b/apps/docs/content/docs/models/compatible-gateways/ollama.mdx index 505e89f9..c58556e2 100644 --- a/apps/docs/content/docs/models/compatible-gateways/ollama.mdx +++ b/apps/docs/content/docs/models/compatible-gateways/ollama.mdx @@ -31,28 +31,12 @@ Ollama does not require an API key for local use, but the OpenAI SDK expects one ## Get the Model List -Use the OpenAI-compatible models endpoint: +Use the OpenAI-compatible models endpoint through `listModels()`: ```ts -const response = await fetch("http://localhost:11434/v1/models", { - headers: { - Authorization: "Bearer ollama", - }, -}); - -if (!response.ok) { - throw new Error(`Ollama models request failed: ${response.status}`); -} - -const body = (await response.json()) as { - data: Array<{ - id: string; - object?: string; - owned_by?: string; - }>; -}; +const models = await client.listModels(); -console.table(body.data.map((model) => ({ id: model.id, owner: model.owned_by }))); +console.table(models.data.map((model) => ({ id: model.id, owner: model.ownedBy }))); ``` ## Notes diff --git a/apps/docs/content/docs/models/compatible-gateways/opencode.mdx b/apps/docs/content/docs/models/compatible-gateways/opencode.mdx index 9c2656f4..cde38546 100644 --- a/apps/docs/content/docs/models/compatible-gateways/opencode.mdx +++ b/apps/docs/content/docs/models/compatible-gateways/opencode.mdx @@ -40,30 +40,17 @@ const model = client.completionModel("opencode-zen"); ## Get the Model List -If your OpenCode endpoint exposes an OpenAI-compatible model list, query `/models` from the configured base URL. +If your OpenCode endpoint exposes an OpenAI-compatible model list, call `listModels()` on the client configured with that base URL. ```ts -const baseUrl = "https://opencode.ai/zen/go/v1"; - -const response = await fetch(`${baseUrl}/models`, { - headers: { - Authorization: `Bearer ${process.env.OPENCODE_API_KEY}`, - }, +const client = new OpenAIClient({ + baseUrl: "https://opencode.ai/zen/go/v1", + apiKey: process.env.OPENCODE_API_KEY, }); -if (!response.ok) { - throw new Error(`OpenCode models request failed: ${response.status}`); -} - -const body = (await response.json()) as { - data: Array<{ - id: string; - object?: string; - owned_by?: string; - }>; -}; +const models = await client.listModels(); -console.table(body.data.map((model) => ({ id: model.id, owner: model.owned_by }))); +console.table(models.data.map((model) => ({ id: model.id, owner: model.ownedBy }))); ``` ## Notes diff --git a/apps/docs/content/docs/models/compatible-gateways/openrouter.mdx b/apps/docs/content/docs/models/compatible-gateways/openrouter.mdx index 8b25c709..3d3b5981 100644 --- a/apps/docs/content/docs/models/compatible-gateways/openrouter.mdx +++ b/apps/docs/content/docs/models/compatible-gateways/openrouter.mdx @@ -31,42 +31,18 @@ console.log(response.output); ## Get the Model List -OpenRouter's models API returns the model ids and metadata you can use when choosing a model. +OpenRouter's models API returns the model ids and metadata you can use when choosing a model. Because the client was created with `baseUrl`, `listModels()` calls OpenRouter's `/models` endpoint. ```ts -const response = await fetch("https://openrouter.ai/api/v1/models", { - headers: { - Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, - }, -}); +const models = await client.listModels(); -if (!response.ok) { - throw new Error(`OpenRouter models request failed: ${response.status}`); -} - -const body = (await response.json()) as { - data: Array<{ - id: string; - name: string; - context_length?: number; - supported_parameters?: string[]; - architecture?: { - input_modalities?: string[]; - output_modalities?: string[]; - }; - }>; -}; - -const textModels = body.data - .filter((model) => model.architecture?.output_modalities?.includes("text") ?? true) - .map((model) => ({ +console.table( + models.data.map((model) => ({ id: model.id, name: model.name, - contextLength: model.context_length, - supportsTools: model.supported_parameters?.includes("tools") ?? false, - })); - -console.table(textModels.slice(0, 20)); + contextLength: model.contextLength, + })), +); ``` Use the `id` field directly: @@ -79,6 +55,8 @@ const model = client.completionModel("anthropic/claude-opus-4.6"); OpenRouter supports query parameters on the models endpoint. Use them when your UI or configuration screen only needs models with specific capabilities. +`listModels()` does not expose gateway-specific filters. Use `fetch` directly when you need OpenRouter query parameters. + ```ts const response = await fetch( "https://openrouter.ai/api/v1/models?supported_parameters=tools", diff --git a/apps/docs/content/docs/models/compatible-gateways/portkey.mdx b/apps/docs/content/docs/models/compatible-gateways/portkey.mdx new file mode 100644 index 00000000..b3022cd0 --- /dev/null +++ b/apps/docs/content/docs/models/compatible-gateways/portkey.mdx @@ -0,0 +1,52 @@ +--- +title: Portkey +description: Use Portkey's AI Gateway with Anvia through OpenAI-compatible endpoints. +--- + +Portkey exposes an OpenAI-compatible gateway at `https://api.portkey.ai/v1`. In Anvia, configure `OpenAIClient` with Portkey's base URL and pass Portkey headers through `headers`. + +## Create the Client + +```ts +import { AgentBuilder } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; + +const client = new OpenAIClient({ + baseUrl: "https://api.portkey.ai/v1", + apiKey: process.env.OPENAI_API_KEY, + headers: { + "x-portkey-api-key": process.env.PORTKEY_API_KEY ?? "", + "x-portkey-provider": "openai", + }, +}); + +const model = client.completionModel("gpt-5-mini"); + +const agent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly.") + .build(); + +const response = await agent.prompt("Hello!").send(); + +console.log(response.output); +``` + +For saved Portkey providers, use the provider slug header, such as `x-portkey-provider: @openai-prod`, instead of passing a raw upstream provider key. + +## Get the Model List + +If your Portkey provider or config exposes model listing for the selected route, `listModels()` calls Portkey's configured `/models` endpoint. + +```ts +const models = await client.listModels(); + +console.table(models.data.map((model) => ({ id: model.id, owner: model.ownedBy }))); +``` + +## Notes + +- Portkey supports provider headers, virtual providers, configs, retries, caching, and observability. Keep those choices in your application configuration. +- The `apiKey` value is forwarded as the OpenAI SDK authorization header. For provider slugs stored in Portkey, use the provider slug headers documented by Portkey. +- Model capabilities still depend on the upstream provider and Portkey route. + +For current Portkey details, see the [Portkey AI Gateway guide](https://portkey.ai/docs/guides/getting-started/getting-started-with-ai-gateway) and [headers reference](https://portkey.ai/docs/api-reference/inference-api/headers). diff --git a/apps/docs/content/docs/models/compatible-gateways/vercel-ai-gateway.mdx b/apps/docs/content/docs/models/compatible-gateways/vercel-ai-gateway.mdx new file mode 100644 index 00000000..ed01d297 --- /dev/null +++ b/apps/docs/content/docs/models/compatible-gateways/vercel-ai-gateway.mdx @@ -0,0 +1,48 @@ +--- +title: Vercel AI Gateway +description: Use Vercel AI Gateway's OpenAI-compatible API with Anvia. +--- + +Vercel AI Gateway exposes an OpenAI-compatible API at `https://ai-gateway.vercel.sh/v1`. + +## Create the Client + +```ts +import { AgentBuilder } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; + +const client = new OpenAIClient({ + baseUrl: "https://ai-gateway.vercel.sh/v1", + apiKey: process.env.AI_GATEWAY_API_KEY, +}); + +const model = client.completionModel("anthropic/claude-sonnet-4.6"); + +const agent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly.") + .build(); + +const response = await agent.prompt("Hello!").send(); + +console.log(response.output); +``` + +Use Vercel AI Gateway model ids directly, usually in `provider/model` form. + +## Get the Model List + +Vercel AI Gateway supports `GET /models` on its OpenAI-compatible API. + +```ts +const models = await client.listModels(); + +console.table(models.data.map((model) => ({ id: model.id, owner: model.ownedBy }))); +``` + +## Notes + +- Vercel AI Gateway can route across providers and models behind one API key. +- Provider options, fallbacks, attachments, and model-specific behavior still depend on the selected gateway model. +- If you use Vercel OIDC instead of an API key, pass the token through your application configuration as `apiKey`. + +For current Vercel AI Gateway details, see the [OpenAI-compatible API documentation](https://vercel.com/docs/ai-gateway/openai-compat). diff --git a/apps/docs/content/docs/models/index.mdx b/apps/docs/content/docs/models/index.mdx index 5feb19db..396815c6 100644 --- a/apps/docs/content/docs/models/index.mdx +++ b/apps/docs/content/docs/models/index.mdx @@ -25,6 +25,7 @@ const agent = new AgentBuilder("support", model) | --- | --- | | Completion | Agents, extractors, prompt steps, streaming, and tool calls | | Embeddings | Retrieval, document search, semantic routing, and vector stores | +| Model listing | Discovering provider-returned model ids and metadata | | Compatible gateways | OpenAI-compatible gateways, hosted model APIs, and local model backends | | Compatible providers | OpenAI-compatible APIs through `OpenAIClient({ baseUrl, apiKey })` | | Vertex AI | Gemini through `GeminiClient({ vertexai: true, project, location })` | @@ -50,11 +51,29 @@ const client = new OpenAIClient({ Create clients and models once per application runtime when practical. Agents, extractors, pipelines, and retrieval code can reuse the model instances. +## Model Listing + +Use `listModels()` when you need provider-returned model ids or metadata for configuration screens, diagnostics, or model selection. + +```ts +const models = await client.listModels(); +``` + +Model listing fetches live provider data. Anvia does not cache results or add hidden metadata. Beta or private model ids can still be passed directly to `completionModel(...)`, but they only appear in `listModels()` when the provider returns them. + +See [Model Listing](/docs/models/model-listing) for the normalized response shape and compatible-gateway behavior. + ## Compatible Gateways | Gateway | Page | | --- | --- | | OpenRouter | [OpenRouter](/docs/models/compatible-gateways/openrouter) | +| LiteLLM | [LiteLLM](/docs/models/compatible-gateways/litellm) | +| Vercel AI Gateway | [Vercel AI Gateway](/docs/models/compatible-gateways/vercel-ai-gateway) | +| Cloudflare AI Gateway | [Cloudflare AI Gateway](/docs/models/compatible-gateways/cloudflare-ai-gateway) | +| Portkey | [Portkey](/docs/models/compatible-gateways/portkey) | +| Helicone AI Gateway | [Helicone AI Gateway](/docs/models/compatible-gateways/helicone-ai-gateway) | +| LangDB AI Gateway | [LangDB AI Gateway](/docs/models/compatible-gateways/langdb-ai-gateway) | | MiniMax | [MiniMax](/docs/models/compatible-gateways/minimax) | | Moonshot AI | [Moonshot AI](/docs/models/compatible-gateways/moonshot-ai) | | Novita AI | [Novita AI](/docs/models/compatible-gateways/novita-ai) | diff --git a/apps/docs/content/docs/models/meta.json b/apps/docs/content/docs/models/meta.json index 8a960eff..4b5a1429 100644 --- a/apps/docs/content/docs/models/meta.json +++ b/apps/docs/content/docs/models/meta.json @@ -3,5 +3,5 @@ "description": "Providers", "icon": "Bot", "root": true, - "pages": ["index", "embeddings", "providers", "compatible-gateways"] + "pages": ["index", "embeddings", "model-listing", "providers", "compatible-gateways"] } diff --git a/apps/docs/content/docs/models/model-listing.mdx b/apps/docs/content/docs/models/model-listing.mdx new file mode 100644 index 00000000..8c9ce007 --- /dev/null +++ b/apps/docs/content/docs/models/model-listing.mdx @@ -0,0 +1,148 @@ +--- +title: Model Listing +description: List available provider models through Anvia clients. +--- + +Provider clients can list models through the normalized `listModels()` method. + +```ts +import { OpenAIClient } from "@anvia/openai"; + +const client = new OpenAIClient({ apiKey }); +const models = await client.listModels(); + +for (const model of models.data) { + console.log(model.id, model.contextLength); +} +``` + +`listModels()` returns a `ModelList` from `@anvia/core/model-listing`. + +```ts +type ListedModel = { + id: string; + name?: string; + description?: string; + type?: string; + createdAt?: number; + ownedBy?: string; + contextLength?: number; +}; + +type ModelList = { + data: ListedModel[]; +}; +``` + +Only `id` is guaranteed. Providers and compatible gateways expose different metadata, so unknown fields remain omitted. + +## Supported Clients + +```ts +await new OpenAIClient({ apiKey }).listModels(); +await new AnthropicClient({ apiKey }).listModels(); +await new GeminiClient({ apiKey }).listModels(); +await new MistralClient({ apiKey }).listModels(); +``` + +Each client fetches live provider data. Anvia does not add hidden model metadata, cache the result, or include beta/private model ids that the provider does not return. + +## Compatible Gateways + +OpenAI-compatible gateways use the same `OpenAIClient` path. + +```ts +const client = new OpenAIClient({ + baseUrl: "https://openrouter.ai/api/v1", + apiKey: process.env.OPENROUTER_API_KEY, +}); + +const models = await client.listModels(); +``` + +The request goes to the configured gateway models endpoint, usually `GET {baseUrl}/models`. If the gateway returns sparse OpenAI-shaped data, Anvia normalizes fields such as `id`, `createdAt`, and `ownedBy`. If the gateway returns richer fields such as `name` or `context_length`, Anvia includes those as `name` and `contextLength`. + +If the gateway does not expose a compatible model-list endpoint, `listModels()` rejects with `ModelListingError`. + +## Manual Model Lists + +You do not need `listModels()` to use a model. If your app already knows the allowed model ids, define a manual `ModelList` and pass those ids to the provider client. + +```ts +import type { ModelList } from "@anvia/core/model-listing"; +import { OpenAIClient } from "@anvia/openai"; + +const models: ModelList = { + data: [ + { + id: "openai/gpt-5-mini", + name: "GPT-5 Mini", + ownedBy: "openai", + contextLength: 400_000, + }, + { + id: "anthropic/claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + ownedBy: "anthropic", + contextLength: 200_000, + }, + ], +}; + +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_COMPATIBLE_BASE_URL, + apiKey: process.env.OPENAI_COMPATIBLE_API_KEY, +}); + +const selectedModelId = models.data[0]?.id ?? "openai/gpt-5-mini"; +const model = client.completionModel(selectedModelId); +``` + +Use this pattern for configuration screens, private deployments, beta models, or gateways that do not expose `GET /models`. + +If you want a manually defined source to match the same shape as provider clients, implement `ModelListingClient`: + +```ts +import type { ModelList, ModelListingClient } from "@anvia/core/model-listing"; + +function staticModelListing(models: ModelList): ModelListingClient { + return { + async listModels() { + return models; + }, + }; +} +``` + +You can also merge live provider data with manual entries: + +```ts +const liveModels = await client.listModels().catch((): ModelList => ({ data: [] })); + +const mergedModels: ModelList = { + data: [ + ...models.data, + ...liveModels.data.filter( + (liveModel) => !models.data.some((manualModel) => manualModel.id === liveModel.id), + ), + ], +}; +``` + +## Unlisted Models + +You can still use a known beta or private model id directly: + +```ts +const model = client.completionModel("provider/beta-model"); +``` + +That model will not appear in `listModels()` unless the provider includes it in the model-list response. + +## Cookbook + +Run the model-listing cookbook example: + +```sh +pnpm cookbook:providers:10 +``` diff --git a/apps/docs/content/docs/models/providers/compatible-providers.mdx b/apps/docs/content/docs/models/providers/compatible-providers.mdx index 9fe5307f..279b5c0a 100644 --- a/apps/docs/content/docs/models/providers/compatible-providers.mdx +++ b/apps/docs/content/docs/models/providers/compatible-providers.mdx @@ -29,6 +29,14 @@ The compatible client can also create embedding models when the endpoint support const embeddings = client.embeddingModel("provider-embedding-model"); ``` +The same client can list models when the endpoint exposes an OpenAI-compatible model-list endpoint. + +```ts +const models = await client.listModels(); +``` + +`listModels()` fetches from the configured endpoint, usually `GET {baseUrl}/models`, and returns provider-reported data only. Anvia does not add hidden model metadata or cache the result. + Anvia does not need a package for every OpenAI-compatible provider. If the provider exposes OpenAI-compatible completion or embedding endpoints, use `@anvia/openai` and pass the endpoint directly. ## Anthropic-Compatible @@ -55,6 +63,7 @@ const agent = new AgentBuilder("support", model) | --- | --- | | Completion | `client.completionModel(modelName)` | | Embeddings | `client.embeddingModel(modelName)` | +| Model listing | `client.listModels()` | | Custom endpoint | `new OpenAIClient({ baseUrl, apiKey })` | Compatible APIs still differ in model names, attachments, streaming metadata, tool-call behavior, and provider-specific parameters. Treat compatible clients as a shared transport shape, not a guarantee that every model behaves the same. diff --git a/apps/docs/content/docs/models/providers/mistral.mdx b/apps/docs/content/docs/models/providers/mistral.mdx index ced5e2db..f9dd1af2 100644 --- a/apps/docs/content/docs/models/providers/mistral.mdx +++ b/apps/docs/content/docs/models/providers/mistral.mdx @@ -26,7 +26,7 @@ const response = await agent.prompt("Hello!").send(); ## Embedding Model ```ts -import { embedDocuments } from "@anvia/core"; +import { embedDocuments } from "@anvia/core/embeddings"; const embeddings = client.embeddingModel("mistral-embed"); diff --git a/apps/docs/content/docs/models/providers/openai.mdx b/apps/docs/content/docs/models/providers/openai.mdx index 6452540b..8966917a 100644 --- a/apps/docs/content/docs/models/providers/openai.mdx +++ b/apps/docs/content/docs/models/providers/openai.mdx @@ -26,7 +26,7 @@ const response = await agent.prompt("Hello!").send(); ## Embedding Model ```ts -import { embedDocuments } from "@anvia/core"; +import { embedDocuments } from "@anvia/core/embeddings"; const embeddings = client.embeddingModel("text-embedding-3-small"); @@ -38,6 +38,16 @@ const embedded = await embedDocuments(embeddings, documents, { Use embedding models for document preprocessing and retrieval. +## Model Listing + +```ts +const models = await client.listModels(); +``` + +`listModels()` fetches OpenAI's `/models` endpoint and returns a normalized `ModelList`. When `baseUrl` is set, the request goes to that OpenAI-compatible endpoint instead. + +OpenAI's model list is usually sparse, so fields such as `contextLength` may be omitted unless the compatible gateway returns them. + ## Custom Client Options Use the constructor when credentials or base URL come from your own configuration system. @@ -57,6 +67,7 @@ You can also pass an existing OpenAI SDK client when your app already owns provi | --- | --- | | Completion | `client.completionModel("gpt-5.5")` | | Embeddings | `client.embeddingModel("text-embedding-3-small")` | +| Model listing | `client.listModels()` | | Compatible endpoint | `new OpenAIClient({ baseUrl, apiKey })` | Credentials are passed explicitly to the constructor. Anvia does not read environment variables. diff --git a/apps/docs/content/docs/reference/api-coverage.mdx b/apps/docs/content/docs/reference/api-coverage.mdx index f15e867e..6043da98 100644 --- a/apps/docs/content/docs/reference/api-coverage.mdx +++ b/apps/docs/content/docs/reference/api-coverage.mdx @@ -1,126 +1,60 @@ --- title: API Coverage -description: Public package export coverage checked against reference docs. +description: Automated public export coverage for reference docs. --- -This page records the package export coverage audit used to keep reference docs aligned with `packages`. +This page records the coverage policy that keeps handwritten reference pages aligned with public package exports. ## Scope -The audit treats each symbol exported from package entrypoints in `package.json#exports` as a public primitive. Internal source-file exports are outside this checklist unless they are re-exported by a package entrypoint. +The coverage check treats every package entry in `package.json#exports` as a public import path. It uses the TypeScript compiler to enumerate symbols exported from those entrypoints, then verifies that the mapped reference docs mention each import path and exported symbol. -| Package | Public primitives | Reference coverage | -| --- | ---: | --- | -| `@anvia/core` | 222 | [Core](/docs/reference/core) | -| `@anvia/openai` | 17 | [OpenAI Provider](/docs/reference/providers/openai) | -| `@anvia/gemini` | 13 | [Gemini Provider](/docs/reference/providers/gemini) | -| `@anvia/anthropic` | 4 | [Anthropic Provider](/docs/reference/providers/anthropic) | -| `@anvia/mistral` | 10 | [Mistral Provider](/docs/reference/providers/mistral) | -| `@anvia/fastembed` | 6 | [FastEmbed](/docs/reference/integrations/fastembed) | -| `@anvia/transformers` | 6 | [Transformers](/docs/reference/integrations/transformers) | -| `@anvia/chroma` | 4 | [Chroma](/docs/reference/integrations/chroma) | -| `@anvia/pgvector` | 6 | [pgvector](/docs/reference/integrations/pgvector) | -| `@anvia/qdrant` | 5 | [Qdrant](/docs/reference/integrations/qdrant) | -| `@anvia/langfuse` | 6 | [Langfuse](/docs/reference/integrations/langfuse) | -| `@anvia/otel` | 3 | [OpenTelemetry](/docs/reference/integrations/otel) | -| `@anvia/studio` | 59 | [Studio](/docs/reference/studio) | +Internal source-file exports are outside this check unless they are re-exported by a package entrypoint. -## Result +## Current Coverage -The post-update scan reported `TOTAL_MISSING=0`: every public primitive from the package entrypoints is mentioned in its mapped reference docs. +| Package | Public entrypoints | Public exports | Reference coverage | +| --- | ---: | ---: | --- | +| `@anvia/core` | 19 | 250 | [Core](/docs/reference/core) | +| `@anvia/openai` | 1 | 17 | [OpenAI Provider](/docs/reference/providers/openai) | +| `@anvia/gemini` | 1 | 13 | [Gemini Provider](/docs/reference/providers/gemini) | +| `@anvia/anthropic` | 1 | 4 | [Anthropic Provider](/docs/reference/providers/anthropic) | +| `@anvia/mistral` | 1 | 10 | [Mistral Provider](/docs/reference/providers/mistral) | +| `@anvia/fastembed` | 1 | 6 | [FastEmbed](/docs/reference/integrations/fastembed) | +| `@anvia/transformers` | 1 | 6 | [Transformers](/docs/reference/integrations/transformers) | +| `@anvia/chroma` | 1 | 4 | [Chroma](/docs/reference/integrations/chroma) | +| `@anvia/pgvector` | 1 | 6 | [pgvector](/docs/reference/integrations/pgvector) | +| `@anvia/qdrant` | 1 | 5 | [Qdrant](/docs/reference/integrations/qdrant) | +| `@anvia/langfuse` | 1 | 6 | [Langfuse](/docs/reference/integrations/langfuse) | +| `@anvia/otel` | 1 | 3 | [OpenTelemetry](/docs/reference/integrations/otel) | +| `@anvia/studio` | 1 | 61 | [Studio](/docs/reference/studio) | -## Re-run Check - -Run this from the repository root to regenerate the primitive coverage check: - -```bash -node --input-type=module <<'NODE' -import ts from "typescript"; -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; - -function walk(dir) { - return readdirSync(dir).flatMap((name) => { - const path = join(dir, name); - return statSync(path).isDirectory() ? walk(path) : [path]; - }); -} - -const packageDocs = new Map([ - ["@anvia/core", "apps/docs/content/docs/reference/core"], - ["@anvia/openai", "apps/docs/content/docs/reference/providers/openai.mdx"], - ["@anvia/gemini", "apps/docs/content/docs/reference/providers/gemini.mdx"], - ["@anvia/anthropic", "apps/docs/content/docs/reference/providers/anthropic.mdx"], - ["@anvia/mistral", "apps/docs/content/docs/reference/providers/mistral.mdx"], - ["@anvia/fastembed", "apps/docs/content/docs/reference/integrations/fastembed.mdx"], - ["@anvia/transformers", "apps/docs/content/docs/reference/integrations/transformers.mdx"], - ["@anvia/chroma", "apps/docs/content/docs/reference/integrations/chroma.mdx"], - ["@anvia/pgvector", "apps/docs/content/docs/reference/integrations/pgvector.mdx"], - ["@anvia/qdrant", "apps/docs/content/docs/reference/integrations/qdrant.mdx"], - ["@anvia/langfuse", "apps/docs/content/docs/reference/integrations/langfuse.mdx"], - ["@anvia/otel", "apps/docs/content/docs/reference/integrations/otel.mdx"], - ["@anvia/studio", "apps/docs/content/docs/reference/studio"], -]); - -const packages = [ - "packages/core", - "packages/providers/openai", - "packages/providers/gemini", - "packages/providers/anthropic", - "packages/providers/mistral", - "packages/embeddings/fastembed", - "packages/embeddings/transformers", - "packages/vector-stores/chroma", - "packages/vector-stores/pgvector", - "packages/vector-stores/qdrant", - "packages/observability/langfuse", - "packages/observability/otel", - "packages/tools/studio", -]; +The check must report: -let totalMissing = 0; - -for (const packageDir of packages) { - const pkg = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); - const exportsMap = Object.entries(pkg.exports ?? { ".": { import: pkg.main } }); - const publicExports = new Set(); +```txt +TOTAL_MISSING_ENTRYPOINTS=0 TOTAL_MISSING_EXPORTS=0 +``` - for (const [, target] of exportsMap) { - const importPath = typeof target === "string" ? target : target.import; - const sourcePath = importPath.replace(/^\.\/dist\//, "src/").replace(/\.js$/, ".ts"); - const file = join(packageDir, sourcePath); +## Re-run Check - if (!existsSync(file)) continue; +Run this from the repository root: - const program = ts.createProgram([file], { - module: ts.ModuleKind.ESNext, - target: ts.ScriptTarget.ES2022, - moduleResolution: ts.ModuleResolutionKind.Bundler, - skipLibCheck: true, - }); - const checker = program.getTypeChecker(); - const sourceFile = program.getSourceFile(file); - const moduleSymbol = checker.getSymbolAtLocation(sourceFile); +```bash +pnpm docs:reference-check +``` - for (const symbol of checker.getExportsOfModule(moduleSymbol)) { - publicExports.add(symbol.getName()); - } - } +`pnpm docs:typecheck` also runs the same check before MDX generation and TypeScript validation. - const docsPath = packageDocs.get(pkg.name); - const docsText = statSync(docsPath).isDirectory() - ? walk(docsPath) - .filter((file) => file.endsWith(".mdx")) - .map((file) => readFileSync(file, "utf8")) - .join("\n") - : readFileSync(docsPath, "utf8"); +## Documentation Standard - const missing = [...publicExports].sort().filter((name) => !docsText.includes(name)); - totalMissing += missing.length; - console.log(`${pkg.name}: ${publicExports.size} exports, ${missing.length} not mentioned`); - if (missing.length > 0) console.log(` ${missing.join(", ")}`); -} +Each practical reference page should document the public surface with: -console.log(`TOTAL_MISSING=${totalMissing}`); -NODE -``` +| Requirement | Expected content | +| --- | --- | +| Import source | Package or subpath imports, such as `@anvia/core/memory` | +| Signature | TypeScript class, function, interface, type, or constant shape | +| Purpose | What the primitive owns or represents | +| Return behavior | Resolved value, emitted events, side effects, or type-only behavior | +| Notable errors | Validation, provider, transport, persistence, or capability failures | +| Example | Minimal code showing ordinary use when the primitive is runtime-facing | +| Related docs | Guides or cookbook entries for workflow-oriented usage | diff --git a/apps/docs/content/docs/reference/core/agent.mdx b/apps/docs/content/docs/reference/core/agent.mdx index 1ec18ca5..6c63f572 100644 --- a/apps/docs/content/docs/reference/core/agent.mdx +++ b/apps/docs/content/docs/reference/core/agent.mdx @@ -26,18 +26,22 @@ class Agent { readonly observers: AgentObserverRegistration[]; readonly dynamicContexts: DynamicContextRegistration[]; readonly dynamicTools: DynamicToolRegistration[]; + readonly toolMiddlewares: ToolMiddleware[]; + readonly memory?: MemoryRegistration; + readonly eventStore?: AgentEventStoreRegistration; constructor(options: AgentOptions); - prompt(prompt: string | Message): PromptRequest; + prompt(prompt: string | Message | Message[]): PromptRequest; + session(sessionId: string, options?: SessionOptions): AgentSession; asTool(options: AgentToolOptions): Tool<{ prompt: string }, string>; getTool(toolName: string): Tool | undefined; - callTool(toolName: string, args: string): Promise; + callTool(toolName: string, args: string, context?: ToolCallContext): Promise; } ``` Purpose: immutable runnable agent configuration around one completion model. -Return behavior: `prompt(...)` creates a mutable `PromptRequest`; `asTool(...)` exposes the agent as a tool that returns the nested agent output string. +Return behavior: `prompt(...)` creates a mutable `PromptRequest`; `prompt(Message[])` treats the last message as the active prompt and earlier messages as stateless history; `session(...)` creates a durable memory-backed session; `asTool(...)` exposes the agent as a tool that returns the nested agent output string. `asTool({ stream: true })` forwards child stream events when the parent run uses `.stream()`. Notable errors: the constructor throws `TypeError` when `id` is not a non-empty string. `asTool(...)` forwards errors from the nested prompt run. @@ -62,6 +66,9 @@ type AgentOptions = { observers?: AgentObserverRegistration[]; dynamicContexts?: DynamicContextRegistration[]; dynamicTools?: DynamicToolRegistration[]; + toolMiddlewares?: ToolMiddleware[]; + memory?: MemoryRegistration; + eventStore?: AgentEventStoreRegistration; }; ``` @@ -93,7 +100,11 @@ class AgentBuilder { toolChoice(toolChoice: ToolChoice): this; defaultMaxTurns(defaultMaxTurns: number): this; hook(hook: PromptHook): this; + toolMiddleware(middleware: ToolMiddleware): this; + toolMiddlewares(middlewares: ToolMiddleware[]): this; observe(observer: AgentObserver, options?: ObserveOptions): this; + memory(store: MemoryStore, options?: MemoryOptions): this; + eventStore(store: AgentEventStore, options?: AgentEventStoreOptions): this; outputSchema(schema: ZodSchema): this; build(): Agent; } @@ -105,6 +116,94 @@ Return behavior: all mutator methods return `this`; `build()` returns an `Agent` Notable errors: the constructor rejects an empty agent id. `outputSchema(...)` can throw if the schema cannot be converted to provider JSON schema. +## AgentSession + +```ts +class AgentSession { + prompt(prompt: string | Message): PromptRequest; + messages(): Promise; + clear(): Promise; +} +``` + +Purpose: durable conversation scope created by `agent.session(sessionId, options?)`. + +Return behavior: `prompt(...)` loads messages from the configured memory store before the run and appends new messages according to the agent memory policy. Session prompts do not accept `Message[]`; use `agent.prompt(Message[])` for explicit stateless transcripts. + +Notable errors: `agent.session(...)` throws when no memory store is configured or when the session id is empty. + +## Memory + +```ts +type MemorySavePolicy = "message" | "turn" | "run"; + +type MemoryContext = { + sessionId: string; + userId?: string; + metadata?: JsonObject; +}; + +interface MemoryStore { + load(context: MemoryContext): Promise; + append(input: { + context: MemoryContext; + runId: string; + turn: number; + messages: Message[]; + }): Promise; + clear(context: MemoryContext): Promise; + recordError?(input: { + context: MemoryContext; + runId: string; + error: unknown; + messages: Message[]; + }): Promise; +} + +type MemoryOptions = { + savePolicy?: MemorySavePolicy; +}; +``` + +Purpose: configure durable conversation storage for `agent.session(...)`. + +Return behavior: `savePolicy` defaults to `"message"`. Core provides the interface; applications provide the storage implementation. + +## AgentEventStore + +```ts +interface AgentEventStore { + append(input: AgentEventAppendInput): Promise; + load(runId: string): Promise; + clear?(runId: string): Promise; +} + +type AgentEventStoreInclude = "all" | "agent_tool_events"; + +type AgentEventStoreOptions = { + include?: AgentEventStoreInclude; +}; + +type AgentEventAppendInput = { + runId: string; + agentId: string; + agentName?: string; + turn?: number; + toolName?: string; + toolCallId?: string; + internalCallId?: string; + event: unknown; +}; + +type AgentEventRecord = AgentEventAppendInput & { + createdAt?: Date; +}; +``` + +Purpose: persist runtime stream events for replay, debugging, or local inspection. + +Return behavior: `include: "all"` stores parent and child stream events. `include: "agent_tool_events"` stores only nested child-agent events from streaming agent-tools. Event storage is separate from `MemoryStore`, which remains transcript-oriented. + ## Dynamic Tools ```ts @@ -132,13 +231,14 @@ Notable errors: errors from the vector index surface before the model request is class PromptRequest { static fromAgent( agent: Agent, - prompt: string | Message, + prompt: string | Message | Message[], ): PromptRequest; - withHistory(history: Message[]): this; maxTurns(maxTurns: number): this; requestHook(hook: PromptHook): this; withToolConcurrency(concurrency: number): this; + withToolMiddleware(middleware: ToolMiddleware): this; + withToolMiddlewares(middlewares: ToolMiddleware[]): this; withTrace(trace: AgentTraceOptions): this; send(): Promise; stream(): AsyncIterable; @@ -172,20 +272,23 @@ Notable errors: none directly. ## AgentStreamEvent ```ts +type AgentChildStreamEvent = Exclude; + type AgentStreamEvent = | { type: "turn_start"; turn: number; prompt: Message; history: Message[] } | { type: "text_delta"; turn: number; delta: string } | { type: "reasoning_delta"; turn: number; delta: string; id?: string; contentType?: "text" | "summary" | "encrypted" | "redacted"; signature?: string } | { type: "tool_call"; turn: number; toolCall: ToolCall } | { type: "tool_result"; turn: number; toolName: string; toolCallId?: string; internalCallId: string; args: string; result: string } + | { type: "agent_tool_event"; turn: number; toolName: string; toolCallId?: string; internalCallId: string; agentId: string; agentName?: string; event: AgentChildStreamEvent } | { type: "turn_end"; turn: number; response: CompletionResponse } - | { type: "final"; output: string; usage: Usage; messages: Message[]; trace?: AgentTraceInfo } + | { type: "final"; runId: string; output: string; usage: Usage; messages: Message[]; trace?: AgentTraceInfo } | { type: "error"; error: unknown }; ``` Purpose: streaming event union for observing agent execution. -Return behavior: emitted by `PromptRequest.stream()` and `readableStream()`. +Return behavior: emitted by `PromptRequest.stream()` and `readableStream()`. `agent_tool_event` appears when a child agent is exposed with `asTool({ stream: true })`. The terminal `final` event includes `runId`, which can be used with `AgentEventStore.load(...)`. Notable errors: terminal failures are yielded as `{ type: "error" }` and also originate from the same conditions as `send()`. @@ -193,10 +296,16 @@ Notable errors: terminal failures are yielded as `{ type: "error" }` and also or ```ts type HookAction = { type: "continue" } | { type: "terminate"; reason: string }; +type ToolApprovalRequestOptions = { + reason?: string; + rejectMessage?: string; +}; + type ToolCallHookAction = | { type: "continue" } | { type: "skip"; reason: string } - | { type: "terminate"; reason: string }; + | { type: "terminate"; reason: string } + | ({ type: "approval_request" } & ToolApprovalRequestOptions); type RunControl = { continue(): HookAction; @@ -207,6 +316,7 @@ type ToolCallControl = { run(): ToolCallHookAction; skip(reason: string): ToolCallHookAction; cancel(reason: string): ToolCallHookAction; + requestApproval(options?: ToolApprovalRequestOptions): ToolCallHookAction; }; type HookResult = HookAction | undefined; @@ -246,13 +356,14 @@ const toolCallControl: ToolCallControl; function createHook(hook: PromptHook): PromptHook; function cancelPrompt(reason: string): HookAction; function skipTool(reason: string): ToolCallHookAction; +function requestToolApproval(options?: ToolApprovalRequestOptions): ToolCallHookAction; ``` Purpose: intercept completion calls, completion responses, tool calls, and tool results. -Return behavior: callback controls such as `tool.run()`, `tool.skip(...)`, `tool.cancel(...)`, `run.continue()`, and `run.cancel(...)` create actions consumed by `PromptRequest`. The low-level `cancelPrompt(...)` and `skipTool(...)` helpers are also available. +Return behavior: callback controls such as `tool.run()`, `tool.skip(...)`, `tool.cancel(...)`, `tool.requestApproval(...)`, `run.continue()`, and `run.cancel(...)` create actions consumed by `PromptRequest`. The low-level `cancelPrompt(...)`, `skipTool(...)`, and `requestToolApproval(...)` helpers are also available. -Notable errors: a terminating hook produces `PromptCancelledError`. +Notable errors: a terminating hook produces `PromptCancelledError`. If `tool.requestApproval(...)` reaches core without Studio or another approval handler, the request cancels with `PromptCancelledError`. ## Error Classes @@ -284,6 +395,7 @@ type AgentToolOptions = { name: string; description?: string; maxTurns?: number; + stream?: boolean; }; type DynamicContextOptions = { diff --git a/apps/docs/content/docs/reference/core/index.mdx b/apps/docs/content/docs/reference/core/index.mdx index 126fab58..0e7575b8 100644 --- a/apps/docs/content/docs/reference/core/index.mdx +++ b/apps/docs/content/docs/reference/core/index.mdx @@ -3,14 +3,14 @@ title: Core Reference description: Public exports from @anvia/core and its subpaths. --- -`@anvia/core` is the provider-neutral runtime package. The root entry point re-exports the public runtime APIs from most core subpaths. +`@anvia/core` is the provider-neutral runtime package. The root entry point exposes the common app-authoring APIs. Advanced APIs live on focused subpaths. ## Import Paths | Import path | Area | | --- | --- | -| `@anvia/core` | Root export that re-exports the public core subpaths | -| `@anvia/core/agent` | Agents, prompt requests, hooks, and run events | +| `@anvia/core` | Common app-authoring APIs for agents, tools, messages, hooks, skills, and errors | +| `@anvia/core/agent` | Agent builders, hooks, run controls, errors, and run events | | `@anvia/core/completion` | Provider-facing completion messages, requests, responses, usage, and model contracts | | `@anvia/core/image-generation` | Provider-neutral image generation contracts and request builders | | `@anvia/core/audio-generation` | Provider-neutral audio generation contracts and request builders | @@ -21,21 +21,24 @@ description: Public exports from @anvia/core and its subpaths. | `@anvia/core/evals` | Eval suites, metrics, agent targets, and reporters | | `@anvia/core/loaders` | Node file and PDF loaders for ingestion preprocessing | | `@anvia/core/embeddings` | Embedding models, documents, and vector math | +| `@anvia/core/model-listing` | Provider-neutral model listing contracts and errors | | `@anvia/core/vector-store` | In-memory vector store, vector filters, and vector search tools | +| `@anvia/core/memory` | Durable session memory interfaces and in-memory session store | | `@anvia/core/mcp` | MCP connection helpers and normalized MCP types | | `@anvia/core/observability` | Observer interfaces, trace options, and score contracts | | `@anvia/core/skills` | Skill loading, local skill discovery, validation, and generated skill tools | | `@anvia/core/streaming` | Conversion from async iterables to web `ReadableStream` | +| `@anvia/core/internal/agent` | Unstable runtime agent internals for Anvia integration packages | ## Root Export Notes -The root `@anvia/core` export is the convenient application import path. Subpaths are useful when provider packages or libraries need tighter import boundaries. +The root `@anvia/core` export is the convenient application import path. Subpaths are required for advanced APIs and useful when packages need tighter import boundaries. `@anvia/core/loaders` is subpath-only so normal core imports do not load Node filesystem and PDF extraction dependencies. ```ts -import { AgentBuilder, createTool, Message, PipelineBuilder } from "@anvia/core"; +import { AgentBuilder, createTool, Message } from "@anvia/core"; import type { CompletionModel } from "@anvia/core/completion"; ``` -For workflow guidance, start with [SDK Fundamentals](/docs/guides/core-concepts/runtime-boundaries). +For workflow guidance, start with [SDK Fundamentals](/docs/guides/sdk-fundamentals/runtime-boundaries). diff --git a/apps/docs/content/docs/reference/core/internal-agent.mdx b/apps/docs/content/docs/reference/core/internal-agent.mdx new file mode 100644 index 00000000..488db78a --- /dev/null +++ b/apps/docs/content/docs/reference/core/internal-agent.mdx @@ -0,0 +1,26 @@ +--- +title: Internal Agent +description: Unstable runtime agent exports from @anvia/core/internal/agent. +--- + +`@anvia/core/internal/agent` exposes runtime agent internals for Anvia integration packages. + +This entrypoint is intentionally not part of the public application SDK. Prefer `AgentBuilder` from `@anvia/core` or `@anvia/core/agent` in application code. + +## Exports + +- `Agent` +- `AgentSession` +- `AgentOptions` +- `DEFAULT_MAX_TURNS` +- `AgentToolOptions` +- `AgentEventStoreInclude` +- `AgentEventStoreOptions` +- `AgentEventAppendInput` +- `AgentEventRecord` +- `AgentEventStore` +- `AgentEventStoreRegistration` +- `DynamicContextOptions` +- `DynamicContextRegistration` +- `DynamicToolOptions` +- `DynamicToolRegistration` diff --git a/apps/docs/content/docs/reference/core/mcp.mdx b/apps/docs/content/docs/reference/core/mcp.mdx index 6df5e29c..699d03b4 100644 --- a/apps/docs/content/docs/reference/core/mcp.mdx +++ b/apps/docs/content/docs/reference/core/mcp.mdx @@ -23,10 +23,11 @@ Notable errors: rejects when the connection fails, tool listing fails, or the MC const mcp: { stdio(options: McpStdioOptions): McpConnection; http(options: McpHttpOptions): McpConnection; + sse(options: McpSseOptions): McpConnection; }; ``` -Purpose: factories for stdio and streamable HTTP MCP connections. +Purpose: factories for stdio, streamable HTTP, and SSE MCP connections. Return behavior: returns lazy connection objects; network or process work starts when `connectMcp(...)` calls `connect()`. @@ -84,6 +85,12 @@ type McpHttpOptions = { url: string | URL; transport?: StreamableHTTPClientTransportOptions; }; + +type McpSseOptions = { + name: string; + url: string | URL; + transport?: SSEClientTransportOptions; +}; ``` Purpose: connection and lifecycle contracts. diff --git a/apps/docs/content/docs/reference/core/memory.mdx b/apps/docs/content/docs/reference/core/memory.mdx new file mode 100644 index 00000000..a092b424 --- /dev/null +++ b/apps/docs/content/docs/reference/core/memory.mdx @@ -0,0 +1,152 @@ +--- +title: Memory +description: Durable session memory interfaces and save-policy contracts. +--- + +Import from `@anvia/core` or `@anvia/core/memory`. + +## MemoryStore + +```ts +interface MemoryStore { + load(context: MemoryContext): Promise; + append(input: MemoryAppendInput): Promise; + clear(context: MemoryContext): Promise; + recordError?(input: MemoryErrorInput): Promise; +} +``` + +Purpose: application-owned persistence adapter for durable agent sessions. + +Return behavior: `load(...)` returns prior transcript messages for a session; `append(...)` persists new run messages; `clear(...)` deletes the session transcript; `recordError(...)` optionally receives partial run messages when a prompt run fails. + +Notable errors: store implementations should reject when persistence fails. Rejections from `load(...)`, `append(...)`, `clear(...)`, or `recordError(...)` surface through session prompt calls. + +## MemoryContext + +```ts +type MemoryContext = { + sessionId: string; + userId?: string | undefined; + metadata?: JsonObject | undefined; +}; +``` + +Purpose: identifies the conversation scope loaded and saved by a `MemoryStore`. + +Return behavior: passed to every store method. `sessionId` comes from `agent.session(sessionId, options?)`; `userId` and `metadata` come from `SessionOptions`. + +Notable errors: `agent.session(...)` rejects empty session ids before creating a `MemoryContext`. + +## MemoryAppendInput and MemoryErrorInput + +```ts +type MemoryAppendInput = { + context: MemoryContext; + runId: string; + turn: number; + messages: Message[]; +}; + +type MemoryErrorInput = { + context: MemoryContext; + runId: string; + error: unknown; + messages: Message[]; +}; +``` + +Purpose: structured inputs for normal message persistence and failure recording. + +Return behavior: `messages` contains the transcript messages Anvia is asking the store to persist for that save point. `runId` and `turn` let stores group messages by run or model/tool loop turn. + +Notable errors: none directly; store implementations decide how to handle duplicate or partially persisted messages. + +## MemoryOptions + +```ts +type MemorySavePolicy = "message" | "turn" | "run"; + +type MemoryOptions = { + savePolicy?: MemorySavePolicy | undefined; +}; + +type ResolvedMemoryOptions = { + savePolicy: MemorySavePolicy; +}; + +function resolveMemoryOptions(options?: MemoryOptions): ResolvedMemoryOptions; +``` + +Purpose: configures when `AgentSession` appends messages to the configured store. + +Return behavior: `resolveMemoryOptions(...)` fills the default `savePolicy: "message"`. `AgentBuilder.memory(store, options?)` stores the resolved policy in `MemoryRegistration`. + +Notable errors: none directly. + +| Policy | Behavior | +| --- | --- | +| `"message"` | Save completed user, assistant, and tool-result messages as they become available. | +| `"turn"` | Save completed messages after each model/tool loop turn. | +| `"run"` | Save only after a successful final response. | + +## Registration and Session Types + +```ts +type MemoryRegistration = { + store: MemoryStore; + options: ResolvedMemoryOptions; +}; + +type SessionOptions = { + userId?: string | undefined; + metadata?: JsonObject | undefined; +}; +``` + +Purpose: internal agent configuration and per-session metadata contracts. + +Return behavior: `MemoryRegistration` is created by `AgentBuilder.memory(...)`; `SessionOptions` is passed to `agent.session(sessionId, options?)` and becomes part of `MemoryContext`. + +Notable errors: `agent.session(...)` throws when the agent has no memory store configured. + +## Example + +```ts +import { AgentBuilder, type MemoryStore, type Message } from "@anvia/core"; + +class InProcessMemoryStore implements MemoryStore { + private readonly sessions = new Map(); + + async load({ sessionId }) { + return this.sessions.get(sessionId) ?? []; + } + + async append({ context, messages }) { + this.sessions.set(context.sessionId, [...(this.sessions.get(context.sessionId) ?? []), ...messages]); + } + + async clear({ sessionId }) { + this.sessions.delete(sessionId); + } +} + +const agent = new AgentBuilder("support", model) + .memory(new InProcessMemoryStore(), { savePolicy: "turn" }) + .build(); + +const response = await agent + .session("thread_123", { userId: "user_456" }) + .prompt("Continue from the previous answer.") + .send(); +``` + +## Related Guides + +| Topic | Guide | +| --- | --- | +| Memory overview | [Memory](/docs/guides/memory) | +| Raw SQL storage | [Raw SQL](/docs/guides/memory/raw-sql) | +| Prisma storage | [Prisma](/docs/guides/memory/prisma) | +| Drizzle storage | [Drizzle](/docs/guides/memory/drizzle) | +| Multi-agent sessions | [Multi-Agent Memory](/docs/guides/memory/multi-agent) | diff --git a/apps/docs/content/docs/reference/core/meta.json b/apps/docs/content/docs/reference/core/meta.json index c9d56290..7034cc0b 100644 --- a/apps/docs/content/docs/reference/core/meta.json +++ b/apps/docs/content/docs/reference/core/meta.json @@ -15,11 +15,14 @@ "evals", "loaders", "embeddings", + "model-listing", "vector-store", + "memory", "mcp", "observability", "skills", "streaming", + "internal-agent", "schema" ] } diff --git a/apps/docs/content/docs/reference/core/model-listing.mdx b/apps/docs/content/docs/reference/core/model-listing.mdx new file mode 100644 index 00000000..ad7a07a7 --- /dev/null +++ b/apps/docs/content/docs/reference/core/model-listing.mdx @@ -0,0 +1,46 @@ +--- +title: Model Listing +description: Provider-neutral model list contracts. +--- + +Import from `@anvia/core/model-listing` or `@anvia/core`. + +## Model Listing Types + +```ts +type ListedModel = { + id: string; + name?: string; + description?: string; + type?: string; + createdAt?: number; + ownedBy?: string; + contextLength?: number; +}; + +type ModelList = { + data: ListedModel[]; +}; + +interface ModelListingClient { + listModels(): Promise; +} +``` + +Purpose: normalized model-listing contracts shared by provider clients. + +Return behavior: provider clients fetch live provider model data and normalize known fields. Unknown fields remain omitted. + +## ModelListingError + +```ts +class ModelListingError extends Error { + readonly provider?: string; + readonly statusCode?: number; + readonly cause?: unknown; +} +``` + +Purpose: standard error wrapper for provider model-listing failures. + +Return behavior: thrown by provider `listModels()` implementations when SDK or provider requests fail. diff --git a/apps/docs/content/docs/reference/core/observability.mdx b/apps/docs/content/docs/reference/core/observability.mdx index cfeb52ec..f33ce8d9 100644 --- a/apps/docs/content/docs/reference/core/observability.mdx +++ b/apps/docs/content/docs/reference/core/observability.mdx @@ -97,8 +97,12 @@ type AgentToolStartArgs = { }; type AgentToolEndArgs = AgentToolStartArgs & { result: string; skipped: boolean }; type AgentToolErrorArgs = AgentToolStartArgs & { error: unknown }; +type AgentToolStreamEventArgs = AgentToolStartArgs & { + event: ToolCallStreamEvent; +}; interface AgentToolObserver { + streamEvent?(args: AgentToolStreamEventArgs): void | Promise; end(args: AgentToolEndArgs): void | Promise; error?(args: AgentToolErrorArgs): void | Promise; } @@ -106,7 +110,7 @@ interface AgentToolObserver { Purpose: observe model calls and tool calls inside a run. -Return behavior: called by the agent runtime as events complete or fail. +Return behavior: called by the agent runtime as events stream, complete, or fail. `streamEvent(...)` receives nested child-agent stream events emitted by agent tools. Notable errors: observer errors follow the registration error policy. diff --git a/apps/docs/content/docs/reference/core/pipeline.mdx b/apps/docs/content/docs/reference/core/pipeline.mdx index b75d0970..3158f807 100644 --- a/apps/docs/content/docs/reference/core/pipeline.mdx +++ b/apps/docs/content/docs/reference/core/pipeline.mdx @@ -37,31 +37,127 @@ Notable errors: invalid values are normalized to at least `1`. ```ts class Pipeline implements PipelineOp> { - run(input: Input): Promise>; + readonly id: string; + readonly name: string | undefined; + readonly description: string | undefined; + readonly metadata: JsonObject | undefined; + run(input: Input, options?: PipelineRunOptions): Promise>; batch>( inputs: I, options: PipelineBatchOptions, ): Promise>>; + graph(): PipelineGraph; } ``` Purpose: runnable pipeline returned by `PipelineBuilder.build()`. -Return behavior: `run(...)` resolves the final stage output; `batch(...)` preserves input order. +Return behavior: `run(...)` resolves the final stage output; `batch(...)` preserves input order; `graph()` returns inspectable pipeline metadata, nodes, and edges. Notable errors: forwards stage errors. +## Pipeline Graph Types + +```ts +type PipelineMetadata = { + id?: string; + name?: string; + description?: string; + metadata?: JsonObject; +}; + +type PipelineStageMetadata = { + id?: string; + name?: string; + description?: string; + metadata?: JsonObject; +}; + +type PipelineStageKind = + | "input" + | "step" + | "pipeline" + | "parallel" + | "branch" + | "agent" + | "extractor" + | "output"; + +type PipelineGraphNode = { + id: string; + kind: PipelineStageKind; + label: string; + description?: string; + metadata?: JsonObject; + agentId?: string; + agentName?: string; + pipelineId?: string; + branchKey?: string; +}; + +type PipelineGraphEdge = { + id: string; + source: string; + target: string; + label?: string; +}; + +type PipelineGraph = PipelineMetadata & { + id: string; + nodes: PipelineGraphNode[]; + edges: PipelineGraphEdge[]; +}; +``` + +Purpose: automatic graph metadata for Studio and other inspectors. + +Return behavior: stage ids and labels are generated from build order unless optional metadata supplies better values. + +Notable errors: none directly. + +## Pipeline Run Events + +```ts +type PipelineRunEvent = + | { type: "stage_started"; node: PipelineGraphNode } + | { type: "stage_completed"; node: PipelineGraphNode; durationMs: number } + | { type: "stage_failed"; node: PipelineGraphNode; durationMs: number; error: unknown }; + +type PipelineRunObserver = { + onEvent(event: PipelineRunEvent): void | Promise; +}; + +type PipelineRunOptions = { + observer?: PipelineRunObserver; +}; +``` + +Purpose: metadata-only execution events for runtimes such as Studio. + +Return behavior: pass an observer to `pipeline.run(input, { observer })` to receive stage status changes. + +Notable errors: observer errors propagate to the run. + ## PipelineBuilder ```ts class PipelineBuilder { - step(fn: (input: Awaited) => Next | Promise): PipelineBuilder>; - use(op: PipelineOp, Next>): PipelineBuilder>; + constructor(); + constructor(metadata: PipelineMetadata); + step( + fn: (input: Awaited) => Next | Promise, + metadata?: PipelineStageMetadata, + ): PipelineBuilder>; + use( + op: PipelineOp, Next>, + metadata?: PipelineStageMetadata, + ): PipelineBuilder>; parallel, unknown>>>( branches: Branches, + metadata?: PipelineStageMetadata, ): PipelineBuilder>; - prompt(agent: Agent): PipelineBuilder; - extract(extractor: Extractor): PipelineBuilder; + prompt(agent: Agent, metadata?: PipelineStageMetadata): PipelineBuilder; + extract(extractor: Extractor, metadata?: PipelineStageMetadata): PipelineBuilder; build(): Pipeline>; } ``` diff --git a/apps/docs/content/docs/reference/core/tools.mdx b/apps/docs/content/docs/reference/core/tools.mdx index 1cc6ee41..499c6305 100644 --- a/apps/docs/content/docs/reference/core/tools.mdx +++ b/apps/docs/content/docs/reference/core/tools.mdx @@ -12,18 +12,28 @@ interface Tool { readonly name: string; readonly approval?: ToolApprovalPolicy; definition(prompt: string): ToolDefinition | Promise; - call(args: Args): Output | Promise; + call(args: Args, context?: ToolCallContext): Output | Promise; parseApprovalArgs?(args: unknown): Args; } type AnyTool = Omit, "approval"> & { readonly approval?: unknown; }; + +type ToolCallStreamEvent = { + agentId: string; + agentName?: string; + event: unknown; +}; + +type ToolCallContext = { + emitStreamEvent?(event: ToolCallStreamEvent): void | Promise; +}; ``` Purpose: normalized callable tool contract. -Return behavior: `definition(...)` exposes provider JSON schema; `call(...)` executes local logic. Approval metadata is passive and is not included in provider tool definitions. +Return behavior: `definition(...)` exposes provider JSON schema; `call(...)` executes local logic. The optional context is used by runtime-managed tools such as streaming agent-tools. Approval metadata is passive and is not included in provider tool definitions. Notable errors: tool implementations can throw arbitrary errors. @@ -36,7 +46,7 @@ type CreateToolOptions>; - execute(args: z.output): unknown | Promise; + execute(args: z.output, context: ToolCallContext): unknown | Promise; }; function createTool( @@ -82,6 +92,32 @@ Return behavior: core stores this metadata only. Core prompt execution ignores i Notable errors: runtime-specific approval evaluators can surface errors thrown by `when(...)`, `reason(...)`, or `rejectMessage(...)`. +## ToolMiddleware + +```ts +type ToolResultMiddlewareArgs = { + toolName: string; + args: string; + result: string; + originalResult: string; + turn: number; + toolCallId?: string; + internalCallId: string; +}; + +interface ToolMiddleware { + onResult?(args: ToolResultMiddlewareArgs): string | undefined | Promise; +} + +function createToolMiddleware(middleware: ToolMiddleware): ToolMiddleware; +``` + +Purpose: transform serialized tool results during agent runs before the model, stream events, hooks, observers, and messages receive the result. + +Return behavior: returning a string replaces the current result; returning `undefined` keeps it. Multiple middleware callbacks run in registration order. Skill runtime tools are excluded. + +Notable errors: errors thrown by middleware surface as prompt run errors. + ## ToolSet ```ts @@ -94,7 +130,7 @@ class ToolSet { get(toolName: string): Tool | undefined; values(): Tool[]; getToolDefinitions(prompt?: string): Promise; - call(toolName: string, args: string): Promise; + call(toolName: string, args: string, context?: ToolCallContext): Promise; } ``` @@ -157,15 +193,20 @@ Notable errors: validation errors can occur if the model calls it with invalid a ## Serialization Helpers ```ts +type NormalizedToolOutput = string | ToolResultContent[]; + function serializeToolOutput(output: unknown): string; +function isToolResultContentArray(value: unknown): value is ToolResultContent[]; +function normalizeToolResultOutput(output: unknown): NormalizedToolOutput; +function toolResultContentToText(content: ToolResultContent[]): string; function parseToolArgs(args: string): JsonValue; ``` Purpose: convert tool outputs and model-supplied argument strings. -Return behavior: `parseToolArgs("")` returns `{}`; `serializeToolOutput(...)` returns strings unchanged and JSON-stringifies other values. +Return behavior: `parseToolArgs("")` returns `{}`; `serializeToolOutput(...)` returns strings unchanged and JSON-stringifies other values. `normalizeToolResultOutput(...)` preserves `ToolResultContent[]` for multimodal tool results and serializes other values to text. `toolResultContentToText(...)` creates a display string for structured tool result content, rendering image blocks as media-type placeholders. -Notable errors: `parseToolArgs(...)` throws `SyntaxError` for invalid JSON. +Notable errors: `parseToolArgs(...)` throws `SyntaxError` for invalid JSON. `isToolResultContentArray(...)` only recognizes text and image tool result blocks. ## Error Classes diff --git a/apps/docs/content/docs/reference/core/vector-store.mdx b/apps/docs/content/docs/reference/core/vector-store.mdx index 45685864..3d253af5 100644 --- a/apps/docs/content/docs/reference/core/vector-store.mdx +++ b/apps/docs/content/docs/reference/core/vector-store.mdx @@ -49,6 +49,24 @@ type VectorSearchResult = { + id: string; + document: T; + metadata?: Metadata; +}; + +type VectorInspectPage = { + items: Array>; + nextCursor?: string; + totalCount?: number; +}; + type VectorSearchToolOptions = { name: string; description?: string; @@ -73,12 +91,13 @@ interface VectorSearchIndex>>; searchIds(request: VectorSearchRequest): Promise>; asTool(options: VectorSearchToolOptions): Tool<{ query: string; topK?: number }, unknown>; + inspect?(request: VectorInspectRequest): Promise>; } ``` Purpose: search interface shared by in-memory and integration-backed vector stores. -Return behavior: `searchIds(...)` strips documents and metadata; `asTool(...)` wraps search as a tool. +Return behavior: `searchIds(...)` strips documents and metadata; `asTool(...)` wraps search as a tool. `inspect(...)` is optional and returns a cursor page for UIs that need to browse indexed documents. Notable errors: model or store failures reject the returned promises. diff --git a/apps/docs/content/docs/reference/index.mdx b/apps/docs/content/docs/reference/index.mdx index 03a3a320..cbf6d4b3 100644 --- a/apps/docs/content/docs/reference/index.mdx +++ b/apps/docs/content/docs/reference/index.mdx @@ -12,6 +12,8 @@ Use guides when you want workflow guidance. Use reference pages when you already | Package | Purpose | | --- | --- | | `@anvia/core` | Agent runtime, tools, context, workflows, streaming, retrieval primitives, and observability interfaces | +| `@anvia/server` | HTTP response helpers for JSONL and Server-Sent Event streams | +| `@anvia/react` | Client stream parsers, fetch transports, and React chat state | | `@anvia/openai` | OpenAI completion and embedding client | | `@anvia/anthropic` | Anthropic completion client | | `@anvia/gemini` | Gemini API and Vertex AI completion and embedding client | @@ -22,13 +24,16 @@ Use guides when you want workflow guidance. Use reference pages when you already | `@anvia/chroma` | Chroma vector store integration | | `@anvia/qdrant` | Qdrant vector store integration | | `@anvia/pgvector` | Postgres pgvector store integration | +| `@anvia/fastembed` | Local FastEmbed embedding integration | | `@anvia/transformers` | Local Transformers embedding integration | ## Package Entry Points | Package | Public import paths | | --- | --- | -| `@anvia/core` | `@anvia/core`, `@anvia/core/agent`, `@anvia/core/completion`, `@anvia/core/embeddings`, `@anvia/core/extractor`, `@anvia/core/mcp`, `@anvia/core/observability`, `@anvia/core/pipeline`, `@anvia/core/skills`, `@anvia/core/streaming`, `@anvia/core/tool`, `@anvia/core/vector-store` | +| `@anvia/core` | `@anvia/core`, `@anvia/core/agent`, `@anvia/core/audio-generation`, `@anvia/core/completion`, `@anvia/core/embeddings`, `@anvia/core/evals`, `@anvia/core/extractor`, `@anvia/core/image-generation`, `@anvia/core/loaders`, `@anvia/core/mcp`, `@anvia/core/memory`, `@anvia/core/model-listing`, `@anvia/core/observability`, `@anvia/core/pipeline`, `@anvia/core/skills`, `@anvia/core/streaming`, `@anvia/core/tool`, `@anvia/core/transcription`, `@anvia/core/vector-store` | +| `@anvia/server` | `@anvia/server` | +| `@anvia/react` | `@anvia/react` | | `@anvia/openai` | `@anvia/openai` | | `@anvia/anthropic` | `@anvia/anthropic` | | `@anvia/gemini` | `@anvia/gemini` | @@ -37,6 +42,7 @@ Use guides when you want workflow guidance. Use reference pages when you already | `@anvia/chroma` | `@anvia/chroma` | | `@anvia/qdrant` | `@anvia/qdrant` | | `@anvia/pgvector` | `@anvia/pgvector` | +| `@anvia/fastembed` | `@anvia/fastembed` | | `@anvia/transformers` | `@anvia/transformers` | | `@anvia/langfuse` | `@anvia/langfuse` | | `@anvia/otel` | `@anvia/otel` | @@ -46,6 +52,8 @@ Use guides when you want workflow guidance. Use reference pages when you already | Section | Contains | | --- | --- | | [Core](/docs/reference/core) | Public `@anvia/core` exports and subpaths | +| [Server](/docs/reference/server) | HTTP stream response helpers | +| [React](/docs/reference/react) | Client parsers, transports, and hooks | | [Providers](/docs/reference/providers) | OpenAI, Anthropic, Gemini, Mistral, and compatible model adapters | | [Integrations](/docs/reference/integrations) | Chroma, Qdrant, pgvector, Transformers.js, Langfuse, and OpenTelemetry adapters | | [Studio](/docs/reference/studio) | Studio runtime, HTTP contracts, sessions, traces, approvals, stores, and exported types | diff --git a/apps/docs/content/docs/reference/integrations/index.mdx b/apps/docs/content/docs/reference/integrations/index.mdx index 3abc65b7..5e468012 100644 --- a/apps/docs/content/docs/reference/integrations/index.mdx +++ b/apps/docs/content/docs/reference/integrations/index.mdx @@ -14,5 +14,6 @@ Integration packages connect Anvia core contracts to external systems. | `@anvia/pgvector` | Vector store adapter for Postgres pgvector | | `@anvia/fastembed` | Local FastEmbed embedding model adapter | | `@anvia/transformers` | Local Transformers.js embedding model adapter | +| `@anvia/logger` | Structured logging adapters for agent events | | `@anvia/langfuse` | Langfuse tracing and scoring observer | | `@anvia/otel` | OpenTelemetry tracing observer | diff --git a/apps/docs/content/docs/reference/integrations/logger.mdx b/apps/docs/content/docs/reference/integrations/logger.mdx new file mode 100644 index 00000000..acc217cd --- /dev/null +++ b/apps/docs/content/docs/reference/integrations/logger.mdx @@ -0,0 +1,126 @@ +--- +title: Logger +description: Public exports from @anvia/logger. +--- + +Import from `@anvia/logger`. + +## Logger + +```ts +interface Logger { + trace(message: string, context?: Record): void; + debug(message: string, context?: Record): void; + info(message: string, context?: Record): void; + warn(message: string, context?: Record): void; + error(message: string, context?: Record): void; + fatal(message: string, context?: Record): void; + child(bindings: Record): Logger; +} +``` + +Purpose: minimal structured logger interface used by the Anvia logger adapters. + +Return behavior: implemented by `createConsoleLogger(...)`, `createPinoLogger(...)`, or a user-provided custom logger. + +## LogLevel + +```ts +type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal" | "silent"; +``` + +Purpose: supported logging levels. + +## LogContext + +```ts +type LogContext = Record; +``` + +Purpose: structured fields passed alongside a log message. + +## LoggerOptions + +```ts +type LoggerOptions = { + level?: LogLevel; + name?: string; + bindings?: LogContext; +}; +``` + +Purpose: shared logger configuration for base level and default bindings. + +## ConsoleLoggerOptions + +```ts +type ConsoleLoggerOptions = LoggerOptions & { + writer?: (line: string) => void; + timestamp?: () => Date; +}; +``` + +Purpose: configure the built-in JSON console logger. + +Notable behavior: when `level` is omitted, `ANVIA_LOG_LEVEL`, `LOG_LEVEL`, and `NODE_ENV` are used to choose a default. + +## createConsoleLogger + +```ts +function createConsoleLogger(options?: ConsoleLoggerOptions): Logger; +``` + +Purpose: create a lightweight structured JSON logger. + +Return behavior: returns a `Logger` that writes one JSON object per log line. + +## PinoLoggerOptions + +```ts +type PinoLoggerOptions = LoggerOptions & { + pinoOptions?: PinoBaseOptions; + destination?: DestinationStream; +}; +``` + +Purpose: configure the Pino-backed logger. + +Notable behavior: `pinoOptions` are passed to Pino, while `name`, `level`, and `bindings` provide Anvia-friendly defaults. + +## createPinoLogger + +```ts +function createPinoLogger(options?: PinoLoggerOptions): Logger; +``` + +Purpose: create a `Logger` backed by Pino. + +Return behavior: returns a logger that writes structured Pino records and supports child bindings. + +## LoggerObserverOptions + +```ts +type LoggerObserverOptions = { + includeOutput?: boolean; + includeRequest?: boolean; + includeResponse?: boolean; + includeToolResult?: boolean; +}; +``` + +Purpose: control which verbose agent payloads are included in lifecycle logs. + +## createLoggerObserver + +```ts +function createLoggerObserver( + logger: Logger, + options?: LoggerObserverOptions, +): AgentObserver; +``` + +Purpose: adapt an Anvia `Logger` into an agent observer. + +Return behavior: can be passed to `AgentBuilder.observe(...)` to log run, generation, and tool events. + +Notable behavior: final outputs, full model requests, model responses, and tool results are omitted unless the corresponding `LoggerObserverOptions` flag is enabled. diff --git a/apps/docs/content/docs/reference/integrations/meta.json b/apps/docs/content/docs/reference/integrations/meta.json index f92dec76..2a7b57a8 100644 --- a/apps/docs/content/docs/reference/integrations/meta.json +++ b/apps/docs/content/docs/reference/integrations/meta.json @@ -9,6 +9,7 @@ "pgvector", "fastembed", "transformers", + "logger", "langfuse", "otel" ] diff --git a/apps/docs/content/docs/reference/meta.json b/apps/docs/content/docs/reference/meta.json index 8c6f9d2e..fd242fff 100644 --- a/apps/docs/content/docs/reference/meta.json +++ b/apps/docs/content/docs/reference/meta.json @@ -3,5 +3,14 @@ "description": "Public API", "icon": "CodeXml", "root": true, - "pages": ["index", "api-coverage", "core", "providers", "integrations", "studio"] + "pages": [ + "index", + "api-coverage", + "core", + "server", + "react", + "providers", + "integrations", + "studio" + ] } diff --git a/apps/docs/content/docs/reference/providers/anthropic.mdx b/apps/docs/content/docs/reference/providers/anthropic.mdx index afe17b49..327c396c 100644 --- a/apps/docs/content/docs/reference/providers/anthropic.mdx +++ b/apps/docs/content/docs/reference/providers/anthropic.mdx @@ -17,15 +17,16 @@ type AnthropicClientOptions = { class AnthropicClient { readonly client: Anthropic; constructor(options?: AnthropicClientOptions); + listModels(): Promise; completionModel(model?: string): AnthropicCompletionModel; } ``` -Purpose: factory for Anthropic completion models. +Purpose: factory for Anthropic completion models and model listing. -Return behavior: `completionModel(...)` returns a streaming Anvia completion model. +Return behavior: `completionModel(...)` returns a streaming Anvia completion model. `listModels()` fetches Anthropic's model list and returns a normalized `ModelList`. -Notable errors: constructor throws when neither `client` nor `apiKey` is supplied. +Notable errors: constructor throws when neither `client` nor `apiKey` is supplied; `listModels()` rejects with `ModelListingError` when the provider request fails. ## AnthropicCompletionModel diff --git a/apps/docs/content/docs/reference/providers/gemini.mdx b/apps/docs/content/docs/reference/providers/gemini.mdx index 8e2e75bf..da6b48d4 100644 --- a/apps/docs/content/docs/reference/providers/gemini.mdx +++ b/apps/docs/content/docs/reference/providers/gemini.mdx @@ -15,6 +15,7 @@ type GeminiClientOptions = class GeminiClient { readonly client: GoogleGenAI; constructor(options?: GeminiClientOptions); + listModels(): Promise; completionModel(model?: string): GeminiCompletionModel; embeddingModel(model?: string, options?: GeminiEmbeddingModelOptions): GeminiEmbeddingModel; imageGenerationModel(model?: string): GeminiImageGenerationModel; @@ -23,11 +24,11 @@ class GeminiClient { } ``` -Purpose: factory for Gemini API or Vertex AI-backed completion, embedding, image generation, and transcription models. +Purpose: factory for Gemini API or Vertex AI-backed completion, embedding, image generation, transcription, and model listing. -Return behavior: creates or uses a `GoogleGenAI` client, then returns Gemini completion and embedding models. +Return behavior: creates or uses a `GoogleGenAI` client, then returns Gemini completion and embedding models. `listModels()` fetches the Gemini model list and returns a normalized `ModelList`. -Notable errors: underlying SDK calls can fail for missing credentials, invalid project/location, or API errors. +Notable errors: underlying SDK calls can fail for missing credentials, invalid project/location, or API errors; `listModels()` rejects with `ModelListingError` when the provider request fails. ## Multimodal Models diff --git a/apps/docs/content/docs/reference/providers/mistral.mdx b/apps/docs/content/docs/reference/providers/mistral.mdx index 25373f90..61058308 100644 --- a/apps/docs/content/docs/reference/providers/mistral.mdx +++ b/apps/docs/content/docs/reference/providers/mistral.mdx @@ -17,16 +17,17 @@ type MistralClientOptions = { class MistralClient { readonly client: Mistral; constructor(options?: MistralClientOptions); + listModels(): Promise; completionModel(model?: string): MistralCompletionModel; embeddingModel(model?: string, options?: MistralEmbeddingModelOptions): MistralEmbeddingModel; } ``` -Purpose: factory for Mistral completion and embedding models. +Purpose: factory for Mistral completion, embedding, and model listing. -Return behavior: creates or uses a Mistral SDK client, then returns normalized Anvia model adapters. +Return behavior: creates or uses a Mistral SDK client, then returns normalized Anvia model adapters. `listModels()` fetches Mistral's model list and returns a normalized `ModelList`. -Notable errors: constructor throws when neither `client` nor `apiKey` is supplied. +Notable errors: constructor throws when neither `client` nor `apiKey` is supplied; `listModels()` rejects with `ModelListingError` when the provider request fails. ## MistralCompletionModel diff --git a/apps/docs/content/docs/reference/providers/openai.mdx b/apps/docs/content/docs/reference/providers/openai.mdx index 54d9462e..4cdb3c20 100644 --- a/apps/docs/content/docs/reference/providers/openai.mdx +++ b/apps/docs/content/docs/reference/providers/openai.mdx @@ -19,6 +19,7 @@ type OpenAIClientOptions = { class OpenAIClient { readonly client: OpenAI; constructor(options?: OpenAIClientOptions); + listModels(): Promise; completionModel(model?: string): StreamingCompletionModel; embeddingModel(model?: string, options?: ProviderEmbeddingModelOptions): OpenAIEmbeddingModel; imageGenerationModel(model?: string): OpenAIImageGenerationModel; @@ -27,11 +28,11 @@ class OpenAIClient { } ``` -Purpose: factory for OpenAI completion, embedding, image generation, audio generation, and transcription models. +Purpose: factory for OpenAI completion, embedding, image generation, audio generation, transcription, and model listing. -Return behavior: `completionModel(...)` returns a streaming model backed by Responses API by default or chat completions when `completionApi: "chat"` is set. +Return behavior: `completionModel(...)` returns a streaming model backed by Responses API by default or chat completions when `completionApi: "chat"` is set. `listModels()` fetches the configured OpenAI or OpenAI-compatible `/models` endpoint and returns a normalized `ModelList`. -Notable errors: constructor throws when neither `client` nor `apiKey` is supplied. +Notable errors: constructor throws when neither `client` nor `apiKey` is supplied; `listModels()` rejects with `ModelListingError` when the provider request fails. ## Multimodal Models diff --git a/apps/docs/content/docs/reference/react.mdx b/apps/docs/content/docs/reference/react.mdx new file mode 100644 index 00000000..90df360d --- /dev/null +++ b/apps/docs/content/docs/reference/react.mdx @@ -0,0 +1,160 @@ +--- +title: React +description: Client transports and hooks from @anvia/react. +--- + +Import from `@anvia/react`. + +## Types + +```ts +type EventStreamFormat = "jsonl" | "sse"; + +type TransportOptions = { + signal?: AbortSignal; + headers?: HeadersInit; +}; + +type ChatRole = "system" | "user" | "assistant" | "tool"; + +type ChatMessage = { + id: string; + role: ChatRole; + content: string; + metadata?: unknown; +}; + +type DefaultChatRequest = { + message: string; + history: ChatMessage[]; + stream: true; +}; + +type UseChatStatus = "idle" | "streaming" | "error"; +``` + +## EventTransport + +```ts +type EventTransport = { + send(request: TRequest, options?: TransportOptions): AsyncIterable; +}; +``` + +Purpose: common boundary for JSONL, SSE, WebSocket, local, and custom transports. + +## readJsonlStream + +```ts +function readJsonlStream(stream: ReadableStream): AsyncIterable; +``` + +Purpose: parse newline-delimited JSON from a web stream. + +## readSseStream + +```ts +function readSseStream(stream: ReadableStream): AsyncIterable; +``` + +Purpose: parse Server-Sent Events whose `data:` payload is JSON. + +## fetchEventStream + +```ts +type FetchEventStreamOptions = RequestInit & { + format?: "jsonl" | "sse"; + fetch?: typeof fetch; +}; + +function fetchEventStream( + input: string | URL | Request, + options?: FetchEventStreamOptions, +): AsyncIterable; +``` + +Purpose: fetch and parse a streaming response as an async iterable. + +## createFetchTransport + +```ts +type CreateFetchTransportOptions = { + endpoint: string | URL | ((request: TRequest) => string | URL); + method?: string; + format?: "jsonl" | "sse"; + headers?: HeadersInit | ((request: TRequest) => HeadersInit | Promise); + body?: (request: TRequest) => BodyInit | null | undefined | Promise; + mapEvent?: (event: unknown) => TEvent; +}; + +function createFetchTransport( + options: CreateFetchTransportOptions, +): EventTransport; +``` + +Purpose: create a POST JSON transport by default while allowing custom headers, bodies, endpoints, and event mapping. + +## createChatTransport + +```ts +function createChatTransport( + options: CreateFetchTransportOptions, +): EventTransport; +``` + +Purpose: named chat transport helper built on the fetch transport. + +## useChat + +```ts +type UseChatOptions = { + transport?: EventTransport; + endpoint?: string | URL; + format?: "jsonl" | "sse"; + initialMessages?: TMessage[]; + createRequest?: (input: string, messages: TMessage[]) => TRequest; + eventToDelta?: (event: TEvent) => string | undefined; + eventToFinal?: (event: TEvent) => string | undefined; + onEvent?: (event: TEvent) => void; + onError?: (error: unknown) => void; +}; + +type UseChatResult = { + messages: TMessage[]; + events: TEvent[]; + input: string; + setInput(input: string): void; + send(input?: string): Promise; + stop(): void; + reset(messages?: TMessage[]): void; + status: UseChatStatus; + error: unknown; + text: string; +}; + +function useChat(options?: { + transport?: EventTransport; + endpoint?: string | URL; + format?: "jsonl" | "sse"; + createRequest?: (input: string, messages: ChatMessage[]) => TRequest; + eventToDelta?: (event: TEvent) => string | undefined; + eventToFinal?: (event: TEvent) => string | undefined; +}): UseChatResult; +``` + +Purpose: React chat state machine that consumes events from any `EventTransport`. + +Passing `endpoint` creates a default JSONL fetch transport. Passing `transport` makes the hook independent of HTTP. + +## EventStreamHttpError + +```ts +class EventStreamHttpError extends Error { + readonly response: Response; + readonly body: string; +} +``` + +Purpose: thrown by `fetchEventStream(...)` when the HTTP response is not ok. + +For workflow guidance, see [Client Transports](/docs/guides/streaming/client-transports). diff --git a/apps/docs/content/docs/reference/server.mdx b/apps/docs/content/docs/reference/server.mdx new file mode 100644 index 00000000..53d9a19c --- /dev/null +++ b/apps/docs/content/docs/reference/server.mdx @@ -0,0 +1,90 @@ +--- +title: Server +description: HTTP stream helpers from @anvia/server. +--- + +Import from `@anvia/server`. + +## Types + +```ts +type EventStreamFormat = "jsonl" | "sse"; + +type EventStreamErrorEvent = { + type: "error"; + error: unknown; +}; + +type CreateEventStreamOptions = { + format?: EventStreamFormat; + headers?: HeadersInit; + status?: number; + statusText?: string; + jsonl?: JsonlStreamOptions; + sse?: SseStreamOptions; +}; + +type JsonlStreamOptions = { + serialize?: (event: TEvent | EventStreamErrorEvent) => string; +}; + +type SseStreamOptions = { + eventName?: string | ((event: TEvent | EventStreamErrorEvent) => string | undefined); + serialize?: (event: TEvent | EventStreamErrorEvent) => string; + retry?: number; +}; +``` + +## createEventStream + +```ts +function createEventStream( + events: AsyncIterable, + options?: { + format?: "jsonl" | "sse"; + headers?: HeadersInit; + status?: number; + statusText?: string; + jsonl?: JsonlStreamOptions; + sse?: SseStreamOptions; + }, +): Response; +``` + +Purpose: convert an async iterable of events into an HTTP `Response`. + +Default behavior: writes JSONL with `content-type: application/x-ndjson; charset=utf-8`, `cache-control: no-cache, no-transform`, `connection: keep-alive`, and `x-accel-buffering: no`. + +Use `format: "sse"` to emit `text/event-stream`. + +## createJsonlStream + +```ts +function createJsonlStream( + events: AsyncIterable, + options?: { + serialize?: (event: TEvent | { type: "error"; error: unknown }) => string; + }, +): ReadableStream; +``` + +Purpose: encode each event as one JSON line. + +Error behavior: if the iterable throws, the stream emits `{ type: "error", error }` and closes. + +## createSseStream + +```ts +function createSseStream( + events: AsyncIterable, + options?: { + eventName?: string | ((event: TEvent | { type: "error"; error: unknown }) => string | undefined); + serialize?: (event: TEvent | { type: "error"; error: unknown }) => string; + retry?: number; + }, +): ReadableStream; +``` + +Purpose: encode each event as a Server-Sent Event with JSON in `data:` fields. + +For workflow guidance, see [Readable Streams](/docs/guides/streaming/readable-streams). diff --git a/apps/docs/content/docs/reference/studio/runtime.mdx b/apps/docs/content/docs/reference/studio/runtime.mdx index c5c61edf..6da5277c 100644 --- a/apps/docs/content/docs/reference/studio/runtime.mdx +++ b/apps/docs/content/docs/reference/studio/runtime.mdx @@ -9,7 +9,7 @@ Import from `@anvia/studio`. ```ts class Studio implements AnviaStudio { - constructor(agents?: Agent[], options?: StudioOptions); + constructor(targets?: StudioTarget[], options?: StudioOptions); get app(): Hono; fetch(request: Request): Response | Promise; config(): StudioConfig; @@ -21,7 +21,7 @@ class Studio implements AnviaStudio { Purpose: local Studio HTTP runtime and UI/API host. -Return behavior: `start(...)` starts an HTTP server and returns `this`; `fetch(...)` delegates to the Hono app. +Return behavior: pass built agents, built pipelines, or both in the first array. `start(...)` starts an HTTP server and returns `this`; `fetch(...)` delegates to the Hono app. Notable errors: server startup can fail when the port is unavailable; route handlers return structured `StudioErrorResponse` values for request errors. @@ -47,8 +47,12 @@ Notable errors: none directly. ```ts type StudioOptions = { quickPrompts?: Record; + stores?: StudioStores; + ui?: boolean | StudioUiOptions; }; +type StudioTarget = Agent | Pipeline; + type StudioServeOptions = { port?: number; hostname?: string; diff --git a/apps/docs/content/docs/reference/studio/sessions.mdx b/apps/docs/content/docs/reference/studio/sessions.mdx index f9d71b28..5884428f 100644 --- a/apps/docs/content/docs/reference/studio/sessions.mdx +++ b/apps/docs/content/docs/reference/studio/sessions.mdx @@ -30,10 +30,35 @@ type StudioTranscriptToolEntry = { callId?: string; args?: string; result?: string; + childEvents?: StudioTranscriptChildAgentEvent[]; approval?: StudioToolApprovalTranscript; question?: StudioToolQuestionTranscript; }; +type StudioTranscriptChildAgentEvent = + | { + kind: "message"; + agentId: string; + agentName?: string; + text: string; + } + | { + kind: "reasoning"; + agentId: string; + agentName?: string; + reasoningId?: string; + text: string; + } + | { + kind: "tool"; + agentId: string; + agentName?: string; + toolName: string; + callId?: string; + args?: string; + result?: string; + }; + type StudioTranscriptEntry = | StudioTranscriptChatEntry | StudioTranscriptReasoningEntry @@ -86,35 +111,90 @@ type StudioSessionListOptions = { limit: number; }; -type StudioSessionAppendInput = { +type StudioSessionRunStatus = "running" | "success" | "error"; + +type StudioSessionRunTranscriptInput = { id: string; + runId: string; title?: string; - messages: Message[]; transcript: StudioTranscriptEntry[]; + status: StudioSessionRunStatus; + error?: JsonValue; +}; + +type StudioSessionLogLevel = "debug" | "info" | "warn" | "error"; + +type StudioSessionLogCategory = + | "session" + | "run" + | "memory" + | "prompt" + | "model" + | "tool" + | "approval" + | "question" + | "api"; + +type StudioSessionLogEntry = { + id: string; + sessionId: string; + runId?: string; + sequence: number; + timestamp: string; + level: StudioSessionLogLevel; + category: StudioSessionLogCategory; + event: string; + message: string; + metadata?: JsonObject; +}; + +type StudioSessionLogAppendInput = { + sessionId: string; + runId?: string; + level: StudioSessionLogLevel; + category: StudioSessionLogCategory; + event: string; + message: string; + metadata?: JsonObject; +}; + +type StudioSessionLogListOptions = { + sessionId: string; + limit: number; + after?: number; +}; + +type StudioSessionLogEvent = { + type: "session_log"; + log: StudioSessionLogEntry; }; ``` Purpose: arguments for session store methods. -Return behavior: used by `StudioSessionStore`. +Return behavior: used by `StudioSessionStore` and streaming session log events. Notable errors: store implementations may reject invalid or conflicting inputs. ## StudioSessionStore ```ts -type StudioSessionStore = { +type StudioSessionStore = MemoryStore & { readonly kind?: string; listSessions(options: StudioSessionListOptions): StudioSessionSummary[] | Promise; createSession(input: StudioSessionCreateInput): StudioSessionSummary | Promise; getSession(id: string): StudioSession | undefined | Promise; - appendSessionRun(input: StudioSessionAppendInput): StudioSession | undefined | Promise; + saveSessionRunTranscript( + input: StudioSessionRunTranscriptInput, + ): StudioSession | undefined | Promise; + appendSessionLog?(input: StudioSessionLogAppendInput): StudioSessionLogEntry | Promise; + listSessionLogs?(options: StudioSessionLogListOptions): StudioSessionLogEntry[] | Promise; deleteSession?(id: string): boolean | Promise; }; ``` Purpose: persistence adapter for Studio sessions. -Return behavior: methods may be sync or async. +Return behavior: methods may be sync or async. Because the store extends `MemoryStore`, it also loads, appends, and clears model messages for Studio-backed sessions. Log methods are optional for custom stores; the default SQLite store implements them. Notable errors: persistence failures should throw or reject. diff --git a/apps/docs/content/docs/reference/studio/stores.mdx b/apps/docs/content/docs/reference/studio/stores.mdx index 59db678d..8ca82004 100644 --- a/apps/docs/content/docs/reference/studio/stores.mdx +++ b/apps/docs/content/docs/reference/studio/stores.mdx @@ -1,6 +1,6 @@ --- title: Studio Stores -description: Store bundle types and SQLite session/trace store. +description: Store bundle types, in-memory store, and optional SQLite session/trace store. --- Import from `@anvia/studio`. @@ -11,12 +11,29 @@ Import from `@anvia/studio`. type StudioStores = { sessions?: StudioSessionStore | false; traces?: StudioTraceStore; + pipelineLogs?: StudioPipelineLogStore | false; + pipelineRuns?: StudioPipelineRunStore | false; }; ``` -Purpose: optional persistence stores for Studio sessions and traces. +Purpose: optional persistence stores for Studio sessions, traces, pipeline logs, and pipeline run history. -Return behavior: `false` disables sessions; omitted traces disable trace routes unless the session store also implements `StudioTraceStore`. +Return behavior: `false` disables sessions, pipeline logs, or pipeline runs. Omitted traces disable trace routes unless the session store also implements `StudioTraceStore`. The default in-memory store implements session, trace, pipeline log, and pipeline run storage. + +Notable errors: none directly. + +## In-Memory Store + +```ts +function createInMemoryStudioStore(): StudioSessionStore & + StudioTraceStore & + StudioPipelineLogStore & + StudioPipelineRunStore; +``` + +Purpose: create a process-local Studio store for sessions, traces, pipeline logs, and pipeline run history. + +Return behavior: this is the default Studio store when `ANVIA_STUDIO_DB` is not set and no explicit store is provided. Notable errors: none directly. @@ -29,11 +46,11 @@ type SqliteSessionStoreOptions = { function createSqliteSessionStore( options?: SqliteSessionStoreOptions, -): StudioSessionStore & StudioTraceStore; +): StudioSessionStore & StudioTraceStore & StudioPipelineLogStore & StudioPipelineRunStore; ``` -Purpose: create a SQLite-backed session and trace store. +Purpose: create a SQLite-backed session, trace, pipeline log, and pipeline run store. -Return behavior: defaults to in-memory SQLite when `path` is omitted. +Return behavior: defaults to in-memory SQLite when `path` is omitted. When used with a file path, Studio creates dedicated `anvia_studio_*` tables. Notable errors: throws when `node:sqlite` is unavailable, the database cannot open, or SQLite operations fail. diff --git a/apps/docs/content/docs/reference/studio/types.mdx b/apps/docs/content/docs/reference/studio/types.mdx index 62da63c1..19d0ab33 100644 --- a/apps/docs/content/docs/reference/studio/types.mdx +++ b/apps/docs/content/docs/reference/studio/types.mdx @@ -12,8 +12,13 @@ type StudioCapability = | "agents" | "approvals" | "knowledge" + | "memory" + | "mcps" | "observability" + | "pipelines" | "sessions" + | "status" + | "tools" | "traces"; type StudioAgent = { @@ -33,6 +38,52 @@ type StudioAgentConfig = { metadata?: JsonObject; }; +type StudioAgentRuntimeSummary = { + id: string; + name?: string; + description?: string; + model?: JsonValue; + toolCount: number; + staticToolCount: number; + dynamicToolCount: number; + approvalToolCount: number; + mcpToolCount: number; + staticContextCount: number; + dynamicContextCount: number; + observerCount: number; + hasMemory: boolean; + hasHook: boolean; + hasOutputSchema: boolean; + defaultMaxTurns?: number; + metadata?: JsonObject; +}; + +type StudioTarget = Agent | Pipeline; + +type StudioPipeline = { + id: string; + pipeline: Pipeline; + name?: string; + description?: string; + metadata?: JsonObject; +}; + +type StudioPipelineConfig = { + id: string; + name?: string; + description?: string; + metadata?: JsonObject; + stageCount: number; + edgeCount: number; + hasParallelStages: boolean; + agentCount: number; + extractorCount: number; +}; + +type StudioPipelineDetail = StudioPipelineConfig & { + graph: PipelineGraph; +}; + type StudioCapabilityConfig = { enabled: boolean; reason?: string; @@ -44,6 +95,7 @@ type StudioConfig = { description?: string; version?: string; agents: StudioAgentConfig[]; + pipelines: StudioPipelineConfig[]; chat: { quickPrompts: Record }; capabilities: Partial>; unsupportedCapabilities: StudioCapability[]; @@ -52,10 +104,183 @@ type StudioConfig = { Purpose: configuration returned by Studio runtime and HTTP config endpoints. -Return behavior: `Studio.config()` returns `StudioConfig`. +Return behavior: `Studio.config()` returns `StudioConfig`. Pipelines are top-level Studio targets beside agents. Notable errors: none directly. +## Tool Metadata Types + +```ts +type StudioAgentToolSource = "static" | "dynamic"; + +type StudioAgentToolApprovalMetadata = { + required: boolean; + reason?: string; + rejectMessage?: string; +}; + +type StudioAgentToolMetadata = { + agentId: string; + name: string; + description: string; + parameters: JsonObject; + source: StudioAgentToolSource; + approval: StudioAgentToolApprovalMetadata; +}; + +type StudioAgentToolsSummary = { + agentId: string; + tools: StudioAgentToolMetadata[]; +}; + +type StudioToolRunRequest = { + args: JsonValue; + context?: JsonObject; +}; + +type StudioToolRunResponse = { + agentId: string; + toolName: string; + result?: JsonValue; + error?: JsonValue; + status: "success" | "error"; + durationMs: number; + startedAt: string; + endedAt: string; + events: JsonValue[]; +}; + +type StudioAgentMcpToolMetadata = { + name: string; + description: string; + parameters: JsonObject; + source: StudioAgentToolSource; +}; + +type StudioAgentMcpServerMetadata = { + agentId: string; + name: string; + toolCount: number; + tools: StudioAgentMcpToolMetadata[]; +}; + +type StudioAgentMcpsSummary = { + agentId: string; + servers: StudioAgentMcpServerMetadata[]; +}; +``` + +Purpose: metadata returned by `GET /agents/:agentId/tools` and used by the Studio Tools inspector. + +Return behavior: static tools come from `agent.toolSet`; dynamic tools are listed when the dynamic tool index exposes its underlying `ToolSet`. Direct tool runs use `POST /agents/:agentId/tools/:toolName/runs`. + +Notable errors: unknown agents return `not_found`. + +`GET /agents/:agentId/mcps` returns the MCP subset grouped by server name. MCP metadata is available for tools registered through `.mcp(...)`. + +## Memory and Status Types + +```ts +type StudioMemoryUserSummary = { + userId: string; + conversationCount: number; + agentIds: string[]; + lastInteractionAt: string; +}; + +type StudioMemoryConversationSummary = { + id: string; + userId: string; + agentId: string; + title?: string; + createdAt: string; + updatedAt: string; + messageCount: number; + metadata?: JsonObject; +}; + +type StudioMemoryConversationsPage = { + conversations: StudioMemoryConversationSummary[]; + total: number; +}; + +type StudioMemoryUsersPage = { + users: StudioMemoryUserSummary[]; + total: number; +}; + +type StudioMemoryConversationMessages = { + conversation: StudioMemoryConversationSummary; + messages: Message[]; + transcript: StudioTranscriptEntry[]; +}; + +type StudioMemoryConversationSteps = { + conversation: StudioMemoryConversationSummary; + steps: StudioTranscriptEntry[]; +}; + +type StudioStatusSummary = { + runner: { + id: string; + name?: string; + version?: string; + }; + storage: { + sessions?: string; + traces?: string; + pipelineLogs?: string; + pipelineRuns?: string; + }; + counts: { + agents: number; + pipelines: number; + sessions?: number; + traces?: number; + pipelineRuns?: number; + }; + capabilities: Partial>; + generatedAt: string; +}; + +type StudioEvalSuite = RunEvalSuiteOptions & { + id?: string; + description?: string; + metadata?: JsonObject; +}; + +type StudioEvalSuiteConfig = { + id: string; + name: string; + description?: string; + caseCount: number; + metricNames: string[]; + concurrency?: number; + metadata?: JsonObject; +}; + +type StudioEvalRunRequest = { + concurrency?: number; +}; + +type StudioEvalRunResponse = { + runId: string; + suiteId: string; + startedAt: string; + endedAt: string; + durationMs: number; + result: JsonObject; +}; +``` + +Purpose: response contracts for Memory, Status, direct tool runs, eval runs, and richer agent runtime inspection routes. + +Return behavior: Memory routes summarize the active Studio session store; Status returns a compact runtime snapshot. + +Register eval suites with `new Studio(targets, { evals: [...] })`. Studio lists their case and metric counts and runs them through `POST /evals/:evalId/runs`. + +Notable errors: unsupported or missing stores return `unsupported_capability` or `not_found` from route handlers. + ## Run Types ```ts @@ -77,15 +302,115 @@ type AgentRunStreamEvent = | StudioToolApprovalRequestEvent | StudioToolApprovalResultEvent | StudioToolQuestionRequestEvent - | StudioToolQuestionResultEvent; + | StudioToolQuestionResultEvent + | StudioSessionLogEvent + | StudioPipelineLogEvent + | StudioPipelineFinalEvent; + +type StudioObservabilityEventType = "session_log" | "pipeline_log" | "trace"; + +type StudioObservabilityEvent = + | { + type: "session_log"; + log: StudioSessionLogEntry; + } + | { + type: "pipeline_log"; + log: StudioPipelineLogEntry; + } + | { + type: "trace"; + trace: StudioTraceSummary; + }; ``` Purpose: HTTP request and response contracts for Studio agent runs. -Return behavior: non-streaming runs return `AgentRunResponse`; streaming runs emit newline-delimited `AgentRunStreamEvent` values. +Return behavior: non-streaming agent runs return `AgentRunResponse`; streaming agent runs emit newline-delimited `AgentRunStreamEvent` values. `session_log` and `pipeline_log` events are Studio-owned metadata-only runtime logs. + +`GET /observability/events` emits newline-delimited `StudioObservabilityEvent` values. Use the optional `type` query string to subscribe to `session_log`, `pipeline_log`, `trace`, or a comma-separated subset. Notable errors: invalid run request bodies return `bad_request`; unsupported stores or capabilities return `unsupported_capability`. +## Pipeline Run Types + +```ts +type StudioPipelineLogLevel = "debug" | "info" | "warn" | "error"; + +type StudioPipelineLogCategory = + | "pipeline" + | "run" + | "stage" + | "parallel" + | "agent" + | "extractor" + | "api"; + +type StudioPipelineLogEntry = { + id: string; + pipelineId: string; + runId?: string; + sequence: number; + timestamp: string; + level: StudioPipelineLogLevel; + category: StudioPipelineLogCategory; + event: string; + message: string; + metadata?: JsonObject; +}; + +type StudioPipelineLogAppendInput = { + pipelineId: string; + runId?: string; + level: StudioPipelineLogLevel; + category: StudioPipelineLogCategory; + event: string; + message: string; + metadata?: JsonObject; +}; + +type StudioPipelineLogListOptions = { + pipelineId: string; + limit: number; + after?: number; +}; + +type StudioPipelineLogEvent = { + type: "pipeline_log"; + log: StudioPipelineLogEntry; +}; + +type StudioPipelineFinalEvent = { + type: "pipeline_final"; + runId: string; + pipelineId: string; + output: JsonValue; +}; + +type StudioPipelineRunRequest = { + input: JsonValue; + stream?: boolean; + metadata?: JsonObject; +}; + +type StudioPipelineReplayRequest = { + stream?: boolean; + metadata?: JsonObject; +}; + +type StudioPipelineRunResponse = { + runId: string; + pipelineId: string; + output: JsonValue; +}; +``` + +Purpose: HTTP request, response, stream, and persisted log contracts for Studio pipeline runs. + +Return behavior: non-streaming pipeline runs return `StudioPipelineRunResponse`; streaming runs emit `pipeline_log` events and one `pipeline_final` event. + +Notable errors: Studio HTTP pipeline inputs must be JSON-compatible. Use direct `pipeline.run(...)` for non-JSON inputs. + ## Knowledge Types ```ts @@ -129,6 +454,30 @@ type StudioAgentKnowledgeConfig = { staticContext: StudioStaticKnowledgeDocument[]; }; +type StudioKnowledgeItemKind = "static_context" | "dynamic_context" | "dynamic_tool"; + +type StudioKnowledgeItem = { + id: string; + kind: StudioKnowledgeItemKind; + text?: string; + document?: JsonValue; + toolName?: string; + description?: string; + parameterKeys?: string[]; + metadata?: JsonObject; +}; + +type StudioKnowledgeItemsPage = { + agentId: string; + sourceId: string; + kind: StudioKnowledgeSourceKind; + inspectable: boolean; + items: StudioKnowledgeItem[]; + nextCursor?: string; + totalCount?: number; + message?: string; +}; + type StudioKnowledgeSummary = { agents: StudioAgentKnowledgeConfig[]; evidence: StudioKnowledgeEvidence[]; @@ -137,10 +486,62 @@ type StudioKnowledgeSummary = { Purpose: summarize each agent's inspectable static context, dynamic context, dynamic tools, and recent trace evidence. -Return behavior: returned by `GET /knowledge`. +Return behavior: `GET /knowledge` returns `StudioKnowledgeSummary`; source item routes return `StudioKnowledgeItemsPage`. Notable errors: invalid `limit` query values return `bad_request`. +## Pipeline Run Persistence Types + +```ts +type StudioPipelineRunStatus = "running" | "success" | "error"; + +type StudioPipelineRunRecord = { + runId: string; + pipelineId: string; + status: StudioPipelineRunStatus; + input: JsonValue; + output?: JsonValue; + error?: JsonValue; + metadata?: JsonObject; + startedAt: string; + endedAt?: string; + durationMs?: number; +}; + +type StudioPipelineRunSaveInput = { + runId: string; + pipelineId: string; + status: StudioPipelineRunStatus; + input: JsonValue; + output?: JsonValue; + error?: JsonValue; + metadata?: JsonObject; + startedAt: string; + endedAt?: string; + durationMs?: number; +}; + +type StudioPipelineRunListOptions = { + pipelineId: string; + limit: number; +}; + +type StudioPipelineRunStore = { + savePipelineRun( + input: StudioPipelineRunSaveInput, + ): StudioPipelineRunRecord | Promise; + listPipelineRuns( + options: StudioPipelineRunListOptions, + ): StudioPipelineRunRecord[] | Promise; +}; +``` + +Purpose: persistence contract for Studio pipeline run history. + +Return behavior: stores normalize saved run input into `StudioPipelineRunRecord` values and list recent records per pipeline. + +Notable errors: store failures surface through the Studio runtime or HTTP route handling the pipeline request. + ## Error Types ```ts diff --git a/apps/docs/content/docs/studio/configure/capabilities.mdx b/apps/docs/content/docs/studio/configure/capabilities.mdx index d9545abf..af5c281e 100644 --- a/apps/docs/content/docs/studio/configure/capabilities.mdx +++ b/apps/docs/content/docs/studio/configure/capabilities.mdx @@ -13,11 +13,14 @@ curl http://localhost:4021/config { "id": "anvia-studio", "agents": [], + "pipelines": [], "chat": { "quickPrompts": {} }, "capabilities": { "agents": { "enabled": true }, + "status": { "enabled": true }, + "memory": { "enabled": true }, "sessions": { "enabled": true }, "traces": { "enabled": true } }, @@ -30,10 +33,15 @@ curl http://localhost:4021/config | Capability | Enabled when | | --- | --- | | `agents` | Always enabled | +| `status` | Always enabled | | `sessions` | Session storage is available | +| `memory` | Session storage is available | | `traces` | Trace storage is available | +| `tools` | At least one registered agent has static tools or dynamic tool indexes | +| `mcps` | At least one registered agent has tools registered from an MCP server | +| `pipelines` | At least one pipeline is registered | | `observability` | At least one registered agent has observers | -| `approvals` | At least one registered agent has a tool with approval metadata | +| `approvals` | At least one registered agent has a tool with approval metadata or a runtime hook | | `knowledge` | At least one registered agent has static context, dynamic context, or dynamic tools | ## Unsupported Capabilities @@ -47,3 +55,5 @@ If a capability is unavailable, Studio lists it in `unsupportedCapabilities`. Re ``` Most applications should inspect `/config` before building custom tooling around optional Studio surfaces. + +Use `/status` when you need a compact runtime snapshot with record counts, storage adapter labels, and the same resolved capability map. diff --git a/apps/docs/content/docs/studio/configure/meta.json b/apps/docs/content/docs/studio/configure/meta.json index 643b517c..5ecd211b 100644 --- a/apps/docs/content/docs/studio/configure/meta.json +++ b/apps/docs/content/docs/studio/configure/meta.json @@ -2,5 +2,5 @@ "title": "Configure Studio", "defaultOpen": false, "collapsible": true, - "pages": ["serve-options", "quick-prompts", "storage-and-persistence", "capabilities"] + "pages": ["serve-options", "quick-prompts", "storage-and-persistence", "capabilities", "status"] } diff --git a/apps/docs/content/docs/studio/configure/status.mdx b/apps/docs/content/docs/studio/configure/status.mdx new file mode 100644 index 00000000..4b0da831 --- /dev/null +++ b/apps/docs/content/docs/studio/configure/status.mdx @@ -0,0 +1,44 @@ +--- +title: Status +description: Inspect Studio runtime status, storage adapters, counts, and capabilities. +--- + +The Status page gives a compact operational snapshot of the local Studio runtime. + +Open `http://localhost:4021/ui/status` after starting Studio. + +## HTTP Route + +```bash +curl http://localhost:4021/status +``` + +```json +{ + "runner": { + "id": "anvia-studio" + }, + "storage": { + "sessions": "memory", + "traces": "memory", + "pipelineLogs": "available", + "pipelineRuns": "available" + }, + "counts": { + "agents": 1, + "pipelines": 0, + "sessions": 3, + "traces": 5 + }, + "capabilities": { + "agents": { "enabled": true }, + "status": { "enabled": true }, + "sessions": { "enabled": true }, + "memory": { "enabled": true }, + "traces": { "enabled": true } + }, + "generatedAt": "2026-06-01T00:00:00.000Z" +} +``` + +Counts are capped to the current Studio list limits, so use them as a quick local signal rather than a production metric source. diff --git a/apps/docs/content/docs/studio/configure/storage-and-persistence.mdx b/apps/docs/content/docs/studio/configure/storage-and-persistence.mdx index bb17af25..6339b3f6 100644 --- a/apps/docs/content/docs/studio/configure/storage-and-persistence.mdx +++ b/apps/docs/content/docs/studio/configure/storage-and-persistence.mdx @@ -1,19 +1,13 @@ --- title: Storage and Persistence -description: Understand Studio's local SQLite storage for sessions and traces. +description: Understand Studio's in-memory default store and optional SQLite persistence. --- -Studio creates a local SQLite store by default. It stores sessions, transcript entries, and traces for local inspection. +Studio uses an in-memory store by default. It stores sessions, runtime messages, transcript entries, traces, and pipeline logs for local inspection while the process is running, without creating a local database file. -## Default Path +## Optional SQLite -By default, Studio writes to: - -```txt -.anvia-studio/anvia-studio.sqlite -``` - -Set `ANVIA_STUDIO_DB` when you want a specific file. +Set `ANVIA_STUDIO_DB` when you want Studio state to survive process restarts. ```bash ANVIA_STUDIO_DB=.data/studio.sqlite pnpm tsx studio.ts @@ -21,18 +15,34 @@ ANVIA_STUDIO_DB=.data/studio.sqlite pnpm tsx studio.ts `AION_STUDIO_DB` is also read as a compatibility fallback. +SQLite storage uses dedicated `anvia_studio_*` tables, including `anvia_studio_sessions`, `anvia_studio_traces`, `anvia_studio_pipeline_logs`, and `anvia_studio_pipeline_runs`. That makes it safe to point Studio at a shared application database without writing into production product tables. + +You can also pass a store explicitly: + +```ts +import { Studio, createSqliteSessionStore } from "@anvia/studio"; + +new Studio([agent], { + stores: { + sessions: createSqliteSessionStore({ path: ".data/studio.sqlite" }), + }, +}).start(); +``` + ## What Gets Stored | Data | Purpose | | --- | --- | | Sessions | Local conversations for one registered agent | -| Messages | Runtime history used when continuing a session | -| Transcript entries | UI-friendly messages, reasoning, tool calls, approvals, and questions | +| Messages | Runtime history used when continuing a session, stored as message and message-part rows | +| Transcript entries | UI-friendly run output with messages, reasoning, tool calls, approvals, and questions | | Traces | Generation and tool observations captured during runs | +| Pipeline logs | Metadata-only audit events for Studio pipeline runs | +| Memory explorer data | User and conversation summaries derived from stored sessions | ## Sessions and Traces -When storage is available, Studio enables both `sessions` and `traces` capabilities. Runs with a `sessionId` load the stored messages as history and append the final messages after the run completes. +When the default in-memory store or an explicit store is available, Studio enables `sessions`, `memory`, and `traces` capabilities. Runs with a `sessionId` load the stored messages as history and append the final messages after the run completes. ```bash curl http://localhost:4021/config @@ -42,10 +52,11 @@ curl http://localhost:4021/config { "capabilities": { "sessions": { "enabled": true }, + "memory": { "enabled": true }, "traces": { "enabled": true } }, "unsupportedCapabilities": [] } ``` -Use [`Sessions`](/docs/studio/runs/sessions) and [`Traces`](/docs/studio/runs/traces) for the runtime workflow. +Use [`Sessions`](/docs/studio/runs/sessions), [`Memory`](/docs/studio/inspect-context/memory), and [`Traces`](/docs/studio/runs/traces) for the runtime workflow. diff --git a/apps/docs/content/docs/studio/http-api/endpoints.mdx b/apps/docs/content/docs/studio/http-api/endpoints.mdx index e529dafc..354de7f6 100644 --- a/apps/docs/content/docs/studio/http-api/endpoints.mdx +++ b/apps/docs/content/docs/studio/http-api/endpoints.mdx @@ -11,12 +11,31 @@ Studio exposes the same runtime used by the bundled UI as an HTTP API. | --- | --- | --- | | `/health` | `GET` | Check that the runtime is alive | | `/config` | `GET` | Read agents, quick prompts, and enabled capabilities | +| `/status` | `GET` | Read runtime status, storage adapters, counts, and capabilities | | `/agents` | `GET` | List registered agents | | `/agents/:agentId` | `GET` | Read one agent config | +| `/agents/:agentId/runtime` | `GET` | Read richer runtime metadata for one agent | +| `/agents/:agentId/tools` | `GET` | Inspect registered tool metadata for one agent | +| `/agents/:agentId/tools/:toolName/runs` | `POST` | Invoke one registered tool directly with JSON arguments | +| `/agents/:agentId/mcps` | `GET` | Inspect registered MCP server and tool metadata for one agent | | `/agents/:agentId/runs` | `POST` | Run an agent | +| `/observability/events` | `GET` | Stream session logs, pipeline logs, and completed trace summaries as NDJSON | +| `/evals` | `GET` | List registered eval suites | +| `/evals/:evalId` | `GET` | Read one eval suite summary | +| `/evals/:evalId/runs` | `POST` | Run a registered eval suite | +| `/pipelines` | `GET` | List registered pipelines | +| `/pipelines/:pipelineId` | `GET` | Read pipeline metadata and graph | +| `/pipelines/:pipelineId/runs` | `POST` | Run a pipeline with JSON input | +| `/pipelines/:pipelineId/runs/:runId/replay` | `POST` | Replay a saved pipeline run with its original JSON input | +| `/pipelines/:pipelineId/logs` | `GET` | Read metadata-only pipeline audit logs | | `/sessions` | `GET`, `POST` | List or create sessions | | `/sessions/:sessionId` | `GET`, `DELETE` | Read or delete a session | +| `/sessions/:sessionId/logs` | `GET` | Read metadata-only session audit logs | | `/sessions/:sessionId/traces` | `GET` | List traces for a session | +| `/memory/users` | `GET` | List user summaries derived from stored sessions | +| `/memory/conversations` | `GET` | List stored conversation summaries | +| `/memory/conversations/:conversationId/messages` | `GET` | Read stored runtime messages and transcript entries | +| `/memory/conversations/:conversationId/steps` | `GET` | Read transcript steps for a stored conversation | | `/traces` | `GET` | List traces | | `/traces/:traceId` | `GET` | Read one trace | | `/approvals` | `GET` | List pending or resolved approvals | @@ -38,4 +57,4 @@ Studio exposes the same runtime used by the bundled UI as an HTTP API. Error codes are `bad_request`, `conflict`, `not_found`, `unsupported_capability`, and `internal_error`. -For request examples, see [`Run Requests`](/docs/studio/runs/run-requests), [`Streaming Runs`](/docs/studio/runs/streaming-runs), [`Tool Approvals`](/docs/studio/human-in-the-loop/tool-approvals), and [`Human Questions`](/docs/studio/human-in-the-loop/human-questions). +For request examples, see [`Run Requests`](/docs/studio/runs/run-requests), [`Streaming Runs`](/docs/studio/runs/streaming-runs), [`Tool Approvals`](/docs/studio/human-in-the-loop/tool-approvals), [`Human Questions`](/docs/studio/human-in-the-loop/human-questions), and [`Memory`](/docs/studio/inspect-context/memory). diff --git a/apps/docs/content/docs/studio/human-in-the-loop/tool-approvals.mdx b/apps/docs/content/docs/studio/human-in-the-loop/tool-approvals.mdx index de80dfff..314628b8 100644 --- a/apps/docs/content/docs/studio/human-in-the-loop/tool-approvals.mdx +++ b/apps/docs/content/docs/studio/human-in-the-loop/tool-approvals.mdx @@ -3,7 +3,7 @@ title: Tool Approvals description: Use Studio to approve protected tool calls. --- -Studio can handle tool approvals from passive tool metadata. Core still runs tools normally; Studio installs a per-run request hook that interprets the metadata, creates an approval, and waits for a human decision. +Studio can handle tool approvals from passive tool metadata or `tool.requestApproval(...)` in runtime hooks. Core still runs tools normally; Studio installs a per-run request hook that creates approvals and waits for human decisions. ## 1. Add Approval Metadata @@ -84,11 +84,34 @@ curl -X POST http://localhost:4021/approvals/approval_123/decision \ -d '{"approved":false,"reason":"Amount needs finance review."}' ``` +## Hook-Based Approval + +Use a hook when the approval rule depends on request-local state or crosses multiple tools. + +```ts +import { createHook } from "@anvia/core"; + +const approvalHook = createHook({ + onToolCall({ toolName, tool }) { + if (toolName !== "refund_order") { + return tool.run(); + } + + return tool.requestApproval({ + reason: "Review this refund before it is issued.", + rejectMessage: "Refund rejected in Anvia Studio.", + }); + }, +}); +``` + +Attach the hook to the agent with `.hook(approvalHook)` or to one run with `.requestHook(approvalHook)`. Studio emits the same approval request and result events for hook-based approvals. + ## Approval Flow 1. The model requests a tool call. -2. Studio finds approval metadata on the tool. -3. `approval.when(...)` returns `true`. +2. Studio finds approval metadata on the tool or receives `tool.requestApproval(...)` from a hook. +3. The metadata policy or hook request says approval is required. 4. Studio creates a pending approval. 5. A human approves or rejects it in the UI or API. 6. Anvia either runs the tool or sends the rejection message back to the model. @@ -97,4 +120,4 @@ Use tool approval for side effects such as refunds, account changes, deletes, ex ## Advanced Control -Core hooks remain the escape hatch. If your app needs a custom approval service, conditional policy outside the tool, or a different human-feedback flow, use an `onToolCall` request hook and return `tool.run()`, `tool.skip(...)`, or `tool.cancel(...)`. +If your app owns a custom approval service outside Studio, use an `onToolCall` request hook and return `tool.run()` or `tool.skip(...)` after your service resolves. diff --git a/apps/docs/content/docs/studio/inspect-context/memory.mdx b/apps/docs/content/docs/studio/inspect-context/memory.mdx new file mode 100644 index 00000000..0fe3c450 --- /dev/null +++ b/apps/docs/content/docs/studio/inspect-context/memory.mdx @@ -0,0 +1,36 @@ +--- +title: Memory +description: Inspect stored Studio users, conversations, messages, and transcript steps. +--- + +The Memory page is backed by Studio session storage. It summarizes stored sessions by user id, lists conversations, and shows the raw messages and UI transcript steps for a selected conversation. + +Open `http://localhost:4021/ui/memory` after starting Studio. + +## What Studio Shows + +| Surface | What it shows | +| --- | --- | +| Users | User ids derived from session metadata, conversation counts, agent ids, and latest interaction time | +| Conversations | Session id, title, agent id, user id, timestamps, and message count | +| Messages | Runtime messages stored for session continuation | +| Steps | Transcript entries used by Studio for messages, reasoning, tool calls, approvals, and questions | + +If a session has `metadata.userId`, Studio uses that value. Otherwise the Memory page groups the session under `default`. + +## HTTP Routes + +```bash +curl http://localhost:4021/memory/users +curl http://localhost:4021/memory/conversations +curl http://localhost:4021/memory/conversations/session_123/messages +curl http://localhost:4021/memory/conversations/session_123/steps +``` + +Filter conversations by agent or user: + +```bash +curl 'http://localhost:4021/memory/conversations?agentId=support-operations&userId=dev_1' +``` + +The Memory page does not edit or delete stored data. Use the Sessions page or `DELETE /sessions/:sessionId` when you need to remove a conversation. By default this data is in memory only; set `ANVIA_STUDIO_DB` or pass an explicit store when you need persistence. diff --git a/apps/docs/content/docs/studio/inspect-context/meta.json b/apps/docs/content/docs/studio/inspect-context/meta.json index 320c9e6e..705bddbc 100644 --- a/apps/docs/content/docs/studio/inspect-context/meta.json +++ b/apps/docs/content/docs/studio/inspect-context/meta.json @@ -2,5 +2,5 @@ "title": "Inspect Context", "defaultOpen": false, "collapsible": true, - "pages": ["knowledge"] + "pages": ["knowledge", "memory"] } diff --git a/apps/docs/content/docs/studio/meta.json b/apps/docs/content/docs/studio/meta.json index 66cb2bc5..b64080af 100644 --- a/apps/docs/content/docs/studio/meta.json +++ b/apps/docs/content/docs/studio/meta.json @@ -10,6 +10,7 @@ "run-studio", "configure", "agents", + "pipelines", "runs", "human-in-the-loop", "inspect-context", diff --git a/apps/docs/content/docs/studio/overview.mdx b/apps/docs/content/docs/studio/overview.mdx index e2dc051e..840ca93a 100644 --- a/apps/docs/content/docs/studio/overview.mdx +++ b/apps/docs/content/docs/studio/overview.mdx @@ -19,7 +19,7 @@ new Studio([agent]).start(); | Primitive | What it does | | --- | --- | -| `new Studio([agent])` | Registers one or more built Anvia agents | +| `new Studio([agent, pipeline])` | Registers built Anvia agents and pipelines | | `.start(...)` | Starts the local HTTP runtime and bundled Studio UI | | `.fetch(request)` | Lets tests or another runtime call the same Studio API without opening a port | @@ -28,11 +28,15 @@ Studio also exposes development surfaces around the runtime: | Surface | What it helps inspect | | --- | --- | | Agents | Registered agents, names, descriptions, and quick prompts | +| Pipelines | Registered pipeline graphs, stage status, JSON test runs, and pipeline logs | | Runs | One-off prompts, streaming output, max turns, tool concurrency, and trace options | | Sessions | Persisted local conversation history and transcripts | +| Memory | Stored users, conversations, messages, and transcript steps from the session store | | Traces | Model generations, tool spans, usage, errors, and run metadata | +| Tools | Registered tool schemas and direct tool invocation with JSON arguments | | Human in the loop | Tool approvals and `ask_question` requests | | Knowledge | Static context, dynamic context, dynamic tools, and recent retrieval evidence | +| Status | Runtime counts, storage adapters, and enabled capabilities | ## What To Do First diff --git a/apps/docs/content/docs/studio/pipelines/inspect-pipelines.mdx b/apps/docs/content/docs/studio/pipelines/inspect-pipelines.mdx new file mode 100644 index 00000000..a8c9e53c --- /dev/null +++ b/apps/docs/content/docs/studio/pipelines/inspect-pipelines.mdx @@ -0,0 +1,69 @@ +--- +title: Inspect Pipelines +description: Register pipelines in Studio and inspect graph, runs, and logs. +--- + +Studio can inspect built pipelines beside agents. Register both as top-level targets: + +```ts +import { AgentBuilder } from "@anvia/core"; +import { PipelineBuilder } from "@anvia/core/pipeline"; +import { Studio } from "@anvia/studio"; + +const supportAgent = new AgentBuilder("support", model).build(); + +const ticketPipeline = new PipelineBuilder({ + id: "ticket-pipeline", + name: "Ticket Pipeline", +}) + .step((ticket) => ticket.trim(), { name: "Normalize" }) + .prompt(supportAgent, { name: "Draft Reply" }) + .build(); + +new Studio([supportAgent, ticketPipeline]).start({ port: 4021 }); +``` + +Studio reads pipeline graph metadata automatically from build order. Optional pipeline and stage metadata improves labels, descriptions, and inspector details, but every stage gets a generated id and label when metadata is omitted. + +## What Studio Shows + +| Surface | What it shows | +| --- | --- | +| Graph | Input, steps, nested pipelines, parallel branches, agents, extractors, and output | +| Node details | Kind, label, description, metadata, branch key, agent id, or nested pipeline id | +| Run controls | JSON-compatible input sent to `POST /pipelines/:pipelineId/runs` | +| Run history | Persisted pipeline executions with saved output and replay controls | +| Logs | Persisted metadata-only pipeline audit logs with live stream updates | + +Pipeline logs include ids, stage kinds, status, durations, counts, and byte lengths. They do not persist raw input, output, prompt text, model output, tool arguments, tool results, documents, images, or reasoning text. + +## HTTP Routes + +```bash +curl http://localhost:4021/pipelines +curl http://localhost:4021/pipelines/ticket-pipeline +curl 'http://localhost:4021/pipelines/ticket-pipeline/logs?limit=200' +``` + +Run a pipeline with JSON input: + +```bash +curl -X POST http://localhost:4021/pipelines/ticket-pipeline/runs \ + -H 'content-type: application/json' \ + -d '{ + "input": "Customer cannot check out", + "stream": true + }' +``` + +Replay a persisted run with the original saved input: + +```bash +curl -X POST http://localhost:4021/pipelines/ticket-pipeline/runs/RUN_ID/replay \ + -H 'content-type: application/json' \ + -d '{ + "stream": true + }' +``` + +Studio HTTP pipeline inputs must be JSON-compatible. Use direct `pipeline.run(...)` in application code when a pipeline takes non-JSON values. diff --git a/apps/docs/content/docs/studio/pipelines/meta.json b/apps/docs/content/docs/studio/pipelines/meta.json new file mode 100644 index 00000000..f7b5a4c6 --- /dev/null +++ b/apps/docs/content/docs/studio/pipelines/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Pipelines", + "defaultOpen": false, + "collapsible": true, + "pages": ["inspect-pipelines"] +} diff --git a/apps/docs/content/docs/studio/runs/sessions.mdx b/apps/docs/content/docs/studio/runs/sessions.mdx index 06b7f1c7..b8b5396c 100644 --- a/apps/docs/content/docs/studio/runs/sessions.mdx +++ b/apps/docs/content/docs/studio/runs/sessions.mdx @@ -53,4 +53,4 @@ curl http://localhost:4021/sessions/session_123 curl -X DELETE http://localhost:4021/sessions/session_123 ``` -Deleting a session also removes its stored traces from the default SQLite store. +Deleting a session also removes its stored traces from the active Studio store. diff --git a/apps/docs/content/docs/studio/runs/streaming-runs.mdx b/apps/docs/content/docs/studio/runs/streaming-runs.mdx index b510c9b5..90a0bf5f 100644 --- a/apps/docs/content/docs/studio/runs/streaming-runs.mdx +++ b/apps/docs/content/docs/studio/runs/streaming-runs.mdx @@ -38,7 +38,7 @@ Streaming is the best mode for approvals and questions because the run can pause ## Persisting Streaming Runs -When `sessionId` is present, Studio persists the transcript after the final event. +When `sessionId` is present, Studio persists transcript updates while the stream runs. ```bash curl -N -X POST http://localhost:4021/agents/support-operations/runs \ diff --git a/apps/docs/package.json b/apps/docs/package.json index caf9c027..77d680b6 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -4,12 +4,14 @@ "private": true, "type": "module", "scripts": { - "build": "vite build", + "build": "pnpm run changelog:generate && vite build", + "changelog:generate": "node scripts/generate-changelogs.mjs", "cf-typegen": "wrangler types", "deploy": "pnpm run build && wrangler deploy", - "dev": "vite dev", + "dev": "pnpm run changelog:generate && vite dev", "preview": "vite preview", - "typecheck": "fumadocs-mdx && tsr generate && tsc --noEmit" + "reference-check": "node scripts/check-reference-coverage.mjs", + "typecheck": "pnpm run changelog:generate && pnpm run reference-check && fumadocs-mdx && tsr generate && tsc --noEmit" }, "dependencies": { "@tanstack/react-router": "^1.168.26", @@ -18,6 +20,7 @@ "fumadocs-mdx": "^14.3.2", "fumadocs-ui": "^16.8.5", "lucide-react": "^1.14.0", + "mermaid": "^11.15.0", "react": "^19.2.5", "react-dom": "^19.2.5" }, diff --git a/apps/docs/public/assets/anvia-hero-isometric.png b/apps/docs/public/assets/anvia-hero-isometric.png index 368572cd..23613bf9 100644 Binary files a/apps/docs/public/assets/anvia-hero-isometric.png and b/apps/docs/public/assets/anvia-hero-isometric.png differ diff --git a/apps/docs/public/assets/anvia.png b/apps/docs/public/assets/anvia.png index e3b9c3d6..d5499e87 100644 Binary files a/apps/docs/public/assets/anvia.png and b/apps/docs/public/assets/anvia.png differ diff --git a/apps/docs/public/assets/favicon.png b/apps/docs/public/assets/favicon.png index c9561463..d5499e87 100644 Binary files a/apps/docs/public/assets/favicon.png and b/apps/docs/public/assets/favicon.png differ diff --git a/apps/docs/public/assets/jatevo-og.png b/apps/docs/public/assets/jatevo-og.png new file mode 100644 index 00000000..f70b8c20 Binary files /dev/null and b/apps/docs/public/assets/jatevo-og.png differ diff --git a/apps/docs/public/assets/logo.png b/apps/docs/public/assets/logo.png index 6e861136..d5499e87 100644 Binary files a/apps/docs/public/assets/logo.png and b/apps/docs/public/assets/logo.png differ diff --git a/apps/docs/scripts/check-reference-coverage.mjs b/apps/docs/scripts/check-reference-coverage.mjs new file mode 100644 index 00000000..903fbf92 --- /dev/null +++ b/apps/docs/scripts/check-reference-coverage.mjs @@ -0,0 +1,174 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(scriptDir, "../../.."); + +const packageDocs = new Map([ + ["@anvia/core", "apps/docs/content/docs/reference/core"], + ["@anvia/server", "apps/docs/content/docs/reference/server.mdx"], + ["@anvia/react", "apps/docs/content/docs/reference/react.mdx"], + ["@anvia/openai", "apps/docs/content/docs/reference/providers/openai.mdx"], + ["@anvia/gemini", "apps/docs/content/docs/reference/providers/gemini.mdx"], + ["@anvia/anthropic", "apps/docs/content/docs/reference/providers/anthropic.mdx"], + ["@anvia/mistral", "apps/docs/content/docs/reference/providers/mistral.mdx"], + ["@anvia/fastembed", "apps/docs/content/docs/reference/integrations/fastembed.mdx"], + ["@anvia/transformers", "apps/docs/content/docs/reference/integrations/transformers.mdx"], + ["@anvia/chroma", "apps/docs/content/docs/reference/integrations/chroma.mdx"], + ["@anvia/pgvector", "apps/docs/content/docs/reference/integrations/pgvector.mdx"], + ["@anvia/qdrant", "apps/docs/content/docs/reference/integrations/qdrant.mdx"], + ["@anvia/logger", "apps/docs/content/docs/reference/integrations/logger.mdx"], + ["@anvia/langfuse", "apps/docs/content/docs/reference/integrations/langfuse.mdx"], + ["@anvia/otel", "apps/docs/content/docs/reference/integrations/otel.mdx"], + ["@anvia/studio", "apps/docs/content/docs/reference/studio"], +]); + +function walk(dir) { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + return statSync(path).isDirectory() ? walk(path) : [path]; + }); +} + +function discoverPackages() { + const packageDirs = []; + for (const workspaceDir of ["packages"]) { + const root = join(repoRoot, workspaceDir); + for (const name of readdirSync(root)) { + const firstLevel = join(root, name); + if (!statSync(firstLevel).isDirectory()) continue; + + const firstLevelPackage = join(firstLevel, "package.json"); + if (existsSync(firstLevelPackage)) packageDirs.push(firstLevel); + + for (const childName of readdirSync(firstLevel)) { + const secondLevel = join(firstLevel, childName); + if (statSync(secondLevel).isDirectory() && existsSync(join(secondLevel, "package.json"))) { + packageDirs.push(secondLevel); + } + } + } + } + + return packageDirs + .map((dir) => ({ dir, pkg: JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) })) + .filter(({ pkg }) => typeof pkg.name === "string" && pkg.name.startsWith("@anvia/")) + .sort((a, b) => a.pkg.name.localeCompare(b.pkg.name)); +} + +function getExportsMap(pkg) { + if (pkg.exports === undefined) { + return [[".", { import: pkg.main, types: pkg.types }]]; + } + + return Object.entries(pkg.exports); +} + +function getImportTarget(target) { + if (typeof target === "string") return target; + if (target && typeof target === "object") return target.import ?? target.default ?? target.types; + return undefined; +} + +function sourcePathForPackageExport(packageDir, target) { + const importTarget = getImportTarget(target); + if (typeof importTarget !== "string") return undefined; + + return join(packageDir, importTarget.replace(/^\.\/dist\//, "src/").replace(/\.js$/, ".ts")); +} + +function getPublicExports(file) { + const program = ts.createProgram([file], { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + moduleResolution: ts.ModuleResolutionKind.Bundler, + skipLibCheck: true, + }); + const checker = program.getTypeChecker(); + const sourceFile = program.getSourceFile(file); + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + + if (!moduleSymbol) return []; + + return checker.getExportsOfModule(moduleSymbol).map((symbol) => symbol.getName()); +} + +function readDocsText(docsPath) { + const absolutePath = join(repoRoot, docsPath); + if (!existsSync(absolutePath)) { + throw new Error(`Reference docs path does not exist: ${docsPath}`); + } + + if (statSync(absolutePath).isDirectory()) { + return walk(absolutePath) + .filter((file) => file.endsWith(".mdx")) + .map((file) => readFileSync(file, "utf8")) + .join("\n"); + } + + return readFileSync(absolutePath, "utf8"); +} + +let totalMissingEntrypoints = 0; +let totalMissingSymbols = 0; +let totalEntrypoints = 0; +let totalSymbols = 0; +const lines = []; + +for (const { dir, pkg } of discoverPackages()) { + const docsPath = packageDocs.get(pkg.name); + if (!docsPath) { + throw new Error(`No reference docs mapping configured for ${pkg.name}`); + } + + const docsText = readDocsText(docsPath); + const exportEntries = getExportsMap(pkg); + const publicExports = new Set(); + const entrypoints = []; + + for (const [subpath, target] of exportEntries) { + const importPath = subpath === "." ? pkg.name : `${pkg.name}${subpath.slice(1)}`; + entrypoints.push(importPath); + + const sourcePath = sourcePathForPackageExport(dir, target); + if (sourcePath && existsSync(sourcePath)) { + for (const name of getPublicExports(sourcePath)) { + publicExports.add(name); + } + } + } + + const missingEntrypoints = entrypoints.filter((name) => !docsText.includes(name)); + const missingSymbols = [...publicExports].sort().filter((name) => !docsText.includes(name)); + + totalEntrypoints += entrypoints.length; + totalSymbols += publicExports.size; + totalMissingEntrypoints += missingEntrypoints.length; + totalMissingSymbols += missingSymbols.length; + + lines.push( + `${pkg.name}: ${entrypoints.length} entrypoints, ${publicExports.size} exports, ${missingEntrypoints.length} undocumented entrypoints, ${missingSymbols.length} undocumented exports`, + ); + + if (missingEntrypoints.length > 0) { + lines.push(` missing entrypoints: ${missingEntrypoints.join(", ")}`); + } + + if (missingSymbols.length > 0) { + lines.push(` missing exports: ${missingSymbols.join(", ")}`); + } +} + +for (const line of lines) console.log(line); +console.log( + `TOTAL_ENTRYPOINTS=${totalEntrypoints} TOTAL_EXPORTS=${totalSymbols} TOTAL_MISSING_ENTRYPOINTS=${totalMissingEntrypoints} TOTAL_MISSING_EXPORTS=${totalMissingSymbols}`, +); + +if (totalMissingEntrypoints > 0 || totalMissingSymbols > 0) { + console.error( + `Reference coverage failed from ${relative(process.cwd(), fileURLToPath(import.meta.url))}. Document missing public entrypoints or exports before merging.`, + ); + process.exit(1); +} diff --git a/apps/docs/scripts/generate-changelogs.mjs b/apps/docs/scripts/generate-changelogs.mjs new file mode 100644 index 00000000..dce3e21e --- /dev/null +++ b/apps/docs/scripts/generate-changelogs.mjs @@ -0,0 +1,221 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(scriptDir, "../../.."); +const packagesRoot = join(repoRoot, "packages"); +const outputDir = join(repoRoot, "apps/docs/content/docs/changelog"); + +const groupOrder = [ + "core", + "providers", + "embeddings", + "vector-stores", + "logger", + "observability", + "tools", +]; +const groupTitles = new Map([ + ["core", "Core"], + ["providers", "Providers"], + ["embeddings", "Embeddings"], + ["vector-stores", "Vector Stores"], + ["logger", "Logger"], + ["observability", "Observability"], + ["tools", "Tools"], +]); + +const packages = discoverPackages() + .filter(({ pkg }) => pkg.private !== true) + .filter(({ pkg }) => typeof pkg.name === "string" && typeof pkg.version === "string") + .sort((a, b) => { + const groupDiff = groupRank(a.group) - groupRank(b.group); + return groupDiff === 0 ? a.pkg.name.localeCompare(b.pkg.name) : groupDiff; + }); + +rmSync(outputDir, { recursive: true, force: true }); +mkdirSync(outputDir, { recursive: true }); + +writeFileSync(join(outputDir, "meta.json"), `${JSON.stringify(createMeta(packages), null, 2)}\n`); +writeFileSync(join(outputDir, "index.mdx"), createIndex(packages)); + +for (const item of packages) { + writeFileSync(join(outputDir, `${item.slug}.mdx`), createPackagePage(item)); +} + +console.info( + `Generated ${packages.length + 2} changelog docs files in ${relative(repoRoot, outputDir)}`, +); + +function discoverPackages() { + return findPackageDirs(packagesRoot).map((dir) => { + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); + const pathParts = relative(packagesRoot, dir).split("/"); + + return { + dir, + pkg, + group: pathParts[0], + slug: packageSlug(pkg.name), + changelogPath: join(dir, "CHANGELOG.md"), + }; + }); +} + +function findPackageDirs(dir) { + const entries = readdirSync(dir).sort(); + const dirs = []; + + for (const entry of entries) { + const entryPath = join(dir, entry); + if (!statSync(entryPath).isDirectory()) continue; + + if (existsSync(join(entryPath, "package.json"))) { + dirs.push(entryPath); + continue; + } + + dirs.push(...findPackageDirs(entryPath)); + } + + return dirs; +} + +function packageSlug(name) { + return name.replace(/^@anvia\//, "").replaceAll("/", "-"); +} + +function createMeta(items) { + const pages = ["index"]; + + for (const group of orderedGroups(items)) { + const groupItems = items.filter((item) => item.group === group); + if (groupItems.length === 0) continue; + + pages.push(`---${groupTitles.get(group) ?? titleCase(group)}---`); + pages.push(...groupItems.map((item) => item.slug)); + } + + return { + title: "Changelog", + description: "Package release notes", + icon: "History", + root: true, + defaultOpen: false, + collapsible: true, + pages, + }; +} + +function createIndex(items) { + const sections = []; + + for (const group of orderedGroups(items)) { + const groupItems = items.filter((item) => item.group === group); + if (groupItems.length === 0) continue; + + sections.push(`## ${groupTitles.get(group) ?? titleCase(group)} + +| Package | Current version | Release notes | +| --- | --- | --- | +${groupItems + .map( + (item) => + `| \`${item.pkg.name}\` | \`${item.pkg.version}\` | [View changelog](/docs/changelog/${item.slug}) |`, + ) + .join("\n")}`); + } + + return `--- +title: Package Changelog +description: Release history for Anvia packages. +--- + +Anvia package release notes are generated from the package changelog files maintained by Changesets. + +Developers should keep writing release notes with \`pnpm changeset\`. The docs pages in this section are generated from \`packages/**/CHANGELOG.md\`. + +
+ +${sections.join("\n\n")} + +
+`; +} + +function createPackagePage(item) { + const relativeChangelogPath = relative(repoRoot, item.changelogPath); + const sourceUrl = `https://github.com/anvia-hq/anvia/blob/main/${relativeChangelogPath}`; + const body = existsSync(item.changelogPath) + ? normalizeChangelog(readFileSync(item.changelogPath, "utf8"), item.pkg.name) + : missingChangelog(item); + + return `--- +title: ${frontmatterString(item.pkg.name)} +description: ${frontmatterString(`Release notes for ${item.pkg.name}.`)} +--- + +# \`${item.pkg.name}\` + +${item.pkg.description} + +Source: [\`${relativeChangelogPath}\`](${sourceUrl}) + +${body} +`; +} + +function normalizeChangelog(content, packageName) { + const trimmed = content.trim(); + if (trimmed.length === 0) { + return "No release notes have been recorded yet.\n"; + } + + return `${trimmed.replace(new RegExp(`^# ${escapeRegExp(packageName)}\\s*\\n+`), "")}\n`; +} + +function missingChangelog(item) { + return `## No release notes yet + +\`${item.pkg.name}\` is public, but \`${relative(repoRoot, item.changelogPath)}\` does not exist yet. + +Release notes will appear here after the next Changesets version bump creates the package changelog. +`; +} + +function titleCase(value) { + return value + .split("-") + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function orderedGroups(items) { + const discovered = [...new Set(items.map((item) => item.group))]; + return discovered.sort((a, b) => { + const rankDiff = groupRank(a) - groupRank(b); + return rankDiff === 0 ? a.localeCompare(b) : rankDiff; + }); +} + +function groupRank(group) { + const index = groupOrder.indexOf(group); + return index === -1 ? groupOrder.length : index; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function frontmatterString(value) { + return JSON.stringify(value); +} diff --git a/apps/docs/source.config.ts b/apps/docs/source.config.ts index e6869bd0..e7164c66 100644 --- a/apps/docs/source.config.ts +++ b/apps/docs/source.config.ts @@ -106,4 +106,9 @@ export default defineConfig({ export const docs = defineDocs({ dir: "content/docs", + docs: { + postprocess: { + includeProcessedMarkdown: true, + }, + }, }); diff --git a/apps/docs/src/components/docs-route.tsx b/apps/docs/src/components/docs-route.tsx index 6d8bd3c2..76431d1b 100644 --- a/apps/docs/src/components/docs-route.tsx +++ b/apps/docs/src/components/docs-route.tsx @@ -1,4 +1,4 @@ -import { Link, notFound, useLocation } from "@tanstack/react-router"; +import { Link, notFound, redirect, useLocation } from "@tanstack/react-router"; import { createServerFn } from "@tanstack/react-start"; import browserCollections from "collections/browser"; import { useFumadocsLoader } from "fumadocs-core/source/client"; @@ -12,14 +12,51 @@ import { source } from "@/lib/source"; const docsSections = [ { title: "Docs", href: "/docs/guides" }, + { title: "Best Practices", href: "/docs/best-practices" }, + { title: "Frameworks", href: "/docs/frameworks" }, { title: "Models", href: "/docs/models" }, { title: "Studio", href: "/docs/studio/overview" }, { title: "Reference", href: "/docs/reference" }, ]; +const bestPracticeRedirects: Record = { + "agent-structure": "/docs/best-practices/common-patterns/agent-structure", + "backoffice-agent": "/docs/best-practices/real-cases/backoffice-agent", + "coding-agent": "/docs/best-practices/real-cases/coding-agent", + "context-and-memory": "/docs/best-practices/common-patterns/context-and-memory", + "dynamic-tool-catalogs": "/docs/best-practices/tool-patterns/dynamic-tool-catalogs", + "eval-strategy": "/docs/best-practices/quality-observability/eval-strategy", + "harness-blueprint": "/docs/best-practices/common-patterns/harness-blueprint", + "mcp-agent-harness": "/docs/best-practices/mcp-patterns/mcp-agent-harness", + "mcp-server-lifecycle": "/docs/best-practices/mcp-patterns/mcp-server-lifecycle", + "mcp-tool-inspection": "/docs/best-practices/mcp-patterns/mcp-tool-inspection", + pipeline: "/docs/best-practices/common-patterns/pipeline", + "production-guardrails": "/docs/best-practices/common-patterns/production-guardrails", + "production-readiness-checklist": + "/docs/best-practices/operations/production-readiness-checklist", + "rag-agent-context": "/docs/best-practices/knowledge-patterns/rag-agent-context", + "rag-ingestion": "/docs/best-practices/knowledge-patterns/rag-ingestion", + "request-runners": "/docs/best-practices/common-patterns/request-runners", + "research-agent": "/docs/best-practices/real-cases/research-agent", + "side-effect-tools": "/docs/best-practices/tool-patterns/side-effect-tools", + "support-agent": "/docs/best-practices/real-cases/support-agent", + "testing-and-observability": "/docs/best-practices/common-patterns/testing-and-observability", + "tool-validation-and-contracts": + "/docs/best-practices/tool-patterns/tool-validation-and-contracts", + "tools-and-services": "/docs/best-practices/common-patterns/tools-and-services", + "tracing-and-debugging": "/docs/best-practices/quality-observability/tracing-and-debugging", +}; + export const docsServerLoader = createServerFn({ method: "GET" }) .inputValidator((slugs: string[]) => slugs) .handler(async ({ data: slugs }) => { + const bestPracticeRedirect = + slugs[0] === "best-practices" ? bestPracticeRedirects[slugs[1] ?? ""] : undefined; + + if (bestPracticeRedirect && slugs.length === 2) { + throw redirect({ to: bestPracticeRedirect }); + } + const page = source.getPage(slugs); if (!page) { diff --git a/apps/docs/src/components/mdx.tsx b/apps/docs/src/components/mdx.tsx index 5b8e699f..7480e21b 100644 --- a/apps/docs/src/components/mdx.tsx +++ b/apps/docs/src/components/mdx.tsx @@ -1,9 +1,11 @@ import defaultMdxComponents from "fumadocs-ui/mdx"; import type { MDXComponents } from "mdx/types"; +import { Mermaid } from "./mermaid"; export function getMDXComponents(components?: MDXComponents) { return { ...defaultMdxComponents, + Mermaid, ...components, } as unknown as MDXComponents; } diff --git a/apps/docs/src/components/mermaid.tsx b/apps/docs/src/components/mermaid.tsx new file mode 100644 index 00000000..5ec7ea73 --- /dev/null +++ b/apps/docs/src/components/mermaid.tsx @@ -0,0 +1,76 @@ +import { useEffect, useId, useState } from "react"; + +type MermaidProps = { + chart: string; +}; + +type MermaidStatus = + | { state: "loading" } + | { state: "ready"; svg: string } + | { state: "error"; message: string }; + +export function Mermaid({ chart }: MermaidProps) { + const id = useId(); + const [status, setStatus] = useState({ state: "loading" }); + + useEffect(() => { + let cancelled = false; + const renderId = `mermaid-${id.replace(/[^a-zA-Z0-9_-]/g, "")}`; + + async function renderDiagram() { + try { + const mermaid = (await import("mermaid")).default; + + mermaid.initialize({ + startOnLoad: false, + theme: "default", + securityLevel: "strict", + }); + + const { svg } = await mermaid.render(renderId, chart); + + if (!cancelled) { + setStatus({ state: "ready", svg }); + } + } catch (error) { + if (!cancelled) { + setStatus({ + state: "error", + message: error instanceof Error ? error.message : "Unable to render diagram.", + }); + } + } + } + + setStatus({ state: "loading" }); + void renderDiagram(); + + return () => { + cancelled = true; + }; + }, [chart, id]); + + if (status.state === "ready") { + return ( +
+ ); + } + + if (status.state === "error") { + return ( +
+        {`${status.message}\n\n${chart}`}
+      
+ ); + } + + return ( +
+ Loading diagram... +
+ ); +} diff --git a/apps/docs/src/lib/get-llm-text.ts b/apps/docs/src/lib/get-llm-text.ts new file mode 100644 index 00000000..e491e8df --- /dev/null +++ b/apps/docs/src/lib/get-llm-text.ts @@ -0,0 +1,9 @@ +import type { source } from "@/lib/source"; + +export async function getLLMText(page: (typeof source)["$inferPage"]) { + const processed = await page.data.getText("processed"); + + return `# ${page.data.title} (${page.url}) + +${processed}`; +} diff --git a/apps/docs/src/lib/layout.shared.tsx b/apps/docs/src/lib/layout.shared.tsx index f511dad6..d0a931fa 100644 --- a/apps/docs/src/lib/layout.shared.tsx +++ b/apps/docs/src/lib/layout.shared.tsx @@ -3,12 +3,19 @@ import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; import { ChevronDown } from "lucide-react"; const githubUrl = "https://github.com/anvia-hq/anvia"; +const discordUrl = "https://discord.gg/6yegrFJBgp"; const resourceLinks = [ - { text: "Docs", url: "/docs/guides" }, + { text: "Best Practices", url: "/docs/best-practices" }, + { text: "Frameworks", url: "/docs/frameworks" }, + { text: "Changelog", url: "/docs/changelog" }, { text: "Studio", url: "/docs/studio/overview" }, { text: "Reference", url: "/docs/reference" }, { text: "Models", url: "/docs/models" }, ]; +const agentLinks = [ + { text: "llms.txt", url: "/llms.txt", serverOnly: true }, + { text: "llms-full.txt", url: "/llms-full.txt", serverOnly: true }, +]; function GitHubIcon() { return ( @@ -18,26 +25,58 @@ function GitHubIcon() { ); } +function DiscordIcon() { + return ( + + ); +} + function ResourcesDropdown() { + return ; +} + +function AgentsDropdown() { + return ; +} + +function NavDropdown({ + label, + links, +}: { + label: string; + links: Array<{ text: string; url: string; serverOnly?: boolean }>; +}) { return (
  • - {resourceLinks.map((item) => ( - - {item.text} - - ))} + {links.map((item) => + item.serverOnly ? ( + + {item.text} + + ) : ( + + {item.text} + + ), + )}
  • ); @@ -47,7 +86,7 @@ export const baseOptions: BaseLayoutProps = { nav: { title: ( <> - + Anvia ), @@ -65,8 +104,13 @@ export const baseOptions: BaseLayoutProps = { on: "nav", }, { - text: "Blog", - url: "/blog", + type: "custom", + children: , + on: "nav", + }, + { + text: "Sponsors", + url: "/sponsors", active: "nested-url", on: "nav", }, @@ -79,6 +123,15 @@ export const baseOptions: BaseLayoutProps = { icon: , on: "nav", }, + { + type: "icon", + text: "Discord", + label: "Join Discord", + url: discordUrl, + external: true, + icon: , + on: "nav", + }, ], themeSwitch: { enabled: false, diff --git a/apps/docs/src/routeTree.gen.ts b/apps/docs/src/routeTree.gen.ts index 5a6b6ec0..26a371fa 100644 --- a/apps/docs/src/routeTree.gen.ts +++ b/apps/docs/src/routeTree.gen.ts @@ -9,12 +9,30 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SponsorsRouteImport } from './routes/sponsors' +import { Route as LlmsDottxtRouteImport } from './routes/llms[.]txt' +import { Route as LlmsFullDottxtRouteImport } from './routes/llms-full[.]txt' import { Route as BlogRouteImport } from './routes/blog' import { Route as IndexRouteImport } from './routes/index' import { Route as DocsIndexRouteImport } from './routes/docs/index' import { Route as DocsSplatRouteImport } from './routes/docs/$' import { Route as ApiSearchRouteImport } from './routes/api/search' +const SponsorsRoute = SponsorsRouteImport.update({ + id: '/sponsors', + path: '/sponsors', + getParentRoute: () => rootRouteImport, +} as any) +const LlmsDottxtRoute = LlmsDottxtRouteImport.update({ + id: '/llms.txt', + path: '/llms.txt', + getParentRoute: () => rootRouteImport, +} as any) +const LlmsFullDottxtRoute = LlmsFullDottxtRouteImport.update({ + id: '/llms-full.txt', + path: '/llms-full.txt', + getParentRoute: () => rootRouteImport, +} as any) const BlogRoute = BlogRouteImport.update({ id: '/blog', path: '/blog', @@ -44,6 +62,9 @@ const ApiSearchRoute = ApiSearchRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/blog': typeof BlogRoute + '/llms-full.txt': typeof LlmsFullDottxtRoute + '/llms.txt': typeof LlmsDottxtRoute + '/sponsors': typeof SponsorsRoute '/api/search': typeof ApiSearchRoute '/docs/$': typeof DocsSplatRoute '/docs/': typeof DocsIndexRoute @@ -51,6 +72,9 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/blog': typeof BlogRoute + '/llms-full.txt': typeof LlmsFullDottxtRoute + '/llms.txt': typeof LlmsDottxtRoute + '/sponsors': typeof SponsorsRoute '/api/search': typeof ApiSearchRoute '/docs/$': typeof DocsSplatRoute '/docs': typeof DocsIndexRoute @@ -59,21 +83,52 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/blog': typeof BlogRoute + '/llms-full.txt': typeof LlmsFullDottxtRoute + '/llms.txt': typeof LlmsDottxtRoute + '/sponsors': typeof SponsorsRoute '/api/search': typeof ApiSearchRoute '/docs/$': typeof DocsSplatRoute '/docs/': typeof DocsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/blog' | '/api/search' | '/docs/$' | '/docs/' + fullPaths: + | '/' + | '/blog' + | '/llms-full.txt' + | '/llms.txt' + | '/sponsors' + | '/api/search' + | '/docs/$' + | '/docs/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/blog' | '/api/search' | '/docs/$' | '/docs' - id: '__root__' | '/' | '/blog' | '/api/search' | '/docs/$' | '/docs/' + to: + | '/' + | '/blog' + | '/llms-full.txt' + | '/llms.txt' + | '/sponsors' + | '/api/search' + | '/docs/$' + | '/docs' + id: + | '__root__' + | '/' + | '/blog' + | '/llms-full.txt' + | '/llms.txt' + | '/sponsors' + | '/api/search' + | '/docs/$' + | '/docs/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute BlogRoute: typeof BlogRoute + LlmsFullDottxtRoute: typeof LlmsFullDottxtRoute + LlmsDottxtRoute: typeof LlmsDottxtRoute + SponsorsRoute: typeof SponsorsRoute ApiSearchRoute: typeof ApiSearchRoute DocsSplatRoute: typeof DocsSplatRoute DocsIndexRoute: typeof DocsIndexRoute @@ -81,6 +136,27 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/sponsors': { + id: '/sponsors' + path: '/sponsors' + fullPath: '/sponsors' + preLoaderRoute: typeof SponsorsRouteImport + parentRoute: typeof rootRouteImport + } + '/llms.txt': { + id: '/llms.txt' + path: '/llms.txt' + fullPath: '/llms.txt' + preLoaderRoute: typeof LlmsDottxtRouteImport + parentRoute: typeof rootRouteImport + } + '/llms-full.txt': { + id: '/llms-full.txt' + path: '/llms-full.txt' + fullPath: '/llms-full.txt' + preLoaderRoute: typeof LlmsFullDottxtRouteImport + parentRoute: typeof rootRouteImport + } '/blog': { id: '/blog' path: '/blog' @@ -122,6 +198,9 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BlogRoute: BlogRoute, + LlmsFullDottxtRoute: LlmsFullDottxtRoute, + LlmsDottxtRoute: LlmsDottxtRoute, + SponsorsRoute: SponsorsRoute, ApiSearchRoute: ApiSearchRoute, DocsSplatRoute: DocsSplatRoute, DocsIndexRoute: DocsIndexRoute, @@ -129,12 +208,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } -} diff --git a/apps/docs/src/routes/blog.tsx b/apps/docs/src/routes/blog.tsx index 0b7c5c4f..d811ebcc 100644 --- a/apps/docs/src/routes/blog.tsx +++ b/apps/docs/src/routes/blog.tsx @@ -3,10 +3,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/blog")({ beforeLoad: () => { throw redirect({ - to: "/docs/$", - params: { - _splat: "guides", - }, + to: "/sponsors", }); }, }); diff --git a/apps/docs/src/routes/index.tsx b/apps/docs/src/routes/index.tsx index bff2c588..5cfe19c2 100644 --- a/apps/docs/src/routes/index.tsx +++ b/apps/docs/src/routes/index.tsx @@ -23,21 +23,21 @@ export const Route = createFileRoute("/")({ head: () => ({ meta: [ { - title: "Anvia - TypeScript agent runtime for production AI systems", + title: "Anvia - TypeScript runtime for application-owned AI workflows", }, { name: "description", content: - "Anvia is a modern TypeScript agent runtime for typed tools, structured context, provider adapters, retrieval, workflows, and observable execution.", + "Anvia helps TypeScript teams build provider-agnostic agents, typed tools, retrieval, pipelines, streaming, and observability inside application code.", }, ], }), }); const metrics = [ - { value: "13", label: "Runtime packages", icon: Package }, + { value: "16", label: "Runtime packages", icon: Package }, { value: "4", label: "Model adapters", icon: Boxes }, - { value: "7", label: "Integration packages", icon: BookOpen }, + { value: "8", label: "Integration packages", icon: BookOpen }, { value: "1", label: "Core package", icon: Zap }, ]; @@ -102,8 +102,8 @@ const docsEntrypoints = [ const packageGroups = [ { title: "Runtime", - description: "Core agent primitives plus the local inspection surface.", - packages: ["@anvia/core", "@anvia/studio"], + description: "Core agent primitives, application transports, hooks, and inspection.", + packages: ["@anvia/core", "@anvia/server", "@anvia/react", "@anvia/studio"], }, { title: "Model adapters", @@ -124,7 +124,7 @@ const packageGroups = [ { title: "Observability", description: "Trace and score Anvia runs through existing telemetry systems.", - packages: ["@anvia/langfuse", "@anvia/otel"], + packages: ["@anvia/logger", "@anvia/langfuse", "@anvia/otel"], }, ]; @@ -148,8 +148,15 @@ function Page() {
    -
    -
    +
    + Abstract Anvia runtime graphic with muted lime workflow paths +
    + +

    - Build production agents with explicit runtime contracts. + Build provider-agnostic agents inside your application code.

    - Typed tools, structured context, provider-neutral models, retrieval, workflows, and - observable execution for TypeScript teams. + Use typed tools, structured output, retrieval, pipelines, streaming, and Studio + inspection without giving up control of data, permissions, storage, or side effects.

    - -
    -
    - Minimal isometric runtime wireframe connected by neon-lime workflow paths -
    +
    @@ -304,7 +304,7 @@ function Page() {
    {group.packages.map((name) => ( diff --git a/apps/docs/src/routes/llms-full[.]txt.ts b/apps/docs/src/routes/llms-full[.]txt.ts new file mode 100644 index 00000000..f300ba39 --- /dev/null +++ b/apps/docs/src/routes/llms-full[.]txt.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { getLLMText } from "@/lib/get-llm-text"; +import { source } from "@/lib/source"; + +export const Route = createFileRoute("/llms-full.txt")({ + server: { + handlers: { + GET: async () => { + const scanned = await Promise.all(source.getPages().map(getLLMText)); + + return new Response(scanned.join("\n\n"), { + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + }); + }, + }, + }, +}); diff --git a/apps/docs/src/routes/llms[.]txt.ts b/apps/docs/src/routes/llms[.]txt.ts new file mode 100644 index 00000000..7777ada9 --- /dev/null +++ b/apps/docs/src/routes/llms[.]txt.ts @@ -0,0 +1,561 @@ +import { createFileRoute } from "@tanstack/react-router"; + +const llmsTxt = `# Anvia + +> TypeScript runtime for building provider-agnostic agents and application-owned AI workflows. + +Anvia helps teams build agents, typed tools, structured output, retrieval, pipelines, streaming, observability, MCP integrations, local skills, and Studio inspection without giving up ownership of app data, permissions, side effects, storage, or deployment. + +Use this file as the compact agent-facing map. Use [Full AI Context](/llms-full.txt) when you need the complete documentation body. + +## Core Mental Model + +Most Anvia workflows use the same ownership split: + +- Provider clients own credentials, base URLs, and provider SDK wiring. +- Models expose reusable completion or embedding capability. +- Agents own stable runtime identity, instructions, tools, defaults, hooks, and observers. +- Prompt requests own user input, history, sessions, traces, limits, and per-run tool behavior. +- Tools own application behavior, permissions, side effects, and expected product states. +- Pipelines own explicit multi-step composition when one prompt is not enough. + +Primary docs: + +- [Introduction](/docs/guides): SDK overview and core primitives. +- [Best Practices](/docs/best-practices): Pattern library for production harnesses, coding agents, RAG, evals, tracing, dynamic tool catalogs, MCP servers, validation, side-effect tools, real cases, guardrails, and operations. +- [How Anvia Works](/docs/guides/sdk-fundamentals/runtime-boundaries): Responsibility boundaries. +- [Provider Clients and Models](/docs/guides/sdk-fundamentals/clients-and-models): Configure provider access and reusable model capabilities. +- [Prompt Requests](/docs/guides/sdk-fundamentals/prompt-requests): How prompts become normalized model requests. +- [Prompt Responses](/docs/guides/sdk-fundamentals/prompt-responses): Output, usage, trace info, and new messages. + +## Learning Path: Build an Agent + +Use this when you want a promptable runtime with clear instructions and a stable runtime id. + +Goal: + +- Create a provider client. +- Create a reusable completion model. +- Build an agent with instructions. +- Send one prompt and receive a final response. + +Path: + +1. [Getting Started](/docs/guides/getting-started): Install Anvia and run a complete first agent. +2. [How Anvia Works](/docs/guides/sdk-fundamentals/runtime-boundaries): Learn which object owns which responsibility. +3. [Provider Clients and Models](/docs/guides/sdk-fundamentals/clients-and-models): Choose provider client and model. +4. [Creating Agents](/docs/guides/agents/creating-agents): Configure identity, instructions, and runtime behavior. +5. [Prompt Requests](/docs/guides/sdk-fundamentals/prompt-requests): Understand \`agent.prompt(...).send()\`. + +Minimal agent flow: + +1. Install the runtime and a provider adapter. +2. Create a provider client. +3. Create a reusable completion model. +4. Build an agent with stable instructions. +5. Run \`agent.prompt(...).send()\` from application code. + +\`\`\`ts +import { AgentBuilder } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; + +const client = new OpenAIClient({ apiKey }); +const model = client.completionModel("gpt-5.5"); + +const agent = new AgentBuilder("support", model) + .instructions("Answer support questions clearly.") + .build(); + +const response = await agent.prompt("How do I reset my password?").send(); + +console.log(response.output); +\`\`\` + +Add next: + +- [Persist Conversations](/docs/guides/learning-paths/persist-conversations): Store and replay message history. +- [Add Tools](/docs/guides/learning-paths/add-tools): Let agents call typed application behavior. +- [Return Structured Output](/docs/guides/learning-paths/return-structured-output): Return schema-shaped data. +- [Streaming Events](/docs/guides/streaming/streaming-events): Stream incremental events. + +## Learning Path: Add Tools + +Use this when the model needs to inspect data, call services, or perform actions owned by your application. + +Goal: + +- Define a Zod-backed tool. +- Register it on an agent. +- Keep turn limits low. +- Keep permission checks inside tool code. + +Path: + +1. [Creating Tools](/docs/guides/tools/creating-tools): Define tools with input validation. +2. [Tool Schemas](/docs/guides/tools/tool-schemas): Understand how schemas become provider tool definitions. +3. [Agent Tools](/docs/guides/agents/agent-tools): Register tools on agents. +4. [Tool Results](/docs/guides/tools/tool-results): Understand what is sent back to the model. +5. [Tool Errors](/docs/guides/tools/tool-errors): Decide when to return expected states and when to throw. + +Minimal shape: + +\`\`\`ts +import { AgentBuilder, createTool } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; +import { z } from "zod"; + +const lookupOrder = createTool({ + name: "lookup_order", + description: "Look up an order by order id.", + input: z.object({ + orderId: z.string(), + }), + async execute({ orderId }) { + return { orderId, status: "shipped" }; + }, +}); + +const model = new OpenAIClient({ apiKey }).completionModel("gpt-5.5"); + +const agent = new AgentBuilder("support", model) + .instructions("Use tools when order status is needed.") + .tool(lookupOrder) + .defaultMaxTurns(3) + .build(); +\`\`\` + +Production notes: + +- Enforce auth, tenant checks, and permissions inside tool code. +- Return explicit expected states such as \`not_found\` or \`blocked\`. +- Throw only when the workflow should fail or be retried. +- Use [Human in the Loop](/docs/guides/human-in-the-loop) for guarded actions. +- Use [Tool Sets](/docs/guides/tools/tool-sets) when many tools need shared filtering or metadata. +- Use [Server Tools](/docs/guides/mcp/server-tools) when tools come from MCP servers. + +## Learning Path: Persist Conversations + +Use this when an agent needs previous turns. + +Goal: + +- Understand the \`Message[]\` history shape. +- Pass explicit transcripts into prompts. +- Use durable session memory when appropriate. +- Append \`response.messages\` after each run. + +Path: + +1. [Messages and History](/docs/guides/sdk-fundamentals/messages-and-history): Raw message shape. +2. [Prompt Responses](/docs/guides/sdk-fundamentals/prompt-responses): \`response.messages\`, usage, and trace fields. +3. [Memory and Sessions](/docs/guides/sdk-fundamentals/memory-and-sessions): Core-managed durable conversations. +4. [Memory](/docs/guides/memory): Raw SQL, Prisma, and Drizzle storage adapters. +5. [Agent History](/docs/guides/agents/agent-history): Agent-specific history examples. + +Minimal shape: + +\`\`\`ts +import { Message } from "@anvia/core"; + +const history = await conversations.loadMessages(conversationId); +const currentPrompt = Message.user(userInput); + +const response = await agent.prompt([...history, currentPrompt]).send(); + +await conversations.saveMessages(conversationId, [ + ...history, + ...response.messages, +]); +\`\`\` + +Core-managed session shape: + +\`\`\`ts +const response = await agent.session(conversationId).prompt(userInput).send(); +\`\`\` + +Key rule: \`response.messages\` is only the new part of the run. Append it to the history you loaded if you want a full transcript. + +## Learning Path: Return Structured Output + +Use this when application code needs JSON-shaped data it can validate before use. + +Goal: + +- Define schemas with Zod. +- Use agent output schemas for typed final responses. +- Use extractors for schema-first extraction from existing text. +- Handle validation and retry failures deliberately. + +Path: + +1. [Schemas](/docs/guides/structured-output/schemas): Define target shapes. +2. [Zod Schema](/docs/guides/structured-output/zod-schema): Schema conversion behavior. +3. [Agent Output](/docs/guides/structured-output/agent-output): Structured final agent responses. +4. [Extractors](/docs/guides/structured-output/extractors): Convert existing text into typed data. +5. [Output Validation](/docs/guides/structured-output/output-validation): Validate before product use. +6. [Failure Handling](/docs/guides/structured-output/failure-handling): Retry, report, or fail cleanly. + +Choosing the primitive: + +- Use agent output schema when the agent should produce typed final output. +- Use an extractor when existing text should become typed data. +- Use an extractor step when a pipeline should normalize data. +- Use tool output validation when tool results need a contract. + +Agent output shape: + +\`\`\`ts +import { AgentBuilder } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; +import { z } from "zod"; + +const model = new OpenAIClient({ apiKey }).completionModel("gpt-5.5"); + +const agent = new AgentBuilder("classifier", model) + .instructions("Classify support messages.") + .outputSchema( + z.object({ + category: z.enum(["billing", "technical", "account"]), + confidence: z.number(), + }), + ) + .build(); + +const response = await agent.prompt("I cannot update my payment method.").send(); +\`\`\` + +Extractor shape: + +\`\`\`ts +import { ExtractorBuilder } from "@anvia/core/extractor"; +import { z } from "zod"; + +const ticketSchema = z.object({ + customer: z.string(), + priority: z.enum(["low", "medium", "high"]), + summary: z.string(), +}); + +const extractor = new ExtractorBuilder(model, ticketSchema) + .instructions("Extract support ticket fields.") + .retries(1) + .build(); + +const ticket = await extractor.extract("Acme Co. has urgent checkout failures."); +\`\`\` + +## Learning Path: Build a Pipeline + +Use this when one prompt is not enough and the workflow needs explicit testable steps. + +Goal: + +- Create a pipeline. +- Add transform steps. +- Call agents from pipelines. +- Run extractors. +- Add parallel branches when independent work can run side by side. + +Path: + +1. [Pipeline Builder](/docs/guides/pipelines/pipeline-builder): Core API. +2. [Steps](/docs/guides/pipelines/steps): Ordinary transform steps. +3. [Prompt Steps](/docs/guides/pipelines/prompt-steps): Call agents. +4. [Extractor Steps](/docs/guides/pipelines/extractor-steps): Return typed data. +5. [Parallel Branches](/docs/guides/pipelines/parallel-branches): Run independent work side by side. +6. [Composition Patterns](/docs/guides/pipelines/composition-patterns): Larger workflows. + +Use a pipeline for named stages such as normalize input, ask an agent, extract fields, enrich with app data, run parallel checks, and return a final object. Do not use a pipeline just to send one prompt. + +Minimal shape: + +\`\`\`ts +import { PipelineBuilder } from "@anvia/core/pipeline"; + +type TicketInput = { + customer: string; + subject: string; + body: string; +}; + +const pipeline = new PipelineBuilder() + .step((ticket) => ({ + customer: ticket.customer.trim(), + subject: ticket.subject.trim(), + body: ticket.body.trim(), + })) + .step((ticket) => ({ + title: ticket.subject.toLowerCase(), + customer: ticket.customer, + words: ticket.body.split(/\\s+/).length, + })) + .build(); + +const result = await pipeline.run({ + customer: " Acme Co. ", + subject: " Checkout is failing ", + body: "Enterprise checkout fails after payment retries.", +}); +\`\`\` + +Agent and extractor pipeline shape: + +\`\`\`ts +const pipeline = new PipelineBuilder() + .step((ticket) => + [ + "Customer: " + ticket.customer, + "Subject: " + ticket.subject, + "Body: " + ticket.body, + ].join("\\n"), + ) + .prompt(summarizer) + .extract(extractor) + .build(); +\`\`\` + +## Learning Path: Add Retrieval + +Use this when an agent needs searchable knowledge that should not be placed directly in static instructions. + +Goal: + +- Choose an embedding model. +- Embed documents. +- Store vectors. +- Filter results. +- Attach retrieved context to an agent run. + +Path: + +1. [Embeddings](/docs/guides/retrieval/embeddings): Choose an embedding model. +2. [Embed Documents](/docs/guides/retrieval/embed-documents): Convert text into vectors. +3. [Vector Stores](/docs/guides/retrieval/vector-stores): Store and search embeddings. +4. [RAG Context](/docs/guides/retrieval/rag-context): Attach retrieved documents to agents. +5. [Metadata Filters](/docs/guides/retrieval/metadata-filters): Respect tenant, user, or document filters. +6. [LSH](/docs/guides/retrieval/lsh): Narrow local search candidates. + +Use static context when the text is short, global, and stable. Use retrieval when the knowledge base is large, filtered, refreshed, or prompt-dependent. + +Preprocess shape: + +\`\`\`ts +import { embedDocuments } from "@anvia/core/embeddings"; +import { InMemoryVectorStore } from "@anvia/core/vector-store"; +import { OpenAIClient } from "@anvia/openai"; + +const client = new OpenAIClient({ apiKey }); +const embeddings = client.embeddingModel("text-embedding-3-small"); + +const documents = [ + { + id: "password-reset", + title: "Password reset policy", + body: "Password reset links expire after 30 minutes.", + }, +]; + +const embedded = await embedDocuments(embeddings, documents, { + id: (doc) => doc.id, + content: (doc) => doc.title + "\\n" + doc.body, + metadata: (doc) => ({ title: doc.title }), +}); + +export const supportDocs = InMemoryVectorStore.fromDocuments(embedded); +export const supportDocsIndex = supportDocs.index(embeddings); +\`\`\` + +Runtime retrieval shape: + +\`\`\`ts +const agent = new AgentBuilder("support", model) + .instructions("Use retrieved context when answering.") + .dynamicContext(supportDocsIndex, { + topK: 2, + format: (result) => ({ + id: result.id, + text: result.document.title + "\\n" + result.document.body, + }), + }) + .build(); + +const response = await agent.prompt("How long does a reset link last?").send(); +\`\`\` + +Search tool shape: + +\`\`\`ts +const searchDocs = supportDocsIndex.asTool({ + name: "search_docs", + description: "Search support documentation.", +}); + +const agent = new AgentBuilder("support", model) + .tool(searchDocs) + .defaultMaxTurns(3) + .build(); +\`\`\` + +Production notes: + +- Persist vectors in a durable vector store for production. +- Filter retrieval by tenant, user, document ownership, or product policy where needed. +- Keep retrieved context concise and cite document ids or titles when useful. + +## Learning Path: Add Observability + +Use this when you need to inspect what happened during an agent run. + +Goal: + +- Observe prompt run start and end. +- Observe model generation requests and responses. +- Observe tool calls and tool results. +- Capture usage data and trace metadata. + +Path: + +1. [Observers](/docs/guides/observability/observers): Attach runtime observers. +2. [Trace Groups](/docs/guides/observability/trace-groups): Group related work. +3. [Tracing](/docs/guides/observability/tracing): Trace metadata and integrations. +4. [Langfuse](/docs/guides/observability/langfuse): Send traces to Langfuse. +5. [Streaming Events](/docs/guides/streaming/streaming-events): Stream live UI events. +6. [Prompt Responses](/docs/guides/sdk-fundamentals/prompt-responses): Response usage and trace fields. + +Log first: + +- agent id +- prompt run id or conversation id +- model name +- total tokens +- tool names called +- error type and message +- trace id when available + +Minimal trace shape: + +\`\`\`ts +const response = await agent + .prompt("How do I reset my password?") + .withTrace({ + name: "support-question", + userId: "user_123", + metadata: { surface: "docs-example" }, + }) + .send(); + +console.log(response.usage.totalTokens); +console.log(response.trace); +\`\`\` + +Do not log sensitive prompt, document, or tool data unless your product policy allows it. + +## Learning Path: Prepare for Production + +Use this when an Anvia workflow moves from prototype to product code. + +Checklist: + +- Providers: create clients and models once, configure keys through your secret system. +- Tools: enforce auth and permissions inside tool code. +- History: persist \`Message[]\` and append \`response.messages\`. +- Turn limits: keep limits low enough to prevent unbounded tool loops. +- Structured output: validate output before using it in product workflows. +- Retrieval: filter by tenant, user, or document ownership when needed. +- Observability: log run metadata, usage, tool calls, errors, and trace ids. +- Errors: classify setup, provider, validation, tool, cancellation, and runtime-limit failures. +- Testing: cover tools, deterministic pipeline steps, retrieval filters, and Studio routes before broad provider tests. + +Path: + +1. [How Anvia Works](/docs/guides/sdk-fundamentals/runtime-boundaries): Confirm ownership. +2. [Errors and Cancellation](/docs/guides/sdk-fundamentals/errors): Plan failure handling. +3. [Human in the Loop](/docs/guides/human-in-the-loop): Guard side-effect actions. +4. [Messages and History](/docs/guides/sdk-fundamentals/messages-and-history): Persistence shape. +5. [Output Validation](/docs/guides/structured-output/output-validation): Typed workflow safety. +6. [Observers](/docs/guides/observability/observers): Runtime visibility. +7. [Testing](/docs/guides/testing): Verification boundaries. + +Deployment shape: + +- Startup-owned: provider clients, model instances, reusable agents, tools, pipelines, durable stores, connection registries. +- Request-owned: user input, stored history, trace metadata, session ids, request-specific limits, hooks, and authorization context. + +Wrapper shape: + +\`\`\`ts +import { type AgentBuilder, MaxTurnsError, Message, PromptCancelledError } from "@anvia/core"; + +type Agent = ReturnType; + +export async function runSupportAgent(agent: Agent, options: RunSupportAgentOptions) { + try { + const response = await agent + .prompt([...options.history, Message.user(options.input)]) + .withTrace({ + name: "support-agent", + userId: options.userId, + sessionId: options.conversationId, + }) + .maxTurns(3) + .send(); + + await conversations.saveMessages(options.conversationId, [ + ...options.history, + ...response.messages, + ]); + + await usageEvents.record({ + userId: options.userId, + totalTokens: response.usage.totalTokens, + traceId: response.trace?.traceId, + }); + + return response.output; + } catch (error) { + if (error instanceof MaxTurnsError) return "I need fewer steps to finish this."; + if (error instanceof PromptCancelledError) return "The request was cancelled."; + throw error; + } +} +\`\`\` + +## Feature Map + +- [Agents](/docs/guides/agents/creating-agents): Promptable runtime with instructions, tools, context, history, hooks, limits, and output schemas. +- [Tools](/docs/guides/tools/creating-tools): Typed application-owned behavior callable by models. +- [Messages and History](/docs/guides/sdk-fundamentals/messages-and-history): Multi-turn conversation state and provider-neutral messages. +- [Structured Output](/docs/guides/structured-output/schemas): Schema-shaped responses, extractors, validation, and failure handling. +- [Pipelines](/docs/guides/pipelines/pipeline-builder): Compose functions, agents, extractors, batches, and parallel branches. +- [Retrieval](/docs/guides/retrieval/embeddings): Embeddings, document ingestion, vector stores, RAG context, and metadata filters. +- [Human in the Loop](/docs/guides/human-in-the-loop): Tool approvals, human questions, and guarded side effects. +- [MCP](/docs/guides/mcp/connections): Connect Model Context Protocol servers and expose their tools. +- [Streaming](/docs/guides/streaming/streaming-events): Incremental text, reasoning, tool, and final response events. +- [Observability](/docs/guides/observability/observers): Run, generation, tool, usage, score, and trace events. +- [Skills](/docs/guides/skills/local-skills): Load reusable instruction and tool bundles. +- [Studio](/docs/studio/overview): Inspect local agents, sessions, traces, approvals, questions, and run streams. +- [Models](/docs/models): Provider adapters, compatible gateways, embeddings, and model listing. +- [Reference](/docs/reference): Public API exports, types, constructors, and package coverage. + +## Reference + +- [Full AI Context](/llms-full.txt): Complete documentation in one text file. +- [Guides](/docs/guides): Concepts, learning paths, and production workflows. +- [Learning Paths](/docs/guides/learning-paths/build-an-agent): Task-oriented path from first agent to production workflow. +- [Models](/docs/models): Provider adapters, compatible gateways, embeddings, and model listing. +- [Reference](/docs/reference): Public API exports, types, constructors, and package coverage. +`; + +export const Route = createFileRoute("/llms.txt")({ + server: { + handlers: { + GET: async () => + new Response(llmsTxt, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + }), + }, + }, +}); diff --git a/apps/docs/src/routes/sponsors.tsx b/apps/docs/src/routes/sponsors.tsx new file mode 100644 index 00000000..05d18c6a --- /dev/null +++ b/apps/docs/src/routes/sponsors.tsx @@ -0,0 +1,165 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { HomeLayout } from "fumadocs-ui/layouts/home"; +import { ArrowUpRight, Bot, Cpu, Handshake } from "lucide-react"; +import { baseOptions } from "@/lib/layout.shared"; + +export const Route = createFileRoute("/sponsors")({ + component: Page, + head: () => ({ + meta: [ + { + title: "Sponsors - Anvia", + }, + { + name: "description", + content: + "Sponsors helping Anvia development with AI token credits, infrastructure, and production feedback.", + }, + ], + }), +}); + +const sponsors = [ + { + name: "JATEVO.AI", + label: "Founding sponsor", + contribution: "AI token credits for development and testing", + description: + "jatevo.ai provides AI token credits that help Anvia run experiments, validate examples, test provider integrations, and improve agent development workflows.", + href: "https://jatevo.ai?ref=https://anvia.dev", + cta: "Visit sponsor", + logo: "/assets/jatevo-og.png", + }, +]; + +const supportAreas = [ + { + title: "Provider testing", + description: + "Credits make it practical to run examples and compatibility checks against realistic model workloads.", + icon: Cpu, + }, + { + title: "Agent examples", + description: + "Sponsor support keeps harness patterns, docs, and examples grounded in real model behavior.", + icon: Bot, + }, + { + title: "Open development", + description: + "Support helps Anvia keep production patterns documented and accessible to TypeScript teams.", + icon: Handshake, + }, +]; + +function Page() { + return ( + +
    +
    +
    +
    + +
    +

    Sponsors

    +

    + Companies helping Anvia move faster. +

    +

    + Anvia is supported by companies that contribute credits, infrastructure, and feedback + for production AI development. +

    +
    +
    + +
    +
    + {sponsors.map((sponsor, index) => ( +
    +
    +
    +

    + {sponsor.label} +

    + + {String(index + 1).padStart(2, "0")} + +
    +

    + + {sponsor.name} +

    +
    + +
    +
    +

    + {sponsor.contribution} +

    +

    + {sponsor.description} +

    +
    + + + {sponsor.cta} + + +
    +
    + ))} + +
    +

    Interested in supporting Anvia?

    + + Contact hello@anvia.dev + + +
    +
    +
    + +
    +
    + {supportAreas.map((area, index) => { + const Icon = area.icon; + return ( +
    + +

    + {area.title} +

    +

    {area.description}

    +
    + ); + })} +
    +
    +
    +
    + ); +} diff --git a/apps/docs/src/styles/app.css b/apps/docs/src/styles/app.css index 32a0390d..13b1486b 100644 --- a/apps/docs/src/styles/app.css +++ b/apps/docs/src/styles/app.css @@ -4,22 +4,23 @@ @import "fumadocs-ui/css/preset.css"; :root { + --anvia-bg: oklch(13.97% 0 0); --fd-layout-width: 100vw; } .dark { - --color-fd-background: hsl(0 0% 2%); + --color-fd-background: var(--anvia-bg); --color-fd-foreground: hsl(0 0% 91%); - --color-fd-muted: hsl(0 0% 7%); + --color-fd-muted: var(--anvia-bg); --color-fd-muted-foreground: hsl(0 0% 58%); - --color-fd-popover: hsl(0 0% 5%); + --color-fd-popover: var(--anvia-bg); --color-fd-popover-foreground: hsl(0 0% 88%); - --color-fd-card: hsl(0 0% 4.5%); + --color-fd-card: var(--anvia-bg); --color-fd-card-foreground: hsl(0 0% 92%); --color-fd-border: hsl(0 0% 100% / 5%); --color-fd-primary: hsl(68 100% 56%); --color-fd-primary-foreground: hsl(0 0% 4%); - --color-fd-secondary: hsl(0 0% 6.5%); + --color-fd-secondary: var(--anvia-bg); --color-fd-secondary-foreground: hsl(0 0% 88%); --color-fd-accent: hsl(0 0% 100% / 5%); --color-fd-accent-foreground: hsl(0 0% 92%); @@ -28,12 +29,12 @@ html { color-scheme: dark; - background: hsl(0 0% 2%); + background: var(--anvia-bg); } body { min-height: 100vh; - background: hsl(0 0% 2%); + background: var(--anvia-bg); font-feature-settings: "cv02", "cv03", "cv04", "cv11"; } @@ -71,7 +72,7 @@ body { } #nd-nav > div { - background: hsl(0 0% 2% / 94%); + background: oklch(13.97% 0 0 / 94%); border-color: hsl(0 0% 100% / 10%); backdrop-filter: blur(14px); } @@ -100,7 +101,7 @@ body { flex-direction: column; justify-content: flex-end; border-bottom: 1px solid hsl(0 0% 100% / 14%); - background: hsl(0 0% 2% / 94%); + background: oklch(13.97% 0 0 / 94%); padding: 1rem 2rem 0; backdrop-filter: blur(14px); box-shadow: inset 0 -1px 0 hsl(0 0% 0% / 65%); @@ -150,6 +151,26 @@ body { background: var(--color-fd-accent); } +.package-changelog-index table { + width: 100%; + table-layout: fixed; +} + +.package-changelog-index table th:nth-child(1), +.package-changelog-index table td:nth-child(1) { + width: 42%; +} + +.package-changelog-index table th:nth-child(2), +.package-changelog-index table td:nth-child(2) { + width: 28%; +} + +.package-changelog-index table th:nth-child(3), +.package-changelog-index table td:nth-child(3) { + width: 30%; +} + @media (max-width: 767px) { #nd-docs-layout.docs-with-section-tabs { --fd-header-height: 7.25rem; diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts index e7852a6c..f1af9c39 100644 --- a/apps/docs/vite.config.ts +++ b/apps/docs/vite.config.ts @@ -1,4 +1,5 @@ -import { resolve } from "node:path"; +import { readdirSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; import { cloudflare } from "@cloudflare/vite-plugin"; import tailwindcss from "@tailwindcss/vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; @@ -6,6 +7,40 @@ import react from "@vitejs/plugin-react"; import mdx from "fumadocs-mdx/vite"; import { defineConfig } from "vite"; +const docsContentDir = resolve(import.meta.dirname, "content/docs"); + +function getDocsPrerenderPages(dir = docsContentDir): Array<{ + path: string; + prerender: { enabled: true }; +}> { + return readdirSync(dir, { withFileTypes: true }) + .flatMap((entry) => { + const path = join(dir, entry.name); + + if (entry.isDirectory()) { + return getDocsPrerenderPages(path); + } + + if (!entry.isFile() || !/\.(md|mdx)$/.test(entry.name)) { + return []; + } + + const slug = relative(docsContentDir, path) + .replace(/\.(md|mdx)$/, "") + .split(sep) + .join("/") + .replace(/(^|\/)index$/, ""); + + return [ + { + path: slug ? `/docs/${slug}` : "/docs", + prerender: { enabled: true as const }, + }, + ]; + }) + .sort((a, b) => a.path.localeCompare(b.path)); +} + export default defineConfig({ server: { port: 3000, @@ -17,7 +52,17 @@ export default defineConfig({ tanstackStart({ prerender: { enabled: true, + autoSubfolderIndex: true, + autoStaticPathsDiscovery: true, + concurrency: 14, + crawlLinks: true, + filter: ({ path }) => !path.startsWith("/api/"), + retryCount: 2, + retryDelay: 1000, + maxRedirects: 5, + failOnError: true, }, + pages: getDocsPrerenderPages(), }), react(), ], diff --git a/bin/check-upstream-deps.sh b/bin/check-upstream-deps.sh index bb2b4aa8..4cd7d852 100755 --- a/bin/check-upstream-deps.sh +++ b/bin/check-upstream-deps.sh @@ -18,7 +18,7 @@ const options = { }; function usage() { - console.log(`Usage: bin/dependency-report.sh [options] + console.log(`Usage: bin/check-upstream-deps.sh [options] Reports npm updates for external runtime dependencies declared by packages/* wrappers. @@ -164,14 +164,14 @@ function updateKind(current, latest) { return "patch"; } -async function fetchLatestVersion(packageName) { +async function fetchLatestPackageInfo(packageName) { const packageUrlName = packageName .split("/") .map((part) => encodeURIComponent(part)) .join("/"); const registryUrl = `https://registry.npmjs.org/${packageUrlName}`; const response = await fetch(registryUrl, { - headers: { accept: "application/vnd.npm.install-v1+json" }, + headers: { accept: "application/json" }, }); if (!response.ok) { @@ -185,7 +185,10 @@ async function fetchLatestVersion(packageName) { throw new Error(`No latest dist-tag found for ${packageName}`); } - return latest; + return { + latest, + latestPublishedAt: metadata?.time?.[latest] ?? null, + }; } function uniqueDependencies(records) { @@ -196,6 +199,53 @@ function pad(value, width) { return value.padEnd(width, " "); } +function formatRelativeTime(dateString, now) { + if (!dateString) { + return "-"; + } + + const date = new Date(dateString); + const timestamp = date.getTime(); + + if (Number.isNaN(timestamp)) { + return "-"; + } + + const diffMs = now.getTime() - timestamp; + const tense = diffMs < 0 ? "from now" : "ago"; + let remainingSeconds = Math.floor(Math.abs(diffMs) / 1000); + + if (remainingSeconds < 60) { + return "just now"; + } + + const units = [ + ["y", 365 * 24 * 60 * 60], + ["mo", 30 * 24 * 60 * 60], + ["d", 24 * 60 * 60], + ["h", 60 * 60], + ["m", 60], + ]; + const parts = []; + + for (const [label, seconds] of units) { + const value = Math.floor(remainingSeconds / seconds); + + if (value === 0) { + continue; + } + + parts.push(`${value}${label}`); + remainingSeconds -= value * seconds; + + if (parts.length === 2) { + break; + } + } + + return `${parts.join(" ")} ${tense}`; +} + function formatTable(records) { const columns = [ ["Wrapper", (record) => record.wrapper], @@ -203,6 +253,7 @@ function formatTable(records) { ["Field", (record) => record.field], ["Declared", (record) => record.declared], ["Latest", (record) => record.latest ?? "-"], + ["Published", (record) => record.latestPublishedAge ?? "-"], ["Status", (record) => record.status], ]; @@ -266,13 +317,14 @@ for (const packageJsonFile of packageJsonFiles) { } } -const latestByDependency = new Map(); +const latestInfoByDependency = new Map(); const errors = []; +const now = new Date(); await Promise.all( uniqueDependencies(records).map(async (dependency) => { try { - latestByDependency.set(dependency, await fetchLatestVersion(dependency)); + latestInfoByDependency.set(dependency, await fetchLatestPackageInfo(dependency)); } catch (error) { errors.push({ dependency, message: error.message }); } @@ -280,7 +332,10 @@ await Promise.all( ); for (const record of records) { - record.latest = latestByDependency.get(record.dependency) ?? null; + const latestInfo = latestInfoByDependency.get(record.dependency); + record.latest = latestInfo?.latest ?? null; + record.latestPublishedAt = latestInfo?.latestPublishedAt ?? null; + record.latestPublishedAge = formatRelativeTime(record.latestPublishedAt, now); record.status = "unknown"; if (record.latest && record.declaredVersion) { diff --git a/examples/cli-agent/package.json b/examples/cli-agent/package.json index 13fca883..db380686 100644 --- a/examples/cli-agent/package.json +++ b/examples/cli-agent/package.json @@ -27,7 +27,7 @@ "marked": "^15.0.12", "marked-terminal": "^7.3.0", "react": "^19.2.5", - "zod": "^4.4.2" + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "^2.4.13", diff --git a/examples/cli-agent/src/agent.ts b/examples/cli-agent/src/agent.ts index 41900dec..6403260f 100644 --- a/examples/cli-agent/src/agent.ts +++ b/examples/cli-agent/src/agent.ts @@ -1,4 +1,5 @@ import { AgentBuilder } from "@anvia/core/agent"; +import { Message } from "@anvia/core/completion"; import { OpenAIClient } from "@anvia/openai"; import { getModelName, getTavilyApiKey, OPENROUTER_BASE_URL } from "./config.js"; import { toAnviaHistory } from "./memory.js"; @@ -46,11 +47,9 @@ export async function streamAssistantResponse({ const agent = builder.build(); - for await (const event of agent - .prompt(prompt) - .withHistory(toAnviaHistory(history)) - .maxTurns(MAX_TURNS) - .stream()) { + const transcript = [...toAnviaHistory(history), Message.user(prompt)]; + + for await (const event of agent.prompt(transcript).maxTurns(MAX_TURNS).stream()) { if (event.type === "text_delta") { onDelta(event.delta); } diff --git a/examples/cookbook/01_basics/01-text-call.ts b/examples/cookbook/01_basics/01-text-call.ts index 78244c6a..8c5677a3 100644 --- a/examples/cookbook/01_basics/01-text-call.ts +++ b/examples/cookbook/01_basics/01-text-call.ts @@ -2,12 +2,12 @@ import { AgentBuilder } from "@anvia/core/agent"; import { OpenAIClient } from "@anvia/openai"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); // Provider clients create models; AgentBuilder composes model-independent behavior. -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("You are a concise assistant. Answer in two sentences or less.") diff --git a/examples/cookbook/01_basics/02-chat-history.ts b/examples/cookbook/01_basics/02-chat-history.ts index 222f87ca..81c8e7a3 100644 --- a/examples/cookbook/01_basics/02-chat-history.ts +++ b/examples/cookbook/01_basics/02-chat-history.ts @@ -3,10 +3,10 @@ import { Message } from "@anvia/core/completion"; import { OpenAIClient } from "@anvia/openai"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("You are a concise assistant that respects prior conversation context.") @@ -17,6 +17,6 @@ const history = [ Message.assistant("Noted. Your project is named Anvia."), ]; -const response = await agent.prompt("What is my project named?").withHistory(history).send(); +const response = await agent.prompt([...history, Message.user("What is my project named?")]).send(); console.log(response.output); diff --git a/examples/cookbook/01_basics/03-static-context.ts b/examples/cookbook/01_basics/03-static-context.ts index 0db797a6..d88e5b3d 100644 --- a/examples/cookbook/01_basics/03-static-context.ts +++ b/examples/cookbook/01_basics/03-static-context.ts @@ -2,10 +2,10 @@ import { AgentBuilder } from "@anvia/core/agent"; import { OpenAIClient } from "@anvia/openai"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Answer from the supplied context when it is relevant.") // Static context is sent with every request to this agent. diff --git a/examples/cookbook/01_basics/04-stream-text.ts b/examples/cookbook/01_basics/04-stream-text.ts index 02b2f580..75e8f638 100644 --- a/examples/cookbook/01_basics/04-stream-text.ts +++ b/examples/cookbook/01_basics/04-stream-text.ts @@ -2,10 +2,10 @@ import { AgentBuilder } from "@anvia/core/agent"; import { OpenAIClient } from "@anvia/openai"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("You are a concise assistant.") diff --git a/examples/cookbook/01_basics/05-readable-stream-jsonl.ts b/examples/cookbook/01_basics/05-readable-stream-jsonl.ts index 2026e5ec..50d19372 100644 --- a/examples/cookbook/01_basics/05-readable-stream-jsonl.ts +++ b/examples/cookbook/01_basics/05-readable-stream-jsonl.ts @@ -2,10 +2,10 @@ import { AgentBuilder } from "@anvia/core/agent"; import { OpenAIClient } from "@anvia/openai"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("You are a concise assistant.") diff --git a/examples/cookbook/01_basics/06-session-memory.ts b/examples/cookbook/01_basics/06-session-memory.ts new file mode 100644 index 00000000..842d6425 --- /dev/null +++ b/examples/cookbook/01_basics/06-session-memory.ts @@ -0,0 +1,40 @@ +import { AgentBuilder } from "@anvia/core/agent"; +import type { Message } from "@anvia/core/completion"; +import type { MemoryAppendInput, MemoryContext, MemoryStore } from "@anvia/core/memory"; +import { OpenAIClient } from "@anvia/openai"; + +class LocalMemoryStore implements MemoryStore { + private readonly sessions = new Map(); + + async load(context: MemoryContext): Promise { + return [...(this.sessions.get(context.sessionId) ?? [])]; + } + + async append(input: MemoryAppendInput): Promise { + const current = this.sessions.get(input.context.sessionId) ?? []; + this.sessions.set(input.context.sessionId, [...current, ...input.messages]); + } + + async clear(context: MemoryContext): Promise { + this.sessions.delete(context.sessionId); + } +} + +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); +const agentModel = client.completionModel("gpt-5.5"); +const memory = new LocalMemoryStore(); + +const agent = new AgentBuilder("agent", agentModel) + .instructions("You are a concise assistant that remembers durable session context.") + .memory(memory) + .build(); + +const session = agent.session("demo-session", { userId: "cookbook-user" }); + +await session.prompt("Remember that my project is named Anvia.").send(); +const response = await session.prompt("What is my project named?").send(); + +console.log(response.output); diff --git a/examples/cookbook/01_basics/07-server-react-transport.ts b/examples/cookbook/01_basics/07-server-react-transport.ts new file mode 100644 index 00000000..d4e9566d --- /dev/null +++ b/examples/cookbook/01_basics/07-server-react-transport.ts @@ -0,0 +1,43 @@ +import type { AgentStreamEvent } from "@anvia/core/agent"; +import { fetchEventStream } from "@anvia/react"; +import { createEventStream } from "@anvia/server"; + +async function* runEvents(): AsyncIterable { + yield { + type: "turn_start", + turn: 1, + prompt: { role: "user", content: [{ type: "text", text: "Hello" }] }, + history: [], + }; + yield { type: "text_delta", turn: 1, delta: "Hello" }; + yield { type: "text_delta", turn: 1, delta: " from Anvia" }; + yield { + type: "final", + runId: "run_123", + output: "Hello from Anvia", + usage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + }, + messages: [], + }; +} + +const response = createEventStream(runEvents(), { format: "jsonl" }); + +let output = ""; +for await (const event of fetchEventStream("/api/chat", { + fetch: async () => response, +})) { + if (event.type === "text_delta") { + output += event.delta; + } + if (event.type === "final") { + console.log(event.output); + } +} + +console.log(`Accumulated: ${output}`); diff --git a/examples/cookbook/02_tools/01-tool-call.ts b/examples/cookbook/02_tools/01-tool-call.ts index 76ab668e..61170c5b 100644 --- a/examples/cookbook/02_tools/01-tool-call.ts +++ b/examples/cookbook/02_tools/01-tool-call.ts @@ -16,10 +16,10 @@ const addTool = createTool({ }); const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("You are a concise assistant. Use tools when useful.") diff --git a/examples/cookbook/02_tools/02-tool-stream-events.ts b/examples/cookbook/02_tools/02-tool-stream-events.ts index 10a7ac97..7a5b0ef4 100644 --- a/examples/cookbook/02_tools/02-tool-stream-events.ts +++ b/examples/cookbook/02_tools/02-tool-stream-events.ts @@ -22,10 +22,10 @@ const weatherTool = createTool({ }); const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Use the weather tool when the user asks for weather.") diff --git a/examples/cookbook/02_tools/03-hooks-and-tool-concurrency.ts b/examples/cookbook/02_tools/03-hooks-and-tool-concurrency.ts index b1eace86..8b8ab571 100644 --- a/examples/cookbook/02_tools/03-hooks-and-tool-concurrency.ts +++ b/examples/cookbook/02_tools/03-hooks-and-tool-concurrency.ts @@ -38,10 +38,10 @@ const hook = createHook({ }); const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Use tools for arithmetic and then explain the result briefly.") .tools([addTool, multiplyTool]) diff --git a/examples/cookbook/02_tools/04-conditional-tool.ts b/examples/cookbook/02_tools/04-conditional-tool.ts index db31f96d..1e76304a 100644 --- a/examples/cookbook/02_tools/04-conditional-tool.ts +++ b/examples/cookbook/02_tools/04-conditional-tool.ts @@ -17,10 +17,10 @@ const addTool = createTool({ const enableMathTools = process.env.ENABLE_MATH_TOOLS !== "false"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const builderModel = client.completionModel("deepseek/deepseek-v4-pro"); +const builderModel = client.completionModel("gpt-5.5"); const builder = new AgentBuilder("builder", builderModel) .instructions("You are a concise assistant. Use tools only when they are available.") .defaultMaxTurns(2); diff --git a/examples/cookbook/02_tools/05-think-tool.ts b/examples/cookbook/02_tools/05-think-tool.ts index 8d133f64..ca7381a9 100644 --- a/examples/cookbook/02_tools/05-think-tool.ts +++ b/examples/cookbook/02_tools/05-think-tool.ts @@ -17,10 +17,10 @@ const addTool = createTool({ const thinkTool = createThinkTool(); const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); // The think tool gives models an explicit scratchpad step before final answers. const agent = new AgentBuilder("agent", agentModel) diff --git a/examples/cookbook/02_tools/06-tool-closure-context.ts b/examples/cookbook/02_tools/06-tool-closure-context.ts index 3d5c2319..26e40c25 100644 --- a/examples/cookbook/02_tools/06-tool-closure-context.ts +++ b/examples/cookbook/02_tools/06-tool-closure-context.ts @@ -50,10 +50,10 @@ const getTicketTool = createTool({ }); const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Use local tools when the user asks about private support tickets.") .tool(getTicketTool) diff --git a/examples/cookbook/02_tools/07-tool-call-with-chat-history.ts b/examples/cookbook/02_tools/07-tool-call-with-chat-history.ts index 9337d23b..5be7c279 100644 --- a/examples/cookbook/02_tools/07-tool-call-with-chat-history.ts +++ b/examples/cookbook/02_tools/07-tool-call-with-chat-history.ts @@ -1,14 +1,14 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { AgentBuilder } from "@anvia/core/agent"; -import type { Message } from "@anvia/core/completion"; +import { Message, type Message as MessageType } from "@anvia/core/completion"; import { createTool } from "@anvia/core/tool"; import { OpenAIClient } from "@anvia/openai"; import { z } from "zod"; type SavedHistoryRecord = { timestamp: string; - messages: Message[]; + messages: MessageType[]; }; const tickets = new Map([ @@ -52,10 +52,10 @@ const historyPath = new URL("../.memory/tool-call-chat-history.json", import.met const prompt = "Use the ticket tool to summarize TICKET-1001 and remember who owns it."; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Use tools for private ticket data. Use prior chat history when it is relevant.") .tools([getTicketTool]) @@ -63,11 +63,11 @@ const agent = new AgentBuilder("agent", agentModel) .build(); const history = await buildHistory(); -let finalMessages: Message[] | undefined; +let finalMessages: MessageType[] | undefined; let isThinking = false; // This combines persisted history with tool calls in one streaming request. -for await (const event of agent.prompt(prompt).withHistory(history).stream()) { +for await (const event of agent.prompt([...history, Message.user(prompt)]).stream()) { if (event.type !== "reasoning_delta" && isThinking) { process.stdout.write("\n"); isThinking = false; @@ -109,12 +109,12 @@ if (finalMessages !== undefined) { console.log("history file:", historyPath.pathname); } -async function buildHistory(): Promise { +async function buildHistory(): Promise { const records = await readRecords(); return records.slice(-5).flatMap((record) => record.messages); } -async function saveHistory(messages: Message[]): Promise { +async function saveHistory(messages: MessageType[]): Promise { const records = await readRecords(); records.push({ timestamp: new Date().toISOString(), diff --git a/examples/cookbook/02_tools/08-tool-permission-hook.ts b/examples/cookbook/02_tools/08-tool-permission-hook.ts index cea9b9ed..6d43be35 100644 --- a/examples/cookbook/02_tools/08-tool-permission-hook.ts +++ b/examples/cookbook/02_tools/08-tool-permission-hook.ts @@ -47,13 +47,16 @@ const deleteAccountTool = createTool({ const permissionHook = createHook({ onToolCall({ toolName, tool }) { - // Hooks can allow, skip, or terminate tool calls before execution. + // Hooks can allow, skip, request approval, or terminate tool calls before execution. if (toolName === "read_payroll") { return tool.skip("Payroll data is restricted. Summarize that access was denied."); } if (toolName === "delete_account") { - return tool.cancel("Account deletion requires explicit human approval."); + return tool.requestApproval({ + reason: "Account deletion requires explicit human approval.", + rejectMessage: "Account deletion was not approved.", + }); } return tool.run(); @@ -64,10 +67,10 @@ const permissionHook = createHook({ }); const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Use tools for service status and administrative requests.") .tools([getServiceStatusTool, readPayrollTool, deleteAccountTool]) @@ -85,6 +88,7 @@ try { console.log(response.output); } catch (error) { if (error instanceof PromptCancelledError) { + // Without Studio or another approval handler, requestApproval cancels clearly. console.log("prompt cancelled:", error.reason); } else { throw error; diff --git a/examples/cookbook/02_tools/10-tool-result-middleware.ts b/examples/cookbook/02_tools/10-tool-result-middleware.ts new file mode 100644 index 00000000..5674148e --- /dev/null +++ b/examples/cookbook/02_tools/10-tool-result-middleware.ts @@ -0,0 +1,61 @@ +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AgentBuilder } from "@anvia/core/agent"; +import { createTool, createToolMiddleware } from "@anvia/core/tool"; +import { OpenAIClient } from "@anvia/openai"; +import { z } from "zod"; + +const longReportTool = createTool({ + name: "long_report", + description: "Return a long internal report for a topic.", + input: z.object({ + topic: z.string(), + }), + output: z.string(), + execute: ({ topic }) => + [ + `Report topic: ${topic}`, + "Revenue increased in enterprise accounts.", + "Support volume is concentrated around onboarding.", + "Recommended action: prioritize setup automation.", + ] + .join("\n") + .repeat(20), +}); + +const outputGate = createToolMiddleware({ + async onResult({ toolName, result, internalCallId }) { + if (result.length <= 1_000) { + return undefined; + } + + const path = join(tmpdir(), `${toolName}-${internalCallId}.txt`); + await writeFile(path, result, "utf8"); + + return JSON.stringify({ + type: "file_reference", + reason: "tool_output_too_large", + chars: result.length, + path, + }); + }, +}); + +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); +const agentModel = client.completionModel("gpt-5.5"); +const agent = new AgentBuilder("agent", agentModel) + .instructions("Use tools when useful. Summarize tool results briefly.") + .tool(longReportTool) + .toolMiddleware(outputGate) + .defaultMaxTurns(2) + .build(); + +const response = await agent + .prompt("Create a short update from the long report about onboarding.") + .send(); + +console.log(response.output); diff --git a/examples/cookbook/03_structured_output/01-structured-extraction.ts b/examples/cookbook/03_structured_output/01-structured-extraction.ts index e25e6866..760e6bcc 100644 --- a/examples/cookbook/03_structured_output/01-structured-extraction.ts +++ b/examples/cookbook/03_structured_output/01-structured-extraction.ts @@ -10,10 +10,10 @@ const personSchema = z.object({ }); const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const model = client.completionModel("deepseek/deepseek-v4-pro"); +const model = client.completionModel("gpt-5.5"); const extractor = new ExtractorBuilder(model, personSchema).build(); const person = await extractor.extract("Ada Lovelace was a mathematician and computing pioneer."); diff --git a/examples/cookbook/03_structured_output/02-output-schema.ts b/examples/cookbook/03_structured_output/02-output-schema.ts index 39fabde4..41a96cce 100644 --- a/examples/cookbook/03_structured_output/02-output-schema.ts +++ b/examples/cookbook/03_structured_output/02-output-schema.ts @@ -11,10 +11,10 @@ const summarySchema = z .meta({ title: "summary_response" }); const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Return only data that matches the requested schema.") diff --git a/examples/cookbook/03_structured_output/03-extraction-with-history.ts b/examples/cookbook/03_structured_output/03-extraction-with-history.ts index 0c04259e..022436f6 100644 --- a/examples/cookbook/03_structured_output/03-extraction-with-history.ts +++ b/examples/cookbook/03_structured_output/03-extraction-with-history.ts @@ -9,10 +9,10 @@ const taskSchema = z.object({ }); const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const model = client.completionModel("deepseek/deepseek-v4-pro"); +const model = client.completionModel("gpt-5.5"); // This extends basic extraction with context, retries, and prior messages. const extractor = new ExtractorBuilder(model, taskSchema) diff --git a/examples/cookbook/04_providers_and_multimodal/04-rich-reasoning-content.ts b/examples/cookbook/04_providers_and_multimodal/04-rich-reasoning-content.ts index bef20748..ae8229dc 100644 --- a/examples/cookbook/04_providers_and_multimodal/04-rich-reasoning-content.ts +++ b/examples/cookbook/04_providers_and_multimodal/04-rich-reasoning-content.ts @@ -12,12 +12,15 @@ const provider = process.env.ANVIA_REASONING_PROVIDER ?? "openai"; const prompt = "Solve 19 * 23 and explain only the final answer."; const geminiClient = new GeminiClient({ apiKey: process.env.GEMINI_API_KEY }); -const openAIClient = new OpenAIClient({ apiKey: process.env.OPENAI_API_KEY }); +const openAIClient = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); const geminiModel = geminiClient.completionModel( process.env.GEMINI_MODEL ?? "gemini-3.1-flash-lite-preview", ); -const openAIModel = openAIClient.completionModel(process.env.OPENAI_MODEL ?? "gpt-5.5"); +const openAIModel = openAIClient.completionModel("gpt-5.5"); const additionalParams = provider === "gemini" diff --git a/examples/cookbook/04_providers_and_multimodal/05-image-attachment.ts b/examples/cookbook/04_providers_and_multimodal/05-image-attachment.ts index 659124b0..cb8583ea 100644 --- a/examples/cookbook/04_providers_and_multimodal/05-image-attachment.ts +++ b/examples/cookbook/04_providers_and_multimodal/05-image-attachment.ts @@ -3,10 +3,10 @@ import { Message, UserContent } from "@anvia/core/completion"; import { OpenAIClient } from "@anvia/openai"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("google/gemini-3.1-flash-lite-preview"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Answer visual questions briefly.") .build(); diff --git a/examples/cookbook/04_providers_and_multimodal/06-pdf-attachment.ts b/examples/cookbook/04_providers_and_multimodal/06-pdf-attachment.ts index d1c0e62b..652fa79b 100644 --- a/examples/cookbook/04_providers_and_multimodal/06-pdf-attachment.ts +++ b/examples/cookbook/04_providers_and_multimodal/06-pdf-attachment.ts @@ -2,8 +2,11 @@ import { AgentBuilder } from "@anvia/core/agent"; import { Message, UserContent } from "@anvia/core/completion"; import { OpenAIClient } from "@anvia/openai"; -const client = new OpenAIClient({ apiKey: process.env.OPENAI_API_KEY }); -const agentModel = client.completionModel("gpt-5.4"); +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Summarize attached documents in concise bullets.") .build(); diff --git a/examples/cookbook/04_providers_and_multimodal/07-openai-image-generation.ts b/examples/cookbook/04_providers_and_multimodal/07-openai-image-generation.ts index 21de4a75..96f974df 100644 --- a/examples/cookbook/04_providers_and_multimodal/07-openai-image-generation.ts +++ b/examples/cookbook/04_providers_and_multimodal/07-openai-image-generation.ts @@ -2,8 +2,10 @@ import { writeFile } from "node:fs/promises"; import { imageGenerationRequest } from "@anvia/core/image-generation"; import { GPT_IMAGE_2, OpenAIClient } from "@anvia/openai"; -const apiKey = requireEnv("OPENAI_API_KEY"); -const client = new OpenAIClient({ apiKey }); +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); const imageModel = client.imageGenerationModel(process.env.OPENAI_IMAGE_MODEL ?? GPT_IMAGE_2); const response = await imageGenerationRequest(imageModel) @@ -19,11 +21,3 @@ console.log({ mediaType: response.mediaType, output: "openai-image-generation.png", }); - -function requireEnv(name: string): string { - const value = process.env[name]; - if (value === undefined || value.length === 0) { - throw new Error(`Set ${name} before running this cookbook example.`); - } - return value; -} diff --git a/examples/cookbook/04_providers_and_multimodal/08-openai-audio-and-transcription.ts b/examples/cookbook/04_providers_and_multimodal/08-openai-audio-and-transcription.ts index 6d46b2d5..abe6cb44 100644 --- a/examples/cookbook/04_providers_and_multimodal/08-openai-audio-and-transcription.ts +++ b/examples/cookbook/04_providers_and_multimodal/08-openai-audio-and-transcription.ts @@ -3,8 +3,10 @@ import { audioGenerationRequest } from "@anvia/core/audio-generation"; import { transcriptionRequest } from "@anvia/core/transcription"; import { OpenAIClient } from "@anvia/openai"; -const apiKey = requireEnv("OPENAI_API_KEY"); -const client = new OpenAIClient({ apiKey }); +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); const speech = await audioGenerationRequest(client.audioGenerationModel()) .text("Anvia can now generate audio and transcribe audio through provider-neutral APIs.") @@ -28,11 +30,3 @@ console.log({ mediaType: speech.mediaType, transcript: transcript.text, }); - -function requireEnv(name: string): string { - const value = process.env[name]; - if (value === undefined || value.length === 0) { - throw new Error(`Set ${name} before running this cookbook example.`); - } - return value; -} diff --git a/examples/cookbook/04_providers_and_multimodal/10-list-models.ts b/examples/cookbook/04_providers_and_multimodal/10-list-models.ts new file mode 100644 index 00000000..34986644 --- /dev/null +++ b/examples/cookbook/04_providers_and_multimodal/10-list-models.ts @@ -0,0 +1,45 @@ +import type { ModelListingClient } from "@anvia/core/model-listing"; +import { OpenAIClient } from "@anvia/openai"; + +const client = createModelListingClient(); +const models = await client.listModels(); + +console.table( + models.data.slice(0, 20).map((model) => ({ + id: model.id, + name: model.name ?? "", + contextLength: model.contextLength ?? "", + owner: model.ownedBy ?? "", + })), +); + +function createModelListingClient(): ModelListingClient { + if (process.env.OPENAI_API_KEY !== undefined) { + return new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, + }); + } + + return new OpenAIClient({ + client: { + models: { + list: async () => ({ + data: [ + { + id: "demo-text-model", + object: "model", + owned_by: "demo-provider", + context_length: 128_000, + }, + { + id: "demo-embedding-model", + object: "model", + owned_by: "demo-provider", + }, + ], + }), + }, + } as never, + }); +} diff --git a/examples/cookbook/05_pipelines/06-agent-pipeline.ts b/examples/cookbook/05_pipelines/06-agent-pipeline.ts index b76e6860..323d89db 100644 --- a/examples/cookbook/05_pipelines/06-agent-pipeline.ts +++ b/examples/cookbook/05_pipelines/06-agent-pipeline.ts @@ -3,11 +3,11 @@ import { PipelineBuilder } from "@anvia/core/pipeline"; import { OpenAIClient } from "@anvia/openai"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const analystModel = client.completionModel("deepseek/deepseek-v4-pro"); +const analystModel = client.completionModel("gpt-5.5"); const analyst = new AgentBuilder("analyst", analystModel) .instructions( [ diff --git a/examples/cookbook/05_pipelines/07-extractor-pipeline.ts b/examples/cookbook/05_pipelines/07-extractor-pipeline.ts index 24a9c54a..e349fb0a 100644 --- a/examples/cookbook/05_pipelines/07-extractor-pipeline.ts +++ b/examples/cookbook/05_pipelines/07-extractor-pipeline.ts @@ -4,8 +4,8 @@ import { OpenAIClient } from "@anvia/openai"; import { z } from "zod"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const ticketSchema = z.object({ @@ -14,7 +14,7 @@ const ticketSchema = z.object({ priority: z.enum(["low", "normal", "high"]), }); -const model = client.completionModel("deepseek/deepseek-v4-pro"); +const model = client.completionModel("gpt-5.5"); const ticketExtractor = new ExtractorBuilder(model, ticketSchema) .instructions("Extract a support ticket from the provided operational note.") .build(); diff --git a/examples/cookbook/05_pipelines/08-research-pipeline.ts b/examples/cookbook/05_pipelines/08-research-pipeline.ts index 981274eb..0b063eb2 100644 --- a/examples/cookbook/05_pipelines/08-research-pipeline.ts +++ b/examples/cookbook/05_pipelines/08-research-pipeline.ts @@ -1,12 +1,17 @@ import { AgentBuilder } from "@anvia/core/agent"; import { PipelineBuilder } from "@anvia/core/pipeline"; -import { createTool, ToolSet } from "@anvia/core/tool"; +import { + createTool, + type NormalizedToolOutput, + ToolSet, + toolResultContentToText, +} from "@anvia/core/tool"; import { OpenAIClient } from "@anvia/openai"; import { z } from "zod"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const researchTools = ToolSet.fromTools([ @@ -48,7 +53,7 @@ const sourceQuality = new PipelineBuilder() .step((topic) => researchTools.call("source_quality", JSON.stringify({ topic }))) .build(); -const synthesizerModel = client.completionModel("deepseek/deepseek-v4-pro"); +const synthesizerModel = client.completionModel("gpt-5.5"); const synthesizer = new AgentBuilder("synthesizer", synthesizerModel) .instructions( [ @@ -66,8 +71,11 @@ const researchPipeline = new PipelineBuilder() qualityJson: sourceQuality, }) .step(({ notesJson, qualityJson }) => { - const notes = JSON.parse(notesJson) as string[]; - const quality = JSON.parse(qualityJson) as { confidence: string; caveat: string }; + const notes = JSON.parse(toolOutputText(notesJson)) as string[]; + const quality = JSON.parse(toolOutputText(qualityJson)) as { + confidence: string; + caveat: string; + }; return [ "Synthesize this research packet.", @@ -85,3 +93,7 @@ const researchPipeline = new PipelineBuilder() const report = await researchPipeline.run("Anvia pipeline cookbook examples"); console.log(report); + +function toolOutputText(output: NormalizedToolOutput): string { + return typeof output === "string" ? output : toolResultContentToText(output); +} diff --git a/examples/cookbook/05_pipelines/09-financial-market-analysis.ts b/examples/cookbook/05_pipelines/09-financial-market-analysis.ts index 19cf87b6..2d497f7e 100644 --- a/examples/cookbook/05_pipelines/09-financial-market-analysis.ts +++ b/examples/cookbook/05_pipelines/09-financial-market-analysis.ts @@ -1,12 +1,17 @@ import { AgentBuilder } from "@anvia/core/agent"; import { PipelineBuilder } from "@anvia/core/pipeline"; -import { createTool, ToolSet } from "@anvia/core/tool"; +import { + createTool, + type NormalizedToolOutput, + ToolSet, + toolResultContentToText, +} from "@anvia/core/tool"; import { OpenAIClient } from "@anvia/openai"; import { z } from "zod"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const marketTools = ToolSet.fromTools([ @@ -69,7 +74,7 @@ const riskFlags = new PipelineBuilder() .step((ticker) => marketTools.call("risk_flags", JSON.stringify({ ticker }))) .build(); -const marketAnalystModel = client.completionModel("deepseek/deepseek-v4-pro"); +const marketAnalystModel = client.completionModel("gpt-5.5"); const marketAnalyst = new AgentBuilder("market-analyst", marketAnalystModel) .instructions( [ @@ -89,14 +94,14 @@ const marketPipeline = new PipelineBuilder() risksJson: riskFlags, }) .step(({ quoteJson, newsJson, risksJson }) => { - const quote = JSON.parse(quoteJson) as { + const quote = JSON.parse(toolOutputText(quoteJson)) as { ticker: string; price: number; changePercent: number; volume: number; }; - const news = JSON.parse(newsJson) as string[]; - const risks = JSON.parse(risksJson) as string[]; + const news = JSON.parse(toolOutputText(newsJson)) as string[]; + const risks = JSON.parse(toolOutputText(risksJson)) as string[]; return [ `Analyze this mock market packet for ${quote.ticker}.`, @@ -118,3 +123,7 @@ const marketPipeline = new PipelineBuilder() const analysis = await marketPipeline.run("AION"); console.log(analysis); + +function toolOutputText(output: NormalizedToolOutput): string { + return typeof output === "string" ? output : toolResultContentToText(output); +} diff --git a/examples/cookbook/06_retrieval/03-openrouter-rag.ts b/examples/cookbook/06_retrieval/03-openrouter-rag.ts index 93a4295d..f4fa133c 100644 --- a/examples/cookbook/06_retrieval/03-openrouter-rag.ts +++ b/examples/cookbook/06_retrieval/03-openrouter-rag.ts @@ -10,8 +10,8 @@ type PolicyNote = { }; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const embeddingModel = await createTransformersEmbeddingModel(); const notes: PolicyNote[] = [ @@ -31,7 +31,7 @@ const embedded = await embedDocuments(embeddingModel, notes, { }); const index = InMemoryVectorStore.fromDocuments(embedded).index(embeddingModel); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Answer using the retrieved policy context. If context is thin, say so.") .dynamicContext(index, { topK: 1 }) diff --git a/examples/cookbook/06_retrieval/05-rag-search-tool.ts b/examples/cookbook/06_retrieval/05-rag-search-tool.ts index a26177e6..cd583efe 100644 --- a/examples/cookbook/06_retrieval/05-rag-search-tool.ts +++ b/examples/cookbook/06_retrieval/05-rag-search-tool.ts @@ -10,8 +10,8 @@ type Runbook = { }; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const embeddingModel = await createTransformersEmbeddingModel(); const runbooks: Runbook[] = [ @@ -40,7 +40,7 @@ const searchRunbooks = store.index(embeddingModel).asTool({ topK: 2, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Use the runbook search tool before answering incident questions.") .tools([searchRunbooks]) diff --git a/examples/cookbook/06_retrieval/08-pgvector-store.ts b/examples/cookbook/06_retrieval/08-pgvector-store.ts index 26bb41e9..94c2998d 100644 --- a/examples/cookbook/06_retrieval/08-pgvector-store.ts +++ b/examples/cookbook/06_retrieval/08-pgvector-store.ts @@ -29,7 +29,7 @@ const embedded = await embedDocuments(embeddingModel, notes, { }); const store = await PgVectorStore.connect({ - connectionString: process.env.DATABASE_URL ?? "postgres://anvia:anvia@localhost:5432/anvia", + connectionString: process.env.DATABASE_URL ?? "postgres://anvia:anvia@localhost:5439/anvia", tableName: "anvia_market_notes", vectorSize: 384, }); diff --git a/examples/cookbook/07_multi_agent/01-agent-as-tool.ts b/examples/cookbook/07_multi_agent/01-agent-as-tool.ts index 56185683..915e007e 100644 --- a/examples/cookbook/07_multi_agent/01-agent-as-tool.ts +++ b/examples/cookbook/07_multi_agent/01-agent-as-tool.ts @@ -2,12 +2,13 @@ import { AgentBuilder } from "@anvia/core/agent"; import { OpenAIClient } from "@anvia/openai"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const supportAgentModel = client.completionModel("deepseek/deepseek-v4-pro"); -const supportAgent = new AgentBuilder("support", supportAgentModel) +const model = client.completionModel("gpt-5.5"); + +const supportAgent = new AgentBuilder("support", model) .name("Support Specialist") .description("Delegate support triage work to the support specialist agent.") .instructions( @@ -21,8 +22,7 @@ const supportAgent = new AgentBuilder("support", supportAgentModel) ) .build(); -const engineeringAgentModel = client.completionModel("deepseek/deepseek-v4-pro"); -const engineeringAgent = new AgentBuilder("engineering", engineeringAgentModel) +const engineeringAgent = new AgentBuilder("engineering", model) .name("Engineering Specialist") .description("Delegate technical investigation work to the engineering specialist agent.") .instructions( @@ -36,8 +36,7 @@ const engineeringAgent = new AgentBuilder("engineering", engineeringAgentModel) ) .build(); -const commsAgentModel = client.completionModel("deepseek/deepseek-v4-pro"); -const commsAgent = new AgentBuilder("comms", commsAgentModel) +const commsAgent = new AgentBuilder("comms", model) .name("Customer Comms Specialist") .description("Delegate customer update drafting to the customer communications specialist.") .instructions( @@ -50,8 +49,7 @@ const commsAgent = new AgentBuilder("comms", commsAgentModel) ) .build(); -const coordinatorModel = client.completionModel("deepseek/deepseek-v4-pro"); -const coordinator = new AgentBuilder("coordinator", coordinatorModel) +const coordinator = new AgentBuilder("coordinator", model) .name("Incident Coordinator") .instructions( [ diff --git a/examples/cookbook/07_multi_agent/02-parallel-specialists.ts b/examples/cookbook/07_multi_agent/02-parallel-specialists.ts index 729cdf34..e14fbb76 100644 --- a/examples/cookbook/07_multi_agent/02-parallel-specialists.ts +++ b/examples/cookbook/07_multi_agent/02-parallel-specialists.ts @@ -3,8 +3,8 @@ import { PipelineBuilder } from "@anvia/core/pipeline"; import { OpenAIClient } from "@anvia/openai"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const incident = [ @@ -14,8 +14,9 @@ const incident = [ "Constraint: do not claim a root cause until engineering verifies it.", ].join("\n"); -const supportAgentModel = client.completionModel("deepseek/deepseek-v4-pro"); -const supportAgent = new AgentBuilder("support", supportAgentModel) +const model = client.completionModel("gpt-5.5"); + +const supportAgent = new AgentBuilder("support", model) .name("Support Specialist") .instructions( [ @@ -25,8 +26,7 @@ const supportAgent = new AgentBuilder("support", supportAgentModel) ) .build(); -const engineeringAgentModel = client.completionModel("deepseek/deepseek-v4-pro"); -const engineeringAgent = new AgentBuilder("engineering", engineeringAgentModel) +const engineeringAgent = new AgentBuilder("engineering", model) .name("Engineering Specialist") .instructions( [ @@ -36,8 +36,7 @@ const engineeringAgent = new AgentBuilder("engineering", engineeringAgentModel) ) .build(); -const commsAgentModel = client.completionModel("deepseek/deepseek-v4-pro"); -const commsAgent = new AgentBuilder("comms", commsAgentModel) +const commsAgent = new AgentBuilder("comms", model) .name("Customer Comms Specialist") .instructions( [ @@ -47,8 +46,7 @@ const commsAgent = new AgentBuilder("comms", commsAgentModel) ) .build(); -const synthesizerAgentModel = client.completionModel("deepseek/deepseek-v4-pro"); -const synthesizerAgent = new AgentBuilder("synthesizer", synthesizerAgentModel) +const synthesizerAgent = new AgentBuilder("synthesizer", model) .name("Incident Synthesizer") .instructions( [ diff --git a/examples/cookbook/07_multi_agent/03-streaming-agent-tools.ts b/examples/cookbook/07_multi_agent/03-streaming-agent-tools.ts new file mode 100644 index 00000000..26837a1d --- /dev/null +++ b/examples/cookbook/07_multi_agent/03-streaming-agent-tools.ts @@ -0,0 +1,82 @@ +import { AgentBuilder, type AgentStreamEvent } from "@anvia/core/agent"; +import { OpenAIClient } from "@anvia/openai"; + +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); + +const model = client.completionModel("gpt-5.5"); + +const supportAgent = new AgentBuilder("support", model) + .name("Support Specialist") + .description("Summarize customer impact and support next steps.") + .instructions("Return compact support triage bullets using only the provided facts.") + .build(); + +const engineeringAgent = new AgentBuilder("engineering", model) + .name("Engineering Specialist") + .description("Summarize diagnostics and engineering next steps.") + .instructions("Return compact engineering triage bullets without unverified root-cause claims.") + .build(); + +const coordinator = new AgentBuilder("coordinator", model) + .name("Incident Coordinator") + .instructions( + [ + "Coordinate specialist agents through tools.", + "Call specialists when their expertise is useful.", + "Combine specialist findings into one concise incident brief.", + ].join("\n"), + ) + .tools([ + supportAgent.asTool({ name: "ask_support_agent", stream: true }), + engineeringAgent.asTool({ name: "ask_engineering_agent", stream: true }), + ]) + .defaultMaxTurns(4) + .build(); + +const prompt = [ + "Acme Co. reports webhook retries fail for payloads larger than 512 KB.", + "They have missed several order updates in the last hour.", + "Prepare an incident brief for support and engineering.", +].join(" "); + +for await (const event of coordinator.prompt(prompt).withToolConcurrency(2).stream()) { + renderEvent(event); +} + +function renderEvent(event: AgentStreamEvent): void { + if (event.type === "tool_call") { + console.log("\ndelegating:", event.toolCall.function.name); + } + + if (event.type === "agent_tool_event") { + renderChildEvent(event.agentName ?? event.agentId, event.event); + } + + if (event.type === "text_delta") { + process.stdout.write(event.delta); + } + + if (event.type === "final") { + process.stdout.write("\n"); + } +} + +function renderChildEvent( + agentLabel: string, + event: Extract["event"], +): void { + if (event.type === "text_delta") { + process.stdout.write(`\n[${agentLabel}] ${event.delta}`); + } + + if (event.type === "tool_call") { + console.log(`\n[${agentLabel}] tool call:`, event.toolCall.function.name); + } + + if (event.type === "tool_result") { + console.log(`\n[${agentLabel}] tool result:`, event.toolName); + } +} diff --git a/examples/cookbook/07_multi_agent/04-agent-event-store.ts b/examples/cookbook/07_multi_agent/04-agent-event-store.ts new file mode 100644 index 00000000..8534f672 --- /dev/null +++ b/examples/cookbook/07_multi_agent/04-agent-event-store.ts @@ -0,0 +1,80 @@ +import { + AgentBuilder, + type AgentEventAppendInput, + type AgentEventRecord, + type AgentEventStore, +} from "@anvia/core/agent"; +import { OpenAIClient } from "@anvia/openai"; + +class InMemoryAgentEventStore implements AgentEventStore { + readonly records: AgentEventRecord[] = []; + + async append(input: AgentEventAppendInput): Promise { + this.records.push({ ...input, createdAt: new Date() }); + } + + async load(runId: string): Promise { + return this.records.filter((record) => record.runId === runId); + } + + async clear(runId: string): Promise { + const remaining = this.records.filter((record) => record.runId !== runId); + this.records.length = 0; + this.records.push(...remaining); + } +} + +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); + +const model = client.completionModel("gpt-5.5"); + +const supportAgent = new AgentBuilder("support", model) + .name("Support Specialist") + .description("Summarize customer impact and support next steps.") + .instructions("Return compact support triage bullets using only the provided facts.") + .build(); + +const eventStore = new InMemoryAgentEventStore(); + +const coordinator = new AgentBuilder("coordinator", model) + .name("Incident Coordinator") + .instructions("Delegate support triage, then produce a short final brief.") + .tool(supportAgent.asTool({ name: "ask_support_agent", stream: true })) + .eventStore(eventStore, { include: "all" }) + .defaultMaxTurns(3) + .build(); + +const prompt = [ + "Acme Co. reports webhook retries fail for payloads larger than 512 KB.", + "They have missed several order updates in the last hour.", + "Prepare a short support incident brief.", +].join(" "); + +let runId: string | undefined; +for await (const event of coordinator.prompt(prompt).stream()) { + if (event.type === "text_delta") { + process.stdout.write(event.delta); + } + if (event.type === "final") { + runId = event.runId; + } +} + +if (runId !== undefined) { + const savedEvents = await eventStore.load(runId); + const nestedEvents = savedEvents.filter( + (record) => eventType(record.event) === "agent_tool_event", + ); + + console.log("\n\nstored runtime events:", savedEvents.length); + console.log("stored child-agent events:", nestedEvents.length); +} + +function eventType(event: unknown): string | undefined { + return typeof event === "object" && event !== null && "type" in event + ? String(event.type) + : undefined; +} diff --git a/examples/cookbook/08_evals/04-agent-eval-target.ts b/examples/cookbook/08_evals/04-agent-eval-target.ts index 1d4f1ab3..d9520736 100644 --- a/examples/cookbook/08_evals/04-agent-eval-target.ts +++ b/examples/cookbook/08_evals/04-agent-eval-target.ts @@ -2,8 +2,11 @@ import { AgentBuilder, type PromptResponse } from "@anvia/core/agent"; import { agentEvalTarget, contains, exactMatch, runEvalSuite } from "@anvia/core/evals"; import { OpenAIClient } from "@anvia/openai"; -const openAIClient = new OpenAIClient({ apiKey: requireEnv("OPENAI_API_KEY") }); -const model = openAIClient.completionModel(process.env.OPENAI_MODEL ?? "gpt-5.5"); +const openAIClient = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); +const model = openAIClient.completionModel("gpt-5.5"); const agent = new AgentBuilder("support-policy-agent", model) .instructions( @@ -63,11 +66,3 @@ console.log({ failed: result.failed, invalid: result.invalid, }); - -function requireEnv(name: string): string { - const value = process.env[name]; - if (value === undefined || value.length === 0) { - throw new Error(`Set ${name} before running this cookbook example.`); - } - return value; -} diff --git a/examples/cookbook/08_evals/05-llm-judge-and-score.ts b/examples/cookbook/08_evals/05-llm-judge-and-score.ts index 08f66dbc..062442d3 100644 --- a/examples/cookbook/08_evals/05-llm-judge-and-score.ts +++ b/examples/cookbook/08_evals/05-llm-judge-and-score.ts @@ -2,8 +2,11 @@ import { llmJudge, llmScore, runEvalSuite } from "@anvia/core/evals"; import { OpenAIClient } from "@anvia/openai"; import { z } from "zod"; -const openAIClient = new OpenAIClient({ apiKey: requireEnv("OPENAI_API_KEY") }); -const model = openAIClient.completionModel(process.env.OPENAI_MODEL ?? "gpt-5.5"); +const openAIClient = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); +const model = openAIClient.completionModel("gpt-5.5"); const cases = [ { @@ -83,11 +86,3 @@ function scoreForTable(score: unknown): string | number | boolean { } return ""; } - -function requireEnv(name: string): string { - const value = process.env[name]; - if (value === undefined || value.length === 0) { - throw new Error(`Set ${name} before running this cookbook example.`); - } - return value; -} diff --git a/examples/cookbook/09_studio/01-single-agent.ts b/examples/cookbook/09_studio/01-single-agent.ts index d4136d18..9ee04f70 100644 --- a/examples/cookbook/09_studio/01-single-agent.ts +++ b/examples/cookbook/09_studio/01-single-agent.ts @@ -5,8 +5,8 @@ import { Studio } from "@anvia/studio"; import { z } from "zod"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const getOrder = createTool({ @@ -29,7 +29,7 @@ const getOrder = createTool({ }), }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("support-operations", agentModel) .name("Support Operations") .description("Answers operational questions with short, concrete summaries.") diff --git a/examples/cookbook/09_studio/02-multi-agent.ts b/examples/cookbook/09_studio/02-multi-agent.ts index 633c6269..d8f71128 100644 --- a/examples/cookbook/09_studio/02-multi-agent.ts +++ b/examples/cookbook/09_studio/02-multi-agent.ts @@ -5,8 +5,8 @@ import { Studio } from "@anvia/studio"; import { z } from "zod"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const getTicket = createTool({ @@ -53,7 +53,7 @@ const getRunbook = createTool({ }), }); -const supportAgentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const supportAgentModel = client.completionModel("gpt-5.5"); const supportAgent = new AgentBuilder("support-triage", supportAgentModel) .name("Support Triage") .description("Summarizes customer-facing support tickets.") @@ -62,7 +62,7 @@ const supportAgent = new AgentBuilder("support-triage", supportAgentModel) .defaultMaxTurns(2) .build(); -const engineeringAgentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const engineeringAgentModel = client.completionModel("gpt-5.5"); const engineeringAgent = new AgentBuilder("engineering-triage", engineeringAgentModel) .name("Engineering Triage") .description("Turns incidents and runbooks into engineering next steps.") @@ -71,7 +71,7 @@ const engineeringAgent = new AgentBuilder("engineering-triage", engineeringAgent .defaultMaxTurns(2) .build(); -const commsAgentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const commsAgentModel = client.completionModel("gpt-5.5"); const commsAgent = new AgentBuilder("customer-comms", commsAgentModel) .name("Customer Comms") .description("Drafts concise customer updates for incidents.") diff --git a/examples/cookbook/09_studio/03-tool-approval.ts b/examples/cookbook/09_studio/03-tool-approval.ts index 9ffa764b..1c4165e9 100644 --- a/examples/cookbook/09_studio/03-tool-approval.ts +++ b/examples/cookbook/09_studio/03-tool-approval.ts @@ -1,12 +1,12 @@ -import { AgentBuilder } from "@anvia/core/agent"; +import { AgentBuilder, createHook } from "@anvia/core/agent"; import { createTool } from "@anvia/core/tool"; import { OpenAIClient } from "@anvia/openai"; import { Studio } from "@anvia/studio"; import { z } from "zod"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const getOrder = createTool({ @@ -58,18 +58,49 @@ const issueRefund = createTool({ }), }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const cancelOrder = createTool({ + name: "cancel_order", + description: "Cancel an order before fulfillment. This is guarded by a hook approval.", + input: z.object({ + orderId: z.string().describe("The order id to cancel."), + reason: z.string().describe("The reason to record with the cancellation."), + }), + output: z.object({ + orderId: z.string(), + status: z.enum(["cancelled"]), + }), + execute: ({ orderId }) => ({ + orderId, + status: "cancelled" as const, + }), +}); + +const approvalHook = createHook({ + onToolCall({ toolName, args, tool }) { + if (toolName === "cancel_order") { + return tool.requestApproval({ + reason: `Review order cancellation request: ${args}`, + rejectMessage: "Order cancellation rejected in Anvia Studio.", + }); + } + + return tool.run(); + }, +}); + +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("studio-support-operations", agentModel) .name("Studio Support Operations") .description("Handles operational order lookups and guarded refund actions.") .instructions( [ "Use tools for private order data and refund operations.", - "Look up an order before issuing a refund.", - "Keep responses short and mention whether the refund was issued or denied.", + "Look up an order before issuing a refund or cancellation.", + "Keep responses short and mention whether the guarded action was issued, cancelled, or denied.", ].join("\n"), ) - .tools([getOrder, issueRefund]) + .tools([getOrder, issueRefund, cancelOrder]) + .hook(approvalHook) .defaultMaxTurns(5) .build(); diff --git a/examples/cookbook/09_studio/04-ask-question.ts b/examples/cookbook/09_studio/04-ask-question.ts index 4c903741..87798b86 100644 --- a/examples/cookbook/09_studio/04-ask-question.ts +++ b/examples/cookbook/09_studio/04-ask-question.ts @@ -5,8 +5,8 @@ import { Studio } from "@anvia/studio"; import { z } from "zod"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const questionChoiceSchema = z.object({ @@ -68,7 +68,7 @@ const prepareEscalation = createTool({ }), }); -const agentModel = client.completionModel("z-ai/glm-5.1"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("studio-human-feedback", agentModel) .name("Studio Human Feedback") .description("Collects missing operator input through Studio before acting.") diff --git a/examples/cookbook/09_studio/05-knowledge-inspector.ts b/examples/cookbook/09_studio/05-knowledge-inspector.ts index 14d12a59..134537d5 100644 --- a/examples/cookbook/09_studio/05-knowledge-inspector.ts +++ b/examples/cookbook/09_studio/05-knowledge-inspector.ts @@ -20,8 +20,8 @@ class KeywordEmbeddingModel implements EmbeddingModel { } const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const notes: KnowledgeNote[] = [ @@ -91,7 +91,7 @@ const embeddedNotes = await embedDocuments(embeddings, notes, { const knowledgeIndex = InMemoryVectorStore.fromDocuments(embeddedNotes).index(embeddings); const toolIndex = await createToolIndex(embeddings, [getTicket, lookupCustomer]); -const model = client.completionModel("deepseek/deepseek-v4-pro"); +const model = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("studio-knowledge-ops", model) .name("Studio Knowledge Ops") .description("Demonstrates the Studio Knowledge inspector.") diff --git a/examples/cookbook/09_studio/06-subagents.ts b/examples/cookbook/09_studio/06-subagents.ts new file mode 100644 index 00000000..2f4b60ce --- /dev/null +++ b/examples/cookbook/09_studio/06-subagents.ts @@ -0,0 +1,127 @@ +import { AgentBuilder } from "@anvia/core/agent"; +import { createTool } from "@anvia/core/tool"; +import { OpenAIClient } from "@anvia/openai"; +import { Studio } from "@anvia/studio"; +import { z } from "zod"; + +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); + +const model = client.completionModel("gpt-5.5"); + +const getTicket = createTool({ + name: "get_ticket", + description: "Read a support ticket from local application state.", + input: z.object({ + id: z.string().describe("The support ticket id."), + }), + output: z.object({ + id: z.string(), + customer: z.string(), + priority: z.enum(["low", "medium", "high"]), + status: z.string(), + summary: z.string(), + }), + execute: ({ id }) => ({ + id, + customer: "Acme Co.", + priority: "high" as const, + status: "waiting_on_engineering", + summary: "Webhook retries fail when payloads are larger than 512 KB.", + }), +}); + +const getRunbook = createTool({ + name: "get_runbook", + description: "Read an internal incident runbook excerpt.", + input: z.object({ + name: z.string().describe("The runbook name."), + }), + output: z.object({ + name: z.string(), + owner: z.string(), + checklist: z.array(z.string()), + }), + execute: ({ name }) => ({ + name, + owner: "Platform Engineering", + checklist: [ + "Check retry queue depth.", + "Inspect payload-size rejection logs.", + "Confirm whether retries are being dropped or delayed.", + "Prepare replay instructions for missed order updates.", + ], + }), +}); + +const supportAgent = new AgentBuilder("subagent-support", model) + .name("Support Subagent") + .description("Summarizes customer impact from support tickets.") + .instructions( + [ + "Use ticket data when available.", + "Return customer impact, severity, and support follow-up.", + "Do not include engineering remediation unless it is directly in the ticket.", + ].join("\n"), + ) + .tool(getTicket) + .defaultMaxTurns(2) + .build(); + +const engineeringAgent = new AgentBuilder("subagent-engineering", model) + .name("Engineering Subagent") + .description("Turns runbooks and incident facts into engineering diagnostics.") + .instructions( + [ + "Use runbook data when available.", + "Return likely diagnostic checks, owner, and immediate mitigation options.", + "Avoid customer-facing language.", + ].join("\n"), + ) + .tool(getRunbook) + .defaultMaxTurns(2) + .build(); + +const commsAgent = new AgentBuilder("subagent-comms", model) + .name("Comms Subagent") + .description("Drafts concise customer updates from incident facts.") + .instructions( + [ + "Draft customer-facing updates.", + "Acknowledge impact without claiming an unverified root cause.", + "Include the next checkpoint time when useful.", + ].join("\n"), + ) + .build(); + +const coordinator = new AgentBuilder("studio-subagent-coordinator", model) + .name("Studio Subagent Coordinator") + .description("Delegates incident work to specialist subagents and synthesizes the result.") + .instructions( + [ + "You are the coordinator visible in Studio.", + "Delegate support impact work to ask_support_subagent.", + "Delegate engineering diagnostics to ask_engineering_subagent.", + "Delegate customer-facing copy to ask_comms_subagent when the user asks for communication.", + "Combine specialist outputs into one concise operator-ready answer.", + ].join("\n"), + ) + .tools([ + supportAgent.asTool({ name: "ask_support_subagent", stream: true }), + engineeringAgent.asTool({ name: "ask_engineering_subagent", stream: true }), + commsAgent.asTool({ name: "ask_comms_subagent", stream: true }), + ]) + .defaultMaxTurns(4) + .build(); + +new Studio([coordinator], { + quickPrompts: { + "studio-subagent-coordinator": [ + "Prepare an incident brief for TICKET-1001 and include engineering next steps.", + "Draft a customer update for Acme Co. about the webhook retry incident.", + "Use the support and engineering subagents to decide the next operator action.", + ], + }, +}).start(); diff --git a/examples/cookbook/09_studio/07-pipeline-inspector.ts b/examples/cookbook/09_studio/07-pipeline-inspector.ts new file mode 100644 index 00000000..721e13b7 --- /dev/null +++ b/examples/cookbook/09_studio/07-pipeline-inspector.ts @@ -0,0 +1,74 @@ +import { AgentBuilder } from "@anvia/core/agent"; +import { PipelineBuilder } from "@anvia/core/pipeline"; +import { OpenAIClient } from "@anvia/openai"; +import { Studio } from "@anvia/studio"; + +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); + +const replyModel = client.completionModel("gpt-5.5"); +const replyAgent = new AgentBuilder("studio-reply-drafter", replyModel) + .name("Studio Reply Drafter") + .description("Drafts short support replies from normalized ticket context.") + .instructions( + [ + "Draft concise customer support replies.", + "Use only the ticket context provided by the pipeline.", + "Mention the priority and the next operational step.", + ].join("\n"), + ) + .build(); + +const ticketPipeline = new PipelineBuilder({ + id: "ticket-triage-pipeline", + name: "Ticket Triage Pipeline", + description: "Normalizes a ticket, computes metadata, then drafts a reply.", + metadata: { + owner: "support-operations", + }, +}) + .step((ticket) => ticket.trim(), { + name: "Normalize Ticket", + description: "Trim pasted ticket text before branching.", + }) + .parallel( + { + classification: new PipelineBuilder() + .step((ticket) => ({ + topic: ticket.toLowerCase().includes("payment") ? "billing" : "operations", + })) + .build(), + priority: new PipelineBuilder() + .step((ticket) => ({ + priority: + ticket.toLowerCase().includes("outage") || ticket.toLowerCase().includes("enterprise") + ? "high" + : "normal", + })) + .build(), + }, + { + name: "Analyze Ticket", + description: "Run deterministic branch checks for Studio graph inspection.", + }, + ) + .step( + ({ classification, priority }) => + [ + `Topic: ${classification.topic}`, + `Priority: ${priority.priority}`, + "Ticket: Enterprise customer reports payment retries causing checkout outage.", + ].join("\n"), + { + name: "Prepare Reply Prompt", + }, + ) + .prompt(replyAgent, { + name: "Draft Reply", + description: "Send the prepared context to the reply agent.", + }) + .build(); + +new Studio([replyAgent, ticketPipeline]).start(); diff --git a/examples/cookbook/09_studio/08-multiple-pipelines.ts b/examples/cookbook/09_studio/08-multiple-pipelines.ts new file mode 100644 index 00000000..39a9fa90 --- /dev/null +++ b/examples/cookbook/09_studio/08-multiple-pipelines.ts @@ -0,0 +1,140 @@ +import { PipelineBuilder } from "@anvia/core/pipeline"; +import { Studio } from "@anvia/studio"; + +type OrderSnapshot = { + id: string; + status: "processing" | "blocked" | "shipped" | "unknown"; + customer: string; + notes: string; +}; + +const orders: Record = { + "11001": { + id: "11001", + status: "blocked", + customer: "Delta Kit Labs", + notes: "Payment review is complete, but warehouse allocation has not been confirmed.", + }, + "11002": { + id: "11002", + status: "processing", + customer: "Northwind Labs", + notes: "Picking is queued for the next warehouse wave.", + }, + "11003": { + id: "11003", + status: "shipped", + customer: "Aster Supply", + notes: "Carrier pickup completed and tracking is active.", + }, +}; + +const orderStatusPipeline = new PipelineBuilder({ + id: "order-status-pipeline", + name: "Order Status Pipeline", + description: + "Normalizes an order lookup, reads local order state, and returns an operator summary.", + metadata: { + owner: "support-operations", + sampleInput: "ORDER 11001", + }, +}) + .step((raw) => raw.trim().replace(/^order\s+/i, ""), { + name: "Normalize Order Id", + description: "Accept either a bare order id or input like ORDER 11001.", + }) + .step( + (orderId): OrderSnapshot => + orders[orderId] ?? { + id: orderId, + status: "unknown", + customer: "Unknown", + notes: "No local order snapshot was found for this id.", + }, + { + name: "Read Order Snapshot", + description: "Look up the order in local application state.", + }, + ) + .step( + (order) => ({ + title: `Order ${order.id}`, + status: order.status, + customer: order.customer, + nextAction: + order.status === "blocked" + ? "Ask warehouse operations to confirm allocation." + : order.status === "processing" + ? "Monitor the next warehouse wave." + : order.status === "shipped" + ? "Share the active tracking status with the customer." + : "Ask the customer to verify the order id.", + notes: order.notes, + }), + { + name: "Build Operator Summary", + description: "Return a compact result object for Studio inspection.", + }, + ) + .build(); + +const ticketRoutingPipeline = new PipelineBuilder({ + id: "ticket-routing-pipeline", + name: "Ticket Routing Pipeline", + description: "Classifies a pasted support ticket and recommends an operational route.", + metadata: { + owner: "support-operations", + sampleInput: "Enterprise customer reports checkout outage after payment retries failed.", + }, +}) + .step((ticket) => ticket.trim(), { + name: "Normalize Ticket", + description: "Trim pasted ticket text before deterministic branch analysis.", + }) + .parallel( + { + classification: new PipelineBuilder() + .step((ticket) => ({ + topic: ticket.toLowerCase().includes("payment") ? "billing" : "operations", + })) + .build(), + priority: new PipelineBuilder() + .step((ticket) => ({ + priority: + ticket.toLowerCase().includes("outage") || + ticket.toLowerCase().includes("enterprise") || + ticket.toLowerCase().includes("blocked") + ? "high" + : "normal", + })) + .build(), + routing: new PipelineBuilder() + .step((ticket) => ({ + team: ticket.toLowerCase().includes("payment") ? "billing-ops" : "support-ops", + })) + .build(), + }, + { + name: "Analyze Ticket", + description: "Run independent deterministic classifiers in parallel.", + }, + ) + .step( + ({ classification, priority, routing }) => ({ + topic: classification.topic, + priority: priority.priority, + team: routing.team, + handoffRequired: priority.priority === "high", + nextAction: + priority.priority === "high" + ? `Escalate to ${routing.team} with the incident context.` + : `Queue for ${routing.team} review.`, + }), + { + name: "Build Routing Decision", + description: "Merge branch outputs into one routing object.", + }, + ) + .build(); + +new Studio([orderStatusPipeline, ticketRoutingPipeline]).start(); diff --git a/examples/cookbook/09_studio/09-inspection-surfaces.ts b/examples/cookbook/09_studio/09-inspection-surfaces.ts new file mode 100644 index 00000000..543c0c39 --- /dev/null +++ b/examples/cookbook/09_studio/09-inspection-surfaces.ts @@ -0,0 +1,83 @@ +import { AgentBuilder } from "@anvia/core/agent"; +import { createTool } from "@anvia/core/tool"; +import { OpenAIClient } from "@anvia/openai"; +import { Studio } from "@anvia/studio"; +import { z } from "zod"; + +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); + +const tickets = new Map([ + [ + "TICKET-1001", + { + id: "TICKET-1001", + customer: "Delta Kit Labs", + status: "waiting_on_engineering" as const, + priority: "high" as const, + summary: "Checkout webhook retries are delayed for EU tenants.", + }, + ], + [ + "TICKET-1002", + { + id: "TICKET-1002", + customer: "Northstar Supply", + status: "monitoring" as const, + priority: "medium" as const, + summary: "Invoice export is slower than usual after a plan migration.", + }, + ], +]); + +const getTicket = createTool({ + name: "get_ticket", + description: "Read a support ticket from local application state.", + input: z.object({ + id: z.string().describe("Ticket id, for example TICKET-1001."), + }), + output: z.object({ + id: z.string(), + customer: z.string(), + status: z.enum(["waiting_on_engineering", "monitoring"]), + priority: z.enum(["high", "medium"]), + summary: z.string(), + }), + execute: ({ id }) => { + const ticket = tickets.get(id); + if (ticket === undefined) { + throw new Error(`Unknown ticket: ${id}`); + } + return ticket; + }, +}); + +const model = client.completionModel("gpt-5.5"); +const agent = new AgentBuilder("studio-inspection-surfaces", model) + .name("Studio Inspection Surfaces") + .description("Demonstrates Memory, Status, tool runner, and richer agent inspection.") + .instructions( + [ + "Use get_ticket when the user asks about a ticket.", + "Answer with status, priority, customer, and a short next action.", + ].join("\n"), + ) + .tool(getTicket) + .defaultMaxTurns(4) + .build(); + +new Studio([agent], { + quickPrompts: { + "studio-inspection-surfaces": [ + "Summarize TICKET-1001 and give the next support action.", + "Check TICKET-1002 and explain what should be monitored.", + ], + }, +}).start({ port: 4021 }); + +console.log("Open http://localhost:4021/ui/tools to run get_ticket directly."); +console.log("Open http://localhost:4021/ui/memory after creating a session."); +console.log("Open http://localhost:4021/ui/status for runtime counts and capabilities."); +console.log("Open http://localhost:4021/status for the raw status API response."); diff --git a/examples/cookbook/09_studio/10-persistent-store.ts b/examples/cookbook/09_studio/10-persistent-store.ts new file mode 100644 index 00000000..38309e5b --- /dev/null +++ b/examples/cookbook/09_studio/10-persistent-store.ts @@ -0,0 +1,119 @@ +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { AgentBuilder } from "@anvia/core/agent"; +import { PipelineBuilder } from "@anvia/core/pipeline"; +import { createTool } from "@anvia/core/tool"; +import { OpenAIClient } from "@anvia/openai"; +import { createSqliteSessionStore, Studio } from "@anvia/studio"; +import { z } from "zod"; + +const dbPath = process.env.ANVIA_STUDIO_DB ?? ".anvia-studio/cookbook-studio.sqlite"; +mkdirSync(dirname(dbPath), { recursive: true }); + +const store = createSqliteSessionStore({ path: dbPath }); + +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); + +const getEscalation = createTool({ + name: "get_escalation", + description: "Read the current escalation owner for an operational area.", + input: z.object({ + area: z + .string() + .optional() + .describe("Operational area, such as billing, fulfillment, webhooks, payments, or shipping."), + }), + output: z.object({ + area: z.string(), + owner: z.string(), + nextAction: z.string(), + }), + execute: ({ area }) => { + const normalizedArea = escalationArea(area ?? "webhooks"); + return { + area: normalizedArea, + owner: + normalizedArea === "billing" + ? "billing-ops" + : normalizedArea === "fulfillment" + ? "warehouse-ops" + : "platform", + nextAction: + normalizedArea === "billing" + ? "Attach payment event ids to the handoff." + : normalizedArea === "fulfillment" + ? "Confirm allocation before promising shipment timing." + : "Include retry queue depth and recent deploy ids.", + }; + }, +}); + +const model = client.completionModel("gpt-5.5"); +const agent = new AgentBuilder("studio-persistent-ops", model) + .name("Studio Persistent Ops") + .description("Demonstrates persisted Studio sessions, traces, pipeline logs, and run history.") + .instructions("Use get_escalation when the user asks who owns an operational follow-up.") + .tool(getEscalation) + .defaultMaxTurns(4) + .build(); + +const escalationPipeline = new PipelineBuilder({ + id: "persistent-escalation-pipeline", + name: "Persistent Escalation Pipeline", + description: "Creates pipeline logs and replayable run history in the same SQLite store.", +}) + .step((area) => area.trim().toLowerCase(), { + name: "Normalize Area", + }) + .step((area) => ({ + area, + severity: area.includes("webhook") ? "high" : "normal", + owner: area.includes("billing") + ? "billing-ops" + : area.includes("fulfillment") + ? "warehouse-ops" + : "platform", + })) + .build(); + +new Studio([agent, escalationPipeline], { + stores: { + sessions: store, + traces: store, + pipelineLogs: store, + pipelineRuns: store, + }, + quickPrompts: { + "studio-persistent-ops": [ + "Who owns the webhook escalation and what context should I include?", + "Who owns a fulfillment allocation issue?", + ], + }, +}).start({ port: 4021 }); + +console.log(`Studio state is persisted in ${dbPath}`); +console.log("Open http://localhost:4021/ui/playground and create a session."); +console.log("Restart this example, then open /ui/sessions, /ui/tracing, and /ui/pipelines."); + +function escalationArea(area: string): "billing" | "fulfillment" | "webhooks" { + const normalized = area.toLowerCase(); + if ( + normalized.includes("billing") || + normalized.includes("payment") || + normalized.includes("invoice") + ) { + return "billing"; + } + if ( + normalized.includes("fulfillment") || + normalized.includes("shipping") || + normalized.includes("warehouse") || + normalized.includes("allocation") + ) { + return "fulfillment"; + } + return "webhooks"; +} diff --git a/examples/cookbook/09_studio/11-eval-runner.ts b/examples/cookbook/09_studio/11-eval-runner.ts new file mode 100644 index 00000000..6585c640 --- /dev/null +++ b/examples/cookbook/09_studio/11-eval-runner.ts @@ -0,0 +1,45 @@ +import { contains, exactMatch } from "@anvia/core/evals"; +import { Studio } from "@anvia/studio"; + +const supportPolicyEval = { + id: "studio-support-policy", + name: "Studio Support Policy", + description: "Runs a deterministic eval suite from the Studio Evals page.", + cases: [ + { + id: "refund-window", + input: "When can customers request a refund?", + expected: "Refunds are available for 30 days.", + }, + { + id: "billing-owner", + input: "Who can change billing settings?", + expected: "Workspace owners can change billing settings.", + }, + ], + target: async (input: string) => answerSupportPolicy(input), + metrics: [ + exactMatch(), + contains({ + expected: ({ case: testCase }) => + testCase.id === "refund-window" ? "30 days" : "Workspace owners", + }), + ], +}; + +new Studio([], { + evals: [supportPolicyEval], +}).start({ port: 4021 }); + +console.log("Open http://localhost:4021/ui/evals to run the Studio Support Policy suite."); +console.log("The raw API is available at http://localhost:4021/evals/studio-support-policy/runs"); + +function answerSupportPolicy(question: string): string { + if (question.includes("refund")) { + return "Refunds are available for 30 days."; + } + if (question.includes("billing")) { + return "Workspace owners can change billing settings."; + } + return "Please contact support."; +} diff --git a/examples/cookbook/09_studio/12-ui-options.ts b/examples/cookbook/09_studio/12-ui-options.ts new file mode 100644 index 00000000..5bd874ce --- /dev/null +++ b/examples/cookbook/09_studio/12-ui-options.ts @@ -0,0 +1,59 @@ +import { AgentBuilder } from "@anvia/core/agent"; +import { createTool } from "@anvia/core/tool"; +import { OpenAIClient } from "@anvia/openai"; +import { Studio } from "@anvia/studio"; +import { z } from "zod"; + +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); + +const getRunbook = createTool({ + name: "get_runbook", + description: "Read a short runbook by area.", + input: z.object({ + area: z.enum(["payments", "shipping", "incidents"]), + }), + output: z.object({ + area: z.string(), + checklist: z.array(z.string()), + }), + execute: ({ area }) => ({ + area, + checklist: + area === "payments" + ? ["Collect payment event ids", "Check retry status", "Escalate to billing-ops"] + : area === "shipping" + ? ["Confirm allocation", "Check carrier pickup", "Update customer timeline"] + : ["Assign incident owner", "Open customer channel", "Set next checkpoint"], + }), +}); + +const model = client.completionModel("gpt-5.5"); +const agent = new AgentBuilder("studio-custom-ui", model) + .name("Studio Custom UI") + .description("Demonstrates custom Studio UI path, title, and root-route behavior.") + .instructions("Use get_runbook when the user asks for an operational checklist.") + .tool(getRunbook) + .build(); + +new Studio([agent], { + ui: { + path: "/ops", + title: "Operations Studio", + rootRoutes: false, + redirectRoot: true, + }, + quickPrompts: { + "studio-custom-ui": [ + "Give me the payments runbook checklist.", + "What should I do for an incident update?", + ], + }, +}).start({ port: 4021 }); + +console.log("Open http://localhost:4021/ops/playground"); +console.log( + "Root redirects to the custom UI path, and root aliases like /playground are disabled.", +); diff --git a/examples/cookbook/10_integrations/01-mcp-tools.ts b/examples/cookbook/10_integrations/01-mcp-tools.ts index 7c4f9e55..1e57051b 100644 --- a/examples/cookbook/10_integrations/01-mcp-tools.ts +++ b/examples/cookbook/10_integrations/01-mcp-tools.ts @@ -3,8 +3,8 @@ import { connectMcp, mcp } from "@anvia/core/mcp"; import { OpenAIClient } from "@anvia/openai"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const counterMcp = await connectMcp( @@ -16,7 +16,7 @@ const counterMcp = await connectMcp( ); try { - const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); + const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Use MCP tools for arithmetic and counter updates.") .mcp([counterMcp]) diff --git a/examples/cookbook/10_integrations/02-local-skills.ts b/examples/cookbook/10_integrations/02-local-skills.ts index cd0845a6..b9ca57bd 100644 --- a/examples/cookbook/10_integrations/02-local-skills.ts +++ b/examples/cookbook/10_integrations/02-local-skills.ts @@ -5,10 +5,10 @@ import { OpenAIClient } from "@anvia/openai"; const skills = await loadSkills(skill.local(new URL("../skills", import.meta.url).pathname)); const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions( [ diff --git a/examples/cookbook/10_integrations/03-langfuse-tracing.ts b/examples/cookbook/10_integrations/03-langfuse-tracing.ts index d4695c28..351a7669 100644 --- a/examples/cookbook/10_integrations/03-langfuse-tracing.ts +++ b/examples/cookbook/10_integrations/03-langfuse-tracing.ts @@ -5,8 +5,8 @@ import { OpenAIClient } from "@anvia/openai"; import { z } from "zod"; const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const tracing = langfuse.create({ publicKey: process.env.LANGFUSE_PUBLIC_KEY, @@ -37,7 +37,7 @@ const getTicket = createTool({ }), }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Use tools when useful. Answer with a short engineering-focused summary.") .observe(tracing) diff --git a/examples/cookbook/10_integrations/05-otel-tracing.ts b/examples/cookbook/10_integrations/05-otel-tracing.ts index acca1fcc..daebfcff 100644 --- a/examples/cookbook/10_integrations/05-otel-tracing.ts +++ b/examples/cookbook/10_integrations/05-otel-tracing.ts @@ -18,8 +18,8 @@ const sdk = new NodeSDK({ sdk.start(); const client = new OpenAIClient({ - baseUrl: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, }); const tracing = otel.create({ serviceName: "anvia-cookbook", @@ -46,7 +46,7 @@ const getTicket = createTool({ }), }); -const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +const agentModel = client.completionModel("gpt-5.5"); const agent = new AgentBuilder("agent", agentModel) .instructions("Use tools when useful. Answer with a short engineering-focused summary.") .observe(tracing) diff --git a/examples/cookbook/10_integrations/06-agent-logging.ts b/examples/cookbook/10_integrations/06-agent-logging.ts new file mode 100644 index 00000000..2e6024d7 --- /dev/null +++ b/examples/cookbook/10_integrations/06-agent-logging.ts @@ -0,0 +1,60 @@ +import { AgentBuilder } from "@anvia/core/agent"; +import { createTool } from "@anvia/core/tool"; +import { createLoggerObserver, createPinoLogger } from "@anvia/logger"; +import { OpenAIClient } from "@anvia/openai"; +import { z } from "zod"; + +const logger = createPinoLogger({ + name: "anvia-cookbook", + level: "info", +}); + +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); + +const lookupTicket = createTool({ + name: "lookup_ticket", + description: "Read a support ticket from local application state.", + input: z.object({ + id: z.string().describe("The ticket id to read."), + }), + output: z.object({ + id: z.string(), + title: z.string(), + status: z.enum(["open", "pending", "closed"]), + summary: z.string(), + }), + execute: ({ id }) => ({ + id, + title: "Checkout button disabled after address autocomplete", + status: "open" as const, + summary: + "Users can select an address, but checkout remains disabled until they reload the page.", + }), +}); + +const agent = new AgentBuilder("support-logger-demo", client.completionModel("gpt-5.5")) + .instructions("Use tools when useful. Answer with a short engineering-focused summary.") + .observe( + createLoggerObserver(logger, { + includeToolResult: true, + }), + ) + .tool(lookupTicket) + .defaultMaxTurns(2) + .build(); + +const response = await agent + .prompt("Summarize ticket TICKET-1001 for the product engineering team.") + .withTrace({ + name: "support-ticket-logging", + userId: "cookbook-user", + sessionId: "cookbook-session", + metadata: { ticketId: "TICKET-1001", example: "integrations:06" }, + tags: ["cookbook", "logging"], + }) + .send(); + +console.log(response.output); diff --git a/examples/cookbook/README.md b/examples/cookbook/README.md index 8e0ec40c..871572ba 100644 --- a/examples/cookbook/README.md +++ b/examples/cookbook/README.md @@ -20,24 +20,24 @@ Legacy script names such as `cookbook:basic:01`, `cookbook:intermediate:14`, `co | Section | Focus | | --- | --- | -| `01_basics` | First text calls, chat history, static context, streaming, and `ReadableStream` output. | +| `01_basics` | First text calls, explicit transcripts, static context, streaming, HTTP stream transports, `ReadableStream` output, and durable session memory. | | `02_tools` | Tool schemas, streamed tool events, hooks, concurrency, conditional tools, think tools, application state, history with tools, guarded tools, and dynamic tool selection. | | `03_structured_output` | Schema-first extraction, agent output schemas, context, retries, and extraction with prior messages. | -| `04_providers_and_multimodal` | Provider adapters, model capabilities, reasoning streams, image/PDF attachments, image generation, audio generation, and transcription. | +| `04_providers_and_multimodal` | Provider adapters, model capabilities, model listing, reasoning streams, image/PDF attachments, image generation, audio generation, and transcription. | | `05_pipelines` | Step transforms, async steps, composition, named parallel branches, batching, agents, extractors, and richer workflows. | | `06_retrieval` | Embeddings, in-memory search, metadata filters, RAG context, document loaders, vector stores, and embedding provider variants. | -| `07_multi_agent` | Agents as tools and pipeline-backed parallel specialists. | +| `07_multi_agent` | Basic agent-tools, pipeline-backed parallel specialists, streaming agent-tools, and event stores. | | `08_evals` | Deterministic metrics, semantic similarity, custom metrics, agent eval targets, and LLM judge/score. | -| `09_studio` | Single-agent and multi-agent Studio runners, tool approvals, human feedback, and Knowledge inspection. | -| `10_integrations` | MCP tools, local skills, Langfuse tracing, and Langfuse eval reporting. | +| `09_studio` | Single-agent, multi-agent, pipeline, eval, and subagent Studio runners, pipeline replay, realtime observability, tool approvals, human feedback, Knowledge, Memory, Status, tool inspection, SQLite persistence, and UI route options. | +| `10_integrations` | MCP tools, local skills, Langfuse tracing, logging, and eval reporting. | ## Environment Create a repository-root `.env` for examples that call provider APIs: ```sh -OPENROUTER_API_KEY=... OPENAI_API_KEY=... +OPENAI_BASEURL=... GEMINI_API_KEY=... MISTRAL_API_KEY=... LANGFUSE_PUBLIC_KEY=... @@ -60,9 +60,10 @@ Not every example needs every variable. Pure pipeline, dynamic tool, and core ev pnpm cookbook:retrieval:08 ``` -- `retrieval:08` uses the compose pgvector connection by default. Set `DATABASE_URL` to point it at another Postgres database. +- `retrieval:08` uses the compose pgvector connection on host port `5439` by default. Set `DATABASE_URL` to point it at another Postgres database. - Langfuse examples need Langfuse credentials and live in `10_integrations`. -- Studio examples start a local HTTP server and write Studio state under `.anvia-studio`. +- `integrations:06` logs agent lifecycle events with `@anvia/logger`. +- Studio examples start a local HTTP server and keep Studio state in memory unless `ANVIA_STUDIO_DB` is set. `studio:10` shows explicit SQLite store wiring for sessions, traces, pipeline logs, and pipeline run history. - Tool history and loader examples write sample files under `.memory`. - Image and audio generation examples write generated media files in the current working directory. - `providers:09` uses the bundled `assets/audio/voice.wav` sample by default. Set `ANVIA_AUDIO_FILE` to transcribe a different local audio file. diff --git a/examples/cookbook/compose.cookbook.yml b/examples/cookbook/compose.cookbook.yml index 762908d8..68ddf79b 100644 --- a/examples/cookbook/compose.cookbook.yml +++ b/examples/cookbook/compose.cookbook.yml @@ -38,7 +38,7 @@ services: pgvector: image: pgvector/pgvector:pg16 ports: - - "5432:5432" + - "5439:5432" environment: POSTGRES_DB: anvia POSTGRES_USER: anvia diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index 6d5cf639..893cd2da 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -11,6 +11,8 @@ "basics:03": "tsx -r dotenv/config 01_basics/03-static-context.ts dotenv_config_path=../../.env", "basics:04": "tsx -r dotenv/config 01_basics/04-stream-text.ts dotenv_config_path=../../.env", "basics:05": "tsx -r dotenv/config 01_basics/05-readable-stream-jsonl.ts dotenv_config_path=../../.env", + "basics:06": "tsx -r dotenv/config 01_basics/06-session-memory.ts dotenv_config_path=../../.env", + "basics:07": "tsx -r dotenv/config 01_basics/07-server-react-transport.ts dotenv_config_path=../../.env", "tools": "tsx -r dotenv/config 02_tools/01-tool-call.ts dotenv_config_path=../../.env", "tools:01": "tsx -r dotenv/config 02_tools/01-tool-call.ts dotenv_config_path=../../.env", "tools:02": "tsx -r dotenv/config 02_tools/02-tool-stream-events.ts dotenv_config_path=../../.env", @@ -21,6 +23,7 @@ "tools:07": "tsx -r dotenv/config 02_tools/07-tool-call-with-chat-history.ts dotenv_config_path=../../.env", "tools:08": "tsx -r dotenv/config 02_tools/08-tool-permission-hook.ts dotenv_config_path=../../.env", "tools:09": "tsx -r dotenv/config 02_tools/09-dynamic-tools.ts dotenv_config_path=../../.env", + "tools:10": "tsx -r dotenv/config 02_tools/10-tool-result-middleware.ts dotenv_config_path=../../.env", "structured-output": "tsx -r dotenv/config 03_structured_output/01-structured-extraction.ts dotenv_config_path=../../.env", "structured-output:01": "tsx -r dotenv/config 03_structured_output/01-structured-extraction.ts dotenv_config_path=../../.env", "structured-output:02": "tsx -r dotenv/config 03_structured_output/02-output-schema.ts dotenv_config_path=../../.env", @@ -35,6 +38,7 @@ "providers:07": "tsx -r dotenv/config 04_providers_and_multimodal/07-openai-image-generation.ts dotenv_config_path=../../.env", "providers:08": "tsx -r dotenv/config 04_providers_and_multimodal/08-openai-audio-and-transcription.ts dotenv_config_path=../../.env", "providers:09": "tsx -r dotenv/config 04_providers_and_multimodal/09-gemini-image-and-transcription.ts dotenv_config_path=../../.env", + "providers:10": "tsx -r dotenv/config 04_providers_and_multimodal/10-list-models.ts dotenv_config_path=../../.env", "providers-and-multimodal": "tsx -r dotenv/config 04_providers_and_multimodal/01-gemini-text-call.ts dotenv_config_path=../../.env", "pipelines": "tsx -r dotenv/config 05_pipelines/01-step-transform.ts dotenv_config_path=../../.env", "pipelines:01": "tsx -r dotenv/config 05_pipelines/01-step-transform.ts dotenv_config_path=../../.env", @@ -61,6 +65,8 @@ "multi-agent": "tsx -r dotenv/config 07_multi_agent/01-agent-as-tool.ts dotenv_config_path=../../.env", "multi-agent:01": "tsx -r dotenv/config 07_multi_agent/01-agent-as-tool.ts dotenv_config_path=../../.env", "multi-agent:02": "tsx -r dotenv/config 07_multi_agent/02-parallel-specialists.ts dotenv_config_path=../../.env", + "multi-agent:03": "tsx -r dotenv/config 07_multi_agent/03-streaming-agent-tools.ts dotenv_config_path=../../.env", + "multi-agent:04": "tsx -r dotenv/config 07_multi_agent/04-agent-event-store.ts dotenv_config_path=../../.env", "evals": "tsx -r dotenv/config 08_evals/01-basic-metrics.ts dotenv_config_path=../../.env", "evals:01": "tsx -r dotenv/config 08_evals/01-basic-metrics.ts dotenv_config_path=../../.env", "evals:02": "tsx -r dotenv/config 08_evals/02-semantic-similarity.ts dotenv_config_path=../../.env", @@ -73,12 +79,20 @@ "studio:03": "tsx -r dotenv/config 09_studio/03-tool-approval.ts dotenv_config_path=../../.env", "studio:04": "tsx -r dotenv/config 09_studio/04-ask-question.ts dotenv_config_path=../../.env", "studio:05": "tsx -r dotenv/config 09_studio/05-knowledge-inspector.ts dotenv_config_path=../../.env", + "studio:06": "tsx -r dotenv/config 09_studio/06-subagents.ts dotenv_config_path=../../.env", + "studio:07": "tsx -r dotenv/config 09_studio/07-pipeline-inspector.ts dotenv_config_path=../../.env", + "studio:08": "tsx -r dotenv/config 09_studio/08-multiple-pipelines.ts dotenv_config_path=../../.env", + "studio:09": "tsx -r dotenv/config 09_studio/09-inspection-surfaces.ts dotenv_config_path=../../.env", + "studio:10": "tsx -r dotenv/config 09_studio/10-persistent-store.ts dotenv_config_path=../../.env", + "studio:11": "tsx -r dotenv/config 09_studio/11-eval-runner.ts dotenv_config_path=../../.env", + "studio:12": "tsx -r dotenv/config 09_studio/12-ui-options.ts dotenv_config_path=../../.env", "integrations": "tsx -r dotenv/config 10_integrations/01-mcp-tools.ts dotenv_config_path=../../.env", "integrations:01": "tsx -r dotenv/config 10_integrations/01-mcp-tools.ts dotenv_config_path=../../.env", "integrations:02": "tsx -r dotenv/config 10_integrations/02-local-skills.ts dotenv_config_path=../../.env", "integrations:03": "tsx -r dotenv/config 10_integrations/03-langfuse-tracing.ts dotenv_config_path=../../.env", "integrations:04": "tsx -r dotenv/config 10_integrations/04-langfuse-eval-reporting.ts dotenv_config_path=../../.env", "integrations:05": "tsx -r dotenv/config 10_integrations/05-otel-tracing.ts dotenv_config_path=../../.env", + "integrations:06": "tsx -r dotenv/config 10_integrations/06-agent-logging.ts dotenv_config_path=../../.env", "basic": "tsx -r dotenv/config 01_basics/01-text-call.ts dotenv_config_path=../../.env", "basic:01": "tsx -r dotenv/config 01_basics/01-text-call.ts dotenv_config_path=../../.env", "basic:02": "tsx -r dotenv/config 01_basics/02-chat-history.ts dotenv_config_path=../../.env", @@ -104,6 +118,7 @@ "intermediate:12": "tsx -r dotenv/config 02_tools/08-tool-permission-hook.ts dotenv_config_path=../../.env", "intermediate:13": "tsx -r dotenv/config 04_providers_and_multimodal/04-rich-reasoning-content.ts dotenv_config_path=../../.env", "intermediate:14": "tsx -r dotenv/config 02_tools/09-dynamic-tools.ts dotenv_config_path=../../.env", + "intermediate:15": "tsx -r dotenv/config 02_tools/10-tool-result-middleware.ts dotenv_config_path=../../.env", "pipeline": "tsx -r dotenv/config 05_pipelines/01-step-transform.ts dotenv_config_path=../../.env", "pipeline:01": "tsx -r dotenv/config 05_pipelines/01-step-transform.ts dotenv_config_path=../../.env", "pipeline:02": "tsx -r dotenv/config 05_pipelines/02-async-step.ts dotenv_config_path=../../.env", @@ -140,18 +155,21 @@ "@anvia/fastembed": "workspace:*", "@anvia/gemini": "workspace:*", "@anvia/langfuse": "workspace:*", + "@anvia/logger": "workspace:*", "@anvia/mistral": "workspace:*", "@anvia/otel": "workspace:*", "@anvia/openai": "workspace:*", "@anvia/pgvector": "workspace:*", "@anvia/qdrant": "workspace:*", + "@anvia/react": "workspace:*", + "@anvia/server": "workspace:*", "@anvia/studio": "workspace:*", "@anvia/transformers": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/exporter-trace-otlp-http": "^0.216.0", "@opentelemetry/sdk-node": "^0.216.0", "dotenv": "^17.4.2", - "zod": "^4.4.2" + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/examples/cookbook/tsconfig.json b/examples/cookbook/tsconfig.json index ad3d6122..d831b559 100644 --- a/examples/cookbook/tsconfig.json +++ b/examples/cookbook/tsconfig.json @@ -1,9 +1,30 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "baseUrl": ".", "noEmit": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], "jsx": "react-jsx", - "jsxImportSource": "hono/jsx" + "jsxImportSource": "hono/jsx", + "paths": { + "@anvia/chroma": ["../../packages/vector-stores/chroma/src/index.ts"], + "@anvia/core": ["../../packages/core/src/index.ts"], + "@anvia/core/internal/agent": ["../../packages/core/src/internal/agent.ts"], + "@anvia/core/*": ["../../packages/core/src/*/index.ts"], + "@anvia/fastembed": ["../../packages/embeddings/fastembed/src/index.ts"], + "@anvia/gemini": ["../../packages/providers/gemini/src/index.ts"], + "@anvia/langfuse": ["../../packages/observability/langfuse/src/index.ts"], + "@anvia/logger": ["../../packages/logger/src/index.ts"], + "@anvia/mistral": ["../../packages/providers/mistral/src/index.ts"], + "@anvia/openai": ["../../packages/providers/openai/src/index.ts"], + "@anvia/otel": ["../../packages/observability/otel/src/index.ts"], + "@anvia/pgvector": ["../../packages/vector-stores/pgvector/src/index.ts"], + "@anvia/qdrant": ["../../packages/vector-stores/qdrant/src/index.ts"], + "@anvia/react": ["../../packages/react/src/index.ts"], + "@anvia/server": ["../../packages/server/src/index.ts"], + "@anvia/studio": ["../../packages/tools/studio/src/index.ts"], + "@anvia/transformers": ["../../packages/embeddings/transformers/src/index.ts"] + } }, "include": [ "01_basics/**/*.ts", diff --git a/package.json b/package.json index b7615162..b79ac4de 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,15 @@ "docs:deploy": "pnpm --filter docs run deploy", "docs:dev": "pnpm --filter docs dev", "docs:cf-typegen": "pnpm --filter docs cf-typegen", + "docs:reference-check": "pnpm --filter docs reference-check", "cookbook:basics": "pnpm --filter cookbook basics", "cookbook:basics:01": "pnpm --filter cookbook basics:01", "cookbook:basics:02": "pnpm --filter cookbook basics:02", "cookbook:basics:03": "pnpm --filter cookbook basics:03", "cookbook:basics:04": "pnpm --filter cookbook basics:04", "cookbook:basics:05": "pnpm --filter cookbook basics:05", + "cookbook:basics:06": "pnpm --filter cookbook basics:06", + "cookbook:basics:07": "pnpm --filter cookbook basics:07", "cookbook:tools": "pnpm --filter cookbook tools", "cookbook:tools:01": "pnpm --filter cookbook tools:01", "cookbook:tools:02": "pnpm --filter cookbook tools:02", @@ -26,6 +29,7 @@ "cookbook:tools:07": "pnpm --filter cookbook tools:07", "cookbook:tools:08": "pnpm --filter cookbook tools:08", "cookbook:tools:09": "pnpm --filter cookbook tools:09", + "cookbook:tools:10": "pnpm --filter cookbook tools:10", "cookbook:structured-output": "pnpm --filter cookbook structured-output", "cookbook:structured-output:01": "pnpm --filter cookbook structured-output:01", "cookbook:structured-output:02": "pnpm --filter cookbook structured-output:02", @@ -40,6 +44,7 @@ "cookbook:providers:07": "pnpm --filter cookbook providers:07", "cookbook:providers:08": "pnpm --filter cookbook providers:08", "cookbook:providers:09": "pnpm --filter cookbook providers:09", + "cookbook:providers:10": "pnpm --filter cookbook providers:10", "cookbook:providers-and-multimodal": "pnpm --filter cookbook providers-and-multimodal", "cookbook:pipelines": "pnpm --filter cookbook pipelines", "cookbook:pipelines:01": "pnpm --filter cookbook pipelines:01", @@ -66,6 +71,8 @@ "cookbook:multi-agent": "pnpm --filter cookbook multi-agent", "cookbook:multi-agent:01": "pnpm --filter cookbook multi-agent:01", "cookbook:multi-agent:02": "pnpm --filter cookbook multi-agent:02", + "cookbook:multi-agent:03": "pnpm --filter cookbook multi-agent:03", + "cookbook:multi-agent:04": "pnpm --filter cookbook multi-agent:04", "cookbook:evals": "pnpm --filter cookbook evals", "cookbook:evals:01": "pnpm --filter cookbook evals:01", "cookbook:evals:02": "pnpm --filter cookbook evals:02", @@ -78,11 +85,20 @@ "cookbook:studio:03": "pnpm --filter cookbook studio:03", "cookbook:studio:04": "pnpm --filter cookbook studio:04", "cookbook:studio:05": "pnpm --filter cookbook studio:05", + "cookbook:studio:06": "pnpm --filter cookbook studio:06", + "cookbook:studio:07": "pnpm --filter cookbook studio:07", + "cookbook:studio:08": "pnpm --filter cookbook studio:08", + "cookbook:studio:09": "pnpm --filter cookbook studio:09", + "cookbook:studio:10": "pnpm --filter cookbook studio:10", + "cookbook:studio:11": "pnpm --filter cookbook studio:11", + "cookbook:studio:12": "pnpm --filter cookbook studio:12", "cookbook:integrations": "pnpm --filter cookbook integrations", "cookbook:integrations:01": "pnpm --filter cookbook integrations:01", "cookbook:integrations:02": "pnpm --filter cookbook integrations:02", "cookbook:integrations:03": "pnpm --filter cookbook integrations:03", "cookbook:integrations:04": "pnpm --filter cookbook integrations:04", + "cookbook:integrations:05": "pnpm --filter cookbook integrations:05", + "cookbook:integrations:06": "pnpm --filter cookbook integrations:06", "cookbook:basic": "pnpm --filter cookbook basic", "cookbook:basic:01": "pnpm --filter cookbook basic:01", "cookbook:basic:02": "pnpm --filter cookbook basic:02", @@ -108,6 +124,7 @@ "cookbook:intermediate:12": "pnpm --filter cookbook intermediate:12", "cookbook:intermediate:13": "pnpm --filter cookbook intermediate:13", "cookbook:intermediate:14": "pnpm --filter cookbook intermediate:14", + "cookbook:intermediate:15": "pnpm --filter cookbook intermediate:15", "cookbook:pipeline": "pnpm --filter cookbook pipeline", "cookbook:pipeline:01": "pnpm --filter cookbook pipeline:01", "cookbook:pipeline:02": "pnpm --filter cookbook pipeline:02", @@ -135,6 +152,10 @@ "cookbook:multimodal:02": "pnpm --filter cookbook multimodal:02", "cookbook:multimodal:03": "pnpm --filter cookbook multimodal:03", "docs:typecheck": "pnpm --filter docs typecheck", + "changeset": "changeset", + "github-releases": "node scripts/create-github-releases.mjs", + "version-packages": "changeset version", + "release": "pnpm --filter @anvia/core build && pnpm --filter './packages/**' --filter '!@anvia/core' build && node scripts/publish-packages.mjs", "check": "biome check .", "check:staged": "biome check --staged --vcs-enabled=true --vcs-client-kind=git --vcs-use-ignore-file=true --no-errors-on-unmatched", "check:fix": "biome check --write .", @@ -148,6 +169,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.13", + "@changesets/cli": "^2.31.0", "@types/node": "^24.9.1", "husky": "^9.1.7", "tsup": "^8.5.0", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 00000000..93debd4c --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,33 @@ +# @anvia/core + +## 0.4.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +## 0.3.1 + +### Patch Changes + +- b12932d: Update upstream dependencies for PDF loading, globbing, Langfuse tracing, and pgvector support. + + The PDF loader now destroys the `pdfjs-dist` loading task after reading pages, matching the v6 cleanup API. + +## 0.3.0 + +### Minor Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +## 0.2.4 + +### Patch Changes + +- a0a5def: Preserve accumulated streamed tool arguments when a provider final response contains an empty tool input. diff --git a/packages/core/README.md b/packages/core/README.md index ce52d39a..ecc597e6 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -20,7 +20,7 @@ pnpm --filter @anvia/core build ```ts import { z } from "zod"; -import { AgentBuilder, ExtractorBuilder, PipelineBuilder, createTool } from "@anvia/core"; +import { AgentBuilder, createTool } from "@anvia/core"; import { OpenAIClient } from "@anvia/openai"; const client = new OpenAIClient({ @@ -47,9 +47,69 @@ const response = await agent.prompt("What is happening with order A123?").send() console.log(response.output); ``` +## Prompts and Memory + +Use a plain prompt for stateless calls: + +```ts +await agent.prompt("Summarize this ticket.").send(); +``` + +Use a message array when you already own the transcript. The last message is the active prompt and earlier messages are request history: + +```ts +import { Message } from "@anvia/core"; + +await agent + .prompt([ + Message.user("My project is named Anvia."), + Message.assistant("Noted."), + Message.user("What is my project named?"), + ]) + .send(); +``` + +Configure durable conversation memory on the agent, then run through a session: + +```ts +import { AgentBuilder, type MemoryStore, type Message } from "@anvia/core"; +import type { MemoryAppendInput, MemoryContext } from "@anvia/core/memory"; + +class AppMemoryStore implements MemoryStore { + private readonly sessions = new Map(); + + async load(context: MemoryContext): Promise { + return [...(this.sessions.get(context.sessionId) ?? [])]; + } + + async append(input: MemoryAppendInput): Promise { + const current = this.sessions.get(input.context.sessionId) ?? []; + this.sessions.set(input.context.sessionId, [...current, ...input.messages]); + } + + async clear(context: MemoryContext): Promise { + this.sessions.delete(context.sessionId); + } +} + +const memory = new AppMemoryStore(); +const agent = new AgentBuilder("support", model).memory(memory).build(); + +await agent.session("thread_123", { userId: "user_456" }).prompt("Remember my plan.").send(); +await agent.session("thread_123", { userId: "user_456" }).prompt("What is my plan?").send(); +``` + +Memory defaults to `savePolicy: "message"`, which saves the user prompt, each completed assistant message, and each completed tool result as soon as they are ready. You can choose `"turn"` or `"run"` at configuration time: + +```ts +new AgentBuilder("support", model).memory(memory, { savePolicy: "turn" }); +``` + ## Structured Extraction ```ts +import { ExtractorBuilder } from "@anvia/core/extractor"; + const ticketSchema = z.object({ customer: z.string(), priority: z.enum(["low", "medium", "high"]), @@ -66,6 +126,8 @@ const ticket = await extractor.extract( ## Pipelines ```ts +import { PipelineBuilder } from "@anvia/core/pipeline"; + const pipeline = new PipelineBuilder() .step((input) => `Extract this support ticket:\n\n${input}`) .prompt(agent) @@ -80,6 +142,7 @@ const result = await pipeline.run("Customer cannot complete checkout."); - `agent`: agent runtime and `AgentBuilder` - `tool`: typed tool creation and tool sets - `completion`: provider-neutral completion request and response types +- `memory`: durable session memory interfaces and in-memory store - `extractor`: schema-first structured extraction - `pipeline`: typed sequential and parallel workflows - `embeddings`: embedding helpers and document embedding utilities diff --git a/packages/core/package.json b/packages/core/package.json index 0b6bdf73..4d4383b0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,13 +1,21 @@ { "name": "@anvia/core", - "version": "0.1.0", + "version": "0.4.0", "description": "Core runtime primitives for context-aware Anvia agents.", "author": "anvia", "maintainer": "Indra Zulfi", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anvia-hq/anvia", + "directory": "packages/core" + }, "files": [ "dist" ], + "publishConfig": { + "access": "public" + }, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -20,6 +28,10 @@ "types": "./dist/agent/index.d.ts", "import": "./dist/agent/index.js" }, + "./internal/agent": { + "types": "./dist/internal/agent.d.ts", + "import": "./dist/internal/agent.js" + }, "./audio-generation": { "types": "./dist/audio-generation/index.d.ts", "import": "./dist/audio-generation/index.js" @@ -48,6 +60,14 @@ "types": "./dist/mcp/index.d.ts", "import": "./dist/mcp/index.js" }, + "./memory": { + "types": "./dist/memory/index.d.ts", + "import": "./dist/memory/index.js" + }, + "./model-listing": { + "types": "./dist/model-listing/index.d.ts", + "import": "./dist/model-listing/index.js" + }, "./observability": { "types": "./dist/observability/index.d.ts", "import": "./dist/observability/index.js" @@ -82,16 +102,16 @@ } }, "scripts": { - "build": "tsup src/index.ts src/agent/index.ts src/audio-generation/index.ts src/completion/index.ts src/embeddings/index.ts src/evals/index.ts src/image-generation/index.ts src/loaders/index.ts src/extractor/index.ts src/mcp/index.ts src/observability/index.ts src/pipeline/index.ts src/skills/index.ts src/streaming/index.ts src/tool/index.ts src/transcription/index.ts src/vector-store/index.ts --format esm --dts --sourcemap --clean", + "build": "tsup src/index.ts src/agent/index.ts src/internal/agent.ts src/audio-generation/index.ts src/completion/index.ts src/embeddings/index.ts src/evals/index.ts src/image-generation/index.ts src/loaders/index.ts src/extractor/index.ts src/mcp/index.ts src/memory/index.ts src/model-listing/index.ts src/observability/index.ts src/pipeline/index.ts src/skills/index.ts src/streaming/index.ts src/tool/index.ts src/transcription/index.ts src/vector-store/index.ts --format esm --dts --sourcemap --clean", "test": "vitest run", "typecheck": "tsc --noEmit" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "pdfjs-dist": "^5.7.284", - "tinyglobby": "^0.2.16", - "yaml": "^2.8.4", - "zod": "^4.4.2" + "pdfjs-dist": "^6.0.227", + "tinyglobby": "^0.2.17", + "yaml": "^2.9.0", + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 979cc337..0c1c6526 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -7,14 +7,19 @@ import type { Message as MessageType, ToolChoice, } from "../completion/index"; +import type { MemoryRegistration, SessionOptions } from "../memory"; import type { AgentObserverRegistration } from "../observability"; import { createTool } from "../tool/create-tool"; import type { ToolSearchDocument } from "../tool/dynamic-tools"; -import type { AnyTool, Tool } from "../tool/tool"; +import type { ToolMiddleware } from "../tool/middleware"; +import { isSkillTool } from "../tool/skill-tool-marker"; +import type { AnyTool, NormalizedToolOutput, Tool, ToolCallContext } from "../tool/tool"; import { ToolSet } from "../tool/tool-set"; import type { VectorFilter, VectorSearchIndex, VectorSearchResult } from "../vector-store"; import type { PromptHook } from "./hooks"; +import { normalizeAgentId } from "./ids"; import { PromptRequest } from "./request"; +import { isStreamingCompletionModel } from "./utils"; export type AgentOptions = { id: string; @@ -34,6 +39,9 @@ export type AgentOptions = { observers?: AgentObserverRegistration[] | undefined; dynamicContexts?: DynamicContextRegistration[] | undefined; dynamicTools?: DynamicToolRegistration[] | undefined; + toolMiddlewares?: ToolMiddleware[] | undefined; + memory?: MemoryRegistration | undefined; + eventStore?: AgentEventStoreRegistration | undefined; }; export const DEFAULT_MAX_TURNS = 20; @@ -42,6 +50,39 @@ export type AgentToolOptions = { name: string; description?: string | undefined; maxTurns?: number | undefined; + stream?: boolean | undefined; +}; + +export type AgentEventStoreInclude = "all" | "agent_tool_events"; + +export type AgentEventStoreOptions = { + include?: AgentEventStoreInclude | undefined; +}; + +export type AgentEventAppendInput = { + runId: string; + agentId: string; + agentName?: string | undefined; + turn?: number | undefined; + toolName?: string | undefined; + toolCallId?: string | undefined; + internalCallId?: string | undefined; + event: unknown; +}; + +export type AgentEventRecord = AgentEventAppendInput & { + createdAt?: Date | undefined; +}; + +export interface AgentEventStore { + append(input: AgentEventAppendInput): Promise; + load(runId: string): Promise; + clear?(runId: string): Promise; +} + +export type AgentEventStoreRegistration = { + store: AgentEventStore; + options: Required; }; export type DynamicContextOptions = { @@ -85,6 +126,9 @@ export class Agent { readonly observers: AgentObserverRegistration[]; readonly dynamicContexts: DynamicContextRegistration[]; readonly dynamicTools: DynamicToolRegistration[]; + readonly toolMiddlewares: ToolMiddleware[]; + readonly memory: MemoryRegistration | undefined; + readonly eventStore: AgentEventStoreRegistration | undefined; constructor(options: AgentOptions) { this.id = normalizeAgentId(options.id); @@ -104,12 +148,30 @@ export class Agent { this.observers = options.observers ?? []; this.dynamicContexts = options.dynamicContexts ?? []; this.dynamicTools = options.dynamicTools ?? []; + this.toolMiddlewares = options.toolMiddlewares ?? []; + this.memory = options.memory; + this.eventStore = options.eventStore; } - prompt(prompt: string | MessageType): PromptRequest { + prompt(prompt: string | MessageType | MessageType[]): PromptRequest { return PromptRequest.fromAgent(this, prompt); } + session(sessionId: string, options: SessionOptions = {}): AgentSession { + if (this.memory === undefined) { + throw new Error(`Agent "${this.id}" has no memory store configured.`); + } + const normalized = sessionId.trim(); + if (normalized.length === 0) { + throw new TypeError("Session id must be a non-empty string."); + } + return new AgentSession(this, { + sessionId: normalized, + ...(options.userId === undefined ? {} : { userId: options.userId }), + ...(options.metadata === undefined ? {} : { metadata: options.metadata }), + }); + } + asTool(options: AgentToolOptions): Tool<{ prompt: string }, string> { const description = options.description ?? this.description ?? `Prompt the ${options.name} agent.`; @@ -121,12 +183,30 @@ export class Agent { prompt: z.string().describe("The prompt to send to the agent."), }), output: z.string(), - execute: async ({ prompt }) => { + execute: async ({ prompt }, context: ToolCallContext) => { const request = this.prompt(prompt); - const response = - options.maxTurns === undefined - ? await request.send() - : await request.maxTurns(options.maxTurns).send(); + const childRequest = + options.maxTurns === undefined ? request : request.maxTurns(options.maxTurns); + if ( + options.stream === true && + context.emitStreamEvent !== undefined && + this.model.capabilities.streaming && + isStreamingCompletionModel(this.model) + ) { + let output = ""; + for await (const event of childRequest.stream()) { + await context.emitStreamEvent({ + agentId: this.id, + ...(this.name === undefined ? {} : { agentName: this.name }), + event, + }); + if (event.type === "final") { + output = event.output; + } + } + return output; + } + const response = await childRequest.send(); return response.output; }, }); @@ -148,38 +228,67 @@ export class Agent { return undefined; } - async callTool(toolName: string, args: string): Promise { + async callTool( + toolName: string, + args: string, + context?: ToolCallContext, + ): Promise { if (this.toolSet.contains(toolName)) { - return this.toolSet.call(toolName, args); + return this.toolSet.call(toolName, args, context); } for (const registration of this.dynamicTools) { const toolSet = dynamicToolSetFromIndex(registration.index); if (toolSet?.contains(toolName)) { - return toolSet.call(toolName, args); + return toolSet.call(toolName, args, context); } } - return this.toolSet.call(toolName, args); + return this.toolSet.call(toolName, args, context); } -} -function dynamicToolSetFromIndex( - index: VectorSearchIndex, -): ToolSet | undefined { - const maybeIndex = index as { toolSet?: unknown }; - return maybeIndex.toolSet instanceof ToolSet ? maybeIndex.toolSet : undefined; + shouldApplyToolMiddleware(toolName: string): boolean { + return !isSkillTool(this.getTool(toolName)); + } } -function normalizeAgentId(id: string): string { - if (typeof id !== "string") { - throw new TypeError("Agent id must be a string."); +export class AgentSession { + constructor( + private readonly agent: Agent, + private readonly context: { + sessionId: string; + userId?: string | undefined; + metadata?: JsonObject | undefined; + }, + ) {} + + prompt(prompt: string | MessageType): PromptRequest { + if (Array.isArray(prompt)) { + throw new TypeError("AgentSession.prompt does not accept Message[] transcripts."); + } + return PromptRequest.fromAgent(this.agent, prompt, { memoryContext: this.context }); + } + + async messages(): Promise { + const memory = this.agent.memory; + if (memory === undefined) { + throw new Error(`Agent "${this.agent.id}" has no memory store configured.`); + } + return memory.store.load(this.context); } - const normalized = id.trim(); - if (normalized.length === 0) { - throw new TypeError("Agent id must be a non-empty string."); + async clear(): Promise { + const memory = this.agent.memory; + if (memory === undefined) { + throw new Error(`Agent "${this.agent.id}" has no memory store configured.`); + } + await memory.store.clear(this.context); } +} - return normalized; +function dynamicToolSetFromIndex( + index: VectorSearchIndex, +): ToolSet | undefined { + const maybeIndex = index as { toolSet?: unknown }; + return maybeIndex.toolSet instanceof ToolSet ? maybeIndex.toolSet : undefined; } diff --git a/packages/core/src/agent/builder.ts b/packages/core/src/agent/builder.ts index 702f0fa9..d94ecb74 100644 --- a/packages/core/src/agent/builder.ts +++ b/packages/core/src/agent/builder.ts @@ -1,20 +1,31 @@ import type { CompletionModel, Document, JsonObject, JsonValue, ToolChoice } from "../completion"; import type { McpServer } from "../mcp"; +import { + type MemoryOptions, + type MemoryRegistration, + type MemoryStore, + resolveMemoryOptions, +} from "../memory"; import type { AgentObserver, AgentObserverRegistration, ObserveOptions } from "../observability"; import { toProviderJsonSchema, type ZodSchema } from "../schema/zod-schema"; import type { SkillSet } from "../skills"; import type { ToolSearchDocument } from "../tool/dynamic-tools"; +import type { ToolMiddleware } from "../tool/middleware"; import type { AnyTool } from "../tool/tool"; import { ToolSet } from "../tool/tool-set"; import type { VectorSearchIndex } from "../vector-store"; import { Agent, + type AgentEventStore, + type AgentEventStoreOptions, + type AgentEventStoreRegistration, type DynamicContextOptions, type DynamicContextRegistration, type DynamicToolOptions, type DynamicToolRegistration, } from "./agent"; import type { PromptHook } from "./hooks"; +import { normalizeAgentId } from "./ids"; export class AgentBuilder { private readonly agentId: string; @@ -33,6 +44,9 @@ export class AgentBuilder { private observerRegistrations: AgentObserverRegistration[] = []; private dynamicContextRegistrations: DynamicContextRegistration[] = []; private dynamicToolRegistrations: DynamicToolRegistration[] = []; + private middlewareRegistrations: ToolMiddleware[] = []; + private memoryRegistration: MemoryRegistration | undefined; + private eventStoreRegistration: AgentEventStoreRegistration | undefined; private activeToolSet = new ToolSet(); constructor( @@ -135,6 +149,16 @@ export class AgentBuilder { return this; } + toolMiddleware(middleware: ToolMiddleware): this { + this.middlewareRegistrations.push(middleware); + return this; + } + + toolMiddlewares(middlewares: ToolMiddleware[]): this { + this.middlewareRegistrations.push(...middlewares); + return this; + } + observe(observer: AgentObserver, options: ObserveOptions = {}): this { this.observerRegistrations.push({ observer, @@ -143,6 +167,24 @@ export class AgentBuilder { return this; } + memory(store: MemoryStore, options: MemoryOptions = {}): this { + this.memoryRegistration = { + store, + options: resolveMemoryOptions(options), + }; + return this; + } + + eventStore(store: AgentEventStore, options: AgentEventStoreOptions = {}): this { + this.eventStoreRegistration = { + store, + options: { + include: options.include ?? "all", + }, + }; + return this; + } + outputSchema(schema: ZodSchema): this { this.schema = toProviderJsonSchema(schema); return this; @@ -167,6 +209,9 @@ export class AgentBuilder { observers: this.observerRegistrations, dynamicContexts: this.dynamicContextRegistrations, dynamicTools: this.dynamicToolRegistrations, + toolMiddlewares: this.middlewareRegistrations, + memory: this.memoryRegistration, + eventStore: this.eventStoreRegistration, }); } @@ -177,16 +222,3 @@ export class AgentBuilder { return parts.length === 0 ? undefined : parts.join("\n\n"); } } - -function normalizeAgentId(id: string): string { - if (typeof id !== "string") { - throw new TypeError("Agent id must be a string."); - } - - const normalized = id.trim(); - if (normalized.length === 0) { - throw new TypeError("Agent id must be a non-empty string."); - } - - return normalized; -} diff --git a/packages/core/src/agent/hooks.ts b/packages/core/src/agent/hooks.ts index 6bde3e60..89857587 100644 --- a/packages/core/src/agent/hooks.ts +++ b/packages/core/src/agent/hooks.ts @@ -1,10 +1,16 @@ -import type { CompletionResponse, Message } from "../completion/index"; +import type { CompletionResponse, Message, ToolResultContent } from "../completion/index"; export type HookAction = { type: "continue" } | { type: "terminate"; reason: string }; +export type ToolApprovalRequestOptions = { + reason?: string; + rejectMessage?: string; +}; + export type ToolCallHookAction = | { type: "continue" } | { type: "skip"; reason: string } - | { type: "terminate"; reason: string }; + | { type: "terminate"; reason: string } + | ({ type: "approval_request" } & ToolApprovalRequestOptions); export type RunControl = { continue(): HookAction; @@ -15,6 +21,7 @@ export type ToolCallControl = { run(): ToolCallHookAction; skip(reason: string): ToolCallHookAction; cancel(reason: string): ToolCallHookAction; + requestApproval(options?: ToolApprovalRequestOptions): ToolCallHookAction; }; export type HookResult = HookAction | undefined; @@ -52,6 +59,7 @@ export type ToolCallHookArgs = ToolHookArgs & { export type ToolResultHookArgs = ToolHookArgs & { result: string; + structuredResult?: ToolResultContent[] | undefined; run: RunControl; }; @@ -69,6 +77,14 @@ export function skipTool(reason: string): ToolCallHookAction { return { type: "skip", reason }; } +export function requestToolApproval(options: ToolApprovalRequestOptions = {}): ToolCallHookAction { + return { + type: "approval_request", + ...(options.reason === undefined ? {} : { reason: options.reason }), + ...(options.rejectMessage === undefined ? {} : { rejectMessage: options.rejectMessage }), + }; +} + export const runControl: RunControl = { continue() { return { type: "continue" }; @@ -88,6 +104,9 @@ export const toolCallControl: ToolCallControl = { cancel(reason: string) { return { type: "terminate", reason }; }, + requestApproval(options) { + return requestToolApproval(options); + }, }; export interface PromptHook { diff --git a/packages/core/src/agent/ids.ts b/packages/core/src/agent/ids.ts new file mode 100644 index 00000000..26b4192a --- /dev/null +++ b/packages/core/src/agent/ids.ts @@ -0,0 +1,12 @@ +export function normalizeAgentId(id: string): string { + if (typeof id !== "string") { + throw new TypeError("Agent id must be a string."); + } + + const normalized = id.trim(); + if (normalized.length === 0) { + throw new TypeError("Agent id must be a non-empty string."); + } + + return normalized; +} diff --git a/packages/core/src/agent/index.ts b/packages/core/src/agent/index.ts index 13b6463a..1d959614 100644 --- a/packages/core/src/agent/index.ts +++ b/packages/core/src/agent/index.ts @@ -1,5 +1,37 @@ -export * from "./agent"; -export * from "./builder"; -export * from "./errors"; -export * from "./hooks"; -export * from "./request"; +export type { + AgentEventAppendInput, + AgentEventRecord, + AgentEventStore, + AgentEventStoreInclude, + AgentEventStoreOptions, +} from "./agent"; +export { AgentBuilder } from "./builder"; +export { MaxTurnsError, PromptCancelledError } from "./errors"; +export type { + CompletionCallHookArgs, + CompletionResponseHookArgs, + HookAction, + HookResult, + PromptHook, + RunControl, + ToolApprovalRequestOptions, + ToolCallControl, + ToolCallHookAction, + ToolCallHookArgs, + ToolCallHookResult, + ToolHookArgs, + ToolResultHookArgs, +} from "./hooks"; +export { + cancelPrompt, + createHook, + requestToolApproval, + runControl, + skipTool, + toolCallControl, +} from "./hooks"; +export type { + AgentChildStreamEvent, + AgentStreamEvent, + PromptResponse, +} from "./request"; diff --git a/packages/core/src/agent/request-memory.ts b/packages/core/src/agent/request-memory.ts new file mode 100644 index 00000000..ca3f46fc --- /dev/null +++ b/packages/core/src/agent/request-memory.ts @@ -0,0 +1,122 @@ +import type { Message as MessageType } from "../completion/index"; +import type { MemoryContext, MemoryRegistration, MemorySavePolicy } from "../memory"; +import type { Agent } from "./agent"; + +export class PromptRequestMemory { + constructor( + private readonly agent: Agent, + private readonly memoryContext: MemoryContext | undefined, + private readonly initialHistory: MessageType[], + ) {} + + memoryPolicy(): MemorySavePolicy | undefined { + return this.memory()?.options.savePolicy; + } + + pendingTurnMessages(newMessages: MessageType[]): MessageType[] { + return this.memoryPolicy() === "turn" ? [...newMessages] : []; + } + + async prepareRun(runId: string, newMessages: MessageType[]): Promise { + const memory = this.memory(); + if (memory === undefined || this.memoryContext === undefined) { + return this.initialHistory; + } + + const memoryHistory = await memory.store.load(this.memoryContext); + if (memory.options.savePolicy === "message") { + await memory.store.append({ + context: this.memoryContext, + runId, + turn: 1, + messages: newMessages, + }); + } + return [...memoryHistory, ...this.initialHistory]; + } + + async commitMessages( + runId: string, + turn: number, + messages: MessageType[], + pendingTurnMessages: MessageType[], + ): Promise { + const memory = this.memory(); + if (memory === undefined || this.memoryContext === undefined || messages.length === 0) { + return; + } + if (memory.options.savePolicy === "message") { + await memory.store.append({ + context: this.memoryContext, + runId, + turn, + messages, + }); + } else if (memory.options.savePolicy === "turn") { + pendingTurnMessages.push(...messages); + } + } + + async commitCompletedTurn( + runId: string, + turn: number, + pendingTurnMessages: MessageType[], + ): Promise { + const memory = this.memory(); + if ( + memory === undefined || + this.memoryContext === undefined || + memory.options.savePolicy !== "turn" || + pendingTurnMessages.length === 0 + ) { + return; + } + await memory.store.append({ + context: this.memoryContext, + runId, + turn, + messages: [...pendingTurnMessages], + }); + pendingTurnMessages.length = 0; + } + + async commitCompletedRun( + runId: string, + turn: number, + newMessages: MessageType[], + pendingTurnMessages: MessageType[], + ): Promise { + await this.commitCompletedTurn(runId, turn, pendingTurnMessages); + const memory = this.memory(); + if ( + memory === undefined || + this.memoryContext === undefined || + memory.options.savePolicy !== "run" + ) { + return; + } + await memory.store.append({ + context: this.memoryContext, + runId, + turn, + messages: [...newMessages], + }); + } + + async recordError(runId: string, error: unknown, newMessages: MessageType[]): Promise { + const memory = this.memory(); + if (memory === undefined || this.memoryContext === undefined) { + return; + } + await memory.store.recordError?.({ + context: this.memoryContext, + runId, + error, + messages: [...newMessages], + }); + } + + private memory(): MemoryRegistration | undefined { + return this.memoryContext === undefined ? undefined : this.agent.memory; + } +} diff --git a/packages/core/src/agent/request.ts b/packages/core/src/agent/request.ts index 4e0bff3f..a1550ff8 100644 --- a/packages/core/src/agent/request.ts +++ b/packages/core/src/agent/request.ts @@ -3,30 +3,37 @@ import { type CompletionModel, CompletionRequestBuilder, type CompletionResponse, - type Document, + type JsonObject, Message, type Message as MessageType, type ReasoningContentType, type ToolCall, - ToolContent, type ToolDefinition, type ToolResult, + type ToolResultContent, textFromAssistantContent, Usage, } from "../completion/index"; -import { - type ActiveAgentRunObservers, - type ActiveToolObservers, - startAgentRunObservers, -} from "../observability/group"; +import { createAsyncQueue } from "../internal/async-queue"; +import type { MemoryContext } from "../memory"; +import { type ActiveAgentRunObservers, startAgentRunObservers } from "../observability/group"; import type { AgentTraceInfo, AgentTraceOptions } from "../observability/types"; import { toReadableStream } from "../streaming"; +import type { ToolMiddleware } from "../tool/middleware"; import type { Agent } from "./agent"; import { MaxTurnsError, PromptCancelledError } from "./errors"; -import type { PromptHook, ToolHookArgs } from "./hooks"; -import { runControl, toolCallControl } from "./hooks"; +import type { PromptHook } from "./hooks"; +import { runControl } from "./hooks"; +import { PromptRequestMemory } from "./request-memory"; +import { fetchDynamicContext, fetchToolDefinitions } from "./retrieval"; import { type AgentDeltaEvent, CompletionStreamAccumulator } from "./stream-accumulator"; -import { extractRagText, isStreamingCompletionModel, mapWithConcurrency } from "./utils"; +import { + type AgentToolEventPayload, + ToolCallExecutor, + type ToolExecutionEventPayload, + type ToolResultEventPayload, +} from "./tool-execution"; +import { extractRagText, isStreamingCompletionModel } from "./utils"; export type PromptResponse = { output: string; @@ -35,7 +42,7 @@ export type PromptResponse = { trace?: AgentTraceInfo | undefined; }; -export type AgentStreamEvent = +export type AgentChildStreamEvent = | { type: "turn_start"; turn: number; @@ -68,6 +75,7 @@ export type AgentStreamEvent = internalCallId: string; args: string; result: string; + structuredResult?: ToolResultContent[] | undefined; } | { type: "turn_end"; @@ -76,6 +84,7 @@ export type AgentStreamEvent = } | { type: "final"; + runId: string; output: string; usage: Usage; messages: MessageType[]; @@ -86,31 +95,47 @@ export type AgentStreamEvent = error: unknown; }; +export type AgentStreamEvent = + | AgentChildStreamEvent + | { + type: "agent_tool_event"; + turn: number; + toolName: string; + toolCallId?: string; + internalCallId: string; + agentId: string; + agentName?: string; + event: AgentChildStreamEvent; + }; + export class PromptRequest { - private chatHistory: MessageType[] | undefined; + private chatHistory: MessageType[]; private maxTurnCount: number; private activeHook: PromptHook | undefined; private concurrency = 1; private traceOptions: AgentTraceOptions | undefined; + private requestToolMiddlewares: ToolMiddleware[] = []; + private readonly memoryRecorder: PromptRequestMemory; private constructor( private readonly agent: Agent, private readonly promptMessage: MessageType, + initialHistory: MessageType[] = [], + memoryContext: MemoryContext | undefined = undefined, ) { + this.chatHistory = initialHistory; this.maxTurnCount = agent.defaultMaxTurns ?? 0; this.activeHook = agent.hook; + this.memoryRecorder = new PromptRequestMemory(agent, memoryContext, initialHistory); } static fromAgent( agent: Agent, - prompt: string | MessageType, + prompt: string | MessageType | MessageType[], + options: { memoryContext?: MemoryContext | undefined } = {}, ): PromptRequest { - return new PromptRequest(agent, typeof prompt === "string" ? Message.user(prompt) : prompt); - } - - withHistory(history: MessageType[]): this { - this.chatHistory = history; - return this; + const normalized = normalizePromptInput(prompt); + return new PromptRequest(agent, normalized.prompt, normalized.history, options.memoryContext); } maxTurns(maxTurns: number): this { @@ -128,13 +153,26 @@ export class PromptRequest { return this; } + withToolMiddleware(middleware: ToolMiddleware): this { + this.requestToolMiddlewares.push(middleware); + return this; + } + + withToolMiddlewares(middlewares: ToolMiddleware[]): this { + this.requestToolMiddlewares.push(...middlewares); + return this; + } + withTrace(trace: AgentTraceOptions): this { this.traceOptions = trace; return this; } async send(): Promise { + const runId = globalThis.crypto.randomUUID(); const newMessages: MessageType[] = [this.promptMessage]; + this.chatHistory = await this.memoryRecorder.prepareRun(runId, newMessages); + const pendingTurnMessages = this.memoryRecorder.pendingTurnMessages(newMessages); let usage = Usage.empty(); let currentTurns = 0; let lastPrompt = this.promptMessage; @@ -150,12 +188,12 @@ export class PromptRequest { lastPrompt = prompt; currentTurns += 1; - const historyForRequest = [...(this.chatHistory ?? []), ...newMessages.slice(0, -1)]; + const historyForRequest = [...this.chatHistory, ...newMessages.slice(0, -1)]; await this.runCompletionCallHook(prompt, historyForRequest, newMessages); const ragText = extractRagText(prompt); - const dynamicContext = await this.fetchDynamicContext(ragText); - const toolDefs = await this.fetchToolDefinitions(ragText); + const dynamicContext = await fetchDynamicContext(this.agent, ragText); + const toolDefs = await fetchToolDefinitions(this.agent, ragText); const request = new CompletionRequestBuilder(this.agent.model, prompt) .instructions(this.agent.instructions) .messages(historyForRequest) @@ -172,11 +210,24 @@ export class PromptRequest { usage = Usage.add(usage, response.usage); await this.runCompletionResponseHook(prompt, response, newMessages); - newMessages.push(Message.assistant(response.choice, response.messageId)); + const assistantMessage = Message.assistant(response.choice, response.messageId); + newMessages.push(assistantMessage); + await this.memoryRecorder.commitMessages( + runId, + currentTurns, + [assistantMessage], + pendingTurnMessages, + ); const toolCalls = response.choice.filter( (item): item is ToolCall => item.type === "tool_call", ); if (toolCalls.length === 0) { + await this.memoryRecorder.commitCompletedRun( + runId, + currentTurns, + newMessages, + pendingTurnMessages, + ); const result: PromptResponse = { output: textFromAssistantContent(response.choice), usage, @@ -187,20 +238,32 @@ export class PromptRequest { return result; } - const toolResults = await this.executeToolCalls(toolCalls, newMessages, undefined, { - turn: currentTurns, - runObservers, - }); - newMessages.push(Message.tool(toolResults)); + const toolResults = await this.executeToolCalls( + toolCalls, + newMessages, + undefined, + undefined, + { + turn: currentTurns, + runObservers, + toolDefinitions: request.tools, + }, + ); + const toolMessage = Message.tool(toolResults); + newMessages.push(toolMessage); + await this.memoryRecorder.commitMessages( + runId, + currentTurns, + [toolMessage], + pendingTurnMessages, + ); + await this.memoryRecorder.commitCompletedTurn(runId, currentTurns, pendingTurnMessages); } - throw new MaxTurnsError( - this.maxTurnCount, - [...(this.chatHistory ?? []), ...newMessages], - lastPrompt, - ); + throw new MaxTurnsError(this.maxTurnCount, [...this.chatHistory, ...newMessages], lastPrompt); } catch (error) { await runObservers.error({ error, usage, messages: [...newMessages] }); + await this.memoryRecorder.recordError(runId, error, newMessages); throw error; } } @@ -210,11 +273,18 @@ export class PromptRequest { throw new Error("This completion model does not support streaming"); } + const runId = globalThis.crypto.randomUUID(); const newMessages: MessageType[] = [this.promptMessage]; + this.chatHistory = await this.memoryRecorder.prepareRun(runId, newMessages); + const pendingTurnMessages = this.memoryRecorder.pendingTurnMessages(newMessages); let usage = Usage.empty(); let currentTurns = 0; let lastPrompt = this.promptMessage; const runObservers = await this.startRunObservers(); + const emit = async (event: AgentStreamEvent): Promise => { + await this.recordAgentEvent(runId, event); + return event; + }; try { while (currentTurns <= this.maxTurnCount + 1) { @@ -226,18 +296,18 @@ export class PromptRequest { lastPrompt = prompt; currentTurns += 1; - const historyForRequest = [...(this.chatHistory ?? []), ...newMessages.slice(0, -1)]; - yield { + const historyForRequest = [...this.chatHistory, ...newMessages.slice(0, -1)]; + yield await emit({ type: "turn_start", turn: currentTurns, prompt, history: historyForRequest, - }; + }); await this.runCompletionCallHook(prompt, historyForRequest, newMessages); const ragText = extractRagText(prompt); - const dynamicContext = await this.fetchDynamicContext(ragText); - const toolDefs = await this.fetchToolDefinitions(ragText); + const dynamicContext = await fetchDynamicContext(this.agent, ragText); + const toolDefs = await fetchToolDefinitions(this.agent, ragText); const request = new CompletionRequestBuilder(this.agent.model, prompt) .instructions(this.agent.instructions) .messages(historyForRequest) @@ -251,9 +321,16 @@ export class PromptRequest { .build(); assertCompletionRequestSupported(this.agent.model, request, { streaming: true }); + const providerRequest = this.providerTraceRequest(request, { stream: true }); const generationObservers = await runObservers.startGeneration({ turn: currentTurns, request, + ...(providerRequest === undefined ? {} : { providerRequest }), + modelInfo: { + provider: this.agent.model.provider, + defaultModel: this.agent.model.defaultModel, + capabilities: this.agent.model.capabilities, + }, }); const accumulator = new CompletionStreamAccumulator(); const generationStartedAt = Date.now(); @@ -268,7 +345,7 @@ export class PromptRequest { throw event.error; } if (mapped !== undefined) { - yield addTurn(currentTurns, mapped); + yield await emit(addTurn(currentTurns, mapped)); } } } catch (error) { @@ -285,38 +362,56 @@ export class PromptRequest { usage = Usage.add(usage, response.usage); await this.runCompletionResponseHook(prompt, response, newMessages); - newMessages.push(Message.assistant(response.choice, response.messageId)); + const assistantMessage = Message.assistant(response.choice, response.messageId); + newMessages.push(assistantMessage); + await this.memoryRecorder.commitMessages( + runId, + currentTurns, + [assistantMessage], + pendingTurnMessages, + ); const toolCalls = response.choice.filter( (item): item is ToolCall => item.type === "tool_call", ); for (const toolCall of toolCalls) { - yield { type: "tool_call", turn: currentTurns, toolCall }; + yield await emit({ type: "tool_call", turn: currentTurns, toolCall }); } - yield { type: "turn_end", turn: currentTurns, response }; + yield await emit({ type: "turn_end", turn: currentTurns, response }); if (toolCalls.length === 0) { const output = textFromAssistantContent(response.choice); - yield { + await this.memoryRecorder.commitCompletedRun( + runId, + currentTurns, + newMessages, + pendingTurnMessages, + ); + yield await emit({ type: "final", + runId, output, usage, messages: [...newMessages], trace: runObservers.trace, - }; + }); await runObservers.end({ output, usage, messages: [...newMessages] }); return; } - const toolResultEvents = createAsyncQueue(); + const toolResultEvents = createAsyncQueue(); const toolResultsPromise = this.executeToolCalls( toolCalls, newMessages, (result) => { toolResultEvents.enqueue(result); }, + (event) => { + toolResultEvents.enqueue(event); + }, { turn: currentTurns, runObservers, + toolDefinitions: request.tools, }, ); toolResultsPromise.then( @@ -324,20 +419,25 @@ export class PromptRequest { (error: unknown) => toolResultEvents.throw(error), ); for await (const result of toolResultEvents) { - yield { type: "tool_result", turn: currentTurns, ...result }; + yield await emit({ turn: currentTurns, ...result }); } const toolResults = await toolResultsPromise; - newMessages.push(Message.tool(toolResults)); + const toolMessage = Message.tool(toolResults); + newMessages.push(toolMessage); + await this.memoryRecorder.commitMessages( + runId, + currentTurns, + [toolMessage], + pendingTurnMessages, + ); + await this.memoryRecorder.commitCompletedTurn(runId, currentTurns, pendingTurnMessages); } - throw new MaxTurnsError( - this.maxTurnCount, - [...(this.chatHistory ?? []), ...newMessages], - lastPrompt, - ); + throw new MaxTurnsError(this.maxTurnCount, [...this.chatHistory, ...newMessages], lastPrompt); } catch (error) { await runObservers.error({ error, usage, messages: [...newMessages] }); - yield { type: "error", error }; + await this.memoryRecorder.recordError(runId, error, newMessages); + yield await emit({ type: "error", error }); throw error; } } @@ -352,7 +452,17 @@ export class PromptRequest { runObservers: ActiveAgentRunObservers, ): Promise { assertCompletionRequestSupported(this.agent.model, request); - const generationObservers = await runObservers.startGeneration({ turn, request }); + const providerRequest = this.providerTraceRequest(request); + const generationObservers = await runObservers.startGeneration({ + turn, + request, + ...(providerRequest === undefined ? {} : { providerRequest }), + modelInfo: { + provider: this.agent.model.provider, + defaultModel: this.agent.model.defaultModel, + capabilities: this.agent.model.capabilities, + }, + }); try { const response = await this.agent.model.completion(request); await generationObservers.end({ turn, response }); @@ -363,96 +473,38 @@ export class PromptRequest { } } + private providerTraceRequest( + request: ReturnType, + options: { stream?: boolean | undefined } = {}, + ): JsonObject | undefined { + try { + return this.agent.model.traceRequest?.(request, options); + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + }; + } + } + private async executeToolCalls( toolCalls: ToolCall[], newMessages: MessageType[], onResult?: (result: ToolResultEventPayload) => void, + onStreamEvent?: (event: AgentToolEventPayload) => void, observation?: { turn: number; runObservers: ActiveAgentRunObservers; + toolDefinitions?: ToolDefinition[]; }, ): Promise { - return mapWithConcurrency(toolCalls, this.concurrency, async (toolCall) => { - const args = JSON.stringify(toolCall.function.arguments ?? {}); - const internalCallId = globalThis.crypto.randomUUID(); - const hookArgs: ToolHookArgs = { - toolName: toolCall.function.name, - internalCallId, - args, - }; - if (toolCall.callId !== undefined) { - hookArgs.toolCallId = toolCall.callId; - } - - const toolObservers = await observation?.runObservers.startTool({ - turn: observation.turn, - toolCall, - toolName: toolCall.function.name, - internalCallId, - args, - toolCallId: toolCall.callId, - }); - - const callAction = await this.activeHook?.onToolCall?.({ - ...hookArgs, - tool: toolCallControl, - }); - if (callAction?.type === "terminate") { - await this.recordToolError( - toolObservers, - observation?.turn, - toolCall, - internalCallId, - args, - callAction.reason, - ); - throw this.cancelled(newMessages, callAction.reason); - } - - let output: string; - let skipped = false; - if (callAction?.type === "skip") { - output = callAction.reason; - skipped = true; - } else { - try { - output = await this.agent.callTool(toolCall.function.name, args); - } catch (error) { - output = error instanceof Error ? error.toString() : String(error); - } - } - - const resultAction = await this.activeHook?.onToolResult?.({ - ...hookArgs, - result: output, - run: runControl, - }); - await toolObservers?.end({ - turn: observation?.turn ?? 0, - toolCall, - toolName: toolCall.function.name, - internalCallId, - args, - result: output, - skipped, - toolCallId: toolCall.callId, - }); - if (resultAction?.type === "terminate") { - throw this.cancelled(newMessages, resultAction.reason); - } - - const resultPayload: ToolResultEventPayload = { - toolName: toolCall.function.name, - internalCallId, - args, - result: output, - }; - if (toolCall.callId !== undefined) { - resultPayload.toolCallId = toolCall.callId; - } - onResult?.(resultPayload); - return ToolContent.toolResult(toolCall.id, output, toolCall.callId); - }); + const executor = new ToolCallExecutor( + this.agent, + this.activeHook, + this.concurrency, + this.requestToolMiddlewares, + (reason) => this.cancelled(newMessages, reason), + ); + return executor.execute(toolCalls, onResult, onStreamEvent, observation); } private async startRunObservers(): Promise { @@ -467,88 +519,38 @@ export class PromptRequest { instructions: this.agent.instructions, trace: this.traceOptions, prompt: this.promptMessage, - history: this.chatHistory ?? [], + history: this.chatHistory, maxTurns: this.maxTurnCount, }, failOnObserverError, ); } - private async fetchDynamicContext(ragText: string | undefined): Promise { - if (ragText === undefined || ragText.length === 0 || this.agent.dynamicContexts.length === 0) { - return []; - } - - const documents: Document[] = []; - for (const registration of this.agent.dynamicContexts) { - const results = await registration.index.search({ - query: ragText, - topK: registration.options.topK, - threshold: registration.options.threshold, - filter: registration.options.filter, - }); - for (const result of results) { - const formatted = registration.options.format?.(result); - if (formatted !== undefined) { - documents.push(formatted); - } else { - const metadata = formatMetadata(result.metadata); - documents.push({ - id: result.id, - text: - typeof result.document === "string" - ? result.document - : JSON.stringify(result.document, null, 2), - ...(metadata === undefined ? {} : { additionalProps: metadata }), - }); - } - } - } - return documents; - } - - private async fetchToolDefinitions(ragText: string | undefined): Promise { - const staticDefinitions = await this.agent.toolSet.getToolDefinitions(ragText); - if (ragText === undefined || ragText.length === 0 || this.agent.dynamicTools.length === 0) { - return staticDefinitions; + private async recordAgentEvent(runId: string, event: AgentStreamEvent): Promise { + const registration = this.agent.eventStore; + if (registration === undefined) { + return; } - - const definitions = [...staticDefinitions]; - const names = new Set(staticDefinitions.map((definition) => definition.name)); - for (const registration of this.agent.dynamicTools) { - const results = await registration.index.search({ - query: ragText, - topK: registration.options.topK, - threshold: registration.options.threshold, - filter: registration.options.filter, - }); - for (const result of results) { - if (names.has(result.document.toolName)) { - continue; - } - names.add(result.document.toolName); - definitions.push(result.document.definition); - } + if (registration.options.include === "agent_tool_events" && event.type !== "agent_tool_event") { + return; } - return definitions; - } - private async recordToolError( - toolObservers: ActiveToolObservers | undefined, - turn: number | undefined, - toolCall: ToolCall, - internalCallId: string, - args: string, - error: unknown, - ): Promise { - await toolObservers?.error({ - turn: turn ?? 0, - toolCall, - toolName: toolCall.function.name, - internalCallId, - args, - error, - toolCallId: toolCall.callId, + const turn = "turn" in event ? event.turn : undefined; + const agentId = event.type === "agent_tool_event" ? event.agentId : this.agent.id; + const agentName = event.type === "agent_tool_event" ? event.agentName : this.agent.name; + await registration.store.append({ + runId, + agentId, + ...(agentName === undefined ? {} : { agentName }), + ...(turn === undefined ? {} : { turn }), + ...(event.type === "agent_tool_event" + ? { + toolName: event.toolName, + ...(event.toolCallId === undefined ? {} : { toolCallId: event.toolCallId }), + internalCallId: event.internalCallId, + } + : {}), + event, }); } @@ -585,95 +587,30 @@ export class PromptRequest { } private cancelled(newMessages: MessageType[], reason: string): PromptCancelledError { - return new PromptCancelledError([...(this.chatHistory ?? []), ...newMessages], reason); + return new PromptCancelledError([...this.chatHistory, ...newMessages], reason); } } -type ToolResultEventPayload = { - toolName: string; - toolCallId?: string; - internalCallId: string; - args: string; - result: string; -}; - -type AsyncQueueWaiter = { - resolve: (result: IteratorResult) => void; - reject: (error: unknown) => void; -}; - -function createAsyncQueue(): AsyncIterable & { - enqueue(value: T): void; - close(): void; - throw(error: unknown): void; +function normalizePromptInput(prompt: string | MessageType | MessageType[]): { + prompt: MessageType; + history: MessageType[]; } { - const values: T[] = []; - const waiters: AsyncQueueWaiter[] = []; - let closed = false; - let error: unknown; - - function flush(): void { - while (waiters.length > 0 && values.length > 0) { - const waiter = waiters.shift(); - const value = values.shift() as T; - if (waiter !== undefined) { - waiter.resolve({ value, done: false }); - } - } - - if (values.length > 0 || waiters.length === 0 || !closed) { - return; - } - - while (waiters.length > 0) { - const waiter = waiters.shift(); - if (waiter === undefined) { - continue; - } - if (error !== undefined) { - waiter.reject(error); - } else { - waiter.resolve({ value: undefined, done: true }); - } - } + if (typeof prompt === "string") { + return { prompt: Message.user(prompt), history: [] }; + } + if (!Array.isArray(prompt)) { + return { prompt, history: [] }; + } + if (prompt.length === 0) { + throw new TypeError("Prompt transcript must contain at least one message."); + } + const activePrompt = prompt.at(-1); + if (activePrompt === undefined) { + throw new TypeError("Prompt transcript must contain at least one message."); } - return { - enqueue(value: T): void { - if (closed) { - return; - } - values.push(value); - flush(); - }, - close(): void { - closed = true; - flush(); - }, - throw(thrown: unknown): void { - closed = true; - error = thrown; - flush(); - }, - [Symbol.asyncIterator](): AsyncIterator { - return { - next(): Promise> { - if (values.length > 0) { - const value = values.shift() as T; - return Promise.resolve({ value, done: false }); - } - if (error !== undefined) { - return Promise.reject(error); - } - if (closed) { - return Promise.resolve({ value: undefined, done: true }); - } - return new Promise((resolve, reject) => { - waiters.push({ resolve, reject }); - }); - }, - }; - }, + prompt: activePrompt, + history: prompt.slice(0, -1), }; } @@ -699,13 +636,3 @@ function isGenerationDeltaEvent(type: string): boolean { type === "tool_call" ); } - -function formatMetadata( - metadata: Record | undefined, -): Record | undefined { - if (metadata === undefined) { - return undefined; - } - - return Object.fromEntries(Object.entries(metadata).map(([key, value]) => [key, String(value)])); -} diff --git a/packages/core/src/agent/retrieval.ts b/packages/core/src/agent/retrieval.ts new file mode 100644 index 00000000..ed4b946b --- /dev/null +++ b/packages/core/src/agent/retrieval.ts @@ -0,0 +1,77 @@ +import type { Document, ToolDefinition } from "../completion/index"; +import type { Agent } from "./agent"; + +export async function fetchDynamicContext( + agent: Agent, + ragText: string | undefined, +): Promise { + if (ragText === undefined || ragText.length === 0 || agent.dynamicContexts.length === 0) { + return []; + } + + const documents: Document[] = []; + for (const registration of agent.dynamicContexts) { + const results = await registration.index.search({ + query: ragText, + topK: registration.options.topK, + threshold: registration.options.threshold, + filter: registration.options.filter, + }); + for (const result of results) { + const formatted = registration.options.format?.(result); + if (formatted !== undefined) { + documents.push(formatted); + } else { + const metadata = formatMetadata(result.metadata); + documents.push({ + id: result.id, + text: + typeof result.document === "string" + ? result.document + : JSON.stringify(result.document, null, 2), + ...(metadata === undefined ? {} : { additionalProps: metadata }), + }); + } + } + } + return documents; +} + +export async function fetchToolDefinitions( + agent: Agent, + ragText: string | undefined, +): Promise { + const staticDefinitions = await agent.toolSet.getToolDefinitions(ragText); + if (ragText === undefined || ragText.length === 0 || agent.dynamicTools.length === 0) { + return staticDefinitions; + } + + const definitions = [...staticDefinitions]; + const names = new Set(staticDefinitions.map((definition) => definition.name)); + for (const registration of agent.dynamicTools) { + const results = await registration.index.search({ + query: ragText, + topK: registration.options.topK, + threshold: registration.options.threshold, + filter: registration.options.filter, + }); + for (const result of results) { + if (names.has(result.document.toolName)) { + continue; + } + names.add(result.document.toolName); + definitions.push(result.document.definition); + } + } + return definitions; +} + +function formatMetadata( + metadata: Record | undefined, +): Record | undefined { + if (metadata === undefined) { + return undefined; + } + + return Object.fromEntries(Object.entries(metadata).map(([key, value]) => [key, String(value)])); +} diff --git a/packages/core/src/agent/stream-accumulator.ts b/packages/core/src/agent/stream-accumulator.ts index c2cdf46b..cf2ddaf3 100644 --- a/packages/core/src/agent/stream-accumulator.ts +++ b/packages/core/src/agent/stream-accumulator.ts @@ -100,10 +100,11 @@ export class CompletionStreamAccumulator { } response(): CompletionResponse { + const accumulatedResponse = this.buildAccumulatedResponse(); if (this.finalResponse !== undefined) { if (this.finalResponse.choice.length === 0) { const response = { - ...this.buildAccumulatedResponse(), + ...accumulatedResponse, usage: this.finalResponse.usage, rawResponse: this.finalResponse.rawResponse, }; @@ -112,10 +113,10 @@ export class CompletionStreamAccumulator { } return response; } - return this.finalResponse; + return this.mergeFinalResponse(accumulatedResponse, this.finalResponse); } - return this.buildAccumulatedResponse(); + return accumulatedResponse; } private buildAccumulatedResponse(): CompletionResponse { @@ -182,6 +183,47 @@ export class CompletionStreamAccumulator { this.toolCalls.set(toolCall.id, partial); } + private mergeFinalResponse( + accumulatedResponse: CompletionResponse, + finalResponse: CompletionResponse, + ): CompletionResponse { + const accumulatedById = new Map(); + const accumulatedByCallId = new Map(); + for (const content of accumulatedResponse.choice) { + if (content.type !== "tool_call") { + continue; + } + accumulatedById.set(content.id, content); + if (content.callId !== undefined) { + accumulatedByCallId.set(content.callId, content); + } + } + + return { + ...finalResponse, + choice: finalResponse.choice.map((content) => { + if (content.type !== "tool_call" || !isEmptyToolArguments(content.function.arguments)) { + return content; + } + + const accumulated = + accumulatedById.get(content.id) ?? + (content.callId === undefined ? undefined : accumulatedByCallId.get(content.callId)); + if (accumulated === undefined || isEmptyToolArguments(accumulated.function.arguments)) { + return content; + } + + return { + ...content, + function: { + ...content.function, + arguments: accumulated.function.arguments, + }, + }; + }), + }; + } + private appendReasoning( reasoning: ReasoningState, event: Extract, { type: "reasoning_delta" }>, @@ -240,3 +282,19 @@ function reasoningDeltaEvent( if (event.signature !== undefined) mapped.signature = event.signature; return mapped; } + +function isEmptyToolArguments(value: unknown): boolean { + if (value === undefined || value === null) { + return true; + } + if (typeof value === "string") { + return value.trim().length === 0; + } + if (Array.isArray(value)) { + return value.length === 0; + } + if (typeof value === "object") { + return Object.values(value).every((item) => item === undefined); + } + return false; +} diff --git a/packages/core/src/agent/tool-execution.ts b/packages/core/src/agent/tool-execution.ts new file mode 100644 index 00000000..96840371 --- /dev/null +++ b/packages/core/src/agent/tool-execution.ts @@ -0,0 +1,286 @@ +import type { + JsonObject, + ToolCall, + ToolDefinition, + ToolResult, + ToolResultContent, +} from "../completion"; +import { ToolContent } from "../completion"; +import { mapWithConcurrency } from "../internal/concurrency"; +import type { ActiveAgentRunObservers, ActiveToolObservers } from "../observability/group"; +import type { AnyTool, NormalizedToolOutput, ToolCallStreamEvent } from "../tool"; +import { toolResultContentToText } from "../tool"; +import type { ToolMiddleware, ToolResultMiddlewareArgs } from "../tool/middleware"; +import type { Agent } from "./agent"; +import type { PromptHook, ToolHookArgs } from "./hooks"; +import { runControl, toolCallControl } from "./hooks"; +import type { AgentChildStreamEvent } from "./request"; + +const MCP_TOOL_METADATA_KEY = Symbol.for("anvia.mcp.tool.metadata"); + +export type ToolResultEventPayload = { + type: "tool_result"; + toolName: string; + toolCallId?: string; + internalCallId: string; + args: string; + result: string; + structuredResult?: ToolResultContent[] | undefined; +}; + +export type AgentToolEventPayload = { + type: "agent_tool_event"; + toolName: string; + toolCallId?: string; + internalCallId: string; + agentId: string; + agentName?: string; + event: AgentChildStreamEvent; +}; + +export type ToolExecutionEventPayload = ToolResultEventPayload | AgentToolEventPayload; + +export type ToolExecutionObservation = { + turn: number; + runObservers: ActiveAgentRunObservers; + toolDefinitions?: ToolDefinition[]; +}; + +export class ToolCallExecutor { + constructor( + private readonly agent: Agent, + private readonly activeHook: PromptHook | undefined, + private readonly concurrency: number, + private readonly requestToolMiddlewares: ToolMiddleware[], + private readonly cancel: (reason: string) => Error, + ) {} + + async execute( + toolCalls: ToolCall[], + onResult?: (result: ToolResultEventPayload) => void, + onStreamEvent?: (event: AgentToolEventPayload) => void, + observation?: ToolExecutionObservation, + ): Promise { + return mapWithConcurrency(toolCalls, this.concurrency, async (toolCall) => { + const args = JSON.stringify(toolCall.function.arguments ?? {}); + const internalCallId = globalThis.crypto.randomUUID(); + const hookArgs: ToolHookArgs = { + toolName: toolCall.function.name, + internalCallId, + args, + }; + if (toolCall.callId !== undefined) { + hookArgs.toolCallId = toolCall.callId; + } + const tool = this.agent.getTool(toolCall.function.name); + const toolDefinition = observation?.toolDefinitions?.find( + (definition) => definition.name === toolCall.function.name, + ); + const toolMetadata = toolTraceMetadata(tool); + + const toolObservers = await observation?.runObservers.startTool({ + turn: observation.turn, + toolCall, + toolName: toolCall.function.name, + internalCallId, + args, + toolCallId: toolCall.callId, + ...(toolDefinition === undefined ? {} : { toolDefinition }), + ...(toolMetadata === undefined ? {} : { toolMetadata }), + }); + + const callAction = await this.activeHook?.onToolCall?.({ + ...hookArgs, + tool: toolCallControl, + }); + if (callAction?.type === "terminate") { + await recordToolError( + toolObservers, + observation?.turn, + toolCall, + internalCallId, + args, + callAction.reason, + ); + throw this.cancel(callAction.reason); + } + if (callAction?.type === "approval_request") { + const reason = `Tool approval was requested for ${toolCall.function.name}, but no approval handler is installed.`; + await recordToolError( + toolObservers, + observation?.turn, + toolCall, + internalCallId, + args, + reason, + ); + throw this.cancel(reason); + } + + let output: NormalizedToolOutput; + let skipped = false; + if (callAction?.type === "skip") { + output = callAction.reason; + skipped = true; + } else { + try { + output = await this.agent.callTool(toolCall.function.name, args, { + emitStreamEvent: async (event) => { + await toolObservers?.streamEvent({ + turn: observation?.turn ?? 0, + toolCall, + toolName: toolCall.function.name, + internalCallId, + args, + ...(toolCall.callId === undefined ? {} : { toolCallId: toolCall.callId }), + event, + }); + const payload = agentToolEventPayload(toolCall, internalCallId, event); + if (payload !== undefined) { + onStreamEvent?.(payload); + } + }, + }); + } catch (error) { + output = error instanceof Error ? error.toString() : String(error); + } + } + + let result = toolOutputToText(output); + let structuredResult = toolOutputToStructuredResult(output); + if (this.agent.shouldApplyToolMiddleware(toolCall.function.name)) { + const middlewareReplacement = await this.runToolResultMiddlewares({ + ...hookArgs, + result, + originalResult: result, + structuredResult, + originalStructuredResult: structuredResult, + turn: observation?.turn ?? 0, + }); + if (middlewareReplacement !== undefined) { + output = middlewareReplacement; + result = middlewareReplacement; + structuredResult = undefined; + } + } + + const resultAction = await this.activeHook?.onToolResult?.({ + ...hookArgs, + result, + structuredResult, + run: runControl, + }); + await toolObservers?.end({ + turn: observation?.turn ?? 0, + toolCall, + toolName: toolCall.function.name, + internalCallId, + args, + result, + structuredResult, + skipped, + toolCallId: toolCall.callId, + }); + if (resultAction?.type === "terminate") { + throw this.cancel(resultAction.reason); + } + + const resultPayload: ToolResultEventPayload = { + type: "tool_result", + toolName: toolCall.function.name, + internalCallId, + args, + result, + structuredResult, + }; + if (toolCall.callId !== undefined) { + resultPayload.toolCallId = toolCall.callId; + } + onResult?.(resultPayload); + return ToolContent.toolResult(toolCall.id, output, toolCall.callId); + }); + } + + private async runToolResultMiddlewares( + args: ToolResultMiddlewareArgs, + ): Promise { + let result = args.result; + let replaced = false; + for (const middleware of [...this.agent.toolMiddlewares, ...this.requestToolMiddlewares]) { + const replacement = await middleware.onResult?.({ + ...args, + result, + }); + if (replacement !== undefined) { + result = replacement; + replaced = true; + } + } + return replaced ? result : undefined; + } +} + +function toolTraceMetadata(tool: AnyTool | undefined): JsonObject | undefined { + if (tool === undefined) { + return undefined; + } + const metadata = (tool as { [MCP_TOOL_METADATA_KEY]?: unknown })[MCP_TOOL_METADATA_KEY]; + const mcpMetadata = + typeof metadata === "object" && metadata !== null + ? (metadata as { serverName?: unknown }) + : undefined; + return { + approvalRequired: tool.approval !== undefined, + ...(typeof mcpMetadata?.serverName === "string" && mcpMetadata.serverName.length > 0 + ? { mcpServerName: mcpMetadata.serverName } + : {}), + }; +} + +async function recordToolError( + toolObservers: ActiveToolObservers | undefined, + turn: number | undefined, + toolCall: ToolCall, + internalCallId: string, + args: string, + error: unknown, +): Promise { + await toolObservers?.error({ + turn: turn ?? 0, + toolCall, + toolName: toolCall.function.name, + internalCallId, + args, + error, + toolCallId: toolCall.callId, + }); +} + +function toolOutputToText(output: NormalizedToolOutput): string { + return typeof output === "string" ? output : toolResultContentToText(output); +} + +function toolOutputToStructuredResult( + output: NormalizedToolOutput, +): ToolResultContent[] | undefined { + return typeof output === "string" ? undefined : output; +} + +function agentToolEventPayload( + toolCall: ToolCall, + internalCallId: string, + event: ToolCallStreamEvent, +): AgentToolEventPayload | undefined { + if (typeof event.agentId !== "string" || event.agentId.length === 0) { + return undefined; + } + return { + type: "agent_tool_event", + toolName: toolCall.function.name, + ...(toolCall.callId === undefined ? {} : { toolCallId: toolCall.callId }), + internalCallId, + agentId: event.agentId, + ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + event: event.event as AgentChildStreamEvent, + }; +} diff --git a/packages/core/src/agent/utils.ts b/packages/core/src/agent/utils.ts index 19436811..4942a7cc 100644 --- a/packages/core/src/agent/utils.ts +++ b/packages/core/src/agent/utils.ts @@ -35,27 +35,3 @@ export function parseJsonValue(text: string): JsonValue { return text; } } - -export async function mapWithConcurrency( - items: T[], - concurrency: number, - mapper: (item: T) => Promise, -): Promise { - const results: R[] = []; - let next = 0; - - async function worker(): Promise { - while (next < items.length) { - const index = next; - next += 1; - const item = items[index]; - if (item !== undefined) { - results[index] = await mapper(item); - } - } - } - - const workerCount = Math.min(concurrency, items.length); - await Promise.all(Array.from({ length: workerCount }, () => worker())); - return results; -} diff --git a/packages/core/src/completion/types.ts b/packages/core/src/completion/types.ts index e41ee609..d3979ac1 100644 --- a/packages/core/src/completion/types.ts +++ b/packages/core/src/completion/types.ts @@ -372,6 +372,10 @@ export interface CompletionModel { readonly provider: string; readonly defaultModel: string; readonly capabilities: CompletionModelCapabilities; + traceRequest?( + request: CompletionRequest, + options?: { stream?: boolean | undefined }, + ): JsonObject | undefined; completion(request: CompletionRequest): Promise>; } diff --git a/packages/core/src/embeddings/index.ts b/packages/core/src/embeddings/index.ts index 718cc5e3..e90f90d3 100644 --- a/packages/core/src/embeddings/index.ts +++ b/packages/core/src/embeddings/index.ts @@ -1,30 +1,13 @@ -export type Embedding = { - document: string; - vector: number[]; -}; - -export interface EmbeddingModel { - readonly dimensions?: number | undefined; - readonly maxBatchSize?: number | undefined; - embedTexts(texts: string[]): Promise; -} - -export type EmbeddedDocument = { - id: string; - document: T; - metadata?: Metadata | undefined; - embeddings: Embedding[]; -}; +import { mapWithConcurrency } from "../internal/concurrency"; +import type { + EmbedDocumentsOptions, + EmbeddedDocument, + Embedding, + EmbeddingModel, + VectorMetadata, +} from "./types"; -export type VectorMetadataValue = string | number | boolean | null; -export type VectorMetadata = Record; - -export type EmbedDocumentsOptions = { - id?: ((document: T, index: number) => string) | undefined; - content(document: T, index: number): string | string[]; - metadata?: ((document: T, index: number) => Metadata | undefined) | undefined; - concurrency?: number | undefined; -}; +export type * from "./types"; export async function embedText(model: EmbeddingModel, text: string): Promise { const embeddings = await embedTexts(model, [text]); @@ -164,23 +147,3 @@ function chunk(items: T[], size: number): T[][] { } return chunks; } - -async function mapWithConcurrency( - items: T[], - concurrency: number, - mapper: (item: T) => Promise, -): Promise { - const results = new Array(items.length); - let next = 0; - - async function worker(): Promise { - while (next < items.length) { - const index = next; - next += 1; - results[index] = await mapper(items[index] as T); - } - } - - await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())); - return results; -} diff --git a/packages/core/src/embeddings/types.ts b/packages/core/src/embeddings/types.ts new file mode 100644 index 00000000..bd3b07ad --- /dev/null +++ b/packages/core/src/embeddings/types.ts @@ -0,0 +1,27 @@ +export type Embedding = { + document: string; + vector: number[]; +}; + +export interface EmbeddingModel { + readonly dimensions?: number | undefined; + readonly maxBatchSize?: number | undefined; + embedTexts(texts: string[]): Promise; +} + +export type EmbeddedDocument = { + id: string; + document: T; + metadata?: Metadata | undefined; + embeddings: Embedding[]; +}; + +export type VectorMetadataValue = string | number | boolean | null; +export type VectorMetadata = Record; + +export type EmbedDocumentsOptions = { + id?: ((document: T, index: number) => string) | undefined; + content(document: T, index: number): string | string[]; + metadata?: ((document: T, index: number) => Metadata | undefined) | undefined; + concurrency?: number | undefined; +}; diff --git a/packages/core/src/evals/agent-target.ts b/packages/core/src/evals/agent-target.ts new file mode 100644 index 00000000..8d2b9aa4 --- /dev/null +++ b/packages/core/src/evals/agent-target.ts @@ -0,0 +1,28 @@ +import type { Agent } from "../agent/agent"; +import type { PromptResponse } from "../agent/request"; +import type { Message } from "../completion"; +import type { EvalCase, EvalTarget } from "./types"; + +export type AgentEvalTargetOptions = { + prompt?: ((input: Input, testCase: EvalCase) => string | Message) | undefined; + output?: ((response: PromptResponse, testCase: EvalCase) => Output) | undefined; +}; + +export function agentEvalTarget( + agent: Agent, + options?: AgentEvalTargetOptions, +): EvalTarget; +export function agentEvalTarget( + agent: Agent, + options: AgentEvalTargetOptions, +): EvalTarget; +export function agentEvalTarget( + agent: Agent, + options: AgentEvalTargetOptions = {}, +): EvalTarget { + return async (input, testCase) => { + const prompt = options.prompt?.(input, testCase) ?? String(input); + const response = await agent.prompt(prompt).send(); + return options.output === undefined ? response : options.output(response, testCase); + }; +} diff --git a/packages/core/src/evals/format.ts b/packages/core/src/evals/format.ts new file mode 100644 index 00000000..157b9078 --- /dev/null +++ b/packages/core/src/evals/format.ts @@ -0,0 +1,33 @@ +export function defaultOutputValue(output: unknown): unknown { + if ( + typeof output === "object" && + output !== null && + "output" in output && + typeof (output as { output?: unknown }).output === "string" + ) { + return (output as { output: string }).output; + } + return output; +} + +export function stableComparable(value: unknown): string { + if (typeof value === "string") { + return value; + } + return JSON.stringify(value); +} + +export function formatValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/evals/index.ts b/packages/core/src/evals/index.ts index 793da265..64a82bc0 100644 --- a/packages/core/src/evals/index.ts +++ b/packages/core/src/evals/index.ts @@ -1,566 +1,20 @@ -import { z } from "zod"; -import type { Agent, PromptResponse } from "../agent"; -import type { CompletionModel, JsonValue, Message } from "../completion"; -import { cosineSimilarity, type EmbeddingModel, embedText } from "../embeddings"; -import { ExtractorBuilder } from "../extractor"; -import type { ZodSchema } from "../schema"; - -export type EvalMetadata = Record; - -export type EvalCase = { - id: string; - input: Input; - expected?: Expected | undefined; - metadata?: EvalMetadata | undefined; -}; - -export type EvalTarget = ( - input: Input, - testCase: EvalCase, -) => Output | Promise; - -export type EvalOutcomeStatus = "pass" | "fail" | "invalid"; - -export type EvalOutcome = - | { - outcome: "pass"; - score?: Score | undefined; - comment?: string | undefined; - metadata?: EvalMetadata | undefined; - } - | { - outcome: "fail"; - score?: Score | undefined; - comment?: string | undefined; - metadata?: EvalMetadata | undefined; - } - | { - outcome: "invalid"; - reason: string; - score?: Score | undefined; - comment?: string | undefined; - metadata?: EvalMetadata | undefined; - }; - -export const EvalOutcome = { - pass( - score?: Score, - options: { comment?: string | undefined; metadata?: EvalMetadata | undefined } = {}, - ): EvalOutcome { - return { - outcome: "pass", - ...(score === undefined ? {} : { score }), - ...(options.comment === undefined ? {} : { comment: options.comment }), - ...(options.metadata === undefined ? {} : { metadata: options.metadata }), - }; - }, - - fail( - score?: Score, - options: { comment?: string | undefined; metadata?: EvalMetadata | undefined } = {}, - ): EvalOutcome { - return { - outcome: "fail", - ...(score === undefined ? {} : { score }), - ...(options.comment === undefined ? {} : { comment: options.comment }), - ...(options.metadata === undefined ? {} : { metadata: options.metadata }), - }; - }, - - invalid( - reason: string, - options: { - score?: Score | undefined; - comment?: string | undefined; - metadata?: EvalMetadata | undefined; - } = {}, - ): EvalOutcome { - return { - outcome: "invalid", - reason, - ...(options.score === undefined ? {} : { score: options.score }), - ...(options.comment === undefined ? {} : { comment: options.comment }), - ...(options.metadata === undefined ? {} : { metadata: options.metadata }), - }; - }, -}; - -export type EvalMetricArgs = { - suiteName: string; - case: EvalCase; - output: Output; -}; - -export type EvalMetric = { - name: string; - evaluate( - args: EvalMetricArgs, - ): EvalOutcome | Promise>; -}; - -export type EvalMetricResult = { - metricName: string; - outcome: EvalOutcome; - reporterErrors: unknown[]; -}; - -export type EvalCaseResult = { - case: EvalCase; - output?: Output | undefined; - targetError?: unknown; - metrics: EvalMetricResult[]; -}; - -export type EvalSuiteResult = { - name: string; - results: Array>; - passed: number; - failed: number; - invalid: number; - durationMs: number; -}; - -export type EvalReportArgs = { - suiteName: string; - case: EvalCase; - output?: Output | undefined; - targetError?: unknown; - metric: EvalMetric; - outcome: EvalOutcome; -}; - -export type EvalReporter = { - report(args: EvalReportArgs): void | Promise; -}; - -export type RunEvalSuiteOptions = { - name: string; - cases: Array>; - target: EvalTarget; - metrics: Array, NoInfer, unknown, NoInfer>>; - concurrency?: number | undefined; - reporters?: Array, NoInfer, NoInfer>> | undefined; - failOnReporterError?: boolean | undefined; -}; - -export async function runEvalSuite( - options: RunEvalSuiteOptions, -): Promise> { - const startedAt = Date.now(); - const results = await mapWithConcurrency( - options.cases, - Math.max(1, Math.trunc(options.concurrency ?? 1)), - (testCase) => runEvalCase(options, testCase), - ); - const counts = countOutcomes(results); - return { - name: options.name, - results, - ...counts, - durationMs: Date.now() - startedAt, - }; -} - -export type ValueSelector = ( - args: EvalMetricArgs, -) => Value | Promise; - -export type SelectorOrValue = - | Value - | ValueSelector; - -export type ExactMatchOptions = { - name?: string | undefined; - actual?: ValueSelector | undefined; - expected?: SelectorOrValue | undefined; -}; - -export function exactMatch( - options: ExactMatchOptions = {}, -): EvalMetric { - return { - name: options.name ?? "exact_match", - async evaluate(args) { - const actual = await resolveActual(options.actual, args); - const expected = await resolveExpected(options.expected, args); - if (expected === undefined) { - return EvalOutcome.invalid("No expected value provided for exact match."); - } - const passed = stableComparable(actual) === stableComparable(expected); - return passed - ? EvalOutcome.pass(true) - : EvalOutcome.fail(false, { comment: `Expected ${formatValue(expected)}.` }); - }, - }; -} - -export type ContainsOptions = { - name?: string | undefined; - actual?: ValueSelector | undefined; - expected?: SelectorOrValue | undefined; -}; - -export function contains( - options: ContainsOptions = {}, -): EvalMetric { - return { - name: options.name ?? "contains", - async evaluate(args) { - const actual = await resolveActualText(options.actual, args); - const expected = await resolveExpected(options.expected, args); - if (expected === undefined) { - return EvalOutcome.invalid("No expected value provided for contains."); - } - if (typeof expected !== "string" && !(expected instanceof RegExp)) { - return EvalOutcome.invalid("Contains expected value must be a string or RegExp."); - } - const passed = expected instanceof RegExp ? expected.test(actual) : actual.includes(expected); - return passed - ? EvalOutcome.pass(true) - : EvalOutcome.fail(false, { comment: `Output did not contain ${String(expected)}.` }); - }, - }; -} - -export type SemanticSimilarityOptions = { - name?: string | undefined; - model: EmbeddingModel; - threshold: number; - actual?: ValueSelector | undefined; - expected?: SelectorOrValue | undefined; -}; - -export function semanticSimilarity( - options: SemanticSimilarityOptions, -): EvalMetric { - return { - name: options.name ?? "semantic_similarity", - async evaluate(args) { - const actual = await resolveActualText(options.actual, args); - const expected = await resolveExpected(options.expected, args); - if (expected === undefined) { - return EvalOutcome.invalid("No expected value provided for semantic similarity."); - } - if (typeof expected !== "string") { - return EvalOutcome.invalid("Semantic similarity expected value must be a string."); - } - const [actualEmbedding, expectedEmbedding] = await Promise.all([ - embedText(options.model, actual), - embedText(options.model, expected), - ]); - const score = cosineSimilarity(actualEmbedding.vector, expectedEmbedding.vector); - return score >= options.threshold - ? EvalOutcome.pass(score) - : EvalOutcome.fail(score, { comment: `Similarity below threshold ${options.threshold}.` }); - }, - }; -} - -export type LlmJudgeOptions = { - name?: string | undefined; - model: CompletionModel; - schema: ZodSchema; - passes(value: SchemaOutput): boolean; - instructions?: string | undefined; - retries?: number | undefined; - prompt?: ValueSelector | undefined; -}; - -export function llmJudge( - options: LlmJudgeOptions, -): EvalMetric { - const extractor = new ExtractorBuilder(options.model, options.schema) - .instructions( - options.instructions ?? - "Judge the eval case by the requested schema. Submit the judgment using the schema.", - ) - .retries(options.retries ?? 0) - .build(); - - return { - name: options.name ?? "llm_judge", - async evaluate(args) { - try { - const judgment = await extractor.extract(await resolveJudgePrompt(options.prompt, args)); - return options.passes(judgment) ? EvalOutcome.pass(judgment) : EvalOutcome.fail(judgment); - } catch (error) { - return EvalOutcome.invalid(errorMessage(error)); - } - }, - }; -} - -export type LlmScoreMetricScore = { - score: number; - feedback: string; -}; - -export type LlmScoreOptions = { - name?: string | undefined; - model: CompletionModel; - threshold: number; - criteria: string | string[]; - instructions?: string | undefined; - retries?: number | undefined; - prompt?: ValueSelector | undefined; -}; - -export function llmScore( - options: LlmScoreOptions, -): EvalMetric { - const criteria = Array.isArray(options.criteria) ? options.criteria.join("\n") : options.criteria; - const extractor = new ExtractorBuilder( - options.model, - z.object({ - score: z.number(), - feedback: z.string(), - }), - ) - .instructions( - options.instructions ?? - `Score the eval case against these criteria:\n${criteria}\n\nReturn a score between 0 and 1 and brief feedback.`, - ) - .retries(options.retries ?? 0) - .build(); - - return { - name: options.name ?? "llm_score", - async evaluate(args) { - try { - const score = await extractor.extract(await resolveJudgePrompt(options.prompt, args)); - if (score.score < 0 || score.score > 1) { - return EvalOutcome.invalid(`Score ${score.score} outside valid range [0, 1].`, { - score, - }); - } - return score.score >= options.threshold - ? EvalOutcome.pass(score, { comment: score.feedback }) - : EvalOutcome.fail(score, { comment: score.feedback }); - } catch (error) { - return EvalOutcome.invalid(errorMessage(error)); - } - }, - }; -} - -export type AgentEvalTargetOptions = { - prompt?: ((input: Input, testCase: EvalCase) => string | Message) | undefined; - output?: ((response: PromptResponse, testCase: EvalCase) => Output) | undefined; -}; - -export function agentEvalTarget( - agent: Agent, - options?: AgentEvalTargetOptions, -): EvalTarget; -export function agentEvalTarget( - agent: Agent, - options: AgentEvalTargetOptions, -): EvalTarget; -export function agentEvalTarget( - agent: Agent, - options: AgentEvalTargetOptions = {}, -): EvalTarget { - return async (input, testCase) => { - const prompt = options.prompt?.(input, testCase) ?? String(input); - const response = await agent.prompt(prompt).send(); - return options.output === undefined ? response : options.output(response, testCase); - }; -} - -async function runEvalCase( - options: RunEvalSuiteOptions, - testCase: EvalCase, -): Promise> { - let output: Output | undefined; - let targetError: unknown; - try { - output = await options.target(testCase.input, testCase); - } catch (error) { - targetError = error; - } - - const metrics: EvalMetricResult[] = []; - for (const metric of options.metrics) { - const outcome = - targetError === undefined - ? await safeEvaluate(options.name, testCase, output as Output, metric) - : EvalOutcome.invalid(`Target failed: ${errorMessage(targetError)}`); - const reporterErrors = await reportOutcome({ - suiteName: options.name, - testCase, - output, - targetError, - metric, - outcome, - reporters: options.reporters ?? [], - failOnReporterError: options.failOnReporterError === true, - }); - metrics.push({ metricName: metric.name, outcome, reporterErrors }); - } - - return { - case: testCase, - ...(output === undefined ? {} : { output }), - ...(targetError === undefined ? {} : { targetError }), - metrics, - }; -} - -async function safeEvaluate( - suiteName: string, - testCase: EvalCase, - output: Output, - metric: EvalMetric, -): Promise { - try { - return await metric.evaluate({ suiteName, case: testCase, output }); - } catch (error) { - return EvalOutcome.invalid(errorMessage(error)); - } -} - -async function reportOutcome(args: { - suiteName: string; - testCase: EvalCase; - output: Output | undefined; - targetError: unknown; - metric: EvalMetric; - outcome: EvalOutcome; - reporters: Array>; - failOnReporterError: boolean; -}): Promise { - const errors: unknown[] = []; - for (const reporter of args.reporters) { - try { - await reporter.report({ - suiteName: args.suiteName, - case: args.testCase, - output: args.output, - targetError: args.targetError, - metric: args.metric, - outcome: args.outcome, - }); - } catch (error) { - if (args.failOnReporterError) { - throw error; - } - errors.push(error); - } - } - return errors; -} - -function countOutcomes(results: Array>): { - passed: number; - failed: number; - invalid: number; -} { - let passed = 0; - let failed = 0; - let invalid = 0; - for (const result of results) { - for (const metric of result.metrics) { - if (metric.outcome.outcome === "pass") passed += 1; - if (metric.outcome.outcome === "fail") failed += 1; - if (metric.outcome.outcome === "invalid") invalid += 1; - } - } - return { passed, failed, invalid }; -} - -async function resolveActual( - selector: ValueSelector | undefined, - args: EvalMetricArgs, -): Promise { - return selector === undefined ? defaultOutputValue(args.output) : selector(args); -} - -async function resolveActualText( - selector: ValueSelector | undefined, - args: EvalMetricArgs, -): Promise { - const value = selector === undefined ? defaultOutputValue(args.output) : await selector(args); - return typeof value === "string" ? value : JSON.stringify(value); -} - -async function resolveExpected( - selectorOrValue: SelectorOrValue | undefined, - args: EvalMetricArgs, -): Promise { - if (selectorOrValue === undefined) { - return args.case.expected; - } - return typeof selectorOrValue === "function" - ? (selectorOrValue as ValueSelector)(args) - : selectorOrValue; -} - -async function resolveJudgePrompt( - selector: ValueSelector | undefined, - args: EvalMetricArgs, -): Promise { - if (selector !== undefined) { - return selector(args); - } - return [ - `Suite: ${args.suiteName}`, - `Case: ${args.case.id}`, - `Input: ${formatValue(args.case.input)}`, - `Expected: ${formatValue(args.case.expected)}`, - `Output: ${formatValue(defaultOutputValue(args.output))}`, - ].join("\n\n"); -} - -function defaultOutputValue(output: unknown): unknown { - if ( - typeof output === "object" && - output !== null && - "output" in output && - typeof (output as { output?: unknown }).output === "string" - ) { - return (output as { output: string }).output; - } - return output; -} - -function stableComparable(value: unknown): string { - if (typeof value === "string") { - return value; - } - return JSON.stringify(value); -} - -function formatValue(value: unknown): string { - if (typeof value === "string") { - return value; - } - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -async function mapWithConcurrency( - inputs: Input[], - concurrency: number, - mapper: (input: Input) => Promise, -): Promise { - const results = new Array(inputs.length); - let next = 0; - - async function worker(): Promise { - while (next < inputs.length) { - const index = next; - next += 1; - results[index] = await mapper(inputs[index] as Input); - } - } - - await Promise.all(Array.from({ length: Math.min(concurrency, inputs.length) }, () => worker())); - return results; -} +export * from "./agent-target"; +export * from "./metrics"; +export * from "./outcome"; +export { runEvalSuite } from "./runner"; +export type { + EvalCase, + EvalCaseResult, + EvalMetadata, + EvalMetric, + EvalMetricArgs, + EvalMetricResult, + EvalOutcomeStatus, + EvalReportArgs, + EvalReporter, + EvalSuiteResult, + EvalTarget, + RunEvalSuiteOptions, + SelectorOrValue, + ValueSelector, +} from "./types"; diff --git a/packages/core/src/evals/metrics.ts b/packages/core/src/evals/metrics.ts new file mode 100644 index 00000000..3ad08b6e --- /dev/null +++ b/packages/core/src/evals/metrics.ts @@ -0,0 +1,183 @@ +import { z } from "zod"; +import type { CompletionModel } from "../completion"; +import { cosineSimilarity, type EmbeddingModel, embedText } from "../embeddings"; +import { ExtractorBuilder } from "../extractor"; +import type { ZodSchema } from "../schema"; +import { errorMessage, formatValue, stableComparable } from "./format"; +import { EvalOutcome } from "./outcome"; +import { resolveActual, resolveActualText, resolveExpected, resolveJudgePrompt } from "./selectors"; +import type { EvalMetric, SelectorOrValue, ValueSelector } from "./types"; + +export type ExactMatchOptions = { + name?: string | undefined; + actual?: ValueSelector | undefined; + expected?: SelectorOrValue | undefined; +}; + +export function exactMatch( + options: ExactMatchOptions = {}, +): EvalMetric { + return { + name: options.name ?? "exact_match", + async evaluate(args) { + const actual = await resolveActual(options.actual, args); + const expected = await resolveExpected(options.expected, args); + if (expected === undefined) { + return EvalOutcome.invalid("No expected value provided for exact match."); + } + const passed = stableComparable(actual) === stableComparable(expected); + return passed + ? EvalOutcome.pass(true) + : EvalOutcome.fail(false, { comment: `Expected ${formatValue(expected)}.` }); + }, + }; +} + +export type ContainsOptions = { + name?: string | undefined; + actual?: ValueSelector | undefined; + expected?: SelectorOrValue | undefined; +}; + +export function contains( + options: ContainsOptions = {}, +): EvalMetric { + return { + name: options.name ?? "contains", + async evaluate(args) { + const actual = await resolveActualText(options.actual, args); + const expected = await resolveExpected(options.expected, args); + if (expected === undefined) { + return EvalOutcome.invalid("No expected value provided for contains."); + } + if (typeof expected !== "string" && !(expected instanceof RegExp)) { + return EvalOutcome.invalid("Contains expected value must be a string or RegExp."); + } + const passed = expected instanceof RegExp ? expected.test(actual) : actual.includes(expected); + return passed + ? EvalOutcome.pass(true) + : EvalOutcome.fail(false, { comment: `Output did not contain ${String(expected)}.` }); + }, + }; +} + +export type SemanticSimilarityOptions = { + name?: string | undefined; + model: EmbeddingModel; + threshold: number; + actual?: ValueSelector | undefined; + expected?: SelectorOrValue | undefined; +}; + +export function semanticSimilarity( + options: SemanticSimilarityOptions, +): EvalMetric { + return { + name: options.name ?? "semantic_similarity", + async evaluate(args) { + const actual = await resolveActualText(options.actual, args); + const expected = await resolveExpected(options.expected, args); + if (expected === undefined) { + return EvalOutcome.invalid("No expected value provided for semantic similarity."); + } + if (typeof expected !== "string") { + return EvalOutcome.invalid("Semantic similarity expected value must be a string."); + } + const [actualEmbedding, expectedEmbedding] = await Promise.all([ + embedText(options.model, actual), + embedText(options.model, expected), + ]); + const score = cosineSimilarity(actualEmbedding.vector, expectedEmbedding.vector); + return score >= options.threshold + ? EvalOutcome.pass(score) + : EvalOutcome.fail(score, { comment: `Similarity below threshold ${options.threshold}.` }); + }, + }; +} + +export type LlmJudgeOptions = { + name?: string | undefined; + model: CompletionModel; + schema: ZodSchema; + passes(value: SchemaOutput): boolean; + instructions?: string | undefined; + retries?: number | undefined; + prompt?: ValueSelector | undefined; +}; + +export function llmJudge( + options: LlmJudgeOptions, +): EvalMetric { + const extractor = new ExtractorBuilder(options.model, options.schema) + .instructions( + options.instructions ?? + "Judge the eval case by the requested schema. Submit the judgment using the schema.", + ) + .retries(options.retries ?? 0) + .build(); + + return { + name: options.name ?? "llm_judge", + async evaluate(args) { + try { + const judgment = await extractor.extract(await resolveJudgePrompt(options.prompt, args)); + return options.passes(judgment) ? EvalOutcome.pass(judgment) : EvalOutcome.fail(judgment); + } catch (error) { + return EvalOutcome.invalid(errorMessage(error)); + } + }, + }; +} + +export type LlmScoreMetricScore = { + score: number; + feedback: string; +}; + +export type LlmScoreOptions = { + name?: string | undefined; + model: CompletionModel; + threshold: number; + criteria: string | string[]; + instructions?: string | undefined; + retries?: number | undefined; + prompt?: ValueSelector | undefined; +}; + +export function llmScore( + options: LlmScoreOptions, +): EvalMetric { + const criteria = Array.isArray(options.criteria) ? options.criteria.join("\n") : options.criteria; + const extractor = new ExtractorBuilder( + options.model, + z.object({ + score: z.number(), + feedback: z.string(), + }), + ) + .instructions( + options.instructions ?? + `Score the eval case against these criteria:\n${criteria}\n\nReturn a score between 0 and 1 and brief feedback.`, + ) + .retries(options.retries ?? 0) + .build(); + + return { + name: options.name ?? "llm_score", + async evaluate(args) { + try { + const score = await extractor.extract(await resolveJudgePrompt(options.prompt, args)); + if (score.score < 0 || score.score > 1) { + return EvalOutcome.invalid(`Score ${score.score} outside valid range [0, 1].`, { + score, + }); + } + return score.score >= options.threshold + ? EvalOutcome.pass(score, { comment: score.feedback }) + : EvalOutcome.fail(score, { comment: score.feedback }); + } catch (error) { + return EvalOutcome.invalid(errorMessage(error)); + } + }, + }; +} diff --git a/packages/core/src/evals/outcome.ts b/packages/core/src/evals/outcome.ts new file mode 100644 index 00000000..f54e935b --- /dev/null +++ b/packages/core/src/evals/outcome.ts @@ -0,0 +1,65 @@ +import type { EvalMetadata } from "./types"; + +export type EvalOutcome = + | { + outcome: "pass"; + score?: Score | undefined; + comment?: string | undefined; + metadata?: EvalMetadata | undefined; + } + | { + outcome: "fail"; + score?: Score | undefined; + comment?: string | undefined; + metadata?: EvalMetadata | undefined; + } + | { + outcome: "invalid"; + reason: string; + score?: Score | undefined; + comment?: string | undefined; + metadata?: EvalMetadata | undefined; + }; + +export const EvalOutcome = { + pass( + score?: Score, + options: { comment?: string | undefined; metadata?: EvalMetadata | undefined } = {}, + ): EvalOutcome { + return { + outcome: "pass", + ...(score === undefined ? {} : { score }), + ...(options.comment === undefined ? {} : { comment: options.comment }), + ...(options.metadata === undefined ? {} : { metadata: options.metadata }), + }; + }, + + fail( + score?: Score, + options: { comment?: string | undefined; metadata?: EvalMetadata | undefined } = {}, + ): EvalOutcome { + return { + outcome: "fail", + ...(score === undefined ? {} : { score }), + ...(options.comment === undefined ? {} : { comment: options.comment }), + ...(options.metadata === undefined ? {} : { metadata: options.metadata }), + }; + }, + + invalid( + reason: string, + options: { + score?: Score | undefined; + comment?: string | undefined; + metadata?: EvalMetadata | undefined; + } = {}, + ): EvalOutcome { + return { + outcome: "invalid", + reason, + ...(options.score === undefined ? {} : { score: options.score }), + ...(options.comment === undefined ? {} : { comment: options.comment }), + ...(options.metadata === undefined ? {} : { metadata: options.metadata }), + }; + }, +}; diff --git a/packages/core/src/evals/runner.ts b/packages/core/src/evals/runner.ts new file mode 100644 index 00000000..a6a425c7 --- /dev/null +++ b/packages/core/src/evals/runner.ts @@ -0,0 +1,131 @@ +import { mapWithConcurrency } from "../internal/concurrency"; +import { errorMessage } from "./format"; +import { EvalOutcome, type EvalOutcome as EvalOutcomeType } from "./outcome"; +import type { + EvalCase, + EvalCaseResult, + EvalMetric, + EvalMetricResult, + EvalReporter, + EvalSuiteResult, + RunEvalSuiteOptions, +} from "./types"; + +export async function runEvalSuite( + options: RunEvalSuiteOptions, +): Promise> { + const startedAt = Date.now(); + const results = await mapWithConcurrency( + options.cases, + Math.max(1, Math.trunc(options.concurrency ?? 1)), + (testCase) => runEvalCase(options, testCase), + ); + const counts = countOutcomes(results); + return { + name: options.name, + results, + ...counts, + durationMs: Date.now() - startedAt, + }; +} + +async function runEvalCase( + options: RunEvalSuiteOptions, + testCase: EvalCase, +): Promise> { + let output: Output | undefined; + let targetError: unknown; + try { + output = await options.target(testCase.input, testCase); + } catch (error) { + targetError = error; + } + + const metrics: EvalMetricResult[] = []; + for (const metric of options.metrics) { + const outcome = + targetError === undefined + ? await safeEvaluate(options.name, testCase, output as Output, metric) + : EvalOutcome.invalid(`Target failed: ${errorMessage(targetError)}`); + const reporterErrors = await reportOutcome({ + suiteName: options.name, + testCase, + output, + targetError, + metric, + outcome, + reporters: options.reporters ?? [], + failOnReporterError: options.failOnReporterError === true, + }); + metrics.push({ metricName: metric.name, outcome, reporterErrors }); + } + + return { + case: testCase, + ...(output === undefined ? {} : { output }), + ...(targetError === undefined ? {} : { targetError }), + metrics, + }; +} + +async function safeEvaluate( + suiteName: string, + testCase: EvalCase, + output: Output, + metric: EvalMetric, +): Promise { + try { + return await metric.evaluate({ suiteName, case: testCase, output }); + } catch (error) { + return EvalOutcome.invalid(errorMessage(error)); + } +} + +async function reportOutcome(args: { + suiteName: string; + testCase: EvalCase; + output: Output | undefined; + targetError: unknown; + metric: EvalMetric; + outcome: EvalOutcomeType; + reporters: Array>; + failOnReporterError: boolean; +}): Promise { + const errors: unknown[] = []; + for (const reporter of args.reporters) { + try { + await reporter.report({ + suiteName: args.suiteName, + case: args.testCase, + output: args.output, + targetError: args.targetError, + metric: args.metric, + outcome: args.outcome, + }); + } catch (error) { + if (args.failOnReporterError) { + throw error; + } + errors.push(error); + } + } + return errors; +} + +function countOutcomes(results: Array>): { + passed: number; + failed: number; + invalid: number; +} { + let passed = 0; + let failed = 0; + let invalid = 0; + for (const result of results) { + for (const metric of result.metrics) { + if (metric.outcome.outcome === "pass") passed += 1; + if (metric.outcome.outcome === "fail") failed += 1; + if (metric.outcome.outcome === "invalid") invalid += 1; + } + } + return { passed, failed, invalid }; +} diff --git a/packages/core/src/evals/selectors.ts b/packages/core/src/evals/selectors.ts new file mode 100644 index 00000000..c2fbebfb --- /dev/null +++ b/packages/core/src/evals/selectors.ts @@ -0,0 +1,45 @@ +import { defaultOutputValue, formatValue } from "./format"; +import type { EvalMetricArgs, SelectorOrValue, ValueSelector } from "./types"; + +export async function resolveActual( + selector: ValueSelector | undefined, + args: EvalMetricArgs, +): Promise { + return selector === undefined ? defaultOutputValue(args.output) : selector(args); +} + +export async function resolveActualText( + selector: ValueSelector | undefined, + args: EvalMetricArgs, +): Promise { + const value = selector === undefined ? defaultOutputValue(args.output) : await selector(args); + return typeof value === "string" ? value : JSON.stringify(value); +} + +export async function resolveExpected( + selectorOrValue: SelectorOrValue | undefined, + args: EvalMetricArgs, +): Promise { + if (selectorOrValue === undefined) { + return args.case.expected; + } + return typeof selectorOrValue === "function" + ? (selectorOrValue as ValueSelector)(args) + : selectorOrValue; +} + +export async function resolveJudgePrompt( + selector: ValueSelector | undefined, + args: EvalMetricArgs, +): Promise { + if (selector !== undefined) { + return selector(args); + } + return [ + `Suite: ${args.suiteName}`, + `Case: ${args.case.id}`, + `Input: ${formatValue(args.case.input)}`, + `Expected: ${formatValue(args.case.expected)}`, + `Output: ${formatValue(defaultOutputValue(args.output))}`, + ].join("\n\n"); +} diff --git a/packages/core/src/evals/types.ts b/packages/core/src/evals/types.ts new file mode 100644 index 00000000..3e428919 --- /dev/null +++ b/packages/core/src/evals/types.ts @@ -0,0 +1,84 @@ +import type { JsonValue } from "../completion"; +import type { EvalOutcome } from "./outcome"; + +export type EvalMetadata = Record; + +export type EvalCase = { + id: string; + input: Input; + expected?: Expected | undefined; + metadata?: EvalMetadata | undefined; +}; + +export type EvalTarget = ( + input: Input, + testCase: EvalCase, +) => Output | Promise; + +export type EvalOutcomeStatus = "pass" | "fail" | "invalid"; + +export type EvalMetricArgs = { + suiteName: string; + case: EvalCase; + output: Output; +}; + +export type EvalMetric = { + name: string; + evaluate( + args: EvalMetricArgs, + ): EvalOutcome | Promise>; +}; + +export type EvalMetricResult = { + metricName: string; + outcome: EvalOutcome; + reporterErrors: unknown[]; +}; + +export type EvalCaseResult = { + case: EvalCase; + output?: Output | undefined; + targetError?: unknown; + metrics: EvalMetricResult[]; +}; + +export type EvalSuiteResult = { + name: string; + results: Array>; + passed: number; + failed: number; + invalid: number; + durationMs: number; +}; + +export type EvalReportArgs = { + suiteName: string; + case: EvalCase; + output?: Output | undefined; + targetError?: unknown; + metric: EvalMetric; + outcome: EvalOutcome; +}; + +export type EvalReporter = { + report(args: EvalReportArgs): void | Promise; +}; + +export type RunEvalSuiteOptions = { + name: string; + cases: Array>; + target: EvalTarget; + metrics: Array, NoInfer, unknown, NoInfer>>; + concurrency?: number | undefined; + reporters?: Array, NoInfer, NoInfer>> | undefined; + failOnReporterError?: boolean | undefined; +}; + +export type ValueSelector = ( + args: EvalMetricArgs, +) => Value | Promise; + +export type SelectorOrValue = + | Value + | ValueSelector; diff --git a/packages/core/src/extractor/extractor.ts b/packages/core/src/extractor/extractor.ts index d8d7675f..fd7ed360 100644 --- a/packages/core/src/extractor/extractor.ts +++ b/packages/core/src/extractor/extractor.ts @@ -1,4 +1,5 @@ -import { type Agent, AgentBuilder } from "../agent/index"; +import type { Agent } from "../agent/agent"; +import { AgentBuilder } from "../agent/builder"; import { CompletionCapabilityError, type CompletionModel, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d6bea567..faf2b36d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,16 +1,53 @@ -export * from "./agent/index"; -export * from "./audio-generation"; -export * from "./completion/index"; -export * from "./embeddings"; -export * from "./evals"; -export * from "./extractor"; -export * from "./image-generation"; -export * from "./mcp"; -export * from "./observability"; -export * from "./pipeline"; +export { AgentBuilder } from "./agent/builder"; +export { MaxTurnsError, PromptCancelledError } from "./agent/errors"; +export { + cancelPrompt, + createHook, + requestToolApproval, + runControl, + skipTool, + toolCallControl, +} from "./agent/hooks"; +export type { + AgentChildStreamEvent, + AgentStreamEvent, + PromptResponse, +} from "./agent/request"; +export type { + AssistantMessage, + CompletionModel, + CompletionRequest, + CompletionResponse, + Document, + ImageContent, + JsonObject, + JsonPrimitive, + JsonValue, + SystemMessage, + Text, + ToolCall, + ToolDefinition, + ToolMessage, + ToolResult, + ToolResultContent, + UserMessage, +} from "./completion/index"; +export { + AssistantContent, + Message, + Usage, + UserContent, +} from "./completion/index"; +export type { MemoryStore } from "./memory"; export type { ZodSchema } from "./schema"; -export * from "./skills"; -export * from "./streaming"; -export * from "./tool/index"; -export * from "./transcription"; -export * from "./vector-store"; +export { loadSkills, SkillValidationError, skill } from "./skills"; +export type { + AnyTool, + CreateToolOptions, + Tool, + ToolApprovalContext, + ToolApprovalPolicy, + ToolCallContext, + ToolCallStreamEvent, +} from "./tool/index"; +export { createThinkTool, createTool } from "./tool/index"; diff --git a/packages/core/src/internal/agent.ts b/packages/core/src/internal/agent.ts new file mode 100644 index 00000000..6ed626c9 --- /dev/null +++ b/packages/core/src/internal/agent.ts @@ -0,0 +1 @@ +export * from "../agent/agent"; diff --git a/packages/core/src/internal/async-queue.ts b/packages/core/src/internal/async-queue.ts new file mode 100644 index 00000000..c5b1ee58 --- /dev/null +++ b/packages/core/src/internal/async-queue.ts @@ -0,0 +1,81 @@ +type AsyncQueueWaiter = { + resolve: (result: IteratorResult) => void; + reject: (error: unknown) => void; +}; + +export type AsyncQueue = AsyncIterable & { + enqueue(value: T): void; + close(): void; + throw(error: unknown): void; +}; + +export function createAsyncQueue(): AsyncQueue { + const values: T[] = []; + const waiters: AsyncQueueWaiter[] = []; + let closed = false; + let error: unknown; + + function flush(): void { + while (waiters.length > 0 && values.length > 0) { + const waiter = waiters.shift(); + const value = values.shift() as T; + if (waiter !== undefined) { + waiter.resolve({ value, done: false }); + } + } + + if (values.length > 0 || waiters.length === 0 || !closed) { + return; + } + + while (waiters.length > 0) { + const waiter = waiters.shift(); + if (waiter === undefined) { + continue; + } + if (error !== undefined) { + waiter.reject(error); + } else { + waiter.resolve({ value: undefined, done: true }); + } + } + } + + return { + enqueue(value: T): void { + if (closed) { + return; + } + values.push(value); + flush(); + }, + close(): void { + closed = true; + flush(); + }, + throw(thrown: unknown): void { + closed = true; + error = thrown; + flush(); + }, + [Symbol.asyncIterator](): AsyncIterator { + return { + next(): Promise> { + if (values.length > 0) { + const value = values.shift() as T; + return Promise.resolve({ value, done: false }); + } + if (error !== undefined) { + return Promise.reject(error); + } + if (closed) { + return Promise.resolve({ value: undefined, done: true }); + } + return new Promise((resolve, reject) => { + waiters.push({ resolve, reject }); + }); + }, + }; + }, + }; +} diff --git a/packages/core/src/internal/concurrency.ts b/packages/core/src/internal/concurrency.ts new file mode 100644 index 00000000..259f4b61 --- /dev/null +++ b/packages/core/src/internal/concurrency.ts @@ -0,0 +1,20 @@ +export async function mapWithConcurrency( + inputs: Input[], + concurrency: number, + mapper: (input: Input) => Promise, +): Promise { + const limit = Math.max(1, Math.trunc(concurrency)); + const results = new Array(inputs.length); + let nextIndex = 0; + + async function worker(): Promise { + while (nextIndex < inputs.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(inputs[index] as Input); + } + } + + await Promise.all(Array.from({ length: Math.min(limit, inputs.length) }, () => worker())); + return results; +} diff --git a/packages/core/src/loaders/index.ts b/packages/core/src/loaders/index.ts index 7d3ce181..91f6e291 100644 --- a/packages/core/src/loaders/index.ts +++ b/packages/core/src/loaders/index.ts @@ -318,7 +318,8 @@ async function readFileSource(source: FileSource): Promise { async function readPdfPages(source: PdfSource): Promise { const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); const bytes = "bytes" in source ? source.bytes : toUint8Array(await readFile(source.path)); - const document = await pdfjs.getDocument({ data: bytes }).promise; + const loadingTask = pdfjs.getDocument({ data: bytes }); + const document = await loadingTask.promise; const pages: PdfPage[] = []; try { for (let index = 1; index <= document.numPages; index += 1) { @@ -332,7 +333,7 @@ async function readPdfPages(source: PdfSource): Promise { pages.push({ pageNumber: index - 1, text: text.length > 0 ? `${text}\n` : "" }); } } finally { - await document.destroy(); + await loadingTask.destroy(); } return pages; } diff --git a/packages/core/src/mcp/connect.ts b/packages/core/src/mcp/connect.ts index 335baf4d..aa0db210 100644 --- a/packages/core/src/mcp/connect.ts +++ b/packages/core/src/mcp/connect.ts @@ -7,7 +7,7 @@ export async function connectMcp(connection: McpConnection): Promise return { name: connection.name, - tools: tools.map((tool) => createMcpTool(tool, client)), + tools: tools.map((tool) => createMcpTool(tool, client, connection.name)), close: () => client.close(), }; } diff --git a/packages/core/src/mcp/connections.ts b/packages/core/src/mcp/connections.ts index 028413f2..8579dc00 100644 --- a/packages/core/src/mcp/connections.ts +++ b/packages/core/src/mcp/connections.ts @@ -1,7 +1,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { McpClient, McpConnection, McpHttpOptions, McpStdioOptions } from "./types"; +import type { + McpClient, + McpConnection, + McpHttpOptions, + McpSseOptions, + McpStdioOptions, +} from "./types"; export const mcp = { stdio(options: McpStdioOptions): McpConnection { @@ -30,6 +37,19 @@ export const mcp = { }, }; }, + + sse(options: McpSseOptions): McpConnection { + return { + name: options.name, + async connect(): Promise { + const client = createSdkClient(); + await client.connect( + asSdkTransport(new SSEClientTransport(new URL(options.url), options.transport)), + ); + return client as McpClient; + }, + }; + }, }; function createSdkClient(): Client { diff --git a/packages/core/src/mcp/tool.ts b/packages/core/src/mcp/tool.ts index 6c98e58a..baf0eeab 100644 --- a/packages/core/src/mcp/tool.ts +++ b/packages/core/src/mcp/tool.ts @@ -3,8 +3,14 @@ import type { Tool } from "../tool/index"; import { createCallToolParams, mapMcpToolResult } from "./result"; import type { McpClient, McpToolDefinition } from "./types"; -export function createMcpTool(definition: McpToolDefinition, client: McpClient): Tool { - return { +const MCP_TOOL_METADATA_KEY = Symbol.for("anvia.mcp.tool.metadata"); + +export function createMcpTool( + definition: McpToolDefinition, + client: McpClient, + serverName?: string, +): Tool { + const tool: Tool = { name: definition.name, definition(): ToolDefinition { return { @@ -18,4 +24,11 @@ export function createMcpTool(definition: McpToolDefinition, client: McpClient): return mapMcpToolResult(result); }, }; + if (serverName !== undefined) { + Object.defineProperty(tool, MCP_TOOL_METADATA_KEY, { + value: { serverName }, + enumerable: false, + }); + } + return tool; } diff --git a/packages/core/src/mcp/types.ts b/packages/core/src/mcp/types.ts index bacd23e6..e563e08c 100644 --- a/packages/core/src/mcp/types.ts +++ b/packages/core/src/mcp/types.ts @@ -1,3 +1,4 @@ +import type { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js"; import type { StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js"; import type { StreamableHTTPClientTransportOptions } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { JsonObject } from "../completion/index"; @@ -72,3 +73,9 @@ export type McpHttpOptions = { url: string | URL; transport?: StreamableHTTPClientTransportOptions | undefined; }; + +export type McpSseOptions = { + name: string; + url: string | URL; + transport?: SSEClientTransportOptions | undefined; +}; diff --git a/packages/core/src/memory/index.ts b/packages/core/src/memory/index.ts new file mode 100644 index 00000000..e9311364 --- /dev/null +++ b/packages/core/src/memory/index.ts @@ -0,0 +1,54 @@ +import type { JsonObject, Message } from "../completion"; + +export type MemorySavePolicy = "message" | "turn" | "run"; + +export type MemoryContext = { + sessionId: string; + userId?: string | undefined; + metadata?: JsonObject | undefined; +}; + +export type MemoryAppendInput = { + context: MemoryContext; + runId: string; + turn: number; + messages: Message[]; +}; + +export type MemoryErrorInput = { + context: MemoryContext; + runId: string; + error: unknown; + messages: Message[]; +}; + +export interface MemoryStore { + load(context: MemoryContext): Promise; + append(input: MemoryAppendInput): Promise; + clear(context: MemoryContext): Promise; + recordError?(input: MemoryErrorInput): Promise; +} + +export type MemoryOptions = { + savePolicy?: MemorySavePolicy | undefined; +}; + +export type ResolvedMemoryOptions = { + savePolicy: MemorySavePolicy; +}; + +export type MemoryRegistration = { + store: MemoryStore; + options: ResolvedMemoryOptions; +}; + +export type SessionOptions = { + userId?: string | undefined; + metadata?: JsonObject | undefined; +}; + +export function resolveMemoryOptions(options: MemoryOptions = {}): ResolvedMemoryOptions { + return { + savePolicy: options.savePolicy ?? "message", + }; +} diff --git a/packages/core/src/model-listing/index.ts b/packages/core/src/model-listing/index.ts new file mode 100644 index 00000000..066380d2 --- /dev/null +++ b/packages/core/src/model-listing/index.ts @@ -0,0 +1,37 @@ +export type ListedModel = { + id: string; + name?: string; + description?: string; + type?: string; + createdAt?: number; + ownedBy?: string; + contextLength?: number; +}; + +export type ModelList = { + data: ListedModel[]; +}; + +export interface ModelListingClient { + listModels(): Promise; +} + +type ModelListingErrorOptions = { + provider?: string | undefined; + statusCode?: number | undefined; + cause?: unknown; +}; + +export class ModelListingError extends Error { + readonly provider?: string | undefined; + readonly statusCode?: number | undefined; + override readonly cause?: unknown; + + constructor(message: string, options: ModelListingErrorOptions = {}) { + super(message, { cause: options.cause }); + this.name = "ModelListingError"; + this.provider = options.provider; + this.statusCode = options.statusCode; + this.cause = options.cause; + } +} diff --git a/packages/core/src/observability/group.ts b/packages/core/src/observability/group.ts index 2b67471a..51558ca7 100644 --- a/packages/core/src/observability/group.ts +++ b/packages/core/src/observability/group.ts @@ -12,6 +12,7 @@ import type { AgentToolErrorArgs, AgentToolObserver, AgentToolStartArgs, + AgentToolStreamEventArgs, AgentTraceInfo, } from "./types"; @@ -155,6 +156,19 @@ export class ActiveToolObservers { private readonly failOnObserverError: boolean, ) {} + async streamEvent(args: AgentToolStreamEventArgs): Promise { + for (const observer of this.toolObservers) { + if (observer.streamEvent === undefined) { + continue; + } + try { + await observer.streamEvent(args); + } catch (error) { + this.handleError(error); + } + } + } + async end(args: AgentToolEndArgs): Promise { for (const observer of this.toolObservers) { try { diff --git a/packages/core/src/observability/index.ts b/packages/core/src/observability/index.ts index 731c2897..7a0b5c92 100644 --- a/packages/core/src/observability/index.ts +++ b/packages/core/src/observability/index.ts @@ -13,6 +13,7 @@ export type { AgentToolErrorArgs, AgentToolObserver, AgentToolStartArgs, + AgentToolStreamEventArgs, AgentTraceInfo, AgentTraceOptions, ObserveOptions, diff --git a/packages/core/src/observability/types.ts b/packages/core/src/observability/types.ts index d5b37b2d..765a5784 100644 --- a/packages/core/src/observability/types.ts +++ b/packages/core/src/observability/types.ts @@ -1,10 +1,15 @@ import type { + CompletionModelCapabilities, CompletionRequest, CompletionResponse, + JsonObject, Message, ToolCall, + ToolDefinition, + ToolResultContent, Usage, } from "../completion"; +import type { ToolCallStreamEvent } from "../tool"; export type AgentTraceInfo = { traceId?: string | undefined; @@ -47,6 +52,12 @@ export type AgentRunErrorArgs = { export type AgentGenerationStartArgs = { turn: number; request: CompletionRequest; + providerRequest?: JsonObject | undefined; + modelInfo?: { + provider: string; + defaultModel: string; + capabilities?: CompletionModelCapabilities | undefined; + }; }; export type AgentGenerationEndArgs = { @@ -67,10 +78,13 @@ export type AgentToolStartArgs = { args: string; internalCallId: string; toolCallId?: string | undefined; + toolDefinition?: ToolDefinition | undefined; + toolMetadata?: JsonObject | undefined; }; export type AgentToolEndArgs = AgentToolStartArgs & { result: string; + structuredResult?: ToolResultContent[] | undefined; skipped: boolean; }; @@ -78,12 +92,17 @@ export type AgentToolErrorArgs = AgentToolStartArgs & { error: unknown; }; +export type AgentToolStreamEventArgs = AgentToolStartArgs & { + event: ToolCallStreamEvent; +}; + export interface AgentGenerationObserver { end(args: AgentGenerationEndArgs): void | Promise; error?(args: AgentGenerationErrorArgs): void | Promise; } export interface AgentToolObserver { + streamEvent?(args: AgentToolStreamEventArgs): void | Promise; end(args: AgentToolEndArgs): void | Promise; error?(args: AgentToolErrorArgs): void | Promise; } diff --git a/packages/core/src/pipeline/builder.ts b/packages/core/src/pipeline/builder.ts new file mode 100644 index 00000000..1dae9936 --- /dev/null +++ b/packages/core/src/pipeline/builder.ts @@ -0,0 +1,215 @@ +import type { Agent } from "../agent/agent"; +import type { CompletionModel } from "../completion"; +import type { Extractor } from "../extractor"; +import { + appendChildNode, + appendNode, + initialBuilderState, + nextStageLabel, + withOutputNode, + withTerminalNodes, +} from "./graph"; +import { Pipeline } from "./pipeline"; +import { runNode } from "./runtime"; +import type { + ParallelOutput, + PipelineBuilderState, + PipelineExecutor, + PipelineGraphNode, + PipelineMetadata, + PipelineOp, + PipelineRunContext, + PipelineStageMetadata, +} from "./types"; + +/** Builds a typed pipeline from an original input type to an inferred output type. */ +export class PipelineBuilder { + private readonly executor: PipelineExecutor; + private readonly state: PipelineBuilderState; + + constructor(); + constructor(metadata: PipelineMetadata); + constructor(executor: (input: Input) => Output | Promise); + constructor(executor: PipelineExecutor, state: PipelineBuilderState); + constructor( + metadataOrExecutor?: + | PipelineMetadata + | ((input: Input) => Output | Promise) + | PipelineExecutor, + state?: PipelineBuilderState, + ) { + if (state !== undefined) { + this.executor = metadataOrExecutor as PipelineExecutor; + this.state = state; + return; + } + + if (typeof metadataOrExecutor === "function") { + const executor = metadataOrExecutor as (input: Input) => Output | Promise; + this.executor = ((input) => executor(input)) as PipelineExecutor; + this.state = initialBuilderState({}); + return; + } + + this.executor = identity as PipelineExecutor; + this.state = initialBuilderState(metadataOrExecutor ?? {}); + } + + /** Add a synchronous or asynchronous transform stage. */ + step( + fn: (input: Awaited) => Next | Promise, + metadata?: PipelineStageMetadata, + ): PipelineBuilder> { + const next = appendNode( + this.state, + "step", + metadata?.name ?? nextStageLabel(this.state, "Step"), + { + description: metadata?.description, + metadata: metadata?.metadata, + preferredId: metadata?.id, + }, + ); + return new PipelineBuilder>( + async (input, context): Promise> => { + const value = await this.runStep(input, context); + const result = await runNode(context, next.node, () => fn(value)); + return result as Awaited; + }, + next.state, + ); + } + + /** Compose another pipeline operation after the current stage. */ + use( + op: PipelineOp, Next>, + metadata?: PipelineStageMetadata, + ): PipelineBuilder> { + const nested = op instanceof Pipeline ? op : undefined; + const next = appendNode( + this.state, + nested === undefined ? "step" : "pipeline", + metadata?.name ?? + nested?.name ?? + nested?.id ?? + nextStageLabel(this.state, nested === undefined ? "Operation" : "Pipeline"), + { + description: metadata?.description ?? nested?.description, + metadata: metadata?.metadata ?? nested?.metadata, + preferredId: metadata?.id, + pipelineId: nested?.id, + }, + ); + return new PipelineBuilder>( + async (input, context): Promise> => { + const value = await this.runStep(input, context); + const result = await runNode(context, next.node, () => op.run(value)); + return result as Awaited; + }, + next.state, + ); + } + + /** Run named branch operations concurrently from the current value. */ + parallel, unknown>>>( + branches: Branches, + metadata?: PipelineStageMetadata, + ): PipelineBuilder> { + const parallel = appendNode( + this.state, + "parallel", + metadata?.name ?? `${Object.keys(branches).length} parallel branches`, + { + description: metadata?.description, + metadata: metadata?.metadata, + preferredId: metadata?.id, + }, + ); + let nextState = parallel.state; + const branchNodes: Record = {}; + for (const key of Object.keys(branches)) { + const branch = appendChildNode(nextState, parallel.node.id, "branch", key, { + branchKey: key, + }); + nextState = branch.state; + branchNodes[key] = branch.node; + } + nextState = withTerminalNodes( + nextState, + Object.values(branchNodes).map((node) => node.id), + ); + + return new PipelineBuilder>(async (input, context) => { + const value = await this.runStep(input, context); + const entries = await runNode(context, parallel.node, () => + Promise.all( + Object.entries(branches).map(async ([key, op]) => { + const node = branchNodes[key] as PipelineGraphNode; + const output = await runNode(context, node, () => op.run(value)); + return [key, output] as const; + }), + ), + ); + return Object.fromEntries(entries) as ParallelOutput; + }, nextState); + } + + /** Send the current value to an agent as text and continue with the agent output. */ + prompt( + agent: Agent, + metadata?: PipelineStageMetadata, + ): PipelineBuilder { + const next = appendNode(this.state, "agent", metadata?.name ?? agent.name ?? agent.id, { + description: metadata?.description ?? agent.description, + metadata: metadata?.metadata, + preferredId: metadata?.id, + agentId: agent.id, + agentName: agent.name, + }); + return new PipelineBuilder(async (input, context) => { + const value = await this.runStep(input, context); + return runNode(context, next.node, async () => { + const response = await agent.prompt(String(value)).send(); + return response.output; + }); + }, next.state); + } + + /** Send the current value to an extractor as text and continue with typed schema data. */ + extract( + extractor: Extractor, + metadata?: PipelineStageMetadata, + ): PipelineBuilder { + const next = appendNode( + this.state, + "extractor", + metadata?.name ?? nextStageLabel(this.state, "Extractor"), + { + description: metadata?.description, + metadata: metadata?.metadata, + preferredId: metadata?.id, + }, + ); + return new PipelineBuilder(async (input, context) => { + const value = await this.runStep(input, context); + return runNode(context, next.node, () => extractor.extract(String(value))); + }, next.state); + } + + /** Finish the builder and return a runnable pipeline. */ + build(): Pipeline> { + const graph = withOutputNode(this.state); + return new Pipeline>( + (input, context) => this.runStep(input, context) as Promise>, + graph, + ); + } + + private async runStep(input: Input, context: PipelineRunContext): Promise> { + return (await this.executor(input, context)) as Awaited; + } +} + +function identity(input: T): T { + return input; +} diff --git a/packages/core/src/pipeline/graph.ts b/packages/core/src/pipeline/graph.ts new file mode 100644 index 00000000..94795731 --- /dev/null +++ b/packages/core/src/pipeline/graph.ts @@ -0,0 +1,191 @@ +import type { JsonObject } from "../completion"; +import type { + PipelineBuilderState, + PipelineGraph, + PipelineGraphNode, + PipelineMetadata, + PipelineStageKind, +} from "./types"; + +export function initialBuilderState(metadata: PipelineMetadata): PipelineBuilderState { + return { + graph: initialGraph(metadata), + terminalNodeId: "input", + terminalNodeIds: ["input"], + nextNodeIndex: 1, + nextEdgeIndex: 1, + }; +} + +export function initialGraph(metadata: PipelineMetadata): PipelineGraph { + const id = normalizeId(metadata.id ?? "pipeline"); + return { + id, + ...(metadata.name === undefined ? {} : { name: metadata.name }), + ...(metadata.description === undefined ? {} : { description: metadata.description }), + ...(metadata.metadata === undefined ? {} : { metadata: metadata.metadata }), + nodes: [{ id: "input", kind: "input", label: "Input" }], + edges: [], + }; +} + +export function appendNode( + state: PipelineBuilderState, + kind: PipelineStageKind, + label: string, + options: { + description?: string | undefined; + metadata?: JsonObject | undefined; + preferredId?: string | undefined; + agentId?: string | undefined; + agentName?: string | undefined; + pipelineId?: string | undefined; + } = {}, +): { state: PipelineBuilderState; node: PipelineGraphNode } { + const node = graphNode(kind, label, state.nextNodeIndex, { + ...options, + existingIds: new Set(state.graph.nodes.map((item) => item.id)), + }); + return { + node, + state: appendGraphNode(state, node, activeTerminalNodeIds(state), [node.id]), + }; +} + +export function appendChildNode( + state: PipelineBuilderState, + parentId: string, + kind: PipelineStageKind, + label: string, + options: { + branchKey?: string | undefined; + } = {}, +): { state: PipelineBuilderState; node: PipelineGraphNode } { + const node = graphNode(kind, label, state.nextNodeIndex, { + ...options, + existingIds: new Set(state.graph.nodes.map((item) => item.id)), + }); + return { + node, + state: appendGraphNode(state, node, [parentId], activeTerminalNodeIds(state)), + }; +} + +export function activeTerminalNodeIds(state: PipelineBuilderState): string[] { + return state.terminalNodeIds.length > 0 ? state.terminalNodeIds : [state.terminalNodeId]; +} + +export function withTerminalNodes( + state: PipelineBuilderState, + terminalNodeIds: string[], +): PipelineBuilderState { + return { + ...state, + terminalNodeId: terminalNodeIds.at(-1) ?? state.terminalNodeId, + terminalNodeIds, + }; +} + +export function withOutputNode(state: PipelineBuilderState): PipelineGraph { + const graph = cloneGraph(state.graph); + if (graph.nodes.some((node) => node.id === "output")) { + return graph; + } + graph.nodes.push({ id: "output", kind: "output", label: "Output" }); + graph.edges.push( + ...activeTerminalNodeIds(state).map((sourceId, index) => ({ + id: `edge_${state.nextEdgeIndex + index}`, + source: sourceId, + target: "output", + })), + ); + return graph; +} + +export function nextStageLabel(state: PipelineBuilderState, prefix: string): string { + return `${prefix} ${state.nextNodeIndex}`; +} + +export function cloneGraph(graph: PipelineGraph): PipelineGraph { + return { + ...graph, + nodes: graph.nodes.map((node) => ({ ...node })), + edges: graph.edges.map((edge) => ({ ...edge })), + }; +} + +function appendGraphNode( + state: PipelineBuilderState, + node: PipelineGraphNode, + sourceIds: string[], + terminalNodeIds: string[], +): PipelineBuilderState { + const edges = sourceIds.map((sourceId, index) => ({ + id: `edge_${state.nextEdgeIndex + index}`, + source: sourceId, + target: node.id, + })); + const terminalNodeId = terminalNodeIds.at(-1) ?? state.terminalNodeId; + return { + graph: { + ...state.graph, + nodes: [...state.graph.nodes, node], + edges: [...state.graph.edges, ...edges], + }, + terminalNodeId, + terminalNodeIds, + nextNodeIndex: state.nextNodeIndex + 1, + nextEdgeIndex: state.nextEdgeIndex + edges.length, + }; +} + +function graphNode( + kind: PipelineStageKind, + label: string, + index: number, + options: { + description?: string | undefined; + metadata?: JsonObject | undefined; + preferredId?: string | undefined; + agentId?: string | undefined; + agentName?: string | undefined; + pipelineId?: string | undefined; + branchKey?: string | undefined; + existingIds?: Set | undefined; + } = {}, +): PipelineGraphNode { + const id = uniqueGraphNodeId( + normalizeId(options.preferredId ?? `${kind}_${index}`), + options.existingIds ?? new Set(), + ); + return { + id, + kind, + label, + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.metadata === undefined ? {} : { metadata: options.metadata }), + ...(options.agentId === undefined ? {} : { agentId: options.agentId }), + ...(options.agentName === undefined ? {} : { agentName: options.agentName }), + ...(options.pipelineId === undefined ? {} : { pipelineId: options.pipelineId }), + ...(options.branchKey === undefined ? {} : { branchKey: options.branchKey }), + }; +} + +function normalizeId(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "_") + .replace(/^_+|_+$/g, ""); + return normalized.length === 0 ? "pipeline" : normalized; +} + +function uniqueGraphNodeId(baseId: string, existingIds: Set): string { + let id = baseId; + let suffix = 2; + while (existingIds.has(id)) { + id = `${baseId}_${suffix}`; + suffix += 1; + } + return id; +} diff --git a/packages/core/src/pipeline/index.ts b/packages/core/src/pipeline/index.ts index 53ed8506..bc88ed02 100644 --- a/packages/core/src/pipeline/index.ts +++ b/packages/core/src/pipeline/index.ts @@ -1,124 +1,3 @@ -import type { Agent } from "../agent"; -import type { CompletionModel } from "../completion"; -import type { Extractor } from "../extractor"; - -/** Minimal interface for anything that can run as a pipeline stage. */ -export interface PipelineOp { - run(input: Input): Output | Promise; -} - -export interface PipelineBatchOptions { - /** Maximum number of inputs processed at the same time. */ - concurrency: number; -} - -type AwaitedOutput = Op extends PipelineOp ? Awaited : never; - -type ParallelOutput>> = { - [Key in keyof Branches]: AwaitedOutput; -}; - -/** Runnable pipeline returned by `PipelineBuilder.build()`. */ -export class Pipeline implements PipelineOp> { - constructor(private readonly executor: (input: Input) => Output | Promise) {} - - /** Run one input through the built pipeline and return the final stage output. */ - async run(input: Input): Promise> { - return await this.executor(input); - } - - /** Run many inputs through the same pipeline with bounded concurrency. */ - async batch>( - inputs: I, - options: PipelineBatchOptions, - ): Promise>> { - return mapWithConcurrency([...inputs], options.concurrency, (input) => this.run(input)); - } -} - -/** Builds a typed pipeline from an original input type to an inferred output type. */ -export class PipelineBuilder { - constructor( - private readonly executor: (input: Input) => Output | Promise = identity as ( - input: Input, - ) => Output, - ) {} - - /** Add a synchronous or asynchronous transform stage. */ - step( - fn: (input: Awaited) => Next | Promise, - ): PipelineBuilder> { - return new PipelineBuilder>(async (input): Promise> => { - const result = await fn(await this.runStep(input)); - return result as Awaited; - }); - } - - /** Compose another pipeline operation after the current stage. */ - use(op: PipelineOp, Next>): PipelineBuilder> { - return new PipelineBuilder>(async (input): Promise> => { - const result = await op.run(await this.runStep(input)); - return result as Awaited; - }); - } - - /** Run named branch operations concurrently from the current value. */ - parallel, unknown>>>( - branches: Branches, - ): PipelineBuilder> { - return new PipelineBuilder>(async (input) => { - const value = await this.runStep(input); - const entries = await Promise.all( - Object.entries(branches).map(async ([key, op]) => [key, await op.run(value)] as const), - ); - return Object.fromEntries(entries) as ParallelOutput; - }); - } - - /** Send the current value to an agent as text and continue with the agent output. */ - prompt(agent: Agent): PipelineBuilder { - return this.step(async (input) => { - const response = await agent.prompt(String(input)).send(); - return response.output; - }); - } - - /** Send the current value to an extractor as text and continue with typed schema data. */ - extract(extractor: Extractor): PipelineBuilder { - return this.step((input) => extractor.extract(String(input))); - } - - /** Finish the builder and return a runnable pipeline. */ - build(): Pipeline> { - return new Pipeline>((input) => this.runStep(input)); - } - - private async runStep(input: Input): Promise> { - return (await this.executor(input)) as Awaited; - } -} - -function identity(input: T): T { - return input; -} - -async function mapWithConcurrency( - inputs: Input[], - concurrency: number, - fn: (input: Input) => Promise, -): Promise { - const limit = Math.max(1, Math.trunc(concurrency)); - const results = new Array(inputs.length); - let nextIndex = 0; - - async function worker(): Promise { - while (nextIndex < inputs.length) { - const index = nextIndex; - nextIndex += 1; - results[index] = await fn(inputs[index] as Input); - } - } - - await Promise.all(Array.from({ length: Math.min(limit, inputs.length) }, () => worker())); - return results; -} +export * from "./builder"; +export * from "./pipeline"; +export * from "./types"; diff --git a/packages/core/src/pipeline/pipeline.ts b/packages/core/src/pipeline/pipeline.ts new file mode 100644 index 00000000..114fe5d1 --- /dev/null +++ b/packages/core/src/pipeline/pipeline.ts @@ -0,0 +1,45 @@ +import type { JsonObject } from "../completion"; +import { mapWithConcurrency } from "../internal/concurrency"; +import { cloneGraph, initialGraph } from "./graph"; +import type { + PipelineBatchOptions, + PipelineExecutor, + PipelineGraph, + PipelineOp, + PipelineRunOptions, +} from "./types"; + +/** Runnable pipeline returned by `PipelineBuilder.build()`. */ +export class Pipeline implements PipelineOp> { + readonly id: string; + readonly name: string | undefined; + readonly description: string | undefined; + readonly metadata: JsonObject | undefined; + + constructor( + private readonly executor: PipelineExecutor, + private readonly pipelineGraph: PipelineGraph = initialGraph({}), + ) { + this.id = pipelineGraph.id; + this.name = pipelineGraph.name; + this.description = pipelineGraph.description; + this.metadata = pipelineGraph.metadata; + } + + /** Run one input through the built pipeline and return the final stage output. */ + async run(input: Input, options: PipelineRunOptions = {}): Promise> { + return (await this.executor(input, { observer: options.observer })) as Awaited; + } + + /** Run many inputs through the same pipeline with bounded concurrency. */ + async batch>( + inputs: I, + options: PipelineBatchOptions, + ): Promise>> { + return mapWithConcurrency([...inputs], options.concurrency, (input) => this.run(input)); + } + + graph(): PipelineGraph { + return cloneGraph(this.pipelineGraph); + } +} diff --git a/packages/core/src/pipeline/runtime.ts b/packages/core/src/pipeline/runtime.ts new file mode 100644 index 00000000..b213fe37 --- /dev/null +++ b/packages/core/src/pipeline/runtime.ts @@ -0,0 +1,27 @@ +import type { PipelineGraphNode, PipelineRunContext } from "./types"; + +export async function runNode( + context: PipelineRunContext, + node: PipelineGraphNode, + fn: () => Output | Promise, +): Promise> { + const startedAt = Date.now(); + await context.observer?.onEvent({ type: "stage_started", node }); + try { + const output = (await fn()) as Awaited; + await context.observer?.onEvent({ + type: "stage_completed", + node, + durationMs: Date.now() - startedAt, + }); + return output; + } catch (error) { + await context.observer?.onEvent({ + type: "stage_failed", + node, + durationMs: Date.now() - startedAt, + error, + }); + throw error; + } +} diff --git a/packages/core/src/pipeline/types.ts b/packages/core/src/pipeline/types.ts new file mode 100644 index 00000000..64a2ec74 --- /dev/null +++ b/packages/core/src/pipeline/types.ts @@ -0,0 +1,109 @@ +import type { JsonObject } from "../completion"; + +/** Minimal interface for anything that can run as a pipeline stage. */ +export interface PipelineOp { + run(input: Input): Output | Promise; +} + +export interface PipelineBatchOptions { + /** Maximum number of inputs processed at the same time. */ + concurrency: number; +} + +export type AwaitedOutput = + Op extends PipelineOp ? Awaited : never; + +export type ParallelOutput>> = { + [Key in keyof Branches]: AwaitedOutput; +}; + +export type PipelineMetadata = { + id?: string | undefined; + name?: string | undefined; + description?: string | undefined; + metadata?: JsonObject | undefined; +}; + +export type PipelineStageMetadata = { + id?: string | undefined; + name?: string | undefined; + description?: string | undefined; + metadata?: JsonObject | undefined; +}; + +export type PipelineStageKind = + | "input" + | "step" + | "pipeline" + | "parallel" + | "branch" + | "agent" + | "extractor" + | "output"; + +export type PipelineGraphNode = { + id: string; + kind: PipelineStageKind; + label: string; + description?: string | undefined; + metadata?: JsonObject | undefined; + agentId?: string | undefined; + agentName?: string | undefined; + pipelineId?: string | undefined; + branchKey?: string | undefined; +}; + +export type PipelineGraphEdge = { + id: string; + source: string; + target: string; + label?: string | undefined; +}; + +export type PipelineGraph = PipelineMetadata & { + id: string; + nodes: PipelineGraphNode[]; + edges: PipelineGraphEdge[]; +}; + +export type PipelineRunEvent = + | { + type: "stage_started"; + node: PipelineGraphNode; + } + | { + type: "stage_completed"; + node: PipelineGraphNode; + durationMs: number; + } + | { + type: "stage_failed"; + node: PipelineGraphNode; + durationMs: number; + error: unknown; + }; + +export type PipelineRunObserver = { + onEvent(event: PipelineRunEvent): void | Promise; +}; + +export type PipelineRunOptions = { + observer?: PipelineRunObserver | undefined; +}; + +export type PipelineRunContext = { + observer?: PipelineRunObserver | undefined; +}; + +export type PipelineExecutor = ( + input: Input, + context: PipelineRunContext, +) => Output | Promise; + +export type PipelineBuilderState = { + graph: PipelineGraph; + terminalNodeId: string; + terminalNodeIds: string[]; + nextNodeIndex: number; + nextEdgeIndex: number; +}; diff --git a/packages/core/src/skills/tools.ts b/packages/core/src/skills/tools.ts index 6bdb88a4..4a6858ef 100644 --- a/packages/core/src/skills/tools.ts +++ b/packages/core/src/skills/tools.ts @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import { isAbsolute, relative, resolve } from "node:path"; import { z } from "zod"; import { type AnyTool, createTool } from "../tool"; +import { markSkillTool } from "../tool/skill-tool-marker"; import type { Skill } from "./types"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -12,48 +13,56 @@ export function createSkillTools(skills: Skill[]): AnyTool[] { const registry = new SkillRegistry(skills); return [ - createTool({ - name: "get_skill_instructions", - description: "Load the full SKILL.md instructions for an Agent Skill.", - input: z.object({ - skillName: z.string().describe("The name of the skill to load."), + markSkillTool( + createTool({ + name: "get_skill_instructions", + description: "Load the full SKILL.md instructions for an Agent Skill.", + input: z.object({ + skillName: z.string().describe("The name of the skill to load."), + }), + output: z.string(), + execute: ({ skillName }) => registry.get(skillName).instructions, }), - output: z.string(), - execute: ({ skillName }) => registry.get(skillName).instructions, - }), - createTool({ - name: "get_skill_reference", - description: "Read a reference file from an Agent Skill.", - input: z.object({ - skillName: z.string().describe("The name of the skill."), - referencePath: z.string().describe("A path listed in the skill references."), + ), + markSkillTool( + createTool({ + name: "get_skill_reference", + description: "Read a reference file from an Agent Skill.", + input: z.object({ + skillName: z.string().describe("The name of the skill."), + referencePath: z.string().describe("A path listed in the skill references."), + }), + output: z.string(), + execute: ({ skillName, referencePath }) => registry.readReference(skillName, referencePath), }), - output: z.string(), - execute: ({ skillName, referencePath }) => registry.readReference(skillName, referencePath), - }), - createTool({ - name: "get_skill_script", - description: "Read a script file from an Agent Skill.", - input: z.object({ - skillName: z.string().describe("The name of the skill."), - scriptPath: z.string().describe("A path listed in the skill scripts."), + ), + markSkillTool( + createTool({ + name: "get_skill_script", + description: "Read a script file from an Agent Skill.", + input: z.object({ + skillName: z.string().describe("The name of the skill."), + scriptPath: z.string().describe("A path listed in the skill scripts."), + }), + output: z.string(), + execute: ({ skillName, scriptPath }) => registry.readScript(skillName, scriptPath), }), - output: z.string(), - execute: ({ skillName, scriptPath }) => registry.readScript(skillName, scriptPath), - }), - createTool({ - name: "run_skill_script", - description: "Execute a script from an Agent Skill with optional arguments.", - input: z.object({ - skillName: z.string().describe("The name of the skill."), - scriptPath: z.string().describe("A path listed in the skill scripts."), - args: z.array(z.string()).optional().describe("Arguments passed to the script."), - timeoutMs: z.number().int().positive().optional().describe("Execution timeout in ms."), + ), + markSkillTool( + createTool({ + name: "run_skill_script", + description: "Execute a script from an Agent Skill with optional arguments.", + input: z.object({ + skillName: z.string().describe("The name of the skill."), + scriptPath: z.string().describe("A path listed in the skill scripts."), + args: z.array(z.string()).optional().describe("Arguments passed to the script."), + timeoutMs: z.number().int().positive().optional().describe("Execution timeout in ms."), + }), + output: z.string(), + execute: ({ skillName, scriptPath, args = [], timeoutMs = DEFAULT_TIMEOUT_MS }) => + registry.runScript(skillName, scriptPath, args, timeoutMs), }), - output: z.string(), - execute: ({ skillName, scriptPath, args = [], timeoutMs = DEFAULT_TIMEOUT_MS }) => - registry.runScript(skillName, scriptPath, args, timeoutMs), - }), + ), ]; } diff --git a/packages/core/src/tool/create-tool.ts b/packages/core/src/tool/create-tool.ts index 21b2a1b0..f88db239 100644 --- a/packages/core/src/tool/create-tool.ts +++ b/packages/core/src/tool/create-tool.ts @@ -1,10 +1,11 @@ import type { z } from "zod"; import { toProviderJsonSchema, type ZodSchema } from "../schema/zod-schema"; -import type { Tool, ToolApprovalPolicy } from "./tool"; +import type { Tool, ToolApprovalPolicy, ToolCallContext } from "./tool"; export type CreateToolOptions< InputSchema extends ZodSchema, OutputSchema extends ZodSchema | undefined = undefined, + Output = unknown, > = { name: string; description: string; @@ -13,21 +14,32 @@ export type CreateToolOptions< approval?: ToolApprovalPolicy>; execute( args: z.output, + context: ToolCallContext, ): OutputSchema extends ZodSchema ? z.input | Promise> - : unknown | Promise; + : Output | Promise; }; -type ToolOutput = OutputSchema extends ZodSchema - ? z.output - : unknown; +type CreateToolOutput< + OutputSchema extends ZodSchema | undefined, + Output, +> = OutputSchema extends ZodSchema ? z.output : Output; + +export function createTool( + options: CreateToolOptions & { output?: undefined }, +): Tool, Output>; + +export function createTool( + options: CreateToolOptions, +): Tool, z.output>; export function createTool< InputSchema extends ZodSchema, OutputSchema extends ZodSchema | undefined = undefined, + Output = unknown, >( - options: CreateToolOptions, -): Tool, ToolOutput> { + options: CreateToolOptions, +): Tool, CreateToolOutput> { const parameters = toProviderJsonSchema(options.input); return { @@ -40,12 +52,12 @@ export function createTool< parameters, }; }, - async call(args): Promise> { + async call(args, context = {}): Promise> { const parsedArgs = options.input.parse(args); - const result = await options.execute(parsedArgs); + const result = await options.execute(parsedArgs, context); return ( options.output === undefined ? result : options.output.parse(result) - ) as ToolOutput; + ) as CreateToolOutput; }, parseApprovalArgs(args): z.output { return options.input.parse(args); diff --git a/packages/core/src/tool/dynamic-tools.ts b/packages/core/src/tool/dynamic-tools.ts index 83e9fada..fb330b0e 100644 --- a/packages/core/src/tool/dynamic-tools.ts +++ b/packages/core/src/tool/dynamic-tools.ts @@ -1,7 +1,12 @@ import type { ToolDefinition } from "../completion"; import type { EmbeddedDocument, EmbeddingModel, VectorMetadata } from "../embeddings"; import { embedDocuments } from "../embeddings"; -import type { VectorSearchResult, VectorSearchToolOptions } from "../vector-store"; +import type { + VectorInspectPage, + VectorInspectRequest, + VectorSearchResult, + VectorSearchToolOptions, +} from "../vector-store"; import { InMemoryVectorStore, type VectorSearchIndex, @@ -86,10 +91,21 @@ export function isDynamicToolIndex(value: unknown): value is DynamicToolIndex { class DynamicToolSearchIndex implements DynamicToolIndex { + readonly inspect?: ( + request: VectorInspectRequest, + ) => Promise, Metadata>>; + constructor( private readonly index: VectorSearchIndex, Metadata>, readonly toolSet: ToolSet, - ) {} + ) { + if (index.inspect !== undefined) { + this.inspect = (request) => + index.inspect?.(request) as Promise< + VectorInspectPage, Metadata> + >; + } + } search( request: VectorSearchRequest, diff --git a/packages/core/src/tool/index.ts b/packages/core/src/tool/index.ts index b09474e8..2dad7c4e 100644 --- a/packages/core/src/tool/index.ts +++ b/packages/core/src/tool/index.ts @@ -1,6 +1,7 @@ export * from "./create-tool"; export * from "./dynamic-tools"; export * from "./errors"; +export * from "./middleware"; export * from "./think-tool"; export * from "./tool"; export * from "./tool-set"; diff --git a/packages/core/src/tool/middleware.ts b/packages/core/src/tool/middleware.ts new file mode 100644 index 00000000..3fa32b16 --- /dev/null +++ b/packages/core/src/tool/middleware.ts @@ -0,0 +1,21 @@ +import type { ToolResultContent } from "../completion"; + +export type ToolResultMiddlewareArgs = { + toolName: string; + args: string; + result: string; + originalResult: string; + structuredResult?: ToolResultContent[] | undefined; + originalStructuredResult?: ToolResultContent[] | undefined; + turn: number; + toolCallId?: string | undefined; + internalCallId: string; +}; + +export interface ToolMiddleware { + onResult?(args: ToolResultMiddlewareArgs): string | undefined | Promise; +} + +export function createToolMiddleware(middleware: ToolMiddleware): ToolMiddleware { + return middleware; +} diff --git a/packages/core/src/tool/skill-tool-marker.ts b/packages/core/src/tool/skill-tool-marker.ts new file mode 100644 index 00000000..4bbd8678 --- /dev/null +++ b/packages/core/src/tool/skill-tool-marker.ts @@ -0,0 +1,15 @@ +import type { AnyTool } from "./tool"; + +const skillToolMarker = Symbol.for("@anvia/core.skillTool"); + +export function markSkillTool(tool: T): T { + Object.defineProperty(tool, skillToolMarker, { + value: true, + enumerable: false, + }); + return tool; +} + +export function isSkillTool(tool: AnyTool | undefined): boolean { + return tool !== undefined && (tool as Record)[skillToolMarker] === true; +} diff --git a/packages/core/src/tool/tool-set.ts b/packages/core/src/tool/tool-set.ts index 6ea0fa2f..bbb02ea1 100644 --- a/packages/core/src/tool/tool-set.ts +++ b/packages/core/src/tool/tool-set.ts @@ -1,6 +1,12 @@ import type { ToolDefinition } from "../completion/types"; import { ToolCallError, ToolJsonError, ToolNotFoundError } from "./errors"; -import { type AnyTool, parseToolArgs, serializeToolOutput } from "./tool"; +import { + type AnyTool, + type NormalizedToolOutput, + normalizeToolResultOutput, + parseToolArgs, + type ToolCallContext, +} from "./tool"; export class ToolSet { private readonly tools = new Map(); @@ -50,7 +56,11 @@ export class ToolSet { return defs; } - async call(toolName: string, args: string): Promise { + async call( + toolName: string, + args: string, + context?: ToolCallContext, + ): Promise { const tool = this.tools.get(toolName); if (tool === undefined) { throw new ToolNotFoundError(toolName); @@ -64,8 +74,8 @@ export class ToolSet { } try { - const output = await tool.call(parsedArgs); - return serializeToolOutput(output); + const output = await tool.call(parsedArgs, context); + return normalizeToolResultOutput(output); } catch (error) { if (error instanceof Error) { throw new ToolCallError(error.message, error); diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 372762f9..262bb03a 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -1,4 +1,4 @@ -import type { JsonObject, JsonValue, ToolDefinition } from "../completion/types"; +import type { JsonObject, JsonValue, ToolDefinition, ToolResultContent } from "../completion/types"; export type ToolApprovalRunContext = { agentId: string; @@ -22,11 +22,21 @@ export type ToolApprovalPolicy = { rejectMessage?: string | ((ctx: ToolApprovalContext) => string | Promise); }; +export type ToolCallStreamEvent = { + agentId: string; + agentName?: string | undefined; + event: unknown; +}; + +export type ToolCallContext = { + emitStreamEvent?(event: ToolCallStreamEvent): void | Promise; +}; + export interface Tool { readonly name: string; readonly approval?: ToolApprovalPolicy; definition(prompt: string): ToolDefinition | Promise; - call(args: Args): Output | Promise; + call(args: Args, context?: ToolCallContext): Output | Promise; parseApprovalArgs?(args: unknown): Args; } @@ -34,6 +44,14 @@ export type AnyTool = Omit, "approval"> & { readonly approval?: unknown; }; +export type NormalizedToolOutput = string | ToolResultContent[]; + +export const ToolOutput = { + content(content: ToolResultContent[]): ToolResultContent[] { + return content; + }, +}; + export function serializeToolOutput(output: unknown): string { if (typeof output === "string") { return output; @@ -43,6 +61,41 @@ export function serializeToolOutput(output: unknown): string { return serialized === undefined ? String(output) : serialized; } +export function isToolResultContentArray(value: unknown): value is ToolResultContent[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((item) => { + if (typeof item !== "object" || item === null || !("type" in item)) { + return false; + } + if (item.type === "text") { + return "text" in item && typeof item.text === "string"; + } + if (item.type === "image") { + return ( + "data" in item && + typeof item.data === "string" && + (!("mediaType" in item) || + item.mediaType === undefined || + typeof item.mediaType === "string") + ); + } + return false; + }) + ); +} + +export function normalizeToolResultOutput(output: unknown): NormalizedToolOutput { + return isToolResultContentArray(output) ? output : serializeToolOutput(output); +} + +export function toolResultContentToText(content: ToolResultContent[]): string { + return content + .map((item) => (item.type === "text" ? item.text : `[image:${item.mediaType ?? "image/png"}]`)) + .join("\n"); +} + export function parseToolArgs(args: string): JsonValue { if (args.trim() === "") { return {}; diff --git a/packages/core/src/vector-store/index.ts b/packages/core/src/vector-store/index.ts index cd392dfd..ce2f7cea 100644 --- a/packages/core/src/vector-store/index.ts +++ b/packages/core/src/vector-store/index.ts @@ -30,10 +30,29 @@ export type VectorSearchResult = { + id: string; + document: T; + metadata?: Metadata | undefined; +}; + +export type VectorInspectPage = { + items: Array>; + nextCursor?: string | undefined; + totalCount?: number | undefined; +}; + export interface VectorSearchIndex { search(request: VectorSearchRequest): Promise>>; searchIds(request: VectorSearchRequest): Promise>; asTool(options: VectorSearchToolOptions): Tool<{ query: string; topK?: number }, unknown>; + inspect?(request: VectorInspectRequest): Promise>; } export type VectorSearchToolOptions = { @@ -168,6 +187,25 @@ export class InMemoryVectorIndex ({ score, id })); } + async inspect(request: VectorInspectRequest): Promise> { + const limit = Math.max(0, Math.trunc(request.limit)); + const start = Math.max(0, Math.trunc(Number(request.cursor ?? "0"))); + const documents = this.store + .values() + .filter((document) => matchesVectorFilter(document.metadata, request.filter)); + const page = documents.slice(start, start + limit); + const nextOffset = start + page.length; + return { + items: page.map((document) => ({ + id: document.id, + document: document.document, + ...(document.metadata === undefined ? {} : { metadata: document.metadata }), + })), + ...(nextOffset < documents.length ? { nextCursor: String(nextOffset) } : {}), + totalCount: documents.length, + }; + } + asTool(options: VectorSearchToolOptions): Tool<{ query: string; topK?: number }, unknown> { return createVectorSearchTool(this, options); } diff --git a/packages/core/test/agent-tool.test.ts b/packages/core/test/agent-tool.test.ts index c17731d2..ce6e8f24 100644 --- a/packages/core/test/agent-tool.test.ts +++ b/packages/core/test/agent-tool.test.ts @@ -10,7 +10,7 @@ import { MaxTurnsError, ToolSet, Usage, -} from "../src/index"; +} from "./helpers/imports"; class QueueModel implements CompletionModel { readonly provider = "test"; diff --git a/packages/core/test/completion-capabilities.test.ts b/packages/core/test/completion-capabilities.test.ts index b6eded3a..46e4df07 100644 --- a/packages/core/test/completion-capabilities.test.ts +++ b/packages/core/test/completion-capabilities.test.ts @@ -14,7 +14,7 @@ import { type StreamingCompletionModel, Usage, UserContent, -} from "../src/index"; +} from "./helpers/imports"; const fullCapabilities: CompletionModelCapabilities = { streaming: true, diff --git a/packages/core/test/completion-documents.test.ts b/packages/core/test/completion-documents.test.ts index c53efcc4..55330a32 100644 --- a/packages/core/test/completion-documents.test.ts +++ b/packages/core/test/completion-documents.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { formatDocument, Message, normalizeDocuments } from "../src/index"; +import { formatDocument, Message, normalizeDocuments } from "./helpers/imports"; describe("completion document normalization", () => { it("returns no message for empty documents", () => { diff --git a/packages/core/test/embeddings-vector-store.test.ts b/packages/core/test/embeddings-vector-store.test.ts index 86b4cb7d..6a52bea8 100644 --- a/packages/core/test/embeddings-vector-store.test.ts +++ b/packages/core/test/embeddings-vector-store.test.ts @@ -25,7 +25,7 @@ import { type StreamingCompletionModel, Usage, vectorFilter, -} from "../src/index"; +} from "./helpers/imports"; class KeywordEmbeddingModel implements EmbeddingModel { readonly maxBatchSize: number; @@ -245,6 +245,44 @@ describe("in-memory vector store", () => { await expect(tool.call({ query: "cat" })).resolves.toMatchObject([{ id: "cat" }]); expect(await tool.definition("")).toMatchObject({ name: "search_docs" }); }); + + it("inspects paginated documents without embeddings", async () => { + const model = new KeywordEmbeddingModel(); + const index = InMemoryVectorStore.fromDocuments(await sampleEmbedded(model)).index(model); + + await expect(index.inspect({ limit: 2 })).resolves.toEqual({ + items: [ + { + id: "cat", + document: { id: "cat", title: "Cat guide", texts: ["cat", "pet"] }, + metadata: { category: "animal", rank: 3 }, + }, + { + id: "dog", + document: { id: "dog", title: "Dog guide", texts: ["dog"] }, + metadata: { category: "animal", rank: 3 }, + }, + ], + nextCursor: "2", + totalCount: 3, + }); + await expect(index.inspect({ limit: 2, cursor: "2" })).resolves.toEqual({ + items: [ + { + id: "risk", + document: { id: "risk", title: "Risk memo", texts: ["risk"] }, + metadata: { category: "finance", rank: 1 }, + }, + ], + totalCount: 3, + }); + await expect( + index.inspect({ limit: 5, filter: vectorFilter.eq("category", "animal") }), + ).resolves.toMatchObject({ + items: [{ id: "cat" }, { id: "dog" }], + totalCount: 2, + }); + }); }); describe("agent dynamic context", () => { @@ -311,6 +349,25 @@ describe("agent dynamic tools", () => { ]); }); + it("passes dynamic tool inspection through to the wrapped index", async () => { + const embeddingModel = new KeywordEmbeddingModel(); + const index = await createToolIndex(embeddingModel, [issueRefundTool, lookupDogTool]); + + await expect(index.inspect?.({ limit: 1 })).resolves.toMatchObject({ + items: [ + { + id: "issue_refund", + document: { + toolName: "issue_refund", + definition: expect.objectContaining({ name: "issue_refund" }), + }, + }, + ], + nextCursor: "1", + totalCount: 2, + }); + }); + it("injects selected dynamic tools into send requests and executes them", async () => { const embeddingModel = new KeywordEmbeddingModel(); const index = await createToolIndex(embeddingModel, [issueRefundTool, lookupDogTool]); diff --git a/packages/core/test/evals.test.ts b/packages/core/test/evals.test.ts index f7052f83..25e3c371 100644 --- a/packages/core/test/evals.test.ts +++ b/packages/core/test/evals.test.ts @@ -18,7 +18,7 @@ import { runEvalSuite, semanticSimilarity, Usage, -} from "../src/index"; +} from "./helpers/imports"; class QueueModel implements CompletionModel { readonly provider = "test"; diff --git a/packages/core/test/extractor.test.ts b/packages/core/test/extractor.test.ts index e3076190..c4176643 100644 --- a/packages/core/test/extractor.test.ts +++ b/packages/core/test/extractor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import * as agentLite from "../src/index"; +import * as agentLite from "./helpers/imports"; import { AssistantContent, type CompletionModel, @@ -9,7 +9,7 @@ import { ExtractorBuilder, Message, Usage, -} from "../src/index"; +} from "./helpers/imports"; class QueueModel implements CompletionModel { readonly provider = "test"; diff --git a/packages/core/test/helpers/imports.ts b/packages/core/test/helpers/imports.ts new file mode 100644 index 00000000..fb65df61 --- /dev/null +++ b/packages/core/test/helpers/imports.ts @@ -0,0 +1,18 @@ +export * from "../../src/agent"; +export * from "../../src/audio-generation"; +export * from "../../src/completion"; +export * from "../../src/embeddings"; +export * from "../../src/evals"; +export * from "../../src/extractor"; +export * from "../../src/image-generation"; +export * from "../../src/internal/agent"; +export * from "../../src/mcp"; +export * from "../../src/memory"; +export * from "../../src/model-listing"; +export * from "../../src/observability"; +export * from "../../src/pipeline"; +export * from "../../src/skills"; +export * from "../../src/streaming"; +export * from "../../src/tool"; +export * from "../../src/transcription"; +export * from "../../src/vector-store"; diff --git a/packages/core/test/mcp-connections.test.ts b/packages/core/test/mcp-connections.test.ts new file mode 100644 index 00000000..7f39b3f4 --- /dev/null +++ b/packages/core/test/mcp-connections.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mcp } from "../src/mcp"; + +const sdk = vi.hoisted(() => { + type ClientRecord = { + metadata: unknown; + connectCalls: unknown[]; + }; + + type SseTransportRecord = { + url: URL; + options: unknown; + }; + + const clients: ClientRecord[] = []; + const sseTransports: SseTransportRecord[] = []; + + class Client { + readonly metadata: unknown; + readonly connectCalls: unknown[] = []; + + constructor(metadata: unknown) { + this.metadata = metadata; + clients.push(this); + } + + async connect(transport: unknown): Promise { + this.connectCalls.push(transport); + } + } + + class SSEClientTransport { + readonly url: URL; + readonly options: unknown; + + constructor(url: URL, options: unknown) { + this.url = url; + this.options = options; + sseTransports.push(this); + } + } + + return { Client, SSEClientTransport, clients, sseTransports }; +}); + +vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ + Client: sdk.Client, +})); + +vi.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({ + SSEClientTransport: sdk.SSEClientTransport, +})); + +describe("MCP connection factories", () => { + beforeEach(() => { + sdk.clients.length = 0; + sdk.sseTransports.length = 0; + }); + + it("creates legacy SSE connections with the SDK SSE transport", async () => { + const transportOptions = { + requestInit: { + headers: { + "x-api-key": "test-key", + }, + }, + }; + + const connection = mcp.sse({ + name: "legacy-server", + url: "http://localhost:3000/sse", + transport: transportOptions, + }); + + expect(connection.name).toBe("legacy-server"); + + const client = await connection.connect(); + + expect(sdk.clients).toHaveLength(1); + expect(client).toBe(sdk.clients[0]); + expect(sdk.clients[0]?.metadata).toEqual({ + name: "@anvia/core", + version: "0.1.0", + }); + expect(sdk.sseTransports).toHaveLength(1); + expect(sdk.sseTransports[0]).toBeInstanceOf(sdk.SSEClientTransport); + expect(sdk.sseTransports[0]?.url.href).toBe("http://localhost:3000/sse"); + expect(sdk.sseTransports[0]?.options).toBe(transportOptions); + expect(sdk.clients[0]?.connectCalls).toEqual([sdk.sseTransports[0]]); + }); +}); diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 2819d471..6a781def 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -10,13 +10,14 @@ import { connectMcp, createHook, createTool, + createToolMiddleware, type McpClient, type McpConnection, type McpServer, Message, type StreamingCompletionModel, Usage, -} from "../src/index"; +} from "./helpers/imports"; class QueueModel implements CompletionModel { readonly provider = "test"; @@ -218,6 +219,35 @@ describe("MCP tools", () => { ); }); + it("applies tool result middleware to MCP tools", async () => { + const model = new QueueModel([ + response([AssistantContent.toolCall("call_1", "mcp_add", { x: 2, y: 5 })]), + response([AssistantContent.text("done")]), + ]); + const agent = new AgentBuilder("test-agent", model) + .mcp([fakeMcpServer()]) + .toolMiddleware( + createToolMiddleware({ + onResult({ result }) { + return `mcp:${result}`; + }, + }), + ) + .build(); + + await expect(agent.prompt("add").send()).resolves.toMatchObject({ output: "done" }); + + expect(model.requests[1]?.chatHistory.at(-1)).toEqual( + Message.tool([ + { + type: "tool_result", + id: "call_1", + content: [{ type: "text", text: "mcp:7" }], + }, + ]), + ); + }); + it("registers MCP tools with stream and preserves hooks", async () => { const model = new StreamingQueueModel([ [ diff --git a/packages/core/test/memory.test.ts b/packages/core/test/memory.test.ts new file mode 100644 index 00000000..4640426a --- /dev/null +++ b/packages/core/test/memory.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + AgentBuilder, + AssistantContent, + type CompletionModel, + type CompletionRequest, + type CompletionResponse, + type CompletionStreamEvent, + createTool, + type MemoryAppendInput, + type MemoryContext, + type MemoryErrorInput, + type MemoryStore, + Message, + type Message as MessageType, + type StreamingCompletionModel, + Usage, +} from "./helpers/imports"; + +class QueueModel implements CompletionModel { + readonly provider = "test"; + readonly defaultModel = "test"; + readonly capabilities = { + streaming: false, + tools: true, + toolChoice: true, + imageInput: true, + documentInput: true, + outputSchema: true, + reasoning: true, + }; + readonly requests: CompletionRequest[] = []; + + constructor(private readonly responses: CompletionResponse[]) {} + + async completion(request: CompletionRequest): Promise { + this.requests.push(request); + const response = this.responses.shift(); + if (response === undefined) { + throw new Error("No queued response"); + } + return response; + } +} + +class StreamingQueueModel implements StreamingCompletionModel { + readonly provider = "test"; + readonly defaultModel = "test"; + readonly capabilities = { + streaming: true, + tools: true, + toolChoice: true, + imageInput: true, + documentInput: true, + outputSchema: true, + reasoning: true, + }; + readonly requests: CompletionRequest[] = []; + + constructor(private readonly responses: CompletionStreamEvent[][]) {} + + async completion(): Promise { + throw new Error("completion should not be called"); + } + + async *streamCompletion(request: CompletionRequest): AsyncIterable { + this.requests.push(request); + const response = this.responses.shift(); + if (response === undefined) { + throw new Error("No queued response"); + } + yield* response; + } +} + +class RecordingMemoryStore implements MemoryStore { + readonly appendCalls: MemoryAppendInput[] = []; + readonly errorCalls: MemoryErrorInput[] = []; + private readonly sessions = new Map(); + + constructor(initial: Record = {}) { + for (const [sessionId, messages] of Object.entries(initial)) { + this.sessions.set(sessionId, messages); + } + } + + async load(context: MemoryContext): Promise { + return [...(this.sessions.get(context.sessionId) ?? [])]; + } + + async append(input: MemoryAppendInput): Promise { + this.appendCalls.push({ ...input, messages: [...input.messages] }); + const current = this.sessions.get(input.context.sessionId) ?? []; + this.sessions.set(input.context.sessionId, [...current, ...input.messages]); + } + + async clear(context: MemoryContext): Promise { + this.sessions.delete(context.sessionId); + } + + async recordError(input: MemoryErrorInput): Promise { + this.errorCalls.push({ ...input, messages: [...input.messages] }); + } +} + +function response(choice: CompletionResponse["choice"]): CompletionResponse { + return { + choice, + usage: Usage.empty(), + rawResponse: {}, + }; +} + +const addTool = createTool({ + name: "add", + description: "Add numbers", + input: z.object({ + x: z.number(), + y: z.number(), + }), + output: z.number(), + execute: (args) => args.x + args.y, +}); + +describe("agent memory", () => { + it("uses prompt transcripts as stateless history", async () => { + const model = new QueueModel([response([AssistantContent.text("Anvia")])]); + const agent = new AgentBuilder("test-agent", model).build(); + const transcript = [ + Message.user("My project is named Anvia."), + Message.assistant("Noted."), + Message.user("What is my project named?"), + ]; + + await agent.prompt(transcript).send(); + + expect(model.requests[0]?.chatHistory).toEqual(transcript); + }); + + it("rejects empty prompt transcripts", async () => { + const model = new QueueModel([]); + const agent = new AgentBuilder("test-agent", model).build(); + + expect(() => agent.prompt([])).toThrow("at least one message"); + }); + + it("loads session messages before running", async () => { + const previous = [Message.user("My project is named Anvia."), Message.assistant("Noted.")]; + const store = new RecordingMemoryStore({ session_1: previous }); + const model = new QueueModel([response([AssistantContent.text("Anvia")])]); + const agent = new AgentBuilder("test-agent", model).memory(store).build(); + + await agent.session("session_1").prompt("What is my project named?").send(); + + expect(model.requests[0]?.chatHistory).toEqual([ + ...previous, + Message.user("What is my project named?"), + ]); + }); + + it("saves messages incrementally by default", async () => { + const store = new RecordingMemoryStore(); + const model = new QueueModel([ + response([AssistantContent.toolCall("call_1", "add", { x: 2, y: 5 })]), + response([AssistantContent.text("7")]), + ]); + const agent = new AgentBuilder("test-agent", model).memory(store).tool(addTool).build(); + + await agent.session("session_1").prompt("add").send(); + + expect(store.appendCalls.map((call) => call.messages.map((message) => message.role))).toEqual([ + ["user"], + ["assistant"], + ["tool"], + ["assistant"], + ]); + await expect(agent.session("session_1").messages()).resolves.toHaveLength(4); + }); + + it("records failed runs after preserving completed messages", async () => { + const store = new RecordingMemoryStore(); + const model = new QueueModel([ + response([AssistantContent.toolCall("call_1", "add", { x: 2, y: 5 })]), + ]); + const agent = new AgentBuilder("test-agent", model).memory(store).tool(addTool).build(); + + await expect(agent.session("session_1").prompt("add").send()).rejects.toThrow( + "No queued response", + ); + + expect(store.appendCalls.map((call) => call.messages.map((message) => message.role))).toEqual([ + ["user"], + ["assistant"], + ["tool"], + ]); + expect(store.errorCalls).toHaveLength(1); + expect(store.errorCalls[0]?.messages.map((message) => message.role)).toEqual([ + "user", + "assistant", + "tool", + ]); + }); + + it("supports turn save policy", async () => { + const store = new RecordingMemoryStore(); + const model = new QueueModel([ + response([AssistantContent.toolCall("call_1", "add", { x: 2, y: 5 })]), + response([AssistantContent.text("7")]), + ]); + const agent = new AgentBuilder("test-agent", model) + .memory(store, { savePolicy: "turn" }) + .tool(addTool) + .build(); + + await agent.session("session_1").prompt("add").send(); + + expect(store.appendCalls.map((call) => call.messages.map((message) => message.role))).toEqual([ + ["user", "assistant", "tool"], + ["assistant"], + ]); + }); + + it("supports run save policy", async () => { + const store = new RecordingMemoryStore(); + const model = new QueueModel([response([AssistantContent.text("done")])]); + const agent = new AgentBuilder("test-agent", model) + .memory(store, { savePolicy: "run" }) + .build(); + + await agent.session("session_1").prompt("hello").send(); + + expect(store.appendCalls.map((call) => call.messages.map((message) => message.role))).toEqual([ + ["user", "assistant"], + ]); + }); + + it("does not save nested streaming agent-tool events as memory messages", async () => { + const store = new RecordingMemoryStore(); + const parentModel = new StreamingQueueModel([ + [ + { + type: "tool_call", + toolCall: AssistantContent.toolCall("call_child", "ask_child", { prompt: "inspect" }), + }, + ], + [{ type: "text_delta", delta: "parent done" }], + ]); + const childModel = new StreamingQueueModel([ + [ + { type: "text_delta", delta: "child " }, + { type: "text_delta", delta: "done" }, + ], + ]); + const childAgent = new AgentBuilder("child", childModel).build(); + const parentAgent = new AgentBuilder("parent", parentModel) + .memory(store) + .tool(childAgent.asTool({ name: "ask_child", stream: true })) + .build(); + + for await (const _event of parentAgent.session("session_1").prompt("delegate").stream()) { + // exhaust stream + } + + expect(store.appendCalls.map((call) => call.messages.map((message) => message.role))).toEqual([ + ["user"], + ["assistant"], + ["tool"], + ["assistant"], + ]); + await expect(parentAgent.session("session_1").messages()).resolves.toHaveLength(4); + }); + + it("rejects transcript input for session prompts", () => { + const store = new RecordingMemoryStore(); + const model = new QueueModel([]); + const agent = new AgentBuilder("test-agent", model).memory(store).build(); + const prompt = agent.session("session_1").prompt as unknown as ( + input: MessageType[], + ) => unknown; + + expect(() => prompt([Message.user("hello")])).toThrow("does not accept Message[]"); + }); +}); diff --git a/packages/core/test/message-content.test.ts b/packages/core/test/message-content.test.ts index 2d037faf..7e9649b9 100644 --- a/packages/core/test/message-content.test.ts +++ b/packages/core/test/message-content.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { AssistantContent, Message, reasoningDisplayText, UserContent } from "../src/index"; +import { AssistantContent, Message, reasoningDisplayText, UserContent } from "./helpers/imports"; describe("message attachment content", () => { it("creates user image and document attachments", () => { diff --git a/packages/core/test/model-listing.test.ts b/packages/core/test/model-listing.test.ts new file mode 100644 index 00000000..9e8e8fba --- /dev/null +++ b/packages/core/test/model-listing.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { type ModelList, ModelListingError } from "./helpers/imports"; + +describe("model listing", () => { + it("represents provider model lists", () => { + const list: ModelList = { + data: [ + { + id: "model-1", + name: "Model 1", + description: "A listed model.", + type: "model", + createdAt: 1_700_000_000, + ownedBy: "provider", + contextLength: 128_000, + }, + ], + }; + + expect(list.data[0]).toEqual({ + id: "model-1", + name: "Model 1", + description: "A listed model.", + type: "model", + createdAt: 1_700_000_000, + ownedBy: "provider", + contextLength: 128_000, + }); + }); + + it("preserves provider error context", () => { + const cause = new Error("unauthorized"); + const error = new ModelListingError("OpenAI model listing failed", { + provider: "OpenAI", + statusCode: 401, + cause, + }); + + expect(error.name).toBe("ModelListingError"); + expect(error.provider).toBe("OpenAI"); + expect(error.statusCode).toBe(401); + expect(error.cause).toBe(cause); + }); +}); diff --git a/packages/core/test/observability.test.ts b/packages/core/test/observability.test.ts index dfef407c..e68f0910 100644 --- a/packages/core/test/observability.test.ts +++ b/packages/core/test/observability.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import * as anvia from "../src/index"; +import * as anvia from "./helpers/imports"; import { AgentBuilder, type AgentGenerationEndArgs, @@ -17,10 +17,11 @@ import { type CompletionStreamEvent, createHook, createTool, + type JsonObject, type StreamingCompletionModel, skipTool, Usage, -} from "../src/index"; +} from "./helpers/imports"; // @ts-expect-error - Langfuse moved to @anvia/langfuse. const removedLangfuseExport = anvia.langfuse; @@ -42,6 +43,15 @@ class QueueModel implements CompletionModel { constructor(private readonly responses: CompletionResponse[]) {} + traceRequest(request: CompletionRequest): JsonObject { + return { + provider: this.provider, + stream: false, + model: request.model ?? this.defaultModel, + messageCount: request.chatHistory.length, + }; + } + async completion(request: CompletionRequest): Promise { this.requests.push(request); const response = this.responses.shift(); @@ -72,6 +82,15 @@ class StreamingQueueModel implements StreamingCompletionModel { throw new Error("completion should not be called"); } + traceRequest(request: CompletionRequest, options: { stream?: boolean } = {}): JsonObject { + return { + provider: this.provider, + stream: options.stream === true, + model: request.model ?? this.defaultModel, + messageCount: request.chatHistory.length, + }; + } + async *streamCompletion(request: CompletionRequest): AsyncIterable { this.requests.push(request); const response = this.responses.shift(); @@ -159,6 +178,24 @@ describe("agent observability", () => { }, }, }); + expect(observer.events).toContainEqual( + expect.objectContaining({ + type: "generation_start", + args: expect.objectContaining({ + modelInfo: { + provider: "test", + defaultModel: "test", + capabilities: expect.objectContaining({ streaming: false }), + }, + providerRequest: expect.objectContaining({ + provider: "test", + stream: false, + model: "test", + messageCount: 1, + }), + }), + }), + ); }); it("records multiple turns and tool calls", async () => { @@ -181,6 +218,20 @@ describe("agent observability", () => { "generation_end", "run_end", ]); + expect(observer.events).toContainEqual( + expect.objectContaining({ + type: "tool_start", + args: expect.objectContaining({ + toolDefinition: expect.objectContaining({ + name: "add", + description: "Add numbers", + }), + toolMetadata: expect.objectContaining({ + approvalRequired: false, + }), + }), + }), + ); expect(observer.events).toContainEqual( expect.objectContaining({ type: "tool_end", @@ -265,6 +316,23 @@ describe("agent observability", () => { "generation_end", "run_end", ]); + expect(observer.events).toContainEqual( + expect.objectContaining({ + type: "generation_start", + args: expect.objectContaining({ + modelInfo: { + provider: "test", + defaultModel: "test", + capabilities: expect.objectContaining({ streaming: true }), + }, + providerRequest: expect.objectContaining({ + provider: "test", + stream: true, + model: "test", + }), + }), + }), + ); expect(observer.events).toContainEqual( expect.objectContaining({ type: "generation_end", diff --git a/packages/core/test/pipeline.test.ts b/packages/core/test/pipeline.test.ts index c31dd69d..7e7cc113 100644 --- a/packages/core/test/pipeline.test.ts +++ b/packages/core/test/pipeline.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import * as anvia from "../src/index"; +import * as anvia from "./helpers/imports"; import { AgentBuilder, AssistantContent, @@ -11,7 +11,7 @@ import { PipelineBuilder, type PipelineOp, Usage, -} from "../src/index"; +} from "./helpers/imports"; class QueueModel implements CompletionModel { readonly provider = "test"; @@ -161,6 +161,75 @@ describe("PipelineBuilder", () => { await expect(op.run(5)).resolves.toBe(15); }); + it("exposes an automatic graph", () => { + const model = new QueueModel([response([AssistantContent.text("answer")])]); + const agent = new AgentBuilder("support", model).name("Support").build(); + const op = new PipelineBuilder({ + id: "ticket_triage", + name: "Ticket triage", + description: "Prepare a support answer.", + metadata: { owner: "support" }, + }) + .step((value) => value.trim()) + .parallel({ + upper: new PipelineBuilder().step((value) => value.toUpperCase()).build(), + length: new PipelineBuilder().step((value) => value.length).build(), + }) + .prompt(agent) + .build(); + + expect(op.graph()).toMatchObject({ + id: "ticket_triage", + name: "Ticket triage", + description: "Prepare a support answer.", + metadata: { owner: "support" }, + nodes: [ + { id: "input", kind: "input", label: "Input" }, + { kind: "step", label: "Step 1" }, + { kind: "parallel", label: "2 parallel branches" }, + { kind: "branch", label: "upper", branchKey: "upper" }, + { kind: "branch", label: "length", branchKey: "length" }, + { kind: "agent", label: "Support", agentId: "support", agentName: "Support" }, + { id: "output", kind: "output", label: "Output" }, + ], + }); + expect(op.graph().edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ source: "input", target: "step_1" }), + expect.objectContaining({ source: "step_1", target: "parallel_2" }), + expect.objectContaining({ source: "parallel_2", target: "branch_3" }), + expect.objectContaining({ source: "parallel_2", target: "branch_4" }), + expect.objectContaining({ source: "branch_3", target: "agent_5" }), + expect.objectContaining({ source: "branch_4", target: "agent_5" }), + expect.objectContaining({ source: "agent_5", target: "output" }), + ]), + ); + }); + + it("emits pipeline stage run events without changing output", async () => { + const events: string[] = []; + const op = new PipelineBuilder() + .step((value) => value + 1) + .step((value) => value * 2) + .build(); + + await expect( + op.run(2, { + observer: { + onEvent(event) { + events.push(`${event.type}:${event.node.id}`); + }, + }, + }), + ).resolves.toBe(6); + expect(events).toEqual([ + "stage_started:step_1", + "stage_completed:step_1", + "stage_started:step_2", + "stage_completed:step_2", + ]); + }); + it("does not expose the old helper-first methods at type level", () => { const builder = new PipelineBuilder(); const op = builder.step((value) => value + 1).build(); diff --git a/packages/core/test/prompt-request.test.ts b/packages/core/test/prompt-request.test.ts index 0a81b8da..4d39c318 100644 --- a/packages/core/test/prompt-request.test.ts +++ b/packages/core/test/prompt-request.test.ts @@ -9,11 +9,14 @@ import { cancelPrompt, createHook, createTool, + createToolMiddleware, MaxTurnsError, Message, PromptCancelledError, + requestToolApproval, + ToolOutput, Usage, -} from "../src/index"; +} from "./helpers/imports"; class QueueModel implements CompletionModel { readonly provider = "test"; @@ -126,6 +129,171 @@ describe("PromptRequest", () => { expect(finalToolMessage?.role === "tool" ? finalToolMessage.content : []).toHaveLength(2); }); + it("runs tool result middleware before hooks and the next model turn", async () => { + const model = new QueueModel([ + response([AssistantContent.toolCall("call_1", "add", { x: 2, y: 5 }, "fc_1")]), + response([AssistantContent.text("done")]), + ]); + const events: string[] = []; + const outputGate = createToolMiddleware({ + onResult({ toolName, result, originalResult, toolCallId }) { + events.push(`${toolName}:${toolCallId}:${originalResult}`); + return `stored:${result}`; + }, + }); + const hook = createHook({ + onToolResult({ result }) { + events.push(`hook:${result}`); + }, + }); + const agent = new AgentBuilder("test-agent", model) + .tool(addTool) + .toolMiddleware(outputGate) + .hook(hook) + .build(); + + await expect(agent.prompt("add").send()).resolves.toMatchObject({ output: "done" }); + + expect(events).toEqual(["add:fc_1:7", "hook:stored:7"]); + expect(model.requests[1]?.chatHistory.at(-1)).toEqual( + Message.tool([ + { + type: "tool_result", + id: "call_1", + callId: "fc_1", + content: [{ type: "text", text: "stored:7" }], + }, + ]), + ); + }); + + it("sends structured tool result content to the next model turn", async () => { + const structuredContent = ToolOutput.content([ + { type: "text", text: '{"coordMap":"0,0,100,100,100,100"}' }, + { type: "image", data: "base64-png", mediaType: "image/png" }, + ]); + const screenshotTool = createTool({ + name: "computer_screenshot", + description: "Return screenshot", + input: z.object({}), + execute: () => structuredContent, + }); + const model = new QueueModel([ + response([AssistantContent.toolCall("call_1", "computer_screenshot", {}, "fc_1")]), + response([AssistantContent.text("done")]), + ]); + const events: string[] = []; + const hook = createHook({ + onToolResult({ result, structuredResult }) { + events.push(`${result}:${structuredResult?.length ?? 0}`); + }, + }); + const agent = new AgentBuilder("test-agent", model).tool(screenshotTool).hook(hook).build(); + + await expect(agent.prompt("screenshot").send()).resolves.toMatchObject({ output: "done" }); + + expect(events).toEqual(['{"coordMap":"0,0,100,100,100,100"}\n[image:image/png]:2']); + expect(model.requests[1]?.chatHistory.at(-1)).toEqual( + Message.tool([ + { + type: "tool_result", + id: "call_1", + callId: "fc_1", + content: structuredContent, + }, + ]), + ); + }); + + it("lets middleware observe structured results and replace them with text", async () => { + const structuredContent = ToolOutput.content([ + { type: "text", text: "screen" }, + { type: "image", data: "base64-png", mediaType: "image/png" }, + ]); + const screenshotTool = createTool({ + name: "computer_screenshot", + description: "Return screenshot", + input: z.object({}), + execute: () => structuredContent, + }); + const model = new QueueModel([ + response([AssistantContent.toolCall("call_1", "computer_screenshot", {})]), + response([AssistantContent.text("done")]), + ]); + const seen: string[] = []; + const agent = new AgentBuilder("test-agent", model) + .tool(screenshotTool) + .toolMiddleware( + createToolMiddleware({ + onResult({ result, structuredResult, originalStructuredResult }) { + seen.push( + `${result}:${structuredResult?.length ?? 0}:${originalStructuredResult?.length ?? 0}`, + ); + return "stored:screenshot"; + }, + }), + ) + .build(); + + await expect(agent.prompt("screenshot").send()).resolves.toMatchObject({ output: "done" }); + + expect(seen).toEqual(["screen\n[image:image/png]:2:2"]); + expect(model.requests[1]?.chatHistory.at(-1)).toEqual( + Message.tool([ + { + type: "tool_result", + id: "call_1", + content: [{ type: "text", text: "stored:screenshot" }], + }, + ]), + ); + }); + + it("composes agent and request tool result middleware in order", async () => { + const model = new QueueModel([ + response([AssistantContent.toolCall("call_1", "add", { x: 2, y: 5 })]), + response([AssistantContent.text("done")]), + ]); + const events: string[] = []; + const keep = createToolMiddleware({ + onResult({ result, originalResult }) { + events.push(`keep:${result}:${originalResult}`); + return undefined; + }, + }); + const agentAppend = createToolMiddleware({ + onResult({ result, originalResult }) { + events.push(`agent:${result}:${originalResult}`); + return `${result}:agent`; + }, + }); + const requestAppend = createToolMiddleware({ + onResult({ result, originalResult }) { + events.push(`request:${result}:${originalResult}`); + return `${result}:request`; + }, + }); + const agent = new AgentBuilder("test-agent", model) + .tool(addTool) + .toolMiddlewares([keep, agentAppend]) + .build(); + + await expect( + agent.prompt("add").withToolMiddleware(requestAppend).send(), + ).resolves.toMatchObject({ output: "done" }); + + expect(events).toEqual(["keep:7:7", "agent:7:7", "request:7:agent:7"]); + expect(model.requests[1]?.chatHistory.at(-1)).toEqual( + Message.tool([ + { + type: "tool_result", + id: "call_1", + content: [{ type: "text", text: "7:agent:request" }], + }, + ]), + ); + }); + it("runs object-shaped hooks and continues when callbacks return nothing", async () => { const model = new QueueModel([ response([AssistantContent.toolCall("call_1", "add", { x: 2, y: 5 }, "fc_1")]), @@ -241,6 +409,37 @@ describe("PromptRequest", () => { expect(model.requests).toHaveLength(1); }); + it("cancels clearly when a tool call hook requests approval without a handler", async () => { + let executed = false; + const guardedTool = createTool({ + name: "guarded", + description: "A guarded tool", + input: z.object({}), + output: z.string(), + execute() { + executed = true; + return "should not run"; + }, + }); + const model = new QueueModel([ + response([AssistantContent.toolCall("call_1", "guarded", {})]), + response([AssistantContent.text("should not be requested")]), + ]); + const hook = createHook({ + onToolCall({ tool }) { + return tool.requestApproval({ reason: "Guarded action." }); + }, + }); + const agent = new AgentBuilder("test-agent", model).tool(guardedTool).hook(hook).build(); + + await expect(agent.prompt("run guarded").send()).rejects.toMatchObject({ + name: "PromptCancelledError", + reason: "Tool approval was requested for guarded, but no approval handler is installed.", + }); + expect(executed).toBe(false); + expect(model.requests).toHaveLength(1); + }); + it("executes a tool after async approval-style hook allows it", async () => { let executed = false; const guardedTool = createTool({ @@ -337,6 +536,10 @@ describe("PromptRequest", () => { it("keeps low-level hook action helpers available", () => { expect(cancelPrompt("blocked")).toEqual({ type: "terminate", reason: "blocked" }); + expect(requestToolApproval({ reason: "review" })).toEqual({ + type: "approval_request", + reason: "review", + }); }); it("uses requestHook for one request instead of the agent hook", async () => { diff --git a/packages/core/test/public-exports.test.ts b/packages/core/test/public-exports.test.ts new file mode 100644 index 00000000..29fd9f61 --- /dev/null +++ b/packages/core/test/public-exports.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import * as publicAgent from "../src/agent"; +import * as audioGeneration from "../src/audio-generation"; +import * as completion from "../src/completion"; +import * as embeddings from "../src/embeddings"; +import * as evals from "../src/evals"; +import * as extractor from "../src/extractor"; +import * as imageGeneration from "../src/image-generation"; +import * as publicCore from "../src/index"; +import * as internalAgent from "../src/internal/agent"; +import * as loaders from "../src/loaders"; +import * as mcp from "../src/mcp"; +import * as modelListing from "../src/model-listing"; +import * as observability from "../src/observability"; +import * as pipeline from "../src/pipeline"; +import * as skills from "../src/skills"; +import * as streaming from "../src/streaming"; +import * as tool from "../src/tool"; +import * as transcription from "../src/transcription"; +import * as vectorStore from "../src/vector-store"; + +describe("public exports", () => { + it("exposes AgentBuilder from the public entrypoints", () => { + expect("AgentBuilder" in publicCore).toBe(true); + expect("AgentBuilder" in publicAgent).toBe(true); + }); + + it("keeps runtime Agent out of public entrypoints", () => { + expect("Agent" in publicCore).toBe(false); + expect("Agent" in publicAgent).toBe(false); + }); + + it("exposes runtime Agent through the internal agent entrypoint", () => { + expect("Agent" in internalAgent).toBe(true); + }); + + it("keeps public subpath runtime exports available", () => { + expect(audioGeneration).toHaveProperty("AudioGenerationRequestBuilder"); + expect(audioGeneration).toHaveProperty("audioGenerationRequest"); + expect(completion).toHaveProperty("CompletionRequestBuilder"); + expect(completion).toHaveProperty("Message"); + expect(embeddings).toHaveProperty("embedText"); + expect(evals).toHaveProperty("runEvalSuite"); + expect(evals).toHaveProperty("EvalOutcome"); + expect(extractor).toHaveProperty("ExtractorBuilder"); + expect(imageGeneration).toHaveProperty("ImageGenerationRequestBuilder"); + expect(loaders).toHaveProperty("FileLoader"); + expect(mcp).toHaveProperty("connectMcp"); + expect(modelListing).toHaveProperty("ModelListingError"); + expect(observability).toHaveProperty("createObserver"); + expect(pipeline).toHaveProperty("PipelineBuilder"); + expect(skills).toHaveProperty("loadSkills"); + expect(streaming).toHaveProperty("toReadableStream"); + expect(tool).toHaveProperty("createTool"); + expect(transcription).toHaveProperty("TranscriptionRequestBuilder"); + expect(vectorStore).toHaveProperty("InMemoryVectorStore"); + }); +}); diff --git a/packages/core/test/skills.test.ts b/packages/core/test/skills.test.ts index 4a1fbe03..e40916f9 100644 --- a/packages/core/test/skills.test.ts +++ b/packages/core/test/skills.test.ts @@ -10,13 +10,15 @@ import { type CompletionResponse, type CompletionStreamEvent, createHook, + createToolMiddleware, loadSkills, + Message, SkillValidationError, type StreamingCompletionModel, skill, ToolSet, Usage, -} from "../src/index"; +} from "./helpers/imports"; const tempDirs: string[] = []; @@ -268,6 +270,102 @@ describe("skills", () => { ]); }); + it("does not apply tool result middleware to skill tools added with skills", async () => { + const root = await tempRoot(); + await writeSkill(root, "review", { + description: "Review things.", + body: "# Review\nUse direct feedback.", + }); + const skillSet = await loadSkills(skill.local(root)); + const model = new QueueModel([ + response([ + AssistantContent.toolCall("call_1", "get_skill_instructions", { skillName: "review" }), + ]), + response([AssistantContent.text("loaded")]), + ]); + const events: string[] = []; + const agent = new AgentBuilder("test-agent", model) + .skills(skillSet) + .toolMiddleware( + createToolMiddleware({ + onResult({ result }) { + events.push(`middleware:${result}`); + return "middleware changed result"; + }, + }), + ) + .hook( + createHook({ + onToolResult({ result }) { + events.push(`hook:${result}`); + }, + }), + ) + .defaultMaxTurns(1) + .build(); + + await expect(agent.prompt("review").send()).resolves.toMatchObject({ output: "loaded" }); + + expect(events).toEqual(["hook:# Review\nUse direct feedback."]); + expect(model.requests[1]?.chatHistory.at(-1)).toEqual( + Message.tool([ + { + type: "tool_result", + id: "call_1", + content: [{ type: "text", text: "# Review\nUse direct feedback." }], + }, + ]), + ); + }); + + it("does not apply tool result middleware to skill tools added manually", async () => { + const root = await tempRoot(); + await writeSkill(root, "review", { + description: "Review things.", + body: "# Review\nUse direct feedback.", + }); + const skillSet = await loadSkills(skill.local(root)); + const model = new QueueModel([ + response([ + AssistantContent.toolCall("call_1", "get_skill_instructions", { skillName: "review" }), + ]), + response([AssistantContent.text("loaded")]), + ]); + const events: string[] = []; + const agent = new AgentBuilder("test-agent", model) + .tools(skillSet.tools) + .toolMiddleware( + createToolMiddleware({ + onResult({ result }) { + events.push(`middleware:${result}`); + return "middleware changed result"; + }, + }), + ) + .hook( + createHook({ + onToolResult({ result }) { + events.push(`hook:${result}`); + }, + }), + ) + .defaultMaxTurns(1) + .build(); + + await expect(agent.prompt("review").send()).resolves.toMatchObject({ output: "loaded" }); + + expect(events).toEqual(["hook:# Review\nUse direct feedback."]); + expect(model.requests[1]?.chatHistory.at(-1)).toEqual( + Message.tool([ + { + type: "tool_result", + id: "call_1", + content: [{ type: "text", text: "# Review\nUse direct feedback." }], + }, + ]), + ); + }); + it("adds skill tools to streaming runs", async () => { const root = await tempRoot(); await writeSkill(root, "review", { diff --git a/packages/core/test/stream-accumulator.test.ts b/packages/core/test/stream-accumulator.test.ts new file mode 100644 index 00000000..fdf5b98f --- /dev/null +++ b/packages/core/test/stream-accumulator.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { CompletionStreamAccumulator } from "../src/agent/stream-accumulator"; +import { AssistantContent, Usage } from "../src/completion"; + +describe("CompletionStreamAccumulator", () => { + it("preserves accumulated streamed tool arguments when the final tool input is empty", () => { + const accumulator = new CompletionStreamAccumulator(); + const rawResponse = { provider: "minimax" }; + + accumulator.accept({ + type: "message_id", + id: "msg_1", + }); + accumulator.accept({ + type: "tool_call_delta", + id: "toolu_1", + name: "Write", + }); + accumulator.accept({ + type: "tool_call_delta", + id: "toolu_1", + argumentsDelta: '{"file_path":"src/main.tsx","content":"hello"}', + }); + accumulator.accept({ + type: "final", + response: { + choice: [AssistantContent.toolCall("toolu_1", "Write", {})], + usage: { ...Usage.empty(), inputTokens: 2, outputTokens: 1, totalTokens: 3 }, + rawResponse, + messageId: "msg_1", + }, + }); + + expect(accumulator.response()).toEqual({ + choice: [ + AssistantContent.toolCall("toolu_1", "Write", { + file_path: "src/main.tsx", + content: "hello", + }), + ], + usage: { ...Usage.empty(), inputTokens: 2, outputTokens: 1, totalTokens: 3 }, + rawResponse, + messageId: "msg_1", + }); + }); + + it("preserves accumulated start-block tool input when the final tool input is empty", () => { + const accumulator = new CompletionStreamAccumulator(); + + accumulator.accept({ + type: "tool_call_delta", + id: "toolu_1", + name: "Write", + argumentsDelta: '{"file_path":"src/main.tsx","content":"hello"}', + }); + accumulator.accept({ + type: "final", + response: { + choice: [AssistantContent.toolCall("toolu_1", "Write", {})], + usage: Usage.empty(), + rawResponse: {}, + }, + }); + + expect(accumulator.response().choice).toEqual([ + AssistantContent.toolCall("toolu_1", "Write", { + file_path: "src/main.tsx", + content: "hello", + }), + ]); + }); + + it("keeps non-empty final tool arguments over accumulated streamed arguments", () => { + const accumulator = new CompletionStreamAccumulator(); + + accumulator.accept({ + type: "tool_call_delta", + id: "toolu_1", + name: "Write", + argumentsDelta: '{"file_path":"src/main.tsx","content":"streamed"}', + }); + accumulator.accept({ + type: "final", + response: { + choice: [ + AssistantContent.toolCall("toolu_1", "Write", { + file_path: "src/main.tsx", + content: "final", + }), + ], + usage: Usage.empty(), + rawResponse: {}, + }, + }); + + expect(accumulator.response().choice).toEqual([ + AssistantContent.toolCall("toolu_1", "Write", { + file_path: "src/main.tsx", + content: "final", + }), + ]); + }); +}); diff --git a/packages/core/test/streaming.test.ts b/packages/core/test/streaming.test.ts index a1500ad7..43ffbd32 100644 --- a/packages/core/test/streaming.test.ts +++ b/packages/core/test/streaming.test.ts @@ -2,16 +2,21 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { AgentBuilder, + type AgentEventAppendInput, + type AgentEventRecord, + type AgentEventStore, type AgentStreamEvent, AssistantContent, type CompletionRequest, type CompletionResponse, type CompletionStreamEvent, createTool, + createToolMiddleware, Message, type StreamingCompletionModel, + ToolOutput, toReadableStream, -} from "../src/index"; +} from "./helpers/imports"; class StreamingQueueModel implements StreamingCompletionModel { readonly provider = "test"; @@ -43,6 +48,24 @@ class StreamingQueueModel implements StreamingCompletionModel { } } +class RecordingEventStore implements AgentEventStore { + readonly appendCalls: AgentEventAppendInput[] = []; + + async append(input: AgentEventAppendInput): Promise { + this.appendCalls.push(input); + } + + async load(runId: string): Promise { + return this.appendCalls.filter((call) => call.runId === runId); + } + + async clear(runId: string): Promise { + const remaining = this.appendCalls.filter((call) => call.runId !== runId); + this.appendCalls.length = 0; + this.appendCalls.push(...remaining); + } +} + const addTool = createTool({ name: "add", description: "Add numbers", @@ -148,6 +171,108 @@ describe("PromptRequest streaming", () => { expect(model.requests).toHaveLength(2); }); + it("streams transformed tool results from middleware", async () => { + const model = new StreamingQueueModel([ + [ + { + type: "tool_call_delta", + id: "call_1", + name: "add", + argumentsDelta: '{"x":2,"y":5}', + }, + ], + [{ type: "text_delta", delta: "done" }], + ]); + const agent = new AgentBuilder("test-agent", model) + .tool(addTool) + .toolMiddleware( + createToolMiddleware({ + onResult({ result }) { + return `stored:${result}`; + }, + }), + ) + .build(); + + const events = await collect(agent.prompt("add").stream()); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "tool_result", + turn: 1, + toolName: "add", + result: "stored:7", + }), + ); + expect(model.requests[1]?.chatHistory.at(-1)).toEqual( + Message.tool([ + { + type: "tool_result", + id: "call_1", + content: [{ type: "text", text: "stored:7" }], + }, + ]), + ); + }); + + it("streams structured tool results with a display string", async () => { + const structuredContent = ToolOutput.content([ + { type: "text", text: "screen" }, + { type: "image", data: "base64-png", mediaType: "image/png" }, + ]); + const screenshotTool = createTool({ + name: "computer_screenshot", + description: "Return screenshot", + input: z.object({}), + execute: () => structuredContent, + }); + const model = new StreamingQueueModel([ + [ + { + type: "tool_call", + toolCall: AssistantContent.toolCall("call_1", "computer_screenshot", {}), + }, + ], + [{ type: "text_delta", delta: "done" }], + ]); + const eventStore = new RecordingEventStore(); + const agent = new AgentBuilder("test-agent", model) + .tool(screenshotTool) + .eventStore(eventStore, { include: "all" }) + .build(); + + const events = await collect(agent.prompt("screenshot").stream()); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "tool_result", + turn: 1, + toolName: "computer_screenshot", + result: "screen\n[image:image/png]", + structuredResult: structuredContent, + }), + ); + expect(model.requests[1]?.chatHistory.at(-1)).toEqual( + Message.tool([ + { + type: "tool_result", + id: "call_1", + content: structuredContent, + }, + ]), + ); + expect( + eventStore.appendCalls.some((call) => { + const event = JSON.parse(JSON.stringify(call.event)) as AgentStreamEvent; + return ( + event.type === "tool_result" && + event.result === "screen\n[image:image/png]" && + event.structuredResult?.[1]?.type === "image" + ); + }), + ).toBe(true); + }); + it("streams concurrent tool results as each tool finishes", async () => { const slowRelease = deferred(); const slowStarted = deferred(); @@ -247,6 +372,178 @@ describe("PromptRequest streaming", () => { ); }); + it("streams child agent events from streaming agent tools", async () => { + const parentModel = new StreamingQueueModel([ + [ + { + type: "tool_call", + toolCall: AssistantContent.toolCall("call_child", "ask_child", { prompt: "inspect" }), + }, + ], + [{ type: "text_delta", delta: "parent done" }], + ]); + const childModel = new StreamingQueueModel([ + [ + { type: "text_delta", delta: "child " }, + { type: "text_delta", delta: "done" }, + ], + ]); + const childAgent = new AgentBuilder("child", childModel).name("Child Agent").build(); + const parentAgent = new AgentBuilder("parent", parentModel) + .tool(childAgent.asTool({ name: "ask_child", stream: true })) + .build(); + + const events = await collect(parentAgent.prompt("delegate").stream()); + const childEvents = events.filter((event) => event.type === "agent_tool_event"); + + expect(childEvents.map((event) => event.event.type)).toEqual([ + "turn_start", + "text_delta", + "text_delta", + "turn_end", + "final", + ]); + expect(childEvents).toContainEqual( + expect.objectContaining({ + type: "agent_tool_event", + turn: 1, + toolName: "ask_child", + internalCallId: expect.any(String), + agentId: "child", + agentName: "Child Agent", + event: expect.objectContaining({ type: "text_delta", delta: "child " }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "tool_result", + toolName: "ask_child", + result: "child done", + }), + ); + expect(events.at(-1)).toMatchObject({ type: "final", output: "parent done" }); + }); + + it("streams child tool calls and child tool results from streaming agent tools", async () => { + const parentModel = new StreamingQueueModel([ + [ + { + type: "tool_call", + toolCall: AssistantContent.toolCall("call_child", "ask_child", { prompt: "add" }), + }, + ], + [{ type: "text_delta", delta: "parent done" }], + ]); + const childModel = new StreamingQueueModel([ + [ + { + type: "tool_call", + toolCall: AssistantContent.toolCall("call_add", "add", { x: 2, y: 5 }), + }, + ], + [{ type: "text_delta", delta: "7" }], + ]); + const childAgent = new AgentBuilder("child", childModel) + .tool(addTool) + .defaultMaxTurns(2) + .build(); + const parentAgent = new AgentBuilder("parent", parentModel) + .tool(childAgent.asTool({ name: "ask_child", stream: true })) + .build(); + + const events = await collect(parentAgent.prompt("delegate").stream()); + const childEvents = events.filter((event) => event.type === "agent_tool_event"); + + expect(childEvents).toContainEqual( + expect.objectContaining({ + type: "agent_tool_event", + event: expect.objectContaining({ + type: "tool_call", + toolCall: AssistantContent.toolCall("call_add", "add", { x: 2, y: 5 }), + }), + }), + ); + expect(childEvents).toContainEqual( + expect.objectContaining({ + type: "agent_tool_event", + event: expect.objectContaining({ + type: "tool_result", + toolName: "add", + result: "7", + }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "tool_result", + toolName: "ask_child", + result: "7", + }), + ); + }); + + it("persists streamed parent and child agent events to the event store", async () => { + const eventStore = new RecordingEventStore(); + const parentModel = new StreamingQueueModel([ + [ + { + type: "tool_call", + toolCall: AssistantContent.toolCall("call_child", "ask_child", { prompt: "inspect" }), + }, + ], + [{ type: "text_delta", delta: "parent done" }], + ]); + const childModel = new StreamingQueueModel([[{ type: "text_delta", delta: "child done" }]]); + const childAgent = new AgentBuilder("child", childModel).build(); + const parentAgent = new AgentBuilder("parent", parentModel) + .tool(childAgent.asTool({ name: "ask_child", stream: true })) + .eventStore(eventStore, { include: "all" }) + .build(); + + const events = await collect(parentAgent.prompt("delegate").stream()); + const finalEvent = events.find( + (event): event is Extract => event.type === "final", + ); + + expect(eventStore.appendCalls).toHaveLength(events.length); + expect(finalEvent).toMatchObject({ type: "final", runId: expect.any(String) }); + expect(eventStore.appendCalls.every((call) => call.runId === finalEvent?.runId)).toBe(true); + expect(eventStore.appendCalls.some((call) => eventType(call.event) === "turn_start")).toBe( + true, + ); + expect( + eventStore.appendCalls.some( + (call) => eventType(call.event) === "agent_tool_event" && call.agentId === "child", + ), + ).toBe(true); + }); + + it("can persist only streamed child agent tool events", async () => { + const eventStore = new RecordingEventStore(); + const parentModel = new StreamingQueueModel([ + [ + { + type: "tool_call", + toolCall: AssistantContent.toolCall("call_child", "ask_child", { prompt: "inspect" }), + }, + ], + [{ type: "text_delta", delta: "parent done" }], + ]); + const childModel = new StreamingQueueModel([[{ type: "text_delta", delta: "child done" }]]); + const childAgent = new AgentBuilder("child", childModel).build(); + const parentAgent = new AgentBuilder("parent", parentModel) + .tool(childAgent.asTool({ name: "ask_child", stream: true })) + .eventStore(eventStore, { include: "agent_tool_events" }) + .build(); + + await collect(parentAgent.prompt("delegate").stream()); + + expect(eventStore.appendCalls.length).toBeGreaterThan(0); + expect( + eventStore.appendCalls.every((call) => eventType(call.event) === "agent_tool_event"), + ).toBe(true); + }); + it("buffers reasoning deltas without ids into one reasoning message", async () => { const model = new StreamingQueueModel([ [ @@ -464,6 +761,12 @@ function rejectAfter(ms: number, message: string): Promise { }); } +function eventType(event: unknown): string | undefined { + return typeof event === "object" && event !== null && "type" in event + ? String(event.type) + : undefined; +} + async function readAll(readable: ReadableStream): Promise { const reader = readable.getReader(); const decoder = new TextDecoder(); diff --git a/packages/core/test/think-tool.test.ts b/packages/core/test/think-tool.test.ts index 537183e3..e3935fc6 100644 --- a/packages/core/test/think-tool.test.ts +++ b/packages/core/test/think-tool.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createThinkTool, ToolCallError, ToolSet } from "../src/index"; +import { createThinkTool, ToolCallError, ToolSet } from "./helpers/imports"; describe("createThinkTool", () => { it("creates the default think tool definition", async () => { diff --git a/packages/core/test/tool-set.test.ts b/packages/core/test/tool-set.test.ts index 8608d3e9..caafe9a5 100644 --- a/packages/core/test/tool-set.test.ts +++ b/packages/core/test/tool-set.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { createTool, ToolCallError, ToolJsonError, ToolNotFoundError, ToolSet } from "../src/index"; +import { + createTool, + ToolCallError, + ToolJsonError, + ToolNotFoundError, + ToolOutput, + ToolSet, +} from "./helpers/imports"; const addTool = createTool({ name: "add", @@ -127,6 +134,23 @@ describe("ToolSet", () => { await expect(toolSet.call("object_output", "{}")).resolves.toBe('{"ok":true}'); }); + it("passes through structured tool result content", async () => { + const content = ToolOutput.content([ + { type: "text", text: '{"coordMap":"0,0,100,100,100,100"}' }, + { type: "image", data: "base64-png", mediaType: "image/png" }, + ]); + const toolSet = ToolSet.fromTools([ + createTool({ + name: "screenshot", + description: "Return screenshot", + input: z.object({}), + execute: () => content, + }), + ]); + + await expect(toolSet.call("screenshot", "{}")).resolves.toEqual(content); + }); + it("adds tool arrays and tool sets without duplicate definitions", async () => { const toolSet = new ToolSet().addTools([addTool, addTool]); const echoTool = createTool({ diff --git a/packages/embeddings/fastembed/CHANGELOG.md b/packages/embeddings/fastembed/CHANGELOG.md new file mode 100644 index 00000000..e893dba8 --- /dev/null +++ b/packages/embeddings/fastembed/CHANGELOG.md @@ -0,0 +1,33 @@ +# @anvia/fastembed + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 diff --git a/packages/embeddings/fastembed/README.md b/packages/embeddings/fastembed/README.md index 6e81b387..7f355366 100644 --- a/packages/embeddings/fastembed/README.md +++ b/packages/embeddings/fastembed/README.md @@ -19,7 +19,8 @@ pnpm --filter @anvia/fastembed build ## Usage ```ts -import { embedDocuments, InMemoryVectorStore } from "@anvia/core"; +import { embedDocuments } from "@anvia/core/embeddings"; +import { InMemoryVectorStore } from "@anvia/core/vector-store"; import { createFastEmbedEmbeddingModel } from "@anvia/fastembed"; const embeddingModel = await createFastEmbedEmbeddingModel(); diff --git a/packages/embeddings/fastembed/package.json b/packages/embeddings/fastembed/package.json index 8708f9d0..424482ad 100644 --- a/packages/embeddings/fastembed/package.json +++ b/packages/embeddings/fastembed/package.json @@ -1,13 +1,21 @@ { "name": "@anvia/fastembed", - "version": "0.1.0", + "version": "0.2.0", "description": "FastEmbed embedding model adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anvia-hq/anvia", + "directory": "packages/embeddings/fastembed" + }, "files": [ "dist" ], + "publishConfig": { + "access": "public" + }, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/embeddings/fastembed/src/index.ts b/packages/embeddings/fastembed/src/index.ts index a7c6a0fd..a87f020b 100644 --- a/packages/embeddings/fastembed/src/index.ts +++ b/packages/embeddings/fastembed/src/index.ts @@ -1,4 +1,4 @@ -import type { Embedding, EmbeddingModel } from "@anvia/core"; +import type { Embedding, EmbeddingModel } from "@anvia/core/embeddings"; import type { ExecutionProvider } from "fastembed"; import { EmbeddingModel as FastEmbedModel, FlagEmbedding } from "fastembed"; diff --git a/packages/embeddings/fastembed/test/embedding.test.ts b/packages/embeddings/fastembed/test/embedding.test.ts index f0ce9c72..b82395c7 100644 --- a/packages/embeddings/fastembed/test/embedding.test.ts +++ b/packages/embeddings/fastembed/test/embedding.test.ts @@ -1,4 +1,4 @@ -import { embedTexts } from "@anvia/core"; +import { embedTexts } from "@anvia/core/embeddings"; import { describe, expect, it, vi } from "vitest"; import { createFastEmbedEmbeddingModel, diff --git a/packages/embeddings/fastembed/vitest.config.ts b/packages/embeddings/fastembed/vitest.config.ts index 1386ba45..8f0fea5e 100644 --- a/packages/embeddings/fastembed/vitest.config.ts +++ b/packages/embeddings/fastembed/vitest.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { + "@anvia/core/embeddings": new URL("../../core/src/embeddings/index.ts", import.meta.url) + .pathname, "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, }, }, diff --git a/packages/embeddings/transformers/CHANGELOG.md b/packages/embeddings/transformers/CHANGELOG.md new file mode 100644 index 00000000..6954d549 --- /dev/null +++ b/packages/embeddings/transformers/CHANGELOG.md @@ -0,0 +1,33 @@ +# @anvia/transformers + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.2 + +### Patch Changes + +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 diff --git a/packages/embeddings/transformers/README.md b/packages/embeddings/transformers/README.md index 3e406999..bab8770e 100644 --- a/packages/embeddings/transformers/README.md +++ b/packages/embeddings/transformers/README.md @@ -19,7 +19,8 @@ pnpm --filter @anvia/transformers build ## Usage ```ts -import { embedDocuments, InMemoryVectorStore } from "@anvia/core"; +import { embedDocuments } from "@anvia/core/embeddings"; +import { InMemoryVectorStore } from "@anvia/core/vector-store"; import { createTransformersEmbeddingModel } from "@anvia/transformers"; const embeddingModel = await createTransformersEmbeddingModel(); diff --git a/packages/embeddings/transformers/package.json b/packages/embeddings/transformers/package.json index 05b6a6ee..c3e0f850 100644 --- a/packages/embeddings/transformers/package.json +++ b/packages/embeddings/transformers/package.json @@ -1,13 +1,21 @@ { "name": "@anvia/transformers", - "version": "0.1.0", + "version": "0.2.0", "description": "Transformers.js embedding model adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anvia-hq/anvia", + "directory": "packages/embeddings/transformers" + }, "files": [ "dist" ], + "publishConfig": { + "access": "public" + }, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/embeddings/transformers/src/index.ts b/packages/embeddings/transformers/src/index.ts index 26b3c595..cf3df454 100644 --- a/packages/embeddings/transformers/src/index.ts +++ b/packages/embeddings/transformers/src/index.ts @@ -1,4 +1,4 @@ -import type { Embedding, EmbeddingModel } from "@anvia/core"; +import type { Embedding, EmbeddingModel } from "@anvia/core/embeddings"; import { pipeline as transformersPipeline } from "@huggingface/transformers"; export const DEFAULT_TRANSFORMERS_EMBEDDING_MODEL = "Xenova/all-MiniLM-L6-v2"; diff --git a/packages/embeddings/transformers/test/embedding.test.ts b/packages/embeddings/transformers/test/embedding.test.ts index ca531b2c..43558edf 100644 --- a/packages/embeddings/transformers/test/embedding.test.ts +++ b/packages/embeddings/transformers/test/embedding.test.ts @@ -1,4 +1,4 @@ -import { embedTexts } from "@anvia/core"; +import { embedTexts } from "@anvia/core/embeddings"; import { describe, expect, it, vi } from "vitest"; import { createTransformersEmbeddingModel, diff --git a/packages/embeddings/transformers/vitest.config.ts b/packages/embeddings/transformers/vitest.config.ts index 1386ba45..8f0fea5e 100644 --- a/packages/embeddings/transformers/vitest.config.ts +++ b/packages/embeddings/transformers/vitest.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { + "@anvia/core/embeddings": new URL("../../core/src/embeddings/index.ts", import.meta.url) + .pathname, "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, }, }, diff --git a/packages/logger/CHANGELOG.md b/packages/logger/CHANGELOG.md new file mode 100644 index 00000000..c66f7a51 --- /dev/null +++ b/packages/logger/CHANGELOG.md @@ -0,0 +1,30 @@ +# @anvia/logger + +## 0.3.1 + +### Patch Changes + +- c9728d4: Update upstream runtime dependencies to their latest compatible releases. + +## 0.3.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.2.0 + +### Minor Changes + +- c55f5cd: Add the first `@anvia/logger` release with structured logger types, console and Pino logger factories, and an agent observer that logs Anvia run, generation, and tool lifecycle events. + +## 0.1.0 + +### Minor Changes + +- Initial release with structured logger types, console and Pino logger factories, and an agent observer that logs Anvia run, generation, and tool lifecycle events. diff --git a/packages/logger/README.md b/packages/logger/README.md new file mode 100644 index 00000000..da5e1d51 --- /dev/null +++ b/packages/logger/README.md @@ -0,0 +1,77 @@ +# @anvia/logger + +Structured logger adapters for Anvia. + +Use this package when you want Anvia agent observer events to be written to a normal application logger. The package keeps logging outside `@anvia/core`: core emits lifecycle events, and `@anvia/logger` decides how those events become logs. + +## Installation + +```sh +pnpm add @anvia/logger @anvia/core +``` + +In this monorepo, the package is available through the workspace: + +```sh +pnpm --filter @anvia/logger build +``` + +## Usage + +```ts +import { AgentBuilder } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; +import { createLoggerObserver, createPinoLogger } from "@anvia/logger"; + +const logger = createPinoLogger({ + name: "support-app", + level: "info", +}); + +const client = new OpenAIClient({ + apiKey, +}); + +const agent = new AgentBuilder("support", client.completionModel()) + .instructions("Answer support questions clearly.") + .observe(createLoggerObserver(logger)) + .build(); + +const response = await agent.prompt("How do I reset my password?").send(); + +console.log(response.output); +``` + +The logger observer omits final outputs, full model requests, model responses, and tool results by default. Pass `LoggerObserverOptions` to opt in when your data policy allows those payloads in logs. + +For local development without Pino output, use the console logger: + +```ts +import { createConsoleLogger } from "@anvia/logger"; + +const logger = createConsoleLogger({ + name: "support-app", + level: "debug", +}); +``` + +## Exports + +- `createConsoleLogger` +- `createPinoLogger` +- `createLoggerObserver` +- `Logger` +- `LoggerOptions` +- `LogContext` +- `LogLevel` +- `ConsoleLoggerOptions` +- `PinoLoggerOptions` +- `LoggerObserverOptions` + +## Development + +```sh +pnpm --filter @anvia/logger typecheck +pnpm --filter @anvia/logger test +pnpm --filter @anvia/logger build +``` diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 00000000..f98cae9f --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,43 @@ +{ + "name": "@anvia/logger", + "version": "0.3.1", + "description": "Structured logger adapters for Anvia.", + "author": "anvia", + "maintainer": "Indra Zulfi", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anvia-hq/anvia", + "directory": "packages/logger" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup src/index.ts --format esm --dts --sourcemap --clean", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@anvia/core": "workspace:*", + "pino": "^10.3.1" + }, + "devDependencies": { + "@types/node": "^24.9.1", + "tsup": "^8.5.0", + "typescript": "^5.9.3", + "vitest": "^4.0.8" + } +} diff --git a/packages/logger/src/console.ts b/packages/logger/src/console.ts new file mode 100644 index 00000000..25aa1441 --- /dev/null +++ b/packages/logger/src/console.ts @@ -0,0 +1,82 @@ +import { resolveLogLevel, shouldLog } from "./levels"; +import type { LogContext, Logger, LoggerOptions, LogLevel } from "./types"; + +type ConsoleWriter = (line: string) => void; + +export type ConsoleLoggerOptions = LoggerOptions & { + writer?: ConsoleWriter | undefined; + timestamp?: (() => Date) | undefined; +}; + +export function createConsoleLogger(options: ConsoleLoggerOptions = {}): Logger { + return new ConsoleLogger({ + bindings: createInitialBindings(options), + level: resolveLogLevel(options.level), + writer: options.writer ?? console.log, + timestamp: options.timestamp ?? (() => new Date()), + }); +} + +type ConsoleLoggerState = { + bindings: LogContext; + level: LogLevel; + writer: ConsoleWriter; + timestamp: () => Date; +}; + +class ConsoleLogger implements Logger { + constructor(private readonly state: ConsoleLoggerState) {} + + trace(message: string, context?: LogContext): void { + this.write("trace", message, context); + } + + debug(message: string, context?: LogContext): void { + this.write("debug", message, context); + } + + info(message: string, context?: LogContext): void { + this.write("info", message, context); + } + + warn(message: string, context?: LogContext): void { + this.write("warn", message, context); + } + + error(message: string, context?: LogContext): void { + this.write("error", message, context); + } + + fatal(message: string, context?: LogContext): void { + this.write("fatal", message, context); + } + + child(bindings: LogContext): Logger { + return new ConsoleLogger({ + ...this.state, + bindings: { ...this.state.bindings, ...bindings }, + }); + } + + private write(level: Exclude, message: string, context?: LogContext): void { + if (!shouldLog(this.state.level, level)) { + return; + } + + const payload = { + time: this.state.timestamp().toISOString(), + level, + msg: message, + ...this.state.bindings, + ...(context ?? {}), + }; + this.state.writer(JSON.stringify(payload)); + } +} + +function createInitialBindings(options: LoggerOptions): LogContext { + return { + ...(options.name === undefined ? {} : { name: options.name }), + ...(options.bindings ?? {}), + }; +} diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts new file mode 100644 index 00000000..ec6bdb99 --- /dev/null +++ b/packages/logger/src/index.ts @@ -0,0 +1,4 @@ +export { type ConsoleLoggerOptions, createConsoleLogger } from "./console"; +export { createLoggerObserver, type LoggerObserverOptions } from "./observer"; +export { createPinoLogger, type PinoLoggerOptions } from "./pino"; +export type { LogContext, Logger, LoggerOptions, LogLevel } from "./types"; diff --git a/packages/logger/src/levels.ts b/packages/logger/src/levels.ts new file mode 100644 index 00000000..8209bd0d --- /dev/null +++ b/packages/logger/src/levels.ts @@ -0,0 +1,50 @@ +import type { LogLevel } from "./types"; + +const levelPriority: Record, number> = { + trace: 10, + debug: 20, + info: 30, + warn: 40, + error: 50, + fatal: 60, +}; + +export function shouldLog( + configuredLevel: LogLevel, + messageLevel: Exclude, +): boolean { + if (configuredLevel === "silent") { + return false; + } + + return levelPriority[messageLevel] >= levelPriority[configuredLevel]; +} + +export function resolveLogLevel(level: LogLevel | undefined): LogLevel { + if (level !== undefined) { + return level; + } + + const envLevel = readEnv("ANVIA_LOG_LEVEL") ?? readEnv("LOG_LEVEL"); + if (isLogLevel(envLevel)) { + return envLevel; + } + + return readEnv("NODE_ENV") === "production" ? "error" : "info"; +} + +function readEnv(name: string): string | undefined { + return typeof process === "undefined" ? undefined : process.env[name]; +} + +function isLogLevel(value: string | undefined): value is LogLevel { + return ( + value === "trace" || + value === "debug" || + value === "info" || + value === "warn" || + value === "error" || + value === "fatal" || + value === "silent" + ); +} diff --git a/packages/logger/src/observer.ts b/packages/logger/src/observer.ts new file mode 100644 index 00000000..93418d91 --- /dev/null +++ b/packages/logger/src/observer.ts @@ -0,0 +1,181 @@ +import type { + AgentGenerationEndArgs, + AgentGenerationErrorArgs, + AgentGenerationObserver, + AgentGenerationStartArgs, + AgentObserver, + AgentRunEndArgs, + AgentRunErrorArgs, + AgentRunObserver, + AgentRunStartArgs, + AgentToolEndArgs, + AgentToolErrorArgs, + AgentToolObserver, + AgentToolStartArgs, + AgentToolStreamEventArgs, +} from "@anvia/core/observability"; +import type { LogContext, Logger } from "./types"; + +export type LoggerObserverOptions = { + includeOutput?: boolean | undefined; + includeRequest?: boolean | undefined; + includeResponse?: boolean | undefined; + includeToolResult?: boolean | undefined; +}; + +export function createLoggerObserver( + logger: Logger, + options: LoggerObserverOptions = {}, +): AgentObserver { + return { + startRun(args) { + return new LoggerRunObserver(logger, options, args); + }, + }; +} + +class LoggerRunObserver implements AgentRunObserver { + private readonly logger: Logger; + + constructor( + logger: Logger, + private readonly options: LoggerObserverOptions, + args: AgentRunStartArgs, + ) { + this.logger = logger.child({ + component: "anvia.agent", + ...(args.agentName === undefined ? {} : { agentName: args.agentName }), + ...(args.trace?.name === undefined ? {} : { traceName: args.trace.name }), + ...(args.trace?.userId === undefined ? {} : { userId: args.trace.userId }), + ...(args.trace?.sessionId === undefined ? {} : { sessionId: args.trace.sessionId }), + ...(args.trace?.traceId === undefined ? {} : { traceId: args.trace.traceId }), + }); + this.logger.info("agent run started", { + agentDescription: args.agentDescription, + maxTurns: args.maxTurns, + historyLength: args.history.length, + promptRole: args.prompt.role, + trace: args.trace, + }); + } + + startGeneration(args: AgentGenerationStartArgs): AgentGenerationObserver { + this.logger.info("agent generation started", generationStartContext(args, this.options)); + return new LoggerGenerationObserver(this.logger, this.options); + } + + startTool(args: AgentToolStartArgs): AgentToolObserver { + const toolLogger = this.logger.child({ + turn: args.turn, + toolName: args.toolName, + internalCallId: args.internalCallId, + ...(args.toolCallId === undefined ? {} : { toolCallId: args.toolCallId }), + }); + toolLogger.info("agent tool started", { + args: args.args, + toolMetadata: args.toolMetadata, + }); + return new LoggerToolObserver(toolLogger, this.options); + } + + end(args: AgentRunEndArgs): void { + this.logger.info("agent run ended", { + ...(this.options.includeOutput === true ? { output: args.output } : {}), + usage: args.usage, + messageCount: args.messages.length, + }); + } + + error(args: AgentRunErrorArgs): void { + this.logger.error("agent run failed", { + error: serializeError(args.error), + usage: args.usage, + messageCount: args.messages.length, + }); + } +} + +class LoggerGenerationObserver implements AgentGenerationObserver { + constructor( + private readonly logger: Logger, + private readonly options: LoggerObserverOptions, + ) {} + + end(args: AgentGenerationEndArgs): void { + this.logger.info("agent generation ended", generationEndContext(args, this.options)); + } + + error(args: AgentGenerationErrorArgs): void { + this.logger.error("agent generation failed", { + turn: args.turn, + error: serializeError(args.error), + }); + } +} + +class LoggerToolObserver implements AgentToolObserver { + constructor( + private readonly logger: Logger, + private readonly options: LoggerObserverOptions, + ) {} + + streamEvent(args: AgentToolStreamEventArgs): void { + this.logger.debug("agent tool stream event", { + event: args.event, + }); + } + + end(args: AgentToolEndArgs): void { + this.logger.info("agent tool ended", { + skipped: args.skipped, + ...(this.options.includeToolResult === true ? { result: args.result } : {}), + ...(this.options.includeToolResult === true && args.structuredResult !== undefined + ? { structuredResult: args.structuredResult } + : {}), + }); + } + + error(args: AgentToolErrorArgs): void { + this.logger.error("agent tool failed", { + error: serializeError(args.error), + }); + } +} + +function generationStartContext( + args: AgentGenerationStartArgs, + options: LoggerObserverOptions, +): LogContext { + return { + turn: args.turn, + provider: args.modelInfo?.provider, + model: args.modelInfo?.defaultModel, + providerRequest: args.providerRequest, + ...(options.includeRequest === true ? { request: args.request } : {}), + }; +} + +function generationEndContext( + args: AgentGenerationEndArgs, + options: LoggerObserverOptions, +): LogContext { + return { + turn: args.turn, + firstDeltaMs: args.firstDeltaMs, + usage: args.response.usage, + ...(options.includeResponse === true ? { response: args.response } : {}), + }; +} + +function serializeError(error: unknown): unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + cause: error.cause, + }; + } + + return error; +} diff --git a/packages/logger/src/pino.ts b/packages/logger/src/pino.ts new file mode 100644 index 00000000..65aa20e2 --- /dev/null +++ b/packages/logger/src/pino.ts @@ -0,0 +1,71 @@ +import pino, { + type DestinationStream, + type LoggerOptions as PinoBaseOptions, + type Logger as PinoLoggerInstance, +} from "pino"; +import { resolveLogLevel } from "./levels"; +import type { LogContext, Logger, LoggerOptions } from "./types"; + +export type PinoLoggerOptions = LoggerOptions & { + pinoOptions?: PinoBaseOptions | undefined; + destination?: DestinationStream | undefined; +}; + +export function createPinoLogger(options: PinoLoggerOptions = {}): Logger { + const pinoOptions: PinoBaseOptions = { + ...options.pinoOptions, + level: options.pinoOptions?.level ?? resolveLogLevel(options.level), + ...(options.name === undefined ? {} : { name: options.name }), + ...(options.bindings === undefined ? {} : { base: options.bindings }), + }; + + const instance = + options.destination === undefined ? pino(pinoOptions) : pino(pinoOptions, options.destination); + + return new PinoLogger(instance); +} + +class PinoLogger implements Logger { + constructor(private readonly logger: PinoLoggerInstance) {} + + trace(message: string, context?: LogContext): void { + this.write("trace", message, context); + } + + debug(message: string, context?: LogContext): void { + this.write("debug", message, context); + } + + info(message: string, context?: LogContext): void { + this.write("info", message, context); + } + + warn(message: string, context?: LogContext): void { + this.write("warn", message, context); + } + + error(message: string, context?: LogContext): void { + this.write("error", message, context); + } + + fatal(message: string, context?: LogContext): void { + this.write("fatal", message, context); + } + + child(bindings: LogContext): Logger { + return new PinoLogger(this.logger.child(bindings)); + } + + private write( + level: "trace" | "debug" | "info" | "warn" | "error" | "fatal", + message: string, + context?: LogContext, + ): void { + if (context === undefined) { + this.logger[level](message); + return; + } + + this.logger[level](context, message); + } +} diff --git a/packages/logger/src/types.ts b/packages/logger/src/types.ts new file mode 100644 index 00000000..91e1fd3f --- /dev/null +++ b/packages/logger/src/types.ts @@ -0,0 +1,19 @@ +export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal" | "silent"; + +export type LogContext = Record; + +export interface Logger { + trace(message: string, context?: LogContext): void; + debug(message: string, context?: LogContext): void; + info(message: string, context?: LogContext): void; + warn(message: string, context?: LogContext): void; + error(message: string, context?: LogContext): void; + fatal(message: string, context?: LogContext): void; + child(bindings: LogContext): Logger; +} + +export type LoggerOptions = { + level?: LogLevel | undefined; + name?: string | undefined; + bindings?: LogContext | undefined; +}; diff --git a/packages/logger/test/logger.test.ts b/packages/logger/test/logger.test.ts new file mode 100644 index 00000000..6e78d609 --- /dev/null +++ b/packages/logger/test/logger.test.ts @@ -0,0 +1,239 @@ +import type { AgentRunObserver, AgentToolObserver } from "@anvia/core/observability"; +import { describe, expect, it } from "vitest"; +import { createConsoleLogger, createLoggerObserver, createPinoLogger, type Logger } from "../src"; + +describe("createConsoleLogger", () => { + it("writes structured JSON logs and filters below the configured level", () => { + const lines: string[] = []; + const logger = createConsoleLogger({ + level: "info", + name: "test-app", + bindings: { requestId: "req_1" }, + timestamp: () => new Date("2026-06-01T00:00:00.000Z"), + writer: (line) => lines.push(line), + }); + + logger.debug("hidden", { hidden: true }); + logger.info("visible", { value: 1 }); + + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0] ?? "{}")).toEqual({ + time: "2026-06-01T00:00:00.000Z", + level: "info", + msg: "visible", + name: "test-app", + requestId: "req_1", + value: 1, + }); + }); + + it("creates child loggers with inherited bindings", () => { + const lines: string[] = []; + const logger = createConsoleLogger({ + level: "trace", + bindings: { service: "api" }, + timestamp: () => new Date("2026-06-01T00:00:00.000Z"), + writer: (line) => lines.push(line), + }).child({ agentName: "support" }); + + logger.trace("child log"); + + expect(JSON.parse(lines[0] ?? "{}")).toMatchObject({ + service: "api", + agentName: "support", + msg: "child log", + }); + }); +}); + +describe("createPinoLogger", () => { + it("adapts Pino to the Anvia logger interface", () => { + const lines: unknown[] = []; + const logger = createPinoLogger({ + level: "info", + name: "test-app", + destination: { + write: (line: string) => { + lines.push(JSON.parse(line)); + }, + }, + }); + + logger.info("hello", { value: 1 }); + + expect(lines).toHaveLength(1); + expect(lines[0]).toMatchObject({ + level: 30, + name: "test-app", + msg: "hello", + value: 1, + }); + }); +}); + +describe("createLoggerObserver", () => { + it("logs agent run, generation, and tool lifecycle events", async () => { + const logger = new RecordingLogger(); + const observer = createLoggerObserver(logger, { + includeToolResult: true, + }); + + const run = (await observer.startRun({ + agentName: "support", + agentDescription: "Support assistant", + instructions: "Answer support questions.", + trace: { + name: "support-run", + userId: "user_1", + sessionId: "session_1", + }, + prompt: { role: "user", content: [{ type: "text", text: "summarize" }] }, + history: [], + maxTurns: 2, + })) as AgentRunObserver; + + const generation = await run.startGeneration?.({ + turn: 1, + request: { + chatHistory: [{ role: "user", content: [{ type: "text", text: "summarize" }] }], + documents: [], + tools: [], + }, + providerRequest: { provider: "test" }, + modelInfo: { + provider: "test", + defaultModel: "test-model", + }, + }); + + generation?.end({ + turn: 1, + response: { + choice: [{ type: "text", text: "ok" }], + usage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + }, + rawResponse: {}, + }, + }); + + const tool = (await run.startTool?.({ + turn: 1, + toolCall: { + type: "tool_call", + id: "tool_1", + function: { + name: "lookup", + arguments: "{}", + }, + }, + toolName: "lookup", + args: "{}", + internalCallId: "internal_1", + })) as AgentToolObserver; + + tool.end({ + turn: 1, + toolCall: { + type: "tool_call", + id: "tool_1", + function: { + name: "lookup", + arguments: "{}", + }, + }, + toolName: "lookup", + args: "{}", + internalCallId: "internal_1", + result: "found", + skipped: false, + }); + + run.end({ + output: "ok", + usage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + cachedInputTokens: 0, + cacheCreationInputTokens: 0, + }, + messages: [], + }); + + expect(logger.records.map((record) => record.message)).toEqual([ + "agent run started", + "agent generation started", + "agent generation ended", + "agent tool started", + "agent tool ended", + "agent run ended", + ]); + expect(logger.records[0]?.context).toMatchObject({ + component: "anvia.agent", + agentName: "support", + userId: "user_1", + sessionId: "session_1", + maxTurns: 2, + }); + expect(logger.records[4]?.context).toMatchObject({ + toolName: "lookup", + result: "found", + }); + expect(logger.records[5]?.context).not.toHaveProperty("output"); + }); +}); + +class RecordingLogger implements Logger { + readonly records: { level: string; message: string; context: Record }[]; + + constructor( + private readonly bindings: Record = {}, + records?: { level: string; message: string; context: Record }[], + ) { + this.records = records ?? []; + } + + trace(message: string, context?: Record): void { + this.record("trace", message, context); + } + + debug(message: string, context?: Record): void { + this.record("debug", message, context); + } + + info(message: string, context?: Record): void { + this.record("info", message, context); + } + + warn(message: string, context?: Record): void { + this.record("warn", message, context); + } + + error(message: string, context?: Record): void { + this.record("error", message, context); + } + + fatal(message: string, context?: Record): void { + this.record("fatal", message, context); + } + + child(bindings: Record): Logger { + return new RecordingLogger({ ...this.bindings, ...bindings }, this.records); + } + + private record(level: string, message: string, context?: Record): void { + this.records.push({ + level, + message, + context: { + ...this.bindings, + ...(context ?? {}), + }, + }); + } +} diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json new file mode 100644 index 00000000..fbaa0ee6 --- /dev/null +++ b/packages/logger/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": ".", + "paths": { + "@anvia/core": ["../core/src/index.ts"], + "@anvia/core/*": ["../core/src/*/index.ts"] + } + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/packages/logger/vitest.config.ts b/packages/logger/vitest.config.ts new file mode 100644 index 00000000..8f425ed9 --- /dev/null +++ b/packages/logger/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@anvia/core": new URL("../core/src/index.ts", import.meta.url).pathname, + "@anvia/core/observability": new URL("../core/src/observability/index.ts", import.meta.url) + .pathname, + }, + }, + test: { + environment: "node", + }, +}); diff --git a/packages/observability/langfuse/CHANGELOG.md b/packages/observability/langfuse/CHANGELOG.md new file mode 100644 index 00000000..18a9f6ac --- /dev/null +++ b/packages/observability/langfuse/CHANGELOG.md @@ -0,0 +1,38 @@ +# @anvia/langfuse + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.7 + +### Patch Changes + +- b12932d: Update upstream dependencies for PDF loading, globbing, Langfuse tracing, and pgvector support. + + The PDF loader now destroys the `pdfjs-dist` loading task after reading pages, matching the v6 cleanup API. + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.1.6 + +### Patch Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 diff --git a/packages/observability/langfuse/package.json b/packages/observability/langfuse/package.json index f1b61ff6..6344cf05 100644 --- a/packages/observability/langfuse/package.json +++ b/packages/observability/langfuse/package.json @@ -1,13 +1,21 @@ { "name": "@anvia/langfuse", - "version": "0.1.0", + "version": "0.2.0", "description": "Langfuse tracing adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anvia-hq/anvia", + "directory": "packages/observability/langfuse" + }, "files": [ "dist" ], + "publishConfig": { + "access": "public" + }, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -23,10 +31,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@anvia/core": "workspace:*", - "@langfuse/otel": "^5.2.0", - "@langfuse/tracing": "^5.2.0", - "@opentelemetry/sdk-node": "^0.216.0" + "@anvia/core": "^0.4.0", + "@langfuse/otel": "^5.4.1", + "@langfuse/tracing": "^5.4.1", + "@opentelemetry/sdk-node": "^0.218.0" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/packages/observability/langfuse/src/index.ts b/packages/observability/langfuse/src/index.ts index 4110dd23..d420aac8 100644 --- a/packages/observability/langfuse/src/index.ts +++ b/packages/observability/langfuse/src/index.ts @@ -1,24 +1,22 @@ -import { - type AgentGenerationEndArgs, - type AgentGenerationErrorArgs, - type AgentGenerationObserver, - type AgentGenerationStartArgs, - type AgentObserver, - type AgentRunEndArgs, - type AgentRunErrorArgs, - type AgentRunObserver, - type AgentRunStartArgs, - type AgentToolEndArgs, - type AgentToolErrorArgs, - type AgentToolObserver, - type AgentToolStartArgs, - type AgentTraceInfo, - type EvalOutcome, - type EvalReportArgs, - type EvalReporter, - type JsonValue, - textFromAssistantContent, -} from "@anvia/core"; +import { type JsonValue, textFromAssistantContent } from "@anvia/core/completion"; +import type { EvalOutcome, EvalReportArgs, EvalReporter } from "@anvia/core/evals"; +import type { + AgentGenerationEndArgs, + AgentGenerationErrorArgs, + AgentGenerationObserver, + AgentGenerationStartArgs, + AgentObserver, + AgentRunEndArgs, + AgentRunErrorArgs, + AgentRunObserver, + AgentRunStartArgs, + AgentToolEndArgs, + AgentToolErrorArgs, + AgentToolObserver, + AgentToolStartArgs, + AgentToolStreamEventArgs, + AgentTraceInfo, +} from "@anvia/core/observability"; import { LangfuseSpanProcessor } from "@langfuse/otel"; import { type LangfuseAgent, @@ -451,9 +449,146 @@ class LangfuseGenerationObserver implements AgentGenerationObserver { } class LangfuseToolObserver implements AgentToolObserver { + private readonly childAgents = new Map(); + private readonly childGenerations = new Map(); + private readonly childTools: Array<{ + agentId: string; + toolName: string; + toolCallId?: string; + tool: LangfuseTool; + ended: boolean; + }> = []; + constructor(private readonly tool: LangfuseTool) {} + streamEvent(args: AgentToolStreamEventArgs): void { + const wrapper = args.event; + const child = isRecord(wrapper.event) ? wrapper.event : undefined; + if (child === undefined) { + return; + } + + const agentId = wrapper.agentId; + const agentName = wrapper.agentName; + const childTurn = typeof child.turn === "number" ? child.turn : args.turn; + const agent = this.childAgent(agentId, agentName, args); + + if (child.type === "turn_start") { + const generation = agent.startObservation( + `${agentLabel(agentId, agentName)}.model.turn.${childTurn}`, + { + input: { + prompt: child.prompt, + history: child.history, + }, + metadata: childMetadata(args, agentId, agentName, childTurn), + }, + { asType: "generation" }, + ); + this.childGenerations.set(generationKey(agentId, childTurn), generation); + return; + } + + if (child.type === "turn_end") { + const generation = this.childGenerations.get(generationKey(agentId, childTurn)); + if (generation !== undefined) { + generation + .update({ + output: child.response, + ...(isRecord(child.response) && isRecord(child.response.usage) + ? { usageDetails: usageDetailsFromRecord(child.response.usage) } + : {}), + metadata: childMetadata(args, agentId, agentName, childTurn), + }) + .end(); + this.childGenerations.delete(generationKey(agentId, childTurn)); + } + return; + } + + if (child.type === "tool_call" && isRecord(child.toolCall)) { + const toolCall = child.toolCall; + const toolCallFunction = isRecord(toolCall.function) ? toolCall.function : undefined; + const toolName = typeof toolCallFunction?.name === "string" ? toolCallFunction.name : "tool"; + const toolCallId = + typeof toolCall.callId === "string" + ? toolCall.callId + : typeof toolCall.id === "string" + ? toolCall.id + : undefined; + const childTool = agent.startObservation( + `${agentLabel(agentId, agentName)}.${toolName}`, + { + input: { + args: toolCallFunction?.arguments ?? {}, + toolCall, + }, + metadata: { + ...childMetadata(args, agentId, agentName, childTurn), + toolName, + toolCallId, + }, + }, + { asType: "tool" }, + ); + this.childTools.push({ + agentId, + toolName, + ...(toolCallId === undefined ? {} : { toolCallId }), + tool: childTool, + ended: false, + }); + return; + } + + if (child.type === "tool_result") { + const toolName = typeof child.toolName === "string" ? child.toolName : "tool"; + const toolCallId = typeof child.toolCallId === "string" ? child.toolCallId : undefined; + const childTool = this.findChildTool(agentId, toolName, toolCallId); + if (childTool !== undefined) { + childTool.ended = true; + childTool.tool + .update({ + output: typeof child.result === "string" ? child.result : child, + metadata: { + ...childMetadata(args, agentId, agentName, childTurn), + toolName, + toolCallId, + internalCallId: + typeof child.internalCallId === "string" ? child.internalCallId : undefined, + args: typeof child.args === "string" ? child.args : undefined, + }, + }) + .end(); + } + return; + } + + if (child.type === "final") { + agent + .update({ + output: child.output, + ...(isRecord(child.usage) ? { metadata: { usage: child.usage } } : {}), + }) + .end(); + this.childAgents.delete(agentId); + return; + } + + if (child.type === "error") { + agent + .update({ + level: "ERROR", + statusMessage: errorMessage(child.error), + output: { error: errorMessage(child.error) }, + }) + .end(); + this.childAgents.delete(agentId); + } + } + end(args: AgentToolEndArgs): void { + this.endOpenChildren(); const attributes: Parameters[0] = { output: args.result, metadata: { @@ -471,6 +606,7 @@ class LangfuseToolObserver implements AgentToolObserver { } error(args: AgentToolErrorArgs): void { + this.endOpenChildren(); this.tool .update({ level: "ERROR", @@ -484,6 +620,65 @@ class LangfuseToolObserver implements AgentToolObserver { }) .end(); } + + private childAgent( + agentId: string, + agentName: string | undefined, + args: AgentToolStartArgs, + ): LangfuseAgent { + const existing = this.childAgents.get(agentId); + if (existing !== undefined) { + return existing; + } + const agent = this.tool.startObservation( + `${agentLabel(agentId, agentName)}.run`, + { + metadata: childMetadata(args, agentId, agentName, args.turn), + }, + { asType: "agent" }, + ); + this.childAgents.set(agentId, agent); + return agent; + } + + private findChildTool( + agentId: string, + toolName: string, + toolCallId: string | undefined, + ): (typeof this.childTools)[number] | undefined { + for (let index = this.childTools.length - 1; index >= 0; index -= 1) { + const childTool = this.childTools[index]; + if ( + childTool === undefined || + childTool.ended || + childTool.agentId !== agentId || + childTool.toolName !== toolName + ) { + continue; + } + if (toolCallId === undefined || childTool.toolCallId === toolCallId) { + return childTool; + } + } + return undefined; + } + + private endOpenChildren(): void { + for (const generation of this.childGenerations.values()) { + generation.end(); + } + this.childGenerations.clear(); + for (const tool of this.childTools) { + if (!tool.ended) { + tool.tool.end(); + tool.ended = true; + } + } + for (const agent of this.childAgents.values()) { + agent.end(); + } + this.childAgents.clear(); + } } function modelParameters( @@ -509,6 +704,49 @@ function usageDetails(usage: AgentGenerationEndArgs["response"]["usage"]): Recor }; } +function usageDetailsFromRecord(usage: Record): Record { + return { + inputTokens: numberValue(usage.inputTokens) ?? 0, + outputTokens: numberValue(usage.outputTokens) ?? 0, + totalTokens: + numberValue(usage.totalTokens) ?? + (numberValue(usage.inputTokens) ?? 0) + (numberValue(usage.outputTokens) ?? 0), + }; +} + +function childMetadata( + args: AgentToolStartArgs, + agentId: string, + agentName: string | undefined, + childTurn: number, +): Record { + return { + source: "agent_tool_event", + childAgentId: agentId, + childAgentName: agentName, + childTurn, + parentToolName: args.toolName, + parentInternalCallId: args.internalCallId, + parentToolCallId: args.toolCallId, + }; +} + +function generationKey(agentId: string, turn: number): string { + return `${agentId}:${turn}`; +} + +function agentLabel(agentId: string, agentName: string | undefined): string { + return (agentName ?? agentId).replaceAll(/\s+/g, "_"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; +} + function emptyToUndefined(value: string | undefined): string | undefined { return value === undefined || value.length === 0 ? undefined : value; } diff --git a/packages/observability/langfuse/test/langfuse.test.ts b/packages/observability/langfuse/test/langfuse.test.ts index ba63c7f1..9526558c 100644 --- a/packages/observability/langfuse/test/langfuse.test.ts +++ b/packages/observability/langfuse/test/langfuse.test.ts @@ -1,13 +1,10 @@ -import { - type AgentGenerationStartArgs, - type AgentRunObserver, - type AgentToolObserver, - AssistantContent, - EvalOutcome, - type Message, - runEvalSuite, - type Usage, -} from "@anvia/core"; +import { AssistantContent, type Message, type Usage } from "@anvia/core/completion"; +import { EvalOutcome, runEvalSuite } from "@anvia/core/evals"; +import type { + AgentGenerationStartArgs, + AgentRunObserver, + AgentToolObserver, +} from "@anvia/core/observability"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createLangfuseEvalReporter as createReporter, langfuse } from "../src/index"; @@ -220,6 +217,170 @@ describe("langfuse", () => { expect(root.end).toHaveBeenCalledOnce(); }); + it("nests streamed child agent observations under the parent tool observation", async () => { + const root = fakeObservation("root", "trace-1", "obs-root"); + const turn = fakeObservation("turn", "trace-1", "obs-turn"); + const parentTool = fakeObservation("parent-tool", "trace-1", "obs-parent-tool"); + const childAgent = fakeObservation("child-agent", "trace-1", "obs-child-agent"); + const childGeneration = fakeObservation("child-generation", "trace-1", "obs-child-generation"); + const childTool = fakeObservation("child-tool", "trace-1", "obs-child-tool"); + root.startObservation.mockReturnValueOnce(turn); + turn.startObservation.mockReturnValueOnce(parentTool); + parentTool.startObservation.mockReturnValueOnce(childAgent); + childAgent.startObservation.mockReturnValueOnce(childGeneration).mockReturnValueOnce(childTool); + mocks.startObservation.mockReturnValueOnce(root); + + const tracing = langfuse.create({ publicKey: "public", secretKey: "secret" }); + const run = (await tracing.startRun({ + agentName: "support", + prompt: userMessage("delegate"), + history: [], + maxTurns: 2, + })) as AgentRunObserver; + const parentToolCall = AssistantContent.toolCall("call-child", "ask_child", { + prompt: "inspect", + }); + const tool = await run.startTool?.({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + internalCallId: "internal-child", + toolCallId: "call-child", + }); + + await tool?.streamEvent?.({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + internalCallId: "internal-child", + toolCallId: "call-child", + event: { + agentId: "child", + agentName: "Child Agent", + event: { type: "turn_start", turn: 1, prompt: userMessage("inspect"), history: [] }, + }, + }); + await tool?.streamEvent?.({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + internalCallId: "internal-child", + toolCallId: "call-child", + event: { + agentId: "child", + agentName: "Child Agent", + event: { + type: "tool_call", + turn: 1, + toolCall: AssistantContent.toolCall("call-add", "add", { x: 2, y: 5 }), + }, + }, + }); + await tool?.streamEvent?.({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + internalCallId: "internal-child", + toolCallId: "call-child", + event: { + agentId: "child", + agentName: "Child Agent", + event: { + type: "tool_result", + turn: 1, + toolName: "add", + toolCallId: "call-add", + internalCallId: "internal-add", + args: '{"x":2,"y":5}', + result: "7", + }, + }, + }); + await tool?.streamEvent?.({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + internalCallId: "internal-child", + toolCallId: "call-child", + event: { + agentId: "child", + agentName: "Child Agent", + event: { + type: "turn_end", + turn: 1, + response: { + messageId: "msg-child", + choice: [AssistantContent.text("7")], + usage: usage(2, 1), + rawResponse: {}, + }, + }, + }, + }); + await tool?.streamEvent?.({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + internalCallId: "internal-child", + toolCallId: "call-child", + event: { + agentId: "child", + agentName: "Child Agent", + event: { + type: "final", + runId: "child-run", + output: "7", + usage: usage(2, 1), + messages: [], + }, + }, + }); + await tool?.end({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + result: "7", + skipped: false, + internalCallId: "internal-child", + toolCallId: "call-child", + }); + + expect(parentTool.startObservation).toHaveBeenCalledWith( + "Child_Agent.run", + expect.objectContaining({ + metadata: expect.objectContaining({ + source: "agent_tool_event", + childAgentId: "child", + parentToolName: "ask_child", + }), + }), + { asType: "agent" }, + ); + expect(childAgent.startObservation).toHaveBeenCalledWith( + "Child_Agent.model.turn.1", + expect.any(Object), + { asType: "generation" }, + ); + expect(childAgent.startObservation).toHaveBeenCalledWith( + "Child_Agent.add", + expect.any(Object), + { asType: "tool" }, + ); + expect(childTool.update).toHaveBeenCalledWith( + expect.objectContaining({ + output: "7", + metadata: expect.objectContaining({ parentToolName: "ask_child" }), + }), + ); + }); + it("scores traces through the Langfuse public API", async () => { const tracing = langfuse.create({ publicKey: "public", diff --git a/packages/observability/langfuse/vitest.config.ts b/packages/observability/langfuse/vitest.config.ts index 1386ba45..93d7b2ea 100644 --- a/packages/observability/langfuse/vitest.config.ts +++ b/packages/observability/langfuse/vitest.config.ts @@ -3,6 +3,11 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { + "@anvia/core/completion": new URL("../../core/src/completion/index.ts", import.meta.url) + .pathname, + "@anvia/core/evals": new URL("../../core/src/evals/index.ts", import.meta.url).pathname, + "@anvia/core/observability": new URL("../../core/src/observability/index.ts", import.meta.url) + .pathname, "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, }, }, diff --git a/packages/observability/otel/CHANGELOG.md b/packages/observability/otel/CHANGELOG.md new file mode 100644 index 00000000..c1535d20 --- /dev/null +++ b/packages/observability/otel/CHANGELOG.md @@ -0,0 +1,33 @@ +# @anvia/otel + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.5 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.3 + +### Patch Changes + +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 diff --git a/packages/observability/otel/package.json b/packages/observability/otel/package.json index 54a41bff..fd56fbe2 100644 --- a/packages/observability/otel/package.json +++ b/packages/observability/otel/package.json @@ -1,13 +1,21 @@ { "name": "@anvia/otel", - "version": "0.1.0", + "version": "0.2.0", "description": "OpenTelemetry tracing adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anvia-hq/anvia", + "directory": "packages/observability/otel" + }, "files": [ "dist" ], + "publishConfig": { + "access": "public" + }, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/observability/otel/src/index.ts b/packages/observability/otel/src/index.ts index 1c7effb0..9bd97da5 100644 --- a/packages/observability/otel/src/index.ts +++ b/packages/observability/otel/src/index.ts @@ -1,20 +1,21 @@ -import { - type AgentGenerationEndArgs, - type AgentGenerationErrorArgs, - type AgentGenerationObserver, - type AgentGenerationStartArgs, - type AgentObserver, - type AgentRunEndArgs, - type AgentRunErrorArgs, - type AgentRunObserver, - type AgentRunStartArgs, - type AgentToolEndArgs, - type AgentToolErrorArgs, - type AgentToolObserver, - type AgentToolStartArgs, - type AgentTraceInfo, - textFromAssistantContent, -} from "@anvia/core"; +import { textFromAssistantContent } from "@anvia/core/completion"; +import type { + AgentGenerationEndArgs, + AgentGenerationErrorArgs, + AgentGenerationObserver, + AgentGenerationStartArgs, + AgentObserver, + AgentRunEndArgs, + AgentRunErrorArgs, + AgentRunObserver, + AgentRunStartArgs, + AgentToolEndArgs, + AgentToolErrorArgs, + AgentToolObserver, + AgentToolStartArgs, + AgentToolStreamEventArgs, + AgentTraceInfo, +} from "@anvia/core/observability"; import { type Attributes, type Context, @@ -109,7 +110,7 @@ class OtelRunObserver implements AgentRunObserver { }, this.rootContext, ); - return new OtelToolObserver(tool); + return new OtelToolObserver(this.tracer, tool); } end(args: AgentRunEndArgs): void { @@ -144,19 +145,244 @@ class OtelGenerationObserver implements AgentGenerationObserver { } class OtelToolObserver implements AgentToolObserver { - constructor(private readonly tool: Span) {} + private readonly childAgents = new Map(); + private readonly childGenerations = new Map(); + private readonly childTools: Array<{ + agentId: string; + toolName: string; + toolCallId?: string; + span: Span; + ended: boolean; + }> = []; + private readonly toolContext: Context; + + constructor( + private readonly tracer: Tracer, + private readonly tool: Span, + ) { + this.toolContext = trace.setSpan(ROOT_CONTEXT, tool); + } + + streamEvent(args: AgentToolStreamEventArgs): void { + const wrapper = args.event; + const child = isRecord(wrapper.event) ? wrapper.event : undefined; + if (child === undefined) { + return; + } + + const agentId = wrapper.agentId; + const agentName = wrapper.agentName; + const childTurn = typeof child.turn === "number" ? child.turn : args.turn; + const agent = this.childAgent(agentId, agentName, args); + + if (child.type === "turn_start") { + const generation = this.tracer.startSpan( + `${agentLabel(agentId, agentName)}.model.turn.${childTurn}`, + { + kind: SpanKind.CLIENT, + attributes: compactAttributes({ + "anvia.child_agent.id": agentId, + "anvia.child_agent.name": agentName, + "anvia.child_agent.turn": childTurn, + "anvia.parent_tool.name": args.toolName, + "anvia.parent_tool.internal_call_id": args.internalCallId, + "anvia.parent_tool.call_id": args.toolCallId, + "anvia.generation.input": jsonString({ + prompt: child.prompt, + history: child.history, + }), + }), + }, + trace.setSpan(ROOT_CONTEXT, agent), + ); + this.childGenerations.set(generationKey(agentId, childTurn), generation); + return; + } + + if (child.type === "turn_end") { + const generation = this.childGenerations.get(generationKey(agentId, childTurn)); + if (generation !== undefined) { + generation.setAttributes( + compactAttributes({ + "anvia.child_agent.id": agentId, + "anvia.child_agent.name": agentName, + "anvia.child_agent.turn": childTurn, + "anvia.generation.output": jsonString(child.response), + ...(isRecord(child.response) && isRecord(child.response.usage) + ? usageAttributesFromRecord(child.response.usage) + : {}), + }), + ); + generation.setStatus({ code: SpanStatusCode.OK }); + generation.end(); + this.childGenerations.delete(generationKey(agentId, childTurn)); + } + return; + } + + if (child.type === "tool_call" && isRecord(child.toolCall)) { + const toolCall = child.toolCall; + const toolCallFunction = isRecord(toolCall.function) ? toolCall.function : undefined; + const toolName = typeof toolCallFunction?.name === "string" ? toolCallFunction.name : "tool"; + const toolCallId = + typeof toolCall.callId === "string" + ? toolCall.callId + : typeof toolCall.id === "string" + ? toolCall.id + : undefined; + const span = this.tracer.startSpan( + `${agentLabel(agentId, agentName)}.${toolName}`, + { + kind: SpanKind.INTERNAL, + attributes: compactAttributes({ + "anvia.child_agent.id": agentId, + "anvia.child_agent.name": agentName, + "anvia.child_agent.turn": childTurn, + "anvia.tool.name": toolName, + "anvia.tool.call_id": toolCallId, + "anvia.tool.args": jsonString(toolCallFunction?.arguments ?? {}), + "anvia.parent_tool.name": args.toolName, + "anvia.parent_tool.internal_call_id": args.internalCallId, + "anvia.parent_tool.call_id": args.toolCallId, + }), + }, + trace.setSpan(ROOT_CONTEXT, agent), + ); + this.childTools.push({ + agentId, + toolName, + ...(toolCallId === undefined ? {} : { toolCallId }), + span, + ended: false, + }); + return; + } + + if (child.type === "tool_result") { + const toolName = typeof child.toolName === "string" ? child.toolName : "tool"; + const toolCallId = typeof child.toolCallId === "string" ? child.toolCallId : undefined; + const span = this.findChildTool(agentId, toolName, toolCallId); + if (span !== undefined) { + span.ended = true; + span.span.setAttributes( + compactAttributes({ + "anvia.child_agent.id": agentId, + "anvia.child_agent.name": agentName, + "anvia.child_agent.turn": childTurn, + "anvia.tool.name": toolName, + "anvia.tool.call_id": toolCallId, + "anvia.tool.internal_call_id": + typeof child.internalCallId === "string" ? child.internalCallId : undefined, + "anvia.tool.args": typeof child.args === "string" ? child.args : undefined, + "anvia.tool.result": typeof child.result === "string" ? child.result : undefined, + }), + ); + span.span.setStatus({ code: SpanStatusCode.OK }); + span.span.end(); + } + return; + } + + if (child.type === "final") { + agent.setAttributes( + compactAttributes({ + "anvia.child_agent.output": typeof child.output === "string" ? child.output : undefined, + "anvia.child_agent.messages": jsonString(child.messages), + ...(isRecord(child.usage) ? usageAttributesFromRecord(child.usage) : {}), + }), + ); + agent.setStatus({ code: SpanStatusCode.OK }); + agent.end(); + this.childAgents.delete(agentId); + return; + } + + if (child.type === "error") { + recordSpanError(agent, child.error); + agent.end(); + this.childAgents.delete(agentId); + } + } end(args: AgentToolEndArgs): void { + this.endOpenChildren(); this.tool.setAttributes(toolEndAttributes(args)); this.tool.setStatus({ code: SpanStatusCode.OK }); this.tool.end(); } error(args: AgentToolErrorArgs): void { + this.endOpenChildren(); recordSpanError(this.tool, args.error); this.tool.setAttributes(toolErrorAttributes(args)); this.tool.end(); } + + private childAgent( + agentId: string, + agentName: string | undefined, + args: AgentToolStartArgs, + ): Span { + const existing = this.childAgents.get(agentId); + if (existing !== undefined) { + return existing; + } + const span = this.tracer.startSpan( + `${agentLabel(agentId, agentName)}.run`, + { + kind: SpanKind.INTERNAL, + attributes: compactAttributes({ + "anvia.child_agent.id": agentId, + "anvia.child_agent.name": agentName, + "anvia.parent_tool.name": args.toolName, + "anvia.parent_tool.internal_call_id": args.internalCallId, + "anvia.parent_tool.call_id": args.toolCallId, + }), + }, + this.toolContext, + ); + this.childAgents.set(agentId, span); + return span; + } + + private findChildTool( + agentId: string, + toolName: string, + toolCallId: string | undefined, + ): (typeof this.childTools)[number] | undefined { + for (let index = this.childTools.length - 1; index >= 0; index -= 1) { + const childTool = this.childTools[index]; + if ( + childTool === undefined || + childTool.ended || + childTool.agentId !== agentId || + childTool.toolName !== toolName + ) { + continue; + } + if (toolCallId === undefined || childTool.toolCallId === toolCallId) { + return childTool; + } + } + return undefined; + } + + private endOpenChildren(): void { + for (const generation of this.childGenerations.values()) { + generation.end(); + } + this.childGenerations.clear(); + for (const tool of this.childTools) { + if (!tool.ended) { + tool.span.end(); + tool.ended = true; + } + } + for (const agent of this.childAgents.values()) { + agent.end(); + } + this.childAgents.clear(); + } } function rootSpanName(args: AgentRunStartArgs): string { @@ -264,6 +490,16 @@ function usageAttributes(usage: AgentRunEndArgs["usage"]): Attributes { }; } +function usageAttributesFromRecord(usage: Record): Attributes { + return compactAttributes({ + "anvia.usage.input_tokens": numberValue(usage.inputTokens), + "anvia.usage.output_tokens": numberValue(usage.outputTokens), + "anvia.usage.total_tokens": numberValue(usage.totalTokens), + "anvia.usage.cached_input_tokens": numberValue(usage.cachedInputTokens), + "anvia.usage.cache_creation_input_tokens": numberValue(usage.cacheCreationInputTokens), + }); +} + function modelParameters( request: AgentGenerationStartArgs["request"], ): Record { @@ -314,6 +550,22 @@ function parentContextFromTraceId(traceId: string | undefined): Context { }); } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; +} + +function generationKey(agentId: string, turn: number): string { + return `${agentId}:${turn}`; +} + +function agentLabel(agentId: string, agentName: string | undefined): string { + return (agentName ?? agentId).replaceAll(/\s+/g, "_"); +} + function isValidTraceId(traceId: string | undefined): traceId is string { return ( traceId !== undefined && diff --git a/packages/observability/otel/test/otel.test.ts b/packages/observability/otel/test/otel.test.ts index 9b24e481..54654dc6 100644 --- a/packages/observability/otel/test/otel.test.ts +++ b/packages/observability/otel/test/otel.test.ts @@ -1,10 +1,5 @@ -import { - type AgentGenerationStartArgs, - AssistantContent, - type Message, - type ToolCall, - type Usage, -} from "@anvia/core"; +import { AssistantContent, Message, type ToolCall, type Usage } from "@anvia/core/completion"; +import type { AgentGenerationStartArgs } from "@anvia/core/observability"; import { type Attributes, type Context, @@ -218,6 +213,145 @@ describe("otel", () => { expect(tracer.spans.every((span) => span.ended)).toBe(true); }); + it("nests streamed child agent spans under the parent tool span", async () => { + const tracer = new FakeTracer(); + const tracing = otel.create({ tracer: tracer.tracer }); + const run = await tracing.startRun({ + agentName: "support", + prompt: userMessage("delegate"), + history: [], + maxTurns: 2, + }); + const parentToolCall = AssistantContent.toolCall("call-child", "ask_child", { + prompt: "inspect", + }); + const tool = await run?.startTool?.({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + internalCallId: "internal-child", + toolCallId: "call-child", + }); + + await tool?.streamEvent?.({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + internalCallId: "internal-child", + toolCallId: "call-child", + event: { + agentId: "child", + agentName: "Child Agent", + event: { type: "turn_start", turn: 1, prompt: userMessage("inspect"), history: [] }, + }, + }); + await tool?.streamEvent?.({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + internalCallId: "internal-child", + toolCallId: "call-child", + event: { + agentId: "child", + agentName: "Child Agent", + event: { + type: "tool_call", + turn: 1, + toolCall: AssistantContent.toolCall("call-add", "add", { x: 2, y: 5 }), + }, + }, + }); + await tool?.streamEvent?.({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + internalCallId: "internal-child", + toolCallId: "call-child", + event: { + agentId: "child", + agentName: "Child Agent", + event: { + type: "tool_result", + turn: 1, + toolName: "add", + toolCallId: "call-add", + internalCallId: "internal-add", + args: '{"x":2,"y":5}', + result: "7", + }, + }, + }); + await tool?.streamEvent?.({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + internalCallId: "internal-child", + toolCallId: "call-child", + event: { + agentId: "child", + agentName: "Child Agent", + event: { + type: "turn_end", + turn: 1, + response: { + messageId: "msg-child", + choice: [AssistantContent.text("7")], + usage: usage(2, 1), + rawResponse: {}, + }, + }, + }, + }); + await tool?.streamEvent?.({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + internalCallId: "internal-child", + toolCallId: "call-child", + event: { + agentId: "child", + agentName: "Child Agent", + event: { + type: "final", + runId: "child-run", + output: "7", + usage: usage(2, 1), + messages: [Message.assistant("7")], + }, + }, + }); + await tool?.end({ + turn: 1, + toolName: "ask_child", + args: '{"prompt":"inspect"}', + toolCall: parentToolCall, + result: "7", + skipped: false, + internalCallId: "internal-child", + toolCallId: "call-child", + }); + + const parentTool = tracer.spans.find((span) => span.name === "tool.ask_child"); + const childAgent = tracer.spans.find((span) => span.name === "Child_Agent.run"); + const childGeneration = tracer.spans.find((span) => span.name === "Child_Agent.model.turn.1"); + const childTool = tracer.spans.find((span) => span.name === "Child_Agent.add"); + + expect(childAgent?.parentSpanId).toBe(parentTool?.spanContextValue.spanId); + expect(childGeneration?.parentSpanId).toBe(childAgent?.spanContextValue.spanId); + expect(childTool?.parentSpanId).toBe(childAgent?.spanContextValue.spanId); + expect(childTool?.attributes).toMatchObject({ + "anvia.parent_tool.name": "ask_child", + "anvia.child_agent.id": "child", + "anvia.tool.result": "7", + }); + }); + it("joins valid incoming trace ids and ignores invalid ones", async () => { const tracer = new FakeTracer(); const tracing = otel.create({ tracer: tracer.tracer }); diff --git a/packages/observability/otel/vitest.config.ts b/packages/observability/otel/vitest.config.ts index 1386ba45..89eb7921 100644 --- a/packages/observability/otel/vitest.config.ts +++ b/packages/observability/otel/vitest.config.ts @@ -3,6 +3,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { + "@anvia/core/completion": new URL("../../core/src/completion/index.ts", import.meta.url) + .pathname, + "@anvia/core/observability": new URL("../../core/src/observability/index.ts", import.meta.url) + .pathname, "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, }, }, diff --git a/packages/providers/anthropic/CHANGELOG.md b/packages/providers/anthropic/CHANGELOG.md new file mode 100644 index 00000000..ce0f6034 --- /dev/null +++ b/packages/providers/anthropic/CHANGELOG.md @@ -0,0 +1,53 @@ +# @anvia/anthropic + +## 0.3.1 + +### Patch Changes + +- c9728d4: Update upstream runtime dependencies to their latest compatible releases. + +## 0.3.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.2.0 + +### Minor Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +### Patch Changes + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.10 + +### Patch Changes + +- 49e43a3: Update upstream runtime dependencies for Anthropic, Gemini, OpenAI, and Studio. + +## 0.1.9 + +### Patch Changes + +- 896ae21: Update upstream provider and runtime dependencies. + +## 0.1.8 + +### Patch Changes + +- 1ad360d: Fix Anthropic-compatible streaming tool inputs and update provider dependencies. diff --git a/packages/providers/anthropic/package.json b/packages/providers/anthropic/package.json index 65adffec..fc725b77 100644 --- a/packages/providers/anthropic/package.json +++ b/packages/providers/anthropic/package.json @@ -1,13 +1,21 @@ { "name": "@anvia/anthropic", - "version": "0.1.0", + "version": "0.3.1", "description": "Anthropic provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anvia-hq/anvia", + "directory": "packages/providers/anthropic" + }, "files": [ "dist" ], + "publishConfig": { + "access": "public" + }, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -23,8 +31,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@anthropic-ai/sdk": "^0.92.0", - "@anvia/core": "workspace:*" + "@anthropic-ai/sdk": "^0.102.0", + "@anvia/core": "^0.4.0" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/packages/providers/anthropic/src/anthropic/client.ts b/packages/providers/anthropic/src/anthropic/client.ts index d9fc6ab5..e09c65a0 100644 --- a/packages/providers/anthropic/src/anthropic/client.ts +++ b/packages/providers/anthropic/src/anthropic/client.ts @@ -1,4 +1,9 @@ import Anthropic from "@anthropic-ai/sdk"; +import { + type ModelList, + type ModelListingClient, + ModelListingError, +} from "@anvia/core/model-listing"; import { AnthropicCompletionModel } from "./completion"; export type AnthropicClientOptions = { @@ -7,7 +12,7 @@ export type AnthropicClientOptions = { client?: Anthropic | undefined; }; -export class AnthropicClient { +export class AnthropicClient implements ModelListingClient { readonly client: Anthropic; constructor(options: AnthropicClientOptions = {}) { @@ -22,6 +27,18 @@ export class AnthropicClient { completionModel(model = "claude-sonnet-4-20250514"): AnthropicCompletionModel { return new AnthropicCompletionModel(this.client, model); } + + async listModels(): Promise { + try { + const response = await this.client.models.list(); + const data = (await collectModelsFromResponse(response)) + .map(toListedModel) + .filter(isListedModel); + return { data }; + } catch (error) { + throw toModelListingError("Anthropic", error); + } + } } function requireApiKey(apiKey: string | undefined): string { @@ -33,3 +50,102 @@ function requireApiKey(apiKey: string | undefined): string { return apiKey; } + +async function collectModelsFromResponse(response: unknown): Promise { + if (isAsyncIterable(response)) { + const models: unknown[] = []; + for await (const model of response) { + models.push(model); + } + return models; + } + + if (Array.isArray(response)) { + return response; + } + + if (isObject(response) && Array.isArray(response.data)) { + return response.data; + } + + return []; +} + +function toListedModel(model: unknown): ModelList["data"][number] | undefined { + if (!isObject(model) || typeof model.id !== "string") { + return undefined; + } + + const createdAt = + typeof model.created_at === "string" + ? secondsFromDateString(model.created_at) + : typeof model.created_at === "number" + ? model.created_at + : undefined; + + return { + id: model.id, + ...(typeof model.display_name === "string" ? { name: model.display_name } : {}), + ...(typeof model.name === "string" ? { name: model.name } : {}), + ...(typeof model.description === "string" ? { description: model.description } : {}), + ...(typeof model.type === "string" ? { type: model.type } : {}), + ...(createdAt === undefined ? {} : { createdAt }), + ...(typeof model.owned_by === "string" ? { ownedBy: model.owned_by } : {}), + ...(typeof model.max_input_tokens === "number" + ? { contextLength: model.max_input_tokens } + : {}), + ...(typeof model.context_length === "number" ? { contextLength: model.context_length } : {}), + }; +} + +function secondsFromDateString(value: string): number | undefined { + const time = Date.parse(value); + return Number.isNaN(time) ? undefined : Math.floor(time / 1000); +} + +function isListedModel( + model: ModelList["data"][number] | undefined, +): model is ModelList["data"][number] { + return model !== undefined; +} + +function toModelListingError(provider: string, error: unknown): ModelListingError { + if (error instanceof ModelListingError) { + return error; + } + + const statusCode = getStatusCode(error); + return new ModelListingError(`${provider} model listing failed: ${getErrorMessage(error)}`, { + provider, + ...(statusCode === undefined ? {} : { statusCode }), + cause: error, + }); +} + +function getStatusCode(error: unknown): number | undefined { + if (!isObject(error)) { + return undefined; + } + + if (typeof error.status === "number") { + return error.status; + } + + if (typeof error.statusCode === "number") { + return error.statusCode; + } + + return undefined; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return isObject(value) && Symbol.asyncIterator in value; +} diff --git a/packages/providers/anthropic/src/anthropic/completion.ts b/packages/providers/anthropic/src/anthropic/completion.ts index 2aab8be3..1e275c0e 100644 --- a/packages/providers/anthropic/src/anthropic/completion.ts +++ b/packages/providers/anthropic/src/anthropic/completion.ts @@ -9,6 +9,7 @@ import { type CompletionStreamEvent, type DocumentContent, type ImageContent, + type JsonObject, type JsonValue, type Message as MessageType, type ReasoningContent, @@ -16,6 +17,7 @@ import { type ToolChoice, type ToolContent, type ToolDefinition, + type ToolResultContent, Usage, type UserContent, } from "@anvia/core/completion"; @@ -45,6 +47,17 @@ export class AnthropicCompletionModel implements StreamingCompletionModel { readonly defaultModel = "claude-sonnet-4-20250514", ) {} + traceRequest( + request: CompletionRequest, + options: { stream?: boolean | undefined } = {}, + ): JsonObject { + const params = toAnthropicMessagesParams(this.defaultModel, request); + if (options.stream === true) { + params.stream = true; + } + return providerRequestSummary(params, request, options); + } + async completion(request: CompletionRequest): Promise { assertCompletionRequestSupported(this, request); const params = toAnthropicMessagesParams(this.defaultModel, request); @@ -57,6 +70,7 @@ export class AnthropicCompletionModel implements StreamingCompletionModel { const params = { ...toAnthropicMessagesParams(this.defaultModel, request), stream: true }; const stream = await this.client.messages.create(params as never); const toolIdsByIndex = new Map(); + const blocksWithInitialToolInput = new Set(); for await (const event of stream as unknown as AsyncIterable) { if (isPlainObject(event) && event.type === "content_block_start") { const index = numberFrom(event.index); @@ -65,8 +79,20 @@ export class AnthropicCompletionModel implements StreamingCompletionModel { if (id !== undefined) { toolIdsByIndex.set(index, id); } + if (toolInputArgumentsDelta(block.input) !== undefined) { + blocksWithInitialToolInput.add(index); + } } for (const mapped of fromAnthropicStreamEvent(event)) { + if ( + mapped.type === "tool_call_delta" && + mapped.argumentsDelta !== undefined && + isPlainObject(event) && + event.type === "content_block_delta" && + blocksWithInitialToolInput.has(numberFrom(event.index)) + ) { + continue; + } if (mapped.type === "tool_call_delta" && mapped.id.startsWith("tool_")) { const index = Number(mapped.id.slice("tool_".length)); yield { ...mapped, id: toolIdsByIndex.get(index) ?? mapped.id }; @@ -113,6 +139,48 @@ export function toAnthropicMessagesParams( return params; } +function providerRequestSummary( + params: AnthropicCreateParams, + request: CompletionRequest, + options: { stream?: boolean | undefined }, +): JsonObject { + return compactJsonObject({ + provider: "anthropic", + api: "messages", + stream: options.stream === true, + model: stringFrom(params.model), + parameterKeys: Object.keys(params).sort(), + messageCount: Array.isArray(params.messages) ? params.messages.length : undefined, + toolCount: request.tools.length, + toolNames: request.tools.map((tool) => tool.name), + hasSystem: typeof params.system === "string" && params.system.length > 0, + temperature: request.temperature, + maxTokens: request.maxTokens ?? numberFrom(params.max_tokens), + toolChoice: toolChoiceSummary(request.toolChoice), + additionalParamKeys: isPlainObject(request.additionalParams) + ? Object.keys(request.additionalParams).sort() + : undefined, + }); +} + +function toolChoiceSummary(toolChoice: ToolChoice | undefined): JsonValue | undefined { + if (toolChoice === undefined || typeof toolChoice === "string") { + return toolChoice; + } + return { type: toolChoice.type, name: toolChoice.name }; +} + +function compactJsonObject(values: Record): JsonObject { + return Object.fromEntries( + Object.entries(values).flatMap(([key, value]) => { + if (value === undefined) { + return []; + } + return [[key, toJsonValue(value)]]; + }), + ) as JsonObject; +} + function requestMessages(request: CompletionRequest): MessageType[] { return orderedRequestMessages(request); } @@ -200,6 +268,7 @@ export function fromAnthropicStreamEvent(event: unknown): CompletionStreamEvent[ return [ toolCallDelta(stringFrom(block.id) ?? `tool_${numberFrom(event.index)}`, { name: stringFrom(block.name), + argumentsDelta: toolInputArgumentsDelta(block.input), }), ]; } @@ -323,12 +392,32 @@ function toolContentToAnthropicBlock(content: ToolContent): AnthropicContentBloc return { type: "tool_result", tool_use_id: content.callId ?? content.id, - content: content.content - .map((item) => (item.type === "text" ? item.text : item.data)) - .join("\n"), + content: toolResultContentToAnthropicContent(content.content), }; } +function toolResultContentToAnthropicContent( + content: ToolResultContent[], +): string | AnthropicContentBlock[] { + if (content.every((item) => item.type === "text")) { + return content.map((item) => item.text).join("\n"); + } + + return content.map((item) => { + if (item.type === "text") { + return { type: "text", text: item.text }; + } + return { + type: "image", + source: { + type: "base64", + media_type: item.mediaType ?? "image/png", + data: item.data, + }, + }; + }); +} + function reasoningContentToAnthropicBlocks(content: ReasoningContent): AnthropicContentBlock[] { if (content.type === "text" || content.type === "summary") { const block: AnthropicContentBlock = { @@ -456,6 +545,31 @@ function toJsonValue(value: unknown): JsonValue { return String(value); } +function toolInputArgumentsDelta(input: unknown): string | undefined { + if (input === undefined) { + return undefined; + } + + if (typeof input === "string") { + const trimmed = input.trim(); + if (trimmed.length === 0 || trimmed === "{}") { + return undefined; + } + try { + JSON.parse(trimmed); + return trimmed; + } catch { + return JSON.stringify(input); + } + } + + if (isPlainObject(input) && Object.keys(input).length === 0) { + return undefined; + } + + return JSON.stringify(toJsonValue(input)); +} + function toolCallDelta( id: string, values: { name?: string | undefined; argumentsDelta?: string | undefined }, diff --git a/packages/providers/anthropic/test/anthropic-completion.test.ts b/packages/providers/anthropic/test/anthropic-completion.test.ts index 99febe56..b76310d7 100644 --- a/packages/providers/anthropic/test/anthropic-completion.test.ts +++ b/packages/providers/anthropic/test/anthropic-completion.test.ts @@ -1,4 +1,14 @@ -import { AssistantContent, type CompletionRequest, Message, Usage, UserContent } from "@anvia/core"; +import { AgentBuilder } from "@anvia/core/agent"; +import { + AssistantContent, + type CompletionRequest, + type CompletionStreamEvent, + Message, + ToolContent, + Usage, + UserContent, +} from "@anvia/core/completion"; +import type { Tool } from "@anvia/core/tool"; import { describe, expect, it } from "vitest"; import { AnthropicCompletionModel, @@ -88,6 +98,73 @@ describe("Anthropic Messages mapping", () => { }); }); + it("maps multimodal tool results to Anthropic content blocks", () => { + const params = toAnthropicMessagesParams("claude-sonnet-4-20250514", { + chatHistory: [ + Message.assistant([ + AssistantContent.toolCall("toolu_1", "computer_screenshot", {}, "fc_1"), + ]), + Message.tool( + ToolContent.toolResult( + "toolu_1", + [ + { type: "text", text: '{"coordMap":"0,0,100,100,100,100"}' }, + { type: "image", data: "base64-png", mediaType: "image/png" }, + ], + "fc_1", + ), + ), + ], + documents: [], + tools: [], + }); + + expect(params.messages).toContainEqual({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "fc_1", + content: [ + { type: "text", text: '{"coordMap":"0,0,100,100,100,100"}' }, + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "base64-png", + }, + }, + ], + }, + ], + }); + }); + + it("summarizes provider request metadata for traces", () => { + const model = new AnthropicCompletionModel({} as never, "claude-test"); + const request: CompletionRequest = { + instructions: "Be concise.", + chatHistory: [Message.user("What is 2+5?")], + documents: [], + tools: [{ name: "add", description: "Add numbers", parameters: { type: "object" } }], + maxTokens: 256, + toolChoice: "auto", + }; + + expect(model.traceRequest(request, { stream: true })).toMatchObject({ + provider: "anthropic", + api: "messages", + stream: true, + model: "claude-test", + messageCount: 1, + toolCount: 1, + toolNames: ["add"], + hasSystem: true, + parameterKeys: expect.arrayContaining(["messages", "model", "stream", "system", "tools"]), + }); + }); + it("prepends normalized static context before chat history and maps system messages", () => { const request: CompletionRequest = { chatHistory: [Message.system("Use context."), Message.user("What is the owner?")], @@ -331,4 +408,240 @@ describe("Anthropic Messages mapping", () => { }), ).toEqual([{ type: "tool_call_delta", id: "toolu_1", name: "lookup" }]); }); + + it("preserves complete tool input from streamed content_block_start events", async () => { + const events = await collectStreamEvents([ + { + type: "content_block_start", + index: 2, + content_block: { + type: "tool_use", + id: "toolu_write", + name: "Write", + input: { + file_path: "src/main.tsx", + content: "console.log('ok');", + }, + }, + }, + { type: "content_block_stop", index: 2 }, + ]); + + expect(accumulatedToolArguments(events, "toolu_write")).toEqual({ + file_path: "src/main.tsx", + content: "console.log('ok');", + }); + }); + + it("does not duplicate streamed tool input when input_json_delta also arrives", async () => { + const events = await collectStreamEvents([ + { + type: "content_block_start", + index: 2, + content_block: { + type: "tool_use", + id: "toolu_write", + name: "Write", + input: '{"file_path":"src/main.tsx","content":"start"}', + }, + }, + { + type: "content_block_delta", + index: 2, + delta: { + type: "input_json_delta", + partial_json: '{"file_path":"src/main.tsx","content":"delta"}', + }, + }, + { type: "content_block_stop", index: 2 }, + ]); + + expect( + events.filter( + (event) => event.type === "tool_call_delta" && event.argumentsDelta !== undefined, + ), + ).toHaveLength(1); + expect(accumulatedToolArguments(events, "toolu_write")).toEqual({ + file_path: "src/main.tsx", + content: "start", + }); + }); + + it("keeps streamed input_json_delta tool arguments when the final message has empty tool input", async () => { + const toolCalls: unknown[] = []; + const model = anthropicModelWithStreams([ + [ + { type: "message_start", message: { id: "msg_1" } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_1", name: "Write", input: {} }, + }, + { + type: "content_block_delta", + index: 0, + delta: { + type: "input_json_delta", + partial_json: '{"file_path":"src/main.tsx","content":"hello"}', + }, + }, + { + type: "message_stop", + message: { + id: "msg_1", + content: [{ type: "tool_use", id: "toolu_1", name: "Write", input: {} }], + }, + }, + ], + finalTextStream(), + ]); + const agent = new AgentBuilder("test-agent", model).tool(writeTool(toolCalls)).build(); + + const events = await collect(agent.prompt("write").stream()); + + expect(events).toContainEqual({ + type: "tool_call", + turn: 1, + toolCall: AssistantContent.toolCall("toolu_1", "Write", { + file_path: "src/main.tsx", + content: "hello", + }), + }); + expect(toolCalls).toEqual([{ file_path: "src/main.tsx", content: "hello" }]); + }); + + it("keeps streamed start-block tool arguments when the final message has empty tool input", async () => { + const toolCalls: unknown[] = []; + const model = anthropicModelWithStreams([ + [ + { type: "message_start", message: { id: "msg_1" } }, + { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "toolu_1", + name: "Write", + input: { file_path: "src/main.tsx", content: "hello" }, + }, + }, + { + type: "message_stop", + message: { + id: "msg_1", + content: [{ type: "tool_use", id: "toolu_1", name: "Write", input: {} }], + }, + }, + ], + finalTextStream(), + ]); + const agent = new AgentBuilder("test-agent", model).tool(writeTool(toolCalls)).build(); + + const events = await collect(agent.prompt("write").stream()); + + expect(events).toContainEqual({ + type: "tool_call", + turn: 1, + toolCall: AssistantContent.toolCall("toolu_1", "Write", { + file_path: "src/main.tsx", + content: "hello", + }), + }); + expect(toolCalls).toEqual([{ file_path: "src/main.tsx", content: "hello" }]); + }); }); + +async function collectStreamEvents(events: unknown[]): Promise { + const model = new AnthropicCompletionModel( + { + messages: { + create: async () => streamFrom(events), + }, + } as never, + "claude-test", + ); + + const mapped: CompletionStreamEvent[] = []; + for await (const event of model.streamCompletion({ + chatHistory: [Message.user("write a file")], + documents: [], + tools: [], + })) { + mapped.push(event); + } + return mapped; +} + +function anthropicModelWithStreams(streams: unknown[][]): AnthropicCompletionModel { + return new AnthropicCompletionModel( + { + messages: { + create: async () => streamFrom(streams.shift() ?? []), + }, + } as never, + "claude-test", + ); +} + +function writeTool(calls: unknown[]): Tool { + return { + name: "Write", + definition() { + return { + name: "Write", + description: "Write a file", + parameters: { + type: "object", + properties: { + file_path: { type: "string" }, + content: { type: "string" }, + }, + required: ["file_path", "content"], + }, + }; + }, + call(args) { + calls.push(args); + return "written"; + }, + }; +} + +function finalTextStream(): unknown[] { + return [ + { type: "message_start", message: { id: "msg_2" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "done" } }, + { + type: "message_stop", + message: { + id: "msg_2", + content: [{ type: "text", text: "done" }], + }, + }, + ]; +} + +async function* streamFrom(events: unknown[]): AsyncIterable { + for (const event of events) { + yield event; + } +} + +async function collect(events: AsyncIterable): Promise { + const result: T[] = []; + for await (const event of events) { + result.push(event); + } + return result; +} + +function accumulatedToolArguments(events: CompletionStreamEvent[], id: string): unknown { + const argumentsText = events + .flatMap((event) => + event.type === "tool_call_delta" && event.id === id && event.argumentsDelta !== undefined + ? [event.argumentsDelta] + : [], + ) + .join(""); + return argumentsText.length === 0 ? {} : JSON.parse(argumentsText); +} diff --git a/packages/providers/anthropic/test/client.test.ts b/packages/providers/anthropic/test/client.test.ts index e5f84336..7cc746d0 100644 --- a/packages/providers/anthropic/test/client.test.ts +++ b/packages/providers/anthropic/test/client.test.ts @@ -1,4 +1,4 @@ -import { Message } from "@anvia/core"; +import { Message } from "@anvia/core/completion"; import { describe, expect, it } from "vitest"; import { AnthropicClient, AnthropicCompletionModel } from "../src/index"; @@ -37,4 +37,41 @@ describe("Anthropic client", () => { }, ]); }); + + it("lists models from the Anthropic SDK", async () => { + const client = { + models: { + list: async () => + asyncIterable([ + { + id: "claude-sonnet-4-20250514", + display_name: "Claude Sonnet 4", + created_at: "2025-05-14T00:00:00Z", + max_input_tokens: 200_000, + type: "model", + }, + ]), + }, + }; + + const anthropic = new AnthropicClient({ client: client as never }); + + await expect(anthropic.listModels()).resolves.toEqual({ + data: [ + { + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + type: "model", + createdAt: 1_747_180_800, + contextLength: 200_000, + }, + ], + }); + }); }); + +async function* asyncIterable(items: unknown[]): AsyncIterable { + for (const item of items) { + yield item; + } +} diff --git a/packages/providers/anthropic/vitest.config.ts b/packages/providers/anthropic/vitest.config.ts index 2572a081..279809c5 100644 --- a/packages/providers/anthropic/vitest.config.ts +++ b/packages/providers/anthropic/vitest.config.ts @@ -3,8 +3,12 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { + "@anvia/core/agent": new URL("../../core/src/agent/index.ts", import.meta.url).pathname, "@anvia/core/completion": new URL("../../core/src/completion/index.ts", import.meta.url) .pathname, + "@anvia/core/model-listing": new URL("../../core/src/model-listing/index.ts", import.meta.url) + .pathname, + "@anvia/core/tool": new URL("../../core/src/tool/index.ts", import.meta.url).pathname, "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, }, }, diff --git a/packages/providers/gemini/CHANGELOG.md b/packages/providers/gemini/CHANGELOG.md new file mode 100644 index 00000000..a5ed4649 --- /dev/null +++ b/packages/providers/gemini/CHANGELOG.md @@ -0,0 +1,51 @@ +# @anvia/gemini + +## 0.2.1 + +### Patch Changes + +- c9728d4: Update upstream runtime dependencies to their latest compatible releases. + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.10 + +### Patch Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.9 + +### Patch Changes + +- 49e43a3: Update upstream runtime dependencies for Anthropic, Gemini, OpenAI, and Studio. + +## 0.1.8 + +### Patch Changes + +- 896ae21: Update upstream provider and runtime dependencies. + +## 0.1.7 + +### Patch Changes + +- 1ad360d: Fix Anthropic-compatible streaming tool inputs and update provider dependencies. diff --git a/packages/providers/gemini/package.json b/packages/providers/gemini/package.json index af84b5ed..5e15786c 100644 --- a/packages/providers/gemini/package.json +++ b/packages/providers/gemini/package.json @@ -1,13 +1,21 @@ { "name": "@anvia/gemini", - "version": "0.1.0", + "version": "0.2.1", "description": "Gemini provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anvia-hq/anvia", + "directory": "packages/providers/gemini" + }, "files": [ "dist" ], + "publishConfig": { + "access": "public" + }, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -23,8 +31,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@anvia/core": "workspace:*", - "@google/genai": "^1.51.0" + "@anvia/core": "^0.4.0", + "@google/genai": "^2.8.0" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/packages/providers/gemini/src/gemini/client.ts b/packages/providers/gemini/src/gemini/client.ts index 4c7d19a8..95d29a4e 100644 --- a/packages/providers/gemini/src/gemini/client.ts +++ b/packages/providers/gemini/src/gemini/client.ts @@ -1,3 +1,8 @@ +import { + type ModelList, + type ModelListingClient, + ModelListingError, +} from "@anvia/core/model-listing"; import { GoogleGenAI } from "@google/genai"; import { GeminiCompletionModel } from "./completion"; import { GeminiEmbeddingModel, type GeminiEmbeddingModelOptions } from "./embedding"; @@ -27,7 +32,7 @@ export type GeminiClientOptions = (GeminiApiClientOptions | VertexClientOptions) client?: GoogleGenAI | undefined; }; -export class GeminiClient { +export class GeminiClient implements ModelListingClient { readonly client: GoogleGenAI; constructor(options: GeminiClientOptions = {}) { @@ -56,6 +61,18 @@ export class GeminiClient { transcriptionModel(model = "gemini-2.5-flash"): GeminiTranscriptionModel { return new GeminiTranscriptionModel(this.client, model); } + + async listModels(): Promise { + try { + const response = await this.client.models.list({ config: { pageSize: 1000 } }); + const data = (await collectModelsFromResponse(response)) + .map(toListedModel) + .filter(isListedModel); + return { data }; + } catch (error) { + throw toModelListingError("Gemini", error); + } + } } export function toGoogleGenAIOptions(options: GeminiClientOptions): Record { @@ -79,3 +96,115 @@ function requireOption(value: string | undefined, name: string, label: string): return value; } + +async function collectModelsFromResponse(response: unknown): Promise { + if (isAsyncIterable(response)) { + const models: unknown[] = []; + for await (const model of response) { + models.push(model); + } + return models; + } + + if (Array.isArray(response)) { + return response; + } + + if (isObject(response) && Array.isArray(response.models)) { + return response.models; + } + + if (isObject(response) && Array.isArray(response.data)) { + return response.data; + } + + return []; +} + +function toListedModel(model: unknown): ModelList["data"][number] | undefined { + if (!isObject(model)) { + return undefined; + } + + const id = + stringValue(model.baseModelId) ?? + stringValue(model.base_model_id) ?? + normalizeGeminiModelId(stringValue(model.name)); + + if (id === undefined) { + return undefined; + } + + return { + id, + ...(typeof model.displayName === "string" ? { name: model.displayName } : {}), + ...(typeof model.display_name === "string" ? { name: model.display_name } : {}), + ...(typeof model.description === "string" ? { description: model.description } : {}), + ...(typeof model.type === "string" ? { type: model.type } : {}), + ...(typeof model.inputTokenLimit === "number" ? { contextLength: model.inputTokenLimit } : {}), + ...(typeof model.input_token_limit === "number" + ? { contextLength: model.input_token_limit } + : {}), + }; +} + +function normalizeGeminiModelId(name: string | undefined): string | undefined { + const trimmed = name?.trim().replace(/^models\//, ""); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} + +function stringValue(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +function isListedModel( + model: ModelList["data"][number] | undefined, +): model is ModelList["data"][number] { + return model !== undefined; +} + +function toModelListingError(provider: string, error: unknown): ModelListingError { + if (error instanceof ModelListingError) { + return error; + } + + const statusCode = getStatusCode(error); + return new ModelListingError(`${provider} model listing failed: ${getErrorMessage(error)}`, { + provider, + ...(statusCode === undefined ? {} : { statusCode }), + cause: error, + }); +} + +function getStatusCode(error: unknown): number | undefined { + if (!isObject(error)) { + return undefined; + } + + if (typeof error.status === "number") { + return error.status; + } + + if (typeof error.statusCode === "number") { + return error.statusCode; + } + + return undefined; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return isObject(value) && Symbol.asyncIterator in value; +} diff --git a/packages/providers/gemini/src/gemini/completion.ts b/packages/providers/gemini/src/gemini/completion.ts index 67bade05..54606f5f 100644 --- a/packages/providers/gemini/src/gemini/completion.ts +++ b/packages/providers/gemini/src/gemini/completion.ts @@ -6,6 +6,7 @@ import { type CompletionRequest, type CompletionResponse, type CompletionStreamEvent, + type JsonObject, type JsonValue, type Message as MessageType, type StreamingCompletionModel, @@ -43,6 +44,14 @@ export class GeminiCompletionModel implements StreamingCompletionModel { readonly defaultModel = "gemini-2.5-flash", ) {} + traceRequest( + request: CompletionRequest, + options: { stream?: boolean | undefined } = {}, + ): JsonObject { + const params = toGeminiGenerateContentParams(this.defaultModel, request); + return providerRequestSummary(params, request, options); + } + async completion(request: CompletionRequest): Promise { assertCompletionRequestSupported(this, request); const params = toGeminiGenerateContentParams(this.defaultModel, request); @@ -85,6 +94,51 @@ export function toGeminiGenerateContentParams( return params; } +function providerRequestSummary( + params: GeminiGenerateParams, + request: CompletionRequest, + options: { stream?: boolean | undefined }, +): JsonObject { + const config = isPlainObject(params.config) ? params.config : {}; + return compactJsonObject({ + provider: "gemini", + api: options.stream === true ? "models.generateContentStream" : "models.generateContent", + stream: options.stream === true, + model: typeof params.model === "string" ? params.model : undefined, + parameterKeys: Object.keys(params).sort(), + contentCount: Array.isArray(params.contents) ? params.contents.length : undefined, + configKeys: Object.keys(config).sort(), + toolCount: request.tools.length, + toolNames: request.tools.map((tool) => tool.name), + hasSystemInstruction: config.systemInstruction !== undefined, + hasOutputSchema: request.outputSchema !== undefined, + temperature: request.temperature, + maxTokens: request.maxTokens, + toolChoice: toolChoiceSummary(request.toolChoice), + additionalParamKeys: isPlainObject(request.additionalParams) + ? Object.keys(request.additionalParams).sort() + : undefined, + }); +} + +function toolChoiceSummary(toolChoice: ToolChoice | undefined): JsonValue | undefined { + if (toolChoice === undefined || typeof toolChoice === "string") { + return toolChoice; + } + return { type: toolChoice.type, name: toolChoice.name }; +} + +function compactJsonObject(values: Record): JsonObject { + return Object.fromEntries( + Object.entries(values).flatMap(([key, value]) => { + if (value === undefined) { + return []; + } + return [[key, toJsonValue(value)]]; + }), + ) as JsonObject; +} + function requestMessages(request: CompletionRequest): MessageType[] { return orderedRequestMessages(request); } @@ -322,7 +376,11 @@ function toolResultResponse( >, ): Record { return { - content: content.map((item) => (item.type === "text" ? item.text : item.data)).join("\n"), + content: content + .map((item) => + item.type === "text" ? item.text : `[image:${item.mediaType ?? "image/png"}]`, + ) + .join("\n"), }; } diff --git a/packages/providers/gemini/test/client.test.ts b/packages/providers/gemini/test/client.test.ts index 3dd096e9..a8b66359 100644 --- a/packages/providers/gemini/test/client.test.ts +++ b/packages/providers/gemini/test/client.test.ts @@ -33,6 +33,35 @@ describe("GeminiClient", () => { expect(client.completionModel()).toBeInstanceOf(GeminiCompletionModel); expect(client.embeddingModel()).toBeInstanceOf(GeminiEmbeddingModel); }); + + it("lists models from the Gemini SDK", async () => { + const client = new GeminiClient({ + client: { + models: { + list: async () => + asyncIterable([ + { + name: "models/gemini-2.5-flash", + displayName: "Gemini 2.5 Flash", + description: "Fast Gemini model.", + inputTokenLimit: 1_048_576, + }, + ]), + }, + } as never, + }); + + await expect(client.listModels()).resolves.toEqual({ + data: [ + { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + description: "Fast Gemini model.", + contextLength: 1_048_576, + }, + ], + }); + }); }); function fakeSdk() { @@ -44,3 +73,9 @@ function fakeSdk() { }, }; } + +async function* asyncIterable(items: unknown[]): AsyncIterable { + for (const item of items) { + yield item; + } +} diff --git a/packages/providers/gemini/test/completion.test.ts b/packages/providers/gemini/test/completion.test.ts index e3322214..dc6f2155 100644 --- a/packages/providers/gemini/test/completion.test.ts +++ b/packages/providers/gemini/test/completion.test.ts @@ -4,7 +4,7 @@ import { Message, ToolContent, UserContent, -} from "@anvia/core"; +} from "@anvia/core/completion"; import { describe, expect, it } from "vitest"; import { fromGeminiGenerateContentResponse, @@ -69,6 +69,30 @@ describe("Gemini completion mapping", () => { }); }); + it("summarizes provider request metadata for traces", () => { + const model = new GeminiCompletionModel({} as never, "gemini-test"); + const request: CompletionRequest = { + instructions: "Be concise.", + chatHistory: [Message.user("What is 2+5?")], + documents: [], + tools: [{ name: "add", description: "Add numbers", parameters: { type: "object" } }], + maxTokens: 128, + toolChoice: "auto", + }; + + expect(model.traceRequest(request, { stream: true })).toMatchObject({ + provider: "gemini", + api: "models.generateContentStream", + stream: true, + model: "gemini-test", + contentCount: 1, + toolCount: 1, + toolNames: ["add"], + hasSystemInstruction: true, + parameterKeys: expect.arrayContaining(["config", "contents", "model"]), + }); + }); + it("maps normalized requests to Gemini generateContent params", () => { const request: CompletionRequest = { instructions: "Use the support policy.", diff --git a/packages/providers/gemini/vitest.config.ts b/packages/providers/gemini/vitest.config.ts index d0455d5c..e56625c2 100644 --- a/packages/providers/gemini/vitest.config.ts +++ b/packages/providers/gemini/vitest.config.ts @@ -15,6 +15,8 @@ export default defineConfig({ "../../core/src/image-generation/index.ts", import.meta.url, ).pathname, + "@anvia/core/model-listing": new URL("../../core/src/model-listing/index.ts", import.meta.url) + .pathname, "@anvia/core/transcription": new URL("../../core/src/transcription/index.ts", import.meta.url) .pathname, "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, diff --git a/packages/providers/mistral/CHANGELOG.md b/packages/providers/mistral/CHANGELOG.md new file mode 100644 index 00000000..289b139b --- /dev/null +++ b/packages/providers/mistral/CHANGELOG.md @@ -0,0 +1,41 @@ +# @anvia/mistral + +## 0.2.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.1.6 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.1.5 + +### Patch Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.4 + +### Patch Changes + +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 diff --git a/packages/providers/mistral/README.md b/packages/providers/mistral/README.md index e6c145c2..03f6461e 100644 --- a/packages/providers/mistral/README.md +++ b/packages/providers/mistral/README.md @@ -1,6 +1,22 @@ # @anvia/mistral -Mistral provider adapter for Anvia. +Mistral completion and embedding provider adapter for Anvia. + +Use this package when you want Anvia agents, extractors, pipelines, embeddings, or model listing to run on Mistral APIs. + +## Installation + +```sh +pnpm add @anvia/mistral @anvia/core +``` + +In this monorepo, the package is available through the workspace: + +```sh +pnpm --filter @anvia/mistral build +``` + +## Usage ```ts import { AgentBuilder } from "@anvia/core"; @@ -24,6 +40,30 @@ const embeddings = client.embeddingModel("mistral-embed"); const vectors = await embeddings.embedTexts(["Refunds take five business days."]); ``` +## Model Listing + +```ts +const models = await client.listModels(); +``` + ## Capabilities -The v1 adapter supports text completions, streaming, tools, tool choice, structured output, and Mistral embeddings. Image inputs, document file inputs, transcription, audio generation, image generation, and model listing are not implemented yet. +The v1 adapter supports text completions, streaming, tools, tool choice, structured output, Mistral embeddings, and model listing. Image inputs, document file inputs, transcription, audio generation, and image generation are not implemented yet. + +## Exports + +- `MistralClient` +- `MistralCompletionModel` +- `MistralEmbeddingModel` +- `MistralClientOptions` +- `MistralEmbeddingModelOptions` +- `mistralMessageHelpers` +- `mistral` + +## Development + +```sh +pnpm --filter @anvia/mistral typecheck +pnpm --filter @anvia/mistral test +pnpm --filter @anvia/mistral build +``` diff --git a/packages/providers/mistral/package.json b/packages/providers/mistral/package.json index edb4bf8b..4b34de16 100644 --- a/packages/providers/mistral/package.json +++ b/packages/providers/mistral/package.json @@ -1,13 +1,21 @@ { "name": "@anvia/mistral", - "version": "0.1.0", + "version": "0.2.0", "description": "Mistral provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anvia-hq/anvia", + "directory": "packages/providers/mistral" + }, "files": [ "dist" ], + "publishConfig": { + "access": "public" + }, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -24,7 +32,7 @@ }, "dependencies": { "@anvia/core": "workspace:*", - "@mistralai/mistralai": "^2.2.1" + "@mistralai/mistralai": "^2.2.5" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/packages/providers/mistral/src/mistral/client.ts b/packages/providers/mistral/src/mistral/client.ts index 485abea4..a80e3878 100644 --- a/packages/providers/mistral/src/mistral/client.ts +++ b/packages/providers/mistral/src/mistral/client.ts @@ -1,3 +1,8 @@ +import { + type ModelList, + type ModelListingClient, + ModelListingError, +} from "@anvia/core/model-listing"; import { Mistral } from "@mistralai/mistralai"; import { MistralCompletionModel } from "./completion"; import { MistralEmbeddingModel, type MistralEmbeddingModelOptions } from "./embedding"; @@ -8,7 +13,7 @@ export type MistralClientOptions = { client?: Mistral | undefined; }; -export class MistralClient { +export class MistralClient implements ModelListingClient { readonly client: Mistral; constructor(options: MistralClientOptions = {}) { @@ -30,6 +35,16 @@ export class MistralClient { ): MistralEmbeddingModel { return new MistralEmbeddingModel(this.client, model, options); } + + async listModels(): Promise { + try { + const response = await this.client.models.list(); + const data = collectModelsFromResponse(response).map(toListedModel).filter(isListedModel); + return { data }; + } catch (error) { + throw toModelListingError("Mistral", error); + } + } } function requireApiKey(apiKey: string | undefined): string { @@ -39,3 +54,80 @@ function requireApiKey(apiKey: string | undefined): string { return apiKey; } + +function collectModelsFromResponse(response: unknown): unknown[] { + if (Array.isArray(response)) { + return response; + } + + if (isObject(response) && Array.isArray(response.data)) { + return response.data; + } + + return []; +} + +function toListedModel(model: unknown): ModelList["data"][number] | undefined { + if (!isObject(model) || typeof model.id !== "string") { + return undefined; + } + + return { + id: model.id, + ...(typeof model.name === "string" ? { name: model.name } : {}), + ...(typeof model.description === "string" ? { description: model.description } : {}), + ...(typeof model.type === "string" ? { type: model.type } : {}), + ...(typeof model.created === "number" ? { createdAt: model.created } : {}), + ...(typeof model.ownedBy === "string" ? { ownedBy: model.ownedBy } : {}), + ...(typeof model.owned_by === "string" ? { ownedBy: model.owned_by } : {}), + ...(typeof model.maxContextLength === "number" + ? { contextLength: model.maxContextLength } + : {}), + ...(typeof model.max_context_length === "number" + ? { contextLength: model.max_context_length } + : {}), + }; +} + +function isListedModel( + model: ModelList["data"][number] | undefined, +): model is ModelList["data"][number] { + return model !== undefined; +} + +function toModelListingError(provider: string, error: unknown): ModelListingError { + if (error instanceof ModelListingError) { + return error; + } + + const statusCode = getStatusCode(error); + return new ModelListingError(`${provider} model listing failed: ${getErrorMessage(error)}`, { + provider, + ...(statusCode === undefined ? {} : { statusCode }), + cause: error, + }); +} + +function getStatusCode(error: unknown): number | undefined { + if (!isObject(error)) { + return undefined; + } + + if (typeof error.status === "number") { + return error.status; + } + + if (typeof error.statusCode === "number") { + return error.statusCode; + } + + return undefined; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/providers/mistral/src/mistral/completion.ts b/packages/providers/mistral/src/mistral/completion.ts index 112bf8a9..22ea0004 100644 --- a/packages/providers/mistral/src/mistral/completion.ts +++ b/packages/providers/mistral/src/mistral/completion.ts @@ -7,6 +7,7 @@ import { type CompletionResponse, type CompletionStreamEvent, type DocumentContent, + type JsonObject, type JsonValue, type Message as MessageType, type StreamingCompletionModel, @@ -40,6 +41,14 @@ export class MistralCompletionModel implements StreamingCompletionModel { readonly defaultModel = "mistral-large-latest", ) {} + traceRequest( + request: CompletionRequest, + options: { stream?: boolean | undefined } = {}, + ): JsonObject { + const params = toMistralChatParams(this.defaultModel, request); + return providerRequestSummary(params, request, options); + } + async completion(request: CompletionRequest): Promise { assertCompletionRequestSupported(this, request); const params = toMistralChatParams(this.defaultModel, request); @@ -102,6 +111,48 @@ export function toMistralChatParams( return params; } +function providerRequestSummary( + params: MistralChatParams, + request: CompletionRequest, + options: { stream?: boolean | undefined }, +): JsonObject { + return compactJsonObject({ + provider: "mistral", + api: options.stream === true ? "chat.stream" : "chat.complete", + stream: options.stream === true, + model: stringFrom(params.model), + parameterKeys: Object.keys(params).sort(), + messageCount: Array.isArray(params.messages) ? params.messages.length : undefined, + toolCount: request.tools.length, + toolNames: request.tools.map((tool) => tool.name), + hasOutputSchema: request.outputSchema !== undefined, + temperature: request.temperature, + maxTokens: request.maxTokens, + toolChoice: toolChoiceSummary(request.toolChoice), + additionalParamKeys: isPlainObject(request.additionalParams) + ? Object.keys(request.additionalParams).sort() + : undefined, + }); +} + +function toolChoiceSummary(toolChoice: ToolChoice | undefined): JsonValue | undefined { + if (toolChoice === undefined || typeof toolChoice === "string") { + return toolChoice; + } + return { type: toolChoice.type, name: toolChoice.name }; +} + +function compactJsonObject(values: Record): JsonObject { + return Object.fromEntries( + Object.entries(values).flatMap(([key, value]) => { + if (value === undefined) { + return []; + } + return [[key, toJsonValue(value)]]; + }), + ) as JsonObject; +} + function requestMessages(request: CompletionRequest): MessageType[] { return orderedRequestMessages(request, { includeInstructionsAsSystem: true }); } @@ -266,7 +317,9 @@ function toolContentToMistralMessage(content: ToolContent): MistralChatMessage { toolCallId: content.callId ?? content.id, name: content.id, content: content.content - .map((item) => (item.type === "text" ? item.text : item.data)) + .map((item) => + item.type === "text" ? item.text : `[image:${item.mediaType ?? "image/png"}]`, + ) .join("\n"), }; } diff --git a/packages/providers/mistral/test/client.test.ts b/packages/providers/mistral/test/client.test.ts index b1e29868..6c77141b 100644 --- a/packages/providers/mistral/test/client.test.ts +++ b/packages/providers/mistral/test/client.test.ts @@ -14,6 +14,42 @@ describe("MistralClient", () => { expect(client.completionModel()).toBeInstanceOf(MistralCompletionModel); expect(client.embeddingModel()).toBeInstanceOf(MistralEmbeddingModel); }); + + it("lists models from the Mistral SDK", async () => { + const client = new MistralClient({ + client: { + models: { + list: async () => ({ + data: [ + { + id: "mistral-large-latest", + name: "Mistral Large", + description: "Large model.", + created: 1_700_000_000, + ownedBy: "mistralai", + maxContextLength: 128_000, + type: "base", + }, + ], + }), + }, + } as never, + }); + + await expect(client.listModels()).resolves.toEqual({ + data: [ + { + id: "mistral-large-latest", + name: "Mistral Large", + description: "Large model.", + type: "base", + createdAt: 1_700_000_000, + ownedBy: "mistralai", + contextLength: 128_000, + }, + ], + }); + }); }); function fakeSdk() { diff --git a/packages/providers/mistral/test/completion.test.ts b/packages/providers/mistral/test/completion.test.ts index 7b2077ec..d267e4db 100644 --- a/packages/providers/mistral/test/completion.test.ts +++ b/packages/providers/mistral/test/completion.test.ts @@ -5,7 +5,7 @@ import { ToolContent, Usage, UserContent, -} from "@anvia/core"; +} from "@anvia/core/completion"; import { describe, expect, it } from "vitest"; import { fromMistralChatResponse, @@ -83,6 +83,28 @@ describe("Mistral completion mapping", () => { expect(calls).toHaveLength(0); }); + it("summarizes provider request metadata for traces", () => { + const model = new MistralCompletionModel({} as never, "mistral-test"); + const request: CompletionRequest = { + chatHistory: [Message.user("What is 2+5?")], + documents: [], + tools: [{ name: "add", description: "Add numbers", parameters: { type: "object" } }], + maxTokens: 128, + toolChoice: "auto", + }; + + expect(model.traceRequest(request, { stream: true })).toMatchObject({ + provider: "mistral", + api: "chat.stream", + stream: true, + model: "mistral-test", + messageCount: 1, + toolCount: 1, + toolNames: ["add"], + parameterKeys: expect.arrayContaining(["messages", "model", "tools"]), + }); + }); + it("maps normalized requests to Mistral chat params", () => { const request: CompletionRequest = { instructions: "Use the support policy.", diff --git a/packages/providers/mistral/vitest.config.ts b/packages/providers/mistral/vitest.config.ts index c374c018..443710c4 100644 --- a/packages/providers/mistral/vitest.config.ts +++ b/packages/providers/mistral/vitest.config.ts @@ -7,6 +7,8 @@ export default defineConfig({ .pathname, "@anvia/core/embeddings": new URL("../../core/src/embeddings/index.ts", import.meta.url) .pathname, + "@anvia/core/model-listing": new URL("../../core/src/model-listing/index.ts", import.meta.url) + .pathname, "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, }, }, diff --git a/packages/providers/openai/CHANGELOG.md b/packages/providers/openai/CHANGELOG.md new file mode 100644 index 00000000..86c7cca0 --- /dev/null +++ b/packages/providers/openai/CHANGELOG.md @@ -0,0 +1,67 @@ +# @anvia/openai + +## 0.3.1 + +### Patch Changes + +- c9728d4: Update upstream runtime dependencies to their latest compatible releases. + +## 0.3.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.2.0 + +### Minor Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +### Patch Changes + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.1.11 + +### Patch Changes + +- 49e43a3: Update upstream runtime dependencies for Anthropic, Gemini, OpenAI, and Studio. + +## 0.1.10 + +### Patch Changes + +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 + +## 0.1.9 + +### Patch Changes + +- 1f7d3aa: Republish packages with registry-safe dependency metadata. + +## 0.1.8 + +### Patch Changes + +- 1ad360d: Fix Anthropic-compatible streaming tool inputs and update provider dependencies. diff --git a/packages/providers/openai/README.md b/packages/providers/openai/README.md index 94c23459..67833b88 100644 --- a/packages/providers/openai/README.md +++ b/packages/providers/openai/README.md @@ -54,6 +54,41 @@ const model = client.completionModel("openai/gpt-5.2"); You can also force a specific completion API with `completionApi: "responses"` or `completionApi: "chat"`. +### Reasoning tool-call providers + +Some OpenAI-compatible chat-completions providers return reasoning in provider-specific +fields while using normal tool calls. For example, Moonshot Kimi K2.6 returns +`reasoning_content` when thinking is enabled. + +The chat-completions adapter preserves this reasoning in assistant history and sends it +back as `reasoning_content` on later turns. This matters after tool calls: providers +such as Moonshot can reject the next request if an assistant `tool_calls` message is +missing its prior `reasoning_content`. + +For Moonshot Kimi K2.6 thinking mode: + +```ts +const client = new OpenAIClient({ + apiKey: process.env.OPENAI_API_KEY, + baseUrl: "https://api.moonshot.ai/v1", +}); + +const model = client.completionModel("kimi-k2.6"); + +const response = await model.completion({ + chatHistory, + documents: [], + tools, + maxTokens: 16_000, + additionalParams: { + thinking: { type: "enabled", keep: "all" }, + }, +}); +``` + +Provider caveat: Moonshot rejects forced/specified `tool_choice` while thinking is +enabled. Let the model choose tools naturally when using Kimi thinking mode. + ## Other Models ```ts diff --git a/packages/providers/openai/package.json b/packages/providers/openai/package.json index d9b8c689..9832863d 100644 --- a/packages/providers/openai/package.json +++ b/packages/providers/openai/package.json @@ -1,13 +1,21 @@ { "name": "@anvia/openai", - "version": "0.1.0", + "version": "0.3.1", "description": "OpenAI provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anvia-hq/anvia", + "directory": "packages/providers/openai" + }, "files": [ "dist" ], + "publishConfig": { + "access": "public" + }, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -24,7 +32,7 @@ }, "dependencies": { "@anvia/core": "workspace:*", - "openai": "^6.35.0" + "openai": "^6.42.0" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/packages/providers/openai/src/openai/chat-completion.ts b/packages/providers/openai/src/openai/chat-completion.ts index e08f9c5c..0faebe89 100644 --- a/packages/providers/openai/src/openai/chat-completion.ts +++ b/packages/providers/openai/src/openai/chat-completion.ts @@ -8,6 +8,8 @@ import { type CompletionStreamEvent, type DocumentContent, type ImageContent, + type JsonObject, + type JsonValue, type Message as MessageType, type StreamingCompletionModel, type ToolChoice, @@ -40,6 +42,19 @@ export class OpenAIChatCompletionModel implements StreamingCompletionModel { readonly defaultModel = "openai/gpt-5.2", ) {} + traceRequest( + request: CompletionRequest, + options: { stream?: boolean | undefined } = {}, + ): JsonObject { + const params: ChatCompletionParams = toOpenAIChatCompletionParams(this.defaultModel, request); + if (options.stream === true) { + params.stream = true; + const streamOptions = isPlainObject(params.stream_options) ? params.stream_options : {}; + params.stream_options = { ...streamOptions, include_usage: true }; + } + return providerRequestSummary(params, request, options); + } + async completion(request: CompletionRequest): Promise { assertCompletionRequestSupported(this, request); const params = toOpenAIChatCompletionParams(this.defaultModel, request); @@ -107,6 +122,66 @@ export function toOpenAIChatCompletionParams( return params; } +function providerRequestSummary( + params: ChatCompletionParams, + request: CompletionRequest, + options: { stream?: boolean | undefined }, +): JsonObject { + return compactJsonObject({ + provider: "openai-chat", + api: "chat.completions", + stream: options.stream === true, + model: stringFrom(params.model), + parameterKeys: Object.keys(params).sort(), + messageCount: Array.isArray(params.messages) ? params.messages.length : undefined, + toolCount: request.tools.length, + toolNames: request.tools.map((tool) => tool.name), + hasOutputSchema: request.outputSchema !== undefined, + temperature: request.temperature, + maxTokens: request.maxTokens, + toolChoice: toolChoiceSummary(request.toolChoice), + additionalParamKeys: isPlainObject(request.additionalParams) + ? Object.keys(request.additionalParams).sort() + : undefined, + }); +} + +function toolChoiceSummary(toolChoice: ToolChoice | undefined): JsonValue | undefined { + if (toolChoice === undefined || typeof toolChoice === "string") { + return toolChoice; + } + return { type: toolChoice.type, name: toolChoice.name }; +} + +function compactJsonObject(values: Record): JsonObject { + return Object.fromEntries( + Object.entries(values).flatMap(([key, value]) => { + if (value === undefined) { + return []; + } + return [[key, toJsonValue(value)]]; + }), + ) as JsonObject; +} + +function toJsonValue(value: unknown): JsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => toJsonValue(item)); + } + if (isPlainObject(value)) { + return compactJsonObject(value); + } + return String(value); +} + function requestMessages(request: CompletionRequest): MessageType[] { return orderedRequestMessages(request, { includeInstructionsAsSystem: true }); } @@ -122,6 +197,11 @@ export function fromOpenAIChatCompletionResponse(response: unknown): CompletionR choice.push(AssistantContent.text(message.content)); } + const reasoning = stringFrom(message.reasoning) ?? stringFrom(message.reasoning_content); + if (reasoning !== undefined && reasoning.length > 0) { + choice.push(AssistantContent.reasoning(reasoning)); + } + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; for (const toolCall of toolCalls) { if (!isPlainObject(toolCall)) { @@ -250,13 +330,17 @@ function messageToChatMessages(message: MessageType): ChatMessage[] { const text = message.content .flatMap((content) => (content.type === "text" ? [content.text] : [])) .join("\n"); + const reasoning = message.content + .flatMap((content) => (content.type === "reasoning" ? [content.text] : [])) + .filter((text) => text.length > 0) + .join("\n"); if (message.content.some((content) => content.type === "image")) { throw new Error("OpenAI chat completions does not support image content in assistant history"); } const toolCalls = message.content .filter((content) => content.type === "tool_call") .map((content) => ({ - id: content.id, + id: content.callId ?? content.id, type: "function", function: { name: content.function.name, @@ -270,6 +354,9 @@ function messageToChatMessages(message: MessageType): ChatMessage[] { if (text.length > 0) { chatMessage.content = text; } + if (reasoning.length > 0) { + chatMessage.reasoning_content = reasoning; + } if (toolCalls.length > 0) { chatMessage.tool_calls = toolCalls; } @@ -282,7 +369,9 @@ function toolContentToChatMessage(content: ToolContent): ChatMessage { role: "tool", tool_call_id: content.callId ?? content.id, content: content.content - .map((item) => (item.type === "text" ? item.text : item.data)) + .map((item) => + item.type === "text" ? item.text : `[image:${item.mediaType ?? "image/png"}]`, + ) .join("\n"), }; } diff --git a/packages/providers/openai/src/openai/client.ts b/packages/providers/openai/src/openai/client.ts index 841e7ff8..99d0b38b 100644 --- a/packages/providers/openai/src/openai/client.ts +++ b/packages/providers/openai/src/openai/client.ts @@ -1,4 +1,9 @@ import type { StreamingCompletionModel } from "@anvia/core/completion"; +import { + type ModelList, + type ModelListingClient, + ModelListingError, +} from "@anvia/core/model-listing"; import OpenAI from "openai"; import { OpenAIAudioGenerationModel, TTS_1 } from "./audio-generation"; import { OpenAIChatCompletionModel } from "./chat-completion"; @@ -15,7 +20,7 @@ export type OpenAIClientOptions = { client?: OpenAI | undefined; }; -export class OpenAIClient { +export class OpenAIClient implements ModelListingClient { readonly client: OpenAI; private readonly completionApi: "responses" | "chat"; @@ -55,6 +60,18 @@ export class OpenAIClient { transcriptionModel(model = WHISPER_1): OpenAITranscriptionModel { return new OpenAITranscriptionModel(this.client, model); } + + async listModels(): Promise { + try { + const response = await this.client.models.list(); + const data = (await collectModelsFromResponse(response)) + .map(toListedModel) + .filter(isListedModel); + return { data }; + } catch (error) { + throw toModelListingError("OpenAI", error); + } + } } function requireApiKey(apiKey: string | undefined): string { @@ -64,3 +81,92 @@ function requireApiKey(apiKey: string | undefined): string { return apiKey; } + +async function collectModelsFromResponse(response: unknown): Promise { + if (isAsyncIterable(response)) { + const models: unknown[] = []; + for await (const model of response) { + models.push(model); + } + return models; + } + + if (Array.isArray(response)) { + return response; + } + + if (isObject(response) && Array.isArray(response.data)) { + return response.data; + } + + return []; +} + +function toListedModel(model: unknown): ModelList["data"][number] | undefined { + if (!isObject(model) || typeof model.id !== "string") { + return undefined; + } + + return { + id: model.id, + ...(typeof model.name === "string" ? { name: model.name } : {}), + ...(typeof model.description === "string" ? { description: model.description } : {}), + ...(typeof model.type === "string" + ? { type: model.type } + : typeof model.object === "string" + ? { type: model.object } + : {}), + ...(typeof model.created === "number" ? { createdAt: model.created } : {}), + ...(typeof model.created_at === "number" ? { createdAt: model.created_at } : {}), + ...(typeof model.owned_by === "string" ? { ownedBy: model.owned_by } : {}), + ...(typeof model.context_length === "number" ? { contextLength: model.context_length } : {}), + ...(typeof model.contextLength === "number" ? { contextLength: model.contextLength } : {}), + }; +} + +function isListedModel( + model: ModelList["data"][number] | undefined, +): model is ModelList["data"][number] { + return model !== undefined; +} + +function toModelListingError(provider: string, error: unknown): ModelListingError { + if (error instanceof ModelListingError) { + return error; + } + + const statusCode = getStatusCode(error); + return new ModelListingError(`${provider} model listing failed: ${getErrorMessage(error)}`, { + provider, + ...(statusCode === undefined ? {} : { statusCode }), + cause: error, + }); +} + +function getStatusCode(error: unknown): number | undefined { + if (!isObject(error)) { + return undefined; + } + + if (typeof error.status === "number") { + return error.status; + } + + if (typeof error.statusCode === "number") { + return error.statusCode; + } + + return undefined; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return isObject(value) && Symbol.asyncIterator in value; +} diff --git a/packages/providers/openai/src/openai/responses.ts b/packages/providers/openai/src/openai/responses.ts index 7990089b..d2e978a5 100644 --- a/packages/providers/openai/src/openai/responses.ts +++ b/packages/providers/openai/src/openai/responses.ts @@ -8,6 +8,8 @@ import { type CompletionStreamEvent, type DocumentContent, type ImageContent, + type JsonObject, + type JsonValue, type Message as MessageType, type Reasoning, type ReasoningContent, @@ -15,6 +17,7 @@ import { type ToolChoice, type ToolContent, type ToolDefinition, + type ToolResultContent, Usage, type UserContent, } from "@anvia/core/completion"; @@ -42,6 +45,17 @@ export class OpenAIResponsesCompletionModel implements StreamingCompletionModel readonly defaultModel = "gpt-5", ) {} + traceRequest( + request: CompletionRequest, + options: { stream?: boolean | undefined } = {}, + ): JsonObject { + const params = toOpenAIResponsesParams(this.defaultModel, request); + if (options.stream === true) { + params.stream = true; + } + return providerRequestSummary(params, request, options); + } + async completion(request: CompletionRequest): Promise { assertCompletionRequestSupported(this, request); const params = toOpenAIResponsesParams(this.defaultModel, request); @@ -109,6 +123,67 @@ export function toOpenAIResponsesParams( return params; } +function providerRequestSummary( + params: ResponsesCreateParams, + request: CompletionRequest, + options: { stream?: boolean | undefined }, +): JsonObject { + return compactJsonObject({ + provider: "openai", + api: "responses", + stream: options.stream === true, + model: stringFrom(params.model), + parameterKeys: Object.keys(params).sort(), + inputCount: Array.isArray(params.input) ? params.input.length : undefined, + toolCount: request.tools.length, + toolNames: request.tools.map((tool) => tool.name), + hasInstructions: typeof params.instructions === "string" && params.instructions.length > 0, + hasOutputSchema: request.outputSchema !== undefined, + temperature: request.temperature, + maxTokens: request.maxTokens, + toolChoice: toolChoiceSummary(request.toolChoice), + additionalParamKeys: isPlainObject(request.additionalParams) + ? Object.keys(request.additionalParams).sort() + : undefined, + }); +} + +function toolChoiceSummary(toolChoice: ToolChoice | undefined): JsonValue | undefined { + if (toolChoice === undefined || typeof toolChoice === "string") { + return toolChoice; + } + return { type: toolChoice.type, name: toolChoice.name }; +} + +function compactJsonObject(values: Record): JsonObject { + return Object.fromEntries( + Object.entries(values).flatMap(([key, value]) => { + if (value === undefined) { + return []; + } + return [[key, toJsonValue(value)]]; + }), + ) as JsonObject; +} + +function toJsonValue(value: unknown): JsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => toJsonValue(item)); + } + if (isPlainObject(value)) { + return compactJsonObject(value); + } + return String(value); +} + function requestMessages(request: CompletionRequest): MessageType[] { return orderedRequestMessages(request); } @@ -322,12 +397,29 @@ function toolContentToOpenAIResponsesItem(content: ToolContent): ResponsesInputI return { type: "function_call_output", call_id: content.callId ?? content.id, - output: content.content - .map((item) => (item.type === "text" ? item.text : item.data)) - .join("\n"), + output: toolResultContentToOpenAIResponsesOutput(content.content), }; } +function toolResultContentToOpenAIResponsesOutput( + content: ToolResultContent[], +): string | ResponsesInputItem[] { + if (content.every((item) => item.type === "text")) { + return content.map((item) => item.text).join("\n"); + } + + return content.map((item) => { + if (item.type === "text") { + return { type: "input_text", text: item.text }; + } + return { + type: "input_image", + image_url: `data:${item.mediaType ?? "image/png"};base64,${item.data}`, + detail: "auto", + }; + }); +} + function reasoningItemToAssistantContent(item: Record): Reasoning { const content = reasoningContentFromOpenAIItem(item); const id = stringFrom(item.id); diff --git a/packages/providers/openai/test/client.test.ts b/packages/providers/openai/test/client.test.ts new file mode 100644 index 00000000..8386aec8 --- /dev/null +++ b/packages/providers/openai/test/client.test.ts @@ -0,0 +1,63 @@ +import type { ModelListingError } from "@anvia/core/model-listing"; +import { describe, expect, it } from "vitest"; +import { OpenAIClient } from "../src/index"; + +describe("OpenAIClient", () => { + it("lists OpenAI and compatible gateway models", async () => { + const client = new OpenAIClient({ + client: { + models: { + list: async () => ({ + data: [ + { + id: "gpt-5", + object: "model", + created: 1_700_000_000, + owned_by: "openai", + }, + { + id: "anthropic/claude-opus", + name: "Claude Opus", + context_length: 200_000, + }, + ], + }), + }, + } as never, + }); + + await expect(client.listModels()).resolves.toEqual({ + data: [ + { + id: "gpt-5", + type: "model", + createdAt: 1_700_000_000, + ownedBy: "openai", + }, + { + id: "anthropic/claude-opus", + name: "Claude Opus", + contextLength: 200_000, + }, + ], + }); + }); + + it("wraps model listing failures", async () => { + const client = new OpenAIClient({ + client: { + models: { + list: async () => { + throw Object.assign(new Error("unauthorized"), { status: 401 }); + }, + }, + } as never, + }); + + await expect(client.listModels()).rejects.toMatchObject({ + name: "ModelListingError", + provider: "OpenAI", + statusCode: 401, + } satisfies Partial); + }); +}); diff --git a/packages/providers/openai/test/openai-chat-completion.test.ts b/packages/providers/openai/test/openai-chat-completion.test.ts index f25a465c..36457b45 100644 --- a/packages/providers/openai/test/openai-chat-completion.test.ts +++ b/packages/providers/openai/test/openai-chat-completion.test.ts @@ -1,6 +1,16 @@ -import { Message, UserContent } from "@anvia/core"; +import { + AssistantContent, + type CompletionRequest, + Message, + ToolContent, + UserContent, +} from "@anvia/core/completion"; import { describe, expect, it } from "vitest"; import { OpenAIChatCompletionModel, OpenAIClient } from "../src/index"; +import { + fromOpenAIChatCompletionResponse, + toOpenAIChatCompletionParams, +} from "../src/openai/chat-completion"; describe("OpenAI chat-completions client path", () => { it("exposes OpenAI chat-completions capability metadata", () => { @@ -64,6 +74,79 @@ describe("OpenAI chat-completions client path", () => { expect(openai.completionModel("custom-chat-model")).toBeInstanceOf(OpenAIChatCompletionModel); }); + it("preserves assistant reasoning and provider tool call ids across tool turns", () => { + const params = toOpenAIChatCompletionParams("kimi-k2.6", { + chatHistory: [ + Message.assistant([ + AssistantContent.reasoning("provider reasoning text"), + AssistantContent.toolCall("tool_0", "create_task", { title: "A" }, "call_abc"), + ]), + Message.tool(ToolContent.toolResult("tool_0", '{"id":"task_1"}', "call_abc")), + Message.user("continue"), + ], + documents: [], + tools: [], + }); + + expect(params.messages).toEqual([ + { + role: "assistant", + reasoning_content: "provider reasoning text", + tool_calls: [ + { + id: "call_abc", + type: "function", + function: { name: "create_task", arguments: '{"title":"A"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_abc", content: '{"id":"task_1"}' }, + { role: "user", content: "continue" }, + ]); + }); + + it("summarizes provider request metadata for traces", () => { + const model = new OpenAIChatCompletionModel({} as never, "chat-test"); + const request: CompletionRequest = { + chatHistory: [Message.user("What is 2+5?")], + documents: [], + tools: [{ name: "add", description: "Add numbers", parameters: { type: "object" } }], + maxTokens: 64, + toolChoice: { type: "function", name: "add" }, + }; + + expect(model.traceRequest(request, { stream: true })).toMatchObject({ + provider: "openai-chat", + api: "chat.completions", + stream: true, + model: "chat-test", + messageCount: 1, + toolCount: 1, + toolNames: ["add"], + parameterKeys: expect.arrayContaining(["messages", "model", "stream", "stream_options"]), + }); + }); + + it("maps non-streaming reasoning_content responses to assistant reasoning", () => { + const response = fromOpenAIChatCompletionResponse({ + choices: [ + { + message: { + role: "assistant", + content: "created", + reasoning_content: "provider reasoning text", + }, + }, + ], + usage: {}, + }); + + expect(response.choice).toEqual([ + AssistantContent.text("created"), + AssistantContent.reasoning("provider reasoning text"), + ]); + }); + it("rejects unsupported document file input before provider calls", async () => { const calls: unknown[] = []; const model = new OpenAIChatCompletionModel( diff --git a/packages/providers/openai/test/openai-responses.test.ts b/packages/providers/openai/test/openai-responses.test.ts index 6b816be4..72aca753 100644 --- a/packages/providers/openai/test/openai-responses.test.ts +++ b/packages/providers/openai/test/openai-responses.test.ts @@ -1,4 +1,11 @@ -import { AssistantContent, type CompletionRequest, Message, Usage, UserContent } from "@anvia/core"; +import { + AssistantContent, + type CompletionRequest, + Message, + ToolContent, + Usage, + UserContent, +} from "@anvia/core/completion"; import { describe, expect, it } from "vitest"; import { OpenAIResponsesCompletionModel } from "../src/index"; import { @@ -70,6 +77,63 @@ describe("OpenAI Responses mapping", () => { }); }); + it("maps multimodal tool outputs to Responses API output content", () => { + const params = toOpenAIResponsesParams("gpt-5", { + chatHistory: [ + Message.assistant([AssistantContent.toolCall("call_1", "computer_screenshot", {}, "fc_1")]), + Message.tool( + ToolContent.toolResult( + "call_1", + [ + { type: "text", text: '{"coordMap":"0,0,100,100,100,100"}' }, + { type: "image", data: "base64-png", mediaType: "image/png" }, + ], + "fc_1", + ), + ), + ], + documents: [], + tools: [], + }); + + expect(params.input).toContainEqual({ + type: "function_call_output", + call_id: "fc_1", + output: [ + { type: "input_text", text: '{"coordMap":"0,0,100,100,100,100"}' }, + { + type: "input_image", + image_url: "data:image/png;base64,base64-png", + detail: "auto", + }, + ], + }); + }); + + it("summarizes provider request metadata for traces", () => { + const model = new OpenAIResponsesCompletionModel({} as never, "gpt-test"); + const request: CompletionRequest = { + instructions: "Be concise.", + chatHistory: [Message.user("What is 2+5?")], + documents: [], + tools: [{ name: "add", description: "Add numbers", parameters: { type: "object" } }], + temperature: 0.2, + maxTokens: 128, + toolChoice: "auto", + }; + + expect(model.traceRequest(request, { stream: true })).toMatchObject({ + provider: "openai", + api: "responses", + stream: true, + model: "gpt-test", + inputCount: 1, + toolCount: 1, + toolNames: ["add"], + parameterKeys: expect.arrayContaining(["input", "model", "stream", "tools"]), + }); + }); + it("prepends normalized static context before chat history", () => { const request: CompletionRequest = { chatHistory: [Message.system("Use context."), Message.user("What is the owner?")], diff --git a/packages/providers/openai/vitest.config.ts b/packages/providers/openai/vitest.config.ts index d0455d5c..e56625c2 100644 --- a/packages/providers/openai/vitest.config.ts +++ b/packages/providers/openai/vitest.config.ts @@ -15,6 +15,8 @@ export default defineConfig({ "../../core/src/image-generation/index.ts", import.meta.url, ).pathname, + "@anvia/core/model-listing": new URL("../../core/src/model-listing/index.ts", import.meta.url) + .pathname, "@anvia/core/transcription": new URL("../../core/src/transcription/index.ts", import.meta.url) .pathname, "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md new file mode 100644 index 00000000..56e227ee --- /dev/null +++ b/packages/react/CHANGELOG.md @@ -0,0 +1,13 @@ +# @anvia/react + +## 0.3.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +## 0.2.0 + +### Minor Changes + +- eb90638: Add server stream response helpers and React client transports for JSONL and Server-Sent Event agent streams. diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 00000000..71684569 --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,41 @@ +# @anvia/react + +React hooks and client transports for Anvia applications. + +```tsx +import { useChat } from "@anvia/react"; + +export function Chat() { + const chat = useChat({ endpoint: "/api/chat" }); + + return ( +
    { + event.preventDefault(); + void chat.send(); + }} + > +
    {chat.text}
    + chat.setInput(event.target.value)} /> + +
    + ); +} +``` + +## Exports + +- `readJsonlStream(stream)` parses newline-delimited JSON streams. +- `readSseStream(stream)` parses Server-Sent Events with JSON `data:` payloads. +- `fetchEventStream(url, options)` fetches JSONL or SSE streams as `AsyncIterable`. +- `createFetchTransport(options)` creates an `EventTransport`. +- `createChatTransport(options)` creates the default fetch-backed chat transport. +- `useChat(options)` manages React chat state from any `EventTransport`. + +The shared boundary is: + +```ts +type EventTransport = { + send(request: TRequest, options?: TransportOptions): AsyncIterable; +}; +``` diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 00000000..78701628 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,44 @@ +{ + "name": "@anvia/react", + "version": "0.3.0", + "description": "React hooks and client transports for Anvia applications.", + "author": "anvia", + "maintainer": "Indra Zulfi", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anvia-hq/anvia", + "directory": "packages/react" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup src/index.ts --format esm --dts --sourcemap --clean --external react", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": ">=18" + }, + "devDependencies": { + "@types/node": "^24.9.1", + "@types/react": "^19.2.14", + "react": "^19.2.6", + "tsup": "^8.5.0", + "typescript": "^5.9.3", + "vitest": "^4.0.8" + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 00000000..0440aa0e --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,533 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +export type EventStreamFormat = "jsonl" | "sse"; + +export type TransportOptions = { + signal?: AbortSignal; + headers?: HeadersInit; +}; + +export type EventTransport = { + send(request: TRequest, options?: TransportOptions): AsyncIterable; +}; + +export type FetchEventStreamOptions = Omit & { + format?: EventStreamFormat; + fetch?: typeof fetch; + headers?: HeadersInit; +}; + +export type CreateFetchTransportOptions = { + endpoint: string | URL | ((request: TRequest) => string | URL); + method?: string; + format?: EventStreamFormat; + fetch?: typeof fetch; + headers?: HeadersInit | ((request: TRequest) => HeadersInit | Promise); + body?: (request: TRequest) => BodyInit | null | undefined | Promise; + init?: Omit; + mapEvent?: (event: unknown) => TEvent; +}; + +export type ChatRole = "system" | "user" | "assistant" | "tool"; + +export type ChatMessage = { + id: string; + role: ChatRole; + content: string; + metadata?: unknown; +}; + +export type DefaultChatRequest = { + message: string; + history: ChatMessage[]; + stream: true; +}; + +export type UseChatStatus = "idle" | "streaming" | "error"; + +export type UseChatOptions< + TRequest = DefaultChatRequest, + TEvent = unknown, + TMessage extends ChatMessage = ChatMessage, +> = { + transport?: EventTransport; + endpoint?: string | URL; + format?: EventStreamFormat; + initialMessages?: TMessage[]; + createRequest?: (input: string, messages: TMessage[]) => TRequest; + eventToDelta?: (event: TEvent) => string | undefined; + eventToFinal?: (event: TEvent) => string | undefined; + onEvent?: (event: TEvent) => void; + onError?: (error: unknown) => void; +}; + +export type UseChatResult = { + messages: TMessage[]; + events: TEvent[]; + input: string; + setInput(input: string): void; + send(input?: string): Promise; + stop(): void; + reset(messages?: TMessage[]): void; + status: UseChatStatus; + error: unknown; + text: string; +}; + +export class EventStreamHttpError extends Error { + constructor( + readonly response: Response, + readonly body: string, + ) { + super(`Event stream request failed with status ${response.status}`); + this.name = "EventStreamHttpError"; + } +} + +export async function* readJsonlStream( + stream: ReadableStream, +): AsyncIterable { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const next = await reader.read(); + if (next.done === true) { + break; + } + + buffer += decoder.decode(next.value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length > 0) { + yield JSON.parse(trimmed) as TEvent; + } + } + } + + buffer += decoder.decode(); + const trimmed = buffer.trim(); + if (trimmed.length > 0) { + yield JSON.parse(trimmed) as TEvent; + } + } finally { + reader.releaseLock(); + } +} + +export async function* readSseStream( + stream: ReadableStream, +): AsyncIterable { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let event = createEmptySseEvent(); + + try { + while (true) { + const next = await reader.read(); + if (next.done === true) { + break; + } + + buffer += decoder.decode(next.value, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const parsed = parseSseLine(line, event); + event = parsed.event; + if (parsed.complete === true && parsed.data !== undefined) { + yield JSON.parse(parsed.data) as TEvent; + } + } + } + + buffer += decoder.decode(); + if (buffer.length > 0) { + const parsed = parseSseLine(buffer, event); + event = parsed.event; + if (parsed.complete === true && parsed.data !== undefined) { + yield JSON.parse(parsed.data) as TEvent; + } + } + const data = flushSseEvent(event); + if (data !== undefined) { + yield JSON.parse(data) as TEvent; + } + } finally { + reader.releaseLock(); + } +} + +export async function* fetchEventStream( + input: string | URL | Request, + options: FetchEventStreamOptions = {}, +): AsyncIterable { + const fetchImpl = options.fetch ?? globalThis.fetch; + if (fetchImpl === undefined) { + throw new Error("fetchEventStream requires a fetch implementation"); + } + + const response = await fetchImpl(input, fetchOptions(options)); + if (!response.ok) { + throw new EventStreamHttpError(response, await response.text()); + } + if (response.body === null) { + throw new Error("Event stream response does not include a body"); + } + + const format = options.format ?? inferEventStreamFormat(response.headers.get("content-type")); + if (format === "sse") { + yield* readSseStream(response.body); + return; + } + + yield* readJsonlStream(response.body); +} + +export function createFetchTransport( + options: CreateFetchTransportOptions, +): EventTransport { + return { + async *send(request, transportOptions = {}) { + const endpoint = + typeof options.endpoint === "function" ? options.endpoint(request) : options.endpoint; + const requestHeaders = await resolveHeaders(options.headers, request); + const headers = mergeHeaders(requestHeaders, transportOptions.headers); + const method = options.method ?? "POST"; + const body = await resolveBody(options.body, request, headers); + const init: FetchEventStreamOptions = { + ...(options.init ?? {}), + method, + headers, + format: options.format ?? "jsonl", + }; + + if (body !== undefined) { + init.body = body; + } + if (transportOptions.signal !== undefined) { + init.signal = transportOptions.signal; + } + if (options.fetch !== undefined) { + init.fetch = options.fetch; + } + + for await (const event of fetchEventStream(endpoint, init)) { + yield options.mapEvent === undefined ? (event as TEvent) : options.mapEvent(event); + } + }, + }; +} + +export function createChatTransport( + options: CreateFetchTransportOptions, +): EventTransport { + return createFetchTransport(options); +} + +export function useChat< + TRequest = DefaultChatRequest, + TEvent = unknown, + TMessage extends ChatMessage = ChatMessage, +>(options: UseChatOptions = {}): UseChatResult { + const [messages, setMessages] = useState(() => [...(options.initialMessages ?? [])]); + const [events, setEvents] = useState([]); + const [input, setInput] = useState(""); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(); + const abortRef = useRef(undefined); + const messagesRef = useRef(messages); + + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + const transport = useMemo(() => { + if (options.transport !== undefined) { + return options.transport; + } + if (options.endpoint === undefined) { + return undefined; + } + + return createChatTransport({ + endpoint: options.endpoint, + format: options.format ?? "jsonl", + }); + }, [options.transport, options.endpoint, options.format]); + + const createRequest = options.createRequest ?? defaultCreateRequest; + const eventToDelta = options.eventToDelta ?? defaultEventToDelta; + const eventToFinal = options.eventToFinal ?? defaultEventToFinal; + + const appendAssistantText = useCallback((assistantId: string, text: string) => { + setMessages((current) => + current.map((message) => + message.id === assistantId + ? ({ ...message, content: `${message.content}${text}` } as TMessage) + : message, + ), + ); + }, []); + + const replaceAssistantText = useCallback((assistantId: string, text: string) => { + setMessages((current) => + current.map((message) => + message.id === assistantId ? ({ ...message, content: text } as TMessage) : message, + ), + ); + }, []); + + const send = useCallback( + async (nextInput?: string) => { + if (transport === undefined) { + throw new Error("useChat requires either transport or endpoint"); + } + + const content = nextInput ?? input; + if (content.trim().length === 0) { + return; + } + + abortRef.current?.abort(); + const abortController = new AbortController(); + abortRef.current = abortController; + + const userMessage = createMessage("user", content); + const assistantMessage = createMessage("assistant", ""); + const requestMessages = [...messagesRef.current, userMessage]; + const request = createRequest(content, requestMessages); + + setInput(""); + setError(undefined); + setStatus("streaming"); + setEvents([]); + setMessages([...requestMessages, assistantMessage]); + + try { + for await (const event of transport.send(request, { signal: abortController.signal })) { + setEvents((current) => [...current, event]); + options.onEvent?.(event); + + const delta = eventToDelta(event); + if (delta !== undefined && delta.length > 0) { + appendAssistantText(assistantMessage.id, delta); + } + + const final = eventToFinal(event); + if (final !== undefined) { + replaceAssistantText(assistantMessage.id, final); + } + } + + if (!abortController.signal.aborted) { + setStatus("idle"); + } + } catch (caught) { + if (isAbortError(caught)) { + setStatus("idle"); + return; + } + + setError(caught); + setStatus("error"); + options.onError?.(caught); + } finally { + if (abortRef.current === abortController) { + abortRef.current = undefined; + } + } + }, + [ + appendAssistantText, + createRequest, + eventToDelta, + eventToFinal, + input, + options, + replaceAssistantText, + transport, + ], + ); + + const stop = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = undefined; + setStatus("idle"); + }, []); + + const reset = useCallback((nextMessages?: TMessage[]) => { + const resetMessages = nextMessages ?? []; + messagesRef.current = resetMessages; + abortRef.current?.abort(); + abortRef.current = undefined; + setMessages(resetMessages); + setEvents([]); + setError(undefined); + setInput(""); + setStatus("idle"); + }, []); + + const text = messages + .filter((message) => message.role === "assistant") + .map((message) => message.content) + .join(""); + + return { + messages, + events, + input, + setInput, + send, + stop, + reset, + status, + error, + text, + }; +} + +function fetchOptions(options: FetchEventStreamOptions): RequestInit { + const { format: _format, fetch: _fetch, ...init } = options; + return init; +} + +function inferEventStreamFormat(contentType: string | null): EventStreamFormat { + return contentType?.toLowerCase().includes("text/event-stream") ? "sse" : "jsonl"; +} + +function createEmptySseEvent(): { data: string[] } { + return { data: [] }; +} + +function parseSseLine( + line: string, + event: { data: string[] }, +): { event: { data: string[] }; complete?: true; data?: string } { + if (line === "") { + const data = flushSseEvent(event); + return data === undefined + ? { event: createEmptySseEvent(), complete: true } + : { event: createEmptySseEvent(), complete: true, data }; + } + + if (line.startsWith(":")) { + return { event }; + } + + const separator = line.indexOf(":"); + const field = separator === -1 ? line : line.slice(0, separator); + const value = separator === -1 ? "" : line.slice(separator + 1).replace(/^ /, ""); + + if (field === "data") { + event.data.push(value); + } + + return { event }; +} + +function flushSseEvent(event: { data: string[] }): string | undefined { + if (event.data.length === 0) { + return undefined; + } + + return event.data.join("\n"); +} + +async function resolveHeaders( + headers: CreateFetchTransportOptions["headers"], + request: TRequest, +): Promise { + return typeof headers === "function" ? headers(request) : headers; +} + +async function resolveBody( + body: CreateFetchTransportOptions["body"], + request: TRequest, + headers: Headers, +): Promise { + if (body !== undefined) { + return body(request); + } + + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + + return JSON.stringify(request); +} + +function mergeHeaders(...values: (HeadersInit | undefined)[]): Headers { + const headers = new Headers(); + + for (const value of values) { + if (value === undefined) { + continue; + } + new Headers(value).forEach((headerValue, key) => { + headers.set(key, headerValue); + }); + } + + return headers; +} + +function defaultCreateRequest( + input: string, + messages: TMessage[], +): TRequest { + return { + message: input, + history: messages.slice(0, -1), + stream: true, + } as TRequest; +} + +function createMessage(role: ChatRole, content: string): TMessage { + return { + id: createId(), + role, + content, + } as TMessage; +} + +function createId(): string { + return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2); +} + +function defaultEventToDelta(event: TEvent): string | undefined { + if (!isRecord(event)) { + return undefined; + } + + return event.type === "text_delta" && typeof event.delta === "string" ? event.delta : undefined; +} + +function defaultEventToFinal(event: TEvent): string | undefined { + if (!isRecord(event)) { + return undefined; + } + + return event.type === "final" && typeof event.output === "string" ? event.output : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} diff --git a/packages/react/test/transport.test.ts b/packages/react/test/transport.test.ts new file mode 100644 index 00000000..be864bdb --- /dev/null +++ b/packages/react/test/transport.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { + createChatTransport, + createFetchTransport, + fetchEventStream, + readJsonlStream, + readSseStream, +} from "../src"; + +describe("@anvia/react transports", () => { + it("reads jsonl streams", async () => { + const parsed = await collect<{ type: string }>( + readJsonlStream(streamFrom('{"type":"one"}\n{"type":"two"}\n')), + ); + + expect(parsed).toEqual([{ type: "one" }, { type: "two" }]); + }); + + it("reads server-sent event streams", async () => { + const parsed = await collect<{ type: string; text: string }>( + readSseStream(streamFrom('event: text\ndata: {"type":"text_delta","text":"hello"}\n\n')), + ); + + expect(parsed).toEqual([{ type: "text_delta", text: "hello" }]); + }); + + it("fetches event streams as async iterables", async () => { + const parsed = await collect<{ type: string }>( + fetchEventStream("https://example.test/events", { + fetch: async () => + new Response(streamFrom('{"type":"one"}\n'), { + headers: { "content-type": "application/x-ndjson" }, + }), + }), + ); + + expect(parsed).toEqual([{ type: "one" }]); + }); + + it("creates fetch transports", async () => { + const transport = createFetchTransport<{ message: string }, { type: string }>({ + endpoint: "https://example.test/chat", + fetch: async (_input, init) => + new Response(streamFrom(`${JSON.stringify({ type: "body", body: init?.body })}\n`)), + }); + + const parsed = await collect(transport.send({ message: "hi" })); + + expect(parsed).toEqual([{ type: "body", body: '{"message":"hi"}' }]); + }); + + it("creates chat transports as fetch transports", async () => { + const transport = createChatTransport<{ message: string }, { type: string }>({ + endpoint: "https://example.test/chat", + fetch: async () => new Response(streamFrom('{"type":"final","output":"done"}\n')), + }); + + await expect(collect(transport.send({ message: "hi" }))).resolves.toEqual([ + { type: "final", output: "done" }, + ]); + }); +}); + +async function collect(events: AsyncIterable): Promise { + const items: T[] = []; + for await (const event of events) { + items.push(event); + } + return items; +} + +function streamFrom(text: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 00000000..171cb287 --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "outDir": "dist" + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md new file mode 100644 index 00000000..00a00921 --- /dev/null +++ b/packages/server/CHANGELOG.md @@ -0,0 +1,13 @@ +# @anvia/server + +## 0.3.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +## 0.2.0 + +### Minor Changes + +- eb90638: Add server stream response helpers and React client transports for JSONL and Server-Sent Event agent streams. diff --git a/packages/server/README.md b/packages/server/README.md new file mode 100644 index 00000000..05486439 --- /dev/null +++ b/packages/server/README.md @@ -0,0 +1,19 @@ +# @anvia/server + +Server-side stream helpers for Anvia applications. + +```ts +import { createEventStream } from "@anvia/server"; + +return createEventStream(agent.prompt("Draft a reply.").stream(), { + format: "jsonl", +}); +``` + +## Exports + +- `createEventStream(events, options)` returns a streaming `Response`. +- `createJsonlStream(events, options)` returns a JSONL `ReadableStream`. +- `createSseStream(events, options)` returns a Server-Sent Event `ReadableStream`. + +JSONL is the default transport format. Use `format: "sse"` when you need `text/event-stream` compatibility. diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 00000000..bea456d8 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,39 @@ +{ + "name": "@anvia/server", + "version": "0.3.0", + "description": "Server-side event stream helpers for Anvia applications.", + "author": "anvia", + "maintainer": "Indra Zulfi", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anvia-hq/anvia", + "directory": "packages/server" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup src/index.ts --format esm --dts --sourcemap --clean", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^24.9.1", + "tsup": "^8.5.0", + "typescript": "^5.9.3", + "vitest": "^4.0.8" + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 00000000..2ab4cb48 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,174 @@ +export type EventStreamFormat = "jsonl" | "sse"; + +export type EventStreamErrorEvent = { + type: "error"; + error: unknown; +}; + +export type CreateEventStreamOptions = { + format?: EventStreamFormat; + headers?: HeadersInit; + status?: number; + statusText?: string; + jsonl?: JsonlStreamOptions; + sse?: SseStreamOptions; +}; + +export type JsonlStreamOptions = { + serialize?: (event: TEvent | EventStreamErrorEvent) => string; +}; + +export type SseStreamOptions = { + eventName?: string | ((event: TEvent | EventStreamErrorEvent) => string | undefined); + serialize?: (event: TEvent | EventStreamErrorEvent) => string; + retry?: number; +}; + +export function createEventStream( + events: AsyncIterable, + options: CreateEventStreamOptions = {}, +): Response { + const format = options.format ?? "jsonl"; + const headers = new Headers(options.headers); + + if (!headers.has("cache-control")) { + headers.set("cache-control", "no-cache, no-transform"); + } + if (!headers.has("connection")) { + headers.set("connection", "keep-alive"); + } + if (!headers.has("x-accel-buffering")) { + headers.set("x-accel-buffering", "no"); + } + + const body = + format === "sse" + ? createSseStream(events, options.sse) + : createJsonlStream(events, options.jsonl); + + if (!headers.has("content-type")) { + headers.set( + "content-type", + format === "sse" ? "text/event-stream; charset=utf-8" : "application/x-ndjson; charset=utf-8", + ); + } + + const responseInit: ResponseInit = { headers }; + if (options.status !== undefined) { + responseInit.status = options.status; + } + if (options.statusText !== undefined) { + responseInit.statusText = options.statusText; + } + + return new Response(body, responseInit); +} + +export function createJsonlStream( + events: AsyncIterable, + options: JsonlStreamOptions = {}, +): ReadableStream { + const encoder = new TextEncoder(); + const iterator = events[Symbol.asyncIterator](); + const serialize = options.serialize ?? serializeJson; + + return new ReadableStream({ + async pull(controller) { + try { + const next = await iterator.next(); + if (next.done === true) { + controller.close(); + return; + } + + controller.enqueue(encoder.encode(`${serialize(next.value)}\n`)); + } catch (error) { + controller.enqueue(encoder.encode(`${serialize(errorEvent(error))}\n`)); + controller.close(); + } + }, + async cancel() { + await iterator.return?.(); + }, + }); +} + +export function createSseStream( + events: AsyncIterable, + options: SseStreamOptions = {}, +): ReadableStream { + const encoder = new TextEncoder(); + const iterator = events[Symbol.asyncIterator](); + const serialize = options.serialize ?? serializeJson; + + return new ReadableStream({ + start(controller) { + if (options.retry !== undefined) { + controller.enqueue(encoder.encode(`retry: ${options.retry}\n\n`)); + } + }, + async pull(controller) { + try { + const next = await iterator.next(); + if (next.done === true) { + controller.close(); + return; + } + + controller.enqueue( + encoder.encode(formatSseEvent(next.value, serialize, options.eventName)), + ); + } catch (error) { + const event = errorEvent(error); + controller.enqueue(encoder.encode(formatSseEvent(event, serialize, options.eventName))); + controller.close(); + } + }, + async cancel() { + await iterator.return?.(); + }, + }); +} + +function formatSseEvent( + event: TEvent | EventStreamErrorEvent, + serialize: (event: TEvent | EventStreamErrorEvent) => string, + eventName: SseStreamOptions["eventName"], +): string { + const name = typeof eventName === "function" ? eventName(event) : eventName; + const lines: string[] = []; + + if (name !== undefined && name.length > 0) { + lines.push(`event: ${name}`); + } + + for (const line of serialize(event).split(/\r?\n/)) { + lines.push(`data: ${line}`); + } + + lines.push("", ""); + return lines.join("\n"); +} + +function serializeJson(value: unknown): string { + return JSON.stringify(value); +} + +function errorEvent(error: unknown): EventStreamErrorEvent { + return { + type: "error", + error: serializeError(error), + }; +} + +function serializeError(error: unknown): unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + + return error; +} diff --git a/packages/server/test/streams.test.ts b/packages/server/test/streams.test.ts new file mode 100644 index 00000000..da3258a8 --- /dev/null +++ b/packages/server/test/streams.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { createEventStream, createJsonlStream, createSseStream } from "../src"; + +describe("@anvia/server streams", () => { + it("serializes async iterables as jsonl", async () => { + const text = await readText(createJsonlStream(events([{ type: "one" }, { type: "two" }]))); + + expect(text).toBe('{"type":"one"}\n{"type":"two"}\n'); + }); + + it("serializes async iterables as server-sent events", async () => { + const text = await readText( + createSseStream(events([{ type: "one", value: "hello\nworld" }]), { + eventName: (event) => event.type, + }), + ); + + expect(text).toBe('event: one\ndata: {"type":"one","value":"hello\\nworld"}\n\n'); + }); + + it("creates event stream responses with default jsonl headers", async () => { + const response = createEventStream(events([{ type: "one" }])); + + expect(response.headers.get("content-type")).toBe("application/x-ndjson; charset=utf-8"); + expect(response.headers.get("cache-control")).toBe("no-cache, no-transform"); + expect(await response.text()).toBe('{"type":"one"}\n'); + }); + + it("creates event stream responses with sse headers", async () => { + const response = createEventStream(events([{ type: "one" }]), { format: "sse" }); + + expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8"); + expect(await response.text()).toBe('data: {"type":"one"}\n\n'); + }); + + it("emits an error event when iteration fails", async () => { + const text = await readText( + createJsonlStream( + (async function* () { + yield { type: "one" }; + throw new Error("stream failed"); + })(), + ), + ); + + expect(text).toContain('{"type":"one"}\n'); + expect(text).toContain('"type":"error"'); + expect(text).toContain('"message":"stream failed"'); + }); +}); + +async function* events(items: T[]): AsyncIterable { + for (const item of items) { + yield item; + } +} + +async function readText(stream: ReadableStream): Promise { + return new Response(stream).text(); +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 00000000..171cb287 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "outDir": "dist" + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/packages/tools/studio/CHANGELOG.md b/packages/tools/studio/CHANGELOG.md new file mode 100644 index 00000000..6c45abc9 --- /dev/null +++ b/packages/tools/studio/CHANGELOG.md @@ -0,0 +1,106 @@ +# @anvia/studio + +## 0.5.2 + +### Patch Changes + +- 46dbd72: Use shared `@anvia/server` and `@anvia/react` stream helpers internally while preserving Studio stream behavior and UI transcript handling. + +## 0.5.1 + +### Patch Changes + +- c9728d4: Update upstream runtime dependencies to their latest compatible releases. + +## 0.5.0 + +### Minor Changes + +- e84d775: Clean up the `@anvia/core` public import surface by keeping common app-authoring APIs on the root export, moving advanced APIs to focused subpaths, and exposing runtime agent internals through `@anvia/core/internal/agent` for Anvia integration packages. + +### Patch Changes + +- Updated dependencies [e84d775] + - @anvia/core@0.4.0 + +## 0.4.1 + +### Patch Changes + +- 6c53426: Make Studio UI routes consistently use the configured UI path, add the missing Evals shell route, restore the dynamic tools Knowledge tab, and make runtime JSON serialization safe for cyclic model metadata. + +## 0.4.0 + +### Minor Changes + +- b542b87: Add Studio inspection surfaces for memory, runtime status, richer agent metadata, direct tool invocation, pipeline replay controls, realtime observability events, and eval suite runs, with in-memory storage as the default and optional SQLite persistence. + +### Patch Changes + +- b542b87: Allow Studio to accept typed pipelines with arbitrary input and output types, and update the cookbook Studio inspection example to point at the correct UI routes. + +## 0.3.0 + +### Minor Changes + +- e74df22: Add Studio inspection surfaces for memory, runtime status, richer agent metadata, direct tool invocation, pipeline replay controls, realtime observability events, and eval suite runs, with in-memory storage as the default and optional SQLite persistence. + +## 0.2.11 + +### Patch Changes + +- Updated dependencies [b12932d] + - @anvia/core@0.3.1 + +## 0.2.10 + +### Patch Changes + +- 09c70f5: Add first-class multimodal tool result support. + + Tools can now return `ToolResultContent[]` directly, or use `ToolOutput.content(...)`, and agent execution will pass structured text/image tool results to model turns instead of JSON-stringifying them. Tool middleware, hooks, observers, stream events, and Studio transcript surfaces keep the existing display string while exposing optional structured result content. + + OpenAI Responses and Anthropic now serialize multimodal tool result images as provider-visible image blocks. Text-only provider fallbacks render image results as media-type placeholders instead of raw base64. + + Update provider and tracing wrapper dependencies to the latest checked upstream releases. + +- Updated dependencies [09c70f5] + - @anvia/core@0.3.0 + +## 0.2.9 + +### Patch Changes + +- 49e43a3: Update upstream runtime dependencies for Anthropic, Gemini, OpenAI, and Studio. + +## 0.2.8 + +### Patch Changes + +- 896ae21: Update upstream provider and runtime dependencies. + +## 0.2.7 + +### Patch Changes + +- a0a5def: Lazy-load the default SQLite store so importing Studio does not require `node:sqlite` in Bun-compatible runtimes. +- Updated dependencies [a0a5def] + - @anvia/core@0.2.4 + +## 0.2.6 + +### Patch Changes + +- 1f7d3aa: Republish packages with registry-safe dependency metadata. + +## 0.2.5 + +### Patch Changes + +- 1ad360d: Fix Anthropic-compatible streaming tool inputs and update provider dependencies. + +## 0.2.4 + +### Patch Changes + +- 1e5b78d: Polish the Studio UI with updated sidebar, page surfaces, tracing views, playground logs, transcript auto-scroll, and full-width markdown tables. diff --git a/packages/tools/studio/README.md b/packages/tools/studio/README.md index 5a1d54a8..7ac86967 100644 --- a/packages/tools/studio/README.md +++ b/packages/tools/studio/README.md @@ -1,8 +1,8 @@ # @anvia/studio -Studio UI and HTTP runtime for Anvia agents. +Studio UI and HTTP runtime for Anvia agents, pipelines, tools, MCPs, memory, status, and knowledge inspection. -Use this package to serve local agents over HTTP, inspect sessions and traces in the browser UI, and exercise tool approval workflows during development. +Use this package to serve local agents and pipelines over HTTP, inspect sessions, traces, tools, MCPs, Memory, Status, and Knowledge in the browser UI, and exercise tool approval workflows during development. ## Installation @@ -41,24 +41,41 @@ new Studio([agent]).start({ Then open: ```txt -http://localhost:4021/playground +http://localhost:4021/ui/playground ``` +## Browser UI + +Studio exposes: + +- Chat playground and persisted sessions +- Trace browser and session logs +- Realtime observability stream for session logs, pipeline logs, and completed traces +- Eval suite runner for registered `runEvalSuite` configurations +- Pipeline graph, logs, run history, and replay-from-history controls +- Rich agent runtime details, direct tool invocation, static tool, dynamic tool, and MCP inspectors +- Memory explorer for users, conversations, messages, and transcript steps backed by the session store +- Status dashboard for storage adapters, record counts, and enabled capabilities +- Knowledge tabs for static context, dynamic context, dynamic tools, and retrieval log + ## Session Storage -Studio uses a local SQLite store by default so sessions and traces can persist across process restarts. If you omit the port, Studio uses `RUNNER_PORT` and then falls back to `4021`. +Studio uses an in-memory store by default. Sessions, traces, and pipeline run history are available while the process is running, but they do not create local files unless you opt in to SQLite. If you omit the port, Studio uses `RUNNER_PORT` and then falls back to `4021`. -Set `ANVIA_STUDIO_DB` to control the database path: +Set `ANVIA_STUDIO_DB` to persist Studio data in SQLite: ```sh ANVIA_STUDIO_DB=.anvia/studio.sqlite node ./dist/server.js ``` +SQLite storage uses dedicated `anvia_studio_*` tables so it can share an application database without writing into product tables. + ## Exports - `Studio` +- `createInMemoryStudioStore` - `createSqliteSessionStore` -- Studio session, trace, approval, and runtime types +- Studio session, trace, approval, pipeline, memory, status, knowledge, tool, MCP, and runtime types ## Development diff --git a/packages/tools/studio/components.json b/packages/tools/studio/components.json index 350d2000..35212d93 100644 --- a/packages/tools/studio/components.json +++ b/packages/tools/studio/components.json @@ -17,5 +17,5 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" + "iconLibrary": "phosphor" } diff --git a/packages/tools/studio/package.json b/packages/tools/studio/package.json index 54a99757..58a4fe7f 100644 --- a/packages/tools/studio/package.json +++ b/packages/tools/studio/package.json @@ -1,13 +1,21 @@ { "name": "@anvia/studio", - "version": "0.1.0", + "version": "0.5.2", "description": "Studio UI and HTTP runtime for Anvia agents.", "author": "anvia", "maintainer": "Indra Zulfi", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/anvia-hq/anvia", + "directory": "packages/tools/studio" + }, "files": [ "dist" ], + "publishConfig": { + "access": "public" + }, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -24,21 +32,24 @@ }, "dependencies": { "@anvia/core": "workspace:*", - "@hono/node-server": "^2.0.1", - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-scroll-area": "^1.2.10", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", + "@anvia/react": "workspace:*", + "@anvia/server": "workspace:*", + "@hono/node-server": "^2.0.4", + "@phosphor-icons/react": "^2.1.10", + "@radix-ui/react-alert-dialog": "^1.1.16", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-scroll-area": "^1.2.11", + "@radix-ui/react-select": "^2.3.0", + "@radix-ui/react-separator": "^1.1.9", + "@radix-ui/react-slot": "^1.2.5", + "@xyflow/react": "^12.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "hono": "^4.12.16", - "lucide-react": "^1.14.0", - "react": "^19.2.5", - "react-dom": "^19.2.5", + "hono": "^4.12.24", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-markdown": "^10.1.0", - "tailwind-merge": "^3.5.0" + "tailwind-merge": "^3.6.0" }, "devDependencies": { "@tailwindcss/typography": "^0.5.19", diff --git a/packages/tools/studio/src/index.ts b/packages/tools/studio/src/index.ts index 9a88d39a..cdd73b25 100644 --- a/packages/tools/studio/src/index.ts +++ b/packages/tools/studio/src/index.ts @@ -1,4 +1,5 @@ export * from "./runner"; export * from "./sqlite"; +export { createInMemoryStudioStore } from "./storage/memory-store"; export * from "./trace"; export * from "./types"; diff --git a/packages/tools/studio/src/runtime/approvals.ts b/packages/tools/studio/src/runtime/approvals.ts index 9cd319ea..d1a1ef38 100644 --- a/packages/tools/studio/src/runtime/approvals.ts +++ b/packages/tools/studio/src/runtime/approvals.ts @@ -1,11 +1,17 @@ -import type { - AnyTool, - JsonObject, - PromptHook, - ToolApprovalContext, - ToolApprovalPolicy, -} from "@anvia/core"; -import { createHook, parseToolArgs } from "@anvia/core"; +import { + createHook, + type PromptHook, + type ToolApprovalRequestOptions, + type ToolCallHookAction, + type ToolCallHookArgs, +} from "@anvia/core/agent"; +import type { JsonObject } from "@anvia/core/completion"; +import { + type AnyTool, + parseToolArgs, + type ToolApprovalContext, + type ToolApprovalPolicy, +} from "@anvia/core/tool"; import type { Context, Hono } from "hono"; import type { AgentRunStreamEvent, @@ -42,7 +48,7 @@ type ApprovalRequest = { export type ApprovalRuntime = { approvals: Map; - createHook(context: ApprovalHookContext): PromptHook; + createHook(context: ApprovalHookContext): StudioApprovalHook; list(options: ApprovalListOptions): StudioToolApproval[]; decide( id: string, @@ -57,6 +63,13 @@ type ApprovalListOptions = { sessionId?: string; }; +export type StudioApprovalHook = PromptHook & { + handleApprovalRequest( + args: ToolCallHookArgs, + request: ToolApprovalRequestOptions, + ): Promise; +}; + export function registerApprovalRoutes(app: Hono, approvals: ApprovalRuntime): void { app.get("/approvals", (c) => { const status = parseApprovalStatus(c.req.query("status")); @@ -147,51 +160,74 @@ export function createApprovalRuntime(): ApprovalRuntime { return { approvals, createHook(context) { - return createHook({ - async onToolCall({ toolName, toolCallId, internalCallId, args, tool: control }) { - const registeredTool = context.getTool(toolName); - if (registeredTool?.approval === undefined) { - return control.run(); - } - const approval = registeredTool.approval as ToolApprovalPolicy; + const handleApprovalRequest: StudioApprovalHook["handleApprovalRequest"] = async ( + { toolName, toolCallId, internalCallId, args, tool: control }, + request, + ) => { + const decision = await requestApproval(approvals, context, { + toolName, + ...(toolCallId === undefined ? {} : { toolCallId }), + internalCallId, + args, + ...(request.reason === undefined ? {} : { reason: request.reason }), + ...(request.rejectMessage === undefined ? {} : { rejectMessage: request.rejectMessage }), + }); - const rawParsedArgs = parseToolArgs(args); - const parsedArgs = registeredTool.parseApprovalArgs?.(rawParsedArgs) ?? rawParsedArgs; - const approvalContext = { - toolName, - args: parsedArgs, - rawArgs: args, - ...(toolCallId === undefined ? {} : { toolCallId }), - internalCallId, - run: { - agentId: context.agentId, - runId: context.runId, - ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), - ...(context.metadata === undefined ? {} : { metadata: context.metadata }), - }, - }; + return decision.approved + ? control.run() + : control.skip(decision.reason ?? request.rejectMessage ?? "Rejected in Anvia Studio."); + }; + return { + ...createHook({ + async onToolCall({ toolName, toolCallId, internalCallId, args, tool: control }) { + const registeredTool = context.getTool(toolName); + if (registeredTool?.approval === undefined) { + return control.run(); + } + const approval = registeredTool.approval as ToolApprovalPolicy; - const required = await approval.when(approvalContext); - if (!required) { - return control.run(); - } + const rawParsedArgs = parseToolArgs(args); + const parsedArgs = registeredTool.parseApprovalArgs?.(rawParsedArgs) ?? rawParsedArgs; + const approvalContext = { + toolName, + args: parsedArgs, + rawArgs: args, + ...(toolCallId === undefined ? {} : { toolCallId }), + internalCallId, + run: { + agentId: context.agentId, + runId: context.runId, + ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), + ...(context.metadata === undefined ? {} : { metadata: context.metadata }), + }, + }; - const reason = await resolveApprovalText(approval.reason, approvalContext); - const rejectMessage = await resolveApprovalText(approval.rejectMessage, approvalContext); - const decision = await requestApproval(approvals, context, { - toolName, - ...(toolCallId === undefined ? {} : { toolCallId }), - internalCallId, - args, - ...(reason === undefined ? {} : { reason }), - ...(rejectMessage === undefined ? {} : { rejectMessage }), - }); + const required = await approval.when(approvalContext); + if (!required) { + return control.run(); + } - return decision.approved - ? control.run() - : control.skip(decision.reason ?? rejectMessage ?? "Rejected in Anvia Studio."); - }, - }); + const reason = await resolveApprovalText(approval.reason, approvalContext); + const rejectMessage = await resolveApprovalText( + approval.rejectMessage, + approvalContext, + ); + const decision = await requestApproval(approvals, context, { + toolName, + ...(toolCallId === undefined ? {} : { toolCallId }), + internalCallId, + args, + ...(reason === undefined ? {} : { reason }), + ...(rejectMessage === undefined ? {} : { rejectMessage }), + }); + + return decision.approved + ? control.run() + : control.skip(decision.reason ?? rejectMessage ?? "Rejected in Anvia Studio."); + }, + }), + handleApprovalRequest, + }; }, list(options) { return [...approvals.values()] diff --git a/packages/tools/studio/src/runtime/evals.ts b/packages/tools/studio/src/runtime/evals.ts new file mode 100644 index 00000000..e56479e7 --- /dev/null +++ b/packages/tools/studio/src/runtime/evals.ts @@ -0,0 +1,83 @@ +import { runEvalSuite } from "@anvia/core/evals"; +import type { Context, Hono } from "hono"; +import type { StudioEvalRunRequest, StudioEvalRunResponse, StudioEvalSuite } from "../types"; +import { toJsonValue } from "./json"; +import { errorResponse, evalConfig, isJsonObject, isObject, isPositiveInteger } from "./shared"; + +export function registerEvalRoutes( + app: Hono, + props: { + evals: StudioEvalSuite[]; + evalMap: Map; + }, +): void { + app.get("/evals", (c) => + c.json({ + evals: props.evals.map(evalConfig), + }), + ); + + app.get("/evals/:evalId", (c) => { + const suite = props.evalMap.get(c.req.param("evalId")); + if (suite === undefined) { + return errorResponse(c, 404, "not_found", "Eval suite not found"); + } + return c.json(evalConfig(suite)); + }); + + app.post("/evals/:evalId/runs", async (c) => { + const suite = props.evalMap.get(c.req.param("evalId")); + if (suite === undefined) { + return errorResponse(c, 404, "not_found", "Eval suite not found"); + } + + const body = await parseEvalRunRequest(c); + if ("error" in body) { + return body.error; + } + + const runId = globalThis.crypto.randomUUID(); + const startedAt = Date.now(); + const result = await runEvalSuite({ + ...suite, + ...(body.concurrency === undefined ? {} : { concurrency: body.concurrency }), + }); + const endedAt = Date.now(); + const jsonResult = toJsonValue(result); + const response: StudioEvalRunResponse = { + runId, + suiteId: suite.id ?? suite.name, + startedAt: new Date(startedAt).toISOString(), + endedAt: new Date(endedAt).toISOString(), + durationMs: endedAt - startedAt, + result: isJsonObject(jsonResult) ? jsonResult : { value: jsonResult }, + }; + return c.json(response); + }); +} + +async function parseEvalRunRequest( + c: Context, +): Promise { + let body: unknown = {}; + try { + body = await c.req.json(); + } catch { + body = {}; + } + + if (!isObject(body)) { + return { error: errorResponse(c, 400, "bad_request", "Request body must be an object") }; + } + + const request: StudioEvalRunRequest = {}; + if ("concurrency" in body) { + if (!isPositiveInteger(body.concurrency)) { + return { + error: errorResponse(c, 400, "bad_request", "concurrency must be a positive integer"), + }; + } + request.concurrency = body.concurrency; + } + return request; +} diff --git a/packages/tools/studio/src/runtime/json.ts b/packages/tools/studio/src/runtime/json.ts index 9c6be00a..cc75fd60 100644 --- a/packages/tools/studio/src/runtime/json.ts +++ b/packages/tools/studio/src/runtime/json.ts @@ -1,6 +1,10 @@ -import type { JsonObject, JsonValue } from "@anvia/core"; +import type { JsonObject, JsonValue } from "@anvia/core/completion"; export function toJsonValue(value: unknown): JsonValue { + return toJsonValueInternal(value, new WeakSet()); +} + +function toJsonValueInternal(value: unknown, seen: WeakSet): JsonValue { if ( value === null || typeof value === "string" || @@ -13,17 +17,40 @@ export function toJsonValue(value: unknown): JsonValue { return null; } if (Array.isArray(value)) { - return value.map((item) => toJsonValue(item)); + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + try { + return value.map((item) => toJsonValueInternal(item, seen)); + } finally { + seen.delete(value); + } } if (typeof value === "object") { - return compactJsonObject(value as Record); + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + try { + return compactJsonObjectInternal(value as Record, seen); + } finally { + seen.delete(value); + } } return String(value); } export function compactJsonObject(values: Record): JsonObject { + return compactJsonObjectInternal(values, new WeakSet()); +} + +function compactJsonObjectInternal( + values: Record, + seen: WeakSet, +): JsonObject { const entries = Object.entries(values).flatMap(([key, value]) => - value === undefined ? [] : [[key, toJsonValue(value)]], + value === undefined ? [] : [[key, toJsonValueInternal(value, seen)]], ); return Object.fromEntries(entries) as JsonObject; } diff --git a/packages/tools/studio/src/runtime/knowledge.ts b/packages/tools/studio/src/runtime/knowledge.ts index 18247c61..bca9aff0 100644 --- a/packages/tools/studio/src/runtime/knowledge.ts +++ b/packages/tools/studio/src/runtime/knowledge.ts @@ -1,16 +1,28 @@ -import type { JsonObject, JsonValue } from "@anvia/core"; +import type { JsonObject, JsonValue } from "@anvia/core/completion"; import type { Hono } from "hono"; import type { StudioAgent, StudioAgentKnowledgeConfig, StudioKnowledgeEvidence, StudioKnowledgeEvidenceDocument, + StudioKnowledgeItem, + StudioKnowledgeItemsPage, + StudioKnowledgeSourceKind, + StudioKnowledgeSourceSummary, StudioKnowledgeSummary, StudioTrace, StudioTraceStore, } from "../types"; -import { compactJsonObject } from "./json"; -import { errorResponse, parseLimit } from "./shared"; +import { compactJsonObject, toJsonValue } from "./json"; +import { errorResponse, optionalQueryString, parseLimit } from "./shared"; + +type InspectableIndex = { + inspect?: (request: { limit: number; cursor?: string | undefined; filter?: unknown }) => Promise<{ + items: Array<{ id: string; document: unknown; metadata?: Record }>; + nextCursor?: string | undefined; + totalCount?: number | undefined; + }>; +}; export function registerKnowledgeRoutes( app: Hono, @@ -26,11 +38,39 @@ export function registerKnowledgeRoutes( } const summary: StudioKnowledgeSummary = { - agents: props.agents.map(agentKnowledgeConfig), + agents: await Promise.all(props.agents.map(agentKnowledgeConfig)), evidence: await recentKnowledgeEvidence(props.traceStore, limit), }; return c.json(summary); }); + + app.get("/knowledge/items", async (c) => { + const limit = parseLimit(c.req.query("limit")); + if (limit === undefined) { + return errorResponse(c, 400, "bad_request", "Invalid limit"); + } + + const agentId = optionalQueryString(c.req.query("agentId")); + const sourceId = optionalQueryString(c.req.query("sourceId")); + if (agentId === undefined || sourceId === undefined) { + return errorResponse(c, 400, "bad_request", "agentId and sourceId are required"); + } + + const agent = props.agents.find((item) => item.id === agentId); + if (agent === undefined) { + return errorResponse(c, 404, "not_found", "Agent not found"); + } + + const page = await knowledgeItemsPage(agent, sourceId, { + limit, + cursor: optionalQueryString(c.req.query("cursor")), + }); + if (page === undefined) { + return errorResponse(c, 404, "not_found", "Knowledge source not found"); + } + + return c.json(page); + }); } export function agentHasKnowledge(agent: StudioAgent): boolean { @@ -41,16 +81,12 @@ export function agentHasKnowledge(agent: StudioAgent): boolean { ); } -function agentKnowledgeConfig(agent: StudioAgent): StudioAgentKnowledgeConfig { +async function agentKnowledgeConfig(agent: StudioAgent): Promise { const agentName = agent.name ?? agent.agent.name; return { agentId: agent.id, ...(agentName === undefined ? {} : { agentName }), - sources: [ - { kind: "static_context", count: agent.agent.staticContext.length }, - { kind: "dynamic_context", count: agent.agent.dynamicContexts.length }, - { kind: "dynamic_tools", count: agent.agent.dynamicTools.length }, - ], + sources: await knowledgeSources(agent), staticContext: agent.agent.staticContext.map((document) => ({ id: document.id, text: document.text, @@ -61,6 +97,260 @@ function agentKnowledgeConfig(agent: StudioAgent): StudioAgentKnowledgeConfig { }; } +async function knowledgeSources(agent: StudioAgent): Promise { + const sources: StudioKnowledgeSourceSummary[] = [ + { + sourceId: staticSourceId(), + kind: "static_context", + label: "Static context", + count: agent.agent.staticContext.length, + inspectable: true, + itemCount: agent.agent.staticContext.length, + }, + ]; + + const dynamicContextSources = await Promise.all( + agent.agent.dynamicContexts.map(async (registration, index) => { + const inspect = inspectFn(registration.index); + const count = await inspectableCount(inspect, registration.options.filter); + return { + sourceId: dynamicContextSourceId(index), + kind: "dynamic_context" as const, + label: `Dynamic context ${index + 1}`, + count: 1, + registrationIndex: index, + topK: registration.options.topK, + ...(registration.options.threshold === undefined + ? {} + : { threshold: registration.options.threshold }), + inspectable: inspect !== undefined, + ...(count === undefined ? {} : { itemCount: count }), + }; + }), + ); + + const dynamicToolSources = await Promise.all( + agent.agent.dynamicTools.map(async (registration, index) => { + const inspect = inspectFn(registration.index); + const count = await inspectableCount(inspect, registration.options.filter); + return { + sourceId: dynamicToolsSourceId(index), + kind: "dynamic_tools" as const, + label: `Dynamic tools ${index + 1}`, + count: 1, + registrationIndex: index, + topK: registration.options.topK, + ...(registration.options.threshold === undefined + ? {} + : { threshold: registration.options.threshold }), + inspectable: inspect !== undefined, + ...(count === undefined ? {} : { itemCount: count }), + }; + }), + ); + + return [...sources, ...dynamicContextSources, ...dynamicToolSources]; +} + +async function inspectableCount( + inspect: InspectableIndex["inspect"] | undefined, + filter?: unknown, +): Promise { + if (inspect === undefined) { + return undefined; + } + const page = await inspect({ limit: 1, filter }); + return page.totalCount; +} + +async function knowledgeItemsPage( + agent: StudioAgent, + sourceId: string, + request: { limit: number; cursor?: string | undefined }, +): Promise { + if (sourceId === staticSourceId()) { + return staticKnowledgeItemsPage(agent, request); + } + + const dynamicContextIndex = dynamicSourceIndex(sourceId, "dynamic_context"); + if (dynamicContextIndex !== undefined) { + const registration = agent.agent.dynamicContexts[dynamicContextIndex]; + if (registration === undefined) { + return undefined; + } + const inspect = inspectFn(registration.index); + if (inspect === undefined) { + return nonInspectablePage(agent.id, sourceId, "dynamic_context"); + } + const page = await inspect({ + limit: request.limit, + cursor: request.cursor, + filter: registration.options.filter, + }); + return { + agentId: agent.id, + sourceId, + kind: "dynamic_context", + inspectable: true, + items: page.items.map((item) => dynamicContextItem(item)), + ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }), + ...(page.totalCount === undefined ? {} : { totalCount: page.totalCount }), + }; + } + + const dynamicToolsIndex = dynamicSourceIndex(sourceId, "dynamic_tools"); + if (dynamicToolsIndex !== undefined) { + const registration = agent.agent.dynamicTools[dynamicToolsIndex]; + if (registration === undefined) { + return undefined; + } + const inspect = inspectFn(registration.index); + if (inspect === undefined) { + return nonInspectablePage(agent.id, sourceId, "dynamic_tools"); + } + const page = await inspect({ + limit: request.limit, + cursor: request.cursor, + filter: registration.options.filter, + }); + return { + agentId: agent.id, + sourceId, + kind: "dynamic_tools", + inspectable: true, + items: page.items.map((item) => dynamicToolItem(item)), + ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }), + ...(page.totalCount === undefined ? {} : { totalCount: page.totalCount }), + }; + } + + return undefined; +} + +function inspectFn(index: unknown): InspectableIndex["inspect"] | undefined { + if (!isRecord(index) || typeof index.inspect !== "function") { + return undefined; + } + const inspect = index.inspect; + return (request) => + inspect.call(index, request) as ReturnType>; +} + +function staticKnowledgeItemsPage( + agent: StudioAgent, + request: { limit: number; cursor?: string | undefined }, +): StudioKnowledgeItemsPage { + const start = Math.max(0, Math.trunc(Number(request.cursor ?? "0"))); + const page = agent.agent.staticContext.slice(start, start + request.limit); + const nextOffset = start + page.length; + return { + agentId: agent.id, + sourceId: staticSourceId(), + kind: "static_context", + inspectable: true, + items: page.map((document) => ({ + id: document.id, + kind: "static_context", + text: document.text, + ...(document.additionalProps === undefined + ? {} + : { metadata: jsonObjectFromRecord(document.additionalProps) }), + })), + ...(nextOffset < agent.agent.staticContext.length ? { nextCursor: String(nextOffset) } : {}), + totalCount: agent.agent.staticContext.length, + }; +} + +function nonInspectablePage( + agentId: string, + sourceId: string, + kind: StudioKnowledgeSourceKind, +): StudioKnowledgeItemsPage { + return { + agentId, + sourceId, + kind, + inspectable: false, + items: [], + message: "This source can be searched at runtime, but it does not expose browseable chunks.", + }; +} + +function dynamicContextItem(item: { + id: string; + document: unknown; + metadata?: Record | undefined; +}): StudioKnowledgeItem { + const text = + isRecord(item.document) && typeof item.document.text === "string" + ? item.document.text + : typeof item.document === "string" + ? item.document + : undefined; + return { + id: item.id, + kind: "dynamic_context", + ...(text === undefined ? { document: toJsonValue(item.document) } : { text }), + ...(item.metadata === undefined ? {} : { metadata: jsonObjectFromRecord(item.metadata) }), + }; +} + +function dynamicToolItem(item: { + id: string; + document: unknown; + metadata?: Record | undefined; +}): StudioKnowledgeItem { + const document = isRecord(item.document) ? item.document : {}; + const definition = isRecord(document.definition) ? document.definition : {}; + const toolName = + typeof document.toolName === "string" + ? document.toolName + : typeof definition.name === "string" + ? definition.name + : item.id; + const description = typeof definition.description === "string" ? definition.description : ""; + return { + id: item.id, + kind: "dynamic_tool", + toolName, + description, + parameterKeys: parameterKeys(definition.parameters), + document: toJsonValue(item.document), + ...(item.metadata === undefined ? {} : { metadata: jsonObjectFromRecord(item.metadata) }), + }; +} + +function parameterKeys(parameters: unknown): string[] { + if (!isRecord(parameters) || !isRecord(parameters.properties)) { + return []; + } + return Object.keys(parameters.properties); +} + +function staticSourceId(): string { + return "static-context"; +} + +function dynamicContextSourceId(index: number): string { + return `dynamic-context-${index}`; +} + +function dynamicToolsSourceId(index: number): string { + return `dynamic-tools-${index}`; +} + +function dynamicSourceIndex( + sourceId: string, + kind: "dynamic_context" | "dynamic_tools", +): number | undefined { + const prefix = kind === "dynamic_context" ? "dynamic-context-" : "dynamic-tools-"; + if (!sourceId.startsWith(prefix)) { + return undefined; + } + const index = Number(sourceId.slice(prefix.length)); + return Number.isInteger(index) && index >= 0 ? index : undefined; +} + async function recentKnowledgeEvidence( traceStore: StudioTraceStore | undefined, limit: number, diff --git a/packages/tools/studio/src/runtime/mcps.ts b/packages/tools/studio/src/runtime/mcps.ts new file mode 100644 index 00000000..5393364c --- /dev/null +++ b/packages/tools/studio/src/runtime/mcps.ts @@ -0,0 +1,75 @@ +import type { Hono } from "hono"; +import type { + StudioAgent, + StudioAgentMcpServerMetadata, + StudioAgentMcpToolMetadata, +} from "../types"; +import { errorResponse } from "./shared"; +import { agentToolItems, mcpServerName } from "./tool-metadata"; + +export function registerMcpRoutes( + app: Hono, + props: { + agentMap: Map; + }, +): void { + app.get("/agents/:agentId/mcps", async (c) => { + const agentId = c.req.param("agentId"); + const agent = props.agentMap.get(agentId); + if (agent === undefined) { + return errorResponse(c, 404, "not_found", "Agent not found"); + } + + return c.json({ + agentId, + servers: await agentMcpMetadata(agent), + }); + }); +} + +export async function agentMcpMetadata( + agent: StudioAgent, +): Promise { + const servers = new Map(); + const seen = new Set(); + + for (const { tool, source } of agentToolItems(agent)) { + const serverName = mcpServerName(tool); + if (serverName === undefined) { + continue; + } + + const definition = await tool.definition(""); + const key = `${serverName}:${source}:${definition.name}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + + const tools = servers.get(serverName) ?? []; + tools.push({ + name: definition.name, + description: definition.description, + parameters: definition.parameters, + source, + }); + servers.set(serverName, tools); + } + + return [...servers.entries()] + .map(([name, tools]) => { + const sortedTools = tools.sort((left, right) => { + if (left.source !== right.source) { + return left.source === "static" ? -1 : 1; + } + return left.name.localeCompare(right.name); + }); + return { + agentId: agent.id, + name, + toolCount: sortedTools.length, + tools: sortedTools, + }; + }) + .sort((left, right) => left.name.localeCompare(right.name)); +} diff --git a/packages/tools/studio/src/runtime/memory.ts b/packages/tools/studio/src/runtime/memory.ts new file mode 100644 index 00000000..43cbd667 --- /dev/null +++ b/packages/tools/studio/src/runtime/memory.ts @@ -0,0 +1,125 @@ +import type { Hono } from "hono"; +import type { + StudioMemoryConversationMessages, + StudioMemoryConversationSteps, + StudioMemoryConversationSummary, + StudioMemoryConversationsPage, + StudioMemoryUsersPage, + StudioSession, + StudioSessionStore, + StudioSessionSummary, +} from "../types"; +import { errorResponse, optionalQueryString, parseLimit } from "./shared"; + +const DEFAULT_USER_ID = "default"; + +export function registerMemoryRoutes( + app: Hono, + props: { + sessionStore: StudioSessionStore; + }, +): void { + app.get("/memory/users", async (c) => { + const limit = parseLimit(c.req.query("limit")); + if (limit === undefined) { + return errorResponse(c, 400, "bad_request", "limit must be a positive integer"); + } + + const sessions = await props.sessionStore.listSessions({ limit: 100 }); + const users = new Map(); + for (const session of sessions) { + const userId = sessionUserId(session); + const existing = users.get(userId); + if (existing === undefined) { + users.set(userId, { + userId, + conversationCount: 1, + agentIds: [session.agentId], + lastInteractionAt: session.updatedAt, + }); + continue; + } + existing.conversationCount += 1; + if (!existing.agentIds.includes(session.agentId)) { + existing.agentIds.push(session.agentId); + } + if (new Date(session.updatedAt).getTime() > new Date(existing.lastInteractionAt).getTime()) { + existing.lastInteractionAt = session.updatedAt; + } + } + + const page = [...users.values()] + .sort( + (left, right) => + new Date(right.lastInteractionAt).getTime() - new Date(left.lastInteractionAt).getTime(), + ) + .slice(0, limit); + return c.json({ users: page, total: users.size } satisfies StudioMemoryUsersPage); + }); + + app.get("/memory/conversations", async (c) => { + const limit = parseLimit(c.req.query("limit")); + if (limit === undefined) { + return errorResponse(c, 400, "bad_request", "limit must be a positive integer"); + } + + const agentId = optionalQueryString(c.req.query("agentId")); + const userId = optionalQueryString(c.req.query("userId")); + const sessions = await props.sessionStore.listSessions({ + ...(agentId === undefined ? {} : { agentId }), + limit: 100, + }); + const conversations = sessions + .map(memoryConversationSummary) + .filter((session) => userId === undefined || session.userId === userId) + .slice(0, limit); + + return c.json({ + conversations, + total: conversations.length, + } satisfies StudioMemoryConversationsPage); + }); + + app.get("/memory/conversations/:conversationId/messages", async (c) => { + const session = await props.sessionStore.getSession(c.req.param("conversationId")); + if (session === undefined) { + return errorResponse(c, 404, "not_found", "Conversation not found"); + } + return c.json({ + conversation: memoryConversationSummary(session), + messages: session.messages, + transcript: session.transcript, + } satisfies StudioMemoryConversationMessages); + }); + + app.get("/memory/conversations/:conversationId/steps", async (c) => { + const session = await props.sessionStore.getSession(c.req.param("conversationId")); + if (session === undefined) { + return errorResponse(c, 404, "not_found", "Conversation not found"); + } + return c.json({ + conversation: memoryConversationSummary(session), + steps: session.transcript, + } satisfies StudioMemoryConversationSteps); + }); +} + +function memoryConversationSummary( + session: StudioSession | StudioSessionSummary, +): StudioMemoryConversationSummary { + return { + id: session.id, + userId: sessionUserId(session), + agentId: session.agentId, + ...(session.title === undefined ? {} : { title: session.title }), + createdAt: session.createdAt, + updatedAt: session.updatedAt, + messageCount: session.messageCount, + ...(session.metadata === undefined ? {} : { metadata: session.metadata }), + }; +} + +function sessionUserId(session: Pick): string { + const userId = session.metadata?.userId; + return typeof userId === "string" && userId.trim().length > 0 ? userId : DEFAULT_USER_ID; +} diff --git a/packages/tools/studio/src/runtime/observability.ts b/packages/tools/studio/src/runtime/observability.ts new file mode 100644 index 00000000..ef90d142 --- /dev/null +++ b/packages/tools/studio/src/runtime/observability.ts @@ -0,0 +1,242 @@ +import type { Hono } from "hono"; +import type { + StudioObservabilityEvent, + StudioObservabilityEventType, + StudioPipelineLogStore, + StudioSessionStore, + StudioTrace, + StudioTraceStore, + StudioTraceSummary, +} from "../types"; +import type { ResolvedStores } from "./shared"; +import { streamStudioJsonl } from "./streams"; + +type ObservabilitySubscription = { + close: () => void; + next: () => Promise>; + push: (event: StudioObservabilityEvent) => void; +}; + +const defaultBufferSize = 1000; + +export class StudioObservabilityHub { + private readonly subscriptions = new Set(); + + emit(event: StudioObservabilityEvent): void { + for (const subscription of this.subscriptions) { + subscription.push(event); + } + } + + subscribe( + options: { types?: Set } = {}, + ): ObservabilitySubscription { + const subscription = createSubscription(options.types); + this.subscriptions.add(subscription); + return { + close: () => { + subscription.close(); + this.subscriptions.delete(subscription); + }, + next: subscription.next, + push: subscription.push, + }; + } +} + +export function observeStores(stores: ResolvedStores, hub: StudioObservabilityHub): ResolvedStores { + return { + ...stores, + ...(stores.sessions === undefined + ? {} + : { sessions: observeSessionStore(stores.sessions, hub) }), + ...(stores.traces === undefined ? {} : { traces: observeTraceStore(stores.traces, hub) }), + ...(stores.pipelineLogs === undefined + ? {} + : { pipelineLogs: observePipelineLogStore(stores.pipelineLogs, hub) }), + }; +} + +export function registerObservabilityRoutes(app: Hono, hub: StudioObservabilityHub): void { + app.get("/observability/events", (c) => { + const types = parseEventTypes(c.req.query("type")); + if (types === false) { + return c.json( + { + error: { + code: "bad_request", + message: "type must include session_log, pipeline_log, or trace", + }, + }, + 400, + ); + } + + return streamStudioJsonl(observabilityEvents(hub, types)); + }); +} + +function observabilityEvents( + hub: StudioObservabilityHub, + types: Set | undefined, +): AsyncIterable { + const subscription = hub.subscribe(types === undefined ? {} : { types }); + + return { + [Symbol.asyncIterator]() { + return { + next: () => subscription.next(), + async return() { + subscription.close(); + return { done: true, value: undefined }; + }, + }; + }, + }; +} + +function createSubscription( + types: Set | undefined, +): ObservabilitySubscription { + const values: StudioObservabilityEvent[] = []; + const resolvers: Array<(value: IteratorResult) => void> = []; + let closed = false; + + return { + close() { + closed = true; + for (const resolve of resolvers.splice(0)) { + resolve({ done: true, value: undefined }); + } + }, + next() { + const value = values.shift(); + if (value !== undefined) { + return Promise.resolve({ done: false, value }); + } + if (closed) { + return Promise.resolve({ done: true, value: undefined }); + } + return new Promise((resolve) => resolvers.push(resolve)); + }, + push(event) { + if (closed || (types !== undefined && !types.has(event.type))) { + return; + } + const resolve = resolvers.shift(); + if (resolve !== undefined) { + resolve({ done: false, value: event }); + return; + } + if (values.length >= defaultBufferSize) { + values.shift(); + } + values.push(event); + }, + }; +} + +function observeSessionStore( + store: StudioSessionStore, + hub: StudioObservabilityHub, +): StudioSessionStore { + return new Proxy(store, { + get(target, property, receiver) { + if (property !== "appendSessionLog") { + return boundProperty(target, property, receiver); + } + const appendSessionLog = target.appendSessionLog?.bind(target); + if (appendSessionLog === undefined) { + return undefined; + } + return async (...args: Parameters>) => { + const log = await appendSessionLog(...args); + hub.emit({ type: "session_log", log }); + return log; + }; + }, + }); +} + +function observePipelineLogStore( + store: StudioPipelineLogStore, + hub: StudioObservabilityHub, +): StudioPipelineLogStore { + return new Proxy(store, { + get(target, property, receiver) { + if (property !== "appendPipelineLog") { + return boundProperty(target, property, receiver); + } + const appendPipelineLog = target.appendPipelineLog.bind(target); + return async (...args: Parameters) => { + const log = await appendPipelineLog(...args); + hub.emit({ type: "pipeline_log", log }); + return log; + }; + }, + }); +} + +function observeTraceStore(store: StudioTraceStore, hub: StudioObservabilityHub): StudioTraceStore { + return new Proxy(store, { + get(target, property, receiver) { + if (property !== "saveTrace") { + return boundProperty(target, property, receiver); + } + const saveTrace = target.saveTrace.bind(target); + return async (...args: Parameters) => { + const trace = await saveTrace(...args); + hub.emit({ type: "trace", trace: traceSummary(trace) }); + return trace; + }; + }, + }); +} + +function boundProperty( + target: T, + property: string | symbol, + receiver: unknown, +): unknown { + const value = Reflect.get(target, property, receiver); + return typeof value === "function" ? value.bind(target) : value; +} + +function parseEventTypes( + value: string | undefined, +): Set | undefined | false { + if (value === undefined || value.trim().length === 0) { + return undefined; + } + + const types = new Set(); + for (const type of value.split(",")) { + const trimmed = type.trim(); + if (!isEventType(trimmed)) { + return false; + } + types.add(trimmed); + } + return types; +} + +function isEventType(value: string): value is StudioObservabilityEventType { + return value === "session_log" || value === "pipeline_log" || value === "trace"; +} + +function traceSummary(trace: StudioTrace): StudioTraceSummary { + return { + id: trace.id, + sessionId: trace.sessionId, + ...(trace.name === undefined ? {} : { name: trace.name }), + status: trace.status, + startedAt: trace.startedAt, + ...(trace.endedAt === undefined ? {} : { endedAt: trace.endedAt }), + ...(trace.durationMs === undefined ? {} : { durationMs: trace.durationMs }), + ...(trace.output === undefined ? {} : { output: trace.output }), + ...(trace.error === undefined ? {} : { error: trace.error }), + ...(trace.usage === undefined ? {} : { usage: trace.usage }), + ...(trace.metadata === undefined ? {} : { metadata: trace.metadata }), + observationCount: trace.observations.length, + }; +} diff --git a/packages/tools/studio/src/runtime/pipeline-logs.ts b/packages/tools/studio/src/runtime/pipeline-logs.ts new file mode 100644 index 00000000..66d3a580 --- /dev/null +++ b/packages/tools/studio/src/runtime/pipeline-logs.ts @@ -0,0 +1,196 @@ +import type { JsonObject } from "@anvia/core/completion"; +import type { PipelineGraphNode, PipelineRunEvent } from "@anvia/core/pipeline"; +import type { + StudioPipeline, + StudioPipelineLogAppendInput, + StudioPipelineLogEntry, + StudioPipelineLogStore, +} from "../types"; +import { serializeError } from "./shared"; + +export async function appendPipelineLog( + store: StudioPipelineLogStore | undefined, + input: StudioPipelineLogAppendInput, +): Promise { + return store?.appendPipelineLog(input); +} + +export async function* emitPipelineLog( + store: StudioPipelineLogStore | undefined, + input: StudioPipelineLogAppendInput, +): AsyncIterable<{ type: "pipeline_log"; log: StudioPipelineLogEntry }> { + const log = await appendPipelineLog(store, input); + if (log !== undefined) { + yield { type: "pipeline_log", log }; + } +} + +export function pipelineRunReceivedLog(props: { + pipeline: StudioPipeline; + runId: string; + stream: boolean; + input: unknown; + metadata?: JsonObject; +}): StudioPipelineLogAppendInput { + return { + pipelineId: props.pipeline.id, + runId: props.runId, + level: "info", + category: "api", + event: "pipeline.run_received", + message: "Pipeline run request received", + metadata: cleanMetadata({ + stream: props.stream, + inputBytes: byteLength(formatUnknown(props.input)), + metadataKeys: Object.keys(props.metadata ?? {}), + }), + }; +} + +export function pipelineRunStartedLog( + pipeline: StudioPipeline, + runId: string, +): StudioPipelineLogAppendInput { + const graph = pipeline.pipeline.graph(); + return { + pipelineId: pipeline.id, + runId, + level: "info", + category: "run", + event: "pipeline.run_started", + message: "Pipeline run started", + metadata: cleanMetadata({ + stageCount: graph.nodes.filter((node) => node.kind !== "input" && node.kind !== "output") + .length, + edgeCount: graph.edges.length, + }), + }; +} + +export function pipelineRunCompletedLog(props: { + pipelineId: string; + runId: string; + durationMs: number; + output: unknown; +}): StudioPipelineLogAppendInput { + return { + pipelineId: props.pipelineId, + runId: props.runId, + level: "info", + category: "run", + event: "pipeline.run_completed", + message: "Pipeline run completed", + metadata: cleanMetadata({ + durationMs: props.durationMs, + outputBytes: byteLength(formatUnknown(props.output)), + }), + }; +} + +export function pipelineRunFailedLog( + pipelineId: string, + runId: string, + error: unknown, + startedAt: number, +): StudioPipelineLogAppendInput { + return { + pipelineId, + runId, + level: "error", + category: "run", + event: "pipeline.run_failed", + message: "Pipeline run failed", + metadata: cleanMetadata({ + durationMs: Date.now() - startedAt, + error: serializeError(error), + }), + }; +} + +export function pipelineStageLog( + pipelineId: string, + runId: string, + event: PipelineRunEvent, +): StudioPipelineLogAppendInput { + const category = stageCategory(event.node); + if (event.type === "stage_started") { + return { + pipelineId, + runId, + level: "debug", + category, + event: `${event.node.kind}.started`, + message: `${event.node.label} started`, + metadata: nodeMetadata(event.node), + }; + } + if (event.type === "stage_completed") { + return { + pipelineId, + runId, + level: "debug", + category, + event: `${event.node.kind}.completed`, + message: `${event.node.label} completed`, + metadata: cleanMetadata({ + ...nodeMetadata(event.node), + durationMs: event.durationMs, + }), + }; + } + return { + pipelineId, + runId, + level: "error", + category, + event: `${event.node.kind}.failed`, + message: `${event.node.label} failed`, + metadata: cleanMetadata({ + ...nodeMetadata(event.node), + durationMs: event.durationMs, + error: serializeError(event.error), + }), + }; +} + +function stageCategory(node: PipelineGraphNode): StudioPipelineLogAppendInput["category"] { + if (node.kind === "parallel" || node.kind === "branch") { + return "parallel"; + } + if (node.kind === "agent") { + return "agent"; + } + if (node.kind === "extractor") { + return "extractor"; + } + return "stage"; +} + +function nodeMetadata(node: PipelineGraphNode): JsonObject { + return cleanMetadata({ + nodeId: node.id, + kind: node.kind, + label: node.label, + agentId: node.agentId, + pipelineId: node.pipelineId, + branchKey: node.branchKey, + }); +} + +function cleanMetadata(value: Record): JsonObject { + return Object.fromEntries( + Object.entries(value).filter(([, item]) => item !== undefined), + ) as JsonObject; +} + +function byteLength(value: string | undefined): number | undefined { + return value === undefined ? undefined : new TextEncoder().encode(value).length; +} + +function formatUnknown(value: unknown): string | undefined { + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} diff --git a/packages/tools/studio/src/runtime/pipelines.ts b/packages/tools/studio/src/runtime/pipelines.ts new file mode 100644 index 00000000..7759f68b --- /dev/null +++ b/packages/tools/studio/src/runtime/pipelines.ts @@ -0,0 +1,527 @@ +import type { JsonObject, JsonValue } from "@anvia/core/completion"; +import type { PipelineRunEvent } from "@anvia/core/pipeline"; +import type { Context, Hono } from "hono"; +import type { + AgentRunStreamEvent, + StudioPipeline, + StudioPipelineDetail, + StudioPipelineLogStore, + StudioPipelineReplayRequest, + StudioPipelineRunRequest, + StudioPipelineRunResponse, + StudioPipelineRunSaveInput, + StudioPipelineRunStore, +} from "../types"; +import { + appendPipelineLog, + emitPipelineLog, + pipelineRunCompletedLog, + pipelineRunFailedLog, + pipelineRunReceivedLog, + pipelineRunStartedLog, + pipelineStageLog, +} from "./pipeline-logs"; +import { AsyncEventQueue } from "./runs"; +import { errorResponse, isJsonObject, isObject, pipelineConfig, serializeError } from "./shared"; +import { streamStudioJsonl } from "./streams"; + +export function registerPipelineRoutes( + app: Hono, + props: { + pipelines: StudioPipeline[]; + pipelineMap: Map; + logStore?: StudioPipelineLogStore; + runStore?: StudioPipelineRunStore; + }, +): void { + app.get("/pipelines", (c) => + c.json({ + pipelines: props.pipelines.map(pipelineConfig), + }), + ); + + app.get("/pipelines/:pipelineId", (c) => { + const pipeline = props.pipelineMap.get(c.req.param("pipelineId")); + if (pipeline === undefined) { + return errorResponse(c, 404, "not_found", "Pipeline not found"); + } + return c.json(pipelineDetail(pipeline)); + }); + + app.get("/pipelines/:pipelineId/logs", async (c) => { + const pipelineId = c.req.param("pipelineId"); + if (!props.pipelineMap.has(pipelineId)) { + return errorResponse(c, 404, "not_found", "Pipeline not found"); + } + if (props.logStore === undefined) { + return errorResponse( + c, + 501, + "unsupported_capability", + 'Capability "pipelines.logs" is not implemented by this runner', + { capability: "pipelines", operation: "logs" }, + ); + } + + const limit = parsePipelineLogLimit(c.req.query("limit")); + if (limit === undefined) { + return errorResponse(c, 400, "bad_request", "limit must be a positive integer"); + } + const after = parsePipelineLogAfter(c.req.query("after")); + if (after === false) { + return errorResponse(c, 400, "bad_request", "after must be a non-negative integer"); + } + + const logs = await props.logStore.listPipelineLogs({ + pipelineId, + limit, + ...(after === undefined ? {} : { after }), + }); + const last = logs.at(-1); + return c.json({ + logs, + ...(logs.length === limit && last !== undefined ? { nextCursor: last.sequence } : {}), + }); + }); + + app.get("/pipelines/:pipelineId/runs", async (c) => { + const pipelineId = c.req.param("pipelineId"); + if (!props.pipelineMap.has(pipelineId)) { + return errorResponse(c, 404, "not_found", "Pipeline not found"); + } + if (props.runStore === undefined) { + return errorResponse( + c, + 501, + "unsupported_capability", + 'Capability "pipelines.runs" is not implemented by this runner', + { capability: "pipelines", operation: "runs" }, + ); + } + + const limit = parsePipelineLogLimit(c.req.query("limit")); + if (limit === undefined) { + return errorResponse(c, 400, "bad_request", "limit must be a positive integer"); + } + + const runs = await props.runStore.listPipelineRuns({ pipelineId, limit }); + return c.json({ runs }); + }); + + app.post("/pipelines/:pipelineId/runs", async (c) => { + const pipeline = props.pipelineMap.get(c.req.param("pipelineId")); + if (pipeline === undefined) { + return errorResponse(c, 404, "not_found", "Pipeline not found"); + } + + const body = await parsePipelineRunRequest(c); + if ("error" in body) { + return body.error; + } + + return executePipelineRun(c, props, pipeline, body); + }); + + app.post("/pipelines/:pipelineId/runs/:runId/replay", async (c) => { + const pipeline = props.pipelineMap.get(c.req.param("pipelineId")); + if (pipeline === undefined) { + return errorResponse(c, 404, "not_found", "Pipeline not found"); + } + if (props.runStore === undefined) { + return errorResponse( + c, + 501, + "unsupported_capability", + 'Capability "pipelines.runs" is not implemented by this runner', + { capability: "pipelines", operation: "runs" }, + ); + } + + const body = await parsePipelineReplayRequest(c); + if ("error" in body) { + return body.error; + } + + const sourceRunId = c.req.param("runId"); + const runs = await props.runStore.listPipelineRuns({ + pipelineId: pipeline.id, + limit: 1000, + }); + const sourceRun = runs.find((run) => run.runId === sourceRunId); + if (sourceRun === undefined) { + return errorResponse(c, 404, "not_found", "Pipeline run not found"); + } + if (sourceRun.status === "running") { + return errorResponse(c, 409, "conflict", "Cannot replay a running pipeline run"); + } + + return executePipelineRun(c, props, pipeline, { + input: sourceRun.input, + ...(body.stream === undefined ? {} : { stream: body.stream }), + metadata: replayMetadata(sourceRun.metadata, body.metadata, sourceRun.runId), + }); + }); +} + +async function executePipelineRun( + c: Context, + props: { + logStore?: StudioPipelineLogStore; + runStore?: StudioPipelineRunStore; + }, + pipeline: StudioPipeline, + body: StudioPipelineRunRequest, +): Promise { + const runId = globalThis.crypto.randomUUID(); + const startedAt = Date.now(); + const startedAtIso = new Date(startedAt).toISOString(); + await appendPipelineLog( + props.logStore, + pipelineRunReceivedLog({ + pipeline, + runId, + stream: body.stream === true, + input: body.input, + ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + }), + ); + await savePipelineRun(props.runStore, { + runId, + pipelineId: pipeline.id, + status: "running", + input: body.input, + ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + startedAt: startedAtIso, + }); + + if (body.stream === true) { + return streamPipelineRun(c, { + pipeline, + runId, + input: body.input, + startedAt, + startedAtIso, + ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + ...(props.logStore === undefined ? {} : { logStore: props.logStore }), + ...(props.runStore === undefined ? {} : { runStore: props.runStore }), + }); + } + + try { + await appendPipelineLog(props.logStore, pipelineRunStartedLog(pipeline, runId)); + const output = await pipeline.pipeline.run(body.input, { + observer: { + async onEvent(event) { + await appendPipelineLog(props.logStore, pipelineStageLog(pipeline.id, runId, event)); + }, + }, + }); + const jsonOutput = toJsonValue(output); + const endedAt = Date.now(); + await savePipelineRun(props.runStore, { + runId, + pipelineId: pipeline.id, + status: "success", + input: body.input, + output: jsonOutput, + ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + startedAt: startedAtIso, + endedAt: new Date(endedAt).toISOString(), + durationMs: endedAt - startedAt, + }); + await appendPipelineLog( + props.logStore, + pipelineRunCompletedLog({ + pipelineId: pipeline.id, + runId, + durationMs: endedAt - startedAt, + output: jsonOutput, + }), + ); + const response: StudioPipelineRunResponse = { + runId, + pipelineId: pipeline.id, + output: jsonOutput, + }; + return c.json(response); + } catch (error) { + const endedAt = Date.now(); + await savePipelineRun(props.runStore, { + runId, + pipelineId: pipeline.id, + status: "error", + input: body.input, + error: serializeError(error), + ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + startedAt: startedAtIso, + endedAt: new Date(endedAt).toISOString(), + durationMs: endedAt - startedAt, + }); + await appendPipelineLog( + props.logStore, + pipelineRunFailedLog(pipeline.id, runId, error, startedAt), + ); + return errorResponse(c, 500, "internal_error", "Pipeline run failed", serializeError(error)); + } +} + +function pipelineDetail(pipeline: StudioPipeline): StudioPipelineDetail { + const graph = pipeline.pipeline.graph(); + graph.id = pipeline.id; + return { + ...pipelineConfig(pipeline), + graph, + }; +} + +function streamPipelineRun( + _c: Context, + props: { + pipeline: StudioPipeline; + runId: string; + input: JsonValue; + startedAt: number; + startedAtIso: string; + metadata?: JsonObject; + logStore?: StudioPipelineLogStore; + runStore?: StudioPipelineRunStore; + }, +): Response { + return streamStudioJsonl(pipelineRunEvents(props)); +} + +async function* pipelineRunEvents(props: { + pipeline: StudioPipeline; + runId: string; + input: JsonValue; + startedAt: number; + startedAtIso: string; + metadata?: JsonObject; + logStore?: StudioPipelineLogStore; + runStore?: StudioPipelineRunStore; +}): AsyncIterable { + yield* emitPipelineLog(props.logStore, pipelineRunStartedLog(props.pipeline, props.runId)); + + const events = new AsyncEventQueue(); + const run = props.pipeline.pipeline + .run(props.input, { + observer: { + async onEvent(event: PipelineRunEvent) { + const log = await appendPipelineLog( + props.logStore, + pipelineStageLog(props.pipeline.id, props.runId, event), + ); + if (log !== undefined) { + events.push({ type: "pipeline_log", log }); + } + }, + }, + }) + .then(async (output) => { + const jsonOutput = toJsonValue(output); + const endedAt = Date.now(); + await savePipelineRun(props.runStore, { + runId: props.runId, + pipelineId: props.pipeline.id, + status: "success", + input: props.input, + output: jsonOutput, + ...(props.metadata === undefined ? {} : { metadata: props.metadata }), + startedAt: props.startedAtIso, + endedAt: new Date(endedAt).toISOString(), + durationMs: endedAt - props.startedAt, + }); + const log = await appendPipelineLog( + props.logStore, + pipelineRunCompletedLog({ + pipelineId: props.pipeline.id, + runId: props.runId, + durationMs: endedAt - props.startedAt, + output: jsonOutput, + }), + ); + if (log !== undefined) { + events.push({ type: "pipeline_log", log }); + } + events.push({ + type: "pipeline_final", + runId: props.runId, + pipelineId: props.pipeline.id, + output: jsonOutput, + }); + }) + .catch(async (error) => { + const endedAt = Date.now(); + await savePipelineRun(props.runStore, { + runId: props.runId, + pipelineId: props.pipeline.id, + status: "error", + input: props.input, + error: serializeError(error), + ...(props.metadata === undefined ? {} : { metadata: props.metadata }), + startedAt: props.startedAtIso, + endedAt: new Date(endedAt).toISOString(), + durationMs: endedAt - props.startedAt, + }); + const log = await appendPipelineLog( + props.logStore, + pipelineRunFailedLog(props.pipeline.id, props.runId, error, props.startedAt), + ); + if (log !== undefined) { + events.push({ type: "pipeline_log", log }); + } + events.push({ type: "error", error: serializeError(error) } as AgentRunStreamEvent); + }) + .finally(() => events.close()); + + try { + while (true) { + const next = await events.next(); + if (next.done === true) { + break; + } + yield next.value; + } + } finally { + await run; + } +} + +async function savePipelineRun( + store: StudioPipelineRunStore | undefined, + input: StudioPipelineRunSaveInput, +) { + return store?.savePipelineRun(input); +} + +async function parsePipelineRunRequest( + c: Context, +): Promise { + let body: unknown; + try { + body = await c.req.json(); + } catch { + return { error: errorResponse(c, 400, "bad_request", "Request body must be JSON") }; + } + + if (!isObject(body)) { + return { error: errorResponse(c, 400, "bad_request", "Request body must be an object") }; + } + if (!("input" in body) || !isJsonValue(body.input)) { + return { error: errorResponse(c, 400, "bad_request", "input must be JSON-compatible") }; + } + + const request: StudioPipelineRunRequest = { + input: body.input, + }; + if ("stream" in body) { + if (typeof body.stream !== "boolean") { + return { error: errorResponse(c, 400, "bad_request", "stream must be a boolean") }; + } + request.stream = body.stream; + } + if ("metadata" in body) { + if (!isJsonObject(body.metadata)) { + return { error: errorResponse(c, 400, "bad_request", "metadata must be an object") }; + } + request.metadata = body.metadata; + } + return request; +} + +async function parsePipelineReplayRequest( + c: Context, +): Promise { + let body: unknown; + try { + body = await c.req.json(); + } catch { + return { error: errorResponse(c, 400, "bad_request", "Request body must be JSON") }; + } + + if (!isObject(body)) { + return { error: errorResponse(c, 400, "bad_request", "Request body must be an object") }; + } + + const request: StudioPipelineReplayRequest = {}; + if ("stream" in body) { + if (typeof body.stream !== "boolean") { + return { error: errorResponse(c, 400, "bad_request", "stream must be a boolean") }; + } + request.stream = body.stream; + } + if ("metadata" in body) { + if (!isJsonObject(body.metadata)) { + return { error: errorResponse(c, 400, "bad_request", "metadata must be an object") }; + } + request.metadata = body.metadata; + } + return request; +} + +function replayMetadata( + sourceMetadata: JsonObject | undefined, + requestMetadata: JsonObject | undefined, + sourceRunId: string, +): JsonObject { + return { + ...(sourceMetadata ?? {}), + ...(requestMetadata ?? {}), + replayOf: sourceRunId, + }; +} + +function parsePipelineLogLimit(value: string | undefined): number | undefined { + if (value === undefined || value.trim().length === 0) { + return 200; + } + const limit = Number(value); + if (!Number.isInteger(limit) || limit <= 0) { + return undefined; + } + return Math.min(limit, 1000); +} + +function parsePipelineLogAfter(value: string | undefined): number | undefined | false { + if (value === undefined || value.trim().length === 0) { + return undefined; + } + const after = Number(value); + if (!Number.isInteger(after) || after < 0) { + return false; + } + return after; +} + +function isJsonValue(value: unknown): value is JsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return Number.isFinite(value) || typeof value !== "number"; + } + if (Array.isArray(value)) { + return value.every(isJsonValue); + } + if (isObject(value)) { + return Object.values(value).every((item) => item === undefined || isJsonValue(item)); + } + return false; +} + +function toJsonValue(value: unknown): JsonValue { + if (isJsonValue(value)) { + return value; + } + if (value === undefined) { + return null; + } + try { + const parsed = JSON.parse(JSON.stringify(value)) as unknown; + return isJsonValue(parsed) ? parsed : String(value); + } catch { + return String(value); + } +} diff --git a/packages/tools/studio/src/runtime/questions.ts b/packages/tools/studio/src/runtime/questions.ts index b4c38a40..fd901a48 100644 --- a/packages/tools/studio/src/runtime/questions.ts +++ b/packages/tools/studio/src/runtime/questions.ts @@ -1,5 +1,6 @@ -import type { JsonObject, JsonValue, PromptHook } from "@anvia/core"; -import { createHook, parseToolArgs } from "@anvia/core"; +import { createHook, type PromptHook } from "@anvia/core/agent"; +import type { JsonObject, JsonValue } from "@anvia/core/completion"; +import { parseToolArgs } from "@anvia/core/tool"; import type { Context, Hono } from "hono"; import type { AgentRunStreamEvent, diff --git a/packages/tools/studio/src/runtime/runs.ts b/packages/tools/studio/src/runtime/runs.ts index d26ba8e7..a560ef54 100644 --- a/packages/tools/studio/src/runtime/runs.ts +++ b/packages/tools/studio/src/runtime/runs.ts @@ -1,11 +1,13 @@ -import type { AgentStreamEvent, AgentTraceOptions, Message } from "@anvia/core"; +import type { AgentStreamEvent } from "@anvia/core/agent"; +import type { Message } from "@anvia/core/completion"; +import type { AgentTraceOptions } from "@anvia/core/observability"; import type { Context } from "hono"; -import { stream as streamResponse } from "hono/streaming"; import type { AgentRunRequest, AgentRunStreamEvent, StudioSession, StudioSessionStore, + StudioTranscriptChildAgentEvent, StudioTranscriptEntry, } from "../types"; import { @@ -19,6 +21,7 @@ import { isPositiveInteger, serializeError, } from "./shared"; +import { streamStudioJsonl } from "./streams"; export class AsyncEventQueue { private readonly values: T[] = []; @@ -109,26 +112,10 @@ export async function* mergeRunAndApprovalEvents( } export function streamAgentRunEvents( - c: Context, + _c: Context, events: AsyncIterable, ): Response { - c.header("content-type", "application/x-ndjson; charset=utf-8"); - c.header("cache-control", "no-cache, no-transform"); - c.header("connection", "keep-alive"); - c.header("transfer-encoding", "chunked"); - c.header("x-accel-buffering", "no"); - - return streamResponse( - c, - async (stream) => { - for await (const event of events) { - await stream.write(`${JSON.stringify(event)}\n`); - } - }, - async (error, stream) => { - await stream.write(`${JSON.stringify({ type: "error", error: serializeError(error) })}\n`); - }, - ); + return streamStudioJsonl(events); } export function traceForRun( @@ -151,30 +138,53 @@ export function traceForRun( }; } -export async function* persistStreamingSessionRun(props: { +export async function* persistStreamingSessionTranscript(props: { stream: AsyncIterable; store: StudioSessionStore; session: StudioSession; message: string | Message; + runId: string; }): AsyncIterable { const transcript: StudioTranscriptEntry[] = [messageToTranscriptEntry(props.message, 0)]; + const title = optionalTitle(props.message); + + await props.store.saveSessionRunTranscript({ + id: props.session.id, + runId: props.runId, + ...title, + transcript, + status: "running", + }); - for await (const event of props.stream) { - acceptTranscriptStreamEvent(transcript, event); + try { + for await (const event of props.stream) { + acceptTranscriptStreamEvent(transcript, event); - if (event.type === "final") { - const nextSession = await props.store.appendSessionRun({ + const nextSession = await props.store.saveSessionRunTranscript({ id: props.session.id, - ...optionalTitle(props.message), - messages: event.messages, + runId: props.runId, + ...title, transcript, + status: event.type === "final" ? "success" : event.type === "error" ? "error" : "running", + ...(event.type === "error" ? { error: serializeError(event.error) } : {}), }); if (nextSession === undefined) { throw new Error("Session not found"); } - } - yield event; + yield event; + } + } catch (error) { + appendTranscriptAssistantError(transcript, errorText(error)); + await props.store.saveSessionRunTranscript({ + id: props.session.id, + runId: props.runId, + ...title, + transcript, + status: "error", + error: serializeError(error), + }); + throw error; } } @@ -207,11 +217,33 @@ function acceptTranscriptStreamEvent( ...(event.toolCallId === undefined ? {} : { callId: event.toolCallId }), args: event.args, result: event.result, + ...(event.structuredResult === undefined + ? {} + : { structuredResult: event.structuredResult }), }); return; } matched.args = matched.args ?? event.args; matched.result = event.result; + if (event.structuredResult !== undefined) { + matched.structuredResult = event.structuredResult; + } + } + if (event.type === "agent_tool_event") { + const matched = findTranscriptToolEntry(transcript, event.toolName, event.toolCallId); + if (matched === undefined) { + transcript.push({ + entryId: transcript.length, + kind: "tool", + toolName: event.toolName, + ...(event.toolCallId === undefined ? {} : { callId: event.toolCallId }), + childEvents: [childAgentTranscriptEvent(event)].filter( + (childEvent): childEvent is StudioTranscriptChildAgentEvent => childEvent !== undefined, + ), + }); + return; + } + appendChildAgentTranscriptEvent(matched, event); } if (event.type === "tool_approval_request") { const matched = findTranscriptToolEntry( @@ -282,6 +314,9 @@ function acceptTranscriptStreamEvent( if (event.type === "final" && event.trace?.traceId !== undefined) { assignTranscriptTraceId(transcript, event.trace.traceId); } + if (event.type === "error") { + appendTranscriptAssistantError(transcript, errorText(event.error)); + } } function approvalCallId(approval: { callId?: string; toolCallId?: string }): string | undefined { @@ -292,6 +327,133 @@ function questionCallId(question: { callId?: string; toolCallId?: string }): str return question.callId ?? question.toolCallId; } +function appendChildAgentTranscriptEvent( + entry: Extract, + event: Extract, +): void { + const childEvent = childAgentTranscriptEvent(event); + if (childEvent === undefined) { + return; + } + const childEvents = entry.childEvents ?? []; + if (childEvent.kind === "message") { + const last = childEvents.at(-1); + if (last?.kind === "message" && last.agentId === childEvent.agentId) { + last.text = `${last.text}${childEvent.text}`; + } else { + childEvents.push(childEvent); + } + } else if (childEvent.kind === "reasoning") { + const last = childEvents.at(-1); + if ( + last?.kind === "reasoning" && + last.agentId === childEvent.agentId && + (last.reasoningId ?? "") === (childEvent.reasoningId ?? "") + ) { + last.text = `${last.text}${childEvent.text}`; + } else { + childEvents.push(childEvent); + } + } else { + const matched = findChildAgentToolEvent(childEvents, childEvent); + if (matched === undefined) { + childEvents.push(childEvent); + } else { + if (matched.args === undefined && childEvent.args !== undefined) { + matched.args = childEvent.args; + } + if (childEvent.result !== undefined) { + matched.result = childEvent.result; + } + } + } + entry.childEvents = childEvents; +} + +function childAgentTranscriptEvent( + event: Extract, +): StudioTranscriptChildAgentEvent | undefined { + const child = event.event; + if (child.type === "text_delta") { + return { + kind: "message", + agentId: event.agentId, + ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + text: child.delta, + }; + } + if (child.type === "reasoning_delta") { + return { + kind: "reasoning", + agentId: event.agentId, + ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + ...(child.id === undefined ? {} : { reasoningId: child.id }), + text: child.delta, + }; + } + if (child.type === "tool_call") { + return { + kind: "tool", + agentId: event.agentId, + ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + toolName: child.toolCall.function.name, + ...(child.toolCall.callId === undefined && child.toolCall.id === undefined + ? {} + : { callId: child.toolCall.callId ?? child.toolCall.id }), + args: formatJson(child.toolCall.function.arguments), + }; + } + if (child.type === "tool_result") { + return { + kind: "tool", + agentId: event.agentId, + ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + toolName: child.toolName, + ...(child.toolCallId === undefined ? {} : { callId: child.toolCallId }), + args: child.args, + result: child.result, + ...(child.structuredResult === undefined ? {} : { structuredResult: child.structuredResult }), + }; + } + if (child.type === "error") { + return { + kind: "message", + agentId: event.agentId, + ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + text: `Error: ${errorText(child.error)}`, + }; + } + return undefined; +} + +function errorText(error: unknown): string { + if (typeof error === "string") { + return error; + } + return JSON.stringify(serializeError(error)); +} + +function findChildAgentToolEvent( + childEvents: StudioTranscriptChildAgentEvent[], + event: Extract, +): Extract | undefined { + for (let index = childEvents.length - 1; index >= 0; index -= 1) { + const childEvent = childEvents[index]; + if ( + childEvent?.kind !== "tool" || + childEvent.agentId !== event.agentId || + childEvent.toolName !== event.toolName || + childEvent.result !== undefined + ) { + continue; + } + if (event.callId === undefined || childEvent.callId === event.callId) { + return childEvent; + } + } + return undefined; +} + export function transcriptFromMessages(messages: Message[]): StudioTranscriptEntry[] { const transcript: StudioTranscriptEntry[] = []; for (const message of messages) { @@ -319,8 +481,11 @@ export function transcriptFromMessages(messages: Message[]): StudioTranscriptEnt toolName: "tool_result", callId: content.callId ?? content.id, result: content.content - .map((item) => ("text" in item ? item.text : "[image]")) + .map((item) => + "text" in item ? item.text : `[image:${item.mediaType ?? "image/png"}]`, + ) .join("\n"), + structuredResult: content.content, }); } continue; @@ -360,7 +525,7 @@ function messageToTranscriptEntry( function appendTranscriptAssistantText(transcript: StudioTranscriptEntry[], delta: string): void { const last = transcript.at(-1); - if (last?.kind === "message" && last.role === "assistant") { + if (last?.kind === "message" && last.role === "assistant" && last.tone !== "error") { last.text = `${last.text}${delta}`; return; } @@ -372,6 +537,25 @@ function appendTranscriptAssistantText(transcript: StudioTranscriptEntry[], delt }); } +function appendTranscriptAssistantError(transcript: StudioTranscriptEntry[], text: string): void { + const last = transcript.at(-1); + if ( + last?.kind === "message" && + last.role === "assistant" && + last.tone === "error" && + last.text === text + ) { + return; + } + transcript.push({ + entryId: transcript.length, + kind: "message", + role: "assistant", + text, + tone: "error", + }); +} + function assignTranscriptTraceId(transcript: StudioTranscriptEntry[], traceId: string): void { for (let index = transcript.length - 1; index >= 0; index -= 1) { const entry = transcript[index]; @@ -446,7 +630,9 @@ function extractMessageText(message: string | Message): string { return [`${item.function.name}(${formatJson(item.function.arguments)})`]; } if (item.type === "tool_result") { - return item.content.map((result) => ("text" in result ? result.text : "[image]")); + return item.content.map((result) => + "text" in result ? result.text : `[image:${result.mediaType ?? "image/png"}]`, + ); } return []; }) diff --git a/packages/tools/studio/src/runtime/session-logs.ts b/packages/tools/studio/src/runtime/session-logs.ts new file mode 100644 index 00000000..16b72d9d --- /dev/null +++ b/packages/tools/studio/src/runtime/session-logs.ts @@ -0,0 +1,579 @@ +import type { JsonObject, JsonValue, Message } from "@anvia/core/completion"; +import type { + AgentRunStreamEvent, + StudioSession, + StudioSessionLogAppendInput, + StudioSessionLogEntry, + StudioSessionStore, +} from "../types"; +import { serializeError } from "./shared"; + +export async function appendSessionLog( + store: StudioSessionStore | undefined, + input: StudioSessionLogAppendInput, +): Promise { + return store?.appendSessionLog?.(input); +} + +export async function* streamSessionRunLogs(props: { + stream: AsyncIterable; + store: StudioSessionStore; + session: StudioSession; + runId: string; + startedAt: number; +}): AsyncIterable { + yield* emitLog(props.store, runStartedLog(props.session, props.runId)); + yield* emitLog(props.store, memoryLoadedLog(props.session, props.runId)); + + try { + for await (const event of props.stream) { + for (const input of logsFromStreamEvent({ + event, + runId: props.runId, + sessionId: props.session.id, + startedAt: props.startedAt, + })) { + yield* emitLog(props.store, input); + } + yield event; + } + } catch (error) { + yield* emitLog( + props.store, + runFailedLog(props.session.id, props.runId, error, props.startedAt), + ); + throw error; + } +} + +export function sessionCreatedLog( + session: StudioSession | { id: string; agentId: string; title?: string }, +): StudioSessionLogAppendInput { + return { + sessionId: session.id, + level: "info", + category: "session", + event: "session.created", + message: "Session created", + metadata: cleanMetadata({ + agentId: session.agentId, + hasTitle: session.title !== undefined, + titleLength: session.title?.length ?? 0, + }), + }; +} + +export function runReceivedLog(props: { + sessionId: string; + runId: string; + agentId: string; + message: string | Message; + stream: boolean; + maxTurns?: number; + toolConcurrency?: number; + hasTrace: boolean; + metadata?: JsonObject; +}): StudioSessionLogAppendInput { + return { + sessionId: props.sessionId, + runId: props.runId, + level: "info", + category: "api", + event: "run.received", + message: "Run request received", + metadata: cleanMetadata({ + agentId: props.agentId, + stream: props.stream, + message: messageSummary(props.message), + maxTurns: props.maxTurns, + toolConcurrency: props.toolConcurrency, + hasTrace: props.hasTrace, + metadataKeys: Object.keys(props.metadata ?? {}), + }), + }; +} + +export function runStartedLog(session: StudioSession, runId: string): StudioSessionLogAppendInput { + return { + sessionId: session.id, + runId, + level: "info", + category: "run", + event: "run.started", + message: "Run started", + metadata: cleanMetadata({ + agentId: session.agentId, + existingMessageCount: session.messageCount, + }), + }; +} + +export function memoryLoadedLog( + session: StudioSession, + runId: string, +): StudioSessionLogAppendInput { + return { + sessionId: session.id, + runId, + level: "debug", + category: "memory", + event: "memory.loaded", + message: "Session memory loaded", + metadata: cleanMetadata({ + messageCount: session.messageCount, + transcriptEntries: session.transcript.length, + }), + }; +} + +export function runCompletedLog(props: { + sessionId: string; + runId: string; + durationMs: number; + usage?: unknown; + output?: string; + messageCount?: number; +}): StudioSessionLogAppendInput { + return { + sessionId: props.sessionId, + runId: props.runId, + level: "info", + category: "run", + event: "run.completed", + message: "Run completed", + metadata: cleanMetadata({ + durationMs: props.durationMs, + usage: usageSummary(props.usage), + outputBytes: byteLength(props.output), + messageCount: props.messageCount, + }), + }; +} + +export function memorySavedLog(props: { + sessionId: string; + runId: string; + messageCount?: number; +}): StudioSessionLogAppendInput { + return { + sessionId: props.sessionId, + runId: props.runId, + level: "debug", + category: "memory", + event: "memory.saved", + message: "Session memory saved", + metadata: cleanMetadata({ + messageCount: props.messageCount, + }), + }; +} + +export function runFailedLog( + sessionId: string, + runId: string, + error: unknown, + startedAt: number, +): StudioSessionLogAppendInput { + return { + sessionId, + runId, + level: "error", + category: "run", + event: "run.failed", + message: "Run failed", + metadata: cleanMetadata({ + durationMs: Date.now() - startedAt, + error: serializeError(error), + }), + }; +} + +function logsFromStreamEvent(props: { + event: AgentRunStreamEvent; + sessionId: string; + runId: string; + startedAt: number; +}): StudioSessionLogAppendInput[] { + const { event, sessionId, runId } = props; + if (event.type === "turn_start") { + return [ + { + sessionId, + runId, + level: "debug", + category: "prompt", + event: "prompt.prepared", + message: `Turn ${event.turn} prompt prepared`, + metadata: cleanMetadata({ + turn: event.turn, + prompt: messageSummary(event.prompt), + historyCount: event.history.length, + }), + }, + ]; + } + if (event.type === "tool_call") { + return [ + { + sessionId, + runId, + level: "info", + category: "tool", + event: "tool.called", + message: `Tool ${event.toolCall.function.name} called`, + metadata: cleanMetadata({ + turn: event.turn, + toolName: event.toolCall.function.name, + callId: event.toolCall.callId ?? event.toolCall.id, + argumentBytes: byteLength(formatUnknown(event.toolCall.function.arguments)), + }), + }, + ]; + } + if (event.type === "tool_result") { + return [ + { + sessionId, + runId, + level: "info", + category: "tool", + event: "tool.completed", + message: `Tool ${event.toolName} completed`, + metadata: cleanMetadata({ + turn: event.turn, + toolName: event.toolName, + callId: event.toolCallId, + internalCallId: event.internalCallId, + argumentBytes: byteLength(event.args), + resultBytes: byteLength(event.result), + structuredResultBytes: + event.structuredResult === undefined + ? undefined + : byteLength(JSON.stringify(event.structuredResult)), + }), + }, + ]; + } + if (event.type === "turn_end") { + return [ + { + sessionId, + runId, + level: "debug", + category: "model", + event: "model.turn.completed", + message: `Model turn ${event.turn} completed`, + metadata: cleanMetadata({ + turn: event.turn, + contentCount: event.response.choice.length, + usage: usageSummary(event.response.usage), + }), + }, + ]; + } + if (event.type === "final") { + return [ + runCompletedLog({ + sessionId, + runId, + durationMs: Date.now() - props.startedAt, + usage: event.usage, + output: event.output, + messageCount: event.messages.length, + }), + memorySavedLog({ sessionId, runId, messageCount: event.messages.length }), + ]; + } + if (event.type === "error") { + return [runFailedLog(sessionId, runId, event.error, props.startedAt)]; + } + if (event.type === "tool_approval_request") { + return [ + { + sessionId, + runId, + level: "info", + category: "approval", + event: "approval.requested", + message: `Approval requested for ${event.approval.toolName}`, + metadata: cleanMetadata({ + approvalId: event.approval.id, + toolName: event.approval.toolName, + callId: event.approval.callId, + status: event.approval.status, + hasReason: event.approval.reason !== undefined, + argumentBytes: byteLength(event.approval.args), + }), + }, + ]; + } + if (event.type === "tool_approval_result") { + return [ + { + sessionId, + runId, + level: event.approval.status === "approved" ? "info" : "warn", + category: "approval", + event: "approval.resolved", + message: `Approval ${event.approval.status} for ${event.approval.toolName}`, + metadata: cleanMetadata({ + approvalId: event.approval.id, + toolName: event.approval.toolName, + callId: event.approval.callId, + status: event.approval.status, + hasReason: event.approval.reason !== undefined, + }), + }, + ]; + } + if (event.type === "tool_question_request") { + return [ + { + sessionId, + runId, + level: "info", + category: "question", + event: "question.requested", + message: `Question requested by ${event.question.toolName}`, + metadata: cleanMetadata({ + questionId: event.question.id, + toolName: event.question.toolName, + callId: event.question.callId, + status: event.question.status, + questionCount: event.question.questions.length, + argumentBytes: byteLength(event.question.args), + }), + }, + ]; + } + if (event.type === "tool_question_result") { + return [ + { + sessionId, + runId, + level: "info", + category: "question", + event: "question.answered", + message: `Question answered for ${event.question.toolName}`, + metadata: cleanMetadata({ + questionId: event.question.id, + toolName: event.question.toolName, + callId: event.question.callId, + status: event.question.status, + answerCount: event.question.answers?.length ?? 0, + }), + }, + ]; + } + if (event.type === "agent_tool_event") { + return childAgentLog(event, sessionId, runId); + } + return []; +} + +async function* emitLog( + store: StudioSessionStore, + input: StudioSessionLogAppendInput, +): AsyncIterable { + const log = await appendSessionLog(store, input); + if (log !== undefined) { + yield { type: "session_log", log }; + } +} + +function childAgentLog( + event: Extract, + sessionId: string, + runId: string, +): StudioSessionLogAppendInput[] { + const child = event.event; + if (child.type === "tool_call") { + return [ + { + sessionId, + runId, + level: "debug", + category: "tool", + event: "child_tool.called", + message: `Child agent ${event.agentName ?? event.agentId} called ${child.toolCall.function.name}`, + metadata: cleanMetadata({ + parentToolName: event.toolName, + agentId: event.agentId, + hasAgentName: event.agentName !== undefined, + turn: event.turn, + childTurn: child.turn, + toolName: child.toolCall.function.name, + callId: child.toolCall.callId ?? child.toolCall.id, + argumentBytes: byteLength(formatUnknown(child.toolCall.function.arguments)), + }), + }, + ]; + } + if (child.type === "tool_result") { + return [ + { + sessionId, + runId, + level: "debug", + category: "tool", + event: "child_tool.completed", + message: `Child agent ${event.agentName ?? event.agentId} completed ${child.toolName}`, + metadata: cleanMetadata({ + parentToolName: event.toolName, + agentId: event.agentId, + hasAgentName: event.agentName !== undefined, + turn: event.turn, + childTurn: child.turn, + toolName: child.toolName, + callId: child.toolCallId, + resultBytes: byteLength(child.result), + structuredResultBytes: + child.structuredResult === undefined + ? undefined + : byteLength(JSON.stringify(child.structuredResult)), + }), + }, + ]; + } + if (child.type === "turn_start") { + return [ + { + sessionId, + runId, + level: "debug", + category: "run", + event: "child_agent.turn_started", + message: `Child agent ${event.agentName ?? event.agentId} turn ${child.turn} started`, + metadata: cleanMetadata({ + parentToolName: event.toolName, + agentId: event.agentId, + hasAgentName: event.agentName !== undefined, + childTurn: child.turn, + historyCount: child.history.length, + }), + }, + ]; + } + if (child.type === "final") { + return [ + { + sessionId, + runId, + level: "debug", + category: "run", + event: "child_agent.completed", + message: `Child agent ${event.agentName ?? event.agentId} completed`, + metadata: cleanMetadata({ + parentToolName: event.toolName, + agentId: event.agentId, + hasAgentName: event.agentName !== undefined, + usage: usageSummary(child.usage), + outputBytes: byteLength(child.output), + messageCount: child.messages.length, + }), + }, + ]; + } + if (child.type === "error") { + return [ + { + sessionId, + runId, + level: "error", + category: "run", + event: "child_agent.failed", + message: `Child agent ${event.agentName ?? event.agentId} failed`, + metadata: cleanMetadata({ + parentToolName: event.toolName, + agentId: event.agentId, + hasAgentName: event.agentName !== undefined, + error: serializeError(child.error), + }), + }, + ]; + } + return []; +} + +function messageSummary(message: string | Message): JsonObject { + if (typeof message === "string") { + return { + role: "user", + contentKind: "text", + byteLength: byteLength(message), + }; + } + return { + role: message.role, + contentKind: Array.isArray(message.content) ? "parts" : "text", + partCount: Array.isArray(message.content) ? message.content.length : 1, + byteLength: byteLength(formatUnknown(message.content)), + }; +} + +function usageSummary(value: unknown): JsonObject | undefined { + if (value === undefined || value === null || typeof value !== "object") { + return undefined; + } + const record = value as Record; + return cleanMetadata({ + inputTokens: numericValue(record.inputTokens), + outputTokens: numericValue(record.outputTokens), + totalTokens: numericValue(record.totalTokens), + cachedInputTokens: numericValue(record.cachedInputTokens), + cacheCreationInputTokens: numericValue(record.cacheCreationInputTokens), + }); +} + +function cleanMetadata(value: Record): JsonObject { + const cleaned: JsonObject = {}; + for (const [key, item] of Object.entries(value)) { + if (item === undefined) { + continue; + } + const jsonValue = cleanJsonValue(item); + if (jsonValue !== undefined) { + cleaned[key] = jsonValue; + } + } + return cleaned; +} + +function cleanJsonValue(value: unknown): JsonValue | undefined { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + if (Array.isArray(value)) { + return value + .map((item) => cleanJsonValue(item)) + .filter((item): item is JsonValue => item !== undefined); + } + if (typeof value === "object" && value !== null) { + return cleanMetadata(value as Record); + } + return undefined; +} + +function numericValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function byteLength(value: string | undefined): number { + return value === undefined ? 0 : new TextEncoder().encode(value).byteLength; +} + +function formatUnknown(value: unknown): string { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/packages/tools/studio/src/runtime/sessions.ts b/packages/tools/studio/src/runtime/sessions.ts index 0b2b3b10..52b0477e 100644 --- a/packages/tools/studio/src/runtime/sessions.ts +++ b/packages/tools/studio/src/runtime/sessions.ts @@ -1,6 +1,7 @@ -import type { JsonObject } from "@anvia/core"; +import type { JsonObject } from "@anvia/core/completion"; import type { Context, Hono } from "hono"; import type { StudioAgent, StudioSessionStore, StudioTraceStore } from "../types"; +import { appendSessionLog, sessionCreatedLog } from "./session-logs"; import { errorResponse, isJsonObject, @@ -51,6 +52,7 @@ export function registerSessionRoutes( ...(body.title === undefined ? {} : { title: body.title }), ...(body.metadata === undefined ? {} : { metadata: body.metadata }), }); + await appendSessionLog(props.sessionStore, sessionCreatedLog(session)); return c.json(session, 201); }); @@ -62,6 +64,43 @@ export function registerSessionRoutes( return c.json(session); }); + app.get("/sessions/:sessionId/logs", async (c) => { + const sessionId = c.req.param("sessionId"); + const session = await props.sessionStore.getSession(sessionId); + if (session === undefined) { + return errorResponse(c, 404, "not_found", "Session not found"); + } + if (props.sessionStore.listSessionLogs === undefined) { + return errorResponse( + c, + 501, + "unsupported_capability", + 'Capability "sessions.logs" is not implemented by this runner', + { capability: "sessions", operation: "logs" }, + ); + } + + const limit = parseSessionLogLimit(c.req.query("limit")); + if (limit === undefined) { + return errorResponse(c, 400, "bad_request", "limit must be a positive integer"); + } + const after = parseSessionLogAfter(c.req.query("after")); + if (after === false) { + return errorResponse(c, 400, "bad_request", "after must be a non-negative integer"); + } + + const logs = await props.sessionStore.listSessionLogs({ + sessionId, + limit, + ...(after === undefined ? {} : { after }), + }); + const last = logs.at(-1); + return c.json({ + logs, + ...(logs.length === limit && last !== undefined ? { nextCursor: last.sequence } : {}), + }); + }); + app.delete("/sessions/:sessionId", async (c) => { if (props.sessionStore.deleteSession === undefined) { return errorResponse( @@ -100,6 +139,28 @@ export function registerSessionRoutes( }); } +function parseSessionLogLimit(value: string | undefined): number | undefined { + if (value === undefined || value.trim().length === 0) { + return 200; + } + const limit = Number(value); + if (!Number.isInteger(limit) || limit <= 0) { + return undefined; + } + return Math.min(limit, 1000); +} + +function parseSessionLogAfter(value: string | undefined): number | undefined | false { + if (value === undefined || value.trim().length === 0) { + return undefined; + } + const after = Number(value); + if (!Number.isInteger(after) || after < 0) { + return false; + } + return after; +} + async function parseCreateSessionRequest(c: Context): Promise< | { agentId: string; diff --git a/packages/tools/studio/src/runtime/shared.ts b/packages/tools/studio/src/runtime/shared.ts index 0d74d881..9f540a3a 100644 --- a/packages/tools/studio/src/runtime/shared.ts +++ b/packages/tools/studio/src/runtime/shared.ts @@ -1,25 +1,37 @@ -import { join } from "node:path"; -import type { AgentTraceOptions, JsonObject, JsonValue, Message } from "@anvia/core"; +import type { JsonObject, JsonValue, Message } from "@anvia/core/completion"; +import type { AgentTraceOptions } from "@anvia/core/observability"; import type { Context } from "hono"; +import { createInMemoryStudioStore } from "../storage/memory-store"; import { createSqliteSessionStore } from "../storage/sqlite-store"; import type { StudioAgent, StudioAgentConfig, + StudioAgentRuntimeSummary, StudioCapability, StudioCapabilityConfig, StudioConfig, StudioErrorCode, StudioErrorResponse, + StudioEvalSuite, + StudioEvalSuiteConfig, + StudioPipeline, + StudioPipelineConfig, + StudioPipelineLogStore, + StudioPipelineRunStore, StudioSessionStore, StudioStores, StudioTraceStatus, StudioTraceStore, StudioUiOptions, } from "../types"; +import { toJsonValue } from "./json"; +import { agentHasMcpTools, agentToolItems, mcpServerName } from "./tool-metadata"; export type ResolvedStores = { sessions?: StudioSessionStore; traces?: StudioTraceStore; + pipelineLogs?: StudioPipelineLogStore; + pipelineRuns?: StudioPipelineRunStore; }; export type StudioRuntimeOptions = { @@ -28,24 +40,36 @@ export type StudioRuntimeOptions = { description?: string; version?: string; agents: StudioAgent[]; + pipelines: StudioPipeline[]; + evals: StudioEvalSuite[]; stores?: StudioStores; ui?: boolean | StudioUiOptions; }; export function resolveStores(options: StudioRuntimeOptions): ResolvedStores { - const defaultPath = - process.env.ANVIA_STUDIO_DB ?? - process.env.AION_STUDIO_DB ?? - join(process.cwd(), ".anvia-studio", `${safeFileName(runnerId(options))}.sqlite`); - const defaultStore = createSqliteSessionStore({ path: defaultPath }); + const defaultStore = defaultStudioStore(); const sessions = resolveSessionStore(options, defaultStore); const traces = resolveTraceStore(options, sessions, defaultStore); + const pipelineLogs = resolvePipelineLogStore(options, sessions, defaultStore); + const pipelineRuns = resolvePipelineRunStore(options, sessions, pipelineLogs, defaultStore); return { ...(sessions === undefined ? {} : { sessions }), ...(traces === undefined ? {} : { traces }), + ...(pipelineLogs === undefined ? {} : { pipelineLogs }), + ...(pipelineRuns === undefined ? {} : { pipelineRuns }), }; } +function defaultStudioStore(): StudioSessionStore & + StudioTraceStore & + StudioPipelineLogStore & + StudioPipelineRunStore { + const sqlitePath = process.env.ANVIA_STUDIO_DB ?? process.env.AION_STUDIO_DB; + return sqlitePath === undefined + ? createInMemoryStudioStore() + : createSqliteSessionStore({ path: sqlitePath }); +} + function resolveSessionStore( options: StudioRuntimeOptions, defaultStore: StudioSessionStore, @@ -77,6 +101,44 @@ function resolveTraceStore( return defaultStore; } +function resolvePipelineLogStore( + options: StudioRuntimeOptions, + sessionStore: StudioSessionStore | undefined, + defaultStore: StudioPipelineLogStore, +): StudioPipelineLogStore | undefined { + if (options.stores?.pipelineLogs === false) { + return undefined; + } + if (options.stores?.pipelineLogs !== undefined) { + return options.stores.pipelineLogs; + } + if (sessionStore !== undefined && isPipelineLogStore(sessionStore)) { + return sessionStore; + } + return defaultStore; +} + +function resolvePipelineRunStore( + options: StudioRuntimeOptions, + sessionStore: StudioSessionStore | undefined, + pipelineLogStore: StudioPipelineLogStore | undefined, + defaultStore: StudioPipelineRunStore, +): StudioPipelineRunStore | undefined { + if (options.stores?.pipelineRuns === false) { + return undefined; + } + if (options.stores?.pipelineRuns !== undefined) { + return options.stores.pipelineRuns; + } + if (sessionStore !== undefined && isPipelineRunStore(sessionStore)) { + return sessionStore; + } + if (pipelineLogStore !== undefined && isPipelineRunStore(pipelineLogStore)) { + return pipelineLogStore; + } + return defaultStore; +} + function isTraceStore(store: StudioSessionStore): store is StudioSessionStore & StudioTraceStore { const candidate = store as Partial; return ( @@ -86,6 +148,24 @@ function isTraceStore(store: StudioSessionStore): store is StudioSessionStore & ); } +function isPipelineLogStore( + store: StudioSessionStore, +): store is StudioSessionStore & StudioPipelineLogStore { + const candidate = store as Partial; + return ( + typeof candidate.appendPipelineLog === "function" && + typeof candidate.listPipelineLogs === "function" + ); +} + +function isPipelineRunStore(store: object): store is object & StudioPipelineRunStore { + const candidate = store as Partial; + return ( + typeof candidate.savePipelineRun === "function" && + typeof candidate.listPipelineRuns === "function" + ); +} + export function unsupportedCapabilities(stores: ResolvedStores): StudioCapability[] { return [ ...(stores.sessions === undefined ? (["sessions"] as const) : []), @@ -108,9 +188,25 @@ export function normalizeAgents(agents: StudioAgent[]): StudioAgent[] { }); } +export function normalizePipelines(pipelines: StudioPipeline[]): StudioPipeline[] { + const ids = new Set(); + return pipelines.map((pipeline) => { + const id = pipeline.id.trim(); + if (id.length === 0) { + throw new Error("Studio pipeline id cannot be empty"); + } + if (ids.has(id)) { + throw new Error(`Duplicate Studio pipeline id: ${id}`); + } + ids.add(id); + return { ...pipeline, id }; + }); +} + export function buildConfig( options: StudioRuntimeOptions, agents: StudioAgent[], + pipelines: StudioPipeline[], stores: ResolvedStores, ): StudioConfig { return { @@ -119,10 +215,12 @@ export function buildConfig( ...(options.description === undefined ? {} : { description: options.description }), ...(options.version === undefined ? {} : { version: options.version }), agents: agents.map(agentConfig), + pipelines: pipelines.map(pipelineConfig), + evals: options.evals.map(evalConfig), chat: { quickPrompts: Object.fromEntries(agents.map((agent) => [agent.id, agent.quickPrompts ?? []])), }, - capabilities: capabilityConfig(options, agents, stores), + capabilities: capabilityConfig(options, agents, pipelines, stores), unsupportedCapabilities: unsupportedCapabilities(stores), }; } @@ -143,26 +241,92 @@ export function agentConfig(agent: StudioAgent): StudioAgentConfig { }; } +export function agentRuntimeSummary(agent: StudioAgent): StudioAgentRuntimeSummary { + const tools = agentToolItems(agent); + const name = agent.name ?? agent.agent.name; + const description = agent.description ?? agent.agent.description; + return { + id: agent.id, + ...(name === undefined ? {} : { name }), + ...(description === undefined ? {} : { description }), + model: toJsonValue(agent.agent.model), + toolCount: tools.length, + staticToolCount: tools.filter((item) => item.source === "static").length, + dynamicToolCount: tools.filter((item) => item.source === "dynamic").length, + approvalToolCount: tools.filter((item) => item.tool.approval !== undefined).length, + mcpToolCount: tools.filter((item) => mcpServerName(item.tool) !== undefined).length, + staticContextCount: agent.agent.staticContext.length, + dynamicContextCount: agent.agent.dynamicContexts.length, + observerCount: agent.agent.observers.length, + hasMemory: agent.agent.memory !== undefined, + hasHook: agent.agent.hook !== undefined, + hasOutputSchema: agent.agent.outputSchema !== undefined, + ...(agent.agent.defaultMaxTurns === undefined + ? {} + : { defaultMaxTurns: agent.agent.defaultMaxTurns }), + ...(agent.metadata === undefined ? {} : { metadata: agent.metadata }), + }; +} + +export function pipelineConfig(pipeline: StudioPipeline): StudioPipelineConfig { + const graph = pipeline.pipeline.graph(); + const stageNodes = graph.nodes.filter((node) => node.kind !== "input" && node.kind !== "output"); + return { + id: pipeline.id, + ...(pipeline.name === undefined ? {} : { name: pipeline.name }), + ...(pipeline.description === undefined ? {} : { description: pipeline.description }), + ...(pipeline.metadata === undefined ? {} : { metadata: pipeline.metadata }), + stageCount: stageNodes.length, + edgeCount: graph.edges.length, + hasParallelStages: graph.nodes.some((node) => node.kind === "parallel"), + agentCount: graph.nodes.filter((node) => node.kind === "agent").length, + extractorCount: graph.nodes.filter((node) => node.kind === "extractor").length, + }; +} + export function capabilityConfig( _options: StudioRuntimeOptions, agents: StudioAgent[], + pipelines: StudioPipeline[], stores: ResolvedStores, ): Partial> { const capabilities: Partial> = { agents: { enabled: true }, + observability: { enabled: true }, + status: { enabled: true }, }; if (stores.sessions !== undefined) { capabilities.sessions = { enabled: true }; + capabilities.memory = { enabled: true }; } if (stores.traces !== undefined) { capabilities.traces = { enabled: true }; } - - if (agents.some((agent) => agent.agent.observers.length > 0)) { - capabilities.observability = { enabled: true }; + if (pipelines.length > 0) { + capabilities.pipelines = { enabled: true }; + } + if (_options.evals.length > 0) { + capabilities.evals = { enabled: true }; } - if (agents.some((agent) => agent.agent.toolSet.values().some((tool) => tool.approval))) { + if ( + agents.some( + (agent) => agent.agent.toolSet.values().length > 0 || agent.agent.dynamicTools.length > 0, + ) + ) { + capabilities.tools = { enabled: true }; + } + if (agents.some(agentHasMcpTools)) { + capabilities.mcps = { enabled: true }; + } + + if ( + agents.some( + (agent) => + agent.agent.hook !== undefined || + agent.agent.toolSet.values().some((tool) => tool.approval), + ) + ) { capabilities.approvals = { enabled: true }; } if ( @@ -178,6 +342,18 @@ export function capabilityConfig( return capabilities; } +export function evalConfig(suite: StudioEvalSuite): StudioEvalSuiteConfig { + return { + id: suite.id ?? suite.name, + name: suite.name, + ...(suite.description === undefined ? {} : { description: suite.description }), + caseCount: suite.cases.length, + metricNames: suite.metrics.map((metric) => metric.name), + ...(suite.concurrency === undefined ? {} : { concurrency: suite.concurrency }), + ...(suite.metadata === undefined ? {} : { metadata: suite.metadata }), + }; +} + export function optionalQueryString(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; @@ -202,10 +378,6 @@ export function parseTraceStatus(value: string | undefined): StudioTraceStatus | return status === "running" || status === "success" || status === "error" ? status : false; } -export function safeFileName(value: string): string { - return value.replace(/[^a-zA-Z0-9._-]+/g, "_") || "anvia-studio"; -} - export function isMessageInput(value: unknown): value is string | Message { return typeof value === "string" || isMessage(value); } diff --git a/packages/tools/studio/src/runtime/status.ts b/packages/tools/studio/src/runtime/status.ts new file mode 100644 index 00000000..3f94b33a --- /dev/null +++ b/packages/tools/studio/src/runtime/status.ts @@ -0,0 +1,63 @@ +import type { Hono } from "hono"; +import type { StudioAgent, StudioPipeline, StudioStatusSummary } from "../types"; +import { + capabilityConfig, + type ResolvedStores, + runnerId, + type StudioRuntimeOptions, +} from "./shared"; + +export function registerStatusRoutes( + app: Hono, + props: { + options: StudioRuntimeOptions; + agents: StudioAgent[]; + pipelines: StudioPipeline[]; + stores: ResolvedStores; + }, +): void { + app.get("/status", async (c) => { + const summary: StudioStatusSummary = { + runner: { + id: runnerId(props.options), + ...(props.options.name === undefined ? {} : { name: props.options.name }), + ...(props.options.version === undefined ? {} : { version: props.options.version }), + }, + storage: { + ...(props.stores.sessions?.kind === undefined + ? {} + : { sessions: props.stores.sessions.kind }), + ...(props.stores.traces?.kind === undefined ? {} : { traces: props.stores.traces.kind }), + ...(props.stores.pipelineLogs === undefined ? {} : { pipelineLogs: "available" }), + ...(props.stores.pipelineRuns === undefined ? {} : { pipelineRuns: "available" }), + }, + counts: { + agents: props.agents.length, + pipelines: props.pipelines.length, + ...(props.stores.sessions === undefined + ? {} + : { sessions: (await props.stores.sessions.listSessions({ limit: 100 })).length }), + ...(props.stores.traces?.listTraces === undefined + ? {} + : { traces: (await props.stores.traces.listTraces({ limit: 100 })).length }), + ...(props.stores.pipelineRuns === undefined || props.pipelines.length === 0 + ? {} + : { + pipelineRuns: ( + await Promise.all( + props.pipelines.map((pipeline) => + props.stores.pipelineRuns?.listPipelineRuns({ + pipelineId: pipeline.id, + limit: 100, + }), + ), + ) + ).reduce((sum, runs) => sum + (runs?.length ?? 0), 0), + }), + }, + capabilities: capabilityConfig(props.options, props.agents, props.pipelines, props.stores), + generatedAt: new Date().toISOString(), + }; + return c.json(summary); + }); +} diff --git a/packages/tools/studio/src/runtime/streams.ts b/packages/tools/studio/src/runtime/streams.ts new file mode 100644 index 00000000..d6e89077 --- /dev/null +++ b/packages/tools/studio/src/runtime/streams.ts @@ -0,0 +1,67 @@ +import { createEventStream } from "@anvia/server"; +import { serializeError } from "./shared"; + +type StudioStreamErrorEvent = { + type: "error"; + error: unknown; +}; + +const studioJsonlHeaders: HeadersInit = { + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "content-type": "application/x-ndjson; charset=utf-8", + "transfer-encoding": "chunked", + "x-accel-buffering": "no", +}; + +export function streamStudioJsonl(events: AsyncIterable): Response { + return createEventStream(withStudioStreamErrors(events), { + format: "jsonl", + headers: studioJsonlHeaders, + }); +} + +function withStudioStreamErrors( + events: AsyncIterable, +): AsyncIterable { + const iterator = events[Symbol.asyncIterator](); + let done = false; + + return { + [Symbol.asyncIterator](): AsyncIterator { + return { + async next(): Promise> { + if (done) { + return { done: true, value: undefined }; + } + + try { + const next = await iterator.next(); + if (next.done === true) { + done = true; + } + return next; + } catch (error) { + done = true; + return { + done: false, + value: studioStreamError(error), + }; + } + }, + async return(): Promise> { + done = true; + await iterator.return?.(); + return { done: true, value: undefined }; + }, + }; + }, + }; +} + +function studioStreamError(error: unknown): StudioStreamErrorEvent { + return { + type: "error", + error: serializeError(error), + }; +} diff --git a/packages/tools/studio/src/runtime/studio.ts b/packages/tools/studio/src/runtime/studio.ts index d241718b..42540c90 100644 --- a/packages/tools/studio/src/runtime/studio.ts +++ b/packages/tools/studio/src/runtime/studio.ts @@ -1,11 +1,13 @@ import { - Agent, createHook, type HookAction, - type JsonObject, type PromptHook, type ToolCallHookAction, -} from "@anvia/core"; +} from "@anvia/core/agent"; +import { type Message as CoreMessage, type JsonObject, Message } from "@anvia/core/completion"; +import { Agent } from "@anvia/core/internal/agent"; +import { resolveMemoryOptions } from "@anvia/core/memory"; +import { Pipeline } from "@anvia/core/pipeline"; import { serve } from "@hono/node-server"; import type { Hono } from "hono"; import { Hono as HonoApp } from "hono"; @@ -16,8 +18,10 @@ import type { StudioAgent, StudioConfig, StudioOptions, + StudioPipeline, StudioServeOptions, StudioSessionStore, + StudioTarget, StudioTraceStore, } from "../types"; import { @@ -26,25 +30,50 @@ import { resolveStudioUiOptions, studioUiEntryPath, } from "../ui/routes"; -import { createApprovalRuntime, registerApprovalRoutes } from "./approvals"; +import { + createApprovalRuntime, + registerApprovalRoutes, + type StudioApprovalHook, +} from "./approvals"; +import { registerEvalRoutes } from "./evals"; import { registerKnowledgeRoutes } from "./knowledge"; +import { registerMcpRoutes } from "./mcps"; +import { registerMemoryRoutes } from "./memory"; +import { + observeStores, + registerObservabilityRoutes, + StudioObservabilityHub, +} from "./observability"; +import { registerPipelineRoutes } from "./pipelines"; import { createQuestionRuntime, registerQuestionRoutes } from "./questions"; import { AsyncEventQueue, mergeRunAndApprovalEvents, optionalTitle, parseRunRequest, - persistStreamingSessionRun, + persistStreamingSessionTranscript, streamAgentRunEvents, traceForRun, transcriptFromMessages, } from "./runs"; +import { + appendSessionLog, + memoryLoadedLog, + memorySavedLog, + runCompletedLog, + runFailedLog, + runReceivedLog, + runStartedLog, + streamSessionRunLogs, +} from "./session-logs"; import { registerSessionRoutes } from "./sessions"; import { agentConfig, + agentRuntimeSummary, buildConfig, errorResponse, normalizeAgents, + normalizePipelines, resolveStores, runnerId, type StudioRuntimeOptions, @@ -52,6 +81,8 @@ import { unsupportedCapabilities, unsupportedCapability, } from "./shared"; +import { registerStatusRoutes } from "./status"; +import { registerToolRoutes } from "./tools"; import { registerTraceRoutes } from "./trace-routes"; type StudioApp = AnviaStudio & { @@ -65,8 +96,8 @@ export class Studio implements AnviaStudio { private server: ReturnType | undefined; private sigintHandler: (() => void) | undefined; - constructor(agents: Agent[] = [], options: StudioOptions = {}) { - this.options = studioOptionsFromAgents(agents, options); + constructor(targets: StudioTarget[] = [], options: StudioOptions = {}) { + this.options = studioOptionsFromTargets(targets, options); this.studio = createStudioApp(this.options); } @@ -130,9 +161,21 @@ export class Studio implements AnviaStudio { } } -function studioOptionsFromAgents(agents: Agent[], options: StudioOptions): StudioRuntimeOptions { +function studioOptionsFromTargets( + targets: StudioTarget[], + options: StudioOptions, +): StudioRuntimeOptions { + const agents = targets.filter((target): target is Agent => target instanceof Agent); + const pipelines = targets.filter( + // biome-ignore lint/suspicious/noExplicitAny: Studio accepts heterogeneous user pipelines. + (target): target is Pipeline => target instanceof Pipeline, + ); return { agents: inferStudioAgents(agents, options.quickPrompts ?? {}), + pipelines: inferStudioPipelines(pipelines), + evals: options.evals ?? [], + ...(options.stores === undefined ? {} : { stores: options.stores }), + ...(options.ui === undefined ? {} : { ui: options.ui }), }; } @@ -149,6 +192,21 @@ function inferStudioAgents(agents: Agent[], quickPrompts: Record>): StudioPipeline[] { + const ids = new Set(); + return pipelines.map((pipeline) => { + const id = uniqueAgentId(pipeline.id || "pipeline", ids); + return { + id, + pipeline, + ...(pipeline.name === undefined ? {} : { name: pipeline.name }), + ...(pipeline.description === undefined ? {} : { description: pipeline.description }), + ...(pipeline.metadata === undefined ? {} : { metadata: pipeline.metadata }), + }; + }); +} + function uniqueAgentId(baseId: string, ids: Set): string { let id = baseId; let suffix = 2; @@ -167,17 +225,22 @@ function agentMetadata(agent: Agent): JsonObject { dynamicContextCount: agent.dynamicContexts.length, dynamicToolCount: agent.dynamicTools.length, hasOutputSchema: agent.outputSchema !== undefined, + hasHook: agent.hook !== undefined, observerCount: agent.observers.length, approvalToolCount: agent.toolSet.values().filter((tool) => tool.approval !== undefined).length, }; } function createStudioApp(options: StudioRuntimeOptions): StudioApp { - const stores = resolveStores(options); - const agents = normalizeAgents(options.agents).map((agent) => - withStudioTraceObserver(agent, stores.traces), - ); + const observabilityHub = new StudioObservabilityHub(); + const stores = observeStores(resolveStores(options), observabilityHub); + const agents = normalizeAgents(options.agents) + .map((agent) => withStudioSessionMemory(agent, stores.sessions)) + .map((agent) => withStudioTraceObserver(agent, stores.traces)); + const pipelines = normalizePipelines(options.pipelines); const agentMap = new Map(agents.map((agent) => [agent.id, agent])); + const pipelineMap = new Map(pipelines.map((pipeline) => [pipeline.id, pipeline])); + const evalMap = new Map(options.evals.map((suite) => [suite.id ?? suite.name, suite])); const approvalRuntime = createApprovalRuntime(); const questionRuntime = createQuestionRuntime(); const app = new HonoApp(); @@ -202,7 +265,8 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { }), ); - app.get("/config", (c) => c.json(buildConfig(options, agents, stores))); + app.get("/config", (c) => c.json(buildConfig(options, agents, pipelines, stores))); + registerStatusRoutes(app, { options, agents, pipelines, stores }); app.get("/agents", (c) => c.json({ agents: agents.map(agentConfig) })); @@ -215,12 +279,34 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { return c.json(agentConfig(agent)); }); + app.get("/agents/:agentId/runtime", (c) => { + const agent = agentMap.get(c.req.param("agentId")); + if (agent === undefined) { + return errorResponse(c, 404, "not_found", "Agent not found"); + } + + return c.json(agentRuntimeSummary(agent)); + }); + + registerMcpRoutes(app, { agentMap }); + registerToolRoutes(app, { agentMap }); registerApprovalRoutes(app, approvalRuntime); registerQuestionRoutes(app, questionRuntime); + registerObservabilityRoutes(app, observabilityHub); + registerEvalRoutes(app, { + evals: options.evals, + evalMap, + }); registerKnowledgeRoutes(app, { agents, ...(stores.traces === undefined ? {} : { traceStore: stores.traces }), }); + registerPipelineRoutes(app, { + pipelines, + pipelineMap, + ...(stores.pipelineLogs === undefined ? {} : { logStore: stores.pipelineLogs }), + ...(stores.pipelineRuns === undefined ? {} : { runStore: stores.pipelineRuns }), + }); app.post("/agents/:agentId/runs", async (c) => { const agentId = c.req.param("agentId"); @@ -248,12 +334,36 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { } const runId = globalThis.crypto.randomUUID(); - const request = agent.agent.prompt(body.message); + const runStartedAt = Date.now(); if (session !== undefined) { - request.withHistory(session.messages); - } else if (body.history !== undefined) { - request.withHistory(body.history); + await appendSessionLog( + stores.sessions, + runReceivedLog({ + sessionId: session.id, + runId, + agentId, + message: body.message, + stream: body.stream === true, + ...(body.maxTurns === undefined ? {} : { maxTurns: body.maxTurns }), + ...(body.toolConcurrency === undefined ? {} : { toolConcurrency: body.toolConcurrency }), + hasTrace: body.trace !== undefined, + ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + }), + ); } + const memoryMetadata = { + agentId, + ...(body.metadata ?? {}), + studioRunId: runId, + }; + const request = + session !== undefined + ? agent.agent.session(session.id, { metadata: memoryMetadata }).prompt(body.message) + : agent.agent.prompt( + body.history !== undefined + ? [...body.history, normalizePromptMessage(body.message)] + : body.message, + ); if (body.maxTurns !== undefined) { request.maxTurns(body.maxTurns); } @@ -295,16 +405,27 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { const stream = session === undefined || stores.sessions === undefined ? runStream - : persistStreamingSessionRun({ - stream: runStream, + : persistStreamingSessionTranscript({ + stream: streamSessionRunLogs({ + stream: runStream, + store: stores.sessions, + session, + runId, + startedAt: runStartedAt, + }), store: stores.sessions, session, message: body.message, + runId, }); return streamAgentRunEvents(c, stream); } try { + if (session !== undefined) { + await appendSessionLog(stores.sessions, runStartedLog(session, runId)); + await appendSessionLog(stores.sessions, memoryLoadedLog(session, runId)); + } const effectiveHook = composeHooks( composeHooks( agent.agent.hook, @@ -328,20 +449,61 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { } const response = await request.send(); if (session !== undefined && stores.sessions !== undefined) { - await stores.sessions.appendSessionRun({ + await stores.sessions.saveSessionRunTranscript({ id: session.id, + runId, ...optionalTitle(body.message), - messages: response.messages, transcript: transcriptFromMessages(response.messages), + status: "success", }); + await appendSessionLog( + stores.sessions, + runCompletedLog({ + sessionId: session.id, + runId, + durationMs: Date.now() - runStartedAt, + usage: response.usage, + output: response.output, + messageCount: response.messages.length, + }), + ); + await appendSessionLog( + stores.sessions, + memorySavedLog({ + sessionId: session.id, + runId, + messageCount: response.messages.length, + }), + ); } return c.json(response); } catch (error) { + if (session !== undefined && stores.sessions !== undefined) { + const messages = await stores.sessions.load({ + sessionId: session.id, + metadata: memoryMetadata, + }); + await stores.sessions.saveSessionRunTranscript({ + id: session.id, + runId, + ...optionalTitle(body.message), + transcript: transcriptFromMessages(messages.slice(session.messageCount)), + status: "error", + error: serializeError(error), + }); + await appendSessionLog( + stores.sessions, + runFailedLog(session.id, runId, error, runStartedAt), + ); + } return errorResponse(c, 500, "internal_error", "Agent run failed", serializeError(error)); } }); if (stores.sessions !== undefined) { + registerMemoryRoutes(app, { + sessionStore: stores.sessions, + }); registerSessionRoutes(app, { agentMap, sessionStore: stores.sessions, @@ -364,7 +526,7 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { return app.fetch(request); }, config(): StudioConfig { - return buildConfig(options, agents, stores); + return buildConfig(options, agents, pipelines, stores); }, close() {}, ...(stores.sessions === undefined ? {} : { sessionStore: stores.sessions }), @@ -372,6 +534,29 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { }; } +function normalizePromptMessage(message: string | CoreMessage): CoreMessage { + return typeof message === "string" ? Message.user(message) : message; +} + +function withStudioSessionMemory( + studioAgent: StudioAgent, + sessionStore: StudioSessionStore | undefined, +): StudioAgent { + if (sessionStore === undefined) { + return studioAgent; + } + + return { + ...studioAgent, + agent: cloneAgent(studioAgent.agent, { + memory: { + store: sessionStore, + options: resolveMemoryOptions({ savePolicy: "message" }), + }, + }), + }; +} + function withStudioTraceObserver( studioAgent: StudioAgent, traceStore: StudioTraceStore | undefined, @@ -382,31 +567,42 @@ function withStudioTraceObserver( return { ...studioAgent, - agent: new Agent({ - id: studioAgent.agent.id, - name: studioAgent.agent.name, - description: studioAgent.agent.description, - model: studioAgent.agent.model, - instructions: studioAgent.agent.instructions, - staticContext: studioAgent.agent.staticContext, - temperature: studioAgent.agent.temperature, - maxTokens: studioAgent.agent.maxTokens, - additionalParams: studioAgent.agent.additionalParams, - toolSet: studioAgent.agent.toolSet, - toolChoice: studioAgent.agent.toolChoice, - defaultMaxTurns: studioAgent.agent.defaultMaxTurns, - hook: studioAgent.agent.hook, - outputSchema: studioAgent.agent.outputSchema, + agent: cloneAgent(studioAgent.agent, { observers: [ ...studioAgent.agent.observers, { observer: new StudioTraceObserver({ store: traceStore }) }, ], - dynamicContexts: studioAgent.agent.dynamicContexts, - dynamicTools: studioAgent.agent.dynamicTools, }), }; } +function cloneAgent( + agent: Agent, + overrides: Partial[0]> = {}, +): Agent { + return new Agent({ + id: agent.id, + name: agent.name, + description: agent.description, + model: agent.model, + instructions: agent.instructions, + staticContext: agent.staticContext, + temperature: agent.temperature, + maxTokens: agent.maxTokens, + additionalParams: agent.additionalParams, + toolSet: agent.toolSet, + toolChoice: agent.toolChoice, + defaultMaxTurns: agent.defaultMaxTurns, + hook: agent.hook, + outputSchema: agent.outputSchema, + observers: agent.observers, + dynamicContexts: agent.dynamicContexts, + dynamicTools: agent.dynamicTools, + memory: agent.memory, + ...overrides, + }); +} + function hasStudioTraceObserver(agent: Agent): boolean { return agent.observers.some( (registration) => registration.observer instanceof StudioTraceObserver, @@ -442,6 +638,9 @@ function composeHooks( if (firstAction?.type === "skip" || firstAction?.type === "terminate") { return firstAction; } + if (firstAction?.type === "approval_request") { + return (await approvalRequestHandler(second)?.(args, firstAction)) ?? firstAction; + } const secondAction = await second.onToolCall?.(args); return secondAction ?? firstAction ?? undefined; }, @@ -453,3 +652,12 @@ function composeHooks( }, }); } + +function approvalRequestHandler( + hook: PromptHook, +): StudioApprovalHook["handleApprovalRequest"] | undefined { + const candidate = hook as Partial; + return typeof candidate.handleApprovalRequest === "function" + ? candidate.handleApprovalRequest + : undefined; +} diff --git a/packages/tools/studio/src/runtime/tool-metadata.ts b/packages/tools/studio/src/runtime/tool-metadata.ts new file mode 100644 index 00000000..e77ea5ac --- /dev/null +++ b/packages/tools/studio/src/runtime/tool-metadata.ts @@ -0,0 +1,52 @@ +import { type AnyTool, ToolSet } from "@anvia/core/tool"; +import type { StudioAgent, StudioAgentToolApprovalMetadata, StudioAgentToolSource } from "../types"; + +export type AgentToolItem = { + tool: AnyTool; + source: StudioAgentToolSource; +}; + +const MCP_TOOL_METADATA_KEY = Symbol.for("anvia.mcp.tool.metadata"); + +export function agentToolItems(agent: StudioAgent): AgentToolItem[] { + return [ + ...agent.agent.toolSet.values().map((tool) => ({ tool, source: "static" as const })), + ...agent.agent.dynamicTools.flatMap((registration) => { + const maybeToolSet = (registration.index as { toolSet?: unknown }).toolSet; + if (!(maybeToolSet instanceof ToolSet)) { + return []; + } + return maybeToolSet.values().map((tool) => ({ tool, source: "dynamic" as const })); + }), + ]; +} + +export function approvalMetadata(tool: AnyTool): StudioAgentToolApprovalMetadata { + const approval = tool.approval; + if (approval === undefined || typeof approval !== "object" || approval === null) { + return { required: false }; + } + + const policy = approval as { + reason?: unknown; + rejectMessage?: unknown; + }; + return { + required: true, + ...(typeof policy.reason === "string" ? { reason: policy.reason } : {}), + ...(typeof policy.rejectMessage === "string" ? { rejectMessage: policy.rejectMessage } : {}), + }; +} + +export function mcpServerName(tool: AnyTool): string | undefined { + const metadata = (tool as { [MCP_TOOL_METADATA_KEY]?: unknown })[MCP_TOOL_METADATA_KEY]; + if (typeof metadata !== "object" || metadata === null) { + return undefined; + } + const serverName = (metadata as { serverName?: unknown }).serverName; + return typeof serverName === "string" && serverName.length > 0 ? serverName : undefined; +} + +export function agentHasMcpTools(agent: StudioAgent): boolean { + return agentToolItems(agent).some(({ tool }) => mcpServerName(tool) !== undefined); +} diff --git a/packages/tools/studio/src/runtime/tools.ts b/packages/tools/studio/src/runtime/tools.ts new file mode 100644 index 00000000..91df7b47 --- /dev/null +++ b/packages/tools/studio/src/runtime/tools.ts @@ -0,0 +1,141 @@ +import type { Context, Hono } from "hono"; +import type { + StudioAgent, + StudioAgentToolMetadata, + StudioToolRunRequest, + StudioToolRunResponse, +} from "../types"; +import { serializeUnknown, toJsonValue } from "./json"; +import { errorResponse, isJsonObject, isJsonValue } from "./shared"; +import { agentToolItems, approvalMetadata } from "./tool-metadata"; + +export function registerToolRoutes( + app: Hono, + props: { + agentMap: Map; + }, +): void { + app.get("/agents/:agentId/tools", async (c) => { + const agentId = c.req.param("agentId"); + const agent = props.agentMap.get(agentId); + if (agent === undefined) { + return errorResponse(c, 404, "not_found", "Agent not found"); + } + + return c.json({ + agentId, + tools: await agentToolMetadata(agent), + }); + }); + + app.post("/agents/:agentId/tools/:toolName/runs", async (c) => { + const agentId = c.req.param("agentId"); + const toolName = c.req.param("toolName"); + const agent = props.agentMap.get(agentId); + if (agent === undefined) { + return errorResponse(c, 404, "not_found", "Agent not found"); + } + if (agent.agent.getTool(toolName) === undefined) { + return errorResponse(c, 404, "not_found", "Tool not found"); + } + + const body = await parseToolRunRequest(c); + if ("error" in body) { + return body.error; + } + + const started = Date.now(); + const startedAt = new Date(started).toISOString(); + const events: unknown[] = []; + try { + const result = await agent.agent.callTool(toolName, JSON.stringify(body.args), { + emitStreamEvent(event) { + events.push(event); + }, + }); + const ended = Date.now(); + return c.json({ + agentId, + toolName, + status: "success", + result: toJsonValue(result), + durationMs: ended - started, + startedAt, + endedAt: new Date(ended).toISOString(), + events: events.map(toJsonValue), + } satisfies StudioToolRunResponse); + } catch (error) { + const ended = Date.now(); + return c.json( + { + agentId, + toolName, + status: "error", + error: serializeUnknown(error), + durationMs: ended - started, + startedAt, + endedAt: new Date(ended).toISOString(), + events: events.map(toJsonValue), + } satisfies StudioToolRunResponse, + 500, + ); + } + }); +} + +async function parseToolRunRequest( + c: Context, +): Promise { + let body: unknown; + try { + body = await c.req.json(); + } catch { + return { error: errorResponse(c, 400, "bad_request", "Request body must be JSON") }; + } + if (body === undefined || body === null) { + return { args: {} } satisfies StudioToolRunRequest; + } + if (!isJsonObject(body)) { + return { error: errorResponse(c, 400, "bad_request", "Request body must be an object") }; + } + const args = Object.hasOwn(body, "args") ? body.args : {}; + if (!isJsonValue(args)) { + return { error: errorResponse(c, 400, "bad_request", "args must be JSON-compatible") }; + } + const request: StudioToolRunRequest = { args }; + if (Object.hasOwn(body, "context")) { + if (!isJsonObject(body.context)) { + return { error: errorResponse(c, 400, "bad_request", "context must be an object") }; + } + request.context = body.context; + } + return request; +} + +export async function agentToolMetadata(agent: StudioAgent): Promise { + const seen = new Set(); + const metadata: StudioAgentToolMetadata[] = []; + for (const { tool, source } of agentToolItems(agent)) { + const key = `${source}:${tool.name}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + const definition = await tool.definition(""); + metadata.push({ + agentId: agent.id, + name: definition.name, + description: definition.description, + parameters: definition.parameters, + source, + approval: approvalMetadata(tool), + }); + } + + return metadata.sort((left, right) => { + if (left.source !== right.source) { + return left.source === "static" ? -1 : 1; + } + return left.name.localeCompare(right.name); + }); +} diff --git a/packages/tools/studio/src/storage/memory-store.ts b/packages/tools/studio/src/storage/memory-store.ts new file mode 100644 index 00000000..4804990f --- /dev/null +++ b/packages/tools/studio/src/storage/memory-store.ts @@ -0,0 +1,363 @@ +import type { JsonObject, JsonValue, Message } from "@anvia/core/completion"; +import type { MemoryAppendInput, MemoryContext, MemoryErrorInput } from "@anvia/core/memory"; +import type { + StudioPipelineLogAppendInput, + StudioPipelineLogEntry, + StudioPipelineLogListOptions, + StudioPipelineLogStore, + StudioPipelineRunListOptions, + StudioPipelineRunRecord, + StudioPipelineRunSaveInput, + StudioPipelineRunStore, + StudioSession, + StudioSessionCreateInput, + StudioSessionListOptions, + StudioSessionLogAppendInput, + StudioSessionLogEntry, + StudioSessionLogListOptions, + StudioSessionRunTranscriptInput, + StudioSessionStore, + StudioSessionSummary, + StudioSessionTraceListOptions, + StudioTrace, + StudioTraceListOptions, + StudioTraceStore, + StudioTraceSummary, + StudioTranscriptEntry, +} from "../types"; + +type MemorySessionRecord = StudioSessionSummary & { + messages: Message[]; + runs: Array; + logs: StudioSessionLogEntry[]; +}; + +export function createInMemoryStudioStore(): StudioSessionStore & + StudioTraceStore & + StudioPipelineLogStore & + StudioPipelineRunStore { + return new InMemoryStudioStore(); +} + +class InMemoryStudioStore + implements StudioSessionStore, StudioTraceStore, StudioPipelineLogStore, StudioPipelineRunStore +{ + readonly kind = "memory"; + private readonly sessions = new Map(); + private readonly traces = new Map(); + private readonly pipelineLogs = new Map(); + private readonly pipelineRuns = new Map(); + + listSessions(options: StudioSessionListOptions): StudioSessionSummary[] { + return [...this.sessions.values()] + .filter((session) => options.agentId === undefined || session.agentId === options.agentId) + .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) + .slice(0, options.limit) + .map(sessionSummary); + } + + createSession(input: StudioSessionCreateInput): StudioSessionSummary { + const now = new Date().toISOString(); + const session: MemorySessionRecord = { + id: input.id, + agentId: input.agentId, + ...(input.title === undefined ? {} : { title: input.title }), + createdAt: now, + updatedAt: now, + messageCount: 0, + ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + messages: [], + runs: [], + logs: [], + }; + this.sessions.set(input.id, session); + return sessionSummary(session); + } + + getSession(id: string): StudioSession | undefined { + const session = this.sessions.get(id); + return session === undefined ? undefined : materializeSession(session); + } + + load(context: MemoryContext): Promise { + return Promise.resolve(this.sessions.get(context.sessionId)?.messages ?? []); + } + + append(input: MemoryAppendInput): Promise { + const session = this.sessions.get(input.context.sessionId); + if (session !== undefined) { + session.messages.push(...input.messages); + session.messageCount = session.messages.length; + session.updatedAt = new Date().toISOString(); + } + return Promise.resolve(); + } + + clear(context: MemoryContext): Promise { + const session = this.sessions.get(context.sessionId); + if (session !== undefined) { + session.messages = []; + session.runs = []; + session.messageCount = 0; + session.updatedAt = new Date().toISOString(); + } + return Promise.resolve(); + } + + async recordError(input: MemoryErrorInput): Promise { + await this.saveSessionRunTranscript({ + id: input.context.sessionId, + runId: studioRunId(input.context) ?? input.runId, + transcript: transcriptFromMessagesFallback(input.messages), + status: "error", + error: serializeJsonError(input.error), + }); + } + + saveSessionRunTranscript(input: StudioSessionRunTranscriptInput): StudioSession | undefined { + const session = this.sessions.get(input.id); + if (session === undefined) { + return undefined; + } + const now = new Date().toISOString(); + const existingIndex = session.runs.findIndex((run) => run.runId === input.runId); + const run = { + ...input, + transcript: renumberTranscript(input.transcript), + createdAt: existingIndex === -1 ? now : (session.runs[existingIndex]?.createdAt ?? now), + updatedAt: now, + }; + if (existingIndex === -1) { + session.runs.push(run); + } else { + session.runs[existingIndex] = run; + } + session.updatedAt = now; + return materializeSession(session); + } + + appendSessionLog(input: StudioSessionLogAppendInput): StudioSessionLogEntry { + const session = this.sessions.get(input.sessionId); + const logs = session?.logs ?? []; + const entry: StudioSessionLogEntry = { + id: globalThis.crypto.randomUUID(), + sessionId: input.sessionId, + ...(input.runId === undefined ? {} : { runId: input.runId }), + sequence: logs.length, + timestamp: new Date().toISOString(), + level: input.level, + category: input.category, + event: input.event, + message: input.message, + ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + }; + if (session !== undefined) { + session.logs.push(entry); + session.updatedAt = entry.timestamp; + } + return entry; + } + + listSessionLogs(options: StudioSessionLogListOptions): StudioSessionLogEntry[] { + return (this.sessions.get(options.sessionId)?.logs ?? []) + .filter((log) => options.after === undefined || log.sequence > options.after) + .slice(0, options.limit); + } + + deleteSession(id: string): boolean { + for (const trace of this.traces.values()) { + if (trace.sessionId === id) { + this.traces.delete(trace.id); + } + } + return this.sessions.delete(id); + } + + listTraces(options: StudioTraceListOptions): StudioTraceSummary[] { + return [...this.traces.values()] + .filter((trace) => options.sessionId === undefined || trace.sessionId === options.sessionId) + .filter((trace) => options.status === undefined || trace.status === options.status) + .filter((trace) => options.agentId === undefined || traceAgentId(trace) === options.agentId) + .sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt)) + .slice(0, options.limit) + .map(traceSummary); + } + + listSessionTraces(options: StudioSessionTraceListOptions): StudioTraceSummary[] { + return this.listTraces({ sessionId: options.sessionId, limit: options.limit }); + } + + getTrace(id: string): StudioTrace | undefined { + return this.traces.get(id); + } + + saveTrace(trace: StudioTrace): StudioTrace { + this.traces.set(trace.id, trace); + return trace; + } + + appendPipelineLog(input: StudioPipelineLogAppendInput): StudioPipelineLogEntry { + const logs = this.pipelineLogs.get(input.pipelineId) ?? []; + const entry: StudioPipelineLogEntry = { + id: globalThis.crypto.randomUUID(), + pipelineId: input.pipelineId, + ...(input.runId === undefined ? {} : { runId: input.runId }), + sequence: logs.length, + timestamp: new Date().toISOString(), + level: input.level, + category: input.category, + event: input.event, + message: input.message, + ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + }; + this.pipelineLogs.set(input.pipelineId, [...logs, entry]); + return entry; + } + + listPipelineLogs(options: StudioPipelineLogListOptions): StudioPipelineLogEntry[] { + return (this.pipelineLogs.get(options.pipelineId) ?? []) + .filter((log) => options.after === undefined || log.sequence > options.after) + .slice(0, options.limit); + } + + savePipelineRun(input: StudioPipelineRunSaveInput): StudioPipelineRunRecord { + const record: StudioPipelineRunRecord = { + runId: input.runId, + pipelineId: input.pipelineId, + status: input.status, + input: input.input, + ...(input.output === undefined ? {} : { output: input.output }), + ...(input.error === undefined ? {} : { error: input.error }), + ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + startedAt: input.startedAt, + ...(input.endedAt === undefined ? {} : { endedAt: input.endedAt }), + ...(input.durationMs === undefined ? {} : { durationMs: input.durationMs }), + }; + this.pipelineRuns.set(input.runId, record); + return record; + } + + listPipelineRuns(options: StudioPipelineRunListOptions): StudioPipelineRunRecord[] { + return [...this.pipelineRuns.values()] + .filter((run) => run.pipelineId === options.pipelineId) + .sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt)) + .slice(0, options.limit); + } +} + +function sessionSummary(session: MemorySessionRecord): StudioSessionSummary { + return { + id: session.id, + agentId: session.agentId, + ...(session.title === undefined ? {} : { title: session.title }), + createdAt: session.createdAt, + updatedAt: session.updatedAt, + messageCount: session.messages.length, + ...(session.metadata === undefined ? {} : { metadata: session.metadata }), + }; +} + +function materializeSession(session: MemorySessionRecord): StudioSession { + return { + ...sessionSummary(session), + messages: [...session.messages], + transcript: renumberTranscript(session.runs.flatMap((run) => run.transcript)), + }; +} + +function traceSummary(trace: StudioTrace): StudioTraceSummary { + return { + id: trace.id, + sessionId: trace.sessionId, + ...(trace.name === undefined ? {} : { name: trace.name }), + status: trace.status, + startedAt: trace.startedAt, + ...(trace.endedAt === undefined ? {} : { endedAt: trace.endedAt }), + ...(trace.durationMs === undefined ? {} : { durationMs: trace.durationMs }), + ...(trace.output === undefined ? {} : { output: trace.output }), + ...(trace.error === undefined ? {} : { error: trace.error }), + ...(trace.usage === undefined ? {} : { usage: trace.usage }), + ...(trace.metadata === undefined ? {} : { metadata: trace.metadata }), + observationCount: trace.observations.length, + }; +} + +function traceAgentId(trace: StudioTrace): string | undefined { + const nestedMetadata = trace.metadata?.metadata; + return isJsonObject(nestedMetadata) && typeof nestedMetadata.agentId === "string" + ? nestedMetadata.agentId + : undefined; +} + +function renumberTranscript(entries: StudioTranscriptEntry[]): StudioTranscriptEntry[] { + return entries.map((entry, entryId) => ({ ...entry, entryId })); +} + +function studioRunId(context: MemoryContext): string | undefined { + const value = context.metadata?.studioRunId; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function serializeJsonError(error: unknown): JsonValue { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + }; + } + return isJsonValue(error) ? error : String(error); +} + +function transcriptFromMessagesFallback(messages: Message[]): StudioTranscriptEntry[] { + const transcript: StudioTranscriptEntry[] = []; + for (const message of messages) { + if (message.role === "system") { + continue; + } + if (message.role === "user") { + for (const content of message.content) { + if (content.type === "text") { + transcript.push({ + entryId: transcript.length, + kind: "message", + role: "user", + text: content.text, + }); + } + } + continue; + } + if (message.role === "assistant") { + for (const content of message.content) { + if (content.type === "text") { + transcript.push({ + entryId: transcript.length, + kind: "message", + role: "assistant", + text: content.text, + }); + } + } + } + } + return transcript; +} + +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isJsonValue(value: unknown): value is JsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return true; + } + if (Array.isArray(value)) { + return value.every(isJsonValue); + } + return isJsonObject(value) && Object.values(value).every(isJsonValue); +} diff --git a/packages/tools/studio/src/storage/sqlite-store.ts b/packages/tools/studio/src/storage/sqlite-store.ts index bab0cd85..be1bd86a 100644 --- a/packages/tools/studio/src/storage/sqlite-store.ts +++ b/packages/tools/studio/src/storage/sqlite-store.ts @@ -2,12 +2,25 @@ import { mkdirSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; -import type { JsonObject, Message } from "@anvia/core"; +import type { JsonObject, JsonValue, Message } from "@anvia/core/completion"; +import type { MemoryAppendInput, MemoryContext, MemoryErrorInput } from "@anvia/core/memory"; import type { + StudioPipelineLogAppendInput, + StudioPipelineLogEntry, + StudioPipelineLogListOptions, + StudioPipelineLogStore, + StudioPipelineRunListOptions, + StudioPipelineRunRecord, + StudioPipelineRunSaveInput, + StudioPipelineRunStore, StudioSession, - StudioSessionAppendInput, StudioSessionCreateInput, StudioSessionListOptions, + StudioSessionLogAppendInput, + StudioSessionLogEntry, + StudioSessionLogListOptions, + StudioSessionRunStatus, + StudioSessionRunTranscriptInput, StudioSessionStore, StudioSessionSummary, StudioSessionTraceListOptions, @@ -18,25 +31,52 @@ import type { StudioTranscriptEntry, } from "../types"; -const { DatabaseSync } = createRequire(import.meta.url)( - "node:sqlite", -) as typeof import("node:sqlite"); - export type SqliteSessionStoreOptions = { path?: string; }; +type DatabaseSyncConstructor = typeof DatabaseSyncType; + +let DatabaseSync: DatabaseSyncConstructor | undefined; + type SessionRow = { id: string; agent_id: string; title: string | null; metadata_json: string | null; - messages_json: string; - transcript_json: string; created_at: string; updated_at: string; }; +type SessionSummaryRow = SessionRow & { + message_count: number; +}; + +type MessageRow = { + session_id: string; + message_index: number; + role: Message["role"]; + message_id: string | null; + created_at: string; +}; + +type MessagePartRow = { + session_id: string; + message_index: number; + part_index: number; + type: string; + part_json: string; +}; + +type StoredMessagePart = { + type: string; + value: unknown; +}; + +type TableInfoRow = { + name: string; +}; + type TraceRow = { id: string; session_id: string; @@ -54,13 +94,65 @@ type TraceRow = { duration_ms: number | null; }; +type SessionRunRow = { + run_id: string; + session_id: string; + status: StudioSessionRunStatus; + title: string | null; + transcript_json: string; + error_json: string | null; + created_at: string; + updated_at: string; +}; + +type SessionLogRow = { + id: string; + session_id: string; + run_id: string | null; + sequence: number; + timestamp: string; + level: StudioSessionLogEntry["level"]; + category: StudioSessionLogEntry["category"]; + event: string; + message: string; + metadata_json: string | null; +}; + +type PipelineLogRow = { + id: string; + pipeline_id: string; + run_id: string | null; + sequence: number; + timestamp: string; + level: StudioPipelineLogEntry["level"]; + category: StudioPipelineLogEntry["category"]; + event: string; + message: string; + metadata_json: string | null; +}; + +type PipelineRunRow = { + run_id: string; + pipeline_id: string; + status: StudioPipelineRunRecord["status"]; + input_json: string; + output_json: string | null; + error_json: string | null; + metadata_json: string | null; + started_at: string; + ended_at: string | null; + duration_ms: number | null; +}; + export function createSqliteSessionStore( options: SqliteSessionStoreOptions = {}, -): StudioSessionStore & StudioTraceStore { +): StudioSessionStore & StudioTraceStore & StudioPipelineLogStore & StudioPipelineRunStore { return new SqliteSessionStore(options.path ?? ":memory:"); } -class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { +class SqliteSessionStore + implements StudioSessionStore, StudioTraceStore, StudioPipelineLogStore, StudioPipelineRunStore +{ readonly kind = "sqlite"; private db: DatabaseSyncType | undefined; @@ -68,19 +160,22 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { listSessions(options: StudioSessionListOptions): StudioSessionSummary[] { const db = this.database(); - const agentClause = options.agentId === undefined ? "" : "WHERE agent_id = $agentId"; + const agentClause = options.agentId === undefined ? "" : "WHERE s.agent_id = $agentId"; const rows = db .prepare( - `SELECT id, agent_id, title, metadata_json, messages_json, transcript_json, created_at, updated_at - FROM runner_sessions + `SELECT s.id, s.agent_id, s.title, s.metadata_json, s.created_at, s.updated_at, + COUNT(m.message_index) AS message_count + FROM anvia_studio_sessions s + LEFT JOIN anvia_studio_session_messages m ON m.session_id = s.id ${agentClause} - ORDER BY updated_at DESC + GROUP BY s.id, s.agent_id, s.title, s.metadata_json, s.created_at, s.updated_at + ORDER BY s.updated_at DESC LIMIT $limit`, ) .all({ $agentId: options.agentId ?? null, $limit: options.limit, - }) as SessionRow[]; + }) as SessionSummaryRow[]; return rows.map(toSessionSummary); } @@ -89,16 +184,14 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { const db = this.database(); const now = new Date().toISOString(); db.prepare( - `INSERT INTO runner_sessions ( + `INSERT INTO anvia_studio_sessions ( id, agent_id, title, metadata_json, - messages_json, - transcript_json, created_at, updated_at - ) VALUES ($id, $agentId, $title, $metadata, '[]', '[]', $now, $now)`, + ) VALUES ($id, $agentId, $title, $metadata, $now, $now)`, ).run({ $id: input.id, $agentId: input.agentId, @@ -119,66 +212,333 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { } getSession(id: string): StudioSession | undefined { - const db = this.database(); - const row = db - .prepare( - `SELECT id, agent_id, title, metadata_json, messages_json, transcript_json, created_at, updated_at - FROM runner_sessions - WHERE id = $id`, - ) - .get({ $id: id }) as SessionRow | undefined; + const row = this.getSessionRow(id); - return row === undefined ? undefined : toSession(row); + return row === undefined + ? undefined + : toSession(row, this.listSessionMessages(id), this.listSessionRunRows(id)); } - appendSessionRun(input: StudioSessionAppendInput): StudioSession | undefined { + load(context: MemoryContext): Promise { + const session = this.getSession(context.sessionId); + return Promise.resolve(session?.messages ?? []); + } + + append(input: MemoryAppendInput): Promise { const db = this.database(); try { db.exec("BEGIN IMMEDIATE"); const row = db .prepare( - `SELECT id, agent_id, title, metadata_json, messages_json, transcript_json, created_at, updated_at - FROM runner_sessions + `SELECT id, agent_id, title, metadata_json, created_at, updated_at + FROM anvia_studio_sessions WHERE id = $id`, ) - .get({ $id: input.id }) as SessionRow | undefined; + .get({ $id: input.context.sessionId }) as SessionRow | undefined; if (row === undefined) { db.exec("ROLLBACK"); - return undefined; + return Promise.resolve(); } - const current = toSession(row); - const messages = [...current.messages, ...input.messages]; - const transcript = renumberTranscript([...current.transcript, ...input.transcript]); - const title = current.title ?? input.title; const updatedAt = new Date().toISOString(); + const nextIndex = this.nextMessageIndex(input.context.sessionId); + + this.insertMessages(input.context.sessionId, input.messages, nextIndex, updatedAt); + db.prepare( + `UPDATE anvia_studio_sessions + SET updated_at = $updatedAt + WHERE id = $id`, + ).run({ + $id: input.context.sessionId, + $updatedAt: updatedAt, + }); + db.exec("COMMIT"); + return Promise.resolve(); + } catch (error) { + if (db.isTransaction) { + db.exec("ROLLBACK"); + } + throw error; + } + } + + clear(context: MemoryContext): Promise { + const db = this.database(); + const updatedAt = new Date().toISOString(); + + try { + db.exec("BEGIN IMMEDIATE"); + db.prepare( + `UPDATE anvia_studio_sessions + SET updated_at = $updatedAt + WHERE id = $id`, + ).run({ + $id: context.sessionId, + $updatedAt: updatedAt, + }); + db.prepare("DELETE FROM anvia_studio_session_messages WHERE session_id = $id").run({ + $id: context.sessionId, + }); + db.prepare("DELETE FROM anvia_studio_session_runs WHERE session_id = $id").run({ + $id: context.sessionId, + }); + db.exec("COMMIT"); + return Promise.resolve(); + } catch (error) { + if (db.isTransaction) { + db.exec("ROLLBACK"); + } + throw error; + } + } + + async recordError(input: MemoryErrorInput): Promise { + const runId = studioRunId(input.context) ?? input.runId; + const existing = this.getSessionRun(input.context.sessionId, runId); + const transcript = + existing === undefined || + parseJsonArray(existing.transcript_json).length === 0 + ? transcriptFromMessagesFallback(input.messages) + : parseJsonArray(existing.transcript_json); + await this.saveSessionRunTranscript({ + id: input.context.sessionId, + runId, + transcript, + status: "error", + error: serializeJsonError(input.error), + }); + } + + saveSessionRunTranscript(input: StudioSessionRunTranscriptInput): StudioSession | undefined { + const db = this.database(); + const now = new Date().toISOString(); + + try { + db.exec("BEGIN IMMEDIATE"); + const row = this.getSessionRow(input.id); + if (row === undefined) { + db.exec("ROLLBACK"); + return undefined; + } + const current = toSession( + row, + this.listSessionMessages(input.id), + this.listSessionRunRows(input.id), + ); + const title = current.title ?? input.title; db.prepare( - `UPDATE runner_sessions + `INSERT INTO anvia_studio_session_runs ( + run_id, + session_id, + status, + title, + transcript_json, + error_json, + created_at, + updated_at + ) VALUES ( + $runId, + $sessionId, + $status, + $title, + $transcript, + $error, + $now, + $now + ) + ON CONFLICT(run_id) DO UPDATE SET + status = excluded.status, + title = COALESCE(anvia_studio_session_runs.title, excluded.title), + transcript_json = excluded.transcript_json, + error_json = excluded.error_json, + updated_at = excluded.updated_at`, + ).run({ + $runId: input.runId, + $sessionId: input.id, + $status: input.status, + $title: input.title ?? null, + $transcript: JSON.stringify(renumberTranscript(input.transcript)), + $error: input.error === undefined ? null : JSON.stringify(input.error), + $now: now, + }); + + db.prepare( + `UPDATE anvia_studio_sessions SET title = $title, - messages_json = $messages, - transcript_json = $transcript, updated_at = $updatedAt WHERE id = $id`, ).run({ $id: input.id, $title: title ?? null, - $messages: JSON.stringify(messages), - $transcript: JSON.stringify(transcript), - $updatedAt: updatedAt, + $updatedAt: now, }); db.exec("COMMIT"); - return { - ...current, - ...(title === undefined ? {} : { title }), - updatedAt, - messageCount: messages.length, - messages, - transcript, + const updated = this.getSession(input.id); + return updated; + } catch (error) { + if (db.isTransaction) { + db.exec("ROLLBACK"); + } + throw error; + } + } + + appendSessionLog(input: StudioSessionLogAppendInput): StudioSessionLogEntry { + const db = this.database(); + const now = new Date().toISOString(); + + try { + db.exec("BEGIN IMMEDIATE"); + const row = this.getSessionRow(input.sessionId); + if (row === undefined) { + throw new Error("Session not found"); + } + const sequence = this.nextSessionLogSequence(input.sessionId); + const entry: StudioSessionLogEntry = { + id: globalThis.crypto.randomUUID(), + sessionId: input.sessionId, + ...(input.runId === undefined ? {} : { runId: input.runId }), + sequence, + timestamp: now, + level: input.level, + category: input.category, + event: input.event, + message: input.message, + ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + }; + + db.prepare( + `INSERT INTO anvia_studio_session_logs ( + id, + session_id, + run_id, + sequence, + timestamp, + level, + category, + event, + message, + metadata_json + ) VALUES ( + $id, + $sessionId, + $runId, + $sequence, + $timestamp, + $level, + $category, + $event, + $message, + $metadata + )`, + ).run({ + $id: entry.id, + $sessionId: entry.sessionId, + $runId: entry.runId ?? null, + $sequence: entry.sequence, + $timestamp: entry.timestamp, + $level: entry.level, + $category: entry.category, + $event: entry.event, + $message: entry.message, + $metadata: entry.metadata === undefined ? null : JSON.stringify(entry.metadata), + }); + + db.exec("COMMIT"); + return entry; + } catch (error) { + if (db.isTransaction) { + db.exec("ROLLBACK"); + } + throw error; + } + } + + listSessionLogs(options: StudioSessionLogListOptions): StudioSessionLogEntry[] { + const db = this.database(); + const afterClause = options.after === undefined ? "" : "AND sequence > $after"; + const rows = db + .prepare( + `SELECT id, session_id, run_id, sequence, timestamp, level, category, event, message, + metadata_json + FROM anvia_studio_session_logs + WHERE session_id = $sessionId + ${afterClause} + ORDER BY sequence ASC + LIMIT $limit`, + ) + .all({ + $sessionId: options.sessionId, + $after: options.after ?? null, + $limit: options.limit, + }) as SessionLogRow[]; + + return rows.map(toSessionLog); + } + + appendPipelineLog(input: StudioPipelineLogAppendInput): StudioPipelineLogEntry { + const db = this.database(); + const now = new Date().toISOString(); + + try { + db.exec("BEGIN IMMEDIATE"); + const sequence = this.nextPipelineLogSequence(input.pipelineId); + const entry: StudioPipelineLogEntry = { + id: globalThis.crypto.randomUUID(), + pipelineId: input.pipelineId, + ...(input.runId === undefined ? {} : { runId: input.runId }), + sequence, + timestamp: now, + level: input.level, + category: input.category, + event: input.event, + message: input.message, + ...(input.metadata === undefined ? {} : { metadata: input.metadata }), }; + + db.prepare( + `INSERT INTO anvia_studio_pipeline_logs ( + id, + pipeline_id, + run_id, + sequence, + timestamp, + level, + category, + event, + message, + metadata_json + ) VALUES ( + $id, + $pipelineId, + $runId, + $sequence, + $timestamp, + $level, + $category, + $event, + $message, + $metadata + )`, + ).run({ + $id: entry.id, + $pipelineId: entry.pipelineId, + $runId: entry.runId ?? null, + $sequence: entry.sequence, + $timestamp: entry.timestamp, + $level: entry.level, + $category: entry.category, + $event: entry.event, + $message: entry.message, + $metadata: entry.metadata === undefined ? null : JSON.stringify(entry.metadata), + }); + + db.exec("COMMIT"); + return entry; } catch (error) { if (db.isTransaction) { db.exec("ROLLBACK"); @@ -187,13 +547,121 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { } } + listPipelineLogs(options: StudioPipelineLogListOptions): StudioPipelineLogEntry[] { + const db = this.database(); + const afterClause = options.after === undefined ? "" : "AND sequence > $after"; + const rows = db + .prepare( + `SELECT id, pipeline_id, run_id, sequence, timestamp, level, category, event, message, + metadata_json + FROM anvia_studio_pipeline_logs + WHERE pipeline_id = $pipelineId + ${afterClause} + ORDER BY sequence ASC + LIMIT $limit`, + ) + .all({ + $pipelineId: options.pipelineId, + $after: options.after ?? null, + $limit: options.limit, + }) as PipelineLogRow[]; + + return rows.map(toPipelineLog); + } + + savePipelineRun(input: StudioPipelineRunSaveInput): StudioPipelineRunRecord { + const db = this.database(); + db.prepare( + `INSERT INTO anvia_studio_pipeline_runs ( + run_id, + pipeline_id, + status, + input_json, + output_json, + error_json, + metadata_json, + started_at, + ended_at, + duration_ms + ) VALUES ( + $runId, + $pipelineId, + $status, + $input, + $output, + $error, + $metadata, + $startedAt, + $endedAt, + $durationMs + ) + ON CONFLICT(run_id) DO UPDATE SET + pipeline_id = excluded.pipeline_id, + status = excluded.status, + input_json = excluded.input_json, + output_json = excluded.output_json, + error_json = excluded.error_json, + metadata_json = excluded.metadata_json, + started_at = excluded.started_at, + ended_at = excluded.ended_at, + duration_ms = excluded.duration_ms`, + ).run({ + $runId: input.runId, + $pipelineId: input.pipelineId, + $status: input.status, + $input: JSON.stringify(input.input), + $output: input.output === undefined ? null : JSON.stringify(input.output), + $error: input.error === undefined ? null : JSON.stringify(input.error), + $metadata: input.metadata === undefined ? null : JSON.stringify(input.metadata), + $startedAt: input.startedAt, + $endedAt: input.endedAt ?? null, + $durationMs: input.durationMs ?? null, + }); + + return { + runId: input.runId, + pipelineId: input.pipelineId, + status: input.status, + input: input.input, + ...(input.output === undefined ? {} : { output: input.output }), + ...(input.error === undefined ? {} : { error: input.error }), + ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + startedAt: input.startedAt, + ...(input.endedAt === undefined ? {} : { endedAt: input.endedAt }), + ...(input.durationMs === undefined ? {} : { durationMs: input.durationMs }), + }; + } + + listPipelineRuns(options: StudioPipelineRunListOptions): StudioPipelineRunRecord[] { + const db = this.database(); + const rows = db + .prepare( + `SELECT run_id, pipeline_id, status, input_json, output_json, error_json, + metadata_json, started_at, ended_at, duration_ms + FROM anvia_studio_pipeline_runs + WHERE pipeline_id = $pipelineId + ORDER BY started_at DESC + LIMIT $limit`, + ) + .all({ + $pipelineId: options.pipelineId, + $limit: options.limit, + }) as PipelineRunRow[]; + + return rows.map(toPipelineRun); + } + deleteSession(id: string): boolean { const db = this.database(); try { db.exec("BEGIN IMMEDIATE"); - db.prepare("DELETE FROM runner_traces WHERE session_id = $id").run({ $id: id }); - const result = db.prepare("DELETE FROM runner_sessions WHERE id = $id").run({ $id: id }) as { + db.prepare("DELETE FROM anvia_studio_traces WHERE session_id = $id").run({ $id: id }); + db.prepare("DELETE FROM anvia_studio_session_runs WHERE session_id = $id").run({ $id: id }); + db.prepare("DELETE FROM anvia_studio_session_logs WHERE session_id = $id").run({ $id: id }); + const result = db + .prepare("DELETE FROM anvia_studio_sessions WHERE id = $id") + .run({ $id: id }) as { changes: number | bigint; }; db.exec("COMMIT"); @@ -234,8 +702,8 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { `SELECT t.id, t.session_id, t.name, t.status, t.trace_json, t.input_json, t.output, t.error_json, t.usage_json, t.metadata_json, t.observations_json, t.started_at, t.ended_at, t.duration_ms - FROM runner_traces t - LEFT JOIN runner_sessions s ON s.id = t.session_id + FROM anvia_studio_traces t + LEFT JOIN anvia_studio_sessions s ON s.id = t.session_id ${whereClause} ORDER BY t.started_at DESC LIMIT $limit`, @@ -251,7 +719,7 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { .prepare( `SELECT id, session_id, name, status, trace_json, input_json, output, error_json, usage_json, metadata_json, observations_json, started_at, ended_at, duration_ms - FROM runner_traces + FROM anvia_studio_traces WHERE session_id = $sessionId ORDER BY started_at DESC LIMIT $limit`, @@ -270,7 +738,7 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { .prepare( `SELECT id, session_id, name, status, trace_json, input_json, output, error_json, usage_json, metadata_json, observations_json, started_at, ended_at, duration_ms - FROM runner_traces + FROM anvia_studio_traces WHERE id = $id`, ) .get({ $id: id }) as TraceRow | undefined; @@ -281,7 +749,7 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { saveTrace(trace: StudioTrace): StudioTrace { const db = this.database(); db.prepare( - `INSERT INTO runner_traces ( + `INSERT INTO anvia_studio_traces ( id, session_id, name, @@ -355,26 +823,107 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { mkdirSync(dirname(resolve(this.path)), { recursive: true }); } - const db = new DatabaseSync(this.path, { + const db = new (loadDatabaseSync())(this.path, { allowUnknownNamedParameters: true, timeout: 5000, }); db.exec(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; - CREATE TABLE IF NOT EXISTS runner_sessions ( + `); + guardAgainstLegacySessionSchema(db); + db.exec(` + CREATE TABLE IF NOT EXISTS anvia_studio_sessions ( id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, title TEXT, metadata_json TEXT, - messages_json TEXT NOT NULL, - transcript_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) STRICT; - CREATE INDEX IF NOT EXISTS runner_sessions_agent_updated_idx - ON runner_sessions(agent_id, updated_at DESC); - CREATE TABLE IF NOT EXISTS runner_traces ( + CREATE INDEX IF NOT EXISTS anvia_studio_sessions_agent_updated_idx + ON anvia_studio_sessions(agent_id, updated_at DESC); + CREATE TABLE IF NOT EXISTS anvia_studio_session_messages ( + session_id TEXT NOT NULL, + message_index INTEGER NOT NULL, + role TEXT NOT NULL, + message_id TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY(session_id, message_index), + FOREIGN KEY(session_id) REFERENCES anvia_studio_sessions(id) ON DELETE CASCADE + ) STRICT; + CREATE INDEX IF NOT EXISTS anvia_studio_session_messages_session_idx + ON anvia_studio_session_messages(session_id, message_index ASC); + CREATE TABLE IF NOT EXISTS anvia_studio_session_message_parts ( + session_id TEXT NOT NULL, + message_index INTEGER NOT NULL, + part_index INTEGER NOT NULL, + type TEXT NOT NULL, + part_json TEXT NOT NULL, + PRIMARY KEY(session_id, message_index, part_index), + FOREIGN KEY(session_id, message_index) + REFERENCES anvia_studio_session_messages(session_id, message_index) + ON DELETE CASCADE + ) STRICT; + CREATE TABLE IF NOT EXISTS anvia_studio_session_runs ( + run_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + status TEXT NOT NULL, + title TEXT, + transcript_json TEXT NOT NULL, + error_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(session_id) REFERENCES anvia_studio_sessions(id) ON DELETE CASCADE + ) STRICT; + CREATE INDEX IF NOT EXISTS anvia_studio_session_runs_session_created_idx + ON anvia_studio_session_runs(session_id, created_at ASC); + CREATE TABLE IF NOT EXISTS anvia_studio_session_logs ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + run_id TEXT, + sequence INTEGER NOT NULL, + timestamp TEXT NOT NULL, + level TEXT NOT NULL, + category TEXT NOT NULL, + event TEXT NOT NULL, + message TEXT NOT NULL, + metadata_json TEXT, + UNIQUE(session_id, sequence), + FOREIGN KEY(session_id) REFERENCES anvia_studio_sessions(id) ON DELETE CASCADE + ) STRICT; + CREATE INDEX IF NOT EXISTS anvia_studio_session_logs_session_sequence_idx + ON anvia_studio_session_logs(session_id, sequence ASC); + CREATE TABLE IF NOT EXISTS anvia_studio_pipeline_logs ( + id TEXT PRIMARY KEY, + pipeline_id TEXT NOT NULL, + run_id TEXT, + sequence INTEGER NOT NULL, + timestamp TEXT NOT NULL, + level TEXT NOT NULL, + category TEXT NOT NULL, + event TEXT NOT NULL, + message TEXT NOT NULL, + metadata_json TEXT, + UNIQUE(pipeline_id, sequence) + ) STRICT; + CREATE INDEX IF NOT EXISTS anvia_studio_pipeline_logs_pipeline_sequence_idx + ON anvia_studio_pipeline_logs(pipeline_id, sequence ASC); + CREATE TABLE IF NOT EXISTS anvia_studio_pipeline_runs ( + run_id TEXT PRIMARY KEY, + pipeline_id TEXT NOT NULL, + status TEXT NOT NULL, + input_json TEXT NOT NULL, + output_json TEXT, + error_json TEXT, + metadata_json TEXT, + started_at TEXT NOT NULL, + ended_at TEXT, + duration_ms INTEGER + ) STRICT; + CREATE INDEX IF NOT EXISTS anvia_studio_pipeline_runs_pipeline_started_idx + ON anvia_studio_pipeline_runs(pipeline_id, started_at DESC); + CREATE TABLE IF NOT EXISTS anvia_studio_traces ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, name TEXT, @@ -390,26 +939,179 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { ended_at TEXT, duration_ms INTEGER ) STRICT; - CREATE INDEX IF NOT EXISTS runner_traces_session_started_idx - ON runner_traces(session_id, started_at DESC); + CREATE INDEX IF NOT EXISTS anvia_studio_traces_session_started_idx + ON anvia_studio_traces(session_id, started_at DESC); `); this.db = db; return db; } + + private getSessionRow(id: string): SessionRow | undefined { + return this.database() + .prepare( + `SELECT id, agent_id, title, metadata_json, created_at, updated_at + FROM anvia_studio_sessions + WHERE id = $id`, + ) + .get({ $id: id }) as SessionRow | undefined; + } + + private getSessionRun(sessionId: string, runId: string): SessionRunRow | undefined { + return this.database() + .prepare( + `SELECT run_id, session_id, status, title, transcript_json, error_json, created_at, updated_at + FROM anvia_studio_session_runs + WHERE session_id = $sessionId AND run_id = $runId`, + ) + .get({ $sessionId: sessionId, $runId: runId }) as SessionRunRow | undefined; + } + + private listSessionRunRows(sessionId: string): SessionRunRow[] { + return this.database() + .prepare( + `SELECT run_id, session_id, status, title, transcript_json, error_json, created_at, updated_at + FROM anvia_studio_session_runs + WHERE session_id = $sessionId + ORDER BY created_at ASC`, + ) + .all({ $sessionId: sessionId }) as SessionRunRow[]; + } + + private nextSessionLogSequence(sessionId: string): number { + const row = this.database() + .prepare( + `SELECT COALESCE(MAX(sequence) + 1, 0) AS next_sequence + FROM anvia_studio_session_logs + WHERE session_id = $sessionId`, + ) + .get({ $sessionId: sessionId }) as { next_sequence: number }; + return row.next_sequence; + } + + private nextPipelineLogSequence(pipelineId: string): number { + const row = this.database() + .prepare( + `SELECT COALESCE(MAX(sequence) + 1, 0) AS next_sequence + FROM anvia_studio_pipeline_logs + WHERE pipeline_id = $pipelineId`, + ) + .get({ $pipelineId: pipelineId }) as { next_sequence: number }; + return row.next_sequence; + } + + private listSessionMessages(sessionId: string): Message[] { + const db = this.database(); + const messageRows = db + .prepare( + `SELECT session_id, message_index, role, message_id, created_at + FROM anvia_studio_session_messages + WHERE session_id = $sessionId + ORDER BY message_index ASC`, + ) + .all({ $sessionId: sessionId }) as MessageRow[]; + + if (messageRows.length === 0) { + return []; + } + + const partRows = db + .prepare( + `SELECT session_id, message_index, part_index, type, part_json + FROM anvia_studio_session_message_parts + WHERE session_id = $sessionId + ORDER BY message_index ASC, part_index ASC`, + ) + .all({ $sessionId: sessionId }) as MessagePartRow[]; + const partsByMessage = new Map(); + for (const partRow of partRows) { + const parts = partsByMessage.get(partRow.message_index) ?? []; + parts.push(partRow); + partsByMessage.set(partRow.message_index, parts); + } + + return messageRows.map((row) => + messageFromRows(row, partsByMessage.get(row.message_index) ?? []), + ); + } + + private nextMessageIndex(sessionId: string): number { + const row = this.database() + .prepare( + `SELECT COALESCE(MAX(message_index) + 1, 0) AS next_index + FROM anvia_studio_session_messages + WHERE session_id = $sessionId`, + ) + .get({ $sessionId: sessionId }) as { next_index: number }; + return row.next_index; + } + + private insertMessages( + sessionId: string, + messages: Message[], + startIndex: number, + createdAt: string, + ): void { + const db = this.database(); + const insertMessage = db.prepare( + `INSERT INTO anvia_studio_session_messages ( + session_id, + message_index, + role, + message_id, + created_at + ) VALUES ($sessionId, $messageIndex, $role, $messageId, $createdAt)`, + ); + const insertPart = db.prepare( + `INSERT INTO anvia_studio_session_message_parts ( + session_id, + message_index, + part_index, + type, + part_json + ) VALUES ($sessionId, $messageIndex, $partIndex, $type, $partJson)`, + ); + + messages.forEach((message, messageOffset) => { + const messageIndex = startIndex + messageOffset; + insertMessage.run({ + $sessionId: sessionId, + $messageIndex: messageIndex, + $role: message.role, + $messageId: message.role === "assistant" ? (message.id ?? null) : null, + $createdAt: createdAt, + }); + + messageParts(message).forEach((part, partIndex) => { + insertPart.run({ + $sessionId: sessionId, + $messageIndex: messageIndex, + $partIndex: partIndex, + $type: part.type, + $partJson: JSON.stringify(part.value), + }); + }); + }); + } } -function toSession(row: SessionRow): StudioSession { - const summary = toSessionSummary(row); +function toSession( + row: SessionRow, + messages: Message[], + runRows: SessionRunRow[] = [], +): StudioSession { + const summary = toSessionSummary({ ...row, message_count: messages.length }); + const runTranscript = runRows.flatMap((runRow) => + parseJsonArray(runRow.transcript_json), + ); return { ...summary, - messages: parseJsonArray(row.messages_json), - transcript: renumberTranscript(parseJsonArray(row.transcript_json)), + messages, + transcript: renumberTranscript(runTranscript), }; } -function toSessionSummary(row: SessionRow): StudioSessionSummary { - const messages = parseJsonArray(row.messages_json); +function toSessionSummary(row: SessionSummaryRow): StudioSessionSummary { const metadata = parseJsonValue(row.metadata_json); return { id: row.id, @@ -417,11 +1119,143 @@ function toSessionSummary(row: SessionRow): StudioSessionSummary { ...(row.title === null ? {} : { title: row.title }), createdAt: row.created_at, updatedAt: row.updated_at, - messageCount: messages.length, + messageCount: row.message_count, + ...(metadata === undefined ? {} : { metadata }), + }; +} + +function toSessionLog(row: SessionLogRow): StudioSessionLogEntry { + const metadata = parseJsonValue(row.metadata_json); + return { + id: row.id, + sessionId: row.session_id, + ...(row.run_id === null ? {} : { runId: row.run_id }), + sequence: row.sequence, + timestamp: row.timestamp, + level: row.level, + category: row.category, + event: row.event, + message: row.message, ...(metadata === undefined ? {} : { metadata }), }; } +function toPipelineLog(row: PipelineLogRow): StudioPipelineLogEntry { + const metadata = parseJsonValue(row.metadata_json); + return { + id: row.id, + pipelineId: row.pipeline_id, + ...(row.run_id === null ? {} : { runId: row.run_id }), + sequence: row.sequence, + timestamp: row.timestamp, + level: row.level, + category: row.category, + event: row.event, + message: row.message, + ...(metadata === undefined ? {} : { metadata }), + }; +} + +function toPipelineRun(row: PipelineRunRow): StudioPipelineRunRecord { + const output = parseJsonValue(row.output_json); + const error = parseJsonValue(row.error_json); + const metadata = parseJsonValue(row.metadata_json); + return { + runId: row.run_id, + pipelineId: row.pipeline_id, + status: row.status, + input: JSON.parse(row.input_json) as JsonValue, + ...(output === undefined ? {} : { output }), + ...(error === undefined ? {} : { error }), + ...(metadata === undefined ? {} : { metadata }), + startedAt: row.started_at, + ...(row.ended_at === null ? {} : { endedAt: row.ended_at }), + ...(row.duration_ms === null ? {} : { durationMs: row.duration_ms }), + }; +} + +function messageParts(message: Message): StoredMessagePart[] { + if (message.role === "system") { + return [{ type: "text", value: { type: "text", text: message.content } }]; + } + + return message.content.map((content) => ({ + type: content.type, + value: content, + })); +} + +function messageFromRows(row: MessageRow, partRows: MessagePartRow[]): Message { + const parts = partRows.map((partRow) => JSON.parse(partRow.part_json) as unknown); + + if (row.role === "system") { + return { role: "system", content: systemContentFromParts(parts) }; + } + if (row.role === "user") { + return { + role: "user", + content: parts as Extract["content"], + }; + } + if (row.role === "assistant") { + return { + role: "assistant", + ...(row.message_id === null ? {} : { id: row.message_id }), + content: parts as Extract["content"], + }; + } + if (row.role === "tool") { + return { + role: "tool", + content: parts as Extract["content"], + }; + } + + throw new Error(`Unsupported stored message role: ${row.role}`); +} + +function systemContentFromParts(parts: unknown[]): string { + const first = parts[0]; + if ( + typeof first === "object" && + first !== null && + "type" in first && + first.type === "text" && + "text" in first && + typeof first.text === "string" + ) { + return first.text; + } + return ""; +} + +function guardAgainstLegacySessionSchema(db: DatabaseSyncType): void { + const columns = db.prepare("PRAGMA table_info('anvia_studio_sessions')").all() as TableInfoRow[]; + if (columns.some((column) => column.name === "messages_json")) { + throw new Error( + "Existing Studio SQLite DB uses the legacy messages_json schema. Delete or recreate the Studio SQLite DB to use normalized session messages.", + ); + } +} + +function loadDatabaseSync(): DatabaseSyncConstructor { + if (DatabaseSync !== undefined) { + return DatabaseSync; + } + + try { + ({ DatabaseSync } = createRequire(import.meta.url)( + "node:sqlite", + ) as typeof import("node:sqlite")); + return DatabaseSync; + } catch (error) { + throw new Error( + "The default Studio SQLite store requires Node.js with node:sqlite support. Provide custom Studio stores or disable persisted stores when running Studio in a runtime without node:sqlite.", + { cause: error }, + ); + } +} + function toTrace(row: TraceRow): StudioTrace { const trace = parseJsonValue(row.trace_json); const input = parseJsonValue(row.input_json); @@ -469,3 +1303,109 @@ function parseJsonValue(value: string | null): T | undefined { function renumberTranscript(entries: StudioTranscriptEntry[]): StudioTranscriptEntry[] { return entries.map((entry, entryId) => ({ ...entry, entryId })); } + +function studioRunId(context: MemoryContext): string | undefined { + const value = context.metadata?.studioRunId; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function serializeJsonError(error: unknown): JsonValue { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + }; + } + if ( + error === null || + typeof error === "string" || + typeof error === "number" || + typeof error === "boolean" + ) { + return error; + } + return String(error); +} + +function transcriptFromMessagesFallback(messages: Message[]): StudioTranscriptEntry[] { + const transcript: StudioTranscriptEntry[] = []; + for (const message of messages) { + if (message.role === "system") { + continue; + } + if (message.role === "user") { + for (const content of message.content) { + if (content.type === "text") { + transcript.push({ + entryId: transcript.length, + kind: "message", + role: "user", + text: content.text, + }); + } + } + continue; + } + if (message.role === "tool") { + for (const content of message.content) { + transcript.push({ + entryId: transcript.length, + kind: "tool", + toolName: "tool_result", + callId: content.callId ?? content.id, + result: content.content + .map((item) => + "text" in item ? item.text : `[image:${item.mediaType ?? "image/png"}]`, + ) + .join("\n"), + structuredResult: content.content, + }); + } + continue; + } + + for (const content of message.content) { + if (content.type === "text") { + appendAssistantTranscriptText(transcript, content.text); + } else if (content.type === "reasoning") { + transcript.push({ + entryId: transcript.length, + kind: "reasoning", + ...(content.id === undefined ? {} : { reasoningId: content.id }), + text: content.text, + }); + } else if (content.type === "tool_call") { + transcript.push({ + entryId: transcript.length, + kind: "tool", + toolName: content.function.name, + callId: content.callId ?? content.id, + args: formatJson(content.function.arguments), + }); + } + } + } + return transcript; +} + +function appendAssistantTranscriptText(transcript: StudioTranscriptEntry[], text: string): void { + const last = transcript.at(-1); + if (last?.kind === "message" && last.role === "assistant") { + last.text = `${last.text}${text}`; + return; + } + transcript.push({ + entryId: transcript.length, + kind: "message", + role: "assistant", + text, + }); +} + +function formatJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} diff --git a/packages/tools/studio/src/traces/trace-observer.ts b/packages/tools/studio/src/traces/trace-observer.ts index 95a6103c..ddc1d1be 100644 --- a/packages/tools/studio/src/traces/trace-observer.ts +++ b/packages/tools/studio/src/traces/trace-observer.ts @@ -1,3 +1,4 @@ +import type { JsonObject, JsonValue } from "@anvia/core/completion"; import type { AgentGenerationEndArgs, AgentGenerationErrorArgs, @@ -12,9 +13,8 @@ import type { AgentToolErrorArgs, AgentToolObserver, AgentToolStartArgs, - JsonObject, - JsonValue, -} from "@anvia/core"; + AgentToolStreamEventArgs, +} from "@anvia/core/observability"; import type { StudioTrace, StudioTraceObservation, @@ -74,11 +74,7 @@ class StudioRunTraceObserver implements AgentRunObserver { startedAt, input: toJsonValue(args.request), output: toJsonValue(endArgs.response), - metadata: { - model: args.request.model ?? "default", - toolCount: args.request.tools.length, - ...(endArgs.firstDeltaMs === undefined ? {} : { firstDeltaMs: endArgs.firstDeltaMs }), - }, + metadata: generationMetadata(args, endArgs), }), ); }, @@ -92,10 +88,7 @@ class StudioRunTraceObserver implements AgentRunObserver { startedAt, input: toJsonValue(args.request), error: serializeError(errorArgs.error), - metadata: { - model: args.request.model ?? "default", - toolCount: args.request.tools.length, - }, + metadata: generationMetadata(args), }), ); }, @@ -104,34 +97,38 @@ class StudioRunTraceObserver implements AgentRunObserver { startTool(args: AgentToolStartArgs): AgentToolObserver { const startedAt = new Date(); + const childTrace = new ChildAgentToolTraceAccumulator(args); return { + streamEvent: (streamArgs: AgentToolStreamEventArgs) => { + childTrace.accept(streamArgs); + }, end: (endArgs: AgentToolEndArgs) => { - this.observations.push( - traceObservation({ - kind: "tool", - name: args.toolName, - status: "success", - turn: args.turn, - startedAt, - input: parseOrString(args.args), - output: parseOrString(endArgs.result), - metadata: toolMetadata(args, endArgs.skipped), - }), - ); + const parentObservation = traceObservation({ + kind: "tool", + name: args.toolName, + status: "success", + turn: args.turn, + startedAt, + input: parseOrString(args.args), + output: parseOrString(endArgs.result), + metadata: toolMetadata(args, endArgs.skipped, endArgs.result), + }); + this.observations.push(parentObservation); + this.observations.push(...childTrace.observations(parentObservation.id)); }, error: (errorArgs: AgentToolErrorArgs) => { - this.observations.push( - traceObservation({ - kind: "tool", - name: args.toolName, - status: "error", - turn: args.turn, - startedAt, - input: parseOrString(args.args), - error: serializeError(errorArgs.error), - metadata: toolMetadata(args, false), - }), - ); + const parentObservation = traceObservation({ + kind: "tool", + name: args.toolName, + status: "error", + turn: args.turn, + startedAt, + input: parseOrString(args.args), + error: serializeError(errorArgs.error), + metadata: toolMetadata(args, false), + }); + this.observations.push(parentObservation); + this.observations.push(...childTrace.observations(parentObservation.id)); }, }; } @@ -197,20 +194,277 @@ class StudioRunTraceObserver implements AgentRunObserver { } } +class ChildAgentToolTraceAccumulator { + private readonly agentStarts = new Map< + string, + { + startedAt: Date; + agentId: string; + agentName?: string; + } + >(); + private readonly generationStarts = new Map< + string, + { + startedAt: Date; + input?: JsonValue; + agentId: string; + agentName?: string; + childTurn: number; + } + >(); + private readonly toolStarts: Array<{ + startedAt: Date; + agentId: string; + agentName?: string; + childTurn: number; + toolName: string; + toolCallId?: string; + internalCallId?: string; + input?: JsonValue; + completed: boolean; + }> = []; + private readonly completedObservations: StudioTraceObservation[] = []; + + constructor(private readonly parent: AgentToolStartArgs) {} + + accept(args: AgentToolStreamEventArgs): void { + const wrapper = args.event; + const child = isRecord(wrapper.event) ? wrapper.event : undefined; + if (child === undefined) { + return; + } + + const agentId = wrapper.agentId; + const agentName = wrapper.agentName; + const childTurn = typeof child.turn === "number" ? child.turn : this.parent.turn; + + if (!this.agentStarts.has(agentId)) { + this.agentStarts.set(agentId, { + startedAt: new Date(), + agentId, + ...(agentName === undefined ? {} : { agentName }), + }); + } + + if (child.type === "turn_start") { + this.generationStarts.set(generationKey(agentId, childTurn), { + startedAt: new Date(), + input: toJsonValue({ + prompt: child.prompt, + history: child.history, + }), + agentId, + ...(agentName === undefined ? {} : { agentName }), + childTurn, + }); + return; + } + + if (child.type === "turn_end") { + const key = generationKey(agentId, childTurn); + const start = this.generationStarts.get(key); + this.generationStarts.delete(key); + this.completedObservations.push( + traceObservation({ + kind: "generation", + name: `${agentLabel(agentId, agentName)}.model.turn.${childTurn}`, + status: "success", + turn: this.parent.turn, + startedAt: start?.startedAt ?? new Date(), + ...(start?.input === undefined ? {} : { input: start.input }), + output: toJsonValue(child.response), + metadata: this.childMetadata(agentId, agentName, childTurn), + }), + ); + return; + } + + if (child.type === "tool_call" && isRecord(child.toolCall)) { + const toolCall = child.toolCall; + const toolCallFunction = isRecord(toolCall.function) ? toolCall.function : undefined; + const toolName = typeof toolCallFunction?.name === "string" ? toolCallFunction.name : "tool"; + const callId = + typeof toolCall.callId === "string" + ? toolCall.callId + : typeof toolCall.id === "string" + ? toolCall.id + : undefined; + this.toolStarts.push({ + startedAt: new Date(), + agentId, + ...(agentName === undefined ? {} : { agentName }), + childTurn, + toolName, + ...(callId === undefined ? {} : { toolCallId: callId }), + input: toJsonValue(toolCallFunction?.arguments ?? {}), + completed: false, + }); + return; + } + + if (child.type === "tool_result") { + const toolName = typeof child.toolName === "string" ? child.toolName : "tool"; + const toolCallId = typeof child.toolCallId === "string" ? child.toolCallId : undefined; + const internalCallId = + typeof child.internalCallId === "string" ? child.internalCallId : undefined; + const start = this.findToolStart(agentId, toolName, toolCallId); + const input = + start?.input ?? (typeof child.args === "string" ? parseOrString(child.args) : undefined); + if (start !== undefined) { + start.completed = true; + } + this.completedObservations.push( + traceObservation({ + kind: "tool", + name: `${agentLabel(agentId, agentName)}.${toolName}`, + status: "success", + turn: this.parent.turn, + startedAt: start?.startedAt ?? new Date(), + ...(input === undefined ? {} : { input }), + ...(typeof child.result === "string" ? { output: parseOrString(child.result) } : {}), + metadata: { + ...this.childMetadata(agentId, agentName, childTurn), + ...(toolCallId === undefined ? {} : { toolCallId }), + ...(internalCallId === undefined ? {} : { internalCallId }), + }, + }), + ); + return; + } + + if (child.type === "error") { + this.completedObservations.push( + traceObservation({ + kind: "tool", + name: `${agentLabel(agentId, agentName)}.error`, + status: "error", + turn: this.parent.turn, + startedAt: new Date(), + error: serializeError(child.error), + metadata: this.childMetadata(agentId, agentName, childTurn), + }), + ); + } + } + + observations(parentObservationId: string): StudioTraceObservation[] { + const observations: StudioTraceObservation[] = []; + const agentObservationIds = new Map(); + + for (const agentStart of this.agentStarts.values()) { + const agentChildren = this.completedObservations.filter( + (observation) => + isRecord(observation.metadata) && + observation.metadata.childAgentId === agentStart.agentId, + ); + const childStartTimes = agentChildren.map((observation) => Date.parse(observation.startedAt)); + const childEndTimes = agentChildren.map((observation) => + Date.parse(observation.endedAt ?? observation.startedAt), + ); + const startedAt = + childStartTimes.length === 0 + ? agentStart.startedAt + : new Date(Math.min(agentStart.startedAt.getTime(), ...childStartTimes)); + const endedAt = + childEndTimes.length === 0 ? new Date() : new Date(Math.max(...childEndTimes)); + const agentObservation = traceObservation({ + parentObservationId, + kind: "agent", + name: `${agentLabel(agentStart.agentId, agentStart.agentName)}.run`, + status: agentChildren.some((observation) => observation.status === "error") + ? "error" + : "success", + turn: this.parent.turn, + startedAt, + endedAt, + metadata: this.childMetadata(agentStart.agentId, agentStart.agentName, this.parent.turn), + }); + observations.push(agentObservation); + agentObservationIds.set(agentStart.agentId, agentObservation.id); + } + + for (const observation of this.completedObservations) { + const childAgentId = isRecord(observation.metadata) + ? stringValue(observation.metadata.childAgentId) + : undefined; + const childAgentObservationId = + childAgentId === undefined ? undefined : agentObservationIds.get(childAgentId); + observations.push({ + ...observation, + parentObservationId: childAgentObservationId ?? parentObservationId, + }); + } + + return observations; + } + + private findToolStart( + agentId: string, + toolName: string, + toolCallId: string | undefined, + ): (typeof this.toolStarts)[number] | undefined { + for (let index = this.toolStarts.length - 1; index >= 0; index -= 1) { + const start = this.toolStarts[index]; + if ( + start === undefined || + start.completed || + start.agentId !== agentId || + start.toolName !== toolName + ) { + continue; + } + if (toolCallId === undefined || start.toolCallId === toolCallId) { + return start; + } + } + return undefined; + } + + private childMetadata( + agentId: string, + agentName: string | undefined, + childTurn: number, + ): JsonObject { + return compactJsonObject({ + source: "agent_tool_event", + childAgentId: agentId, + childAgentName: agentName, + childTurn, + parentToolName: this.parent.toolName, + parentInternalCallId: this.parent.internalCallId, + parentToolCallId: this.parent.toolCallId, + }); + } +} + +function generationKey(agentId: string, turn: number): string { + return `${agentId}:${turn}`; +} + +function agentLabel(agentId: string, agentName: string | undefined): string { + return (agentName ?? agentId).replaceAll(/\s+/g, "_"); +} + function traceObservation(props: { + parentObservationId?: string; kind: StudioTraceObservation["kind"]; name: string; status: StudioTraceStatus; turn: number; startedAt: Date; + endedAt?: Date; input?: JsonValue; output?: JsonValue; error?: JsonValue; metadata?: JsonObject; }): StudioTraceObservation { - const endedAt = new Date(); + const endedAt = props.endedAt ?? new Date(); return { id: globalThis.crypto.randomUUID(), + ...(props.parentObservationId === undefined + ? {} + : { parentObservationId: props.parentObservationId }), kind: props.kind, name: props.name, status: props.status, @@ -238,14 +492,152 @@ function traceMetadata(args: AgentRunStartArgs, messages: JsonValue): JsonObject }); } -function toolMetadata(args: AgentToolStartArgs, skipped: boolean): JsonObject { +function generationMetadata( + args: AgentGenerationStartArgs, + endArgs?: AgentGenerationEndArgs, +): JsonObject { + const request = args.request; + const response = endArgs?.response; + const rawResponse = isRecord(response?.rawResponse) ? response.rawResponse : undefined; + const effectiveModel = + request.model ?? stringValue(rawResponse?.model) ?? args.modelInfo?.defaultModel ?? "default"; + const providerResponse = providerResponseSummary(rawResponse); + const usage = response?.usage; + + return compactJsonObject({ + provider: args.modelInfo?.provider, + model: effectiveModel, + requestedModel: request.model, + defaultModel: args.modelInfo?.defaultModel, + messageId: response?.messageId, + usage, + toolCount: request.tools.length, + toolNames: request.tools.map((tool) => tool.name), + documentCount: request.documents.length, + historyCount: request.chatHistory.length, + temperature: request.temperature, + maxTokens: request.maxTokens, + toolChoice: request.toolChoice, + additionalParamKeys: isRecord(request.additionalParams) + ? Object.keys(request.additionalParams).sort() + : undefined, + hasOutputSchema: request.outputSchema !== undefined, + firstDeltaMs: endArgs?.firstDeltaMs, + providerResponse, + modelInfo: compactJsonObject({ + provider: args.modelInfo?.provider, + model: effectiveModel, + requestedModel: request.model, + defaultModel: args.modelInfo?.defaultModel, + capabilities: args.modelInfo?.capabilities, + }), + modelCall: compactJsonObject({ + request: completionRequestSummary(request), + providerRequest: args.providerRequest, + }), + response: compactJsonObject({ + messageId: response?.messageId, + usage, + contentTypes: response?.choice.map((item) => item.type), + providerResponse, + }), + tools: compactJsonObject({ + count: request.tools.length, + names: request.tools.map((tool) => tool.name), + toolChoice: request.toolChoice, + hasOutputSchema: request.outputSchema !== undefined, + }), + timing: compactJsonObject({ + firstDeltaMs: endArgs?.firstDeltaMs, + }), + }); +} + +function completionRequestSummary(request: AgentGenerationStartArgs["request"]): JsonObject { + return compactJsonObject({ + model: request.model, + instructions: request.instructions === undefined ? undefined : { present: true }, + messageCount: request.chatHistory.length, + documentCount: request.documents.length, + documentIds: request.documents.map((document) => document.id), + toolCount: request.tools.length, + toolNames: request.tools.map((tool) => tool.name), + temperature: request.temperature, + maxTokens: request.maxTokens, + toolChoice: request.toolChoice, + additionalParamKeys: isRecord(request.additionalParams) + ? Object.keys(request.additionalParams).sort() + : undefined, + hasOutputSchema: request.outputSchema !== undefined, + }); +} + +function providerResponseSummary( + rawResponse: Record | undefined, +): JsonObject | undefined { + if (rawResponse === undefined) { + return undefined; + } + const reasoning = isRecord(rawResponse.reasoning) ? rawResponse.reasoning : undefined; + const text = isRecord(rawResponse.text) ? rawResponse.text : undefined; + const toolUsage = isRecord(rawResponse.tool_usage) ? rawResponse.tool_usage : undefined; + const webSearch = isRecord(toolUsage?.web_search) ? toolUsage.web_search : undefined; + const summary = compactJsonObject({ + id: rawResponse.id, + status: rawResponse.status, + serviceTier: rawResponse.service_tier, + store: rawResponse.store, + parallelToolCalls: rawResponse.parallel_tool_calls, + promptCacheKey: rawResponse.prompt_cache_key, + promptCacheRetention: rawResponse.prompt_cache_retention, + reasoningEffort: reasoning?.effort, + textVerbosity: text?.verbosity, + webSearchRequestCount: webSearch?.num_requests, + }); + return Object.keys(summary).length === 0 ? undefined : summary; +} + +function toolMetadata(args: AgentToolStartArgs, skipped: boolean, result?: string): JsonObject { + const schema = isRecord(args.toolDefinition?.parameters) ? args.toolDefinition.parameters : {}; + const properties = isRecord(schema.properties) ? schema.properties : {}; + const required = Array.isArray(schema.required) + ? schema.required.filter((item): item is string => typeof item === "string") + : []; return compactJsonObject({ internalCallId: args.internalCallId, toolCallId: args.toolCallId, skipped, + argumentBytes: byteLength(args.args), + resultBytes: result === undefined ? undefined : byteLength(result), + hasCallSignature: args.toolCall.signature !== undefined, + hasAdditionalParams: args.toolCall.additionalParams !== undefined, + toolDescription: args.toolDefinition?.description, + parameterKeys: Object.keys(properties).sort(), + requiredParameterKeys: required, + approvalRequired: args.toolMetadata?.approvalRequired, + mcpServerName: args.toolMetadata?.mcpServerName, + tools: compactJsonObject({ + name: args.toolName, + internalCallId: args.internalCallId, + toolCallId: args.toolCallId, + skipped, + description: args.toolDefinition?.description, + parameterKeys: Object.keys(properties).sort(), + requiredParameterKeys: required, + approvalRequired: args.toolMetadata?.approvalRequired, + mcpServerName: args.toolMetadata?.mcpServerName, + argumentBytes: byteLength(args.args), + resultBytes: result === undefined ? undefined : byteLength(result), + hasCallSignature: args.toolCall.signature !== undefined, + hasAdditionalParams: args.toolCall.additionalParams !== undefined, + }), }); } +function byteLength(value: string): number { + return new TextEncoder().encode(value).length; +} + function compactJsonObject(values: Record): JsonObject { const entries = Object.entries(values).flatMap(([key, value]) => value === undefined ? [] : [[key, toJsonValue(value)]], @@ -265,6 +657,14 @@ function parseOrString(value: string): JsonValue { } } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + function serializeError(error: unknown): JsonValue { if (error instanceof Error) { return compactJsonObject({ diff --git a/packages/tools/studio/src/types.ts b/packages/tools/studio/src/types.ts index 40ada9b6..708df107 100644 --- a/packages/tools/studio/src/types.ts +++ b/packages/tools/studio/src/types.ts @@ -1,22 +1,30 @@ +import type { AgentStreamEvent, PromptResponse } from "@anvia/core/agent"; import type { - Agent, - AgentStreamEvent, - AgentTraceInfo, - AgentTraceOptions, JsonObject, JsonValue, Message, - PromptResponse, + ToolResultContent, Usage, -} from "@anvia/core"; +} from "@anvia/core/completion"; +import type { RunEvalSuiteOptions } from "@anvia/core/evals"; +import type { Agent } from "@anvia/core/internal/agent"; +import type { MemoryStore } from "@anvia/core/memory"; +import type { AgentTraceInfo, AgentTraceOptions } from "@anvia/core/observability"; +import type { Pipeline, PipelineGraph } from "@anvia/core/pipeline"; import type { Hono } from "hono"; export type StudioCapability = | "agents" | "approvals" + | "evals" + | "memory" | "knowledge" + | "mcps" | "observability" + | "pipelines" | "sessions" + | "status" + | "tools" | "traces"; export type StudioAgent = { @@ -28,6 +36,10 @@ export type StudioAgent = { metadata?: JsonObject; }; +// Studio accepts arbitrary pipelines and validates run inputs at the HTTP boundary. +// biome-ignore lint/suspicious/noExplicitAny: input/output types remain user-defined outside Studio. +export type StudioTarget = Agent | Pipeline; + export type StudioAgentConfig = { id: string; name?: string; @@ -36,6 +48,84 @@ export type StudioAgentConfig = { metadata?: JsonObject; }; +export type StudioAgentRuntimeSummary = { + id: string; + name?: string; + description?: string; + model?: JsonValue; + toolCount: number; + staticToolCount: number; + dynamicToolCount: number; + approvalToolCount: number; + mcpToolCount: number; + staticContextCount: number; + dynamicContextCount: number; + observerCount: number; + hasMemory: boolean; + hasHook: boolean; + hasOutputSchema: boolean; + defaultMaxTurns?: number; + metadata?: JsonObject; +}; + +export type StudioPipeline = { + id: string; + // biome-ignore lint/suspicious/noExplicitAny: Studio stores heterogeneous user pipelines. + pipeline: Pipeline; + name?: string; + description?: string; + metadata?: JsonObject; +}; + +export type StudioPipelineConfig = { + id: string; + name?: string; + description?: string; + metadata?: JsonObject; + stageCount: number; + edgeCount: number; + hasParallelStages: boolean; + agentCount: number; + extractorCount: number; +}; + +export type StudioPipelineDetail = StudioPipelineConfig & { + graph: PipelineGraph; +}; + +export type StudioEvalSuite< + Input = unknown, + Output = unknown, + Expected = unknown, +> = RunEvalSuiteOptions & { + id?: string; + description?: string; + metadata?: JsonObject; +}; + +export type StudioEvalSuiteConfig = { + id: string; + name: string; + description?: string; + caseCount: number; + metricNames: string[]; + concurrency?: number; + metadata?: JsonObject; +}; + +export type StudioEvalRunRequest = { + concurrency?: number; +}; + +export type StudioEvalRunResponse = { + runId: string; + suiteId: string; + startedAt: string; + endedAt: string; + durationMs: number; + result: JsonObject; +}; + export type StudioCapabilityConfig = { enabled: boolean; reason?: string; @@ -47,6 +137,8 @@ export type StudioConfig = { description?: string; version?: string; agents: StudioAgentConfig[]; + pipelines: StudioPipelineConfig[]; + evals: StudioEvalSuiteConfig[]; chat: { quickPrompts: Record; }; @@ -54,11 +146,70 @@ export type StudioConfig = { unsupportedCapabilities: StudioCapability[]; }; +export type StudioAgentToolSource = "static" | "dynamic"; + +export type StudioAgentToolApprovalMetadata = { + required: boolean; + reason?: string; + rejectMessage?: string; +}; + +export type StudioAgentToolMetadata = { + agentId: string; + name: string; + description: string; + parameters: JsonObject; + source: StudioAgentToolSource; + approval: StudioAgentToolApprovalMetadata; +}; + +export type StudioAgentToolsSummary = { + agentId: string; + tools: StudioAgentToolMetadata[]; +}; + +export type StudioToolRunRequest = { + args: JsonValue; + context?: JsonObject; +}; + +export type StudioToolRunResponse = { + agentId: string; + toolName: string; + result?: JsonValue; + error?: JsonValue; + status: "success" | "error"; + durationMs: number; + startedAt: string; + endedAt: string; + events: JsonValue[]; +}; + +export type StudioAgentMcpToolMetadata = { + name: string; + description: string; + parameters: JsonObject; + source: StudioAgentToolSource; +}; + +export type StudioAgentMcpServerMetadata = { + agentId: string; + name: string; + toolCount: number; + tools: StudioAgentMcpToolMetadata[]; +}; + +export type StudioAgentMcpsSummary = { + agentId: string; + servers: StudioAgentMcpServerMetadata[]; +}; + export type StudioTranscriptChatEntry = { entryId: number; kind: "message"; role: "user" | "assistant"; text: string; + tone?: "error"; traceId?: string; }; @@ -76,10 +227,37 @@ export type StudioTranscriptToolEntry = { callId?: string; args?: string; result?: string; + structuredResult?: ToolResultContent[]; + childEvents?: StudioTranscriptChildAgentEvent[]; approval?: StudioToolApprovalTranscript; question?: StudioToolQuestionTranscript; }; +export type StudioTranscriptChildAgentEvent = + | { + kind: "message"; + agentId: string; + agentName?: string; + text: string; + } + | { + kind: "reasoning"; + agentId: string; + agentName?: string; + reasoningId?: string; + text: string; + } + | { + kind: "tool"; + agentId: string; + agentName?: string; + toolName: string; + callId?: string; + args?: string; + result?: string; + structuredResult?: ToolResultContent[]; + }; + export type StudioTranscriptEntry = | StudioTranscriptChatEntry | StudioTranscriptReasoningEntry @@ -112,14 +290,60 @@ export type StudioSessionListOptions = { limit: number; }; -export type StudioSessionAppendInput = { +export type StudioSessionRunStatus = "running" | "success" | "error"; + +export type StudioSessionRunTranscriptInput = { id: string; + runId: string; title?: string; - messages: Message[]; transcript: StudioTranscriptEntry[]; + status: StudioSessionRunStatus; + error?: JsonValue; +}; + +export type StudioSessionLogLevel = "debug" | "info" | "warn" | "error"; + +export type StudioSessionLogCategory = + | "session" + | "run" + | "memory" + | "prompt" + | "model" + | "tool" + | "approval" + | "question" + | "api"; + +export type StudioSessionLogEntry = { + id: string; + sessionId: string; + runId?: string; + sequence: number; + timestamp: string; + level: StudioSessionLogLevel; + category: StudioSessionLogCategory; + event: string; + message: string; + metadata?: JsonObject; }; -export type StudioSessionStore = { +export type StudioSessionLogAppendInput = { + sessionId: string; + runId?: string; + level: StudioSessionLogLevel; + category: StudioSessionLogCategory; + event: string; + message: string; + metadata?: JsonObject; +}; + +export type StudioSessionLogListOptions = { + sessionId: string; + limit: number; + after?: number; +}; + +export type StudioSessionStore = MemoryStore & { readonly kind?: string; listSessions( options: StudioSessionListOptions, @@ -128,18 +352,25 @@ export type StudioSessionStore = { input: StudioSessionCreateInput, ): StudioSessionSummary | Promise; getSession(id: string): StudioSession | undefined | Promise; - appendSessionRun( - input: StudioSessionAppendInput, + saveSessionRunTranscript( + input: StudioSessionRunTranscriptInput, ): StudioSession | undefined | Promise; + appendSessionLog?( + input: StudioSessionLogAppendInput, + ): StudioSessionLogEntry | Promise; + listSessionLogs?( + options: StudioSessionLogListOptions, + ): StudioSessionLogEntry[] | Promise; deleteSession?(id: string): boolean | Promise; }; export type StudioTraceStatus = "running" | "success" | "error"; -export type StudioTraceObservationKind = "generation" | "tool"; +export type StudioTraceObservationKind = "agent" | "generation" | "tool"; export type StudioTraceObservation = { id: string; + parentObservationId?: string; kind: StudioTraceObservationKind; name: string; status: StudioTraceStatus; @@ -174,6 +405,22 @@ export type StudioTrace = StudioTraceSummary & { observations: StudioTraceObservation[]; }; +export type StudioObservabilityEventType = "session_log" | "pipeline_log" | "trace"; + +export type StudioObservabilityEvent = + | { + type: "session_log"; + log: StudioSessionLogEntry; + } + | { + type: "pipeline_log"; + log: StudioPipelineLogEntry; + } + | { + type: "trace"; + trace: StudioTraceSummary; + }; + export type StudioTraceListOptions = { limit: number; agentId?: string; @@ -201,8 +448,15 @@ export type StudioTraceStore = { export type StudioKnowledgeSourceKind = "static_context" | "dynamic_context" | "dynamic_tools"; export type StudioKnowledgeSourceSummary = { + sourceId?: string; kind: StudioKnowledgeSourceKind; + label?: string; count: number; + registrationIndex?: number; + topK?: number; + threshold?: number; + inspectable?: boolean; + itemCount?: number; }; export type StudioStaticKnowledgeDocument = { @@ -238,14 +492,102 @@ export type StudioAgentKnowledgeConfig = { staticContext: StudioStaticKnowledgeDocument[]; }; +export type StudioKnowledgeItemKind = "static_context" | "dynamic_context" | "dynamic_tool"; + +export type StudioKnowledgeItem = { + id: string; + kind: StudioKnowledgeItemKind; + text?: string; + document?: JsonValue; + toolName?: string; + description?: string; + parameterKeys?: string[]; + metadata?: JsonObject; +}; + +export type StudioKnowledgeItemsPage = { + agentId: string; + sourceId: string; + kind: StudioKnowledgeSourceKind; + inspectable: boolean; + items: StudioKnowledgeItem[]; + nextCursor?: string; + totalCount?: number; + message?: string; +}; + export type StudioKnowledgeSummary = { agents: StudioAgentKnowledgeConfig[]; evidence: StudioKnowledgeEvidence[]; }; +export type StudioMemoryUserSummary = { + userId: string; + conversationCount: number; + agentIds: string[]; + lastInteractionAt: string; +}; + +export type StudioMemoryConversationSummary = { + id: string; + userId: string; + agentId: string; + title?: string; + createdAt: string; + updatedAt: string; + messageCount: number; + metadata?: JsonObject; +}; + +export type StudioMemoryConversationsPage = { + conversations: StudioMemoryConversationSummary[]; + total: number; +}; + +export type StudioMemoryUsersPage = { + users: StudioMemoryUserSummary[]; + total: number; +}; + +export type StudioMemoryConversationMessages = { + conversation: StudioMemoryConversationSummary; + messages: Message[]; + transcript: StudioTranscriptEntry[]; +}; + +export type StudioMemoryConversationSteps = { + conversation: StudioMemoryConversationSummary; + steps: StudioTranscriptEntry[]; +}; + +export type StudioStatusSummary = { + runner: { + id: string; + name?: string; + version?: string; + }; + storage: { + sessions?: string; + traces?: string; + pipelineLogs?: string; + pipelineRuns?: string; + }; + counts: { + agents: number; + pipelines: number; + sessions?: number; + traces?: number; + pipelineRuns?: number; + }; + capabilities: Partial>; + generatedAt: string; +}; + export type StudioStores = { sessions?: StudioSessionStore | false; traces?: StudioTraceStore; + pipelineLogs?: StudioPipelineLogStore | false; + pipelineRuns?: StudioPipelineRunStore | false; }; export type StudioUiOptions = { @@ -258,7 +600,11 @@ export type StudioUiOptions = { }; export type StudioOptions = { + // biome-ignore lint/suspicious/noExplicitAny: Studio accepts eval suites with arbitrary user-defined case and output types. + evals?: Array>; quickPrompts?: Record; + stores?: StudioStores; + ui?: boolean | StudioUiOptions; }; export type StudioServeOptions = { @@ -362,6 +708,131 @@ export type StudioToolQuestionResultEvent = { question: StudioToolQuestion; }; +export type StudioSessionLogEvent = { + type: "session_log"; + log: StudioSessionLogEntry; +}; + +export type StudioPipelineLogLevel = "debug" | "info" | "warn" | "error"; + +export type StudioPipelineLogCategory = + | "pipeline" + | "run" + | "stage" + | "parallel" + | "agent" + | "extractor" + | "api"; + +export type StudioPipelineLogEntry = { + id: string; + pipelineId: string; + runId?: string; + sequence: number; + timestamp: string; + level: StudioPipelineLogLevel; + category: StudioPipelineLogCategory; + event: string; + message: string; + metadata?: JsonObject; +}; + +export type StudioPipelineLogAppendInput = { + pipelineId: string; + runId?: string; + level: StudioPipelineLogLevel; + category: StudioPipelineLogCategory; + event: string; + message: string; + metadata?: JsonObject; +}; + +export type StudioPipelineLogListOptions = { + pipelineId: string; + limit: number; + after?: number; +}; + +export type StudioPipelineLogStore = { + appendPipelineLog( + input: StudioPipelineLogAppendInput, + ): StudioPipelineLogEntry | Promise; + listPipelineLogs( + options: StudioPipelineLogListOptions, + ): StudioPipelineLogEntry[] | Promise; +}; + +export type StudioPipelineLogEvent = { + type: "pipeline_log"; + log: StudioPipelineLogEntry; +}; + +export type StudioPipelineFinalEvent = { + type: "pipeline_final"; + runId: string; + pipelineId: string; + output: JsonValue; +}; + +export type StudioPipelineRunStatus = "running" | "success" | "error"; + +export type StudioPipelineRunRecord = { + runId: string; + pipelineId: string; + status: StudioPipelineRunStatus; + input: JsonValue; + output?: JsonValue; + error?: JsonValue; + metadata?: JsonObject; + startedAt: string; + endedAt?: string; + durationMs?: number; +}; + +export type StudioPipelineRunSaveInput = { + runId: string; + pipelineId: string; + status: StudioPipelineRunStatus; + input: JsonValue; + output?: JsonValue; + error?: JsonValue; + metadata?: JsonObject; + startedAt: string; + endedAt?: string; + durationMs?: number; +}; + +export type StudioPipelineRunListOptions = { + pipelineId: string; + limit: number; +}; + +export type StudioPipelineRunStore = { + savePipelineRun( + input: StudioPipelineRunSaveInput, + ): StudioPipelineRunRecord | Promise; + listPipelineRuns( + options: StudioPipelineRunListOptions, + ): StudioPipelineRunRecord[] | Promise; +}; + +export type StudioPipelineRunRequest = { + input: JsonValue; + stream?: boolean; + metadata?: JsonObject; +}; + +export type StudioPipelineReplayRequest = { + stream?: boolean; + metadata?: JsonObject; +}; + +export type StudioPipelineRunResponse = { + runId: string; + pipelineId: string; + output: JsonValue; +}; + export type AgentRunRequest = { message: string | Message; history?: Message[]; @@ -380,7 +851,10 @@ export type AgentRunStreamEvent = | StudioToolApprovalRequestEvent | StudioToolApprovalResultEvent | StudioToolQuestionRequestEvent - | StudioToolQuestionResultEvent; + | StudioToolQuestionResultEvent + | StudioSessionLogEvent + | StudioPipelineLogEvent + | StudioPipelineFinalEvent; export type StudioErrorCode = | "bad_request" diff --git a/packages/tools/studio/src/ui/app/app.tsx b/packages/tools/studio/src/ui/app/app.tsx index 85223be4..0058d8ae 100644 --- a/packages/tools/studio/src/ui/app/app.tsx +++ b/packages/tools/studio/src/ui/app/app.tsx @@ -1,4 +1,6 @@ -import { ArrowUp, Plus, Trash2 } from "lucide-react"; +import type { Message, ToolResultContent } from "@anvia/core/completion"; +import { createChatTransport, EventStreamHttpError, useChat } from "@anvia/react"; +import { Archive, ArrowSquareOut, ArrowUp, Moon, Plus, Sun } from "@phosphor-icons/react"; import { type ChangeEvent, type KeyboardEvent, @@ -9,12 +11,20 @@ import { } from "react"; import type { AgentRunStreamEvent, + StudioAgentMcpsSummary, + StudioAgentToolsSummary, StudioConfig, + StudioEvalRunResponse, StudioKnowledgeSummary, + StudioPipelineDetail, + StudioPipelineLogEntry, + StudioPipelineRunRecord, StudioSession, + StudioSessionLogEntry, StudioSessionSummary, StudioTrace, StudioTraceSummary, + StudioTranscriptChildAgentEvent, } from "../../types"; import { Button } from "./components/ui/button"; import { @@ -27,8 +37,13 @@ import { import { Textarea } from "./components/ui/textarea"; import { cn } from "./lib/utils"; import { AgentsPage } from "./modules/agents/agents-page"; +import { EvalsPage } from "./modules/evals/evals-page"; import { KnowledgePage } from "./modules/knowledge/knowledge-page"; +import { McpsPage } from "./modules/mcps/mcps-page"; +import { MemoryPage } from "./modules/memory/memory-page"; +import { PipelinesPage } from "./modules/pipelines/pipelines-page"; import { TranscriptItem } from "./modules/playground/transcript-item"; +import { SessionLogsPanel } from "./modules/session-logs/session-logs-panel"; import { DeleteSessionDialog, SessionsPage } from "./modules/sessions/sessions-page"; import { errorMessage, @@ -37,8 +52,10 @@ import { titleFromText, } from "./modules/shared/format"; import { + defaultKnowledgeTab, logoSrc, pageLocationFromLocation, + updateKnowledgePath, updatePagePath, updateSessionPath, updateTracePath, @@ -59,6 +76,7 @@ import { } from "./modules/shared/transcript"; import type { ActivePage, + KnowledgeTab, RunState, SessionLoadState, ToolApprovalUpdate, @@ -67,22 +85,123 @@ import type { TranscriptEntry, } from "./modules/shared/types"; import { NavButton } from "./modules/shell/nav-button"; +import { StatusPage } from "./modules/status/status-page"; +import { ToolsPage } from "./modules/tools/tools-page"; import { TraceBrowser } from "./modules/tracing/trace-browser"; -function applyDarkTheme(): void { - document.documentElement.classList.add("dark"); +type StudioTheme = "light" | "dark"; + +type StudioAgentRunRequest = { + agentId: string; + message: string; + sessionId?: string; + history?: Message[]; + stream: true; + metadata: { + source: string; + }; +}; + +const studioThemeStorageKey = "anvia-studio-theme"; + +function readInitialStudioTheme(): StudioTheme { + if (typeof window === "undefined") { + return "dark"; + } + try { + const stored = window.localStorage.getItem(studioThemeStorageKey); + return stored === "light" || stored === "dark" ? stored : "dark"; + } catch { + return "dark"; + } +} + +function applyStudioTheme(theme: StudioTheme): void { + if (typeof document === "undefined") { + return; + } + document.documentElement.classList.toggle("dark", theme === "dark"); + document.documentElement.style.colorScheme = theme; +} + +function storeStudioTheme(theme: StudioTheme): void { + try { + window.localStorage.setItem(studioThemeStorageKey, theme); + } catch { + // Ignore storage failures so private or restricted browsing still toggles the UI. + } +} + +const initialStudioTheme = readInitialStudioTheme(); +applyStudioTheme(initialStudioTheme); + +async function responseErrorMessage(response: Response, label: string): Promise { + let detail = ""; + try { + const body = (await response.json()) as unknown; + if ( + typeof body === "object" && + body !== null && + "error" in body && + typeof body.error === "object" && + body.error !== null && + "message" in body.error && + typeof body.error.message === "string" + ) { + detail = `: ${body.error.message}`; + } + } catch { + // Ignore non-JSON error bodies. + } + return `${label} with HTTP ${response.status}${detail}`; +} + +function agentRunErrorMessage(error: unknown): string { + if (error instanceof EventStreamHttpError) { + return error.response.status === 401 + ? "Authentication required" + : `Run failed with HTTP ${error.response.status}`; + } + return errorMessage(error); +} + +function serializedStreamErrorText(error: unknown): string { + if (typeof error === "string") { + return error; + } + try { + return JSON.stringify(error) ?? String(error); + } catch { + return String(error); + } } export function StudioConsole() { const initialLocation = pageLocationFromLocation(); const [config, setConfig] = useState(); const [selectedAgentId, setSelectedAgentId] = useState(""); + const [mcpsAgentId, setMcpsAgentId] = useState(""); + const [toolsAgentId, setToolsAgentId] = useState(""); + const [selectedPipelineId, setSelectedPipelineId] = useState(""); + const [selectedEvalId, setSelectedEvalId] = useState(""); const [selectedSessionId, setSelectedSessionId] = useState(""); const [allSessions, setAllSessions] = useState([]); const [traces, setTraces] = useState([]); + const [sessionLogs, setSessionLogs] = useState([]); + const [pipelineDetail, setPipelineDetail] = useState(); + const [pipelineLogs, setPipelineLogs] = useState([]); + const [pipelineRuns, setPipelineRuns] = useState([]); const [messages, setMessages] = useState([]); const [prompt, setPrompt] = useState(""); + const [pipelineRunInput, setPipelineRunInput] = useState('"Hello from Studio"'); + const [pipelineRunOutput, setPipelineRunOutput] = useState(""); + const [evalRunResult, setEvalRunResult] = useState(); + const [activePipelineRunId, setActivePipelineRunId] = useState(""); const [activePage, setActivePage] = useState(() => initialLocation.page); + const [theme, setTheme] = useState(() => initialStudioTheme); + const [knowledgeTab, setKnowledgeTab] = useState( + () => initialLocation.knowledgeTab ?? defaultKnowledgeTab, + ); const [selectedTraceId, setSelectedTraceId] = useState(() => initialLocation.traceId ?? ""); const [traceSessionDetailId, setTraceSessionDetailId] = useState( () => initialLocation.traceSessionId, @@ -94,10 +213,54 @@ export function StudioConsole() { const [decidingApprovals, setDecidingApprovals] = useState>(() => new Set()); const [answeringQuestions, setAnsweringQuestions] = useState>(() => new Set()); const [sessionLoadState, setSessionLoadState] = useState("idle"); + const [sessionLogLoadState, setSessionLogLoadState] = useState("idle"); const [traceLoadState, setTraceLoadState] = useState("idle"); const [knowledge, setKnowledge] = useState(); const [knowledgeLoadState, setKnowledgeLoadState] = useState<"idle" | "loading">("idle"); + const [mcps, setMcps] = useState(); + const [mcpsLoadState, setMcpsLoadState] = useState<"idle" | "loading">("idle"); + const [tools, setTools] = useState(); + const [toolsLoadState, setToolsLoadState] = useState<"idle" | "loading">("idle"); + const [pipelineDetailLoadState, setPipelineDetailLoadState] = useState<"idle" | "loading">( + "idle", + ); + const [pipelineLogLoadState, setPipelineLogLoadState] = useState<"idle" | "loading">("idle"); + const [pipelineRunLoadState, setPipelineRunLoadState] = useState<"idle" | "loading">("idle"); + const [pipelineRunState, setPipelineRunState] = useState("idle"); + const [evalRunState, setEvalRunState] = useState("idle"); const promptRef = useRef(null); + const transcriptScrollerRef = useRef(null); + const transcriptStickToBottomRef = useRef(true); + const playgroundRunRequestRef = useRef(undefined); + const playgroundRunErrorRef = useRef(undefined); + const playgroundVisibleEventRef = useRef>(Promise.resolve()); + + function updateTranscriptStickiness() { + const node = transcriptScrollerRef.current; + if (node === null) { + return; + } + transcriptStickToBottomRef.current = + node.scrollHeight - node.scrollTop - node.clientHeight < 80; + } + + useEffect(() => { + if ( + activePage !== "playground" || + messages.length === 0 || + !transcriptStickToBottomRef.current + ) { + return; + } + const frame = window.requestAnimationFrame(() => { + const node = transcriptScrollerRef.current; + if (node === null) { + return; + } + node.scrollTop = node.scrollHeight; + }); + return () => window.cancelAnimationFrame(frame); + }, [activePage, messages]); const loadConfig = useCallback(async () => { setStatus("Loading"); @@ -115,6 +278,10 @@ export function StudioConsole() { const nextConfig = (await response.json()) as StudioConfig; setConfig(nextConfig); setSelectedAgentId((current) => current || nextConfig.agents[0]?.id || ""); + setMcpsAgentId((current) => current || nextConfig.agents[0]?.id || ""); + setToolsAgentId((current) => current || nextConfig.agents[0]?.id || ""); + setSelectedPipelineId((current) => current || nextConfig.pipelines[0]?.id || ""); + setSelectedEvalId((current) => current || nextConfig.evals[0]?.id || ""); setStatus("Connected"); } catch (loadError) { setError(errorMessage(loadError)); @@ -129,10 +296,65 @@ export function StudioConsole() { const sessionsEnabled = config?.capabilities.sessions?.enabled === true; const tracesEnabled = config?.capabilities.traces?.enabled === true; const knowledgeEnabled = config?.capabilities.knowledge?.enabled === true; + const mcpsEnabled = config?.capabilities.mcps?.enabled === true; + const toolsEnabled = config?.capabilities.tools?.enabled === true; + const pipelinesEnabled = config?.capabilities.pipelines?.enabled === true; + const evalsEnabled = config?.capabilities.evals?.enabled === true; + const memoryEnabled = config?.capabilities.memory?.enabled === true; + const statusEnabled = config?.capabilities.status?.enabled === true; + const agents = config?.agents ?? []; + const pipelines = config?.pipelines ?? []; + const evals = config?.evals ?? []; + const hasAgents = agents.length > 0; + const selectedAgent = + agents.find((agent) => agent.id === selectedAgentId) ?? agents[0] ?? undefined; + const selectedAgentQuickPrompts = selectedAgent?.quickPrompts ?? []; + const hasMessages = messages.length > 0; + const playgroundChat = useChat({ + transport: createChatTransport({ + endpoint: (request) => `/agents/${encodeURIComponent(request.agentId)}/runs`, + method: "POST", + format: "jsonl", + headers: { + "content-type": "application/json", + }, + body: (request) => { + const { agentId: _agentId, ...body } = request; + return JSON.stringify(body); + }, + mapEvent: (event) => event as AgentRunStreamEvent, + }), + createRequest: () => { + const request = playgroundRunRequestRef.current; + if (request === undefined) { + throw new Error("Missing playground run request"); + } + return request; + }, + eventToDelta: () => undefined, + eventToFinal: () => undefined, + onEvent(event) { + const visibleDelta = acceptStreamEvent(event); + if (visibleDelta) { + playgroundVisibleEventRef.current = playgroundVisibleEventRef.current.then(nextPaint); + } + }, + onError(error) { + playgroundRunErrorRef.current = error; + const message = agentRunErrorMessage(error); + setError(message); + appendAssistantError(message); + }, + }); useEffect(() => { - applyDarkTheme(); - }, []); + applyStudioTheme(theme); + storeStudioTheme(theme); + }, [theme]); + + useEffect(() => { + setRunState(playgroundChat.status === "streaming" ? "running" : "idle"); + }, [playgroundChat.status]); const loadAllSessions = useCallback(async () => { if (!sessionsEnabled) { @@ -156,14 +378,43 @@ export function StudioConsole() { void loadAllSessions(); }, [loadAllSessions]); + const loadSessionLogs = useCallback( + async (sessionId: string): Promise => { + if (!sessionsEnabled) { + setSessionLogs([]); + return []; + } + + setSessionLogLoadState("loading"); + try { + const params = new URLSearchParams({ limit: "1000" }); + const response = await fetch(`/sessions/${encodeURIComponent(sessionId)}/logs?${params}`); + if (!response.ok) { + throw new Error(`Session logs failed with HTTP ${response.status}`); + } + const body = (await response.json()) as { logs: StudioSessionLogEntry[] }; + setSessionLogs(body.logs); + return body.logs; + } catch (loadError) { + setError(errorMessage(loadError)); + setSessionLogs([]); + return []; + } finally { + setSessionLogLoadState("idle"); + } + }, + [sessionsEnabled], + ); + async function createSession(title: string): Promise { + const agentId = selectedAgent?.id ?? selectedAgentId; const response = await fetch("/sessions", { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ - agentId: selectedAgentId, + agentId, title, metadata: { source: "anvia-studio", @@ -171,12 +422,13 @@ export function StudioConsole() { }), }); if (!response.ok) { - throw new Error(`Session create failed with HTTP ${response.status}`); + throw new Error(await responseErrorMessage(response, "Session create failed")); } const session = (await response.json()) as StudioSessionSummary; setSelectedSessionId(session.id); setAllSessions((current) => [session, ...current.filter((item) => item.id !== session.id)]); updateSessionPath(session.id); + await loadSessionLogs(session.id); return session; } @@ -319,6 +571,161 @@ export function StudioConsole() { } }, [activePage, loadKnowledge]); + const loadMcps = useCallback( + async (agentId: string) => { + if (!mcpsEnabled || agentId.length === 0) { + setMcps(undefined); + return; + } + + setMcpsLoadState("loading"); + try { + const response = await fetch(`/agents/${encodeURIComponent(agentId)}/mcps`); + if (!response.ok) { + throw new Error(`MCPs failed with HTTP ${response.status}`); + } + setMcps((await response.json()) as StudioAgentMcpsSummary); + } catch (loadError) { + setError(errorMessage(loadError)); + setMcps(undefined); + } finally { + setMcpsLoadState("idle"); + } + }, + [mcpsEnabled], + ); + + useEffect(() => { + if (activePage === "mcps") { + void loadMcps(mcpsAgentId || selectedAgentId); + } + }, [activePage, loadMcps, mcpsAgentId, selectedAgentId]); + + const loadTools = useCallback( + async (agentId: string) => { + if (!toolsEnabled || agentId.length === 0) { + setTools(undefined); + return; + } + + setToolsLoadState("loading"); + try { + const response = await fetch(`/agents/${encodeURIComponent(agentId)}/tools`); + if (!response.ok) { + throw new Error(`Tools failed with HTTP ${response.status}`); + } + setTools((await response.json()) as StudioAgentToolsSummary); + } catch (loadError) { + setError(errorMessage(loadError)); + setTools(undefined); + } finally { + setToolsLoadState("idle"); + } + }, + [toolsEnabled], + ); + + useEffect(() => { + if (activePage === "tools") { + void loadTools(toolsAgentId || selectedAgentId); + } + }, [activePage, loadTools, selectedAgentId, toolsAgentId]); + + const loadPipelineLogs = useCallback( + async (pipelineId: string): Promise => { + if (!pipelinesEnabled || pipelineId.length === 0) { + setPipelineLogs([]); + return []; + } + + setPipelineLogLoadState("loading"); + try { + const params = new URLSearchParams({ limit: "1000" }); + const response = await fetch(`/pipelines/${encodeURIComponent(pipelineId)}/logs?${params}`); + if (!response.ok) { + throw new Error(`Pipeline logs failed with HTTP ${response.status}`); + } + const body = (await response.json()) as { logs: StudioPipelineLogEntry[] }; + setPipelineLogs(body.logs); + return body.logs; + } catch (loadError) { + setError(errorMessage(loadError)); + setPipelineLogs([]); + return []; + } finally { + setPipelineLogLoadState("idle"); + } + }, + [pipelinesEnabled], + ); + + const loadPipelineRuns = useCallback( + async (pipelineId: string): Promise => { + if (!pipelinesEnabled || pipelineId.length === 0) { + setPipelineRuns([]); + return []; + } + + setPipelineRunLoadState("loading"); + try { + const params = new URLSearchParams({ limit: "50" }); + const response = await fetch(`/pipelines/${encodeURIComponent(pipelineId)}/runs?${params}`); + if (!response.ok) { + throw new Error(`Pipeline runs failed with HTTP ${response.status}`); + } + const body = (await response.json()) as { runs: StudioPipelineRunRecord[] }; + setPipelineRuns(body.runs); + return body.runs; + } catch (loadError) { + setError(errorMessage(loadError)); + setPipelineRuns([]); + return []; + } finally { + setPipelineRunLoadState("idle"); + } + }, + [pipelinesEnabled], + ); + + const loadPipeline = useCallback( + async (pipelineId: string) => { + if (!pipelinesEnabled || pipelineId.length === 0) { + setPipelineDetail(undefined); + setPipelineLogs([]); + setPipelineRuns([]); + return; + } + + setPipelineDetailLoadState("loading"); + setError(""); + try { + const response = await fetch(`/pipelines/${encodeURIComponent(pipelineId)}`); + if (!response.ok) { + throw new Error(`Pipeline load failed with HTTP ${response.status}`); + } + setSelectedPipelineId(pipelineId); + setPipelineDetail((await response.json()) as StudioPipelineDetail); + await Promise.all([loadPipelineLogs(pipelineId), loadPipelineRuns(pipelineId)]); + } catch (loadError) { + setError(errorMessage(loadError)); + setPipelineDetail(undefined); + } finally { + setPipelineDetailLoadState("idle"); + } + }, + [loadPipelineLogs, loadPipelineRuns, pipelinesEnabled], + ); + + useEffect(() => { + if (activePage !== "pipelines") { + return; + } + const pipelineId = selectedPipelineId || config?.pipelines[0]?.id || ""; + if (pipelineId.length > 0) { + void loadPipeline(pipelineId); + } + }, [activePage, config?.pipelines, loadPipeline, selectedPipelineId]); + const loadSession = useCallback( async (sessionId: string, options: { updatePath?: boolean } = {}) => { if (runState === "running") { @@ -333,7 +740,10 @@ export function StudioConsole() { throw new Error(`Session load failed with HTTP ${response.status}`); } const session = (await response.json()) as StudioSession; - const traceSummaries = await loadSessionTraceSummaries(session.id); + const [traceSummaries] = await Promise.all([ + loadSessionTraceSummaries(session.id), + loadSessionLogs(session.id), + ]); setTranscriptSequence(nextSequence(session.transcript)); setSelectedAgentId(session.agentId); setSelectedSessionId(session.id); @@ -349,7 +759,7 @@ export function StudioConsole() { setSessionLoadState("idle"); } }, - [runState, loadSessionTraceSummaries], + [runState, loadSessionTraceSummaries, loadSessionLogs], ); const startNewChat = useCallback( @@ -359,6 +769,7 @@ export function StudioConsole() { } resetTranscriptSequence(); setSelectedSessionId(""); + setSessionLogs([]); setMessages([]); setPrompt(""); setActivePage("playground"); @@ -380,6 +791,7 @@ export function StudioConsole() { setSelectedAgentId(agentId); resetTranscriptSequence(); setSelectedSessionId(""); + setSessionLogs([]); setMessages([]); setPrompt(""); setActivePage("playground"); @@ -409,6 +821,7 @@ export function StudioConsole() { if (selectedSessionId === session.id) { resetTranscriptSequence(); setSelectedSessionId(""); + setSessionLogs([]); setMessages([]); setPrompt(""); if (activePage === "playground") { @@ -430,6 +843,7 @@ export function StudioConsole() { const location = pageLocationFromLocation(); setActivePage(location.page); + setKnowledgeTab(location.knowledgeTab ?? defaultKnowledgeTab); setSelectedTraceId(location.traceId ?? ""); setTraceSessionDetailId(location.traceSessionId); if (location.page === "tracing" && location.traceSessionId !== undefined) { @@ -450,6 +864,7 @@ export function StudioConsole() { function handlePopState() { const location = pageLocationFromLocation(); setActivePage(location.page); + setKnowledgeTab(location.knowledgeTab ?? defaultKnowledgeTab); setSelectedTraceId(location.traceId ?? ""); setTraceSessionDetailId(location.traceSessionId); if (location.page === "tracing" && location.traceSessionId !== undefined) { @@ -472,7 +887,13 @@ export function StudioConsole() { async function runPrompt(text: string) { const trimmed = text.trim(); - if (trimmed.length === 0 || selectedAgentId.length === 0 || runState === "running") { + const agentId = selectedAgent?.id ?? selectedAgentId; + if ( + trimmed.length === 0 || + agentId.length === 0 || + runState === "running" || + playgroundChat.status === "streaming" + ) { return; } @@ -480,6 +901,7 @@ export function StudioConsole() { setActivePage("playground"); setError(""); setPrompt(""); + transcriptStickToBottomRef.current = true; requestAnimationFrame(() => resizeTextarea(promptRef.current)); setMessages((current) => [ ...current, @@ -492,15 +914,71 @@ export function StudioConsole() { ? (await createSession(titleFromText(trimmed))).id : selectedSessionId; const history = sessionsEnabled ? undefined : toHistory(messages); - const response = await fetch(`/agents/${encodeURIComponent(selectedAgentId)}/runs`, { + playgroundRunErrorRef.current = undefined; + playgroundVisibleEventRef.current = Promise.resolve(); + playgroundRunRequestRef.current = { + agentId, + message: trimmed, + ...(sessionId.length === 0 ? {} : { sessionId }), + ...(history === undefined ? {} : { history }), + stream: true, + metadata: { + source: "anvia-studio", + }, + }; + + await playgroundChat.send(trimmed); + await playgroundVisibleEventRef.current; + + if (playgroundRunErrorRef.current === undefined) { + await loadAllSessions(); + if (sessionId.length > 0) { + setSelectedSessionId(sessionId); + const [traceSummaries] = await Promise.all([ + loadSessionTraceSummaries(sessionId), + loadSessionLogs(sessionId), + ]); + setMessages((current) => enrichTranscriptWithTraceIds(current, traceSummaries)); + } + setStatus("Connected"); + } + } catch (runError) { + const message = errorMessage(runError); + setError(message); + appendAssistantError(message); + } finally { + playgroundRunRequestRef.current = undefined; + playgroundChat.reset(); + setRunState("idle"); + } + } + + async function runPipeline() { + const pipelineId = selectedPipelineId || config?.pipelines[0]?.id || ""; + if (pipelineId.length === 0 || pipelineRunState === "running") { + return; + } + + let input: unknown; + try { + input = JSON.parse(pipelineRunInput); + } catch { + setError("Pipeline input must be valid JSON"); + return; + } + + setPipelineRunState("running"); + setPipelineRunOutput(""); + setActivePipelineRunId(""); + setError(""); + try { + const response = await fetch(`/pipelines/${encodeURIComponent(pipelineId)}/runs`, { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ - message: trimmed, - ...(sessionId.length === 0 ? {} : { sessionId }), - ...(history === undefined ? {} : { history }), + input, stream: true, metadata: { source: "anvia-studio", @@ -508,35 +986,111 @@ export function StudioConsole() { }), }); - if (response.status === 401) { - throw new Error("Authentication required"); + if (!response.ok || response.body === null) { + throw new Error(await responseErrorMessage(response, "Pipeline run failed")); } + + await consumePipelineRunStream(response.body); + await Promise.all([loadPipelineLogs(pipelineId), loadPipelineRuns(pipelineId)]); + setStatus("Connected"); + } catch (runError) { + setError(errorMessage(runError)); + } finally { + setPipelineRunState("idle"); + } + } + + async function replayPipelineRun(runId: string) { + const pipelineId = selectedPipelineId || config?.pipelines[0]?.id || ""; + if (pipelineId.length === 0 || runId.length === 0 || pipelineRunState === "running") { + return; + } + + setPipelineRunState("running"); + setPipelineRunOutput(""); + setActivePipelineRunId(""); + setError(""); + try { + const response = await fetch( + `/pipelines/${encodeURIComponent(pipelineId)}/runs/${encodeURIComponent(runId)}/replay`, + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + stream: true, + metadata: { + source: "anvia-studio", + }, + }), + }, + ); + if (!response.ok || response.body === null) { - throw new Error(`Run failed with HTTP ${response.status}`); + throw new Error(await responseErrorMessage(response, "Pipeline replay failed")); } - await readJsonl(response.body, async (event) => { - const visibleDelta = acceptStreamEvent(event as AgentRunStreamEvent); - if (visibleDelta) { - await nextPaint(); - } + await consumePipelineRunStream(response.body); + await Promise.all([loadPipelineLogs(pipelineId), loadPipelineRuns(pipelineId)]); + setStatus("Connected"); + } catch (runError) { + setError(errorMessage(runError)); + } finally { + setPipelineRunState("idle"); + } + } + + async function runEvalSuite() { + const evalId = selectedEvalId || config?.evals[0]?.id || ""; + if (evalId.length === 0 || evalRunState === "running") { + return; + } + + setEvalRunState("running"); + setEvalRunResult(undefined); + setError(""); + try { + const response = await fetch(`/evals/${encodeURIComponent(evalId)}/runs`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({}), }); - await loadAllSessions(); - if (sessionId.length > 0) { - setSelectedSessionId(sessionId); - const traceSummaries = await loadSessionTraceSummaries(sessionId); - setMessages((current) => enrichTranscriptWithTraceIds(current, traceSummaries)); + if (!response.ok) { + throw new Error(await responseErrorMessage(response, "Eval run failed")); } + setEvalRunResult((await response.json()) as StudioEvalRunResponse); setStatus("Connected"); } catch (runError) { - const message = errorMessage(runError); - setError(message); - appendAssistantText(`\n${message}`); + setError(errorMessage(runError)); } finally { - setRunState("idle"); + setEvalRunState("idle"); } } + async function consumePipelineRunStream(body: ReadableStream) { + await readJsonl(body, async (event) => { + if (isPipelineLogEvent(event)) { + if (event.log.runId !== undefined) { + setActivePipelineRunId((current) => current || event.log.runId || ""); + } + appendPipelineLogEntry(event.log); + await nextPaint(); + return; + } + if (isPipelineFinalEvent(event)) { + setPipelineRunOutput(JSON.stringify(event.output, null, 2)); + await nextPaint(); + return; + } + if (isErrorStreamEvent(event)) { + throw new Error(JSON.stringify(event.error)); + } + }); + } + function acceptStreamEvent(event: AgentRunStreamEvent): boolean { if (event.type === "text_delta") { appendAssistantText(event.delta); @@ -560,9 +1114,16 @@ export function StudioConsole() { callId: event.toolCallId, args: event.args, result: event.result, + ...(event.structuredResult === undefined + ? {} + : { structuredResult: event.structuredResult }), }); return true; } + if (event.type === "agent_tool_event") { + appendAgentToolEvent(event); + return true; + } if (event.type === "tool_approval_request") { updateToolApproval(event.approval); return true; @@ -579,16 +1140,42 @@ export function StudioConsole() { updateToolQuestion(event.question); return true; } + if (event.type === "session_log") { + appendSessionLogEntry(event.log); + return true; + } if (event.type === "final" && event.trace?.traceId !== undefined) { assignAssistantTraceId(event.trace.traceId); return true; } if (event.type === "error") { - setError(JSON.stringify(event.error)); + const message = serializedStreamErrorText(event.error); + playgroundRunErrorRef.current = event.error; + setError(message); + appendAssistantError(message); + return true; } return false; } + function appendSessionLogEntry(log: StudioSessionLogEntry) { + setSessionLogs((current) => { + if (current.some((item) => item.id === log.id)) { + return current; + } + return [...current, log].sort((left, right) => left.sequence - right.sequence); + }); + } + + function appendPipelineLogEntry(log: StudioPipelineLogEntry) { + setPipelineLogs((current) => { + if (current.some((item) => item.id === log.id)) { + return current; + } + return [...current, log].sort((left, right) => left.sequence - right.sequence); + }); + } + function appendAssistantText(delta: string) { setMessages((current) => { const next = [...current]; @@ -607,6 +1194,19 @@ export function StudioConsole() { }); } + function appendAssistantError(message: string) { + setMessages((current) => [ + ...current, + { + entryId: nextTranscriptId(), + kind: "message", + role: "assistant", + text: message, + tone: "error", + }, + ]); + } + function assignAssistantTraceId(traceId: string) { setMessages((current) => { const next = [...current]; @@ -796,6 +1396,7 @@ export function StudioConsole() { callId: string | undefined; args: string; result: string; + structuredResult?: ToolResultContent[]; }) { setMessages((current) => { const next = [...current]; @@ -807,6 +1408,9 @@ export function StudioConsole() { ...existing, args: existing.args ?? props.args, result: props.result, + ...(props.structuredResult === undefined + ? {} + : { structuredResult: props.structuredResult }), }; return next; } @@ -819,11 +1423,169 @@ export function StudioConsole() { ...(props.callId === undefined ? {} : { callId: props.callId }), args: props.args, result: props.result, + ...(props.structuredResult === undefined + ? {} + : { structuredResult: props.structuredResult }), }); return next; }); } + function appendAgentToolEvent(event: Extract) { + const childEvent = childAgentTranscriptEvent(event); + if (childEvent === undefined) { + return; + } + setMessages((current) => { + const next = [...current]; + const matchedIndex = findMatchingToolIndex(next, event.toolName, event.toolCallId); + if (matchedIndex < 0) { + next.push({ + entryId: nextTranscriptId(), + kind: "tool", + toolName: event.toolName, + ...(event.toolCallId === undefined ? {} : { callId: event.toolCallId }), + childEvents: [childEvent], + }); + return next; + } + + const existing = next[matchedIndex]; + if (existing === undefined || existing.kind !== "tool") { + return next; + } + const childEvents = [...(existing.childEvents ?? [])]; + appendChildAgentTranscriptEvent(childEvents, childEvent); + next[matchedIndex] = { + ...existing, + childEvents, + }; + return next; + }); + } + + function childAgentTranscriptEvent( + event: Extract, + ): StudioTranscriptChildAgentEvent | undefined { + const child = event.event; + if (child.type === "text_delta") { + return { + kind: "message", + agentId: event.agentId, + ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + text: child.delta, + }; + } + if (child.type === "reasoning_delta") { + return { + kind: "reasoning", + agentId: event.agentId, + ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + ...(child.id === undefined ? {} : { reasoningId: child.id }), + text: child.delta, + }; + } + if (child.type === "tool_call") { + return { + kind: "tool", + agentId: event.agentId, + ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + toolName: child.toolCall.function.name, + ...(child.toolCall.callId === undefined && child.toolCall.id === undefined + ? {} + : { callId: child.toolCall.callId ?? child.toolCall.id }), + args: formatToolValue(child.toolCall.function.arguments), + }; + } + if (child.type === "tool_result") { + return { + kind: "tool", + agentId: event.agentId, + ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + toolName: child.toolName, + ...(child.toolCallId === undefined ? {} : { callId: child.toolCallId }), + args: child.args, + result: child.result, + ...(child.structuredResult === undefined + ? {} + : { structuredResult: child.structuredResult }), + }; + } + if (child.type === "error") { + return { + kind: "message", + agentId: event.agentId, + ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + text: `Error: ${errorMessage(child.error)}`, + }; + } + return undefined; + } + + function appendChildAgentTranscriptEvent( + childEvents: StudioTranscriptChildAgentEvent[], + childEvent: StudioTranscriptChildAgentEvent, + ) { + if (childEvent.kind === "message") { + const last = childEvents.at(-1); + if (last?.kind === "message" && last.agentId === childEvent.agentId) { + childEvents[childEvents.length - 1] = { ...last, text: `${last.text}${childEvent.text}` }; + } else { + childEvents.push(childEvent); + } + return; + } + if (childEvent.kind === "reasoning") { + const last = childEvents.at(-1); + if ( + last?.kind === "reasoning" && + last.agentId === childEvent.agentId && + (last.reasoningId ?? "") === (childEvent.reasoningId ?? "") + ) { + childEvents[childEvents.length - 1] = { ...last, text: `${last.text}${childEvent.text}` }; + } else { + childEvents.push(childEvent); + } + return; + } + const matchedIndex = findChildAgentToolEventIndex(childEvents, childEvent); + if (matchedIndex < 0) { + childEvents.push(childEvent); + return; + } + const matched = childEvents[matchedIndex]; + if (matched?.kind === "tool") { + childEvents[matchedIndex] = { + ...matched, + ...(matched.args !== undefined || childEvent.args === undefined + ? {} + : { args: childEvent.args }), + ...(childEvent.result === undefined ? {} : { result: childEvent.result }), + }; + } + } + + function findChildAgentToolEventIndex( + childEvents: StudioTranscriptChildAgentEvent[], + event: Extract, + ): number { + for (let index = childEvents.length - 1; index >= 0; index -= 1) { + const childEvent = childEvents[index]; + if ( + childEvent?.kind !== "tool" || + childEvent.agentId !== event.agentId || + childEvent.toolName !== event.toolName || + childEvent.result !== undefined + ) { + continue; + } + if (event.callId === undefined || childEvent.callId === event.callId) { + return index; + } + } + return -1; + } + function updatePrompt(event: ChangeEvent) { setPrompt(formValue(event)); resizeTextarea(event.currentTarget); @@ -838,20 +1600,31 @@ export function StudioConsole() { void runPrompt(prompt); } - const agents = config?.agents ?? []; - const selectedAgent = - agents.find((agent) => agent.id === selectedAgentId) ?? agents[0] ?? undefined; - const selectedAgentQuickPrompts = selectedAgent?.quickPrompts ?? []; - const hasMessages = messages.length > 0; - function navigatePage(page: ActivePage) { + if (page === "playground" && !hasAgents) { + return; + } + setActivePage(page); + if (page === "pipelines") { + const pipelineId = selectedPipelineId || pipelines[0]?.id || ""; + if (pipelineId.length > 0) { + setSelectedPipelineId(pipelineId); + void loadPipeline(pipelineId); + } + } if (page === "tracing") { setSelectedTraceId(""); setTraceSessionDetailId(undefined); updatePagePath("tracing"); return; } + if (page === "knowledge") { + setSelectedTraceId(""); + setTraceSessionDetailId(undefined); + updateKnowledgePath(knowledgeTab); + return; + } setSelectedTraceId(""); setTraceSessionDetailId(undefined); if (page === "playground" && selectedSessionId.length > 0) { @@ -861,6 +1634,61 @@ export function StudioConsole() { updatePagePath(page); } + function navigateKnowledgeTab(tab: KnowledgeTab) { + setActivePage("knowledge"); + setKnowledgeTab(tab); + setSelectedTraceId(""); + setTraceSessionDetailId(undefined); + updateKnowledgePath(tab); + } + + useEffect(() => { + if (config === undefined || hasAgents || activePage !== "playground") { + return; + } + + const nextPage: ActivePage = pipelinesEnabled + ? "pipelines" + : evalsEnabled + ? "evals" + : sessionsEnabled + ? "sessions" + : tracesEnabled + ? "tracing" + : "agents"; + + resetTranscriptSequence(); + setSelectedSessionId(""); + setSessionLogs([]); + setMessages([]); + setPrompt(""); + setActivePage(nextPage); + updatePagePath(nextPage); + + if (nextPage === "pipelines") { + const pipelineId = selectedPipelineId || pipelines[0]?.id || ""; + if (pipelineId.length > 0) { + setSelectedPipelineId(pipelineId); + void loadPipeline(pipelineId); + } + } + if (nextPage === "evals") { + setSelectedEvalId(selectedEvalId || config.evals[0]?.id || ""); + } + }, [ + activePage, + config, + hasAgents, + loadPipeline, + evalsEnabled, + pipelines, + pipelinesEnabled, + selectedEvalId, + selectedPipelineId, + sessionsEnabled, + tracesEnabled, + ]); + function selectTrace(traceId: string) { setActivePage("tracing"); setSelectedTraceId(traceId); @@ -873,25 +1701,43 @@ export function StudioConsole() { } return ( -
    -