diff --git a/.changeset/refactor-studio-core-dedup.md b/.changeset/refactor-studio-core-dedup.md new file mode 100644 index 00000000..38dd6cb3 --- /dev/null +++ b/.changeset/refactor-studio-core-dedup.md @@ -0,0 +1,6 @@ +--- +"@anvia/core": patch +"@anvia/studio": patch +--- + +Refactor internal code quality: consolidate duplicate utilities, eliminate conditional spread patterns, and improve file organization. diff --git a/CHANGESET_GUIDE.md b/CHANGESET_GUIDE.md index fe288098..fa6ffc7e 100644 --- a/CHANGESET_GUIDE.md +++ b/CHANGESET_GUIDE.md @@ -44,9 +44,9 @@ Run the relevant checks: ```sh pnpm install --frozen-lockfile -pnpm --filter './packages/**' typecheck -pnpm --filter './packages/**' test -pnpm --filter './packages/**' build +pnpm --filter './packages/*' typecheck +pnpm --filter './packages/*' test +pnpm --filter './packages/*' build ``` If docs changed, also run: @@ -96,7 +96,7 @@ pnpm release That command runs: ```sh -pnpm --filter './packages/**' build && changeset publish +pnpm --filter './packages/*' build && changeset publish ``` ## Manual Version Check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4c69fd6..1a76300d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,9 +13,9 @@ Thanks for taking the time to improve Anvia. This project is a TypeScript pnpm w Good contributions usually fall into one of these areas: - Core runtime fixes or focused feature work in `packages/core`. -- Provider adapter fixes in `packages/providers/*`. -- Vector store or embedding adapter fixes in `packages/vector-stores/*` and `packages/embeddings/*`. -- Studio runtime or UI fixes in `packages/tools/studio`. +- Provider adapter fixes in `packages/provider-*`. +- Vector store or embedding adapter fixes in `packages/vector-*` and `packages/embedding-*`. +- Studio runtime or UI fixes in `packages/tool-studio`. - Cookbook examples that clarify an existing workflow or introduce a missing one. - Documentation updates in `apps/docs` or package READMEs. - Tests that capture bugs, edge cases, or public behavior. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c626e7d0..892d97a4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -25,18 +25,20 @@ pnpm install │ └── cookbook/ # Runnable learning path ├── packages/ │ ├── core/ # @anvia/core -│ ├── providers/ # Provider adapters -│ ├── embeddings/ # Embedding adapters -│ ├── observability/ # Observability adapters -│ ├── tools/studio/ # @anvia/studio -│ └── vector-stores/ # Vector store adapters +│ ├── embedding-*/ # Embedding adapters +│ ├── logger/ # @anvia/logger +│ ├── observability-*/ # Observability adapters +│ ├── provider-*/ # Provider adapters +│ ├── react/ # @anvia/react +│ ├── server/ # @anvia/server +│ ├── tool-*/ # Tool packages +│ └── vector-*/ # Vector store adapters └── bin/ # Local maintenance scripts ``` The workspace is declared in `pnpm-workspace.yaml` and includes: - `packages/*` -- `packages/*/*` - `apps/*` - `examples/*` diff --git a/README.md b/README.md index e3744242..5a7214ab 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ new Studio([agent]).start({ port: 4021 });

- Preview of Anvia Studio from packages/tools/studio. + Preview of Anvia Studio from packages/tool-studio.

## What You Can Build diff --git a/apps/docs/content/docs/changelog/anthropic.mdx b/apps/docs/content/docs/changelog/anthropic.mdx index 9f35afb9..29b78e65 100644 --- a/apps/docs/content/docs/changelog/anthropic.mdx +++ b/apps/docs/content/docs/changelog/anthropic.mdx @@ -7,7 +7,13 @@ description: "Release notes for @anvia/anthropic." Anthropic provider adapter for Anvia. -Source: [`packages/providers/anthropic/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/providers/anthropic/CHANGELOG.md) +Source: [`packages/provider-anthropic/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/provider-anthropic/CHANGELOG.md) + +## 0.3.6 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. ## 0.3.5 diff --git a/apps/docs/content/docs/changelog/chroma.mdx b/apps/docs/content/docs/changelog/chroma.mdx index 300b9ff9..54454970 100644 --- a/apps/docs/content/docs/changelog/chroma.mdx +++ b/apps/docs/content/docs/changelog/chroma.mdx @@ -7,7 +7,27 @@ description: "Release notes for @anvia/chroma." ChromaDB vector store adapter for Anvia. -Source: [`packages/vector-stores/chroma/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/vector-stores/chroma/CHANGELOG.md) +Source: [`packages/vector-chroma/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/vector-chroma/CHANGELOG.md) + +## 0.2.7 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.2.6 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [2d039f6] + - @anvia/core@0.6.1 ## 0.2.4 diff --git a/apps/docs/content/docs/changelog/core.mdx b/apps/docs/content/docs/changelog/core.mdx index 921b4826..1d32258b 100644 --- a/apps/docs/content/docs/changelog/core.mdx +++ b/apps/docs/content/docs/changelog/core.mdx @@ -9,6 +9,18 @@ Core runtime primitives for context-aware Anvia agents. Source: [`packages/core/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/core/CHANGELOG.md) +## 0.6.2 + +### Patch Changes + +- 4806f3e: Add `PromptRequest.steer()` for enqueueing user messages at safe model-turn boundaries during active prompt runs. + +## 0.6.1 + +### Patch Changes + +- 2d039f6: Add ergonomic tool result message helpers and export `ToolContent` from the root entrypoint. + ## 0.6.0 ### Minor Changes diff --git a/apps/docs/content/docs/changelog/fastembed.mdx b/apps/docs/content/docs/changelog/fastembed.mdx index 856a1159..d4f0e5d0 100644 --- a/apps/docs/content/docs/changelog/fastembed.mdx +++ b/apps/docs/content/docs/changelog/fastembed.mdx @@ -7,7 +7,27 @@ description: "Release notes for @anvia/fastembed." FastEmbed embedding model adapter for Anvia. -Source: [`packages/embeddings/fastembed/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/embeddings/fastembed/CHANGELOG.md) +Source: [`packages/embedding-fastembed/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/embedding-fastembed/CHANGELOG.md) + +## 0.2.7 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.2.6 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [2d039f6] + - @anvia/core@0.6.1 ## 0.2.4 diff --git a/apps/docs/content/docs/changelog/gemini.mdx b/apps/docs/content/docs/changelog/gemini.mdx index 9ca6fbb4..5dedbace 100644 --- a/apps/docs/content/docs/changelog/gemini.mdx +++ b/apps/docs/content/docs/changelog/gemini.mdx @@ -7,7 +7,13 @@ description: "Release notes for @anvia/gemini." Gemini provider adapter for Anvia. -Source: [`packages/providers/gemini/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/providers/gemini/CHANGELOG.md) +Source: [`packages/provider-gemini/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/provider-gemini/CHANGELOG.md) + +## 0.2.5 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. ## 0.2.4 diff --git a/apps/docs/content/docs/changelog/index.mdx b/apps/docs/content/docs/changelog/index.mdx index d7e09ba1..79ac5f66 100644 --- a/apps/docs/content/docs/changelog/index.mdx +++ b/apps/docs/content/docs/changelog/index.mdx @@ -13,57 +13,59 @@ Developers should keep writing release notes with `pnpm changeset`. The docs pag | Package | Current version | Release notes | | --- | --- | --- | -| `@anvia/core` | `0.6.0` | [View changelog](/docs/changelog/core) | +| `@anvia/core` | `0.6.2` | [View changelog](/docs/changelog/core) | ## Providers | Package | Current version | Release notes | | --- | --- | --- | -| `@anvia/anthropic` | `0.3.5` | [View changelog](/docs/changelog/anthropic) | -| `@anvia/gemini` | `0.2.4` | [View changelog](/docs/changelog/gemini) | -| `@anvia/mistral` | `0.2.5` | [View changelog](/docs/changelog/mistral) | -| `@anvia/openai` | `0.3.6` | [View changelog](/docs/changelog/openai) | +| `@anvia/anthropic` | `0.3.6` | [View changelog](/docs/changelog/anthropic) | +| `@anvia/gemini` | `0.2.5` | [View changelog](/docs/changelog/gemini) | +| `@anvia/mistral` | `0.2.8` | [View changelog](/docs/changelog/mistral) | +| `@anvia/openai` | `0.3.9` | [View changelog](/docs/changelog/openai) | ## Embeddings | Package | Current version | Release notes | | --- | --- | --- | -| `@anvia/fastembed` | `0.2.4` | [View changelog](/docs/changelog/fastembed) | -| `@anvia/transformers` | `0.2.4` | [View changelog](/docs/changelog/transformers) | +| `@anvia/fastembed` | `0.2.7` | [View changelog](/docs/changelog/fastembed) | +| `@anvia/transformers` | `0.2.7` | [View changelog](/docs/changelog/transformers) | ## Vector Stores | Package | Current version | Release notes | | --- | --- | --- | -| `@anvia/chroma` | `0.2.4` | [View changelog](/docs/changelog/chroma) | -| `@anvia/pgvector` | `0.2.4` | [View changelog](/docs/changelog/pgvector) | -| `@anvia/qdrant` | `0.2.4` | [View changelog](/docs/changelog/qdrant) | +| `@anvia/chroma` | `0.2.7` | [View changelog](/docs/changelog/chroma) | +| `@anvia/milvus` | `0.3.2` | [View changelog](/docs/changelog/milvus) | +| `@anvia/pgvector` | `0.2.7` | [View changelog](/docs/changelog/pgvector) | +| `@anvia/pinecone` | `0.3.2` | [View changelog](/docs/changelog/pinecone) | +| `@anvia/qdrant` | `0.2.7` | [View changelog](/docs/changelog/qdrant) | ## Logger | Package | Current version | Release notes | | --- | --- | --- | -| `@anvia/logger` | `0.3.5` | [View changelog](/docs/changelog/logger) | +| `@anvia/logger` | `0.3.7` | [View changelog](/docs/changelog/logger) | ## Observability | Package | Current version | Release notes | | --- | --- | --- | -| `@anvia/langfuse` | `0.2.3` | [View changelog](/docs/changelog/langfuse) | -| `@anvia/otel` | `0.2.4` | [View changelog](/docs/changelog/otel) | +| `@anvia/langfuse` | `0.2.4` | [View changelog](/docs/changelog/langfuse) | +| `@anvia/otel` | `0.2.7` | [View changelog](/docs/changelog/otel) | ## Tools | Package | Current version | Release notes | | --- | --- | --- | -| `@anvia/sandbox` | `0.2.1` | [View changelog](/docs/changelog/sandbox) | -| `@anvia/studio` | `0.5.10` | [View changelog](/docs/changelog/studio) | +| `@anvia/sandbox` | `0.3.2` | [View changelog](/docs/changelog/sandbox) | +| `@anvia/studio` | `0.6.0` | [View changelog](/docs/changelog/studio) | ## React | Package | Current version | Release notes | | --- | --- | --- | -| `@anvia/react` | `0.3.1` | [View changelog](/docs/changelog/react) | +| `@anvia/react` | `0.4.0` | [View changelog](/docs/changelog/react) | ## Server diff --git a/apps/docs/content/docs/changelog/langfuse.mdx b/apps/docs/content/docs/changelog/langfuse.mdx index 4dcfb857..269ba99e 100644 --- a/apps/docs/content/docs/changelog/langfuse.mdx +++ b/apps/docs/content/docs/changelog/langfuse.mdx @@ -7,7 +7,13 @@ description: "Release notes for @anvia/langfuse." Langfuse tracing adapter for Anvia. -Source: [`packages/observability/langfuse/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/observability/langfuse/CHANGELOG.md) +Source: [`packages/observability-langfuse/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/observability-langfuse/CHANGELOG.md) + +## 0.2.4 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. ## 0.2.3 diff --git a/apps/docs/content/docs/changelog/logger.mdx b/apps/docs/content/docs/changelog/logger.mdx index 09b6c155..a88c39cb 100644 --- a/apps/docs/content/docs/changelog/logger.mdx +++ b/apps/docs/content/docs/changelog/logger.mdx @@ -9,6 +9,20 @@ Structured logger adapters for Anvia. Source: [`packages/logger/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/logger/CHANGELOG.md) +## 0.3.7 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.3.6 + +### Patch Changes + +- Updated dependencies [2d039f6] + - @anvia/core@0.6.1 + ## 0.3.5 ### Patch Changes diff --git a/apps/docs/content/docs/changelog/meta.json b/apps/docs/content/docs/changelog/meta.json index 7cc6ecba..0edc4162 100644 --- a/apps/docs/content/docs/changelog/meta.json +++ b/apps/docs/content/docs/changelog/meta.json @@ -19,7 +19,9 @@ "transformers", "---Vector Stores---", "chroma", + "milvus", "pgvector", + "pinecone", "qdrant", "---Logger---", "logger", diff --git a/apps/docs/content/docs/changelog/milvus.mdx b/apps/docs/content/docs/changelog/milvus.mdx new file mode 100644 index 00000000..9de2f1b9 --- /dev/null +++ b/apps/docs/content/docs/changelog/milvus.mdx @@ -0,0 +1,30 @@ +--- +title: "@anvia/milvus" +description: "Release notes for @anvia/milvus." +--- + +# `@anvia/milvus` + +Milvus vector store adapter for Anvia. + +Source: [`packages/vector-milvus/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/vector-milvus/CHANGELOG.md) + +## 0.3.2 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.3.1 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + +## 0.3.0 + +### Minor Changes + +- ce25d82: Add Pinecone and Milvus vector store adapters following the existing pattern (Chroma, PgVector, Qdrant). Both implement the `VectorSearchIndex` interface with full filter translation, multi-embedding support, and `asTool()` integration. + diff --git a/apps/docs/content/docs/changelog/mistral.mdx b/apps/docs/content/docs/changelog/mistral.mdx index ea7a34a7..5942863f 100644 --- a/apps/docs/content/docs/changelog/mistral.mdx +++ b/apps/docs/content/docs/changelog/mistral.mdx @@ -7,7 +7,27 @@ description: "Release notes for @anvia/mistral." Mistral provider adapter for Anvia. -Source: [`packages/providers/mistral/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/providers/mistral/CHANGELOG.md) +Source: [`packages/provider-mistral/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/provider-mistral/CHANGELOG.md) + +## 0.2.8 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.2.7 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + +## 0.2.6 + +### Patch Changes + +- Updated dependencies [2d039f6] + - @anvia/core@0.6.1 ## 0.2.5 diff --git a/apps/docs/content/docs/changelog/openai.mdx b/apps/docs/content/docs/changelog/openai.mdx index 2b3e719a..cc3e78d0 100644 --- a/apps/docs/content/docs/changelog/openai.mdx +++ b/apps/docs/content/docs/changelog/openai.mdx @@ -7,7 +7,27 @@ description: "Release notes for @anvia/openai." OpenAI provider adapter for Anvia. -Source: [`packages/providers/openai/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/providers/openai/CHANGELOG.md) +Source: [`packages/provider-openai/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/provider-openai/CHANGELOG.md) + +## 0.3.9 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.3.8 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + +## 0.3.7 + +### Patch Changes + +- Updated dependencies [2d039f6] + - @anvia/core@0.6.1 ## 0.3.6 diff --git a/apps/docs/content/docs/changelog/otel.mdx b/apps/docs/content/docs/changelog/otel.mdx index 90b9f41c..1cb9547a 100644 --- a/apps/docs/content/docs/changelog/otel.mdx +++ b/apps/docs/content/docs/changelog/otel.mdx @@ -7,7 +7,27 @@ description: "Release notes for @anvia/otel." OpenTelemetry tracing adapter for Anvia. -Source: [`packages/observability/otel/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/observability/otel/CHANGELOG.md) +Source: [`packages/observability-otel/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/observability-otel/CHANGELOG.md) + +## 0.2.7 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.2.6 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [2d039f6] + - @anvia/core@0.6.1 ## 0.2.4 diff --git a/apps/docs/content/docs/changelog/pgvector.mdx b/apps/docs/content/docs/changelog/pgvector.mdx index e0aa61a8..bd79ff7e 100644 --- a/apps/docs/content/docs/changelog/pgvector.mdx +++ b/apps/docs/content/docs/changelog/pgvector.mdx @@ -7,7 +7,27 @@ description: "Release notes for @anvia/pgvector." Postgres pgvector store adapter for Anvia. -Source: [`packages/vector-stores/pgvector/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/vector-stores/pgvector/CHANGELOG.md) +Source: [`packages/vector-pgvector/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/vector-pgvector/CHANGELOG.md) + +## 0.2.7 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.2.6 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [2d039f6] + - @anvia/core@0.6.1 ## 0.2.4 diff --git a/apps/docs/content/docs/changelog/pinecone.mdx b/apps/docs/content/docs/changelog/pinecone.mdx new file mode 100644 index 00000000..8eb85e09 --- /dev/null +++ b/apps/docs/content/docs/changelog/pinecone.mdx @@ -0,0 +1,30 @@ +--- +title: "@anvia/pinecone" +description: "Release notes for @anvia/pinecone." +--- + +# `@anvia/pinecone` + +Pinecone vector store adapter for Anvia. + +Source: [`packages/vector-pinecone/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/vector-pinecone/CHANGELOG.md) + +## 0.3.2 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.3.1 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + +## 0.3.0 + +### Minor Changes + +- ce25d82: Add Pinecone and Milvus vector store adapters following the existing pattern (Chroma, PgVector, Qdrant). Both implement the `VectorSearchIndex` interface with full filter translation, multi-embedding support, and `asTool()` integration. + diff --git a/apps/docs/content/docs/changelog/qdrant.mdx b/apps/docs/content/docs/changelog/qdrant.mdx index 298df780..c9a54460 100644 --- a/apps/docs/content/docs/changelog/qdrant.mdx +++ b/apps/docs/content/docs/changelog/qdrant.mdx @@ -7,7 +7,27 @@ description: "Release notes for @anvia/qdrant." Qdrant vector store adapter for Anvia. -Source: [`packages/vector-stores/qdrant/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/vector-stores/qdrant/CHANGELOG.md) +Source: [`packages/vector-qdrant/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/vector-qdrant/CHANGELOG.md) + +## 0.2.7 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.2.6 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [2d039f6] + - @anvia/core@0.6.1 ## 0.2.4 diff --git a/apps/docs/content/docs/changelog/react.mdx b/apps/docs/content/docs/changelog/react.mdx index 7af9d142..f384f54b 100644 --- a/apps/docs/content/docs/changelog/react.mdx +++ b/apps/docs/content/docs/changelog/react.mdx @@ -9,6 +9,12 @@ React hooks and client transports for Anvia applications. Source: [`packages/react/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/react/CHANGELOG.md) +## 0.4.0 + +### Minor Changes + +- da736e9: Add `useCompletion` hook for single-prompt text completion streaming and `createDirectTransport` for in-process transport without HTTP. + ## 0.3.1 ### Patch Changes diff --git a/apps/docs/content/docs/changelog/sandbox.mdx b/apps/docs/content/docs/changelog/sandbox.mdx index 090604c1..152b184b 100644 --- a/apps/docs/content/docs/changelog/sandbox.mdx +++ b/apps/docs/content/docs/changelog/sandbox.mdx @@ -7,7 +7,33 @@ description: "Release notes for @anvia/sandbox." Sandboxed workspace tools for Anvia agents. -Source: [`packages/tools/sandbox/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/tools/sandbox/CHANGELOG.md) +Source: [`packages/tool-sandbox/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/tool-sandbox/CHANGELOG.md) + +## 0.3.2 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.3.1 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + +## 0.3.0 + +### Minor Changes + +- 18403b7: Add sandbox V2 controls for persistent workspaces, lifecycle cleanup, streaming command execution, file-size limits, observability hooks, language presets, and model-facing tool policies. + +## 0.2.2 + +### Patch Changes + +- Updated dependencies [2d039f6] + - @anvia/core@0.6.1 ## 0.2.1 diff --git a/apps/docs/content/docs/changelog/studio.mdx b/apps/docs/content/docs/changelog/studio.mdx index 355f89d5..4ea9141d 100644 --- a/apps/docs/content/docs/changelog/studio.mdx +++ b/apps/docs/content/docs/changelog/studio.mdx @@ -7,7 +7,40 @@ description: "Release notes for @anvia/studio." Studio UI and HTTP runtime for Anvia agents. -Source: [`packages/tools/studio/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/tools/studio/CHANGELOG.md) +Source: [`packages/tool-studio/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/tool-studio/CHANGELOG.md) + +## 0.6.0 + +### Minor Changes + +- e09746c: Add multi-provider model selection and multimodal attachment support to Studio, including cookbook documentation and assistant loading feedback in the playground. + +## 0.5.14 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.5.13 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + +## 0.5.12 + +### Patch Changes + +- Updated dependencies [da736e9] + - @anvia/react@0.4.0 + +## 0.5.11 + +### Patch Changes + +- Updated dependencies [2d039f6] + - @anvia/core@0.6.1 ## 0.5.10 diff --git a/apps/docs/content/docs/changelog/transformers.mdx b/apps/docs/content/docs/changelog/transformers.mdx index 253f1161..06db5427 100644 --- a/apps/docs/content/docs/changelog/transformers.mdx +++ b/apps/docs/content/docs/changelog/transformers.mdx @@ -7,7 +7,27 @@ description: "Release notes for @anvia/transformers." Transformers.js embedding model adapter for Anvia. -Source: [`packages/embeddings/transformers/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/embeddings/transformers/CHANGELOG.md) +Source: [`packages/embedding-transformers/CHANGELOG.md`](https://github.com/anvia-hq/anvia/blob/main/packages/embedding-transformers/CHANGELOG.md) + +## 0.2.7 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.2.6 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + +## 0.2.5 + +### Patch Changes + +- Updated dependencies [2d039f6] + - @anvia/core@0.6.1 ## 0.2.4 diff --git a/apps/docs/content/docs/guides/cookbook.mdx b/apps/docs/content/docs/guides/cookbook.mdx index 5bb82a5e..551fedd7 100644 --- a/apps/docs/content/docs/guides/cookbook.mdx +++ b/apps/docs/content/docs/guides/cookbook.mdx @@ -13,13 +13,13 @@ 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, dynamic tool selection, and Docker sandbox tools | | Structured output | Extraction, output schemas, context, retries, and extraction with history | -| Providers and multimodal | Provider adapters, model capabilities, model listing, 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, transcription, and Anthropic | | 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 | +| Retrieval | Embeddings, vector search, metadata filters, RAG context, document loaders, vector stores, Milvus, Pinecone, and embedding provider variants | | 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, multi-agent, and subagent runners, tool approvals, questions, and Knowledge inspection | -| Integrations | MCP tools, local skills, Langfuse tracing, and Langfuse eval reporting | +| Integrations | MCP tools, local skills, Langfuse tracing, logging, and Langfuse eval reporting | ## 1. Install Dependencies @@ -35,8 +35,13 @@ Create a local `.env` file for examples that call provider APIs: OPENAI_API_KEY=... OPENAI_BASEURL=... ANTHROPIC_API_KEY=... +ANTHROPIC_BASEURL=... GEMINI_API_KEY=... MISTRAL_API_KEY=... +DATABASE_URL=... +PINECONE_API_KEY=... +PINECONE_INDEX_NAME=... +PINECONE_NAMESPACE=... ``` Anvia clients still receive credentials explicitly in code. The cookbook uses `dotenv` only as a local configuration source. @@ -78,9 +83,9 @@ pnpm cookbook:integrations:04 Legacy names such as `cookbook:basic:05`, `cookbook:intermediate:14`, `cookbook:pipeline:04`, `cookbook:rag:05`, and `cookbook:multimodal:03` remain available as aliases. -## 4. Use Chroma Examples +## 4. Use Vector Store Examples -Start ChromaDB before running the Chroma-backed RAG examples: +Start the local vector databases before running Chroma, Qdrant, pgvector, or Milvus examples: ```sh docker compose -f examples/cookbook/compose.cookbook.yml up -d @@ -88,8 +93,11 @@ pnpm cookbook:retrieval:05 pnpm cookbook:retrieval:06 pnpm cookbook:retrieval:07 pnpm cookbook:retrieval:08 +pnpm cookbook:retrieval:12 ``` +`retrieval:13` uses Pinecone. Create a 384-dimension Pinecone index first, then set `PINECONE_API_KEY`, `PINECONE_INDEX_NAME`, and optionally `PINECONE_NAMESPACE`. + Use the in-memory and Transformers examples when you do not need a separate vector database. ## 5. Map Examples to Guides @@ -100,15 +108,17 @@ Use the in-memory and Transformers examples when you do not need a separate vect | 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) | +| Use Anthropic | `providers:11` | [Anthropic Provider](/docs/reference/providers/anthropic) | | Stream agent events | `tools:02` | [Streaming Events](/docs/guides/streaming/streaming-events) | | Stream over HTTP transports | `basics:07` | [Client Transports](/docs/guides/streaming/client-transports) | | Render reasoning summaries | `providers:04` | [Streaming Events](/docs/guides/streaming/streaming-events) | | Select dynamic tools | `tools:09` | [Tool Sets](/docs/guides/tools/tool-sets) | | Run code in a Docker sandbox | `tools:11` | [Sandbox](/docs/guides/sandbox) | | Add approval behavior | `tools:08`, `studio:03` | [Human in the Loop](/docs/guides/human-in-the-loop) | -| Add retrieval | `retrieval:01` through `retrieval:06` | [Add Retrieval](/docs/guides/learning-paths/add-retrieval) | +| Add retrieval | `retrieval:01` through `retrieval:13` | [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) | +| Add logging | `integrations:06`, `integrations:07` | [Logging](/docs/guides/observability/logging) | | 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/sandbox/agent-tools.mdx b/apps/docs/content/docs/guides/sandbox/agent-tools.mdx index 4c23a608..ae484e3c 100644 --- a/apps/docs/content/docs/guides/sandbox/agent-tools.mdx +++ b/apps/docs/content/docs/guides/sandbox/agent-tools.mdx @@ -12,9 +12,24 @@ import { createSandboxTools, DockerSandbox } from "@anvia/sandbox"; const sandbox = new DockerSandbox(); const session = await sandbox.createSession(); +const tools = createSandboxTools(session, { + allow: ["exec_command", "read_file", "write_file", "list_files"], + exec: { + allowedCommands: ["node", "npm"], + defaultTimeoutMs: 10_000, + maxTimeoutMs: 30_000, + }, + readFile: { + maxBytes: 64_000, + }, + writeFile: { + maxBytes: 64_000, + }, +}); + const agent = new AgentBuilder("coder", model) .instructions("Use the sandbox for code execution and file operations.") - .tools(createSandboxTools(session)) + .tools(tools) .defaultMaxTurns(6) .build(); @@ -39,3 +54,22 @@ The default bundle exposes: | `list_files` | List files and directories under a workspace path | Keep the session lifetime explicit. Create the session before the agent run and destroy it in a `finally` block. + +## Tool Policy + +Use `allow` to choose which sandbox tools are exposed. Use `exec.allowedCommands` or `exec.blockedCommands` to keep model-chosen commands inside the product boundary. + +```ts +const tools = createSandboxTools(session, { + allow: ["exec_command", "read_file", "list_files"], + exec: { + allowedCommands: ["node", "npm"], + maxTimeoutMs: 30_000, + }, + readFile: { + maxBytes: 64_000, + }, +}); +``` + +Tool policy is separate from Docker isolation. Docker limits protect the host; tool policy controls what the model can ask the session to do. diff --git a/apps/docs/content/docs/guides/sandbox/docker-sessions.mdx b/apps/docs/content/docs/guides/sandbox/docker-sessions.mdx index 0fc34101..3dd81ed5 100644 --- a/apps/docs/content/docs/guides/sandbox/docker-sessions.mdx +++ b/apps/docs/content/docs/guides/sandbox/docker-sessions.mdx @@ -1,6 +1,6 @@ --- title: Docker Sessions -description: Create and clean up ephemeral Docker sandbox sessions. +description: Create, stream, persist, and clean up Docker sandbox sessions. --- Create a `DockerSandbox`, start a session, run commands, then destroy the session when the workflow is done. @@ -10,12 +10,23 @@ import { DockerSandbox } from "@anvia/sandbox"; const sandbox = new DockerSandbox({ image: "node:22-bookworm", + workspace: { + mode: "ephemeral", + }, + lifecycle: { + ttlMs: 10 * 60_000, + idleTimeoutMs: 60_000, + }, limits: { timeoutMs: 30_000, maxOutputBytes: 64_000, + maxFileBytes: 5_000_000, memoryMb: 512, cpus: 1, }, + network: { + mode: "none", + }, }); const session = await sandbox.createSession({ @@ -43,3 +54,72 @@ try { `DockerSandbox` disables network access by default, drops Docker capabilities, enables `no-new-privileges`, rejects unsafe paths, truncates large output, and enforces command timeouts. Do not bind mount the host project into a sandbox for untrusted code. Stage the files a task needs through `manifest`, `writeFile(...)`, or `write_file`. + +## Workspace Modes + +The default workspace mode is ephemeral. Each session gets a new Docker volume, and `destroy()` removes it. + +```ts +const sandbox = new DockerSandbox({ + workspace: { + mode: "ephemeral", + }, +}); +``` + +Use a persistent workspace only when continuity is intentional. Persistent workspaces reuse a named Docker volume and keep it after `destroy()` by default. + +```ts +const sandbox = new DockerSandbox({ + workspace: { + mode: "persistent", + id: `user-${userId}`, + }, +}); +``` + +You can also choose persistence per session: + +```ts +const session = await sandbox.createSession({ + workspace: { + mode: "persistent", + id: `session-${sessionId}`, + }, +}); +``` + +## Streaming Commands + +Use `execStream(...)` when command output should appear while the command is still running. + +```ts +for await (const event of session.execStream({ + command: "npm", + args: ["test"], + timeoutMs: 60_000, +})) { + if (event.type === "stdout" || event.type === "stderr") { + process.stdout.write(event.text); + } + + if (event.type === "exit") { + console.log(event.result.exitCode); + } +} +``` + +## Lifecycle Cleanup + +Set lifecycle timers when a session should clean itself up even if application code forgets to call `destroy()`. + +```ts +const sandbox = new DockerSandbox({ + lifecycle: { + ttlMs: 10 * 60_000, + idleTimeoutMs: 60_000, + }, +}); +``` + +`ttlMs` is a maximum lifetime. `idleTimeoutMs` starts after the session has no active operations. diff --git a/apps/docs/content/docs/guides/sandbox/index.mdx b/apps/docs/content/docs/guides/sandbox/index.mdx index 923c03c9..2c710e9d 100644 --- a/apps/docs/content/docs/guides/sandbox/index.mdx +++ b/apps/docs/content/docs/guides/sandbox/index.mdx @@ -1,6 +1,6 @@ --- title: Sandbox -description: Run code and file operations inside an ephemeral Docker workspace. +description: Run code and file operations inside an isolated Docker workspace. --- `@anvia/sandbox` lets an agent work inside an isolated Docker workspace instead of the host filesystem. @@ -26,6 +26,6 @@ Use a sandbox when an agent needs a scratch workspace for operations such as: | Inspect command output | Capture stdout, stderr, exit code, and timeout state | | Run multi-step tool flows | Share files between sandbox tools without exposing project files | -V1 sessions are ephemeral. `destroy()` removes the container and workspace volume. +Sessions are ephemeral by default. `destroy()` removes the container and workspace volume unless you explicitly choose a persistent workspace. Next, create your first [Docker session](/docs/guides/sandbox/docker-sessions). diff --git a/apps/docs/content/docs/packages/index.mdx b/apps/docs/content/docs/packages/index.mdx index cbd1bf55..5e246db9 100644 --- a/apps/docs/content/docs/packages/index.mdx +++ b/apps/docs/content/docs/packages/index.mdx @@ -35,6 +35,8 @@ Each guide covers one `@anvia/*` package end to end: installation, configuration | Package | Purpose | | --- | --- | | [`@anvia/chroma`](/docs/packages/chroma) | ChromaDB vector store adapter | +| [`@anvia/milvus`](/docs/packages/milvus) | Milvus vector store adapter | +| [`@anvia/pinecone`](/docs/packages/pinecone) | Pinecone vector store adapter | | [`@anvia/pgvector`](/docs/packages/pgvector) | Postgres pgvector vector store adapter | | [`@anvia/qdrant`](/docs/packages/qdrant) | Qdrant vector store adapter | diff --git a/apps/docs/content/docs/packages/meta.json b/apps/docs/content/docs/packages/meta.json index 5a235807..7cffcdbb 100644 --- a/apps/docs/content/docs/packages/meta.json +++ b/apps/docs/content/docs/packages/meta.json @@ -20,6 +20,8 @@ "transformers", "---Vector Stores---", "chroma", + "milvus", + "pinecone", "pgvector", "qdrant", "---Observability---", diff --git a/apps/docs/content/docs/packages/milvus.mdx b/apps/docs/content/docs/packages/milvus.mdx new file mode 100644 index 00000000..77235f13 --- /dev/null +++ b/apps/docs/content/docs/packages/milvus.mdx @@ -0,0 +1,176 @@ +--- +title: "@anvia/milvus" +description: Milvus vector store adapter for Anvia. +--- + +`@anvia/milvus` connects Anvia's vector store interface to Milvus. Use it to store embedded documents in a Milvus collection and search them through Anvia retrieval workflows. + +## Install + +```sh +pnpm add @anvia/milvus +``` + +The Milvus client (`@zilliz/milvus2-sdk-node`) is a transitive dependency. By default, the adapter connects to `localhost:19530`; pass a custom client when you need a different endpoint. + +## Quick Start + +```ts +import { createFastEmbedEmbeddingModel } from "@anvia/fastembed"; +import { MilvusVectorStore } from "@anvia/milvus"; + +const model = await createFastEmbedEmbeddingModel(); + +const store = await MilvusVectorStore.connect({ + collectionName: "documents", + vectorSize: 384, // must match your embedding model's dimensions +}); + +await store.upsertDocuments([ + { + id: "doc-1", + document: "Anvia is a TypeScript AI agent framework.", + embeddings: await model.embedTexts(["Anvia is a TypeScript AI agent framework."]), + }, +]); + +const index = store.index(model); +const results = await index.search({ query: "What is Anvia?", topK: 5 }); +console.log(results[0].document); +``` + +## Connection Options + +```ts +type MilvusVectorStoreConnectOptions = { + client?: MilvusClientLike; // pre-configured Milvus client + collectionName: string; // required + vectorSize: number; // required: embedding dimensions + createIfMissing?: boolean; // default: true + metric?: MilvusMetric; // default: "COSINE" +}; +``` + +```ts +// Default: creates the collection if missing and loads it +const store = await MilvusVectorStore.connect({ + collectionName: "docs", + vectorSize: 384, +}); + +// Don't create if missing +const store = await MilvusVectorStore.connect({ + collectionName: "docs", + vectorSize: 384, + createIfMissing: false, +}); + +// Custom distance metric +const store = await MilvusVectorStore.connect({ + collectionName: "docs", + vectorSize: 384, + metric: "IP", +}); +``` + +When `createIfMissing` is `true` (default), the adapter: + +1. Checks whether the collection exists +2. Creates a collection with an `id`, document fields, and a `FloatVector` field +3. Creates an HNSW index on the vector field +4. Loads the collection before returning the store + +## Upserting Documents + +```ts +await store.upsertDocuments([ + { + id: "doc-1", + document: { title: "Getting Started", content: "..." }, + metadata: { category: "guide", version: 2 }, + embeddings: await model.embedTexts(["Getting Started content..."]), + }, +]); +``` + +- Documents are inserted as Milvus rows with document content and metadata fields +- Documents with multiple embeddings get indexed as `doc-1#embedding:0`, `doc-1#embedding:1`, etc. +- Row IDs are deterministic SHA-256 hashes of the document ID + +## Searching + +```ts +const index = store.index(model); + +// Basic search +const results = await index.search({ query: "agent tools", topK: 5 }); + +// With threshold +const results = await index.search({ + query: "agent tools", + topK: 10, + threshold: 0.7, +}); + +// With metadata filter +const results = await index.search({ + query: "agent tools", + topK: 5, + filter: { type: "eq", key: "category", value: "guide" }, +}); +``` + +## Metadata Filters + +```ts +// Equality +{ type: "eq", key: "category", value: "guide" } + +// Range +{ type: "gt", key: "version", value: 2 } +{ type: "lt", key: "version", value: 5 } + +// Logical combinators +{ type: "and", filters: [...] } +{ type: "or", filters: [...] } +``` + +Filters are translated to Milvus boolean expressions such as `category == "guide"` or `(category == "guide") && (version > 2)`. + +## Distance Metrics + +| Metric | Milvus value | Notes | +| --- | --- | --- | +| Cosine (default) | `"COSINE"` | Best for text embeddings | +| L2 | `"L2"` | Euclidean distance | +| Inner product | `"IP"` | Dot product similarity | + +## Agent Tool + +Expose the vector index as an agent tool: + +```ts +import { AgentBuilder } from "@anvia/core"; + +const searchTool = index.asTool({ + name: "search_docs", + description: "Search the documentation.", +}); + +const agent = new AgentBuilder("support", model) + .tool(searchTool) + .build(); +``` + +## Error Handling + +- `connect()` throws if Milvus is unreachable +- `connect({ createIfMissing: false })` throws if the collection does not exist +- `upsertDocuments()` throws if a document has zero embeddings +- Metadata keys starting with `__anvia_` are reserved and rejected + +## Related + +- [Retrieval Guide](/docs/guides/retrieval) for RAG concepts +- [`@anvia/fastembed`](/docs/packages/fastembed) for local embeddings +- [`@anvia/transformers`](/docs/packages/transformers) for local Transformers.js embeddings diff --git a/apps/docs/content/docs/packages/pinecone.mdx b/apps/docs/content/docs/packages/pinecone.mdx new file mode 100644 index 00000000..fc3971e2 --- /dev/null +++ b/apps/docs/content/docs/packages/pinecone.mdx @@ -0,0 +1,172 @@ +--- +title: "@anvia/pinecone" +description: Pinecone vector store adapter for Anvia. +--- + +`@anvia/pinecone` connects Anvia's vector store interface to Pinecone. Use it to store embedded documents in a Pinecone index and query them through Anvia retrieval workflows. + +## Install + +```sh +pnpm add @anvia/pinecone +``` + +The Pinecone client (`@pinecone-database/pinecone`) is a transitive dependency. Configure Pinecone credentials the same way you would for the official client. + +## Quick Start + +```ts +import { createFastEmbedEmbeddingModel } from "@anvia/fastembed"; +import { PineconeVectorStore } from "@anvia/pinecone"; + +const model = await createFastEmbedEmbeddingModel(); + +const store = await PineconeVectorStore.connect({ + indexName: "documents", + namespace: "support", +}); + +await store.upsertDocuments([ + { + id: "doc-1", + document: "Anvia is a TypeScript AI agent framework.", + embeddings: await model.embedTexts(["Anvia is a TypeScript AI agent framework."]), + }, +]); + +const index = store.index(model); +const results = await index.search({ query: "What is Anvia?", topK: 5 }); +console.log(results[0].document); +``` + +## Connection Options + +```ts +type PineconeVectorStoreConnectOptions = { + client?: PineconeClientLike; // pre-configured Pinecone client + indexName: string; // required + namespace?: string; // default: "" + createIfMissing?: boolean; // default: true + metric?: PineconeMetric; // default: "cosine" +}; +``` + +```ts +// Default: creates the index if missing and uses the default namespace +const store = await PineconeVectorStore.connect({ + indexName: "docs", +}); + +// Use a namespace +const store = await PineconeVectorStore.connect({ + indexName: "docs", + namespace: "production", +}); + +// Don't create if missing +const store = await PineconeVectorStore.connect({ + indexName: "docs", + createIfMissing: false, +}); + +// Custom distance metric +const store = await PineconeVectorStore.connect({ + indexName: "docs", + metric: "dotproduct", +}); +``` + +## Upserting Documents + +```ts +await store.upsertDocuments([ + { + id: "doc-1", + document: { title: "Getting Started", content: "..." }, + metadata: { category: "guide", version: 2 }, + embeddings: await model.embedTexts(["Getting Started content..."]), + }, +]); +``` + +- Documents are stored as Pinecone vectors with document content and metadata in the vector metadata +- Documents with multiple embeddings get indexed as `doc-1#embedding:0`, `doc-1#embedding:1`, etc. +- Vector IDs are deterministic SHA-256 hashes of the document ID + +## Searching + +```ts +const index = store.index(model); + +// Basic search +const results = await index.search({ query: "agent tools", topK: 5 }); + +// With threshold +const results = await index.search({ + query: "agent tools", + topK: 10, + threshold: 0.7, +}); + +// With metadata filter +const results = await index.search({ + query: "agent tools", + topK: 5, + filter: { type: "eq", key: "category", value: "guide" }, +}); +``` + +## Metadata Filters + +```ts +// Equality +{ type: "eq", key: "category", value: "guide" } + +// Range +{ type: "gt", key: "version", value: 2 } +{ type: "lt", key: "version", value: 5 } + +// Logical combinators +{ type: "and", filters: [...] } +{ type: "or", filters: [...] } +``` + +Filters are translated to Pinecone's native filter format (`$eq`, `$gt`, `$lt`, `$and`, `$or`). + +## Distance Metrics + +| Metric | Pinecone value | Notes | +| --- | --- | --- | +| Cosine (default) | `"cosine"` | Best for text embeddings | +| Euclidean | `"euclidean"` | L2 distance | +| Dot product | `"dotproduct"` | Dot product similarity | + +## Agent Tool + +Expose the vector index as an agent tool: + +```ts +import { AgentBuilder } from "@anvia/core"; + +const searchTool = index.asTool({ + name: "search_docs", + description: "Search the documentation.", +}); + +const agent = new AgentBuilder("support", model) + .tool(searchTool) + .build(); +``` + +## Error Handling + +- `connect()` throws if Pinecone credentials or network access fail +- `connect({ createIfMissing: false })` throws if the index does not exist +- `upsertDocuments()` throws if a document has zero embeddings +- Metadata keys starting with `__anvia_` are reserved and rejected + +## Related + +- [Retrieval Guide](/docs/guides/retrieval) for RAG concepts +- [`@anvia/fastembed`](/docs/packages/fastembed) for local embeddings +- [`@anvia/transformers`](/docs/packages/transformers) for local Transformers.js embeddings diff --git a/apps/docs/content/docs/packages/sandbox.mdx b/apps/docs/content/docs/packages/sandbox.mdx index b019995e..9e2f753d 100644 --- a/apps/docs/content/docs/packages/sandbox.mdx +++ b/apps/docs/content/docs/packages/sandbox.mdx @@ -36,12 +36,15 @@ type DockerSandboxOptions = { image?: string; // Docker image (default: "node:22-bookworm") pull?: "missing" | "always" | "never"; // pull policy (default: "missing") workdir?: string; // workspace directory (default: "/workspace") - network?: boolean | "none" | "host" | string; // network mode (default: false/none) + workspace?: SandboxWorkspaceOptions; // ephemeral or persistent volume + lifecycle?: SandboxLifecycleOptions; // ttl and idle cleanup + network?: boolean | "none" | "host" | string | { mode: boolean | "none" | "host" | string }; user?: string; // container user dockerPath?: string; // path to docker CLI labels?: Record; // Docker container labels limits?: SandboxLimits; // resource limits security?: DockerSandboxSecurityOptions; + hooks?: SandboxHooks; }; ``` @@ -49,15 +52,23 @@ type DockerSandboxOptions = { // Custom image and limits const sandbox = new DockerSandbox({ image: "python:3.12-slim", - limits: { timeoutMs: 30_000, memoryMb: 512, cpus: 1 }, + limits: { timeoutMs: 30_000, maxFileBytes: 5_000_000, memoryMb: 512, cpus: 1 }, }); // Network access enabled const sandbox = new DockerSandbox({ - network: "host", + network: { mode: "host" }, }); ``` +### Presets + +```ts +const nodeSandbox = DockerSandbox.node(); +const pythonSandbox = DockerSandbox.python(); +const denoSandbox = DockerSandbox.deno(); +``` + ### Security Defaults By default, sandboxes run with: @@ -86,6 +97,7 @@ Each session creates one Docker container and one Docker volume mounted at the w ```ts const session = await sandbox.createSession({ id: "my-session", // optional custom ID + workspace: { mode: "ephemeral" }, metadata: { userId: "123" }, // optional metadata manifest: { files: { @@ -100,6 +112,17 @@ const session = await sandbox.createSession({ The manifest seeds the workspace with files and directories before any commands run. +Use a persistent workspace only when continuity is explicit: + +```ts +const session = await sandbox.createSession({ + workspace: { + mode: "persistent", + id: `user-${userId}`, + }, +}); +``` + ### Executing Commands ```ts @@ -122,6 +145,16 @@ console.log(result.durationMs); console.log(result.timedOut); ``` +Stream command output with `execStream(...)`: + +```ts +for await (const event of session.execStream({ command: "npm", args: ["test"] })) { + if (event.type === "stdout" || event.type === "stderr") { + process.stdout.write(event.text); + } +} +``` + ### File Operations ```ts @@ -155,7 +188,13 @@ import { createSandboxTools } from "@anvia/sandbox"; import { AgentBuilder } from "@anvia/core"; const session = await sandbox.createSession(); -const tools = createSandboxTools(session); +const tools = createSandboxTools(session, { + allow: ["exec_command", "read_file", "write_file", "list_files"], + exec: { + allowedCommands: ["node", "npm"], + maxTimeoutMs: 30_000, + }, +}); const agent = new AgentBuilder("coder", model) .instructions("Write and run code to solve problems.") diff --git a/apps/docs/content/docs/reference/api-coverage.mdx b/apps/docs/content/docs/reference/api-coverage.mdx index 146ad728..aca28dde 100644 --- a/apps/docs/content/docs/reference/api-coverage.mdx +++ b/apps/docs/content/docs/reference/api-coverage.mdx @@ -16,7 +16,7 @@ Internal source-file exports are outside this check unless they are re-exported | Package | Public entrypoints | Public exports | Reference coverage | | --- | ---: | ---: | --- | | `@anvia/core` | 19 | 250 | [Core](/docs/reference/core) | -| `@anvia/sandbox` | 1 | 23 | [Sandbox](/docs/reference/sandbox) | +| `@anvia/sandbox` | 1 | 36 | [Sandbox](/docs/reference/sandbox) | | `@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) | @@ -28,7 +28,7 @@ Internal source-file exports are outside this check unless they are re-exported | `@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) | +| `@anvia/studio` | 1 | 128 | [Studio](/docs/reference/studio) | The check must report: diff --git a/apps/docs/content/docs/reference/core/completion.mdx b/apps/docs/content/docs/reference/core/completion.mdx index 32327021..e5f9fc17 100644 --- a/apps/docs/content/docs/reference/core/completion.mdx +++ b/apps/docs/content/docs/reference/core/completion.mdx @@ -244,6 +244,18 @@ Return behavior: streaming models yield deltas and finish with a `final` event. Notable errors: provider adapters can throw transport, authentication, provider validation, stream, or capability validation errors. +## Model Utilities + +```ts +function isStreamingCompletionModel(model: CompletionModel): model is StreamingCompletionModel; +``` + +Purpose: type guard that checks whether a completion model supports streaming. + +Return behavior: returns `true` when the model implements `streamCompletion`. + +Notable errors: none. + ## Capability Validation ```ts @@ -355,6 +367,18 @@ Return behavior: `build()` returns a `CompletionRequest`; `send()` validates cap Notable errors: `send()` throws `CompletionCapabilityError` before provider transport when the request uses unsupported features, and otherwise forwards model errors. +## Tool Result Serialization + +```ts +function serializeToolResultOutput(output: unknown): string; +``` + +Purpose: serializes tool output to a string, with defensive error handling for non-serializable values. + +Return behavior: returns string values unchanged; JSON-stringifies objects and arrays; falls back to `String(output)` when serialization fails. + +Notable errors: none. + ## Document Helpers ```ts diff --git a/apps/docs/content/docs/reference/integrations/chroma.mdx b/apps/docs/content/docs/reference/integrations/chroma.mdx index 9d015f71..310cf69e 100644 --- a/apps/docs/content/docs/reference/integrations/chroma.mdx +++ b/apps/docs/content/docs/reference/integrations/chroma.mdx @@ -71,3 +71,14 @@ Purpose: convert Anvia vector filters to Chroma `where` filters. Return behavior: returns `undefined` when no filter is supplied. Notable errors: none directly. + +## ChromaCollectionLike + +```ts +type ChromaCollectionLike = { + upsert(options: Record): Promise; + query(options: Record): Promise; +}; +``` + +Purpose: duck-typed interface for a Chroma collection. diff --git a/apps/docs/content/docs/reference/integrations/milvus.mdx b/apps/docs/content/docs/reference/integrations/milvus.mdx new file mode 100644 index 00000000..ebba33da --- /dev/null +++ b/apps/docs/content/docs/reference/integrations/milvus.mdx @@ -0,0 +1,81 @@ +--- +title: Milvus +description: Public exports from @anvia/milvus. +--- + +Import from `@anvia/milvus`. + +## MilvusVectorStoreConnectOptions + +```ts +type MilvusVectorStoreConnectOptions = { + client?: MilvusClientLike; + collectionName: string; + vectorSize: number; + createIfMissing?: boolean; + metric?: "COSINE" | "L2" | "IP"; +}; +``` + +Purpose: connection options for a Milvus collection. + +Return behavior: consumed by `MilvusVectorStore.connect(...)`. + +Notable errors: collection lookup fails when `createIfMissing` is `false` and the collection does not exist. + +Design note: `connect(...)` optionally creates a collection with HNSW index, then loads it into memory. This keeps constructors synchronous and side-effect free while ensuring the collection exists before ingestion or search. + +## MilvusVectorStore + +```ts +class MilvusVectorStore { + static connect( + options: MilvusVectorStoreConnectOptions, + ): Promise>; + upsertDocuments(documents: Array>): Promise; + index(model: EmbeddingModel): MilvusVectorIndex; +} +``` + +Purpose: Milvus-backed document storage. + +Return behavior: `connect(...)` resolves a store; `index(...)` binds it to an embedding model. + +Notable errors: connection and insert calls reject on Milvus errors; `upsertDocuments(...)` throws when a document has no embeddings. + +## MilvusVectorIndex + +```ts +class MilvusVectorIndex + implements VectorSearchIndex { + search(request: VectorSearchRequest): Promise>>; + searchIds(request: VectorSearchRequest): Promise>; + asTool(options: VectorSearchToolOptions): Tool<{ query: string; topK?: number }, unknown>; +} +``` + +Purpose: query-time Milvus search adapter. + +Return behavior: embeds the query, searches Milvus with boolean expression filters, deduplicates multi-embedding document IDs, and returns normalized results. + +Notable errors: embedding or Milvus search failures reject. + +## filterToMilvusExpr + +```ts +function filterToMilvusExpr(filter: VectorFilter | undefined): string | undefined; +``` + +Purpose: convert Anvia vector filters to Milvus boolean expression strings. + +Return behavior: returns `undefined` when no filter is supplied. + +Notable errors: none directly. + +## MilvusMetric + +```ts +type MilvusMetric = "COSINE" | "L2" | "IP"; +``` + +Purpose: distance metric for a Milvus collection. diff --git a/apps/docs/content/docs/reference/integrations/pinecone.mdx b/apps/docs/content/docs/reference/integrations/pinecone.mdx new file mode 100644 index 00000000..ba9a0e26 --- /dev/null +++ b/apps/docs/content/docs/reference/integrations/pinecone.mdx @@ -0,0 +1,102 @@ +--- +title: Pinecone +description: Public exports from @anvia/pinecone. +--- + +Import from `@anvia/pinecone`. + +## PineconeVectorStoreConnectOptions + +```ts +type PineconeVectorStoreConnectOptions = { + client?: PineconeClientLike; + indexName: string; + namespace?: string; + createIfMissing?: boolean; + metric?: "cosine" | "euclidean" | "dotproduct"; +}; +``` + +Purpose: connection options for a Pinecone index. + +Return behavior: consumed by `PineconeVectorStore.connect(...)`. + +Notable errors: index lookup fails when `createIfMissing` is `false` and the index does not exist. + +Design note: `connect(...)` optionally creates a serverless index, then resolves a namespace handle. This keeps constructors synchronous and side-effect free while ensuring the index exists before ingestion or search. + +## PineconeVectorStore + +```ts +class PineconeVectorStore { + static connect( + options: PineconeVectorStoreConnectOptions, + ): Promise>; + upsertDocuments(documents: Array>): Promise; + index(model: EmbeddingModel): PineconeVectorIndex; +} +``` + +Purpose: Pinecone-backed document storage. + +Return behavior: `connect(...)` resolves a store; `index(...)` binds it to an embedding model. + +Notable errors: connection and upsert calls reject on Pinecone errors; `upsertDocuments(...)` throws when a document has no embeddings. + +## PineconeVectorIndex + +```ts +class PineconeVectorIndex + implements VectorSearchIndex { + search(request: VectorSearchRequest): Promise>>; + searchIds(request: VectorSearchRequest): Promise>; + asTool(options: VectorSearchToolOptions): Tool<{ query: string; topK?: number }, unknown>; +} +``` + +Purpose: query-time Pinecone search adapter. + +Return behavior: embeds the query, queries Pinecone with metadata, deduplicates multi-embedding document IDs, and returns normalized results. + +Notable errors: embedding or Pinecone query failures reject. + +## filterToPineconeFilter + +```ts +function filterToPineconeFilter(filter: VectorFilter | undefined): unknown; +``` + +Purpose: convert Anvia vector filters to Pinecone filter objects. + +Return behavior: returns `undefined` when no filter is supplied. + +Notable errors: none directly. + +## PineconeMetric + +```ts +type PineconeMetric = "cosine" | "euclidean" | "dotproduct"; +``` + +Purpose: distance metric for a Pinecone index. + +## PineconeIndexLike + +```ts +type PineconeIndexLike = { + namespace(namespace: string): PineconeNamespaceLike; +}; +``` + +Purpose: duck-typed interface for a Pinecone index handle. + +## PineconeNamespaceLike + +```ts +type PineconeNamespaceLike = { + upsert(vectors: Array>): Promise; + query(options: Record): Promise; +}; +``` + +Purpose: duck-typed interface for a Pinecone namespace. diff --git a/apps/docs/content/docs/reference/sandbox.mdx b/apps/docs/content/docs/reference/sandbox.mdx index 0f3b721a..919bc63f 100644 --- a/apps/docs/content/docs/reference/sandbox.mdx +++ b/apps/docs/content/docs/reference/sandbox.mdx @@ -11,6 +11,9 @@ Import from `@anvia/sandbox`. class DockerSandbox implements Sandbox { readonly provider: "docker"; constructor(options?: DockerSandboxOptions); + static node(options?: DockerSandboxOptions): DockerSandbox; + static python(options?: DockerSandboxOptions): DockerSandbox; + static deno(options?: DockerSandboxOptions): DockerSandbox; createSession(options?: SandboxCreateSessionOptions): Promise; } ``` @@ -34,6 +37,7 @@ interface SandboxSession { readonly provider: string; readonly workdir: string; exec(options: SandboxExecOptions): Promise; + execStream(options: SandboxExecOptions): AsyncIterable; readFile(path: string): Promise; readTextFile(path: string): Promise; writeFile(path: string, data: string | Uint8Array): Promise; @@ -52,10 +56,19 @@ Return behavior: file paths are sandbox-relative; absolute paths and traversal o ```ts type SandboxCreateSessionOptions = { id?: string; + workspace?: SandboxWorkspaceOptions; manifest?: SandboxManifest; metadata?: Record; }; +type SandboxWorkspaceOptions = + | { mode?: "ephemeral" } + | { + mode: "persistent"; + id: string; + destroyOnSessionDestroy?: boolean; + }; + type SandboxManifest = { files?: Record; directories?: string[]; @@ -65,13 +78,20 @@ type SandboxManifest = { type SandboxLimits = { timeoutMs?: number; maxOutputBytes?: number; + maxFileBytes?: number; memoryMb?: number; cpus?: number; pidsLimit?: number; }; + +type SandboxLifecycleOptions = { + ttlMs?: number; + idleTimeoutMs?: number; + autoDestroy?: boolean; +}; ``` -Purpose: seed files, directories, environment, metadata, and runtime limits for a sandbox session. +Purpose: seed files, directories, environment, metadata, workspace mode, lifecycle cleanup, and runtime limits for a sandbox session. ## Docker Options @@ -80,12 +100,15 @@ type DockerSandboxOptions = { image?: string; pull?: "missing" | "always" | "never"; workdir?: string; - network?: SandboxNetworkMode; + workspace?: SandboxWorkspaceOptions; + lifecycle?: SandboxLifecycleOptions; + network?: SandboxNetworkMode | DockerSandboxNetworkOptions; user?: string; dockerPath?: string; labels?: Record; limits?: SandboxLimits; security?: DockerSandboxSecurityOptions; + hooks?: SandboxHooks; }; type DockerSandboxSecurityOptions = { @@ -94,11 +117,17 @@ type DockerSandboxSecurityOptions = { dropCapabilities?: string[]; }; +type DockerSandboxNetworkOptions = { + mode: SandboxNetworkMode; +}; + type SandboxNetworkMode = boolean | "none" | "host" | string; ``` Defaults: `image` is `node:22-bookworm`, `pull` is `missing`, `workdir` is `/workspace`, network access is disabled, `noNewPrivileges` is enabled, and Docker capabilities are dropped with `["ALL"]`. +Static presets: `DockerSandbox.node(...)`, `DockerSandbox.python(...)`, and `DockerSandbox.deno(...)` create sandboxes with language-specific default images. + ## Execution ```ts @@ -124,9 +153,14 @@ type SandboxExecResult = { stdoutTruncated: boolean; stderrTruncated: boolean; }; + +type SandboxExecStreamEvent = + | { type: "stdout"; chunk: Uint8Array; text: string } + | { type: "stderr"; chunk: Uint8Array; text: string } + | { type: "exit"; result: SandboxExecResult }; ``` -Return behavior: non-zero command exits are returned in `SandboxExecResult`; infrastructure failures throw. +Return behavior: non-zero command exits are returned in `SandboxExecResult`; infrastructure failures throw. `execStream(...)` yields stdout and stderr chunks as they arrive, then yields one `exit` event with the final result. ## Files @@ -151,8 +185,12 @@ function createSandboxTools( ): AnyTool[]; type SandboxToolsOptions = { + allow?: SandboxToolName[]; include?: SandboxToolName[]; execTimeoutMs?: number; + exec?: SandboxExecToolPolicy; + readFile?: SandboxFileToolPolicy; + writeFile?: SandboxFileToolPolicy; }; type SandboxToolName = @@ -161,13 +199,59 @@ type SandboxToolName = | "write_file" | "list_files"; +type SandboxExecToolPolicy = { + allowedCommands?: string[]; + blockedCommands?: string[]; + defaultTimeoutMs?: number; + maxTimeoutMs?: number; +}; + +type SandboxFileToolPolicy = { + maxBytes?: number; +}; + type SandboxToolsFactory = ( session: SandboxSession, options?: SandboxToolsOptions, ) => AnyTool[]; ``` -Purpose: expose a live sandbox session as Anvia tools. The default bundle includes `exec_command`, `read_file`, `write_file`, and `list_files`. +Purpose: expose a live sandbox session as Anvia tools. The default bundle includes `exec_command`, `read_file`, `write_file`, and `list_files`. Use `allow` or `include` to select tools and `SandboxExecToolPolicy` or `SandboxFileToolPolicy` to bound model-facing operations. + +## Hooks + +```ts +type SandboxHooks = { + onSessionCreate?: (event: SandboxSessionEvent) => void | Promise; + onExecStart?: (event: SandboxExecEvent) => void | Promise; + onExecEnd?: (event: SandboxExecEndEvent) => void | Promise; + onFileWrite?: (event: SandboxFileWriteEvent) => void | Promise; + onDestroy?: (event: SandboxSessionEvent) => void | Promise; +}; + +type SandboxSessionEvent = { + sessionId: string; + provider: string; + workdir: string; +}; + +type SandboxExecEvent = SandboxSessionEvent & { + command: string; + args: string[]; + cwd?: string; +}; + +type SandboxExecEndEvent = SandboxExecEvent & { + result: SandboxExecResult; +}; + +type SandboxFileWriteEvent = SandboxSessionEvent & { + path: string; + size: number; +}; +``` + +Purpose: observe session creation, command execution, file writes, and cleanup without exposing those details to the model. For workflow guidance, see [Sandbox](/docs/guides/sandbox) and the `tools:11` cookbook example. @@ -180,6 +264,8 @@ class SandboxDockerCommandError extends SandboxError {} class SandboxSessionDestroyedError extends SandboxError {} class SandboxPathError extends SandboxError {} class SandboxTimeoutError extends SandboxError {} +class SandboxFileSizeError extends SandboxError {} +class SandboxToolPolicyError extends SandboxError {} ``` -Purpose: typed failures for Docker setup, path validation, destroyed sessions, and sandbox operations. +Purpose: typed failures for Docker setup, path validation, destroyed sessions, file size limits, tool policy limits, and sandbox operations. diff --git a/apps/docs/content/docs/reference/studio/traces.mdx b/apps/docs/content/docs/reference/studio/traces.mdx index e3020de1..4510aec7 100644 --- a/apps/docs/content/docs/reference/studio/traces.mdx +++ b/apps/docs/content/docs/reference/studio/traces.mdx @@ -54,6 +54,18 @@ Return behavior: trace stores return summaries for lists and full traces for det Notable errors: none directly. +## Trace Utilities + +```ts +function traceSummary(trace: StudioTrace): StudioTraceSummary; +``` + +Purpose: converts a full `StudioTrace` to a `StudioTraceSummary` by projecting summary fields and counting observations. + +Return behavior: returns a plain object with trace summary fields. + +Notable errors: none. + ## Trace Store Types ```ts diff --git a/apps/docs/content/docs/reference/studio/types.mdx b/apps/docs/content/docs/reference/studio/types.mdx index 18e1ce21..886baff3 100644 --- a/apps/docs/content/docs/reference/studio/types.mdx +++ b/apps/docs/content/docs/reference/studio/types.mdx @@ -95,7 +95,9 @@ type StudioConfig = { description?: string; version?: string; agents: StudioAgentConfig[]; + models?: StudioModelsConfig; pipelines: StudioPipelineConfig[]; + evals: StudioEvalSuiteConfig[]; chat: { quickPrompts: Record }; capabilities: Partial>; unsupportedCapabilities: StudioCapability[]; @@ -108,6 +110,88 @@ Return behavior: `Studio.config()` returns `StudioConfig`. Pipelines are top-lev Notable errors: none directly. +## Model Configuration Types + +```ts +type StudioModelRef = string | { provider: string; model: string }; + +type StudioModelModality = "text" | "image" | "document" | "audio" | "video"; + +type StudioModelModalities = { + input: StudioModelModality[]; + output?: StudioModelModality[]; +}; + +type StudioModelDefinition = { + id: string; + name?: string; + description?: string; + modalities?: StudioModelModalities; + capabilities?: Partial; + metadata?: JsonObject; +}; + +type StudioModelProvider = { + id: string; + name?: string; + defaultModel?: string; + models?: StudioModelDefinition[]; + createCompletionModel(model: string): CompletionModel | StreamingCompletionModel; + listModels?: () => Promise; + metadata?: JsonObject; +}; + +type StudioAgentModelPolicy = { + default?: StudioModelRef; + allowed?: Array; +}; + +type StudioModelConfig = { + providers: StudioModelProvider[]; + default?: StudioModelRef; + agents?: Record; +}; + +type StudioModelSummary = StudioModelDefinition & { + ref: string; + providerId: string; + providerName?: string; +}; + +type StudioModelProviderConfig = { + id: string; + name?: string; + defaultModel?: string; + models: StudioModelSummary[]; + metadata?: JsonObject; + warning?: string; +}; + +type StudioAgentModelPolicyConfig = { + default?: string; + allowed?: string[]; +}; + +type StudioModelsConfig = { + providers: StudioModelProviderConfig[]; + default?: string; + agents: Record; +}; + +type StudioAgentModelsSummary = { + agentId: string; + defaultModel?: string; + models: StudioModelSummary[]; + warnings?: JsonObject[]; +}; +``` + +Purpose: configure the provider/model menu Studio exposes for agent runs. `StudioModelProvider` bridges human-facing model metadata to runtime `CompletionModel` instances. `StudioModelModalities` records whether a model accepts text, image, document, audio, or video input and which output modalities it can produce. + +Return behavior: `StudioModelConfig` is accepted through `new Studio(targets, { models })`. `StudioModelsConfig` appears on `GET /config`; `StudioAgentModelsSummary` appears on `GET /agents/:agentId/models`; `StudioModelSummary` values use canonical `provider:model` refs for UI selection and HTTP run requests. + +Notable errors: run requests that select a model outside an agent's `StudioAgentModelPolicy` return `bad_request`. Unsupported modality selections are reported as warnings when metadata is available. + ## Tool Metadata Types ```ts @@ -168,6 +252,14 @@ type StudioAgentMcpsSummary = { agentId: string; servers: StudioAgentMcpServerMetadata[]; }; + +type StudioTranscriptAttachment = { + kind: "image" | "document"; + name?: string; + mediaType?: string; + data?: string; + url?: string; +}; ``` Purpose: metadata returned by `GET /agents/:agentId/tools` and used by the Studio Tools inspector. @@ -288,6 +380,7 @@ type AgentRunRequest = { message: string | Message; history?: Message[]; sessionId?: string; + model?: StudioModelRef; stream?: boolean; maxTurns?: number; toolConcurrency?: number; diff --git a/apps/docs/content/docs/studio/configure/meta.json b/apps/docs/content/docs/studio/configure/meta.json index 5ecd211b..cf0c0141 100644 --- a/apps/docs/content/docs/studio/configure/meta.json +++ b/apps/docs/content/docs/studio/configure/meta.json @@ -2,5 +2,12 @@ "title": "Configure Studio", "defaultOpen": false, "collapsible": true, - "pages": ["serve-options", "quick-prompts", "storage-and-persistence", "capabilities", "status"] + "pages": [ + "serve-options", + "quick-prompts", + "model-providers", + "storage-and-persistence", + "capabilities", + "status" + ] } diff --git a/apps/docs/content/docs/studio/configure/model-providers.mdx b/apps/docs/content/docs/studio/configure/model-providers.mdx new file mode 100644 index 00000000..f24b8731 --- /dev/null +++ b/apps/docs/content/docs/studio/configure/model-providers.mdx @@ -0,0 +1,160 @@ +--- +title: Model Providers +description: Configure Studio to select models across providers per run. +--- + +Studio can expose a model selector for each agent. Use it when you want the same agent instructions, tools, sessions, and traces to run against multiple provider models. + +The agent still needs a normal default model at build time. The Studio `models` option adds runtime model choices for Studio runs. + +```ts +import { AnthropicClient } from "@anvia/anthropic"; +import { AgentBuilder } from "@anvia/core/agent"; +import { OpenAIClient } from "@anvia/openai"; +import { Studio } from "@anvia/studio"; + +const openai = new OpenAIClient({ apiKey: process.env.OPENAI_API_KEY }); +const anthropic = new AnthropicClient({ apiKey: process.env.ANTHROPIC_API_KEY }); + +const agent = new AgentBuilder("assistant", openai.completionModel("gpt-5.5")) + .name("Assistant") + .instructions("You are a helpful general-purpose assistant.") + .build(); + +new Studio([agent], { + models: { + default: "openai:gpt-5.5", + providers: [ + { + id: "openai", + name: "OpenAI", + defaultModel: "gpt-5.5", + createCompletionModel: (model) => openai.completionModel(model), + listModels: () => openai.listModels(), + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + modalities: { + input: ["text", "image", "document"], + output: ["text"], + }, + }, + ], + }, + { + id: "anthropic", + name: "Anthropic", + defaultModel: "claude-opus-4-8", + createCompletionModel: (model) => anthropic.completionModel(model), + listModels: () => anthropic.listModels(), + models: [ + { + id: "claude-opus-4-8", + name: "Claude Opus 4.8", + modalities: { + input: ["text", "image", "document"], + output: ["text"], + }, + }, + ], + }, + ], + agents: { + assistant: { + default: "openai:gpt-5.5", + allowed: ["openai:gpt-5.5", "anthropic:claude-opus-4-8"], + }, + }, + }, +}).start({ port: 4021 }); +``` + +## Provider Config + +Each provider entry describes how Studio should create a runtime model from a selected model id. + +| Field | Purpose | +| --- | --- | +| `id` | Stable provider id used in model refs, such as `openai` | +| `name` | Human label shown in Studio | +| `defaultModel` | Provider-local fallback model id | +| `createCompletionModel` | Factory that returns a `CompletionModel` or `StreamingCompletionModel` | +| `listModels` | Optional dynamic model catalog from the provider | +| `models` | Optional static metadata for labels, modalities, capabilities, and custom metadata | + +Static `models` metadata is useful when the provider's model list does not include all the display or modality information you want Studio to show. + +## Model Refs + +Studio identifies configured models with `provider:model` refs. + +```ts +models: { + default: "openai:gpt-5.5", + agents: { + assistant: { + default: "anthropic:claude-opus-4-8", + allowed: ["openai:*", "anthropic:claude-opus-4-8"], + }, + }, +} +``` + +Use an object ref when constructing config programmatically: + +```ts +default: { provider: "openai", model: "gpt-5.5" } +``` + +The `allowed` list can include exact refs or provider wildcards such as `openai:*`. + +## Multimodal Metadata + +Use `modalities` to tell Studio which file inputs a model can accept. + +```ts +{ + id: "gpt-5.5", + modalities: { + input: ["text", "image", "document"], + output: ["text"], + }, + capabilities: { + streaming: true, + tools: true, + imageInput: true, + documentInput: true, + }, +} +``` + +When a user attaches images or documents, Studio sends a structured `Message` to the selected model. Attachments are read in the browser and sent with the run request; Studio does not upload them to a standalone file store. + +## HTTP API + +Inspect the resolved Studio-wide model config: + +```bash +curl http://localhost:4021/models +``` + +Inspect the model choices allowed for one agent: + +```bash +curl http://localhost:4021/agents/assistant/models +``` + +Run an agent with a selected model: + +```bash +curl -X POST http://localhost:4021/agents/assistant/runs \ + -H 'content-type: application/json' \ + -d '{"message":"Draft a short project update.","model":"anthropic:claude-opus-4-8"}' +``` + +Studio persists the selected model ref in session metadata so continued sessions can keep using the same model unless the user changes it. + +## Cookbook + +See `examples/cookbook/09_studio/13-multi-provider-models.ts` for a complete runnable OpenAI and Anthropic setup. diff --git a/apps/docs/scripts/check-reference-coverage.mjs b/apps/docs/scripts/check-reference-coverage.mjs index df63c457..77a4158e 100644 --- a/apps/docs/scripts/check-reference-coverage.mjs +++ b/apps/docs/scripts/check-reference-coverage.mjs @@ -20,6 +20,8 @@ const packageDocs = new Map([ ["@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/pinecone", "apps/docs/content/docs/reference/integrations/pinecone.mdx"], + ["@anvia/milvus", "apps/docs/content/docs/reference/integrations/milvus.mdx"], ["@anvia/logger", "apps/docs/content/docs/reference/integrations/logger.mdx"], ["@anvia/langfuse", "apps/docs/content/docs/reference/integrations/langfuse.mdx"], ["@anvia/otel", "apps/docs/content/docs/reference/integrations/otel.mdx"], @@ -35,21 +37,12 @@ function walk(dir) { 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); - } - } + const root = join(repoRoot, "packages"); + + for (const name of readdirSync(root)) { + const dir = join(root, name); + if (statSync(dir).isDirectory() && existsSync(join(dir, "package.json"))) { + packageDirs.push(dir); } } diff --git a/apps/docs/scripts/generate-changelogs.mjs b/apps/docs/scripts/generate-changelogs.mjs index dce3e21e..ca557161 100644 --- a/apps/docs/scripts/generate-changelogs.mjs +++ b/apps/docs/scripts/generate-changelogs.mjs @@ -60,17 +60,27 @@ function discoverPackages() { return findPackageDirs(packagesRoot).map((dir) => { const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); const pathParts = relative(packagesRoot, dir).split("/"); + const folderName = pathParts[0]; return { dir, pkg, - group: pathParts[0], + group: packageGroup(folderName), slug: packageSlug(pkg.name), changelogPath: join(dir, "CHANGELOG.md"), }; }); } +function packageGroup(folderName) { + if (folderName.startsWith("provider-")) return "providers"; + if (folderName.startsWith("embedding-")) return "embeddings"; + if (folderName.startsWith("vector-")) return "vector-stores"; + if (folderName.startsWith("observability-")) return "observability"; + if (folderName.startsWith("tool-")) return "tools"; + return folderName; +} + function findPackageDirs(dir) { const entries = readdirSync(dir).sort(); const dirs = []; diff --git a/apps/docs/src/routeTree.gen.ts b/apps/docs/src/routeTree.gen.ts index 9080bbc1..26a371fa 100644 --- a/apps/docs/src/routeTree.gen.ts +++ b/apps/docs/src/routeTree.gen.ts @@ -208,12 +208,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } -} diff --git a/apps/docs/src/routes/index.tsx b/apps/docs/src/routes/index.tsx index 199a4506..37c5dc3c 100644 --- a/apps/docs/src/routes/index.tsx +++ b/apps/docs/src/routes/index.tsx @@ -35,9 +35,9 @@ export const Route = createFileRoute("/")({ }); const metrics = [ - { value: "17", label: "Runtime packages", icon: Package }, + { value: "19", label: "Runtime packages", icon: Package }, { value: "4", label: "Model adapters", icon: Boxes }, - { value: "8", label: "Integration packages", icon: BookOpen }, + { value: "10", label: "Integration packages", icon: BookOpen }, { value: "1", label: "Core package", icon: Zap }, ]; @@ -118,6 +118,8 @@ const packageGroups = [ "@anvia/fastembed", "@anvia/transformers", "@anvia/chroma", + "@anvia/milvus", + "@anvia/pinecone", "@anvia/qdrant", "@anvia/pgvector", ], diff --git a/examples/cookbook/04_providers_and_multimodal/11-anthropic-text-call.ts b/examples/cookbook/04_providers_and_multimodal/11-anthropic-text-call.ts new file mode 100644 index 00000000..89cf3f9c --- /dev/null +++ b/examples/cookbook/04_providers_and_multimodal/11-anthropic-text-call.ts @@ -0,0 +1,19 @@ +import { AnthropicClient } from "@anvia/anthropic"; +import { AgentBuilder } from "@anvia/core/agent"; + +const client = new AnthropicClient({ + apiKey: process.env.ANTHROPIC_API_KEY, + baseUrl: process.env.ANTHROPIC_BASEURL, +}); + +const agentModel = client.completionModel( + process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-20250514", +); + +const agent = new AgentBuilder("anthropic-agent", agentModel) + .instructions("You are a concise assistant. Answer in two sentences or less.") + .build(); + +const response = await agent.prompt("Explain what a provider adapter does.").send(); + +console.log(response.output); diff --git a/examples/cookbook/06_retrieval/12-milvus-vector-store.ts b/examples/cookbook/06_retrieval/12-milvus-vector-store.ts new file mode 100644 index 00000000..52ccdaa8 --- /dev/null +++ b/examples/cookbook/06_retrieval/12-milvus-vector-store.ts @@ -0,0 +1,41 @@ +import { embedDocuments } from "@anvia/core/embeddings"; +import { MilvusVectorStore } from "@anvia/milvus"; +import { createTransformersEmbeddingModel } from "@anvia/transformers"; + +type MarketNote = { + id: string; + text: string; + sector: string; +}; + +const embeddingModel = await createTransformersEmbeddingModel(); +const notes: MarketNote[] = [ + { + id: "cloud", + text: "Cloud infrastructure demand remained resilient into quarter end.", + sector: "technology", + }, + { + id: "rates", + text: "Rate-sensitive sectors traded lower after yields moved higher.", + sector: "macro", + }, +]; + +const embedded = await embedDocuments(embeddingModel, notes, { + id: (note) => note.id, + content: (note) => `${note.sector}: ${note.text}`, +}); + +const store = await MilvusVectorStore.connect({ + collectionName: "anvia_market_notes", + vectorSize: 384, +}); +await store.upsertDocuments(embedded); + +const results = await store.index(embeddingModel).search({ + query: "technology demand", + topK: 2, +}); + +console.log(results); diff --git a/examples/cookbook/06_retrieval/13-pinecone-vector-store.ts b/examples/cookbook/06_retrieval/13-pinecone-vector-store.ts new file mode 100644 index 00000000..f3522101 --- /dev/null +++ b/examples/cookbook/06_retrieval/13-pinecone-vector-store.ts @@ -0,0 +1,55 @@ +import { embedDocuments } from "@anvia/core/embeddings"; +import { vectorFilter } from "@anvia/core/vector-store"; +import { PineconeVectorStore } from "@anvia/pinecone"; +import { createTransformersEmbeddingModel } from "@anvia/transformers"; + +type MarketNote = { + id: string; + text: string; + sector: string; +}; + +requireEnv("PINECONE_API_KEY"); + +const embeddingModel = await createTransformersEmbeddingModel(); +const notes: MarketNote[] = [ + { + id: "cloud", + text: "Cloud infrastructure demand remained resilient into quarter end.", + sector: "technology", + }, + { + id: "rates", + text: "Rate-sensitive sectors traded lower after yields moved higher.", + sector: "macro", + }, +]; + +const embedded = await embedDocuments(embeddingModel, notes, { + id: (note) => note.id, + content: (note) => note.text, + metadata: (note) => ({ sector: note.sector }), +}); + +const store = await PineconeVectorStore.connect({ + indexName: requireEnv("PINECONE_INDEX_NAME"), + namespace: process.env.PINECONE_NAMESPACE ?? "anvia-cookbook", + createIfMissing: false, +}); +await store.upsertDocuments(embedded); + +const results = await store.index(embeddingModel).search({ + query: "technology demand", + topK: 2, + filter: vectorFilter.eq("sector", "technology"), +}); + +console.log(results); + +function requireEnv(name: string): string { + const value = process.env[name]; + if (value === undefined || value.length === 0) { + throw new Error(`Set ${name} before running this cookbook example.`); + } + return value; +} diff --git a/examples/cookbook/09_studio/13-multi-provider-models.ts b/examples/cookbook/09_studio/13-multi-provider-models.ts new file mode 100644 index 00000000..77eff90a --- /dev/null +++ b/examples/cookbook/09_studio/13-multi-provider-models.ts @@ -0,0 +1,99 @@ +import { AnthropicClient } from "@anvia/anthropic"; +import { AgentBuilder } from "@anvia/core/agent"; +import { OpenAIClient } from "@anvia/openai"; +import { Studio } from "@anvia/studio"; + +const openai = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); + +const anthropic = new AnthropicClient({ + baseUrl: process.env.ANTHROPIC_BASEURL, + apiKey: process.env.ANTHROPIC_API_KEY, +}); + +const agent = new AgentBuilder("studio-model-router", openai.completionModel("gpt-5.5")) + .name("Studio Model Router") + .description("Demonstrates Studio model selection across multiple providers.") + .instructions( + [ + "You are a helpful general-purpose assistant used to compare provider behavior in Anvia Studio.", + "Answer clearly and concisely. Adapt your tone and depth to the user's request.", + ].join("\n"), + ) + .build(); + +new Studio([agent], { + models: { + default: "openai:gpt-5.5", + providers: [ + { + id: "openai", + name: "OpenAI", + defaultModel: "gpt-5.5", + createCompletionModel: (model) => openai.completionModel(model), + listModels: () => openai.listModels(), + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + modalities: { + input: ["text", "image", "document"], + output: ["text"], + }, + capabilities: { + streaming: true, + tools: true, + imageInput: true, + documentInput: true, + outputSchema: true, + reasoning: true, + }, + }, + ], + }, + { + id: "anthropic", + name: "Anthropic", + defaultModel: "claude-opus-4-8", + createCompletionModel: (model) => anthropic.completionModel(model), + listModels: () => anthropic.listModels(), + models: [ + { + id: "claude-opus-4-8", + name: "Claude Sonnet 4", + modalities: { + input: ["text", "image", "document"], + output: ["text"], + }, + capabilities: { + streaming: true, + tools: true, + imageInput: true, + documentInput: true, + reasoning: true, + }, + }, + ], + }, + ], + agents: { + "studio-model-router": { + default: "openai:gpt-5.5", + allowed: ["openai:gpt-5.5", "anthropic:claude-opus-4-8"], + }, + }, + }, + quickPrompts: { + "studio-model-router": [ + "Explain the difference between latency, quality, and cost when choosing a model.", + "Draft a short project update for a weekly team check-in.", + "What should I consider before routing multimodal work to a model?", + ], + }, +}).start({ port: 4021 }); + +console.log("Open http://localhost:4021/ui/playground"); +console.log("Use the model selector in the message composer to switch providers per run."); +console.log("Open http://localhost:4021/agents/studio-model-router/models for the model catalog."); diff --git a/examples/cookbook/10_integrations/07-console-logging.ts b/examples/cookbook/10_integrations/07-console-logging.ts new file mode 100644 index 00000000..a17394b0 --- /dev/null +++ b/examples/cookbook/10_integrations/07-console-logging.ts @@ -0,0 +1,62 @@ +import { AgentBuilder } from "@anvia/core/agent"; +import { createTool } from "@anvia/core/tool"; +import { createConsoleLogger, createLoggerObserver } from "@anvia/logger"; +import { OpenAIClient } from "@anvia/openai"; +import { z } from "zod"; + +const logger = createConsoleLogger({ + name: "anvia-cookbook", + level: process.env.LOG_LEVEL === "debug" ? "debug" : "info", + bindings: { example: "integrations:07" }, +}); + +logger.child({ component: "setup" }).info("console logger configured"); + +const client = new OpenAIClient({ + baseUrl: process.env.OPENAI_BASEURL, + apiKey: process.env.OPENAI_API_KEY, +}); + +const lookupTicket = createTool({ + name: "lookup_ticket", + description: "Read a support ticket from local application state.", + input: z.object({ + id: z.string().describe("The ticket id to read."), + }), + output: z.object({ + id: z.string(), + title: z.string(), + status: z.enum(["open", "pending", "closed"]), + summary: z.string(), + }), + execute: ({ id }) => ({ + id, + title: "Search indexing delay after content publish", + status: "pending" as const, + summary: "New articles are visible in the CMS but missing from the customer search index.", + }), +}); + +const agent = new AgentBuilder("support-console-logger-demo", client.completionModel("gpt-5.5")) + .instructions("Use tools when useful. Answer with a short engineering-focused summary.") + .observe( + createLoggerObserver(logger, { + includeToolResult: true, + }), + ) + .tool(lookupTicket) + .defaultMaxTurns(2) + .build(); + +const response = await agent + .prompt("Summarize ticket TICKET-2002 for the search team.") + .withTrace({ + name: "support-ticket-console-logging", + userId: "cookbook-user", + sessionId: "cookbook-session", + metadata: { ticketId: "TICKET-2002", example: "integrations:07" }, + tags: ["cookbook", "logging"], + }) + .send(); + +console.log(response.output); diff --git a/examples/cookbook/README.md b/examples/cookbook/README.md index 14f78783..fee4734b 100644 --- a/examples/cookbook/README.md +++ b/examples/cookbook/README.md @@ -28,7 +28,7 @@ Legacy script names such as `cookbook:basic:01`, `cookbook:intermediate:14`, `co | `06_retrieval` | Embeddings, in-memory search, metadata filters, RAG context, document loaders, vector stores, and embedding provider variants. | | `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, multi-agent, pipeline, eval, and subagent Studio runners, pipeline replay, realtime observability, tool approvals, human feedback, Knowledge, Memory, Status, tool inspection, SQLite persistence, and UI route options. | +| `09_studio` | Single-agent, multi-agent, pipeline, eval, and subagent Studio runners, pipeline replay, realtime observability, tool approvals, human feedback, Knowledge, Memory, Status, tool inspection, SQLite persistence, multi-provider model selection, and UI route options. | | `10_integrations` | MCP tools, local skills, Langfuse tracing, logging, and eval reporting. | ## Environment @@ -38,19 +38,24 @@ Create a repository-root `.env` for examples that call provider APIs: ```sh OPENAI_API_KEY=... OPENAI_BASEURL=... +ANTHROPIC_API_KEY=... +ANTHROPIC_BASEURL=... GEMINI_API_KEY=... MISTRAL_API_KEY=... LANGFUSE_PUBLIC_KEY=... LANGFUSE_SECRET_KEY=... LANGFUSE_BASE_URL=... DATABASE_URL=... +PINECONE_API_KEY=... +PINECONE_INDEX_NAME=... +PINECONE_NAMESPACE=... ``` Not every example needs every variable. Pure pipeline, dynamic tool, and core eval examples run without provider credentials. ## External Services and Side Effects -- Chroma, Qdrant, and pgvector examples use `compose.cookbook.yml` from the cookbook directory: +- Chroma, Qdrant, pgvector, and Milvus examples use `compose.cookbook.yml` from the cookbook directory: ```sh docker compose -f examples/cookbook/compose.cookbook.yml up -d @@ -58,12 +63,14 @@ Not every example needs every variable. Pure pipeline, dynamic tool, and core ev pnpm cookbook:retrieval:06 pnpm cookbook:retrieval:07 pnpm cookbook:retrieval:08 + pnpm cookbook:retrieval:12 ``` - `retrieval:08` uses the compose pgvector connection on host port `5439` by default. Set `DATABASE_URL` to point it at another Postgres database. +- `retrieval:13` uses a hosted Pinecone index. Create a 384-dimension index first, then set `PINECONE_API_KEY`, `PINECONE_INDEX_NAME`, and optionally `PINECONE_NAMESPACE`. - Langfuse examples need Langfuse credentials and live in `10_integrations`. -- `integrations:06` logs agent lifecycle events with `@anvia/logger`. -- Studio examples start a local HTTP server and keep Studio state in memory by default. `studio:10` shows explicit SQLite store wiring for sessions, traces, pipeline logs, and pipeline run history. +- `integrations:06` logs agent lifecycle events with Pino through `@anvia/logger`; `integrations:07` shows the built-in console logger. +- Studio examples start a local HTTP server and keep Studio state in memory by default. `studio:10` shows explicit SQLite store wiring for sessions, traces, pipeline logs, and pipeline run history. `studio:13` shows the Studio message-composer model selector across OpenAI and Anthropic. - Tool history and loader examples write sample files under `.memory`. - Image and audio generation examples write generated media files in the current working directory. - `providers:09` uses the bundled `assets/audio/voice.wav` sample by default. Set `ANVIA_AUDIO_FILE` to transcribe a different local audio file. diff --git a/examples/cookbook/compose.cookbook.yml b/examples/cookbook/compose.cookbook.yml index 68ddf79b..616cc4a4 100644 --- a/examples/cookbook/compose.cookbook.yml +++ b/examples/cookbook/compose.cookbook.yml @@ -55,7 +55,34 @@ services: timeout: 3s retries: 20 + milvus: + image: milvusdb/milvus:v2.5.17 + command: ["milvus", "run", "standalone"] + security_opt: + - seccomp:unconfined + environment: + ETCD_USE_EMBED: "true" + ETCD_DATA_DIR: /var/lib/milvus/etcd + COMMON_STORAGETYPE: local + ports: + - "19530:19530" + - "9091:9091" + volumes: + - milvus-data:/var/lib/milvus + healthcheck: + test: + [ + "CMD", + "bash", + "-lc", + "curl -sf http://127.0.0.1:9091/healthz", + ] + interval: 5s + timeout: 3s + retries: 30 + volumes: chroma-data: qdrant-data: pgvector-data: + milvus-data: diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index b1e2323f..92c6d852 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -40,6 +40,7 @@ "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:11": "tsx -r dotenv/config 04_providers_and_multimodal/11-anthropic-text-call.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", @@ -63,6 +64,8 @@ "retrieval:09": "tsx -r dotenv/config 06_retrieval/09-local-minilm-rag.ts dotenv_config_path=../../.env", "retrieval:10": "tsx -r dotenv/config 06_retrieval/10-fastembed-rag.ts dotenv_config_path=../../.env", "retrieval:11": "tsx -r dotenv/config 06_retrieval/11-mistral-embeddings-rag.ts dotenv_config_path=../../.env", + "retrieval:12": "tsx -r dotenv/config 06_retrieval/12-milvus-vector-store.ts dotenv_config_path=../../.env", + "retrieval:13": "tsx -r dotenv/config 06_retrieval/13-pinecone-vector-store.ts dotenv_config_path=../../.env", "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", @@ -87,6 +90,7 @@ "studio:10": "tsx -r dotenv/config 09_studio/10-persistent-store.ts dotenv_config_path=../../.env", "studio:11": "tsx -r dotenv/config 09_studio/11-eval-runner.ts dotenv_config_path=../../.env", "studio:12": "tsx -r dotenv/config 09_studio/12-ui-options.ts dotenv_config_path=../../.env", + "studio:13": "tsx -r dotenv/config 09_studio/13-multi-provider-models.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", @@ -94,6 +98,7 @@ "integrations:04": "tsx -r dotenv/config 10_integrations/04-langfuse-eval-reporting.ts dotenv_config_path=../../.env", "integrations:05": "tsx -r dotenv/config 10_integrations/05-otel-tracing.ts dotenv_config_path=../../.env", "integrations:06": "tsx -r dotenv/config 10_integrations/06-agent-logging.ts dotenv_config_path=../../.env", + "integrations:07": "tsx -r dotenv/config 10_integrations/07-console-logging.ts dotenv_config_path=../../.env", "basic": "tsx -r dotenv/config 01_basics/01-text-call.ts dotenv_config_path=../../.env", "basic:01": "tsx -r dotenv/config 01_basics/01-text-call.ts dotenv_config_path=../../.env", "basic:02": "tsx -r dotenv/config 01_basics/02-chat-history.ts dotenv_config_path=../../.env", @@ -151,16 +156,19 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@anvia/anthropic": "workspace:*", "@anvia/chroma": "workspace:*", "@anvia/core": "workspace:*", "@anvia/fastembed": "workspace:*", "@anvia/gemini": "workspace:*", "@anvia/langfuse": "workspace:*", "@anvia/logger": "workspace:*", + "@anvia/milvus": "workspace:*", "@anvia/mistral": "workspace:*", "@anvia/otel": "workspace:*", "@anvia/openai": "workspace:*", "@anvia/pgvector": "workspace:*", + "@anvia/pinecone": "workspace:*", "@anvia/qdrant": "workspace:*", "@anvia/react": "workspace:*", "@anvia/sandbox": "workspace:*", diff --git a/examples/cookbook/tsconfig.json b/examples/cookbook/tsconfig.json index d831b559..9fbc586f 100644 --- a/examples/cookbook/tsconfig.json +++ b/examples/cookbook/tsconfig.json @@ -7,23 +7,26 @@ "jsx": "react-jsx", "jsxImportSource": "hono/jsx", "paths": { - "@anvia/chroma": ["../../packages/vector-stores/chroma/src/index.ts"], + "@anvia/anthropic": ["../../packages/provider-anthropic/src/index.ts"], + "@anvia/chroma": ["../../packages/vector-chroma/src/index.ts"], "@anvia/core": ["../../packages/core/src/index.ts"], "@anvia/core/internal/agent": ["../../packages/core/src/internal/agent.ts"], "@anvia/core/*": ["../../packages/core/src/*/index.ts"], - "@anvia/fastembed": ["../../packages/embeddings/fastembed/src/index.ts"], - "@anvia/gemini": ["../../packages/providers/gemini/src/index.ts"], - "@anvia/langfuse": ["../../packages/observability/langfuse/src/index.ts"], + "@anvia/fastembed": ["../../packages/embedding-fastembed/src/index.ts"], + "@anvia/gemini": ["../../packages/provider-gemini/src/index.ts"], + "@anvia/langfuse": ["../../packages/observability-langfuse/src/index.ts"], "@anvia/logger": ["../../packages/logger/src/index.ts"], - "@anvia/mistral": ["../../packages/providers/mistral/src/index.ts"], - "@anvia/openai": ["../../packages/providers/openai/src/index.ts"], - "@anvia/otel": ["../../packages/observability/otel/src/index.ts"], - "@anvia/pgvector": ["../../packages/vector-stores/pgvector/src/index.ts"], - "@anvia/qdrant": ["../../packages/vector-stores/qdrant/src/index.ts"], + "@anvia/milvus": ["../../packages/vector-milvus/src/index.ts"], + "@anvia/mistral": ["../../packages/provider-mistral/src/index.ts"], + "@anvia/openai": ["../../packages/provider-openai/src/index.ts"], + "@anvia/otel": ["../../packages/observability-otel/src/index.ts"], + "@anvia/pgvector": ["../../packages/vector-pgvector/src/index.ts"], + "@anvia/pinecone": ["../../packages/vector-pinecone/src/index.ts"], + "@anvia/qdrant": ["../../packages/vector-qdrant/src/index.ts"], "@anvia/react": ["../../packages/react/src/index.ts"], "@anvia/server": ["../../packages/server/src/index.ts"], - "@anvia/studio": ["../../packages/tools/studio/src/index.ts"], - "@anvia/transformers": ["../../packages/embeddings/transformers/src/index.ts"] + "@anvia/studio": ["../../packages/tool-studio/src/index.ts"], + "@anvia/transformers": ["../../packages/embedding-transformers/src/index.ts"] } }, "include": [ diff --git a/package.json b/package.json index 7192d29a..c03d83f6 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "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:11": "pnpm --filter cookbook providers:11", "cookbook:providers-and-multimodal": "pnpm --filter cookbook providers-and-multimodal", "cookbook:pipelines": "pnpm --filter cookbook pipelines", "cookbook:pipelines:01": "pnpm --filter cookbook pipelines:01", @@ -69,6 +70,8 @@ "cookbook:retrieval:09": "pnpm --filter cookbook retrieval:09", "cookbook:retrieval:10": "pnpm --filter cookbook retrieval:10", "cookbook:retrieval:11": "pnpm --filter cookbook retrieval:11", + "cookbook:retrieval:12": "pnpm --filter cookbook retrieval:12", + "cookbook:retrieval:13": "pnpm --filter cookbook retrieval:13", "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", @@ -93,6 +96,7 @@ "cookbook:studio:10": "pnpm --filter cookbook studio:10", "cookbook:studio:11": "pnpm --filter cookbook studio:11", "cookbook:studio:12": "pnpm --filter cookbook studio:12", + "cookbook:studio:13": "pnpm --filter cookbook studio:13", "cookbook:integrations": "pnpm --filter cookbook integrations", "cookbook:integrations:01": "pnpm --filter cookbook integrations:01", "cookbook:integrations:02": "pnpm --filter cookbook integrations:02", @@ -100,6 +104,7 @@ "cookbook:integrations:04": "pnpm --filter cookbook integrations:04", "cookbook:integrations:05": "pnpm --filter cookbook integrations:05", "cookbook:integrations:06": "pnpm --filter cookbook integrations:06", + "cookbook:integrations:07": "pnpm --filter cookbook integrations:07", "cookbook:basic": "pnpm --filter cookbook basic", "cookbook:basic:01": "pnpm --filter cookbook basic:01", "cookbook:basic:02": "pnpm --filter cookbook basic:02", @@ -156,7 +161,7 @@ "changeset": "changeset", "github-releases": "node scripts/create-github-releases.mjs", "version-packages": "changeset version", - "release": "pnpm --filter @anvia/core build && pnpm --filter './packages/**' --filter '!@anvia/core' build && node scripts/publish-packages.mjs", + "release": "pnpm --filter @anvia/core build && pnpm --filter './packages/*' --filter '!@anvia/core' build && node scripts/publish-packages.mjs", "check": "biome check .", "check:staged": "biome check --staged --vcs-enabled=true --vcs-client-kind=git --vcs-use-ignore-file=true --no-errors-on-unmatched", "check:fix": "biome check --write .", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 6534a32d..d63acbbb 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @anvia/core +## 0.6.2 + +### Patch Changes + +- 4806f3e: Add `PromptRequest.steer()` for enqueueing user messages at safe model-turn boundaries during active prompt runs. + ## 0.6.1 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index b0925d94..b459f733 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/core", - "version": "0.6.1", + "version": "0.6.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 29ee21fe..ab011989 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -8,6 +8,7 @@ import type { ToolChoice, } from "../completion/index"; import type { MemoryRegistration, SessionOptions } from "../memory"; +import { compact } from "../internal/compact"; import type { AgentObserverRegistration } from "../observability"; import { createTool } from "../tool/create-tool"; import type { ToolSearchDocument } from "../tool/dynamic-tools"; @@ -176,8 +177,8 @@ export class Agent { } return new AgentSession(this, { sessionId: normalized, - ...(options.userId === undefined ? {} : { userId: options.userId }), - ...(options.metadata === undefined ? {} : { metadata: options.metadata }), + ...(options.userId !== undefined && { userId: options.userId }), + ...(options.metadata !== undefined && { metadata: options.metadata }), }); } @@ -204,11 +205,11 @@ export class Agent { ) { let output = ""; for await (const event of childRequest.stream()) { - await context.emitStreamEvent({ + await context.emitStreamEvent(compact({ agentId: this.id, - ...(this.name === undefined ? {} : { agentName: this.name }), + agentName: this.name, event, - }); + })); if (event.type === "final") { output = event.output; } diff --git a/packages/core/src/agent/hooks.ts b/packages/core/src/agent/hooks.ts index 425eb3d4..43c6a770 100644 --- a/packages/core/src/agent/hooks.ts +++ b/packages/core/src/agent/hooks.ts @@ -1,4 +1,5 @@ import type { CompletionResponse, Message, ToolResultContent, Usage } from "../completion/index"; +import { compact } from "../internal/compact"; export type HookAction = { type: "continue" } | { type: "terminate"; reason: string }; export type ToolApprovalRequestOptions = { @@ -123,11 +124,11 @@ export function skipTool(reason: string): ToolCallHookAction { } export function requestToolApproval(options: ToolApprovalRequestOptions = {}): ToolCallHookAction { - return { - type: "approval_request", - ...(options.reason === undefined ? {} : { reason: options.reason }), - ...(options.rejectMessage === undefined ? {} : { rejectMessage: options.rejectMessage }), - }; + return compact({ + type: "approval_request" as const, + reason: options.reason, + rejectMessage: options.rejectMessage, + }) as ToolCallHookAction; } export const runControl: RunControl = { diff --git a/packages/core/src/agent/index.ts b/packages/core/src/agent/index.ts index 517e2bc2..a9b48701 100644 --- a/packages/core/src/agent/index.ts +++ b/packages/core/src/agent/index.ts @@ -55,4 +55,4 @@ export type { AgentChildStreamEvent, AgentStreamEvent, PromptResponse, -} from "./request"; +} from "./request-types"; diff --git a/packages/core/src/agent/request-types.ts b/packages/core/src/agent/request-types.ts new file mode 100644 index 00000000..145445ef --- /dev/null +++ b/packages/core/src/agent/request-types.ts @@ -0,0 +1,106 @@ +import { + type CompletionResponse, + type Message as MessageType, + type ReasoningContentType, + type ToolCall, + type ToolResultContent, + Usage, +} from "../completion/index"; +import type { AgentTraceInfo } from "../observability/types"; +import type { AgentDeltaEvent } from "./stream-accumulator"; + +export type PromptResponse = { + output: string; + usage: Usage; + messages: MessageType[]; + trace?: AgentTraceInfo | undefined; +}; + +export type AgentChildStreamEvent = + | { + type: "turn_start"; + turn: number; + prompt: MessageType; + history: MessageType[]; + } + | { + type: "text_delta"; + turn: number; + delta: string; + } + | { + type: "reasoning_delta"; + turn: number; + delta: string; + id?: string; + contentType?: ReasoningContentType; + signature?: string; + } + | { + type: "tool_call"; + turn: number; + toolCall: ToolCall; + } + | { + type: "tool_result"; + turn: number; + toolName: string; + toolCallId?: string; + internalCallId: string; + args: string; + result: string; + structuredResult?: ToolResultContent[] | undefined; + } + | { + type: "turn_end"; + turn: number; + response: CompletionResponse; + } + | { + type: "final"; + runId: string; + output: string; + usage: Usage; + messages: MessageType[]; + trace?: AgentTraceInfo | undefined; + } + | { + type: "error"; + 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 function addTurn(turn: number, event: AgentDeltaEvent): AgentStreamEvent { + if (event.type === "text_delta") { + return { type: "text_delta", turn, delta: event.delta }; + } + if (event.type === "reasoning_delta") { + const mapped: AgentStreamEvent = { type: "reasoning_delta", turn, delta: event.delta }; + if (event.id !== undefined) mapped.id = event.id; + if (event.contentType !== undefined) mapped.contentType = event.contentType; + if (event.signature !== undefined) mapped.signature = event.signature; + return mapped; + } + return { type: "tool_call", turn, toolCall: event.toolCall }; +} + +export function isGenerationDeltaEvent(type: string): boolean { + return ( + type === "text_delta" || + type === "reasoning_delta" || + type === "tool_call_delta" || + type === "tool_call" + ); +} diff --git a/packages/core/src/agent/request.ts b/packages/core/src/agent/request.ts index 56d39313..12166562 100644 --- a/packages/core/src/agent/request.ts +++ b/packages/core/src/agent/request.ts @@ -6,27 +6,32 @@ import { type JsonObject, Message, type Message as MessageType, - type ReasoningContentType, type ToolCall, type ToolDefinition, type ToolResult, - type ToolResultContent, textFromAssistantContent, Usage, } from "../completion/index"; import { createAsyncQueue } from "../internal/async-queue"; +import { compact } from "../internal/compact"; import type { MemoryContext } from "../memory"; import { type ActiveAgentRunObservers, startAgentRunObservers } from "../observability/group"; -import type { AgentTraceInfo, AgentTraceOptions } from "../observability/types"; +import type { AgentTraceOptions } from "../observability/types"; import { toReadableStream } from "../streaming"; import type { AgentMiddleware, ToolMiddleware } from "../tool/middleware"; import type { Agent } from "./agent"; import { MaxTurnsError, PromptCancelledError } from "./errors"; import type { PromptHook } from "./hooks"; import { runControl } from "./hooks"; +import { + type AgentStreamEvent, + type PromptResponse, + addTurn, + isGenerationDeltaEvent, +} from "./request-types"; import { PromptRequestMemory } from "./request-memory"; import { fetchDynamicContext, fetchToolDefinitions } from "./retrieval"; -import { type AgentDeltaEvent, CompletionStreamAccumulator } from "./stream-accumulator"; +import { CompletionStreamAccumulator } from "./stream-accumulator"; import { type AgentToolEventPayload, ToolCallExecutor, @@ -35,79 +40,6 @@ import { } from "./tool-execution"; import { extractRagText, isStreamingCompletionModel } from "./utils"; -export type PromptResponse = { - output: string; - usage: Usage; - messages: MessageType[]; - trace?: AgentTraceInfo | undefined; -}; - -export type AgentChildStreamEvent = - | { - type: "turn_start"; - turn: number; - prompt: MessageType; - history: MessageType[]; - } - | { - type: "text_delta"; - turn: number; - delta: string; - } - | { - type: "reasoning_delta"; - turn: number; - delta: string; - id?: string; - contentType?: ReasoningContentType; - signature?: string; - } - | { - type: "tool_call"; - turn: number; - toolCall: ToolCall; - } - | { - type: "tool_result"; - turn: number; - toolName: string; - toolCallId?: string; - internalCallId: string; - args: string; - result: string; - structuredResult?: ToolResultContent[] | undefined; - } - | { - type: "turn_end"; - turn: number; - response: CompletionResponse; - } - | { - type: "final"; - runId: string; - output: string; - usage: Usage; - messages: MessageType[]; - trace?: AgentTraceInfo | undefined; - } - | { - type: "error"; - 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; @@ -115,6 +47,8 @@ export class PromptRequest { private concurrency = 1; private traceOptions: AgentTraceOptions | undefined; private requestMiddlewares: AgentMiddleware[] = []; + private readonly steeringMessages: MessageType[] = []; + private runState: "idle" | "running" | "completed" | "errored" | "cancelled" = "idle"; private readonly memoryRecorder: PromptRequestMemory; private constructor( @@ -189,7 +123,17 @@ export class PromptRequest { return this; } + steer(input: string | MessageType | MessageType[]): boolean { + if (this.isTerminal()) { + return false; + } + + this.steeringMessages.push(...normalizeSteeringInput(input)); + return true; + } + async send(): Promise { + this.startRun(); const runId = globalThis.crypto.randomUUID(); const newMessages: MessageType[] = [this.promptMessage]; this.chatHistory = await this.memoryRecorder.prepareRun(runId, newMessages); @@ -254,6 +198,13 @@ export class PromptRequest { (item): item is ToolCall => item.type === "tool_call", ); if (toolCalls.length === 0) { + if ( + await this.drainSteeringMessages(runId, currentTurns, newMessages, pendingTurnMessages) + ) { + await this.memoryRecorder.commitCompletedTurn(runId, currentTurns, pendingTurnMessages); + continue; + } + await this.memoryRecorder.commitCompletedRun( runId, currentTurns, @@ -266,6 +217,7 @@ export class PromptRequest { messages: [...newMessages], trace: runObservers.trace, }; + this.runState = "completed"; await this.runRunEndHook(result, newMessages); await runObservers.end(result); return result; @@ -290,12 +242,14 @@ export class PromptRequest { [toolMessage], pendingTurnMessages, ); + await this.drainSteeringMessages(runId, currentTurns, newMessages, pendingTurnMessages); await this.memoryRecorder.commitCompletedTurn(runId, currentTurns, pendingTurnMessages); } throw new MaxTurnsError(this.maxTurnCount, [...this.chatHistory, ...newMessages], lastPrompt); } catch (error) { const finalError = await this.runRunErrorHook(error, usage, newMessages); + this.runState = finalError instanceof PromptCancelledError ? "cancelled" : "errored"; await runObservers.error({ error: finalError, usage, messages: [...newMessages] }); await this.memoryRecorder.recordError(runId, finalError, newMessages); throw finalError; @@ -307,6 +261,7 @@ export class PromptRequest { throw new Error("This completion model does not support streaming"); } + this.startRun(); const runId = globalThis.crypto.randomUUID(); const newMessages: MessageType[] = [this.promptMessage]; this.chatHistory = await this.memoryRecorder.prepareRun(runId, newMessages); @@ -359,16 +314,16 @@ export class PromptRequest { assertCompletionRequestSupported(this.agent.model, request, { streaming: true }); const providerRequest = this.providerTraceRequest(request, { stream: true }); - const generationObservers = await runObservers.startGeneration({ + const generationObservers = await runObservers.startGeneration(compact({ turn: currentTurns, request, - ...(providerRequest === undefined ? {} : { providerRequest }), + providerRequest, modelInfo: { provider: this.agent.model.provider, defaultModel: this.agent.model.defaultModel, capabilities: this.agent.model.capabilities, }, - }); + })); const accumulator = new CompletionStreamAccumulator(); const generationStartedAt = Date.now(); let firstDeltaMs: number | undefined; @@ -392,11 +347,11 @@ export class PromptRequest { } let response = accumulator.response(); - await generationObservers.end({ + await generationObservers.end(compact({ turn: currentTurns, response, - ...(firstDeltaMs === undefined ? {} : { firstDeltaMs }), - }); + firstDeltaMs, + })); response = await this.runCompletionResponseMiddlewares(request, response, currentTurns); usage = Usage.add(usage, response.usage); await this.runCompletionResponseHook(prompt, response, newMessages); @@ -419,6 +374,13 @@ export class PromptRequest { yield await emit({ type: "turn_end", turn: currentTurns, response }); if (toolCalls.length === 0) { + if ( + await this.drainSteeringMessages(runId, currentTurns, newMessages, pendingTurnMessages) + ) { + await this.memoryRecorder.commitCompletedTurn(runId, currentTurns, pendingTurnMessages); + continue; + } + const output = textFromAssistantContent(response.choice); await this.memoryRecorder.commitCompletedRun( runId, @@ -434,6 +396,7 @@ export class PromptRequest { messages: [...newMessages], trace: runObservers.trace, }); + this.runState = "completed"; await this.runRunEndHook({ output, usage, messages: [...newMessages] }, newMessages); await runObservers.end({ output, usage, messages: [...newMessages] }); return; @@ -471,12 +434,14 @@ export class PromptRequest { [toolMessage], pendingTurnMessages, ); + await this.drainSteeringMessages(runId, currentTurns, newMessages, pendingTurnMessages); await this.memoryRecorder.commitCompletedTurn(runId, currentTurns, pendingTurnMessages); } throw new MaxTurnsError(this.maxTurnCount, [...this.chatHistory, ...newMessages], lastPrompt); } catch (error) { const finalError = await this.runRunErrorHook(error, usage, newMessages); + this.runState = finalError instanceof PromptCancelledError ? "cancelled" : "errored"; await runObservers.error({ error: finalError, usage, messages: [...newMessages] }); await this.memoryRecorder.recordError(runId, finalError, newMessages); yield await emit({ type: "error", error: finalError }); @@ -495,16 +460,16 @@ export class PromptRequest { ): Promise { assertCompletionRequestSupported(this.agent.model, request); const providerRequest = this.providerTraceRequest(request); - const generationObservers = await runObservers.startGeneration({ + const generationObservers = await runObservers.startGeneration(compact({ turn, request, - ...(providerRequest === undefined ? {} : { providerRequest }), + providerRequest, modelInfo: { provider: this.agent.model.provider, defaultModel: this.agent.model.defaultModel, capabilities: this.agent.model.capabilities, }, - }); + })); try { const response = await this.agent.model.completion(request); await generationObservers.end({ turn, response }); @@ -583,14 +548,13 @@ export class PromptRequest { await registration.store.append({ runId, agentId, - ...(agentName === undefined ? {} : { agentName }), - ...(turn === undefined ? {} : { turn }), + ...compact({ agentName, turn }), ...(event.type === "agent_tool_event" - ? { + ? compact({ toolName: event.toolName, - ...(event.toolCallId === undefined ? {} : { toolCallId: event.toolCallId }), + toolCallId: event.toolCallId, internalCallId: event.internalCallId, - } + }) : {}), event, }); @@ -758,6 +722,34 @@ export class PromptRequest { return [...this.agent.middlewares, ...this.requestMiddlewares]; } + private async drainSteeringMessages( + runId: string, + turn: number, + newMessages: MessageType[], + pendingTurnMessages: MessageType[], + ): Promise { + const messages = this.steeringMessages.splice(0); + if (messages.length === 0) { + return false; + } + + newMessages.push(...messages); + await this.memoryRecorder.commitMessages(runId, turn, messages, pendingTurnMessages); + return true; + } + + private startRun(): void { + if (!this.isTerminal()) { + this.runState = "running"; + } + } + + private isTerminal(): boolean { + return ( + this.runState === "completed" || this.runState === "errored" || this.runState === "cancelled" + ); + } + private cancelled(newMessages: MessageType[], reason: string): PromptCancelledError { return new PromptCancelledError([...this.chatHistory, ...newMessages], reason); } @@ -786,25 +778,9 @@ function normalizePromptInput(prompt: string | MessageType | MessageType[]): { }; } -function addTurn(turn: number, event: AgentDeltaEvent): AgentStreamEvent { - if (event.type === "text_delta") { - return { type: "text_delta", turn, delta: event.delta }; - } - if (event.type === "reasoning_delta") { - const mapped: AgentStreamEvent = { type: "reasoning_delta", turn, delta: event.delta }; - if (event.id !== undefined) mapped.id = event.id; - if (event.contentType !== undefined) mapped.contentType = event.contentType; - if (event.signature !== undefined) mapped.signature = event.signature; - return mapped; +function normalizeSteeringInput(input: string | MessageType | MessageType[]): MessageType[] { + if (typeof input === "string") { + return [Message.user(input)]; } - return { type: "tool_call", turn, toolCall: event.toolCall }; -} - -function isGenerationDeltaEvent(type: string): boolean { - return ( - type === "text_delta" || - type === "reasoning_delta" || - type === "tool_call_delta" || - type === "tool_call" - ); + return Array.isArray(input) ? [...input] : [input]; } diff --git a/packages/core/src/agent/retrieval.ts b/packages/core/src/agent/retrieval.ts index ed4b946b..f48410e6 100644 --- a/packages/core/src/agent/retrieval.ts +++ b/packages/core/src/agent/retrieval.ts @@ -1,4 +1,5 @@ import type { Document, ToolDefinition } from "../completion/index"; +import { compact } from "../internal/compact"; import type { Agent } from "./agent"; export async function fetchDynamicContext( @@ -23,14 +24,14 @@ export async function fetchDynamicContext( documents.push(formatted); } else { const metadata = formatMetadata(result.metadata); - documents.push({ + documents.push(compact({ id: result.id, text: typeof result.document === "string" ? result.document : JSON.stringify(result.document, null, 2), - ...(metadata === undefined ? {} : { additionalProps: metadata }), - }); + additionalProps: metadata, + }) as Document); } } } diff --git a/packages/core/src/agent/tool-execution.ts b/packages/core/src/agent/tool-execution.ts index 9b6d56ad..0d379587 100644 --- a/packages/core/src/agent/tool-execution.ts +++ b/packages/core/src/agent/tool-execution.ts @@ -6,6 +6,7 @@ import type { ToolResultContent, } from "../completion"; import { ToolContent } from "../completion"; +import { compact } from "../internal/compact"; import { mapWithConcurrency } from "../internal/concurrency"; import type { ActiveAgentRunObservers, ActiveToolObservers } from "../observability/group"; import type { AnyTool, NormalizedToolOutput, ToolCallStreamEvent } from "../tool"; @@ -18,7 +19,7 @@ import type { import type { Agent } from "./agent"; import type { PromptHook, ToolHookArgs } from "./hooks"; import { runControl, toolCallControl } from "./hooks"; -import type { AgentChildStreamEvent } from "./request"; +import type { AgentChildStreamEvent } from "./request-types"; const MCP_TOOL_METADATA_KEY = Symbol.for("anvia.mcp.tool.metadata"); @@ -82,16 +83,16 @@ export class ToolCallExecutor { ); const toolMetadata = toolTraceMetadata(tool); - const toolObservers = await observation?.runObservers.startTool({ + const toolObservers = await observation?.runObservers.startTool(compact({ turn: observation.turn, toolCall, toolName: toolCall.function.name, internalCallId, args, toolCallId: toolCall.callId, - ...(toolDefinition === undefined ? {} : { toolDefinition }), - ...(toolMetadata === undefined ? {} : { toolMetadata }), - }); + toolDefinition, + toolMetadata, + })); const callAction = await this.activeHook?.onToolCall?.({ ...hookArgs, @@ -136,15 +137,17 @@ export class ToolCallExecutor { try { output = await this.agent.callTool(toolCall.function.name, effectiveArgs, { emitStreamEvent: async (event) => { - await toolObservers?.streamEvent({ - turn: observation?.turn ?? 0, - toolCall, - toolName: toolCall.function.name, - internalCallId, - args: effectiveArgs, - ...(toolCall.callId === undefined ? {} : { toolCallId: toolCall.callId }), - event, - }); + await toolObservers?.streamEvent( + compact({ + turn: observation?.turn ?? 0, + toolCall, + toolName: toolCall.function.name, + internalCallId, + args: effectiveArgs, + toolCallId: toolCall.callId, + event, + }), + ); const payload = agentToolEventPayload(toolCall, internalCallId, event); if (payload !== undefined) { onStreamEvent?.(payload); @@ -164,7 +167,7 @@ export class ToolCallExecutor { toolName: toolCall.function.name, internalCallId, args: effectiveArgs, - ...(toolCall.callId === undefined ? {} : { toolCallId: toolCall.callId }), + ...(toolCall.callId !== undefined && { toolCallId: toolCall.callId }), error, }); if (errorAction?.type === "terminate") { @@ -357,13 +360,13 @@ function agentToolEventPayload( if (typeof event.agentId !== "string" || event.agentId.length === 0) { return undefined; } - return { - type: "agent_tool_event", + return compact({ + type: "agent_tool_event" as const, toolName: toolCall.function.name, - ...(toolCall.callId === undefined ? {} : { toolCallId: toolCall.callId }), + toolCallId: toolCall.callId, internalCallId, agentId: event.agentId, - ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + agentName: event.agentName, event: event.event as AgentChildStreamEvent, - }; + }); } diff --git a/packages/core/src/agent/utils.ts b/packages/core/src/agent/utils.ts index 4942a7cc..82bbeebf 100644 --- a/packages/core/src/agent/utils.ts +++ b/packages/core/src/agent/utils.ts @@ -2,14 +2,8 @@ import type { CompletionModel, JsonValue, Message as MessageType, - StreamingCompletionModel, } from "../completion/index"; - -export function isStreamingCompletionModel( - model: CompletionModel, -): model is StreamingCompletionModel { - return "streamCompletion" in model && typeof model.streamCompletion === "function"; -} +export { isStreamingCompletionModel } from "../completion/create-completion"; export function extractRagText(message: MessageType): string | undefined { if (message.role === "user") { diff --git a/packages/core/src/completion/create-completion.ts b/packages/core/src/completion/create-completion.ts index a190945a..637dcb97 100644 --- a/packages/core/src/completion/create-completion.ts +++ b/packages/core/src/completion/create-completion.ts @@ -154,7 +154,7 @@ function messagesFromInput(input: CreateCompletionInput | undefined): MessageTyp return Array.isArray(input) ? [...input] : [input]; } -function isStreamingCompletionModel(model: CompletionModel): model is StreamingCompletionModel { +export function isStreamingCompletionModel(model: CompletionModel): model is StreamingCompletionModel { return typeof (model as { streamCompletion?: unknown }).streamCompletion === "function"; } diff --git a/packages/core/src/completion/types.ts b/packages/core/src/completion/types.ts index 64e8cc7a..75fe4e7f 100644 --- a/packages/core/src/completion/types.ts +++ b/packages/core/src/completion/types.ts @@ -200,7 +200,7 @@ export const ToolContent = { }, }; -function serializeToolResultOutput(output: unknown): string { +export function serializeToolResultOutput(output: unknown): string { if (typeof output === "string") { return output; } @@ -213,7 +213,7 @@ function serializeToolResultOutput(output: unknown): string { } } -function isToolResultContentArray(value: unknown): value is ToolResultContent[] { +export function isToolResultContentArray(value: unknown): value is ToolResultContent[] { return ( Array.isArray(value) && value.length > 0 && diff --git a/packages/core/src/embeddings/index.ts b/packages/core/src/embeddings/index.ts index e90f90d3..853dad56 100644 --- a/packages/core/src/embeddings/index.ts +++ b/packages/core/src/embeddings/index.ts @@ -1,3 +1,4 @@ +import { compact } from "../internal/compact"; import { mapWithConcurrency } from "../internal/concurrency"; import type { EmbedDocumentsOptions, @@ -85,7 +86,7 @@ export async function embedDocuments ({ id: item.id, document: item.document, - ...(item.metadata === undefined ? {} : { metadata: item.metadata }), + ...(item.metadata !== undefined && { metadata: item.metadata }), embeddings: byDocument.get(index) ?? [], })); } diff --git a/packages/core/src/evals/agent-target.ts b/packages/core/src/evals/agent-target.ts index 8d2b9aa4..ceb36f2f 100644 --- a/packages/core/src/evals/agent-target.ts +++ b/packages/core/src/evals/agent-target.ts @@ -1,5 +1,5 @@ import type { Agent } from "../agent/agent"; -import type { PromptResponse } from "../agent/request"; +import type { PromptResponse } from "../agent/request-types"; import type { Message } from "../completion"; import type { EvalCase, EvalTarget } from "./types"; diff --git a/packages/core/src/evals/outcome.ts b/packages/core/src/evals/outcome.ts index f54e935b..3f6d036a 100644 --- a/packages/core/src/evals/outcome.ts +++ b/packages/core/src/evals/outcome.ts @@ -1,3 +1,4 @@ +import { compact } from "../internal/compact"; import type { EvalMetadata } from "./types"; export type EvalOutcome = @@ -26,24 +27,24 @@ export const EvalOutcome = { score?: Score, options: { comment?: string | undefined; metadata?: EvalMetadata | undefined } = {}, ): EvalOutcome { - return { - outcome: "pass", - ...(score === undefined ? {} : { score }), - ...(options.comment === undefined ? {} : { comment: options.comment }), - ...(options.metadata === undefined ? {} : { metadata: options.metadata }), - }; + return compact({ + outcome: "pass" as const, + score, + comment: options.comment, + metadata: options.metadata, + }) as EvalOutcome; }, fail( score?: Score, options: { comment?: string | undefined; metadata?: EvalMetadata | undefined } = {}, ): EvalOutcome { - return { - outcome: "fail", - ...(score === undefined ? {} : { score }), - ...(options.comment === undefined ? {} : { comment: options.comment }), - ...(options.metadata === undefined ? {} : { metadata: options.metadata }), - }; + return compact({ + outcome: "fail" as const, + score, + comment: options.comment, + metadata: options.metadata, + }) as EvalOutcome; }, invalid( @@ -54,12 +55,12 @@ export const EvalOutcome = { metadata?: EvalMetadata | undefined; } = {}, ): EvalOutcome { - return { - outcome: "invalid", + return compact({ + outcome: "invalid" as const, reason, - ...(options.score === undefined ? {} : { score: options.score }), - ...(options.comment === undefined ? {} : { comment: options.comment }), - ...(options.metadata === undefined ? {} : { metadata: options.metadata }), - }; + score: options.score, + comment: options.comment, + metadata: options.metadata, + }) as EvalOutcome; }, }; diff --git a/packages/core/src/evals/runner.ts b/packages/core/src/evals/runner.ts index a6a425c7..899e2048 100644 --- a/packages/core/src/evals/runner.ts +++ b/packages/core/src/evals/runner.ts @@ -1,3 +1,4 @@ +import { compact } from "../internal/compact"; import { mapWithConcurrency } from "../internal/concurrency"; import { errorMessage } from "./format"; import { EvalOutcome, type EvalOutcome as EvalOutcomeType } from "./outcome"; @@ -60,12 +61,12 @@ async function runEvalCase( metrics.push({ metricName: metric.name, outcome, reporterErrors }); } - return { + return compact({ case: testCase, - ...(output === undefined ? {} : { output }), - ...(targetError === undefined ? {} : { targetError }), + output, + targetError, metrics, - }; + }) as EvalCaseResult; } async function safeEvaluate( diff --git a/packages/core/src/extractor/extractor.ts b/packages/core/src/extractor/extractor.ts index fd7ed360..ce72a900 100644 --- a/packages/core/src/extractor/extractor.ts +++ b/packages/core/src/extractor/extractor.ts @@ -1,5 +1,6 @@ import type { Agent } from "../agent/agent"; import { AgentBuilder } from "../agent/builder"; +import { extractRagText } from "../agent/utils"; import { CompletionCapabilityError, type CompletionModel, @@ -176,10 +177,4 @@ function extractSubmittedData(response: CompletionResponse, schema: ZodSchema return schema.parse(submitted.function.arguments); } -function extractRagText(message: Message): string | undefined { - if (message.role === "user") { - return message.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"); - } - return undefined; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0aa3b0ae..39477ab0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,7 +12,7 @@ export type { AgentChildStreamEvent, AgentStreamEvent, PromptResponse, -} from "./agent/request"; +} from "./agent/request-types"; export type { AssistantMessage, CompletionModel, diff --git a/packages/core/src/internal/compact.ts b/packages/core/src/internal/compact.ts new file mode 100644 index 00000000..9caf7310 --- /dev/null +++ b/packages/core/src/internal/compact.ts @@ -0,0 +1,26 @@ +/** + * Strips keys whose resolved type includes `undefined`. + * + * Replaces the verbose `...(x === undefined ? {} : { key: x })` pattern. + * + * @example + * compact({ id: "1", title: session.title, metadata: session.metadata }) + * // returns { id: "1" } when title and metadata are undefined + */ +export type Compact = { + [K in keyof T as undefined extends T[K] ? never : K]: Exclude; +}; + +export function compact>(obj: T): Compact { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined) { + result[key] = value; + } + } + return result as Compact; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/core/src/mcp/result.ts b/packages/core/src/mcp/result.ts index 2ad7f90b..9838c645 100644 --- a/packages/core/src/mcp/result.ts +++ b/packages/core/src/mcp/result.ts @@ -1,4 +1,5 @@ import type { JsonValue } from "../completion/index"; +import { isRecord } from "../internal/compact"; import type { McpToolCallContent, McpToolCallResult } from "./types"; export function createCallToolParams( @@ -9,7 +10,7 @@ export function createCallToolParams( return { name }; } - if (!isPlainRecord(args)) { + if (!isRecord(args)) { throw new Error("MCP tool arguments must be a JSON object"); } @@ -67,7 +68,3 @@ function serializeMcpValue(value: unknown): string { const serialized = JSON.stringify(value); return serialized === undefined ? String(value) : serialized; } - -function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/core/src/pipeline/graph.ts b/packages/core/src/pipeline/graph.ts index 94795731..9be8b85d 100644 --- a/packages/core/src/pipeline/graph.ts +++ b/packages/core/src/pipeline/graph.ts @@ -1,4 +1,5 @@ import type { JsonObject } from "../completion"; +import { compact } from "../internal/compact"; import type { PipelineBuilderState, PipelineGraph, @@ -20,10 +21,12 @@ export function initialBuilderState(metadata: PipelineMetadata): PipelineBuilder export function initialGraph(metadata: PipelineMetadata): PipelineGraph { const id = normalizeId(metadata.id ?? "pipeline"); return { - id, - ...(metadata.name === undefined ? {} : { name: metadata.name }), - ...(metadata.description === undefined ? {} : { description: metadata.description }), - ...(metadata.metadata === undefined ? {} : { metadata: metadata.metadata }), + ...compact({ + id, + name: metadata.name, + description: metadata.description, + metadata: metadata.metadata, + }), nodes: [{ id: "input", kind: "input", label: "Input" }], edges: [], }; @@ -158,17 +161,17 @@ function graphNode( normalizeId(options.preferredId ?? `${kind}_${index}`), options.existingIds ?? new Set(), ); - return { + return compact({ id, kind, label, - ...(options.description === undefined ? {} : { description: options.description }), - ...(options.metadata === undefined ? {} : { metadata: options.metadata }), - ...(options.agentId === undefined ? {} : { agentId: options.agentId }), - ...(options.agentName === undefined ? {} : { agentName: options.agentName }), - ...(options.pipelineId === undefined ? {} : { pipelineId: options.pipelineId }), - ...(options.branchKey === undefined ? {} : { branchKey: options.branchKey }), - }; + description: options.description, + metadata: options.metadata, + agentId: options.agentId, + agentName: options.agentName, + pipelineId: options.pipelineId, + branchKey: options.branchKey, + }) as PipelineGraphNode; } function normalizeId(value: string): string { diff --git a/packages/core/src/skills/local.ts b/packages/core/src/skills/local.ts index 41e41821..54a9fa4a 100644 --- a/packages/core/src/skills/local.ts +++ b/packages/core/src/skills/local.ts @@ -1,6 +1,7 @@ import { readdir, readFile, stat } from "node:fs/promises"; import { basename, join, relative, resolve, sep } from "node:path"; import { parse as parseYaml } from "yaml"; +import { isRecord } from "../internal/compact"; import type { Skill, SkillLoader, SkillValidationIssue } from "./types"; import { SkillValidationError } from "./types"; @@ -177,7 +178,3 @@ async function collectFiles(root: string, directory: string, files: string[]): P function toPortablePath(path: string): string { return sep === "/" ? path : path.split(sep).join("/"); } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/core/src/tool/create-tool.ts b/packages/core/src/tool/create-tool.ts index f88db239..34e84dde 100644 --- a/packages/core/src/tool/create-tool.ts +++ b/packages/core/src/tool/create-tool.ts @@ -1,4 +1,5 @@ import type { z } from "zod"; +import { compact } from "../internal/compact"; import { toProviderJsonSchema, type ZodSchema } from "../schema/zod-schema"; import type { Tool, ToolApprovalPolicy, ToolCallContext } from "./tool"; @@ -43,8 +44,7 @@ export function createTool< const parameters = toProviderJsonSchema(options.input); return { - name: options.name, - ...(options.approval === undefined ? {} : { approval: options.approval }), + ...compact({ name: options.name, approval: options.approval }), definition() { return { name: options.name, diff --git a/packages/core/src/tool/dynamic-tools.ts b/packages/core/src/tool/dynamic-tools.ts index fb330b0e..435a59e0 100644 --- a/packages/core/src/tool/dynamic-tools.ts +++ b/packages/core/src/tool/dynamic-tools.ts @@ -1,6 +1,7 @@ import type { ToolDefinition } from "../completion"; import type { EmbeddedDocument, EmbeddingModel, VectorMetadata } from "../embeddings"; import { embedDocuments } from "../embeddings"; +import { compact } from "../internal/compact"; import type { VectorInspectPage, VectorInspectRequest, @@ -46,12 +47,12 @@ export async function embedTools = { + const document: ToolSearchDocument = compact({ toolName: tool.name, definition, text: texts.join("\n"), - ...(metadata === undefined ? {} : { metadata }), - }; + metadata, + }) as ToolSearchDocument; return { tool, document, texts, metadata }; }); diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 262bb03a..3803a734 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -1,4 +1,8 @@ import type { JsonObject, JsonValue, ToolDefinition, ToolResultContent } from "../completion/types"; +import { + isToolResultContentArray, + serializeToolResultOutput as serializeToolOutput, +} from "../completion/types"; export type ToolApprovalRunContext = { agentId: string; @@ -52,39 +56,7 @@ export const ToolOutput = { }, }; -export function serializeToolOutput(output: unknown): string { - if (typeof output === "string") { - return output; - } - - const serialized = JSON.stringify(output); - return serialized === undefined ? String(output) : serialized; -} - -export function isToolResultContentArray(value: unknown): value is ToolResultContent[] { - return ( - Array.isArray(value) && - value.length > 0 && - value.every((item) => { - if (typeof item !== "object" || item === null || !("type" in item)) { - return false; - } - if (item.type === "text") { - return "text" in item && typeof item.text === "string"; - } - if (item.type === "image") { - return ( - "data" in item && - typeof item.data === "string" && - (!("mediaType" in item) || - item.mediaType === undefined || - typeof item.mediaType === "string") - ); - } - return false; - }) - ); -} +export { isToolResultContentArray, serializeToolOutput }; export function normalizeToolResultOutput(output: unknown): NormalizedToolOutput { return isToolResultContentArray(output) ? output : serializeToolOutput(output); diff --git a/packages/core/src/vector-store/index.ts b/packages/core/src/vector-store/index.ts index d3fe7507..08efee02 100644 --- a/packages/core/src/vector-store/index.ts +++ b/packages/core/src/vector-store/index.ts @@ -7,6 +7,7 @@ import { embedText, type VectorMetadata, } from "../embeddings"; +import { compact } from "../internal/compact"; import { createTool } from "../tool/create-tool"; import type { Tool } from "../tool/tool"; import { matchesVectorFilter, type VectorFilter } from "./filter"; @@ -195,7 +196,7 @@ export class InMemoryVectorIndex ({ id: document.id, document: document.document, - ...(document.metadata === undefined ? {} : { metadata: document.metadata }), + ...(document.metadata !== undefined && { metadata: document.metadata }), })), ...(nextOffset < documents.length ? { nextCursor: String(nextOffset) } : {}), totalCount: documents.length, diff --git a/packages/core/test/prompt-request.test.ts b/packages/core/test/prompt-request.test.ts index ee12f235..d479de4a 100644 --- a/packages/core/test/prompt-request.test.ts +++ b/packages/core/test/prompt-request.test.ts @@ -83,6 +83,39 @@ describe("PromptRequest", () => { expect(model.requests[0]?.chatHistory[0]).toEqual(Message.user("hello")); }); + it("send consumes steering queued before no-tool finalization", async () => { + const model = new QueueModel([ + response([AssistantContent.text("first")]), + response([AssistantContent.text("second")]), + ]); + let steer: ((input: string) => boolean) | undefined; + const agent = new AgentBuilder("test-agent", model) + .hook( + createHook({ + onTurnEnd({ turn }) { + if (turn === 1) { + expect(steer?.("revise")).toBe(true); + } + }, + }), + ) + .build(); + const request = agent.prompt("hello"); + steer = request.steer.bind(request); + + const result = await request.send(); + + expect(result.output).toBe("second"); + expect(model.requests).toHaveLength(2); + expect(model.requests[1]?.chatHistory.at(-1)).toEqual(Message.user("revise")); + expect(result.messages).toEqual([ + Message.user("hello"), + Message.assistant("first"), + Message.user("revise"), + Message.assistant("second"), + ]); + }); + it("merges repeated instruction blocks", async () => { const model = new QueueModel([response([AssistantContent.text("done")])]); const agent = new AgentBuilder("test-agent", model) @@ -723,6 +756,36 @@ describe("PromptRequest", () => { expect(events).toEqual(["completion_error:No queued response", "run_error:No queued response"]); }); + it("rejects steering after final, error, and cancel", async () => { + const finalModel = new QueueModel([response([AssistantContent.text("done")])]); + const finalAgent = new AgentBuilder("test-agent", finalModel).build(); + const finalRequest = finalAgent.prompt("hello"); + + const result = await finalRequest.send(); + const finalMessages = [...result.messages]; + + expect(finalRequest.steer("late")).toBe(false); + expect(result.messages).toEqual(finalMessages); + expect(finalModel.requests).toHaveLength(1); + + const errorRequest = new AgentBuilder("test-agent", new QueueModel([])).build().prompt("hello"); + await expect(errorRequest.send()).rejects.toThrow("No queued response"); + expect(errorRequest.steer("late")).toBe(false); + + const cancelRequest = new AgentBuilder("test-agent", new QueueModel([])) + .hook( + createHook({ + onRunStart() { + return cancelPrompt("stop"); + }, + }), + ) + .build() + .prompt("hello"); + await expect(cancelRequest.send()).rejects.toBeInstanceOf(PromptCancelledError); + expect(cancelRequest.steer("late")).toBe(false); + }); + it("runs tool error hooks and keeps tool errors as model-visible results", async () => { const failingTool = createTool({ name: "fail", diff --git a/packages/core/test/streaming.test.ts b/packages/core/test/streaming.test.ts index 43ffbd32..0a27c593 100644 --- a/packages/core/test/streaming.test.ts +++ b/packages/core/test/streaming.test.ts @@ -32,7 +32,11 @@ class StreamingQueueModel implements StreamingCompletionModel { }; readonly requests: CompletionRequest[] = []; - constructor(private readonly responses: CompletionStreamEvent[][]) {} + constructor( + private readonly responses: Array< + Iterable | AsyncIterable + >, + ) {} async completion(): Promise { throw new Error("completion should not be called"); @@ -44,7 +48,9 @@ class StreamingQueueModel implements StreamingCompletionModel { if (response === undefined) { throw new Error("No queued response"); } - yield* response; + for await (const event of response) { + yield event; + } } } @@ -101,6 +107,142 @@ describe("PromptRequest streaming", () => { expect(model.requests[0]?.chatHistory[0]).toEqual(Message.user("hi")); }); + it("continues when steering arrives before a no-tool response finalizes", async () => { + const model = new StreamingQueueModel([ + [{ type: "text_delta", delta: "first" }], + [{ type: "text_delta", delta: "second" }], + ]); + const agent = new AgentBuilder("test-agent", model).build(); + const request = agent.prompt("hi"); + const iterator = request.stream()[Symbol.asyncIterator](); + + expect(await nextEvent(iterator)).toMatchObject({ + type: "turn_start", + turn: 1, + prompt: Message.user("hi"), + }); + expect(await nextEvent(iterator)).toMatchObject({ + type: "text_delta", + turn: 1, + delta: "first", + }); + expect(await nextEvent(iterator)).toMatchObject({ type: "turn_end", turn: 1 }); + + expect(request.steer("revise")).toBe(true); + + const rest = await collectIterator(iterator); + expect(rest.map((event) => event.type)).toEqual([ + "turn_start", + "text_delta", + "turn_end", + "final", + ]); + expect(rest[0]).toMatchObject({ + type: "turn_start", + turn: 2, + prompt: Message.user("revise"), + }); + expect(model.requests).toHaveLength(2); + expect(model.requests[1]?.chatHistory.at(-1)).toEqual(Message.user("revise")); + expect(rest.at(-1)).toMatchObject({ + type: "final", + output: "second", + messages: [ + Message.user("hi"), + Message.assistant("first"), + Message.user("revise"), + Message.assistant("second"), + ], + }); + }); + + it("appends steering after tool results before the next completion turn", async () => { + const toolStarted = deferred(); + const toolRelease = deferred(); + const slowAddTool = createTool({ + name: "slow_add", + description: "Add numbers slowly", + input: z.object({ + x: z.number(), + y: z.number(), + }), + output: z.number(), + async execute(args) { + toolStarted.resolve(); + await toolRelease.promise; + return args.x + args.y; + }, + }); + const toolCall = AssistantContent.toolCall("call_1", "slow_add", { x: 2, y: 5 }); + const model = new StreamingQueueModel([ + [{ type: "tool_call", toolCall }], + [{ type: "text_delta", delta: "done" }], + ]); + const agent = new AgentBuilder("test-agent", model).tool(slowAddTool).build(); + const request = agent.prompt("add"); + const eventsPromise = collect(request.stream()); + + await toolStarted.promise; + expect(request.steer("also explain")).toBe(true); + toolRelease.resolve(7); + + const events = await eventsPromise; + expect(events.at(-1)).toMatchObject({ type: "final", output: "done" }); + expect(model.requests).toHaveLength(2); + expect(model.requests[1]?.chatHistory.slice(-3)).toEqual([ + Message.assistant([toolCall]), + Message.tool([ + { + type: "tool_result", + id: "call_1", + content: [{ type: "text", text: "7" }], + }, + ]), + Message.user("also explain"), + ]); + }); + + it("consumes multiple steering calls in FIFO order", async () => { + const model = new StreamingQueueModel([ + [{ type: "text_delta", delta: "base" }], + [{ type: "text_delta", delta: "done" }], + ]); + const agent = new AgentBuilder("test-agent", model).build(); + const request = agent.prompt("start"); + const iterator = request.stream()[Symbol.asyncIterator](); + const firstSteer = Message.user("first steer"); + const secondSteer = Message.user("second steer"); + + expect(await nextEvent(iterator)).toMatchObject({ type: "turn_start", turn: 1 }); + expect(await nextEvent(iterator)).toMatchObject({ type: "text_delta", turn: 1 }); + expect(await nextEvent(iterator)).toMatchObject({ type: "turn_end", turn: 1 }); + + expect(request.steer(firstSteer)).toBe(true); + expect(request.steer([secondSteer])).toBe(true); + + const rest = await collectIterator(iterator); + expect(rest[0]).toMatchObject({ + type: "turn_start", + turn: 2, + prompt: secondSteer, + }); + expect(model.requests[1]?.chatHistory.slice(-3)).toEqual([ + Message.assistant("base"), + firstSteer, + secondSteer, + ]); + expect(rest.at(-1)).toMatchObject({ + type: "final", + messages: [ + Message.user("start"), + Message.assistant("base"), + firstSteer, + secondSteer, + Message.assistant("done"), + ], + }); + }); + it("merges usage-only final stream responses with accumulated text", async () => { const model = new StreamingQueueModel([ [ diff --git a/packages/embeddings/fastembed/CHANGELOG.md b/packages/embedding-fastembed/CHANGELOG.md similarity index 79% rename from packages/embeddings/fastembed/CHANGELOG.md rename to packages/embedding-fastembed/CHANGELOG.md index d6ef3fb0..853f2668 100644 --- a/packages/embeddings/fastembed/CHANGELOG.md +++ b/packages/embedding-fastembed/CHANGELOG.md @@ -1,5 +1,18 @@ # @anvia/fastembed +## 0.2.7 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.2.6 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + ## 0.2.5 ### Patch Changes diff --git a/packages/embeddings/fastembed/README.md b/packages/embedding-fastembed/README.md similarity index 100% rename from packages/embeddings/fastembed/README.md rename to packages/embedding-fastembed/README.md diff --git a/packages/embeddings/fastembed/package.json b/packages/embedding-fastembed/package.json similarity index 92% rename from packages/embeddings/fastembed/package.json rename to packages/embedding-fastembed/package.json index b2a8eade..a9c80c44 100644 --- a/packages/embeddings/fastembed/package.json +++ b/packages/embedding-fastembed/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/fastembed", - "version": "0.2.5", + "version": "0.2.7", "description": "FastEmbed embedding model adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "https://github.com/anvia-hq/anvia", - "directory": "packages/embeddings/fastembed" + "directory": "packages/embedding-fastembed" }, "files": [ "dist" diff --git a/packages/embeddings/fastembed/src/index.ts b/packages/embedding-fastembed/src/index.ts similarity index 100% rename from packages/embeddings/fastembed/src/index.ts rename to packages/embedding-fastembed/src/index.ts diff --git a/packages/embeddings/fastembed/test/embedding.test.ts b/packages/embedding-fastembed/test/embedding.test.ts similarity index 100% rename from packages/embeddings/fastembed/test/embedding.test.ts rename to packages/embedding-fastembed/test/embedding.test.ts diff --git a/packages/embeddings/transformers/tsconfig.json b/packages/embedding-fastembed/tsconfig.json similarity index 69% rename from packages/embeddings/transformers/tsconfig.json rename to packages/embedding-fastembed/tsconfig.json index 9fd54c2d..6bb5dcd8 100644 --- a/packages/embeddings/transformers/tsconfig.json +++ b/packages/embedding-fastembed/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist" }, diff --git a/packages/embeddings/transformers/vitest.config.ts b/packages/embedding-fastembed/vitest.config.ts similarity index 50% rename from packages/embeddings/transformers/vitest.config.ts rename to packages/embedding-fastembed/vitest.config.ts index 8f0fea5e..cd35596b 100644 --- a/packages/embeddings/transformers/vitest.config.ts +++ b/packages/embedding-fastembed/vitest.config.ts @@ -3,9 +3,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { - "@anvia/core/embeddings": new URL("../../core/src/embeddings/index.ts", import.meta.url) + "@anvia/core/embeddings": new URL("../core/src/embeddings/index.ts", import.meta.url) .pathname, - "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, + "@anvia/core": new URL("../core/src/index.ts", import.meta.url).pathname, }, }, test: { diff --git a/packages/embeddings/transformers/CHANGELOG.md b/packages/embedding-transformers/CHANGELOG.md similarity index 79% rename from packages/embeddings/transformers/CHANGELOG.md rename to packages/embedding-transformers/CHANGELOG.md index 7dc16f35..b6ffd4cb 100644 --- a/packages/embeddings/transformers/CHANGELOG.md +++ b/packages/embedding-transformers/CHANGELOG.md @@ -1,5 +1,18 @@ # @anvia/transformers +## 0.2.7 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.2.6 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + ## 0.2.5 ### Patch Changes diff --git a/packages/embeddings/transformers/README.md b/packages/embedding-transformers/README.md similarity index 100% rename from packages/embeddings/transformers/README.md rename to packages/embedding-transformers/README.md diff --git a/packages/embeddings/transformers/package.json b/packages/embedding-transformers/package.json similarity index 92% rename from packages/embeddings/transformers/package.json rename to packages/embedding-transformers/package.json index 510c83c3..e7209a2f 100644 --- a/packages/embeddings/transformers/package.json +++ b/packages/embedding-transformers/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/transformers", - "version": "0.2.5", + "version": "0.2.7", "description": "Transformers.js embedding model adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "https://github.com/anvia-hq/anvia", - "directory": "packages/embeddings/transformers" + "directory": "packages/embedding-transformers" }, "files": [ "dist" diff --git a/packages/embeddings/transformers/src/index.ts b/packages/embedding-transformers/src/index.ts similarity index 100% rename from packages/embeddings/transformers/src/index.ts rename to packages/embedding-transformers/src/index.ts diff --git a/packages/embeddings/transformers/test/embedding.test.ts b/packages/embedding-transformers/test/embedding.test.ts similarity index 100% rename from packages/embeddings/transformers/test/embedding.test.ts rename to packages/embedding-transformers/test/embedding.test.ts diff --git a/packages/observability/langfuse/tsconfig.json b/packages/embedding-transformers/tsconfig.json similarity index 69% rename from packages/observability/langfuse/tsconfig.json rename to packages/embedding-transformers/tsconfig.json index 9fd54c2d..6bb5dcd8 100644 --- a/packages/observability/langfuse/tsconfig.json +++ b/packages/embedding-transformers/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist" }, diff --git a/packages/embeddings/fastembed/vitest.config.ts b/packages/embedding-transformers/vitest.config.ts similarity index 50% rename from packages/embeddings/fastembed/vitest.config.ts rename to packages/embedding-transformers/vitest.config.ts index 8f0fea5e..cd35596b 100644 --- a/packages/embeddings/fastembed/vitest.config.ts +++ b/packages/embedding-transformers/vitest.config.ts @@ -3,9 +3,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { - "@anvia/core/embeddings": new URL("../../core/src/embeddings/index.ts", import.meta.url) + "@anvia/core/embeddings": new URL("../core/src/embeddings/index.ts", import.meta.url) .pathname, - "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, + "@anvia/core": new URL("../core/src/index.ts", import.meta.url).pathname, }, }, test: { diff --git a/packages/logger/CHANGELOG.md b/packages/logger/CHANGELOG.md index 2633c538..3d18a8ec 100644 --- a/packages/logger/CHANGELOG.md +++ b/packages/logger/CHANGELOG.md @@ -1,5 +1,12 @@ # @anvia/logger +## 0.3.7 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + ## 0.3.6 ### Patch Changes diff --git a/packages/logger/package.json b/packages/logger/package.json index f2fe70de..1fa55d4a 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/logger", - "version": "0.3.6", + "version": "0.3.7", "description": "Structured logger adapters for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", diff --git a/packages/observability/langfuse/CHANGELOG.md b/packages/observability-langfuse/CHANGELOG.md similarity index 90% rename from packages/observability/langfuse/CHANGELOG.md rename to packages/observability-langfuse/CHANGELOG.md index 2697c9e1..8acf5c64 100644 --- a/packages/observability/langfuse/CHANGELOG.md +++ b/packages/observability-langfuse/CHANGELOG.md @@ -1,5 +1,11 @@ # @anvia/langfuse +## 0.2.4 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + ## 0.2.3 ### Patch Changes diff --git a/packages/observability/langfuse/README.md b/packages/observability-langfuse/README.md similarity index 100% rename from packages/observability/langfuse/README.md rename to packages/observability-langfuse/README.md diff --git a/packages/observability/langfuse/package.json b/packages/observability-langfuse/package.json similarity index 92% rename from packages/observability/langfuse/package.json rename to packages/observability-langfuse/package.json index 1612ca7f..db0dbd3c 100644 --- a/packages/observability/langfuse/package.json +++ b/packages/observability-langfuse/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/langfuse", - "version": "0.2.3", + "version": "0.2.4", "description": "Langfuse tracing adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "https://github.com/anvia-hq/anvia", - "directory": "packages/observability/langfuse" + "directory": "packages/observability-langfuse" }, "files": [ "dist" diff --git a/packages/observability/langfuse/src/index.ts b/packages/observability-langfuse/src/index.ts similarity index 100% rename from packages/observability/langfuse/src/index.ts rename to packages/observability-langfuse/src/index.ts diff --git a/packages/observability/langfuse/test/langfuse.test.ts b/packages/observability-langfuse/test/langfuse.test.ts similarity index 100% rename from packages/observability/langfuse/test/langfuse.test.ts rename to packages/observability-langfuse/test/langfuse.test.ts diff --git a/packages/embeddings/fastembed/tsconfig.json b/packages/observability-langfuse/tsconfig.json similarity index 69% rename from packages/embeddings/fastembed/tsconfig.json rename to packages/observability-langfuse/tsconfig.json index 9fd54c2d..6bb5dcd8 100644 --- a/packages/embeddings/fastembed/tsconfig.json +++ b/packages/observability-langfuse/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist" }, diff --git a/packages/observability-langfuse/vitest.config.ts b/packages/observability-langfuse/vitest.config.ts new file mode 100644 index 00000000..e362d69c --- /dev/null +++ b/packages/observability-langfuse/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@anvia/core/completion": new URL("../core/src/completion/index.ts", import.meta.url) + .pathname, + "@anvia/core/evals": new URL("../core/src/evals/index.ts", import.meta.url).pathname, + "@anvia/core/observability": new URL("../core/src/observability/index.ts", import.meta.url) + .pathname, + "@anvia/core": new URL("../core/src/index.ts", import.meta.url).pathname, + }, + }, + test: { + environment: "node", + }, +}); diff --git a/packages/observability/otel/CHANGELOG.md b/packages/observability-otel/CHANGELOG.md similarity index 79% rename from packages/observability/otel/CHANGELOG.md rename to packages/observability-otel/CHANGELOG.md index bfa46033..8343f01d 100644 --- a/packages/observability/otel/CHANGELOG.md +++ b/packages/observability-otel/CHANGELOG.md @@ -1,5 +1,18 @@ # @anvia/otel +## 0.2.7 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.2.6 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + ## 0.2.5 ### Patch Changes diff --git a/packages/observability/otel/README.md b/packages/observability-otel/README.md similarity index 100% rename from packages/observability/otel/README.md rename to packages/observability-otel/README.md diff --git a/packages/observability/otel/package.json b/packages/observability-otel/package.json similarity index 92% rename from packages/observability/otel/package.json rename to packages/observability-otel/package.json index 912ed008..484f2f28 100644 --- a/packages/observability/otel/package.json +++ b/packages/observability-otel/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/otel", - "version": "0.2.5", + "version": "0.2.7", "description": "OpenTelemetry tracing adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "https://github.com/anvia-hq/anvia", - "directory": "packages/observability/otel" + "directory": "packages/observability-otel" }, "files": [ "dist" diff --git a/packages/observability/otel/src/index.ts b/packages/observability-otel/src/index.ts similarity index 100% rename from packages/observability/otel/src/index.ts rename to packages/observability-otel/src/index.ts diff --git a/packages/observability/otel/test/otel.test.ts b/packages/observability-otel/test/otel.test.ts similarity index 100% rename from packages/observability/otel/test/otel.test.ts rename to packages/observability-otel/test/otel.test.ts diff --git a/packages/observability/otel/tsconfig.json b/packages/observability-otel/tsconfig.json similarity index 69% rename from packages/observability/otel/tsconfig.json rename to packages/observability-otel/tsconfig.json index 9fd54c2d..6bb5dcd8 100644 --- a/packages/observability/otel/tsconfig.json +++ b/packages/observability-otel/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist" }, diff --git a/packages/observability-otel/vitest.config.ts b/packages/observability-otel/vitest.config.ts new file mode 100644 index 00000000..a2b56dd5 --- /dev/null +++ b/packages/observability-otel/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@anvia/core/completion": new URL("../core/src/completion/index.ts", import.meta.url) + .pathname, + "@anvia/core/observability": new URL("../core/src/observability/index.ts", import.meta.url) + .pathname, + "@anvia/core": new URL("../core/src/index.ts", import.meta.url).pathname, + }, + }, + test: { + environment: "node", + }, +}); diff --git a/packages/observability/langfuse/vitest.config.ts b/packages/observability/langfuse/vitest.config.ts deleted file mode 100644 index 93d7b2ea..00000000 --- a/packages/observability/langfuse/vitest.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - resolve: { - alias: { - "@anvia/core/completion": new URL("../../core/src/completion/index.ts", import.meta.url) - .pathname, - "@anvia/core/evals": new URL("../../core/src/evals/index.ts", import.meta.url).pathname, - "@anvia/core/observability": new URL("../../core/src/observability/index.ts", import.meta.url) - .pathname, - "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, - }, - }, - test: { - environment: "node", - }, -}); diff --git a/packages/observability/otel/vitest.config.ts b/packages/observability/otel/vitest.config.ts deleted file mode 100644 index 89eb7921..00000000 --- a/packages/observability/otel/vitest.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - resolve: { - alias: { - "@anvia/core/completion": new URL("../../core/src/completion/index.ts", import.meta.url) - .pathname, - "@anvia/core/observability": new URL("../../core/src/observability/index.ts", import.meta.url) - .pathname, - "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, - }, - }, - test: { - environment: "node", - }, -}); diff --git a/packages/providers/anthropic/CHANGELOG.md b/packages/provider-anthropic/CHANGELOG.md similarity index 91% rename from packages/providers/anthropic/CHANGELOG.md rename to packages/provider-anthropic/CHANGELOG.md index c6ab51b2..f0affd40 100644 --- a/packages/providers/anthropic/CHANGELOG.md +++ b/packages/provider-anthropic/CHANGELOG.md @@ -1,5 +1,11 @@ # @anvia/anthropic +## 0.3.6 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + ## 0.3.5 ### Patch Changes diff --git a/packages/providers/anthropic/README.md b/packages/provider-anthropic/README.md similarity index 100% rename from packages/providers/anthropic/README.md rename to packages/provider-anthropic/README.md diff --git a/packages/providers/anthropic/package.json b/packages/provider-anthropic/package.json similarity index 77% rename from packages/providers/anthropic/package.json rename to packages/provider-anthropic/package.json index 53e4b941..15bbd4c5 100644 --- a/packages/providers/anthropic/package.json +++ b/packages/provider-anthropic/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/anthropic", - "version": "0.3.5", + "version": "0.3.6", "description": "Anthropic provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "https://github.com/anvia-hq/anvia", - "directory": "packages/providers/anthropic" + "directory": "packages/provider-anthropic" }, "files": [ "dist" @@ -26,10 +26,10 @@ } }, "scripts": { - "prebuild": "test -f ../../core/dist/index.d.ts || pnpm --filter @anvia/core build", + "prebuild": "test -f ../core/dist/index.d.ts || pnpm --filter @anvia/core build", "build": "tsup src/index.ts --format esm --dts --sourcemap --clean", "test": "vitest run", - "pretypecheck": "test -f ../../core/dist/index.d.ts || pnpm --filter @anvia/core build", + "pretypecheck": "test -f ../core/dist/index.d.ts || pnpm --filter @anvia/core build", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/providers/anthropic/src/anthropic/client.ts b/packages/provider-anthropic/src/anthropic/client.ts similarity index 100% rename from packages/providers/anthropic/src/anthropic/client.ts rename to packages/provider-anthropic/src/anthropic/client.ts diff --git a/packages/providers/anthropic/src/anthropic/completion.ts b/packages/provider-anthropic/src/anthropic/completion.ts similarity index 100% rename from packages/providers/anthropic/src/anthropic/completion.ts rename to packages/provider-anthropic/src/anthropic/completion.ts diff --git a/packages/providers/anthropic/src/anthropic/index.ts b/packages/provider-anthropic/src/anthropic/index.ts similarity index 100% rename from packages/providers/anthropic/src/anthropic/index.ts rename to packages/provider-anthropic/src/anthropic/index.ts diff --git a/packages/providers/anthropic/src/index.ts b/packages/provider-anthropic/src/index.ts similarity index 100% rename from packages/providers/anthropic/src/index.ts rename to packages/provider-anthropic/src/index.ts diff --git a/packages/providers/anthropic/src/request-messages.ts b/packages/provider-anthropic/src/request-messages.ts similarity index 100% rename from packages/providers/anthropic/src/request-messages.ts rename to packages/provider-anthropic/src/request-messages.ts diff --git a/packages/providers/anthropic/src/utils.ts b/packages/provider-anthropic/src/utils.ts similarity index 100% rename from packages/providers/anthropic/src/utils.ts rename to packages/provider-anthropic/src/utils.ts diff --git a/packages/providers/anthropic/test/anthropic-completion.test.ts b/packages/provider-anthropic/test/anthropic-completion.test.ts similarity index 100% rename from packages/providers/anthropic/test/anthropic-completion.test.ts rename to packages/provider-anthropic/test/anthropic-completion.test.ts diff --git a/packages/providers/anthropic/test/client.test.ts b/packages/provider-anthropic/test/client.test.ts similarity index 100% rename from packages/providers/anthropic/test/client.test.ts rename to packages/provider-anthropic/test/client.test.ts diff --git a/packages/provider-anthropic/tsconfig.json b/packages/provider-anthropic/tsconfig.json new file mode 100644 index 00000000..6bb5dcd8 --- /dev/null +++ b/packages/provider-anthropic/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/packages/provider-anthropic/vitest.config.ts b/packages/provider-anthropic/vitest.config.ts new file mode 100644 index 00000000..8b9340fe --- /dev/null +++ b/packages/provider-anthropic/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@anvia/core/agent": new URL("../core/src/agent/index.ts", import.meta.url).pathname, + "@anvia/core/completion": new URL("../core/src/completion/index.ts", import.meta.url) + .pathname, + "@anvia/core/model-listing": new URL("../core/src/model-listing/index.ts", import.meta.url) + .pathname, + "@anvia/core/tool": new URL("../core/src/tool/index.ts", import.meta.url).pathname, + "@anvia/core": new URL("../core/src/index.ts", import.meta.url).pathname, + }, + }, + test: { + environment: "node", + }, +}); diff --git a/packages/providers/gemini/CHANGELOG.md b/packages/provider-gemini/CHANGELOG.md similarity index 90% rename from packages/providers/gemini/CHANGELOG.md rename to packages/provider-gemini/CHANGELOG.md index fc1bb6ae..e66d5260 100644 --- a/packages/providers/gemini/CHANGELOG.md +++ b/packages/provider-gemini/CHANGELOG.md @@ -1,5 +1,11 @@ # @anvia/gemini +## 0.2.5 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + ## 0.2.4 ### Patch Changes diff --git a/packages/providers/gemini/README.md b/packages/provider-gemini/README.md similarity index 100% rename from packages/providers/gemini/README.md rename to packages/provider-gemini/README.md diff --git a/packages/providers/gemini/package.json b/packages/provider-gemini/package.json similarity index 77% rename from packages/providers/gemini/package.json rename to packages/provider-gemini/package.json index 48b4b950..4226693a 100644 --- a/packages/providers/gemini/package.json +++ b/packages/provider-gemini/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/gemini", - "version": "0.2.4", + "version": "0.2.5", "description": "Gemini provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "https://github.com/anvia-hq/anvia", - "directory": "packages/providers/gemini" + "directory": "packages/provider-gemini" }, "files": [ "dist" @@ -26,10 +26,10 @@ } }, "scripts": { - "prebuild": "test -f ../../core/dist/index.d.ts || pnpm --filter @anvia/core build", + "prebuild": "test -f ../core/dist/index.d.ts || pnpm --filter @anvia/core build", "build": "tsup src/index.ts --format esm --dts --sourcemap --clean", "test": "vitest run", - "pretypecheck": "test -f ../../core/dist/index.d.ts || pnpm --filter @anvia/core build", + "pretypecheck": "test -f ../core/dist/index.d.ts || pnpm --filter @anvia/core build", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/providers/gemini/src/gemini/client.ts b/packages/provider-gemini/src/gemini/client.ts similarity index 100% rename from packages/providers/gemini/src/gemini/client.ts rename to packages/provider-gemini/src/gemini/client.ts diff --git a/packages/providers/gemini/src/gemini/completion.ts b/packages/provider-gemini/src/gemini/completion.ts similarity index 100% rename from packages/providers/gemini/src/gemini/completion.ts rename to packages/provider-gemini/src/gemini/completion.ts diff --git a/packages/providers/gemini/src/gemini/embedding.ts b/packages/provider-gemini/src/gemini/embedding.ts similarity index 100% rename from packages/providers/gemini/src/gemini/embedding.ts rename to packages/provider-gemini/src/gemini/embedding.ts diff --git a/packages/providers/gemini/src/gemini/image-generation.ts b/packages/provider-gemini/src/gemini/image-generation.ts similarity index 100% rename from packages/providers/gemini/src/gemini/image-generation.ts rename to packages/provider-gemini/src/gemini/image-generation.ts diff --git a/packages/providers/gemini/src/gemini/index.ts b/packages/provider-gemini/src/gemini/index.ts similarity index 100% rename from packages/providers/gemini/src/gemini/index.ts rename to packages/provider-gemini/src/gemini/index.ts diff --git a/packages/providers/gemini/src/gemini/transcription.ts b/packages/provider-gemini/src/gemini/transcription.ts similarity index 100% rename from packages/providers/gemini/src/gemini/transcription.ts rename to packages/provider-gemini/src/gemini/transcription.ts diff --git a/packages/providers/gemini/src/index.ts b/packages/provider-gemini/src/index.ts similarity index 100% rename from packages/providers/gemini/src/index.ts rename to packages/provider-gemini/src/index.ts diff --git a/packages/providers/gemini/src/request-messages.ts b/packages/provider-gemini/src/request-messages.ts similarity index 100% rename from packages/providers/gemini/src/request-messages.ts rename to packages/provider-gemini/src/request-messages.ts diff --git a/packages/providers/gemini/test/client.test.ts b/packages/provider-gemini/test/client.test.ts similarity index 100% rename from packages/providers/gemini/test/client.test.ts rename to packages/provider-gemini/test/client.test.ts diff --git a/packages/providers/gemini/test/completion.test.ts b/packages/provider-gemini/test/completion.test.ts similarity index 100% rename from packages/providers/gemini/test/completion.test.ts rename to packages/provider-gemini/test/completion.test.ts diff --git a/packages/providers/gemini/test/embedding.test.ts b/packages/provider-gemini/test/embedding.test.ts similarity index 100% rename from packages/providers/gemini/test/embedding.test.ts rename to packages/provider-gemini/test/embedding.test.ts diff --git a/packages/providers/gemini/test/multimodal.test.ts b/packages/provider-gemini/test/multimodal.test.ts similarity index 100% rename from packages/providers/gemini/test/multimodal.test.ts rename to packages/provider-gemini/test/multimodal.test.ts diff --git a/packages/provider-gemini/tsconfig.json b/packages/provider-gemini/tsconfig.json new file mode 100644 index 00000000..6bb5dcd8 --- /dev/null +++ b/packages/provider-gemini/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/packages/provider-gemini/vitest.config.ts b/packages/provider-gemini/vitest.config.ts new file mode 100644 index 00000000..53ce3386 --- /dev/null +++ b/packages/provider-gemini/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@anvia/core/audio-generation": new URL( + "../core/src/audio-generation/index.ts", + import.meta.url, + ).pathname, + "@anvia/core/completion": new URL("../core/src/completion/index.ts", import.meta.url) + .pathname, + "@anvia/core/embeddings": new URL("../core/src/embeddings/index.ts", import.meta.url) + .pathname, + "@anvia/core/image-generation": new URL( + "../core/src/image-generation/index.ts", + import.meta.url, + ).pathname, + "@anvia/core/model-listing": new URL("../core/src/model-listing/index.ts", import.meta.url) + .pathname, + "@anvia/core/transcription": new URL("../core/src/transcription/index.ts", import.meta.url) + .pathname, + "@anvia/core": new URL("../core/src/index.ts", import.meta.url).pathname, + }, + }, + test: { + environment: "node", + }, +}); diff --git a/packages/providers/mistral/CHANGELOG.md b/packages/provider-mistral/CHANGELOG.md similarity index 87% rename from packages/providers/mistral/CHANGELOG.md rename to packages/provider-mistral/CHANGELOG.md index 96f7e362..01b80167 100644 --- a/packages/providers/mistral/CHANGELOG.md +++ b/packages/provider-mistral/CHANGELOG.md @@ -1,5 +1,18 @@ # @anvia/mistral +## 0.2.8 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.2.7 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + ## 0.2.6 ### Patch Changes diff --git a/packages/providers/mistral/README.md b/packages/provider-mistral/README.md similarity index 100% rename from packages/providers/mistral/README.md rename to packages/provider-mistral/README.md diff --git a/packages/providers/mistral/package.json b/packages/provider-mistral/package.json similarity index 78% rename from packages/providers/mistral/package.json rename to packages/provider-mistral/package.json index a7bd298b..7e5676d9 100644 --- a/packages/providers/mistral/package.json +++ b/packages/provider-mistral/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/mistral", - "version": "0.2.6", + "version": "0.2.8", "description": "Mistral provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "https://github.com/anvia-hq/anvia", - "directory": "packages/providers/mistral" + "directory": "packages/provider-mistral" }, "files": [ "dist" @@ -26,10 +26,10 @@ } }, "scripts": { - "prebuild": "test -f ../../core/dist/index.d.ts || pnpm --filter @anvia/core build", + "prebuild": "test -f ../core/dist/index.d.ts || pnpm --filter @anvia/core build", "build": "tsup src/index.ts --format esm --dts --sourcemap --clean", "test": "vitest run", - "pretypecheck": "test -f ../../core/dist/index.d.ts || pnpm --filter @anvia/core build", + "pretypecheck": "test -f ../core/dist/index.d.ts || pnpm --filter @anvia/core build", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/providers/mistral/src/index.ts b/packages/provider-mistral/src/index.ts similarity index 100% rename from packages/providers/mistral/src/index.ts rename to packages/provider-mistral/src/index.ts diff --git a/packages/providers/mistral/src/mistral/client.ts b/packages/provider-mistral/src/mistral/client.ts similarity index 100% rename from packages/providers/mistral/src/mistral/client.ts rename to packages/provider-mistral/src/mistral/client.ts diff --git a/packages/providers/mistral/src/mistral/completion.ts b/packages/provider-mistral/src/mistral/completion.ts similarity index 100% rename from packages/providers/mistral/src/mistral/completion.ts rename to packages/provider-mistral/src/mistral/completion.ts diff --git a/packages/providers/mistral/src/mistral/embedding.ts b/packages/provider-mistral/src/mistral/embedding.ts similarity index 100% rename from packages/providers/mistral/src/mistral/embedding.ts rename to packages/provider-mistral/src/mistral/embedding.ts diff --git a/packages/providers/mistral/src/mistral/index.ts b/packages/provider-mistral/src/mistral/index.ts similarity index 100% rename from packages/providers/mistral/src/mistral/index.ts rename to packages/provider-mistral/src/mistral/index.ts diff --git a/packages/providers/mistral/src/request-messages.ts b/packages/provider-mistral/src/request-messages.ts similarity index 100% rename from packages/providers/mistral/src/request-messages.ts rename to packages/provider-mistral/src/request-messages.ts diff --git a/packages/providers/mistral/src/utils.ts b/packages/provider-mistral/src/utils.ts similarity index 100% rename from packages/providers/mistral/src/utils.ts rename to packages/provider-mistral/src/utils.ts diff --git a/packages/providers/mistral/test/client.test.ts b/packages/provider-mistral/test/client.test.ts similarity index 100% rename from packages/providers/mistral/test/client.test.ts rename to packages/provider-mistral/test/client.test.ts diff --git a/packages/providers/mistral/test/completion.test.ts b/packages/provider-mistral/test/completion.test.ts similarity index 100% rename from packages/providers/mistral/test/completion.test.ts rename to packages/provider-mistral/test/completion.test.ts diff --git a/packages/providers/mistral/test/embedding.test.ts b/packages/provider-mistral/test/embedding.test.ts similarity index 100% rename from packages/providers/mistral/test/embedding.test.ts rename to packages/provider-mistral/test/embedding.test.ts diff --git a/packages/provider-mistral/tsconfig.json b/packages/provider-mistral/tsconfig.json new file mode 100644 index 00000000..6bb5dcd8 --- /dev/null +++ b/packages/provider-mistral/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/packages/provider-mistral/vitest.config.ts b/packages/provider-mistral/vitest.config.ts new file mode 100644 index 00000000..aa1afec3 --- /dev/null +++ b/packages/provider-mistral/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@anvia/core/completion": new URL("../core/src/completion/index.ts", import.meta.url) + .pathname, + "@anvia/core/embeddings": new URL("../core/src/embeddings/index.ts", import.meta.url) + .pathname, + "@anvia/core/model-listing": new URL("../core/src/model-listing/index.ts", import.meta.url) + .pathname, + "@anvia/core": new URL("../core/src/index.ts", import.meta.url).pathname, + }, + }, + test: { + environment: "node", + }, +}); diff --git a/packages/providers/openai/CHANGELOG.md b/packages/provider-openai/CHANGELOG.md similarity index 89% rename from packages/providers/openai/CHANGELOG.md rename to packages/provider-openai/CHANGELOG.md index 912b94c9..78cbb6f2 100644 --- a/packages/providers/openai/CHANGELOG.md +++ b/packages/provider-openai/CHANGELOG.md @@ -1,5 +1,18 @@ # @anvia/openai +## 0.3.9 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.3.8 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + ## 0.3.7 ### Patch Changes diff --git a/packages/providers/openai/README.md b/packages/provider-openai/README.md similarity index 100% rename from packages/providers/openai/README.md rename to packages/provider-openai/README.md diff --git a/packages/providers/openai/package.json b/packages/provider-openai/package.json similarity index 77% rename from packages/providers/openai/package.json rename to packages/provider-openai/package.json index b28cffe0..8d9b5610 100644 --- a/packages/providers/openai/package.json +++ b/packages/provider-openai/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/openai", - "version": "0.3.7", + "version": "0.3.9", "description": "OpenAI provider adapter for Anvia.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "https://github.com/anvia-hq/anvia", - "directory": "packages/providers/openai" + "directory": "packages/provider-openai" }, "files": [ "dist" @@ -26,9 +26,9 @@ } }, "scripts": { - "prebuild": "test -f ../../core/dist/index.d.ts || pnpm --filter @anvia/core build", + "prebuild": "test -f ../core/dist/index.d.ts || pnpm --filter @anvia/core build", "build": "tsup src/index.ts --format esm --dts --sourcemap --clean", - "pretypecheck": "test -f ../../core/dist/index.d.ts || pnpm --filter @anvia/core build", + "pretypecheck": "test -f ../core/dist/index.d.ts || pnpm --filter @anvia/core build", "test": "vitest run", "typecheck": "tsc --noEmit" }, diff --git a/packages/providers/openai/src/index.ts b/packages/provider-openai/src/index.ts similarity index 100% rename from packages/providers/openai/src/index.ts rename to packages/provider-openai/src/index.ts diff --git a/packages/providers/openai/src/openai/audio-generation.ts b/packages/provider-openai/src/openai/audio-generation.ts similarity index 100% rename from packages/providers/openai/src/openai/audio-generation.ts rename to packages/provider-openai/src/openai/audio-generation.ts diff --git a/packages/providers/openai/src/openai/chat-completion.ts b/packages/provider-openai/src/openai/chat-completion.ts similarity index 100% rename from packages/providers/openai/src/openai/chat-completion.ts rename to packages/provider-openai/src/openai/chat-completion.ts diff --git a/packages/providers/openai/src/openai/client.ts b/packages/provider-openai/src/openai/client.ts similarity index 100% rename from packages/providers/openai/src/openai/client.ts rename to packages/provider-openai/src/openai/client.ts diff --git a/packages/providers/openai/src/openai/embedding.ts b/packages/provider-openai/src/openai/embedding.ts similarity index 100% rename from packages/providers/openai/src/openai/embedding.ts rename to packages/provider-openai/src/openai/embedding.ts diff --git a/packages/providers/openai/src/openai/image-generation.ts b/packages/provider-openai/src/openai/image-generation.ts similarity index 100% rename from packages/providers/openai/src/openai/image-generation.ts rename to packages/provider-openai/src/openai/image-generation.ts diff --git a/packages/providers/openai/src/openai/index.ts b/packages/provider-openai/src/openai/index.ts similarity index 100% rename from packages/providers/openai/src/openai/index.ts rename to packages/provider-openai/src/openai/index.ts diff --git a/packages/providers/openai/src/openai/responses.ts b/packages/provider-openai/src/openai/responses.ts similarity index 100% rename from packages/providers/openai/src/openai/responses.ts rename to packages/provider-openai/src/openai/responses.ts diff --git a/packages/providers/openai/src/openai/transcription.ts b/packages/provider-openai/src/openai/transcription.ts similarity index 100% rename from packages/providers/openai/src/openai/transcription.ts rename to packages/provider-openai/src/openai/transcription.ts diff --git a/packages/providers/openai/src/request-messages.ts b/packages/provider-openai/src/request-messages.ts similarity index 100% rename from packages/providers/openai/src/request-messages.ts rename to packages/provider-openai/src/request-messages.ts diff --git a/packages/providers/openai/src/utils.ts b/packages/provider-openai/src/utils.ts similarity index 100% rename from packages/providers/openai/src/utils.ts rename to packages/provider-openai/src/utils.ts diff --git a/packages/providers/openai/test/client.test.ts b/packages/provider-openai/test/client.test.ts similarity index 100% rename from packages/providers/openai/test/client.test.ts rename to packages/provider-openai/test/client.test.ts diff --git a/packages/providers/openai/test/embedding.test.ts b/packages/provider-openai/test/embedding.test.ts similarity index 100% rename from packages/providers/openai/test/embedding.test.ts rename to packages/provider-openai/test/embedding.test.ts diff --git a/packages/providers/openai/test/multimodal.test.ts b/packages/provider-openai/test/multimodal.test.ts similarity index 100% rename from packages/providers/openai/test/multimodal.test.ts rename to packages/provider-openai/test/multimodal.test.ts diff --git a/packages/providers/openai/test/openai-chat-completion.test.ts b/packages/provider-openai/test/openai-chat-completion.test.ts similarity index 100% rename from packages/providers/openai/test/openai-chat-completion.test.ts rename to packages/provider-openai/test/openai-chat-completion.test.ts diff --git a/packages/providers/openai/test/openai-responses.test.ts b/packages/provider-openai/test/openai-responses.test.ts similarity index 100% rename from packages/providers/openai/test/openai-responses.test.ts rename to packages/provider-openai/test/openai-responses.test.ts diff --git a/packages/provider-openai/tsconfig.json b/packages/provider-openai/tsconfig.json new file mode 100644 index 00000000..6bb5dcd8 --- /dev/null +++ b/packages/provider-openai/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/packages/provider-openai/vitest.config.ts b/packages/provider-openai/vitest.config.ts new file mode 100644 index 00000000..53ce3386 --- /dev/null +++ b/packages/provider-openai/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@anvia/core/audio-generation": new URL( + "../core/src/audio-generation/index.ts", + import.meta.url, + ).pathname, + "@anvia/core/completion": new URL("../core/src/completion/index.ts", import.meta.url) + .pathname, + "@anvia/core/embeddings": new URL("../core/src/embeddings/index.ts", import.meta.url) + .pathname, + "@anvia/core/image-generation": new URL( + "../core/src/image-generation/index.ts", + import.meta.url, + ).pathname, + "@anvia/core/model-listing": new URL("../core/src/model-listing/index.ts", import.meta.url) + .pathname, + "@anvia/core/transcription": new URL("../core/src/transcription/index.ts", import.meta.url) + .pathname, + "@anvia/core": new URL("../core/src/index.ts", import.meta.url).pathname, + }, + }, + test: { + environment: "node", + }, +}); diff --git a/packages/providers/anthropic/tsconfig.json b/packages/providers/anthropic/tsconfig.json deleted file mode 100644 index 9fd54c2d..00000000 --- a/packages/providers/anthropic/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist" - }, - "include": ["src", "test", "vitest.config.ts"] -} diff --git a/packages/providers/anthropic/vitest.config.ts b/packages/providers/anthropic/vitest.config.ts deleted file mode 100644 index 279809c5..00000000 --- a/packages/providers/anthropic/vitest.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - resolve: { - alias: { - "@anvia/core/agent": new URL("../../core/src/agent/index.ts", import.meta.url).pathname, - "@anvia/core/completion": new URL("../../core/src/completion/index.ts", import.meta.url) - .pathname, - "@anvia/core/model-listing": new URL("../../core/src/model-listing/index.ts", import.meta.url) - .pathname, - "@anvia/core/tool": new URL("../../core/src/tool/index.ts", import.meta.url).pathname, - "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, - }, - }, - test: { - environment: "node", - }, -}); diff --git a/packages/providers/gemini/tsconfig.json b/packages/providers/gemini/tsconfig.json deleted file mode 100644 index 9fd54c2d..00000000 --- a/packages/providers/gemini/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist" - }, - "include": ["src", "test", "vitest.config.ts"] -} diff --git a/packages/providers/gemini/vitest.config.ts b/packages/providers/gemini/vitest.config.ts deleted file mode 100644 index e56625c2..00000000 --- a/packages/providers/gemini/vitest.config.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - resolve: { - alias: { - "@anvia/core/audio-generation": new URL( - "../../core/src/audio-generation/index.ts", - import.meta.url, - ).pathname, - "@anvia/core/completion": new URL("../../core/src/completion/index.ts", import.meta.url) - .pathname, - "@anvia/core/embeddings": new URL("../../core/src/embeddings/index.ts", import.meta.url) - .pathname, - "@anvia/core/image-generation": new URL( - "../../core/src/image-generation/index.ts", - import.meta.url, - ).pathname, - "@anvia/core/model-listing": new URL("../../core/src/model-listing/index.ts", import.meta.url) - .pathname, - "@anvia/core/transcription": new URL("../../core/src/transcription/index.ts", import.meta.url) - .pathname, - "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, - }, - }, - test: { - environment: "node", - }, -}); diff --git a/packages/providers/mistral/tsconfig.json b/packages/providers/mistral/tsconfig.json deleted file mode 100644 index 9fd54c2d..00000000 --- a/packages/providers/mistral/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist" - }, - "include": ["src", "test", "vitest.config.ts"] -} diff --git a/packages/providers/mistral/vitest.config.ts b/packages/providers/mistral/vitest.config.ts deleted file mode 100644 index 443710c4..00000000 --- a/packages/providers/mistral/vitest.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - resolve: { - alias: { - "@anvia/core/completion": new URL("../../core/src/completion/index.ts", import.meta.url) - .pathname, - "@anvia/core/embeddings": new URL("../../core/src/embeddings/index.ts", import.meta.url) - .pathname, - "@anvia/core/model-listing": new URL("../../core/src/model-listing/index.ts", import.meta.url) - .pathname, - "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, - }, - }, - test: { - environment: "node", - }, -}); diff --git a/packages/providers/openai/tsconfig.json b/packages/providers/openai/tsconfig.json deleted file mode 100644 index 9fd54c2d..00000000 --- a/packages/providers/openai/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist" - }, - "include": ["src", "test", "vitest.config.ts"] -} diff --git a/packages/providers/openai/vitest.config.ts b/packages/providers/openai/vitest.config.ts deleted file mode 100644 index e56625c2..00000000 --- a/packages/providers/openai/vitest.config.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - resolve: { - alias: { - "@anvia/core/audio-generation": new URL( - "../../core/src/audio-generation/index.ts", - import.meta.url, - ).pathname, - "@anvia/core/completion": new URL("../../core/src/completion/index.ts", import.meta.url) - .pathname, - "@anvia/core/embeddings": new URL("../../core/src/embeddings/index.ts", import.meta.url) - .pathname, - "@anvia/core/image-generation": new URL( - "../../core/src/image-generation/index.ts", - import.meta.url, - ).pathname, - "@anvia/core/model-listing": new URL("../../core/src/model-listing/index.ts", import.meta.url) - .pathname, - "@anvia/core/transcription": new URL("../../core/src/transcription/index.ts", import.meta.url) - .pathname, - "@anvia/core": new URL("../../core/src/index.ts", import.meta.url).pathname, - }, - }, - test: { - environment: "node", - }, -}); diff --git a/packages/tool-sandbox/CHANGELOG.md b/packages/tool-sandbox/CHANGELOG.md new file mode 100644 index 00000000..405b384f --- /dev/null +++ b/packages/tool-sandbox/CHANGELOG.md @@ -0,0 +1,40 @@ +# @anvia/sandbox + +## 0.3.2 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.3.1 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + +## 0.3.0 + +### Minor Changes + +- 18403b7: Add sandbox V2 controls for persistent workspaces, lifecycle cleanup, streaming command execution, file-size limits, observability hooks, language presets, and model-facing tool policies. + +## 0.2.2 + +### Patch Changes + +- Updated dependencies [2d039f6] + - @anvia/core@0.6.1 + +## 0.2.1 + +### Patch Changes + +- Updated dependencies [e54aece] + - @anvia/core@0.6.0 + +## 0.2.0 + +### Minor Changes + +- 74797ba: Add a Docker-backed sandbox package for ephemeral agent workspaces. diff --git a/packages/tools/sandbox/package.json b/packages/tool-sandbox/package.json similarity index 86% rename from packages/tools/sandbox/package.json rename to packages/tool-sandbox/package.json index 2d9c5843..0c51f4b9 100644 --- a/packages/tools/sandbox/package.json +++ b/packages/tool-sandbox/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/sandbox", - "version": "0.2.2", + "version": "0.3.2", "description": "Sandboxed workspace tools for Anvia agents.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "https://github.com/anvia-hq/anvia", - "directory": "packages/tools/sandbox" + "directory": "packages/tool-sandbox" }, "files": [ "dist" @@ -27,7 +27,7 @@ }, "scripts": { "build": "pnpm run build:deps && tsup src/index.ts --format esm --dts --sourcemap --clean", - "build:deps": "test -f ../../core/dist/index.d.ts || pnpm --filter @anvia/core build", + "build:deps": "test -f ../core/dist/index.d.ts || pnpm --filter @anvia/core build", "test": "pnpm run build:deps && vitest run", "typecheck": "pnpm run build:deps && tsc --noEmit" }, diff --git a/packages/tools/sandbox/src/docker-cli.ts b/packages/tool-sandbox/src/docker-cli.ts similarity index 100% rename from packages/tools/sandbox/src/docker-cli.ts rename to packages/tool-sandbox/src/docker-cli.ts diff --git a/packages/tool-sandbox/src/docker-sandbox.ts b/packages/tool-sandbox/src/docker-sandbox.ts new file mode 100644 index 00000000..06a49776 --- /dev/null +++ b/packages/tool-sandbox/src/docker-sandbox.ts @@ -0,0 +1,698 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { assertDockerCli, runDockerCli } from "./docker-cli"; +import { + SandboxDockerCommandError, + SandboxFileSizeError, + SandboxSessionDestroyedError, + SandboxTimeoutError, +} from "./errors"; +import { containerPath, normalizeSandboxPath, parentSandboxPath } from "./path"; +import type { + DockerSandboxOptions, + Sandbox, + SandboxCreateSessionOptions, + SandboxExecOptions, + SandboxExecResult, + SandboxExecStreamEvent, + SandboxFileEntry, + SandboxFileType, + SandboxHooks, + SandboxLifecycleOptions, + SandboxLimits, + SandboxManifest, + SandboxSession, + SandboxSessionEvent, + SandboxWorkspaceOptions, +} from "./types"; + +const defaultImage = "node:22-bookworm"; +const defaultWorkdir = "/workspace"; +const defaultTimeoutMs = 30_000; +const defaultMaxOutputBytes = 1024 * 1024; + +export class DockerSandbox implements Sandbox { + readonly provider = "docker"; + + private readonly image: string; + private readonly pull: "missing" | "always" | "never"; + private readonly workdir: string; + private readonly workspace: SandboxWorkspaceOptions; + private readonly lifecycle: Required> & + Omit; + private readonly network: NonNullable; + private readonly dockerPath: string; + private readonly labels: Record; + private readonly limits: SandboxLimits; + private readonly security: Required>; + private readonly hooks: SandboxHooks; + private readonly user: string | undefined; + + constructor(options: DockerSandboxOptions = {}) { + this.image = options.image ?? defaultImage; + this.pull = options.pull ?? "missing"; + this.workdir = options.workdir ?? defaultWorkdir; + this.workspace = options.workspace ?? { mode: "ephemeral" }; + this.lifecycle = { + autoDestroy: options.lifecycle?.autoDestroy ?? true, + ...(options.lifecycle?.ttlMs === undefined ? {} : { ttlMs: options.lifecycle.ttlMs }), + ...(options.lifecycle?.idleTimeoutMs === undefined + ? {} + : { idleTimeoutMs: options.lifecycle.idleTimeoutMs }), + }; + this.network = options.network ?? false; + this.dockerPath = options.dockerPath ?? "docker"; + this.labels = options.labels ?? {}; + this.limits = options.limits ?? {}; + this.security = { + readonlyRootfs: options.security?.readonlyRootfs ?? false, + noNewPrivileges: options.security?.noNewPrivileges ?? true, + dropCapabilities: options.security?.dropCapabilities ?? ["ALL"], + }; + this.hooks = options.hooks ?? {}; + this.user = options.user; + } + + static node(options: DockerSandboxOptions = {}): DockerSandbox { + return new DockerSandbox({ ...options, image: options.image ?? "node:22-bookworm" }); + } + + static python(options: DockerSandboxOptions = {}): DockerSandbox { + return new DockerSandbox({ ...options, image: options.image ?? "python:3.13-bookworm" }); + } + + static deno(options: DockerSandboxOptions = {}): DockerSandbox { + return new DockerSandbox({ ...options, image: options.image ?? "denoland/deno:debian" }); + } + + async createSession(options: SandboxCreateSessionOptions = {}): Promise { + await this.ensureImage(); + + const id = sanitizeResourceId(options.id ?? randomUUID()); + const workspace = options.workspace ?? this.workspace; + const workspaceId = getWorkspaceId(workspace, id); + const containerName = `anvia-sandbox-${id}`; + const volumeName = `anvia-sandbox-${workspaceId}-workspace`; + const removeVolumeOnDestroy = shouldDestroyWorkspace(workspace); + + await assertDockerCli(["volume", "create", volumeName], this.cliOptions()); + + try { + await assertDockerCli( + this.createRunArgs(containerName, volumeName, workspace, options.metadata), + { + ...this.cliOptions(), + timeoutMs: this.limits.timeoutMs ?? defaultTimeoutMs, + }, + ); + + const session = new DockerSandboxSession({ + id, + containerName, + volumeName, + workdir: this.workdir, + dockerPath: this.dockerPath, + limits: this.limits, + lifecycle: this.lifecycle, + removeVolumeOnDestroy, + env: options.manifest?.env ?? {}, + hooks: this.hooks, + }); + + await session.applyManifest(options.manifest); + await this.hooks.onSessionCreate?.(session.event()); + return session; + } catch (error) { + await this.cleanup(containerName, removeVolumeOnDestroy ? volumeName : undefined); + throw error; + } + } + + private async ensureImage(): Promise { + if (this.pull === "always") { + await assertDockerCli(["pull", this.image], this.cliOptions()); + return; + } + + if (this.pull === "missing") { + const inspect = await runDockerCli(["image", "inspect", this.image], this.cliOptions()); + if (inspect.exitCode !== 0) { + await assertDockerCli(["pull", this.image], this.cliOptions()); + } + } + } + + private createRunArgs( + containerName: string, + volumeName: string, + workspace: SandboxWorkspaceOptions, + metadata: Record | undefined, + ): string[] { + const args = [ + "run", + "-d", + "--name", + containerName, + "-v", + `${volumeName}:${this.workdir}`, + "-w", + this.workdir, + "--label", + "anvia.sandbox=true", + "--label", + `anvia.sandbox.workspace.mode=${workspace.mode ?? "ephemeral"}`, + "--label", + `anvia.sandbox.workspace.volume=${volumeName}`, + ]; + + for (const [key, value] of Object.entries(this.labels)) { + args.push("--label", `${key}=${value}`); + } + + if (metadata !== undefined) { + for (const [key, value] of Object.entries(metadata)) { + args.push("--label", `anvia.sandbox.metadata.${key}=${value}`); + } + } + + this.appendNetworkArgs(args); + this.appendLimitArgs(args); + this.appendSecurityArgs(args); + + if (this.user !== undefined) { + args.push("-u", this.user); + } + + args.push( + this.image, + "sh", + "-c", + "trap 'exit 0' TERM INT; while :; do sleep 3600 & wait $!; done", + ); + return args; + } + + private appendNetworkArgs(args: string[]): void { + const mode = typeof this.network === "object" ? this.network.mode : this.network; + + if (mode === false || mode === "none") { + args.push("--network", "none"); + return; + } + + if (mode !== true) { + args.push("--network", mode); + } + } + + private appendLimitArgs(args: string[]): void { + if (this.limits.memoryMb !== undefined) { + args.push("--memory", `${this.limits.memoryMb}m`); + } + + if (this.limits.cpus !== undefined) { + args.push("--cpus", String(this.limits.cpus)); + } + + if (this.limits.pidsLimit !== undefined) { + args.push("--pids-limit", String(this.limits.pidsLimit)); + } + } + + private appendSecurityArgs(args: string[]): void { + if (this.security.readonlyRootfs) { + args.push("--read-only"); + } + + if (this.security.noNewPrivileges) { + args.push("--security-opt", "no-new-privileges"); + } + + for (const capability of this.security.dropCapabilities) { + args.push("--cap-drop", capability); + } + } + + private cliOptions() { + return { + dockerPath: this.dockerPath, + maxOutputBytes: this.limits.maxOutputBytes ?? defaultMaxOutputBytes, + }; + } + + private async cleanup(containerName: string, volumeName: string | undefined): Promise { + await runDockerCli(["rm", "-f", containerName], this.cliOptions()).catch(() => undefined); + + if (volumeName !== undefined) { + await runDockerCli(["volume", "rm", "-f", volumeName], this.cliOptions()).catch( + () => undefined, + ); + } + } +} + +class DockerSandboxSession implements SandboxSession { + readonly provider = "docker"; + readonly id: string; + readonly workdir: string; + + private readonly containerName: string; + private readonly volumeName: string; + private readonly dockerPath: string; + private readonly limits: SandboxLimits; + private readonly lifecycle: Required> & + Omit; + private readonly removeVolumeOnDestroy: boolean; + private readonly env: Record; + private readonly hooks: SandboxHooks; + private ttlTimer: ReturnType | undefined; + private idleTimer: ReturnType | undefined; + private activeOperations = 0; + private destroyed = false; + + constructor(options: { + id: string; + containerName: string; + volumeName: string; + workdir: string; + dockerPath: string; + limits: SandboxLimits; + lifecycle: Required> & + Omit; + removeVolumeOnDestroy: boolean; + env: Record; + hooks: SandboxHooks; + }) { + this.id = options.id; + this.containerName = options.containerName; + this.volumeName = options.volumeName; + this.workdir = options.workdir; + this.dockerPath = options.dockerPath; + this.limits = options.limits; + this.lifecycle = options.lifecycle; + this.removeVolumeOnDestroy = options.removeVolumeOnDestroy; + this.env = options.env; + this.hooks = options.hooks; + this.startLifecycleTimers(); + } + + async applyManifest(manifest: SandboxManifest | undefined): Promise { + await this.runOperation(async () => { + for (const directory of manifest?.directories ?? []) { + await this.mkdir(directory); + } + + for (const [filePath, content] of Object.entries(manifest?.files ?? {})) { + await this.writeFile(filePath, content); + } + }); + } + + async exec(options: SandboxExecOptions): Promise { + return this.runOperation(async () => { + await this.hooks.onExecStart?.({ + ...this.event(), + command: options.command, + args: options.args ?? [], + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + }); + + const normalizedResult = await this.execCommand(options); + + await this.hooks.onExecEnd?.({ + ...this.event(), + command: options.command, + args: options.args ?? [], + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + result: normalizedResult, + }); + + return normalizedResult; + }); + } + + async *execStream(options: SandboxExecOptions): AsyncIterable { + const events: SandboxExecStreamEvent[] = []; + let notify: (() => void) | undefined; + let done = false; + let error: unknown; + + const push = (event: SandboxExecStreamEvent) => { + events.push(event); + notify?.(); + notify = undefined; + }; + + const wait = () => + new Promise((resolve) => { + notify = resolve; + }); + + const run = this.exec({ + ...options, + onStdout: (chunk) => { + options.onStdout?.(chunk); + push({ type: "stdout", chunk, text: Buffer.from(chunk).toString("utf8") }); + }, + onStderr: (chunk) => { + options.onStderr?.(chunk); + push({ type: "stderr", chunk, text: Buffer.from(chunk).toString("utf8") }); + }, + }) + .then((result) => { + push({ type: "exit", result }); + }) + .catch((caught) => { + error = caught; + }) + .finally(() => { + done = true; + notify?.(); + notify = undefined; + }); + + try { + while (!done || events.length > 0) { + const event = events.shift(); + if (event !== undefined) { + yield event; + continue; + } + + await wait(); + } + + if (error !== undefined) { + throw error; + } + } finally { + await run; + } + } + + async readFile(filePath: string): Promise { + return this.runOperation(async () => { + const normalized = normalizeSandboxPath(filePath); + const tempDir = await mkdtemp(path.join(os.tmpdir(), "anvia-sandbox-read-")); + const target = path.join(tempDir, path.basename(normalized)); + + try { + await assertDockerCli( + ["cp", `${this.containerName}:${containerPath(this.workdir, normalized)}`, target], + this.cliOptions(), + ); + const { readFile } = await import("node:fs/promises"); + const bytes = await readFile(target); + this.assertFileSize(bytes.byteLength, filePath); + return bytes; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + } + + async readTextFile(filePath: string): Promise { + const bytes = await this.readFile(filePath); + return new TextDecoder().decode(bytes); + } + + async writeFile(filePath: string, data: string | Uint8Array): Promise { + await this.runOperation(async () => { + const size = byteLength(data); + this.assertFileSize(size, filePath); + const normalized = normalizeSandboxPath(filePath); + await this.mkdir(parentSandboxPath(normalized)); + + const tempDir = await mkdtemp(path.join(os.tmpdir(), "anvia-sandbox-write-")); + const source = path.join(tempDir, path.basename(normalized)); + + try { + await writeFile(source, data); + await assertDockerCli( + ["cp", source, `${this.containerName}:${containerPath(this.workdir, normalized)}`], + this.cliOptions(), + ); + await this.hooks.onFileWrite?.({ ...this.event(), path: normalized, size }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + } + + async writeTextFile(filePath: string, content: string): Promise { + await this.writeFile(filePath, content); + } + + async listFiles(filePath = "."): Promise { + return this.runOperation(async () => { + const normalized = normalizeSandboxPath(filePath, { allowRoot: true }); + const target = containerPath(this.workdir, normalized); + const result = await this.execCommand({ + command: "find", + args: [target, "-mindepth", "1", "-maxdepth", "1", "-printf", "%p\t%y\t%s\n"], + }); + + if (result.timedOut) { + throw new SandboxTimeoutError(`Listing files timed out for ${filePath}.`); + } + + if (result.exitCode !== 0) { + throw new SandboxDockerCommandError(`Unable to list sandbox path: ${filePath}`, result); + } + + return result.stdout + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => this.parseFindEntry(line)); + }); + } + + async destroy(): Promise { + if (this.destroyed) { + return; + } + + this.destroyed = true; + this.clearLifecycleTimers(); + await runDockerCli(["rm", "-f", this.containerName], this.cliOptions()).catch(() => undefined); + + if (this.removeVolumeOnDestroy) { + await runDockerCli(["volume", "rm", "-f", this.volumeName], this.cliOptions()).catch( + () => undefined, + ); + } + + await this.hooks.onDestroy?.(this.event()); + } + + private async mkdir(directoryPath: string): Promise { + const normalized = normalizeSandboxPath(directoryPath, { allowRoot: true }); + const result = await this.execCommand({ + command: "mkdir", + args: ["-p", containerPath(this.workdir, normalized)], + }); + + if (result.exitCode !== 0) { + throw new SandboxDockerCommandError( + `Unable to create sandbox directory: ${directoryPath}`, + result, + ); + } + } + + private parseFindEntry(line: string): SandboxFileEntry { + const [rawPath, rawType, rawSize] = line.split("\t"); + const absolutePath = rawPath ?? ""; + const relativePath = absolutePath.startsWith(`${this.workdir}/`) + ? absolutePath.slice(this.workdir.length + 1) + : absolutePath; + const size = rawSize === undefined ? undefined : Number(rawSize); + const entry: SandboxFileEntry = { + path: relativePath, + type: mapFindType(rawType), + }; + + if (size !== undefined && Number.isFinite(size)) { + entry.size = size; + } + + return entry; + } + + private cliOptions() { + return { + dockerPath: this.dockerPath, + maxOutputBytes: this.limits.maxOutputBytes ?? defaultMaxOutputBytes, + }; + } + + private async execCommand(options: SandboxExecOptions): Promise { + if (options.command.trim().length === 0) { + throw new SandboxDockerCommandError("Sandbox command cannot be empty.", { + stdout: "", + stderr: "", + exitCode: 1, + }); + } + + const args = ["exec"]; + + if (options.input !== undefined) { + args.push("-i"); + } + + const cwd = containerPath(this.workdir, options.cwd ?? "."); + args.push("-w", cwd); + + for (const [key, value] of Object.entries({ ...this.env, ...options.env })) { + args.push("-e", `${key}=${value}`); + } + + args.push(this.containerName, options.command, ...(options.args ?? [])); + + const cliOptions = { + dockerPath: this.dockerPath, + timeoutMs: options.timeoutMs ?? this.limits.timeoutMs ?? defaultTimeoutMs, + maxOutputBytes: this.limits.maxOutputBytes ?? defaultMaxOutputBytes, + ...(options.input === undefined ? {} : { input: options.input }), + ...(options.signal === undefined ? {} : { signal: options.signal }), + ...(options.onStdout === undefined ? {} : { onStdout: options.onStdout }), + ...(options.onStderr === undefined ? {} : { onStderr: options.onStderr }), + }; + + const result = await runDockerCli(args, cliOptions); + + if (result.timedOut) { + return { + ...result, + exitCode: result.exitCode === 0 ? 124 : result.exitCode, + }; + } + + return result; + } + + private assertActive(): void { + if (this.destroyed) { + throw new SandboxSessionDestroyedError(`Sandbox session ${this.id} has been destroyed.`); + } + } + + event(): SandboxSessionEvent { + return { + sessionId: this.id, + provider: this.provider, + workdir: this.workdir, + }; + } + + private async runOperation(operation: () => Promise): Promise { + this.assertActive(); + this.activeOperations += 1; + this.clearIdleTimer(); + + try { + return await operation(); + } finally { + this.activeOperations -= 1; + this.scheduleIdleTimer(); + } + } + + private assertFileSize(size: number, filePath: string): void { + if (this.limits.maxFileBytes !== undefined && size > this.limits.maxFileBytes) { + throw new SandboxFileSizeError( + `Sandbox file exceeds maxFileBytes (${size} > ${this.limits.maxFileBytes}): ${filePath}`, + ); + } + } + + private startLifecycleTimers(): void { + if (!this.lifecycle.autoDestroy) { + return; + } + + if (this.lifecycle.ttlMs !== undefined) { + this.ttlTimer = setTimeout(() => { + void this.destroy().catch(() => undefined); + }, this.lifecycle.ttlMs); + this.ttlTimer.unref?.(); + } + + this.scheduleIdleTimer(); + } + + private scheduleIdleTimer(): void { + if (!this.lifecycle.autoDestroy || this.lifecycle.idleTimeoutMs === undefined) { + return; + } + + if (this.destroyed || this.activeOperations > 0) { + return; + } + + this.clearIdleTimer(); + this.idleTimer = setTimeout(() => { + void this.destroy().catch(() => undefined); + }, this.lifecycle.idleTimeoutMs); + this.idleTimer.unref?.(); + } + + private clearLifecycleTimers(): void { + if (this.ttlTimer !== undefined) { + clearTimeout(this.ttlTimer); + this.ttlTimer = undefined; + } + this.clearIdleTimer(); + } + + private clearIdleTimer(): void { + if (this.idleTimer !== undefined) { + clearTimeout(this.idleTimer); + this.idleTimer = undefined; + } + } +} + +function getWorkspaceId(workspace: SandboxWorkspaceOptions, sessionId: string): string { + if (workspace.mode === "persistent") { + return sanitizeResourceId(workspace.id); + } + + return sessionId; +} + +function shouldDestroyWorkspace(workspace: SandboxWorkspaceOptions): boolean { + if (workspace.mode === "persistent") { + return workspace.destroyOnSessionDestroy ?? false; + } + + return true; +} + +function sanitizeResourceId(id: string): string { + const sanitized = id + .toLowerCase() + .replaceAll(/[^a-z0-9_.-]/g, "-") + .replaceAll(/^-+|-+$/g, ""); + return sanitized.length > 0 ? sanitized : randomUUID(); +} + +function byteLength(data: string | Uint8Array): number { + return typeof data === "string" ? Buffer.byteLength(data) : data.byteLength; +} + +function mapFindType(type: string | undefined): SandboxFileType { + if (type === "f") { + return "file"; + } + if (type === "d") { + return "directory"; + } + if (type === "l") { + return "symlink"; + } + return "other"; +} diff --git a/packages/tools/sandbox/src/errors.ts b/packages/tool-sandbox/src/errors.ts similarity index 84% rename from packages/tools/sandbox/src/errors.ts rename to packages/tool-sandbox/src/errors.ts index 5a7efbba..70aa88c2 100644 --- a/packages/tools/sandbox/src/errors.ts +++ b/packages/tool-sandbox/src/errors.ts @@ -28,3 +28,7 @@ export class SandboxSessionDestroyedError extends SandboxError {} export class SandboxPathError extends SandboxError {} export class SandboxTimeoutError extends SandboxError {} + +export class SandboxFileSizeError extends SandboxError {} + +export class SandboxToolPolicyError extends SandboxError {} diff --git a/packages/tools/sandbox/src/index.ts b/packages/tool-sandbox/src/index.ts similarity index 100% rename from packages/tools/sandbox/src/index.ts rename to packages/tool-sandbox/src/index.ts diff --git a/packages/tools/sandbox/src/path.ts b/packages/tool-sandbox/src/path.ts similarity index 100% rename from packages/tools/sandbox/src/path.ts rename to packages/tool-sandbox/src/path.ts diff --git a/packages/tools/sandbox/src/tools.ts b/packages/tool-sandbox/src/tools.ts similarity index 66% rename from packages/tools/sandbox/src/tools.ts rename to packages/tool-sandbox/src/tools.ts index e5ecea0d..193e4126 100644 --- a/packages/tools/sandbox/src/tools.ts +++ b/packages/tool-sandbox/src/tools.ts @@ -1,5 +1,6 @@ import { type AnyTool, createTool } from "@anvia/core/tool"; import { z } from "zod"; +import { SandboxToolPolicyError } from "./errors"; import type { SandboxExecOptions, SandboxExecResult, @@ -49,7 +50,7 @@ export function createSandboxTools( options: SandboxToolsOptions = {}, ): AnyTool[] { const include = new Set( - options.include ?? ["exec_command", "read_file", "write_file", "list_files"], + options.allow ?? options.include ?? ["exec_command", "read_file", "write_file", "list_files"], ); const tools: AnyTool[] = []; @@ -58,11 +59,11 @@ export function createSandboxTools( } if (include.has("read_file")) { - tools.push(createReadFileTool(session)); + tools.push(createReadFileTool(session, options)); } if (include.has("write_file")) { - tools.push(createWriteFileTool(session)); + tools.push(createWriteFileTool(session, options)); } if (include.has("list_files")) { @@ -73,6 +74,8 @@ export function createSandboxTools( } function createExecCommandTool(session: SandboxSession, options: SandboxToolsOptions): AnyTool { + const policy = options.exec ?? {}; + return createTool({ name: "exec_command", description: @@ -80,6 +83,8 @@ function createExecCommandTool(session: SandboxSession, options: SandboxToolsOpt input: execCommandInput, output: textOutput, execute: async ({ command, args, cwd, env, timeoutMs, input }) => { + assertCommandAllowed(command, options); + const execOptions: SandboxExecOptions = { command, }; @@ -93,8 +98,9 @@ function createExecCommandTool(session: SandboxSession, options: SandboxToolsOpt if (env !== undefined) { execOptions.env = env; } - const effectiveTimeoutMs = timeoutMs ?? options.execTimeoutMs; + const effectiveTimeoutMs = timeoutMs ?? policy.defaultTimeoutMs ?? options.execTimeoutMs; if (effectiveTimeoutMs !== undefined) { + assertTimeoutAllowed(effectiveTimeoutMs, options); execOptions.timeoutMs = effectiveTimeoutMs; } if (input !== undefined) { @@ -108,23 +114,28 @@ function createExecCommandTool(session: SandboxSession, options: SandboxToolsOpt }); } -function createReadFileTool(session: SandboxSession): AnyTool { +function createReadFileTool(session: SandboxSession, options: SandboxToolsOptions): AnyTool { return createTool({ name: "read_file", description: "Read a text file from the sandbox workspace.", input: readFileInput, output: textOutput, - execute: async ({ path }) => session.readTextFile(path), + execute: async ({ path }) => { + const content = await session.readTextFile(path); + assertReadAllowed(content, options); + return content; + }, }); } -function createWriteFileTool(session: SandboxSession): AnyTool { +function createWriteFileTool(session: SandboxSession, options: SandboxToolsOptions): AnyTool { return createTool({ name: "write_file", description: "Write a text file inside the sandbox workspace. Creates parent directories.", input: writeFileInput, output: textOutput, execute: async ({ path, content }) => { + assertContentAllowed(content, options); await session.writeTextFile(path, content); return `Wrote ${path}`; }, @@ -179,3 +190,41 @@ function formatExecResult(result: SandboxExecResult): string { return parts.join("\n\n"); } + +function assertCommandAllowed(command: string, options: SandboxToolsOptions): void { + const policy = options.exec; + + if (policy?.blockedCommands?.includes(command)) { + throw new SandboxToolPolicyError(`Command is blocked by sandbox tool policy: ${command}`); + } + + if (policy?.allowedCommands !== undefined && !policy.allowedCommands.includes(command)) { + throw new SandboxToolPolicyError(`Command is not allowed by sandbox tool policy: ${command}`); + } +} + +function assertTimeoutAllowed(timeoutMs: number, options: SandboxToolsOptions): void { + const maxTimeoutMs = options.exec?.maxTimeoutMs; + + if (maxTimeoutMs !== undefined && timeoutMs > maxTimeoutMs) { + throw new SandboxToolPolicyError( + `Command timeout exceeds sandbox tool policy (${timeoutMs} > ${maxTimeoutMs}).`, + ); + } +} + +function assertContentAllowed(content: string, options: SandboxToolsOptions): void { + const maxBytes = options.writeFile?.maxBytes; + + if (maxBytes !== undefined && Buffer.byteLength(content) > maxBytes) { + throw new SandboxToolPolicyError("File content exceeds sandbox tool policy."); + } +} + +function assertReadAllowed(content: string, options: SandboxToolsOptions): void { + const maxBytes = options.readFile?.maxBytes; + + if (maxBytes !== undefined && Buffer.byteLength(content) > maxBytes) { + throw new SandboxToolPolicyError("File content exceeds sandbox tool policy."); + } +} diff --git a/packages/tools/sandbox/src/types.ts b/packages/tool-sandbox/src/types.ts similarity index 54% rename from packages/tools/sandbox/src/types.ts rename to packages/tool-sandbox/src/types.ts index 0e566555..8658f0d7 100644 --- a/packages/tools/sandbox/src/types.ts +++ b/packages/tool-sandbox/src/types.ts @@ -4,6 +4,22 @@ export type SandboxFileType = "file" | "directory" | "symlink" | "other"; export type SandboxNetworkMode = boolean | "none" | "host" | string; +export type SandboxWorkspaceOptions = + | { + mode?: "ephemeral"; + } + | { + mode: "persistent"; + id: string; + destroyOnSessionDestroy?: boolean; + }; + +export interface SandboxLifecycleOptions { + ttlMs?: number; + idleTimeoutMs?: number; + autoDestroy?: boolean; +} + export interface Sandbox { readonly provider: string; @@ -16,6 +32,7 @@ export interface SandboxSession { readonly workdir: string; exec(options: SandboxExecOptions): Promise; + execStream(options: SandboxExecOptions): AsyncIterable; readFile(path: string): Promise; readTextFile(path: string): Promise; writeFile(path: string, data: string | Uint8Array): Promise; @@ -26,6 +43,7 @@ export interface SandboxSession { export interface SandboxCreateSessionOptions { id?: string; + workspace?: SandboxWorkspaceOptions; manifest?: SandboxManifest; metadata?: Record; } @@ -39,6 +57,7 @@ export interface SandboxManifest { export interface SandboxLimits { timeoutMs?: number; maxOutputBytes?: number; + maxFileBytes?: number; memoryMb?: number; cpus?: number; pidsLimit?: number; @@ -67,6 +86,22 @@ export interface SandboxExecResult { stderrTruncated: boolean; } +export type SandboxExecStreamEvent = + | { + type: "stdout"; + chunk: Uint8Array; + text: string; + } + | { + type: "stderr"; + chunk: Uint8Array; + text: string; + } + | { + type: "exit"; + result: SandboxExecResult; + }; + export interface SandboxFileEntry { path: string; type: SandboxFileType; @@ -79,25 +114,76 @@ export interface DockerSandboxSecurityOptions { dropCapabilities?: string[]; } +export interface DockerSandboxNetworkOptions { + mode: SandboxNetworkMode; +} + +export interface SandboxSessionEvent { + sessionId: string; + provider: string; + workdir: string; +} + +export interface SandboxExecEvent extends SandboxSessionEvent { + command: string; + args: string[]; + cwd?: string; +} + +export interface SandboxExecEndEvent extends SandboxExecEvent { + result: SandboxExecResult; +} + +export interface SandboxFileWriteEvent extends SandboxSessionEvent { + path: string; + size: number; +} + +export interface SandboxHooks { + onSessionCreate?: (event: SandboxSessionEvent) => void | Promise; + onExecStart?: (event: SandboxExecEvent) => void | Promise; + onExecEnd?: (event: SandboxExecEndEvent) => void | Promise; + onFileWrite?: (event: SandboxFileWriteEvent) => void | Promise; + onDestroy?: (event: SandboxSessionEvent) => void | Promise; +} + export interface DockerSandboxOptions { image?: string; pull?: "missing" | "always" | "never"; workdir?: string; - network?: SandboxNetworkMode; + workspace?: SandboxWorkspaceOptions; + lifecycle?: SandboxLifecycleOptions; + network?: SandboxNetworkMode | DockerSandboxNetworkOptions; user?: string; dockerPath?: string; labels?: Record; limits?: SandboxLimits; security?: DockerSandboxSecurityOptions; + hooks?: SandboxHooks; } export interface SandboxToolsOptions { + allow?: SandboxToolName[]; include?: SandboxToolName[]; execTimeoutMs?: number; + exec?: SandboxExecToolPolicy; + readFile?: SandboxFileToolPolicy; + writeFile?: SandboxFileToolPolicy; } export type SandboxToolName = "exec_command" | "read_file" | "write_file" | "list_files"; +export interface SandboxExecToolPolicy { + allowedCommands?: string[]; + blockedCommands?: string[]; + defaultTimeoutMs?: number; + maxTimeoutMs?: number; +} + +export interface SandboxFileToolPolicy { + maxBytes?: number; +} + export type SandboxToolsFactory = ( session: SandboxSession, options?: SandboxToolsOptions, diff --git a/packages/tool-sandbox/test/docker-sandbox.integration.test.ts b/packages/tool-sandbox/test/docker-sandbox.integration.test.ts new file mode 100644 index 00000000..bcb7bc05 --- /dev/null +++ b/packages/tool-sandbox/test/docker-sandbox.integration.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import { DockerSandbox } from "../src/docker-sandbox"; + +const runDockerTests = process.env.ANVIA_SANDBOX_DOCKER_TESTS === "1"; + +describe.skipIf(!runDockerTests)("DockerSandbox integration", () => { + it("creates an ephemeral workspace, runs commands, and cleans up", async () => { + const sandbox = new DockerSandbox({ + image: "node:22-bookworm", + pull: "missing", + limits: { + timeoutMs: 10_000, + maxOutputBytes: 64_000, + }, + }); + const session = await sandbox.createSession({ + id: `vitest-${Date.now()}`, + manifest: { + directories: ["src"], + files: { + "src/index.js": "console.log('hello sandbox')", + }, + }, + }); + + try { + const result = await session.exec({ + command: "node", + args: ["src/index.js"], + }); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe("hello sandbox"); + + await session.writeTextFile("out/result.txt", "done"); + await expect(session.readTextFile("out/result.txt")).resolves.toBe("done"); + await expect(session.listFiles("out")).resolves.toEqual([ + { path: "out/result.txt", type: "file", size: 4 }, + ]); + } finally { + await session.destroy(); + } + }, 60_000); + + it("reports command timeout", async () => { + const sandbox = new DockerSandbox({ + image: "node:22-bookworm", + pull: "missing", + limits: { + timeoutMs: 500, + }, + }); + const session = await sandbox.createSession({ id: `timeout-${Date.now()}` }); + + try { + const result = await session.exec({ + command: "node", + args: ["-e", "setTimeout(() => {}, 10_000)"], + }); + expect(result.timedOut).toBe(true); + expect(result.exitCode).not.toBe(0); + } finally { + await session.destroy(); + } + }, 60_000); + + it("streams command output and enforces file size limits", async () => { + const sandbox = new DockerSandbox({ + image: "node:22-bookworm", + pull: "missing", + limits: { + timeoutMs: 10_000, + maxFileBytes: 4, + }, + }); + const session = await sandbox.createSession({ id: `stream-${Date.now()}` }); + + try { + const events = []; + + for await (const event of session.execStream({ + command: "node", + args: ["-e", "console.log('one'); console.error('two')"], + })) { + events.push(event); + } + + expect(events.map((event) => event.type)).toEqual(["stdout", "stderr", "exit"]); + expect(events.find((event) => event.type === "stdout")?.text.trim()).toBe("one"); + expect(events.find((event) => event.type === "stderr")?.text.trim()).toBe("two"); + await expect(session.writeTextFile("too-large.txt", "12345")).rejects.toThrow("maxFileBytes"); + } finally { + await session.destroy(); + } + }, 60_000); + + it("emits hooks for public sandbox operations", async () => { + const events: string[] = []; + const sandbox = new DockerSandbox({ + image: "node:22-bookworm", + pull: "missing", + hooks: { + onSessionCreate: (event) => { + events.push(`create:${event.sessionId}`); + }, + onExecStart: (event) => { + events.push(`exec:start:${event.command}`); + }, + onExecEnd: (event) => { + events.push(`exec:end:${event.result.exitCode}`); + }, + onFileWrite: (event) => { + events.push(`write:${event.path}:${event.size}`); + }, + onDestroy: (event) => { + events.push(`destroy:${event.sessionId}`); + }, + }, + }); + const session = await sandbox.createSession({ id: `hooks-${Date.now()}` }); + + await session.writeTextFile("out/result.txt", "ok"); + await session.listFiles("out"); + await session.exec({ command: "node", args: ["-e", "console.log('hook')"] }); + await session.destroy(); + + expect(events).toEqual([ + `create:${session.id}`, + "write:out/result.txt:2", + "exec:start:node", + "exec:end:0", + `destroy:${session.id}`, + ]); + }, 60_000); + + it("can reuse an explicit persistent workspace", async () => { + const workspaceId = `vitest-persistent-${Date.now()}`; + const sandbox = new DockerSandbox({ + image: "node:22-bookworm", + pull: "missing", + }); + const first = await sandbox.createSession({ + id: `${workspaceId}-first`, + workspace: { + mode: "persistent", + id: workspaceId, + }, + }); + + await first.writeTextFile("state.txt", "kept"); + await first.destroy(); + + const second = await sandbox.createSession({ + id: `${workspaceId}-second`, + workspace: { + mode: "persistent", + id: workspaceId, + destroyOnSessionDestroy: true, + }, + }); + + try { + await expect(second.readTextFile("state.txt")).resolves.toBe("kept"); + } finally { + await second.destroy(); + } + }, 60_000); +}); diff --git a/packages/tools/sandbox/test/path.test.ts b/packages/tool-sandbox/test/path.test.ts similarity index 100% rename from packages/tools/sandbox/test/path.test.ts rename to packages/tool-sandbox/test/path.test.ts diff --git a/packages/tools/sandbox/test/tools.test.ts b/packages/tool-sandbox/test/tools.test.ts similarity index 61% rename from packages/tools/sandbox/test/tools.test.ts rename to packages/tool-sandbox/test/tools.test.ts index 1545be44..bc022caf 100644 --- a/packages/tools/sandbox/test/tools.test.ts +++ b/packages/tool-sandbox/test/tools.test.ts @@ -20,6 +20,12 @@ describe("createSandboxTools", () => { expect(tools.map((tool) => tool.name)).toEqual(["read_file"]); }); + it("supports allow as a tool selection alias", () => { + const tools = createSandboxTools(createSession(), { allow: ["list_files"] }); + + expect(tools.map((tool) => tool.name)).toEqual(["list_files"]); + }); + it("exec_command calls the sandbox session with structured args", async () => { const session = createSession(); const [tool] = createSandboxTools(session, { include: ["exec_command"], execTimeoutMs: 1234 }); @@ -45,6 +51,44 @@ describe("createSandboxTools", () => { expect(output).toContain("stdout:"); }); + it("enforces exec command policy", async () => { + const session = createSession(); + const [tool] = createSandboxTools(session, { + allow: ["exec_command"], + exec: { + allowedCommands: ["node"], + maxTimeoutMs: 1000, + }, + }); + if (tool === undefined) { + throw new Error("Expected exec_command tool."); + } + + await expect(tool.call({ command: "python" })).rejects.toThrow( + "Command is not allowed by sandbox tool policy", + ); + await expect(tool.call({ command: "node", timeoutMs: 2000 })).rejects.toThrow( + "Command timeout exceeds sandbox tool policy", + ); + }); + + it("enforces file tool byte policies", async () => { + const session = createSession(); + const tools = Object.fromEntries( + createSandboxTools(session, { + readFile: { maxBytes: 4 }, + writeFile: { maxBytes: 4 }, + }).map((tool) => [tool.name, tool] as const), + ); + + await expect(tools.read_file?.call({ path: "a.txt" })).rejects.toThrow( + "File content exceeds sandbox tool policy", + ); + await expect(tools.write_file?.call({ path: "a.txt", content: "content" })).rejects.toThrow( + "File content exceeds sandbox tool policy", + ); + }); + it("read_file, write_file, and list_files delegate to the session", async () => { const session = createSession(); const tools = Object.fromEntries( @@ -78,6 +122,21 @@ function createSession(): SandboxSession { stdoutTruncated: false, stderrTruncated: false, })), + execStream: vi.fn(async function* () { + yield { + type: "exit" as const, + result: { + stdout: "ok\n", + stderr: "", + exitCode: 0, + durationMs: 10, + timedOut: false, + aborted: false, + stdoutTruncated: false, + stderrTruncated: false, + }, + }; + }), readFile: vi.fn(async () => new TextEncoder().encode("file content")), readTextFile: vi.fn(async () => "file content"), writeFile: vi.fn(async () => undefined), diff --git a/packages/tool-sandbox/tsconfig.json b/packages/tool-sandbox/tsconfig.json new file mode 100644 index 00000000..6bb5dcd8 --- /dev/null +++ b/packages/tool-sandbox/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/packages/tool-sandbox/vitest.config.ts b/packages/tool-sandbox/vitest.config.ts new file mode 100644 index 00000000..209e8cdf --- /dev/null +++ b/packages/tool-sandbox/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@anvia/core/tool": new URL("../core/src/tool/index.ts", import.meta.url).pathname, + "@anvia/core": new URL("../core/src/index.ts", import.meta.url).pathname, + }, + }, + test: { + environment: "node", + }, +}); diff --git a/packages/tools/studio/CHANGELOG.md b/packages/tool-studio/CHANGELOG.md similarity index 90% rename from packages/tools/studio/CHANGELOG.md rename to packages/tool-studio/CHANGELOG.md index 35544bcb..6e5e6a4c 100644 --- a/packages/tools/studio/CHANGELOG.md +++ b/packages/tool-studio/CHANGELOG.md @@ -1,5 +1,24 @@ # @anvia/studio +## 0.6.0 + +### Minor Changes + +- e09746c: Add multi-provider model selection and multimodal attachment support to Studio, including cookbook documentation and assistant loading feedback in the playground. + +## 0.5.14 + +### Patch Changes + +- Updated dependencies [4806f3e] + - @anvia/core@0.6.2 + +## 0.5.13 + +### Patch Changes + +- 3572881: Flatten package folders to the top-level `packages/*` workspace layout. This only updates repository layout metadata and does not change package behavior. + ## 0.5.12 ### Patch Changes diff --git a/packages/tools/studio/README.md b/packages/tool-studio/README.md similarity index 62% rename from packages/tools/studio/README.md rename to packages/tool-studio/README.md index 2248b63c..f5d93631 100644 --- a/packages/tools/studio/README.md +++ b/packages/tool-studio/README.md @@ -44,6 +44,68 @@ Then open: http://localhost:4021/ui/playground ``` +## Multi-Provider Models + +Studio can expose a shared model catalog and let each agent choose from registered providers: + +```ts +import { AgentBuilder } from "@anvia/core"; +import { AnthropicClient } from "@anvia/anthropic"; +import { OpenAIClient } from "@anvia/openai"; +import { Studio } from "@anvia/studio"; + +const openai = new OpenAIClient({ apiKey: process.env.OPENAI_API_KEY }); +const anthropic = new AnthropicClient({ apiKey: process.env.ANTHROPIC_API_KEY }); + +const agent = new AgentBuilder("support", openai.completionModel("gpt-5")) + .name("Support") + .instructions("Answer support questions clearly.") + .build(); + +new Studio([agent], { + models: { + providers: [ + { + id: "openai", + name: "OpenAI", + defaultModel: "gpt-5", + createCompletionModel: (model) => openai.completionModel(model), + listModels: () => openai.listModels(), + models: [ + { + id: "gpt-5", + modalities: { input: ["text", "image", "document"], output: ["text"] }, + }, + ], + }, + { + id: "anthropic", + name: "Anthropic", + defaultModel: "claude-sonnet-4-20250514", + createCompletionModel: (model) => anthropic.completionModel(model), + }, + ], + agents: { + support: { + default: "openai:gpt-5", + allowed: ["openai:*", "anthropic:claude-sonnet-4-20250514"], + }, + }, + }, +}).start(); +``` + +The playground message composer shows the allowed models for the selected agent. API callers can +also select a model per run: + +```json +{ + "message": "Summarize this ticket", + "model": "anthropic:claude-sonnet-4-20250514", + "stream": true +} +``` + ## Browser UI Studio exposes: diff --git a/packages/tools/studio/components.json b/packages/tool-studio/components.json similarity index 100% rename from packages/tools/studio/components.json rename to packages/tool-studio/components.json diff --git a/packages/tools/studio/package.json b/packages/tool-studio/package.json similarity index 89% rename from packages/tools/studio/package.json rename to packages/tool-studio/package.json index d8c1db1e..399cfcfb 100644 --- a/packages/tools/studio/package.json +++ b/packages/tool-studio/package.json @@ -1,6 +1,6 @@ { "name": "@anvia/studio", - "version": "0.5.12", + "version": "0.6.0", "description": "Studio UI and HTTP runtime for Anvia agents.", "author": "anvia", "maintainer": "Indra Zulfi", @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "https://github.com/anvia-hq/anvia", - "directory": "packages/tools/studio" + "directory": "packages/tool-studio" }, "files": [ "dist" @@ -27,7 +27,7 @@ }, "scripts": { "build": "pnpm run build:deps && tsup && vite build --config vite.config.ts", - "build:deps": "(test -f ../../core/dist/index.d.ts || pnpm --filter @anvia/core build) && pnpm --filter @anvia/server --filter @anvia/react build", + "build:deps": "(test -f ../core/dist/index.d.ts || pnpm --filter @anvia/core build) && pnpm --filter @anvia/server --filter @anvia/react build", "test": "pnpm run build:deps && vitest run", "typecheck": "pnpm run build:deps && tsc --noEmit" }, diff --git a/packages/tools/studio/src/index.ts b/packages/tool-studio/src/index.ts similarity index 100% rename from packages/tools/studio/src/index.ts rename to packages/tool-studio/src/index.ts diff --git a/packages/tools/studio/src/runner.ts b/packages/tool-studio/src/runner.ts similarity index 100% rename from packages/tools/studio/src/runner.ts rename to packages/tool-studio/src/runner.ts diff --git a/packages/tools/studio/src/runtime/approvals.ts b/packages/tool-studio/src/runtime/approvals.ts similarity index 81% rename from packages/tools/studio/src/runtime/approvals.ts rename to packages/tool-studio/src/runtime/approvals.ts index d1a1ef38..518d2fde 100644 --- a/packages/tools/studio/src/runtime/approvals.ts +++ b/packages/tool-studio/src/runtime/approvals.ts @@ -19,7 +19,10 @@ import type { StudioToolApprovalDecision, StudioToolApprovalStatus, } from "../types"; -import { errorResponse, isObject, optionalQueryString } from "./shared"; +import { compact } from "./compact"; +import { errorResponse } from "./http"; +import { optionalQueryString } from "./query"; +import { isObject } from "./type-guards"; type PendingApproval = StudioToolApproval & { status: "pending"; @@ -146,12 +149,12 @@ async function parseApprovalDecisionRequest( return { error: errorResponse(c, 400, "bad_request", "reason must be a string") }; } - return { + return compact({ approved: body.approved, - ...(typeof body.reason === "string" && body.reason.trim().length > 0 - ? { reason: body.reason.trim() } - : {}), - }; + reason: typeof body.reason === "string" && body.reason.trim().length > 0 + ? body.reason.trim() + : undefined, + }) as StudioToolApprovalDecision; } export function createApprovalRuntime(): ApprovalRuntime { @@ -164,14 +167,14 @@ export function createApprovalRuntime(): ApprovalRuntime { { toolName, toolCallId, internalCallId, args, tool: control }, request, ) => { - const decision = await requestApproval(approvals, context, { + const decision = await requestApproval(approvals, context, compact({ toolName, - ...(toolCallId === undefined ? {} : { toolCallId }), + toolCallId, internalCallId, args, - ...(request.reason === undefined ? {} : { reason: request.reason }), - ...(request.rejectMessage === undefined ? {} : { rejectMessage: request.rejectMessage }), - }); + reason: request.reason, + rejectMessage: request.rejectMessage, + }) as ApprovalRequest); return decision.approved ? control.run() @@ -188,19 +191,19 @@ export function createApprovalRuntime(): ApprovalRuntime { const rawParsedArgs = parseToolArgs(args); const parsedArgs = registeredTool.parseApprovalArgs?.(rawParsedArgs) ?? rawParsedArgs; - const approvalContext = { + const approvalContext = compact({ toolName, args: parsedArgs, rawArgs: args, - ...(toolCallId === undefined ? {} : { toolCallId }), + toolCallId, internalCallId, - run: { + run: compact({ agentId: context.agentId, runId: context.runId, - ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), - ...(context.metadata === undefined ? {} : { metadata: context.metadata }), - }, - }; + sessionId: context.sessionId, + metadata: context.metadata, + }), + }); const required = await approval.when(approvalContext); if (!required) { @@ -212,14 +215,14 @@ export function createApprovalRuntime(): ApprovalRuntime { approval.rejectMessage, approvalContext, ); - const decision = await requestApproval(approvals, context, { + const decision = await requestApproval(approvals, context, compact({ toolName, - ...(toolCallId === undefined ? {} : { toolCallId }), + toolCallId, internalCallId, args, - ...(reason === undefined ? {} : { reason }), - ...(rejectMessage === undefined ? {} : { rejectMessage }), - }); + reason, + rejectMessage, + }) as ApprovalRequest); return decision.approved ? control.run() @@ -263,15 +266,13 @@ export function createApprovalRuntime(): ApprovalRuntime { const reason = decision.approved ? decision.reason : (decision.reason ?? approval.rejectMessage ?? "Rejected in Anvia Studio."); - const resolved = resolveApproval(approval, decision.approved ? "approved" : "rejected", { - ...(reason === undefined ? {} : { reason }), - }); + const resolved = resolveApproval(approval, decision.approved ? "approved" : "rejected", compact({ reason })); approvals.set(id, resolved); approval.emit?.({ type: "tool_approval_result", approval: resolved }); - approval.resolve({ + approval.resolve(compact({ approved: decision.approved, - ...(reason === undefined ? {} : { reason }), - }); + reason, + }) as StudioToolApprovalDecision); return publicApproval(resolved); }, }; @@ -284,19 +285,21 @@ async function requestApproval( ): Promise { const id = globalThis.crypto.randomUUID(); const approval: PendingApproval = { - id, - runId: context.runId, - agentId: context.agentId, - ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), - toolName: request.toolName, - ...(request.toolCallId === undefined ? {} : { callId: request.toolCallId }), - internalCallId: request.internalCallId, - args: request.args, - status: "pending", - requestedAt: new Date().toISOString(), - ...(request.reason === undefined ? {} : { reason: request.reason }), - ...(request.rejectMessage === undefined ? {} : { rejectMessage: request.rejectMessage }), - ...(context.emit === undefined ? {} : { emit: context.emit }), + ...compact({ + id, + runId: context.runId, + agentId: context.agentId, + sessionId: context.sessionId, + toolName: request.toolName, + callId: request.toolCallId, + internalCallId: request.internalCallId, + args: request.args, + status: "pending" as const, + requestedAt: new Date().toISOString(), + reason: request.reason, + rejectMessage: request.rejectMessage, + emit: context.emit, + }), resolve: () => {}, }; @@ -305,25 +308,23 @@ async function requestApproval( const current = approvals.get(id); if (!isPendingApproval(current)) { if (current !== undefined) { - resolve({ + resolve(compact({ approved: current.status === "approved", - ...(current.reason === undefined ? {} : { reason: current.reason }), - }); + reason: current.reason, + }) as StudioToolApprovalDecision); } return; } const reason = decision.approved ? decision.reason : (decision.reason ?? request.rejectMessage ?? "Rejected in Anvia Studio."); - const resolved = resolveApproval(current, decision.approved ? "approved" : "rejected", { - ...(reason === undefined ? {} : { reason }), - }); + const resolved = resolveApproval(current, decision.approved ? "approved" : "rejected", compact({ reason })); approvals.set(id, resolved); context.emit?.({ type: "tool_approval_result", approval: resolved }); - resolve({ + resolve(compact({ approved: decision.approved, - ...(reason === undefined ? {} : { reason }), - }); + reason, + }) as StudioToolApprovalDecision); }; }); @@ -352,9 +353,11 @@ function resolveApproval( ): StudioToolApproval { return publicApproval({ ...approval, - status, - resolvedAt: new Date().toISOString(), - ...(options.reason === undefined ? {} : { reason: options.reason }), + ...compact({ + status, + resolvedAt: new Date().toISOString(), + reason: options.reason, + }), }); } diff --git a/packages/tool-studio/src/runtime/compact.ts b/packages/tool-studio/src/runtime/compact.ts new file mode 100644 index 00000000..31c70594 --- /dev/null +++ b/packages/tool-studio/src/runtime/compact.ts @@ -0,0 +1,22 @@ +/** + * Strips keys whose resolved type includes `undefined`. + * + * Replaces the verbose `...(x === undefined ? {} : { key: x })` pattern. + * + * @example + * compact({ id: "1", title: session.title, metadata: session.metadata }) + * // returns { id: "1" } when title and metadata are undefined + */ +export type Compact = { + [K in keyof T as undefined extends T[K] ? never : K]: Exclude; +}; + +export function compact>(obj: T): Compact { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined) { + result[key] = value; + } + } + return result as Compact; +} diff --git a/packages/tool-studio/src/runtime/config.ts b/packages/tool-studio/src/runtime/config.ts new file mode 100644 index 00000000..41500499 --- /dev/null +++ b/packages/tool-studio/src/runtime/config.ts @@ -0,0 +1,204 @@ +import type { JsonValue } from "@anvia/core/completion"; +import { compact } from "./compact"; +import type { + StudioAgent, + StudioAgentConfig, + StudioAgentRuntimeSummary, + StudioCapability, + StudioCapabilityConfig, + StudioConfig, + StudioEvalSuite, + StudioEvalSuiteConfig, + StudioPipeline, + StudioPipelineConfig, + StudioStores, + StudioUiOptions, +} from "../types"; +import { serializeUnknown } from "./json"; +import { createStudioModelRegistry, studioModelsConfig } from "./models"; +import { agentHasMcpTools, agentToolItems, mcpServerName } from "./tool-metadata"; + +export type ResolvedStores = { + sessions?: import("../types").StudioSessionStore; + traces?: import("../types").StudioTraceStore; + pipelineLogs?: import("../types").StudioPipelineLogStore; + pipelineRuns?: import("../types").StudioPipelineRunStore; +}; + +export type StudioRuntimeOptions = { + id?: string; + name?: string; + description?: string; + version?: string; + agents: StudioAgent[]; + pipelines: StudioPipeline[]; + evals: StudioEvalSuite[]; + models?: import("../types").StudioModelConfig; + stores?: StudioStores; + ui?: boolean | StudioUiOptions; +}; + +export function runnerId(options: StudioRuntimeOptions): string { + return options.id ?? "anvia-studio"; +} + +export function agentConfig(agent: StudioAgent): StudioAgentConfig { + const name = agent.name ?? agent.agent.name; + const description = agent.description ?? agent.agent.description; + return compact({ + id: agent.id, + name, + description, + quickPrompts: agent.quickPrompts ?? [], + metadata: agent.metadata, + }) as StudioAgentConfig; +} + +export function agentRuntimeSummary(agent: StudioAgent): StudioAgentRuntimeSummary { + const tools = agentToolItems(agent); + const name = agent.name ?? agent.agent.name; + const description = agent.description ?? agent.agent.description; + return compact({ + id: agent.id, + name, + description, + model: toJsonValue(agent.agent.model), + toolCount: tools.length, + staticToolCount: tools.filter((item) => item.source === "static").length, + dynamicToolCount: tools.filter((item) => item.source === "dynamic").length, + approvalToolCount: tools.filter((item) => item.tool.approval !== undefined).length, + mcpToolCount: tools.filter((item) => mcpServerName(item.tool) !== undefined).length, + staticContextCount: agent.agent.staticContext.length, + dynamicContextCount: agent.agent.dynamicContexts.length, + observerCount: agent.agent.observers.length, + hasMemory: agent.agent.memory !== undefined, + hasHook: agent.agent.hook !== undefined, + hasOutputSchema: agent.agent.outputSchema !== undefined, + defaultMaxTurns: agent.agent.defaultMaxTurns, + metadata: agent.metadata, + }) as StudioAgentRuntimeSummary; +} + +export function pipelineConfig(pipeline: StudioPipeline): StudioPipelineConfig { + const graph = pipeline.pipeline.graph(); + const stageNodes = graph.nodes.filter((node) => node.kind !== "input" && node.kind !== "output"); + return compact({ + id: pipeline.id, + name: pipeline.name, + description: pipeline.description, + metadata: pipeline.metadata, + stageCount: stageNodes.length, + edgeCount: graph.edges.length, + hasParallelStages: graph.nodes.some((node) => node.kind === "parallel"), + agentCount: graph.nodes.filter((node) => node.kind === "agent").length, + extractorCount: graph.nodes.filter((node) => node.kind === "extractor").length, + }) as StudioPipelineConfig; +} + +export function buildConfig( + options: StudioRuntimeOptions, + agents: StudioAgent[], + pipelines: StudioPipeline[], + stores: ResolvedStores, +): StudioConfig { + const models = + options.models === undefined + ? undefined + : studioModelsConfig(createStudioModelRegistry(options.models), agents); + return compact({ + id: runnerId(options), + name: options.name, + description: options.description, + version: options.version, + agents: agents.map(agentConfig), + models, + pipelines: pipelines.map(pipelineConfig), + evals: options.evals.map(evalConfig), + chat: { + quickPrompts: Object.fromEntries(agents.map((agent) => [agent.id, agent.quickPrompts ?? []])), + }, + capabilities: capabilityConfig(options, agents, pipelines, stores), + unsupportedCapabilities: unsupportedCapabilities(stores), + }) as StudioConfig; +} + +export function capabilityConfig( + _options: StudioRuntimeOptions, + agents: StudioAgent[], + pipelines: StudioPipeline[], + stores: ResolvedStores, +): Partial> { + const capabilities: Partial> = { + agents: { enabled: true }, + observability: { enabled: true }, + status: { enabled: true }, + }; + + if (stores.sessions !== undefined) { + capabilities.sessions = { enabled: true }; + capabilities.memory = { enabled: true }; + } + if (stores.traces !== undefined) { + capabilities.traces = { enabled: true }; + } + if (pipelines.length > 0) { + capabilities.pipelines = { enabled: true }; + } + if (_options.evals.length > 0) { + capabilities.evals = { 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.hook !== undefined || + agent.agent.toolSet.values().some((tool) => tool.approval), + ) + ) { + capabilities.approvals = { enabled: true }; + } + if ( + agents.some( + (agent) => + agent.agent.staticContext.length > 0 || + agent.agent.dynamicContexts.length > 0 || + agent.agent.dynamicTools.length > 0, + ) + ) { + capabilities.knowledge = { enabled: true }; + } + return capabilities; +} + +export function evalConfig(suite: StudioEvalSuite): StudioEvalSuiteConfig { + return compact({ + id: suite.id ?? suite.name, + name: suite.name, + description: suite.description, + caseCount: suite.cases.length, + metricNames: suite.metrics.map((metric) => metric.name), + concurrency: suite.concurrency, + metadata: suite.metadata, + }) as StudioEvalSuiteConfig; +} + +export function unsupportedCapabilities(stores: ResolvedStores): import("../types").StudioCapability[] { + return [ + ...(stores.sessions === undefined ? (["sessions"] as const) : []), + ...(stores.traces === undefined ? (["traces"] as const) : []), + ]; +} + +function toJsonValue(value: unknown): JsonValue { + return serializeUnknown(value); +} diff --git a/packages/tools/studio/src/runtime/evals.ts b/packages/tool-studio/src/runtime/evals.ts similarity index 90% rename from packages/tools/studio/src/runtime/evals.ts rename to packages/tool-studio/src/runtime/evals.ts index e56479e7..09b078ec 100644 --- a/packages/tools/studio/src/runtime/evals.ts +++ b/packages/tool-studio/src/runtime/evals.ts @@ -1,8 +1,11 @@ import { runEvalSuite } from "@anvia/core/evals"; import type { Context, Hono } from "hono"; import type { StudioEvalRunRequest, StudioEvalRunResponse, StudioEvalSuite } from "../types"; +import { compact } from "./compact"; import { toJsonValue } from "./json"; -import { errorResponse, evalConfig, isJsonObject, isObject, isPositiveInteger } from "./shared"; +import { evalConfig } from "./config"; +import { errorResponse } from "./http"; +import { isJsonObject, isObject, isPositiveInteger } from "./type-guards"; export function registerEvalRoutes( app: Hono, @@ -40,7 +43,7 @@ export function registerEvalRoutes( const startedAt = Date.now(); const result = await runEvalSuite({ ...suite, - ...(body.concurrency === undefined ? {} : { concurrency: body.concurrency }), + ...compact({ concurrency: body.concurrency }), }); const endedAt = Date.now(); const jsonResult = toJsonValue(result); diff --git a/packages/tool-studio/src/runtime/http.ts b/packages/tool-studio/src/runtime/http.ts new file mode 100644 index 00000000..84d5c25a --- /dev/null +++ b/packages/tool-studio/src/runtime/http.ts @@ -0,0 +1,62 @@ +import type { JsonValue } from "@anvia/core/completion"; +import type { Context } from "hono"; +import type { StudioCapability, StudioErrorCode, StudioErrorResponse } from "../types"; +import { serializeUnknown } from "./json"; +import { isObject } from "./type-guards"; + +export function errorResponse( + c: Context, + status: 400 | 404 | 409 | 500 | 501, + code: StudioErrorCode, + message: string, + details?: JsonValue, +): Response { + const body: StudioErrorResponse = { + error: { + code, + message, + }, + }; + if (details !== undefined) { + body.error.details = details; + } + return c.json(body, status); +} + +export function unsupportedCapability(c: Context, capability: StudioCapability): Response { + return errorResponse( + c, + 501, + "unsupported_capability", + `Capability "${capability}" is not implemented by this runner`, + { capability }, + ); +} + +export function serializeError(error: unknown): JsonValue { + return serializeUnknown(error); +} + +/** + * Parse and validate a JSON request body. + * + * Returns the validated result from `validate`, or an error response + * if the body is not valid JSON or fails validation. + */ +export async function parseJsonBody( + c: Context, + validate: (body: unknown) => T | { error: Response }, +): Promise { + let body: unknown; + try { + body = await c.req.json(); + } catch { + return { error: errorResponse(c, 400, "bad_request", "Request body must be JSON") }; + } + + if (!isObject(body)) { + return { error: errorResponse(c, 400, "bad_request", "Request body must be an object") }; + } + + return validate(body); +} diff --git a/packages/tools/studio/src/runtime/json.ts b/packages/tool-studio/src/runtime/json.ts similarity index 85% rename from packages/tools/studio/src/runtime/json.ts rename to packages/tool-studio/src/runtime/json.ts index c1744b15..e3aef9b3 100644 --- a/packages/tools/studio/src/runtime/json.ts +++ b/packages/tool-studio/src/runtime/json.ts @@ -63,3 +63,19 @@ export function serializeUnknown(error: unknown): JsonValue { } return toJsonValue(error); } + +export function formatJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +export function formatUnknown(value: unknown): string | undefined { + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} diff --git a/packages/tools/studio/src/runtime/knowledge.ts b/packages/tool-studio/src/runtime/knowledge.ts similarity index 89% rename from packages/tools/studio/src/runtime/knowledge.ts rename to packages/tool-studio/src/runtime/knowledge.ts index bca9aff0..46b4bea2 100644 --- a/packages/tools/studio/src/runtime/knowledge.ts +++ b/packages/tool-studio/src/runtime/knowledge.ts @@ -13,8 +13,10 @@ import type { StudioTrace, StudioTraceStore, } from "../types"; +import { compact } from "./compact"; import { compactJsonObject, toJsonValue } from "./json"; -import { errorResponse, optionalQueryString, parseLimit } from "./shared"; +import { errorResponse } from "./http"; +import { optionalQueryString, parseLimit } from "./query"; type InspectableIndex = { inspect?: (request: { limit: number; cursor?: string | undefined; filter?: unknown }) => Promise<{ @@ -85,14 +87,12 @@ async function agentKnowledgeConfig(agent: StudioAgent): Promise ({ id: document.id, text: document.text, - ...(document.additionalProps === undefined - ? {} - : { additionalProps: jsonObjectFromRecord(document.additionalProps) }), + ...compact({ additionalProps: document.additionalProps !== undefined ? jsonObjectFromRecord(document.additionalProps) : undefined }), })), }; } @@ -120,11 +120,9 @@ async function knowledgeSources(agent: StudioAgent): Promise dynamicContextItem(item)), - ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }), - ...(page.totalCount === undefined ? {} : { totalCount: page.totalCount }), + ...compact({ nextCursor: page.nextCursor, totalCount: page.totalCount }), }; } @@ -219,8 +214,7 @@ async function knowledgeItemsPage( kind: "dynamic_tools", inspectable: true, items: page.items.map((item) => dynamicToolItem(item)), - ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }), - ...(page.totalCount === undefined ? {} : { totalCount: page.totalCount }), + ...compact({ nextCursor: page.nextCursor, totalCount: page.totalCount }), }; } @@ -252,11 +246,9 @@ function staticKnowledgeItemsPage( id: document.id, kind: "static_context", text: document.text, - ...(document.additionalProps === undefined - ? {} - : { metadata: jsonObjectFromRecord(document.additionalProps) }), + ...compact({ metadata: document.additionalProps !== undefined ? jsonObjectFromRecord(document.additionalProps) : undefined }), })), - ...(nextOffset < agent.agent.staticContext.length ? { nextCursor: String(nextOffset) } : {}), + ...compact({ nextCursor: nextOffset < agent.agent.staticContext.length ? String(nextOffset) : undefined }), totalCount: agent.agent.staticContext.length, }; } @@ -291,7 +283,7 @@ function dynamicContextItem(item: { id: item.id, kind: "dynamic_context", ...(text === undefined ? { document: toJsonValue(item.document) } : { text }), - ...(item.metadata === undefined ? {} : { metadata: jsonObjectFromRecord(item.metadata) }), + ...compact({ metadata: item.metadata !== undefined ? jsonObjectFromRecord(item.metadata) : undefined }), }; } @@ -316,7 +308,7 @@ function dynamicToolItem(item: { description, parameterKeys: parameterKeys(definition.parameters), document: toJsonValue(item.document), - ...(item.metadata === undefined ? {} : { metadata: jsonObjectFromRecord(item.metadata) }), + ...compact({ metadata: item.metadata !== undefined ? jsonObjectFromRecord(item.metadata) : undefined }), }; } @@ -398,7 +390,7 @@ function evidenceFromTrace(trace: StudioTrace): StudioKnowledgeEvidence[] { observationName: observation.name, turn: observation.turn, startedAt: observation.startedAt, - ...(query === undefined ? {} : { query }), + ...compact({ query }), documentCount: documents.length, toolCount: tools.length, documents, @@ -480,11 +472,7 @@ function evidenceDocument(value: unknown): StudioKnowledgeEvidenceDocument[] { return []; } return [ - { - ...(id === undefined ? {} : { id }), - ...(text === undefined ? {} : { text }), - ...(additionalProps === undefined ? {} : { additionalProps }), - }, + compact({ id, text, additionalProps }), ]; } diff --git a/packages/tools/studio/src/runtime/mcps.ts b/packages/tool-studio/src/runtime/mcps.ts similarity index 97% rename from packages/tools/studio/src/runtime/mcps.ts rename to packages/tool-studio/src/runtime/mcps.ts index 5393364c..4bcef08a 100644 --- a/packages/tools/studio/src/runtime/mcps.ts +++ b/packages/tool-studio/src/runtime/mcps.ts @@ -4,7 +4,7 @@ import type { StudioAgentMcpServerMetadata, StudioAgentMcpToolMetadata, } from "../types"; -import { errorResponse } from "./shared"; +import { errorResponse } from "./http"; import { agentToolItems, mcpServerName } from "./tool-metadata"; export function registerMcpRoutes( diff --git a/packages/tools/studio/src/runtime/memory.ts b/packages/tool-studio/src/runtime/memory.ts similarity index 93% rename from packages/tools/studio/src/runtime/memory.ts rename to packages/tool-studio/src/runtime/memory.ts index 43cbd667..80aefc0a 100644 --- a/packages/tools/studio/src/runtime/memory.ts +++ b/packages/tool-studio/src/runtime/memory.ts @@ -9,7 +9,9 @@ import type { StudioSessionStore, StudioSessionSummary, } from "../types"; -import { errorResponse, optionalQueryString, parseLimit } from "./shared"; +import { compact } from "./compact"; +import { errorResponse } from "./http"; +import { optionalQueryString, parseLimit } from "./query"; const DEFAULT_USER_ID = "default"; @@ -66,7 +68,7 @@ export function registerMemoryRoutes( const agentId = optionalQueryString(c.req.query("agentId")); const userId = optionalQueryString(c.req.query("userId")); const sessions = await props.sessionStore.listSessions({ - ...(agentId === undefined ? {} : { agentId }), + ...compact({ agentId }), limit: 100, }); const conversations = sessions @@ -107,16 +109,16 @@ export function registerMemoryRoutes( function memoryConversationSummary( session: StudioSession | StudioSessionSummary, ): StudioMemoryConversationSummary { - return { + return compact({ id: session.id, userId: sessionUserId(session), agentId: session.agentId, - ...(session.title === undefined ? {} : { title: session.title }), + title: session.title, createdAt: session.createdAt, updatedAt: session.updatedAt, messageCount: session.messageCount, - ...(session.metadata === undefined ? {} : { metadata: session.metadata }), - }; + metadata: session.metadata, + }) as StudioMemoryConversationSummary; } function sessionUserId(session: Pick): string { diff --git a/packages/tool-studio/src/runtime/models.ts b/packages/tool-studio/src/runtime/models.ts new file mode 100644 index 00000000..24c565c4 --- /dev/null +++ b/packages/tool-studio/src/runtime/models.ts @@ -0,0 +1,456 @@ +import type { CompletionModel, JsonObject, Message } from "@anvia/core/completion"; +import type { Hono } from "hono"; +import type { + AgentRunRequest, + StudioAgent, + StudioAgentModelPolicy, + StudioAgentModelPolicyConfig, + StudioAgentModelsSummary, + StudioModelConfig, + StudioModelDefinition, + StudioModelModality, + StudioModelProvider, + StudioModelProviderConfig, + StudioModelRef, + StudioModelSummary, + StudioModelsConfig, +} from "../types"; +import { compact } from "./compact"; +import { errorResponse, serializeError } from "./http"; + +export const STUDIO_MODEL_METADATA_KEY = "studioModel"; + +type RuntimeProvider = StudioModelProvider & { + staticModels: Map; +}; + +export type StudioModelRegistry = { + readonly defaultModel?: string; + readonly providers: Map; + readonly agentPolicies: Map; + readonly modelCache: Map; +}; + +type NormalizedAgentModelPolicy = { + default?: string; + allowed?: string[]; +}; + +type ResolveModelInput = { + agent: StudioAgent; + request: AgentRunRequest; + sessionMetadata?: JsonObject | undefined; +}; + +export type StudioResolvedModel = { + ref?: string; + model?: CompletionModel; + warnings: JsonObject[]; +}; + +export function createStudioModelRegistry( + config: StudioModelConfig | undefined, +): StudioModelRegistry | undefined { + if (config === undefined) { + return undefined; + } + + const providers = new Map(); + for (const provider of config.providers) { + const id = normalizeProviderId(provider.id); + if (providers.has(id)) { + throw new Error(`Duplicate Studio model provider id: ${id}`); + } + const staticModels = new Map(); + for (const model of provider.models ?? []) { + const modelId = normalizeModelId(model.id); + if (staticModels.has(modelId)) { + throw new Error(`Duplicate Studio model id for provider ${id}: ${modelId}`); + } + staticModels.set(modelId, { ...model, id: modelId }); + } + providers.set(id, { + ...provider, + id, + ...compact({ defaultModel: provider.defaultModel !== undefined ? normalizeModelId(provider.defaultModel) : undefined }), + staticModels, + }); + } + + return { + ...compact({ defaultModel: config.default !== undefined ? normalizeModelRef(config.default) : undefined }), + providers, + agentPolicies: normalizeAgentPolicies(config.agents ?? {}), + modelCache: new Map(), + }; +} + +export function studioModelsConfig( + registry: StudioModelRegistry | undefined, + agents: StudioAgent[], +): StudioModelsConfig | undefined { + if (registry === undefined) { + return undefined; + } + + const providers = [...registry.providers.values()].map((provider): StudioModelProviderConfig => { + const models = [...provider.staticModels.values()].map((model) => + modelSummary(provider, model.id, model), + ); + return { + id: provider.id, + ...compact({ name: provider.name, defaultModel: provider.defaultModel, metadata: provider.metadata }), + models, + }; + }); + + const agentIds = new Set(agents.map((agent) => agent.id)); + const agentsConfig = Object.fromEntries( + [...registry.agentPolicies.entries()] + .filter(([agentId]) => agentIds.has(agentId)) + .map(([agentId, policy]) => [agentId, publicPolicy(policy)]), + ); + + return { + providers, + ...compact({ default: registry.defaultModel }), + agents: agentsConfig, + }; +} + +export function registerModelRoutes( + app: Hono, + props: { registry?: StudioModelRegistry | undefined; agentMap: Map }, +): void { + app.get("/models", async (c) => { + if (props.registry === undefined) { + return errorResponse(c, 404, "not_found", "Model registry not configured"); + } + const providers = await Promise.all( + [...props.registry.providers.values()].map((provider) => providerCatalog(provider)), + ); + return c.json({ + providers, + ...compact({ defaultModel: props.registry.defaultModel }), + }); + }); + + app.get("/models/:providerId", async (c) => { + if (props.registry === undefined) { + return errorResponse(c, 404, "not_found", "Model registry not configured"); + } + const provider = props.registry.providers.get(c.req.param("providerId")); + if (provider === undefined) { + return errorResponse(c, 404, "not_found", "Model provider not found"); + } + return c.json(await providerCatalog(provider)); + }); + + app.get("/agents/:agentId/models", 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"); + } + if (props.registry === undefined) { + return c.json({ + agentId, + models: [], + } satisfies StudioAgentModelsSummary); + } + return c.json(await agentModelsCatalog(props.registry, agent)); + }); +} + +export function resolveStudioModel( + registry: StudioModelRegistry | undefined, + input: ResolveModelInput, +): StudioResolvedModel { + if (registry === undefined) { + return { warnings: [] }; + } + + const selectedRef = + normalizeOptionalModelRef(input.request.model) ?? + sessionModelRef(input.sessionMetadata) ?? + registry.agentPolicies.get(input.agent.id)?.default ?? + registry.defaultModel; + if (selectedRef === undefined) { + return { warnings: [] }; + } + + ensureModelAllowed(registry, input.agent.id, selectedRef); + const { providerId, modelId } = parseModelRef(selectedRef); + const provider = registry.providers.get(providerId); + if (provider === undefined) { + throw new ModelSelectionError(`Unknown model provider: ${providerId}`); + } + + let model = registry.modelCache.get(selectedRef); + if (model === undefined) { + model = provider.createCompletionModel(modelId); + registry.modelCache.set(selectedRef, model); + } + + const metadata = provider.staticModels.get(modelId); + return { + ref: selectedRef, + model, + warnings: modelWarnings(selectedRef, metadata, input.request), + }; +} + +export function sessionModelRef(metadata: JsonObject | undefined): string | undefined { + const value = metadata?.[STUDIO_MODEL_METADATA_KEY]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +export function normalizeOptionalModelRef(ref: StudioModelRef | undefined): string | undefined { + return ref === undefined ? undefined : normalizeModelRef(ref); +} + +export function normalizeModelRef(ref: StudioModelRef): string { + if (typeof ref === "string") { + const trimmed = ref.trim(); + const parsed = parseModelRef(trimmed); + return `${parsed.providerId}:${parsed.modelId}`; + } + return `${normalizeProviderId(ref.provider)}:${normalizeModelId(ref.model)}`; +} + +export function parseModelRef(ref: string): { providerId: string; modelId: string } { + const index = ref.indexOf(":"); + if (index <= 0 || index === ref.length - 1) { + throw new ModelSelectionError(`Model ref must use provider:model format: ${ref}`); + } + return { + providerId: normalizeProviderId(ref.slice(0, index)), + modelId: normalizeModelId(ref.slice(index + 1)), + }; +} + +export class ModelSelectionError extends Error { + constructor(message: string) { + super(message); + this.name = "ModelSelectionError"; + } +} + +async function agentModelsCatalog( + registry: StudioModelRegistry, + agent: StudioAgent, +): Promise { + const policy = registry.agentPolicies.get(agent.id); + const catalogs = await Promise.all([...registry.providers.values()].map(providerCatalog)); + const warnings = catalogs.flatMap((catalog) => + catalog.warning === undefined + ? [] + : [{ providerId: catalog.id, warning: catalog.warning } satisfies JsonObject], + ); + const models = catalogs + .flatMap((catalog) => catalog.models) + .filter((model) => { + if (policy?.allowed === undefined) { + return true; + } + return allowedByPolicy(policy.allowed, model.ref); + }); + const exactPolicyModels = (policy?.allowed ?? []) + .filter((allowed) => !allowed.endsWith(":*")) + .filter((allowed) => models.every((model) => model.ref !== allowed)) + .flatMap((ref) => { + try { + const { providerId, modelId } = parseModelRef(ref); + const provider = registry.providers.get(providerId); + return provider === undefined + ? [] + : [ + modelSummary(provider, modelId, { + id: modelId, + }), + ]; + } catch { + return []; + } + }); + + const defaultModel = policy?.default ?? registry.defaultModel; + return { + agentId: agent.id, + ...compact({ defaultModel }), + models: [...models, ...exactPolicyModels], + ...compact({ warnings: warnings.length === 0 ? undefined : warnings }), + }; +} + +async function providerCatalog(provider: RuntimeProvider): Promise { + const models = new Map(); + for (const model of provider.staticModels.values()) { + models.set(model.id, modelSummary(provider, model.id, model)); + } + + let warning: string | undefined; + if (provider.listModels !== undefined) { + try { + const listed = await provider.listModels(); + for (const model of listed.data) { + const staticModel = provider.staticModels.get(model.id); + models.set( + model.id, + modelSummary(provider, model.id, { + id: model.id, + ...compact({ name: model.name, description: model.description }), + ...(staticModel ?? {}), + metadata: { + ...compact({ + type: model.type, + createdAt: model.createdAt, + ownedBy: model.ownedBy, + contextLength: model.contextLength, + }), + ...(staticModel?.metadata ?? {}), + }, + }), + ); + } + } catch (error) { + const serialized = serializeError(error); + warning = + typeof serialized === "object" && serialized !== null && "message" in serialized + ? String(serialized.message) + : String(error); + } + } + + return { + id: provider.id, + ...compact({ name: provider.name, defaultModel: provider.defaultModel, metadata: provider.metadata, warning }), + models: [...models.values()].sort((left, right) => left.ref.localeCompare(right.ref)), + }; +} + +function modelSummary( + provider: RuntimeProvider, + modelId: string, + model: StudioModelDefinition, +): StudioModelSummary { + return { + ...model, + id: modelId, + ref: `${provider.id}:${modelId}`, + providerId: provider.id, + ...compact({ providerName: provider.name }), + }; +} + +function ensureModelAllowed(registry: StudioModelRegistry, agentId: string, ref: string): void { + const { providerId } = parseModelRef(ref); + if (!registry.providers.has(providerId)) { + throw new ModelSelectionError(`Unknown model provider: ${providerId}`); + } + const policy = registry.agentPolicies.get(agentId); + if (policy?.allowed !== undefined && !allowedByPolicy(policy.allowed, ref)) { + throw new ModelSelectionError(`Model ${ref} is not allowed for agent ${agentId}`); + } +} + +function allowedByPolicy(allowed: string[], ref: string): boolean { + return allowed.some((entry) => + entry.endsWith(":*") ? ref.startsWith(entry.slice(0, -1)) : entry === ref, + ); +} + +function normalizeAgentPolicies( + policies: Record, +): Map { + return new Map( + Object.entries(policies).map(([agentId, policy]) => [ + agentId.trim(), + { + ...compact({ + default: policy.default !== undefined ? normalizeModelRef(policy.default) : undefined, + allowed: policy.allowed?.map((entry) => + typeof entry === "string" && entry.trim().endsWith(":*") + ? `${normalizeProviderId(entry.trim().slice(0, -2))}:*` + : normalizeModelRef(entry), + ), + }), + }, + ]), + ); +} + +function publicPolicy(policy: NormalizedAgentModelPolicy): StudioAgentModelPolicyConfig { + return compact({ default: policy.default, allowed: policy.allowed }); +} + +function modelWarnings( + ref: string, + model: StudioModelDefinition | undefined, + request: AgentRunRequest, +): JsonObject[] { + const warnings: JsonObject[] = []; + const modalities = requestModalities(request); + const missingModalities = [...modalities].filter( + (modality) => !model?.modalities?.input.includes(modality), + ); + if (model?.modalities !== undefined && missingModalities.length > 0) { + warnings.push({ + model: ref, + kind: "modality", + message: `Model ${ref} does not declare input support for ${missingModalities.join(", ")}`, + missing: missingModalities, + }); + } + + if (request.stream === true && model?.capabilities?.streaming === false) { + warnings.push({ + model: ref, + kind: "capability", + message: `Model ${ref} is configured as non-streaming but the run requested streaming`, + capability: "streaming", + }); + } + + return warnings; +} + +function requestModalities(request: AgentRunRequest): Set { + const modalities = new Set(["text"]); + for (const message of requestMessages(request)) { + if (typeof message === "string") { + continue; + } + if (message.role === "user" || message.role === "assistant") { + for (const content of message.content) { + if (content.type === "image") { + modalities.add("image"); + } + if (content.type === "document") { + modalities.add("document"); + } + } + } + } + return modalities; +} + +function requestMessages(request: AgentRunRequest): Array { + return [...(request.history ?? []), request.message]; +} + +function normalizeProviderId(id: string): string { + const trimmed = id.trim(); + if (trimmed.length === 0 || trimmed.includes(":")) { + throw new ModelSelectionError("Model provider id must be non-empty and cannot contain ':'"); + } + return trimmed; +} + +function normalizeModelId(id: string): string { + const trimmed = id.trim(); + if (trimmed.length === 0) { + throw new ModelSelectionError("Model id cannot be empty"); + } + return trimmed; +} diff --git a/packages/tools/studio/src/runtime/observability.ts b/packages/tool-studio/src/runtime/observability.ts similarity index 86% rename from packages/tools/studio/src/runtime/observability.ts rename to packages/tool-studio/src/runtime/observability.ts index ef90d142..91611a1f 100644 --- a/packages/tools/studio/src/runtime/observability.ts +++ b/packages/tool-studio/src/runtime/observability.ts @@ -8,7 +8,8 @@ import type { StudioTraceStore, StudioTraceSummary, } from "../types"; -import type { ResolvedStores } from "./shared"; +import type { ResolvedStores } from "./config"; +import { compact } from "./compact"; import { streamStudioJsonl } from "./streams"; type ObservabilitySubscription = { @@ -47,13 +48,11 @@ export class StudioObservabilityHub { export function observeStores(stores: ResolvedStores, hub: StudioObservabilityHub): ResolvedStores { return { ...stores, - ...(stores.sessions === undefined - ? {} - : { sessions: observeSessionStore(stores.sessions, hub) }), - ...(stores.traces === undefined ? {} : { traces: observeTraceStore(stores.traces, hub) }), - ...(stores.pipelineLogs === undefined - ? {} - : { pipelineLogs: observePipelineLogStore(stores.pipelineLogs, hub) }), + ...compact({ + sessions: stores.sessions !== undefined ? observeSessionStore(stores.sessions, hub) : undefined, + traces: stores.traces !== undefined ? observeTraceStore(stores.traces, hub) : undefined, + pipelineLogs: stores.pipelineLogs !== undefined ? observePipelineLogStore(stores.pipelineLogs, hub) : undefined, + }), }; } @@ -80,7 +79,7 @@ function observabilityEvents( hub: StudioObservabilityHub, types: Set | undefined, ): AsyncIterable { - const subscription = hub.subscribe(types === undefined ? {} : { types }); + const subscription = hub.subscribe(compact({ types })); return { [Symbol.asyncIterator]() { @@ -228,15 +227,17 @@ function traceSummary(trace: StudioTrace): StudioTraceSummary { return { id: trace.id, sessionId: trace.sessionId, - ...(trace.name === undefined ? {} : { name: trace.name }), status: trace.status, startedAt: trace.startedAt, - ...(trace.endedAt === undefined ? {} : { endedAt: trace.endedAt }), - ...(trace.durationMs === undefined ? {} : { durationMs: trace.durationMs }), - ...(trace.output === undefined ? {} : { output: trace.output }), - ...(trace.error === undefined ? {} : { error: trace.error }), - ...(trace.usage === undefined ? {} : { usage: trace.usage }), - ...(trace.metadata === undefined ? {} : { metadata: trace.metadata }), observationCount: trace.observations.length, + ...compact({ + name: trace.name, + endedAt: trace.endedAt, + durationMs: trace.durationMs, + output: trace.output, + error: trace.error, + usage: trace.usage, + metadata: trace.metadata, + }), }; } diff --git a/packages/tools/studio/src/runtime/pipeline-logs.ts b/packages/tool-studio/src/runtime/pipeline-logs.ts similarity index 88% rename from packages/tools/studio/src/runtime/pipeline-logs.ts rename to packages/tool-studio/src/runtime/pipeline-logs.ts index 66d3a580..4e9a60ba 100644 --- a/packages/tools/studio/src/runtime/pipeline-logs.ts +++ b/packages/tool-studio/src/runtime/pipeline-logs.ts @@ -6,7 +6,9 @@ import type { StudioPipelineLogEntry, StudioPipelineLogStore, } from "../types"; -import { serializeError } from "./shared"; +import { compact } from "./compact"; +import { formatUnknown } from "./json"; +import { serializeError } from "./http"; export async function appendPipelineLog( store: StudioPipelineLogStore | undefined, @@ -39,7 +41,7 @@ export function pipelineRunReceivedLog(props: { category: "api", event: "pipeline.run_received", message: "Pipeline run request received", - metadata: cleanMetadata({ + metadata: compact({ stream: props.stream, inputBytes: byteLength(formatUnknown(props.input)), metadataKeys: Object.keys(props.metadata ?? {}), @@ -59,7 +61,7 @@ export function pipelineRunStartedLog( category: "run", event: "pipeline.run_started", message: "Pipeline run started", - metadata: cleanMetadata({ + metadata: compact({ stageCount: graph.nodes.filter((node) => node.kind !== "input" && node.kind !== "output") .length, edgeCount: graph.edges.length, @@ -80,7 +82,7 @@ export function pipelineRunCompletedLog(props: { category: "run", event: "pipeline.run_completed", message: "Pipeline run completed", - metadata: cleanMetadata({ + metadata: compact({ durationMs: props.durationMs, outputBytes: byteLength(formatUnknown(props.output)), }), @@ -100,7 +102,7 @@ export function pipelineRunFailedLog( category: "run", event: "pipeline.run_failed", message: "Pipeline run failed", - metadata: cleanMetadata({ + metadata: compact({ durationMs: Date.now() - startedAt, error: serializeError(error), }), @@ -132,7 +134,7 @@ export function pipelineStageLog( category, event: `${event.node.kind}.completed`, message: `${event.node.label} completed`, - metadata: cleanMetadata({ + metadata: compact({ ...nodeMetadata(event.node), durationMs: event.durationMs, }), @@ -145,7 +147,7 @@ export function pipelineStageLog( category, event: `${event.node.kind}.failed`, message: `${event.node.label} failed`, - metadata: cleanMetadata({ + metadata: compact({ ...nodeMetadata(event.node), durationMs: event.durationMs, error: serializeError(event.error), @@ -167,7 +169,7 @@ function stageCategory(node: PipelineGraphNode): StudioPipelineLogAppendInput["c } function nodeMetadata(node: PipelineGraphNode): JsonObject { - return cleanMetadata({ + return compact({ nodeId: node.id, kind: node.kind, label: node.label, @@ -177,20 +179,6 @@ function nodeMetadata(node: PipelineGraphNode): JsonObject { }); } -function cleanMetadata(value: Record): JsonObject { - return Object.fromEntries( - Object.entries(value).filter(([, item]) => item !== undefined), - ) as JsonObject; -} - function byteLength(value: string | undefined): number | undefined { return value === undefined ? undefined : new TextEncoder().encode(value).length; } - -function formatUnknown(value: unknown): string | undefined { - try { - return JSON.stringify(value); - } catch { - return undefined; - } -} diff --git a/packages/tools/studio/src/runtime/pipelines.ts b/packages/tool-studio/src/runtime/pipelines.ts similarity index 94% rename from packages/tools/studio/src/runtime/pipelines.ts rename to packages/tool-studio/src/runtime/pipelines.ts index 8af99a8e..44bde86a 100644 --- a/packages/tools/studio/src/runtime/pipelines.ts +++ b/packages/tool-studio/src/runtime/pipelines.ts @@ -12,6 +12,7 @@ import type { StudioPipelineRunSaveInput, StudioPipelineRunStore, } from "../types"; +import { compact } from "./compact"; import { toJsonValue } from "./json"; import { appendPipelineLog, @@ -23,7 +24,9 @@ import { pipelineStageLog, } from "./pipeline-logs"; import { AsyncEventQueue } from "./runs"; -import { errorResponse, isJsonObject, isObject, pipelineConfig, serializeError } from "./shared"; +import { pipelineConfig } from "./config"; +import { errorResponse, parseJsonBody, serializeError } from "./http"; +import { isJsonObject, isObject } from "./type-guards"; import { streamStudioJsonl } from "./streams"; export function registerPipelineRoutes( @@ -76,7 +79,7 @@ export function registerPipelineRoutes( const logs = await props.logStore.listPipelineLogs({ pipelineId, limit, - ...(after === undefined ? {} : { after }), + ...compact({ after }), }); const last = logs.at(-1); return c.json({ @@ -157,7 +160,7 @@ export function registerPipelineRoutes( return executePipelineRun(c, props, pipeline, { input: sourceRun.input, - ...(body.stream === undefined ? {} : { stream: body.stream }), + ...compact({ stream: body.stream }), metadata: replayMetadata(sourceRun.metadata, body.metadata, sourceRun.runId), }); }); @@ -182,7 +185,7 @@ async function executePipelineRun( runId, stream: body.stream === true, input: body.input, - ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + ...compact({ metadata: body.metadata }), }), ); await savePipelineRun(props.runStore, { @@ -190,7 +193,7 @@ async function executePipelineRun( pipelineId: pipeline.id, status: "running", input: body.input, - ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + ...compact({ metadata: body.metadata }), startedAt: startedAtIso, }); @@ -201,9 +204,8 @@ async function executePipelineRun( input: body.input, startedAt, startedAtIso, - ...(body.metadata === undefined ? {} : { metadata: body.metadata }), - ...(props.logStore === undefined ? {} : { logStore: props.logStore }), - ...(props.runStore === undefined ? {} : { runStore: props.runStore }), + ...compact({ metadata: body.metadata }), + ...compact({ logStore: props.logStore, runStore: props.runStore }), }); } @@ -224,7 +226,7 @@ async function executePipelineRun( status: "success", input: body.input, output: jsonOutput, - ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + ...compact({ metadata: body.metadata }), startedAt: startedAtIso, endedAt: new Date(endedAt).toISOString(), durationMs: endedAt - startedAt, @@ -252,7 +254,7 @@ async function executePipelineRun( status: "error", input: body.input, error: serializeError(error), - ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + ...compact({ metadata: body.metadata }), startedAt: startedAtIso, endedAt: new Date(endedAt).toISOString(), durationMs: endedAt - startedAt, @@ -326,7 +328,7 @@ async function* pipelineRunEvents(props: { status: "success", input: props.input, output: jsonOutput, - ...(props.metadata === undefined ? {} : { metadata: props.metadata }), + ...compact({ metadata: props.metadata }), startedAt: props.startedAtIso, endedAt: new Date(endedAt).toISOString(), durationMs: endedAt - props.startedAt, @@ -358,7 +360,7 @@ async function* pipelineRunEvents(props: { status: "error", input: props.input, error: serializeError(error), - ...(props.metadata === undefined ? {} : { metadata: props.metadata }), + ...compact({ metadata: props.metadata }), startedAt: props.startedAtIso, endedAt: new Date(endedAt).toISOString(), durationMs: endedAt - props.startedAt, diff --git a/packages/tool-studio/src/runtime/query.ts b/packages/tool-studio/src/runtime/query.ts new file mode 100644 index 00000000..ee5c6a69 --- /dev/null +++ b/packages/tool-studio/src/runtime/query.ts @@ -0,0 +1,53 @@ +import type { StudioTraceStatus } from "../types"; + +export function optionalQueryString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} + +/** + * Parse a `limit` query parameter. + * + * @param value Raw query-string value. + * @param defaultMax Default when the parameter is absent. + * @param max Upper bound (values above are clamped). + */ +export function parseLimit( + value: string | undefined, + defaultMax = 50, + max = 100, +): number | undefined { + if (value === undefined || value.trim().length === 0) { + return defaultMax; + } + const limit = Number(value); + if (!Number.isInteger(limit) || limit <= 0) { + return undefined; + } + return Math.min(limit, max); +} + +export function parseTraceStatus(value: string | undefined): StudioTraceStatus | undefined | false { + const status = optionalQueryString(value); + if (status === undefined) { + return undefined; + } + return status === "running" || status === "success" || status === "error" ? status : false; +} + +/** + * Parse an `after` (cursor / sequence) query parameter. + * + * Returns `undefined` when absent, `false` when invalid, or the + * parsed non-negative integer. + */ +export function parseAfter(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; +} diff --git a/packages/tools/studio/src/runtime/questions.ts b/packages/tool-studio/src/runtime/questions.ts similarity index 92% rename from packages/tools/studio/src/runtime/questions.ts rename to packages/tool-studio/src/runtime/questions.ts index fd901a48..37f0e013 100644 --- a/packages/tools/studio/src/runtime/questions.ts +++ b/packages/tool-studio/src/runtime/questions.ts @@ -10,7 +10,10 @@ import type { StudioToolQuestionPrompt, StudioToolQuestionStatus, } from "../types"; -import { errorResponse, isObject, optionalQueryString } from "./shared"; +import { compact } from "./compact"; +import { errorResponse } from "./http"; +import { optionalQueryString } from "./query"; +import { isObject } from "./type-guards"; type PendingQuestion = StudioToolQuestion & { status: "pending"; @@ -139,12 +142,12 @@ async function parseQuestionAnswerRequest( if ("custom" in answer && typeof answer.custom !== "boolean") { return { error: errorResponse(c, 400, "bad_request", "custom must be a boolean") }; } - answers.push({ + answers.push(compact({ questionId: answer.questionId.trim(), answer: answer.answer.trim(), - ...(typeof answer.choice === "string" ? { choice: answer.choice } : {}), - ...(typeof answer.custom === "boolean" ? { custom: answer.custom } : {}), - }); + choice: typeof answer.choice === "string" ? answer.choice : undefined, + custom: typeof answer.custom === "boolean" ? answer.custom : undefined, + }) as StudioToolQuestionAnswer); } return { answers }; @@ -167,13 +170,13 @@ export function createQuestionRuntime(): QuestionRuntime { return control.skip(prompts.error); } - const answers = await requestQuestion(questions, context, { + const answers = await requestQuestion(questions, context, compact({ toolName, - ...(toolCallId === undefined ? {} : { toolCallId }), + toolCallId, internalCallId, args, questions: prompts.questions, - }); + }) as QuestionRequest); return control.skip(JSON.stringify({ answers })); }, @@ -226,18 +229,20 @@ async function requestQuestion( ): Promise { const id = globalThis.crypto.randomUUID(); const question: PendingQuestion = { - id, - runId: context.runId, - agentId: context.agentId, - ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), - toolName: request.toolName, - ...(request.toolCallId === undefined ? {} : { callId: request.toolCallId }), - internalCallId: request.internalCallId, - args: request.args, - questions: request.questions, - status: "pending", - requestedAt: new Date().toISOString(), - ...(context.emit === undefined ? {} : { emit: context.emit }), + ...compact({ + id, + runId: context.runId, + agentId: context.agentId, + sessionId: context.sessionId, + toolName: request.toolName, + callId: request.toolCallId, + internalCallId: request.internalCallId, + args: request.args, + questions: request.questions, + status: "pending" as const, + requestedAt: new Date().toISOString(), + emit: context.emit, + }), resolve: () => {}, }; diff --git a/packages/tools/studio/src/runtime/runs.ts b/packages/tool-studio/src/runtime/runs.ts similarity index 84% rename from packages/tools/studio/src/runtime/runs.ts rename to packages/tool-studio/src/runtime/runs.ts index a500e4bb..b5cbdc7e 100644 --- a/packages/tools/studio/src/runtime/runs.ts +++ b/packages/tool-studio/src/runtime/runs.ts @@ -7,11 +7,14 @@ import type { AgentRunStreamEvent, StudioSession, StudioSessionStore, + StudioTranscriptAttachment, StudioTranscriptChildAgentEvent, StudioTranscriptEntry, } from "../types"; +import { compact } from "./compact"; +import { errorResponse, parseJsonBody, serializeError } from "./http"; +import { formatJson } from "./json"; import { - errorResponse, isAgentTraceOptions, isJsonObject, isMessage, @@ -19,8 +22,7 @@ import { isNonNegativeInteger, isObject, isPositiveInteger, - serializeError, -} from "./shared"; +} from "./type-guards"; import { streamStudioJsonl } from "./streams"; export { transcriptFromMessages } from "./transcript"; @@ -222,17 +224,15 @@ function acceptTranscriptStreamEvent( if (event.type === "tool_result") { const matched = findTranscriptToolEntry(transcript, event.toolName, event.toolCallId); if (matched === undefined) { - transcript.push({ + transcript.push(compact({ entryId: transcript.length, - kind: "tool", + kind: "tool" as const, toolName: event.toolName, - ...(event.toolCallId === undefined ? {} : { callId: event.toolCallId }), + callId: event.toolCallId, args: event.args, result: event.result, - ...(event.structuredResult === undefined - ? {} - : { structuredResult: event.structuredResult }), - }); + structuredResult: event.structuredResult, + }) as StudioTranscriptEntry); return; } matched.args = matched.args ?? event.args; @@ -244,15 +244,15 @@ function acceptTranscriptStreamEvent( if (event.type === "agent_tool_event") { const matched = findTranscriptToolEntry(transcript, event.toolName, event.toolCallId); if (matched === undefined) { - transcript.push({ + transcript.push(compact({ entryId: transcript.length, - kind: "tool", + kind: "tool" as const, toolName: event.toolName, - ...(event.toolCallId === undefined ? {} : { callId: event.toolCallId }), + callId: event.toolCallId, childEvents: [childAgentTranscriptEvent(event)].filter( (childEvent): childEvent is StudioTranscriptChildAgentEvent => childEvent !== undefined, ), - }); + }) as StudioTranscriptEntry); return; } appendChildAgentTranscriptEvent(matched, event); @@ -278,15 +278,13 @@ function acceptTranscriptStreamEvent( approvalCallId(event.approval), ); if (matched !== undefined) { - matched.approval = { + matched.approval = compact({ id: event.approval.id, status: event.approval.status, requestedAt: event.approval.requestedAt, - ...(event.approval.resolvedAt === undefined - ? {} - : { resolvedAt: event.approval.resolvedAt }), - ...(event.approval.reason === undefined ? {} : { reason: event.approval.reason }), - }; + resolvedAt: event.approval.resolvedAt, + reason: event.approval.reason, + }) as NonNullable; } } if (event.type === "tool_question_request") { @@ -311,16 +309,14 @@ function acceptTranscriptStreamEvent( questionCallId(event.question), ); if (matched !== undefined) { - matched.question = { + matched.question = compact({ id: event.question.id, status: event.question.status, requestedAt: event.question.requestedAt, - ...(event.question.answeredAt === undefined - ? {} - : { answeredAt: event.question.answeredAt }), + answeredAt: event.question.answeredAt, questions: event.question.questions, - ...(event.question.answers === undefined ? {} : { answers: event.question.answers }), - }; + answers: event.question.answers, + }) as NonNullable; } } if (event.type === "final" && event.trace?.traceId !== undefined) { @@ -387,53 +383,51 @@ function childAgentTranscriptEvent( ): StudioTranscriptChildAgentEvent | undefined { const child = event.event; if (child.type === "text_delta") { - return { - kind: "message", + return compact({ + kind: "message" as const, agentId: event.agentId, - ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + agentName: event.agentName, text: child.delta, - }; + }) as StudioTranscriptChildAgentEvent; } if (child.type === "reasoning_delta") { - return { - kind: "reasoning", + return compact({ + kind: "reasoning" as const, agentId: event.agentId, - ...(event.agentName === undefined ? {} : { agentName: event.agentName }), - ...(child.id === undefined ? {} : { reasoningId: child.id }), + agentName: event.agentName, + reasoningId: child.id, text: child.delta, - }; + }) as StudioTranscriptChildAgentEvent; } if (child.type === "tool_call") { - return { - kind: "tool", + return compact({ + kind: "tool" as const, agentId: event.agentId, - ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + agentName: event.agentName, toolName: child.toolCall.function.name, - ...(child.toolCall.callId === undefined && child.toolCall.id === undefined - ? {} - : { callId: child.toolCall.callId ?? child.toolCall.id }), + callId: child.toolCall.callId ?? child.toolCall.id, args: formatJson(child.toolCall.function.arguments), - }; + }) as StudioTranscriptChildAgentEvent; } if (child.type === "tool_result") { - return { - kind: "tool", + return compact({ + kind: "tool" as const, agentId: event.agentId, - ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + agentName: event.agentName, toolName: child.toolName, - ...(child.toolCallId === undefined ? {} : { callId: child.toolCallId }), + callId: child.toolCallId, args: child.args, result: child.result, - ...(child.structuredResult === undefined ? {} : { structuredResult: child.structuredResult }), - }; + structuredResult: child.structuredResult, + }) as StudioTranscriptChildAgentEvent; } if (child.type === "error") { - return { - kind: "message", + return compact({ + kind: "message" as const, agentId: event.agentId, - ...(event.agentName === undefined ? {} : { agentName: event.agentName }), + agentName: event.agentName, text: `Error: ${errorText(child.error)}`, - }; + }) as StudioTranscriptChildAgentEvent; } return undefined; } @@ -476,6 +470,7 @@ function messageToTranscriptEntry( kind: "message", role, text: extractMessageText(message), + ...(role === "user" ? optionalTranscriptAttachments(message) : {}), }; } @@ -532,12 +527,12 @@ function appendTranscriptReasoningText( last.text = `${last.text}${delta}`; return; } - transcript.push({ + transcript.push(compact({ entryId: transcript.length, - kind: "reasoning", - ...(reasoningId === undefined ? {} : { reasoningId }), + kind: "reasoning" as const, + reasoningId, text: delta, - }); + }) as StudioTranscriptEntry); } function findTranscriptToolEntry( @@ -595,12 +590,37 @@ function extractMessageText(message: string | Message): string { .join("\n"); } -function formatJson(value: unknown): string { - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } +function optionalTranscriptAttachments(message: string | Message): { + attachments?: StudioTranscriptAttachment[]; +} { + if (typeof message === "string" || message.role !== "user") { + return {}; + } + const attachments = message.content.flatMap((content): StudioTranscriptAttachment[] => { + if (content.type === "image") { + return [ + { + kind: "image", + ...(content.source.type === "base64" + ? { data: content.source.data, mediaType: content.source.mediaType } + : { url: content.source.url }), + }, + ]; + } + if (content.type === "document") { + return [ + compact({ + kind: "document" as const, + name: content.source.filename, + mediaType: content.source.mediaType, + data: content.source.type === "base64" ? content.source.data : undefined, + url: content.source.type === "url" ? content.source.url : undefined, + }) as StudioTranscriptAttachment, + ]; + } + return []; + }); + return attachments.length === 0 ? {} : { attachments }; } export async function parseRunRequest(c: Context): Promise { @@ -669,6 +689,30 @@ export async function parseRunRequest(c: Context): Promise; - return cleanMetadata({ + return compact({ inputTokens: numericValue(record.inputTokens), outputTokens: numericValue(record.outputTokens), totalTokens: numericValue(record.totalTokens), @@ -525,40 +527,6 @@ function usageSummary(value: unknown): JsonObject | undefined { }); } -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; } @@ -567,13 +535,4 @@ 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/tool-studio/src/runtime/sessions.ts similarity index 83% rename from packages/tools/studio/src/runtime/sessions.ts rename to packages/tool-studio/src/runtime/sessions.ts index 52b0477e..cbc61d08 100644 --- a/packages/tools/studio/src/runtime/sessions.ts +++ b/packages/tool-studio/src/runtime/sessions.ts @@ -1,15 +1,11 @@ import type { JsonObject } from "@anvia/core/completion"; import type { Context, Hono } from "hono"; import type { StudioAgent, StudioSessionStore, StudioTraceStore } from "../types"; +import { compact } from "./compact"; import { appendSessionLog, sessionCreatedLog } from "./session-logs"; -import { - errorResponse, - isJsonObject, - isObject, - optionalQueryString, - parseLimit, - unsupportedCapability, -} from "./shared"; +import { errorResponse, parseJsonBody, unsupportedCapability } from "./http"; +import { isJsonObject, isObject } from "./type-guards"; +import { optionalQueryString, parseAfter, parseLimit } from "./query"; export function registerSessionRoutes( app: Hono, @@ -31,7 +27,7 @@ export function registerSessionRoutes( } const sessions = await props.sessionStore.listSessions({ - ...(agentId === undefined ? {} : { agentId }), + ...compact({ agentId }), limit, }); return c.json({ sessions }); @@ -49,8 +45,7 @@ export function registerSessionRoutes( const session = await props.sessionStore.createSession({ id: globalThis.crypto.randomUUID(), agentId: body.agentId, - ...(body.title === undefined ? {} : { title: body.title }), - ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + ...compact({ title: body.title, metadata: body.metadata }), }); await appendSessionLog(props.sessionStore, sessionCreatedLog(session)); return c.json(session, 201); @@ -80,11 +75,11 @@ export function registerSessionRoutes( ); } - const limit = parseSessionLogLimit(c.req.query("limit")); + const limit = parseLimit(c.req.query("limit"), 200, 1000); if (limit === undefined) { return errorResponse(c, 400, "bad_request", "limit must be a positive integer"); } - const after = parseSessionLogAfter(c.req.query("after")); + const after = parseAfter(c.req.query("after")); if (after === false) { return errorResponse(c, 400, "bad_request", "after must be a non-negative integer"); } @@ -92,7 +87,7 @@ export function registerSessionRoutes( const logs = await props.sessionStore.listSessionLogs({ sessionId, limit, - ...(after === undefined ? {} : { after }), + ...compact({ after }), }); const last = logs.at(-1); return c.json({ @@ -139,28 +134,6 @@ 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/tool-studio/src/runtime/shared.ts b/packages/tool-studio/src/runtime/shared.ts new file mode 100644 index 00000000..7754320d --- /dev/null +++ b/packages/tool-studio/src/runtime/shared.ts @@ -0,0 +1,181 @@ +import { createInMemoryStudioStore } from "../storage/memory-store"; +import type { + StudioAgent, + StudioPipeline, + StudioPipelineLogStore, + StudioPipelineRunStore, + StudioSessionStore, + StudioStores, + StudioTraceStore, + StudioUiOptions, +} from "../types"; +import type { StudioEvalSuite, StudioModelConfig } from "../types"; +import { compact } from "./compact"; + +export type ResolvedStores = { + sessions?: StudioSessionStore; + traces?: StudioTraceStore; + pipelineLogs?: StudioPipelineLogStore; + pipelineRuns?: StudioPipelineRunStore; +}; + +export type StudioRuntimeOptions = { + id?: string; + name?: string; + description?: string; + version?: string; + agents: StudioAgent[]; + pipelines: StudioPipeline[]; + evals: StudioEvalSuite[]; + models?: StudioModelConfig; + stores?: StudioStores; + ui?: boolean | StudioUiOptions; +}; + +export function resolveStores(options: StudioRuntimeOptions): ResolvedStores { + const defaultStore = defaultStudioStore(); + const sessions = resolveSessionStore(options, defaultStore); + const traces = resolveTraceStore(options, sessions, defaultStore); + const pipelineLogs = resolvePipelineLogStore(options, sessions, defaultStore); + const pipelineRuns = resolvePipelineRunStore(options, sessions, pipelineLogs, defaultStore); + return compact({ + sessions, + traces, + pipelineLogs, + pipelineRuns, + }) as ResolvedStores; +} + +function defaultStudioStore(): StudioSessionStore & + StudioTraceStore & + StudioPipelineLogStore & + StudioPipelineRunStore { + return createInMemoryStudioStore(); +} + +function resolveSessionStore( + options: StudioRuntimeOptions, + defaultStore: StudioSessionStore, +): StudioSessionStore | undefined { + if (options.stores?.sessions === false) { + return undefined; + } + if (options.stores?.sessions !== undefined) { + return options.stores.sessions; + } + + return defaultStore; +} + +function resolveTraceStore( + options: StudioRuntimeOptions, + sessionStore: StudioSessionStore | undefined, + defaultStore: StudioTraceStore, +): StudioTraceStore | undefined { + if (options.stores?.traces !== undefined) { + return options.stores.traces; + } + if (sessionStore === undefined) { + return undefined; + } + if (isTraceStore(sessionStore)) { + return sessionStore; + } + return defaultStore; +} + +function resolvePipelineLogStore( + options: StudioRuntimeOptions, + sessionStore: StudioSessionStore | undefined, + defaultStore: StudioPipelineLogStore, +): StudioPipelineLogStore | undefined { + if (options.stores?.pipelineLogs === false) { + return undefined; + } + if (options.stores?.pipelineLogs !== undefined) { + return options.stores.pipelineLogs; + } + if (sessionStore !== undefined && isPipelineLogStore(sessionStore)) { + return sessionStore; + } + return defaultStore; +} + +function resolvePipelineRunStore( + options: StudioRuntimeOptions, + sessionStore: StudioSessionStore | undefined, + pipelineLogStore: StudioPipelineLogStore | undefined, + defaultStore: StudioPipelineRunStore, +): StudioPipelineRunStore | undefined { + if (options.stores?.pipelineRuns === false) { + return undefined; + } + if (options.stores?.pipelineRuns !== undefined) { + return options.stores.pipelineRuns; + } + if (sessionStore !== undefined && isPipelineRunStore(sessionStore)) { + return sessionStore; + } + if (pipelineLogStore !== undefined && isPipelineRunStore(pipelineLogStore)) { + return pipelineLogStore; + } + return defaultStore; +} + +function isTraceStore(store: StudioSessionStore): store is StudioSessionStore & StudioTraceStore { + const candidate = store as Partial; + return ( + typeof candidate.listSessionTraces === "function" && + typeof candidate.getTrace === "function" && + typeof candidate.saveTrace === "function" + ); +} + +function isPipelineLogStore( + store: StudioSessionStore, +): store is StudioSessionStore & StudioPipelineLogStore { + const candidate = store as Partial; + return ( + typeof candidate.appendPipelineLog === "function" && + typeof candidate.listPipelineLogs === "function" + ); +} + +function isPipelineRunStore(store: object): store is object & StudioPipelineRunStore { + const candidate = store as Partial; + return ( + typeof candidate.savePipelineRun === "function" && + typeof candidate.getPipelineRun === "function" && + typeof candidate.listPipelineRuns === "function" + ); +} + +export function normalizeAgents(agents: StudioAgent[]): StudioAgent[] { + const ids = new Set(); + return agents.map((agent) => { + const id = agent.id.trim(); + if (id.length === 0) { + throw new Error("Studio agent id cannot be empty"); + } + if (ids.has(id)) { + throw new Error(`Duplicate runner agent id: ${id}`); + } + ids.add(id); + return { ...agent, id }; + }); +} + +export function normalizePipelines(pipelines: StudioPipeline[]): StudioPipeline[] { + const ids = new Set(); + return pipelines.map((pipeline) => { + const id = pipeline.id.trim(); + if (id.length === 0) { + throw new Error("Studio pipeline id cannot be empty"); + } + if (ids.has(id)) { + throw new Error(`Duplicate Studio pipeline id: ${id}`); + } + ids.add(id); + return { ...pipeline, id }; + }); +} diff --git a/packages/tools/studio/src/runtime/status.ts b/packages/tool-studio/src/runtime/status.ts similarity index 53% rename from packages/tools/studio/src/runtime/status.ts rename to packages/tool-studio/src/runtime/status.ts index 3f94b33a..a12ef175 100644 --- a/packages/tools/studio/src/runtime/status.ts +++ b/packages/tool-studio/src/runtime/status.ts @@ -1,11 +1,12 @@ import type { Hono } from "hono"; import type { StudioAgent, StudioPipeline, StudioStatusSummary } from "../types"; +import { compact } from "./compact"; import { capabilityConfig, type ResolvedStores, runnerId, type StudioRuntimeOptions, -} from "./shared"; +} from "./config"; export function registerStatusRoutes( app: Hono, @@ -20,30 +21,28 @@ export function registerStatusRoutes( const summary: StudioStatusSummary = { runner: { id: runnerId(props.options), - ...(props.options.name === undefined ? {} : { name: props.options.name }), - ...(props.options.version === undefined ? {} : { version: props.options.version }), + ...compact({ name: props.options.name, version: props.options.version }), }, storage: { - ...(props.stores.sessions?.kind === undefined - ? {} - : { sessions: props.stores.sessions.kind }), - ...(props.stores.traces?.kind === undefined ? {} : { traces: props.stores.traces.kind }), - ...(props.stores.pipelineLogs === undefined ? {} : { pipelineLogs: "available" }), - ...(props.stores.pipelineRuns === undefined ? {} : { pipelineRuns: "available" }), + ...compact({ + sessions: props.stores.sessions?.kind, + traces: props.stores.traces?.kind, + pipelineLogs: props.stores.pipelineLogs !== undefined ? "available" as const : undefined, + pipelineRuns: props.stores.pipelineRuns !== undefined ? "available" as const : undefined, + }), }, counts: { agents: props.agents.length, pipelines: props.pipelines.length, - ...(props.stores.sessions === undefined - ? {} - : { sessions: (await props.stores.sessions.listSessions({ limit: 100 })).length }), - ...(props.stores.traces?.listTraces === undefined - ? {} - : { traces: (await props.stores.traces.listTraces({ limit: 100 })).length }), - ...(props.stores.pipelineRuns === undefined || props.pipelines.length === 0 - ? {} - : { - pipelineRuns: ( + ...compact({ + sessions: props.stores.sessions !== undefined + ? (await props.stores.sessions.listSessions({ limit: 100 })).length + : undefined, + traces: props.stores.traces?.listTraces !== undefined + ? (await props.stores.traces.listTraces({ limit: 100 })).length + : undefined, + pipelineRuns: props.stores.pipelineRuns !== undefined && props.pipelines.length > 0 + ? ( await Promise.all( props.pipelines.map((pipeline) => props.stores.pipelineRuns?.listPipelineRuns({ @@ -52,8 +51,9 @@ export function registerStatusRoutes( }), ), ) - ).reduce((sum, runs) => sum + (runs?.length ?? 0), 0), - }), + ).reduce((sum, runs) => sum + (runs?.length ?? 0), 0) + : undefined, + }), }, capabilities: capabilityConfig(props.options, props.agents, props.pipelines, props.stores), generatedAt: new Date().toISOString(), diff --git a/packages/tools/studio/src/runtime/streams.ts b/packages/tool-studio/src/runtime/streams.ts similarity index 97% rename from packages/tools/studio/src/runtime/streams.ts rename to packages/tool-studio/src/runtime/streams.ts index d6e89077..767ebdb4 100644 --- a/packages/tools/studio/src/runtime/streams.ts +++ b/packages/tool-studio/src/runtime/streams.ts @@ -1,5 +1,5 @@ import { createEventStream } from "@anvia/server"; -import { serializeError } from "./shared"; +import { serializeError } from "./http"; type StudioStreamErrorEvent = { type: "error"; diff --git a/packages/tools/studio/src/runtime/studio.ts b/packages/tool-studio/src/runtime/studio.ts similarity index 82% rename from packages/tools/studio/src/runtime/studio.ts rename to packages/tool-studio/src/runtime/studio.ts index 2c913627..a54793be 100644 --- a/packages/tools/studio/src/runtime/studio.ts +++ b/packages/tool-studio/src/runtime/studio.ts @@ -4,6 +4,7 @@ import { type PromptHook, type ToolCallHookAction, } from "@anvia/core/agent"; +import { compact } from "./compact"; import { type Message as CoreMessage, type JsonObject, Message } from "@anvia/core/completion"; import { Agent } from "@anvia/core/internal/agent"; import { Pipeline } from "@anvia/core/pipeline"; @@ -38,6 +39,14 @@ import { registerEvalRoutes } from "./evals"; import { registerKnowledgeRoutes } from "./knowledge"; import { registerMcpRoutes } from "./mcps"; import { registerMemoryRoutes } from "./memory"; +import { + createStudioModelRegistry, + ModelSelectionError, + registerModelRoutes, + resolveStudioModel, + STUDIO_MODEL_METADATA_KEY, + sessionModelRef, +} from "./models"; import { observeStores, registerObservabilityRoutes, @@ -70,16 +79,13 @@ import { agentConfig, agentRuntimeSummary, buildConfig, - errorResponse, - normalizeAgents, - normalizePipelines, - resolveStores, runnerId, + type ResolvedStores, type StudioRuntimeOptions, - serializeError, unsupportedCapabilities, - unsupportedCapability, -} from "./shared"; +} from "./config"; +import { resolveStores, normalizeAgents, normalizePipelines } from "./shared"; +import { errorResponse, serializeError, unsupportedCapability } from "./http"; import { registerStatusRoutes } from "./status"; import { registerToolRoutes } from "./tools"; import { registerTraceRoutes } from "./trace-routes"; @@ -125,7 +131,7 @@ export class Studio implements AnviaStudio { const port = serveOptions.port ?? Number(process.env.RUNNER_PORT ?? 4021); this.server = serve({ fetch: (request) => this.fetch(request), - ...(serveOptions.hostname === undefined ? {} : { hostname: serveOptions.hostname }), + ...compact({ hostname: serveOptions.hostname }), port, }); @@ -173,8 +179,9 @@ function studioOptionsFromTargets( agents: inferStudioAgents(agents, options.quickPrompts ?? {}), pipelines: inferStudioPipelines(pipelines), evals: options.evals ?? [], - ...(options.stores === undefined ? {} : { stores: options.stores }), - ...(options.ui === undefined ? {} : { ui: options.ui }), + ...compact({ models: options.models }), + ...compact({ stores: options.stores }), + ...compact({ ui: options.ui }), }; } @@ -199,9 +206,9 @@ function inferStudioPipelines(pipelines: Array>): StudioPipel return { id, pipeline, - ...(pipeline.name === undefined ? {} : { name: pipeline.name }), - ...(pipeline.description === undefined ? {} : { description: pipeline.description }), - ...(pipeline.metadata === undefined ? {} : { metadata: pipeline.metadata }), + ...compact({ name: pipeline.name }), + ...compact({ description: pipeline.description }), + ...compact({ metadata: pipeline.metadata }), }; }); } @@ -219,7 +226,7 @@ function uniqueAgentId(baseId: string, ids: Set): string { function agentMetadata(agent: Agent): JsonObject { return { - ...(agent.defaultMaxTurns === undefined ? {} : { defaultMaxTurns: agent.defaultMaxTurns }), + ...compact({ defaultMaxTurns: agent.defaultMaxTurns }), staticContextCount: agent.staticContext.length, dynamicContextCount: agent.dynamicContexts.length, dynamicToolCount: agent.dynamicTools.length, @@ -236,6 +243,7 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { const agents = normalizeAgents(options.agents).map((agent) => withStudioTraceObserver(agent, stores.traces), ); + const modelRegistry = createStudioModelRegistry(options.models); const pipelines = normalizePipelines(options.pipelines); const agentMap = new Map(agents.map((agent) => [agent.id, agent])); const pipelineMap = new Map(pipelines.map((pipeline) => [pipeline.id, pipeline])); @@ -287,6 +295,7 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { return c.json(agentRuntimeSummary(agent)); }); + registerModelRoutes(app, { registry: modelRegistry, agentMap }); registerMcpRoutes(app, { agentMap }); registerToolRoutes(app, { agentMap }); registerApprovalRoutes(app, approvalRuntime); @@ -298,13 +307,13 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { }); registerKnowledgeRoutes(app, { agents, - ...(stores.traces === undefined ? {} : { traceStore: stores.traces }), + ...compact({ traceStore: stores.traces }), }); registerPipelineRoutes(app, { pipelines, pipelineMap, - ...(stores.pipelineLogs === undefined ? {} : { logStore: stores.pipelineLogs }), - ...(stores.pipelineRuns === undefined ? {} : { runStore: stores.pipelineRuns }), + ...compact({ logStore: stores.pipelineLogs }), + ...compact({ runStore: stores.pipelineRuns }), }); app.post("/agents/:agentId/runs", async (c) => { @@ -332,6 +341,24 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { return errorResponse(c, 400, "bad_request", "Session belongs to another agent"); } + let selectedModel: ReturnType; + try { + selectedModel = resolveStudioModel(modelRegistry, { + agent, + request: body, + sessionMetadata: session?.metadata, + }); + } catch (error) { + if (error instanceof ModelSelectionError) { + return errorResponse(c, 400, "bad_request", error.message); + } + throw error; + } + const runAgent = + selectedModel.model === undefined + ? agent.agent + : cloneAgent(agent.agent, { model: selectedModel.model }); + const runId = globalThis.crypto.randomUUID(); const runStartedAt = Date.now(); if (session !== undefined) { @@ -343,16 +370,47 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { agentId, message: body.message, stream: body.stream === true, - ...(body.maxTurns === undefined ? {} : { maxTurns: body.maxTurns }), - ...(body.toolConcurrency === undefined ? {} : { toolConcurrency: body.toolConcurrency }), + ...compact({ maxTurns: body.maxTurns }), + ...compact({ toolConcurrency: body.toolConcurrency }), hasTrace: body.trace !== undefined, - ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + ...(body.metadata === undefined && selectedModel.ref === undefined + ? {} + : { + metadata: { + ...(body.metadata ?? {}), + ...(selectedModel.ref === undefined + ? {} + : { [STUDIO_MODEL_METADATA_KEY]: selectedModel.ref }), + }, + }), }), ); } + if (session !== undefined && selectedModel.ref !== undefined) { + for (const warning of selectedModel.warnings) { + await appendSessionLog(stores.sessions, { + sessionId: session.id, + runId, + level: "warn", + category: "model", + event: "model.warning", + message: typeof warning.message === "string" ? warning.message : "Model warning", + metadata: warning, + }); + } + if (sessionModelRef(session.metadata) !== selectedModel.ref) { + await stores.sessions?.updateSessionMetadata?.(session.id, { + ...(session.metadata ?? {}), + [STUDIO_MODEL_METADATA_KEY]: selectedModel.ref, + }); + } + } const memoryMetadata = { agentId, ...(body.metadata ?? {}), + ...(selectedModel.ref === undefined + ? {} + : { [STUDIO_MODEL_METADATA_KEY]: selectedModel.ref }), studioRunId: runId, }; const promptMessage = normalizePromptMessage(body.message); @@ -360,7 +418,7 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { const shouldPersistSessionMessages = session !== undefined && sessionStore !== undefined && - !usesStoreAsAgentMemory(agent.agent, sessionStore); + !usesStoreAsAgentMemory(runAgent, sessionStore); if (shouldPersistSessionMessages) { await sessionStore.append({ context: { sessionId: session.id, metadata: memoryMetadata }, @@ -371,10 +429,10 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { } const request = session !== undefined - ? agent.agent.memory === undefined - ? agent.agent.prompt([...session.messages, promptMessage]) - : agent.agent.session(session.id, { metadata: memoryMetadata }).prompt(body.message) - : agent.agent.prompt( + ? runAgent.memory === undefined + ? runAgent.prompt([...session.messages, promptMessage]) + : runAgent.session(session.id, { metadata: memoryMetadata }).prompt(body.message) + : runAgent.prompt( body.history !== undefined ? [...body.history, promptMessage] : body.message, ); if (body.maxTurns !== undefined) { @@ -393,21 +451,21 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { const runtimeEvents = new AsyncEventQueue(); const effectiveHook = composeHooks( composeHooks( - agent.agent.hook, + runAgent.hook, approvalRuntime.createHook({ runId, agentId, - ...(session?.id === undefined ? {} : { sessionId: session.id }), - ...(body.metadata === undefined ? {} : { metadata: body.metadata }), - getTool: (toolName) => agent.agent.getTool(toolName), + ...compact({ sessionId: session?.id }), + ...compact({ metadata: body.metadata }), + getTool: (toolName) => runAgent.getTool(toolName), emit: (event) => runtimeEvents.push(event), }), ), questionRuntime.createHook({ runId, agentId, - ...(session?.id === undefined ? {} : { sessionId: session.id }), - ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + ...compact({ sessionId: session?.id }), + ...compact({ metadata: body.metadata }), emit: (event) => runtimeEvents.push(event), }), ); @@ -442,20 +500,20 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { } const effectiveHook = composeHooks( composeHooks( - agent.agent.hook, + runAgent.hook, approvalRuntime.createHook({ runId, agentId, - ...(session?.id === undefined ? {} : { sessionId: session.id }), - ...(body.metadata === undefined ? {} : { metadata: body.metadata }), - getTool: (toolName) => agent.agent.getTool(toolName), + ...compact({ sessionId: session?.id }), + ...compact({ metadata: body.metadata }), + getTool: (toolName) => runAgent.getTool(toolName), }), ), questionRuntime.createHook({ runId, agentId, - ...(session?.id === undefined ? {} : { sessionId: session.id }), - ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + ...compact({ sessionId: session?.id }), + ...compact({ metadata: body.metadata }), }), ); if (effectiveHook !== undefined) { @@ -529,7 +587,7 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { registerSessionRoutes(app, { agentMap, sessionStore: stores.sessions, - ...(stores.traces === undefined ? {} : { traceStore: stores.traces }), + ...compact({ traceStore: stores.traces }), }); } @@ -551,8 +609,8 @@ function createStudioApp(options: StudioRuntimeOptions): StudioApp { return buildConfig(options, agents, pipelines, stores); }, close() {}, - ...(stores.sessions === undefined ? {} : { sessionStore: stores.sessions }), - ...(stores.traces === undefined ? {} : { traceStore: stores.traces }), + ...compact({ sessionStore: stores.sessions }), + ...compact({ traceStore: stores.traces }), }; } diff --git a/packages/tools/studio/src/runtime/tool-metadata.ts b/packages/tool-studio/src/runtime/tool-metadata.ts similarity index 100% rename from packages/tools/studio/src/runtime/tool-metadata.ts rename to packages/tool-studio/src/runtime/tool-metadata.ts diff --git a/packages/tools/studio/src/runtime/tools.ts b/packages/tool-studio/src/runtime/tools.ts similarity index 97% rename from packages/tools/studio/src/runtime/tools.ts rename to packages/tool-studio/src/runtime/tools.ts index 91df7b47..c7b01287 100644 --- a/packages/tools/studio/src/runtime/tools.ts +++ b/packages/tool-studio/src/runtime/tools.ts @@ -6,7 +6,8 @@ import type { StudioToolRunResponse, } from "../types"; import { serializeUnknown, toJsonValue } from "./json"; -import { errorResponse, isJsonObject, isJsonValue } from "./shared"; +import { errorResponse } from "./http"; +import { isJsonObject, isJsonValue } from "./type-guards"; import { agentToolItems, approvalMetadata } from "./tool-metadata"; export function registerToolRoutes( diff --git a/packages/tools/studio/src/runtime/trace-routes.ts b/packages/tool-studio/src/runtime/trace-routes.ts similarity index 84% rename from packages/tools/studio/src/runtime/trace-routes.ts rename to packages/tool-studio/src/runtime/trace-routes.ts index ed655b30..9e29d4fe 100644 --- a/packages/tools/studio/src/runtime/trace-routes.ts +++ b/packages/tool-studio/src/runtime/trace-routes.ts @@ -1,6 +1,8 @@ import type { Hono } from "hono"; import type { StudioTraceStore } from "../types"; -import { errorResponse, optionalQueryString, parseLimit, parseTraceStatus } from "./shared"; +import { compact } from "./compact"; +import { errorResponse } from "./http"; +import { optionalQueryString, parseLimit, parseTraceStatus } from "./query"; export function registerTraceRoutes(app: Hono, traceStore: StudioTraceStore): void { app.get("/traces", async (c) => { @@ -28,9 +30,7 @@ export function registerTraceRoutes(app: Hono, traceStore: StudioTraceStore): vo const sessionId = optionalQueryString(c.req.query("sessionId")); const traces = await traceStore.listTraces({ limit, - ...(agentId === undefined ? {} : { agentId }), - ...(sessionId === undefined ? {} : { sessionId }), - ...(status === undefined ? {} : { status }), + ...compact({ agentId, sessionId, status }), }); return c.json({ traces }); }); diff --git a/packages/tools/studio/src/runtime/transcript.ts b/packages/tool-studio/src/runtime/transcript.ts similarity index 55% rename from packages/tools/studio/src/runtime/transcript.ts rename to packages/tool-studio/src/runtime/transcript.ts index c233a442..1aec4c4c 100644 --- a/packages/tools/studio/src/runtime/transcript.ts +++ b/packages/tool-studio/src/runtime/transcript.ts @@ -1,5 +1,7 @@ import type { Message } from "@anvia/core/completion"; -import type { StudioTranscriptEntry } from "../types"; +import type { StudioTranscriptAttachment, StudioTranscriptEntry } from "../types"; +import { compact } from "./compact"; +import { formatJson } from "./json"; export function renumberTranscript(entries: StudioTranscriptEntry[]): StudioTranscriptEntry[] { return entries.map((entry, entryId) => ({ ...entry, entryId })); @@ -12,16 +14,29 @@ export function transcriptFromMessages(messages: Message[]): StudioTranscriptEnt continue; } if (message.role === "user") { + const attachments = attachmentsFromMessage(message); + let textEntryAdded = false; for (const content of message.content) { if (content.type === "text") { - transcript.push({ + transcript.push(compact({ entryId: transcript.length, - kind: "message", - role: "user", + kind: "message" as const, + role: "user" as const, text: content.text, - }); + attachments: attachments.length === 0 ? undefined : attachments, + }) as StudioTranscriptEntry); + textEntryAdded = true; } } + if (!textEntryAdded && attachments.length > 0) { + transcript.push({ + entryId: transcript.length, + kind: "message", + role: "user", + text: "", + attachments, + }); + } continue; } if (message.role === "tool") { @@ -46,12 +61,12 @@ export function transcriptFromMessages(messages: Message[]): StudioTranscriptEnt if (content.type === "text") { appendAssistantTranscriptText(transcript, content.text); } else if (content.type === "reasoning") { - transcript.push({ + transcript.push(compact({ entryId: transcript.length, - kind: "reasoning", - ...(content.id === undefined ? {} : { reasoningId: content.id }), + kind: "reasoning" as const, + reasoningId: content.id, text: content.text, - }); + }) as StudioTranscriptEntry); } else if (content.type === "tool_call") { transcript.push({ entryId: transcript.length, @@ -66,6 +81,36 @@ export function transcriptFromMessages(messages: Message[]): StudioTranscriptEnt return transcript; } +function attachmentsFromMessage(message: Message): StudioTranscriptAttachment[] { + if (message.role !== "user" && message.role !== "assistant") { + return []; + } + return message.content.flatMap((content): StudioTranscriptAttachment[] => { + if (content.type === "image") { + return [ + { + kind: "image", + ...(content.source.type === "base64" + ? { data: content.source.data, mediaType: content.source.mediaType } + : { url: content.source.url }), + }, + ]; + } + if (content.type === "document") { + return [ + compact({ + kind: "document" as const, + name: content.source.filename, + mediaType: content.source.mediaType, + data: content.source.type === "base64" ? content.source.data : undefined, + url: content.source.type === "url" ? content.source.url : undefined, + }) as StudioTranscriptAttachment, + ]; + } + return []; + }); +} + function appendAssistantTranscriptText(transcript: StudioTranscriptEntry[], text: string): void { const last = transcript.at(-1); if (last?.kind === "message" && last.role === "assistant") { @@ -79,11 +124,3 @@ function appendAssistantTranscriptText(transcript: StudioTranscriptEntry[], text text, }); } - -function formatJson(value: unknown): string { - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} diff --git a/packages/tool-studio/src/runtime/type-guards.ts b/packages/tool-studio/src/runtime/type-guards.ts new file mode 100644 index 00000000..93eb6d67 --- /dev/null +++ b/packages/tool-studio/src/runtime/type-guards.ts @@ -0,0 +1,84 @@ +import type { JsonObject, JsonValue, Message } from "@anvia/core/completion"; +import type { AgentTraceOptions } from "@anvia/core/observability"; + +export function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isJsonObject(value: unknown): value is JsonObject { + return isObject(value) && Object.values(value).every(isJsonValue); +} + +export function isJsonValue(value: unknown): value is JsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return true; + } + if (Array.isArray(value)) { + return value.every(isJsonValue); + } + return isJsonObject(value); +} + +export function isMessageInput(value: unknown): value is string | Message { + return typeof value === "string" || isMessage(value); +} + +export function isMessage(value: unknown): value is Message { + if (!isObject(value) || typeof value.role !== "string") { + return false; + } + if (value.role === "system") { + return typeof value.content === "string"; + } + if (value.role === "user" || value.role === "assistant" || value.role === "tool") { + return Array.isArray(value.content); + } + return false; +} + +export function isAgentTraceOptions(value: unknown): value is AgentTraceOptions { + if (!isObject(value)) { + return false; + } + return ( + optionalString(value.name) && + optionalString(value.userId) && + optionalString(value.sessionId) && + optionalString(value.version) && + optionalString(value.traceId) && + optionalBoolean(value.failOnObserverError) && + optionalStringArray(value.tags) && + optionalObject(value.metadata) + ); +} + +export function isNonNegativeInteger(value: unknown): value is number { + return Number.isInteger(value) && typeof value === "number" && value >= 0; +} + +export function isPositiveInteger(value: unknown): value is number { + return Number.isInteger(value) && typeof value === "number" && value > 0; +} + +function optionalString(value: unknown): boolean { + return value === undefined || typeof value === "string"; +} + +function optionalBoolean(value: unknown): boolean { + return value === undefined || typeof value === "boolean"; +} + +function optionalStringArray(value: unknown): boolean { + return ( + value === undefined || (Array.isArray(value) && value.every((item) => typeof item === "string")) + ); +} + +function optionalObject(value: unknown): boolean { + return value === undefined || isObject(value); +} diff --git a/packages/tools/studio/src/sqlite.ts b/packages/tool-studio/src/sqlite.ts similarity index 100% rename from packages/tools/studio/src/sqlite.ts rename to packages/tool-studio/src/sqlite.ts diff --git a/packages/tools/studio/src/storage/memory-store.ts b/packages/tool-studio/src/storage/memory-store.ts similarity index 84% rename from packages/tools/studio/src/storage/memory-store.ts rename to packages/tool-studio/src/storage/memory-store.ts index 2987f448..8a55b19b 100644 --- a/packages/tools/studio/src/storage/memory-store.ts +++ b/packages/tool-studio/src/storage/memory-store.ts @@ -1,5 +1,6 @@ import type { JsonObject, JsonValue, Message } from "@anvia/core/completion"; import type { MemoryAppendInput, MemoryContext, MemoryErrorInput } from "@anvia/core/memory"; +import { compact } from "../runtime/compact"; import { renumberTranscript, transcriptFromMessages } from "../runtime/transcript"; import type { StudioPipelineLogAppendInput, @@ -59,13 +60,15 @@ class InMemoryStudioStore createSession(input: StudioSessionCreateInput): StudioSessionSummary { const now = new Date().toISOString(); const session: MemorySessionRecord = { - id: input.id, - agentId: input.agentId, - ...(input.title === undefined ? {} : { title: input.title }), - createdAt: now, - updatedAt: now, - messageCount: 0, - ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + ...compact({ + id: input.id, + agentId: input.agentId, + title: input.title, + createdAt: now, + updatedAt: now, + messageCount: 0, + metadata: input.metadata, + }), messages: [], runs: [], logs: [], @@ -79,6 +82,20 @@ class InMemoryStudioStore return session === undefined ? undefined : materializeSession(session); } + updateSessionMetadata(id: string, metadata: JsonObject | undefined): StudioSession | undefined { + const session = this.sessions.get(id); + if (session === undefined) { + return undefined; + } + if (metadata === undefined) { + delete session.metadata; + } else { + session.metadata = metadata; + } + session.updatedAt = new Date().toISOString(); + return materializeSession(session); + } + load(context: MemoryContext): Promise { return Promise.resolve(this.sessions.get(context.sessionId)?.messages ?? []); } @@ -139,18 +156,18 @@ class InMemoryStudioStore appendSessionLog(input: StudioSessionLogAppendInput): StudioSessionLogEntry { const session = this.sessions.get(input.sessionId); const logs = session?.logs ?? []; - const entry: StudioSessionLogEntry = { + const entry: StudioSessionLogEntry = compact({ id: globalThis.crypto.randomUUID(), sessionId: input.sessionId, - ...(input.runId === undefined ? {} : { runId: input.runId }), + runId: input.runId, sequence: logs.length, timestamp: new Date().toISOString(), level: input.level, category: input.category, event: input.event, message: input.message, - ...(input.metadata === undefined ? {} : { metadata: input.metadata }), - }; + metadata: input.metadata, + }) as StudioSessionLogEntry; if (session !== undefined) { session.logs.push(entry); session.updatedAt = entry.timestamp; @@ -198,18 +215,18 @@ class InMemoryStudioStore appendPipelineLog(input: StudioPipelineLogAppendInput): StudioPipelineLogEntry { const logs = this.pipelineLogs.get(input.pipelineId) ?? []; - const entry: StudioPipelineLogEntry = { + const entry: StudioPipelineLogEntry = compact({ id: globalThis.crypto.randomUUID(), pipelineId: input.pipelineId, - ...(input.runId === undefined ? {} : { runId: input.runId }), + runId: input.runId, sequence: logs.length, timestamp: new Date().toISOString(), level: input.level, category: input.category, event: input.event, message: input.message, - ...(input.metadata === undefined ? {} : { metadata: input.metadata }), - }; + metadata: input.metadata, + }) as StudioPipelineLogEntry; this.pipelineLogs.set(input.pipelineId, [...logs, entry]); return entry; } @@ -221,18 +238,18 @@ class InMemoryStudioStore } savePipelineRun(input: StudioPipelineRunSaveInput): StudioPipelineRunRecord { - const record: StudioPipelineRunRecord = { + const record: StudioPipelineRunRecord = compact({ runId: input.runId, pipelineId: input.pipelineId, status: input.status, input: input.input, - ...(input.output === undefined ? {} : { output: input.output }), - ...(input.error === undefined ? {} : { error: input.error }), - ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + output: input.output, + error: input.error, + metadata: input.metadata, startedAt: input.startedAt, - ...(input.endedAt === undefined ? {} : { endedAt: input.endedAt }), - ...(input.durationMs === undefined ? {} : { durationMs: input.durationMs }), - }; + endedAt: input.endedAt, + durationMs: input.durationMs, + }) as StudioPipelineRunRecord; this.pipelineRuns.set(input.runId, record); return record; } @@ -254,15 +271,15 @@ class InMemoryStudioStore } function sessionSummary(session: MemorySessionRecord): StudioSessionSummary { - return { + return compact({ id: session.id, agentId: session.agentId, - ...(session.title === undefined ? {} : { title: session.title }), + title: session.title, createdAt: session.createdAt, updatedAt: session.updatedAt, messageCount: session.messages.length, - ...(session.metadata === undefined ? {} : { metadata: session.metadata }), - }; + metadata: session.metadata, + }) as StudioSessionSummary; } function materializeSession(session: MemorySessionRecord): StudioSession { @@ -274,20 +291,20 @@ function materializeSession(session: MemorySessionRecord): StudioSession { } function traceSummary(trace: StudioTrace): StudioTraceSummary { - return { + return compact({ id: trace.id, sessionId: trace.sessionId, - ...(trace.name === undefined ? {} : { name: trace.name }), + name: trace.name, status: trace.status, startedAt: trace.startedAt, - ...(trace.endedAt === undefined ? {} : { endedAt: trace.endedAt }), - ...(trace.durationMs === undefined ? {} : { durationMs: trace.durationMs }), - ...(trace.output === undefined ? {} : { output: trace.output }), - ...(trace.error === undefined ? {} : { error: trace.error }), - ...(trace.usage === undefined ? {} : { usage: trace.usage }), - ...(trace.metadata === undefined ? {} : { metadata: trace.metadata }), + endedAt: trace.endedAt, + durationMs: trace.durationMs, + output: trace.output, + error: trace.error, + usage: trace.usage, + metadata: trace.metadata, observationCount: trace.observations.length, - }; + }) as StudioTraceSummary; } function traceAgentId(trace: StudioTrace): string | undefined { diff --git a/packages/tools/studio/src/storage/sqlite-store.ts b/packages/tool-studio/src/storage/sqlite-store.ts similarity index 95% rename from packages/tools/studio/src/storage/sqlite-store.ts rename to packages/tool-studio/src/storage/sqlite-store.ts index 652ad857..ce75f3c8 100644 --- a/packages/tools/studio/src/storage/sqlite-store.ts +++ b/packages/tool-studio/src/storage/sqlite-store.ts @@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path"; import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; import type { JsonObject, JsonValue, Message } from "@anvia/core/completion"; import type { MemoryAppendInput, MemoryContext, MemoryErrorInput } from "@anvia/core/memory"; +import { compact } from "../runtime/compact"; import { renumberTranscript, transcriptFromMessages } from "../runtime/transcript"; import type { StudioPipelineLogAppendInput, @@ -204,11 +205,11 @@ class SqliteSessionStore return { id: input.id, agentId: input.agentId, - ...(input.title === undefined ? {} : { title: input.title }), + ...compact({ title: input.title }), createdAt: now, updatedAt: now, messageCount: 0, - ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + ...compact({ metadata: input.metadata }), }; } @@ -220,6 +221,27 @@ class SqliteSessionStore : toSession(row, this.listSessionMessages(id), this.listSessionRunRows(id)); } + updateSessionMetadata(id: string, metadata: JsonObject | undefined): StudioSession | undefined { + const db = this.database(); + const now = new Date().toISOString(); + const result = db + .prepare( + `UPDATE anvia_studio_sessions + SET metadata_json = $metadata, + updated_at = $now + WHERE id = $id`, + ) + .run({ + $id: id, + $metadata: metadata === undefined ? null : JSON.stringify(metadata), + $now: now, + }); + if (result.changes === 0) { + return undefined; + } + return this.getSession(id); + } + load(context: MemoryContext): Promise { const session = this.getSession(context.sessionId); return Promise.resolve(session?.messages ?? []); @@ -402,14 +424,14 @@ class SqliteSessionStore const entry: StudioSessionLogEntry = { id: globalThis.crypto.randomUUID(), sessionId: input.sessionId, - ...(input.runId === undefined ? {} : { runId: input.runId }), + ...compact({ runId: input.runId }), sequence, timestamp: now, level: input.level, category: input.category, event: input.event, message: input.message, - ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + ...compact({ metadata: input.metadata }), }; db.prepare( @@ -491,14 +513,14 @@ class SqliteSessionStore const entry: StudioPipelineLogEntry = { id: globalThis.crypto.randomUUID(), pipelineId: input.pipelineId, - ...(input.runId === undefined ? {} : { runId: input.runId }), + ...compact({ runId: input.runId }), sequence, timestamp: now, level: input.level, category: input.category, event: input.event, message: input.message, - ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + ...compact({ metadata: input.metadata }), }; db.prepare( @@ -624,12 +646,12 @@ class SqliteSessionStore pipelineId: input.pipelineId, status: input.status, input: input.input, - ...(input.output === undefined ? {} : { output: input.output }), - ...(input.error === undefined ? {} : { error: input.error }), - ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + ...compact({ output: input.output }), + ...compact({ error: input.error }), + ...compact({ metadata: input.metadata }), startedAt: input.startedAt, - ...(input.endedAt === undefined ? {} : { endedAt: input.endedAt }), - ...(input.durationMs === undefined ? {} : { durationMs: input.durationMs }), + ...compact({ endedAt: input.endedAt }), + ...compact({ durationMs: input.durationMs }), }; } @@ -1137,11 +1159,11 @@ function toSessionSummary(row: SessionSummaryRow): StudioSessionSummary { return { id: row.id, agentId: row.agent_id, - ...(row.title === null ? {} : { title: row.title }), + ...compact({ title: row.title ?? undefined }), createdAt: row.created_at, updatedAt: row.updated_at, messageCount: row.message_count, - ...(metadata === undefined ? {} : { metadata }), + ...compact({ metadata }), }; } @@ -1150,14 +1172,14 @@ function toSessionLog(row: SessionLogRow): StudioSessionLogEntry { return { id: row.id, sessionId: row.session_id, - ...(row.run_id === null ? {} : { runId: row.run_id }), + ...compact({ runId: row.run_id ?? undefined }), sequence: row.sequence, timestamp: row.timestamp, level: row.level, category: row.category, event: row.event, message: row.message, - ...(metadata === undefined ? {} : { metadata }), + ...compact({ metadata }), }; } @@ -1166,14 +1188,14 @@ function toPipelineLog(row: PipelineLogRow): StudioPipelineLogEntry { return { id: row.id, pipelineId: row.pipeline_id, - ...(row.run_id === null ? {} : { runId: row.run_id }), + ...compact({ runId: row.run_id ?? undefined }), sequence: row.sequence, timestamp: row.timestamp, level: row.level, category: row.category, event: row.event, message: row.message, - ...(metadata === undefined ? {} : { metadata }), + ...compact({ metadata }), }; } @@ -1186,12 +1208,12 @@ function toPipelineRun(row: PipelineRunRow): StudioPipelineRunRecord { pipelineId: row.pipeline_id, status: row.status, input: JSON.parse(row.input_json) as JsonValue, - ...(output === undefined ? {} : { output }), - ...(error === undefined ? {} : { error }), - ...(metadata === undefined ? {} : { metadata }), + ...compact({ output }), + ...compact({ error }), + ...compact({ metadata }), startedAt: row.started_at, - ...(row.ended_at === null ? {} : { endedAt: row.ended_at }), - ...(row.duration_ms === null ? {} : { durationMs: row.duration_ms }), + ...compact({ endedAt: row.ended_at ?? undefined }), + ...compact({ durationMs: row.duration_ms ?? undefined }), }; } @@ -1221,7 +1243,7 @@ function messageFromRows(row: MessageRow, partRows: MessagePartRow[]): Message { if (row.role === "assistant") { return { role: "assistant", - ...(row.message_id === null ? {} : { id: row.message_id }), + ...compact({ id: row.message_id ?? undefined }), content: parts as Extract["content"], }; } @@ -1282,8 +1304,8 @@ function toTrace(row: TraceRow): StudioTrace { const input = parseJsonValue(row.input_json); return { ...toTraceSummary(row), - ...(trace === undefined ? {} : { trace }), - ...(input === undefined ? {} : { input }), + ...compact({ trace }), + ...compact({ input }), observations: parseJsonArray(row.observations_json), }; } @@ -1296,15 +1318,15 @@ function toTraceSummary(row: TraceRow): StudioTraceSummary { return { id: row.id, sessionId: row.session_id, - ...(row.name === null ? {} : { name: row.name }), + ...compact({ name: row.name ?? undefined }), status: row.status, startedAt: row.started_at, - ...(row.ended_at === null ? {} : { endedAt: row.ended_at }), - ...(row.duration_ms === null ? {} : { durationMs: row.duration_ms }), - ...(row.output === null ? {} : { output: row.output }), - ...(error === undefined ? {} : { error }), - ...(usage === undefined ? {} : { usage }), - ...(metadata === undefined ? {} : { metadata }), + ...compact({ endedAt: row.ended_at ?? undefined }), + ...compact({ durationMs: row.duration_ms ?? undefined }), + ...compact({ output: row.output ?? undefined }), + ...compact({ error }), + ...compact({ usage }), + ...compact({ metadata }), observationCount: observations.length, }; } diff --git a/packages/tools/studio/src/trace.ts b/packages/tool-studio/src/trace.ts similarity index 100% rename from packages/tools/studio/src/trace.ts rename to packages/tool-studio/src/trace.ts diff --git a/packages/tools/studio/src/traces/trace-observer.ts b/packages/tool-studio/src/traces/trace-observer.ts similarity index 95% rename from packages/tools/studio/src/traces/trace-observer.ts rename to packages/tool-studio/src/traces/trace-observer.ts index b2d8e566..b8460157 100644 --- a/packages/tools/studio/src/traces/trace-observer.ts +++ b/packages/tool-studio/src/traces/trace-observer.ts @@ -15,6 +15,7 @@ import type { AgentToolStartArgs, AgentToolStreamEventArgs, } from "@anvia/core/observability"; +import { compact } from "../runtime/compact"; import { compactJsonObject, serializeUnknown as serializeError, @@ -176,7 +177,7 @@ class StudioRunTraceObserver implements AgentRunObserver { const trace: StudioTrace = { id: this.props.id, sessionId, - ...(this.props.args.trace?.name === undefined ? {} : { name: this.props.args.trace.name }), + ...compact({ name: this.props.args.trace?.name }), status, trace: this.trace, startedAt: this.startedAt.toISOString(), @@ -187,9 +188,9 @@ class StudioRunTraceObserver implements AgentRunObserver { prompt: this.props.args.prompt, history: this.props.args.history, }), - ...(result.output === undefined ? {} : { output: result.output }), - ...(result.error === undefined ? {} : { error: result.error }), - ...(result.usage === undefined ? {} : { usage: result.usage }), + ...compact({ output: result.output }), + ...compact({ error: result.error }), + ...compact({ usage: result.usage }), metadata, observations: this.observations, observationCount: this.observations.length, @@ -248,7 +249,7 @@ class ChildAgentToolTraceAccumulator { this.agentStarts.set(agentId, { startedAt: new Date(), agentId, - ...(agentName === undefined ? {} : { agentName }), + ...compact({ agentName }), }); } @@ -260,7 +261,7 @@ class ChildAgentToolTraceAccumulator { history: child.history, }), agentId, - ...(agentName === undefined ? {} : { agentName }), + ...compact({ agentName }), childTurn, }); return; @@ -277,7 +278,7 @@ class ChildAgentToolTraceAccumulator { status: "success", turn: this.parent.turn, startedAt: start?.startedAt ?? new Date(), - ...(start?.input === undefined ? {} : { input: start.input }), + ...compact({ input: start?.input }), output: toJsonValue(child.response), metadata: this.childMetadata(agentId, agentName, childTurn), }), @@ -298,10 +299,10 @@ class ChildAgentToolTraceAccumulator { this.toolStarts.push({ startedAt: new Date(), agentId, - ...(agentName === undefined ? {} : { agentName }), + ...compact({ agentName }), childTurn, toolName, - ...(callId === undefined ? {} : { toolCallId: callId }), + ...compact({ toolCallId: callId }), input: toJsonValue(toolCallFunction?.arguments ?? {}), completed: false, }); @@ -326,12 +327,12 @@ class ChildAgentToolTraceAccumulator { status: "success", turn: this.parent.turn, startedAt: start?.startedAt ?? new Date(), - ...(input === undefined ? {} : { input }), + ...compact({ input }), ...(typeof child.result === "string" ? { output: parseOrString(child.result) } : {}), metadata: { ...this.childMetadata(agentId, agentName, childTurn), - ...(toolCallId === undefined ? {} : { toolCallId }), - ...(internalCallId === undefined ? {} : { internalCallId }), + ...compact({ toolCallId }), + ...compact({ internalCallId }), }, }), ); @@ -477,10 +478,10 @@ function traceObservation(props: { startedAt: props.startedAt.toISOString(), endedAt: endedAt.toISOString(), durationMs: durationMs(props.startedAt, endedAt), - ...(props.input === undefined ? {} : { input: props.input }), - ...(props.output === undefined ? {} : { output: props.output }), - ...(props.error === undefined ? {} : { error: props.error }), - ...(props.metadata === undefined ? {} : { metadata: props.metadata }), + ...compact({ input: props.input }), + ...compact({ output: props.output }), + ...compact({ error: props.error }), + ...compact({ metadata: props.metadata }), }; } diff --git a/packages/tools/studio/src/types.ts b/packages/tool-studio/src/types.ts similarity index 87% rename from packages/tools/studio/src/types.ts rename to packages/tool-studio/src/types.ts index db5abbb6..aa7ff015 100644 --- a/packages/tools/studio/src/types.ts +++ b/packages/tool-studio/src/types.ts @@ -1,14 +1,19 @@ import type { AgentStreamEvent, PromptResponse } from "@anvia/core/agent"; +import { compact } from "./runtime/compact"; import type { + CompletionModel, + CompletionModelCapabilities, JsonObject, JsonValue, Message, + StreamingCompletionModel, ToolResultContent, Usage, } from "@anvia/core/completion"; import type { RunEvalSuiteOptions } from "@anvia/core/evals"; import type { Agent } from "@anvia/core/internal/agent"; import type { MemoryStore } from "@anvia/core/memory"; +import type { ModelList } from "@anvia/core/model-listing"; import type { AgentTraceInfo, AgentTraceOptions } from "@anvia/core/observability"; import type { Pipeline, PipelineGraph } from "@anvia/core/pipeline"; import type { Hono } from "hono"; @@ -27,6 +32,78 @@ export type StudioCapability = | "tools" | "traces"; +export type StudioModelRef = string | { provider: string; model: string }; + +export type StudioModelModality = "text" | "image" | "document" | "audio" | "video"; + +export type StudioModelModalities = { + input: StudioModelModality[]; + output?: StudioModelModality[]; +}; + +export type StudioModelDefinition = { + id: string; + name?: string; + description?: string; + modalities?: StudioModelModalities; + capabilities?: Partial; + metadata?: JsonObject; +}; + +export type StudioModelProvider = { + id: string; + name?: string; + defaultModel?: string; + models?: StudioModelDefinition[]; + createCompletionModel(model: string): CompletionModel | StreamingCompletionModel; + listModels?: () => Promise; + metadata?: JsonObject; +}; + +export type StudioAgentModelPolicy = { + default?: StudioModelRef; + allowed?: Array; +}; + +export type StudioModelConfig = { + providers: StudioModelProvider[]; + default?: StudioModelRef; + agents?: Record; +}; + +export type StudioModelSummary = StudioModelDefinition & { + ref: string; + providerId: string; + providerName?: string; +}; + +export type StudioModelProviderConfig = { + id: string; + name?: string; + defaultModel?: string; + models: StudioModelSummary[]; + metadata?: JsonObject; + warning?: string; +}; + +export type StudioAgentModelPolicyConfig = { + default?: string; + allowed?: string[]; +}; + +export type StudioModelsConfig = { + providers: StudioModelProviderConfig[]; + default?: string; + agents: Record; +}; + +export type StudioAgentModelsSummary = { + agentId: string; + defaultModel?: string; + models: StudioModelSummary[]; + warnings?: JsonObject[]; +}; + export type StudioAgent = { id: string; agent: Agent; @@ -137,6 +214,7 @@ export type StudioConfig = { description?: string; version?: string; agents: StudioAgentConfig[]; + models?: StudioModelsConfig; pipelines: StudioPipelineConfig[]; evals: StudioEvalSuiteConfig[]; chat: { @@ -211,6 +289,15 @@ export type StudioTranscriptChatEntry = { text: string; tone?: "error"; traceId?: string; + attachments?: StudioTranscriptAttachment[]; +}; + +export type StudioTranscriptAttachment = { + kind: "image" | "document"; + name?: string; + mediaType?: string; + data?: string; + url?: string; }; export type StudioTranscriptReasoningEntry = { @@ -355,6 +442,10 @@ export type StudioSessionStore = MemoryStore & { saveSessionRunTranscript( input: StudioSessionRunTranscriptInput, ): StudioSession | undefined | Promise; + updateSessionMetadata?( + id: string, + metadata: JsonObject | undefined, + ): StudioSession | undefined | Promise; appendSessionLog?( input: StudioSessionLogAppendInput, ): StudioSessionLogEntry | Promise; @@ -605,6 +696,7 @@ export type StudioOptions = { quickPrompts?: Record; stores?: StudioStores; ui?: boolean | StudioUiOptions; + models?: StudioModelConfig; }; export type StudioServeOptions = { @@ -848,6 +940,7 @@ export type AgentRunRequest = { stream?: boolean; maxTurns?: number; toolConcurrency?: number; + model?: StudioModelRef; metadata?: JsonObject; trace?: AgentTraceOptions; }; @@ -879,6 +972,23 @@ export type StudioErrorResponse = { }; }; +export function traceSummary(trace: StudioTrace): StudioTraceSummary { + return compact({ + id: trace.id, + sessionId: trace.sessionId, + name: trace.name, + status: trace.status, + startedAt: trace.startedAt, + endedAt: trace.endedAt, + durationMs: trace.durationMs, + output: trace.output, + error: trace.error, + usage: trace.usage, + metadata: trace.metadata, + observationCount: trace.observations.length, + }) as StudioTraceSummary; +} + export type AnviaStudio = { readonly app: Hono; fetch(request: Request): Response | Promise; diff --git a/packages/tools/studio/src/ui/app/app.tsx b/packages/tool-studio/src/ui/app/app.tsx similarity index 85% rename from packages/tools/studio/src/ui/app/app.tsx rename to packages/tool-studio/src/ui/app/app.tsx index 1651d398..265295d3 100644 --- a/packages/tools/studio/src/ui/app/app.tsx +++ b/packages/tool-studio/src/ui/app/app.tsx @@ -1,6 +1,15 @@ -import type { Message, ToolResultContent } from "@anvia/core/completion"; +import type { Message, ToolResultContent, UserContent } from "@anvia/core/completion"; import { createChatTransport, EventStreamHttpError, useChat } from "@anvia/react"; -import { Archive, ArrowSquareOut, ArrowUp, Moon, Plus, Sun } from "@phosphor-icons/react"; +import { + Archive, + ArrowSquareOut, + ArrowUp, + Moon, + Paperclip, + Plus, + Sun, + X, +} from "@phosphor-icons/react"; import { type ChangeEvent, type KeyboardEvent, @@ -15,10 +24,12 @@ import { import type { AgentRunStreamEvent, StudioAgentMcpsSummary, + StudioAgentModelsSummary, StudioAgentToolsSummary, StudioConfig, StudioEvalRunResponse, StudioKnowledgeSummary, + StudioModelSummary, StudioPipelineDetail, StudioPipelineLogEntry, StudioPipelineRunRecord, @@ -151,15 +162,29 @@ type StudioTheme = "light" | "dark"; type StudioAgentRunRequest = { agentId: string; - message: string; + message: string | Message; sessionId?: string; history?: Message[]; + model?: string; stream: true; metadata: { source: string; + studioModel?: string; }; }; +const studioModelMetadataKey = "studioModel"; +const supportedAttachmentTypes = ".png,.jpg,.jpeg,.webp,.gif,.pdf,.txt,.md,.csv,.json"; + +type PromptAttachment = { + id: string; + name: string; + mediaType: string; + kind: "image" | "document"; + data: string; + size: number; +}; + const studioThemeStorageKey = "anvia-studio-theme"; function readInitialStudioTheme(): StudioTheme { @@ -251,6 +276,7 @@ export function StudioConsole() { const [pipelineRuns, setPipelineRuns] = useState([]); const [messages, setMessages] = useState([]); const [prompt, setPrompt] = useState(""); + const [attachments, setAttachments] = useState([]); const [pipelineRunInput, setPipelineRunInput] = useState('"Hello from Studio"'); const [pipelineRunOutput, setPipelineRunOutput] = useState(""); const [evalRunResult, setEvalRunResult] = useState(); @@ -279,6 +305,8 @@ export function StudioConsole() { const [mcpsLoadState, setMcpsLoadState] = useState<"idle" | "loading">("idle"); const [tools, setTools] = useState(); const [toolsLoadState, setToolsLoadState] = useState<"idle" | "loading">("idle"); + const [agentModels, setAgentModels] = useState(); + const [selectedModelRef, setSelectedModelRef] = useState(""); const [pipelineDetailLoadState, setPipelineDetailLoadState] = useState<"idle" | "loading">( "idle", ); @@ -287,6 +315,7 @@ export function StudioConsole() { const [pipelineRunState, setPipelineRunState] = useState("idle"); const [evalRunState, setEvalRunState] = useState("idle"); const promptRef = useRef(null); + const attachmentInputRef = useRef(null); const transcriptScrollerRef = useRef(null); const transcriptStickToBottomRef = useRef(true); const playgroundRunRequestRef = useRef(undefined); @@ -392,8 +421,53 @@ export function StudioConsole() { ); const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? agents[0] ?? undefined; + const selectedAgentModelId = selectedAgent?.id; const selectedAgentQuickPrompts = selectedAgent?.quickPrompts ?? []; + const selectedAgentModels = + agentModels !== undefined && agentModels.agentId === selectedAgent?.id + ? agentModels.models + : []; const hasMessages = messages.length > 0; + + useEffect(() => { + if (config?.models === undefined || selectedAgentModelId === undefined) { + setAgentModels(undefined); + setSelectedModelRef(""); + return; + } + + const agentId = selectedAgentModelId; + let cancelled = false; + async function loadAgentModels() { + try { + const response = await fetch(`/agents/${encodeURIComponent(agentId)}/models`); + if (!response.ok) { + throw new Error(`Agent models failed with HTTP ${response.status}`); + } + const body = (await response.json()) as StudioAgentModelsSummary; + if (cancelled) { + return; + } + setAgentModels(body); + setSelectedModelRef((current) => + modelRefAvailable(body.models, current) + ? current + : (body.defaultModel ?? body.models[0]?.ref ?? ""), + ); + } catch (loadError) { + if (!cancelled) { + setAgentModels(undefined); + setError(errorMessage(loadError)); + } + } + } + + void loadAgentModels(); + return () => { + cancelled = true; + }; + }, [config?.models, selectedAgentModelId]); + const playgroundChat = useChat({ transport: createChatTransport({ endpoint: (request) => `/agents/${encodeURIComponent(request.agentId)}/runs`, @@ -502,6 +576,7 @@ export function StudioConsole() { title, metadata: { source: "anvia-studio", + ...(selectedModelRef.length === 0 ? {} : { [studioModelMetadataKey]: selectedModelRef }), }, }), }); @@ -830,8 +905,10 @@ export function StudioConsole() { ]); setTranscriptSequence(nextSequence(session.transcript)); setSelectedAgentId(session.agentId); + setSelectedModelRef(sessionModelRef(session)); setSelectedSessionId(session.id); setMessages(enrichTranscriptWithTraceIds(session.transcript, traceSummaries)); + setAttachments([]); if (options.updatePath !== false) { setActivePage("playground"); updateSessionPath(session.id); @@ -856,6 +933,7 @@ export function StudioConsole() { setSessionLogs([]); setMessages([]); setPrompt(""); + setAttachments([]); setActivePage("playground"); setError(""); if (options.updatePath !== false) { @@ -873,11 +951,13 @@ export function StudioConsole() { } setSelectedAgentId(agentId); + setSelectedModelRef(""); resetTranscriptSequence(); setSelectedSessionId(""); setSessionLogs([]); setMessages([]); setPrompt(""); + setAttachments([]); setActivePage("playground"); setError(""); updateSessionPath(undefined); @@ -908,6 +988,7 @@ export function StudioConsole() { setSessionLogs([]); setMessages([]); setPrompt(""); + setAttachments([]); if (activePage === "playground") { updateSessionPath(undefined); } @@ -971,9 +1052,10 @@ export function StudioConsole() { async function runPrompt(text: string) { const trimmed = text.trim(); + const promptAttachments = attachments; const agentId = selectedAgent?.id ?? selectedAgentId; if ( - trimmed.length === 0 || + (trimmed.length === 0 && promptAttachments.length === 0) || agentId.length === 0 || runState === "running" || playgroundChat.status === "streaming" @@ -985,11 +1067,31 @@ export function StudioConsole() { setActivePage("playground"); setError(""); setPrompt(""); + setAttachments([]); transcriptStickToBottomRef.current = true; requestAnimationFrame(() => resizeTextarea(promptRef.current)); + const promptMessage = + promptAttachments.length === 0 + ? trimmed + : userMessageWithAttachments(trimmed, promptAttachments); setMessages((current) => [ ...current, - { entryId: nextTranscriptId(), kind: "message", role: "user", text: trimmed }, + { + entryId: nextTranscriptId(), + kind: "message", + role: "user", + text: trimmed, + ...(promptAttachments.length === 0 + ? {} + : { attachments: transcriptAttachmentsForPrompt(promptAttachments) }), + }, + { + entryId: nextTranscriptId(), + kind: "message", + role: "assistant", + text: "", + tone: "pending", + }, ]); try { @@ -1002,12 +1104,14 @@ export function StudioConsole() { playgroundVisibleEventRef.current = Promise.resolve(); playgroundRunRequestRef.current = { agentId, - message: trimmed, + message: promptMessage, ...(sessionId.length === 0 ? {} : { sessionId }), ...(history === undefined ? {} : { history }), + ...(selectedModelRef.length === 0 ? {} : { model: selectedModelRef }), stream: true, metadata: { source: "anvia-studio", + ...(selectedModelRef.length === 0 ? {} : { studioModel: selectedModelRef }), }, }; @@ -1228,8 +1332,11 @@ export function StudioConsole() { appendSessionLogEntry(event.log); return true; } - if (event.type === "final" && event.trace?.traceId !== undefined) { - assignAssistantTraceId(event.trace.traceId); + if (event.type === "final") { + if (event.trace?.traceId !== undefined) { + assignAssistantTraceId(event.trace.traceId); + } + clearPendingAssistant(); return true; } if (event.type === "error") { @@ -1265,7 +1372,12 @@ export function StudioConsole() { const next = [...current]; const last = next.at(-1); if (last?.kind === "message" && last.role === "assistant") { - next[next.length - 1] = { ...last, text: `${last.text}${delta}` }; + if (last.tone === "pending") { + const { tone: _tone, ...readyMessage } = last; + next[next.length - 1] = { ...readyMessage, text: delta }; + } else { + next[next.length - 1] = { ...last, text: `${last.text}${delta}` }; + } } else { next.push({ entryId: nextTranscriptId(), @@ -1279,16 +1391,25 @@ export function StudioConsole() { } function appendAssistantError(message: string) { - setMessages((current) => [ - ...current, - { - entryId: nextTranscriptId(), - kind: "message", - role: "assistant", + setMessages((current) => { + const next = [...current]; + const last = next.at(-1); + const entry = { + entryId: + last?.kind === "message" && last.role === "assistant" && last.tone === "pending" + ? last.entryId + : nextTranscriptId(), + kind: "message" as const, + role: "assistant" as const, text: message, - tone: "error", - }, - ]); + tone: "error" as const, + }; + if (last?.kind === "message" && last.role === "assistant" && last.tone === "pending") { + next[next.length - 1] = entry; + return next; + } + return [...next, entry]; + }); } function assignAssistantTraceId(traceId: string) { @@ -1305,9 +1426,28 @@ export function StudioConsole() { }); } + function clearPendingAssistant() { + setMessages((current) => withoutPendingAssistant(current)); + } + + function withoutPendingAssistant(entries: TranscriptEntry[]): TranscriptEntry[] { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if ( + entry?.kind === "message" && + entry.role === "assistant" && + entry.tone === "pending" && + entry.text.trim().length === 0 + ) { + return entries.filter((_, itemIndex) => itemIndex !== index); + } + } + return [...entries]; + } + function updateToolApproval(approval: ToolApprovalUpdate) { setMessages((current) => { - const next = [...current]; + const next = withoutPendingAssistant(current); const matchedIndex = findMatchingToolIndexByCall(next, approval.toolName, approval.callId); if (matchedIndex < 0) { next.push({ @@ -1345,7 +1485,7 @@ export function StudioConsole() { function updateToolQuestion(question: ToolQuestionUpdate) { setMessages((current) => { - const next = [...current]; + const next = withoutPendingAssistant(current); const matchedIndex = findMatchingToolIndexByCall(next, question.toolName, question.callId); if (matchedIndex < 0) { next.push({ @@ -1446,7 +1586,7 @@ export function StudioConsole() { function appendReasoningText(delta: string, reasoningId: string | undefined) { setMessages((current) => { - const next = [...current]; + const next = withoutPendingAssistant(current); const last = next.at(-1); if (last?.kind === "reasoning" && (last.reasoningId ?? "") === (reasoningId ?? "")) { next[next.length - 1] = { ...last, text: `${last.text}${delta}` }; @@ -1464,7 +1604,7 @@ export function StudioConsole() { function appendToolCall(toolName: string, args: string, callId: string | undefined) { setMessages((current) => [ - ...current, + ...withoutPendingAssistant(current), { entryId: nextTranscriptId(), kind: "tool", @@ -1483,7 +1623,7 @@ export function StudioConsole() { structuredResult?: ToolResultContent[]; }) { setMessages((current) => { - const next = [...current]; + const next = withoutPendingAssistant(current); const matchedIndex = findMatchingToolIndex(next, props.toolName, props.callId); if (matchedIndex >= 0) { const existing = next[matchedIndex]; @@ -1521,7 +1661,7 @@ export function StudioConsole() { return; } setMessages((current) => { - const next = [...current]; + const next = withoutPendingAssistant(current); const matchedIndex = findMatchingToolIndex(next, event.toolName, event.toolCallId); if (matchedIndex < 0) { next.push({ @@ -1675,6 +1815,25 @@ export function StudioConsole() { resizeTextarea(event.currentTarget); } + async function addPromptAttachments(event: ChangeEvent) { + const files = Array.from(event.currentTarget.files ?? []); + event.currentTarget.value = ""; + if (files.length === 0) { + return; + } + + try { + const nextAttachments = await Promise.all(files.map(fileToAttachment)); + setAttachments((current) => [...current, ...nextAttachments]); + } catch (attachmentError) { + setError(errorMessage(attachmentError)); + } + } + + function removePromptAttachment(id: string) { + setAttachments((current) => current.filter((attachment) => attachment.id !== id)); + } + function handlePromptKeyDown(event: KeyboardEvent) { if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) { return; @@ -1738,6 +1897,7 @@ export function StudioConsole() { setSessionLogs([]); setMessages([]); setPrompt(""); + setAttachments([]); setActivePage(nextPage); updatePagePath(nextPage); @@ -2050,9 +2210,74 @@ export function StudioConsole() { onKeyDown={handlePromptKeyDown} placeholder="Ask anything..." /> + {attachments.length === 0 ? null : ( +
+ {attachments.map((attachment) => ( + + + {attachment.kind === "image" ? "Image" : "Doc"} / {attachment.name} + + + + ))} +
+ )}
-
+ void addPromptAttachments(event)} + /> + +
+
+ {selectedAgentModels.length === 0 ? null : ( + + )} {agents.length > 1 ? (