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 ? (
+ );
+}
+
+function MessageAttachments(props: {
+ attachments: NonNullable
["attachments"]>;
+}) {
+ return (
+
+ {props.attachments.map((attachment, index) => {
+ const key = `${attachment.kind}-${attachment.name ?? attachment.mediaType ?? index}-${index}`;
+ if (attachment.kind === "image") {
+ const src =
+ attachment.url ??
+ (attachment.data === undefined
+ ? undefined
+ : `data:${attachment.mediaType ?? "image/png"};base64,${attachment.data}`);
+ return (
+
+ {src === undefined ? (
+
+ Image
+
+ ) : (
+

+ )}
+
+ );
+ }
+ return (
+
+
+ {attachment.name ?? attachment.mediaType ?? "Document"}
+
+
+ );
+ })}
+
+ );
+}
+
function hasMarkdownTable(text: string): boolean {
const lines = text.split("\n");
return lines.some((line, index) => {
diff --git a/packages/tools/studio/src/ui/app/modules/session-logs/session-logs-panel.tsx b/packages/tool-studio/src/ui/app/modules/session-logs/session-logs-panel.tsx
similarity index 100%
rename from packages/tools/studio/src/ui/app/modules/session-logs/session-logs-panel.tsx
rename to packages/tool-studio/src/ui/app/modules/session-logs/session-logs-panel.tsx
diff --git a/packages/tools/studio/src/ui/app/modules/sessions/sessions-page.tsx b/packages/tool-studio/src/ui/app/modules/sessions/sessions-page.tsx
similarity index 100%
rename from packages/tools/studio/src/ui/app/modules/sessions/sessions-page.tsx
rename to packages/tool-studio/src/ui/app/modules/sessions/sessions-page.tsx
diff --git a/packages/tools/studio/src/ui/app/modules/shared/format.ts b/packages/tool-studio/src/ui/app/modules/shared/format.ts
similarity index 100%
rename from packages/tools/studio/src/ui/app/modules/shared/format.ts
rename to packages/tool-studio/src/ui/app/modules/shared/format.ts
diff --git a/packages/tools/studio/src/ui/app/modules/shared/navigation.ts b/packages/tool-studio/src/ui/app/modules/shared/navigation.ts
similarity index 100%
rename from packages/tools/studio/src/ui/app/modules/shared/navigation.ts
rename to packages/tool-studio/src/ui/app/modules/shared/navigation.ts
diff --git a/packages/tools/studio/src/ui/app/modules/shared/object.ts b/packages/tool-studio/src/ui/app/modules/shared/object.ts
similarity index 100%
rename from packages/tools/studio/src/ui/app/modules/shared/object.ts
rename to packages/tool-studio/src/ui/app/modules/shared/object.ts
diff --git a/packages/tools/studio/src/ui/app/modules/shared/path.ts b/packages/tool-studio/src/ui/app/modules/shared/path.ts
similarity index 100%
rename from packages/tools/studio/src/ui/app/modules/shared/path.ts
rename to packages/tool-studio/src/ui/app/modules/shared/path.ts
diff --git a/packages/tools/studio/src/ui/app/modules/shared/renderers.tsx b/packages/tool-studio/src/ui/app/modules/shared/renderers.tsx
similarity index 100%
rename from packages/tools/studio/src/ui/app/modules/shared/renderers.tsx
rename to packages/tool-studio/src/ui/app/modules/shared/renderers.tsx
diff --git a/packages/tools/studio/src/ui/app/modules/shared/transcript.ts b/packages/tool-studio/src/ui/app/modules/shared/transcript.ts
similarity index 100%
rename from packages/tools/studio/src/ui/app/modules/shared/transcript.ts
rename to packages/tool-studio/src/ui/app/modules/shared/transcript.ts
diff --git a/packages/tools/studio/src/ui/app/modules/shared/types.ts b/packages/tool-studio/src/ui/app/modules/shared/types.ts
similarity index 78%
rename from packages/tools/studio/src/ui/app/modules/shared/types.ts
rename to packages/tool-studio/src/ui/app/modules/shared/types.ts
index f3bb6a6e..9bf2644d 100644
--- a/packages/tools/studio/src/ui/app/modules/shared/types.ts
+++ b/packages/tool-studio/src/ui/app/modules/shared/types.ts
@@ -1,6 +1,15 @@
-import type { StudioTrace, StudioTranscriptEntry } from "../../../../types";
+import type {
+ StudioTrace,
+ StudioTranscriptChatEntry,
+ StudioTranscriptEntry,
+} from "../../../../types";
-export type TranscriptEntry = StudioTranscriptEntry;
+export type PendingAssistantMessage = Omit & {
+ role: "assistant";
+ text: "";
+ tone: "pending";
+};
+export type TranscriptEntry = StudioTranscriptEntry | PendingAssistantMessage;
export type ChatMessage = Extract;
export type ToolMessage = Extract;
export type ToolApproval = NonNullable;
diff --git a/packages/tools/studio/src/ui/app/modules/shell/nav-button.tsx b/packages/tool-studio/src/ui/app/modules/shell/nav-button.tsx
similarity index 100%
rename from packages/tools/studio/src/ui/app/modules/shell/nav-button.tsx
rename to packages/tool-studio/src/ui/app/modules/shell/nav-button.tsx
diff --git a/packages/tools/studio/src/ui/app/modules/status/status-page.tsx b/packages/tool-studio/src/ui/app/modules/status/status-page.tsx
similarity index 100%
rename from packages/tools/studio/src/ui/app/modules/status/status-page.tsx
rename to packages/tool-studio/src/ui/app/modules/status/status-page.tsx
diff --git a/packages/tools/studio/src/ui/app/modules/tools/tools-page.tsx b/packages/tool-studio/src/ui/app/modules/tools/tools-page.tsx
similarity index 100%
rename from packages/tools/studio/src/ui/app/modules/tools/tools-page.tsx
rename to packages/tool-studio/src/ui/app/modules/tools/tools-page.tsx
diff --git a/packages/tools/studio/src/ui/app/modules/tracing/trace-browser.tsx b/packages/tool-studio/src/ui/app/modules/tracing/trace-browser.tsx
similarity index 100%
rename from packages/tools/studio/src/ui/app/modules/tracing/trace-browser.tsx
rename to packages/tool-studio/src/ui/app/modules/tracing/trace-browser.tsx
diff --git a/packages/tools/studio/src/ui/app/styles.css b/packages/tool-studio/src/ui/app/styles.css
similarity index 100%
rename from packages/tools/studio/src/ui/app/styles.css
rename to packages/tool-studio/src/ui/app/styles.css
diff --git a/packages/tools/studio/src/ui/assets/anvia.png b/packages/tool-studio/src/ui/assets/anvia.png
similarity index 100%
rename from packages/tools/studio/src/ui/assets/anvia.png
rename to packages/tool-studio/src/ui/assets/anvia.png
diff --git a/packages/tools/studio/src/ui/assets/logo.png b/packages/tool-studio/src/ui/assets/logo.png
similarity index 100%
rename from packages/tools/studio/src/ui/assets/logo.png
rename to packages/tool-studio/src/ui/assets/logo.png
diff --git a/packages/tools/studio/src/ui/public/assets/anvia.png b/packages/tool-studio/src/ui/public/assets/anvia.png
similarity index 100%
rename from packages/tools/studio/src/ui/public/assets/anvia.png
rename to packages/tool-studio/src/ui/public/assets/anvia.png
diff --git a/packages/tools/studio/src/ui/public/assets/logo.png b/packages/tool-studio/src/ui/public/assets/logo.png
similarity index 100%
rename from packages/tools/studio/src/ui/public/assets/logo.png
rename to packages/tool-studio/src/ui/public/assets/logo.png
diff --git a/packages/tools/studio/src/ui/routes.tsx b/packages/tool-studio/src/ui/routes.tsx
similarity index 100%
rename from packages/tools/studio/src/ui/routes.tsx
rename to packages/tool-studio/src/ui/routes.tsx
diff --git a/packages/tools/studio/test/runner.test.ts b/packages/tool-studio/test/runner.test.ts
similarity index 94%
rename from packages/tools/studio/test/runner.test.ts
rename to packages/tool-studio/test/runner.test.ts
index 10063b74..91c8c921 100644
--- a/packages/tools/studio/test/runner.test.ts
+++ b/packages/tool-studio/test/runner.test.ts
@@ -413,6 +413,228 @@ describe("Anvia studio", () => {
});
});
+ it("exposes configured and listed provider models", async () => {
+ const agent = new AgentBuilder("support", new QueueModel([])).name("Support").build();
+ const runner = new Studio([agent], {
+ models: {
+ default: "openai:gpt-5",
+ providers: [
+ {
+ id: "openai",
+ name: "OpenAI",
+ defaultModel: "gpt-5",
+ models: [
+ {
+ id: "gpt-5",
+ name: "GPT-5",
+ modalities: { input: ["text", "image", "document"], output: ["text"] },
+ capabilities: { streaming: true, tools: true },
+ },
+ ],
+ createCompletionModel: () => new QueueModel([]),
+ listModels: async () => ({
+ data: [{ id: "gpt-5-mini", name: "GPT-5 mini", ownedBy: "provider" }],
+ }),
+ },
+ ],
+ agents: {
+ support: {
+ default: "openai:gpt-5",
+ allowed: ["openai:*"],
+ },
+ },
+ },
+ });
+
+ expect(runner.config().models).toMatchObject({
+ default: "openai:gpt-5",
+ providers: [
+ {
+ id: "openai",
+ name: "OpenAI",
+ defaultModel: "gpt-5",
+ models: [
+ {
+ ref: "openai:gpt-5",
+ providerId: "openai",
+ providerName: "OpenAI",
+ modalities: { input: ["text", "image", "document"], output: ["text"] },
+ },
+ ],
+ },
+ ],
+ agents: {
+ support: {
+ default: "openai:gpt-5",
+ allowed: ["openai:*"],
+ },
+ },
+ });
+
+ const models = await runner.fetch(new Request("http://runner.test/agents/support/models"));
+ await expect(models.json()).resolves.toMatchObject({
+ agentId: "support",
+ defaultModel: "openai:gpt-5",
+ models: [
+ { ref: "openai:gpt-5", name: "GPT-5" },
+ { ref: "openai:gpt-5-mini", name: "GPT-5 mini", metadata: { ownedBy: "provider" } },
+ ],
+ });
+ });
+
+ it("uses the selected provider model for runs and persists the session default", async () => {
+ const baseModel = new QueueModel([]);
+ const selectedModel = new QueueModel([
+ response([AssistantContent.text("First answer")]),
+ response([AssistantContent.text("Second answer")]),
+ ]);
+ const agent = new AgentBuilder("support", baseModel).build();
+ const runner = new Studio([agent], {
+ models: {
+ providers: [
+ {
+ id: "test",
+ defaultModel: "primary",
+ models: [{ id: "secondary", modalities: { input: ["text"], output: ["text"] } }],
+ createCompletionModel: (model) => {
+ if (model !== "secondary") {
+ throw new Error(`Unexpected model: ${model}`);
+ }
+ return selectedModel;
+ },
+ },
+ ],
+ agents: {
+ support: {
+ allowed: ["test:secondary"],
+ },
+ },
+ },
+ });
+ const created = await runner.fetch(
+ new Request("http://runner.test/sessions", {
+ method: "POST",
+ body: JSON.stringify({ agentId: "support", title: "Support" }),
+ }),
+ );
+ const session = (await created.json()) as { id: string };
+
+ const firstRun = await runner.fetch(
+ new Request("http://runner.test/agents/support/runs", {
+ method: "POST",
+ body: JSON.stringify({
+ sessionId: session.id,
+ message: "first",
+ model: "test:secondary",
+ }),
+ }),
+ );
+ expect(firstRun.status).toBe(200);
+
+ const secondRun = await runner.fetch(
+ new Request("http://runner.test/agents/support/runs", {
+ method: "POST",
+ body: JSON.stringify({
+ sessionId: session.id,
+ message: "second",
+ }),
+ }),
+ );
+ expect(secondRun.status).toBe(200);
+ expect(baseModel.requests).toHaveLength(0);
+ expect(selectedModel.requests).toHaveLength(2);
+
+ const loaded = await runner.fetch(new Request(`http://runner.test/sessions/${session.id}`));
+ await expect(loaded.json()).resolves.toMatchObject({
+ metadata: {
+ studioModel: "test:secondary",
+ },
+ });
+ });
+
+ it("rejects models outside the agent policy", async () => {
+ const agent = new AgentBuilder("support", new QueueModel([])).build();
+ const runner = new Studio([agent], {
+ models: {
+ providers: [
+ {
+ id: "test",
+ createCompletionModel: () => new QueueModel([]),
+ },
+ ],
+ agents: {
+ support: {
+ allowed: ["test:allowed"],
+ },
+ },
+ },
+ });
+
+ const run = await runner.fetch(
+ new Request("http://runner.test/agents/support/runs", {
+ method: "POST",
+ body: JSON.stringify({
+ message: "hello",
+ model: "test:blocked",
+ }),
+ }),
+ );
+
+ expect(run.status).toBe(400);
+ await expect(run.json()).resolves.toMatchObject({
+ error: {
+ code: "bad_request",
+ message: "Model test:blocked is not allowed for agent support",
+ },
+ });
+ });
+
+ it("accepts multimodal message payloads from Studio runs", async () => {
+ const model = new QueueModel([response([AssistantContent.text("image noted")])]);
+ const agent = new AgentBuilder("support", model).build();
+ const runner = new Studio([agent]);
+
+ const run = await runner.fetch(
+ new Request("http://runner.test/agents/support/runs", {
+ method: "POST",
+ body: JSON.stringify({
+ message: {
+ role: "user",
+ content: [
+ { type: "text", text: "Describe this image." },
+ {
+ type: "image",
+ source: {
+ type: "base64",
+ data: "aW1hZ2U=",
+ mediaType: "image/png",
+ },
+ },
+ ],
+ },
+ }),
+ }),
+ );
+
+ expect(run.status).toBe(200);
+ expect(model.requests[0]?.chatHistory.at(-1)).toMatchObject({
+ role: "user",
+ content: [
+ { type: "text", text: "Describe this image." },
+ { type: "image", source: { type: "base64", mediaType: "image/png" } },
+ ],
+ });
+
+ const body = (await run.json()) as { messages: Message[] };
+ expect(body.messages[0]).toMatchObject({
+ role: "user",
+ content: [
+ { type: "text", text: "Describe this image." },
+ { type: "image", source: { type: "base64", mediaType: "image/png" } },
+ ],
+ });
+ });
+
it("registers pipelines separately from agents", async () => {
const agent = new AgentBuilder("support", new QueueModel([])).name("Support").build();
const pipeline = new PipelineBuilder({
diff --git a/packages/tools/studio/test/studio-ui-helpers.test.ts b/packages/tool-studio/test/studio-ui-helpers.test.ts
similarity index 100%
rename from packages/tools/studio/test/studio-ui-helpers.test.ts
rename to packages/tool-studio/test/studio-ui-helpers.test.ts
diff --git a/packages/tools/studio/test/trace-browser.test.ts b/packages/tool-studio/test/trace-browser.test.ts
similarity index 100%
rename from packages/tools/studio/test/trace-browser.test.ts
rename to packages/tool-studio/test/trace-browser.test.ts
diff --git a/packages/tools/studio/tsconfig.json b/packages/tool-studio/tsconfig.json
similarity index 86%
rename from packages/tools/studio/tsconfig.json
rename to packages/tool-studio/tsconfig.json
index d2ad5f19..a5ce09c4 100644
--- a/packages/tools/studio/tsconfig.json
+++ b/packages/tool-studio/tsconfig.json
@@ -1,5 +1,5 @@
{
- "extends": "../../../tsconfig.base.json",
+ "extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"jsx": "react-jsx",
diff --git a/packages/tools/studio/tsup.config.ts b/packages/tool-studio/tsup.config.ts
similarity index 100%
rename from packages/tools/studio/tsup.config.ts
rename to packages/tool-studio/tsup.config.ts
diff --git a/packages/tools/studio/vite.config.ts b/packages/tool-studio/vite.config.ts
similarity index 100%
rename from packages/tools/studio/vite.config.ts
rename to packages/tool-studio/vite.config.ts
diff --git a/packages/tool-studio/vitest.config.ts b/packages/tool-studio/vitest.config.ts
new file mode 100644
index 00000000..d82d3835
--- /dev/null
+++ b/packages/tool-studio/vitest.config.ts
@@ -0,0 +1,29 @@
+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/embeddings": new URL("../core/src/embeddings/index.ts", import.meta.url)
+ .pathname,
+ "@anvia/core/evals": new URL("../core/src/evals/index.ts", import.meta.url).pathname,
+ "@anvia/core/internal/agent": new URL("../core/src/internal/agent.ts", import.meta.url)
+ .pathname,
+ "@anvia/core/mcp": new URL("../core/src/mcp/index.ts", import.meta.url).pathname,
+ "@anvia/core/memory": new URL("../core/src/memory/index.ts", import.meta.url).pathname,
+ "@anvia/core/observability": new URL("../core/src/observability/index.ts", import.meta.url)
+ .pathname,
+ "@anvia/core/pipeline": new URL("../core/src/pipeline/index.ts", import.meta.url).pathname,
+ "@anvia/core/tool": new URL("../core/src/tool/index.ts", import.meta.url).pathname,
+ "@anvia/core/vector-store": new URL("../core/src/vector-store/index.ts", import.meta.url)
+ .pathname,
+ "@anvia/core": new URL("../core/src/index.ts", import.meta.url).pathname,
+ "@": new URL("./src/ui/app", import.meta.url).pathname,
+ },
+ },
+ test: {
+ environment: "node",
+ },
+});
diff --git a/packages/tools/sandbox/CHANGELOG.md b/packages/tools/sandbox/CHANGELOG.md
deleted file mode 100644
index 323a9f48..00000000
--- a/packages/tools/sandbox/CHANGELOG.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# @anvia/sandbox
-
-## 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/src/docker-sandbox.ts b/packages/tools/sandbox/src/docker-sandbox.ts
deleted file mode 100644
index 4c4b7a0c..00000000
--- a/packages/tools/sandbox/src/docker-sandbox.ts
+++ /dev/null
@@ -1,444 +0,0 @@
-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,
- SandboxSessionDestroyedError,
- SandboxTimeoutError,
-} from "./errors";
-import { containerPath, normalizeSandboxPath, parentSandboxPath } from "./path";
-import type {
- DockerSandboxOptions,
- Sandbox,
- SandboxCreateSessionOptions,
- SandboxExecOptions,
- SandboxExecResult,
- SandboxFileEntry,
- SandboxFileType,
- SandboxLimits,
- SandboxManifest,
- SandboxNetworkMode,
- SandboxSession,
-} 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 network: SandboxNetworkMode;
- private readonly dockerPath: string;
- private readonly labels: Record;
- private readonly limits: SandboxLimits;
- private readonly security: Required>;
- private readonly user: string | undefined;
-
- constructor(options: DockerSandboxOptions = {}) {
- this.image = options.image ?? defaultImage;
- this.pull = options.pull ?? "missing";
- this.workdir = options.workdir ?? defaultWorkdir;
- 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.user = options.user;
- }
-
- async createSession(options: SandboxCreateSessionOptions = {}): Promise {
- await this.ensureImage();
-
- const id = sanitizeResourceId(options.id ?? randomUUID());
- const containerName = `anvia-sandbox-${id}`;
- const volumeName = `anvia-sandbox-${id}-workspace`;
-
- await assertDockerCli(["volume", "create", volumeName], this.cliOptions());
-
- try {
- await assertDockerCli(this.createRunArgs(containerName, volumeName, 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,
- env: options.manifest?.env ?? {},
- });
-
- await session.applyManifest(options.manifest);
- return session;
- } catch (error) {
- await this.cleanup(containerName, volumeName);
- 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,
- metadata: Record | undefined,
- ): string[] {
- const args = [
- "run",
- "-d",
- "--name",
- containerName,
- "-v",
- `${volumeName}:${this.workdir}`,
- "-w",
- this.workdir,
- "--label",
- "anvia.sandbox=true",
- ];
-
- 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 {
- if (this.network === false || this.network === "none") {
- args.push("--network", "none");
- return;
- }
-
- if (this.network !== true) {
- args.push("--network", this.network);
- }
- }
-
- 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): Promise {
- await runDockerCli(["rm", "-f", containerName], this.cliOptions()).catch(() => 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 env: Record;
- private destroyed = false;
-
- constructor(options: {
- id: string;
- containerName: string;
- volumeName: string;
- workdir: string;
- dockerPath: string;
- limits: SandboxLimits;
- env: Record;
- }) {
- 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.env = options.env;
- }
-
- async applyManifest(manifest: SandboxManifest | undefined): Promise