From 74f85f2a32a5d617f7893125bb7c7754b0b153f1 Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Sun, 3 May 2026 23:00:46 +0700 Subject: [PATCH 01/89] Enable docs static prerendering --- apps/docs/vite.config.ts | 47 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) 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(), ], From 438c806ac7226d6c0b8e1117a45d91022b23f159 Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Mon, 4 May 2026 14:42:33 +0700 Subject: [PATCH 02/89] feat: add core memory sessions --- .../guides/agents/agent-configuration.mdx | 5 +- .../docs/guides/agents/agent-history.mdx | 8 +- .../docs/guides/agents/creating-agents.mdx | 5 +- .../docs/guides/agents/run-lifecycle.mdx | 8 +- apps/docs/content/docs/guides/cookbook.mdx | 2 +- .../content/docs/guides/getting-started.mdx | 5 +- apps/docs/content/docs/guides/index.mdx | 12 +- .../learning-paths/add-observability.mdx | 2 +- .../guides/learning-paths/add-retrieval.mdx | 2 +- .../guides/learning-paths/build-an-agent.mdx | 6 +- .../learning-paths/persist-conversations.mdx | 29 +- .../learning-paths/prepare-for-production.mdx | 14 +- .../return-structured-output.mdx | 2 +- .../content/docs/guides/memory/drizzle.mdx | 137 +++++++ .../docs/content/docs/guides/memory/index.mdx | 91 +++++ .../docs/content/docs/guides/memory/meta.json | 6 + .../content/docs/guides/memory/prisma.mdx | 124 +++++++ .../content/docs/guides/memory/raw-sql.mdx | 138 ++++++++ apps/docs/content/docs/guides/meta.json | 3 +- .../attachments.mdx | 5 +- .../clients-and-models.mdx | 2 +- .../errors.mdx | 0 .../sdk-fundamentals/memory-and-sessions.mdx | 89 +++++ .../messages-and-history.mdx | 10 +- .../meta.json | 1 + .../package-exports.mdx | 0 .../prompt-requests.mdx | 9 +- .../prompt-responses.mdx | 7 +- .../runtime-boundaries.mdx | 2 +- .../guides/testing/agents-and-retrieval.mdx | 7 +- .../content/docs/reference/core/agent.mdx | 64 +++- .../content/docs/reference/core/index.mdx | 3 +- examples/cli-agent/src/agent.ts | 9 +- .../cookbook/01_basics/02-chat-history.ts | 2 +- .../cookbook/01_basics/06-session-memory.ts | 40 +++ .../07-tool-call-with-chat-history.ts | 12 +- examples/cookbook/README.md | 2 +- examples/cookbook/package.json | 1 + package.json | 1 + packages/core/README.md | 64 ++++ packages/core/package.json | 8 +- packages/core/src/agent/agent.ts | 55 ++- packages/core/src/agent/builder.ts | 16 + packages/core/src/agent/index.ts | 1 + packages/core/src/agent/request.ts | 220 ++++++++++-- packages/core/src/index.ts | 1 + packages/core/src/memory/index.ts | 54 +++ packages/core/test/memory.test.ts | 216 +++++++++++ packages/tools/studio/package.json | 2 +- packages/tools/studio/src/runtime/runs.ts | 40 ++- packages/tools/studio/src/runtime/studio.ts | 120 +++++-- .../tools/studio/src/storage/sqlite-store.ts | 335 ++++++++++++++++-- packages/tools/studio/src/types.ts | 15 +- packages/tools/studio/test/runner.test.ts | 122 ++++++- 54 files changed, 1938 insertions(+), 196 deletions(-) create mode 100644 apps/docs/content/docs/guides/memory/drizzle.mdx create mode 100644 apps/docs/content/docs/guides/memory/index.mdx create mode 100644 apps/docs/content/docs/guides/memory/meta.json create mode 100644 apps/docs/content/docs/guides/memory/prisma.mdx create mode 100644 apps/docs/content/docs/guides/memory/raw-sql.mdx rename apps/docs/content/docs/guides/{core-concepts => sdk-fundamentals}/attachments.mdx (97%) rename apps/docs/content/docs/guides/{core-concepts => sdk-fundamentals}/clients-and-models.mdx (95%) rename apps/docs/content/docs/guides/{core-concepts => sdk-fundamentals}/errors.mdx (100%) create mode 100644 apps/docs/content/docs/guides/sdk-fundamentals/memory-and-sessions.mdx rename apps/docs/content/docs/guides/{core-concepts => sdk-fundamentals}/messages-and-history.mdx (90%) rename apps/docs/content/docs/guides/{core-concepts => sdk-fundamentals}/meta.json (91%) rename apps/docs/content/docs/guides/{core-concepts => sdk-fundamentals}/package-exports.mdx (100%) rename apps/docs/content/docs/guides/{core-concepts => sdk-fundamentals}/prompt-requests.mdx (87%) rename apps/docs/content/docs/guides/{core-concepts => sdk-fundamentals}/prompt-responses.mdx (90%) rename apps/docs/content/docs/guides/{core-concepts => sdk-fundamentals}/runtime-boundaries.mdx (96%) create mode 100644 examples/cookbook/01_basics/06-session-memory.ts create mode 100644 packages/core/src/memory/index.ts create mode 100644 packages/core/test/memory.test.ts 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/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/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/cookbook.mdx b/apps/docs/content/docs/guides/cookbook.mdx index 9bf9d045..d3d150ca 100644 --- a/apps/docs/content/docs/guides/cookbook.mdx +++ b/apps/docs/content/docs/guides/cookbook.mdx @@ -98,7 +98,7 @@ 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) | | Stream agent events | `tools:02` | [Streaming Events](/docs/guides/streaming/streaming-events) | | Render reasoning summaries | `providers:04` | [Streaming Events](/docs/guides/streaming/streaming-events) | | Select dynamic tools | `tools:09` | [Tool Sets](/docs/guides/tools/tool-sets) | 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/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..2d8b814f 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 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..80cc6651 100644 --- a/apps/docs/content/docs/guides/learning-paths/add-retrieval.mdx +++ b/apps/docs/content/docs/guides/learning-paths/add-retrieval.mdx @@ -133,5 +133,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-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..e47af249 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. @@ -80,16 +80,17 @@ Put agent calls behind a small application-owned wrapper so logging, history, tr ```ts import { MaxTurnsError, + Message, PromptCancelledError, type Agent, - type Message, + type Message as MessageType, } from "@anvia/core"; type RunSupportAgentOptions = { userId: string; conversationId: string; input: string; - history: Message[]; + history: MessageType[]; }; export async function runSupportAgent( @@ -98,8 +99,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..32dd010a 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 @@ -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/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..74861659 --- /dev/null +++ b/apps/docs/content/docs/guides/memory/index.mdx @@ -0,0 +1,91 @@ +--- +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. + +## 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. + +## 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..e932810b --- /dev/null +++ b/apps/docs/content/docs/guides/memory/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Memory", + "defaultOpen": false, + "collapsible": true, + "pages": ["index", "raw-sql", "prisma", "drizzle"] +} 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/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 100% rename from apps/docs/content/docs/guides/core-concepts/package-exports.mdx rename to apps/docs/content/docs/guides/sdk-fundamentals/package-exports.mdx 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/testing/agents-and-retrieval.mdx b/apps/docs/content/docs/guides/testing/agents-and-retrieval.mdx index 73ee9d01..17069195 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,16 @@ 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 Agent, type Message as MessageType } from "@anvia/core"; + 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/reference/core/agent.mdx b/apps/docs/content/docs/reference/core/agent.mdx index 1ec18ca5..4ffae75b 100644 --- a/apps/docs/content/docs/reference/core/agent.mdx +++ b/apps/docs/content/docs/reference/core/agent.mdx @@ -26,9 +26,11 @@ class Agent { readonly observers: AgentObserverRegistration[]; readonly dynamicContexts: DynamicContextRegistration[]; readonly dynamicTools: DynamicToolRegistration[]; + readonly memory?: MemoryRegistration; 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; @@ -37,7 +39,7 @@ class Agent { 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. Notable errors: the constructor throws `TypeError` when `id` is not a non-empty string. `asTool(...)` forwards errors from the nested prompt run. @@ -62,6 +64,7 @@ type AgentOptions = { observers?: AgentObserverRegistration[]; dynamicContexts?: DynamicContextRegistration[]; dynamicTools?: DynamicToolRegistration[]; + memory?: MemoryRegistration; }; ``` @@ -94,6 +97,7 @@ class AgentBuilder { defaultMaxTurns(defaultMaxTurns: number): this; hook(hook: PromptHook): this; observe(observer: AgentObserver, options?: ObserveOptions): this; + memory(store: MemoryStore, options?: MemoryOptions): this; outputSchema(schema: ZodSchema): this; build(): Agent; } @@ -105,6 +109,59 @@ 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. + ## Dynamic Tools ```ts @@ -132,10 +189,9 @@ 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; diff --git a/apps/docs/content/docs/reference/core/index.mdx b/apps/docs/content/docs/reference/core/index.mdx index 126fab58..988d8490 100644 --- a/apps/docs/content/docs/reference/core/index.mdx +++ b/apps/docs/content/docs/reference/core/index.mdx @@ -22,6 +22,7 @@ description: Public exports from @anvia/core and its subpaths. | `@anvia/core/loaders` | Node file and PDF loaders for ingestion preprocessing | | `@anvia/core/embeddings` | Embedding models, documents, and vector math | | `@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 | @@ -38,4 +39,4 @@ import { AgentBuilder, createTool, Message, PipelineBuilder } 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/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/02-chat-history.ts b/examples/cookbook/01_basics/02-chat-history.ts index 222f87ca..66f3ee87 100644 --- a/examples/cookbook/01_basics/02-chat-history.ts +++ b/examples/cookbook/01_basics/02-chat-history.ts @@ -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/06-session-memory.ts b/examples/cookbook/01_basics/06-session-memory.ts new file mode 100644 index 00000000..d7e63013 --- /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: "https://openrouter.ai/api/v1", + apiKey: process.env.OPENROUTER_API_KEY, +}); +const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +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/02_tools/07-tool-call-with-chat-history.ts b/examples/cookbook/02_tools/07-tool-call-with-chat-history.ts index 9337d23b..68fc4598 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([ @@ -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/README.md b/examples/cookbook/README.md index 8e0ec40c..58f2acf1 100644 --- a/examples/cookbook/README.md +++ b/examples/cookbook/README.md @@ -20,7 +20,7 @@ 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, `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. | diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index 6d5cf639..6aca45d6 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -11,6 +11,7 @@ "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", "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", diff --git a/package.json b/package.json index b7615162..465efd4f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "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:tools": "pnpm --filter cookbook tools", "cookbook:tools:01": "pnpm --filter cookbook tools:01", "cookbook:tools:02": "pnpm --filter cookbook tools:02", diff --git a/packages/core/README.md b/packages/core/README.md index ce52d39a..7f61b20d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -47,6 +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 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).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 @@ -80,6 +143,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..433f7677 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/core", - "version": "0.1.0", + "version": "0.1.1", "description": "Core runtime primitives for context-aware Anvia agents.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -48,6 +48,10 @@ "types": "./dist/mcp/index.d.ts", "import": "./dist/mcp/index.js" }, + "./memory": { + "types": "./dist/memory/index.d.ts", + "import": "./dist/memory/index.js" + }, "./observability": { "types": "./dist/observability/index.d.ts", "import": "./dist/observability/index.js" @@ -82,7 +86,7 @@ } }, "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/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/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" }, diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 979cc337..4b3a6386 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -7,6 +7,7 @@ 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"; @@ -34,6 +35,7 @@ export type AgentOptions = { observers?: AgentObserverRegistration[] | undefined; dynamicContexts?: DynamicContextRegistration[] | undefined; dynamicTools?: DynamicToolRegistration[] | undefined; + memory?: MemoryRegistration | undefined; }; export const DEFAULT_MAX_TURNS = 20; @@ -85,6 +87,7 @@ export class Agent { readonly observers: AgentObserverRegistration[]; readonly dynamicContexts: DynamicContextRegistration[]; readonly dynamicTools: DynamicToolRegistration[]; + readonly memory: MemoryRegistration | undefined; constructor(options: AgentOptions) { this.id = normalizeAgentId(options.id); @@ -104,12 +107,28 @@ export class Agent { this.observers = options.observers ?? []; this.dynamicContexts = options.dynamicContexts ?? []; this.dynamicTools = options.dynamicTools ?? []; + this.memory = options.memory; } - 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.`; @@ -164,6 +183,40 @@ export class Agent { } } +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); + } + + 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); + } +} + function dynamicToolSetFromIndex( index: VectorSearchIndex, ): ToolSet | undefined { diff --git a/packages/core/src/agent/builder.ts b/packages/core/src/agent/builder.ts index 702f0fa9..08eec092 100644 --- a/packages/core/src/agent/builder.ts +++ b/packages/core/src/agent/builder.ts @@ -1,5 +1,11 @@ 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"; @@ -33,6 +39,7 @@ export class AgentBuilder { private observerRegistrations: AgentObserverRegistration[] = []; private dynamicContextRegistrations: DynamicContextRegistration[] = []; private dynamicToolRegistrations: DynamicToolRegistration[] = []; + private memoryRegistration: MemoryRegistration | undefined; private activeToolSet = new ToolSet(); constructor( @@ -143,6 +150,14 @@ export class AgentBuilder { return this; } + memory(store: MemoryStore, options: MemoryOptions = {}): this { + this.memoryRegistration = { + store, + options: resolveMemoryOptions(options), + }; + return this; + } + outputSchema(schema: ZodSchema): this { this.schema = toProviderJsonSchema(schema); return this; @@ -167,6 +182,7 @@ export class AgentBuilder { observers: this.observerRegistrations, dynamicContexts: this.dynamicContextRegistrations, dynamicTools: this.dynamicToolRegistrations, + memory: this.memoryRegistration, }); } diff --git a/packages/core/src/agent/index.ts b/packages/core/src/agent/index.ts index 13b6463a..0f3d6111 100644 --- a/packages/core/src/agent/index.ts +++ b/packages/core/src/agent/index.ts @@ -1,3 +1,4 @@ +export * from "../memory"; export * from "./agent"; export * from "./builder"; export * from "./errors"; diff --git a/packages/core/src/agent/request.ts b/packages/core/src/agent/request.ts index 4e0bff3f..8113560c 100644 --- a/packages/core/src/agent/request.ts +++ b/packages/core/src/agent/request.ts @@ -14,6 +14,7 @@ import { textFromAssistantContent, Usage, } from "../completion/index"; +import type { MemoryContext, MemoryRegistration, MemorySavePolicy } from "../memory"; import { type ActiveAgentRunObservers, type ActiveToolObservers, @@ -87,7 +88,7 @@ export type AgentStreamEvent = }; export class PromptRequest { - private chatHistory: MessageType[] | undefined; + private chatHistory: MessageType[]; private maxTurnCount: number; private activeHook: PromptHook | undefined; private concurrency = 1; @@ -96,21 +97,21 @@ export class PromptRequest { private constructor( private readonly agent: Agent, private readonly promptMessage: MessageType, + private readonly initialHistory: MessageType[] = [], + private readonly memoryContext: MemoryContext | undefined = undefined, ) { + this.chatHistory = initialHistory; this.maxTurnCount = agent.defaultMaxTurns ?? 0; this.activeHook = agent.hook; } 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 { @@ -134,7 +135,10 @@ export class PromptRequest { } async send(): Promise { + const runId = globalThis.crypto.randomUUID(); const newMessages: MessageType[] = [this.promptMessage]; + await this.prepareMemoryRun(runId, newMessages); + const pendingTurnMessages = this.memoryPolicy() === "turn" ? [...newMessages] : []; let usage = Usage.empty(); let currentTurns = 0; let lastPrompt = this.promptMessage; @@ -150,7 +154,7 @@ 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); @@ -172,11 +176,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.commitMemoryMessages( + runId, + currentTurns, + [assistantMessage], + pendingTurnMessages, + ); const toolCalls = response.choice.filter( (item): item is ToolCall => item.type === "tool_call", ); if (toolCalls.length === 0) { + await this.commitCompletedMemoryRun( + runId, + currentTurns, + newMessages, + pendingTurnMessages, + ); const result: PromptResponse = { output: textFromAssistantContent(response.choice), usage, @@ -191,16 +208,16 @@ export class PromptRequest { turn: currentTurns, runObservers, }); - newMessages.push(Message.tool(toolResults)); + const toolMessage = Message.tool(toolResults); + newMessages.push(toolMessage); + await this.commitMemoryMessages(runId, currentTurns, [toolMessage], pendingTurnMessages); + await this.commitCompletedMemoryTurn(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.recordMemoryError(runId, error, newMessages); throw error; } } @@ -210,7 +227,10 @@ export class PromptRequest { throw new Error("This completion model does not support streaming"); } + const runId = globalThis.crypto.randomUUID(); const newMessages: MessageType[] = [this.promptMessage]; + await this.prepareMemoryRun(runId, newMessages); + const pendingTurnMessages = this.memoryPolicy() === "turn" ? [...newMessages] : []; let usage = Usage.empty(); let currentTurns = 0; let lastPrompt = this.promptMessage; @@ -226,7 +246,7 @@ export class PromptRequest { lastPrompt = prompt; currentTurns += 1; - const historyForRequest = [...(this.chatHistory ?? []), ...newMessages.slice(0, -1)]; + const historyForRequest = [...this.chatHistory, ...newMessages.slice(0, -1)]; yield { type: "turn_start", turn: currentTurns, @@ -285,7 +305,14 @@ 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.commitMemoryMessages( + runId, + currentTurns, + [assistantMessage], + pendingTurnMessages, + ); const toolCalls = response.choice.filter( (item): item is ToolCall => item.type === "tool_call", ); @@ -296,6 +323,12 @@ export class PromptRequest { if (toolCalls.length === 0) { const output = textFromAssistantContent(response.choice); + await this.commitCompletedMemoryRun( + runId, + currentTurns, + newMessages, + pendingTurnMessages, + ); yield { type: "final", output, @@ -327,16 +360,16 @@ export class PromptRequest { yield { type: "tool_result", turn: currentTurns, ...result }; } const toolResults = await toolResultsPromise; - newMessages.push(Message.tool(toolResults)); + const toolMessage = Message.tool(toolResults); + newMessages.push(toolMessage); + await this.commitMemoryMessages(runId, currentTurns, [toolMessage], pendingTurnMessages); + await this.commitCompletedMemoryTurn(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.recordMemoryError(runId, error, newMessages); yield { type: "error", error }; throw error; } @@ -467,7 +500,7 @@ export class PromptRequest { instructions: this.agent.instructions, trace: this.traceOptions, prompt: this.promptMessage, - history: this.chatHistory ?? [], + history: this.chatHistory, maxTurns: this.maxTurnCount, }, failOnObserverError, @@ -585,8 +618,143 @@ export class PromptRequest { } private cancelled(newMessages: MessageType[], reason: string): PromptCancelledError { - return new PromptCancelledError([...(this.chatHistory ?? []), ...newMessages], reason); + return new PromptCancelledError([...this.chatHistory, ...newMessages], reason); + } + + private memory(): MemoryRegistration | undefined { + return this.memoryContext === undefined ? undefined : this.agent.memory; } + + private memoryPolicy(): MemorySavePolicy | undefined { + return this.memory()?.options.savePolicy; + } + + private async prepareMemoryRun(runId: string, newMessages: MessageType[]): Promise { + const memory = this.memory(); + if (memory === undefined || this.memoryContext === undefined) { + this.chatHistory = this.initialHistory; + return; + } + + const memoryHistory = await memory.store.load(this.memoryContext); + this.chatHistory = [...memoryHistory, ...this.initialHistory]; + if (memory.options.savePolicy === "message") { + await memory.store.append({ + context: this.memoryContext, + runId, + turn: 1, + messages: newMessages, + }); + } + } + + private async commitMemoryMessages( + 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); + } + } + + private async commitCompletedMemoryTurn( + 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; + } + + private async commitCompletedMemoryRun( + runId: string, + turn: number, + newMessages: MessageType[], + pendingTurnMessages: MessageType[], + ): Promise { + await this.commitCompletedMemoryTurn(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], + }); + } + + private async recordMemoryError( + 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], + }); + } +} + +function normalizePromptInput(prompt: string | MessageType | MessageType[]): { + prompt: MessageType; + history: MessageType[]; +} { + 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 { + prompt: activePrompt, + history: prompt.slice(0, -1), + }; } type ToolResultEventPayload = { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d6bea567..9f1fc564 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,7 @@ export * from "./evals"; export * from "./extractor"; export * from "./image-generation"; export * from "./mcp"; +export * from "./memory"; export * from "./observability"; export * from "./pipeline"; export type { ZodSchema } from "./schema"; 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/test/memory.test.ts b/packages/core/test/memory.test.ts new file mode 100644 index 00000000..ca545dd1 --- /dev/null +++ b/packages/core/test/memory.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + AgentBuilder, + AssistantContent, + type CompletionModel, + type CompletionRequest, + type CompletionResponse, + createTool, + type MemoryAppendInput, + type MemoryContext, + type MemoryErrorInput, + type MemoryStore, + Message, + type Message as MessageType, + Usage, +} from "../src/index"; + +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 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("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/tools/studio/package.json b/packages/tools/studio/package.json index 54a99757..ae34fff3 100644 --- a/packages/tools/studio/package.json +++ b/packages/tools/studio/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/studio", - "version": "0.1.0", + "version": "0.1.1", "description": "Studio UI and HTTP runtime for Anvia agents.", "author": "anvia", "maintainer": "Indra Zulfi", diff --git a/packages/tools/studio/src/runtime/runs.ts b/packages/tools/studio/src/runtime/runs.ts index d26ba8e7..e835cced 100644 --- a/packages/tools/studio/src/runtime/runs.ts +++ b/packages/tools/studio/src/runtime/runs.ts @@ -151,30 +151,52 @@ 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) { + await props.store.saveSessionRunTranscript({ + id: props.session.id, + runId: props.runId, + ...title, + transcript, + status: "error", + error: serializeError(error), + }); + throw error; } } diff --git a/packages/tools/studio/src/runtime/studio.ts b/packages/tools/studio/src/runtime/studio.ts index d241718b..cd52b7d3 100644 --- a/packages/tools/studio/src/runtime/studio.ts +++ b/packages/tools/studio/src/runtime/studio.ts @@ -1,9 +1,12 @@ import { Agent, + type Message as CoreMessage, createHook, type HookAction, type JsonObject, + Message, type PromptHook, + resolveMemoryOptions, type ToolCallHookAction, } from "@anvia/core"; import { serve } from "@hono/node-server"; @@ -34,7 +37,7 @@ import { mergeRunAndApprovalEvents, optionalTitle, parseRunRequest, - persistStreamingSessionRun, + persistStreamingSessionTranscript, streamAgentRunEvents, traceForRun, transcriptFromMessages, @@ -174,9 +177,9 @@ function agentMetadata(agent: Agent): JsonObject { function createStudioApp(options: StudioRuntimeOptions): StudioApp { const stores = resolveStores(options); - const agents = normalizeAgents(options.agents).map((agent) => - withStudioTraceObserver(agent, stores.traces), - ); + const agents = normalizeAgents(options.agents) + .map((agent) => withStudioSessionMemory(agent, stores.sessions)) + .map((agent) => withStudioTraceObserver(agent, stores.traces)); const agentMap = new Map(agents.map((agent) => [agent.id, agent])); const approvalRuntime = createApprovalRuntime(); const questionRuntime = createQuestionRuntime(); @@ -248,12 +251,19 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { } const runId = globalThis.crypto.randomUUID(); - const request = agent.agent.prompt(body.message); - if (session !== undefined) { - request.withHistory(session.messages); - } else if (body.history !== undefined) { - request.withHistory(body.history); - } + 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,11 +305,12 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { const stream = session === undefined || stores.sessions === undefined ? runStream - : persistStreamingSessionRun({ + : persistStreamingSessionTranscript({ stream: runStream, store: stores.sessions, session, message: body.message, + runId, }); return streamAgentRunEvents(c, stream); } @@ -328,15 +339,30 @@ 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", }); } 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), + }); + } return errorResponse(c, 500, "internal_error", "Agent run failed", serializeError(error)); } }); @@ -372,6 +398,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 +431,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, diff --git a/packages/tools/studio/src/storage/sqlite-store.ts b/packages/tools/studio/src/storage/sqlite-store.ts index bab0cd85..aff66483 100644 --- a/packages/tools/studio/src/storage/sqlite-store.ts +++ b/packages/tools/studio/src/storage/sqlite-store.ts @@ -2,12 +2,20 @@ 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, + MemoryAppendInput, + MemoryContext, + MemoryErrorInput, + Message, +} from "@anvia/core"; import type { StudioSession, - StudioSessionAppendInput, StudioSessionCreateInput, StudioSessionListOptions, + StudioSessionRunStatus, + StudioSessionRunTranscriptInput, StudioSessionStore, StudioSessionSummary, StudioSessionTraceListOptions, @@ -54,6 +62,17 @@ 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; +}; + export function createSqliteSessionStore( options: SqliteSessionStoreOptions = {}, ): StudioSessionStore & StudioTraceStore { @@ -119,19 +138,17 @@ 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.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 { @@ -142,43 +159,147 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { FROM runner_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(); + db.prepare( + `UPDATE runner_sessions + SET messages_json = $messages, + updated_at = $updatedAt + WHERE id = $id`, + ).run({ + $id: input.context.sessionId, + $messages: JSON.stringify(messages), + $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 runner_sessions + SET messages_json = '[]', + transcript_json = '[]', + updated_at = $updatedAt + WHERE id = $id`, + ).run({ + $id: context.sessionId, + $updatedAt: updatedAt, + }); + db.prepare("DELETE FROM runner_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.listSessionRunRows(input.id)); + const title = current.title ?? input.title; + + db.prepare( + `INSERT INTO runner_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(runner_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 runner_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"); @@ -193,6 +314,7 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { try { db.exec("BEGIN IMMEDIATE"); db.prepare("DELETE FROM runner_traces WHERE session_id = $id").run({ $id: id }); + db.prepare("DELETE FROM runner_session_runs WHERE session_id = $id").run({ $id: id }); const result = db.prepare("DELETE FROM runner_sessions WHERE id = $id").run({ $id: id }) as { changes: number | bigint; }; @@ -374,6 +496,19 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { ) 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_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 runner_sessions(id) ON DELETE CASCADE + ) STRICT; + CREATE INDEX IF NOT EXISTS runner_session_runs_session_created_idx + ON runner_session_runs(session_id, created_at ASC); CREATE TABLE IF NOT EXISTS runner_traces ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, @@ -397,14 +532,49 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { this.db = db; return db; } + + private getSessionRow(id: string): SessionRow | undefined { + return this.database() + .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; + } + + 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 runner_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 runner_session_runs + WHERE session_id = $sessionId + ORDER BY created_at ASC`, + ) + .all({ $sessionId: sessionId }) as SessionRunRow[]; + } } -function toSession(row: SessionRow): StudioSession { +function toSession(row: SessionRow, runRows: SessionRunRow[] = []): StudioSession { const summary = toSessionSummary(row); + const legacyTranscript = parseJsonArray(row.transcript_json); + const runTranscript = runRows.flatMap((runRow) => + parseJsonArray(runRow.transcript_json), + ); return { ...summary, messages: parseJsonArray(row.messages_json), - transcript: renumberTranscript(parseJsonArray(row.transcript_json)), + transcript: renumberTranscript([...legacyTranscript, ...runTranscript]), }; } @@ -469,3 +639,106 @@ 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]")) + .join("\n"), + }); + } + 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/types.ts b/packages/tools/studio/src/types.ts index 40ada9b6..06120860 100644 --- a/packages/tools/studio/src/types.ts +++ b/packages/tools/studio/src/types.ts @@ -5,6 +5,7 @@ import type { AgentTraceOptions, JsonObject, JsonValue, + MemoryStore, Message, PromptResponse, Usage, @@ -112,14 +113,18 @@ 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 StudioSessionStore = { +export type StudioSessionStore = MemoryStore & { readonly kind?: string; listSessions( options: StudioSessionListOptions, @@ -128,8 +133,8 @@ export type StudioSessionStore = { input: StudioSessionCreateInput, ): StudioSessionSummary | Promise; getSession(id: string): StudioSession | undefined | Promise; - appendSessionRun( - input: StudioSessionAppendInput, + saveSessionRunTranscript( + input: StudioSessionRunTranscriptInput, ): StudioSession | undefined | Promise; deleteSession?(id: string): boolean | Promise; }; diff --git a/packages/tools/studio/test/runner.test.ts b/packages/tools/studio/test/runner.test.ts index 94526341..cf5975b7 100644 --- a/packages/tools/studio/test/runner.test.ts +++ b/packages/tools/studio/test/runner.test.ts @@ -24,6 +24,7 @@ import { } from "@anvia/core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Studio } from "../src/index"; +import { createSqliteSessionStore } from "../src/sqlite"; class QueueModel { readonly provider = "test"; @@ -110,6 +111,31 @@ class GatedReasoningModel implements StreamingCompletionModel { } } +class FailingStreamingModel 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[] = []; + + async completion(): Promise { + throw new Error("completion should not be called"); + } + + async *streamCompletion(request: CompletionRequest): AsyncIterable { + this.requests.push(request); + yield { type: "text_delta", delta: "partial" }; + throw new Error("stream failed"); + } +} + class KeywordEmbeddingModel implements EmbeddingModel { readonly calls: string[][] = []; @@ -1572,7 +1598,7 @@ describe("Anvia studio", () => { }); }); - it("persists failed runner traces without mutating session history", async () => { + it("persists failed runner traces with partial session memory", async () => { const agent = new AgentBuilder("support", new QueueModel([])).build(); const runner = new Studio([agent]); @@ -1596,9 +1622,9 @@ describe("Anvia studio", () => { const loaded = await runner.fetch(new Request(`http://runner.test/sessions/${session.id}`)); await expect(loaded.json()).resolves.toMatchObject({ - messageCount: 0, - messages: [], - transcript: [], + messageCount: 1, + messages: [Message.user("fail")], + transcript: [{ kind: "message", role: "user", text: "fail" }], }); const traces = (await ( @@ -1617,6 +1643,94 @@ describe("Anvia studio", () => { }); }); + it("persists streaming failures with partial transcript entries", async () => { + const model = new FailingStreamingModel(); + const agent = new AgentBuilder("support", model).build(); + const runner = new Studio([agent]); + + const created = await runner.fetch( + new Request("http://runner.test/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentId: "support" }), + }), + ); + const session = (await created.json()) as { id: string }; + + const run = await runner.fetch( + new Request("http://runner.test/agents/support/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "stream fail", sessionId: session.id, stream: true }), + }), + ); + + expect(run.status).toBe(200); + expect(await readJsonl(run)).toContainEqual( + expect.objectContaining({ + type: "error", + error: expect.objectContaining({ message: "stream failed" }), + }), + ); + + const loaded = await runner.fetch(new Request(`http://runner.test/sessions/${session.id}`)); + await expect(loaded.json()).resolves.toMatchObject({ + messageCount: 1, + messages: [Message.user("stream fail")], + transcript: [ + { kind: "message", role: "user", text: "stream fail" }, + { kind: "message", role: "assistant", text: "partial" }, + ], + }); + }); + + it("uses the SQLite session store as a core memory store", async () => { + const store = createSqliteSessionStore({ path: ":memory:" }); + store.createSession({ id: "session_1", agentId: "support" }); + + await store.append({ + context: { sessionId: "session_1" }, + runId: "run_1", + turn: 1, + messages: [Message.user("hi")], + }); + await expect(store.load({ sessionId: "session_1" })).resolves.toEqual([Message.user("hi")]); + + await store.saveSessionRunTranscript({ + id: "session_1", + runId: "run_1", + title: "hi", + status: "success", + transcript: [{ entryId: 0, kind: "message", role: "user", text: "hi" }], + }); + expect((await store.listSessions({ limit: 10 }))[0]).toMatchObject({ + id: "session_1", + title: "hi", + messageCount: 1, + }); + expect((await store.getSession("session_1"))?.transcript).toEqual([ + { entryId: 0, kind: "message", role: "user", text: "hi" }, + ]); + + await store.recordError?.({ + context: { sessionId: "session_1", metadata: { studioRunId: "run_2" } }, + runId: "core_run_2", + error: new Error("failed"), + messages: [Message.user("failed")], + }); + expect((await store.getSession("session_1"))?.transcript).toEqual([ + { entryId: 0, kind: "message", role: "user", text: "hi" }, + { entryId: 1, kind: "message", role: "user", text: "failed" }, + ]); + + await store.clear({ sessionId: "session_1" }); + expect(await store.getSession("session_1")).toMatchObject({ + messageCount: 0, + messages: [], + transcript: [], + }); + }); + it("lists global runner traces with filters", async () => { const mainAgent = new AgentBuilder( "main", From 74d0eb1ca958377420a8c748cb3b3b2c6943ff52 Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Mon, 4 May 2026 23:16:00 +0700 Subject: [PATCH 03/89] Add tool result middleware --- .../docs/guides/agents/runtime-hooks.mdx | 4 +- apps/docs/content/docs/guides/tools/meta.json | 1 + .../docs/guides/tools/tool-middleware.mdx | 70 +++++++++++++ .../docs/guides/tools/tool-results.mdx | 2 + .../content/docs/reference/core/agent.mdx | 6 ++ .../content/docs/reference/core/tools.mdx | 26 +++++ .../02_tools/10-tool-result-middleware.ts | 61 ++++++++++++ examples/cookbook/package.json | 2 + package.json | 2 + packages/core/package.json | 2 +- packages/core/src/agent/agent.ts | 9 ++ packages/core/src/agent/builder.ts | 13 +++ packages/core/src/agent/request.ts | 35 +++++++ packages/core/src/skills/tools.ts | 85 +++++++++------- packages/core/src/tool/index.ts | 1 + packages/core/src/tool/middleware.ts | 17 ++++ packages/core/src/tool/skill-tool-marker.ts | 15 +++ packages/core/test/mcp.test.ts | 30 ++++++ packages/core/test/prompt-request.test.ts | 84 ++++++++++++++++ packages/core/test/skills.test.ts | 98 +++++++++++++++++++ packages/core/test/streaming.test.ts | 45 +++++++++ 21 files changed, 568 insertions(+), 40 deletions(-) create mode 100644 apps/docs/content/docs/guides/tools/tool-middleware.mdx create mode 100644 examples/cookbook/02_tools/10-tool-result-middleware.ts create mode 100644 packages/core/src/tool/middleware.ts create mode 100644 packages/core/src/tool/skill-tool-marker.ts diff --git a/apps/docs/content/docs/guides/agents/runtime-hooks.mdx b/apps/docs/content/docs/guides/agents/runtime-hooks.mdx index 1c23d7f5..c5153808 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 @@ -48,6 +48,8 @@ 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 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. 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-middleware.mdx b/apps/docs/content/docs/guides/tools/tool-middleware.mdx new file mode 100644 index 00000000..69b53078 --- /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"; + +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/reference/core/agent.mdx b/apps/docs/content/docs/reference/core/agent.mdx index 4ffae75b..e469b2ec 100644 --- a/apps/docs/content/docs/reference/core/agent.mdx +++ b/apps/docs/content/docs/reference/core/agent.mdx @@ -26,6 +26,7 @@ class Agent { readonly observers: AgentObserverRegistration[]; readonly dynamicContexts: DynamicContextRegistration[]; readonly dynamicTools: DynamicToolRegistration[]; + readonly toolMiddlewares: ToolMiddleware[]; readonly memory?: MemoryRegistration; constructor(options: AgentOptions); @@ -64,6 +65,7 @@ type AgentOptions = { observers?: AgentObserverRegistration[]; dynamicContexts?: DynamicContextRegistration[]; dynamicTools?: DynamicToolRegistration[]; + toolMiddlewares?: ToolMiddleware[]; memory?: MemoryRegistration; }; ``` @@ -96,6 +98,8 @@ 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; outputSchema(schema: ZodSchema): this; @@ -195,6 +199,8 @@ class PromptRequest { 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; diff --git a/apps/docs/content/docs/reference/core/tools.mdx b/apps/docs/content/docs/reference/core/tools.mdx index 1cc6ee41..44e4edc9 100644 --- a/apps/docs/content/docs/reference/core/tools.mdx +++ b/apps/docs/content/docs/reference/core/tools.mdx @@ -82,6 +82,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 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..196e4cef --- /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: "https://openrouter.ai/api/v1", + apiKey: process.env.OPENROUTER_API_KEY, +}); +const agentModel = client.completionModel("deepseek/deepseek-v4-pro"); +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/package.json b/examples/cookbook/package.json index 6aca45d6..5df33f63 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -22,6 +22,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", @@ -105,6 +106,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", diff --git a/package.json b/package.json index 465efd4f..60727e00 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,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", @@ -109,6 +110,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", diff --git a/packages/core/package.json b/packages/core/package.json index 433f7677..53c3508d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/core", - "version": "0.1.1", + "version": "0.1.2", "description": "Core runtime primitives for context-aware Anvia agents.", "author": "anvia", "maintainer": "Indra Zulfi", diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 4b3a6386..75b20760 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -11,6 +11,8 @@ 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 { ToolMiddleware } from "../tool/middleware"; +import { isSkillTool } from "../tool/skill-tool-marker"; import type { AnyTool, Tool } from "../tool/tool"; import { ToolSet } from "../tool/tool-set"; import type { VectorFilter, VectorSearchIndex, VectorSearchResult } from "../vector-store"; @@ -35,6 +37,7 @@ export type AgentOptions = { observers?: AgentObserverRegistration[] | undefined; dynamicContexts?: DynamicContextRegistration[] | undefined; dynamicTools?: DynamicToolRegistration[] | undefined; + toolMiddlewares?: ToolMiddleware[] | undefined; memory?: MemoryRegistration | undefined; }; @@ -87,6 +90,7 @@ export class Agent { readonly observers: AgentObserverRegistration[]; readonly dynamicContexts: DynamicContextRegistration[]; readonly dynamicTools: DynamicToolRegistration[]; + readonly toolMiddlewares: ToolMiddleware[]; readonly memory: MemoryRegistration | undefined; constructor(options: AgentOptions) { @@ -107,6 +111,7 @@ export class Agent { this.observers = options.observers ?? []; this.dynamicContexts = options.dynamicContexts ?? []; this.dynamicTools = options.dynamicTools ?? []; + this.toolMiddlewares = options.toolMiddlewares ?? []; this.memory = options.memory; } @@ -181,6 +186,10 @@ export class Agent { return this.toolSet.call(toolName, args); } + + shouldApplyToolMiddleware(toolName: string): boolean { + return !isSkillTool(this.getTool(toolName)); + } } export class AgentSession { diff --git a/packages/core/src/agent/builder.ts b/packages/core/src/agent/builder.ts index 08eec092..fbe7ab2f 100644 --- a/packages/core/src/agent/builder.ts +++ b/packages/core/src/agent/builder.ts @@ -10,6 +10,7 @@ import type { AgentObserver, AgentObserverRegistration, ObserveOptions } from ". 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"; @@ -39,6 +40,7 @@ export class AgentBuilder { private observerRegistrations: AgentObserverRegistration[] = []; private dynamicContextRegistrations: DynamicContextRegistration[] = []; private dynamicToolRegistrations: DynamicToolRegistration[] = []; + private middlewareRegistrations: ToolMiddleware[] = []; private memoryRegistration: MemoryRegistration | undefined; private activeToolSet = new ToolSet(); @@ -142,6 +144,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, @@ -182,6 +194,7 @@ export class AgentBuilder { observers: this.observerRegistrations, dynamicContexts: this.dynamicContextRegistrations, dynamicTools: this.dynamicToolRegistrations, + toolMiddlewares: this.middlewareRegistrations, memory: this.memoryRegistration, }); } diff --git a/packages/core/src/agent/request.ts b/packages/core/src/agent/request.ts index 8113560c..569c517f 100644 --- a/packages/core/src/agent/request.ts +++ b/packages/core/src/agent/request.ts @@ -22,6 +22,7 @@ import { } from "../observability/group"; import type { AgentTraceInfo, AgentTraceOptions } from "../observability/types"; import { toReadableStream } from "../streaming"; +import type { ToolMiddleware, ToolResultMiddlewareArgs } from "../tool/middleware"; import type { Agent } from "./agent"; import { MaxTurnsError, PromptCancelledError } from "./errors"; import type { PromptHook, ToolHookArgs } from "./hooks"; @@ -93,6 +94,7 @@ export class PromptRequest { private activeHook: PromptHook | undefined; private concurrency = 1; private traceOptions: AgentTraceOptions | undefined; + private requestToolMiddlewares: ToolMiddleware[] = []; private constructor( private readonly agent: Agent, @@ -129,6 +131,16 @@ 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; @@ -455,6 +467,15 @@ export class PromptRequest { } } + if (this.agent.shouldApplyToolMiddleware(toolCall.function.name)) { + output = await this.runToolResultMiddlewares({ + ...hookArgs, + result: output, + originalResult: output, + turn: observation?.turn ?? 0, + }); + } + const resultAction = await this.activeHook?.onToolResult?.({ ...hookArgs, result: output, @@ -488,6 +509,20 @@ export class PromptRequest { }); } + private async runToolResultMiddlewares(args: ToolResultMiddlewareArgs): Promise { + let result = args.result; + for (const middleware of [...this.agent.toolMiddlewares, ...this.requestToolMiddlewares]) { + const replacement = await middleware.onResult?.({ + ...args, + result, + }); + if (replacement !== undefined) { + result = replacement; + } + } + return result; + } + private async startRunObservers(): Promise { const failOnObserverError = this.traceOptions?.failOnObserverError === true || 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/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..945f5a93 --- /dev/null +++ b/packages/core/src/tool/middleware.ts @@ -0,0 +1,17 @@ +export type ToolResultMiddlewareArgs = { + toolName: string; + args: string; + result: string; + originalResult: string; + 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/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 2819d471..8d8fd83f 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -10,6 +10,7 @@ import { connectMcp, createHook, createTool, + createToolMiddleware, type McpClient, type McpConnection, type McpServer, @@ -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/prompt-request.test.ts b/packages/core/test/prompt-request.test.ts index 0a81b8da..721f5d27 100644 --- a/packages/core/test/prompt-request.test.ts +++ b/packages/core/test/prompt-request.test.ts @@ -9,6 +9,7 @@ import { cancelPrompt, createHook, createTool, + createToolMiddleware, MaxTurnsError, Message, PromptCancelledError, @@ -126,6 +127,89 @@ 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("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")]), diff --git a/packages/core/test/skills.test.ts b/packages/core/test/skills.test.ts index 4a1fbe03..4269ae74 100644 --- a/packages/core/test/skills.test.ts +++ b/packages/core/test/skills.test.ts @@ -10,7 +10,9 @@ import { type CompletionResponse, type CompletionStreamEvent, createHook, + createToolMiddleware, loadSkills, + Message, SkillValidationError, type StreamingCompletionModel, skill, @@ -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/streaming.test.ts b/packages/core/test/streaming.test.ts index a1500ad7..25e705f2 100644 --- a/packages/core/test/streaming.test.ts +++ b/packages/core/test/streaming.test.ts @@ -8,6 +8,7 @@ import { type CompletionResponse, type CompletionStreamEvent, createTool, + createToolMiddleware, Message, type StreamingCompletionModel, toReadableStream, @@ -148,6 +149,50 @@ 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 concurrent tool results as each tool finishes", async () => { const slowRelease = deferred(); const slowStarted = deferred(); From 1315c5f3d490fbf28c58da5eef8a04fff67a77d8 Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Tue, 5 May 2026 10:43:48 +0700 Subject: [PATCH 04/89] chore: update provider dependencies --- packages/core/package.json | 4 +- packages/observability/langfuse/package.json | 6 +- packages/providers/anthropic/package.json | 4 +- packages/providers/gemini/package.json | 4 +- packages/providers/openai/package.json | 4 +- pnpm-lock.yaml | 125 ++++++++++++------- 6 files changed, 89 insertions(+), 58 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 53c3508d..9cc28569 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/core", - "version": "0.1.2", + "version": "0.1.3", "description": "Core runtime primitives for context-aware Anvia agents.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -95,7 +95,7 @@ "pdfjs-dist": "^5.7.284", "tinyglobby": "^0.2.16", "yaml": "^2.8.4", - "zod": "^4.4.2" + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/packages/observability/langfuse/package.json b/packages/observability/langfuse/package.json index f1b61ff6..1407f0a9 100644 --- a/packages/observability/langfuse/package.json +++ b/packages/observability/langfuse/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/langfuse", - "version": "0.1.0", + "version": "0.1.1", "description": "Langfuse tracing adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -24,8 +24,8 @@ }, "dependencies": { "@anvia/core": "workspace:*", - "@langfuse/otel": "^5.2.0", - "@langfuse/tracing": "^5.2.0", + "@langfuse/otel": "^5.3.0", + "@langfuse/tracing": "^5.3.0", "@opentelemetry/sdk-node": "^0.216.0" }, "devDependencies": { diff --git a/packages/providers/anthropic/package.json b/packages/providers/anthropic/package.json index 65adffec..4673d589 100644 --- a/packages/providers/anthropic/package.json +++ b/packages/providers/anthropic/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/anthropic", - "version": "0.1.0", + "version": "0.1.1", "description": "Anthropic provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -23,7 +23,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@anthropic-ai/sdk": "^0.92.0", + "@anthropic-ai/sdk": "^0.93.0", "@anvia/core": "workspace:*" }, "devDependencies": { diff --git a/packages/providers/gemini/package.json b/packages/providers/gemini/package.json index af84b5ed..236dac78 100644 --- a/packages/providers/gemini/package.json +++ b/packages/providers/gemini/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/gemini", - "version": "0.1.0", + "version": "0.1.1", "description": "Gemini provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -24,7 +24,7 @@ }, "dependencies": { "@anvia/core": "workspace:*", - "@google/genai": "^1.51.0" + "@google/genai": "^1.52.0" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/packages/providers/openai/package.json b/packages/providers/openai/package.json index d9b8c689..63b56737 100644 --- a/packages/providers/openai/package.json +++ b/packages/providers/openai/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/openai", - "version": "0.1.0", + "version": "0.1.1", "description": "OpenAI provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -24,7 +24,7 @@ }, "dependencies": { "@anvia/core": "workspace:*", - "openai": "^6.35.0" + "openai": "^6.36.0" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60b0558b..01324f8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.8 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) apps/docs: dependencies: @@ -40,13 +40,13 @@ importers: version: 1.167.52(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) fumadocs-core: specifier: ^16.8.5 - version: 16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2) + version: 16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3) fumadocs-mdx: specifier: ^14.3.2 - version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2))(react@19.2.5)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3))(react@19.2.5)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) fumadocs-ui: specifier: ^16.8.5 - version: 16.8.5(@tailwindcss/oxide@4.2.4)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.4) + version: 16.8.5(@tailwindcss/oxide@4.2.4)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.4) lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.5) @@ -211,7 +211,7 @@ importers: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.29.0 - version: 1.29.0(zod@4.4.2) + version: 1.29.0(zod@4.4.3) pdfjs-dist: specifier: ^5.7.284 version: 5.7.284 @@ -222,8 +222,8 @@ importers: specifier: ^2.8.4 version: 2.8.4 zod: - specifier: ^4.4.2 - version: 4.4.2 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@types/node': specifier: ^24.9.1 @@ -236,7 +236,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.8 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) packages/embeddings/fastembed: dependencies: @@ -288,11 +288,11 @@ importers: specifier: workspace:* version: link:../../core '@langfuse/otel': - specifier: ^5.2.0 - version: 5.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.216.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1)) + specifier: ^5.3.0 + version: 5.3.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.216.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1)) '@langfuse/tracing': - specifier: ^5.2.0 - version: 5.2.0(@opentelemetry/api@1.9.1) + specifier: ^5.3.0 + version: 5.3.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-node': specifier: ^0.216.0 version: 0.216.0(@opentelemetry/api@1.9.1) @@ -335,8 +335,8 @@ importers: packages/providers/anthropic: dependencies: '@anthropic-ai/sdk': - specifier: ^0.92.0 - version: 0.92.0(zod@4.4.2) + specifier: ^0.93.0 + version: 0.93.0(zod@4.4.3) '@anvia/core': specifier: workspace:* version: link:../../core @@ -360,8 +360,8 @@ importers: specifier: workspace:* version: link:../../core '@google/genai': - specifier: ^1.51.0 - version: 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.2)) + specifier: ^1.52.0 + version: 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) devDependencies: '@types/node': specifier: ^24.9.1 @@ -404,8 +404,8 @@ importers: specifier: workspace:* version: link:../../core openai: - specifier: ^6.35.0 - version: 6.35.0(ws@8.20.0)(zod@4.4.2) + specifier: ^6.36.0 + version: 6.36.0(ws@8.20.0)(zod@4.4.3) devDependencies: '@types/node': specifier: ^24.9.1 @@ -586,8 +586,8 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} - '@anthropic-ai/sdk@0.92.0': - resolution: {integrity: sha512-l653JFC83wCglH8H83t1xpgDurCyPyslYW1maPRdCsfuNuGbLvQjQ81sWd3Go3LWRm0jNspzAhuqAYV8r9joSw==} + '@anthropic-ai/sdk@0.93.0': + resolution: {integrity: sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1382,8 +1382,8 @@ packages: tailwindcss: optional: true - '@google/genai@1.51.0': - resolution: {integrity: sha512-vTZZF3CSimN7cn2zsLpW2p5WF0eZa5Gz69ITMPCNHpPrDlAstOfGifSfi0p/s9Z9400f7xJRkgvkQNrcM7pJ6w==} + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} peerDependencies: '@modelcontextprotocol/sdk': ^1.25.2 @@ -1609,13 +1609,13 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} - '@langfuse/core@5.2.0': - resolution: {integrity: sha512-zUD0vdrFN/LsufBfR6oPWCT4KIb4zeH8sZtF1YjKVTrPjR+WPWJJb9TI20PFByHo4XFJoLUXdTJNfMWtUx0iEg==} + '@langfuse/core@5.3.0': + resolution: {integrity: sha512-9JnDpSMBxsy6Mw5YTc0vERpAYJG5jJKxLtgv1mgA4yrNGWOtqV6ShkSSb/mWE7CeyKFnIFM0lRJpqblrMaRfQA==} peerDependencies: '@opentelemetry/api': ^1.9.0 - '@langfuse/otel@5.2.0': - resolution: {integrity: sha512-U2ArG13tjY7Ybl5K8QE91Igpg2HXoBHXQNqmnw9JNZYatSV/ABgLS6SlUGQS/i4qbdQT+9k6lK3GUf99D+6B4w==} + '@langfuse/otel@5.3.0': + resolution: {integrity: sha512-CJUz3oCD0RMbe+1cPxnuLD/JhsUnLt02DhLP6ZXzhFoi07rHsf8T5oUyCwLRNRH4x2tl87u3aTiOcBJqSK6/fQ==} engines: {node: '>=20'} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -1623,8 +1623,8 @@ packages: '@opentelemetry/exporter-trace-otlp-http': '>=0.202.0 <1.0.0' '@opentelemetry/sdk-trace-base': ^2.0.1 - '@langfuse/tracing@5.2.0': - resolution: {integrity: sha512-lMNkcwujfsEAHFiw9uJSTGd+90BwdeR1BVP3c/bANujkIfZ/nWhkTSO9IlRU5Gs4dMLAFqaPGEMVZaIgEPLquw==} + '@langfuse/tracing@5.3.0': + resolution: {integrity: sha512-Fz6da1O+OqrwG69nF1UAjdaZrYUfR+h+DyVjXJLTNhTrOCqx/jTiIVAP5A32rB07+l4ESgzfO4K222A6cdPW1w==} engines: {node: '>=20'} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -4629,8 +4629,8 @@ packages: onnxruntime-web@1.26.0-dev.20260416-b7804b056c: resolution: {integrity: sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==} - openai@6.35.0: - resolution: {integrity: sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q==} + openai@6.36.0: + resolution: {integrity: sha512-Has2YbIusMq9wQEierFsgf9c783dy1y9arX459LmphNacEkkM5yxi2RIyXP0LmkOroQyW19iTwALHL8Yf26UKA==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -5633,6 +5633,9 @@ packages: zod@4.4.2: resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -5643,11 +5646,11 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/sdk@0.92.0(zod@4.4.2)': + '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: - zod: 4.4.2 + zod: 4.4.3 '@anush008/tokenizers-darwin-universal@0.0.0': optional: true @@ -6169,14 +6172,14 @@ snapshots: '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - '@google/genai@1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.2))': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.5.6 ws: 8.20.0 optionalDependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.2) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color @@ -6348,21 +6351,21 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} - '@langfuse/core@5.2.0(@opentelemetry/api@1.9.1)': + '@langfuse/core@5.3.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@langfuse/otel@5.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.216.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))': + '@langfuse/otel@5.3.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.216.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))': dependencies: - '@langfuse/core': 5.2.0(@opentelemetry/api@1.9.1) + '@langfuse/core': 5.3.0(@opentelemetry/api@1.9.1) '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/exporter-trace-otlp-http': 0.216.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) - '@langfuse/tracing@5.2.0(@opentelemetry/api@1.9.1)': + '@langfuse/tracing@5.3.0(@opentelemetry/api@1.9.1)': dependencies: - '@langfuse/core': 5.2.0(@opentelemetry/api@1.9.1) + '@langfuse/core': 5.3.0(@opentelemetry/api@1.9.1) '@opentelemetry/api': 1.9.1 '@mdx-js/mdx@3.1.1': @@ -6426,6 +6429,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.15) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.4.1(express@5.2.1) + hono: 4.12.15 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@napi-rs/canvas-android-arm64@0.1.100': optional: true @@ -8533,7 +8558,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2): + fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -8562,18 +8587,18 @@ snapshots: lucide-react: 1.14.0(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - zod: 4.4.2 + zod: 4.4.3 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2))(react@19.2.5)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)): + fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3))(react@19.2.5)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.0 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2) + fumadocs-core: 16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3) js-yaml: 4.1.1 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 @@ -8595,7 +8620,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.8.5(@tailwindcss/oxide@4.2.4)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.4): + fumadocs-ui@16.8.5(@tailwindcss/oxide@4.2.4)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.4): dependencies: '@fumadocs/tailwind': 0.0.5(@tailwindcss/oxide@4.2.4)(tailwindcss@4.2.4) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -8609,7 +8634,7 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.5) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) class-variance-authority: 0.7.1 - fumadocs-core: 16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2) + fumadocs-core: 16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3) lucide-react: 1.14.0(react@19.2.5) motion: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) next-themes: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -9726,10 +9751,10 @@ snapshots: platform: 1.3.6 protobufjs: 7.5.6 - openai@6.35.0(ws@8.20.0)(zod@4.4.2): + openai@6.36.0(ws@8.20.0)(zod@4.4.3): optionalDependencies: ws: 8.20.0 - zod: 4.4.2 + zod: 4.4.3 p-retry@4.6.2: dependencies: @@ -10854,8 +10879,14 @@ snapshots: dependencies: zod: 4.4.2 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@3.25.76: {} zod@4.4.2: {} + zod@4.4.3: {} + zwitch@2.0.4: {} From 3e12274818d8db45cad27923a3141116d71a29bd Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Tue, 5 May 2026 10:43:48 +0700 Subject: [PATCH 05/89] chore: update provider dependencies --- README.md | 2 +- packages/core/package.json | 4 +- packages/observability/langfuse/package.json | 6 +- packages/providers/anthropic/package.json | 4 +- packages/providers/gemini/package.json | 4 +- packages/providers/openai/package.json | 4 +- pnpm-lock.yaml | 125 ++++++++++++------- 7 files changed, 90 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 39679391..97955d40 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

MIT license - @anvia/core v0.1.0 + @anvia/core v0.1.3 TypeScript 5.9 pnpm 11.0.4 Node.js runtime diff --git a/packages/core/package.json b/packages/core/package.json index 53c3508d..9cc28569 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/core", - "version": "0.1.2", + "version": "0.1.3", "description": "Core runtime primitives for context-aware Anvia agents.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -95,7 +95,7 @@ "pdfjs-dist": "^5.7.284", "tinyglobby": "^0.2.16", "yaml": "^2.8.4", - "zod": "^4.4.2" + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/packages/observability/langfuse/package.json b/packages/observability/langfuse/package.json index f1b61ff6..1407f0a9 100644 --- a/packages/observability/langfuse/package.json +++ b/packages/observability/langfuse/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/langfuse", - "version": "0.1.0", + "version": "0.1.1", "description": "Langfuse tracing adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -24,8 +24,8 @@ }, "dependencies": { "@anvia/core": "workspace:*", - "@langfuse/otel": "^5.2.0", - "@langfuse/tracing": "^5.2.0", + "@langfuse/otel": "^5.3.0", + "@langfuse/tracing": "^5.3.0", "@opentelemetry/sdk-node": "^0.216.0" }, "devDependencies": { diff --git a/packages/providers/anthropic/package.json b/packages/providers/anthropic/package.json index 65adffec..4673d589 100644 --- a/packages/providers/anthropic/package.json +++ b/packages/providers/anthropic/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/anthropic", - "version": "0.1.0", + "version": "0.1.1", "description": "Anthropic provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -23,7 +23,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@anthropic-ai/sdk": "^0.92.0", + "@anthropic-ai/sdk": "^0.93.0", "@anvia/core": "workspace:*" }, "devDependencies": { diff --git a/packages/providers/gemini/package.json b/packages/providers/gemini/package.json index af84b5ed..236dac78 100644 --- a/packages/providers/gemini/package.json +++ b/packages/providers/gemini/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/gemini", - "version": "0.1.0", + "version": "0.1.1", "description": "Gemini provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -24,7 +24,7 @@ }, "dependencies": { "@anvia/core": "workspace:*", - "@google/genai": "^1.51.0" + "@google/genai": "^1.52.0" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/packages/providers/openai/package.json b/packages/providers/openai/package.json index d9b8c689..63b56737 100644 --- a/packages/providers/openai/package.json +++ b/packages/providers/openai/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/openai", - "version": "0.1.0", + "version": "0.1.1", "description": "OpenAI provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -24,7 +24,7 @@ }, "dependencies": { "@anvia/core": "workspace:*", - "openai": "^6.35.0" + "openai": "^6.36.0" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60b0558b..01324f8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.8 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) apps/docs: dependencies: @@ -40,13 +40,13 @@ importers: version: 1.167.52(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) fumadocs-core: specifier: ^16.8.5 - version: 16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2) + version: 16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3) fumadocs-mdx: specifier: ^14.3.2 - version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2))(react@19.2.5)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3))(react@19.2.5)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) fumadocs-ui: specifier: ^16.8.5 - version: 16.8.5(@tailwindcss/oxide@4.2.4)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.4) + version: 16.8.5(@tailwindcss/oxide@4.2.4)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.4) lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.5) @@ -211,7 +211,7 @@ importers: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.29.0 - version: 1.29.0(zod@4.4.2) + version: 1.29.0(zod@4.4.3) pdfjs-dist: specifier: ^5.7.284 version: 5.7.284 @@ -222,8 +222,8 @@ importers: specifier: ^2.8.4 version: 2.8.4 zod: - specifier: ^4.4.2 - version: 4.4.2 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@types/node': specifier: ^24.9.1 @@ -236,7 +236,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.8 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) packages/embeddings/fastembed: dependencies: @@ -288,11 +288,11 @@ importers: specifier: workspace:* version: link:../../core '@langfuse/otel': - specifier: ^5.2.0 - version: 5.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.216.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1)) + specifier: ^5.3.0 + version: 5.3.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.216.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1)) '@langfuse/tracing': - specifier: ^5.2.0 - version: 5.2.0(@opentelemetry/api@1.9.1) + specifier: ^5.3.0 + version: 5.3.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-node': specifier: ^0.216.0 version: 0.216.0(@opentelemetry/api@1.9.1) @@ -335,8 +335,8 @@ importers: packages/providers/anthropic: dependencies: '@anthropic-ai/sdk': - specifier: ^0.92.0 - version: 0.92.0(zod@4.4.2) + specifier: ^0.93.0 + version: 0.93.0(zod@4.4.3) '@anvia/core': specifier: workspace:* version: link:../../core @@ -360,8 +360,8 @@ importers: specifier: workspace:* version: link:../../core '@google/genai': - specifier: ^1.51.0 - version: 1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.2)) + specifier: ^1.52.0 + version: 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) devDependencies: '@types/node': specifier: ^24.9.1 @@ -404,8 +404,8 @@ importers: specifier: workspace:* version: link:../../core openai: - specifier: ^6.35.0 - version: 6.35.0(ws@8.20.0)(zod@4.4.2) + specifier: ^6.36.0 + version: 6.36.0(ws@8.20.0)(zod@4.4.3) devDependencies: '@types/node': specifier: ^24.9.1 @@ -586,8 +586,8 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} - '@anthropic-ai/sdk@0.92.0': - resolution: {integrity: sha512-l653JFC83wCglH8H83t1xpgDurCyPyslYW1maPRdCsfuNuGbLvQjQ81sWd3Go3LWRm0jNspzAhuqAYV8r9joSw==} + '@anthropic-ai/sdk@0.93.0': + resolution: {integrity: sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1382,8 +1382,8 @@ packages: tailwindcss: optional: true - '@google/genai@1.51.0': - resolution: {integrity: sha512-vTZZF3CSimN7cn2zsLpW2p5WF0eZa5Gz69ITMPCNHpPrDlAstOfGifSfi0p/s9Z9400f7xJRkgvkQNrcM7pJ6w==} + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} peerDependencies: '@modelcontextprotocol/sdk': ^1.25.2 @@ -1609,13 +1609,13 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} - '@langfuse/core@5.2.0': - resolution: {integrity: sha512-zUD0vdrFN/LsufBfR6oPWCT4KIb4zeH8sZtF1YjKVTrPjR+WPWJJb9TI20PFByHo4XFJoLUXdTJNfMWtUx0iEg==} + '@langfuse/core@5.3.0': + resolution: {integrity: sha512-9JnDpSMBxsy6Mw5YTc0vERpAYJG5jJKxLtgv1mgA4yrNGWOtqV6ShkSSb/mWE7CeyKFnIFM0lRJpqblrMaRfQA==} peerDependencies: '@opentelemetry/api': ^1.9.0 - '@langfuse/otel@5.2.0': - resolution: {integrity: sha512-U2ArG13tjY7Ybl5K8QE91Igpg2HXoBHXQNqmnw9JNZYatSV/ABgLS6SlUGQS/i4qbdQT+9k6lK3GUf99D+6B4w==} + '@langfuse/otel@5.3.0': + resolution: {integrity: sha512-CJUz3oCD0RMbe+1cPxnuLD/JhsUnLt02DhLP6ZXzhFoi07rHsf8T5oUyCwLRNRH4x2tl87u3aTiOcBJqSK6/fQ==} engines: {node: '>=20'} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -1623,8 +1623,8 @@ packages: '@opentelemetry/exporter-trace-otlp-http': '>=0.202.0 <1.0.0' '@opentelemetry/sdk-trace-base': ^2.0.1 - '@langfuse/tracing@5.2.0': - resolution: {integrity: sha512-lMNkcwujfsEAHFiw9uJSTGd+90BwdeR1BVP3c/bANujkIfZ/nWhkTSO9IlRU5Gs4dMLAFqaPGEMVZaIgEPLquw==} + '@langfuse/tracing@5.3.0': + resolution: {integrity: sha512-Fz6da1O+OqrwG69nF1UAjdaZrYUfR+h+DyVjXJLTNhTrOCqx/jTiIVAP5A32rB07+l4ESgzfO4K222A6cdPW1w==} engines: {node: '>=20'} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -4629,8 +4629,8 @@ packages: onnxruntime-web@1.26.0-dev.20260416-b7804b056c: resolution: {integrity: sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==} - openai@6.35.0: - resolution: {integrity: sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q==} + openai@6.36.0: + resolution: {integrity: sha512-Has2YbIusMq9wQEierFsgf9c783dy1y9arX459LmphNacEkkM5yxi2RIyXP0LmkOroQyW19iTwALHL8Yf26UKA==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -5633,6 +5633,9 @@ packages: zod@4.4.2: resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -5643,11 +5646,11 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/sdk@0.92.0(zod@4.4.2)': + '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 optionalDependencies: - zod: 4.4.2 + zod: 4.4.3 '@anush008/tokenizers-darwin-universal@0.0.0': optional: true @@ -6169,14 +6172,14 @@ snapshots: '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - '@google/genai@1.51.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.2))': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.5.6 ws: 8.20.0 optionalDependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.2) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color @@ -6348,21 +6351,21 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} - '@langfuse/core@5.2.0(@opentelemetry/api@1.9.1)': + '@langfuse/core@5.3.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 - '@langfuse/otel@5.2.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.216.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))': + '@langfuse/otel@5.3.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.216.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))': dependencies: - '@langfuse/core': 5.2.0(@opentelemetry/api@1.9.1) + '@langfuse/core': 5.3.0(@opentelemetry/api@1.9.1) '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/exporter-trace-otlp-http': 0.216.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) - '@langfuse/tracing@5.2.0(@opentelemetry/api@1.9.1)': + '@langfuse/tracing@5.3.0(@opentelemetry/api@1.9.1)': dependencies: - '@langfuse/core': 5.2.0(@opentelemetry/api@1.9.1) + '@langfuse/core': 5.3.0(@opentelemetry/api@1.9.1) '@opentelemetry/api': 1.9.1 '@mdx-js/mdx@3.1.1': @@ -6426,6 +6429,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.15) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.4.1(express@5.2.1) + hono: 4.12.15 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@napi-rs/canvas-android-arm64@0.1.100': optional: true @@ -8533,7 +8558,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2): + fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -8562,18 +8587,18 @@ snapshots: lucide-react: 1.14.0(react@19.2.5) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - zod: 4.4.2 + zod: 4.4.3 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2))(react@19.2.5)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)): + fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3))(react@19.2.5)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.0 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2) + fumadocs-core: 16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3) js-yaml: 4.1.1 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 @@ -8595,7 +8620,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.8.5(@tailwindcss/oxide@4.2.4)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.4): + fumadocs-ui@16.8.5(@tailwindcss/oxide@4.2.4)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.4): dependencies: '@fumadocs/tailwind': 0.0.5(@tailwindcss/oxide@4.2.4)(tailwindcss@4.2.4) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -8609,7 +8634,7 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.5) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) class-variance-authority: 0.7.1 - fumadocs-core: 16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.2) + fumadocs-core: 16.8.5(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.168.26(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@1.14.0(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.4.3) lucide-react: 1.14.0(react@19.2.5) motion: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) next-themes: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -9726,10 +9751,10 @@ snapshots: platform: 1.3.6 protobufjs: 7.5.6 - openai@6.35.0(ws@8.20.0)(zod@4.4.2): + openai@6.36.0(ws@8.20.0)(zod@4.4.3): optionalDependencies: ws: 8.20.0 - zod: 4.4.2 + zod: 4.4.3 p-retry@4.6.2: dependencies: @@ -10854,8 +10879,14 @@ snapshots: dependencies: zod: 4.4.2 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@3.25.76: {} zod@4.4.2: {} + zod@4.4.3: {} + zwitch@2.0.4: {} From 61f01821c85a3501bdb9084ca9a4ed9077be2d89 Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Tue, 5 May 2026 14:54:30 +0700 Subject: [PATCH 06/89] feat: add provider model listing --- README.md | 2 +- apps/docs/content/docs/guides/cookbook.mdx | 3 +- .../models/compatible-gateways/minimax.mdx | 22 +-- .../compatible-gateways/moonshot-ai.mdx | 22 +-- .../models/compatible-gateways/novita-ai.mdx | 22 +-- .../models/compatible-gateways/nvidia-nim.mdx | 23 +--- .../compatible-gateways/ollama-cloud.mdx | 22 +-- .../models/compatible-gateways/ollama.mdx | 22 +-- .../models/compatible-gateways/opencode.mdx | 25 +--- .../models/compatible-gateways/openrouter.mdx | 40 ++---- apps/docs/content/docs/models/index.mdx | 13 ++ apps/docs/content/docs/models/meta.json | 2 +- .../content/docs/models/model-listing.mdx | 83 ++++++++++++ .../models/providers/compatible-providers.mdx | 9 ++ .../content/docs/models/providers/openai.mdx | 11 ++ .../content/docs/reference/core/index.mdx | 1 + .../content/docs/reference/core/meta.json | 1 + .../docs/reference/core/model-listing.mdx | 46 +++++++ .../docs/reference/providers/anthropic.mdx | 7 +- .../docs/reference/providers/gemini.mdx | 7 +- .../docs/reference/providers/mistral.mdx | 7 +- .../docs/reference/providers/openai.mdx | 7 +- .../10-list-models.ts | 51 +++++++ examples/cookbook/README.md | 2 +- examples/cookbook/package.json | 1 + package.json | 1 + packages/core/package.json | 8 +- packages/core/src/index.ts | 1 + packages/core/src/model-listing/index.ts | 37 +++++ packages/core/test/model-listing.test.ts | 44 ++++++ packages/providers/anthropic/package.json | 2 +- .../anthropic/src/anthropic/client.ts | 114 +++++++++++++++- .../providers/anthropic/test/client.test.ts | 37 +++++ packages/providers/gemini/package.json | 2 +- .../providers/gemini/src/gemini/client.ts | 127 +++++++++++++++++- packages/providers/gemini/test/client.test.ts | 35 +++++ packages/providers/mistral/package.json | 2 +- .../providers/mistral/src/mistral/client.ts | 90 ++++++++++++- .../providers/mistral/test/client.test.ts | 36 +++++ packages/providers/openai/package.json | 2 +- .../providers/openai/src/openai/client.ts | 104 +++++++++++++- packages/providers/openai/test/client.test.ts | 63 +++++++++ 42 files changed, 968 insertions(+), 188 deletions(-) create mode 100644 apps/docs/content/docs/models/model-listing.mdx create mode 100644 apps/docs/content/docs/reference/core/model-listing.mdx create mode 100644 examples/cookbook/04_providers_and_multimodal/10-list-models.ts create mode 100644 packages/core/src/model-listing/index.ts create mode 100644 packages/core/test/model-listing.test.ts create mode 100644 packages/providers/openai/test/client.test.ts diff --git a/README.md b/README.md index 97955d40..c21ec018 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

MIT license - @anvia/core v0.1.3 + @anvia/core v0.1.4 TypeScript 5.9 pnpm 11.0.4 Node.js runtime diff --git a/apps/docs/content/docs/guides/cookbook.mdx b/apps/docs/content/docs/guides/cookbook.mdx index d3d150ca..d57d8404 100644 --- a/apps/docs/content/docs/guides/cookbook.mdx +++ b/apps/docs/content/docs/guides/cookbook.mdx @@ -13,7 +13,7 @@ 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 | @@ -99,6 +99,7 @@ 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/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) | | Render reasoning summaries | `providers:04` | [Streaming Events](/docs/guides/streaming/streaming-events) | | Select dynamic tools | `tools:09` | [Tool Sets](/docs/guides/tools/tool-sets) | 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/index.mdx b/apps/docs/content/docs/models/index.mdx index 5feb19db..1af651cb 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,6 +51,18 @@ 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 | 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..8a647fce --- /dev/null +++ b/apps/docs/content/docs/models/model-listing.mdx @@ -0,0 +1,83 @@ +--- +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`. + +## 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/openai.mdx b/apps/docs/content/docs/models/providers/openai.mdx index 6452540b..042c19c7 100644 --- a/apps/docs/content/docs/models/providers/openai.mdx +++ b/apps/docs/content/docs/models/providers/openai.mdx @@ -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/core/index.mdx b/apps/docs/content/docs/reference/core/index.mdx index 988d8490..d9f85cba 100644 --- a/apps/docs/content/docs/reference/core/index.mdx +++ b/apps/docs/content/docs/reference/core/index.mdx @@ -21,6 +21,7 @@ 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 | diff --git a/apps/docs/content/docs/reference/core/meta.json b/apps/docs/content/docs/reference/core/meta.json index c9d56290..e4b98f66 100644 --- a/apps/docs/content/docs/reference/core/meta.json +++ b/apps/docs/content/docs/reference/core/meta.json @@ -15,6 +15,7 @@ "evals", "loaders", "embeddings", + "model-listing", "vector-store", "mcp", "observability", 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/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/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..d665d34f --- /dev/null +++ b/examples/cookbook/04_providers_and_multimodal/10-list-models.ts @@ -0,0 +1,51 @@ +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.OPENROUTER_API_KEY !== undefined) { + return new OpenAIClient({ + baseUrl: "https://openrouter.ai/api/v1", + apiKey: process.env.OPENROUTER_API_KEY, + }); + } + + if (process.env.OPENAI_API_KEY !== undefined) { + return new OpenAIClient({ + 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/README.md b/examples/cookbook/README.md index 58f2acf1..43ecb7d4 100644 --- a/examples/cookbook/README.md +++ b/examples/cookbook/README.md @@ -23,7 +23,7 @@ Legacy script names such as `cookbook:basic:01`, `cookbook:intermediate:14`, `co | `01_basics` | First text calls, explicit transcripts, static context, streaming, `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. | diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index 5df33f63..6532328f 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -37,6 +37,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", diff --git a/package.json b/package.json index 60727e00..b2c25c57 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,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", diff --git a/packages/core/package.json b/packages/core/package.json index 9cc28569..d81cee21 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/core", - "version": "0.1.3", + "version": "0.1.4", "description": "Core runtime primitives for context-aware Anvia agents.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -52,6 +52,10 @@ "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" @@ -86,7 +90,7 @@ } }, "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/memory/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/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" }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9f1fc564..e3d6543e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,7 @@ export * from "./extractor"; export * from "./image-generation"; export * from "./mcp"; export * from "./memory"; +export * from "./model-listing"; export * from "./observability"; export * from "./pipeline"; export type { ZodSchema } from "./schema"; 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/test/model-listing.test.ts b/packages/core/test/model-listing.test.ts new file mode 100644 index 00000000..f6609c5a --- /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 "../src/index"; + +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/providers/anthropic/package.json b/packages/providers/anthropic/package.json index 4673d589..b543cde5 100644 --- a/packages/providers/anthropic/package.json +++ b/packages/providers/anthropic/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/anthropic", - "version": "0.1.1", + "version": "0.1.2", "description": "Anthropic provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", diff --git a/packages/providers/anthropic/src/anthropic/client.ts b/packages/providers/anthropic/src/anthropic/client.ts index d9fc6ab5..250881ee 100644 --- a/packages/providers/anthropic/src/anthropic/client.ts +++ b/packages/providers/anthropic/src/anthropic/client.ts @@ -1,4 +1,5 @@ import Anthropic from "@anthropic-ai/sdk"; +import { type ModelList, type ModelListingClient, ModelListingError } from "@anvia/core"; import { AnthropicCompletionModel } from "./completion"; export type AnthropicClientOptions = { @@ -7,7 +8,7 @@ export type AnthropicClientOptions = { client?: Anthropic | undefined; }; -export class AnthropicClient { +export class AnthropicClient implements ModelListingClient { readonly client: Anthropic; constructor(options: AnthropicClientOptions = {}) { @@ -22,6 +23,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 +46,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/test/client.test.ts b/packages/providers/anthropic/test/client.test.ts index e5f84336..eb36d5ab 100644 --- a/packages/providers/anthropic/test/client.test.ts +++ b/packages/providers/anthropic/test/client.test.ts @@ -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/gemini/package.json b/packages/providers/gemini/package.json index 236dac78..8570083d 100644 --- a/packages/providers/gemini/package.json +++ b/packages/providers/gemini/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/gemini", - "version": "0.1.1", + "version": "0.1.2", "description": "Gemini provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", diff --git a/packages/providers/gemini/src/gemini/client.ts b/packages/providers/gemini/src/gemini/client.ts index 4c7d19a8..741177d3 100644 --- a/packages/providers/gemini/src/gemini/client.ts +++ b/packages/providers/gemini/src/gemini/client.ts @@ -1,3 +1,4 @@ +import { type ModelList, type ModelListingClient, ModelListingError } from "@anvia/core"; import { GoogleGenAI } from "@google/genai"; import { GeminiCompletionModel } from "./completion"; import { GeminiEmbeddingModel, type GeminiEmbeddingModelOptions } from "./embedding"; @@ -27,7 +28,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 +57,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 +92,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/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/mistral/package.json b/packages/providers/mistral/package.json index edb4bf8b..67347b28 100644 --- a/packages/providers/mistral/package.json +++ b/packages/providers/mistral/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/mistral", - "version": "0.1.0", + "version": "0.1.1", "description": "Mistral provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", diff --git a/packages/providers/mistral/src/mistral/client.ts b/packages/providers/mistral/src/mistral/client.ts index 485abea4..3341717b 100644 --- a/packages/providers/mistral/src/mistral/client.ts +++ b/packages/providers/mistral/src/mistral/client.ts @@ -1,3 +1,4 @@ +import { type ModelList, type ModelListingClient, ModelListingError } from "@anvia/core"; import { Mistral } from "@mistralai/mistralai"; import { MistralCompletionModel } from "./completion"; import { MistralEmbeddingModel, type MistralEmbeddingModelOptions } from "./embedding"; @@ -8,7 +9,7 @@ export type MistralClientOptions = { client?: Mistral | undefined; }; -export class MistralClient { +export class MistralClient implements ModelListingClient { readonly client: Mistral; constructor(options: MistralClientOptions = {}) { @@ -30,6 +31,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 +50,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/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/openai/package.json b/packages/providers/openai/package.json index 63b56737..817291e1 100644 --- a/packages/providers/openai/package.json +++ b/packages/providers/openai/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/openai", - "version": "0.1.1", + "version": "0.1.2", "description": "OpenAI provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", diff --git a/packages/providers/openai/src/openai/client.ts b/packages/providers/openai/src/openai/client.ts index 841e7ff8..58e17c6c 100644 --- a/packages/providers/openai/src/openai/client.ts +++ b/packages/providers/openai/src/openai/client.ts @@ -1,3 +1,4 @@ +import { type ModelList, type ModelListingClient, ModelListingError } from "@anvia/core"; import type { StreamingCompletionModel } from "@anvia/core/completion"; import OpenAI from "openai"; import { OpenAIAudioGenerationModel, TTS_1 } from "./audio-generation"; @@ -15,7 +16,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 +56,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 +77,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/test/client.test.ts b/packages/providers/openai/test/client.test.ts new file mode 100644 index 00000000..90e306b3 --- /dev/null +++ b/packages/providers/openai/test/client.test.ts @@ -0,0 +1,63 @@ +import type { ModelListingError } from "@anvia/core"; +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); + }); +}); From 9cccabd3fa1f9902ed0e772946d8e88839e5c0f6 Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Tue, 5 May 2026 15:17:35 +0700 Subject: [PATCH 07/89] Add llms text routes to docs --- apps/docs/source.config.ts | 5 + apps/docs/src/lib/get-llm-text.ts | 9 + apps/docs/src/lib/layout.shared.tsx | 27 +- apps/docs/src/routeTree.gen.ts | 64 ++- apps/docs/src/routes/llms-full[.]txt.ts | 19 + apps/docs/src/routes/llms[.]txt.ts | 557 ++++++++++++++++++++++++ 6 files changed, 676 insertions(+), 5 deletions(-) create mode 100644 apps/docs/src/lib/get-llm-text.ts create mode 100644 apps/docs/src/routes/llms-full[.]txt.ts create mode 100644 apps/docs/src/routes/llms[.]txt.ts 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/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..e1ef41b8 100644 --- a/apps/docs/src/lib/layout.shared.tsx +++ b/apps/docs/src/lib/layout.shared.tsx @@ -9,6 +9,10 @@ const resourceLinks = [ { text: "Reference", url: "/docs/reference" }, { text: "Models", url: "/docs/models" }, ]; +const agentLinks = [ + { text: "llms.txt", url: "/llms.txt" }, + { text: "llms-full.txt", url: "/llms-full.txt" }, +]; function GitHubIcon() { return ( @@ -19,17 +23,31 @@ function GitHubIcon() { } function ResourcesDropdown() { + return ; +} + +function AgentsDropdown() { + return ; +} + +function NavDropdown({ + label, + links, +}: { + label: string; + links: Array<{ text: string; url: string }>; +}) { return (

  • - {resourceLinks.map((item) => ( + {links.map((item) => ( , on: "nav", }, + { + type: "custom", + children: , + on: "nav", + }, { text: "Blog", url: "/blog", diff --git a/apps/docs/src/routeTree.gen.ts b/apps/docs/src/routeTree.gen.ts index 5a6b6ec0..9862c20c 100644 --- a/apps/docs/src/routeTree.gen.ts +++ b/apps/docs/src/routeTree.gen.ts @@ -9,12 +9,24 @@ // 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 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 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 +56,8 @@ const ApiSearchRoute = ApiSearchRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/blog': typeof BlogRoute + '/llms-full.txt': typeof LlmsFullDottxtRoute + '/llms.txt': typeof LlmsDottxtRoute '/api/search': typeof ApiSearchRoute '/docs/$': typeof DocsSplatRoute '/docs/': typeof DocsIndexRoute @@ -51,6 +65,8 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/blog': typeof BlogRoute + '/llms-full.txt': typeof LlmsFullDottxtRoute + '/llms.txt': typeof LlmsDottxtRoute '/api/search': typeof ApiSearchRoute '/docs/$': typeof DocsSplatRoute '/docs': typeof DocsIndexRoute @@ -59,21 +75,47 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/blog': typeof BlogRoute + '/llms-full.txt': typeof LlmsFullDottxtRoute + '/llms.txt': typeof LlmsDottxtRoute '/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' + | '/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' + | '/api/search' + | '/docs/$' + | '/docs' + id: + | '__root__' + | '/' + | '/blog' + | '/llms-full.txt' + | '/llms.txt' + | '/api/search' + | '/docs/$' + | '/docs/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute BlogRoute: typeof BlogRoute + LlmsFullDottxtRoute: typeof LlmsFullDottxtRoute + LlmsDottxtRoute: typeof LlmsDottxtRoute ApiSearchRoute: typeof ApiSearchRoute DocsSplatRoute: typeof DocsSplatRoute DocsIndexRoute: typeof DocsIndexRoute @@ -81,6 +123,20 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/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 +178,8 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BlogRoute: BlogRoute, + LlmsFullDottxtRoute: LlmsFullDottxtRoute, + LlmsDottxtRoute: LlmsDottxtRoute, ApiSearchRoute: ApiSearchRoute, DocsSplatRoute: DocsSplatRoute, DocsIndexRoute: DocsIndexRoute, 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..eaaaf18e --- /dev/null +++ b/apps/docs/src/routes/llms[.]txt.ts @@ -0,0 +1,557 @@ +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, request-specific context, traces, limits, and cancellation. +- 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. +- [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"; +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"; + +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 { InMemoryVectorStore, embedDocuments } from "@anvia/core"; +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 { MaxTurnsError, Message, PromptCancelledError, type Agent } from "@anvia/core"; + +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", + }, + }), + }, + }, +}); From 1a128782292a33b1d906d5c855f497e18064587d Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Wed, 6 May 2026 09:44:56 +0700 Subject: [PATCH 08/89] feat: stream subagent tool events --- README.md | 2 +- .../docs/guides/agents/event-store.mdx | 173 ++++++++++ .../docs/content/docs/guides/agents/meta.json | 1 + .../guides/agents/multi-agent-workflows.mdx | 26 +- apps/docs/content/docs/guides/cookbook.mdx | 8 +- .../docs/content/docs/guides/memory/index.mdx | 4 + .../docs/content/docs/guides/memory/meta.json | 2 +- .../docs/guides/memory/multi-agent.mdx | 61 ++++ .../guides/streaming/streaming-events.mdx | 26 ++ .../content/docs/reference/core/agent.mdx | 44 ++- .../content/docs/reference/core/tools.mdx | 18 +- .../07_multi_agent/01-agent-as-tool.ts | 14 +- .../07_multi_agent/02-parallel-specialists.ts | 14 +- .../03-streaming-agent-tools.ts | 82 +++++ .../07_multi_agent/04-agent-event-store.ts | 80 +++++ examples/cookbook/09_studio/06-subagents.ts | 127 +++++++ examples/cookbook/README.md | 4 +- examples/cookbook/package.json | 3 + package.json | 3 + packages/core/package.json | 2 +- packages/core/src/agent/agent.ts | 75 +++- packages/core/src/agent/builder.ts | 15 + packages/core/src/agent/request.ts | 139 +++++++- packages/core/src/observability/group.ts | 14 + packages/core/src/observability/index.ts | 1 + packages/core/src/observability/types.ts | 6 + packages/core/src/tool/create-tool.ts | 7 +- packages/core/src/tool/tool-set.ts | 6 +- packages/core/src/tool/tool.ts | 12 +- packages/core/test/memory.test.ts | 68 ++++ packages/core/test/streaming.test.ts | 199 +++++++++++ packages/observability/langfuse/package.json | 2 +- packages/observability/langfuse/src/index.ts | 241 +++++++++++++ .../langfuse/test/langfuse.test.ts | 164 +++++++++ packages/observability/otel/package.json | 2 +- packages/observability/otel/src/index.ts | 256 +++++++++++++- packages/observability/otel/test/otel.test.ts | 141 +++++++- packages/tools/studio/package.json | 2 +- packages/tools/studio/src/runtime/runs.ts | 146 ++++++++ .../tools/studio/src/traces/trace-observer.ts | 320 ++++++++++++++++-- packages/tools/studio/src/types.ts | 28 +- packages/tools/studio/src/ui/app/app.tsx | 157 +++++++++ .../modules/playground/transcript-item.tsx | 55 +++ .../ui/app/modules/tracing/trace-browser.tsx | 136 ++++++-- packages/tools/studio/test/runner.test.ts | 149 ++++++++ 45 files changed, 2912 insertions(+), 123 deletions(-) create mode 100644 apps/docs/content/docs/guides/agents/event-store.mdx create mode 100644 apps/docs/content/docs/guides/memory/multi-agent.mdx create mode 100644 examples/cookbook/07_multi_agent/03-streaming-agent-tools.ts create mode 100644 examples/cookbook/07_multi_agent/04-agent-event-store.ts create mode 100644 examples/cookbook/09_studio/06-subagents.ts diff --git a/README.md b/README.md index c21ec018..81c817ec 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

    MIT license - @anvia/core v0.1.4 + @anvia/core v0.1.5 TypeScript 5.9 pnpm 11.0.4 Node.js runtime 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/cookbook.mdx b/apps/docs/content/docs/guides/cookbook.mdx index d57d8404..d5c44986 100644 --- a/apps/docs/content/docs/guides/cookbook.mdx +++ b/apps/docs/content/docs/guides/cookbook.mdx @@ -16,9 +16,9 @@ Each level introduces one layer at a time: | 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 @@ -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 ``` @@ -107,6 +107,6 @@ Use the in-memory and Transformers examples when you do not need a separate vect | 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/memory/index.mdx b/apps/docs/content/docs/guides/memory/index.mdx index 74861659..a1dba60b 100644 --- a/apps/docs/content/docs/guides/memory/index.mdx +++ b/apps/docs/content/docs/guides/memory/index.mdx @@ -24,6 +24,8 @@ Use `agent.prompt([...messages])` when your application already owns an explicit 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 @@ -80,6 +82,8 @@ Memory defaults to `savePolicy: "message"`. 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: diff --git a/apps/docs/content/docs/guides/memory/meta.json b/apps/docs/content/docs/guides/memory/meta.json index e932810b..47247b86 100644 --- a/apps/docs/content/docs/guides/memory/meta.json +++ b/apps/docs/content/docs/guides/memory/meta.json @@ -2,5 +2,5 @@ "title": "Memory", "defaultOpen": false, "collapsible": true, - "pages": ["index", "raw-sql", "prisma", "drizzle"] + "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/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/reference/core/agent.mdx b/apps/docs/content/docs/reference/core/agent.mdx index e469b2ec..db83fd1e 100644 --- a/apps/docs/content/docs/reference/core/agent.mdx +++ b/apps/docs/content/docs/reference/core/agent.mdx @@ -28,19 +28,20 @@ class Agent { readonly dynamicTools: DynamicToolRegistration[]; readonly toolMiddlewares: ToolMiddleware[]; readonly memory?: MemoryRegistration; + readonly eventStore?: AgentEventStoreRegistration; constructor(options: AgentOptions); 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`; `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. +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. @@ -67,6 +68,7 @@ type AgentOptions = { dynamicTools?: DynamicToolRegistration[]; toolMiddlewares?: ToolMiddleware[]; memory?: MemoryRegistration; + eventStore?: AgentEventStoreRegistration; }; ``` @@ -102,6 +104,7 @@ class AgentBuilder { 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; } @@ -166,6 +169,35 @@ 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 AgentEventStoreOptions = { + include?: "all" | "agent_tool_events"; +}; + +type AgentEventAppendInput = { + runId: string; + agentId: string; + agentName?: string; + turn?: number; + toolName?: string; + toolCallId?: string; + internalCallId?: string; + event: unknown; +}; +``` + +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 @@ -234,20 +266,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()`. @@ -346,6 +381,7 @@ type AgentToolOptions = { name: string; description?: string; maxTurns?: number; + stream?: boolean; }; type DynamicContextOptions = { diff --git a/apps/docs/content/docs/reference/core/tools.mdx b/apps/docs/content/docs/reference/core/tools.mdx index 44e4edc9..50edda8d 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( @@ -120,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; } ``` 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..a9569beb 100644 --- a/examples/cookbook/07_multi_agent/01-agent-as-tool.ts +++ b/examples/cookbook/07_multi_agent/01-agent-as-tool.ts @@ -6,8 +6,9 @@ const client = new OpenAIClient({ apiKey: process.env.OPENROUTER_API_KEY, }); -const supportAgentModel = client.completionModel("deepseek/deepseek-v4-pro"); -const supportAgent = new AgentBuilder("support", supportAgentModel) +const model = client.completionModel("deepseek/deepseek-v4-pro"); + +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..89200ac0 100644 --- a/examples/cookbook/07_multi_agent/02-parallel-specialists.ts +++ b/examples/cookbook/07_multi_agent/02-parallel-specialists.ts @@ -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("deepseek/deepseek-v4-pro"); + +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..1022a1f6 --- /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: "https://openrouter.ai/api/v1", + apiKey: process.env.OPENROUTER_API_KEY, +}); + +const model = client.completionModel("deepseek/deepseek-v4-pro"); + +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..958d2dc3 --- /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: "https://openrouter.ai/api/v1", + apiKey: process.env.OPENROUTER_API_KEY, +}); + +const model = client.completionModel("deepseek/deepseek-v4-pro"); + +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/09_studio/06-subagents.ts b/examples/cookbook/09_studio/06-subagents.ts new file mode 100644 index 00000000..b26c2a51 --- /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: "https://openrouter.ai/api/v1", + apiKey: process.env.OPENROUTER_API_KEY, +}); + +const model = client.completionModel("deepseek/deepseek-v4-pro"); + +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/README.md b/examples/cookbook/README.md index 43ecb7d4..030b824f 100644 --- a/examples/cookbook/README.md +++ b/examples/cookbook/README.md @@ -26,9 +26,9 @@ Legacy script names such as `cookbook:basic:01`, `cookbook:intermediate:14`, `co | `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. | +| `09_studio` | Single-agent, multi-agent, and subagent Studio runners, tool approvals, human feedback, and Knowledge inspection. | | `10_integrations` | MCP tools, local skills, Langfuse tracing, and Langfuse eval reporting. | ## Environment diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index 6532328f..64520e1e 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -64,6 +64,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", @@ -76,6 +78,7 @@ "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", "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", diff --git a/package.json b/package.json index b2c25c57..2484799f 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,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", @@ -81,6 +83,7 @@ "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:integrations": "pnpm --filter cookbook integrations", "cookbook:integrations:01": "pnpm --filter cookbook integrations:01", "cookbook:integrations:02": "pnpm --filter cookbook integrations:02", diff --git a/packages/core/package.json b/packages/core/package.json index d81cee21..5885e65c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/core", - "version": "0.1.4", + "version": "0.1.5", "description": "Core runtime primitives for context-aware Anvia agents.", "author": "anvia", "maintainer": "Indra Zulfi", diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 75b20760..be152f77 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -13,11 +13,12 @@ import { createTool } from "../tool/create-tool"; import type { ToolSearchDocument } from "../tool/dynamic-tools"; import type { ToolMiddleware } from "../tool/middleware"; import { isSkillTool } from "../tool/skill-tool-marker"; -import type { AnyTool, Tool } from "../tool/tool"; +import type { AnyTool, 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 { PromptRequest } from "./request"; +import { isStreamingCompletionModel } from "./utils"; export type AgentOptions = { id: string; @@ -39,6 +40,7 @@ export type AgentOptions = { dynamicTools?: DynamicToolRegistration[] | undefined; toolMiddlewares?: ToolMiddleware[] | undefined; memory?: MemoryRegistration | undefined; + eventStore?: AgentEventStoreRegistration | undefined; }; export const DEFAULT_MAX_TURNS = 20; @@ -47,6 +49,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 = { @@ -92,6 +127,7 @@ export class Agent { readonly dynamicTools: DynamicToolRegistration[]; readonly toolMiddlewares: ToolMiddleware[]; readonly memory: MemoryRegistration | undefined; + readonly eventStore: AgentEventStoreRegistration | undefined; constructor(options: AgentOptions) { this.id = normalizeAgentId(options.id); @@ -113,6 +149,7 @@ export class Agent { this.dynamicTools = options.dynamicTools ?? []; this.toolMiddlewares = options.toolMiddlewares ?? []; this.memory = options.memory; + this.eventStore = options.eventStore; } prompt(prompt: string | MessageType | MessageType[]): PromptRequest { @@ -145,12 +182,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; }, }); @@ -172,19 +227,19 @@ 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); } shouldApplyToolMiddleware(toolName: string): boolean { diff --git a/packages/core/src/agent/builder.ts b/packages/core/src/agent/builder.ts index fbe7ab2f..92bea92b 100644 --- a/packages/core/src/agent/builder.ts +++ b/packages/core/src/agent/builder.ts @@ -16,6 +16,9 @@ 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, @@ -42,6 +45,7 @@ export class AgentBuilder { private dynamicToolRegistrations: DynamicToolRegistration[] = []; private middlewareRegistrations: ToolMiddleware[] = []; private memoryRegistration: MemoryRegistration | undefined; + private eventStoreRegistration: AgentEventStoreRegistration | undefined; private activeToolSet = new ToolSet(); constructor( @@ -170,6 +174,16 @@ export class AgentBuilder { 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; @@ -196,6 +210,7 @@ export class AgentBuilder { dynamicTools: this.dynamicToolRegistrations, toolMiddlewares: this.middlewareRegistrations, memory: this.memoryRegistration, + eventStore: this.eventStoreRegistration, }); } diff --git a/packages/core/src/agent/request.ts b/packages/core/src/agent/request.ts index 569c517f..ea204d59 100644 --- a/packages/core/src/agent/request.ts +++ b/packages/core/src/agent/request.ts @@ -22,6 +22,7 @@ import { } from "../observability/group"; import type { AgentTraceInfo, AgentTraceOptions } from "../observability/types"; import { toReadableStream } from "../streaming"; +import type { ToolCallStreamEvent } from "../tool"; import type { ToolMiddleware, ToolResultMiddlewareArgs } from "../tool/middleware"; import type { Agent } from "./agent"; import { MaxTurnsError, PromptCancelledError } from "./errors"; @@ -37,7 +38,7 @@ export type PromptResponse = { trace?: AgentTraceInfo | undefined; }; -export type AgentStreamEvent = +export type AgentChildStreamEvent = | { type: "turn_start"; turn: number; @@ -78,6 +79,7 @@ export type AgentStreamEvent = } | { type: "final"; + runId: string; output: string; usage: Usage; messages: MessageType[]; @@ -88,6 +90,19 @@ 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[]; private maxTurnCount: number; @@ -216,10 +231,16 @@ export class PromptRequest { return result; } - const toolResults = await this.executeToolCalls(toolCalls, newMessages, undefined, { - turn: currentTurns, - runObservers, - }); + const toolResults = await this.executeToolCalls( + toolCalls, + newMessages, + undefined, + undefined, + { + turn: currentTurns, + runObservers, + }, + ); const toolMessage = Message.tool(toolResults); newMessages.push(toolMessage); await this.commitMemoryMessages(runId, currentTurns, [toolMessage], pendingTurnMessages); @@ -247,6 +268,10 @@ export class PromptRequest { 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) { @@ -259,12 +284,12 @@ export class PromptRequest { currentTurns += 1; const historyForRequest = [...this.chatHistory, ...newMessages.slice(0, -1)]; - yield { + yield await emit({ type: "turn_start", turn: currentTurns, prompt, history: historyForRequest, - }; + }); await this.runCompletionCallHook(prompt, historyForRequest, newMessages); const ragText = extractRagText(prompt); @@ -300,7 +325,7 @@ export class PromptRequest { throw event.error; } if (mapped !== undefined) { - yield addTurn(currentTurns, mapped); + yield await emit(addTurn(currentTurns, mapped)); } } } catch (error) { @@ -329,9 +354,9 @@ export class PromptRequest { (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); @@ -341,24 +366,28 @@ export class PromptRequest { newMessages, pendingTurnMessages, ); - yield { + 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, @@ -369,7 +398,7 @@ 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; const toolMessage = Message.tool(toolResults); @@ -382,7 +411,7 @@ export class PromptRequest { } catch (error) { await runObservers.error({ error, usage, messages: [...newMessages] }); await this.recordMemoryError(runId, error, newMessages); - yield { type: "error", error }; + yield await emit({ type: "error", error }); throw error; } } @@ -412,6 +441,7 @@ export class PromptRequest { toolCalls: ToolCall[], newMessages: MessageType[], onResult?: (result: ToolResultEventPayload) => void, + onStreamEvent?: (event: AgentToolEventPayload) => void, observation?: { turn: number; runObservers: ActiveAgentRunObservers; @@ -461,7 +491,23 @@ export class PromptRequest { skipped = true; } else { try { - output = await this.agent.callTool(toolCall.function.name, args); + 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); } @@ -496,6 +542,7 @@ export class PromptRequest { } const resultPayload: ToolResultEventPayload = { + type: "tool_result", toolName: toolCall.function.name, internalCallId, args, @@ -542,6 +589,34 @@ export class PromptRequest { ); } + private async recordAgentEvent(runId: string, event: AgentStreamEvent): Promise { + const registration = this.agent.eventStore; + if (registration === undefined) { + return; + } + if (registration.options.include === "agent_tool_events" && event.type !== "agent_tool_event") { + return; + } + + 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, + }); + } + private async fetchDynamicContext(ragText: string | undefined): Promise { if (ragText === undefined || ragText.length === 0 || this.agent.dynamicContexts.length === 0) { return []; @@ -793,6 +868,7 @@ function normalizePromptInput(prompt: string | MessageType | MessageType[]): { } type ToolResultEventPayload = { + type: "tool_result"; toolName: string; toolCallId?: string; internalCallId: string; @@ -800,6 +876,37 @@ type ToolResultEventPayload = { result: string; }; +type AgentToolEventPayload = { + type: "agent_tool_event"; + toolName: string; + toolCallId?: string; + internalCallId: string; + agentId: string; + agentName?: string; + event: AgentChildStreamEvent; +}; + +type ToolExecutionEventPayload = ToolResultEventPayload | AgentToolEventPayload; + +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, + }; +} + type AsyncQueueWaiter = { resolve: (result: IteratorResult) => void; reject: (error: unknown) => void; 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..fa193397 100644 --- a/packages/core/src/observability/types.ts +++ b/packages/core/src/observability/types.ts @@ -5,6 +5,7 @@ import type { ToolCall, Usage, } from "../completion"; +import type { ToolCallStreamEvent } from "../tool"; export type AgentTraceInfo = { traceId?: string | undefined; @@ -78,12 +79,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/tool/create-tool.ts b/packages/core/src/tool/create-tool.ts index 21b2a1b0..e582e632 100644 --- a/packages/core/src/tool/create-tool.ts +++ b/packages/core/src/tool/create-tool.ts @@ -1,6 +1,6 @@ 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, @@ -13,6 +13,7 @@ export type CreateToolOptions< approval?: ToolApprovalPolicy>; execute( args: z.output, + context: ToolCallContext, ): OutputSchema extends ZodSchema ? z.input | Promise> : unknown | Promise; @@ -40,9 +41,9 @@ 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; diff --git a/packages/core/src/tool/tool-set.ts b/packages/core/src/tool/tool-set.ts index 6ea0fa2f..79d62463 100644 --- a/packages/core/src/tool/tool-set.ts +++ b/packages/core/src/tool/tool-set.ts @@ -1,6 +1,6 @@ import type { ToolDefinition } from "../completion/types"; import { ToolCallError, ToolJsonError, ToolNotFoundError } from "./errors"; -import { type AnyTool, parseToolArgs, serializeToolOutput } from "./tool"; +import { type AnyTool, parseToolArgs, serializeToolOutput, type ToolCallContext } from "./tool"; export class ToolSet { private readonly tools = new Map(); @@ -50,7 +50,7 @@ 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,7 +64,7 @@ export class ToolSet { } try { - const output = await tool.call(parsedArgs); + const output = await tool.call(parsedArgs, context); return serializeToolOutput(output); } catch (error) { if (error instanceof Error) { diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 372762f9..b19b2532 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -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; } diff --git a/packages/core/test/memory.test.ts b/packages/core/test/memory.test.ts index ca545dd1..f2101dba 100644 --- a/packages/core/test/memory.test.ts +++ b/packages/core/test/memory.test.ts @@ -6,6 +6,7 @@ import { type CompletionModel, type CompletionRequest, type CompletionResponse, + type CompletionStreamEvent, createTool, type MemoryAppendInput, type MemoryContext, @@ -13,6 +14,7 @@ import { type MemoryStore, Message, type Message as MessageType, + type StreamingCompletionModel, Usage, } from "../src/index"; @@ -42,6 +44,36 @@ class QueueModel implements CompletionModel { } } +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[] = []; @@ -203,6 +235,42 @@ describe("agent memory", () => { ]); }); + 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([]); diff --git a/packages/core/test/streaming.test.ts b/packages/core/test/streaming.test.ts index 25e705f2..dd5ab9f2 100644 --- a/packages/core/test/streaming.test.ts +++ b/packages/core/test/streaming.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { AgentBuilder, + type AgentEventAppendInput, + type AgentEventRecord, + type AgentEventStore, type AgentStreamEvent, AssistantContent, type CompletionRequest, @@ -44,6 +47,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", @@ -292,6 +313,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([ [ @@ -509,6 +702,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/observability/langfuse/package.json b/packages/observability/langfuse/package.json index 1407f0a9..e06bfef0 100644 --- a/packages/observability/langfuse/package.json +++ b/packages/observability/langfuse/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/langfuse", - "version": "0.1.1", + "version": "0.1.2", "description": "Langfuse tracing adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", diff --git a/packages/observability/langfuse/src/index.ts b/packages/observability/langfuse/src/index.ts index 4110dd23..0e6d6ce2 100644 --- a/packages/observability/langfuse/src/index.ts +++ b/packages/observability/langfuse/src/index.ts @@ -12,6 +12,7 @@ import { type AgentToolErrorArgs, type AgentToolObserver, type AgentToolStartArgs, + type AgentToolStreamEventArgs, type AgentTraceInfo, type EvalOutcome, type EvalReportArgs, @@ -451,9 +452,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 +609,7 @@ class LangfuseToolObserver implements AgentToolObserver { } error(args: AgentToolErrorArgs): void { + this.endOpenChildren(); this.tool .update({ level: "ERROR", @@ -484,6 +623,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 +707,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..59445e5f 100644 --- a/packages/observability/langfuse/test/langfuse.test.ts +++ b/packages/observability/langfuse/test/langfuse.test.ts @@ -220,6 +220,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/otel/package.json b/packages/observability/otel/package.json index 54a41bff..13d74f79 100644 --- a/packages/observability/otel/package.json +++ b/packages/observability/otel/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/otel", - "version": "0.1.0", + "version": "0.1.1", "description": "OpenTelemetry tracing adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", diff --git a/packages/observability/otel/src/index.ts b/packages/observability/otel/src/index.ts index 1c7effb0..2b00dbfb 100644 --- a/packages/observability/otel/src/index.ts +++ b/packages/observability/otel/src/index.ts @@ -12,6 +12,7 @@ import { type AgentToolErrorArgs, type AgentToolObserver, type AgentToolStartArgs, + type AgentToolStreamEventArgs, type AgentTraceInfo, textFromAssistantContent, } from "@anvia/core"; @@ -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..db415d60 100644 --- a/packages/observability/otel/test/otel.test.ts +++ b/packages/observability/otel/test/otel.test.ts @@ -1,7 +1,7 @@ import { type AgentGenerationStartArgs, AssistantContent, - type Message, + Message, type ToolCall, type Usage, } from "@anvia/core"; @@ -218,6 +218,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/tools/studio/package.json b/packages/tools/studio/package.json index ae34fff3..5f116230 100644 --- a/packages/tools/studio/package.json +++ b/packages/tools/studio/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/studio", - "version": "0.1.1", + "version": "0.1.2", "description": "Studio UI and HTTP runtime for Anvia agents.", "author": "anvia", "maintainer": "Indra Zulfi", diff --git a/packages/tools/studio/src/runtime/runs.ts b/packages/tools/studio/src/runtime/runs.ts index e835cced..6f04c06c 100644 --- a/packages/tools/studio/src/runtime/runs.ts +++ b/packages/tools/studio/src/runtime/runs.ts @@ -6,6 +6,7 @@ import type { AgentRunStreamEvent, StudioSession, StudioSessionStore, + StudioTranscriptChildAgentEvent, StudioTranscriptEntry, } from "../types"; import { @@ -235,6 +236,22 @@ function acceptTranscriptStreamEvent( matched.args = matched.args ?? event.args; matched.result = event.result; } + 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( transcript, @@ -314,6 +331,135 @@ 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, + }; + } + 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 (error instanceof Error) { + return error.message; + } + 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) { diff --git a/packages/tools/studio/src/traces/trace-observer.ts b/packages/tools/studio/src/traces/trace-observer.ts index 95a6103c..3b6d7401 100644 --- a/packages/tools/studio/src/traces/trace-observer.ts +++ b/packages/tools/studio/src/traces/trace-observer.ts @@ -12,6 +12,7 @@ import type { AgentToolErrorArgs, AgentToolObserver, AgentToolStartArgs, + AgentToolStreamEventArgs, JsonObject, JsonValue, } from "@anvia/core"; @@ -104,34 +105,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), + }); + 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 +202,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, @@ -265,6 +527,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 06120860..94878e4e 100644 --- a/packages/tools/studio/src/types.ts +++ b/packages/tools/studio/src/types.ts @@ -77,10 +77,35 @@ export type StudioTranscriptToolEntry = { callId?: string; args?: string; result?: string; + 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; + }; + export type StudioTranscriptEntry = | StudioTranscriptChatEntry | StudioTranscriptReasoningEntry @@ -141,10 +166,11 @@ export type StudioSessionStore = MemoryStore & { 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; diff --git a/packages/tools/studio/src/ui/app/app.tsx b/packages/tools/studio/src/ui/app/app.tsx index 85223be4..0b44d45a 100644 --- a/packages/tools/studio/src/ui/app/app.tsx +++ b/packages/tools/studio/src/ui/app/app.tsx @@ -15,6 +15,7 @@ import type { StudioSessionSummary, StudioTrace, StudioTraceSummary, + StudioTranscriptChildAgentEvent, } from "../../types"; import { Button } from "./components/ui/button"; import { @@ -563,6 +564,10 @@ export function StudioConsole() { }); return true; } + if (event.type === "agent_tool_event") { + appendAgentToolEvent(event); + return true; + } if (event.type === "tool_approval_request") { updateToolApproval(event.approval); return true; @@ -824,6 +829,158 @@ export function StudioConsole() { }); } + 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, + }; + } + 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); diff --git a/packages/tools/studio/src/ui/app/modules/playground/transcript-item.tsx b/packages/tools/studio/src/ui/app/modules/playground/transcript-item.tsx index 2ff89edf..b3251c5a 100644 --- a/packages/tools/studio/src/ui/app/modules/playground/transcript-item.tsx +++ b/packages/tools/studio/src/ui/app/modules/playground/transcript-item.tsx @@ -86,9 +86,11 @@ function ToolEntry(props: { ); const approval = props.entry.approval; const question = props.entry.question; + const childEvents = props.entry.childEvents ?? []; const hasPayload = props.entry.args !== undefined || props.entry.result !== undefined || + childEvents.length > 0 || approval !== undefined || question !== undefined; const pendingApproval = approval?.status === "pending"; @@ -172,6 +174,7 @@ function ToolEntry(props: { {question !== undefined || props.entry.args === undefined ? null : ( )} + {childEvents.length === 0 ? null : } {question !== undefined || props.entry.result === undefined ? null : ( )} @@ -181,6 +184,58 @@ function ToolEntry(props: { ); } +function ChildAgentActivity(props: { events: NonNullable }) { + return ( +

    +
    + Subagent activity +
    +
    + {props.events.map((event) => { + const agentLabel = event.agentName ?? event.agentId; + if (event.kind === "message" || event.kind === "reasoning") { + return ( +
    +
    + + {event.kind === "reasoning" ? "Reasoning" : "Response"} + + + {agentLabel} + +
    + +
    + ); + } + return ( +
    +
    + + Tool + + + {agentLabel} / {event.toolName} + +
    + {event.args === undefined ? null : } + {event.result === undefined ? null : ( + + )} +
    + ); + })} +
    +
    + ); +} + function ToolQuestionPanel(props: { question: ToolQuestion; disabled: boolean; diff --git a/packages/tools/studio/src/ui/app/modules/tracing/trace-browser.tsx b/packages/tools/studio/src/ui/app/modules/tracing/trace-browser.tsx index d9bd8245..b26d2a79 100644 --- a/packages/tools/studio/src/ui/app/modules/tracing/trace-browser.tsx +++ b/packages/tools/studio/src/ui/app/modules/tracing/trace-browser.tsx @@ -313,29 +313,15 @@ function TracePanel(props: { subtitle={formatDuration(turn.durationMs)} onSelect={() => selectTimelineItem(trace.id, `turn:${turn.turn}`)} /> - {turn.observations.map((observation) => { - const usageText = observationUsageText(observation); - return ( - 0 - ? `${formatDuration(observation.durationMs)} · ${usageText}` - : formatDuration(observation.durationMs) - } - onSelect={() => - selectTimelineItem(trace.id, `observation:${observation.id}`) - } - key={observation.id} - /> - ); - })} + + selectTimelineItem(trace.id, `observation:${observationId}`) + } + traceActive={traceActive} + />
    ))} @@ -451,6 +437,78 @@ function TraceTreeRow(props: { ); } +function TraceObservationRows(props: { + activeKey: TraceInspectorKey; + isLastTurn: boolean; + observations: TraceObservationItem[]; + traceActive: boolean; + onSelect: (observationId: string) => void; +}) { + const roots = traceObservationTree(props.observations); + return ( + <> + {roots.map((node, index) => ( + + ))} + + ); +} + +function TraceObservationNodeRow(props: { + activeKey: TraceInspectorKey; + ancestorLevels: number[]; + isLastSibling: boolean; + level: number; + node: TraceObservationNode; + traceActive: boolean; + onSelect: (observationId: string) => void; +}) { + const usageText = observationUsageText(props.node.observation); + const childAncestorLevels = props.isLastSibling + ? props.ancestorLevels + : [...props.ancestorLevels, props.level]; + return ( + <> + 0} + isLastSibling={props.isLastSibling} + level={props.level} + tone={props.node.observation.kind} + title={traceObservationLabel(props.node.observation)} + subtitle={ + usageText.length > 0 + ? `${formatDuration(props.node.observation.durationMs)} · ${usageText}` + : formatDuration(props.node.observation.durationMs) + } + onSelect={() => props.onSelect(props.node.observation.id)} + /> + {props.node.children.map((child, index) => ( + + ))} + + ); +} + function TraceDetailPane(props: { trace: StudioTrace; turns: Array<{ turn: number; observations: TraceObservationItem[]; durationMs?: number }>; @@ -698,6 +756,34 @@ function traceTurns(trace: StudioTrace): Array<{ })); } +type TraceObservationNode = { + observation: TraceObservationItem; + children: TraceObservationNode[]; +}; + +function traceObservationTree(observations: TraceObservationItem[]): TraceObservationNode[] { + const nodes = new Map(); + for (const observation of observations) { + nodes.set(observation.id, { observation, children: [] }); + } + + const roots: TraceObservationNode[] = []; + for (const observation of observations) { + const node = nodes.get(observation.id); + if (node === undefined) { + continue; + } + const parentId = observation.parentObservationId; + const parent = parentId === undefined ? undefined : nodes.get(parentId); + if (parent === undefined) { + roots.push(node); + } else { + parent.children.push(node); + } + } + return roots; +} + function selectedTraceDetail( trace: StudioTrace, turns: Array<{ turn: number; observations: TraceObservationItem[]; durationMs?: number }>, @@ -762,6 +848,7 @@ function selectedTraceDetail( error: observation.error, metadata: { status: observation.status, + parentObservationId: observation.parentObservationId ?? null, turn: observation.turn, startedAt: observation.startedAt, endedAt: observation.endedAt ?? null, @@ -785,6 +872,9 @@ function selectedTraceDetail( } function traceObservationLabel(observation: TraceObservationItem): string { + if (observation.kind === "agent") { + return observation.name; + } return observation.kind === "tool" ? `tool.${observation.name}` : observation.name; } diff --git a/packages/tools/studio/test/runner.test.ts b/packages/tools/studio/test/runner.test.ts index cf5975b7..c8e25655 100644 --- a/packages/tools/studio/test/runner.test.ts +++ b/packages/tools/studio/test/runner.test.ts @@ -1400,6 +1400,155 @@ describe("Anvia studio", () => { }); }); + it("persists streaming subagent activity in tool transcript entries", async () => { + const parentModel = new StreamingQueueModel([ + [ + { + type: "tool_call", + toolCall: AssistantContent.toolCall("call_child", "ask_child", { prompt: "inspect" }), + }, + ], + [{ type: "text_delta", delta: "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) + .name("Child Agent") + .tool(addTool) + .defaultMaxTurns(2) + .build(); + const parentAgent = new AgentBuilder("parent", parentModel) + .tool(childAgent.asTool({ name: "ask_child", stream: true })) + .defaultMaxTurns(2) + .build(); + const runner = new Studio([parentAgent]); + + const created = await runner.fetch( + new Request("http://runner.test/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentId: "parent" }), + }), + ); + const session = (await created.json()) as { id: string }; + + const res = await runner.fetch( + new Request("http://runner.test/agents/parent/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "delegate", sessionId: session.id, stream: true }), + }), + ); + + expect(res.status).toBe(200); + const events = await readJsonl(res); + expect(events).toContainEqual(expect.objectContaining({ type: "agent_tool_event" })); + + const loaded = await runner.fetch(new Request(`http://runner.test/sessions/${session.id}`)); + await expect(loaded.json()).resolves.toMatchObject({ + transcript: [ + { kind: "message", role: "user", text: "delegate" }, + { + kind: "tool", + toolName: "ask_child", + result: "7", + childEvents: [ + { + kind: "tool", + agentId: "child", + agentName: "Child Agent", + toolName: "add", + result: "7", + }, + { + kind: "message", + agentId: "child", + agentName: "Child Agent", + text: "7", + }, + ], + }, + { kind: "message", role: "assistant", text: "done" }, + ], + }); + + const traces = (await ( + await runner.fetch(new Request(`http://runner.test/sessions/${session.id}/traces`)) + ).json()) as { traces: Array<{ id: string }> }; + const trace = await runner.fetch( + new Request(`http://runner.test/traces/${traces.traces[0]?.id}`), + ); + const traceBody = (await trace.json()) as { + observations: Array<{ + id: string; + parentObservationId?: string; + kind: string; + name: string; + status: string; + output?: unknown; + metadata?: Record; + }>; + }; + expect(traceBody).toMatchObject({ + observations: [ + { kind: "generation", name: "model.turn.1", status: "success" }, + { kind: "tool", name: "ask_child", status: "success", output: 7 }, + { + kind: "agent", + name: "Child_Agent.run", + status: "success", + metadata: expect.objectContaining({ + source: "agent_tool_event", + childAgentId: "child", + parentToolName: "ask_child", + }), + }, + { + kind: "generation", + name: "Child_Agent.model.turn.1", + status: "success", + metadata: expect.objectContaining({ + source: "agent_tool_event", + childAgentId: "child", + parentToolName: "ask_child", + }), + }, + { + kind: "tool", + name: "Child_Agent.add", + status: "success", + output: 7, + metadata: expect.objectContaining({ + source: "agent_tool_event", + childAgentId: "child", + parentToolName: "ask_child", + }), + }, + { kind: "generation", name: "Child_Agent.model.turn.2", status: "success" }, + { kind: "generation", name: "model.turn.2", status: "success" }, + ], + }); + + const parentToolObservation = traceBody.observations.find( + (observation) => observation.kind === "tool" && observation.name === "ask_child", + ); + const childAgentObservation = traceBody.observations.find( + (observation) => observation.kind === "agent" && observation.name === "Child_Agent.run", + ); + const childToolObservation = traceBody.observations.find( + (observation) => observation.kind === "tool" && observation.name === "Child_Agent.add", + ); + expect(childAgentObservation?.parentObservationId).toBe(parentToolObservation?.id); + expect(childToolObservation?.parentObservationId).toBe(childAgentObservation?.id); + }); + it("validates session run requests", async () => { const agent = new AgentBuilder("support", new QueueModel([])).build(); const runner = new Studio([agent]); From b024159062f64f9034f8851361d320416bcd028f Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Thu, 7 May 2026 14:59:26 +0700 Subject: [PATCH 09/89] chore: bump wrapper dependencies --- packages/observability/langfuse/package.json | 4 +- packages/providers/anthropic/package.json | 4 +- packages/tools/studio/package.json | 8 +- pnpm-lock.yaml | 755 +++++++++++++++++-- 4 files changed, 692 insertions(+), 79 deletions(-) diff --git a/packages/observability/langfuse/package.json b/packages/observability/langfuse/package.json index e06bfef0..a3132473 100644 --- a/packages/observability/langfuse/package.json +++ b/packages/observability/langfuse/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/langfuse", - "version": "0.1.2", + "version": "0.1.3", "description": "Langfuse tracing adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -26,7 +26,7 @@ "@anvia/core": "workspace:*", "@langfuse/otel": "^5.3.0", "@langfuse/tracing": "^5.3.0", - "@opentelemetry/sdk-node": "^0.216.0" + "@opentelemetry/sdk-node": "^0.217.0" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/packages/providers/anthropic/package.json b/packages/providers/anthropic/package.json index b543cde5..26741930 100644 --- a/packages/providers/anthropic/package.json +++ b/packages/providers/anthropic/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/anthropic", - "version": "0.1.2", + "version": "0.1.3", "description": "Anthropic provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -23,7 +23,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@anthropic-ai/sdk": "^0.93.0", + "@anthropic-ai/sdk": "^0.95.0", "@anvia/core": "workspace:*" }, "devDependencies": { diff --git a/packages/tools/studio/package.json b/packages/tools/studio/package.json index 5f116230..92368274 100644 --- a/packages/tools/studio/package.json +++ b/packages/tools/studio/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/studio", - "version": "0.1.2", + "version": "0.1.3", "description": "Studio UI and HTTP runtime for Anvia agents.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -33,10 +33,10 @@ "@radix-ui/react-slot": "^1.2.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "hono": "^4.12.16", + "hono": "^4.12.18", "lucide-react": "^1.14.0", - "react": "^19.2.5", - "react-dom": "^19.2.5", + "react": "^19.2.6", + "react-dom": "^19.2.6", "react-markdown": "^10.1.0", "tailwind-merge": "^3.5.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01324f8f..50f87146 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.8 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) apps/docs: dependencies: @@ -236,7 +236,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.8 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.4)) packages/embeddings/fastembed: dependencies: @@ -289,13 +289,13 @@ importers: version: link:../../core '@langfuse/otel': specifier: ^5.3.0 - version: 5.3.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.216.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1)) + version: 5.3.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1)) '@langfuse/tracing': specifier: ^5.3.0 version: 5.3.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-node': - specifier: ^0.216.0 - version: 0.216.0(@opentelemetry/api@1.9.1) + specifier: ^0.217.0 + version: 0.217.0(@opentelemetry/api@1.9.1) devDependencies: '@types/node': specifier: ^24.9.1 @@ -335,8 +335,8 @@ importers: packages/providers/anthropic: dependencies: '@anthropic-ai/sdk': - specifier: ^0.93.0 - version: 0.93.0(zod@4.4.3) + specifier: ^0.95.0 + version: 0.95.0(zod@4.4.3) '@anvia/core': specifier: workspace:* version: link:../../core @@ -427,25 +427,25 @@ importers: version: link:../../core '@hono/node-server': specifier: ^2.0.1 - version: 2.0.1(hono@4.12.16) + version: 2.0.1(hono@4.12.18) '@radix-ui/react-alert-dialog': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-dialog': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-scroll-area': specifier: ^1.2.10 - version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-select': specifier: ^2.2.6 - version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-separator': specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-slot': specifier: ^1.2.4 - version: 1.2.4(@types/react@19.2.14)(react@19.2.5) + version: 1.2.4(@types/react@19.2.14)(react@19.2.6) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -453,20 +453,20 @@ importers: specifier: ^2.1.1 version: 2.1.1 hono: - specifier: ^4.12.16 - version: 4.12.16 + specifier: ^4.12.18 + version: 4.12.18 lucide-react: specifier: ^1.14.0 - version: 1.14.0(react@19.2.5) + version: 1.14.0(react@19.2.6) react: - specifier: ^19.2.5 - version: 19.2.5 + specifier: ^19.2.6 + version: 19.2.6 react-dom: - specifier: ^19.2.5 - version: 19.2.5(react@19.2.5) + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.14)(react@19.2.5) + version: 10.1.0(@types/react@19.2.14)(react@19.2.6) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -586,8 +586,8 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} - '@anthropic-ai/sdk@0.93.0': - resolution: {integrity: sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==} + '@anthropic-ai/sdk@0.95.0': + resolution: {integrity: sha512-7It2B76OFJH9jC/a0TicXFMq0ZZM25ei+i/mK7JnsE1Ibmo0Yfkqm+DXOHeU/ZxxKwLLGPP6qaAvKmQmgV6XhA==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1746,6 +1746,10 @@ packages: resolution: {integrity: sha512-KmGTgvxTJ0J01d4mOeX1wMV5NUTNf9HebIuOOGDfIn0a/IrnXIQbOnlylDyl9tkDv4h0DUpdI/GqCdLzfTkUXg==} engines: {node: '>=8.0.0'} + '@opentelemetry/api-logs@0.217.0': + resolution: {integrity: sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -1756,6 +1760,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.9.0 + '@opentelemetry/configuration@0.217.0': + resolution: {integrity: sha512-xCtrYOhBqdy6ZOMfe0Oa73ZKF+2LMhoOv4L5vmwAHVvOXUg+V3fvKuEIr9ZyD0Ow+vxllEjWO6PV1wd0DOtyvw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/context-async-hooks@2.7.1': resolution: {integrity: sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -1774,60 +1784,120 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-grpc@0.217.0': + resolution: {integrity: sha512-vC5S0Dc+noxD86CVtNu1+awCHPA5Kewi1Sg23ps+9lh4YifwsKXh3pe4XTNEKtUJiAcjpJ5dqStGakLbrSE+YQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-http@0.216.0': resolution: {integrity: sha512-8SUzQY/aExKkz6Ab3vOf6gu690Xk4wHH90dGwXinejQzazn5HCIRR7yPVU/2fEuiZ73R92MU4qI3djHfYP7NJg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-http@0.217.0': + resolution: {integrity: sha512-KfLAdt1uilVE+3FxbgVnp2ZrzqbIawzcesnRoi+Kh9ckB5Ld5D8btUgoBvwTbdmuNx1j6b132Wsh72azq+pPNQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-proto@0.216.0': resolution: {integrity: sha512-fjnNDdsoG98yIcv4yCaw07+9aZeh28gyq1YPXDb0yBksaMWCMR11VGDKANd6CJHdgFloWv9G12x95symD7fq9g==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-proto@0.217.0': + resolution: {integrity: sha512-Se0GG/ZO24mQTlQj7zprR4pNI0nKe4lPDPBsuJmi6508b9TlZEuUd3EfyuHk6oJxzL7fGyDFYAbxNigQvRP2ZQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-grpc@0.216.0': resolution: {integrity: sha512-62ZAduALHuMucuBpNGFhdxFJZ5IQafLW17UE0nVvPVuem3zNslLR0H+4R1xraU07/HCL11AbuicSXlqUkdkotA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-grpc@0.217.0': + resolution: {integrity: sha512-0GpJKnCoVaVA1rKBMVPHziznfOQlXgH72S9ktjBAF1AnAVPzX7vVEBGrhwiSxxHDAiefXk+J8znApsMb/K6Z3w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-http@0.216.0': resolution: {integrity: sha512-/4VRxjy3spitqFuSkAt9qNwICiDB5T3zqLr+DYd50O7HMMBgWAf9tAL8q98eTVbzwRyRIxsz5Kq1+U5xEyN6gA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-http@0.217.0': + resolution: {integrity: sha512-1zkMzzhiNJdVmLxuwkltqWGw4fOOam47bqRxmuQNjyKJe/9NmY5cIrZ4kiQV7sVGxoOgT0ZvGUfLcjvtpC/b9Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-proto@0.216.0': resolution: {integrity: sha512-N7GCCXbw/le32/MrVL4Oj/FU9emFfHEHyGwubpcZLOtcuhUtFFAZWzPKJL1Etm0iNo37JA2JvG4W+5zNe/1NKQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-proto@0.217.0': + resolution: {integrity: sha512-nfxt/KxVGFkjkO/M+58y1ugHu/dwPtxG4eYq0KApcQ7xk5CHzhdn+IuLZfDSvNDrJ3Uy5q++Fj/wbK7i8yryfQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-prometheus@0.216.0': resolution: {integrity: sha512-faltPHeLPyHCGm0MuSrQxv8UXvckZbWo9hUHNwGYiDPF687gaVj5UN24vHlz7VeADnBb6UXTfuw1t4MK4xmcrA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-prometheus@0.217.0': + resolution: {integrity: sha512-U9MCXxJu0sBCh5aEkylYRR4xVIL8D1CW6dGwvYXbfFr0qveSorfD0XJchCAWoW6QfAAIcY/yxjf4Dj8OgkHBPw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-grpc@0.216.0': resolution: {integrity: sha512-XTU//H/Gn+8F9LOWdOC9uyjgcIq/v7T+8aYMr+orBaOpzds05MpFD0jJASZ0mWimt0JWJTuQ8eto/k5/jvtwmw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-grpc@0.217.0': + resolution: {integrity: sha512-fPZs2fw7veLH3pEKu8vSepUa2fQpAE2P7al6qU10aH9GrEJJ8YaPgsd5xON7by5rbcEVS71FOU2aWyK6nzB7VQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-http@0.216.0': resolution: {integrity: sha512-DhWjvj0PUPFwFnhOEivpum8sJzj6FTuyx88zff+oHVLUhfd6cLyw4AIai/F4j0PZqYZBFuMT/OTMUd9wdXnBEQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-http@0.217.0': + resolution: {integrity: sha512-38YQoqtYjglz2GV94LGUN/djLvxtvGIQO68o6qAFPVshjmwSdX1F2i0c7vn3lEl1L5B/YqjB/bgKXaVx7KO+RQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-proto@0.216.0': resolution: {integrity: sha512-MlUFZlQCm2hWHADU1GntUIziy3A4QcqM9uSZfbqeEolZWk1QdbPQjO2t4LTE4QAA1niEXcYZC2SC23i/gVk8Pw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-proto@0.217.0': + resolution: {integrity: sha512-nPV8gKHUiSuTZpQcnZU3/pBlK7crSyEGpZuh5MtWySB0vv6NNG0QvvfKitQt+Fc2Mc6qfyU54KlZcurwoTbrVg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-zipkin@2.7.1': resolution: {integrity: sha512-mfsD9bKAxcKrh5+y08TPodvClBO0CznBE3p79YAGnO81WI4LrdsGA65T53e4iTSbCalW4WaUpkbeJcbpyIUHfg==} engines: {node: ^18.19.0 || >=20.6.0} @@ -1840,24 +1910,48 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation@0.217.0': + resolution: {integrity: sha512-24ucQMjz7Y34Kw3trbxL2ZrssbtgWnR+Clpaa+YdeWuuyH3Cvk23Q03PcQvqiZrDvt8AmQmjgg9v6Y9PHoxG7w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.216.0': resolution: {integrity: sha512-sSnvb5f+FYa4mfYxj03rmmUh+aDwo3jok62dgIWUDw8ZCUPzEbgtv/YhZyKUSlKNNey7Uc5xmJgmtTLLIV6UDQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.217.0': + resolution: {integrity: sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-grpc-exporter-base@0.216.0': resolution: {integrity: sha512-CrW+2cmZR6mcgtsncWK4WmAn7SC9RwVSHMLbi0IfOXfOYXBaSVKtCCkKYJQWa31VUg7aJFJSpD0n4ISVUN1jdQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-grpc-exporter-base@0.217.0': + resolution: {integrity: sha512-7RTAdZuOsCDnsyqTCG4+bDzrfnsWdzkRs7z0AVi/V3tEQx0oKeyc+OuRWYxnRsmaJXgxcmB8vb/lfxn58Dj6Ag==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-transformer@0.216.0': resolution: {integrity: sha512-g4Rb6sAsxQAo11eDjixfKxelruBsQFdJ8Wo23FCj7D6OXbidgXMu2xaRSYs4RdlomzAXSJuc86RcS3xmE8A6uA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-transformer@0.217.0': + resolution: {integrity: sha512-MKK8UHKFUOGAvbZRWh90MhwHG+Fxm6OROBdjKPCF+HQobjuJ/Kuf8Chs8CR45X1aqotxrMj7OxTdsXe8sXuGVA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/propagator-b3@2.7.1': resolution: {integrity: sha512-RJid6E2CKyeGfKBzXKF21ejabGMHypFkPAh3qZ+NvI+SGjuIye79t3PmiqcDgtRzdKH6ynXzbfslQ8DfpRUg2A==} engines: {node: ^18.19.0 || >=20.6.0} @@ -1882,6 +1976,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.4.0 <1.10.0' + '@opentelemetry/sdk-logs@0.217.0': + resolution: {integrity: sha512-BB+PcHItcZDL63dPMW+mJvwN9rk37wuIDjRxbVlg6pPDvDR/7GL7UJHbGsllgoggOoTimsKgENaWPoGch/oE1A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + '@opentelemetry/sdk-metrics@2.7.1': resolution: {integrity: sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -1894,6 +1994,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-node@0.217.0': + resolution: {integrity: sha512-K/60pSv42+NQiZKy1pAH18nYDkxltsDV4O3SJ233J0E9raU1ksyL9gsKuS8p30bYBb4AMPCfDuutHQaHYpcv0Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.7.1': resolution: {integrity: sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -2667,6 +2773,9 @@ packages: '@speed-highlight/core@1.2.15': resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3664,6 +3773,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -3973,8 +4085,8 @@ packages: resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==} engines: {node: '>=16.9.0'} - hono@4.12.16: - resolution: {integrity: sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==} + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} engines: {node: '>=16.9.0'} html-url-attributes@3.0.1: @@ -4837,6 +4949,11 @@ packages: peerDependencies: react: ^19.2.5 + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -4883,6 +5000,10 @@ packages: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -5118,6 +5239,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5646,9 +5770,10 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': + '@anthropic-ai/sdk@0.95.0(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 optionalDependencies: zod: 4.4.3 @@ -6165,6 +6290,12 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + '@floating-ui/react-dom@2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@floating-ui/utils@0.2.11': {} '@fumadocs/tailwind@0.0.5(@tailwindcss/oxide@4.2.4)(tailwindcss@4.2.4)': @@ -6201,9 +6332,9 @@ snapshots: dependencies: hono: 4.12.15 - '@hono/node-server@2.0.1(hono@4.12.16)': + '@hono/node-server@2.0.1(hono@4.12.18)': dependencies: - hono: 4.12.16 + hono: 4.12.18 '@huggingface/hub@2.11.0': dependencies: @@ -6355,12 +6486,12 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 - '@langfuse/otel@5.3.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.216.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))': + '@langfuse/otel@5.3.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))': dependencies: '@langfuse/core': 5.3.0(@opentelemetry/api@1.9.1) '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-http': 0.216.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.217.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) '@langfuse/tracing@5.3.0(@opentelemetry/api@1.9.1)': @@ -6527,6 +6658,10 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs@0.217.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api@1.9.1': {} '@opentelemetry/configuration@0.216.0(@opentelemetry/api@1.9.1)': @@ -6535,6 +6670,12 @@ snapshots: '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) yaml: 2.8.4 + '@opentelemetry/configuration@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + yaml: 2.8.4 + '@opentelemetry/context-async-hooks@2.7.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -6554,6 +6695,16 @@ snapshots: '@opentelemetry/otlp-transformer': 0.216.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-logs': 0.216.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http@0.216.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -6563,6 +6714,15 @@ snapshots: '@opentelemetry/otlp-transformer': 0.216.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-logs': 0.216.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.217.0 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto@0.216.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -6574,6 +6734,17 @@ snapshots: '@opentelemetry/sdk-logs': 0.216.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.217.0 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc@0.216.0(@opentelemetry/api@1.9.1)': dependencies: '@grpc/grpc-js': 1.14.3 @@ -6586,6 +6757,18 @@ snapshots: '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http@0.216.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -6595,6 +6778,15 @@ snapshots: '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto@0.216.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -6605,6 +6797,16 @@ snapshots: '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus@0.216.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -6613,6 +6815,14 @@ snapshots: '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/exporter-prometheus@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/exporter-trace-otlp-grpc@0.216.0(@opentelemetry/api@1.9.1)': dependencies: '@grpc/grpc-js': 1.14.3 @@ -6624,6 +6834,17 @@ snapshots: '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http@0.216.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -6633,6 +6854,15 @@ snapshots: '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto@0.216.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -6642,6 +6872,15 @@ snapshots: '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin@2.7.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -6659,12 +6898,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@opentelemetry/instrumentation@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.217.0 + import-in-the-middle: 3.0.1 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + '@opentelemetry/otlp-exporter-base@0.216.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/otlp-transformer': 0.216.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base@0.216.0(@opentelemetry/api@1.9.1)': dependencies: '@grpc/grpc-js': 1.14.3 @@ -6673,6 +6927,14 @@ snapshots: '@opentelemetry/otlp-exporter-base': 0.216.0(@opentelemetry/api@1.9.1) '@opentelemetry/otlp-transformer': 0.216.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer@0.216.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -6684,6 +6946,17 @@ snapshots: '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) protobufjs: 8.0.1 + '@opentelemetry/otlp-transformer@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.217.0 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + protobufjs: 8.0.1 + '@opentelemetry/propagator-b3@2.7.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -6708,6 +6981,14 @@ snapshots: '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sdk-logs@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.217.0 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -6745,6 +7026,37 @@ snapshots: transitivePeerDependencies: - supports-color + '@opentelemetry/sdk-node@0.217.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.217.0 + '@opentelemetry/configuration': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + '@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -6829,16 +7141,16 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -6852,6 +7164,15 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -6880,18 +7201,42 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)': dependencies: react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.5)': dependencies: react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -6914,12 +7259,40 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -6933,12 +7306,31 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.5)': dependencies: react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) @@ -6950,6 +7342,17 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) @@ -6957,6 +7360,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -7020,6 +7430,24 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/rect': 1.1.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -7030,6 +7458,16 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) @@ -7040,6 +7478,16 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) @@ -7049,11 +7497,20 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -7092,40 +7549,57 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.5) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) aria-hidden: 1.2.6 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) - '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -7137,6 +7611,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) @@ -7144,6 +7625,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -7166,6 +7654,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5) @@ -7174,6 +7668,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) @@ -7181,6 +7683,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) @@ -7188,18 +7697,37 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: react: 19.2.5 optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/rect': 1.1.1 @@ -7207,6 +7735,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) @@ -7214,6 +7749,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -7223,6 +7765,15 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/rect@1.1.1': {} '@rolldown/binding-android-arm64@1.0.0-rc.17': @@ -7401,6 +7952,8 @@ snapshots: '@speed-highlight/core@1.2.15': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@tailwindcss/node@4.2.4': @@ -8492,6 +9045,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fastembed@2.1.0: @@ -8873,7 +9428,7 @@ snapshots: hono@4.12.15: {} - hono@4.12.16: {} + hono@4.12.18: {} html-url-attributes@3.0.1: {} @@ -9122,6 +9677,10 @@ snapshots: dependencies: react: 19.2.5 + lucide-react@1.14.0(react@19.2.6): + dependencies: + react: 19.2.6 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -9952,7 +10511,12 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 - react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5): + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.6): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 @@ -9961,7 +10525,7 @@ snapshots: hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 - react: 19.2.5 + react: 19.2.6 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -9983,6 +10547,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5): dependencies: react: 19.2.5 @@ -9994,6 +10566,17 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.6) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.6) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.6) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): dependencies: get-nonce: 1.0.1 @@ -10002,8 +10585,18 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + get-nonce: 1.0.1 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + react@19.2.5: {} + react@19.2.6: {} + readdirp@3.6.0: dependencies: picomatch: 2.3.2 @@ -10374,6 +10967,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@4.1.0: {} @@ -10629,6 +11227,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5): dependencies: detect-node-es: 1.1.0 @@ -10637,6 +11242,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + use-sync-external-store@1.6.0(react@19.2.5): dependencies: react: 19.2.5 From eaafe0008a20550f648a31b843ffa785a970713b Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Fri, 8 May 2026 09:20:13 +0700 Subject: [PATCH 10/89] chore: bump provider dependencies --- packages/providers/anthropic/package.json | 4 +-- packages/providers/gemini/package.json | 4 +-- packages/providers/openai/package.json | 4 +-- pnpm-lock.yaml | 30 +++++++++++------------ 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/providers/anthropic/package.json b/packages/providers/anthropic/package.json index 26741930..866c623d 100644 --- a/packages/providers/anthropic/package.json +++ b/packages/providers/anthropic/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/anthropic", - "version": "0.1.3", + "version": "0.1.4", "description": "Anthropic provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -23,7 +23,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@anthropic-ai/sdk": "^0.95.0", + "@anthropic-ai/sdk": "^0.95.1", "@anvia/core": "workspace:*" }, "devDependencies": { diff --git a/packages/providers/gemini/package.json b/packages/providers/gemini/package.json index 8570083d..294bea0e 100644 --- a/packages/providers/gemini/package.json +++ b/packages/providers/gemini/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/gemini", - "version": "0.1.2", + "version": "0.1.3", "description": "Gemini provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -24,7 +24,7 @@ }, "dependencies": { "@anvia/core": "workspace:*", - "@google/genai": "^1.52.0" + "@google/genai": "^2.0.0" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/packages/providers/openai/package.json b/packages/providers/openai/package.json index 817291e1..35822787 100644 --- a/packages/providers/openai/package.json +++ b/packages/providers/openai/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/openai", - "version": "0.1.2", + "version": "0.1.4", "description": "OpenAI provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -24,7 +24,7 @@ }, "dependencies": { "@anvia/core": "workspace:*", - "openai": "^6.36.0" + "openai": "^6.37.0" }, "devDependencies": { "@types/node": "^24.9.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50f87146..f9152868 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -335,8 +335,8 @@ importers: packages/providers/anthropic: dependencies: '@anthropic-ai/sdk': - specifier: ^0.95.0 - version: 0.95.0(zod@4.4.3) + specifier: ^0.95.1 + version: 0.95.1(zod@4.4.3) '@anvia/core': specifier: workspace:* version: link:../../core @@ -360,8 +360,8 @@ importers: specifier: workspace:* version: link:../../core '@google/genai': - specifier: ^1.52.0 - version: 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) + specifier: ^2.0.0 + version: 2.0.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) devDependencies: '@types/node': specifier: ^24.9.1 @@ -404,8 +404,8 @@ importers: specifier: workspace:* version: link:../../core openai: - specifier: ^6.36.0 - version: 6.36.0(ws@8.20.0)(zod@4.4.3) + specifier: ^6.37.0 + version: 6.37.0(ws@8.20.0)(zod@4.4.3) devDependencies: '@types/node': specifier: ^24.9.1 @@ -586,8 +586,8 @@ packages: resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} engines: {node: '>=18'} - '@anthropic-ai/sdk@0.95.0': - resolution: {integrity: sha512-7It2B76OFJH9jC/a0TicXFMq0ZZM25ei+i/mK7JnsE1Ibmo0Yfkqm+DXOHeU/ZxxKwLLGPP6qaAvKmQmgV6XhA==} + '@anthropic-ai/sdk@0.95.1': + resolution: {integrity: sha512-OO9AF7hmAoU492c/mD7Q2cPqI2WNAj7rAPHlawgBeUgpwiboLRiDs+grsErGWeHHP9ZRWfzq2OVrODTt8aITVg==} hasBin: true peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1382,8 +1382,8 @@ packages: tailwindcss: optional: true - '@google/genai@1.52.0': - resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + '@google/genai@2.0.0': + resolution: {integrity: sha512-6XpO+YbGutXkm5QgR7NZktISxSz0dw3pSs9NtCUQwvhJc1eyA3KhdKhE/0Uaxp3a6eul3LC0SKau1bXymjOKUg==} engines: {node: '>=20.0.0'} peerDependencies: '@modelcontextprotocol/sdk': ^1.25.2 @@ -4741,8 +4741,8 @@ packages: onnxruntime-web@1.26.0-dev.20260416-b7804b056c: resolution: {integrity: sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==} - openai@6.36.0: - resolution: {integrity: sha512-Has2YbIusMq9wQEierFsgf9c783dy1y9arX459LmphNacEkkM5yxi2RIyXP0LmkOroQyW19iTwALHL8Yf26UKA==} + openai@6.37.0: + resolution: {integrity: sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -5770,7 +5770,7 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - '@anthropic-ai/sdk@0.95.0(zod@4.4.3)': + '@anthropic-ai/sdk@0.95.1(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 standardwebhooks: 1.0.0 @@ -6303,7 +6303,7 @@ snapshots: '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': + '@google/genai@2.0.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.6.2 p-retry: 4.6.2 @@ -10310,7 +10310,7 @@ snapshots: platform: 1.3.6 protobufjs: 7.5.6 - openai@6.36.0(ws@8.20.0)(zod@4.4.3): + openai@6.37.0(ws@8.20.0)(zod@4.4.3): optionalDependencies: ws: 8.20.0 zod: 4.4.3 From 59616218a4d71904332007866e625f6bffd4551e Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Fri, 8 May 2026 10:07:48 +0700 Subject: [PATCH 11/89] docs: complete API reference coverage --- .../docs/guides/retrieval/embed-documents.mdx | 6 +- .../content/docs/guides/retrieval/loaders.mdx | 109 +++++++++++ .../content/docs/guides/retrieval/meta.json | 1 + .../content/docs/reference/api-coverage.mdx | 148 +++++---------- .../content/docs/reference/core/agent.mdx | 8 +- .../content/docs/reference/core/memory.mdx | 152 ++++++++++++++++ .../content/docs/reference/core/meta.json | 1 + .../docs/reference/core/observability.mdx | 6 +- apps/docs/content/docs/reference/index.mdx | 4 +- .../docs/reference/studio/sessions.mdx | 41 ++++- apps/docs/package.json | 3 +- .../docs/scripts/check-reference-coverage.mjs | 171 ++++++++++++++++++ package.json | 1 + 13 files changed, 533 insertions(+), 118 deletions(-) create mode 100644 apps/docs/content/docs/guides/retrieval/loaders.mdx create mode 100644 apps/docs/content/docs/reference/core/memory.mdx create mode 100644 apps/docs/scripts/check-reference-coverage.mjs diff --git a/apps/docs/content/docs/guides/retrieval/embed-documents.mdx b/apps/docs/content/docs/guides/retrieval/embed-documents.mdx index 2b0bb101..e8410713 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,7 +50,7 @@ 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 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..968835e0 --- /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"; + +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/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 db83fd1e..cfeacf87 100644 --- a/apps/docs/content/docs/reference/core/agent.mdx +++ b/apps/docs/content/docs/reference/core/agent.mdx @@ -178,8 +178,10 @@ interface AgentEventStore { clear?(runId: string): Promise; } +type AgentEventStoreInclude = "all" | "agent_tool_events"; + type AgentEventStoreOptions = { - include?: "all" | "agent_tool_events"; + include?: AgentEventStoreInclude; }; type AgentEventAppendInput = { @@ -192,6 +194,10 @@ type AgentEventAppendInput = { internalCallId?: string; event: unknown; }; + +type AgentEventRecord = AgentEventAppendInput & { + createdAt?: Date; +}; ``` Purpose: persist runtime stream events for replay, debugging, or local inspection. 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 e4b98f66..c6f2b623 100644 --- a/apps/docs/content/docs/reference/core/meta.json +++ b/apps/docs/content/docs/reference/core/meta.json @@ -17,6 +17,7 @@ "embeddings", "model-listing", "vector-store", + "memory", "mcp", "observability", "skills", 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/index.mdx b/apps/docs/content/docs/reference/index.mdx index 03a3a320..8af3bdff 100644 --- a/apps/docs/content/docs/reference/index.mdx +++ b/apps/docs/content/docs/reference/index.mdx @@ -22,13 +22,14 @@ 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/openai` | `@anvia/openai` | | `@anvia/anthropic` | `@anvia/anthropic` | | `@anvia/gemini` | `@anvia/gemini` | @@ -37,6 +38,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` | diff --git a/apps/docs/content/docs/reference/studio/sessions.mdx b/apps/docs/content/docs/reference/studio/sessions.mdx index f9d71b28..32d08c21 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,11 +111,15 @@ 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; }; ``` @@ -103,18 +132,20 @@ 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; 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 transcript messages for Studio-backed sessions. Notable errors: persistence failures should throw or reject. diff --git a/apps/docs/package.json b/apps/docs/package.json index caf9c027..5e372606 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -9,7 +9,8 @@ "deploy": "pnpm run build && wrangler deploy", "dev": "vite dev", "preview": "vite preview", - "typecheck": "fumadocs-mdx && tsr generate && tsc --noEmit" + "reference-check": "node scripts/check-reference-coverage.mjs", + "typecheck": "pnpm run reference-check && fumadocs-mdx && tsr generate && tsc --noEmit" }, "dependencies": { "@tanstack/react-router": "^1.168.26", diff --git a/apps/docs/scripts/check-reference-coverage.mjs b/apps/docs/scripts/check-reference-coverage.mjs new file mode 100644 index 00000000..3f2630b3 --- /dev/null +++ b/apps/docs/scripts/check-reference-coverage.mjs @@ -0,0 +1,171 @@ +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/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"], +]); + +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/package.json b/package.json index 2484799f..2a6fd94d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "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", From fe9b8e2397621fced4768dd23702e0ac7c61cef4 Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Fri, 8 May 2026 10:28:20 +0700 Subject: [PATCH 12/89] docs: add framework integration guides --- .../content/docs/frameworks/hono/01-prep.mdx | 60 ++++++ .../docs/frameworks/hono/02-setup-anvia.mdx | 80 ++++++++ .../docs/frameworks/hono/03-route-handler.mdx | 59 ++++++ .../docs/frameworks/hono/04-streaming.mdx | 58 ++++++ .../frameworks/hono/05-tools-and-context.mdx | 98 +++++++++ .../docs/frameworks/hono/06-persistence.mdx | 70 +++++++ .../docs/frameworks/hono/07-deploy.mdx | 55 +++++ .../frameworks/hono/08-troubleshooting.mdx | 71 +++++++ .../frameworks/hono/09-human-in-the-loop.mdx | 188 ++++++++++++++++++ .../docs/frameworks/hono/10-setup-tests.mdx | 94 +++++++++ .../content/docs/frameworks/hono/meta.json | 17 ++ apps/docs/content/docs/frameworks/index.mdx | 46 +++++ apps/docs/content/docs/frameworks/meta.json | 7 + .../docs/frameworks/nextjs/01-prep.mdx | 61 ++++++ .../docs/frameworks/nextjs/02-setup-anvia.mdx | 69 +++++++ .../frameworks/nextjs/03-route-handler.mdx | 74 +++++++ .../docs/frameworks/nextjs/04-streaming.mdx | 67 +++++++ .../nextjs/05-tools-and-context.mdx | 81 ++++++++ .../docs/frameworks/nextjs/06-persistence.mdx | 58 ++++++ .../docs/frameworks/nextjs/07-deploy.mdx | 55 +++++ .../frameworks/nextjs/08-troubleshooting.mdx | 54 +++++ .../nextjs/09-human-in-the-loop.mdx | 164 +++++++++++++++ .../docs/frameworks/nextjs/10-setup-tests.mdx | 88 ++++++++ .../content/docs/frameworks/nextjs/meta.json | 17 ++ .../frameworks/tanstack-start/01-prep.mdx | 61 ++++++ .../tanstack-start/02-setup-anvia.mdx | 76 +++++++ .../tanstack-start/03-route-handler.mdx | 74 +++++++ .../tanstack-start/04-streaming.mdx | 72 +++++++ .../tanstack-start/05-tools-and-context.mdx | 88 ++++++++ .../tanstack-start/06-persistence.mdx | 56 ++++++ .../frameworks/tanstack-start/07-deploy.mdx | 46 +++++ .../tanstack-start/08-troubleshooting.mdx | 51 +++++ .../tanstack-start/09-human-in-the-loop.mdx | 143 +++++++++++++ .../tanstack-start/10-setup-tests.mdx | 101 ++++++++++ .../docs/frameworks/tanstack-start/meta.json | 17 ++ .../human-in-the-loop/approval-handlers.mdx | 16 +- .../docs/guides/human-in-the-loop/index.mdx | 4 +- .../human-in-the-loop/tool-approval.mdx | 10 +- apps/docs/content/docs/meta.json | 2 +- apps/docs/src/components/docs-route.tsx | 1 + apps/docs/src/lib/layout.shared.tsx | 1 + 41 files changed, 2498 insertions(+), 12 deletions(-) create mode 100644 apps/docs/content/docs/frameworks/hono/01-prep.mdx create mode 100644 apps/docs/content/docs/frameworks/hono/02-setup-anvia.mdx create mode 100644 apps/docs/content/docs/frameworks/hono/03-route-handler.mdx create mode 100644 apps/docs/content/docs/frameworks/hono/04-streaming.mdx create mode 100644 apps/docs/content/docs/frameworks/hono/05-tools-and-context.mdx create mode 100644 apps/docs/content/docs/frameworks/hono/06-persistence.mdx create mode 100644 apps/docs/content/docs/frameworks/hono/07-deploy.mdx create mode 100644 apps/docs/content/docs/frameworks/hono/08-troubleshooting.mdx create mode 100644 apps/docs/content/docs/frameworks/hono/09-human-in-the-loop.mdx create mode 100644 apps/docs/content/docs/frameworks/hono/10-setup-tests.mdx create mode 100644 apps/docs/content/docs/frameworks/hono/meta.json create mode 100644 apps/docs/content/docs/frameworks/index.mdx create mode 100644 apps/docs/content/docs/frameworks/meta.json create mode 100644 apps/docs/content/docs/frameworks/nextjs/01-prep.mdx create mode 100644 apps/docs/content/docs/frameworks/nextjs/02-setup-anvia.mdx create mode 100644 apps/docs/content/docs/frameworks/nextjs/03-route-handler.mdx create mode 100644 apps/docs/content/docs/frameworks/nextjs/04-streaming.mdx create mode 100644 apps/docs/content/docs/frameworks/nextjs/05-tools-and-context.mdx create mode 100644 apps/docs/content/docs/frameworks/nextjs/06-persistence.mdx create mode 100644 apps/docs/content/docs/frameworks/nextjs/07-deploy.mdx create mode 100644 apps/docs/content/docs/frameworks/nextjs/08-troubleshooting.mdx create mode 100644 apps/docs/content/docs/frameworks/nextjs/09-human-in-the-loop.mdx create mode 100644 apps/docs/content/docs/frameworks/nextjs/10-setup-tests.mdx create mode 100644 apps/docs/content/docs/frameworks/nextjs/meta.json create mode 100644 apps/docs/content/docs/frameworks/tanstack-start/01-prep.mdx create mode 100644 apps/docs/content/docs/frameworks/tanstack-start/02-setup-anvia.mdx create mode 100644 apps/docs/content/docs/frameworks/tanstack-start/03-route-handler.mdx create mode 100644 apps/docs/content/docs/frameworks/tanstack-start/04-streaming.mdx create mode 100644 apps/docs/content/docs/frameworks/tanstack-start/05-tools-and-context.mdx create mode 100644 apps/docs/content/docs/frameworks/tanstack-start/06-persistence.mdx create mode 100644 apps/docs/content/docs/frameworks/tanstack-start/07-deploy.mdx create mode 100644 apps/docs/content/docs/frameworks/tanstack-start/08-troubleshooting.mdx create mode 100644 apps/docs/content/docs/frameworks/tanstack-start/09-human-in-the-loop.mdx create mode 100644 apps/docs/content/docs/frameworks/tanstack-start/10-setup-tests.mdx create mode 100644 apps/docs/content/docs/frameworks/tanstack-start/meta.json 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..bb9f02c7 --- /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 @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..cd5c0954 --- /dev/null +++ b/apps/docs/content/docs/frameworks/hono/04-streaming.mdx @@ -0,0 +1,58 @@ +--- +title: 04 Streaming +description: Stream Anvia run events from a Hono route. +--- + +Hono handlers can return a standard `Response`, so Anvia `readableStream()` can be used directly. + +## 1. Add `/api/support/stream` + +```ts +import { zValidator } from "@hono/zod-validator"; +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 new Response(supportAgent.prompt(message).readableStream(), { + headers: { + "Content-Type": "application/x-ndjson", + "Cache-Control": "no-cache", + }, + }); +}); +``` + +## 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..e9d1e3d4 --- /dev/null +++ b/apps/docs/content/docs/frameworks/hono/08-troubleshooting.mdx @@ -0,0 +1,71 @@ +--- +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 a Web `Response` with the Anvia stream and NDJSON content type: + +```ts +return new Response(agent.prompt(message).readableStream(), { + headers: { "Content-Type": "application/x-ndjson" }, +}); +``` + +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..a81c2c53 --- /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 `readableStream()` with a small `ReadableStream` 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..cfef3c28 --- /dev/null +++ b/apps/docs/content/docs/frameworks/index.mdx @@ -0,0 +1,46 @@ +--- +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. + +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 newline-delimited stream events 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 | +| Hono | [Hono Prep](/docs/frameworks/hono/01-prep) | Plain Hono routes | + +The Anvia runtime shape stays the same across all three: + +```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..710c0212 --- /dev/null +++ b/apps/docs/content/docs/frameworks/meta.json @@ -0,0 +1,7 @@ +{ + "title": "Frameworks", + "description": "App integrations", + "icon": "Blocks", + "root": true, + "pages": ["index", "nextjs", "tanstack-start", "hono"] +} 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..efb2dc04 --- /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 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 `readableStream()` can be returned directly. + +## 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..cc06b4d5 --- /dev/null +++ b/apps/docs/content/docs/frameworks/nextjs/04-streaming.mdx @@ -0,0 +1,67 @@ +--- +title: 04 Streaming +description: Stream Anvia run events from a Next.js route handler. +--- + +Anvia exposes a Web `ReadableStream`, so Next.js route handlers can return it directly. + +## 1. Create `app/api/support/stream/route.ts` + +```ts +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 new Response(supportAgent.prompt(message).readableStream(), { + headers: { + "Content-Type": "application/x-ndjson", + "Cache-Control": "no-cache", + }, + }); +} +``` + +## 2. Consume Events + +```ts +const response = await fetch("/api/support/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "Draft a refund 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 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..1e638aba --- /dev/null +++ b/apps/docs/content/docs/frameworks/nextjs/08-troubleshooting.mdx @@ -0,0 +1,54 @@ +--- +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 new Response(agent.prompt(message).readableStream(), { + headers: { + "Content-Type": "application/x-ndjson", + "Cache-Control": "no-cache", + }, +}); +``` + +## 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..a086dd6c --- /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 `readableStream()` with a small `ReadableStream` 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/tanstack-start/01-prep.mdx b/apps/docs/content/docs/frameworks/tanstack-start/01-prep.mdx new file mode 100644 index 00000000..6f8d2c3e --- /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 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..5275a6bb --- /dev/null +++ b/apps/docs/content/docs/frameworks/tanstack-start/04-streaming.mdx @@ -0,0 +1,72 @@ +--- +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 `Response`. + +## 1. Add `POST` Streaming Handler + +```ts +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 new Response(supportAgent.prompt(message).readableStream(), { + headers: { + "Content-Type": "application/x-ndjson", + "Cache-Control": "no-cache", + }, + }); + }, + }, + }, +}); +``` + +## 2. Consume Stream Lines + +```ts +const response = await fetch("/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. 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..ebe7edc7 --- /dev/null +++ b/apps/docs/content/docs/frameworks/tanstack-start/08-troubleshooting.mdx @@ -0,0 +1,51 @@ +--- +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, return a `Response`, and set NDJSON headers: + +```ts +return new Response(agent.prompt(message).readableStream(), { + headers: { "Content-Type": "application/x-ndjson" }, +}); +``` + +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/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/index.mdx b/apps/docs/content/docs/guides/human-in-the-loop/index.mdx index 461a6928..0eb8809c 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 @@ -47,6 +47,8 @@ Core stores this metadata but does not enforce it. Studio reads it and installs Use a hook when approval is not a property of the tool itself, or when your app already owns the approval workflow. +In this example, `approvalRuntime` is your application object. It is not exported by Anvia; create it with your database, queue, or reviewer UI. See [Approval Runtimes](/docs/guides/human-in-the-loop/approval-handlers). + ```ts const approvalHook = createHook({ async onToolCall({ toolName, args, tool }) { @@ -54,7 +56,7 @@ const approvalHook = createHook({ return tool.run(); } - const approved = await approvals.waitForDecision({ + const approved = await approvalRuntime.waitForDecision({ toolName, args, reason: "Refunds require staff approval.", 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..269851f7 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 @@ -7,6 +7,8 @@ Use `onToolCall(...)` when approval is runtime behavior rather than tool metadat This is the advanced escape hatch. For Studio-managed approval UI, start with [approval settings](/docs/guides/human-in-the-loop/approval-settings). +The examples use `approvalRuntime` as an application-owned object. Do not import it from Anvia. Build it in your app, then call it from the hook. For the runtime shape, read [Approval Runtimes](/docs/guides/human-in-the-loop/approval-handlers). + ## Require Approval for One Tool ```ts @@ -18,7 +20,7 @@ const approvalHook = createHook({ return tool.run(); } - const approved = await approvals.waitForDecision({ + const approved = await approvalRuntime.waitForDecision({ toolName, args, reason: `Review refund request: ${args}`, @@ -67,7 +69,7 @@ const approvalHook = createHook({ return tool.run(); } - const approved = await approvals.waitForDecision({ + const approved = await approvalRuntime.waitForDecision({ toolName, args, reason: `${toolName} requires human review.`, @@ -109,7 +111,7 @@ const approvalHook = createHook({ return tool.run(); } - const approved = await approvals.waitForDecision({ + const approved = await approvalRuntime.waitForDecision({ toolName, args, reason: `Refund amount is $${parsed.amount}.`, @@ -158,7 +160,7 @@ Anvia does not require a timeout. If the awaited approval promise never resolves ```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/meta.json b/apps/docs/content/docs/meta.json index 00c8194f..674b1f66 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", "frameworks", "models", "studio", "reference"] } diff --git a/apps/docs/src/components/docs-route.tsx b/apps/docs/src/components/docs-route.tsx index 6d8bd3c2..0d4e51ef 100644 --- a/apps/docs/src/components/docs-route.tsx +++ b/apps/docs/src/components/docs-route.tsx @@ -12,6 +12,7 @@ import { source } from "@/lib/source"; const docsSections = [ { title: "Docs", href: "/docs/guides" }, + { title: "Frameworks", href: "/docs/frameworks" }, { title: "Models", href: "/docs/models" }, { title: "Studio", href: "/docs/studio/overview" }, { title: "Reference", href: "/docs/reference" }, diff --git a/apps/docs/src/lib/layout.shared.tsx b/apps/docs/src/lib/layout.shared.tsx index e1ef41b8..7092c7c5 100644 --- a/apps/docs/src/lib/layout.shared.tsx +++ b/apps/docs/src/lib/layout.shared.tsx @@ -5,6 +5,7 @@ import { ChevronDown } from "lucide-react"; const githubUrl = "https://github.com/anvia-hq/anvia"; const resourceLinks = [ { text: "Docs", url: "/docs/guides" }, + { text: "Frameworks", url: "/docs/frameworks" }, { text: "Studio", url: "/docs/studio/overview" }, { text: "Reference", url: "/docs/reference" }, { text: "Models", url: "/docs/models" }, From b9b81fcf07faa44d70338095dd5a88ce9d16bc1f Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Fri, 8 May 2026 10:50:56 +0700 Subject: [PATCH 13/89] docs: expand framework guides --- .../docs/frameworks/express/01-prep.mdx | 57 +++++++ .../frameworks/express/02-setup-anvia.mdx | 66 +++++++++ .../frameworks/express/03-route-handler.mdx | 78 ++++++++++ .../docs/frameworks/express/04-streaming.mdx | 78 ++++++++++ .../express/05-tools-and-context.mdx | 91 ++++++++++++ .../frameworks/express/06-persistence.mdx | 49 ++++++ .../docs/frameworks/express/07-deploy.mdx | 45 ++++++ .../frameworks/express/08-troubleshooting.mdx | 48 ++++++ .../express/09-human-in-the-loop.mdx | 140 ++++++++++++++++++ .../frameworks/express/10-setup-tests.mdx | 78 ++++++++++ .../content/docs/frameworks/express/meta.json | 17 +++ .../docs/frameworks/fastify/01-prep.mdx | 57 +++++++ .../frameworks/fastify/02-setup-anvia.mdx | 66 +++++++++ .../frameworks/fastify/03-route-handler.mdx | 66 +++++++++ .../docs/frameworks/fastify/04-streaming.mdx | 65 ++++++++ .../fastify/05-tools-and-context.mdx | 86 +++++++++++ .../frameworks/fastify/06-persistence.mdx | 49 ++++++ .../docs/frameworks/fastify/07-deploy.mdx | 41 +++++ .../frameworks/fastify/08-troubleshooting.mdx | 49 ++++++ .../fastify/09-human-in-the-loop.mdx | 127 ++++++++++++++++ .../frameworks/fastify/10-setup-tests.mdx | 80 ++++++++++ .../content/docs/frameworks/fastify/meta.json | 17 +++ apps/docs/content/docs/frameworks/index.mdx | 6 +- apps/docs/content/docs/frameworks/meta.json | 11 +- .../docs/frameworks/nestjs/01-prep.mdx | 57 +++++++ .../docs/frameworks/nestjs/02-setup-anvia.mdx | 97 ++++++++++++ .../frameworks/nestjs/03-route-handler.mdx | 75 ++++++++++ .../docs/frameworks/nestjs/04-streaming.mdx | 70 +++++++++ .../nestjs/05-tools-and-context.mdx | 86 +++++++++++ .../docs/frameworks/nestjs/06-persistence.mdx | 49 ++++++ .../docs/frameworks/nestjs/07-deploy.mdx | 45 ++++++ .../frameworks/nestjs/08-troubleshooting.mdx | 46 ++++++ .../nestjs/09-human-in-the-loop.mdx | 136 +++++++++++++++++ .../docs/frameworks/nestjs/10-setup-tests.mdx | 81 ++++++++++ .../content/docs/frameworks/nestjs/meta.json | 17 +++ .../docs/frameworks/sveltekit/01-prep.mdx | 57 +++++++ .../frameworks/sveltekit/02-setup-anvia.mdx | 65 ++++++++ .../frameworks/sveltekit/03-route-handler.mdx | 71 +++++++++ .../frameworks/sveltekit/04-streaming.mdx | 66 +++++++++ .../sveltekit/05-tools-and-context.mdx | 90 +++++++++++ .../frameworks/sveltekit/06-persistence.mdx | 49 ++++++ .../docs/frameworks/sveltekit/07-deploy.mdx | 43 ++++++ .../sveltekit/08-troubleshooting.mdx | 48 ++++++ .../sveltekit/09-human-in-the-loop.mdx | 134 +++++++++++++++++ .../frameworks/sveltekit/10-setup-tests.mdx | 94 ++++++++++++ .../docs/frameworks/sveltekit/meta.json | 17 +++ apps/docs/src/lib/layout.shared.tsx | 18 +++ 47 files changed, 2976 insertions(+), 2 deletions(-) create mode 100644 apps/docs/content/docs/frameworks/express/01-prep.mdx create mode 100644 apps/docs/content/docs/frameworks/express/02-setup-anvia.mdx create mode 100644 apps/docs/content/docs/frameworks/express/03-route-handler.mdx create mode 100644 apps/docs/content/docs/frameworks/express/04-streaming.mdx create mode 100644 apps/docs/content/docs/frameworks/express/05-tools-and-context.mdx create mode 100644 apps/docs/content/docs/frameworks/express/06-persistence.mdx create mode 100644 apps/docs/content/docs/frameworks/express/07-deploy.mdx create mode 100644 apps/docs/content/docs/frameworks/express/08-troubleshooting.mdx create mode 100644 apps/docs/content/docs/frameworks/express/09-human-in-the-loop.mdx create mode 100644 apps/docs/content/docs/frameworks/express/10-setup-tests.mdx create mode 100644 apps/docs/content/docs/frameworks/express/meta.json create mode 100644 apps/docs/content/docs/frameworks/fastify/01-prep.mdx create mode 100644 apps/docs/content/docs/frameworks/fastify/02-setup-anvia.mdx create mode 100644 apps/docs/content/docs/frameworks/fastify/03-route-handler.mdx create mode 100644 apps/docs/content/docs/frameworks/fastify/04-streaming.mdx create mode 100644 apps/docs/content/docs/frameworks/fastify/05-tools-and-context.mdx create mode 100644 apps/docs/content/docs/frameworks/fastify/06-persistence.mdx create mode 100644 apps/docs/content/docs/frameworks/fastify/07-deploy.mdx create mode 100644 apps/docs/content/docs/frameworks/fastify/08-troubleshooting.mdx create mode 100644 apps/docs/content/docs/frameworks/fastify/09-human-in-the-loop.mdx create mode 100644 apps/docs/content/docs/frameworks/fastify/10-setup-tests.mdx create mode 100644 apps/docs/content/docs/frameworks/fastify/meta.json create mode 100644 apps/docs/content/docs/frameworks/nestjs/01-prep.mdx create mode 100644 apps/docs/content/docs/frameworks/nestjs/02-setup-anvia.mdx create mode 100644 apps/docs/content/docs/frameworks/nestjs/03-route-handler.mdx create mode 100644 apps/docs/content/docs/frameworks/nestjs/04-streaming.mdx create mode 100644 apps/docs/content/docs/frameworks/nestjs/05-tools-and-context.mdx create mode 100644 apps/docs/content/docs/frameworks/nestjs/06-persistence.mdx create mode 100644 apps/docs/content/docs/frameworks/nestjs/07-deploy.mdx create mode 100644 apps/docs/content/docs/frameworks/nestjs/08-troubleshooting.mdx create mode 100644 apps/docs/content/docs/frameworks/nestjs/09-human-in-the-loop.mdx create mode 100644 apps/docs/content/docs/frameworks/nestjs/10-setup-tests.mdx create mode 100644 apps/docs/content/docs/frameworks/nestjs/meta.json create mode 100644 apps/docs/content/docs/frameworks/sveltekit/01-prep.mdx create mode 100644 apps/docs/content/docs/frameworks/sveltekit/02-setup-anvia.mdx create mode 100644 apps/docs/content/docs/frameworks/sveltekit/03-route-handler.mdx create mode 100644 apps/docs/content/docs/frameworks/sveltekit/04-streaming.mdx create mode 100644 apps/docs/content/docs/frameworks/sveltekit/05-tools-and-context.mdx create mode 100644 apps/docs/content/docs/frameworks/sveltekit/06-persistence.mdx create mode 100644 apps/docs/content/docs/frameworks/sveltekit/07-deploy.mdx create mode 100644 apps/docs/content/docs/frameworks/sveltekit/08-troubleshooting.mdx create mode 100644 apps/docs/content/docs/frameworks/sveltekit/09-human-in-the-loop.mdx create mode 100644 apps/docs/content/docs/frameworks/sveltekit/10-setup-tests.mdx create mode 100644 apps/docs/content/docs/frameworks/sveltekit/meta.json 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..7c5ee174 --- /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 +``` + +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..b606efdf --- /dev/null +++ b/apps/docs/content/docs/frameworks/express/04-streaming.mdx @@ -0,0 +1,78 @@ +--- +title: 04 Streaming +description: Stream Anvia run events from an Express route. +--- + +Express uses Node response objects. Read from Anvia's Web `ReadableStream` and write NDJSON chunks to `res`. + +## 1. Add `/api/support/stream` + +```ts +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 }, + }); + } + + res.setHeader("Content-Type", "application/x-ndjson"); + res.setHeader("Cache-Control", "no-cache"); + + const reader = supportAgent.prompt(parsed.data.message).readableStream().getReader(); + const decoder = new TextDecoder(); + + while (true) { + const next = await reader.read(); + if (next.done) break; + res.write(decoder.decode(next.value)); + } + + res.end(); + } 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..7bdfe85d --- /dev/null +++ b/apps/docs/content/docs/frameworks/express/08-troubleshooting.mdx @@ -0,0 +1,48 @@ +--- +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 + +Set `application/x-ndjson`, call `res.write(...)`, and check proxy buffering. + +```ts +res.setHeader("Content-Type", "application/x-ndjson"); +res.write(chunk); +``` + +## 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..e6b14e55 --- /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 `readableStream()` with a small `ReadableStream` 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..612da61c --- /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 +``` + +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..2d884850 --- /dev/null +++ b/apps/docs/content/docs/frameworks/fastify/04-streaming.mdx @@ -0,0 +1,65 @@ +--- +title: 04 Streaming +description: Stream Anvia run events from a Fastify route. +--- + +Fastify replies can send stream-like payloads. Set NDJSON headers and return Anvia's `readableStream()`. + +## 1. Add `/api/support/stream` + +```ts +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 }, + }); + } + + return reply + .header("Content-Type", "application/x-ndjson") + .header("Cache-Control", "no-cache") + .send(supportAgent.prompt(parsed.data.message).readableStream()); + }); +} +``` + +## 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..dce04f0e --- /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 +return reply + .header("Content-Type", "application/x-ndjson") + .send(agent.prompt(message).readableStream()); +``` + +## 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..fdbcad96 --- /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 `readableStream()` with a small `ReadableStream` 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/index.mdx b/apps/docs/content/docs/frameworks/index.mdx index cfef3c28..fae4a48a 100644 --- a/apps/docs/content/docs/frameworks/index.mdx +++ b/apps/docs/content/docs/frameworks/index.mdx @@ -26,9 +26,13 @@ Start with the framework that owns your HTTP routes: | --- | --- | --- | | 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 all three: +The Anvia runtime shape stays the same across every framework: ```ts import { AgentBuilder } from "@anvia/core"; diff --git a/apps/docs/content/docs/frameworks/meta.json b/apps/docs/content/docs/frameworks/meta.json index 710c0212..7fb69033 100644 --- a/apps/docs/content/docs/frameworks/meta.json +++ b/apps/docs/content/docs/frameworks/meta.json @@ -3,5 +3,14 @@ "description": "App integrations", "icon": "Blocks", "root": true, - "pages": ["index", "nextjs", "tanstack-start", "hono"] + "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..de66fb5c --- /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 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..3553595b --- /dev/null +++ b/apps/docs/content/docs/frameworks/nestjs/02-setup-anvia.mdx @@ -0,0 +1,97 @@ +--- +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, type Agent } from "@anvia/core"; +import { OpenAIClient } from "@anvia/openai"; +import { z } from "zod"; + +@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).readableStream(); + } + + 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..73d69d7a --- /dev/null +++ b/apps/docs/content/docs/frameworks/nestjs/04-streaming.mdx @@ -0,0 +1,70 @@ +--- +title: 04 Streaming +description: Stream Anvia run events from a NestJS controller. +--- + +With the default Express adapter, inject `@Res()` and pipe Anvia's Web stream into the Node response. + +## 1. Add A Streaming Controller Method + +```ts +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); + } + + res.setHeader("Content-Type", "application/x-ndjson"); + res.setHeader("Cache-Control", "no-cache"); + + const stream = Readable.fromWeb(this.supportAgent.streamSupport(parsed.data.message)); + stream.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..ba3b028f --- /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 `ReadableStream` 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/sveltekit/01-prep.mdx b/apps/docs/content/docs/frameworks/sveltekit/01-prep.mdx new file mode 100644 index 00000000..d7093f1b --- /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 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..3e6ab3ea --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/04-streaming.mdx @@ -0,0 +1,66 @@ +--- +title: 04 Streaming +description: Stream Anvia run events from a SvelteKit endpoint. +--- + +SvelteKit endpoints can return standard `Response` objects, so Anvia streams can be returned directly. + +## 1. Create `src/routes/api/support/stream/+server.ts` + +```ts +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 new Response(supportAgent.prompt(parsed.data.message).readableStream(), { + headers: { + "Content-Type": "application/x-ndjson", + "Cache-Control": "no-cache", + }, + }); +}; +``` + +## 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..7c1aed0b --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/08-troubleshooting.mdx @@ -0,0 +1,48 @@ +--- +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 a `Response` with `application/x-ndjson` and avoid wrapping the stream in `json(...)`. + +```ts +return new Response(agent.prompt(message).readableStream(), { + headers: { "Content-Type": "application/x-ndjson" }, +}); +``` + +## 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..d27e392c --- /dev/null +++ b/apps/docs/content/docs/frameworks/sveltekit/10-setup-tests.mdx @@ -0,0 +1,94 @@ +--- +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 +const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"type":"final"}\n')); + controller.close(); + }, +}); + +vi.mock("$lib/server/ai/support-agent", () => ({ + supportAgent: { + prompt: () => ({ + readableStream: () => 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/src/lib/layout.shared.tsx b/apps/docs/src/lib/layout.shared.tsx index 7092c7c5..ab3634c4 100644 --- a/apps/docs/src/lib/layout.shared.tsx +++ b/apps/docs/src/lib/layout.shared.tsx @@ -3,6 +3,7 @@ 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: "Frameworks", url: "/docs/frameworks" }, @@ -23,6 +24,14 @@ function GitHubIcon() { ); } +function DiscordIcon() { + return ( + + ); +} + function ResourcesDropdown() { return ; } @@ -103,6 +112,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, From 303dc014279e832218ce6246024b5e61c4ba4b06 Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Fri, 8 May 2026 10:56:41 +0700 Subject: [PATCH 14/89] docs: add gateway pages and manual model lists --- .../cloudflare-ai-gateway.mdx | 51 +++++++++++++++ .../helicone-ai-gateway.mdx | 48 ++++++++++++++ .../compatible-gateways/langdb-ai-gateway.mdx | 50 ++++++++++++++ .../models/compatible-gateways/litellm.mdx | 48 ++++++++++++++ .../docs/models/compatible-gateways/meta.json | 6 ++ .../models/compatible-gateways/portkey.mdx | 52 +++++++++++++++ .../compatible-gateways/vercel-ai-gateway.mdx | 48 ++++++++++++++ apps/docs/content/docs/models/index.mdx | 6 ++ .../content/docs/models/model-listing.mdx | 65 +++++++++++++++++++ 9 files changed, 374 insertions(+) create mode 100644 apps/docs/content/docs/models/compatible-gateways/cloudflare-ai-gateway.mdx create mode 100644 apps/docs/content/docs/models/compatible-gateways/helicone-ai-gateway.mdx create mode 100644 apps/docs/content/docs/models/compatible-gateways/langdb-ai-gateway.mdx create mode 100644 apps/docs/content/docs/models/compatible-gateways/litellm.mdx create mode 100644 apps/docs/content/docs/models/compatible-gateways/portkey.mdx create mode 100644 apps/docs/content/docs/models/compatible-gateways/vercel-ai-gateway.mdx 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/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 1af651cb..396815c6 100644 --- a/apps/docs/content/docs/models/index.mdx +++ b/apps/docs/content/docs/models/index.mdx @@ -68,6 +68,12 @@ See [Model Listing](/docs/models/model-listing) for the normalized response shap | 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/model-listing.mdx b/apps/docs/content/docs/models/model-listing.mdx index 8a647fce..8c9ce007 100644 --- a/apps/docs/content/docs/models/model-listing.mdx +++ b/apps/docs/content/docs/models/model-listing.mdx @@ -64,6 +64,71 @@ The request goes to the configured gateway models endpoint, usually `GET {baseUr 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: From 3e312f330f0ded975ce084ea9f311a40e8401721 Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Fri, 8 May 2026 11:11:46 +0700 Subject: [PATCH 15/89] Normalize Studio session persistence --- .../docs/reference/studio/sessions.mdx | 2 +- .../configure/storage-and-persistence.mdx | 6 +- .../docs/studio/runs/streaming-runs.mdx | 2 +- .../tools/studio/src/storage/sqlite-store.ts | 280 ++++++++++++++++-- packages/tools/studio/test/runner.test.ts | 98 ++++++ 5 files changed, 351 insertions(+), 37 deletions(-) diff --git a/apps/docs/content/docs/reference/studio/sessions.mdx b/apps/docs/content/docs/reference/studio/sessions.mdx index 32d08c21..cef66ef5 100644 --- a/apps/docs/content/docs/reference/studio/sessions.mdx +++ b/apps/docs/content/docs/reference/studio/sessions.mdx @@ -146,6 +146,6 @@ type StudioSessionStore = MemoryStore & { Purpose: persistence adapter for Studio sessions. -Return behavior: methods may be sync or async. Because the store extends `MemoryStore`, it also loads, appends, and clears model transcript messages for Studio-backed sessions. +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. Notable errors: persistence failures should throw or reject. 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..e708531b 100644 --- a/apps/docs/content/docs/studio/configure/storage-and-persistence.mdx +++ b/apps/docs/content/docs/studio/configure/storage-and-persistence.mdx @@ -3,7 +3,7 @@ title: Storage and Persistence description: Understand Studio's local SQLite storage for sessions and traces. --- -Studio creates a local SQLite store by default. It stores sessions, transcript entries, and traces for local inspection. +Studio creates a local SQLite store by default. It stores sessions, runtime messages, transcript entries, and traces for local inspection. ## Default Path @@ -26,8 +26,8 @@ ANVIA_STUDIO_DB=.data/studio.sqlite pnpm tsx studio.ts | 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 | ## Sessions and Traces 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/packages/tools/studio/src/storage/sqlite-store.ts b/packages/tools/studio/src/storage/sqlite-store.ts index aff66483..adbcd1f9 100644 --- a/packages/tools/studio/src/storage/sqlite-store.ts +++ b/packages/tools/studio/src/storage/sqlite-store.ts @@ -39,12 +39,39 @@ type SessionRow = { 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; @@ -87,19 +114,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 runner_sessions s + LEFT JOIN runner_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); } @@ -113,11 +143,9 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { 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, @@ -140,7 +168,9 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { getSession(id: string): StudioSession | undefined { const row = this.getSessionRow(id); - return row === undefined ? undefined : toSession(row, this.listSessionRunRows(id)); + return row === undefined + ? undefined + : toSession(row, this.listSessionMessages(id), this.listSessionRunRows(id)); } load(context: MemoryContext): Promise { @@ -155,7 +185,7 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { db.exec("BEGIN IMMEDIATE"); const row = db .prepare( - `SELECT id, agent_id, title, metadata_json, messages_json, transcript_json, created_at, updated_at + `SELECT id, agent_id, title, metadata_json, created_at, updated_at FROM runner_sessions WHERE id = $id`, ) @@ -166,18 +196,16 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { return Promise.resolve(); } - const current = toSession(row); - const messages = [...current.messages, ...input.messages]; const updatedAt = new Date().toISOString(); + const nextIndex = this.nextMessageIndex(input.context.sessionId); + this.insertMessages(input.context.sessionId, input.messages, nextIndex, updatedAt); db.prepare( `UPDATE runner_sessions - SET messages_json = $messages, - updated_at = $updatedAt + SET updated_at = $updatedAt WHERE id = $id`, ).run({ $id: input.context.sessionId, - $messages: JSON.stringify(messages), $updatedAt: updatedAt, }); db.exec("COMMIT"); @@ -198,14 +226,15 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { db.exec("BEGIN IMMEDIATE"); db.prepare( `UPDATE runner_sessions - SET messages_json = '[]', - transcript_json = '[]', - updated_at = $updatedAt + SET updated_at = $updatedAt WHERE id = $id`, ).run({ $id: context.sessionId, $updatedAt: updatedAt, }); + db.prepare("DELETE FROM runner_session_messages WHERE session_id = $id").run({ + $id: context.sessionId, + }); db.prepare("DELETE FROM runner_session_runs WHERE session_id = $id").run({ $id: context.sessionId, }); @@ -247,7 +276,11 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { db.exec("ROLLBACK"); return undefined; } - const current = toSession(row, this.listSessionRunRows(input.id)); + const current = toSession( + row, + this.listSessionMessages(input.id), + this.listSessionRunRows(input.id), + ); const title = current.title ?? input.title; db.prepare( @@ -484,18 +517,41 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { db.exec(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; + `); + guardAgainstLegacySessionSchema(db); + db.exec(` CREATE TABLE IF NOT EXISTS runner_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_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 runner_sessions(id) ON DELETE CASCADE + ) STRICT; + CREATE INDEX IF NOT EXISTS runner_session_messages_session_idx + ON runner_session_messages(session_id, message_index ASC); + CREATE TABLE IF NOT EXISTS runner_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 runner_session_messages(session_id, message_index) + ON DELETE CASCADE + ) STRICT; CREATE TABLE IF NOT EXISTS runner_session_runs ( run_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, @@ -536,7 +592,7 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { private getSessionRow(id: string): SessionRow | undefined { return this.database() .prepare( - `SELECT id, agent_id, title, metadata_json, messages_json, transcript_json, created_at, updated_at + `SELECT id, agent_id, title, metadata_json, created_at, updated_at FROM runner_sessions WHERE id = $id`, ) @@ -563,23 +619,119 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { ) .all({ $sessionId: sessionId }) as SessionRunRow[]; } + + private listSessionMessages(sessionId: string): Message[] { + const db = this.database(); + const messageRows = db + .prepare( + `SELECT session_id, message_index, role, message_id, created_at + FROM runner_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 runner_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 runner_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 runner_session_messages ( + session_id, + message_index, + role, + message_id, + created_at + ) VALUES ($sessionId, $messageIndex, $role, $messageId, $createdAt)`, + ); + const insertPart = db.prepare( + `INSERT INTO runner_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, runRows: SessionRunRow[] = []): StudioSession { - const summary = toSessionSummary(row); - const legacyTranscript = parseJsonArray(row.transcript_json); +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([...legacyTranscript, ...runTranscript]), + 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, @@ -587,11 +739,75 @@ 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 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('runner_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 toTrace(row: TraceRow): StudioTrace { const trace = parseJsonValue(row.trace_json); const input = parseJsonValue(row.input_json); diff --git a/packages/tools/studio/test/runner.test.ts b/packages/tools/studio/test/runner.test.ts index c8e25655..bea7351a 100644 --- a/packages/tools/studio/test/runner.test.ts +++ b/packages/tools/studio/test/runner.test.ts @@ -1,4 +1,5 @@ import { mkdtempSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -20,12 +21,18 @@ import { type StreamingCompletionModel, skipTool, type Tool, + ToolContent, Usage, + UserContent, } from "@anvia/core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Studio } from "../src/index"; import { createSqliteSessionStore } from "../src/sqlite"; +const { DatabaseSync } = createRequire(import.meta.url)( + "node:sqlite", +) as typeof import("node:sqlite"); + class QueueModel { readonly provider = "test"; readonly defaultModel = "test"; @@ -1880,6 +1887,97 @@ describe("Anvia studio", () => { }); }); + it("persists session messages and parts in normalized SQLite tables", async () => { + const path = join(studioDbDir ?? tmpdir(), "normalized.sqlite"); + const store = createSqliteSessionStore({ path }); + store.createSession({ id: "session_1", agentId: "support" }); + + const messages = [ + Message.system("Use project policy."), + Message.user([ + UserContent.text("hi"), + UserContent.imageUrl("https://example.test/image.png", { detail: "high" }), + UserContent.documentUrl("https://example.test/file.pdf", "application/pdf", { + filename: "file.pdf", + }), + ]), + Message.assistant( + [ + AssistantContent.text("hello"), + AssistantContent.reasoning("thinking", "reasoning_1"), + AssistantContent.toolCall("tool_1", "lookup", { query: "x" }, "call_1"), + AssistantContent.imageBase64("abc123", "image/png"), + ], + "assistant_message_1", + ), + Message.tool( + ToolContent.toolResult( + "tool_1", + [ + { type: "text", text: "lookup result" }, + { type: "image", data: "abc123", mediaType: "image/png" }, + ], + "call_1", + ), + ), + ]; + + await store.append({ + context: { sessionId: "session_1" }, + runId: "run_1", + turn: 1, + messages: messages.slice(0, 2), + }); + await store.append({ + context: { sessionId: "session_1" }, + runId: "run_1", + turn: 2, + messages: messages.slice(2), + }); + + const db = new DatabaseSync(path); + const messageCount = db + .prepare("SELECT COUNT(*) AS count FROM runner_session_messages") + .get() as { count: number }; + const partCount = db + .prepare("SELECT COUNT(*) AS count FROM runner_session_message_parts") + .get() as { count: number }; + expect(messageCount.count).toBe(4); + expect(partCount.count).toBe(9); + db.close(); + + const reloaded = createSqliteSessionStore({ path }); + await expect(reloaded.load({ sessionId: "session_1" })).resolves.toEqual(messages); + expect(await reloaded.getSession("session_1")).toMatchObject({ + id: "session_1", + messageCount: 4, + messages, + }); + }); + + it("rejects legacy SQLite session schemas with messages_json", () => { + const path = join(studioDbDir ?? tmpdir(), "legacy.sqlite"); + const db = new DatabaseSync(path); + db.exec(` + CREATE TABLE runner_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; + `); + db.close(); + + const store = createSqliteSessionStore({ path }); + expect(() => store.createSession({ id: "session_1", agentId: "support" })).toThrow( + "legacy messages_json schema", + ); + }); + it("lists global runner traces with filters", async () => { const mainAgent = new AgentBuilder( "main", From 3a2be1308c55892989486d2d6f78a6b6500dc587 Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Fri, 8 May 2026 11:51:44 +0700 Subject: [PATCH 16/89] Add hook-based Studio approvals --- .../docs/guides/agents/agent-tools.mdx | 13 +- .../docs/guides/agents/runtime-hooks.mdx | 15 +- .../human-in-the-loop/approval-settings.mdx | 4 +- .../docs/guides/human-in-the-loop/index.mdx | 17 +- .../human-in-the-loop/tool-approval.mdx | 50 ++--- .../content/docs/reference/core/agent.mdx | 14 +- .../docs/studio/configure/capabilities.mdx | 2 +- .../human-in-the-loop/tool-approvals.mdx | 31 ++- examples/cli-agent/package.json | 2 +- .../02_tools/08-tool-permission-hook.ts | 8 +- .../cookbook/09_studio/03-tool-approval.ts | 39 +++- examples/cookbook/package.json | 2 +- packages/core/src/agent/hooks.ts | 20 +- packages/core/src/agent/request.ts | 12 ++ packages/core/test/prompt-request.test.ts | 36 ++++ .../tools/studio/src/runtime/approvals.ts | 117 +++++++---- packages/tools/studio/src/runtime/shared.ts | 8 +- packages/tools/studio/src/runtime/studio.ts | 19 +- packages/tools/studio/src/ui/app/app.tsx | 31 ++- packages/tools/studio/test/runner.test.ts | 195 ++++++++++++++++++ pnpm-lock.yaml | 40 +--- 21 files changed, 517 insertions(+), 158 deletions(-) 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/runtime-hooks.mdx b/apps/docs/content/docs/guides/agents/runtime-hooks.mdx index c5153808..b95f9eff 100644 --- a/apps/docs/content/docs/guides/agents/runtime-hooks.mdx +++ b/apps/docs/content/docs/guides/agents/runtime-hooks.mdx @@ -46,33 +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/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 0eb8809c..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,24 +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. - -In this example, `approvalRuntime` is your application object. It is not exported by Anvia; create it with your database, queue, or reviewer UI. See [Approval Runtimes](/docs/guides/human-in-the-loop/approval-handlers). +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 approvalRuntime.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 269851f7..d0892a03 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,11 +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). - -The examples use `approvalRuntime` as an application-owned object. Do not import it from Anvia. Build it in your app, then call it from the hook. For the runtime shape, read [Approval Runtimes](/docs/guides/human-in-the-loop/approval-handlers). +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 @@ -15,25 +13,20 @@ The examples use `approvalRuntime` as an application-owned object. Do not import 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 approvalRuntime.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 @@ -64,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 approvalRuntime.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.`); }, }); ``` @@ -92,7 +80,7 @@ Parse the pending tool arguments when only some calls need review. import { createHook, parseToolArgs } from "@anvia/core"; const approvalHook = createHook({ - async onToolCall({ toolName, args, tool }) { + onToolCall({ toolName, args, tool }) { if (toolName !== "issue_refund") { return tool.run(); } @@ -111,13 +99,10 @@ const approvalHook = createHook({ return tool.run(); } - const approved = await approvalRuntime.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."); }, }); ``` @@ -146,17 +131,20 @@ 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([ diff --git a/apps/docs/content/docs/reference/core/agent.mdx b/apps/docs/content/docs/reference/core/agent.mdx index cfeacf87..6c63f572 100644 --- a/apps/docs/content/docs/reference/core/agent.mdx +++ b/apps/docs/content/docs/reference/core/agent.mdx @@ -296,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; @@ -310,6 +316,7 @@ type ToolCallControl = { run(): ToolCallHookAction; skip(reason: string): ToolCallHookAction; cancel(reason: string): ToolCallHookAction; + requestApproval(options?: ToolApprovalRequestOptions): ToolCallHookAction; }; type HookResult = HookAction | undefined; @@ -349,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 diff --git a/apps/docs/content/docs/studio/configure/capabilities.mdx b/apps/docs/content/docs/studio/configure/capabilities.mdx index d9545abf..61b69be1 100644 --- a/apps/docs/content/docs/studio/configure/capabilities.mdx +++ b/apps/docs/content/docs/studio/configure/capabilities.mdx @@ -33,7 +33,7 @@ curl http://localhost:4021/config | `sessions` | Session storage is available | | `traces` | Trace storage is available | | `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 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/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/cookbook/02_tools/08-tool-permission-hook.ts b/examples/cookbook/02_tools/08-tool-permission-hook.ts index cea9b9ed..509e5ac8 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(); @@ -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/09_studio/03-tool-approval.ts b/examples/cookbook/09_studio/03-tool-approval.ts index 9ffa764b..54840f73 100644 --- a/examples/cookbook/09_studio/03-tool-approval.ts +++ b/examples/cookbook/09_studio/03-tool-approval.ts @@ -1,4 +1,4 @@ -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"; @@ -58,6 +58,36 @@ const issueRefund = createTool({ }), }); +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("deepseek/deepseek-v4-pro"); const agent = new AgentBuilder("studio-support-operations", agentModel) .name("Studio Support Operations") @@ -65,11 +95,12 @@ const agent = new AgentBuilder("studio-support-operations", agentModel) .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/package.json b/examples/cookbook/package.json index 64520e1e..670c2e65 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -158,7 +158,7 @@ "@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/packages/core/src/agent/hooks.ts b/packages/core/src/agent/hooks.ts index 6bde3e60..dbbd28b9 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"; 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; @@ -69,6 +76,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 +103,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/request.ts b/packages/core/src/agent/request.ts index ea204d59..f1d88968 100644 --- a/packages/core/src/agent/request.ts +++ b/packages/core/src/agent/request.ts @@ -483,6 +483,18 @@ export class PromptRequest { ); throw this.cancelled(newMessages, callAction.reason); } + if (callAction?.type === "approval_request") { + const reason = `Tool approval was requested for ${toolCall.function.name}, but no approval handler is installed.`; + await this.recordToolError( + toolObservers, + observation?.turn, + toolCall, + internalCallId, + args, + reason, + ); + throw this.cancelled(newMessages, reason); + } let output: string; let skipped = false; diff --git a/packages/core/test/prompt-request.test.ts b/packages/core/test/prompt-request.test.ts index 721f5d27..90bb943c 100644 --- a/packages/core/test/prompt-request.test.ts +++ b/packages/core/test/prompt-request.test.ts @@ -13,6 +13,7 @@ import { MaxTurnsError, Message, PromptCancelledError, + requestToolApproval, Usage, } from "../src/index"; @@ -325,6 +326,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({ @@ -421,6 +453,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/tools/studio/src/runtime/approvals.ts b/packages/tools/studio/src/runtime/approvals.ts index 9cd319ea..193389a4 100644 --- a/packages/tools/studio/src/runtime/approvals.ts +++ b/packages/tools/studio/src/runtime/approvals.ts @@ -4,6 +4,9 @@ import type { PromptHook, ToolApprovalContext, ToolApprovalPolicy, + ToolApprovalRequestOptions, + ToolCallHookAction, + ToolCallHookArgs, } from "@anvia/core"; import { createHook, parseToolArgs } from "@anvia/core"; import type { Context, Hono } from "hono"; @@ -42,7 +45,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 +60,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 +157,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/shared.ts b/packages/tools/studio/src/runtime/shared.ts index 0d74d881..1c3459b9 100644 --- a/packages/tools/studio/src/runtime/shared.ts +++ b/packages/tools/studio/src/runtime/shared.ts @@ -162,7 +162,13 @@ export function capabilityConfig( if (agents.some((agent) => agent.agent.observers.length > 0)) { capabilities.observability = { enabled: true }; } - if (agents.some((agent) => agent.agent.toolSet.values().some((tool) => tool.approval))) { + if ( + agents.some( + (agent) => + agent.agent.hook !== undefined || + agent.agent.toolSet.values().some((tool) => tool.approval), + ) + ) { capabilities.approvals = { enabled: true }; } if ( diff --git a/packages/tools/studio/src/runtime/studio.ts b/packages/tools/studio/src/runtime/studio.ts index cd52b7d3..0b74d0ed 100644 --- a/packages/tools/studio/src/runtime/studio.ts +++ b/packages/tools/studio/src/runtime/studio.ts @@ -29,7 +29,11 @@ import { resolveStudioUiOptions, studioUiEntryPath, } from "../ui/routes"; -import { createApprovalRuntime, registerApprovalRoutes } from "./approvals"; +import { + createApprovalRuntime, + registerApprovalRoutes, + type StudioApprovalHook, +} from "./approvals"; import { registerKnowledgeRoutes } from "./knowledge"; import { createQuestionRuntime, registerQuestionRoutes } from "./questions"; import { @@ -170,6 +174,7 @@ 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, }; @@ -502,6 +507,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; }, @@ -513,3 +521,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/ui/app/app.tsx b/packages/tools/studio/src/ui/app/app.tsx index 0b44d45a..d13be766 100644 --- a/packages/tools/studio/src/ui/app/app.tsx +++ b/packages/tools/studio/src/ui/app/app.tsx @@ -74,6 +74,27 @@ function applyDarkTheme(): void { document.documentElement.classList.add("dark"); } +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}`; +} + export function StudioConsole() { const initialLocation = pageLocationFromLocation(); const [config, setConfig] = useState(); @@ -158,13 +179,14 @@ export function StudioConsole() { }, [loadAllSessions]); 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", @@ -172,7 +194,7 @@ 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); @@ -473,7 +495,8 @@ 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") { return; } @@ -493,7 +516,7 @@ export function StudioConsole() { ? (await createSession(titleFromText(trimmed))).id : selectedSessionId; const history = sessionsEnabled ? undefined : toHistory(messages); - const response = await fetch(`/agents/${encodeURIComponent(selectedAgentId)}/runs`, { + const response = await fetch(`/agents/${encodeURIComponent(agentId)}/runs`, { method: "POST", headers: { "content-type": "application/json", diff --git a/packages/tools/studio/test/runner.test.ts b/packages/tools/studio/test/runner.test.ts index bea7351a..edad4d2d 100644 --- a/packages/tools/studio/test/runner.test.ts +++ b/packages/tools/studio/test/runner.test.ts @@ -850,6 +850,192 @@ describe("Anvia studio", () => { }); }); + it("handles hook-based approval requests in streaming runs", async () => { + let executed = false; + const refundTool = { + name: "issue_refund", + definition() { + return { + name: "issue_refund", + description: "Issue a customer refund", + parameters: { + type: "object", + properties: { + orderId: { type: "string" }, + amount: { type: "number" }, + }, + required: ["orderId", "amount"], + }, + }; + }, + call(args) { + executed = true; + return `Refunded ${args.amount} for ${args.orderId}`; + }, + } satisfies Tool<{ orderId: string; amount: number }, string>; + const model = new StreamingQueueModel([ + [ + { + type: "tool_call_delta", + id: "call_1", + name: "issue_refund", + argumentsDelta: '{"orderId":"ORD-1","amount":25}', + }, + ], + [{ type: "text_delta", delta: "Refund complete" }], + ]); + const agent = new AgentBuilder("support", model) + .tool(refundTool) + .hook( + createHook({ + onToolCall({ toolName, tool }) { + if (toolName === "issue_refund") { + return tool.requestApproval({ + reason: "Review refund before issuing it.", + rejectMessage: "Rejected by hook.", + }); + } + return tool.run(); + }, + }), + ) + .defaultMaxTurns(2) + .build(); + const runner = new Studio([agent]); + + const res = await runner.fetch( + new Request("http://runner.test/agents/support/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "refund", stream: true }), + }), + ); + + expect(res.status).toBe(200); + const reader = createJsonlReader(res); + let approvalId = ""; + while (approvalId.length === 0) { + const event = await withTimeout(reader.read(), 1_000); + if ((event as { type?: string }).type === "tool_approval_request") { + const approval = (event as { approval: { id: string; reason?: string } }).approval; + approvalId = approval.id; + expect(approval.reason).toBe("Review refund before issuing it."); + } + } + expect(executed).toBe(false); + + const decision = await runner.fetch( + new Request(`http://runner.test/approvals/${approvalId}/decision`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ approved: true }), + }), + ); + expect(decision.status).toBe(200); + + const remaining = await readRemainingJsonl(reader); + expect(executed).toBe(true); + expect(remaining).toContainEqual( + expect.objectContaining({ + type: "tool_approval_result", + approval: expect.objectContaining({ id: approvalId, status: "approved" }), + }), + ); + expect(remaining).toContainEqual( + expect.objectContaining({ + type: "tool_result", + result: "Refunded 25 for ORD-1", + }), + ); + }); + + it("skips rejected hook-based approval requests with the reject message", async () => { + let executed = false; + const refundTool = { + name: "issue_refund", + definition() { + return { + name: "issue_refund", + description: "Issue a customer refund", + parameters: { + type: "object", + properties: { + orderId: { type: "string" }, + amount: { type: "number" }, + }, + required: ["orderId", "amount"], + }, + }; + }, + call() { + executed = true; + return "should not run"; + }, + } satisfies Tool<{ orderId: string; amount: number }, string>; + const model = new StreamingQueueModel([ + [ + { + type: "tool_call_delta", + id: "call_1", + name: "issue_refund", + argumentsDelta: '{"orderId":"ORD-1","amount":25}', + }, + ], + [{ type: "text_delta", delta: "Refund denied" }], + ]); + const agent = new AgentBuilder("support", model) + .tool(refundTool) + .hook( + createHook({ + onToolCall({ tool }) { + return tool.requestApproval({ + reason: "Review refund before issuing it.", + rejectMessage: "Rejected by hook.", + }); + }, + }), + ) + .defaultMaxTurns(2) + .build(); + const runner = new Studio([agent]); + + const res = await runner.fetch( + new Request("http://runner.test/agents/support/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: "refund", stream: true }), + }), + ); + + expect(res.status).toBe(200); + const reader = createJsonlReader(res); + let approvalId = ""; + while (approvalId.length === 0) { + const event = await withTimeout(reader.read(), 1_000); + if ((event as { type?: string }).type === "tool_approval_request") { + approvalId = (event as { approval: { id: string } }).approval.id; + } + } + + const decision = await runner.fetch( + new Request(`http://runner.test/approvals/${approvalId}/decision`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ approved: false }), + }), + ); + expect(decision.status).toBe(200); + + const remaining = await readRemainingJsonl(reader); + expect(executed).toBe(false); + expect(remaining).toContainEqual( + expect.objectContaining({ + type: "tool_result", + result: "Rejected by hook.", + }), + ); + }); + it("pauses ask_question tool calls until Studio receives answers", async () => { const model = new StreamingQueueModel([ [ @@ -1245,6 +1431,15 @@ describe("Anvia studio", () => { expect(runner.config().capabilities.approvals).toEqual({ enabled: true }); }); + it("marks approvals enabled when a registered agent has hooks", () => { + const agent = new AgentBuilder("support", new QueueModel([])) + .hook(createHook({ onToolCall: ({ tool }) => tool.requestApproval() })) + .build(); + const runner = new Studio([agent]); + + expect(runner.config().capabilities.approvals).toEqual({ enabled: true }); + }); + it("serves the runner UI shell routes", async () => { const runner = new Studio(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9152868..c5fcdfa0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,8 +121,8 @@ importers: specifier: ^19.2.5 version: 19.2.5 zod: - specifier: ^4.4.2 - version: 4.4.2 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@biomejs/biome': specifier: ^2.4.13 @@ -183,7 +183,7 @@ importers: version: link:../../packages/embeddings/transformers '@modelcontextprotocol/sdk': specifier: ^1.29.0 - version: 1.29.0(zod@4.4.2) + version: 1.29.0(zod@4.4.3) '@opentelemetry/exporter-trace-otlp-http': specifier: ^0.216.0 version: 0.216.0(@opentelemetry/api@1.9.1) @@ -194,8 +194,8 @@ importers: specifier: ^17.4.2 version: 17.4.2 zod: - specifier: ^4.4.2 - version: 4.4.2 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@types/node': specifier: ^24.9.1 @@ -6532,34 +6532,12 @@ snapshots: '@mistralai/mistralai@2.2.1': dependencies: ws: 8.20.0 - zod: 4.4.2 - zod-to-json-schema: 3.25.2(zod@4.4.2) + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) transitivePeerDependencies: - bufferutil - utf-8-validate - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.2)': - dependencies: - '@hono/node-server': 1.19.14(hono@4.12.15) - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.8 - express: 5.2.1 - express-rate-limit: 8.4.1(express@5.2.1) - hono: 4.12.15 - jose: 6.2.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 4.4.2 - zod-to-json-schema: 3.25.2(zod@4.4.2) - transitivePeerDependencies: - - supports-color - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.15) @@ -11488,10 +11466,6 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 - zod-to-json-schema@3.25.2(zod@4.4.2): - dependencies: - zod: 4.4.2 - zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: zod: 4.4.3 From 67933db1c8148c0e0fea2c2b3ec473b43874092a Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Fri, 8 May 2026 12:10:15 +0700 Subject: [PATCH 17/89] Add Studio session log panel --- .../docs/reference/studio/sessions.mdx | 53 +- .../content/docs/reference/studio/types.mdx | 5 +- .../docs/studio/http-api/endpoints.mdx | 1 + .../tools/studio/src/runtime/session-logs.ts | 571 ++++++++++++++++++ packages/tools/studio/src/runtime/sessions.ts | 61 ++ packages/tools/studio/src/runtime/studio.ts | 62 +- .../tools/studio/src/storage/sqlite-store.ts | 153 +++++ packages/tools/studio/src/types.ts | 56 +- packages/tools/studio/src/ui/app/app.tsx | 136 +++-- .../session-logs/session-logs-panel.tsx | 163 +++++ packages/tools/studio/test/runner.test.ts | 114 ++++ 11 files changed, 1324 insertions(+), 51 deletions(-) create mode 100644 packages/tools/studio/src/runtime/session-logs.ts create mode 100644 packages/tools/studio/src/ui/app/modules/session-logs/session-logs-panel.tsx diff --git a/apps/docs/content/docs/reference/studio/sessions.mdx b/apps/docs/content/docs/reference/studio/sessions.mdx index cef66ef5..5884428f 100644 --- a/apps/docs/content/docs/reference/studio/sessions.mdx +++ b/apps/docs/content/docs/reference/studio/sessions.mdx @@ -121,11 +121,58 @@ type StudioSessionRunTranscriptInput = { 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. @@ -140,12 +187,14 @@ type StudioSessionStore = MemoryStore & { 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. Because the store extends `MemoryStore`, it also loads, appends, and clears model messages for Studio-backed sessions. +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/types.mdx b/apps/docs/content/docs/reference/studio/types.mdx index 62da63c1..a273523f 100644 --- a/apps/docs/content/docs/reference/studio/types.mdx +++ b/apps/docs/content/docs/reference/studio/types.mdx @@ -77,12 +77,13 @@ type AgentRunStreamEvent = | StudioToolApprovalRequestEvent | StudioToolApprovalResultEvent | StudioToolQuestionRequestEvent - | StudioToolQuestionResultEvent; + | StudioToolQuestionResultEvent + | StudioSessionLogEvent; ``` 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 runs return `AgentRunResponse`; streaming runs emit newline-delimited `AgentRunStreamEvent` values. `session_log` events are Studio-owned metadata-only runtime logs for session runs. Notable errors: invalid run request bodies return `bad_request`; unsupported stores or capabilities return `unsupported_capability`. diff --git a/apps/docs/content/docs/studio/http-api/endpoints.mdx b/apps/docs/content/docs/studio/http-api/endpoints.mdx index e529dafc..5c8c0091 100644 --- a/apps/docs/content/docs/studio/http-api/endpoints.mdx +++ b/apps/docs/content/docs/studio/http-api/endpoints.mdx @@ -16,6 +16,7 @@ Studio exposes the same runtime used by the bundled UI as an HTTP API. | `/agents/:agentId/runs` | `POST` | Run an agent | | `/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 | | `/traces` | `GET` | List traces | | `/traces/:traceId` | `GET` | Read one trace | 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..dcf389dc --- /dev/null +++ b/packages/tools/studio/src/runtime/session-logs.ts @@ -0,0 +1,571 @@ +import type { JsonObject, JsonValue, Message } from "@anvia/core"; +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), + }), + }, + ]; + } + 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), + }), + }, + ]; + } + 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..96896ef2 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 { 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/studio.ts b/packages/tools/studio/src/runtime/studio.ts index 0b74d0ed..12a20086 100644 --- a/packages/tools/studio/src/runtime/studio.ts +++ b/packages/tools/studio/src/runtime/studio.ts @@ -46,6 +46,16 @@ import { traceForRun, transcriptFromMessages, } from "./runs"; +import { + appendSessionLog, + memoryLoadedLog, + memorySavedLog, + runCompletedLog, + runFailedLog, + runReceivedLog, + runStartedLog, + streamSessionRunLogs, +} from "./session-logs"; import { registerSessionRoutes } from "./sessions"; import { agentConfig, @@ -256,6 +266,23 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { } const runId = globalThis.crypto.randomUUID(); + const runStartedAt = Date.now(); + if (session !== undefined) { + 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 ?? {}), @@ -311,7 +338,13 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { session === undefined || stores.sessions === undefined ? runStream : persistStreamingSessionTranscript({ - stream: runStream, + stream: streamSessionRunLogs({ + stream: runStream, + store: stores.sessions, + session, + runId, + startedAt: runStartedAt, + }), store: stores.sessions, session, message: body.message, @@ -321,6 +354,10 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { } 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, @@ -351,6 +388,25 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { 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) { @@ -367,6 +423,10 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { 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)); } diff --git a/packages/tools/studio/src/storage/sqlite-store.ts b/packages/tools/studio/src/storage/sqlite-store.ts index adbcd1f9..1fab9a64 100644 --- a/packages/tools/studio/src/storage/sqlite-store.ts +++ b/packages/tools/studio/src/storage/sqlite-store.ts @@ -14,6 +14,9 @@ import type { StudioSession, StudioSessionCreateInput, StudioSessionListOptions, + StudioSessionLogAppendInput, + StudioSessionLogEntry, + StudioSessionLogListOptions, StudioSessionRunStatus, StudioSessionRunTranscriptInput, StudioSessionStore, @@ -100,6 +103,19 @@ type SessionRunRow = { 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; +}; + export function createSqliteSessionStore( options: SqliteSessionStoreOptions = {}, ): StudioSessionStore & StudioTraceStore { @@ -341,6 +357,99 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { } } + 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 runner_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 runner_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); + } + deleteSession(id: string): boolean { const db = this.database(); @@ -348,6 +457,7 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { db.exec("BEGIN IMMEDIATE"); db.prepare("DELETE FROM runner_traces WHERE session_id = $id").run({ $id: id }); db.prepare("DELETE FROM runner_session_runs WHERE session_id = $id").run({ $id: id }); + db.prepare("DELETE FROM runner_session_logs WHERE session_id = $id").run({ $id: id }); const result = db.prepare("DELETE FROM runner_sessions WHERE id = $id").run({ $id: id }) as { changes: number | bigint; }; @@ -565,6 +675,22 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { ) STRICT; CREATE INDEX IF NOT EXISTS runner_session_runs_session_created_idx ON runner_session_runs(session_id, created_at ASC); + CREATE TABLE IF NOT EXISTS runner_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 runner_sessions(id) ON DELETE CASCADE + ) STRICT; + CREATE INDEX IF NOT EXISTS runner_session_logs_session_sequence_idx + ON runner_session_logs(session_id, sequence ASC); CREATE TABLE IF NOT EXISTS runner_traces ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, @@ -620,6 +746,17 @@ class SqliteSessionStore implements StudioSessionStore, StudioTraceStore { .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 runner_session_logs + WHERE session_id = $sessionId`, + ) + .get({ $sessionId: sessionId }) as { next_sequence: number }; + return row.next_sequence; + } + private listSessionMessages(sessionId: string): Message[] { const db = this.database(); const messageRows = db @@ -744,6 +881,22 @@ function toSessionSummary(row: SessionSummaryRow): StudioSessionSummary { }; } +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 messageParts(message: Message): StoredMessagePart[] { if (message.role === "system") { return [{ type: "text", value: { type: "text", text: message.content } }]; diff --git a/packages/tools/studio/src/types.ts b/packages/tools/studio/src/types.ts index 94878e4e..ab2d86f8 100644 --- a/packages/tools/studio/src/types.ts +++ b/packages/tools/studio/src/types.ts @@ -149,6 +149,48 @@ export type StudioSessionRunTranscriptInput = { 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 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( @@ -161,6 +203,12 @@ export type StudioSessionStore = MemoryStore & { saveSessionRunTranscript( input: StudioSessionRunTranscriptInput, ): StudioSession | undefined | Promise; + appendSessionLog?( + input: StudioSessionLogAppendInput, + ): StudioSessionLogEntry | Promise; + listSessionLogs?( + options: StudioSessionLogListOptions, + ): StudioSessionLogEntry[] | Promise; deleteSession?(id: string): boolean | Promise; }; @@ -393,6 +441,11 @@ export type StudioToolQuestionResultEvent = { question: StudioToolQuestion; }; +export type StudioSessionLogEvent = { + type: "session_log"; + log: StudioSessionLogEntry; +}; + export type AgentRunRequest = { message: string | Message; history?: Message[]; @@ -411,7 +464,8 @@ export type AgentRunStreamEvent = | StudioToolApprovalRequestEvent | StudioToolApprovalResultEvent | StudioToolQuestionRequestEvent - | StudioToolQuestionResultEvent; + | StudioToolQuestionResultEvent + | StudioSessionLogEvent; 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 d13be766..8cbbfa9b 100644 --- a/packages/tools/studio/src/ui/app/app.tsx +++ b/packages/tools/studio/src/ui/app/app.tsx @@ -1,4 +1,4 @@ -import { ArrowUp, Plus, Trash2 } from "lucide-react"; +import { ArrowUp, Plus } from "lucide-react"; import { type ChangeEvent, type KeyboardEvent, @@ -12,6 +12,7 @@ import type { StudioConfig, StudioKnowledgeSummary, StudioSession, + StudioSessionLogEntry, StudioSessionSummary, StudioTrace, StudioTraceSummary, @@ -30,6 +31,7 @@ import { cn } from "./lib/utils"; import { AgentsPage } from "./modules/agents/agents-page"; import { KnowledgePage } from "./modules/knowledge/knowledge-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, @@ -102,6 +104,7 @@ export function StudioConsole() { const [selectedSessionId, setSelectedSessionId] = useState(""); const [allSessions, setAllSessions] = useState([]); const [traces, setTraces] = useState([]); + const [sessionLogs, setSessionLogs] = useState([]); const [messages, setMessages] = useState([]); const [prompt, setPrompt] = useState(""); const [activePage, setActivePage] = useState(() => initialLocation.page); @@ -116,6 +119,7 @@ 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"); @@ -178,6 +182,34 @@ 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", { @@ -200,6 +232,7 @@ export function StudioConsole() { setSelectedSessionId(session.id); setAllSessions((current) => [session, ...current.filter((item) => item.id !== session.id)]); updateSessionPath(session.id); + await loadSessionLogs(session.id); return session; } @@ -356,7 +389,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); @@ -372,7 +408,7 @@ export function StudioConsole() { setSessionLoadState("idle"); } }, - [runState, loadSessionTraceSummaries], + [runState, loadSessionTraceSummaries, loadSessionLogs], ); const startNewChat = useCallback( @@ -382,6 +418,7 @@ export function StudioConsole() { } resetTranscriptSequence(); setSelectedSessionId(""); + setSessionLogs([]); setMessages([]); setPrompt(""); setActivePage("playground"); @@ -403,6 +440,7 @@ export function StudioConsole() { setSelectedAgentId(agentId); resetTranscriptSequence(); setSelectedSessionId(""); + setSessionLogs([]); setMessages([]); setPrompt(""); setActivePage("playground"); @@ -432,6 +470,7 @@ export function StudioConsole() { if (selectedSessionId === session.id) { resetTranscriptSequence(); setSelectedSessionId(""); + setSessionLogs([]); setMessages([]); setPrompt(""); if (activePage === "playground") { @@ -548,7 +587,10 @@ export function StudioConsole() { await loadAllSessions(); if (sessionId.length > 0) { setSelectedSessionId(sessionId); - const traceSummaries = await loadSessionTraceSummaries(sessionId); + const [traceSummaries] = await Promise.all([ + loadSessionTraceSummaries(sessionId), + loadSessionLogs(sessionId), + ]); setMessages((current) => enrichTranscriptWithTraceIds(current, traceSummaries)); } setStatus("Connected"); @@ -607,6 +649,10 @@ 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; @@ -617,6 +663,15 @@ export function StudioConsole() { 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 appendAssistantText(delta: string) { setMessages((current) => { const next = [...current]; @@ -1099,6 +1154,33 @@ export function StudioConsole() { onClick={() => navigatePage("knowledge")} /> +
    - + ) : null} diff --git a/packages/tools/studio/src/ui/app/modules/session-logs/session-logs-panel.tsx b/packages/tools/studio/src/ui/app/modules/session-logs/session-logs-panel.tsx new file mode 100644 index 00000000..ff99edac --- /dev/null +++ b/packages/tools/studio/src/ui/app/modules/session-logs/session-logs-panel.tsx @@ -0,0 +1,163 @@ +import { useEffect, useRef } from "react"; +import type { StudioSessionLogEntry } from "../../../../types"; +import { Badge } from "../../components/ui/badge"; +import { cn } from "../../lib/utils"; + +export function SessionLogsPanel(props: { + logs: StudioSessionLogEntry[]; + selectedSessionId: string; + loading: boolean; +}) { + const scrollerRef = useRef(null); + const stickToBottomRef = useRef(true); + + useEffect(() => { + const node = scrollerRef.current; + if (node === null || !stickToBottomRef.current) { + return; + } + node.scrollTop = node.scrollHeight; + }); + + function updateStickiness() { + const node = scrollerRef.current; + if (node === null) { + return; + } + stickToBottomRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24; + } + + return ( + + ); +} + +function LogRow(props: { log: StudioSessionLogEntry }) { + const metadata = Object.entries(props.log.metadata ?? {}).slice(0, 6); + return ( +
    +
    +
    +
    + + {props.log.level} + + + {props.log.category}/{props.log.event} + +
    +

    {props.log.message}

    +
    + +
    + {metadata.length === 0 ? null : ( +
    + {metadata.map(([key, value]) => ( + + {key}={formatMetadata(value)} + + ))} +
    + )} +
    + ); +} + +function formatLogTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ""; + } + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function formatMetadata(value: unknown): string { + if (value === null) { + return "null"; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) { + return `[${value.length}]`; + } + if (typeof value === "object" && value !== null) { + return "{...}"; + } + return ""; +} + +function levelBorderClass(level: StudioSessionLogEntry["level"]): string { + switch (level) { + case "error": + return "border-destructive"; + case "warn": + return "border-yellow-500"; + case "debug": + return "border-muted-foreground/45"; + case "info": + return "border-primary/70"; + } +} + +function levelBadgeClass(level: StudioSessionLogEntry["level"]): string { + switch (level) { + case "error": + return "border-destructive/40 bg-destructive/15 text-destructive"; + case "warn": + return "border-yellow-500/40 bg-yellow-500/15 text-yellow-500"; + case "debug": + return "border-border bg-muted text-muted-foreground"; + case "info": + return "border-primary/40 bg-primary/15 text-primary"; + } +} diff --git a/packages/tools/studio/test/runner.test.ts b/packages/tools/studio/test/runner.test.ts index edad4d2d..fcddc30a 100644 --- a/packages/tools/studio/test/runner.test.ts +++ b/packages/tools/studio/test/runner.test.ts @@ -1602,6 +1602,84 @@ describe("Anvia studio", () => { }); }); + it("streams and persists metadata-only session audit logs", async () => { + const model = new StreamingQueueModel([[{ type: "text_delta", delta: "safe answer" }]]); + const agent = new AgentBuilder("support", model).build(); + const runner = new Studio([agent]); + + const created = await runner.fetch( + new Request("http://runner.test/sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agentId: "support", title: "secret title" }), + }), + ); + const session = (await created.json()) as { id: string }; + + const run = await runner.fetch( + new Request("http://runner.test/agents/support/runs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + message: "my raw secret prompt", + sessionId: session.id, + stream: true, + }), + }), + ); + + expect(run.status).toBe(200); + const events = await readJsonl(run); + const streamedLogs = events.filter( + (event): event is { type: "session_log"; log: { event: string; sequence: number } } => + typeof event === "object" && + event !== null && + "type" in event && + event.type === "session_log", + ); + expect(streamedLogs).toContainEqual( + expect.objectContaining({ log: expect.objectContaining({ event: "run.started" }) }), + ); + expect(streamedLogs).toContainEqual( + expect.objectContaining({ log: expect.objectContaining({ event: "memory.loaded" }) }), + ); + expect(streamedLogs).toContainEqual( + expect.objectContaining({ log: expect.objectContaining({ event: "prompt.prepared" }) }), + ); + expect(streamedLogs).toContainEqual( + expect.objectContaining({ log: expect.objectContaining({ event: "run.completed" }) }), + ); + expect(streamedLogs).toContainEqual( + expect.objectContaining({ log: expect.objectContaining({ event: "memory.saved" }) }), + ); + + const firstPage = await runner.fetch( + new Request(`http://runner.test/sessions/${session.id}/logs?limit=2`), + ); + expect(firstPage.status).toBe(200); + const firstBody = (await firstPage.json()) as { + logs: Array<{ event: string; sequence: number; metadata?: unknown }>; + nextCursor?: number; + }; + expect(firstBody.logs).toHaveLength(2); + expect(firstBody.logs[0]).toMatchObject({ event: "session.created", sequence: 0 }); + expect(firstBody.nextCursor).toBe(1); + + const nextPage = await runner.fetch( + new Request(`http://runner.test/sessions/${session.id}/logs?after=${firstBody.nextCursor}`), + ); + const nextBody = (await nextPage.json()) as { + logs: Array<{ event: string; sequence: number; metadata?: unknown }>; + }; + expect(nextBody.logs[0]).toMatchObject({ event: "run.started", sequence: 2 }); + expect(nextBody.logs.map((log) => log.event)).toContain("run.completed"); + + const serializedLogs = JSON.stringify([...firstBody.logs, ...nextBody.logs]); + expect(serializedLogs).not.toContain("my raw secret prompt"); + expect(serializedLogs).not.toContain("secret title"); + expect(serializedLogs).not.toContain("safe answer"); + }); + it("persists streaming subagent activity in tool transcript entries", async () => { const parentModel = new StreamingQueueModel([ [ @@ -2150,6 +2228,42 @@ describe("Anvia studio", () => { }); }); + it("persists session audit logs with monotonic sequence and deletes them with sessions", async () => { + const store = createSqliteSessionStore({ path: ":memory:" }); + store.createSession({ id: "session_1", agentId: "support" }); + + const first = await store.appendSessionLog?.({ + sessionId: "session_1", + level: "info", + category: "session", + event: "session.created", + message: "Session created", + metadata: { agentId: "support" }, + }); + const second = await store.appendSessionLog?.({ + sessionId: "session_1", + runId: "run_1", + level: "debug", + category: "memory", + event: "memory.loaded", + message: "Session memory loaded", + metadata: { messageCount: 0 }, + }); + + expect(first).toMatchObject({ sequence: 0, event: "session.created" }); + expect(second).toMatchObject({ sequence: 1, event: "memory.loaded", runId: "run_1" }); + expect(await store.listSessionLogs?.({ sessionId: "session_1", limit: 10 })).toEqual([ + expect.objectContaining({ sequence: 0 }), + expect.objectContaining({ sequence: 1 }), + ]); + expect(await store.listSessionLogs?.({ sessionId: "session_1", limit: 10, after: 0 })).toEqual([ + expect.objectContaining({ sequence: 1 }), + ]); + + expect(await store.deleteSession?.("session_1")).toBe(true); + expect(await store.listSessionLogs?.({ sessionId: "session_1", limit: 10 })).toEqual([]); + }); + it("rejects legacy SQLite session schemas with messages_json", () => { const path = join(studioDbDir ?? tmpdir(), "legacy.sqlite"); const db = new DatabaseSync(path); From 981930b68972a952febd44f744a63d255bddd87a Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Fri, 8 May 2026 12:26:01 +0700 Subject: [PATCH 18/89] Add Studio tool and MCP inspectors --- .../content/docs/reference/studio/types.mdx | 55 +++++++ .../docs/studio/configure/capabilities.mdx | 2 + .../docs/studio/http-api/endpoints.mdx | 2 + packages/core/src/mcp/connect.ts | 2 +- packages/core/src/mcp/tool.ts | 17 ++- packages/tools/studio/src/runtime/mcps.ts | 75 ++++++++++ packages/tools/studio/src/runtime/shared.ts | 11 ++ packages/tools/studio/src/runtime/studio.ts | 4 + .../tools/studio/src/runtime/tool-metadata.ts | 52 +++++++ packages/tools/studio/src/runtime/tools.ts | 52 +++++++ packages/tools/studio/src/types.ts | 43 ++++++ packages/tools/studio/src/ui/app/app.tsx | 114 +++++++++++++++ .../src/ui/app/modules/mcps/mcps-page.tsx | 132 +++++++++++++++++ .../src/ui/app/modules/shared/format.ts | 4 + .../studio/src/ui/app/modules/shared/path.ts | 12 +- .../studio/src/ui/app/modules/shared/types.ts | 9 +- .../src/ui/app/modules/shell/nav-button.tsx | 8 +- .../src/ui/app/modules/tools/tools-page.tsx | 137 ++++++++++++++++++ packages/tools/studio/test/runner.test.ts | 108 ++++++++++++++ 19 files changed, 833 insertions(+), 6 deletions(-) create mode 100644 packages/tools/studio/src/runtime/mcps.ts create mode 100644 packages/tools/studio/src/runtime/tool-metadata.ts create mode 100644 packages/tools/studio/src/runtime/tools.ts create mode 100644 packages/tools/studio/src/ui/app/modules/mcps/mcps-page.tsx create mode 100644 packages/tools/studio/src/ui/app/modules/tools/tools-page.tsx diff --git a/apps/docs/content/docs/reference/studio/types.mdx b/apps/docs/content/docs/reference/studio/types.mdx index a273523f..4a3b3f72 100644 --- a/apps/docs/content/docs/reference/studio/types.mdx +++ b/apps/docs/content/docs/reference/studio/types.mdx @@ -12,8 +12,10 @@ type StudioCapability = | "agents" | "approvals" | "knowledge" + | "mcps" | "observability" | "sessions" + | "tools" | "traces"; type StudioAgent = { @@ -56,6 +58,59 @@ Return behavior: `Studio.config()` returns `StudioConfig`. 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 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`. + +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(...)`. + ## Run Types ```ts diff --git a/apps/docs/content/docs/studio/configure/capabilities.mdx b/apps/docs/content/docs/studio/configure/capabilities.mdx index 61b69be1..151f6792 100644 --- a/apps/docs/content/docs/studio/configure/capabilities.mdx +++ b/apps/docs/content/docs/studio/configure/capabilities.mdx @@ -32,6 +32,8 @@ curl http://localhost:4021/config | `agents` | Always enabled | | `sessions` | 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 | | `observability` | At least one registered agent has observers | | `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 | diff --git a/apps/docs/content/docs/studio/http-api/endpoints.mdx b/apps/docs/content/docs/studio/http-api/endpoints.mdx index 5c8c0091..6b83139e 100644 --- a/apps/docs/content/docs/studio/http-api/endpoints.mdx +++ b/apps/docs/content/docs/studio/http-api/endpoints.mdx @@ -13,6 +13,8 @@ Studio exposes the same runtime used by the bundled UI as an HTTP API. | `/config` | `GET` | Read agents, quick prompts, and enabled capabilities | | `/agents` | `GET` | List registered agents | | `/agents/:agentId` | `GET` | Read one agent config | +| `/agents/:agentId/tools` | `GET` | Inspect registered tool metadata for one agent | +| `/agents/:agentId/mcps` | `GET` | Inspect registered MCP server and tool metadata for one agent | | `/agents/:agentId/runs` | `POST` | Run an agent | | `/sessions` | `GET`, `POST` | List or create sessions | | `/sessions/:sessionId` | `GET`, `DELETE` | Read or delete a session | 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/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/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/shared.ts b/packages/tools/studio/src/runtime/shared.ts index 1c3459b9..05e20a0b 100644 --- a/packages/tools/studio/src/runtime/shared.ts +++ b/packages/tools/studio/src/runtime/shared.ts @@ -16,6 +16,7 @@ import type { StudioTraceStore, StudioUiOptions, } from "../types"; +import { agentHasMcpTools } from "./tool-metadata"; export type ResolvedStores = { sessions?: StudioSessionStore; @@ -158,6 +159,16 @@ export function capabilityConfig( if (stores.traces !== undefined) { capabilities.traces = { enabled: true }; } + 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.observers.length > 0)) { capabilities.observability = { enabled: true }; diff --git a/packages/tools/studio/src/runtime/studio.ts b/packages/tools/studio/src/runtime/studio.ts index 12a20086..6748db3f 100644 --- a/packages/tools/studio/src/runtime/studio.ts +++ b/packages/tools/studio/src/runtime/studio.ts @@ -35,6 +35,7 @@ import { type StudioApprovalHook, } from "./approvals"; import { registerKnowledgeRoutes } from "./knowledge"; +import { registerMcpRoutes } from "./mcps"; import { createQuestionRuntime, registerQuestionRoutes } from "./questions"; import { AsyncEventQueue, @@ -69,6 +70,7 @@ import { unsupportedCapabilities, unsupportedCapability, } from "./shared"; +import { registerToolRoutes } from "./tools"; import { registerTraceRoutes } from "./trace-routes"; type StudioApp = AnviaStudio & { @@ -233,6 +235,8 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { return c.json(agentConfig(agent)); }); + registerMcpRoutes(app, { agentMap }); + registerToolRoutes(app, { agentMap }); registerApprovalRoutes(app, approvalRuntime); registerQuestionRoutes(app, questionRuntime); registerKnowledgeRoutes(app, { 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..7a1e82f8 --- /dev/null +++ b/packages/tools/studio/src/runtime/tool-metadata.ts @@ -0,0 +1,52 @@ +import { type AnyTool, ToolSet } from "@anvia/core"; +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..3bc2eb18 --- /dev/null +++ b/packages/tools/studio/src/runtime/tools.ts @@ -0,0 +1,52 @@ +import type { Hono } from "hono"; +import type { StudioAgent, StudioAgentToolMetadata } from "../types"; +import { errorResponse } 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), + }); + }); +} + +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/types.ts b/packages/tools/studio/src/types.ts index ab2d86f8..403f9465 100644 --- a/packages/tools/studio/src/types.ts +++ b/packages/tools/studio/src/types.ts @@ -16,8 +16,10 @@ export type StudioCapability = | "agents" | "approvals" | "knowledge" + | "mcps" | "observability" | "sessions" + | "tools" | "traces"; export type StudioAgent = { @@ -55,6 +57,47 @@ 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 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"; diff --git a/packages/tools/studio/src/ui/app/app.tsx b/packages/tools/studio/src/ui/app/app.tsx index 8cbbfa9b..6da25199 100644 --- a/packages/tools/studio/src/ui/app/app.tsx +++ b/packages/tools/studio/src/ui/app/app.tsx @@ -9,6 +9,8 @@ import { } from "react"; import type { AgentRunStreamEvent, + StudioAgentMcpsSummary, + StudioAgentToolsSummary, StudioConfig, StudioKnowledgeSummary, StudioSession, @@ -30,6 +32,7 @@ import { Textarea } from "./components/ui/textarea"; import { cn } from "./lib/utils"; import { AgentsPage } from "./modules/agents/agents-page"; import { KnowledgePage } from "./modules/knowledge/knowledge-page"; +import { McpsPage } from "./modules/mcps/mcps-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"; @@ -70,6 +73,7 @@ import type { TranscriptEntry, } from "./modules/shared/types"; import { NavButton } from "./modules/shell/nav-button"; +import { ToolsPage } from "./modules/tools/tools-page"; import { TraceBrowser } from "./modules/tracing/trace-browser"; function applyDarkTheme(): void { @@ -101,6 +105,8 @@ export function StudioConsole() { const initialLocation = pageLocationFromLocation(); const [config, setConfig] = useState(); const [selectedAgentId, setSelectedAgentId] = useState(""); + const [mcpsAgentId, setMcpsAgentId] = useState(""); + const [toolsAgentId, setToolsAgentId] = useState(""); const [selectedSessionId, setSelectedSessionId] = useState(""); const [allSessions, setAllSessions] = useState([]); const [traces, setTraces] = useState([]); @@ -123,6 +129,10 @@ export function StudioConsole() { 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 promptRef = useRef(null); const loadConfig = useCallback(async () => { @@ -141,6 +151,8 @@ 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 || ""); setStatus("Connected"); } catch (loadError) { setError(errorMessage(loadError)); @@ -155,6 +167,8 @@ 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; useEffect(() => { applyDarkTheme(); @@ -375,6 +389,66 @@ 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 loadSession = useCallback( async (sessionId: string, options: { updatePath?: boolean } = {}) => { if (runState === "running") { @@ -1147,6 +1221,18 @@ export function StudioConsole() { label="Studio" onClick={() => navigatePage("agents")} /> + navigatePage("tools")} + /> + navigatePage("mcps")} + /> ) : null} + {activePage === "tools" ? ( + { + setToolsAgentId(agentId); + void loadTools(agentId); + }} + /> + ) : null} + + {activePage === "mcps" ? ( + { + setMcpsAgentId(agentId); + void loadMcps(agentId); + }} + /> + ) : null} + {activePage === "knowledge" ? ( void; +}) { + const selectedAgent = + props.agents.find((agent) => agent.id === props.selectedAgentId) ?? props.agents[0]; + const servers = props.summary?.servers ?? []; + + return ( +
    +
    +
    +
    +

    MCPs

    +

    + MCP servers and tools registered on Studio agents +

    +
    + {props.agents.length > 1 ? ( + + ) : null} +
    +
    + +
    + {!props.enabled ? ( + + ) : props.loading ? ( + + ) : servers.length === 0 ? ( + + ) : ( +
    +
    + Server + Tools + Tool + Description + Source + Parameters +
    + {servers.flatMap((server) => + server.tools.map((tool, index) => ( +
    + + {index === 0 ? ( + <> + + {server.name} + + + {server.agentId} + + + ) : null} + + + {index === 0 ? server.toolCount : ""} + + + {tool.name} + +

    + {tool.description} +

    + + {tool.source} + + + + +
    + )), + )} +
    + )} +
    +
    + ); +} + +function EmptyState(props: { title: string; message: string }) { + return ( +
    +
    +

    {props.title}

    +

    {props.message}

    +
    +
    + ); +} + +function sourceBadgeClass(source: "static" | "dynamic"): string { + return source === "dynamic" + ? "border-primary/30 bg-primary/10 text-primary" + : "border-border bg-muted text-muted-foreground"; +} diff --git a/packages/tools/studio/src/ui/app/modules/shared/format.ts b/packages/tools/studio/src/ui/app/modules/shared/format.ts index 6e5291a4..5c90028d 100644 --- a/packages/tools/studio/src/ui/app/modules/shared/format.ts +++ b/packages/tools/studio/src/ui/app/modules/shared/format.ts @@ -77,6 +77,10 @@ export function pageTitle(page: ActivePage, agentName: string | undefined): stri return "Sessions"; case "tracing": return "Traces"; + case "tools": + return "Tools"; + case "mcps": + return "MCPs"; case "knowledge": return "Knowledge"; case "playground": diff --git a/packages/tools/studio/src/ui/app/modules/shared/path.ts b/packages/tools/studio/src/ui/app/modules/shared/path.ts index 340484f3..484618b8 100644 --- a/packages/tools/studio/src/ui/app/modules/shared/path.ts +++ b/packages/tools/studio/src/ui/app/modules/shared/path.ts @@ -72,6 +72,12 @@ function pageLocationFromSegments(segments: string[]): PageLocation { if (first === "agents") { return { page: "agents" }; } + if (first === "tools") { + return { page: "tools" }; + } + if (first === "mcps") { + return { page: "mcps" }; + } if (first === "knowledge") { return { page: "knowledge" }; } @@ -92,7 +98,11 @@ export function updatePagePath(page: ActivePage): void { const normalizedCompatUiPath = normalizePathPrefix(compatUiPath) || "/ui"; if ( normalizedUiPath.length === 0 && - (page === "sessions" || page === "agents" || page === "knowledge") + (page === "sessions" || + page === "agents" || + page === "tools" || + page === "mcps" || + page === "knowledge") ) { updateLocationPath(`${normalizedCompatUiPath}/${page}`); return; diff --git a/packages/tools/studio/src/ui/app/modules/shared/types.ts b/packages/tools/studio/src/ui/app/modules/shared/types.ts index 26fb210c..8414427b 100644 --- a/packages/tools/studio/src/ui/app/modules/shared/types.ts +++ b/packages/tools/studio/src/ui/app/modules/shared/types.ts @@ -17,7 +17,14 @@ export type TraceObservationItem = StudioTrace["observations"][number]; export type RunState = "idle" | "running"; export type SessionLoadState = "idle" | "loading"; export type TraceLoadState = "idle" | "loading"; -export type ActivePage = "playground" | "tracing" | "sessions" | "agents" | "knowledge"; +export type ActivePage = + | "playground" + | "tracing" + | "sessions" + | "agents" + | "tools" + | "mcps" + | "knowledge"; export type TraceStatusFilter = "all" | StudioTrace["status"]; export type TraceInspectorKey = "trace" | "agent" | `turn:${number}` | `observation:${string}`; export type PageLocation = { diff --git a/packages/tools/studio/src/ui/app/modules/shell/nav-button.tsx b/packages/tools/studio/src/ui/app/modules/shell/nav-button.tsx index dc9ca35b..f2f722a5 100644 --- a/packages/tools/studio/src/ui/app/modules/shell/nav-button.tsx +++ b/packages/tools/studio/src/ui/app/modules/shell/nav-button.tsx @@ -5,7 +5,9 @@ import { List, type LucideIcon, MessageSquare, + Plug, ShieldCheck, + Wrench, } from "lucide-react"; import { Button } from "../../components/ui/button"; import { cn } from "../../lib/utils"; @@ -36,7 +38,7 @@ export function NavButton(props: { ); } -type IconName = "activity" | "bot" | "database" | "list" | "message" | "shield"; +type IconName = "activity" | "bot" | "database" | "list" | "message" | "plug" | "shield" | "wrench"; function navIcon(name: IconName): LucideIcon { switch (name) { @@ -50,7 +52,11 @@ function navIcon(name: IconName): LucideIcon { return List; case "message": return MessageSquare; + case "plug": + return Plug; case "shield": return ShieldCheck; + case "wrench": + return Wrench; } } diff --git a/packages/tools/studio/src/ui/app/modules/tools/tools-page.tsx b/packages/tools/studio/src/ui/app/modules/tools/tools-page.tsx new file mode 100644 index 00000000..c341b69b --- /dev/null +++ b/packages/tools/studio/src/ui/app/modules/tools/tools-page.tsx @@ -0,0 +1,137 @@ +import type { StudioAgentToolsSummary, StudioConfig } from "../../../../types"; +import { Badge } from "../../components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../components/ui/select"; +import { cn } from "../../lib/utils"; +import { JsonValueView } from "../shared/renderers"; + +export function ToolsPage(props: { + agents: StudioConfig["agents"]; + selectedAgentId: string; + summary: StudioAgentToolsSummary | undefined; + enabled: boolean; + loading: boolean; + onSelectAgent: (agentId: string) => void; +}) { + const selectedAgent = + props.agents.find((agent) => agent.id === props.selectedAgentId) ?? props.agents[0]; + const tools = props.summary?.tools ?? []; + + return ( +
    +
    +
    +
    +

    Tools

    +

    + Tool definitions registered on Studio agents +

    +
    + {props.agents.length > 1 ? ( + + ) : null} +
    +
    + +
    + {!props.enabled ? ( + + ) : props.loading ? ( + + ) : tools.length === 0 ? ( + + ) : ( +
    +
    + Tool + Description + Source + Approval + Parameters +
    + {tools.map((tool) => ( +
    + + + {tool.name} + + + {tool.agentId} + + +

    + {tool.description} +

    + + {tool.source} + + + + {tool.approval.required ? "required" : "none"} + + {tool.approval.reason === undefined ? null : ( + + {tool.approval.reason} + + )} + + + + +
    + ))} +
    + )} +
    +
    + ); +} + +function EmptyState(props: { title: string; message: string }) { + return ( +
    +
    +

    {props.title}

    +

    {props.message}

    +
    +
    + ); +} + +function sourceBadgeClass(source: "static" | "dynamic"): string { + return source === "dynamic" + ? "border-primary/30 bg-primary/10 text-primary" + : "border-border bg-muted text-muted-foreground"; +} diff --git a/packages/tools/studio/test/runner.test.ts b/packages/tools/studio/test/runner.test.ts index fcddc30a..d35563f3 100644 --- a/packages/tools/studio/test/runner.test.ts +++ b/packages/tools/studio/test/runner.test.ts @@ -11,12 +11,14 @@ import { type CompletionRequest, type CompletionResponse, type CompletionStreamEvent, + connectMcp, createHook, createToolIndex, type Embedding, type EmbeddingModel, embedDocuments, InMemoryVectorStore, + type McpClient, Message, type StreamingCompletionModel, skipTool, @@ -518,6 +520,110 @@ describe("Anvia studio", () => { ]); }); + it("exposes tool metadata for registered agents", async () => { + const embeddings = new KeywordEmbeddingModel(); + const index = await createToolIndex(embeddings, [lookupPolicyTool]); + const refundTool = createRefundTool(() => "ok"); + const agent = new AgentBuilder("support", new QueueModel([])) + .tool(addTool) + .tool(refundTool) + .dynamicTools(index, { topK: 1 }) + .build(); + const runner = new Studio([agent]); + + expect(runner.config().capabilities.tools).toEqual({ enabled: true }); + + const res = await runner.fetch(new Request("http://runner.test/agents/support/tools")); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + agentId: "support", + tools: [ + expect.objectContaining({ + agentId: "support", + name: "add", + description: "Add numbers", + source: "static", + approval: { required: false }, + parameters: expect.objectContaining({ type: "object" }), + }), + expect.objectContaining({ + agentId: "support", + name: "issue_refund", + source: "static", + approval: { required: true, rejectMessage: "Rejected by test." }, + }), + expect.objectContaining({ + agentId: "support", + name: "lookup_policy", + description: "Look up policy documents", + source: "dynamic", + approval: { required: false }, + }), + ], + }); + }); + + it("exposes MCP server metadata for registered agents", async () => { + const mcpClient: McpClient = { + async listTools() { + return { + tools: [ + { + name: "lookup_policy", + description: "Look up policy documents", + inputSchema: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + }, + ], + }; + }, + async callTool() { + return { content: [{ type: "text", text: "policy" }] }; + }, + async close() {}, + }; + const mcpServer = await connectMcp({ + name: "policies", + connect: async () => mcpClient, + }); + const agent = new AgentBuilder("support", new QueueModel([])).mcp([mcpServer]).build(); + const runner = new Studio([agent]); + + expect(runner.config().capabilities.mcps).toEqual({ enabled: true }); + + const res = await runner.fetch(new Request("http://runner.test/agents/support/mcps")); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + agentId: "support", + servers: [ + { + agentId: "support", + name: "policies", + toolCount: 1, + tools: [ + { + name: "lookup_policy", + description: "Look up policy documents", + source: "static", + parameters: { + type: "object", + properties: { + query: { type: "string" }, + }, + required: ["query"], + }, + }, + ], + }, + ], + }); + }); + it("reports knowledge capability and exposes the knowledge inspector route", async () => { const agent = new AgentBuilder("support", new QueueModel([])) .context("Refund policy is 30 days.", "refund-policy") @@ -1469,6 +1575,8 @@ describe("Anvia studio", () => { "/ui/tracing/sessions/session_1", "/ui/sessions", "/ui/agents", + "/ui/tools", + "/ui/mcps", "/ui/knowledge", ]) { const routeShell = await runner.fetch(new Request(`http://runner.test${path}`)); From 86c0096ab2771a137d603223b93f1b6540123283 Mon Sep 17 00:00:00 2001 From: Indra Zulfi Date: Fri, 8 May 2026 13:03:04 +0700 Subject: [PATCH 19/89] Redesign Studio UI --- packages/tools/studio/src/ui/app/app.tsx | 137 +++++----- .../studio/src/ui/app/components/ui/badge.tsx | 4 +- .../src/ui/app/components/ui/button.tsx | 6 +- .../studio/src/ui/app/components/ui/card.tsx | 5 +- .../studio/src/ui/app/components/ui/input.tsx | 2 +- .../src/ui/app/components/ui/select.tsx | 6 +- .../src/ui/app/components/ui/textarea.tsx | 2 +- packages/tools/studio/src/ui/app/index.html | 4 + .../src/ui/app/modules/agents/agents-page.tsx | 231 ++++++++++++++--- .../app/modules/knowledge/knowledge-page.tsx | 22 +- .../src/ui/app/modules/mcps/mcps-page.tsx | 240 +++++++++++------- .../modules/playground/transcript-item.tsx | 24 +- .../session-logs/session-logs-panel.tsx | 77 +++--- .../ui/app/modules/sessions/sessions-page.tsx | 11 +- .../src/ui/app/modules/shared/renderers.tsx | 10 +- .../src/ui/app/modules/shell/nav-button.tsx | 4 +- .../src/ui/app/modules/tools/tools-page.tsx | 206 +++++++++------ .../ui/app/modules/tracing/trace-browser.tsx | 20 +- packages/tools/studio/src/ui/app/styles.css | 150 +++++++---- 19 files changed, 752 insertions(+), 409 deletions(-) diff --git a/packages/tools/studio/src/ui/app/app.tsx b/packages/tools/studio/src/ui/app/app.tsx index 6da25199..d8b2be97 100644 --- a/packages/tools/studio/src/ui/app/app.tsx +++ b/packages/tools/studio/src/ui/app/app.tsx @@ -1,4 +1,4 @@ -import { ArrowUp, Plus } from "lucide-react"; +import { ArrowUp, Plus, Trash2 } from "lucide-react"; import { type ChangeEvent, type KeyboardEvent, @@ -1182,19 +1182,22 @@ export function StudioConsole() { } return ( -
    - -
    -
    -
    +
    +
    +
    {activePage === "playground" ? "Agents" : "Studio"} @@ -1292,15 +1312,7 @@ export function StudioConsole() {
    -
    {activePage === "playground" ? ( -
    -
    -
    -
    +
    +
    +
    +
    {!hasMessages ? ( -
    - No messages +
    +
    +
    +

    + What should this agent work on? +

    +

    + Choose a prompt below or write a task. Studio will stream the response, + tool calls, approvals, and trace data here. +

    +
    ) : null} {messages.map((message) => ( @@ -1347,17 +1368,17 @@ export function StudioConsole() {
    { event.preventDefault(); void runPrompt(prompt); }} > {hasMessages || selectedAgentQuickPrompts.length === 0 ? null : ( -
    +
    {selectedAgentQuickPrompts.map((quickPrompt) => (