diff --git a/.gitignore b/.gitignore index e7e43c6..c092e93 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # ============================================================================= docs/plans/** +docs/superpowers/** # ============================================================================= # Python diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a29437d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Added + +- `client.memories` namespace with `list`, `get`, `create`, `delete`, and + `search` (hybrid NLQ search over the `contextMemory` index) for Context + Center memories. All methods support sync and async in Python and async in + TypeScript; sync-only in Java; subcommands in the Rust CLI. +- Memory model types across all SDKs: `ContextMemory`, + `CreateContextMemoryRequest`, `MemorySearchHit`, `MemorySearchResults`, + `MemoryType`, `MemoryScope`, `MemoryVisibility`. +- `CHANGELOG.md` for release notes. + +### Changed (BREAKING) + +All client CRUD methods now live on namespaces. The `client.agent(name)` +handle factory and `client.mcp` namespace are unchanged. + +**Python migration:** + +| Old (removed) | New | +| ---------------------------- | ------------------------------ | +| `client.list_agents()` | `client.agents.list()` | +| `client.create_agent(req)` | `client.agents.create(req)` | +| `client.list_bots()` | `client.bots.list()` | +| `client.get_bot(name)` | `client.bots.get(name)` | +| `client.list_personas()` | `client.personas.list()` | +| `client.get_persona(name)` | `client.personas.get(name)` | +| `client.create_persona(req)` | `client.personas.create(req)` | +| `client.list_abilities()` | `client.abilities.list()` | +| `client.get_ability(name)` | `client.abilities.get(name)` | + +All `aXxx` async variants follow the same pattern: `client.X.aYyy()`. + +**TypeScript migration:** + +Same shape as Python with camelCase methods and accessed as fields +(`client.agents.list()`, `client.bots.get(name)`, etc.). + +**Java migration:** + +Namespaces are exposed as methods rather than fields: +`client.agents().list()`, `client.bots().get(name)`, +`client.memories().search(query)`, etc. + +**Rust CLI:** + +The CLI was already namespace-shaped via subcommands. New `memories` +subcommand added: `ai-sdk memories list`, `ai-sdk memories search "..."`, +etc. diff --git a/CLAUDE.md b/CLAUDE.md index 18d229f..68748a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,11 +70,27 @@ make install-hooks # Install pre-commit hooks ``` client = AISdk(host, token) + +# default AskCollate agent +response = client.agent().invoke("message") + +# Named dynamic agent response = client.agent("agent-name").invoke("message") -# Streaming -for event in client.agent("agent-name").stream("message"): +# Streaming (works with both) +for event in client.agent().stream("message"): handle(event) + +# Entity CRUD lives on namespaces (composition pattern) +client.agents.list() +client.bots.get("ingestion-bot") +client.personas.list() +client.abilities.get("DataQuality") + +# Context Center memories +client.memories.list(primary_entity_fqn="db.tbl") +client.memories.create(CreateContextMemoryRequest(name="m1", question="...", answer="...")) +results = client.memories.search("explain customer churn") ``` ### Event Types (Streaming) diff --git a/README.md b/README.md index db60d35..7665734 100644 --- a/README.md +++ b/README.md @@ -70,14 +70,18 @@ from ai_sdk import AISdk, AISdkConfig config = AISdkConfig.from_env() # reads AI_SDK_HOST and AI_SDK_TOKEN client = AISdk.from_config(config) -# Invoke an agent +# default AskCollate agent +response = client.agent().call("What data quality tests should I add?") +print(response.response) + +# Named dynamic agent response = client.agent("DataQualityPlannerAgent").call( "What data quality tests should I add for the customers table?" ) print(response.response) -# Stream responses in real time -for event in client.agent("DataQualityPlannerAgent").stream("Analyze the orders table"): +# Stream responses in real time (works with both) +for event in client.agent().stream("Analyze the orders table"): if event.type == "content": print(event.content, end="", flush=True) ``` @@ -96,13 +100,18 @@ const client = new AISdk({ token: 'your-bot-jwt-token' }); -const response = await client.agent('DataQualityPlannerAgent').call( +// default AskCollate agent +const defaultResponse = await client.agent().invoke('What tables have quality issues?'); +console.log(defaultResponse.response); + +// Named dynamic agent +const response = await client.agent('DataQualityPlannerAgent').invoke( 'What data quality tests should I add for the customers table?' ); console.log(response.response); -// Stream responses -for await (const event of client.agent('DataQualityPlannerAgent').stream('Analyze data quality')) { +// Stream responses (works with both) +for await (const event of client.agent().stream('Analyze data quality')) { if (event.type === 'content') { process.stdout.write(event.content || ''); } @@ -129,8 +138,14 @@ AISdk client = new AISdk.Builder() .token("your-bot-jwt-token") .build(); +// default AskCollate agent +InvokeResponse defaultResponse = client.agent() + .invoke("What data quality tests should I add?"); +System.out.println(defaultResponse.getResponse()); + +// Named dynamic agent InvokeResponse response = client.agent("DataQualityPlannerAgent") - .call("What data quality tests should I add?"); + .invoke("What data quality tests should I add?"); System.out.println(response.getResponse()); ``` @@ -143,12 +158,142 @@ curl -sSL https://raw.githubusercontent.com/open-metadata/ai-sdk/main/cli/instal # Configure ai-sdk configure -# Invoke an agent +# default AskCollate agent +ai-sdk invoke --default "Analyze the customers table" + +# Named dynamic agent ai-sdk invoke DataQualityPlannerAgent "Analyze the customers table" ``` Interactive TUI with markdown rendering and syntax highlighting. +## Context Memories + +OpenMetadata's **Context Center** stores reusable knowledge — user preferences, use cases, runbooks, and FAQs — that any AI agent can read on demand. Each memory has a canonical question/answer pair, a type, and an optional `primaryEntity` so it can be retrieved when working with a specific table, dashboard, or pipeline. + +The `client.memories` namespace exposes the full lifecycle. **Hybrid search** combines vector similarity with keyword ranking over the `contextMemory` index — pass a natural-language query and an optional filter map; you get back hits ranked by relevance. + +### Python + +```python +from ai_sdk import AISdk, CreateContextMemoryRequest, MemoryType, MemoryVisibility + +client = AISdk.from_config(AISdkConfig.from_env()) + +# Create a memory tied to a specific table +created = client.memories.create(CreateContextMemoryRequest( + name="orders-grain", + title="Orders grain", + question="What is the grain of the orders table?", + answer="One row per order_id; payments roll up to this grain.", + memory_type=MemoryType.NOTE, + visibility=MemoryVisibility.SHARED, + primary_entity=EntityReference(id="", type="table"), +)) + +# List memories attached to that asset +for m in client.memories.list(primary_entity_fqn="prod.warehouse.orders"): + print(m.title) + +# Hybrid NLQ search +results = client.memories.search("how do we measure order volume", size=5) +for hit in results.hits: + print(f"[{hit.score:.2f}] {hit.memory.title}") + +# Soft delete (use hard_delete=True to remove permanently) +client.memories.delete(created.id) +``` + +### TypeScript + +```typescript +const created = await client.memories.create({ + name: 'orders-grain', + title: 'Orders grain', + question: 'What is the grain of the orders table?', + answer: 'One row per order_id; payments roll up to this grain.', + memoryType: 'Note', + visibility: 'Shared', + primaryEntity: { id: '', type: 'table' }, +}); + +for (const m of await client.memories.list({ primaryEntityFqn: 'prod.warehouse.orders' })) { + console.log(m.title); +} + +const results = await client.memories.search('how do we measure order volume', { size: 5 }); +for (const hit of results.hits) { + console.log(`[${hit.score.toFixed(2)}] ${hit.memory.title}`); +} + +await client.memories.delete(created.id); +``` + +### Java + +```java +import io.openmetadata.ai.models.*; + +ContextMemory created = client.memories().create( + CreateContextMemoryRequest.builder() + .name("orders-grain") + .title("Orders grain") + .question("What is the grain of the orders table?") + .answer("One row per order_id; payments roll up to this grain.") + .memoryType(MemoryType.NOTE) + .visibility(MemoryVisibility.SHARED) + .primaryEntity(EntityReference.builder().id("").type("table").build()) + .build() +); + +for (ContextMemory m : client.memories().list("prod.warehouse.orders", null)) { + System.out.println(m.getTitle()); +} + +MemorySearchResults results = client.memories().search( + "how do we measure order volume", null, 5, 0 +); +for (MemorySearchHit hit : results.getHits()) { + System.out.printf("[%.2f] %s%n", hit.getScore(), hit.getMemory().getTitle()); +} + +client.memories().delete(created.getId()); +``` + +### CLI + +```bash +# Create +ai-sdk memories create \ + --name orders-grain \ + --title "Orders grain" \ + --question "What is the grain of the orders table?" \ + --answer "One row per order_id; payments roll up to this grain." \ + --memory-type note \ + --visibility shared \ + --primary-entity-id --primary-entity-type table + +# List by entity +ai-sdk memories list --entity-fqn prod.warehouse.orders + +# Hybrid NLQ search (use --json to pipe into jq) +ai-sdk memories search "how do we measure order volume" --size 5 --json + +# Delete (add --hard for permanent) +ai-sdk memories delete +``` + +**Filtering search results.** Pass a JSON filter map to scope by entity, visibility, or any indexed field: + +```python +client.memories.search( + "explain churn", + filters={"primaryEntityId": [""], "visibility": ["Entity", "Shared"]}, +) +``` + +See each SDK's README for the full surface (sync + async, all enum values, model fields). + ## Cookbook Real-world examples showing how teams use the AI SDK in production workflows. diff --git a/VERSION b/VERSION index d917d3e..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.2 +0.2.0 diff --git a/cli/CLAUDE.md b/cli/CLAUDE.md index 92505d7..5b37f82 100644 --- a/cli/CLAUDE.md +++ b/cli/CLAUDE.md @@ -63,8 +63,16 @@ The `chat` command launches an interactive terminal interface: ai-sdk chat # Opens agent selector ai-sdk chat AgentName # Opens chat with specific agent ai-sdk chat AgentName -c # Resume conversation +ai-sdk chat --debug # Log SSE events to ~/.ai-sdk/chat-debug.log +ai-sdk chat --debug /tmp/sse.log # Log SSE events to a custom path ``` +The `--debug` flag truncates the log on session start and appends events from +every stream within the session — useful for diagnosing issues like +`Network error: error decoding response body` mid-stream. The log captures +each SSE event, message-level details, and the underlying error and unparsed +buffer when the stream is cut short. + ### TUI Features - **Agent selection menu**: `/agents` or start without agent name diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 3bfd070..bc2d745 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -8,9 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "ai-sdk" -version = "0.1.2" +version = "0.2.0" dependencies = [ "clap", "colored", @@ -28,6 +37,7 @@ dependencies = [ "toml", "urlencoding", "uuid", + "wiremock", ] [[package]] @@ -92,6 +102,16 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -272,6 +292,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "dirs" version = "5.0.1" @@ -363,6 +401,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -370,6 +423,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -378,6 +432,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -413,6 +478,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -472,6 +538,25 @@ dependencies = [ "wasip3", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -495,6 +580,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "1.4.0" @@ -534,6 +625,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -544,9 +641,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -874,6 +973,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1075,7 +1184,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -1162,6 +1271,29 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + [[package]] name = "regex-syntax" version = "0.8.9" @@ -2307,6 +2439,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 335d037..47f512d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ai-sdk" -version = "0.1.2" +version = "0.2.0" edition = "2021" description = "Command-line tool for OpenMetadata AI Agents" authors = ["OpenMetadata "] @@ -30,6 +30,8 @@ pulldown-cmark = "0.10" [dev-dependencies] uuid = { version = "1", features = ["v4"] } +wiremock = "0.6" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } [profile.release] opt-level = "z" diff --git a/cli/README.md b/cli/README.md index f613f00..ed7d2bd 100644 --- a/cli/README.md +++ b/cli/README.md @@ -118,7 +118,7 @@ ai-sdk chat semantic-layer-agent -c ``` **TUI Features:** -- **Agent selection**: Type `/agents` or start without an agent to see available agents +- **Agent selection**: Type `/agents` or start without an agent to see available agents. The list always has **AskCollate (default)** at the top — pick it to chat with the platform's default agent. Press `Enter` to confirm. - **Markdown rendering**: Bold, italic, headers, lists render properly - **Syntax-highlighted code blocks**: SQL, Python, JSON, and more - **Thinking/reasoning display**: Agent's thought process shown in grey @@ -251,12 +251,63 @@ ai-sdk abilities get ai-sdk abilities get --json ``` +### Manage Memories + +Context Memories are reusable knowledge — preferences, use cases, runbooks, FAQs — that AI agents read on demand. The `memories` subcommand exposes the full lifecycle: + +```bash +# List all memories (paginates server-side) +ai-sdk memories list + +# Filter by entity FQN; cap with --limit +ai-sdk memories list --entity-fqn prod.warehouse.orders --limit 20 + +# Get a specific memory by ID +ai-sdk memories get + +# Create +ai-sdk memories create \ + --name orders-grain \ + --title "Orders grain" \ + --question "What is the grain of the orders table?" \ + --answer "One row per order_id." \ + --memory-type note \ + --memory-scope entity-scoped \ + --visibility shared \ + --primary-entity-id \ + --primary-entity-type table \ + --primary-entity-fqn prod.warehouse.orders \ + --tags Domain.Analytics + +# Hybrid NLQ search — natural-language query, ranked hits +ai-sdk memories search "how do we measure order volume" --size 5 + +# Soft delete by default; --hard removes permanently +ai-sdk memories delete +ai-sdk memories delete --hard +``` + +**Memory type values:** `preference`, `use-case`, `note` (default), `runbook`, `faq` +**Memory scope values:** `entity-scoped` (default), `user-global` +**Visibility values:** `private` (default), `entity`, `shared` + +All `memories` subcommands accept `--json` to emit machine-readable output for scripting: + +```bash +# Pipe top hits into jq +ai-sdk memories search "explain churn" --json | jq '.hits[].memory.title' +``` + ### Invoke Agents (One-shot) For scripting or single queries, use the `invoke` command: ```bash -# Simple invocation +# default AskCollate agent +ai-sdk invoke --default "What tables are available?" +ai-sdk invoke -D "What tables are available?" + +# Named dynamic agent ai-sdk invoke semantic-layer-agent "What tables are available?" # Stream response in real-time diff --git a/cli/src/client.rs b/cli/src/client.rs index ad2122f..8b1e44a 100644 --- a/cli/src/client.rs +++ b/cli/src/client.rs @@ -269,6 +269,8 @@ pub struct InvokeResponse { #[serde(default)] pub tools_used: Vec, #[serde(default)] + pub thinking_steps: Vec, + #[serde(default)] pub usage: Option, } @@ -284,10 +286,350 @@ pub struct Usage { pub total_tokens: u32, } +// ==================== Context Memory Types ==================== + +/// High-level type of a Context Center memory. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum MemoryType { + #[serde(rename = "Preference")] + Preference, + #[serde(rename = "UseCase")] + UseCase, + #[serde(rename = "Note")] + Note, + #[serde(rename = "Runbook")] + Runbook, + #[serde(rename = "Faq")] + Faq, +} + +impl MemoryType { + /// Parse from a CLI string (case-insensitive). + pub fn from_cli_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "preference" => Ok(MemoryType::Preference), + "usecase" | "use_case" | "use-case" => Ok(MemoryType::UseCase), + "note" => Ok(MemoryType::Note), + "runbook" => Ok(MemoryType::Runbook), + "faq" => Ok(MemoryType::Faq), + other => Err(format!( + "invalid memory type '{other}' (expected one of: preference, useCase, note, runbook, faq)" + )), + } + } + + /// Wire-format string value. + pub fn as_wire(&self) -> &'static str { + match self { + MemoryType::Preference => "Preference", + MemoryType::UseCase => "UseCase", + MemoryType::Note => "Note", + MemoryType::Runbook => "Runbook", + MemoryType::Faq => "Faq", + } + } +} + +/// Scope where a memory applies. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum MemoryScope { + #[serde(rename = "UserGlobal")] + UserGlobal, + #[serde(rename = "EntityScoped")] + EntityScoped, +} + +impl MemoryScope { + pub fn from_cli_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "userglobal" | "user_global" | "user-global" => Ok(MemoryScope::UserGlobal), + "entityscoped" | "entity_scoped" | "entity-scoped" => Ok(MemoryScope::EntityScoped), + other => Err(format!( + "invalid memory scope '{other}' (expected: userGlobal, entityScoped)" + )), + } + } + + pub fn as_wire(&self) -> &'static str { + match self { + MemoryScope::UserGlobal => "UserGlobal", + MemoryScope::EntityScoped => "EntityScoped", + } + } +} + +/// Visibility level for a memory. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum MemoryVisibility { + #[serde(rename = "Private")] + Private, + #[serde(rename = "Entity")] + Entity, + #[serde(rename = "Shared")] + Shared, +} + +impl MemoryVisibility { + pub fn from_cli_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "private" => Ok(MemoryVisibility::Private), + "entity" => Ok(MemoryVisibility::Entity), + "shared" => Ok(MemoryVisibility::Shared), + other => Err(format!( + "invalid visibility '{other}' (expected: private, entity, shared)" + )), + } + } + + pub fn as_wire(&self) -> &'static str { + match self { + MemoryVisibility::Private => "Private", + MemoryVisibility::Entity => "Entity", + MemoryVisibility::Shared => "Shared", + } + } +} + +/// Tag label wrapper used on the wire when creating a memory with tags. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TagLabel { + #[serde(rename = "tagFQN")] + pub tag_fqn: String, + pub label_type: String, + pub state: String, + pub source: String, +} + +impl TagLabel { + /// Wrap a tag FQN string in the wire format the API expects. + pub fn manual(tag_fqn: impl Into) -> Self { + Self { + tag_fqn: tag_fqn.into(), + label_type: "Manual".to_string(), + state: "Confirmed".to_string(), + source: "Classification".to_string(), + } + } +} + +/// Wire-format share configuration carried inside a create request. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ShareConfig { + pub visibility: MemoryVisibility, +} + +/// Request body for creating a Context Center memory. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateContextMemoryRequest { + pub name: String, + pub question: String, + pub answer: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub memory_type: MemoryType, + pub memory_scope: MemoryScope, + pub share_config: ShareConfig, + #[serde(skip_serializing_if = "Option::is_none")] + pub primary_entity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub related_entities: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, +} + +/// A Context Center memory (response shape). +/// +/// `visibility` is flattened from the server's `shareConfig.visibility`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ContextMemory { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub fully_qualified_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default)] + pub question: String, + #[serde(default)] + pub answer: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + pub memory_type: MemoryType, + pub memory_scope: MemoryScope, + pub visibility: MemoryVisibility, + #[serde(skip_serializing_if = "Option::is_none")] + pub primary_entity: Option, + #[serde(default)] + pub usage_count: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_used_at: Option, + #[serde(default)] + pub deleted: bool, +} + +impl<'de> Deserialize<'de> for ContextMemory { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // Deserialize as a generic JSON value, then extract visibility from shareConfig. + let mut value = serde_json::Value::deserialize(deserializer)?; + + // Extract visibility from shareConfig.visibility, defaulting to "Private". + let visibility = value + .get("shareConfig") + .and_then(|sc| sc.get("visibility")) + .and_then(|v| v.as_str()) + .unwrap_or("Private") + .to_string(); + + if let Some(obj) = value.as_object_mut() { + obj.insert( + "visibility".to_string(), + serde_json::Value::String(visibility), + ); + } + + // Helper struct that mirrors the public layout but is only used for parsing. + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Raw { + id: String, + name: String, + #[serde(default)] + fully_qualified_name: Option, + #[serde(default)] + title: Option, + #[serde(default)] + question: String, + #[serde(default)] + answer: String, + #[serde(default)] + summary: Option, + #[serde(default = "default_memory_type")] + memory_type: MemoryType, + #[serde(default = "default_memory_scope")] + memory_scope: MemoryScope, + #[serde(default = "default_memory_visibility")] + visibility: MemoryVisibility, + #[serde(default)] + primary_entity: Option, + #[serde(default)] + usage_count: u64, + #[serde(default)] + last_used_at: Option, + #[serde(default)] + deleted: bool, + } + + let raw: Raw = serde_json::from_value(value).map_err(serde::de::Error::custom)?; + Ok(ContextMemory { + id: raw.id, + name: raw.name, + fully_qualified_name: raw.fully_qualified_name, + title: raw.title, + question: raw.question, + answer: raw.answer, + summary: raw.summary, + memory_type: raw.memory_type, + memory_scope: raw.memory_scope, + visibility: raw.visibility, + primary_entity: raw.primary_entity, + usage_count: raw.usage_count, + last_used_at: raw.last_used_at, + deleted: raw.deleted, + }) + } +} + +fn default_memory_type() -> MemoryType { + MemoryType::Note +} + +fn default_memory_scope() -> MemoryScope { + MemoryScope::EntityScoped +} + +fn default_memory_visibility() -> MemoryVisibility { + MemoryVisibility::Private +} + +/// Paginated list response for memories. +#[derive(Debug, Clone, Deserialize)] +struct MemoryListResponse { + #[serde(default)] + data: Vec, + #[serde(default)] + paging: Option, +} + +/// A single hit from a hybrid memory search. +#[derive(Debug, Clone, Serialize)] +pub struct MemorySearchHit { + pub memory: ContextMemory, + pub score: f64, +} + +/// Results from a hybrid memory search. +#[derive(Debug, Clone, Serialize)] +pub struct MemorySearchResults { + pub total: u64, + pub hits: Vec, +} + +impl MemorySearchResults { + /// Parse from an OpenSearch-shaped response (`hits.total.value`, `hits.hits[]`). + pub fn from_opensearch(value: &serde_json::Value) -> CliResult { + let hits_block = value.get("hits"); + let total = hits_block + .and_then(|h| h.get("total")) + .map(|t| { + if let Some(obj) = t.as_object() { + obj.get("value").and_then(|v| v.as_u64()).unwrap_or(0) + } else { + t.as_u64().unwrap_or(0) + } + }) + .unwrap_or(0); + + let raw_hits = hits_block + .and_then(|h| h.get("hits")) + .and_then(|h| h.as_array()) + .cloned() + .unwrap_or_default(); + + let mut hits = Vec::with_capacity(raw_hits.len()); + for hit in raw_hits { + let source = hit + .get("_source") + .cloned() + .unwrap_or(serde_json::Value::Object(Default::default())); + let memory: ContextMemory = + serde_json::from_value(source).map_err(|e| CliError::ParseError(e.to_string()))?; + let score = hit.get("_score").and_then(|s| s.as_f64()).unwrap_or(0.0); + hits.push(MemorySearchHit { memory, score }); + } + + Ok(Self { total, hits }) + } +} + /// AI SDK API client. +/// +/// Uses two underlying reqwest clients: +/// - `client`: standard HTTP requests, bounded by `config.timeout`. +/// - `streaming_client`: SSE endpoints, with no request timeout — agent runs +/// can take many minutes and the stream itself indicates progress. #[derive(Clone)] pub struct AISdkClient { client: Client, + streaming_client: Client, base_url: String, token: String, } @@ -300,11 +642,18 @@ impl AISdkClient { .build() .map_err(CliError::from_reqwest)?; + // No `.timeout(...)` → reqwest waits indefinitely for the response + // body, which is what we want for SSE: the server may take minutes + // to finish a multi-step agent run, and the stream itself signals + // liveness via thinking/message events. + let streaming_client = Client::builder().build().map_err(CliError::from_reqwest)?; + // Normalize base URL (remove trailing slash) let base_url = config.host.trim_end_matches('/').to_string(); Ok(Self { client, + streaming_client, base_url, token: config.token.clone(), }) @@ -436,6 +785,114 @@ impl AISdkClient { serde_json::from_str(&body).map_err(|e| CliError::ParseError(e.to_string())) } + /// Create a new chat conversation for the default agent (PLANNER). + /// Returns the conversation ID. + /// The title is truncated to 50 characters to match the chat UI behaviour. + pub async fn create_chat_conversation(&self, title: &str) -> CliResult { + let truncated: String = title.chars().take(50).collect(); + let url = format!("{}/api/v1/assistants/chatConversations", self.base_url); + let body = serde_json::json!({ "title": truncated }); + + let response = self + .client + .post(&url) + .header("Authorization", self.auth_header()) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(CliError::from_reqwest)?; + + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + let body = response.text().await.unwrap_or_default(); + return Err(CliError::from_status(status, &body, None)); + } + + let json: serde_json::Value = response.json().await.map_err(|e| { + CliError::ParseError(format!("Failed to parse conversation response: {e}")) + })?; + + json["id"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| CliError::ParseError("Conversation response missing 'id' field".into())) + } + + /// Synchronously invoke the platform's default agent (PLANNER / CHAT_MODE). + /// Auto-creates a chat conversation when no conversation ID is supplied. + pub async fn invoke_default_agent( + &self, + message: &str, + conversation_id: Option<&str>, + ) -> CliResult { + let cid = match conversation_id { + Some(c) => c.to_string(), + None => self.create_chat_conversation(message).await?, + }; + + let url = format!("{}/api/v1/agents/invoke", self.base_url); + let body = serde_json::json!({ + "message": message, + "conversationId": cid, + "agentType": "PLANNER", + "agentMode": "CHAT_MODE", + }); + + let response = self + .client + .post(&url) + .header("Authorization", self.auth_header()) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(CliError::from_reqwest)?; + + let body_str = self.handle_response(response, None).await?; + serde_json::from_str(&body_str).map_err(|e| CliError::ParseError(e.to_string())) + } + + /// Stream a response from the platform's default agent (PLANNER / CHAT_MODE). + /// Auto-creates a chat conversation when no conversation ID is supplied. + pub async fn stream_default_agent( + &self, + message: &str, + conversation_id: Option<&str>, + ) -> CliResult { + let cid = match conversation_id { + Some(c) => c.to_string(), + None => self.create_chat_conversation(message).await?, + }; + + let url = format!("{}/api/v1/agents/run", self.base_url); + let body = serde_json::json!({ + "message": message, + "conversationId": cid, + "agentType": "PLANNER", + "agentMode": "CHAT_MODE", + }); + + let response = self + .streaming_client + .post(&url) + .header("Authorization", self.auth_header()) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") + .json(&body) + .send() + .await + .map_err(CliError::from_reqwest)?; + + let status = response.status().as_u16(); + if (200..300).contains(&status) { + Ok(response) + } else { + let body = response.text().await.unwrap_or_default(); + Err(CliError::from_status(status, &body, None)) + } + } + /// Get a streaming response from an agent. /// Returns the raw response for SSE processing. pub async fn stream( @@ -452,7 +909,7 @@ impl AISdkClient { }; let response = self - .client + .streaming_client .post(self.agents_url(&format!("/name/{encoded_name}/stream"))) .header("Authorization", self.auth_header()) .header("Content-Type", "application/json") @@ -755,4 +1212,492 @@ impl AISdkClient { serde_json::from_str(&body).map_err(|e| CliError::ParseError(e.to_string())) } + + // ==================== Context Memory Operations ==================== + + /// Base URL for the Context Center memories endpoints. + fn memories_url(&self, path: &str) -> String { + format!("{}/api/v1/contextCenter/memories{}", self.base_url, path) + } + + /// List Context Center memories, paginating through all results. + /// + /// `primary_entity_fqn` filters to memories attached to a given entity FQN. + /// `limit` caps the total number returned; `None` returns everything. + pub async fn list_memories( + &self, + primary_entity_fqn: Option<&str>, + limit: Option, + ) -> CliResult> { + const PAGE_SIZE: usize = 100; + let mut results = Vec::new(); + let mut after: Option = None; + + loop { + let mut query = format!("?limit={PAGE_SIZE}"); + if let Some(fqn) = primary_entity_fqn { + query.push_str(&format!("&primaryEntityFqn={}", encode(fqn))); + } + if let Some(cursor) = &after { + query.push_str(&format!("&after={}", encode(cursor))); + } + + let response = self + .client + .get(self.memories_url(&query)) + .header("Authorization", self.auth_header()) + .send() + .await + .map_err(CliError::from_reqwest)?; + + let body = self.handle_response(response, None).await?; + let list: MemoryListResponse = + serde_json::from_str(&body).map_err(|e| CliError::ParseError(e.to_string()))?; + + results.extend(list.data); + + if let Some(max) = limit { + if results.len() >= max { + results.truncate(max); + return Ok(results); + } + } + + after = list.paging.and_then(|p| p.after); + if after.is_none() { + break; + } + } + + Ok(results) + } + + /// Get a Context Center memory by ID. + pub async fn get_memory(&self, id: &str) -> CliResult { + let encoded_id = encode(id); + let response = self + .client + .get(self.memories_url(&format!("/{encoded_id}"))) + .header("Authorization", self.auth_header()) + .send() + .await + .map_err(CliError::from_reqwest)?; + + let body = self.handle_response(response, None).await?; + serde_json::from_str(&body).map_err(|e| CliError::ParseError(e.to_string())) + } + + /// Create a Context Center memory. + pub async fn create_memory( + &self, + request: &CreateContextMemoryRequest, + ) -> CliResult { + let response = self + .client + .post(self.memories_url("")) + .header("Authorization", self.auth_header()) + .header("Content-Type", "application/json") + .json(request) + .send() + .await + .map_err(CliError::from_reqwest)?; + + let body = self.handle_response(response, None).await?; + serde_json::from_str(&body).map_err(|e| CliError::ParseError(e.to_string())) + } + + /// Delete a Context Center memory by ID. Soft-delete by default. + pub async fn delete_memory(&self, id: &str, hard: bool) -> CliResult<()> { + let encoded_id = encode(id); + let response = self + .client + .delete(self.memories_url(&format!("/{encoded_id}?hardDelete={hard}"))) + .header("Authorization", self.auth_header()) + .send() + .await + .map_err(CliError::from_reqwest)?; + + let status = response.status().as_u16(); + if (200..300).contains(&status) { + Ok(()) + } else { + let body = response.text().await.unwrap_or_default(); + Err(CliError::from_status(status, &body, None)) + } + } + + /// Hybrid NLQ search over the `contextMemory` index. + /// + /// `filters` is a JSON object mapping field name -> list of values; it is + /// serialized and passed as the `filters` query parameter. + pub async fn search_memories( + &self, + q: &str, + filters: Option<&serde_json::Value>, + size: usize, + from: usize, + ) -> CliResult { + let mut url = format!( + "{}/api/v1/hybrid/nlq/search?index=contextMemory&q={}&size={size}&from={from}", + self.base_url, + encode(q), + ); + if let Some(f) = filters { + let json = serde_json::to_string(f) + .map_err(|e| CliError::ParseError(format!("invalid filters JSON: {e}")))?; + url.push_str(&format!("&filters={}", encode(&json))); + } + + let response = self + .client + .get(&url) + .header("Authorization", self.auth_header()) + .send() + .await + .map_err(CliError::from_reqwest)?; + + let body = self.handle_response(response, None).await?; + let value: serde_json::Value = + serde_json::from_str(&body).map_err(|e| CliError::ParseError(e.to_string()))?; + MemorySearchResults::from_opensearch(&value) + } +} + +// ==================== Tests ==================== + +#[cfg(test)] +mod default_agent_tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + /// Build a test client that points at `base_url` with the given token. + fn make_client(base_url: &str, token: &str) -> AISdkClient { + let config = ResolvedConfig { + host: base_url.to_string(), + token: token.to_string(), + timeout: 30, + }; + AISdkClient::new(&config).expect("failed to build test client") + } + + #[tokio::test] + async fn create_chat_conversation_returns_id() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/assistants/chatConversations")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "id": "aaaabbbb-1111-2222-3333-ccccddddeeee" })), + ) + .mount(&server) + .await; + + let client = make_client(&server.uri(), "test-token"); + let id = client + .create_chat_conversation("Hello world") + .await + .unwrap(); + assert_eq!(id, "aaaabbbb-1111-2222-3333-ccccddddeeee"); + } + + #[tokio::test] + async fn invoke_default_agent_creates_conversation_then_calls_invoke() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/assistants/chatConversations")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "id": "11111111-1111-1111-1111-111111111111" })), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/v1/agents/invoke")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "conversationId": "11111111-1111-1111-1111-111111111111", + "response": "Hello from the default agent" + }))) + .mount(&server) + .await; + + let client = make_client(&server.uri(), "test-token"); + let resp = client.invoke_default_agent("Say hi", None).await.unwrap(); + assert_eq!(resp.conversation_id, "11111111-1111-1111-1111-111111111111"); + assert_eq!(resp.response, "Hello from the default agent"); + } + + #[tokio::test] + async fn invoke_default_agent_reuses_supplied_conversation_id() { + let server = MockServer::start().await; + + // Conversation creation must NOT be called when an ID is already supplied. + Mock::given(method("POST")) + .and(path("/api/v1/assistants/chatConversations")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/v1/agents/invoke")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "conversationId": "existing-conv-id", + "response": "Reused conversation" + }))) + .mount(&server) + .await; + + let client = make_client(&server.uri(), "test-token"); + // Supplying an existing conversation ID means no call to chatConversations. + let resp = client + .invoke_default_agent("Follow up", Some("existing-conv-id")) + .await + .unwrap(); + assert_eq!(resp.conversation_id, "existing-conv-id"); + } + + #[tokio::test] + async fn invoke_default_agent_surfaces_thinking_steps() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/assistants/chatConversations")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "id": "55555555-5555-5555-5555-555555555555" })), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/v1/agents/invoke")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "conversationId": "55555555-5555-5555-5555-555555555555", + "response": "Found it.", + "thinkingSteps": ["Exploring assets...", "Synthesizing answer..."] + }))) + .mount(&server) + .await; + + let client = make_client(&server.uri(), "test-token"); + let resp = client.invoke_default_agent("question", None).await.unwrap(); + assert_eq!(resp.response, "Found it."); + assert_eq!( + resp.thinking_steps, + vec!["Exploring assets...", "Synthesizing answer..."] + ); + } + + #[tokio::test] + async fn invoke_default_agent_defaults_thinking_steps_to_empty() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/assistants/chatConversations")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "id": "11111111-1111-1111-1111-111111111111" })), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/v1/agents/invoke")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "conversationId": "11111111-1111-1111-1111-111111111111", + "response": "Hello from the default agent" + }))) + .mount(&server) + .await; + + let client = make_client(&server.uri(), "test-token"); + let resp = client.invoke_default_agent("Say hi", None).await.unwrap(); + assert!(resp.thinking_steps.is_empty()); + } + + #[tokio::test] + async fn stream_default_agent_creates_conversation_then_calls_run() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/v1/assistants/chatConversations")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "id": "stream-conv-id" })), + ) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/api/v1/agents/run")) + .respond_with(ResponseTemplate::new(200).set_body_string("data: done\n\n")) + .mount(&server) + .await; + + let client = make_client(&server.uri(), "test-token"); + let response = client.stream_default_agent("Stream me", None).await; + // We just need a successful HTTP response back — SSE parsing is tested elsewhere. + assert!(response.is_ok()); + } +} + +#[cfg(test)] +mod memory_tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn make_client(base_url: &str, token: &str) -> AISdkClient { + let config = ResolvedConfig { + host: base_url.to_string(), + token: token.to_string(), + timeout: 30, + }; + AISdkClient::new(&config).expect("failed to build test client") + } + + #[test] + fn context_memory_flattens_visibility_from_share_config() { + let raw = json!({ + "id": "abc-123", + "name": "my-memory", + "question": "What?", + "answer": "Because.", + "memoryType": "Note", + "memoryScope": "EntityScoped", + "shareConfig": {"visibility": "Shared"}, + "usageCount": 7 + }); + let memory: ContextMemory = serde_json::from_value(raw).unwrap(); + assert_eq!(memory.id, "abc-123"); + assert_eq!(memory.visibility, MemoryVisibility::Shared); + assert_eq!(memory.usage_count, 7); + } + + #[test] + fn context_memory_defaults_visibility_when_share_config_missing() { + let raw = json!({ + "id": "abc-123", + "name": "my-memory", + "memoryType": "Note", + "memoryScope": "EntityScoped" + }); + let memory: ContextMemory = serde_json::from_value(raw).unwrap(); + assert_eq!(memory.visibility, MemoryVisibility::Private); + } + + #[test] + fn create_request_serializes_visibility_into_share_config() { + let req = CreateContextMemoryRequest { + name: "test".into(), + question: "Q".into(), + answer: "A".into(), + title: None, + description: None, + memory_type: MemoryType::Note, + memory_scope: MemoryScope::EntityScoped, + share_config: ShareConfig { + visibility: MemoryVisibility::Private, + }, + primary_entity: None, + related_entities: None, + tags: Some(vec![TagLabel::manual("PII.Sensitive")]), + }; + let value = serde_json::to_value(&req).unwrap(); + assert_eq!(value["memoryType"], "Note"); + assert_eq!(value["memoryScope"], "EntityScoped"); + assert_eq!(value["shareConfig"]["visibility"], "Private"); + assert_eq!(value["tags"][0]["tagFQN"], "PII.Sensitive"); + assert_eq!(value["tags"][0]["labelType"], "Manual"); + } + + #[test] + fn search_results_parse_opensearch_shape() { + let raw = json!({ + "hits": { + "total": {"value": 42}, + "hits": [ + { + "_score": 1.5, + "_source": { + "id": "m1", + "name": "first", + "memoryType": "Note", + "memoryScope": "EntityScoped", + "shareConfig": {"visibility": "Private"} + } + } + ] + } + }); + let results = MemorySearchResults::from_opensearch(&raw).unwrap(); + assert_eq!(results.total, 42); + assert_eq!(results.hits.len(), 1); + assert_eq!(results.hits[0].score, 1.5); + assert_eq!(results.hits[0].memory.id, "m1"); + } + + #[tokio::test] + async fn list_memories_paginates_through_results() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/api/v1/contextCenter/memories")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": [ + {"id": "1", "name": "one", "memoryType": "Note", "memoryScope": "EntityScoped"} + ], + "paging": {"after": null} + }))) + .mount(&server) + .await; + + let client = make_client(&server.uri(), "tok"); + let memories = client.list_memories(None, None).await.unwrap(); + assert_eq!(memories.len(), 1); + assert_eq!(memories[0].id, "1"); + } + + #[tokio::test] + async fn get_memory_returns_parsed_response() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/api/v1/contextCenter/memories/abc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "abc", + "name": "test", + "memoryType": "Faq", + "memoryScope": "UserGlobal", + "shareConfig": {"visibility": "Entity"} + }))) + .mount(&server) + .await; + + let client = make_client(&server.uri(), "tok"); + let memory = client.get_memory("abc").await.unwrap(); + assert_eq!(memory.id, "abc"); + assert_eq!(memory.memory_type, MemoryType::Faq); + assert_eq!(memory.memory_scope, MemoryScope::UserGlobal); + assert_eq!(memory.visibility, MemoryVisibility::Entity); + } + + #[tokio::test] + async fn delete_memory_passes_hard_delete_param() { + let server = MockServer::start().await; + + Mock::given(method("DELETE")) + .and(path("/api/v1/contextCenter/memories/xyz")) + .and(query_param("hardDelete", "true")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let client = make_client(&server.uri(), "tok"); + client.delete_memory("xyz", true).await.unwrap(); + } } diff --git a/cli/src/commands/chat.rs b/cli/src/commands/chat.rs index 488c8dc..4a3ec27 100644 --- a/cli/src/commands/chat.rs +++ b/cli/src/commands/chat.rs @@ -1,14 +1,48 @@ //! Interactive chat command with TUI interface. +use crate::config; use crate::error::CliResult; use crate::tui::run_tui; +use std::path::PathBuf; /// Run interactive chat session with an agent. -/// If agent_name is None, the TUI will show agent selection on start. +/// +/// Routing logic: +/// - `use_default = true` → use the platform's default agent (PLANNER / CHAT_MODE). +/// The TUI calls `stream_default_agent` for each message. +/// - `agent_name = Some(name)` → use the named dynamic agent (existing behaviour). +/// - both `None` / `false` → show the agent-selection menu on start. +/// +/// The `debug` argument carries the raw clap value: +/// - `None`: debug disabled +/// - `Some("")`: enabled, write to default path (`~/.ai-sdk/chat-debug.log`) +/// - `Some(path)`: enabled, write to caller-provided path pub async fn run_chat( profile: &str, agent_name: Option<&str>, + use_default: bool, conversation_id: Option<&str>, + debug: Option, ) -> CliResult<()> { - run_tui(profile, agent_name, conversation_id.map(String::from)).await + let debug_log_path: Option = match debug { + None => None, + Some(s) if s.is_empty() => Some(config::config_dir()?.join("chat-debug.log")), + Some(s) => Some(PathBuf::from(s)), + }; + + if let Some(path) = debug_log_path.as_ref() { + eprintln!( + "[ai-sdk] SSE debug events will be written to {}", + path.display() + ); + } + + run_tui( + profile, + agent_name, + use_default, + conversation_id.map(String::from), + debug_log_path, + ) + .await } diff --git a/cli/src/commands/invoke.rs b/cli/src/commands/invoke.rs index 491a959..fb8ee47 100644 --- a/cli/src/commands/invoke.rs +++ b/cli/src/commands/invoke.rs @@ -2,15 +2,19 @@ use crate::client::{AISdkClient, InvokeResponse}; use crate::config::ResolvedConfig; -use crate::error::CliResult; +use crate::error::{CliError, CliResult}; use crate::streaming::{process_stream_with_debug, Sender}; use colored::Colorize; use std::io::{self, Write}; /// Invoke an agent synchronously. +/// +/// When `use_default` is `true` (or `agent_name` is `None`), the platform's +/// default agent (PLANNER / CHAT_MODE) is used instead of a named dynamic agent. pub async fn run_invoke( profile: &str, - agent_name: &str, + agent_name: Option<&str>, + use_default: bool, message: Option<&str>, conversation_id: Option<&str>, json_output: bool, @@ -18,7 +22,15 @@ pub async fn run_invoke( let config = ResolvedConfig::load(profile)?; let client = AISdkClient::new(&config)?; - let response = client.invoke(agent_name, message, conversation_id).await?; + let response = match (use_default, agent_name) { + (false, Some(name)) => client.invoke(name, message, conversation_id).await?, + _ => { + let msg = message.ok_or_else(|| { + CliError::Other("A message is required when using the default agent".into()) + })?; + client.invoke_default_agent(msg, conversation_id).await? + } + }; if json_output { print_json(&response)?; @@ -30,9 +42,14 @@ pub async fn run_invoke( } /// Invoke an agent with streaming output. +/// +/// When `use_default` is `true` (or `agent_name` is `None`), the platform's +/// default agent (PLANNER / CHAT_MODE) is used instead of a named dynamic agent. +#[allow(clippy::too_many_arguments)] pub async fn run_stream( profile: &str, - agent_name: &str, + agent_name: Option<&str>, + use_default: bool, message: Option<&str>, conversation_id: Option<&str>, json_output: bool, @@ -42,7 +59,15 @@ pub async fn run_stream( let config = ResolvedConfig::load(profile)?; let client = AISdkClient::new(&config)?; - let response = client.stream(agent_name, message, conversation_id).await?; + let response = match (use_default, agent_name) { + (false, Some(name)) => client.stream(name, message, conversation_id).await?, + _ => { + let msg = message.ok_or_else(|| { + CliError::Other("A message is required when using the default agent".into()) + })?; + client.stream_default_agent(msg, conversation_id).await? + } + }; if json_output { // For JSON output, collect all content and output as JSON at the end @@ -80,6 +105,7 @@ pub async fn run_stream( conversation_id: final_conversation_id.unwrap_or_default(), response: full_content, tools_used, + thinking_steps: Vec::new(), usage: None, }; @@ -93,15 +119,15 @@ pub async fn run_stream( |message| { match message.sender { Sender::System => { - // System messages are "thinking" content + // System messages are "thinking" content. Each step + // is a discrete reasoning event, so render one per + // line for readability. if show_thinking { let text = message.text_content(); if !text.is_empty() { - if !in_thinking { - in_thinking = true; - } - // Display thinking in grey - print!("{}", text.bright_black()); + in_thinking = true; + let trimmed = text.trim_end(); + println!("{}", trimmed.bright_black()); let _ = io::stdout().flush(); } } diff --git a/cli/src/commands/memories.rs b/cli/src/commands/memories.rs new file mode 100644 index 0000000..caa55aa --- /dev/null +++ b/cli/src/commands/memories.rs @@ -0,0 +1,462 @@ +//! Context Center memory management commands. + +use crate::client::{ + AISdkClient, ContextMemory, CreateContextMemoryRequest, EntityReference, MemoryScope, + MemorySearchResults, MemoryType, MemoryVisibility, ShareConfig, TagLabel, +}; +use crate::config::ResolvedConfig; +use crate::error::{CliError, CliResult}; +use clap::Subcommand; +use colored::Colorize; + +/// Memory subcommands for `ai-sdk memories`. +#[derive(Subcommand)] +#[allow(clippy::large_enum_variant)] +pub enum MemoriesCommand { + /// List Context Center memories + List { + /// Filter to memories attached to this primary-entity FQN + #[arg(long = "entity-fqn")] + entity_fqn: Option, + + /// Maximum number of memories to return + #[arg(short, long)] + limit: Option, + + /// Output response as JSON + #[arg(long)] + json: bool, + }, + + /// Get a Context Center memory by ID + Get { + /// Memory ID (UUID) + id: String, + + /// Output response as JSON + #[arg(long)] + json: bool, + }, + + /// Create a new Context Center memory + Create { + /// Stable system name for the memory (must be unique) + #[arg(long)] + name: String, + + /// Canonical question / instruction + #[arg(long)] + question: String, + + /// Canonical answer / retained guidance + #[arg(long)] + answer: String, + + /// Short title shown in the Context Center + #[arg(long)] + title: Option, + + /// Optional markdown description + #[arg(long)] + description: Option, + + /// Memory type (preference, useCase, note, runbook, faq) + #[arg(long = "memory-type", default_value = "note")] + memory_type: String, + + /// Memory scope (userGlobal, entityScoped) + #[arg(long = "memory-scope", default_value = "entityScoped")] + memory_scope: String, + + /// Visibility level (private, entity, shared) + #[arg(long, default_value = "private")] + visibility: String, + + /// ID of the primary entity this memory attaches to + #[arg(long = "primary-entity-id")] + primary_entity_id: Option, + + /// Type of the primary entity (e.g. table, dashboard) + #[arg(long = "primary-entity-type")] + primary_entity_type: Option, + + /// FQN of the primary entity + #[arg(long = "primary-entity-fqn")] + primary_entity_fqn: Option, + + /// Comma-separated list of tag FQNs + #[arg(long, value_delimiter = ',')] + tags: Option>, + + /// Output response as JSON + #[arg(long)] + json: bool, + }, + + /// Delete a Context Center memory by ID + Delete { + /// Memory ID (UUID) + id: String, + + /// Hard delete (default is soft delete) + #[arg(long)] + hard: bool, + }, + + /// Hybrid NLQ search over Context Center memories + Search { + /// Natural-language query string + query: String, + + /// Number of results to return (1-100) + #[arg(long, default_value_t = 15)] + size: usize, + + /// Pagination offset + #[arg(long, default_value_t = 0)] + from: usize, + + /// Output response as JSON + #[arg(long)] + json: bool, + }, +} + +/// Dispatch for the `memories` subcommand. +pub async fn run(profile: &str, cmd: MemoriesCommand) -> CliResult<()> { + let config = ResolvedConfig::load(profile)?; + let client = AISdkClient::new(&config)?; + + match cmd { + MemoriesCommand::List { + entity_fqn, + limit, + json, + } => run_list(&client, entity_fqn.as_deref(), limit, json).await, + MemoriesCommand::Get { id, json } => run_get(&client, &id, json).await, + MemoriesCommand::Create { + name, + question, + answer, + title, + description, + memory_type, + memory_scope, + visibility, + primary_entity_id, + primary_entity_type, + primary_entity_fqn, + tags, + json, + } => { + run_create( + &client, + CreateArgs { + name, + question, + answer, + title, + description, + memory_type, + memory_scope, + visibility, + primary_entity_id, + primary_entity_type, + primary_entity_fqn, + tags, + }, + json, + ) + .await + } + MemoriesCommand::Delete { id, hard } => run_delete(&client, &id, hard).await, + MemoriesCommand::Search { + query, + size, + from, + json, + } => run_search(&client, &query, size, from, json).await, + } +} + +/// Internal struct grouping create arguments to keep the dispatcher signature small. +struct CreateArgs { + name: String, + question: String, + answer: String, + title: Option, + description: Option, + memory_type: String, + memory_scope: String, + visibility: String, + primary_entity_id: Option, + primary_entity_type: Option, + primary_entity_fqn: Option, + tags: Option>, +} + +async fn run_list( + client: &AISdkClient, + entity_fqn: Option<&str>, + limit: Option, + json: bool, +) -> CliResult<()> { + let memories = client.list_memories(entity_fqn, limit).await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&memories).unwrap_or_default() + ); + return Ok(()); + } + + if memories.is_empty() { + println!("No memories found."); + return Ok(()); + } + + println!("{}", "Memories:".bold()); + println!(); + + for memory in &memories { + print_memory_summary(memory); + println!(); + } + + Ok(()) +} + +async fn run_get(client: &AISdkClient, id: &str, json: bool) -> CliResult<()> { + let memory = client.get_memory(id).await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&memory).unwrap_or_default() + ); + return Ok(()); + } + + print_memory_detail(&memory); + Ok(()) +} + +async fn run_create(client: &AISdkClient, args: CreateArgs, json: bool) -> CliResult<()> { + let memory_type = MemoryType::from_cli_str(&args.memory_type) + .map_err(|e| CliError::Other(format!("--memory-type: {e}")))?; + let memory_scope = MemoryScope::from_cli_str(&args.memory_scope) + .map_err(|e| CliError::Other(format!("--memory-scope: {e}")))?; + let visibility = MemoryVisibility::from_cli_str(&args.visibility) + .map_err(|e| CliError::Other(format!("--visibility: {e}")))?; + + let primary_entity = build_entity_reference( + args.primary_entity_id, + args.primary_entity_type, + args.primary_entity_fqn, + ); + + let tags = args + .tags + .map(|ts| ts.into_iter().map(TagLabel::manual).collect::>()); + + let request = CreateContextMemoryRequest { + name: args.name, + question: args.question, + answer: args.answer, + title: args.title, + description: args.description, + memory_type, + memory_scope, + share_config: ShareConfig { visibility }, + primary_entity, + related_entities: None, + tags, + }; + + let memory = client.create_memory(&request).await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&memory).unwrap_or_default() + ); + return Ok(()); + } + + println!( + "{} Created memory: {}", + "Success!".green().bold(), + memory.name + ); + println!(" {} {}", "ID:".cyan(), memory.id); + if let Some(title) = &memory.title { + println!(" {} {}", "Title:".cyan(), title); + } + println!(" {} {}", "Type:".cyan(), memory.memory_type.as_wire()); + println!(" {} {}", "Scope:".cyan(), memory.memory_scope.as_wire()); + println!(" {} {}", "Visibility:".cyan(), memory.visibility.as_wire()); + Ok(()) +} + +async fn run_delete(client: &AISdkClient, id: &str, hard: bool) -> CliResult<()> { + client.delete_memory(id, hard).await?; + let mode = if hard { "hard" } else { "soft" }; + println!( + "{} Deleted memory {} ({} delete)", + "Success!".green().bold(), + id, + mode + ); + Ok(()) +} + +async fn run_search( + client: &AISdkClient, + query: &str, + size: usize, + from: usize, + json: bool, +) -> CliResult<()> { + let results: MemorySearchResults = client.search_memories(query, None, size, from).await?; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&results).unwrap_or_default() + ); + return Ok(()); + } + + if results.hits.is_empty() { + println!("No memories matched."); + return Ok(()); + } + + println!( + "{} {} {}", + "Search Results:".bold(), + results.hits.len(), + format!("(of {} total)", results.total).dimmed() + ); + println!(); + + for hit in &results.hits { + println!( + " {} {} {}", + "*".cyan(), + hit.memory + .title + .as_deref() + .unwrap_or(&hit.memory.name) + .bold(), + format!("[score: {:.3}]", hit.score).dimmed() + ); + println!(" Name: {}", hit.memory.name.dimmed()); + println!(" ID: {}", hit.memory.id.dimmed()); + if !hit.memory.question.is_empty() { + let q = truncate_for_display(&hit.memory.question, 80); + println!(" Q: {}", q.dimmed()); + } + println!(); + } + + Ok(()) +} + +fn build_entity_reference( + id: Option, + entity_type: Option, + fqn: Option, +) -> Option { + if id.is_none() && entity_type.is_none() && fqn.is_none() { + return None; + } + Some(EntityReference { + id, + name: None, + entity_type, + fully_qualified_name: fqn, + display_name: None, + }) +} + +fn print_memory_summary(memory: &ContextMemory) { + let display = memory.title.as_deref().unwrap_or(&memory.name); + println!(" {} {}", "*".cyan(), display.bold()); + println!(" Name: {}", memory.name.dimmed()); + println!(" ID: {}", memory.id.dimmed()); + println!( + " Type: {} | Scope: {} | Visibility: {}", + memory.memory_type.as_wire().dimmed(), + memory.memory_scope.as_wire().dimmed(), + memory.visibility.as_wire().dimmed() + ); + if !memory.question.is_empty() { + let q = truncate_for_display(&memory.question, 80); + println!(" Q: {}", q.dimmed()); + } + if let Some(entity) = &memory.primary_entity { + if let Some(fqn) = &entity.fully_qualified_name { + println!(" Entity: {}", fqn.dimmed()); + } + } +} + +fn print_memory_detail(memory: &ContextMemory) { + let display = memory.title.as_deref().unwrap_or(&memory.name); + println!("{}", display.bold()); + println!(); + println!(" {} {}", "Name:".cyan(), memory.name); + println!(" {} {}", "ID:".cyan(), memory.id); + if let Some(fqn) = &memory.fully_qualified_name { + println!(" {} {}", "FQN:".cyan(), fqn); + } + println!(" {} {}", "Type:".cyan(), memory.memory_type.as_wire()); + println!(" {} {}", "Scope:".cyan(), memory.memory_scope.as_wire()); + println!(" {} {}", "Visibility:".cyan(), memory.visibility.as_wire()); + if !memory.question.is_empty() { + println!(" {}", "Question:".cyan()); + for line in memory.question.lines() { + println!(" {line}"); + } + } + if !memory.answer.is_empty() { + println!(" {}", "Answer:".cyan()); + for line in memory.answer.lines() { + println!(" {line}"); + } + } + if let Some(summary) = &memory.summary { + if !summary.is_empty() { + println!(" {} {}", "Summary:".cyan(), summary); + } + } + if let Some(entity) = &memory.primary_entity { + let label = entity + .fully_qualified_name + .as_deref() + .or(entity.name.as_deref()) + .unwrap_or(""); + if !label.is_empty() { + println!(" {} {}", "Primary Entity:".cyan(), label); + } + } + println!(" {} {}", "Usage Count:".cyan(), memory.usage_count); + if let Some(ts) = memory.last_used_at { + println!(" {} {}", "Last Used (epoch ms):".cyan(), ts); + } + if memory.deleted { + println!(" {} {}", "Deleted:".cyan(), "yes".red()); + } +} + +/// Truncate a string at character boundaries for display. +fn truncate_for_display(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + let prefix: String = s.chars().take(max_chars.saturating_sub(3)).collect(); + format!("{prefix}...") + } +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 6d36cd5..7437a08 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -6,4 +6,5 @@ pub mod bots; pub mod chat; pub mod configure; pub mod invoke; +pub mod memories; pub mod personas; diff --git a/cli/src/config.rs b/cli/src/config.rs index 693f28b..cac4842 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -44,7 +44,7 @@ impl Default for ProfileConfig { } fn default_timeout() -> u64 { - 120 + 900 } /// Credentials structure (stored separately). diff --git a/cli/src/main.rs b/cli/src/main.rs index 043ee60..e4e3131 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -19,6 +19,7 @@ //! ai-sdk invoke "message" --stream # Streaming output //! ai-sdk invoke "message" --json # JSON output //! ai-sdk invoke "message" -c # Continue conversation +//! ai-sdk invoke --default "message" # Use platform default agent (PLANNER) //! ``` mod client; @@ -78,14 +79,24 @@ enum Commands { action: AbilitiesAction, }, + /// Manage Context Center memories (CRUD + hybrid search) + Memories { + #[command(subcommand)] + action: commands::memories::MemoriesCommand, + }, + /// Invoke an agent with a message Invoke { - /// Name of the agent to invoke - agent: String, + /// Name of the agent to invoke (omit when --default is set) + agent: Option, /// Message to send to the agent (optional - uses agent's default prompt if omitted) message: Option, + /// Use the platform's default agent (PLANNER / CHAT_MODE) instead of a named agent + #[arg(short = 'D', long = "default")] + use_default: bool, + /// Enable streaming output #[arg(short, long)] stream: bool, @@ -109,12 +120,22 @@ enum Commands { /// Start an interactive chat session with an agent Chat { - /// Name of the agent to chat with (optional - select from menu if omitted) + /// Name of the agent to chat with (optional - select from menu if omitted, + /// or use --default for the platform's default agent) agent: Option, + /// Use the platform's default agent (PLANNER / CHAT_MODE) instead of a named agent + #[arg(short = 'D', long = "default")] + use_default: bool, + /// Continue an existing conversation #[arg(short = 'c', long = "conversation")] conversation_id: Option, + + /// Write SSE debug events to a file. Pass --debug alone to use the + /// default path (~/.ai-sdk/chat-debug.log) or --debug PATH to override. + #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")] + debug: Option, }, } @@ -416,9 +437,12 @@ async fn main() { } }, + Commands::Memories { action } => commands::memories::run(&cli.profile, action).await, + Commands::Invoke { agent, message, + use_default, stream, json, conversation_id, @@ -428,7 +452,8 @@ async fn main() { if stream || thinking { commands::invoke::run_stream( &cli.profile, - &agent, + agent.as_deref(), + use_default, message.as_deref(), conversation_id.as_deref(), json, @@ -439,7 +464,8 @@ async fn main() { } else { commands::invoke::run_invoke( &cli.profile, - &agent, + agent.as_deref(), + use_default, message.as_deref(), conversation_id.as_deref(), json, @@ -450,10 +476,18 @@ async fn main() { Commands::Chat { agent, + use_default, conversation_id, + debug, } => { - commands::chat::run_chat(&cli.profile, agent.as_deref(), conversation_id.as_deref()) - .await + commands::chat::run_chat( + &cli.profile, + agent.as_deref(), + use_default, + conversation_id.as_deref(), + debug, + ) + .await } }; diff --git a/cli/src/streaming.rs b/cli/src/streaming.rs index 742759b..b5a6392 100644 --- a/cli/src/streaming.rs +++ b/cli/src/streaming.rs @@ -10,6 +10,8 @@ use crate::error::{CliError, CliResult}; use futures_util::StreamExt; use reqwest::Response; use serde::Deserialize; +use std::io::Write; +use std::path::Path; /// Sender types matching ChatMessage. #[derive(Debug, Clone, PartialEq, Deserialize)] @@ -167,15 +169,52 @@ pub async fn process_stream(response: Response, handler: F) -> CliResult( response: Response, - mut handler: F, + handler: F, debug: bool, ) -> CliResult> +where + F: FnMut(ChatMessage), +{ + let writer: Option> = if debug { + Some(Box::new(std::io::stderr())) + } else { + None + }; + process_stream_inner(response, handler, writer).await +} + +/// Process a streaming response and append debug events to a file. +/// +/// The file is opened in append mode so multiple streams in the same session +/// accumulate into one log. Callers that want a fresh log per session should +/// truncate the file once before invoking this. +pub async fn process_stream_with_debug_file( + response: Response, + handler: F, + debug_log_path: &Path, +) -> CliResult> +where + F: FnMut(ChatMessage), +{ + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(debug_log_path)?; + process_stream_inner(response, handler, Some(Box::new(file))).await +} + +/// Shared implementation for stream processing. +async fn process_stream_inner( + response: Response, + mut handler: F, + mut debug_writer: Option>, +) -> CliResult> where F: FnMut(ChatMessage), { @@ -186,7 +225,28 @@ where let mut message_count = 0; while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| CliError::NetworkError(e.to_string()))?; + let chunk = match chunk { + Ok(c) => c, + Err(e) => { + // Surface where the stream died — the most useful detail when + // debugging "error decoding response body" failures. + if let Some(w) = debug_writer.as_mut() { + let _ = writeln!( + w, + "[DEBUG] Stream error after {event_count} events, {message_count} messages: {e}" + ); + if !buffer.is_empty() { + let _ = writeln!( + w, + "[DEBUG] Unparsed buffer at error ({} bytes): {buffer:?}", + buffer.len() + ); + } + let _ = w.flush(); + } + return Err(CliError::NetworkError(e.to_string())); + } + }; let text = String::from_utf8_lossy(&chunk); buffer.push_str(&text); @@ -197,8 +257,8 @@ where if let Some((event_type, payload)) = parse_event(&event_str) { event_count += 1; - if debug { - eprintln!("[DEBUG] Event #{event_count}: {event_type:?}"); + if let Some(w) = debug_writer.as_mut() { + let _ = writeln!(w, "[DEBUG] Event #{event_count}: {event_type:?}"); } // Capture conversation ID from any event @@ -211,8 +271,9 @@ where if let Some(data) = payload.data { if let Some(message) = data.message { message_count += 1; - if debug { - eprintln!( + if let Some(w) = debug_writer.as_mut() { + let _ = writeln!( + w, "[DEBUG] Message #{}: sender={:?}, content_blocks={}", message_count, message.sender, @@ -231,8 +292,8 @@ where if !buffer.trim().is_empty() { if let Some((event_type, payload)) = parse_event(&buffer) { event_count += 1; - if debug { - eprintln!("[DEBUG] Final Event #{event_count}: {event_type:?}"); + if let Some(w) = debug_writer.as_mut() { + let _ = writeln!(w, "[DEBUG] Final Event #{event_count}: {event_type:?}"); } if let Some(id) = &payload.conversation_id { @@ -242,8 +303,9 @@ where if let Some(data) = payload.data { if let Some(message) = data.message { message_count += 1; - if debug { - eprintln!( + if let Some(w) = debug_writer.as_mut() { + let _ = writeln!( + w, "[DEBUG] Final Message #{}: sender={:?}", message_count, message.sender ); @@ -255,8 +317,12 @@ where } } - if debug { - eprintln!("[DEBUG] Stream complete: {event_count} events, {message_count} messages"); + if let Some(w) = debug_writer.as_mut() { + let _ = writeln!( + w, + "[DEBUG] Stream complete: {event_count} events, {message_count} messages" + ); + let _ = w.flush(); } Ok(conversation_id) @@ -352,4 +418,44 @@ data: {"conversationId":"conv-1","data":{"message":{"sender":"assistant","conten let tools = message.tools_used(); assert_eq!(tools, vec!["searchMetadata"]); } + + #[tokio::test] + async fn test_process_stream_with_debug_file_writes_events() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let body = "event: stream-start\ndata: {\"streamId\":\"s1\",\"conversationId\":\"c1\",\"sequence\":0}\n\nevent: message\ndata: {\"conversationId\":\"c1\",\"data\":{\"message\":{\"sender\":\"assistant\",\"content\":[{\"textMessage\":\"hi\"}],\"conversationId\":\"c1\"}}}\n\nevent: stream-completed\ndata: {\"message\":\"done\",\"type\":\"completed\"}\n\n"; + + Mock::given(method("GET")) + .and(path("/sse")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(body), + ) + .mount(&server) + .await; + + let response = reqwest::get(format!("{}/sse", server.uri())).await.unwrap(); + + let log_path = + std::env::temp_dir().join(format!("ai-sdk-test-debug-{}.log", uuid::Uuid::new_v4())); + // Ensure clean slate + let _ = std::fs::remove_file(&log_path); + + let conv_id = process_stream_with_debug_file(response, |_msg| {}, &log_path) + .await + .unwrap(); + assert_eq!(conv_id.as_deref(), Some("c1")); + + let contents = std::fs::read_to_string(&log_path).unwrap(); + assert!(contents.contains("Event #1: StreamStart")); + assert!(contents.contains("Event #2: Message")); + assert!(contents.contains("Message #1: sender=Assistant")); + assert!(contents.contains("Event #3: StreamCompleted")); + assert!(contents.contains("Stream complete: 3 events, 1 messages")); + + let _ = std::fs::remove_file(&log_path); + } } diff --git a/cli/src/tui/app.rs b/cli/src/tui/app.rs index f0f0b73..5b249c7 100644 --- a/cli/src/tui/app.rs +++ b/cli/src/tui/app.rs @@ -65,10 +65,17 @@ impl DisplayMessage { /// Spinner animation frames. const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +/// Sentinel id for the synthetic "AskCollate (default agent)" entry shown +/// at the top of the agent selection menu. Selecting an item with this id +/// routes the chat to the platform's default agent (no specific agent name). +pub const DEFAULT_AGENT_ITEM_ID: &str = "__default_agent__"; + /// Main application state. pub struct App { - /// Name of the agent being chatted with. + /// Name of the agent being chatted with (display label in TUI header). pub agent_name: String, + /// Whether to use the platform's default agent (PLANNER / CHAT_MODE). + pub use_default: bool, /// Conversation ID for multi-turn conversations. pub conversation_id: Option, /// Chat message history. @@ -99,11 +106,12 @@ pub struct App { impl App { /// Create a new app instance. - pub fn new(agent_name: String, conversation_id: Option) -> Self { + pub fn new(agent_name: String, conversation_id: Option, use_default: bool) -> Self { let mut agent_select = SingleSelect::new("Select Agent"); agent_select.focused = true; Self { agent_name, + use_default, conversation_id, messages: Vec::new(), input: String::new(), @@ -153,8 +161,15 @@ impl App { self.streaming_content.push_str(content); } - /// Append thinking content during streaming. + /// Append a thinking step during streaming. + /// + /// Each call is a discrete reasoning step, so we ensure it lands on its + /// own line in the rendered output. If the buffer already has content + /// that doesn't end in a newline, we insert one before appending. pub fn append_thinking_content(&mut self, content: &str) { + if !self.thinking_content.is_empty() && !self.thinking_content.ends_with('\n') { + self.thinking_content.push('\n'); + } self.thinking_content.push_str(content); } @@ -261,20 +276,40 @@ impl App { } /// Show the agent selection menu. + /// + /// A synthetic "AskCollate" entry is prepended to the list as the + /// default option — selecting it routes the chat to the platform's + /// default agent rather than a named dynamic agent. pub fn show_agents(&mut self, agents: Vec) { - let items: Vec = agents - .into_iter() - .map(|name| SelectItem::new(name.clone(), name)) - .collect(); + let mut items: Vec = Vec::with_capacity(agents.len() + 1); + items.push( + SelectItem::new(DEFAULT_AGENT_ITEM_ID, "AskCollate") + .display_name("AskCollate (default)"), + ); + items.extend( + agents + .into_iter() + .map(|name| SelectItem::new(name.clone(), name)), + ); self.agent_select.set_items(items); self.agent_select.focused = true; self.show_agent_menu = true; } /// Select the current agent from menu. + /// + /// If the user picked the synthetic default entry, switch the chat + /// to default-agent mode (`use_default = true`) and label the header + /// "AskCollate". Otherwise route to the named dynamic agent. pub fn select_agent(&mut self) { if let Some(agent) = self.agent_select.selected() { - self.agent_name = agent.name.clone(); + if agent.id == DEFAULT_AGENT_ITEM_ID { + self.use_default = true; + self.agent_name = "AskCollate".to_string(); + } else { + self.use_default = false; + self.agent_name = agent.name.clone(); + } self.conversation_id = None; // Start fresh conversation with new agent self.messages.clear(); } @@ -302,3 +337,81 @@ impl App { input.trim().starts_with('/') } } + +#[cfg(test)] +mod tests { + use super::*; + + fn fresh_app() -> App { + App::new(String::new(), None, false) + } + + #[test] + fn show_agents_prepends_default_askcollate_entry() { + let mut app = fresh_app(); + app.show_agents(vec!["agent-a".to_string(), "agent-b".to_string()]); + + assert!(app.show_agent_menu); + let items = app.agent_select.filtered_items(); + assert_eq!(items.len(), 3); + assert_eq!(items[0].id, DEFAULT_AGENT_ITEM_ID); + assert_eq!(items[0].display_name, "AskCollate (default)"); + assert_eq!(items[1].id, "agent-a"); + assert_eq!(items[2].id, "agent-b"); + } + + #[test] + fn show_agents_with_empty_list_still_offers_default() { + let mut app = fresh_app(); + app.show_agents(vec![]); + let items = app.agent_select.filtered_items(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, DEFAULT_AGENT_ITEM_ID); + } + + #[test] + fn select_default_entry_routes_to_default_agent() { + let mut app = fresh_app(); + app.show_agents(vec!["agent-a".to_string()]); + // First item is the synthetic AskCollate entry — already preselected. + app.select_agent(); + + assert!(app.use_default); + assert_eq!(app.agent_name, "AskCollate"); + assert!(!app.show_agent_menu); + } + + #[test] + fn append_thinking_steps_separates_with_newlines() { + let mut app = fresh_app(); + app.append_thinking_content("step 1"); + app.append_thinking_content("step 2"); + app.append_thinking_content("step 3"); + assert_eq!(app.thinking_content, "step 1\nstep 2\nstep 3"); + // .lines() should yield exactly three rendered lines. + let lines: Vec<&str> = app.thinking_content.lines().collect(); + assert_eq!(lines, vec!["step 1", "step 2", "step 3"]); + } + + #[test] + fn append_thinking_preserves_explicit_newlines() { + let mut app = fresh_app(); + // A step that already ended in a newline shouldn't pick up an extra one. + app.append_thinking_content("step 1\n"); + app.append_thinking_content("step 2"); + assert_eq!(app.thinking_content, "step 1\nstep 2"); + } + + #[test] + fn select_named_agent_clears_default_flag() { + let mut app = fresh_app(); + app.use_default = true; // start in default mode + app.show_agents(vec!["my-agent".to_string()]); + // Move past the synthetic default entry to "my-agent". + app.scroll_down(); + app.select_agent(); + + assert!(!app.use_default); + assert_eq!(app.agent_name, "my-agent"); + } +} diff --git a/cli/src/tui/create_agent.rs b/cli/src/tui/create_agent.rs index 8c24daa..21430f6 100644 --- a/cli/src/tui/create_agent.rs +++ b/cli/src/tui/create_agent.rs @@ -787,10 +787,8 @@ async fn run_wizard_loop( wizard.next_step(); } } - KeyCode::BackTab => { - if wizard.step == Step::BasicDetails { - wizard.toggle_basic_focus(); - } + KeyCode::BackTab if wizard.step == Step::BasicDetails => { + wizard.toggle_basic_focus(); } KeyCode::Backspace => { wizard.handle_backspace(); diff --git a/cli/src/tui/create_persona.rs b/cli/src/tui/create_persona.rs index ed53971..6d9204d 100644 --- a/cli/src/tui/create_persona.rs +++ b/cli/src/tui/create_persona.rs @@ -433,10 +433,8 @@ async fn run_wizard_loop( wizard.next_step(); } } - KeyCode::BackTab => { - if wizard.step == Step::BasicDetails { - wizard.toggle_basic_focus(); - } + KeyCode::BackTab if wizard.step == Step::BasicDetails => { + wizard.toggle_basic_focus(); } KeyCode::Backspace => { if wizard.step == Step::BasicDetails && wizard.basic_focus == BasicDetailsFocus::Name && wizard.name_input.value.is_empty() { @@ -456,19 +454,17 @@ async fn run_wizard_loop( KeyCode::Right => { wizard.handle_right(); } - KeyCode::Up => { + KeyCode::Up if wizard.step == Step::BasicDetails - && wizard.basic_focus == BasicDetailsFocus::Description - { - wizard.toggle_basic_focus(); - } + && wizard.basic_focus == BasicDetailsFocus::Description => + { + wizard.toggle_basic_focus(); } - KeyCode::Down => { + KeyCode::Down if wizard.step == Step::BasicDetails - && wizard.basic_focus == BasicDetailsFocus::Name - { - wizard.toggle_basic_focus(); - } + && wizard.basic_focus == BasicDetailsFocus::Name => + { + wizard.toggle_basic_focus(); } KeyCode::Char(c) => { wizard.handle_char(c); diff --git a/cli/src/tui/markdown.rs b/cli/src/tui/markdown.rs index cad97a0..df1f7b4 100644 --- a/cli/src/tui/markdown.rs +++ b/cli/src/tui/markdown.rs @@ -26,6 +26,9 @@ pub fn render_markdown(text: &str, width: usize) -> Vec> { let mut in_code_block = false; let mut code_lang: Option = None; let mut code_content = String::new(); + // Stack of link destination URLs so we can render `(url)` after the + // visible link text on TagEnd::Link. Stack-shaped to handle nesting. + let mut link_urls: Vec = Vec::new(); for event in parser { match event { @@ -60,6 +63,14 @@ pub fn render_markdown(text: &str, width: usize) -> Vec> { Tag::Emphasis => { style_stack.push(Style::default().add_modifier(Modifier::ITALIC)); } + Tag::Link { dest_url, .. } => { + link_urls.push(dest_url.to_string()); + style_stack.push( + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::UNDERLINED), + ); + } Tag::List(_) => {} Tag::Item => { // Add bullet point @@ -97,11 +108,19 @@ pub fn render_markdown(text: &str, width: usize) -> Vec> { TagEnd::Strong | TagEnd::Emphasis => { style_stack.pop(); } - TagEnd::Item => { - if !current_spans.is_empty() { - lines.push(Line::from(std::mem::take(&mut current_spans))); + TagEnd::Link => { + style_stack.pop(); + // Append " (url)" in dimmed text after the visible label. + if let Some(url) = link_urls.pop() { + current_spans.push(Span::styled( + format!(" ({url})"), + Style::default().fg(Color::DarkGray), + )); } } + TagEnd::Item if !current_spans.is_empty() => { + lines.push(Line::from(std::mem::take(&mut current_spans))); + } _ => {} } } @@ -120,10 +139,8 @@ pub fn render_markdown(text: &str, width: usize) -> Vec> { Style::default().bg(Color::DarkGray).fg(Color::White), )); } - Event::SoftBreak | Event::HardBreak => { - if !current_spans.is_empty() { - lines.push(Line::from(std::mem::take(&mut current_spans))); - } + Event::SoftBreak | Event::HardBreak if !current_spans.is_empty() => { + lines.push(Line::from(std::mem::take(&mut current_spans))); } _ => {} } @@ -142,6 +159,31 @@ pub fn render_markdown(text: &str, width: usize) -> Vec> { lines } +/// Render markdown with all foreground colors collapsed to dark gray — +/// used for thinking steps so they keep the dimmed look while still +/// applying bold/italic/link/code-block structure from the markdown. +/// +/// Background colors and modifiers (bold/italic/underline) are preserved. +pub fn render_markdown_dimmed(text: &str, width: usize) -> Vec> { + let lines = render_markdown(text, width); + lines + .into_iter() + .map(|line| { + let spans: Vec> = line + .spans + .into_iter() + .map(|span| { + let dimmed = Style::default() + .fg(Color::DarkGray) + .add_modifier(span.style.add_modifier); + Span::styled(span.content, dimmed) + }) + .collect(); + Line::from(spans) + }) + .collect() +} + /// Render a code block with syntax highlighting and border. fn render_code_block(code: &str, lang: Option<&str>, width: usize) -> Vec> { let mut lines = Vec::new(); @@ -232,4 +274,63 @@ mod tests { let lines = render_markdown(md, 80); assert!(lines.len() > 1); } + + fn collect_text(lines: &[Line<'static>]) -> String { + lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[test] + fn link_renders_label_with_url_appended() { + let lines = render_markdown("see [docs](https://example.com)", 80); + let text = collect_text(&lines); + assert!(text.contains("docs"), "label missing in: {text}"); + assert!( + text.contains("(https://example.com)"), + "url missing in: {text}" + ); + // the bare `[docs](https://example.com)` literal should not appear + assert!(!text.contains("[docs]"), "raw markdown leaked in: {text}"); + } + + #[test] + fn link_label_uses_underline_modifier() { + let lines = render_markdown("[foo](http://x)", 80); + let label_span = lines + .iter() + .flat_map(|l| l.spans.iter()) + .find(|s| s.content.as_ref() == "foo") + .expect("label span"); + assert!(label_span.style.add_modifier.contains(Modifier::UNDERLINED)); + } + + #[test] + fn dimmed_renderer_keeps_modifiers_but_overrides_color() { + let lines = render_markdown_dimmed("**bold** and [link](http://x)", 80); + // Every span should have DarkGray foreground. + for line in &lines { + for span in &line.spans { + assert_eq!( + span.style.fg, + Some(Color::DarkGray), + "span '{}' is not dimmed", + span.content + ); + } + } + // Modifiers preserved: bold span exists. + let bold_present = lines + .iter() + .flat_map(|l| l.spans.iter()) + .any(|s| s.style.add_modifier.contains(Modifier::BOLD)); + assert!(bold_present, "bold modifier was stripped"); + } } diff --git a/cli/src/tui/ui.rs b/cli/src/tui/ui.rs index b3c4d11..64a6ac7 100644 --- a/cli/src/tui/ui.rs +++ b/cli/src/tui/ui.rs @@ -1,11 +1,11 @@ //! UI layout and rendering for the TUI chat interface. use super::app::{App, DisplayMessage, Status}; -use super::markdown::render_markdown; +use super::markdown::{render_markdown, render_markdown_dimmed}; use crate::client::AISdkClient; use crate::config::ResolvedConfig; use crate::error::{CliError, CliResult}; -use crate::streaming::{process_stream, Sender}; +use crate::streaming::{process_stream, process_stream_with_debug_file, Sender}; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyModifiers}, @@ -22,6 +22,7 @@ use ratatui::{ Frame, Terminal, }; use std::io; +use std::path::PathBuf; use std::time::Duration; use tokio::sync::mpsc; @@ -42,16 +43,46 @@ enum AsyncEvent { } /// Run the TUI chat interface. -/// If agent_name is None, shows agent selection menu on start. +/// +/// Routing logic: +/// - `use_default = true` → use the platform's default agent (PLANNER / CHAT_MODE). +/// - `agent_name = Some(name)` → use the named dynamic agent (existing behaviour). +/// - both `None` / `false` → show the agent-selection menu on start. pub async fn run_tui( profile: &str, agent_name: Option<&str>, + use_default: bool, conversation_id: Option, + debug_log_path: Option, ) -> CliResult<()> { // Load config and create client first (needed for agent list) let config = ResolvedConfig::load(profile)?; let client = AISdkClient::new(&config)?; + // Truncate the debug log once per session and write a session header so + // multiple streams within this run accumulate under a single header. + if let Some(path) = debug_log_path.as_ref() { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + use std::io::Write as _; + let mut f = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path)?; + let agent_label = if use_default { + "default agent".to_string() + } else { + agent_name.unwrap_or("(menu)").to_string() + }; + let _ = writeln!( + f, + "[DEBUG] Chat session started — agent={agent_label}, host={}", + config.host + ); + } + // Setup terminal enable_raw_mode().map_err(|e| CliError::Other(e.to_string()))?; let mut stdout = io::stdout(); @@ -60,14 +91,18 @@ pub async fn run_tui( let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend).map_err(|e| CliError::Other(e.to_string()))?; + // Determine the display name shown in the TUI header. + let display_name = if use_default { + "Default Agent".to_string() + } else { + agent_name.map(String::from).unwrap_or_default() + }; + // Create app state - let mut app = App::new( - agent_name.map(String::from).unwrap_or_default(), - conversation_id, - ); + let mut app = App::new(display_name, conversation_id, use_default); - // If no agent specified, fetch agents and show selection menu - if agent_name.is_none() { + // If no agent specified and not using the default agent, fetch agents and show selection menu + if agent_name.is_none() && !use_default { match client.list_agents().await { Ok(agents) => { let names: Vec = agents @@ -75,11 +110,10 @@ pub async fn run_tui( .filter(|a| a.api_enabled) .map(|a| a.name) .collect(); - if names.is_empty() { - app.set_error("No API-enabled agents available".to_string()); - } else { - app.show_agents(names); - } + // The menu always shows the synthetic "AskCollate (default)" + // entry at the top, so it's useful even when there are no + // API-enabled named agents. + app.show_agents(names); } Err(e) => { app.set_error(format!("Failed to fetch agents: {e}")); @@ -88,7 +122,7 @@ pub async fn run_tui( } // Run the main loop - let result = run_main_loop(&mut terminal, &mut app, &client).await; + let result = run_main_loop(&mut terminal, &mut app, &client, debug_log_path).await; // Restore terminal disable_raw_mode().map_err(|e| CliError::Other(e.to_string()))?; @@ -110,6 +144,7 @@ async fn run_main_loop( terminal: &mut Terminal>, app: &mut App, client: &AISdkClient, + debug_log_path: Option, ) -> CliResult<()> { let (async_tx, mut async_rx) = mpsc::channel::(100); let mut event_stream = EventStream::new(); @@ -238,9 +273,11 @@ async fn run_main_loop( let client = client.clone(); let agent = app.agent_name.clone(); let conv_id = app.conversation_id.clone(); + let use_default = app.use_default; + let debug_path = debug_log_path.clone(); tokio::spawn(async move { - stream_agent_response(tx, client, agent, message, conv_id).await; + stream_agent_response(tx, client, agent, use_default, message, conv_id, debug_path).await; }); } } @@ -274,22 +311,34 @@ async fn run_main_loop( } /// Spawn a task to stream agent response. +/// +/// When `use_default` is `true`, routes through `stream_default_agent` +/// (PLANNER / CHAT_MODE); otherwise uses the named dynamic-agent endpoint. +#[allow(clippy::too_many_arguments)] async fn stream_agent_response( tx: mpsc::Sender, client: AISdkClient, agent: String, + use_default: bool, message: String, conversation_id: Option, + debug_log_path: Option, ) { - let result = client - .stream(&agent, Some(message.as_str()), conversation_id.as_deref()) - .await; + let result = if use_default { + client + .stream_default_agent(message.as_str(), conversation_id.as_deref()) + .await + } else { + client + .stream(&agent, Some(message.as_str()), conversation_id.as_deref()) + .await + }; match result { Ok(response) => { let mut final_conv_id = conversation_id; - let process_result = process_stream(response, |msg| { + let handler = |msg: crate::streaming::ChatMessage| { final_conv_id = Some(msg.conversation_id.clone()); match msg.sender { @@ -311,8 +360,12 @@ async fn stream_agent_response( } Sender::Human => {} } - }) - .await; + }; + + let process_result = match debug_log_path.as_ref() { + Some(path) => process_stream_with_debug_file(response, handler, path).await, + None => process_stream(response, handler).await, + }; match process_result { Ok(_) => { @@ -404,6 +457,15 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(Paragraph::new(header), area); } +/// Prepend a two-space indent to a Line. Used for the thinking-block +/// markdown so each rendered row is visually nested under "Thinking:". +fn indent_two(line: Line<'static>) -> Line<'static> { + let mut spans = Vec::with_capacity(line.spans.len() + 1); + spans.push(Span::raw(" ")); + spans.extend(line.spans); + Line::from(spans) +} + /// Render the chat message area. fn render_chat(frame: &mut Frame, app: &mut App, area: Rect) { let inner_width = area.width.saturating_sub(2) as usize; @@ -419,12 +481,10 @@ fn render_chat(frame: &mut Frame, app: &mut App, area: Rect) { .fg(Color::DarkGray) .add_modifier(Modifier::ITALIC), ))); - for line in thinking.lines() { - all_lines.push(Line::from(Span::styled( - format!(" {line}"), - Style::default().fg(Color::DarkGray), - ))); - } + // Render thinking through markdown so links, bold, code blocks + // render properly — then indent each line two spaces. + let rendered = render_markdown_dimmed(thinking, inner_width.saturating_sub(2)); + all_lines.extend(rendered.into_iter().map(indent_two)); all_lines.push(Line::default()); } @@ -450,7 +510,7 @@ fn render_chat(frame: &mut Frame, app: &mut App, area: Rect) { // Render streaming content if any if app.is_streaming() || !app.streaming_content.is_empty() || !app.thinking_content.is_empty() { - // Show thinking content first (in grey) + // Show thinking content first (in grey, with markdown applied) if !app.thinking_content.is_empty() { all_lines.push(Line::from(Span::styled( "Thinking: ", @@ -458,13 +518,9 @@ fn render_chat(frame: &mut Frame, app: &mut App, area: Rect) { .fg(Color::DarkGray) .add_modifier(Modifier::ITALIC), ))); - // Render thinking in grey - for line in app.thinking_content.lines() { - all_lines.push(Line::from(Span::styled( - format!(" {line}"), - Style::default().fg(Color::DarkGray), - ))); - } + let rendered = + render_markdown_dimmed(&app.thinking_content, inner_width.saturating_sub(2)); + all_lines.extend(rendered.into_iter().map(indent_two)); all_lines.push(Line::default()); } diff --git a/cookbook/dbt-pr-review/agent-config.md b/cookbook/dbt-pr-review/agent-config.md index 96258e9..cce43cd 100644 --- a/cookbook/dbt-pr-review/agent-config.md +++ b/cookbook/dbt-pr-review/agent-config.md @@ -100,7 +100,7 @@ client = AISdk( ) # Create the agent -agent = client.create_agent( +agent = client.agents.create( name="DBTReviewer", description="Reviews dbt model PRs for downstream impact and DQ risks", persona="DBTReviewerPersona", @@ -121,7 +121,7 @@ const client = new AISdk({ token: 'your-jwt-token' }); -const agent = await client.createAgent({ +const agent = await client.agents.create({ name: 'DBTReviewer', description: 'Reviews dbt model PRs for downstream impact and DQ risks', persona: 'DBTReviewerPersona', diff --git a/cookbook/dq-failure-slack-notifications/agent-config.md b/cookbook/dq-failure-slack-notifications/agent-config.md index 84be78f..3b93577 100644 --- a/cookbook/dq-failure-slack-notifications/agent-config.md +++ b/cookbook/dq-failure-slack-notifications/agent-config.md @@ -112,7 +112,7 @@ client = AISdk( ) # Create the agent -agent = client.create_agent( +agent = client.agents.create( name="DataQualityAnalyzer", description="Analyzes DQ test failures, explores lineage impact, and suggests remediation steps", persona="DataAnalyst", @@ -133,7 +133,7 @@ const client = new AISdk({ token: 'your-jwt-token' }); -const agent = await client.createAgent({ +const agent = await client.agents.create({ name: 'DataQualityAnalyzer', description: 'Analyzes DQ test failures, explores lineage impact, and suggests remediation steps', persona: 'DataAnalyst', @@ -163,7 +163,7 @@ CreateAgentRequest request = CreateAgentRequest.builder() .apiEnabled(true) .build(); -Agent agent = client.createAgent(request); +Agent agent = client.agents().create(request); System.out.println("Created agent: " + agent.getName()); ``` diff --git a/cookbook/gdpr-dsar-compliance/agent-config.md b/cookbook/gdpr-dsar-compliance/agent-config.md index 977ae92..f404f27 100644 --- a/cookbook/gdpr-dsar-compliance/agent-config.md +++ b/cookbook/gdpr-dsar-compliance/agent-config.md @@ -124,7 +124,7 @@ client = AISdk( ) # Create the agent -agent = client.create_agent( +agent = client.agents.create( name="GDPRComplianceAnalyzer", description="Handles GDPR deletion requests by searching for customer data, tracing lineage, and checking retention policies", persona="GDPRAnalyst", @@ -145,7 +145,7 @@ const client = new AISdk({ token: 'your-jwt-token' }); -const agent = await client.createAgent({ +const agent = await client.agents.create({ name: 'GDPRComplianceAnalyzer', description: 'Handles GDPR deletion requests by searching for customer data, tracing lineage, and checking retention policies', persona: 'GDPRAnalyst', @@ -175,7 +175,7 @@ CreateAgentRequest request = CreateAgentRequest.builder() .apiEnabled(true) .build(); -Agent agent = client.createAgent(request); +Agent agent = client.agents().create(request); System.out.println("Created agent: " + agent.getName()); ``` diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index a1b319d..e964571 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -6,9 +6,8 @@ Comprehensive reference of all SDK methods, server endpoints, data models, and f | Endpoint | Method | Description | |----------|--------|-------------| -| `/api/v1/api/agents/` | GET | List API-enabled agents (paginated) | -| `/api/v1/api/agents/{name}` | GET | Get agent info | -| `/api/v1/agents/dynamic/` | POST | Create agent | +| `/api/v1/agents/dynamic/` | GET/POST | List API-enabled agents / Create agent | +| `/api/v1/agents/dynamic/{name}` | GET | Get agent info | | `/{agent_name}/invoke` | POST | Invoke agent (sync) | | `/{agent_name}/stream` | POST | Invoke agent (SSE streaming) | | `/api/v1/bots/` | GET | List bots (paginated) | @@ -17,6 +16,9 @@ Comprehensive reference of all SDK methods, server endpoints, data models, and f | `/api/v1/agents/personas/name/{name}` | GET | Get persona by name | | `/api/v1/agents/abilities/` | GET | List abilities (paginated) | | `/api/v1/agents/abilities/name/{name}` | GET | Get ability by name | +| `/api/v1/contextCenter/memories/` | GET/POST | List / Create memories (paginated, optional `primaryEntityFqn` filter) | +| `/api/v1/contextCenter/memories/{id}` | GET/DELETE | Get / Delete memory by ID (`hardDelete=` query param on DELETE) | +| `/api/v1/hybrid/nlq/search?index=contextMemory` | GET | Hybrid NLQ search over the memories index | | `/mcp` | POST | MCP JSON-RPC 2.0 (`tools/list`, `tools/call`) | --- @@ -39,18 +41,25 @@ client = AISdk( ) ``` +Entity CRUD lives on namespaces (`client..()`). Each namespace exposes both sync and async methods. + | Method | Endpoint | Streaming | Async | |--------|----------|-----------|-------| -| `agent(name) -> AgentHandle` | -- (local) | -- | -- | -| `list_agents(limit?) -> list[AgentInfo]` | `GET /api/v1/api/agents/` | No | `alist_agents()` | -| `create_agent(request) -> AgentInfo` | `POST /api/v1/agents/dynamic/` | No | `acreate_agent()` | -| `list_bots(limit?) -> list[BotInfo]` | `GET /api/v1/bots/` | No | `alist_bots()` | -| `get_bot(name) -> BotInfo` | `GET /api/v1/bots/name/{name}` | No | `aget_bot()` | -| `list_personas(limit?) -> list[PersonaInfo]` | `GET /api/v1/agents/personas/` | No | `alist_personas()` | -| `get_persona(name) -> PersonaInfo` | `GET /api/v1/agents/personas/name/{name}` | No | `aget_persona()` | -| `create_persona(request) -> PersonaInfo` | `POST /api/v1/agents/personas/` | No | `acreate_persona()` | -| `list_abilities(limit?) -> list[AbilityInfo]` | `GET /api/v1/agents/abilities/` | No | `alist_abilities()` | -| `get_ability(name) -> AbilityInfo` | `GET /api/v1/agents/abilities/name/{name}` | No | `aget_ability()` | +| `agent(name?) -> AgentHandle` | -- (local) | -- | -- | +| `agents.list(limit?) -> list[AgentInfo]` | `GET /api/v1/agents/dynamic/` | No | `agents.alist()` | +| `agents.create(request) -> AgentInfo` | `POST /api/v1/agents/dynamic/` | No | `agents.acreate()` | +| `bots.list(limit?) -> list[BotInfo]` | `GET /api/v1/bots/` | No | `bots.alist()` | +| `bots.get(name) -> BotInfo` | `GET /api/v1/bots/name/{name}` | No | `bots.aget()` | +| `personas.list(limit?) -> list[PersonaInfo]` | `GET /api/v1/agents/personas/` | No | `personas.alist()` | +| `personas.get(name) -> PersonaInfo` | `GET /api/v1/agents/personas/name/{name}` | No | `personas.aget()` | +| `personas.create(request) -> PersonaInfo` | `POST /api/v1/agents/personas/` | No | `personas.acreate()` | +| `abilities.list(limit?) -> list[AbilityInfo]` | `GET /api/v1/agents/abilities/` | No | `abilities.alist()` | +| `abilities.get(name) -> AbilityInfo` | `GET /api/v1/agents/abilities/name/{name}` | No | `abilities.aget()` | +| `memories.list(primary_entity_fqn?, limit?) -> list[ContextMemory]` | `GET /api/v1/contextCenter/memories/` | No | `memories.alist()` | +| `memories.get(id) -> ContextMemory` | `GET /api/v1/contextCenter/memories/{id}` | No | `memories.aget()` | +| `memories.create(request) -> ContextMemory` | `POST /api/v1/contextCenter/memories/` | No | `memories.acreate()` | +| `memories.delete(id, hard_delete=False) -> None` | `DELETE /api/v1/contextCenter/memories/{id}` | No | `memories.adelete()` | +| `memories.search(query, filters?, size=15, from_=0) -> MemorySearchResults` | `GET /api/v1/hybrid/nlq/search?index=contextMemory` | No | `memories.asearch()` | | `.mcp` property -> `MCPClient` | -- (lazy init) | -- | -- | ### AgentHandle @@ -118,18 +127,25 @@ const client = new AISdk({ }); ``` +Entity CRUD lives on namespace fields (`client..()`). + | Method | Endpoint | Streaming | |--------|----------|-----------| -| `agent(name): AgentHandle` | -- (local) | -- | -| `listAgents(options?): Promise` | `GET /api/v1/api/agents/` | No | -| `createAgent(request): Promise` | `POST /api/v1/agents/dynamic/` | No | -| `listBots(options?): Promise` | `GET /api/v1/bots/` | No | -| `getBot(name): Promise` | `GET /api/v1/bots/name/{name}` | No | -| `listPersonas(options?): Promise` | `GET /api/v1/agents/personas/` | No | -| `getPersona(name): Promise` | `GET /api/v1/agents/personas/name/{name}` | No | -| `createPersona(request): Promise` | `POST /api/v1/agents/personas/` | No | -| `listAbilities(options?): Promise` | `GET /api/v1/agents/abilities/` | No | -| `getAbility(name): Promise` | `GET /api/v1/agents/abilities/name/{name}` | No | +| `agent(name?): AgentHandle` | -- (local) | -- | +| `agents.list(options?): Promise` | `GET /api/v1/agents/dynamic/` | No | +| `agents.create(request): Promise` | `POST /api/v1/agents/dynamic/` | No | +| `bots.list(options?): Promise` | `GET /api/v1/bots/` | No | +| `bots.get(name): Promise` | `GET /api/v1/bots/name/{name}` | No | +| `personas.list(options?): Promise` | `GET /api/v1/agents/personas/` | No | +| `personas.get(name): Promise` | `GET /api/v1/agents/personas/name/{name}` | No | +| `personas.create(request): Promise` | `POST /api/v1/agents/personas/` | No | +| `abilities.list(options?): Promise` | `GET /api/v1/agents/abilities/` | No | +| `abilities.get(name): Promise` | `GET /api/v1/agents/abilities/name/{name}` | No | +| `memories.list(options?): Promise` | `GET /api/v1/contextCenter/memories/` | No | +| `memories.get(id): Promise` | `GET /api/v1/contextCenter/memories/{id}` | No | +| `memories.create(request): Promise` | `POST /api/v1/contextCenter/memories/` | No | +| `memories.delete(id, options?): Promise` | `DELETE /api/v1/contextCenter/memories/{id}` | No | +| `memories.search(query, options?): Promise` | `GET /api/v1/hybrid/nlq/search?index=contextMemory` | No | ### AgentHandle @@ -155,18 +171,25 @@ AISdk client = AISdk.builder() .build(); ``` +Entity CRUD lives on namespace accessor methods (`client.().()`). + | Method | Endpoint | Streaming | |--------|----------|-----------| | `agent(name): AgentHandle` | -- (local) | -- | -| `listAgents() / listAgents(limit)` | `GET /api/v1/api/agents/` | No | -| `createAgent(builder): AgentInfo` | `POST /api/v1/agents/dynamic/` | No | -| `listBots() / listBots(limit)` | `GET /api/v1/bots/` | No | -| `getBot(name): BotInfo` | `GET /api/v1/bots/name/{name}` | No | -| `listPersonas() / listPersonas(limit)` | `GET /api/v1/agents/personas/` | No | -| `getPersona(name): PersonaInfo` | `GET /api/v1/agents/personas/name/{name}` | No | -| `createPersona(request): PersonaInfo` | `POST /api/v1/agents/personas/` | No | -| `listAbilities() / listAbilities(limit)` | `GET /api/v1/agents/abilities/` | No | -| `getAbility(name): AbilityInfo` | `GET /api/v1/agents/abilities/name/{name}` | No | +| `agents().list() / agents().list(limit)` | `GET /api/v1/agents/dynamic/` | No | +| `agents().create(request): AgentInfo` | `POST /api/v1/agents/dynamic/` | No | +| `bots().list() / bots().list(limit)` | `GET /api/v1/bots/` | No | +| `bots().get(name): BotInfo` | `GET /api/v1/bots/name/{name}` | No | +| `personas().list() / personas().list(limit)` | `GET /api/v1/agents/personas/` | No | +| `personas().get(name): PersonaInfo` | `GET /api/v1/agents/personas/name/{name}` | No | +| `personas().create(request): PersonaInfo` | `POST /api/v1/agents/personas/` | No | +| `abilities().list() / abilities().list(limit)` | `GET /api/v1/agents/abilities/` | No | +| `abilities().get(name): AbilityInfo` | `GET /api/v1/agents/abilities/name/{name}` | No | +| `memories().list() / memories().list(fqn, limit)` | `GET /api/v1/contextCenter/memories/` | No | +| `memories().get(id): ContextMemory` | `GET /api/v1/contextCenter/memories/{id}` | No | +| `memories().create(request): ContextMemory` | `POST /api/v1/contextCenter/memories/` | No | +| `memories().delete(id) / delete(id, hardDelete)` | `DELETE /api/v1/contextCenter/memories/{id}` | No | +| `memories().search(query) / search(query, filters, size, from)` | `GET /api/v1/hybrid/nlq/search?index=contextMemory` | No | ### AgentHandle (fluent builder pattern) @@ -213,6 +236,18 @@ AISdk client = AISdk.builder() | `abilities list [--limit N] [--json]` | `GET /api/v1/agents/abilities/` | | `abilities get [--json]` | `GET /api/v1/agents/abilities/name/{name}` | +### Memory Commands + +| Command | Endpoint | +|---------|----------| +| `memories list [--entity-fqn FQN] [--limit N] [--json]` | `GET /api/v1/contextCenter/memories/` | +| `memories get [--json]` | `GET /api/v1/contextCenter/memories/{id}` | +| `memories create --name N --question Q --answer A [...]` | `POST /api/v1/contextCenter/memories/` | +| `memories delete [--hard]` | `DELETE /api/v1/contextCenter/memories/{id}` | +| `memories search [--size N] [--from N] [--json]` | `GET /api/v1/hybrid/nlq/search?index=contextMemory` | + +**Memory create options:** `--title`, `--description`, `--memory-type=note` (preference|use-case|note|runbook|faq), `--memory-scope=entity-scoped` (entity-scoped|user-global), `--visibility=private` (private|entity|shared), `--primary-entity-{id,type,fqn}`, `--tags` + ### Invoke & Chat | Command | Endpoint | Streaming | @@ -324,6 +359,74 @@ ai-sdk configure list # List all config } ``` +### EntityReference + +``` +{ + id: string + type: string // e.g., "table", "dashboard", "pipeline" + name?: string + fullyQualifiedName?: string + displayName?: string +} +``` + +### CreateContextMemoryRequest + +``` +{ + name: string // Required: stable system name + question: string // Required: canonical question/instruction + answer: string // Required: canonical answer/guidance + title?: string // Short title shown in Context Center + description?: string // Optional markdown description + memoryType?: 'Preference' | 'UseCase' | 'Note' | 'Runbook' | 'Faq' + // Default: 'Note' + memoryScope?: 'UserGlobal' | 'EntityScoped' + // Default: 'EntityScoped' + visibility?: 'Private' | 'Entity' | 'Shared' + // Default: 'Private' + primaryEntity?: EntityReference + relatedEntities?: EntityReference[] + tags?: string[] // Tag FQN strings; wrapped to TagLabel on the wire +} +``` + +### ContextMemory + +``` +{ + id: string + name: string + fullyQualifiedName?: string + title?: string + question: string + answer: string + summary?: string + memoryType: MemoryType + memoryScope: MemoryScope + visibility: MemoryVisibility // Flattened from shareConfig.visibility + primaryEntity?: EntityReference + usageCount: number + lastUsedAt?: number // Epoch milliseconds + deleted: boolean +} +``` + +### MemorySearchHit / MemorySearchResults + +``` +MemorySearchHit { + memory: ContextMemory // Parsed from _source + score: number // OpenSearch _score +} + +MemorySearchResults { + total: number // Hit total (parsed from hits.total.value) + hits: MemorySearchHit[] +} +``` + ### MCP Models (Python-only) ```python @@ -463,11 +566,13 @@ Default: 3 retries with exponential backoff. |---------|--------|------------|------|----------| | Agent invoke | sync + async | async | sync | sync | | Agent stream | sync + async | async generator | callback + Stream | `--stream` flag | +| Default agent (`client.agent()` no name) | yes | yes | yes | `--default` flag | | List agents | yes | yes | yes | yes | | Create agent | yes | yes | yes | yes (TUI + CLI) | | Bots (list + get) | yes | yes | yes | yes | | Personas (list + get + create) | yes | yes | yes | yes (TUI + CLI) | | Abilities (list + get) | yes | yes | yes | yes | +| **Context Memories** (list + get + create + delete + search) | yes (sync + async) | yes | yes | yes | | Conversations | `Conversation` class | manual `conversationId` | fluent `.conversationId()` | `-c` flag | | **MCP tools** | **yes** | no | no | no | | Interactive chat | -- | -- | -- | TUI (`chat`) | diff --git a/docs/async.md b/docs/async.md index 53b0e44..52c77ae 100644 --- a/docs/async.md +++ b/docs/async.md @@ -33,7 +33,7 @@ All sync methods have async counterparts: | Sync | Async | |------|-------| -| `client.list_agents()` | `await client.alist_agents()` | +| `client.agents.list()` | `await client.agents.alist()` | | `agent.call()` | `await agent.acall()` | | `agent.stream()` | `await agent.astream()` | | `agent.get_info()` | `await agent.aget_info()` | @@ -162,7 +162,7 @@ async def chat(client: AISdk): ```python async def list_all_agents(client: AISdk) -> list: - agents = await client.alist_agents(limit=100) + agents = await client.agents.alist(limit=100) return [a for a in agents if a.api_enabled] ``` diff --git a/docs/quickstart.md b/docs/quickstart.md index 5f0cd05..4a15db3 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -91,13 +91,13 @@ The SDK also supports creating and managing resources: ```python # List bots, personas, and abilities -bots = client.list_bots() -personas = client.list_personas() -abilities = client.list_abilities() +bots = client.bots.list() +personas = client.personas.list() +abilities = client.abilities.list() # Create a persona from ai_sdk.models import CreatePersonaRequest -persona = client.create_persona(CreatePersonaRequest( +persona = client.personas.create(CreatePersonaRequest( name="MyPersona", description="A helpful analyst", prompt="You are a helpful data analyst..." @@ -105,7 +105,7 @@ persona = client.create_persona(CreatePersonaRequest( # Create an agent from ai_sdk.models import CreateAgentRequest -agent = client.create_agent(CreateAgentRequest( +agent = client.agents.create(CreateAgentRequest( name="MyAgent", description="Custom agent", persona="MyPersona", diff --git a/docs/standalone.md b/docs/standalone.md index d27db2f..2195b7d 100644 --- a/docs/standalone.md +++ b/docs/standalone.md @@ -257,7 +257,7 @@ for event in conv.stream("Analyze this table"): Discover available API-enabled agents: ```python -agents = client.list_agents(limit=20) +agents = client.agents.list(limit=20) for agent in agents: print(f"Name: {agent.name}") @@ -287,7 +287,7 @@ Create new dynamic agents programmatically: from ai_sdk.models import CreateAgentRequest # Create a simple agent -agent = client.create_agent(CreateAgentRequest( +agent = client.agents.create(CreateAgentRequest( name="MyDataAgent", description="An agent for data analysis tasks", persona="DataAnalyst", # Name of an existing persona @@ -296,7 +296,7 @@ agent = client.create_agent(CreateAgentRequest( print(f"Created agent: {agent.name}") # Create an agent with full configuration -agent = client.create_agent(CreateAgentRequest( +agent = client.agents.create(CreateAgentRequest( name="AdvancedAgent", description="An advanced agent with custom configuration", persona="DataAnalyst", @@ -333,12 +333,12 @@ Bots are service accounts used for API authentication and actions. ```python # List all bots -bots = client.list_bots(limit=20) +bots = client.bots.list(limit=20) for bot in bots: print(f"{bot.name}: {bot.display_name}") # Get a specific bot -bot = client.get_bot("my-bot-name") +bot = client.bots.get("my-bot-name") print(f"Bot: {bot.name}") print(f"Display Name: {bot.display_name}") ``` @@ -357,7 +357,7 @@ print(f"Display Name: {bot.display_name}") from ai_sdk.exceptions import BotNotFoundError try: - bot = client.get_bot("nonexistent-bot") + bot = client.bots.get("nonexistent-bot") except BotNotFoundError as e: print(f"Bot not found: {e.bot_name}") ``` @@ -370,17 +370,17 @@ Personas define the behavior and personality of agents. from ai_sdk.models import CreatePersonaRequest # List all personas -personas = client.list_personas(limit=20) +personas = client.personas.list(limit=20) for persona in personas: print(f"{persona.name}: {persona.description}") # Get a specific persona -persona = client.get_persona("DataAnalyst") +persona = client.personas.get("DataAnalyst") print(f"Persona: {persona.name}") print(f"Prompt: {persona.prompt[:100]}...") # Create a new persona -new_persona = client.create_persona(CreatePersonaRequest( +new_persona = client.personas.create(CreatePersonaRequest( name="CustomAnalyst", description="A specialized analyst for custom domains", prompt="You are an expert analyst who helps users understand complex data...", @@ -413,7 +413,7 @@ print(f"Created persona: {new_persona.name}") from ai_sdk.exceptions import PersonaNotFoundError try: - persona = client.get_persona("nonexistent") + persona = client.personas.get("nonexistent") except PersonaNotFoundError as e: print(f"Persona not found: {e.persona_name}") ``` @@ -424,12 +424,12 @@ Abilities are capabilities that can be assigned to agents. ```python # List all abilities -abilities = client.list_abilities(limit=50) +abilities = client.abilities.list(limit=50) for ability in abilities: print(f"{ability.name}: {ability.description}") # Get a specific ability -ability = client.get_ability("search") +ability = client.abilities.get("search") print(f"Ability: {ability.name}") print(f"Description: {ability.description}") ``` @@ -448,7 +448,7 @@ print(f"Description: {ability.description}") from ai_sdk.exceptions import AbilityNotFoundError try: - ability = client.get_ability("nonexistent") + ability = client.abilities.get("nonexistent") except AbilityNotFoundError as e: print(f"Ability not found: {e.ability_name}") ``` @@ -480,7 +480,7 @@ async def main(): print(event.content, end="") # Async list - agents = await client.alist_agents() + agents = await client.agents.alist() # Cleanup await client.aclose() @@ -710,22 +710,22 @@ def main(): try: # List available agents print("Available agents:") - for agent in client.list_agents(): + for agent in client.agents.list(): print(f" - {agent.name}: {agent.description[:50]}...") # List bots print("\n--- Bots ---") - for bot in client.list_bots(): + for bot in client.bots.list(): print(f" - {bot.name}: {bot.display_name}") # List personas print("\n--- Personas ---") - for persona in client.list_personas(): + for persona in client.personas.list(): print(f" - {persona.name}: {persona.description[:50]}...") # List abilities print("\n--- Abilities ---") - for ability in client.list_abilities(): + for ability in client.abilities.list(): print(f" - {ability.name}: {ability.description[:50]}...") # Simple invocation @@ -750,7 +750,7 @@ def main(): # Create a persona (uncomment to run) # print("\n--- Create Persona ---") - # persona = client.create_persona(CreatePersonaRequest( + # persona = client.personas.create(CreatePersonaRequest( # name="MyCustomPersona", # description="A custom persona for testing", # prompt="You are a helpful assistant..." @@ -759,7 +759,7 @@ def main(): # Create an agent (uncomment to run) # print("\n--- Create Agent ---") - # new_agent = client.create_agent(CreateAgentRequest( + # new_agent = client.agents.create(CreateAgentRequest( # name="MyCustomAgent", # description="A custom agent for testing", # persona="DataAnalyst", diff --git a/java/README.md b/java/README.md index dbfe369..77643da 100644 --- a/java/README.md +++ b/java/README.md @@ -46,7 +46,12 @@ public class Example { .maxRetries(3) // optional .build(); - // Simple invocation + // default AskCollate agent + InvokeResponse defaultResponse = client.agent() + .invoke("What tables exist?"); + System.out.println(defaultResponse.getResponse()); + + // Named dynamic agent InvokeResponse response = client.agent("semantic-layer-agent") .invoke("What tables exist?"); @@ -156,7 +161,7 @@ InvokeResponse response = client.agent("semantic-layer-agent") ### Listing Agents ```java -List agents = client.listAgents(); +List agents = client.agents().list(); for (AgentInfo agent : agents) { System.out.println(agent.getName() + ": " + agent.getDescription()); @@ -189,7 +194,7 @@ CreateAgentRequest request = CreateAgentRequest.builder() .abilities(List.of("search", "query")) .build(); -AgentInfo newAgent = client.createAgent(request); +AgentInfo newAgent = client.agents().create(request); System.out.println("Created agent: " + newAgent.getName()); ``` @@ -199,13 +204,13 @@ System.out.println("Created agent: " + newAgent.getName()); import io.openmetadata.ai.models.BotInfo; // List all bots -List bots = client.listBots(); +List bots = client.bots().list(); for (BotInfo bot : bots) { System.out.println(bot.getName() + ": " + bot.getDisplayName()); } // Get a specific bot -BotInfo bot = client.getBot("my-bot-name"); +BotInfo bot = client.bots().get("my-bot-name"); System.out.println("Bot: " + bot.getName()); ``` @@ -216,13 +221,13 @@ import io.openmetadata.ai.models.PersonaInfo; import io.openmetadata.ai.models.CreatePersonaRequest; // List all personas -List personas = client.listPersonas(); +List personas = client.personas().list(); for (PersonaInfo persona : personas) { System.out.println(persona.getName() + ": " + persona.getDescription()); } // Get a specific persona -PersonaInfo persona = client.getPersona("DataAnalyst"); +PersonaInfo persona = client.personas().get("DataAnalyst"); // Create a new persona CreatePersonaRequest request = CreatePersonaRequest.builder() @@ -231,7 +236,7 @@ CreatePersonaRequest request = CreatePersonaRequest.builder() .prompt("You are an expert data analyst who helps users understand their data...") .build(); -PersonaInfo newPersona = client.createPersona(request); +PersonaInfo newPersona = client.personas().create(request); System.out.println("Created persona: " + newPersona.getName()); ``` @@ -241,16 +246,84 @@ System.out.println("Created persona: " + newPersona.getName()); import io.openmetadata.ai.models.AbilityInfo; // List all abilities -List abilities = client.listAbilities(); +List abilities = client.abilities().list(); for (AbilityInfo ability : abilities) { System.out.println(ability.getName() + ": " + ability.getDescription()); } // Get a specific ability -AbilityInfo ability = client.getAbility("search"); +AbilityInfo ability = client.abilities().get("search"); System.out.println("Ability: " + ability.getName()); ``` +### Context Memories + +The `client.memories()` namespace manages reusable Context Center knowledge — preferences, use cases, runbooks, and FAQs that any AI agent can read. + +```java +import io.openmetadata.ai.models.*; +import java.util.List; +import java.util.Map; + +// Create +ContextMemory created = client.memories().create( + CreateContextMemoryRequest.builder() + .name("orders-grain") + .title("Orders grain") + .question("What is the grain of the orders table?") + .answer("One row per order_id.") + .memoryType(MemoryType.NOTE) // PREFERENCE | USE_CASE | NOTE | RUNBOOK | FAQ + .visibility(MemoryVisibility.SHARED) // PRIVATE | ENTITY | SHARED + .primaryEntity(EntityReference.builder().id("").type("table").build()) + .tags(List.of("Domain.Analytics")) + .build() +); + +// Get +ContextMemory fetched = client.memories().get(created.getId()); + +// List (optional FQN filter, optional limit) +List all = client.memories().list(); +List forTable = client.memories().list("prod.warehouse.orders", 50); +for (ContextMemory m : forTable) { + System.out.println(m.getTitle()); +} + +// Hybrid NLQ search — combines vector + keyword ranking over the contextMemory index +MemorySearchResults results = client.memories().search("how do we measure order volume"); +for (MemorySearchHit hit : results.getHits()) { + System.out.printf("[%.2f] %s%n", hit.getScore(), hit.getMemory().getTitle()); +} + +// Search with filters and pagination +MemorySearchResults filtered = client.memories().search( + "explain churn", + Map.of( + "primaryEntityId", List.of(""), + "visibility", List.of("Entity", "Shared") + ), + 20, // size + 0 // from +); + +// Soft delete by default; pass true for hard delete +client.memories().delete(created.getId()); +client.memories().delete(created.getId(), true); +``` + +**Stored fields:** + +| Field | Notes | +|-------|-------| +| `name` | Stable system name (required) | +| `question` / `answer` | Canonical Q/A pair (required) — what an agent retrieves | +| `title`, `description`, `summary` | Human-facing text, optional | +| `memoryType` | `PREFERENCE`, `USE_CASE`, `NOTE`, `RUNBOOK`, or `FAQ` | +| `memoryScope` | `ENTITY_SCOPED` (default) or `USER_GLOBAL` | +| `visibility` | `PRIVATE`, `ENTITY`, or `SHARED` (controls who can read it) | +| `primaryEntity` | Attaches the memory to a specific asset for entity-scoped recall | +| `tags` | List of tag FQN strings (e.g. `"PII.Sensitive"`) | + ## Error Handling The SDK provides specific exception types for different error conditions: diff --git a/java/pom.xml b/java/pom.xml index 49e1812..5e8b2db 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.open-metadata ai-sdk - 0.1.2 + 0.2.0 jar AI SDK diff --git a/java/src/main/java/io/openmetadata/ai/AISdk.java b/java/src/main/java/io/openmetadata/ai/AISdk.java index c00278e..ef45027 100644 --- a/java/src/main/java/io/openmetadata/ai/AISdk.java +++ b/java/src/main/java/io/openmetadata/ai/AISdk.java @@ -1,11 +1,14 @@ package io.openmetadata.ai; import java.time.Duration; -import java.util.List; import java.util.Objects; +import io.openmetadata.ai.api.AbilitiesApi; +import io.openmetadata.ai.api.AgentsApi; +import io.openmetadata.ai.api.BotsApi; +import io.openmetadata.ai.api.MemoriesApi; +import io.openmetadata.ai.api.PersonasApi; import io.openmetadata.ai.internal.AISdkHttpClient; -import io.openmetadata.ai.models.*; /** * Main client for interacting with the AI SDK Agents API. @@ -16,69 +19,61 @@ * AISdk client = AISdk.builder() * .host("https://metadata.example.com") * .token("your-jwt-token") - * .timeout(Duration.ofSeconds(120)) // optional - * .maxRetries(3) // optional * .build(); * * // Simple invocation * InvokeResponse response = client.agent("semantic-layer-agent") * .invoke("What tables exist?"); * - * // Streaming with Consumer callback - * client.agent("semantic-layer-agent") - * .stream("Analyze data quality", event -> { - * if (event.getType() == StreamEvent.Type.CONTENT) { - * System.out.print(event.getContent()); - * } - * }); + * // Namespaced operations + * List agents = client.agents().list(); + * BotInfo bot = client.bots().get("ingestion-bot"); + * List memories = client.memories().list(); * - * // Streaming with Java Stream API - * try (Stream events = client.agent("planner") - * .streamIterator("Analyze orders")) { - * events.filter(e -> e.getType() == StreamEvent.Type.CONTENT) - * .forEach(e -> System.out.print(e.getContent())); - * } - * - * // Multi-turn conversation - * InvokeResponse r1 = client.agent("planner").invoke("Analyze orders"); - * InvokeResponse r2 = client.agent("planner") - * .conversationId(r1.getConversationId()) - * .invoke("Create tests for the issues"); - * - * // List agents - * List agents = client.listAgents(); - * - * // Clean up * client.close(); * } - * - *

This class implements {@link AutoCloseable} for use with try-with-resources: - * - *

{@code
- * try (AISdk client = AISdk.builder()
- *         .host("https://metadata.example.com")
- *         .token("your-jwt-token")
- *         .build()) {
- *     // Use client...
- * }
- * }
*/ public class AISdk implements AutoCloseable { - private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(120); + // Generous default — agent runs can take many minutes. Note this maps to + // HttpClient.Builder.connectTimeout(...) only (TCP connect), so SSE bodies + // are never bounded by it. Per-request HttpRequest.timeout(...) is + // intentionally NOT set on streaming requests; see AISdkHttpClient. + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(900); private static final int DEFAULT_MAX_RETRIES = 3; private static final Duration DEFAULT_RETRY_DELAY = Duration.ofSeconds(1); + private static final String MEMORIES_BASE_PATH = "/api/v1/contextCenter/memories"; + private static final String SEARCH_BASE_PATH = "/api/v1"; private final AISdkHttpClient httpClient; + private final AISdkHttpClient memoriesHttpClient; + private final AISdkHttpClient searchHttpClient; + + private final AgentsApi agentsApi; + private final BotsApi botsApi; + private final PersonasApi personasApi; + private final AbilitiesApi abilitiesApi; + private final MemoriesApi memoriesApi; private AISdk(Builder builder) { + Duration timeout = builder.timeout != null ? builder.timeout : DEFAULT_TIMEOUT; + int maxRetries = builder.maxRetries != null ? builder.maxRetries : DEFAULT_MAX_RETRIES; + Duration retryDelay = builder.retryDelay != null ? builder.retryDelay : DEFAULT_RETRY_DELAY; + this.httpClient = + new AISdkHttpClient(builder.host, builder.token, timeout, maxRetries, retryDelay); + this.memoriesHttpClient = new AISdkHttpClient( - builder.host, - builder.token, - builder.timeout != null ? builder.timeout : DEFAULT_TIMEOUT, - builder.maxRetries != null ? builder.maxRetries : DEFAULT_MAX_RETRIES, - builder.retryDelay != null ? builder.retryDelay : DEFAULT_RETRY_DELAY); + builder.host, builder.token, timeout, maxRetries, retryDelay, MEMORIES_BASE_PATH); + this.searchHttpClient = + new AISdkHttpClient( + builder.host, builder.token, timeout, maxRetries, retryDelay, SEARCH_BASE_PATH); + + this.agentsApi = new AgentsApi(httpClient, this); + this.botsApi = new BotsApi(httpClient); + this.personasApi = new PersonasApi(httpClient); + this.abilitiesApi = new AbilitiesApi(httpClient); + this.memoriesApi = new MemoriesApi(memoriesHttpClient, searchHttpClient); } /** @@ -93,9 +88,6 @@ public static Builder builder() { /** * Gets a handle for interacting with the specified agent. * - *

The returned handle can be used to invoke the agent synchronously, with streaming, or to get - * agent information. - * * @param name the name of the agent * @return an agent handle */ @@ -105,203 +97,48 @@ public AgentHandle agent(String name) { } /** - * Lists all API-enabled agents. Automatically paginates through all results. - * - * @return a list of all agent information - */ - public List listAgents() { - return httpClient.listAgents(); - } - - /** - * Lists API-enabled agents with optional limit. Automatically paginates through all results. - * - * @param limit the maximum number of agents to return - * @return a list of agent information - */ - public List listAgents(int limit) { - return httpClient.listAgents(limit); - } - - /** - * Lists all API-enabled agents with pagination (deprecated). - * - * @param limit the maximum number of agents to return - * @param offset ignored - use listAgents() or listAgents(int limit) instead - * @return a list of agent information - * @deprecated Use listAgents() or listAgents(int limit) instead - */ - @Deprecated - public List listAgents(int limit, int offset) { - return httpClient.listAgents(limit, offset); - } - - // ==================== Bot Operations ==================== - - /** - * Lists all bots. Automatically paginates through all results. - * - * @return a list of all bot information - */ - public List listBots() { - return httpClient.listBots(); - } - - /** - * Lists bots with optional limit. Automatically paginates through all results. - * - * @param limit the maximum number of bots to return - * @return a list of bot information - */ - public List listBots(int limit) { - return httpClient.listBots(limit); - } - - /** - * Gets a bot by name. - * - * @param name the name of the bot - * @return the bot information - * @throws io.openmetadata.ai.exceptions.BotNotFoundException if the bot is not found - */ - public BotInfo getBot(String name) { - Objects.requireNonNull(name, "Bot name cannot be null"); - return httpClient.getBotByName(name); - } - - // ==================== Persona Operations ==================== - - /** - * Lists all personas. Automatically paginates through all results. + * Gets a handle for the platform's default agent. * - * @return a list of all persona information - */ - public List listPersonas() { - return httpClient.listPersonas(); - } - - /** - * Lists personas with optional limit. Automatically paginates through all results. + *

The default agent uses agentType=PLANNER, agentMode=CHAT_MODE. A chat conversation is + * auto-created when {@link DefaultAgentHandle#conversationId} is not set. * - * @param limit the maximum number of personas to return - * @return a list of persona information + * @return a handle for the default agent */ - public List listPersonas(int limit) { - return httpClient.listPersonas(limit); + public DefaultAgentHandle agent() { + return new DefaultAgentHandle(httpClient); } - /** - * Gets a persona by name. - * - * @param name the name of the persona - * @return the persona information - * @throws io.openmetadata.ai.exceptions.PersonaNotFoundException if the persona is not found - */ - public PersonaInfo getPersona(String name) { - Objects.requireNonNull(name, "Persona name cannot be null"); - return httpClient.getPersonaByName(name); + /** Returns the agents namespace. */ + public AgentsApi agents() { + return agentsApi; } - /** - * Creates a new persona. - * - * @param request the request to create the persona - * @return the created persona information - */ - public PersonaInfo createPersona(CreatePersonaRequest request) { - Objects.requireNonNull(request, "CreatePersonaRequest cannot be null"); - return httpClient.createPersona(request); + /** Returns the bots namespace. */ + public BotsApi bots() { + return botsApi; } - // ==================== Ability Operations ==================== - - /** - * Lists all abilities. Automatically paginates through all results. - * - * @return a list of all ability information - */ - public List listAbilities() { - return httpClient.listAbilities(); + /** Returns the personas namespace. */ + public PersonasApi personas() { + return personasApi; } - /** - * Lists abilities with optional limit. Automatically paginates through all results. - * - * @param limit the maximum number of abilities to return - * @return a list of ability information - */ - public List listAbilities(int limit) { - return httpClient.listAbilities(limit); + /** Returns the abilities namespace. */ + public AbilitiesApi abilities() { + return abilitiesApi; } - /** - * Gets an ability by name. - * - * @param name the name of the ability - * @return the ability information - * @throws io.openmetadata.ai.exceptions.AbilityNotFoundException if the ability is not found - */ - public AbilityInfo getAbility(String name) { - Objects.requireNonNull(name, "Ability name cannot be null"); - return httpClient.getAbilityByName(name); - } - - // ==================== Agent Creation ==================== - - /** - * Creates a new dynamic agent. - * - * @param builder the builder containing agent configuration. Persona and ability names will be - * resolved to IDs automatically. - * @return the created agent information - */ - public AgentInfo createAgent(CreateAgentRequest.Builder builder) { - Objects.requireNonNull(builder, "Builder cannot be null"); - - // Resolve persona name to ID - String personaName = builder.getPersonaName(); - if (personaName == null || personaName.isEmpty()) { - throw new IllegalArgumentException("persona is required"); - } - PersonaInfo personaInfo = getPersona(personaName); - EntityReference personaRef = - EntityReference.builder().id(personaInfo.getId()).type("persona").build(); - - // Resolve ability names to IDs if provided - List abilityRefs = null; - List abilityNames = builder.getAbilityNames(); - if (abilityNames != null && !abilityNames.isEmpty()) { - abilityRefs = new java.util.ArrayList<>(); - for (String abilityName : abilityNames) { - AbilityInfo abilityInfo = getAbility(abilityName); - EntityReference abilityRef = - EntityReference.builder().id(abilityInfo.getId()).type("ability").build(); - abilityRefs.add(abilityRef); - } - } - - CreateAgentRequest request = builder.build(personaRef, abilityRefs); - return httpClient.createAgent(request); - } - - /** - * Creates a new dynamic agent. - * - * @param request the request to create the agent (must have resolved persona and ability IDs) - * @return the created agent information - * @deprecated Use {@link #createAgent(CreateAgentRequest.Builder)} instead to automatically - * resolve persona and ability names - */ - @Deprecated - public AgentInfo createAgent(CreateAgentRequest request) { - Objects.requireNonNull(request, "CreateAgentRequest cannot be null"); - return httpClient.createAgent(request); + /** Returns the memories namespace. */ + public MemoriesApi memories() { + return memoriesApi; } /** Closes the client and releases resources. */ @Override public void close() { httpClient.close(); + memoriesHttpClient.close(); + searchHttpClient.close(); } /** Builder for creating {@link AISdk} instances. */ @@ -339,7 +176,13 @@ public Builder token(String token) { /** * Sets the request timeout. * - *

Default: 120 seconds + *

This value is applied as the {@link + * java.net.http.HttpClient.Builder#connectTimeout(Duration) TCP connect timeout} on the + * underlying {@link java.net.http.HttpClient}. It does NOT bound the time spent reading a + * response body: SSE streams from long-running agent runs may continue for many minutes, and + * the stream's own events (thinking/message/tool) signal liveness. + * + *

Default: 900 seconds * * @param timeout the timeout duration * @return this builder diff --git a/java/src/main/java/io/openmetadata/ai/DefaultAgentHandle.java b/java/src/main/java/io/openmetadata/ai/DefaultAgentHandle.java new file mode 100644 index 0000000..49427cd --- /dev/null +++ b/java/src/main/java/io/openmetadata/ai/DefaultAgentHandle.java @@ -0,0 +1,124 @@ +package io.openmetadata.ai; + +import java.util.function.Consumer; +import java.util.stream.Stream; + +import io.openmetadata.ai.internal.AISdkHttpClient; +import io.openmetadata.ai.models.InvokeResponse; +import io.openmetadata.ai.models.StreamEvent; + +/** + * Handle for the platform's default agent (PLANNER / CHAT_MODE). + * + *

Auto-creates a chat conversation when one is not supplied, then invokes the sync + * /api/v1/agents/invoke or streaming /api/v1/agents/run endpoints. + * + *

Example: + * + *

{@code
+ * AISdk client = AISdk.builder().host("...").token("...").build();
+ * InvokeResponse response = client.agent().invoke("Hello");
+ * }
+ */ +public class DefaultAgentHandle { + + private static final String AGENT_TYPE = "PLANNER"; + private static final String AGENT_MODE = "CHAT_MODE"; + private static final int TITLE_MAX_LEN = 50; + + private final AISdkHttpClient httpClient; + private String conversationId; + + DefaultAgentHandle(AISdkHttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * Sets the conversation ID for multi-turn conversations. + * + * @param conversationId the conversation ID from a previous response + * @return this handle for method chaining + */ + public DefaultAgentHandle conversationId(String conversationId) { + this.conversationId = conversationId; + return this; + } + + /** + * Invokes the default agent synchronously. + * + * @param message the message to send + * @return the agent's response + */ + public InvokeResponse invoke(String message) { + String cid = ensureConversation(message); + return httpClient.invokeDefaultAgent(message, cid, AGENT_TYPE, AGENT_MODE); + } + + /** + * Invokes the default agent with streaming, calling the consumer for each event. + * + * @param message the message to send + * @param consumer a consumer that will be called for each streaming event + */ + public void stream(String message, Consumer consumer) { + String cid = ensureConversation(message); + httpClient.streamDefaultAgent(message, cid, AGENT_TYPE, AGENT_MODE, consumer); + } + + /** + * Invokes the default agent with streaming, returning a Stream of events. The caller must close + * the returned Stream when done (use try-with-resources). + * + * @param message the message to send + * @return a Stream of events that must be closed when done + */ + public Stream streamIterator(String message) { + String cid = ensureConversation(message); + return httpClient.streamDefaultAgentIterator(message, cid, AGENT_TYPE, AGENT_MODE); + } + + /** + * Invokes the default agent with streaming, calling the consumer for each content chunk. + * + *

Convenience wrapper around {@link #stream(String, Consumer)} that yields only text content. + * + * @param message the message to send + * @param contentConsumer a consumer that will be called for each content string + */ + public void streamContent(String message, Consumer contentConsumer) { + stream( + message, + event -> { + if (event.getType() == StreamEvent.Type.CONTENT && event.getContent() != null) { + contentConsumer.accept(event.getContent()); + } + }); + } + + /** + * Invokes the default agent with streaming, returning a Stream of content strings. The caller + * must close the returned Stream when done (use try-with-resources). + * + * @param message the message to send + * @return a Stream of content strings that must be closed when done + */ + public Stream streamContentIterator(String message) { + return streamIterator(message) + .filter(e -> e.getType() == StreamEvent.Type.CONTENT && e.getContent() != null) + .map(StreamEvent::getContent); + } + + private String ensureConversation(String message) { + if (conversationId != null && !conversationId.isEmpty()) { + return conversationId; + } + String title = message == null ? "New conversation" : message; + if (title.length() > TITLE_MAX_LEN) { + title = title.substring(0, TITLE_MAX_LEN); + } + String created = httpClient.createChatConversation(title); + this.conversationId = created; + return created; + } +} diff --git a/java/src/main/java/io/openmetadata/ai/api/AbilitiesApi.java b/java/src/main/java/io/openmetadata/ai/api/AbilitiesApi.java new file mode 100644 index 0000000..c412c45 --- /dev/null +++ b/java/src/main/java/io/openmetadata/ai/api/AbilitiesApi.java @@ -0,0 +1,33 @@ +package io.openmetadata.ai.api; + +import java.util.List; +import java.util.Objects; + +import io.openmetadata.ai.internal.AISdkHttpClient; +import io.openmetadata.ai.models.AbilityInfo; + +/** Namespace for ability operations. */ +public class AbilitiesApi { + + private final AISdkHttpClient http; + + public AbilitiesApi(AISdkHttpClient http) { + this.http = Objects.requireNonNull(http, "http cannot be null"); + } + + /** List all abilities. */ + public List list() { + return http.listAbilities(); + } + + /** List abilities up to limit. */ + public List list(int limit) { + return http.listAbilities(limit); + } + + /** Get an ability by name. */ + public AbilityInfo get(String name) { + Objects.requireNonNull(name, "Ability name cannot be null"); + return http.getAbilityByName(name); + } +} diff --git a/java/src/main/java/io/openmetadata/ai/api/AgentsApi.java b/java/src/main/java/io/openmetadata/ai/api/AgentsApi.java new file mode 100644 index 0000000..339a071 --- /dev/null +++ b/java/src/main/java/io/openmetadata/ai/api/AgentsApi.java @@ -0,0 +1,70 @@ +package io.openmetadata.ai.api; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import io.openmetadata.ai.AISdk; +import io.openmetadata.ai.internal.AISdkHttpClient; +import io.openmetadata.ai.models.AbilityInfo; +import io.openmetadata.ai.models.AgentInfo; +import io.openmetadata.ai.models.CreateAgentRequest; +import io.openmetadata.ai.models.EntityReference; +import io.openmetadata.ai.models.PersonaInfo; + +/** + * Namespace for dynamic agent CRUD operations. + * + *

Holds a back-reference to the parent {@link AISdk} so that {@link + * #create(CreateAgentRequest.Builder)} can resolve persona / ability names to entity references via + * the personas / abilities namespaces. + */ +public class AgentsApi { + + private final AISdkHttpClient http; + private final AISdk client; + + public AgentsApi(AISdkHttpClient http, AISdk client) { + this.http = Objects.requireNonNull(http, "http cannot be null"); + this.client = Objects.requireNonNull(client, "client cannot be null"); + } + + /** List all API-enabled dynamic agents. */ + public List list() { + return http.listAgents(); + } + + /** List API-enabled dynamic agents up to limit. */ + public List list(int limit) { + return http.listAgents(limit); + } + + /** + * Create a new dynamic agent. Persona and ability names on the builder are resolved to entity + * references via the personas and abilities namespaces. + */ + public AgentInfo create(CreateAgentRequest.Builder builder) { + Objects.requireNonNull(builder, "Builder cannot be null"); + + String personaName = builder.getPersonaName(); + if (personaName == null || personaName.isEmpty()) { + throw new IllegalArgumentException("persona is required"); + } + PersonaInfo personaInfo = client.personas().get(personaName); + EntityReference personaRef = + EntityReference.builder().id(personaInfo.getId()).type("persona").build(); + + List abilityRefs = null; + List abilityNames = builder.getAbilityNames(); + if (abilityNames != null && !abilityNames.isEmpty()) { + abilityRefs = new ArrayList<>(); + for (String abilityName : abilityNames) { + AbilityInfo abilityInfo = client.abilities().get(abilityName); + abilityRefs.add(EntityReference.builder().id(abilityInfo.getId()).type("ability").build()); + } + } + + CreateAgentRequest request = builder.build(personaRef, abilityRefs); + return http.createAgent(request); + } +} diff --git a/java/src/main/java/io/openmetadata/ai/api/BotsApi.java b/java/src/main/java/io/openmetadata/ai/api/BotsApi.java new file mode 100644 index 0000000..771ae39 --- /dev/null +++ b/java/src/main/java/io/openmetadata/ai/api/BotsApi.java @@ -0,0 +1,33 @@ +package io.openmetadata.ai.api; + +import java.util.List; +import java.util.Objects; + +import io.openmetadata.ai.internal.AISdkHttpClient; +import io.openmetadata.ai.models.BotInfo; + +/** Namespace for bot operations. */ +public class BotsApi { + + private final AISdkHttpClient http; + + public BotsApi(AISdkHttpClient http) { + this.http = Objects.requireNonNull(http, "http cannot be null"); + } + + /** List all bots. */ + public List list() { + return http.listBots(); + } + + /** List bots up to limit. */ + public List list(int limit) { + return http.listBots(limit); + } + + /** Get a bot by name. */ + public BotInfo get(String name) { + Objects.requireNonNull(name, "Bot name cannot be null"); + return http.getBotByName(name); + } +} diff --git a/java/src/main/java/io/openmetadata/ai/api/MemoriesApi.java b/java/src/main/java/io/openmetadata/ai/api/MemoriesApi.java new file mode 100644 index 0000000..9d04bae --- /dev/null +++ b/java/src/main/java/io/openmetadata/ai/api/MemoriesApi.java @@ -0,0 +1,139 @@ +package io.openmetadata.ai.api; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.openmetadata.ai.exceptions.AISdkException; +import io.openmetadata.ai.internal.AISdkHttpClient; +import io.openmetadata.ai.models.ContextMemory; +import io.openmetadata.ai.models.CreateContextMemoryRequest; +import io.openmetadata.ai.models.MemorySearchResults; + +/** + * Namespace for Context Center memory operations. + * + *

Uses two HTTP clients: one rooted at /api/v1/contextCenter/memories for CRUD, and one rooted + * at /api/v1 for the hybrid NLQ search at /hybrid/nlq/search. + */ +public class MemoriesApi { + + private static final int DEFAULT_PAGE_SIZE = 100; + private static final ObjectMapper JSON = new ObjectMapper(); + + private final AISdkHttpClient http; + private final AISdkHttpClient searchHttp; + + public MemoriesApi(AISdkHttpClient http, AISdkHttpClient searchHttp) { + this.http = Objects.requireNonNull(http, "http cannot be null"); + this.searchHttp = Objects.requireNonNull(searchHttp, "searchHttp cannot be null"); + } + + /** List Context Center memories. */ + public List list() { + return list(null, null); + } + + /** List Context Center memories, capped at limit. */ + public List list(Integer limit) { + return list(null, limit); + } + + /** List Context Center memories filtered to a primary entity FQN. */ + public List list(String primaryEntityFqn, Integer limit) { + List results = new ArrayList<>(); + String after = null; + while (true) { + Map params = new LinkedHashMap<>(); + params.put("limit", DEFAULT_PAGE_SIZE); + if (primaryEntityFqn != null) { + params.put("primaryEntityFqn", primaryEntityFqn); + } + if (after != null) { + params.put("after", after); + } + Map response = http.getMap("/", params); + Object data = response.get("data"); + if (data instanceof List) { + for (Object item : (List) data) { + if (item instanceof Map) { + @SuppressWarnings("unchecked") + Map itemMap = (Map) item; + results.add(ContextMemory.fromMap(itemMap)); + if (limit != null && results.size() >= limit) { + return results.subList(0, Math.min(results.size(), limit)); + } + } + } + } + Object paging = response.get("paging"); + if (paging instanceof Map) { + Object next = ((Map) paging).get("after"); + if (next == null) { + break; + } + after = String.valueOf(next); + } else { + break; + } + } + return results; + } + + /** Get a memory by id. */ + public ContextMemory get(String memoryId) { + Objects.requireNonNull(memoryId, "memoryId cannot be null"); + Map response = http.getMap("/" + memoryId, null); + return ContextMemory.fromMap(response); + } + + /** Create a new Context Center memory. */ + public ContextMemory create(CreateContextMemoryRequest request) { + Objects.requireNonNull(request, "request cannot be null"); + Map response = http.postMap("/", request.toApiMap()); + return ContextMemory.fromMap(response); + } + + /** Soft-delete a memory by id. */ + public void delete(String memoryId) { + delete(memoryId, false); + } + + /** Delete a memory by id, optionally hard-deleting. */ + public void delete(String memoryId, boolean hardDelete) { + Objects.requireNonNull(memoryId, "memoryId cannot be null"); + Map params = new LinkedHashMap<>(); + params.put("hardDelete", hardDelete); + http.delete("/" + memoryId, params); + } + + /** Hybrid NLQ search over Context Center memories. */ + public MemorySearchResults search(String query) { + return search(query, null, 15, 0); + } + + /** Hybrid NLQ search with optional filters and pagination. */ + public MemorySearchResults search( + String query, Map> filters, int size, int from) { + Objects.requireNonNull(query, "query cannot be null"); + Map params = new LinkedHashMap<>(); + params.put("q", query); + params.put("index", "contextMemory"); + params.put("size", size); + params.put("from", from); + if (filters != null && !filters.isEmpty()) { + try { + params.put("filters", JSON.writeValueAsString(filters)); + } catch (JsonProcessingException e) { + throw new AISdkException("Failed to serialize search filters", e); + } + } + Map response = searchHttp.getMap("/hybrid/nlq/search", params); + return MemorySearchResults.fromOpenSearch(response); + } +} diff --git a/java/src/main/java/io/openmetadata/ai/api/PersonasApi.java b/java/src/main/java/io/openmetadata/ai/api/PersonasApi.java new file mode 100644 index 0000000..dc42bb1 --- /dev/null +++ b/java/src/main/java/io/openmetadata/ai/api/PersonasApi.java @@ -0,0 +1,40 @@ +package io.openmetadata.ai.api; + +import java.util.List; +import java.util.Objects; + +import io.openmetadata.ai.internal.AISdkHttpClient; +import io.openmetadata.ai.models.CreatePersonaRequest; +import io.openmetadata.ai.models.PersonaInfo; + +/** Namespace for persona operations. */ +public class PersonasApi { + + private final AISdkHttpClient http; + + public PersonasApi(AISdkHttpClient http) { + this.http = Objects.requireNonNull(http, "http cannot be null"); + } + + /** List all personas. */ + public List list() { + return http.listPersonas(); + } + + /** List personas up to limit. */ + public List list(int limit) { + return http.listPersonas(limit); + } + + /** Get a persona by name. */ + public PersonaInfo get(String name) { + Objects.requireNonNull(name, "Persona name cannot be null"); + return http.getPersonaByName(name); + } + + /** Create a new persona. */ + public PersonaInfo create(CreatePersonaRequest request) { + Objects.requireNonNull(request, "CreatePersonaRequest cannot be null"); + return http.createPersona(request); + } +} diff --git a/java/src/main/java/io/openmetadata/ai/internal/AISdkHttpClient.java b/java/src/main/java/io/openmetadata/ai/internal/AISdkHttpClient.java index 342790a..d418360 100644 --- a/java/src/main/java/io/openmetadata/ai/internal/AISdkHttpClient.java +++ b/java/src/main/java/io/openmetadata/ai/internal/AISdkHttpClient.java @@ -10,7 +10,9 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.function.Consumer; import java.util.stream.Stream; @@ -29,6 +31,9 @@ public class AISdkHttpClient implements AutoCloseable { private static final String BOTS_API_PATH = "/api/v1/bots"; private static final String PERSONAS_API_PATH = "/api/v1/agents/personas"; private static final String ABILITIES_API_PATH = "/api/v1/agents/abilities"; + private static final String CHAT_CONVERSATIONS_PATH = "/api/v1/assistants/chatConversations"; + private static final String DEFAULT_AGENT_INVOKE_PATH = "/api/v1/agents/invoke"; + private static final String DEFAULT_AGENT_RUN_PATH = "/api/v1/agents/run"; private static final String CONTENT_TYPE_JSON = "application/json"; private static final String ACCEPT_SSE = "text/event-stream"; @@ -43,9 +48,27 @@ public class AISdkHttpClient implements AutoCloseable { public AISdkHttpClient( String host, String token, Duration timeout, int maxRetries, Duration retryDelay) { + this(host, token, timeout, maxRetries, retryDelay, API_BASE_PATH); + } + + /** Variant that lets callers pin this client to a specific base path. */ + public AISdkHttpClient( + String host, + String token, + Duration timeout, + int maxRetries, + Duration retryDelay, + String basePath) { this.host = normalizeHost(host); - this.baseUrl = this.host + API_BASE_PATH; + this.baseUrl = this.host + basePath; this.token = token; + // Only set connectTimeout (TCP connect). We deliberately do NOT call + // HttpRequest.Builder.timeout(...) on individual requests, because the + // per-request timeout bounds the entire response — including the body + // read — and that would cut long-running agent SSE streams mid-flight. + // SSE streams can run for many minutes; the stream's own events signal + // liveness. The same HttpClient is therefore safe to share across + // streaming and non-streaming calls. this.httpClient = HttpClient.newBuilder().connectTimeout(timeout).build(); this.objectMapper = new ObjectMapper(); this.sseParser = new SseParser(objectMapper); @@ -53,6 +76,11 @@ public AISdkHttpClient( this.retryDelay = retryDelay; } + /** Returns this client's resolved base URL (host + basePath). */ + public String getBaseUrl() { + return baseUrl; + } + private String normalizeHost(String host) { if (host.endsWith("/")) { return host.substring(0, host.length() - 1); @@ -210,7 +238,12 @@ public InvokeResponse invoke(String agentName, InvokeRequest invokeRequest) { } } - /** Invokes an agent with streaming, calling the consumer for each event. */ + /** + * Invokes an agent with streaming, calling the consumer for each event. + * + *

Note: the {@link HttpRequest} below intentionally does NOT call {@code .timeout(...)} — SSE + * streams from agent runs can take many minutes, and the stream's own events signal liveness. + */ public void stream( String agentName, InvokeRequest invokeRequest, Consumer eventConsumer) { String encodedName = URLEncoder.encode(agentName, StandardCharsets.UTF_8); @@ -251,6 +284,9 @@ public void stream( /** * Invokes an agent with streaming, returning a Stream of events. The caller must close the * returned Stream when done. + * + *

Note: the {@link HttpRequest} below intentionally does NOT call {@code .timeout(...)} — SSE + * streams from agent runs can take many minutes, and the stream's own events signal liveness. */ public Stream streamIterator(String agentName, InvokeRequest invokeRequest) { String encodedName = URLEncoder.encode(agentName, StandardCharsets.UTF_8); @@ -286,6 +322,144 @@ public Stream streamIterator(String agentName, InvokeRequest invoke } } + // ==================== Default Agent Operations ==================== + + public String createChatConversation(String title) { + String requestBody; + try { + requestBody = + objectMapper.writeValueAsString(java.util.Collections.singletonMap("title", title)); + } catch (JsonProcessingException e) { + throw new AISdkException("Failed to serialize conversation create request", e); + } + + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(host + CHAT_CONVERSATIONS_PATH)) + .header("Authorization", "Bearer " + token) + .header("Content-Type", CONTENT_TYPE_JSON) + .header("Accept", CONTENT_TYPE_JSON) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + HttpResponse response = executeWithRetry(request); + try { + JsonNode root = objectMapper.readTree(response.body()); + JsonNode idNode = root.get("id"); + if (idNode == null || idNode.isNull()) { + throw new AISdkException("Conversation create response missing 'id' field"); + } + return idNode.asText(); + } catch (JsonProcessingException e) { + throw new AISdkException("Failed to parse conversation create response", e); + } + } + + public InvokeResponse invokeDefaultAgent( + String message, String conversationId, String agentType, String agentMode) { + java.util.Map body = new java.util.LinkedHashMap<>(); + body.put("message", message); + body.put("conversationId", conversationId); + body.put("agentType", agentType); + body.put("agentMode", agentMode); + + String requestBody; + try { + requestBody = objectMapper.writeValueAsString(body); + } catch (JsonProcessingException e) { + throw new AISdkException("Failed to serialize default agent invoke request", e); + } + + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(host + DEFAULT_AGENT_INVOKE_PATH)) + .header("Authorization", "Bearer " + token) + .header("Content-Type", CONTENT_TYPE_JSON) + .header("Accept", CONTENT_TYPE_JSON) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + HttpResponse response = executeWithRetry(request); + try { + return objectMapper.readValue(response.body(), InvokeResponse.class); + } catch (JsonProcessingException e) { + throw new AISdkException("Failed to parse default agent invoke response", e); + } + } + + public void streamDefaultAgent( + String message, + String conversationId, + String agentType, + String agentMode, + Consumer eventConsumer) { + HttpRequest request = + buildDefaultAgentRunRequest(message, conversationId, agentType, agentMode); + + try { + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + handleErrorStatus(response.statusCode(), null, parseRetryAfter(response)); + + try (InputStream inputStream = response.body()) { + sseParser.parse(inputStream, eventConsumer); + } + } catch (AISdkException e) { + throw e; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new AISdkException("Default agent stream request failed: " + e.getMessage(), e); + } + } + + public Stream streamDefaultAgentIterator( + String message, String conversationId, String agentType, String agentMode) { + HttpRequest request = + buildDefaultAgentRunRequest(message, conversationId, agentType, agentMode); + + try { + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + handleErrorStatus(response.statusCode(), null, parseRetryAfter(response)); + + return sseParser.parseAsStream(response.body()); + } catch (AISdkException e) { + throw e; + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new AISdkException("Default agent stream request failed: " + e.getMessage(), e); + } + } + + private HttpRequest buildDefaultAgentRunRequest( + String message, String conversationId, String agentType, String agentMode) { + java.util.Map body = new java.util.LinkedHashMap<>(); + body.put("message", message); + body.put("conversationId", conversationId); + body.put("agentType", agentType); + body.put("agentMode", agentMode); + + String requestBody; + try { + requestBody = objectMapper.writeValueAsString(body); + } catch (JsonProcessingException e) { + throw new AISdkException("Failed to serialize default agent run request", e); + } + + // Intentionally no .timeout(...) — see streamIterator() Javadoc. + return HttpRequest.newBuilder() + .uri(URI.create(host + DEFAULT_AGENT_RUN_PATH)) + .header("Authorization", "Bearer " + token) + .header("Content-Type", CONTENT_TYPE_JSON) + .header("Accept", ACCEPT_SSE) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + } + // ==================== Bot Operations ==================== /** @@ -664,6 +838,91 @@ private void sleep(Duration duration) { } } + // ==================== Generic Helpers (used by API namespaces) ==================== + + /** GETs a path relative to baseUrl, returning the parsed JSON body as a map. */ + public Map getMap(String path, Map params) { + String url = buildUrl(path, params); + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + token) + .header("Accept", CONTENT_TYPE_JSON) + .GET() + .build(); + HttpResponse response = executeWithRetry(request); + return parseMap(response.body()); + } + + /** POSTs a JSON body to a path relative to baseUrl, returning the response as a map. */ + public Map postMap(String path, Object body) { + String requestBody; + try { + requestBody = objectMapper.writeValueAsString(body); + } catch (JsonProcessingException e) { + throw new AISdkException("Failed to serialize request body", e); + } + String url = buildUrl(path, null); + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + token) + .header("Content-Type", CONTENT_TYPE_JSON) + .header("Accept", CONTENT_TYPE_JSON) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + HttpResponse response = executeWithRetry(request); + return parseMap(response.body()); + } + + /** DELETEs a path relative to baseUrl with optional query parameters. */ + public void delete(String path, Map params) { + String url = buildUrl(path, params); + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + token) + .header("Accept", CONTENT_TYPE_JSON) + .DELETE() + .build(); + executeWithRetry(request); + } + + private String buildUrl(String path, Map params) { + StringBuilder sb = new StringBuilder(baseUrl); + if (path != null && !path.isEmpty() && !path.equals("/")) { + if (!path.startsWith("/")) { + sb.append("/"); + } + sb.append(path); + } + if (params != null && !params.isEmpty()) { + char sep = sb.indexOf("?") >= 0 ? '&' : '?'; + for (Map.Entry entry : params.entrySet()) { + if (entry.getValue() == null) { + continue; + } + sb.append(sep); + sb.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)); + sb.append('='); + sb.append(URLEncoder.encode(String.valueOf(entry.getValue()), StandardCharsets.UTF_8)); + sep = '&'; + } + } + return sb.toString(); + } + + private Map parseMap(String body) { + if (body == null || body.isEmpty()) { + return new LinkedHashMap<>(); + } + try { + return objectMapper.readValue(body, new TypeReference>() {}); + } catch (JsonProcessingException e) { + throw new AISdkException("Failed to parse response body", e); + } + } + @Override public void close() { // HttpClient doesn't need explicit closing in Java 11+ diff --git a/java/src/main/java/io/openmetadata/ai/models/ContextMemory.java b/java/src/main/java/io/openmetadata/ai/models/ContextMemory.java new file mode 100644 index 0000000..95e4d7c --- /dev/null +++ b/java/src/main/java/io/openmetadata/ai/models/ContextMemory.java @@ -0,0 +1,205 @@ +package io.openmetadata.ai.models; + +import java.util.Map; + +/** A Context Center memory. */ +public class ContextMemory { + + private String id; + private String name; + private String fullyQualifiedName; + private String title; + private String question; + private String answer; + private String summary; + private MemoryType memoryType; + private MemoryScope memoryScope; + private MemoryVisibility visibility; + private EntityReference primaryEntity; + private int usageCount; + private Long lastUsedAt; + private boolean deleted; + + public ContextMemory() {} + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getFullyQualifiedName() { + return fullyQualifiedName; + } + + public void setFullyQualifiedName(String fullyQualifiedName) { + this.fullyQualifiedName = fullyQualifiedName; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getQuestion() { + return question; + } + + public void setQuestion(String question) { + this.question = question; + } + + public String getAnswer() { + return answer; + } + + public void setAnswer(String answer) { + this.answer = answer; + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + + public MemoryType getMemoryType() { + return memoryType; + } + + public void setMemoryType(MemoryType memoryType) { + this.memoryType = memoryType; + } + + public MemoryScope getMemoryScope() { + return memoryScope; + } + + public void setMemoryScope(MemoryScope memoryScope) { + this.memoryScope = memoryScope; + } + + public MemoryVisibility getVisibility() { + return visibility; + } + + public void setVisibility(MemoryVisibility visibility) { + this.visibility = visibility; + } + + public EntityReference getPrimaryEntity() { + return primaryEntity; + } + + public void setPrimaryEntity(EntityReference primaryEntity) { + this.primaryEntity = primaryEntity; + } + + public int getUsageCount() { + return usageCount; + } + + public void setUsageCount(int usageCount) { + this.usageCount = usageCount; + } + + public Long getLastUsedAt() { + return lastUsedAt; + } + + public void setLastUsedAt(Long lastUsedAt) { + this.lastUsedAt = lastUsedAt; + } + + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } + + /** + * Build a ContextMemory from a parsed JSON map. Flattens shareConfig.visibility back to a + * top-level visibility field. + */ + @SuppressWarnings("unchecked") + public static ContextMemory fromMap(Map data) { + ContextMemory m = new ContextMemory(); + if (data == null) { + return m; + } + if (data.get("id") != null) { + m.id = String.valueOf(data.get("id")); + } + if (data.get("name") != null) { + m.name = String.valueOf(data.get("name")); + } + if (data.get("fullyQualifiedName") != null) { + m.fullyQualifiedName = String.valueOf(data.get("fullyQualifiedName")); + } + if (data.get("title") != null) { + m.title = String.valueOf(data.get("title")); + } + m.question = data.get("question") != null ? String.valueOf(data.get("question")) : ""; + m.answer = data.get("answer") != null ? String.valueOf(data.get("answer")) : ""; + if (data.get("summary") != null) { + m.summary = String.valueOf(data.get("summary")); + } + String memoryTypeStr = + data.get("memoryType") != null ? String.valueOf(data.get("memoryType")) : "Note"; + m.memoryType = MemoryType.fromValue(memoryTypeStr); + String memoryScopeStr = + data.get("memoryScope") != null ? String.valueOf(data.get("memoryScope")) : "EntityScoped"; + m.memoryScope = MemoryScope.fromValue(memoryScopeStr); + + String visibilityStr = "Private"; + Object shareConfig = data.get("shareConfig"); + if (shareConfig instanceof Map) { + Object v = ((Map) shareConfig).get("visibility"); + if (v != null) { + visibilityStr = String.valueOf(v); + } + } + m.visibility = MemoryVisibility.fromValue(visibilityStr); + + Object pe = data.get("primaryEntity"); + if (pe instanceof Map) { + Map peMap = (Map) pe; + m.primaryEntity = + new EntityReference( + peMap.get("id") != null ? String.valueOf(peMap.get("id")) : null, + peMap.get("type") != null ? String.valueOf(peMap.get("type")) : null, + peMap.get("name") != null ? String.valueOf(peMap.get("name")) : null, + peMap.get("displayName") != null ? String.valueOf(peMap.get("displayName")) : null); + } + Object usage = data.get("usageCount"); + if (usage instanceof Number) { + m.usageCount = ((Number) usage).intValue(); + } + Object lastUsed = data.get("lastUsedAt"); + if (lastUsed instanceof Number) { + m.lastUsedAt = ((Number) lastUsed).longValue(); + } + Object deletedVal = data.get("deleted"); + if (deletedVal instanceof Boolean) { + m.deleted = (Boolean) deletedVal; + } + return m; + } +} diff --git a/java/src/main/java/io/openmetadata/ai/models/CreateContextMemoryRequest.java b/java/src/main/java/io/openmetadata/ai/models/CreateContextMemoryRequest.java new file mode 100644 index 0000000..ea49456 --- /dev/null +++ b/java/src/main/java/io/openmetadata/ai/models/CreateContextMemoryRequest.java @@ -0,0 +1,234 @@ +package io.openmetadata.ai.models; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Request to create a Context Center memory. */ +public class CreateContextMemoryRequest { + + private final String name; + private final String question; + private final String answer; + private final String title; + private final String description; + private final MemoryType memoryType; + private final MemoryScope memoryScope; + private final MemoryVisibility visibility; + private final EntityReference primaryEntity; + private final List relatedEntities; + private final List tags; + + private CreateContextMemoryRequest(Builder b) { + this.name = b.name; + this.question = b.question; + this.answer = b.answer; + this.title = b.title; + this.description = b.description; + this.memoryType = b.memoryType; + this.memoryScope = b.memoryScope; + this.visibility = b.visibility; + this.primaryEntity = b.primaryEntity; + this.relatedEntities = b.relatedEntities; + this.tags = b.tags; + } + + public String getName() { + return name; + } + + public String getQuestion() { + return question; + } + + public String getAnswer() { + return answer; + } + + public String getTitle() { + return title; + } + + public String getDescription() { + return description; + } + + public MemoryType getMemoryType() { + return memoryType; + } + + public MemoryScope getMemoryScope() { + return memoryScope; + } + + public MemoryVisibility getVisibility() { + return visibility; + } + + public EntityReference getPrimaryEntity() { + return primaryEntity; + } + + public List getRelatedEntities() { + return relatedEntities; + } + + public List getTags() { + return tags; + } + + /** Convert to API request format (camelCase keys, shareConfig wrapping, tag wrapping). */ + public Map toApiMap() { + Map d = new LinkedHashMap<>(); + d.put("name", name); + d.put("question", question); + d.put("answer", answer); + d.put("memoryType", memoryType.getValue()); + d.put("memoryScope", memoryScope.getValue()); + Map shareConfig = new LinkedHashMap<>(); + shareConfig.put("visibility", visibility.getValue()); + d.put("shareConfig", shareConfig); + if (title != null) { + d.put("title", title); + } + if (description != null) { + d.put("description", description); + } + if (primaryEntity != null) { + d.put("primaryEntity", entityReferenceToMap(primaryEntity)); + } + if (relatedEntities != null) { + List> refs = new ArrayList<>(); + for (EntityReference ref : relatedEntities) { + refs.add(entityReferenceToMap(ref)); + } + d.put("relatedEntities", refs); + } + if (tags != null) { + List> tagLabels = new ArrayList<>(); + for (String t : tags) { + Map tagLabel = new LinkedHashMap<>(); + tagLabel.put("tagFQN", t); + tagLabel.put("labelType", "Manual"); + tagLabel.put("state", "Confirmed"); + tagLabel.put("source", "Classification"); + tagLabels.add(tagLabel); + } + d.put("tags", tagLabels); + } + return d; + } + + private static Map entityReferenceToMap(EntityReference ref) { + Map m = new LinkedHashMap<>(); + if (ref.getId() != null) { + m.put("id", ref.getId()); + } + if (ref.getType() != null) { + m.put("type", ref.getType()); + } + if (ref.getName() != null) { + m.put("name", ref.getName()); + } + if (ref.getDisplayName() != null) { + m.put("displayName", ref.getDisplayName()); + } + return m; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String name; + private String question; + private String answer; + private String title; + private String description; + private MemoryType memoryType = MemoryType.NOTE; + private MemoryScope memoryScope = MemoryScope.ENTITY_SCOPED; + private MemoryVisibility visibility = MemoryVisibility.PRIVATE; + private EntityReference primaryEntity; + private List relatedEntities; + private List tags; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder question(String question) { + this.question = question; + return this; + } + + public Builder answer(String answer) { + this.answer = answer; + return this; + } + + public Builder title(String title) { + this.title = title; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder memoryType(MemoryType memoryType) { + this.memoryType = memoryType; + return this; + } + + public Builder memoryScope(MemoryScope memoryScope) { + this.memoryScope = memoryScope; + return this; + } + + public Builder visibility(MemoryVisibility visibility) { + this.visibility = visibility; + return this; + } + + public Builder primaryEntity(EntityReference primaryEntity) { + this.primaryEntity = primaryEntity; + return this; + } + + public Builder relatedEntities(List relatedEntities) { + this.relatedEntities = relatedEntities; + return this; + } + + public Builder tags(List tags) { + this.tags = tags; + return this; + } + + public CreateContextMemoryRequest build() { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name is required"); + } + if (question == null || question.isEmpty()) { + throw new IllegalArgumentException("question is required"); + } + if (answer == null || answer.isEmpty()) { + throw new IllegalArgumentException("answer is required"); + } + if (memoryType == null) { + throw new IllegalArgumentException("memoryType cannot be null"); + } + if (memoryScope == null) { + throw new IllegalArgumentException("memoryScope cannot be null"); + } + if (visibility == null) { + throw new IllegalArgumentException("visibility cannot be null"); + } + return new CreateContextMemoryRequest(this); + } + } +} diff --git a/java/src/main/java/io/openmetadata/ai/models/InvokeResponse.java b/java/src/main/java/io/openmetadata/ai/models/InvokeResponse.java index 6beb084..5e7a2ea 100644 --- a/java/src/main/java/io/openmetadata/ai/models/InvokeResponse.java +++ b/java/src/main/java/io/openmetadata/ai/models/InvokeResponse.java @@ -18,16 +18,24 @@ public class InvokeResponse { @JsonProperty("toolsUsed") private List toolsUsed; + @JsonProperty("thinkingSteps") + private List thinkingSteps; + @JsonProperty("usage") private Usage usage; public InvokeResponse() {} public InvokeResponse( - String conversationId, String response, List toolsUsed, Usage usage) { + String conversationId, + String response, + List toolsUsed, + List thinkingSteps, + Usage usage) { this.conversationId = conversationId; this.response = response; this.toolsUsed = toolsUsed; + this.thinkingSteps = thinkingSteps; this.usage = usage; } @@ -55,6 +63,14 @@ public void setToolsUsed(List toolsUsed) { this.toolsUsed = toolsUsed; } + public List getThinkingSteps() { + return thinkingSteps != null ? thinkingSteps : java.util.Collections.emptyList(); + } + + public void setThinkingSteps(List thinkingSteps) { + this.thinkingSteps = thinkingSteps; + } + public Usage getUsage() { return usage; } @@ -72,6 +88,7 @@ public static class Builder { private String conversationId; private String response; private List toolsUsed; + private List thinkingSteps; private Usage usage; public Builder conversationId(String conversationId) { @@ -89,13 +106,18 @@ public Builder toolsUsed(List toolsUsed) { return this; } + public Builder thinkingSteps(List thinkingSteps) { + this.thinkingSteps = thinkingSteps; + return this; + } + public Builder usage(Usage usage) { this.usage = usage; return this; } public InvokeResponse build() { - return new InvokeResponse(conversationId, response, toolsUsed, usage); + return new InvokeResponse(conversationId, response, toolsUsed, thinkingSteps, usage); } } @@ -110,6 +132,8 @@ public String toString() { + '\'' + ", toolsUsed=" + toolsUsed + + ", thinkingSteps=" + + thinkingSteps + ", usage=" + usage + '}'; diff --git a/java/src/main/java/io/openmetadata/ai/models/MemoryScope.java b/java/src/main/java/io/openmetadata/ai/models/MemoryScope.java new file mode 100644 index 0000000..0d2f120 --- /dev/null +++ b/java/src/main/java/io/openmetadata/ai/models/MemoryScope.java @@ -0,0 +1,31 @@ +package io.openmetadata.ai.models; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** Scope where a Context Center memory applies. */ +public enum MemoryScope { + USER_GLOBAL("UserGlobal"), + ENTITY_SCOPED("EntityScoped"); + + private final String value; + + MemoryScope(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static MemoryScope fromValue(String value) { + for (MemoryScope scope : values()) { + if (scope.value.equals(value)) { + return scope; + } + } + throw new IllegalArgumentException("Unknown MemoryScope: " + value); + } +} diff --git a/java/src/main/java/io/openmetadata/ai/models/MemorySearchHit.java b/java/src/main/java/io/openmetadata/ai/models/MemorySearchHit.java new file mode 100644 index 0000000..76c3d23 --- /dev/null +++ b/java/src/main/java/io/openmetadata/ai/models/MemorySearchHit.java @@ -0,0 +1,36 @@ +package io.openmetadata.ai.models; + +import java.util.Collections; +import java.util.Map; + +/** A single hit from a hybrid memory search. */ +public class MemorySearchHit { + + private final ContextMemory memory; + private final double score; + + public MemorySearchHit(ContextMemory memory, double score) { + this.memory = memory; + this.score = score; + } + + public ContextMemory getMemory() { + return memory; + } + + public double getScore() { + return score; + } + + /** Build a MemorySearchHit from an OpenSearch hit map. */ + @SuppressWarnings("unchecked") + public static MemorySearchHit fromMap(Map data) { + Map source = + data.get("_source") instanceof Map + ? (Map) data.get("_source") + : Collections.emptyMap(); + Object scoreVal = data.get("_score"); + double score = scoreVal instanceof Number ? ((Number) scoreVal).doubleValue() : 0.0; + return new MemorySearchHit(ContextMemory.fromMap(source), score); + } +} diff --git a/java/src/main/java/io/openmetadata/ai/models/MemorySearchResults.java b/java/src/main/java/io/openmetadata/ai/models/MemorySearchResults.java new file mode 100644 index 0000000..f6cdef9 --- /dev/null +++ b/java/src/main/java/io/openmetadata/ai/models/MemorySearchResults.java @@ -0,0 +1,59 @@ +package io.openmetadata.ai.models; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** Results from a hybrid memory search. */ +public class MemorySearchResults { + + private final int total; + private final List hits; + + public MemorySearchResults(int total, List hits) { + this.total = total; + this.hits = hits != null ? hits : Collections.emptyList(); + } + + public int getTotal() { + return total; + } + + public List getHits() { + return hits; + } + + /** Parse an OpenSearch response shape into MemorySearchResults. */ + @SuppressWarnings("unchecked") + public static MemorySearchResults fromOpenSearch(Map data) { + if (data == null) { + return new MemorySearchResults(0, Collections.emptyList()); + } + Object hitsBlockObj = data.get("hits"); + Map hitsBlock = + hitsBlockObj instanceof Map ? (Map) hitsBlockObj : Collections.emptyMap(); + + int total = 0; + Object totalObj = hitsBlock.get("total"); + if (totalObj instanceof Map) { + Object value = ((Map) totalObj).get("value"); + if (value instanceof Number) { + total = ((Number) value).intValue(); + } + } else if (totalObj instanceof Number) { + total = ((Number) totalObj).intValue(); + } + + Object rawHitsObj = hitsBlock.get("hits"); + List parsedHits = new ArrayList<>(); + if (rawHitsObj instanceof List) { + for (Object item : (List) rawHitsObj) { + if (item instanceof Map) { + parsedHits.add(MemorySearchHit.fromMap((Map) item)); + } + } + } + return new MemorySearchResults(total, parsedHits); + } +} diff --git a/java/src/main/java/io/openmetadata/ai/models/MemoryType.java b/java/src/main/java/io/openmetadata/ai/models/MemoryType.java new file mode 100644 index 0000000..930c94b --- /dev/null +++ b/java/src/main/java/io/openmetadata/ai/models/MemoryType.java @@ -0,0 +1,34 @@ +package io.openmetadata.ai.models; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** High-level type of a Context Center memory. */ +public enum MemoryType { + PREFERENCE("Preference"), + USE_CASE("UseCase"), + NOTE("Note"), + RUNBOOK("Runbook"), + FAQ("Faq"); + + private final String value; + + MemoryType(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static MemoryType fromValue(String value) { + for (MemoryType type : values()) { + if (type.value.equals(value)) { + return type; + } + } + throw new IllegalArgumentException("Unknown MemoryType: " + value); + } +} diff --git a/java/src/main/java/io/openmetadata/ai/models/MemoryVisibility.java b/java/src/main/java/io/openmetadata/ai/models/MemoryVisibility.java new file mode 100644 index 0000000..50c7198 --- /dev/null +++ b/java/src/main/java/io/openmetadata/ai/models/MemoryVisibility.java @@ -0,0 +1,32 @@ +package io.openmetadata.ai.models; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** Visibility level for a Context Center memory. */ +public enum MemoryVisibility { + PRIVATE("Private"), + ENTITY("Entity"), + SHARED("Shared"); + + private final String value; + + MemoryVisibility(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static MemoryVisibility fromValue(String value) { + for (MemoryVisibility visibility : values()) { + if (visibility.value.equals(value)) { + return visibility; + } + } + throw new IllegalArgumentException("Unknown MemoryVisibility: " + value); + } +} diff --git a/java/src/test/java/io/openmetadata/ai/AISdkTest.java b/java/src/test/java/io/openmetadata/ai/AISdkTest.java index a19c34a..9fa9886 100644 --- a/java/src/test/java/io/openmetadata/ai/AISdkTest.java +++ b/java/src/test/java/io/openmetadata/ai/AISdkTest.java @@ -1,6 +1,9 @@ package io.openmetadata.ai; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; import java.time.Duration; import java.util.Arrays; @@ -13,6 +16,7 @@ import org.junit.jupiter.api.Test; import io.openmetadata.ai.exceptions.*; +import io.openmetadata.ai.internal.AISdkHttpClient; import io.openmetadata.ai.models.*; /** Unit tests for the AI SDK. */ @@ -136,7 +140,7 @@ void agentMethodThrowsOnNullName() { void getBotMethodThrowsOnNullName() { AISdk client = AISdk.builder().host("https://metadata.example.com").token("test-token").build(); - assertThrows(NullPointerException.class, () -> client.getBot(null)); + assertThrows(NullPointerException.class, () -> client.bots().get(null)); client.close(); } @@ -147,7 +151,7 @@ void getBotMethodThrowsOnNullName() { void getPersonaMethodThrowsOnNullName() { AISdk client = AISdk.builder().host("https://metadata.example.com").token("test-token").build(); - assertThrows(NullPointerException.class, () -> client.getPersona(null)); + assertThrows(NullPointerException.class, () -> client.personas().get(null)); client.close(); } @@ -156,7 +160,7 @@ void getPersonaMethodThrowsOnNullName() { void createPersonaMethodThrowsOnNullRequest() { AISdk client = AISdk.builder().host("https://metadata.example.com").token("test-token").build(); - assertThrows(NullPointerException.class, () -> client.createPersona(null)); + assertThrows(NullPointerException.class, () -> client.personas().create(null)); client.close(); } @@ -167,7 +171,7 @@ void createPersonaMethodThrowsOnNullRequest() { void getAbilityMethodThrowsOnNullName() { AISdk client = AISdk.builder().host("https://metadata.example.com").token("test-token").build(); - assertThrows(NullPointerException.class, () -> client.getAbility(null)); + assertThrows(NullPointerException.class, () -> client.abilities().get(null)); client.close(); } @@ -229,6 +233,32 @@ void invokeResponseCanBeBuiltWithAllFields() { assertEquals(usage, response.getUsage()); } + @Test + @DisplayName("InvokeResponse surfaces thinkingSteps when present") + void invokeResponseSurfacesThinkingSteps() { + List steps = Arrays.asList("Exploring assets...", "Synthesizing answer..."); + + InvokeResponse response = + InvokeResponse.builder() + .conversationId("conv-456") + .response("Found it.") + .thinkingSteps(steps) + .build(); + + assertEquals("Found it.", response.getResponse()); + assertEquals(steps, response.getThinkingSteps()); + } + + @Test + @DisplayName("InvokeResponse defaults thinkingSteps to empty list when absent") + void invokeResponseDefaultsThinkingStepsToEmptyList() { + InvokeResponse response = + InvokeResponse.builder().conversationId("conv-789").response("ok").build(); + + assertNotNull(response.getThinkingSteps()); + assertTrue(response.getThinkingSteps().isEmpty()); + } + @Test @DisplayName("StreamEvent can be built with all fields") void streamEventCanBeBuiltWithAllFields() { @@ -656,4 +686,99 @@ void streamEventTypesAreDefined() { assertNotNull(StreamEvent.Type.ERROR); assertNotNull(StreamEvent.Type.END); } + + // ==================== Default Agent Handle Tests ==================== + + @Test + @DisplayName("No-arg agent() method returns a DefaultAgentHandle") + void noArgAgentMethodReturnsDefaultAgentHandle() { + AISdk client = AISdk.builder().host("https://metadata.example.com").token("test-token").build(); + + DefaultAgentHandle handle = client.agent(); + assertNotNull(handle); + client.close(); + } + + @Test + @DisplayName("DefaultAgentHandle invoke auto-creates conversation then calls invokeDefaultAgent") + void defaultAgentInvokeCreatesConversationThenCallsInvoke() { + AISdkHttpClient mockHttpClient = mock(AISdkHttpClient.class); + String conversationId = "cccccccc-cccc-cccc-cccc-cccccccccccc"; + InvokeResponse expectedResponse = + InvokeResponse.builder() + .conversationId(conversationId) + .response("hello") + .toolsUsed(Arrays.asList()) + .build(); + + when(mockHttpClient.createChatConversation(anyString())).thenReturn(conversationId); + when(mockHttpClient.invokeDefaultAgent("Say hi", conversationId, "PLANNER", "CHAT_MODE")) + .thenReturn(expectedResponse); + + DefaultAgentHandle handle = new DefaultAgentHandle(mockHttpClient); + InvokeResponse response = handle.invoke("Say hi"); + + assertEquals("hello", response.getResponse()); + verify(mockHttpClient).createChatConversation("Say hi"); + verify(mockHttpClient).invokeDefaultAgent("Say hi", conversationId, "PLANNER", "CHAT_MODE"); + } + + @Test + @DisplayName("DefaultAgentHandle reuses provided conversationId without creating a new one") + void defaultAgentHandleReusesProvidedConversationId() { + AISdkHttpClient mockHttpClient = mock(AISdkHttpClient.class); + String conversationId = "existing-conv-id"; + InvokeResponse expectedResponse = + InvokeResponse.builder().conversationId(conversationId).response("ok").build(); + + when(mockHttpClient.invokeDefaultAgent( + anyString(), eq(conversationId), anyString(), anyString())) + .thenReturn(expectedResponse); + + DefaultAgentHandle handle = new DefaultAgentHandle(mockHttpClient); + handle.conversationId(conversationId); + InvokeResponse response = handle.invoke("Follow-up"); + + assertEquals("ok", response.getResponse()); + verify(mockHttpClient, never()).createChatConversation(anyString()); + verify(mockHttpClient).invokeDefaultAgent("Follow-up", conversationId, "PLANNER", "CHAT_MODE"); + } + + @Test + @DisplayName("DefaultAgentHandle truncates long message to 50 chars for conversation title") + void defaultAgentHandleTruncatesLongTitleTo50Chars() { + AISdkHttpClient mockHttpClient = mock(AISdkHttpClient.class); + String longMessage = "This is a very long message that exceeds fifty characters by far"; + String expectedTitle = longMessage.substring(0, 50); + + when(mockHttpClient.createChatConversation(expectedTitle)).thenReturn("new-conv-id"); + when(mockHttpClient.invokeDefaultAgent(anyString(), anyString(), anyString(), anyString())) + .thenReturn(InvokeResponse.builder().response("ok").build()); + + DefaultAgentHandle handle = new DefaultAgentHandle(mockHttpClient); + handle.invoke(longMessage); + + verify(mockHttpClient).createChatConversation(expectedTitle); + } + + @Test + @DisplayName("DefaultAgentHandle conversationId is retained across subsequent calls") + void defaultAgentHandleRetainsConversationIdAcrossCalls() { + AISdkHttpClient mockHttpClient = mock(AISdkHttpClient.class); + String conversationId = "auto-created-id"; + + when(mockHttpClient.createChatConversation(anyString())).thenReturn(conversationId); + when(mockHttpClient.invokeDefaultAgent( + anyString(), eq(conversationId), anyString(), anyString())) + .thenReturn(InvokeResponse.builder().response("first").build()) + .thenReturn(InvokeResponse.builder().response("second").build()); + + DefaultAgentHandle handle = new DefaultAgentHandle(mockHttpClient); + handle.invoke("First message"); + handle.invoke("Second message"); + + verify(mockHttpClient, times(1)).createChatConversation(anyString()); + verify(mockHttpClient, times(2)) + .invokeDefaultAgent(anyString(), eq(conversationId), anyString(), anyString()); + } } diff --git a/java/src/test/java/io/openmetadata/ai/IntegrationTest.java b/java/src/test/java/io/openmetadata/ai/IntegrationTest.java index 9eacf68..3fb07f6 100644 --- a/java/src/test/java/io/openmetadata/ai/IntegrationTest.java +++ b/java/src/test/java/io/openmetadata/ai/IntegrationTest.java @@ -61,7 +61,7 @@ static void setUp() { if (testAgentName == null || testAgentName.isEmpty()) { try { // First, try to find an existing persona with LLM configured - List personas = client.listPersonas(); + List personas = client.personas().list(); if (!personas.isEmpty()) { String personaName = personas.get(0).getName(); // Create a test agent with discoveryAndSearch ability @@ -74,12 +74,12 @@ static void setUp() { .mode("chat") .apiEnabled(true) // Enable API access for invoke/stream .abilities(Arrays.asList("discoveryAndSearch")); - client.createAgent(builder); + client.agents().create(builder); testAgentName = agentName; System.out.println("Created test agent: " + testAgentName); } else { // Fallback to first available agent - List agents = client.listAgents(); + List agents = client.agents().list(); if (!agents.isEmpty()) { testAgentName = agents.get(0).getName(); } @@ -101,14 +101,14 @@ class ConnectionTests { @DisplayName("Client can be created and list agents") void testClientCreation() { assertNotNull(client); - List agents = client.listAgents(); + List agents = client.agents().list(); assertNotNull(agents); } @Test @DisplayName("List agents returns a list") void testListAgents() { - List agents = client.listAgents(); + List agents = client.agents().list(); assertNotNull(agents); System.out.println("Found " + agents.size() + " API-enabled agents"); } @@ -119,7 +119,7 @@ void testInvalidTokenRejected() { AISdk badClient = AISdk.builder().host(System.getenv("AI_SDK_HOST")).token("invalid-token-12345").build(); - assertThrows(AuthenticationException.class, badClient::listAgents); + assertThrows(AuthenticationException.class, () -> badClient.agents().list()); } } @@ -251,7 +251,7 @@ class PersonaOperationsTests { @Test @DisplayName("List personas returns a list") void testListPersonas() { - List personas = client.listPersonas(); + List personas = client.personas().list(); assertNotNull(personas); System.out.println("Found " + personas.size() + " personas"); } @@ -259,7 +259,7 @@ void testListPersonas() { @Test @DisplayName("List personas with limit respects limit") void testListPersonasWithLimit() { - List personas = client.listPersonas(5); + List personas = client.personas().list(5); assertNotNull(personas); assertTrue(personas.size() <= 5); } @@ -267,14 +267,14 @@ void testListPersonasWithLimit() { @Test @DisplayName("Get persona by name returns correct persona") void testGetPersona() { - List personas = client.listPersonas(); + List personas = client.personas().list(); if (personas.isEmpty()) { System.out.println("Skipping: No personas available"); return; } String personaName = personas.get(0).getName(); - PersonaInfo persona = client.getPersona(personaName); + PersonaInfo persona = client.personas().get(personaName); assertNotNull(persona); assertEquals(personaName, persona.getName()); @@ -290,7 +290,8 @@ void testGetPersona() { @DisplayName("Get non-existent persona throws PersonaNotFoundException") void testGetPersonaNotFound() { assertThrows( - PersonaNotFoundException.class, () -> client.getPersona("non-existent-persona-12345")); + PersonaNotFoundException.class, + () -> client.personas().get("non-existent-persona-12345")); } @Test @@ -305,7 +306,7 @@ void testCreatePersona() { .displayName("Test Persona") .build(); - PersonaInfo created = client.createPersona(request); + PersonaInfo created = client.personas().create(request); assertNotNull(created); assertEquals(personaName, created.getName()); @@ -323,7 +324,7 @@ class BotOperationsTests { @Test @DisplayName("List bots returns a list") void testListBots() { - List bots = client.listBots(); + List bots = client.bots().list(); assertNotNull(bots); System.out.println("Found " + bots.size() + " bots"); } @@ -331,7 +332,7 @@ void testListBots() { @Test @DisplayName("List bots with limit respects limit") void testListBotsWithLimit() { - List bots = client.listBots(5); + List bots = client.bots().list(5); assertNotNull(bots); assertTrue(bots.size() <= 5); } @@ -339,14 +340,14 @@ void testListBotsWithLimit() { @Test @DisplayName("Get bot by name returns correct bot") void testGetBot() { - List bots = client.listBots(); + List bots = client.bots().list(); if (bots.isEmpty()) { System.out.println("Skipping: No bots available"); return; } String botName = bots.get(0).getName(); - BotInfo bot = client.getBot(botName); + BotInfo bot = client.bots().get(botName); assertNotNull(bot); assertEquals(botName, bot.getName()); @@ -361,7 +362,7 @@ void testGetBot() { @Test @DisplayName("Get non-existent bot throws BotNotFoundException") void testGetBotNotFound() { - assertThrows(BotNotFoundException.class, () -> client.getBot("non-existent-bot-12345")); + assertThrows(BotNotFoundException.class, () -> client.bots().get("non-existent-bot-12345")); } } @@ -374,7 +375,7 @@ class AbilityOperationsTests { @Test @DisplayName("List abilities returns a list") void testListAbilities() { - List abilities = client.listAbilities(); + List abilities = client.abilities().list(); assertNotNull(abilities); System.out.println("Found " + abilities.size() + " abilities"); } @@ -382,7 +383,7 @@ void testListAbilities() { @Test @DisplayName("List abilities with limit respects limit") void testListAbilitiesWithLimit() { - List abilities = client.listAbilities(5); + List abilities = client.abilities().list(5); assertNotNull(abilities); assertTrue(abilities.size() <= 5); } @@ -390,7 +391,7 @@ void testListAbilitiesWithLimit() { @Test @DisplayName("Abilities have expected fields") void testAbilityFields() { - List abilities = client.listAbilities(); + List abilities = client.abilities().list(); if (abilities.isEmpty()) { System.out.println("Skipping: No abilities available"); return; @@ -411,7 +412,7 @@ class AgentCRUDOperationsTests { @Test @DisplayName("Create agent creates new agent") void testCreateAgent() { - List personas = client.listPersonas(); + List personas = client.personas().list(); if (personas.isEmpty()) { System.out.println("Skipping: No personas available to create agent"); return; @@ -426,7 +427,7 @@ void testCreateAgent() { .mode("chat") .apiEnabled(true); - AgentInfo created = client.createAgent(builder); + AgentInfo created = client.agents().create(builder); assertNotNull(created); assertEquals(agentName, created.getName()); @@ -436,8 +437,8 @@ void testCreateAgent() { @Test @DisplayName("Create agent with abilities") void testCreateAgentWithAbilities() { - List personas = client.listPersonas(); - List abilities = client.listAbilities(); + List personas = client.personas().list(); + List abilities = client.abilities().list(); if (personas.isEmpty()) { System.out.println("Skipping: No personas available"); @@ -464,7 +465,7 @@ void testCreateAgentWithAbilities() { .abilities(abilityNames) .apiEnabled(true); - AgentInfo created = client.createAgent(builder); + AgentInfo created = client.agents().create(builder); assertNotNull(created); assertEquals(agentName, created.getName()); diff --git a/java/src/test/java/io/openmetadata/ai/StreamingTimeoutTest.java b/java/src/test/java/io/openmetadata/ai/StreamingTimeoutTest.java new file mode 100644 index 0000000..3465235 --- /dev/null +++ b/java/src/test/java/io/openmetadata/ai/StreamingTimeoutTest.java @@ -0,0 +1,133 @@ +package io.openmetadata.ai; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import io.openmetadata.ai.models.StreamEvent; + +/** + * Verifies that an SSE stream is NOT cut off by the configured {@code timeout}. + * + *

Background: java.net.http.HttpClient supports two distinct timeouts: + * + *

    + *
  • {@code HttpClient.Builder.connectTimeout(...)} - TCP connect only. + *
  • {@code HttpRequest.Builder.timeout(...)} - bounds the entire response, including body read. + *
+ * + *

The SDK only configures the former, so a long-running agent stream cannot be terminated by the + * configured timeout. This test pins that contract: even with a 1-second configured timeout, a slow + * streaming response that emits its events with delays exceeding 1 second still completes cleanly. + */ +class StreamingTimeoutTest { + + private HttpServer server; + private int port; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + port = server.getAddress().getPort(); + server.start(); + } + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(0); + } + } + + /** + * Registers a handler that emits 3 SSE message events with 700 ms gaps. Total stream duration + * (~2.1 s) is well over the 1-second client timeout used below — if the per-request timeout were + * applied to body reads, this test would fail with HttpTimeoutException. + */ + private void registerSlowStreamHandler(String path) { + HttpHandler handler = + new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); + exchange.getResponseHeaders().set("Cache-Control", "no-cache"); + exchange.sendResponseHeaders(200, 0); // 0 = chunked + + try (OutputStream out = exchange.getResponseBody()) { + for (int i = 0; i < 3; i++) { + String chunk = + "event: message\n" + + "data: {\"type\":\"content\",\"content\":\"chunk-" + + i + + "\"}\n\n"; + out.write(chunk.getBytes()); + out.flush(); + try { + Thread.sleep(700L); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return; + } + } + String done = "event: message\n" + "data: {\"type\":\"end\"}\n\n"; + out.write(done.getBytes()); + out.flush(); + } + } + }; + server.createContext(path, handler); + } + + @Test + @DisplayName("Slow SSE stream does not time out under a short configured timeout") + void slowStreamDoesNotTimeOutUnderShortTimeout() { + // Streaming endpoint for a named agent: /api/v1/agents/dynamic/name/{agent}/stream + String agentName = "slow-agent"; + String streamPath = "/api/v1/agents/dynamic/name/" + agentName + "/stream"; + registerSlowStreamHandler(streamPath); + + AISdk client = + AISdk.builder() + .host("http://127.0.0.1:" + port) + .token("test-token") + // Deliberately short — would cut the stream if applied to body reads. + .timeout(Duration.ofSeconds(1)) + .maxRetries(0) + .build(); + + List events = new ArrayList<>(); + AtomicBoolean failed = new AtomicBoolean(false); + + try { + client.agent(agentName).stream("hello", events::add); + } catch (RuntimeException e) { + failed.set(true); + fail( + "Stream should not throw on a 1s configured timeout for a ~2.1s SSE stream, " + + "but got: " + + e.getClass().getSimpleName() + + ": " + + e.getMessage()); + } finally { + client.close(); + } + + assertFalse(failed.get(), "Stream call must not have thrown"); + assertFalse(events.isEmpty(), "Should have received at least one streamed event"); + } +} diff --git a/java/src/test/java/io/openmetadata/ai/api/MemoriesApiTest.java b/java/src/test/java/io/openmetadata/ai/api/MemoriesApiTest.java new file mode 100644 index 0000000..c95112e --- /dev/null +++ b/java/src/test/java/io/openmetadata/ai/api/MemoriesApiTest.java @@ -0,0 +1,285 @@ +package io.openmetadata.ai.api; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import io.openmetadata.ai.internal.AISdkHttpClient; +import io.openmetadata.ai.models.ContextMemory; +import io.openmetadata.ai.models.CreateContextMemoryRequest; +import io.openmetadata.ai.models.MemoryScope; +import io.openmetadata.ai.models.MemorySearchResults; +import io.openmetadata.ai.models.MemoryType; +import io.openmetadata.ai.models.MemoryVisibility; + +/** Unit tests for {@link MemoriesApi} request shapes and response parsing. */ +class MemoriesApiTest { + + private AISdkHttpClient http; + private AISdkHttpClient searchHttp; + private MemoriesApi memories; + + @BeforeEach + void setUp() { + http = org.mockito.Mockito.mock(AISdkHttpClient.class); + searchHttp = org.mockito.Mockito.mock(AISdkHttpClient.class); + memories = new MemoriesApi(http, searchHttp); + } + + @Test + @DisplayName("list paginates through all pages") + void listPaginatesThroughAllPages() { + Map page1 = new LinkedHashMap<>(); + page1.put("data", Arrays.asList(memoryMap("m-1", "first"), memoryMap("m-2", "second"))); + Map paging1 = new LinkedHashMap<>(); + paging1.put("after", "cursor-1"); + page1.put("paging", paging1); + + Map page2 = new LinkedHashMap<>(); + page2.put("data", Collections.singletonList(memoryMap("m-3", "third"))); + page2.put("paging", new LinkedHashMap<>()); + + when(http.getMap(eq("/"), any())).thenReturn(page1, page2); + + List all = memories.list(); + + assertEquals(3, all.size()); + assertEquals("m-1", all.get(0).getId()); + assertEquals("m-2", all.get(1).getId()); + assertEquals("m-3", all.get(2).getId()); + verify(http, times(2)).getMap(eq("/"), any()); + } + + @Test + @DisplayName("list applies primaryEntityFqn filter param") + void listPassesPrimaryEntityFqn() { + Map empty = new LinkedHashMap<>(); + empty.put("data", new ArrayList<>()); + empty.put("paging", new LinkedHashMap<>()); + when(http.getMap(eq("/"), any())).thenReturn(empty); + + memories.list("orders.fqn", null); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(http).getMap(eq("/"), captor.capture()); + assertEquals("orders.fqn", captor.getValue().get("primaryEntityFqn")); + } + + @Test + @DisplayName("list respects limit and stops paginating") + void listRespectsLimit() { + Map page1 = new LinkedHashMap<>(); + page1.put("data", Arrays.asList(memoryMap("m-1", "one"), memoryMap("m-2", "two"))); + Map paging1 = new LinkedHashMap<>(); + paging1.put("after", "cursor-1"); + page1.put("paging", paging1); + when(http.getMap(eq("/"), any())).thenReturn(page1); + + List capped = memories.list(null, 1); + + assertEquals(1, capped.size()); + assertEquals("m-1", capped.get(0).getId()); + verify(http, times(1)).getMap(eq("/"), any()); + } + + @Test + @DisplayName("get fetches /{id} and parses ContextMemory") + void getFetchesByIdAndParses() { + when(http.getMap(eq("/abc-123"), any())).thenReturn(memoryMap("abc-123", "got it")); + + ContextMemory cm = memories.get("abc-123"); + + assertEquals("abc-123", cm.getId()); + assertEquals("got it", cm.getName()); + verify(http).getMap(eq("/abc-123"), any()); + } + + @Test + @DisplayName("create posts the toApiMap payload and parses response") + void createPostsApiMapAndParses() { + CreateContextMemoryRequest request = + CreateContextMemoryRequest.builder() + .name("pref-tone") + .question("Preferred tone?") + .answer("Concise.") + .memoryType(MemoryType.PREFERENCE) + .memoryScope(MemoryScope.USER_GLOBAL) + .visibility(MemoryVisibility.SHARED) + .tags(Arrays.asList("PII.Sensitive")) + .build(); + + when(http.postMap(eq("/"), any())).thenReturn(memoryMap("new-id", "pref-tone")); + + ContextMemory created = memories.create(request); + + assertEquals("new-id", created.getId()); + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(http).postMap(eq("/"), captor.capture()); + + Map body = captor.getValue(); + assertEquals("pref-tone", body.get("name")); + assertEquals("Preference", body.get("memoryType")); + assertEquals("UserGlobal", body.get("memoryScope")); + @SuppressWarnings("unchecked") + Map shareConfig = (Map) body.get("shareConfig"); + assertNotNull(shareConfig); + assertEquals("Shared", shareConfig.get("visibility")); + assertNull(body.get("visibility")); + + @SuppressWarnings("unchecked") + List> tagLabels = (List>) body.get("tags"); + assertEquals(1, tagLabels.size()); + Map tag = tagLabels.get(0); + assertEquals("PII.Sensitive", tag.get("tagFQN")); + assertEquals("Manual", tag.get("labelType")); + assertEquals("Confirmed", tag.get("state")); + assertEquals("Classification", tag.get("source")); + } + + @Test + @DisplayName("delete passes hardDelete=false by default") + void deleteDefaultsToSoftDelete() { + memories.delete("mem-x"); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(http).delete(eq("/mem-x"), captor.capture()); + assertEquals(Boolean.FALSE, captor.getValue().get("hardDelete")); + } + + @Test + @DisplayName("delete passes hardDelete=true when requested") + void deleteHonoursHardDelete() { + memories.delete("mem-x", true); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(http).delete(eq("/mem-x"), captor.capture()); + assertEquals(Boolean.TRUE, captor.getValue().get("hardDelete")); + } + + @Test + @DisplayName("search GETs /hybrid/nlq/search with q, index and pagination") + void searchSendsExpectedParams() { + when(searchHttp.getMap(eq("/hybrid/nlq/search"), any())).thenReturn(emptySearchResponse()); + + memories.search("show me orders", null, 25, 5); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(searchHttp).getMap(eq("/hybrid/nlq/search"), captor.capture()); + Map params = captor.getValue(); + assertEquals("show me orders", params.get("q")); + assertEquals("contextMemory", params.get("index")); + assertEquals(25, params.get("size")); + assertEquals(5, params.get("from")); + assertFalse(params.containsKey("filters")); + } + + @Test + @DisplayName("search serializes filters as JSON when provided") + void searchSerializesFilters() { + when(searchHttp.getMap(eq("/hybrid/nlq/search"), any())).thenReturn(emptySearchResponse()); + Map> filters = new LinkedHashMap<>(); + filters.put("primaryEntityId", Arrays.asList("abc")); + filters.put("visibility", Arrays.asList("Shared")); + + memories.search("query", filters, 10, 0); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(searchHttp).getMap(anyString(), captor.capture()); + Object filtersParam = captor.getValue().get("filters"); + assertNotNull(filtersParam); + String filtersJson = String.valueOf(filtersParam); + assertTrue(filtersJson.contains("primaryEntityId")); + assertTrue(filtersJson.contains("Shared")); + } + + @Test + @DisplayName("search parses OpenSearch response into MemorySearchResults") + void searchParsesOpenSearchResponse() { + Map hit = new LinkedHashMap<>(); + hit.put("_score", 0.91); + hit.put("_source", memoryMap("m-1", "matched")); + + Map total = new LinkedHashMap<>(); + total.put("value", 7); + + Map hitsBlock = new LinkedHashMap<>(); + hitsBlock.put("total", total); + hitsBlock.put("hits", Collections.singletonList(hit)); + + Map response = new LinkedHashMap<>(); + response.put("hits", hitsBlock); + + when(searchHttp.getMap(eq("/hybrid/nlq/search"), any())).thenReturn(response); + + MemorySearchResults results = memories.search("orders"); + + assertEquals(7, results.getTotal()); + assertEquals(1, results.getHits().size()); + assertEquals("m-1", results.getHits().get(0).getMemory().getId()); + assertEquals(0.91, results.getHits().get(0).getScore(), 1e-9); + verify(http, never()).getMap(anyString(), any()); + } + + @Test + @DisplayName("ContextMemory.fromMap flattens shareConfig.visibility") + void contextMemoryFromMapFlattensVisibility() { + Map data = memoryMap("id-1", "name-1"); + Map shareConfig = new LinkedHashMap<>(); + shareConfig.put("visibility", "Shared"); + data.put("shareConfig", shareConfig); + + ContextMemory cm = ContextMemory.fromMap(data); + + assertEquals(MemoryVisibility.SHARED, cm.getVisibility()); + } + + // ---------- helpers ---------- + + private static Map memoryMap(String id, String name) { + Map m = new LinkedHashMap<>(); + m.put("id", id); + m.put("name", name); + m.put("question", "q?"); + m.put("answer", "a."); + m.put("memoryType", "Note"); + m.put("memoryScope", "EntityScoped"); + Map share = new LinkedHashMap<>(); + share.put("visibility", "Private"); + m.put("shareConfig", share); + return m; + } + + private static Map emptySearchResponse() { + Map total = new LinkedHashMap<>(); + total.put("value", 0); + Map hits = new LinkedHashMap<>(); + hits.put("total", total); + hits.put("hits", new ArrayList<>()); + Map response = new LinkedHashMap<>(); + response.put("hits", hits); + return response; + } +} diff --git a/n8n-nodes-metadata/README.md b/n8n-nodes-metadata/README.md index 093a34e..0900142 100644 --- a/n8n-nodes-metadata/README.md +++ b/n8n-nodes-metadata/README.md @@ -44,7 +44,8 @@ The node requires OpenMetadata API credentials: | Parameter | Required | Description | |-----------|----------|-------------| -| Agent Name | Yes | Name of the DynamicAgent to invoke | +| Use Default Agent | No | Toggle on to invoke the platform's default agent (PLANNER / CHAT_MODE) without specifying a name | +| Agent Name | Conditional | Name of the DynamicAgent to invoke. Not required when **Use Default Agent** is enabled | | Message | Yes | The query or message to send to the agent | | Conversation ID | No | For multi-turn conversations, pass the conversation ID from a previous response | | Parameters | No | Additional parameters as JSON object | diff --git a/n8n-nodes-metadata/nodes/AISdkAgent/AISdkAgent.node.ts b/n8n-nodes-metadata/nodes/AISdkAgent/AISdkAgent.node.ts index fe914b2..0c1dcdb 100644 --- a/n8n-nodes-metadata/nodes/AISdkAgent/AISdkAgent.node.ts +++ b/n8n-nodes-metadata/nodes/AISdkAgent/AISdkAgent.node.ts @@ -24,7 +24,7 @@ export class AISdkAgent implements INodeType { icon: 'file:metadata.png', group: ['transform'], version: 1, - subtitle: '={{$parameter["agentName"]}}', + subtitle: '={{$parameter["useDefaultAgent"] ? "default" : $parameter["agentName"]}}', description: 'Invoke an OpenMetadata DynamicAgent', defaults: { name: 'AI SDK Agent', @@ -38,6 +38,13 @@ export class AISdkAgent implements INodeType { }, ], properties: [ + { + displayName: 'Use Default Agent', + name: 'useDefaultAgent', + type: 'boolean', + default: false, + description: 'Whether to invoke the platform default agent (no agent name needed)', + }, { displayName: 'Agent Name', name: 'agentName', @@ -45,7 +52,11 @@ export class AISdkAgent implements INodeType { default: '', placeholder: 'my-agent', description: 'The name of the DynamicAgent to invoke', - required: true, + displayOptions: { + show: { + useDefaultAgent: [false], + }, + }, }, { displayName: 'Message', @@ -87,27 +98,40 @@ export class AISdkAgent implements INodeType { for (let i = 0; i < items.length; i++) { try { - const agentName = this.getNodeParameter('agentName', i) as string; + const useDefault = this.getNodeParameter('useDefaultAgent', i, false) as boolean; const message = this.getNodeParameter('message', i) as string; const conversationId = this.getNodeParameter('conversationId', i, '') as string; // Validate required fields - if (!agentName) { - throw new NodeOperationError(this.getNode(), 'Agent Name is required', { itemIndex: i }); - } if (!message) { throw new NodeOperationError(this.getNode(), 'Message is required', { itemIndex: i }); } // Invoke the agent using the SDK - const response = await client.agent(agentName).invoke(message, { - conversationId: conversationId || undefined, - }); + let response; + if (useDefault) { + response = await client.agent().invoke(message, { + conversationId: conversationId || undefined, + }); + } else { + const agentName = this.getNodeParameter('agentName', i) as string; + if (!agentName) { + throw new NodeOperationError( + this.getNode(), + 'Agent Name is required (or enable "Use Default Agent")', + { itemIndex: i }, + ); + } + response = await client.agent(agentName).invoke(message, { + conversationId: conversationId || undefined, + }); + } // Build response object, excluding toolsUsed to match original behavior const result: IDataObject = { conversationId: response.conversationId, response: response.response, + thinkingSteps: response.thinkingSteps || [], }; // Include usage if present @@ -138,7 +162,10 @@ export class AISdkAgent implements INodeType { } // Convert SDK errors to n8n NodeApiError with descriptive messages - const agentName = this.getNodeParameter('agentName', i) as string; + const useDefault = this.getNodeParameter('useDefaultAgent', i, false) as boolean; + const agentLabel = useDefault + ? '' + : (this.getNodeParameter('agentName', i, '') as string); let errorMessage: string; let httpCode: string; @@ -149,7 +176,7 @@ export class AISdkAgent implements INodeType { errorMessage = 'Agent is not API-enabled. Enable API access in the OpenMetadata UI.'; httpCode = '403'; } else if (error instanceof AgentNotFoundError) { - errorMessage = `Agent "${agentName}" not found`; + errorMessage = `Agent "${agentLabel}" not found`; httpCode = '404'; } else if (error instanceof AgentExecutionError) { errorMessage = 'Agent execution failed. Check the agent configuration in OpenMetadata.'; diff --git a/n8n-nodes-metadata/package-lock.json b/n8n-nodes-metadata/package-lock.json index 69df40a..bca5dfa 100644 --- a/n8n-nodes-metadata/package-lock.json +++ b/n8n-nodes-metadata/package-lock.json @@ -1,15 +1,15 @@ { "name": "n8n-nodes-metadata", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "n8n-nodes-metadata", - "version": "0.1.1", + "version": "0.2.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@openmetadata/ai-sdk": "^0.1.1" + "@openmetadata/ai-sdk": "file:../typescript" }, "devDependencies": { "@types/node": "^20.10.0", @@ -27,6 +27,22 @@ "n8n-workflow": "*" } }, + "../typescript": { + "name": "@openmetadata/ai-sdk", + "version": "0.2.0", + "license": "SEE LICENSE IN LICENSE", + "devDependencies": { + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.56.0", + "typescript": "^5.0.0", + "vitest": "^1.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -472,9 +488,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -483,9 +499,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -522,9 +538,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -533,9 +549,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -663,18 +679,13 @@ } }, "node_modules/@openmetadata/ai-sdk": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@openmetadata/ai-sdk/-/ai-sdk-0.1.1.tgz", - "integrity": "sha512-XKz6qsYdiO+VdaMWGJWuRdJPi0b7VuGsmvsrpTSaGru1lUxwerj7LcBQdDSXa0qOW0AD8Ts6fm9L/yXIxfJQ9A==", - "license": "SEE LICENSE IN LICENSE", - "engines": { - "node": ">=18.0.0" - } + "resolved": "../typescript", + "link": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", "cpu": [ "arm" ], @@ -686,9 +697,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", "cpu": [ "arm64" ], @@ -700,9 +711,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", "cpu": [ "arm64" ], @@ -714,9 +725,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", "cpu": [ "x64" ], @@ -728,9 +739,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", "cpu": [ "arm64" ], @@ -742,9 +753,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", "cpu": [ "x64" ], @@ -756,9 +767,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", "cpu": [ "arm" ], @@ -770,9 +781,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", "cpu": [ "arm" ], @@ -784,9 +795,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", "cpu": [ "arm64" ], @@ -798,9 +809,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", "cpu": [ "arm64" ], @@ -812,9 +823,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", "cpu": [ "loong64" ], @@ -826,9 +837,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", "cpu": [ "loong64" ], @@ -840,9 +851,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", "cpu": [ "ppc64" ], @@ -854,9 +865,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", "cpu": [ "ppc64" ], @@ -868,9 +879,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", "cpu": [ "riscv64" ], @@ -882,9 +893,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", "cpu": [ "riscv64" ], @@ -896,9 +907,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", "cpu": [ "s390x" ], @@ -910,9 +921,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", "cpu": [ "x64" ], @@ -924,9 +935,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", "cpu": [ "x64" ], @@ -938,9 +949,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", "cpu": [ "x64" ], @@ -952,9 +963,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", "cpu": [ "arm64" ], @@ -966,9 +977,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", "cpu": [ "arm64" ], @@ -980,9 +991,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", "cpu": [ "ia32" ], @@ -994,9 +1005,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", "cpu": [ "x64" ], @@ -1008,9 +1019,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", "cpu": [ "x64" ], @@ -1043,9 +1054,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.31", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.31.tgz", - "integrity": "sha512-5jsi0wpncvTD33Sh1UCgacK37FFwDn+EG7wCmEvs62fCvBL+n8/76cAYDok21NF6+jaVWIqKwCZyX7Vbu8eB3A==", + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "dev": true, "license": "MIT", "dependencies": { @@ -1368,9 +1379,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -1391,9 +1402,9 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, "license": "MIT", "dependencies": { @@ -1404,9 +1415,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1531,9 +1542,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -1564,15 +1575,15 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -2101,9 +2112,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2112,9 +2123,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2370,9 +2381,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -2557,9 +2568,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2568,9 +2579,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2690,9 +2701,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, "license": "MIT", "dependencies": { @@ -3009,9 +3020,9 @@ "license": "MIT" }, "node_modules/jsonrepair": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.1.tgz", - "integrity": "sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw==", + "version": "3.13.2", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.2.tgz", + "integrity": "sha512-Leuly0nbM4R+S5SVJk3VHfw1oxnlEK9KygdZvfUtEtTawNDyzB4qa1xWTmFt1aeoA7sXZkVTRuIixJ8bAvqVUg==", "dev": true, "license": "ISC", "bin": { @@ -3086,9 +3097,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -3235,16 +3246,16 @@ } }, "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", "dev": true, "license": "MIT", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", - "ufo": "^1.6.1" + "ufo": "^1.6.3" } }, "node_modules/mlly/node_modules/pathe": { @@ -3262,9 +3273,9 @@ "license": "MIT" }, "node_modules/n8n-workflow": { - "version": "1.120.7", - "resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-1.120.7.tgz", - "integrity": "sha512-kTJVxns085po2Tcv9f4bJdKnngxyVnGjA+cQPsNTcZoxM+09R4+lxOWnH5aeAJkRxKEVYZ278/rwF5B6c/mnvg==", + "version": "1.120.13", + "resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-1.120.13.tgz", + "integrity": "sha512-k6OTN1Aa/ua/ECpWstLKHK6Fw3gndvNtWhXTopd5mRsZzNASu28VJ7QtPMSZMgGGDzGKOBVrBBjvnkkR4xFw/A==", "dev": true, "license": "SEE LICENSE IN LICENSE.md", "dependencies": { @@ -3276,9 +3287,9 @@ "form-data": "4.0.0", "jmespath": "0.16.0", "js-base64": "3.7.2", - "jsonrepair": "3.13.1", + "jsonrepair": "3.13.2", "jssha": "3.3.1", - "lodash": "4.17.21", + "lodash": "4.18.1", "luxon": "3.4.4", "md5": "2.3.0", "recast": "0.22.0", @@ -3545,9 +3556,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -3587,9 +3598,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "dev": true, "funding": [ { @@ -3770,9 +3781,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3786,31 +3797,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" } }, @@ -3857,9 +3868,9 @@ } }, "node_modules/sax": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", - "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3867,9 +3878,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -4209,9 +4220,9 @@ } }, "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "dev": true, "license": "MIT" }, diff --git a/n8n-nodes-metadata/package.json b/n8n-nodes-metadata/package.json index 60897e2..153a18e 100644 --- a/n8n-nodes-metadata/package.json +++ b/n8n-nodes-metadata/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-metadata", - "version": "0.1.2", + "version": "0.2.0", "description": "n8n node to invoke OpenMetadata DynamicAgents", "keywords": [ "n8n-community-node-package", @@ -49,7 +49,7 @@ "vitest": "^1.0.0" }, "dependencies": { - "@openmetadata/ai-sdk": "^0.1.1" + "@openmetadata/ai-sdk": "file:../typescript" }, "peerDependencies": { "n8n-workflow": "*" diff --git a/n8n-nodes-metadata/tests/integration.test.ts b/n8n-nodes-metadata/tests/integration.test.ts index 8615d15..abb136d 100644 --- a/n8n-nodes-metadata/tests/integration.test.ts +++ b/n8n-nodes-metadata/tests/integration.test.ts @@ -37,7 +37,7 @@ describe.skipIf(!shouldRun)('n8n Node Integration Tests', () => { }); it('should list agents via SDK', async () => { - const agents = await client.listAgents(); + const agents = await client.agents.list(); expect(Array.isArray(agents)).toBe(true); console.log(`SDK connection verified: Found ${agents.length} agents`); }); diff --git a/python/README.md b/python/README.md index 1107478..b982f1b 100644 --- a/python/README.md +++ b/python/README.md @@ -24,6 +24,11 @@ client = AISdk( token="your-bot-jwt-token" ) +# default AskCollate agent +response = client.agent().call("What data quality tests should I add for the customers table?") +print(response.response) + +# Named dynamic agent response = client.agent("DataQualityPlannerAgent").call( "What data quality tests should I add for the customers table?" ) @@ -58,6 +63,78 @@ async def main(): asyncio.run(main()) ``` +## Context Memories + +The `client.memories` namespace manages reusable Context Center knowledge — preferences, use cases, runbooks, and FAQs that any AI agent can read. + +```python +from ai_sdk import ( + AISdk, + CreateContextMemoryRequest, + EntityReference, + MemoryType, + MemoryVisibility, +) + +# Create +created = client.memories.create(CreateContextMemoryRequest( + name="orders-grain", + title="Orders grain", + question="What is the grain of the orders table?", + answer="One row per order_id.", + memory_type=MemoryType.NOTE, # Preference | UseCase | Note | Runbook | Faq + visibility=MemoryVisibility.SHARED, # Private | Entity | Shared + primary_entity=EntityReference(id="", type="table"), + tags=["Domain.Analytics"], +)) + +# Get / list (filter by entity FQN, optional limit) +client.memories.get(created.id) +client.memories.list(primary_entity_fqn="prod.warehouse.orders", limit=50) + +# Hybrid NLQ search — combines vector + keyword ranking over the contextMemory index. +# Optional filters scope the search by indexed fields. +results = client.memories.search( + "how do we measure order volume", + filters={"visibility": ["Entity", "Shared"]}, + size=10, + from_=0, +) +for hit in results.hits: + print(f"[{hit.score:.2f}] {hit.memory.title}: {hit.memory.answer}") + +# Soft delete by default; pass hard_delete=True to remove permanently +client.memories.delete(created.id) +client.memories.delete(created.id, hard_delete=True) +``` + +### Async + +Every method has an `aXxx` counterpart when the client is constructed with `enable_async=True`: + +```python +client = AISdk(host=..., token=..., enable_async=True) + +await client.memories.acreate(req) +await client.memories.alist(primary_entity_fqn="prod.warehouse.orders") +await client.memories.aget(memory_id) +await client.memories.asearch("explain customer churn") +await client.memories.adelete(memory_id) +``` + +### What's stored + +| Field | Notes | +|-------|-------| +| `name` | Stable system name (required) | +| `question` / `answer` | Canonical Q/A pair (required) — what an agent retrieves | +| `title`, `description`, `summary` | Human-facing text, optional | +| `memory_type` | `Preference`, `UseCase`, `Note`, `Runbook`, or `Faq` | +| `memory_scope` | `EntityScoped` (default) or `UserGlobal` | +| `visibility` | `Private`, `Entity`, or `Shared` (controls who can read it) | +| `primary_entity` | Attaches the memory to a specific asset for entity-scoped recall | +| `tags` | List of tag FQN strings (e.g. `"PII.Sensitive"`) | + ## MCP Tools Access [Model Context Protocol](https://modelcontextprotocol.io/) tools from your Metadata instance: diff --git a/python/pyproject.toml b/python/pyproject.toml index e2ce30a..f520462 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "data-ai-sdk" -version = "0.1.2" +version = "0.2.0" description = "Python SDK for AI Agents - Semantic Intelligence for AI builders" readme = "README.md" license = {file = "LICENSE"} diff --git a/python/src/ai_sdk/__init__.py b/python/src/ai_sdk/__init__.py index 7addc20..7d119bc 100644 --- a/python/src/ai_sdk/__init__.py +++ b/python/src/ai_sdk/__init__.py @@ -139,10 +139,17 @@ from ai_sdk.models import ( AbilityInfo, BotInfo, + ContextMemory, CreateAgentRequest, + CreateContextMemoryRequest, CreatePersonaRequest, EventType, KnowledgeScope, + MemoryScope, + MemorySearchHit, + MemorySearchResults, + MemoryType, + MemoryVisibility, PersonaInfo, ) @@ -158,11 +165,18 @@ "AuthenticationError", "BotInfo", "BotNotFoundError", + "ContextMemory", "Conversation", "CreateAgentRequest", + "CreateContextMemoryRequest", "CreatePersonaRequest", "EventType", "KnowledgeScope", + "MemoryScope", + "MemorySearchHit", + "MemorySearchResults", + "MemoryType", + "MemoryVisibility", "PersonaInfo", "PersonaNotFoundError", "RateLimitError", diff --git a/python/src/ai_sdk/_default_agent.py b/python/src/ai_sdk/_default_agent.py new file mode 100644 index 0000000..75284c7 --- /dev/null +++ b/python/src/ai_sdk/_default_agent.py @@ -0,0 +1,179 @@ +"""Default-agent handle — invokes the platform's default agent (PLANNER/CHAT_MODE). + +The chat UI's flow is mirrored: when no conversation_id is supplied, the SDK +creates one via POST /api/v1/assistants/chatConversations, then calls +/v1/agents/invoke (sync) or /v1/agents/run (SSE). +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterable +from typing import Any + +from ai_sdk._http import AsyncHTTPClient, HTTPClient +from ai_sdk._streaming import AsyncSSEIterator, SSEIterator +from ai_sdk.agent import AgentHandle +from ai_sdk.models import EventType, InvokeResponse, StreamEvent + +DEFAULT_AGENT_TYPE = "PLANNER" +DEFAULT_AGENT_MODE = "CHAT_MODE" +_TITLE_MAX_LEN = 50 + + +class DefaultAgentHandle(AgentHandle): + """Handle for the platform's default agent. + + Auto-creates a chat conversation when none is supplied, then calls + /v1/agents/invoke (sync) or /v1/agents/run (SSE) with agentType=PLANNER, + agentMode=CHAT_MODE. + """ + + def __init__( + self, + chat_http: HTTPClient, + chat_async_http: AsyncHTTPClient | None, + default_http: HTTPClient, + default_async_http: AsyncHTTPClient | None, + ): + # Bypass AgentHandle.__init__ — no agent name involved. + self._name = "" + self._http = default_http + self._async_http = default_async_http + self._chat_http = chat_http + self._chat_async_http = chat_async_http + + def _create_conversation(self, title: str) -> str: + body = {"title": title[:_TITLE_MAX_LEN]} + data = self._chat_http.post("/chatConversations", json=body) + return data["id"] + + async def _acreate_conversation(self, title: str) -> str: + if self._chat_async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + body = {"title": title[:_TITLE_MAX_LEN]} + data = await self._chat_async_http.post("/chatConversations", json=body) + return data["id"] + + def _build_payload(self, message: str | None, conversation_id: str) -> dict[str, Any]: + return { + "message": message or "", + "conversationId": conversation_id, + "agentType": DEFAULT_AGENT_TYPE, + "agentMode": DEFAULT_AGENT_MODE, + } + + def call( + self, + message: str | None = None, + *, + conversation_id: str | None = None, + parameters: dict[str, Any] | None = None, + ) -> InvokeResponse: + if conversation_id is None: + conversation_id = self._create_conversation(message or "New conversation") + payload = self._build_payload(message, conversation_id) + data = self._http.post("/invoke", json=payload) + return InvokeResponse.from_dict(data) + + async def acall( + self, + message: str | None = None, + *, + conversation_id: str | None = None, + parameters: dict[str, Any] | None = None, + ) -> InvokeResponse: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + if conversation_id is None: + conversation_id = await self._acreate_conversation(message or "New conversation") + payload = self._build_payload(message, conversation_id) + data = await self._async_http.post("/invoke", json=payload) + return InvokeResponse.from_dict(data) + + def stream( + self, + message: str | None = None, + *, + conversation_id: str | None = None, + parameters: dict[str, Any] | None = None, + ) -> Iterable[StreamEvent]: + if conversation_id is None: + conversation_id = self._create_conversation(message or "New conversation") + payload = self._build_payload(message, conversation_id) + byte_stream = self._http.post_stream("/run", json=payload) + return SSEIterator(byte_stream) + + def astream( + self, + message: str | None = None, + *, + conversation_id: str | None = None, + parameters: dict[str, Any] | None = None, + ) -> AsyncIterator[StreamEvent]: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + + async_http = self._async_http + chat_async_http = self._chat_async_http + + async def _gen() -> AsyncIterator[StreamEvent]: + cid = conversation_id + if cid is None: + if chat_async_http is None: + raise RuntimeError("Async HTTP client not available.") + body = {"title": (message or "New conversation")[:_TITLE_MAX_LEN]} + data = await chat_async_http.post("/chatConversations", json=body) + cid = data["id"] + payload = { + "message": message or "", + "conversationId": cid, + "agentType": DEFAULT_AGENT_TYPE, + "agentMode": DEFAULT_AGENT_MODE, + } + byte_stream = async_http.post_stream("/run", json=payload) + async for event in AsyncSSEIterator(byte_stream): + yield event + + return _gen() + + def stream_content( + self, + message: str | None = None, + *, + conversation_id: str | None = None, + parameters: dict[str, Any] | None = None, + ) -> Iterable[str]: + for event in self.stream(message, conversation_id=conversation_id, parameters=parameters): + if event.type == EventType.CONTENT and event.content is not None: + yield event.content + + async def astream_content( + self, + message: str | None = None, + *, + conversation_id: str | None = None, + parameters: dict[str, Any] | None = None, + ) -> AsyncIterator[str]: + async for event in self.astream( + message, conversation_id=conversation_id, parameters=parameters + ): + if event.type == EventType.CONTENT and event.content is not None: + yield event.content + + def get_info(self): + raise NotImplementedError("Default agent has no metadata endpoint") + + async def aget_info(self): + raise NotImplementedError("Default agent has no metadata endpoint") + + def __repr__(self) -> str: + return "DefaultAgentHandle()" diff --git a/python/src/ai_sdk/_http.py b/python/src/ai_sdk/_http.py index 42f2d5d..3b20c7f 100644 --- a/python/src/ai_sdk/_http.py +++ b/python/src/ai_sdk/_http.py @@ -127,7 +127,7 @@ def __init__( self, base_url: str, auth: TokenAuth, - timeout: float = 120.0, + timeout: float = 900.0, verify_ssl: bool = True, max_retries: int = 3, retry_delay: float = 1.0, @@ -140,7 +140,7 @@ def __init__( Args: base_url: Base URL for API requests auth: Authentication handler - timeout: Request timeout in seconds + timeout: Request timeout in seconds (applied to non-streaming calls) verify_ssl: Whether to verify SSL certificates max_retries: Maximum number of retry attempts retry_delay: Base delay between retries (exponential backoff) @@ -161,6 +161,14 @@ def __init__( timeout=timeout, verify=verify_ssl, ) + # SSE streams can run for many minutes; the stream's own events signal + # liveness, so we disable the read timeout (but keep modest connect / + # write / pool bounds) only for streaming calls. + self._stream_client = httpx.Client( + base_url=self._base_url, + timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0), + verify=verify_ssl, + ) logger.debug("HTTPClient initialized for %s", self._base_url) @@ -317,6 +325,47 @@ def post( _handle_error(last_response, agent_name=agent_name, request_id=request_id) return {} # Never reached + def delete( + self, + path: str, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make a DELETE request with retry support. + + Args: + path: Request path + params: Query parameters + + Returns: + Response JSON data, or empty dict on 204 No Content + """ + request_id = _generate_request_id() + logger.debug("[req:%s] DELETE %s%s", request_id, self._base_url, path) + + last_response = None + for attempt in range(self._max_retries + 1): + response = self._client.delete( + path, + headers=self._headers(request_id), + params=params, + ) + last_response = response + + if response.status_code < 400: + if response.status_code == 204 or not response.content: + return {} + return response.json() + + if self._should_retry(response, attempt): + self._wait_for_retry(attempt, response) + continue + + break + + assert last_response is not None + _handle_error(last_response, request_id=request_id) + return {} + def post_stream( self, path: str, @@ -342,7 +391,9 @@ def post_stream( headers = self._headers(request_id) headers["Accept"] = "text/event-stream" - with self._client.stream( + # Use the no-read-timeout client: an SSE response can stay open well + # past the configured `timeout`, and its own events signal liveness. + with self._stream_client.stream( "POST", path, headers=headers, @@ -359,6 +410,7 @@ def close(self) -> None: """Close the HTTP client.""" logger.debug("Closing HTTPClient") self._client.close() + self._stream_client.close() def __enter__(self) -> HTTPClient: return self @@ -382,7 +434,7 @@ def __init__( self, base_url: str, auth: TokenAuth, - timeout: float = 120.0, + timeout: float = 900.0, verify_ssl: bool = True, max_retries: int = 3, retry_delay: float = 1.0, @@ -394,7 +446,7 @@ def __init__( Args: base_url: Base URL for API requests auth: Authentication handler - timeout: Request timeout in seconds + timeout: Request timeout in seconds (applied to non-streaming calls) verify_ssl: Whether to verify SSL certificates max_retries: Maximum number of retry attempts retry_delay: Base delay between retries @@ -408,6 +460,7 @@ def __init__( self._retry_delay = retry_delay self._user_agent = user_agent or "ai-sdk-python/0.0.2" self._client: httpx.AsyncClient | None = None + self._stream_client: httpx.AsyncClient | None = None logger.debug("AsyncHTTPClient initialized for %s", self._base_url) @@ -421,6 +474,21 @@ def _get_client(self) -> httpx.AsyncClient: ) return self._client + def _get_stream_client(self) -> httpx.AsyncClient: + """Get or create the async client used for SSE streaming. + + SSE streams can run for many minutes; the stream's own events signal + liveness, so we disable the read timeout (but keep modest connect / + write / pool bounds) only for streaming calls. + """ + if self._stream_client is None: + self._stream_client = httpx.AsyncClient( + base_url=self._base_url, + timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0), + verify=self._verify_ssl, + ) + return self._stream_client + def _headers(self, request_id: str | None = None) -> dict[str, str]: """Get request headers.""" headers = self._auth.get_headers() @@ -546,6 +614,41 @@ async def post( _handle_error(last_response, agent_name=agent_name, request_id=request_id) return {} # Never reached + async def delete( + self, + path: str, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make an async DELETE request with retry support.""" + request_id = _generate_request_id() + logger.debug("[req:%s] async DELETE %s%s", request_id, self._base_url, path) + + client = self._get_client() + last_response = None + + for attempt in range(self._max_retries + 1): + response = await client.delete( + path, + headers=self._headers(request_id), + params=params, + ) + last_response = response + + if response.status_code < 400: + if response.status_code == 204 or not response.content: + return {} + return response.json() + + if self._should_retry(response, attempt): + await self._wait_for_retry(attempt, response) + continue + + break + + assert last_response is not None + _handle_error(last_response, request_id=request_id) + return {} + async def post_stream( self, path: str, @@ -571,7 +674,9 @@ async def post_stream( headers = self._headers(request_id) headers["Accept"] = "text/event-stream" - client = self._get_client() + # Use the no-read-timeout client: an SSE response can stay open well + # past the configured `timeout`, and its own events signal liveness. + client = self._get_stream_client() async with client.stream( "POST", path, @@ -592,6 +697,9 @@ async def close(self) -> None: if self._client is not None: await self._client.aclose() self._client = None + if self._stream_client is not None: + await self._stream_client.aclose() + self._stream_client = None async def __aenter__(self) -> AsyncHTTPClient: return self diff --git a/python/src/ai_sdk/api/__init__.py b/python/src/ai_sdk/api/__init__.py new file mode 100644 index 0000000..102fcc2 --- /dev/null +++ b/python/src/ai_sdk/api/__init__.py @@ -0,0 +1,9 @@ +"""API namespace classes for the AI SDK. + +Each module exposes a small class holding the HTTP clients it needs and +verb methods (list/get/create/delete/etc.). The top-level AISdk client +exposes these as lazy properties. + +Import directly from the canonical module path: + from ai_sdk.api.memories import MemoriesAPI +""" diff --git a/python/src/ai_sdk/api/abilities.py b/python/src/ai_sdk/api/abilities.py new file mode 100644 index 0000000..3a28ce4 --- /dev/null +++ b/python/src/ai_sdk/api/abilities.py @@ -0,0 +1,108 @@ +"""Abilities namespace.""" + +from __future__ import annotations + +import builtins +from collections.abc import Callable +from typing import Any +from urllib.parse import quote + +from ai_sdk._http import AsyncHTTPClient, HTTPClient +from ai_sdk.exceptions import AbilityNotFoundError, AISdkError +from ai_sdk.models import AbilityInfo + +_AbilityInfoList = builtins.list[AbilityInfo] + + +class AbilitiesAPI: + """Namespace for ability operations.""" + + def __init__(self, http: HTTPClient, async_http: AsyncHTTPClient | None) -> None: + self._http = http + self._async_http = async_http + + def list(self, limit: int | None = None) -> _AbilityInfoList: + return _paginate(self._http, "/", AbilityInfo.from_dict, limit=limit) + + async def alist(self, limit: int | None = None) -> _AbilityInfoList: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + return await _apaginate(self._async_http, "/", AbilityInfo.from_dict, limit=limit) + + def get(self, name: str) -> AbilityInfo: + try: + encoded_name = quote(name, safe="") + response = self._http.get(f"/name/{encoded_name}") + return AbilityInfo.from_dict(response) + except AISdkError as e: + if e.status_code == 404: + raise AbilityNotFoundError(name) from e + raise + + async def aget(self, name: str) -> AbilityInfo: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + try: + encoded_name = quote(name, safe="") + response = await self._async_http.get(f"/name/{encoded_name}") + return AbilityInfo.from_dict(response) + except AISdkError as e: + if e.status_code == 404: + raise AbilityNotFoundError(name) from e + raise + + +def _paginate( + http: HTTPClient, + path: str, + mapper: Callable[[dict[str, Any]], Any], + limit: int | None = None, + page_size: int = 100, +) -> builtins.list: + results: builtins.list = [] + after: str | None = None + while True: + params: dict[str, Any] = {"limit": page_size} + if after: + params["after"] = after + response = http.get(path, params=params) + for item in response.get("data", []): + results.append(mapper(item)) + if limit is not None and len(results) >= limit: + return results[:limit] + paging = response.get("paging") or {} + after = paging.get("after") + if not after: + break + return results + + +async def _apaginate( + http: AsyncHTTPClient, + path: str, + mapper: Callable[[dict[str, Any]], Any], + limit: int | None = None, + page_size: int = 100, +) -> builtins.list: + results: builtins.list = [] + after: str | None = None + while True: + params: dict[str, Any] = {"limit": page_size} + if after: + params["after"] = after + response = await http.get(path, params=params) + for item in response.get("data", []): + results.append(mapper(item)) + if limit is not None and len(results) >= limit: + return results[:limit] + paging = response.get("paging") or {} + after = paging.get("after") + if not after: + break + return results diff --git a/python/src/ai_sdk/api/agents.py b/python/src/ai_sdk/api/agents.py new file mode 100644 index 0000000..aad9473 --- /dev/null +++ b/python/src/ai_sdk/api/agents.py @@ -0,0 +1,147 @@ +"""Agents namespace — CRUD for dynamic agents.""" + +from __future__ import annotations + +import builtins +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from ai_sdk._http import AsyncHTTPClient, HTTPClient +from ai_sdk.models import AgentInfo, CreateAgentRequest + +if TYPE_CHECKING: + from ai_sdk.client import AISdk + + +_AgentInfoList = builtins.list[AgentInfo] + + +class AgentsAPI: + """Namespace for dynamic agent CRUD operations. + + Holds a back-reference to AISdk so that create() can resolve the + persona/ability names to entity references via the personas/abilities + namespaces. + """ + + def __init__( + self, + http: HTTPClient, + async_http: AsyncHTTPClient | None, + client: AISdk, + ) -> None: + self._http = http + self._async_http = async_http + self._client = client + + def list(self, limit: int | None = None) -> _AgentInfoList: + """List all API-enabled dynamic agents.""" + return _paginate( + self._http, + "/", + AgentInfo.from_dict, + limit=limit, + extra_params={"apiEnabled": "true"}, + ) + + async def alist(self, limit: int | None = None) -> _AgentInfoList: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + return await _apaginate( + self._async_http, + "/", + AgentInfo.from_dict, + limit=limit, + extra_params={"apiEnabled": "true"}, + ) + + def create(self, request: CreateAgentRequest) -> AgentInfo: + """Create a new dynamic agent.""" + persona = self._client.personas.get(request.persona) + api_dict = request.to_api_dict() + api_dict["persona"] = {"id": persona.id, "type": "persona"} + if request.abilities: + ability_refs = [] + for ability_name in request.abilities: + ability_info = self._client.abilities.get(ability_name) + ability_refs.append({"id": ability_info.id, "type": "ability"}) + api_dict["abilities"] = ability_refs + response = self._http.post("/", json=api_dict) + return AgentInfo.from_dict(response) + + async def acreate(self, request: CreateAgentRequest) -> AgentInfo: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + persona = await self._client.personas.aget(request.persona) + api_dict = request.to_api_dict() + api_dict["persona"] = {"id": persona.id, "type": "persona"} + if request.abilities: + ability_refs = [] + for ability_name in request.abilities: + ability_info = await self._client.abilities.aget(ability_name) + ability_refs.append({"id": ability_info.id, "type": "ability"}) + api_dict["abilities"] = ability_refs + response = await self._async_http.post("/", json=api_dict) + return AgentInfo.from_dict(response) + + +def _paginate( + http: HTTPClient, + path: str, + mapper: Callable[[dict[str, Any]], Any], + limit: int | None = None, + page_size: int = 100, + extra_params: dict[str, Any] | None = None, +) -> builtins.list: + results: builtins.list = [] + after: str | None = None + while True: + params: dict[str, Any] = {"limit": page_size} + if extra_params: + params.update(extra_params) + if after: + params["after"] = after + response = http.get(path, params=params) + for item in response.get("data", []): + results.append(mapper(item)) + if limit is not None and len(results) >= limit: + return results[:limit] + paging = response.get("paging") or {} + after = paging.get("after") + if not after: + break + return results + + +async def _apaginate( + http: AsyncHTTPClient, + path: str, + mapper: Callable[[dict[str, Any]], Any], + limit: int | None = None, + page_size: int = 100, + extra_params: dict[str, Any] | None = None, +) -> builtins.list: + results: builtins.list = [] + after: str | None = None + while True: + params: dict[str, Any] = {"limit": page_size} + if extra_params: + params.update(extra_params) + if after: + params["after"] = after + response = await http.get(path, params=params) + for item in response.get("data", []): + results.append(mapper(item)) + if limit is not None and len(results) >= limit: + return results[:limit] + paging = response.get("paging") or {} + after = paging.get("after") + if not after: + break + return results diff --git a/python/src/ai_sdk/api/bots.py b/python/src/ai_sdk/api/bots.py new file mode 100644 index 0000000..c629377 --- /dev/null +++ b/python/src/ai_sdk/api/bots.py @@ -0,0 +1,99 @@ +"""Bots namespace.""" + +from __future__ import annotations + +import builtins +from collections.abc import Callable +from typing import Any +from urllib.parse import quote + +from ai_sdk._http import AsyncHTTPClient, HTTPClient +from ai_sdk.models import BotInfo + +_BotInfoList = builtins.list[BotInfo] + + +class BotsAPI: + """Namespace for bot operations.""" + + def __init__(self, http: HTTPClient, async_http: AsyncHTTPClient | None) -> None: + self._http = http + self._async_http = async_http + + def list(self, limit: int | None = None) -> _BotInfoList: + """List all bots.""" + return _paginate(self._http, "/", BotInfo.from_dict, limit=limit) + + async def alist(self, limit: int | None = None) -> _BotInfoList: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + return await _apaginate(self._async_http, "/", BotInfo.from_dict, limit=limit) + + def get(self, name: str) -> BotInfo: + """Get a bot by name.""" + encoded_name = quote(name, safe="") + response = self._http.get(f"/name/{encoded_name}", bot_name=name) + return BotInfo.from_dict(response) + + async def aget(self, name: str) -> BotInfo: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + encoded_name = quote(name, safe="") + response = await self._async_http.get(f"/name/{encoded_name}", bot_name=name) + return BotInfo.from_dict(response) + + +def _paginate( + http: HTTPClient, + path: str, + mapper: Callable[[dict[str, Any]], Any], + limit: int | None = None, + page_size: int = 100, +) -> builtins.list: + results: builtins.list = [] + after: str | None = None + while True: + params: dict[str, Any] = {"limit": page_size} + if after: + params["after"] = after + response = http.get(path, params=params) + for item in response.get("data", []): + results.append(mapper(item)) + if limit is not None and len(results) >= limit: + return results[:limit] + paging = response.get("paging") or {} + after = paging.get("after") + if not after: + break + return results + + +async def _apaginate( + http: AsyncHTTPClient, + path: str, + mapper: Callable[[dict[str, Any]], Any], + limit: int | None = None, + page_size: int = 100, +) -> builtins.list: + results: builtins.list = [] + after: str | None = None + while True: + params: dict[str, Any] = {"limit": page_size} + if after: + params["after"] = after + response = await http.get(path, params=params) + for item in response.get("data", []): + results.append(mapper(item)) + if limit is not None and len(results) >= limit: + return results[:limit] + paging = response.get("paging") or {} + after = paging.get("after") + if not after: + break + return results diff --git a/python/src/ai_sdk/api/memories.py b/python/src/ai_sdk/api/memories.py new file mode 100644 index 0000000..c2f3fb4 --- /dev/null +++ b/python/src/ai_sdk/api/memories.py @@ -0,0 +1,240 @@ +"""Memories namespace — CRUD + hybrid search for Context Center memories.""" + +from __future__ import annotations + +import builtins +import json +from typing import Any + +from ai_sdk._http import AsyncHTTPClient, HTTPClient +from ai_sdk.models import ( + ContextMemory, + CreateContextMemoryRequest, + MemorySearchResults, +) + +# Alias `list` so type annotations on methods named `list` don't shadow the builtin. +_MemoryList = builtins.list[ContextMemory] + + +class MemoriesAPI: + """Namespace for Context Center memory operations. + + Backed by two HTTP clients: one rooted at /api/v1/contextCenter/memories + for CRUD, and one rooted at /api/v1 for the hybrid search endpoint at + /hybrid/nlq/search. + """ + + def __init__( + self, + http: HTTPClient, + async_http: AsyncHTTPClient | None, + search_http: HTTPClient, + search_async_http: AsyncHTTPClient | None, + ) -> None: + self._http = http + self._async_http = async_http + self._search_http = search_http + self._search_async_http = search_async_http + + # ------------------------------------------------------------------ + # list / alist + # ------------------------------------------------------------------ + + def list( + self, + primary_entity_fqn: str | None = None, + limit: int | None = None, + ) -> _MemoryList: + """List Context Center memories. + + Args: + primary_entity_fqn: Filter to memories attached to this entity FQN. + limit: Maximum number of memories to return; None returns all. + """ + return _paginate(self._http, primary_entity_fqn=primary_entity_fqn, limit=limit) + + async def alist( + self, + primary_entity_fqn: str | None = None, + limit: int | None = None, + ) -> _MemoryList: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + return await _apaginate( + self._async_http, primary_entity_fqn=primary_entity_fqn, limit=limit + ) + + # ------------------------------------------------------------------ + # get / aget + # ------------------------------------------------------------------ + + def get(self, memory_id: str) -> ContextMemory: + """Get a memory by ID.""" + response = self._http.get(f"/{memory_id}") + return ContextMemory.from_dict(response) + + async def aget(self, memory_id: str) -> ContextMemory: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + response = await self._async_http.get(f"/{memory_id}") + return ContextMemory.from_dict(response) + + # ------------------------------------------------------------------ + # create / acreate + # ------------------------------------------------------------------ + + def create(self, request: CreateContextMemoryRequest) -> ContextMemory: + """Create a new Context Center memory.""" + response = self._http.post("/", json=request.to_api_dict()) + return ContextMemory.from_dict(response) + + async def acreate(self, request: CreateContextMemoryRequest) -> ContextMemory: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + response = await self._async_http.post("/", json=request.to_api_dict()) + return ContextMemory.from_dict(response) + + # ------------------------------------------------------------------ + # delete / adelete + # ------------------------------------------------------------------ + + def delete(self, memory_id: str, hard_delete: bool = False) -> None: + """Delete a memory by ID. Soft delete by default.""" + self._http.delete(f"/{memory_id}", params={"hardDelete": hard_delete}) + + async def adelete(self, memory_id: str, hard_delete: bool = False) -> None: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + await self._async_http.delete(f"/{memory_id}", params={"hardDelete": hard_delete}) + + # ------------------------------------------------------------------ + # search / asearch — hybrid NLQ search over the contextMemory index + # ------------------------------------------------------------------ + + def search( + self, + query: str, + filters: dict[str, builtins.list[str]] | None = None, + size: int = 15, + from_: int = 0, + ) -> MemorySearchResults: + """Hybrid NLQ search over Context Center memories. + + Args: + query: Natural-language query string. + filters: Optional map of field name -> list of values + (e.g. {"primaryEntityId": ["abc"], "visibility": ["Shared"]}). + size: Number of results (1-100, default 15). + from_: Pagination offset. + """ + params = _build_search_params(query, filters, size, from_) + response = self._search_http.get("/hybrid/nlq/search", params=params) + return MemorySearchResults.from_dict(response) + + async def asearch( + self, + query: str, + filters: dict[str, builtins.list[str]] | None = None, + size: int = 15, + from_: int = 0, + ) -> MemorySearchResults: + if self._search_async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + params = _build_search_params(query, filters, size, from_) + response = await self._search_async_http.get("/hybrid/nlq/search", params=params) + return MemorySearchResults.from_dict(response) + + +# ---------------------------------------------------------------------- +# Internal helpers (module-private) +# ---------------------------------------------------------------------- + + +def _build_list_params( + primary_entity_fqn: str | None, + after: str | None, + page_size: int, +) -> dict[str, Any]: + params: dict[str, Any] = {"limit": page_size} + if primary_entity_fqn is not None: + params["primaryEntityFqn"] = primary_entity_fqn + if after is not None: + params["after"] = after + return params + + +def _build_search_params( + query: str, + filters: dict[str, list[str]] | None, + size: int, + from_: int, +) -> dict[str, Any]: + params: dict[str, Any] = { + "q": query, + "index": "contextMemory", + "size": size, + "from": from_, + } + if filters: + params["filters"] = json.dumps(filters) + return params + + +def _paginate( + http: HTTPClient, + primary_entity_fqn: str | None, + limit: int | None, + page_size: int = 100, +) -> list[ContextMemory]: + results: list[ContextMemory] = [] + after: str | None = None + while True: + params = _build_list_params(primary_entity_fqn, after, page_size) + response = http.get("/", params=params) + for item in response.get("data", []): + results.append(ContextMemory.from_dict(item)) + if limit is not None and len(results) >= limit: + return results[:limit] + paging = response.get("paging") or {} + after = paging.get("after") + if not after: + break + return results + + +async def _apaginate( + http: AsyncHTTPClient, + primary_entity_fqn: str | None, + limit: int | None, + page_size: int = 100, +) -> list[ContextMemory]: + results: list[ContextMemory] = [] + after: str | None = None + while True: + params = _build_list_params(primary_entity_fqn, after, page_size) + response = await http.get("/", params=params) + for item in response.get("data", []): + results.append(ContextMemory.from_dict(item)) + if limit is not None and len(results) >= limit: + return results[:limit] + paging = response.get("paging") or {} + after = paging.get("after") + if not after: + break + return results diff --git a/python/src/ai_sdk/api/personas.py b/python/src/ai_sdk/api/personas.py new file mode 100644 index 0000000..c9fb9be --- /dev/null +++ b/python/src/ai_sdk/api/personas.py @@ -0,0 +1,121 @@ +"""Personas namespace.""" + +from __future__ import annotations + +import builtins +from collections.abc import Callable +from typing import Any +from urllib.parse import quote + +from ai_sdk._http import AsyncHTTPClient, HTTPClient +from ai_sdk.exceptions import AISdkError, PersonaNotFoundError +from ai_sdk.models import CreatePersonaRequest, PersonaInfo + +_PersonaInfoList = builtins.list[PersonaInfo] + + +class PersonasAPI: + """Namespace for persona operations.""" + + def __init__(self, http: HTTPClient, async_http: AsyncHTTPClient | None) -> None: + self._http = http + self._async_http = async_http + + def list(self, limit: int | None = None) -> _PersonaInfoList: + return _paginate(self._http, "/", PersonaInfo.from_dict, limit=limit) + + async def alist(self, limit: int | None = None) -> _PersonaInfoList: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + return await _apaginate(self._async_http, "/", PersonaInfo.from_dict, limit=limit) + + def get(self, name: str) -> PersonaInfo: + try: + encoded_name = quote(name, safe="") + response = self._http.get(f"/name/{encoded_name}") + return PersonaInfo.from_dict(response) + except AISdkError as e: + if e.status_code == 404: + raise PersonaNotFoundError(name) from e + raise + + async def aget(self, name: str) -> PersonaInfo: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + try: + encoded_name = quote(name, safe="") + response = await self._async_http.get(f"/name/{encoded_name}") + return PersonaInfo.from_dict(response) + except AISdkError as e: + if e.status_code == 404: + raise PersonaNotFoundError(name) from e + raise + + def create(self, request: CreatePersonaRequest) -> PersonaInfo: + response = self._http.post("/", json=request.to_api_dict()) + return PersonaInfo.from_dict(response) + + async def acreate(self, request: CreatePersonaRequest) -> PersonaInfo: + if self._async_http is None: + raise RuntimeError( + "Async HTTP client not available. " + "Use AISdk with enable_async=True for async operations." + ) + response = await self._async_http.post("/", json=request.to_api_dict()) + return PersonaInfo.from_dict(response) + + +def _paginate( + http: HTTPClient, + path: str, + mapper: Callable[[dict[str, Any]], Any], + limit: int | None = None, + page_size: int = 100, +) -> builtins.list: + results: builtins.list = [] + after: str | None = None + while True: + params: dict[str, Any] = {"limit": page_size} + if after: + params["after"] = after + response = http.get(path, params=params) + for item in response.get("data", []): + results.append(mapper(item)) + if limit is not None and len(results) >= limit: + return results[:limit] + paging = response.get("paging") or {} + after = paging.get("after") + if not after: + break + return results + + +async def _apaginate( + http: AsyncHTTPClient, + path: str, + mapper: Callable[[dict[str, Any]], Any], + limit: int | None = None, + page_size: int = 100, +) -> builtins.list: + results: builtins.list = [] + after: str | None = None + while True: + params: dict[str, Any] = {"limit": page_size} + if after: + params["after"] = after + response = await http.get(path, params=params) + for item in response.get("data", []): + results.append(mapper(item)) + if limit is not None and len(results) >= limit: + return results[:limit] + paging = response.get("paging") or {} + after = paging.get("after") + if not after: + break + return results diff --git a/python/src/ai_sdk/client.py b/python/src/ai_sdk/client.py index c6d1949..54dfd10 100644 --- a/python/src/ai_sdk/client.py +++ b/python/src/ai_sdk/client.py @@ -2,853 +2,231 @@ from __future__ import annotations -from collections.abc import Callable from typing import TYPE_CHECKING, Any -from urllib.parse import quote if TYPE_CHECKING: from ai_sdk.config import AISdkConfig from ai_sdk._http import AsyncHTTPClient, HTTPClient from ai_sdk.agent import AgentHandle +from ai_sdk.api.abilities import AbilitiesAPI +from ai_sdk.api.agents import AgentsAPI +from ai_sdk.api.bots import BotsAPI +from ai_sdk.api.memories import MemoriesAPI +from ai_sdk.api.personas import PersonasAPI from ai_sdk.auth import TokenAuth -from ai_sdk.exceptions import ( - AbilityNotFoundError, - AISdkError, - PersonaNotFoundError, -) from ai_sdk.mcp._client import MCPClient -from ai_sdk.models import ( - AbilityInfo, - AgentInfo, - BotInfo, - CreateAgentRequest, - CreatePersonaRequest, - PersonaInfo, -) class AISdk: - """ - Main client for interacting with AI agents. + """Main client for interacting with AI agents. + + Composition pattern — entity CRUD lives on namespaces: + client.agents.list() + client.bots.get("ingestion-bot") + client.personas.list() + client.abilities.get("DataQuality") + client.memories.search("customer churn") - This client provides access to AI Agents, enabling you to - leverage semantic intelligence capabilities in your AI applications. + The agent handle factory is unchanged: + client.agent("DataQualityPlannerAgent").call("...") Usage: from ai_sdk.client import AISdk - client = AISdk( - host="https://metadata.example.com", - token="your-bot-jwt-token" - ) - - # Get an agent handle - agent = client.agent("DataQualityPlannerAgent") + client = AISdk(host="https://...", token="...") + response = client.agent("DataQualityPlannerAgent").call("Hello") - # Invoke synchronously - response = agent.call("Analyze the customers table") - print(response.response) - - # Or stream the response - for event in agent.stream("Analyze the customers table"): - if event.type == "content": - print(event.content, end="", flush=True) - - Multi-turn conversations: - # Start a conversation - response1 = agent.call("Analyze the orders table") - - # Continue with context - response2 = agent.call( - "Now create tests for the issues you found", - conversation_id=response1.conversation_id - ) - - Async usage: - client = AISdk( - host="https://metadata.example.com", - token="your-bot-jwt-token", - enable_async=True - ) - - agent = client.agent("DataQualityPlannerAgent") - response = await agent.acall("Analyze the customers table") + Async: + client = AISdk(host="...", token="...", enable_async=True) + response = await client.agent("DataQualityPlannerAgent").acall("Hello") From environment: - from ai_sdk.client import AISdk from ai_sdk.config import AISdkConfig - - config = AISdkConfig.from_env() # Uses AI_SDK_HOST, AI_SDK_TOKEN - client = AISdk.from_config(config) + client = AISdk.from_config(AISdkConfig.from_env()) """ def __init__( self, host: str, token: str, - timeout: float = 120.0, + timeout: float = 900.0, verify_ssl: bool = True, enable_async: bool = False, max_retries: int = 3, retry_delay: float = 1.0, user_agent: str | None = None, - ): - """ - Initialize the AI SDK client. - - Args: - host: The server URL (e.g., "https://metadata.example.com") - token: JWT bot token for authentication - timeout: Request timeout in seconds (default: 120) - verify_ssl: Whether to verify SSL certificates (default: True) - enable_async: Enable async operations (default: False) - max_retries: Maximum number of retry attempts for transient errors (default: 3) - retry_delay: Base delay between retries in seconds (default: 1.0) - user_agent: Custom User-Agent string for HTTP requests - - Raises: - ValueError: If token is empty - """ + ) -> None: + if not token: + raise ValueError("token must be a non-empty string") + self._host = host.rstrip("/") self._auth = TokenAuth(token) self._enable_async = enable_async - # Unified HTTP client for all agent operations (consolidated API) - agents_base_url = f"{self._host}/api/v1/agents/dynamic" - self._http = HTTPClient( - base_url=agents_base_url, - auth=self._auth, - timeout=timeout, - verify_ssl=verify_ssl, - max_retries=max_retries, - retry_delay=retry_delay, - user_agent=user_agent, - ) - - # HTTP client for persona operations - personas_base_url = f"{self._host}/api/v1/agents/personas" - self._personas_http = HTTPClient( - base_url=personas_base_url, - auth=self._auth, - timeout=timeout, - verify_ssl=verify_ssl, - max_retries=max_retries, - retry_delay=retry_delay, - user_agent=user_agent, - ) - - # HTTP client for bot operations - bots_base_url = f"{self._host}/api/v1/bots" - self._bots_http = HTTPClient( - base_url=bots_base_url, - auth=self._auth, - timeout=timeout, - verify_ssl=verify_ssl, - max_retries=max_retries, - retry_delay=retry_delay, - user_agent=user_agent, - ) - - # HTTP client for ability operations - abilities_base_url = f"{self._host}/api/v1/agents/abilities" - self._abilities_http = HTTPClient( - base_url=abilities_base_url, - auth=self._auth, - timeout=timeout, - verify_ssl=verify_ssl, - max_retries=max_retries, - retry_delay=retry_delay, - user_agent=user_agent, - ) + common_kwargs: dict[str, Any] = { + "auth": self._auth, + "timeout": timeout, + "verify_ssl": verify_ssl, + "max_retries": max_retries, + "retry_delay": retry_delay, + "user_agent": user_agent, + } + + agents_url = f"{self._host}/api/v1/agents/dynamic" + personas_url = f"{self._host}/api/v1/agents/personas" + bots_url = f"{self._host}/api/v1/bots" + abilities_url = f"{self._host}/api/v1/agents/abilities" + default_agent_url = f"{self._host}/api/v1/agents" + chat_conv_url = f"{self._host}/api/v1/assistants" + memories_url = f"{self._host}/api/v1/contextCenter/memories" + search_url = f"{self._host}/api/v1" + + self._http = HTTPClient(base_url=agents_url, **common_kwargs) + self._personas_http = HTTPClient(base_url=personas_url, **common_kwargs) + self._bots_http = HTTPClient(base_url=bots_url, **common_kwargs) + self._abilities_http = HTTPClient(base_url=abilities_url, **common_kwargs) + self._default_http = HTTPClient(base_url=default_agent_url, **common_kwargs) + self._chat_conv_http = HTTPClient(base_url=chat_conv_url, **common_kwargs) + self._memories_http = HTTPClient(base_url=memories_url, **common_kwargs) + self._search_http = HTTPClient(base_url=search_url, **common_kwargs) self._async_http: AsyncHTTPClient | None = None self._async_personas_http: AsyncHTTPClient | None = None self._async_bots_http: AsyncHTTPClient | None = None self._async_abilities_http: AsyncHTTPClient | None = None - if enable_async: - self._async_http = AsyncHTTPClient( - base_url=agents_base_url, - auth=self._auth, - timeout=timeout, - verify_ssl=verify_ssl, - max_retries=max_retries, - retry_delay=retry_delay, - user_agent=user_agent, - ) - self._async_personas_http = AsyncHTTPClient( - base_url=personas_base_url, - auth=self._auth, - timeout=timeout, - verify_ssl=verify_ssl, - max_retries=max_retries, - retry_delay=retry_delay, - user_agent=user_agent, - ) - self._async_bots_http = AsyncHTTPClient( - base_url=bots_base_url, - auth=self._auth, - timeout=timeout, - verify_ssl=verify_ssl, - max_retries=max_retries, - retry_delay=retry_delay, - user_agent=user_agent, - ) - self._async_abilities_http = AsyncHTTPClient( - base_url=abilities_base_url, - auth=self._auth, - timeout=timeout, - verify_ssl=verify_ssl, - max_retries=max_retries, - retry_delay=retry_delay, - user_agent=user_agent, - ) + self._async_default_http: AsyncHTTPClient | None = None + self._async_chat_conv_http: AsyncHTTPClient | None = None + self._async_memories_http: AsyncHTTPClient | None = None + self._async_search_http: AsyncHTTPClient | None = None + if enable_async: + self._async_http = AsyncHTTPClient(base_url=agents_url, **common_kwargs) + self._async_personas_http = AsyncHTTPClient(base_url=personas_url, **common_kwargs) + self._async_bots_http = AsyncHTTPClient(base_url=bots_url, **common_kwargs) + self._async_abilities_http = AsyncHTTPClient(base_url=abilities_url, **common_kwargs) + self._async_default_http = AsyncHTTPClient(base_url=default_agent_url, **common_kwargs) + self._async_chat_conv_http = AsyncHTTPClient(base_url=chat_conv_url, **common_kwargs) + self._async_memories_http = AsyncHTTPClient(base_url=memories_url, **common_kwargs) + self._async_search_http = AsyncHTTPClient(base_url=search_url, **common_kwargs) + + # Lazily-initialized namespaces + self._agents_ns: AgentsAPI | None = None + self._bots_ns: BotsAPI | None = None + self._personas_ns: PersonasAPI | None = None + self._abilities_ns: AbilitiesAPI | None = None + self._memories_ns: MemoriesAPI | None = None self._mcp_client: MCPClient | None = None # ------------------------------------------------------------------------- - # Pagination Helpers + # Namespaces # ------------------------------------------------------------------------- - def _paginate_list( - self, - http: HTTPClient, - path: str, - mapper: Callable[[dict], Any], - limit: int | None = None, - page_size: int = 100, - extra_params: dict[str, Any] | None = None, - ) -> list: - """ - Paginate through all results from a list endpoint. - - Args: - http: HTTP client to use - path: API path - mapper: Function to map response items to model objects - limit: Maximum number of items to return (None for all) - page_size: Number of items per page - extra_params: Additional query parameters to include in each request - - Returns: - List of all items - """ - results: list = [] - after: str | None = None - - while True: - params: dict[str, Any] = {"limit": page_size} - if extra_params: - params.update(extra_params) - if after: - params["after"] = after - - response = http.get(path, params=params) - data = response.get("data", []) - results.extend(mapper(item) for item in data) - - # Check if we've hit the limit - if limit is not None and len(results) >= limit: - return results[:limit] - - # Check for more pages - paging = response.get("paging", {}) - after = paging.get("after") - if not after: - break - - return results - - async def _apaginate_list( - self, - http: AsyncHTTPClient, - path: str, - mapper: Callable[[dict], Any], - limit: int | None = None, - page_size: int = 100, - extra_params: dict[str, Any] | None = None, - ) -> list: - """ - Paginate through all results from a list endpoint asynchronously. - - Args: - http: Async HTTP client to use - path: API path - mapper: Function to map response items to model objects - limit: Maximum number of items to return (None for all) - page_size: Number of items per page - extra_params: Additional query parameters to include in each request - - Returns: - List of all items - """ - results: list = [] - after: str | None = None - - while True: - params: dict[str, Any] = {"limit": page_size} - if extra_params: - params.update(extra_params) - if after: - params["after"] = after - - response = await http.get(path, params=params) - data = response.get("data", []) - results.extend(mapper(item) for item in data) - - # Check if we've hit the limit - if limit is not None and len(results) >= limit: - return results[:limit] - - # Check for more pages - paging = response.get("paging", {}) - after = paging.get("after") - if not after: - break - - return results - - def agent(self, name: str) -> AgentHandle: - """ - Get a handle to a specific agent. + @property + def agents(self) -> AgentsAPI: + if self._agents_ns is None: + self._agents_ns = AgentsAPI(self._http, self._async_http, client=self) + return self._agents_ns - Args: - name: The agent name (e.g., "DataQualityPlannerAgent") + @property + def bots(self) -> BotsAPI: + if self._bots_ns is None: + self._bots_ns = BotsAPI(self._bots_http, self._async_bots_http) + return self._bots_ns - Returns: - AgentHandle for invoking the agent + @property + def personas(self) -> PersonasAPI: + if self._personas_ns is None: + self._personas_ns = PersonasAPI(self._personas_http, self._async_personas_http) + return self._personas_ns - Example: - agent = client.agent("DataQualityPlannerAgent") - response = agent.call("What tests should I add?") + @property + def abilities(self) -> AbilitiesAPI: + if self._abilities_ns is None: + self._abilities_ns = AbilitiesAPI(self._abilities_http, self._async_abilities_http) + return self._abilities_ns - # Or async (if enable_async=True) - response = await agent.acall("What tests should I add?") - """ - return AgentHandle( - name=name, - http=self._http, - async_http=self._async_http, - ) + @property + def memories(self) -> MemoriesAPI: + if self._memories_ns is None: + self._memories_ns = MemoriesAPI( + http=self._memories_http, + async_http=self._async_memories_http, + search_http=self._search_http, + search_async_http=self._async_search_http, + ) + return self._memories_ns @property def mcp(self) -> MCPClient: - """ - Get the MCP client for tool operations. - - Returns: - MCPClient instance for interacting with OpenMetadata's MCP server - - Example: - tools = client.mcp.list_tools() - result = client.mcp.call_tool(MCPTool.SEARCH_METADATA, {"query": "customer"}) - """ if self._mcp_client is None: - self._mcp_client = MCPClient( - host=self._host, - auth=self._auth, - http=self._http, - ) + self._mcp_client = MCPClient(host=self._host, auth=self._auth, http=self._http) return self._mcp_client - def list_agents( - self, - limit: int | None = None, - ) -> list[AgentInfo]: - """ - List all API-enabled agents. - - Automatically paginates through all results. - - Args: - limit: Maximum number of agents to return. If None, returns all agents. - - Returns: - List of AgentInfo objects for API-enabled agents - """ - return self._paginate_list( - self._http, - "/", - lambda a: AgentInfo.from_dict(a), - limit=limit, - extra_params={"apiEnabled": "true"}, - ) - - async def alist_agents( - self, - limit: int | None = None, - ) -> list[AgentInfo]: - """ - List all API-enabled agents asynchronously. - - Automatically paginates through all results. - - Args: - limit: Maximum number of agents to return. If None, returns all agents. - - Returns: - List of AgentInfo objects for API-enabled agents - - Raises: - RuntimeError: If async client is not available - """ - if self._async_http is None: - raise RuntimeError( - "Async HTTP client not available. " - "Use AISdk with enable_async=True for async operations." - ) - - return await self._apaginate_list( - self._async_http, - "/", - lambda a: AgentInfo.from_dict(a), - limit=limit, - extra_params={"apiEnabled": "true"}, - ) - - def create_agent(self, request: CreateAgentRequest) -> AgentInfo: - """ - Create a new dynamic agent. - - Args: - request: CreateAgentRequest with agent configuration - - Returns: - AgentInfo for the created agent - - Example: - from ai_sdk import CreateAgentRequest - - request = CreateAgentRequest( - name="MyAgent", - description="An agent that helps with data quality", - persona="DataAnalyst", # Name of an existing persona - mode="chat", - api_enabled=True, - ) - agent_info = client.create_agent(request) - """ - # Resolve persona name to ID - persona_info = self.get_persona(request.persona) - api_dict = request.to_api_dict() - api_dict["persona"] = {"id": persona_info.id, "type": "persona"} - - # Resolve ability names to IDs if provided - if request.abilities: - ability_refs = [] - for ability_name in request.abilities: - ability_info = self.get_ability(ability_name) - ability_refs.append({"id": ability_info.id, "type": "ability"}) - api_dict["abilities"] = ability_refs - - response = self._http.post("/", json=api_dict) - return AgentInfo.from_dict(response) - - async def acreate_agent(self, request: CreateAgentRequest) -> AgentInfo: - """ - Create a new dynamic agent asynchronously. - - Args: - request: CreateAgentRequest with agent configuration - - Returns: - AgentInfo for the created agent - - Raises: - RuntimeError: If async client is not available - - Example: - from ai_sdk import CreateAgentRequest - - request = CreateAgentRequest( - name="MyAgent", - description="An agent that helps with data quality", - persona="DataAnalyst", # Name of an existing persona - mode="chat", - api_enabled=True, - ) - agent_info = await client.acreate_agent(request) - """ - if self._async_http is None: - raise RuntimeError( - "Async HTTP client not available. " - "Use AISdk with enable_async=True for async operations." - ) - - # Resolve persona name to ID - persona_info = await self.aget_persona(request.persona) - api_dict = request.to_api_dict() - api_dict["persona"] = {"id": persona_info.id, "type": "persona"} - - # Resolve ability names to IDs if provided - if request.abilities: - ability_refs = [] - for ability_name in request.abilities: - ability_info = await self.aget_ability(ability_name) - ability_refs.append({"id": ability_info.id, "type": "ability"}) - api_dict["abilities"] = ability_refs - - response = await self._async_http.post("/", json=api_dict) - return AgentInfo.from_dict(response) - - # ------------------------------------------------------------------------- - # Bot Operations - # ------------------------------------------------------------------------- - - def list_bots(self, limit: int | None = None) -> list[BotInfo]: - """ - List all bots. - - Automatically paginates through all results. - - Args: - limit: Maximum number of bots to return. If None, returns all bots. - - Returns: - List of BotInfo objects - """ - return self._paginate_list( - self._bots_http, - "/", - lambda b: BotInfo.from_dict(b), - limit=limit, - ) - - async def alist_bots(self, limit: int | None = None) -> list[BotInfo]: - """ - List all bots asynchronously. - - Automatically paginates through all results. - - Args: - limit: Maximum number of bots to return. If None, returns all bots. - - Returns: - List of BotInfo objects - - Raises: - RuntimeError: If async client is not available - """ - if self._async_bots_http is None: - raise RuntimeError( - "Async HTTP client not available. " - "Use AISdk with enable_async=True for async operations." - ) - - return await self._apaginate_list( - self._async_bots_http, - "/", - lambda b: BotInfo.from_dict(b), - limit=limit, - ) - - def get_bot(self, name: str) -> BotInfo: - """ - Get a bot by name. - - Args: - name: The bot name - - Returns: - BotInfo object - - Raises: - BotNotFoundError: If the bot is not found - """ - encoded_name = quote(name, safe="") - response = self._bots_http.get(f"/name/{encoded_name}", bot_name=name) - return BotInfo.from_dict(response) - - async def aget_bot(self, name: str) -> BotInfo: - """ - Get a bot by name asynchronously. - - Args: - name: The bot name - - Returns: - BotInfo object - - Raises: - BotNotFoundError: If the bot is not found - RuntimeError: If async client is not available - """ - if self._async_bots_http is None: - raise RuntimeError( - "Async HTTP client not available. " - "Use AISdk with enable_async=True for async operations." - ) - - encoded_name = quote(name, safe="") - response = await self._async_bots_http.get(f"/name/{encoded_name}", bot_name=name) - return BotInfo.from_dict(response) - # ------------------------------------------------------------------------- - # Persona Operations + # Agent handle factory (unchanged surface) # ------------------------------------------------------------------------- - def list_personas(self, limit: int | None = None) -> list[PersonaInfo]: - """ - List all personas. - - Automatically paginates through all results. - - Args: - limit: Maximum number of personas to return. If None, returns all personas. - - Returns: - List of PersonaInfo objects - """ - return self._paginate_list( - self._personas_http, - "/", - lambda p: PersonaInfo.from_dict(p), - limit=limit, - ) - - async def alist_personas(self, limit: int | None = None) -> list[PersonaInfo]: - """ - List all personas asynchronously. - - Automatically paginates through all results. + def agent(self, name: str | None = None) -> AgentHandle: + """Get a handle to an agent. - Args: - limit: Maximum number of personas to return. If None, returns all personas. - - Returns: - List of PersonaInfo objects - - Raises: - RuntimeError: If async client is not available + - With a name: returns a handle for the named dynamic agent. + - Without a name: returns a handle for the platform's default agent + (PLANNER / CHAT_MODE). """ - if self._async_personas_http is None: - raise RuntimeError( - "Async HTTP client not available. " - "Use AISdk with enable_async=True for async operations." - ) + if name is None: + from ai_sdk._default_agent import DefaultAgentHandle - return await self._apaginate_list( - self._async_personas_http, - "/", - lambda p: PersonaInfo.from_dict(p), - limit=limit, - ) - - def get_persona(self, name: str) -> PersonaInfo: - """ - Get a persona by name. - - Args: - name: The persona name - - Returns: - PersonaInfo for the requested persona - - Raises: - PersonaNotFoundError: If the persona does not exist - """ - try: - encoded_name = quote(name, safe="") - response = self._personas_http.get(f"/name/{encoded_name}") - return PersonaInfo.from_dict(response) - except AISdkError as e: - if e.status_code == 404: - raise PersonaNotFoundError(name) from e - raise - - async def aget_persona(self, name: str) -> PersonaInfo: - """ - Get a persona by name asynchronously. - - Args: - name: The persona name - - Returns: - PersonaInfo for the requested persona - - Raises: - PersonaNotFoundError: If the persona does not exist - RuntimeError: If async client is not available - """ - if self._async_personas_http is None: - raise RuntimeError( - "Async HTTP client not available. " - "Use AISdk with enable_async=True for async operations." + return DefaultAgentHandle( + chat_http=self._chat_conv_http, + chat_async_http=self._async_chat_conv_http, + default_http=self._default_http, + default_async_http=self._async_default_http, ) - - try: - encoded_name = quote(name, safe="") - response = await self._async_personas_http.get(f"/name/{encoded_name}") - return PersonaInfo.from_dict(response) - except AISdkError as e: - if e.status_code == 404: - raise PersonaNotFoundError(name) from e - raise - - def create_persona(self, request: CreatePersonaRequest) -> PersonaInfo: - """ - Create a new persona. - - Args: - request: CreatePersonaRequest with persona details - - Returns: - PersonaInfo for the created persona - """ - response = self._personas_http.post("/", json=request.to_api_dict()) - return PersonaInfo.from_dict(response) - - async def acreate_persona(self, request: CreatePersonaRequest) -> PersonaInfo: - """ - Create a new persona asynchronously. - - Args: - request: CreatePersonaRequest with persona details - - Returns: - PersonaInfo for the created persona - - Raises: - RuntimeError: If async client is not available - """ - if self._async_personas_http is None: - raise RuntimeError( - "Async HTTP client not available. " - "Use AISdk with enable_async=True for async operations." - ) - - response = await self._async_personas_http.post("/", json=request.to_api_dict()) - return PersonaInfo.from_dict(response) + return AgentHandle(name=name, http=self._http, async_http=self._async_http) # ------------------------------------------------------------------------- - # Ability Operations + # Properties # ------------------------------------------------------------------------- - def list_abilities(self, limit: int | None = None) -> list[AbilityInfo]: - """ - List all abilities. - - Automatically paginates through all results. - - Args: - limit: Maximum number of abilities to return. If None, returns all abilities. - - Returns: - List of AbilityInfo objects - """ - return self._paginate_list( - self._abilities_http, - "/", - lambda a: AbilityInfo.from_dict(a), - limit=limit, - ) - - async def alist_abilities(self, limit: int | None = None) -> list[AbilityInfo]: - """ - List all abilities asynchronously. - - Automatically paginates through all results. - - Args: - limit: Maximum number of abilities to return. If None, returns all abilities. - - Returns: - List of AbilityInfo objects - - Raises: - RuntimeError: If async client is not available - """ - if self._async_abilities_http is None: - raise RuntimeError( - "Async HTTP client not available. " - "Use AISdk with enable_async=True for async operations." - ) - - return await self._apaginate_list( - self._async_abilities_http, - "/", - lambda a: AbilityInfo.from_dict(a), - limit=limit, - ) - - def get_ability(self, name: str) -> AbilityInfo: - """ - Get an ability by name. - - Args: - name: The ability name - - Returns: - AbilityInfo for the requested ability - - Raises: - AbilityNotFoundError: If the ability does not exist - """ - try: - encoded_name = quote(name, safe="") - response = self._abilities_http.get(f"/name/{encoded_name}") - return AbilityInfo.from_dict(response) - except AISdkError as e: - if e.status_code == 404: - raise AbilityNotFoundError(name) from e - raise - - async def aget_ability(self, name: str) -> AbilityInfo: - """ - Get an ability by name asynchronously. - - Args: - name: The ability name - - Returns: - AbilityInfo for the requested ability - - Raises: - AbilityNotFoundError: If the ability does not exist - RuntimeError: If async client is not available - """ - if self._async_abilities_http is None: - raise RuntimeError( - "Async HTTP client not available. " - "Use AISdk with enable_async=True for async operations." - ) - - try: - encoded_name = quote(name, safe="") - response = await self._async_abilities_http.get(f"/name/{encoded_name}") - return AbilityInfo.from_dict(response) - except AISdkError as e: - if e.status_code == 404: - raise AbilityNotFoundError(name) from e - raise - @property def host(self) -> str: - """Get the configured host URL.""" return self._host @property def async_enabled(self) -> bool: - """Check if async operations are enabled.""" return self._enable_async + # ------------------------------------------------------------------------- + # Lifecycle + # ------------------------------------------------------------------------- + def close(self) -> None: - """Close the client and release resources.""" - self._http.close() - self._personas_http.close() - self._bots_http.close() - self._abilities_http.close() + for client in ( + self._http, + self._personas_http, + self._bots_http, + self._abilities_http, + self._default_http, + self._chat_conv_http, + self._memories_http, + self._search_http, + ): + client.close() async def aclose(self) -> None: - """Close the async client and release resources.""" - if self._async_http is not None: - await self._async_http.close() - if self._async_personas_http is not None: - await self._async_personas_http.close() - if self._async_bots_http is not None: - await self._async_bots_http.close() - if self._async_abilities_http is not None: - await self._async_abilities_http.close() + for client in ( + self._async_http, + self._async_personas_http, + self._async_bots_http, + self._async_abilities_http, + self._async_default_http, + self._async_chat_conv_http, + self._async_memories_http, + self._async_search_http, + ): + if client is not None: + await client.close() def __enter__(self) -> AISdk: return self @@ -868,35 +246,11 @@ def __repr__(self) -> str: @classmethod def from_config(cls, config: AISdkConfig) -> AISdk: - """ - Create a client from an AISdkConfig object. - - This is the recommended way to create a client when using - environment-based configuration. - - Args: - config: AISdkConfig instance - - Returns: - AISdk client - - Example: - from ai_sdk.client import AISdk - from ai_sdk.config import AISdkConfig - - # From environment variables - config = AISdkConfig.from_env() - client = AISdk.from_config(config) - - # With overrides - config = AISdkConfig.from_env(timeout=30.0) - client = AISdk.from_config(config) - """ + """Create a client from an AISdkConfig object.""" if config.debug: from ai_sdk._logging import set_debug set_debug(True) - return cls( host=config.host, token=config.token, diff --git a/python/src/ai_sdk/config.py b/python/src/ai_sdk/config.py index 802b4a7..5b873f0 100644 --- a/python/src/ai_sdk/config.py +++ b/python/src/ai_sdk/config.py @@ -38,7 +38,7 @@ class AISdkConfig: host: str token: str - timeout: float = 120.0 + timeout: float = 900.0 verify_ssl: bool = True enable_async: bool = False max_retries: int = 3 @@ -72,7 +72,7 @@ def from_env( Environment variables: {PREFIX}_HOST: Server URL (required) {PREFIX}_TOKEN: JWT bot token (required) - {PREFIX}_TIMEOUT: Request timeout in seconds (default: 120) + {PREFIX}_TIMEOUT: Request timeout in seconds (default: 900) {PREFIX}_VERIFY_SSL: Verify SSL certificates (default: true) {PREFIX}_DEBUG: Enable debug logging (default: false) @@ -124,7 +124,7 @@ def get_int(name: str, default: int) -> int: return cls( host=host, token=token, - timeout=overrides.get("timeout", get_float("TIMEOUT", 120.0)), + timeout=overrides.get("timeout", get_float("TIMEOUT", 900.0)), verify_ssl=overrides.get("verify_ssl", get_bool("VERIFY_SSL", True)), enable_async=overrides.get("enable_async", get_bool("ASYNC", False)), max_retries=overrides.get("max_retries", get_int("MAX_RETRIES", 3)), diff --git a/python/src/ai_sdk/generated/api/agent/createAgentRequest.py b/python/src/ai_sdk/generated/api/agent/createAgentRequest.py index 01c2bfc..3875f21 100644 --- a/python/src/ai_sdk/generated/api/agent/createAgentRequest.py +++ b/python/src/ai_sdk/generated/api/agent/createAgentRequest.py @@ -349,9 +349,8 @@ class SemanticsRule(BaseModel): class AgentTypeEnum(Enum): - CHAT = 'CHAT' - OBSERVABILITY = 'OBSERVABILITY' - CHART = 'CHART' + PLANNER = 'PLANNER' + INVENTORY = 'INVENTORY' class AgentModeEnum(Enum): diff --git a/python/src/ai_sdk/integrations/langchain.py b/python/src/ai_sdk/integrations/langchain.py index 0c8ca13..c80b353 100644 --- a/python/src/ai_sdk/integrations/langchain.py +++ b/python/src/ai_sdk/integrations/langchain.py @@ -305,7 +305,7 @@ def create_ai_sdk_tools( """ if agent_names is None: # Get all API-enabled agents - agents = client.list_agents(limit=100) + agents = client.agents.list(limit=100) agent_names = [a.name for a in agents if a.api_enabled] return [AISdkAgentTool.from_client(client, name) for name in agent_names] diff --git a/python/src/ai_sdk/models.py b/python/src/ai_sdk/models.py index b278caa..c0f7a9e 100644 --- a/python/src/ai_sdk/models.py +++ b/python/src/ai_sdk/models.py @@ -10,6 +10,7 @@ from typing import Any from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel class EventType(str, Enum): @@ -25,7 +26,7 @@ class EventType(str, Enum): class InvokeRequest(BaseModel): """Request to invoke an agent.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) message: str | None = Field( default=None, @@ -33,7 +34,6 @@ class InvokeRequest(BaseModel): ) conversation_id: str | None = Field( default=None, - alias="conversationId", description="Optional conversation ID for multi-turn conversations", ) parameters: dict[str, Any] = Field( @@ -56,21 +56,18 @@ def to_api_dict(self) -> dict[str, Any]: class Usage(BaseModel): """Token usage statistics.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) prompt_tokens: int = Field( default=0, - alias="promptTokens", description="Number of tokens in the prompt", ) completion_tokens: int = Field( default=0, - alias="completionTokens", description="Number of tokens in the completion", ) total_tokens: int = Field( default=0, - alias="totalTokens", description="Total number of tokens used", ) @@ -83,19 +80,21 @@ def from_dict(cls, data: dict[str, Any]) -> Usage: class InvokeResponse(BaseModel): """Response from invoking an agent.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) conversation_id: str = Field( ..., - alias="conversationId", description="The conversation ID for multi-turn conversations", ) response: str = Field(..., description="The agent's response text") tools_used: list[str] = Field( default_factory=list, - alias="toolsUsed", description="List of tools used by the agent", ) + thinking_steps: list[str] = Field( + default_factory=list, + description="Intermediate reasoning steps emitted by the agent (Sender.SYSTEM messages)", + ) usage: Usage | None = Field( default=None, description="Token usage statistics", @@ -110,18 +109,16 @@ def from_dict(cls, data: dict[str, Any]) -> InvokeResponse: class StreamEvent(BaseModel): """Event from streaming agent response.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) type: EventType = Field(..., description="Event type: start, content, tool_use, end, error") content: str | None = Field(default=None, description="Content for content events") tool_name: str | None = Field( default=None, - alias="toolName", description="Tool name for tool_use events", ) conversation_id: str | None = Field( default=None, - alias="conversationId", description="Conversation ID", ) error: str | None = Field(default=None, description="Error message for error events") @@ -156,12 +153,11 @@ def from_sse(cls, event_type: str, data: dict[str, Any]) -> StreamEvent: class AgentInfo(BaseModel): """Agent metadata for SDK discovery.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) name: str = Field(..., description="Agent name (identifier)") display_name: str | None = Field( default=None, - alias="displayName", description="Human-readable display name", ) description: str | None = Field(default=None, description="Agent description") @@ -171,7 +167,6 @@ class AgentInfo(BaseModel): ) api_enabled: bool = Field( default=False, - alias="apiEnabled", description="Whether the agent is enabled for API access", ) @@ -189,14 +184,13 @@ def from_dict(cls, data: dict[str, Any]) -> AgentInfo: class EntityReference(BaseModel): """A reference to another entity.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) id: str = Field(..., description="Unique identifier of the referenced entity") type: str = Field(..., description="Type of the referenced entity") name: str | None = Field(default=None, description="Name of the referenced entity") display_name: str | None = Field( default=None, - alias="displayName", description="Display name of the referenced entity", ) @@ -218,19 +212,17 @@ def to_api_dict(self) -> dict[str, Any]: class BotInfo(BaseModel): """Represents a bot entity.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) id: str = Field(..., description="Unique identifier of the bot") name: str = Field(..., description="Name of the bot") display_name: str | None = Field( default=None, - alias="displayName", description="Human-readable display name", ) description: str | None = Field(default=None, description="Description of the bot") bot_user: dict[str, Any] | None = Field( default=None, - alias="botUser", description="Reference to the user this bot acts as", ) @@ -254,13 +246,12 @@ def to_api_dict(self) -> dict[str, Any]: class PersonaInfo(BaseModel): """Represents an AI Persona.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) id: str = Field(..., description="Unique identifier of the persona") name: str = Field(..., description="Name of the persona") display_name: str | None = Field( default=None, - alias="displayName", description="Human-readable display name", ) description: str | None = Field(default=None, description="Description of the persona") @@ -292,11 +283,10 @@ def to_api_dict(self) -> dict[str, Any]: class KnowledgeScope(BaseModel): """Defines what data an agent can access.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) entity_types: list[str] | None = Field( default=None, - alias="entityTypes", description="List of entity types the agent can access", ) services: list[EntityReference] | None = Field( @@ -322,14 +312,13 @@ def to_api_dict(self) -> dict[str, Any]: class CreatePersonaRequest(BaseModel): """Request to create a persona.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) name: str = Field(..., description="Name of the persona") description: str = Field(..., description="Description of the persona") prompt: str = Field(..., description="System prompt that defines the persona's behavior") display_name: str | None = Field( default=None, - alias="displayName", description="Human-readable display name", ) provider: str = Field( @@ -364,7 +353,7 @@ def to_api_dict(self) -> dict[str, Any]: class CreateAgentRequest(BaseModel): """Request to create a dynamic agent.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) name: str = Field(..., description="Name of the agent") description: str = Field(..., description="Description of the agent") @@ -372,13 +361,11 @@ class CreateAgentRequest(BaseModel): mode: str = Field(..., description="Agent mode: 'chat', 'agent', or 'both'") display_name: str | None = Field( default=None, - alias="displayName", description="Human-readable display name", ) icon: str | None = Field(default=None, description="Icon for the agent") bot_name: str | None = Field( default=None, - alias="botName", description="Name of the bot that executes this agent", ) abilities: list[str] | None = Field( @@ -395,7 +382,6 @@ class CreateAgentRequest(BaseModel): ) api_enabled: bool = Field( default=False, - alias="apiEnabled", description="Whether the agent is enabled for API access", ) provider: str = Field( @@ -435,16 +421,196 @@ def to_api_dict(self) -> dict[str, Any]: return d +class MemoryType(str, Enum): + """High-level type of reusable memory.""" + + PREFERENCE = "Preference" + USE_CASE = "UseCase" + NOTE = "Note" + RUNBOOK = "Runbook" + FAQ = "Faq" + + +class MemoryScope(str, Enum): + """Scope where the memory applies.""" + + USER_GLOBAL = "UserGlobal" + ENTITY_SCOPED = "EntityScoped" + + +class MemoryVisibility(str, Enum): + """Visibility level for a memory.""" + + PRIVATE = "Private" + ENTITY = "Entity" + SHARED = "Shared" + + +class CreateContextMemoryRequest(BaseModel): + """Request to create a Context Center memory.""" + + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) + + name: str = Field(..., description="Stable system name for the memory") + question: str = Field(..., description="Canonical question / instruction") + answer: str = Field(..., description="Canonical answer / retained guidance") + title: str | None = Field(default=None, description="Short title shown in Context Center") + description: str | None = Field(default=None, description="Optional markdown description") + memory_type: MemoryType = Field( + default=MemoryType.NOTE, + description="High-level memory type", + ) + memory_scope: MemoryScope = Field( + default=MemoryScope.ENTITY_SCOPED, + description="Scope the memory applies to", + ) + visibility: MemoryVisibility = Field( + default=MemoryVisibility.PRIVATE, + description="Visibility level (Private/Entity/Shared)", + ) + primary_entity: EntityReference | None = Field( + default=None, + description="Primary entity this memory attaches to", + ) + related_entities: list[EntityReference] | None = Field( + default=None, + description="Additional related entities", + ) + tags: list[str] | None = Field( + default=None, + description="Tag FQN strings; wrapped to TagLabel on the wire", + ) + + def to_api_dict(self) -> dict[str, Any]: + """Convert to API request format (camelCase keys).""" + d: dict[str, Any] = { + "name": self.name, + "question": self.question, + "answer": self.answer, + "memoryType": self.memory_type.value, + "memoryScope": self.memory_scope.value, + "shareConfig": {"visibility": self.visibility.value}, + } + if self.title is not None: + d["title"] = self.title + if self.description is not None: + d["description"] = self.description + if self.primary_entity is not None: + d["primaryEntity"] = self.primary_entity.to_api_dict() + if self.related_entities is not None: + d["relatedEntities"] = [e.to_api_dict() for e in self.related_entities] + if self.tags is not None: + d["tags"] = [ + { + "tagFQN": t, + "labelType": "Manual", + "state": "Confirmed", + "source": "Classification", + } + for t in self.tags + ] + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CreateContextMemoryRequest: + """Create from API request format.""" + return cls.model_validate(data) + + +class ContextMemory(BaseModel): + """A Context Center memory.""" + + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) + + id: str = Field(..., description="Unique identifier") + name: str = Field(..., description="Stable system name") + fully_qualified_name: str | None = Field( + default=None, + description="Fully qualified name", + ) + title: str | None = Field(default=None, description="Short title") + question: str = Field(default="", description="Canonical question / instruction") + answer: str = Field(default="", description="Canonical answer / retained guidance") + summary: str | None = Field(default=None, description="Optional summary") + memory_type: MemoryType = Field(default=MemoryType.NOTE) + memory_scope: MemoryScope = Field(default=MemoryScope.ENTITY_SCOPED) + visibility: MemoryVisibility = Field( + default=MemoryVisibility.PRIVATE, + description="Visibility (extracted from shareConfig.visibility)", + ) + primary_entity: EntityReference | None = Field(default=None) + usage_count: int = Field(default=0) + last_used_at: int | None = Field( + default=None, + description="Last-used timestamp in epoch milliseconds", + ) + deleted: bool = Field(default=False) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ContextMemory: + """Create from API response format. Flattens shareConfig.visibility.""" + share_config = data.get("shareConfig") or {} + if isinstance(share_config, dict): + visibility = share_config.get("visibility", "Private") + else: + visibility = "Private" + flattened = {**data, "visibility": visibility} + return cls.model_validate(flattened) + + +class MemorySearchHit(BaseModel): + """A single hit from a hybrid memory search.""" + + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) + + memory: ContextMemory = Field(..., description="The matched memory") + score: float = Field(..., description="Relevance score from the search engine") + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> MemorySearchHit: + """Create from an OpenSearch hit.""" + return cls( + memory=ContextMemory.from_dict(data.get("_source", {})), + score=float(data.get("_score", 0.0)), + ) + + +class MemorySearchResults(BaseModel): + """Results from a hybrid memory search.""" + + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) + + total: int = Field(default=0, description="Total number of matching memories") + hits: list[MemorySearchHit] = Field( + default_factory=list, + description="Ranked search hits", + ) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> MemorySearchResults: + """Create from an OpenSearch response shape.""" + hits_block = data.get("hits") or {} + total_block = hits_block.get("total", 0) + if isinstance(total_block, dict): + total = int(total_block.get("value", 0)) + else: + total = int(total_block) + raw_hits = hits_block.get("hits") or [] + return cls( + total=total, + hits=[MemorySearchHit.from_dict(h) for h in raw_hits], + ) + + class AbilityInfo(BaseModel): """Represents an Ability.""" - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True) id: str = Field(..., description="Unique identifier of the ability") name: str = Field(..., description="Name of the ability") display_name: str | None = Field( default=None, - alias="displayName", description="Human-readable display name", ) description: str | None = Field(default=None, description="Description of the ability") @@ -454,7 +620,6 @@ class AbilityInfo(BaseModel): ) fully_qualified_name: str | None = Field( default=None, - alias="fullyQualifiedName", description="Fully qualified name of the ability", ) tools: list[str] = Field( diff --git a/python/tests/api/__init__.py b/python/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/python/tests/api/test_memories.py b/python/tests/api/test_memories.py new file mode 100644 index 0000000..67f1e1e --- /dev/null +++ b/python/tests/api/test_memories.py @@ -0,0 +1,287 @@ +"""Unit tests for MemoriesAPI.""" + +from __future__ import annotations + +import json as json_module +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from ai_sdk.api.memories import MemoriesAPI +from ai_sdk.models import ( + ContextMemory, + CreateContextMemoryRequest, + EntityReference, + MemorySearchResults, + MemoryType, + MemoryVisibility, +) + + +def _memory_payload(name: str = "m1", primary_fqn: str | None = None) -> dict: + return { + "id": "11111111-1111-1111-1111-111111111111", + "name": name, + "fullyQualifiedName": name, + "title": "T", + "question": "q", + "answer": "a", + "summary": None, + "memoryType": "Note", + "memoryScope": "EntityScoped", + "shareConfig": {"visibility": "Private"}, + "primaryEntity": ( + {"id": "abc", "type": "table", "fullyQualifiedName": primary_fqn} + if primary_fqn + else None + ), + "usageCount": 0, + "lastUsedAt": None, + "deleted": False, + } + + +@pytest.fixture +def mock_http() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def mock_search_http() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def memories(mock_http: MagicMock, mock_search_http: MagicMock) -> MemoriesAPI: + return MemoriesAPI( + http=mock_http, + async_http=None, + search_http=mock_search_http, + search_async_http=None, + ) + + +class TestList: + def test_list_no_filter(self, memories: MemoriesAPI, mock_http: MagicMock) -> None: + mock_http.get.return_value = { + "data": [_memory_payload("m1"), _memory_payload("m2")], + "paging": {}, + } + result = memories.list() + assert len(result) == 2 + assert all(isinstance(m, ContextMemory) for m in result) + mock_http.get.assert_called_once() + path, kwargs = mock_http.get.call_args.args[0], mock_http.get.call_args.kwargs + assert path == "/" + assert "primaryEntityFqn" not in kwargs.get("params", {}) + + def test_list_with_primary_entity_fqn_filter( + self, memories: MemoriesAPI, mock_http: MagicMock + ) -> None: + mock_http.get.return_value = { + "data": [_memory_payload("m1", primary_fqn="db.tbl")], + "paging": {}, + } + result = memories.list(primary_entity_fqn="db.tbl") + assert len(result) == 1 + params = mock_http.get.call_args.kwargs.get("params", {}) + assert params.get("primaryEntityFqn") == "db.tbl" + + def test_list_respects_limit(self, memories: MemoriesAPI, mock_http: MagicMock) -> None: + mock_http.get.return_value = { + "data": [_memory_payload(f"m{i}") for i in range(5)], + "paging": {}, + } + result = memories.list(limit=3) + assert len(result) == 3 + + def test_list_paginates_via_after_token( + self, memories: MemoriesAPI, mock_http: MagicMock + ) -> None: + mock_http.get.side_effect = [ + {"data": [_memory_payload("m1")], "paging": {"after": "page2"}}, + {"data": [_memory_payload("m2")], "paging": {}}, + ] + result = memories.list() + assert len(result) == 2 + assert mock_http.get.call_count == 2 + # second call should pass after=page2 + second_params = mock_http.get.call_args_list[1].kwargs.get("params", {}) + assert second_params.get("after") == "page2" + + +class TestGet: + def test_get_calls_id_endpoint(self, memories: MemoriesAPI, mock_http: MagicMock) -> None: + mock_http.get.return_value = _memory_payload("m1") + result = memories.get("11111111-1111-1111-1111-111111111111") + assert result.id == "11111111-1111-1111-1111-111111111111" + assert result.name == "m1" + mock_http.get.assert_called_once_with("/11111111-1111-1111-1111-111111111111") + + +class TestCreate: + def test_create_posts_serialized_request( + self, memories: MemoriesAPI, mock_http: MagicMock + ) -> None: + mock_http.post.return_value = _memory_payload("m1") + req = CreateContextMemoryRequest( + name="m1", + question="q", + answer="a", + primary_entity=EntityReference(id="abc", type="table"), + tags=["PII.Sensitive"], + visibility=MemoryVisibility.SHARED, + memory_type=MemoryType.PREFERENCE, + ) + result = memories.create(req) + assert isinstance(result, ContextMemory) + assert result.name == "m1" + + path = mock_http.post.call_args.args[0] + body = mock_http.post.call_args.kwargs["json"] + assert path == "/" + assert body["name"] == "m1" + assert body["question"] == "q" + assert body["answer"] == "a" + assert body["memoryType"] == "Preference" + assert body["shareConfig"] == {"visibility": "Shared"} + assert body["primaryEntity"] == {"id": "abc", "type": "table"} + assert body["tags"][0]["tagFQN"] == "PII.Sensitive" + + +class TestDelete: + def test_delete_soft_default(self, memories: MemoriesAPI, mock_http: MagicMock) -> None: + memories.delete("11111111-1111-1111-1111-111111111111") + path = mock_http.delete.call_args.args[0] + params = mock_http.delete.call_args.kwargs.get("params", {}) + assert path == "/11111111-1111-1111-1111-111111111111" + assert params.get("hardDelete") is False + + def test_delete_hard(self, memories: MemoriesAPI, mock_http: MagicMock) -> None: + memories.delete("xyz", hard_delete=True) + params = mock_http.delete.call_args.kwargs.get("params", {}) + assert params.get("hardDelete") is True + + +class TestSearch: + @staticmethod + def _search_response() -> dict: + return { + "hits": { + "total": {"value": 1}, + "hits": [ + { + "_score": 0.95, + "_source": _memory_payload("m1"), + } + ], + } + } + + def test_search_minimal(self, memories: MemoriesAPI, mock_search_http: MagicMock) -> None: + mock_search_http.get.return_value = self._search_response() + result = memories.search("explain customer churn") + assert isinstance(result, MemorySearchResults) + assert result.total == 1 + assert result.hits[0].score == 0.95 + assert result.hits[0].memory.name == "m1" + + path = mock_search_http.get.call_args.args[0] + params = mock_search_http.get.call_args.kwargs.get("params", {}) + assert path == "/hybrid/nlq/search" + assert params["q"] == "explain customer churn" + assert params["index"] == "contextMemory" + assert params["size"] == 15 + assert params["from"] == 0 + assert "filters" not in params + + def test_search_with_filters_serializes_to_json( + self, memories: MemoriesAPI, mock_search_http: MagicMock + ) -> None: + mock_search_http.get.return_value = self._search_response() + memories.search( + "x", + filters={"primaryEntityId": ["abc"], "visibility": ["Entity", "Shared"]}, + size=5, + from_=10, + ) + params = mock_search_http.get.call_args.kwargs.get("params", {}) + assert json_module.loads(params["filters"]) == { + "primaryEntityId": ["abc"], + "visibility": ["Entity", "Shared"], + } + assert params["size"] == 5 + assert params["from"] == 10 + + +class TestAsyncRaisesWhenAsyncDisabled: + def test_alist_raises_runtime_error( + self, mock_http: MagicMock, mock_search_http: MagicMock + ) -> None: + api = MemoriesAPI( + http=mock_http, + async_http=None, + search_http=mock_search_http, + search_async_http=None, + ) + + async def call() -> None: + await api.alist() + + import asyncio + + with pytest.raises(RuntimeError, match="Async HTTP client not available"): + asyncio.run(call()) + + +class TestAsyncMethods: + @pytest.fixture + def mock_async_http(self) -> AsyncMock: + return AsyncMock() + + @pytest.fixture + def mock_async_search_http(self) -> AsyncMock: + return AsyncMock() + + @pytest.fixture + def async_memories( + self, + mock_http: MagicMock, + mock_search_http: MagicMock, + mock_async_http: AsyncMock, + mock_async_search_http: AsyncMock, + ) -> MemoriesAPI: + return MemoriesAPI( + http=mock_http, + async_http=mock_async_http, + search_http=mock_search_http, + search_async_http=mock_async_search_http, + ) + + @pytest.mark.asyncio + async def test_alist(self, async_memories: MemoriesAPI, mock_async_http: AsyncMock) -> None: + mock_async_http.get.return_value = {"data": [_memory_payload("m1")], "paging": {}} + result = await async_memories.alist() + assert len(result) == 1 + assert result[0].name == "m1" + + @pytest.mark.asyncio + async def test_acreate(self, async_memories: MemoriesAPI, mock_async_http: AsyncMock) -> None: + mock_async_http.post.return_value = _memory_payload("m1") + req = CreateContextMemoryRequest(name="m1", question="q", answer="a") + result = await async_memories.acreate(req) + assert result.name == "m1" + + @pytest.mark.asyncio + async def test_adelete(self, async_memories: MemoriesAPI, mock_async_http: AsyncMock) -> None: + await async_memories.adelete("xyz", hard_delete=True) + params = mock_async_http.delete.call_args.kwargs.get("params", {}) + assert params.get("hardDelete") is True + + @pytest.mark.asyncio + async def test_asearch( + self, async_memories: MemoriesAPI, mock_async_search_http: AsyncMock + ) -> None: + mock_async_search_http.get.return_value = {"hits": {"total": {"value": 0}, "hits": []}} + result = await async_memories.asearch("q") + assert result.total == 0 diff --git a/python/tests/integration/test_integration.py b/python/tests/integration/test_integration.py index 46a319b..faab847 100644 --- a/python/tests/integration/test_integration.py +++ b/python/tests/integration/test_integration.py @@ -73,7 +73,7 @@ def test_agent_name(client: AISdk) -> str | None: return name # Get an existing persona to use (they have LLM configured) - personas = client.list_personas() + personas = client.personas.list() if not personas: return None @@ -88,12 +88,12 @@ def test_agent_name(client: AISdk) -> str | None: abilities=["discoveryAndSearch"], api_enabled=True, ) - client.create_agent(request) + client.agents.create(request) return agent_name except Exception as e: print(f"Could not create test agent: {e}") # Fall back to first available agent - agents = client.list_agents() + agents = client.agents.list() if agents: return agents[0].name return None @@ -109,7 +109,7 @@ def test_client_creation(self, client: AISdk) -> None: def test_list_agents(self, client: AISdk) -> None: """Test that we can list agents (validates auth works).""" - agents = client.list_agents() + agents = client.agents.list() # Should return a list (may be empty, but shouldn't error) assert isinstance(agents, list) print(f"Found {len(agents)} API-enabled agents") @@ -121,7 +121,7 @@ def test_invalid_token_rejected(self) -> None: token="invalid-token-12345", ) with pytest.raises(AuthenticationError): - client.list_agents() + client.agents.list() class TestAgentOperations: @@ -178,7 +178,7 @@ class TestAsyncOperations: @pytest.mark.asyncio async def test_async_list_agents(self, async_client: AISdk) -> None: """Test async agent listing.""" - agents = await async_client.alist_agents() + agents = await async_client.agents.alist() assert isinstance(agents, list) @skip_chat @@ -219,25 +219,25 @@ class TestPersonaOperations: def test_list_personas(self, client: AISdk) -> None: """Test listing personas.""" - personas = client.list_personas() + personas = client.personas.list() assert isinstance(personas, list) print(f"Found {len(personas)} personas") def test_list_personas_with_limit(self, client: AISdk) -> None: """Test listing personas with limit.""" - personas = client.list_personas(limit=5) + personas = client.personas.list(limit=5) assert isinstance(personas, list) assert len(personas) <= 5 def test_get_persona(self, client: AISdk) -> None: """Test getting a specific persona.""" # First, list personas to get one that exists - personas = client.list_personas() + personas = client.personas.list() if not personas: pytest.skip("No personas available to test get_persona") persona_name = personas[0].name - persona = client.get_persona(persona_name) + persona = client.personas.get(persona_name) assert persona is not None assert persona.name == persona_name @@ -246,7 +246,7 @@ def test_get_persona(self, client: AISdk) -> None: def test_get_persona_not_found(self, client: AISdk) -> None: """Test that getting a non-existent persona raises error.""" with pytest.raises(PersonaNotFoundError) as exc_info: - client.get_persona("non-existent-persona-12345") + client.personas.get("non-existent-persona-12345") assert exc_info.value.status_code == 404 def test_create_persona(self, client: AISdk) -> None: @@ -259,7 +259,7 @@ def test_create_persona(self, client: AISdk) -> None: display_name="Test Persona", ) - created = client.create_persona(request) + created = client.personas.create(request) assert created is not None assert created.name == persona_name @@ -269,17 +269,17 @@ def test_create_persona(self, client: AISdk) -> None: @pytest.mark.asyncio async def test_async_list_personas(self, async_client: AISdk) -> None: """Test async listing personas.""" - personas = await async_client.alist_personas() + personas = await async_client.personas.alist() assert isinstance(personas, list) @pytest.mark.asyncio async def test_async_get_persona(self, async_client: AISdk) -> None: """Test async getting a specific persona.""" - personas = await async_client.alist_personas() + personas = await async_client.personas.alist() if not personas: pytest.skip("No personas available") - persona = await async_client.aget_persona(personas[0].name) + persona = await async_client.personas.aget(personas[0].name) assert persona is not None @pytest.mark.asyncio @@ -292,7 +292,7 @@ async def test_async_create_persona(self, async_client: AISdk) -> None: prompt="You are a helpful async test assistant.", ) - created = await async_client.acreate_persona(request) + created = await async_client.personas.acreate(request) assert created is not None assert created.name == persona_name @@ -302,24 +302,24 @@ class TestBotOperations: def test_list_bots(self, client: AISdk) -> None: """Test listing bots.""" - bots = client.list_bots() + bots = client.bots.list() assert isinstance(bots, list) print(f"Found {len(bots)} bots") def test_list_bots_with_limit(self, client: AISdk) -> None: """Test listing bots with limit.""" - bots = client.list_bots(limit=5) + bots = client.bots.list(limit=5) assert isinstance(bots, list) assert len(bots) <= 5 def test_get_bot(self, client: AISdk) -> None: """Test getting a specific bot.""" - bots = client.list_bots() + bots = client.bots.list() if not bots: pytest.skip("No bots available to test get_bot") bot_name = bots[0].name - bot = client.get_bot(bot_name) + bot = client.bots.get(bot_name) assert bot is not None assert bot.name == bot_name @@ -328,17 +328,17 @@ def test_get_bot(self, client: AISdk) -> None: @pytest.mark.asyncio async def test_async_list_bots(self, async_client: AISdk) -> None: """Test async listing bots.""" - bots = await async_client.alist_bots() + bots = await async_client.bots.alist() assert isinstance(bots, list) @pytest.mark.asyncio async def test_async_get_bot(self, async_client: AISdk) -> None: """Test async getting a specific bot.""" - bots = await async_client.alist_bots() + bots = await async_client.bots.alist() if not bots: pytest.skip("No bots available") - bot = await async_client.aget_bot(bots[0].name) + bot = await async_client.bots.aget(bots[0].name) assert bot is not None @@ -347,19 +347,19 @@ class TestAbilityOperations: def test_list_abilities(self, client: AISdk) -> None: """Test listing abilities.""" - abilities = client.list_abilities() + abilities = client.abilities.list() assert isinstance(abilities, list) print(f"Found {len(abilities)} abilities") def test_list_abilities_with_limit(self, client: AISdk) -> None: """Test listing abilities with limit.""" - abilities = client.list_abilities(limit=5) + abilities = client.abilities.list(limit=5) assert isinstance(abilities, list) assert len(abilities) <= 5 def test_ability_has_expected_fields(self, client: AISdk) -> None: """Test that abilities have expected fields.""" - abilities = client.list_abilities() + abilities = client.abilities.list() if not abilities: pytest.skip("No abilities available") @@ -371,7 +371,7 @@ def test_ability_has_expected_fields(self, client: AISdk) -> None: @pytest.mark.asyncio async def test_async_list_abilities(self, async_client: AISdk) -> None: """Test async listing abilities.""" - abilities = await async_client.alist_abilities() + abilities = await async_client.abilities.alist() assert isinstance(abilities, list) @@ -381,7 +381,7 @@ class TestAgentCRUDOperations: def test_create_agent(self, client: AISdk) -> None: """Test creating a new agent.""" # First, get a persona to use - personas = client.list_personas() + personas = client.personas.list() if not personas: pytest.skip("No personas available to create agent") @@ -394,7 +394,7 @@ def test_create_agent(self, client: AISdk) -> None: api_enabled=True, ) - created = client.create_agent(request) + created = client.agents.create(request) assert created is not None assert created.name == agent_name @@ -402,8 +402,8 @@ def test_create_agent(self, client: AISdk) -> None: def test_create_agent_with_abilities(self, client: AISdk) -> None: """Test creating an agent with abilities.""" - personas = client.list_personas() - abilities = client.list_abilities() + personas = client.personas.list() + abilities = client.abilities.list() if not personas: pytest.skip("No personas available") @@ -422,7 +422,7 @@ def test_create_agent_with_abilities(self, client: AISdk) -> None: api_enabled=True, ) - created = client.create_agent(request) + created = client.agents.create(request) assert created is not None assert created.name == agent_name @@ -431,7 +431,7 @@ def test_create_agent_with_abilities(self, client: AISdk) -> None: @pytest.mark.asyncio async def test_async_create_agent(self, async_client: AISdk) -> None: """Test async creating an agent.""" - personas = await async_client.alist_personas() + personas = await async_client.personas.alist() if not personas: pytest.skip("No personas available") @@ -444,6 +444,6 @@ async def test_async_create_agent(self, async_client: AISdk) -> None: api_enabled=True, ) - created = await async_client.acreate_agent(request) + created = await async_client.agents.acreate(request) assert created is not None assert created.name == agent_name diff --git a/python/tests/integration/test_memories_integration.py b/python/tests/integration/test_memories_integration.py new file mode 100644 index 0000000..1847058 --- /dev/null +++ b/python/tests/integration/test_memories_integration.py @@ -0,0 +1,74 @@ +"""Integration tests for memories namespace. + +Requires AI_SDK_HOST and AI_SDK_TOKEN environment variables. +""" + +from __future__ import annotations + +import os +import time +import uuid +from collections.abc import Iterator + +import pytest + +from ai_sdk import ( + AISdk, + AISdkConfig, + ContextMemory, + CreateContextMemoryRequest, + MemorySearchResults, + MemoryType, +) + +pytestmark = pytest.mark.skipif( + not (os.getenv("AI_SDK_HOST") and os.getenv("AI_SDK_TOKEN")), + reason="Integration tests require AI_SDK_HOST and AI_SDK_TOKEN", +) + + +@pytest.fixture(scope="module") +def client() -> Iterator[AISdk]: + cfg = AISdkConfig.from_env() + sdk = AISdk.from_config(cfg) + try: + yield sdk + finally: + sdk.close() + + +def _unique_name() -> str: + return f"sdk-int-{uuid.uuid4().hex[:8]}-{int(time.time())}" + + +class TestMemoryRoundTrip: + """End-to-end create -> get -> list -> search -> delete cycle.""" + + def test_full_round_trip(self, client: AISdk) -> None: + name = _unique_name() + req = CreateContextMemoryRequest( + name=name, + title="Integration test memory", + question="What is the integration test asking about?", + answer="A throwaway memory created by the SDK integration test.", + memory_type=MemoryType.NOTE, + ) + + created = client.memories.create(req) + assert isinstance(created, ContextMemory) + assert created.name == name + memory_id = created.id + + try: + fetched = client.memories.get(memory_id) + assert fetched.id == memory_id + assert fetched.name == name + + listed = client.memories.list(limit=200) + assert any(m.id == memory_id for m in listed) + + # Search may need indexing time — best-effort assertion + results = client.memories.search("integration test memory", size=20) + assert isinstance(results, MemorySearchResults) + finally: + client.memories.delete(memory_id, hard_delete=True) diff --git a/python/tests/test_agent_creation.py b/python/tests/test_agent_creation.py index e7df99c..9cc3b66 100644 --- a/python/tests/test_agent_creation.py +++ b/python/tests/test_agent_creation.py @@ -186,7 +186,7 @@ def test_create_agent_minimal(self, client, httpx_mock: HTTPXMock, sample_agent_ persona="DataAnalyst", mode="chat", ) - result = client.create_agent(request) + result = client.agents.create(request) assert isinstance(result, AgentInfo) assert result.name == "MyTestAgent" @@ -246,7 +246,7 @@ def test_create_agent_all_fields( schedule="0 */6 * * *", api_enabled=True, ) - result = client.create_agent(request) + result = client.agents.create(request) assert isinstance(result, AgentInfo) assert result.name == "FullAgent" @@ -279,7 +279,7 @@ def test_create_agent_sends_correct_body(self, client, httpx_mock: HTTPXMock): mode="chat", api_enabled=True, ) - client.create_agent(request) + client.agents.create(request) # Get the POST request (second request, after persona GET) requests = httpx_mock.get_requests() @@ -325,7 +325,7 @@ async def test_acreate_agent(self, async_client, httpx_mock: HTTPXMock, sample_a persona="DataAnalyst", mode="chat", ) - result = await async_client.acreate_agent(request) + result = await async_client.agents.acreate(request) assert isinstance(result, AgentInfo) assert result.name == "MyTestAgent" @@ -342,7 +342,7 @@ async def test_acreate_agent_requires_async_enabled(self, client): ) with pytest.raises(RuntimeError) as exc_info: - await client.acreate_agent(request) + await client.agents.acreate(request) assert "Async HTTP client not available" in str(exc_info.value) diff --git a/python/tests/test_async.py b/python/tests/test_async.py index 632ddef..6de4ead 100644 --- a/python/tests/test_async.py +++ b/python/tests/test_async.py @@ -147,7 +147,7 @@ async def test_alist_agents_returns_list( json=sample_agents_list_response, ) - agents = await async_client.alist_agents() + agents = await async_client.agents.alist() assert len(agents) == 2 assert all(isinstance(a, AgentInfo) for a in agents) diff --git a/python/tests/test_bots.py b/python/tests/test_bots.py index 12c26b4..ad57a93 100644 --- a/python/tests/test_bots.py +++ b/python/tests/test_bots.py @@ -76,7 +76,7 @@ def test_list_bots_returns_bot_info( json=sample_bots_list_response, ) - bots = client.list_bots() + bots = client.bots.list() assert len(bots) == 2 assert all(isinstance(b, BotInfo) for b in bots) @@ -92,7 +92,7 @@ def test_list_bots_with_limit(self, client, httpx_mock: HTTPXMock, sample_bots_l ) # Request only 1 bot, even though API returns 2 - bots = client.list_bots(limit=1) + bots = client.bots.list(limit=1) assert len(bots) == 1 assert bots[0].name == "ingestion-bot" @@ -104,7 +104,7 @@ def test_list_bots_empty_response(self, client, httpx_mock: HTTPXMock): json={"data": []}, ) - bots = client.list_bots() + bots = client.bots.list() assert bots == [] @@ -119,7 +119,7 @@ def test_get_bot_returns_bot_info(self, client, httpx_mock: HTTPXMock, sample_bo json=sample_bot_info_dict, ) - bot = client.get_bot("ingestion-bot") + bot = client.bots.get("ingestion-bot") assert isinstance(bot, BotInfo) assert bot.name == "ingestion-bot" @@ -136,7 +136,7 @@ def test_get_bot_not_found(self, client, httpx_mock: HTTPXMock): ) with pytest.raises(BotNotFoundError) as exc_info: - client.get_bot("nonexistent-bot") + client.bots.get("nonexistent-bot") assert exc_info.value.bot_name == "nonexistent-bot" assert exc_info.value.status_code == 404 @@ -155,7 +155,7 @@ async def test_alist_bots_returns_bot_info( json=sample_bots_list_response, ) - bots = await async_client.alist_bots() + bots = await async_client.bots.alist() assert len(bots) == 2 assert all(isinstance(b, BotInfo) for b in bots) @@ -165,7 +165,7 @@ async def test_alist_bots_returns_bot_info( async def test_alist_bots_without_async_enabled(self, client, httpx_mock: HTTPXMock): """alist_bots raises RuntimeError when async not enabled.""" with pytest.raises(RuntimeError) as exc_info: - await client.alist_bots() + await client.bots.alist() assert "enable_async=True" in str(exc_info.value) @@ -183,7 +183,7 @@ async def test_aget_bot_returns_bot_info( json=sample_bot_info_dict, ) - bot = await async_client.aget_bot("ingestion-bot") + bot = await async_client.bots.aget("ingestion-bot") assert isinstance(bot, BotInfo) assert bot.name == "ingestion-bot" @@ -198,7 +198,7 @@ async def test_aget_bot_not_found(self, async_client, httpx_mock: HTTPXMock): ) with pytest.raises(BotNotFoundError) as exc_info: - await async_client.aget_bot("nonexistent-bot") + await async_client.bots.aget("nonexistent-bot") assert exc_info.value.bot_name == "nonexistent-bot" @@ -206,6 +206,6 @@ async def test_aget_bot_not_found(self, async_client, httpx_mock: HTTPXMock): async def test_aget_bot_without_async_enabled(self, client, httpx_mock: HTTPXMock): """aget_bot raises RuntimeError when async not enabled.""" with pytest.raises(RuntimeError) as exc_info: - await client.aget_bot("any-bot") + await client.bots.aget("any-bot") assert "enable_async=True" in str(exc_info.value) diff --git a/python/tests/test_client.py b/python/tests/test_client.py index 227c3ba..42cc576 100644 --- a/python/tests/test_client.py +++ b/python/tests/test_client.py @@ -55,7 +55,7 @@ def test_list_agents_returns_agent_info( json=sample_agents_list_response, ) - agents = client.list_agents() + agents = client.agents.list() assert len(agents) == 2 assert all(isinstance(a, AgentInfo) for a in agents) @@ -72,7 +72,7 @@ def test_list_agents_with_limit( ) # Request only 1 agent, even though API returns 2 - agents = client.list_agents(limit=1) + agents = client.agents.list(limit=1) assert len(agents) == 1 assert agents[0].name == "DataQualityPlannerAgent" diff --git a/python/tests/test_config.py b/python/tests/test_config.py index b68c499..53a4eca 100644 --- a/python/tests/test_config.py +++ b/python/tests/test_config.py @@ -17,7 +17,7 @@ def test_default_values(self): assert config.host == "https://metadata.example.com" assert config.token == "test-token" - assert config.timeout == 120.0 + assert config.timeout == 900.0 assert config.verify_ssl is True assert config.enable_async is False assert config.max_retries == 3 diff --git a/python/tests/test_default_agent.py b/python/tests/test_default_agent.py new file mode 100644 index 0000000..a4643e2 --- /dev/null +++ b/python/tests/test_default_agent.py @@ -0,0 +1,144 @@ +"""Tests for the default-agent path (client.agent() with no name).""" + +import json + +import pytest +from pytest_httpx import HTTPXMock + +from ai_sdk.client import AISdk +from ai_sdk.models import InvokeResponse + + +@pytest.fixture +def client(): + c = AISdk(host="https://metadata.example.com", token="test-jwt-token") + yield c + c.close() + + +def test_default_agent_call_creates_conversation_then_invokes(client, httpx_mock: HTTPXMock): + """client.agent().call(msg) should create a conversation, then call invoke.""" + httpx_mock.add_response( + method="POST", + url="https://metadata.example.com/api/v1/assistants/chatConversations", + json={"id": "11111111-1111-1111-1111-111111111111"}, + ) + httpx_mock.add_response( + method="POST", + url="https://metadata.example.com/api/v1/agents/invoke", + json={ + "conversationId": "11111111-1111-1111-1111-111111111111", + "response": "hello", + "toolsUsed": [], + }, + ) + + response = client.agent().call("Say hi") + + assert isinstance(response, InvokeResponse) + assert response.conversation_id == "11111111-1111-1111-1111-111111111111" + assert response.response == "hello" + assert response.thinking_steps == [] # backward-compat: absent field defaults to [] + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + create_body = json.loads(requests[0].content) + assert create_body["title"] == "Say hi" # first 50 chars + invoke_body = json.loads(requests[1].content) + assert invoke_body["message"] == "Say hi" + assert invoke_body["conversationId"] == "11111111-1111-1111-1111-111111111111" + assert invoke_body["agentType"] == "PLANNER" + assert invoke_body["agentMode"] == "CHAT_MODE" + + +def test_default_agent_reuses_existing_conversation(client, httpx_mock: HTTPXMock): + """Passing conversation_id skips conversation creation.""" + httpx_mock.add_response( + method="POST", + url="https://metadata.example.com/api/v1/agents/invoke", + json={ + "conversationId": "22222222-2222-2222-2222-222222222222", + "response": "ok", + }, + ) + + response = client.agent().call( + "Hello again", + conversation_id="22222222-2222-2222-2222-222222222222", + ) + + assert response.conversation_id == "22222222-2222-2222-2222-222222222222" + requests = httpx_mock.get_requests() + assert len(requests) == 1 # no conversation creation + assert "/agents/invoke" in str(requests[0].url) + + +def test_default_agent_stream_content(client, httpx_mock: HTTPXMock, sample_sse_stream): + """Streaming default agent: creates conversation, streams to /run.""" + httpx_mock.add_response( + method="POST", + url="https://metadata.example.com/api/v1/assistants/chatConversations", + json={"id": "33333333-3333-3333-3333-333333333333"}, + ) + httpx_mock.add_response( + method="POST", + url="https://metadata.example.com/api/v1/agents/run", + content=b"".join(sample_sse_stream), + headers={"content-type": "text/event-stream"}, + ) + + chunks = list(client.agent().stream_content("Stream please")) + assert "".join(chunks) == "The customers table has 3 issues." + + +def test_default_agent_call_surfaces_thinking_steps(client, httpx_mock: HTTPXMock): + """Backend-supplied thinkingSteps are surfaced on the response object.""" + httpx_mock.add_response( + method="POST", + url="https://metadata.example.com/api/v1/assistants/chatConversations", + json={"id": "55555555-5555-5555-5555-555555555555"}, + ) + httpx_mock.add_response( + method="POST", + url="https://metadata.example.com/api/v1/agents/invoke", + json={ + "conversationId": "55555555-5555-5555-5555-555555555555", + "response": "Found it.", + "toolsUsed": [], + "thinkingSteps": ["Exploring assets...", "Synthesizing answer..."], + }, + ) + + response = client.agent().call("question") + + assert response.response == "Found it." + assert response.thinking_steps == ["Exploring assets...", "Synthesizing answer..."] + + +@pytest.mark.asyncio +async def test_default_agent_acall(httpx_mock: HTTPXMock): + """Async default-agent call: creates conversation, then async invoke.""" + async_client = AISdk( + host="https://metadata.example.com", + token="test-jwt-token", + enable_async=True, + ) + try: + httpx_mock.add_response( + method="POST", + url="https://metadata.example.com/api/v1/assistants/chatConversations", + json={"id": "44444444-4444-4444-4444-444444444444"}, + ) + httpx_mock.add_response( + method="POST", + url="https://metadata.example.com/api/v1/agents/invoke", + json={ + "conversationId": "44444444-4444-4444-4444-444444444444", + "response": "async hi", + }, + ) + response = await async_client.agent().acall("Async test") + assert response.response == "async hi" + finally: + await async_client.aclose() + async_client.close() diff --git a/python/tests/test_models.py b/python/tests/test_models.py index d8f2dcd..5efba33 100644 --- a/python/tests/test_models.py +++ b/python/tests/test_models.py @@ -2,9 +2,17 @@ from ai_sdk.models import ( AgentInfo, + ContextMemory, + CreateContextMemoryRequest, + EntityReference, EventType, InvokeRequest, InvokeResponse, + MemoryScope, + MemorySearchHit, + MemorySearchResults, + MemoryType, + MemoryVisibility, StreamEvent, Usage, ) @@ -150,3 +158,120 @@ def test_from_dict_defaults_missing_fields(self): assert info.description is None # None when not provided assert info.abilities == [] assert info.api_enabled is False + + +class TestCreateContextMemoryRequest: + """Tests for CreateContextMemoryRequest serialization.""" + + def test_minimal_to_api_dict(self): + req = CreateContextMemoryRequest( + name="my-memory", + question="What does X mean?", + answer="X means Y.", + ) + d = req.to_api_dict() + assert d["name"] == "my-memory" + assert d["question"] == "What does X mean?" + assert d["answer"] == "X means Y." + assert d["memoryType"] == "Note" + assert d["memoryScope"] == "EntityScoped" + assert d["shareConfig"] == {"visibility": "Private"} + + def test_with_primary_entity_and_tags(self): + req = CreateContextMemoryRequest( + name="m1", + question="q", + answer="a", + memory_type=MemoryType.PREFERENCE, + visibility=MemoryVisibility.SHARED, + primary_entity=EntityReference(id="abc", type="table"), + tags=["PII.Sensitive"], + ) + d = req.to_api_dict() + assert d["memoryType"] == "Preference" + assert d["shareConfig"] == {"visibility": "Shared"} + assert d["primaryEntity"] == {"id": "abc", "type": "table"} + assert d["tags"] == [ + { + "tagFQN": "PII.Sensitive", + "labelType": "Manual", + "state": "Confirmed", + "source": "Classification", + } + ] + + +class TestContextMemory: + """Tests for ContextMemory parsing.""" + + def test_from_dict_extracts_visibility_from_share_config(self): + data = { + "id": "11111111-1111-1111-1111-111111111111", + "name": "m1", + "fullyQualifiedName": "m1", + "title": "T", + "question": "q", + "answer": "a", + "summary": None, + "memoryType": "Note", + "memoryScope": "EntityScoped", + "shareConfig": {"visibility": "Entity"}, + "primaryEntity": {"id": "abc", "type": "table"}, + "usageCount": 3, + "lastUsedAt": 1700000000000, + "deleted": False, + } + m = ContextMemory.from_dict(data) + assert m.id == "11111111-1111-1111-1111-111111111111" + assert m.visibility == MemoryVisibility.ENTITY + assert m.memory_scope == MemoryScope.ENTITY_SCOPED + assert m.usage_count == 3 + assert m.last_used_at == 1700000000000 + + def test_from_dict_defaults_visibility_when_share_config_missing(self): + data = {"id": "x", "name": "m1"} + m = ContextMemory.from_dict(data) + assert m.visibility == MemoryVisibility.PRIVATE + + +class TestMemorySearchResults: + """Tests for MemorySearchResults parsing.""" + + def test_from_opensearch_response(self): + resp = { + "hits": { + "total": {"value": 2}, + "hits": [ + { + "_score": 1.5, + "_source": { + "id": "11111111-1111-1111-1111-111111111111", + "name": "m1", + "fullyQualifiedName": "m1", + "title": "T", + "question": "q", + "answer": "a", + "summary": None, + "memoryType": "Note", + "memoryScope": "EntityScoped", + "shareConfig": {"visibility": "Private"}, + "primaryEntity": None, + "usageCount": 0, + "lastUsedAt": None, + "deleted": False, + }, + } + ], + } + } + results = MemorySearchResults.from_dict(resp) + assert results.total == 2 + assert len(results.hits) == 1 + assert isinstance(results.hits[0], MemorySearchHit) + assert results.hits[0].score == 1.5 + assert results.hits[0].memory.name == "m1" + + def test_from_dict_handles_empty_hits(self): + results = MemorySearchResults.from_dict({"hits": {"total": {"value": 0}, "hits": []}}) + assert results.total == 0 + assert results.hits == [] diff --git a/python/tests/test_personas.py b/python/tests/test_personas.py index d9b17fa..c3254ef 100644 --- a/python/tests/test_personas.py +++ b/python/tests/test_personas.py @@ -74,7 +74,7 @@ def test_list_personas_returns_persona_info( json=sample_personas_list_response, ) - personas = client.list_personas() + personas = client.personas.list() assert len(personas) == 2 assert all(isinstance(p, PersonaInfo) for p in personas) @@ -92,7 +92,7 @@ def test_list_personas_with_limit( ) # Request only 1 persona, even though API returns 2 - personas = client.list_personas(limit=1) + personas = client.personas.list(limit=1) assert len(personas) == 1 assert personas[0].name == "DataAnalyst" @@ -107,7 +107,7 @@ async def test_alist_personas_returns_persona_info( json=sample_personas_list_response, ) - personas = await async_client.alist_personas() + personas = await async_client.personas.alist() assert len(personas) == 2 assert all(isinstance(p, PersonaInfo) for p in personas) @@ -126,7 +126,7 @@ def test_get_persona_returns_persona_info( json=sample_persona_info_dict, ) - persona = client.get_persona("DataAnalyst") + persona = client.personas.get("DataAnalyst") assert isinstance(persona, PersonaInfo) assert persona.name == "DataAnalyst" @@ -144,7 +144,7 @@ def test_get_persona_not_found_raises_error(self, client, httpx_mock: HTTPXMock) ) with pytest.raises(PersonaNotFoundError) as exc_info: - client.get_persona("NonExistentPersona") + client.personas.get("NonExistentPersona") assert exc_info.value.persona_name == "NonExistentPersona" assert exc_info.value.status_code == 404 @@ -159,7 +159,7 @@ async def test_aget_persona_returns_persona_info( json=sample_persona_info_dict, ) - persona = await async_client.aget_persona("DataAnalyst") + persona = await async_client.personas.aget("DataAnalyst") assert isinstance(persona, PersonaInfo) assert persona.name == "DataAnalyst" @@ -174,7 +174,7 @@ async def test_aget_persona_not_found_raises_error(self, async_client, httpx_moc ) with pytest.raises(PersonaNotFoundError) as exc_info: - await async_client.aget_persona("NonExistentPersona") + await async_client.personas.aget("NonExistentPersona") assert exc_info.value.persona_name == "NonExistentPersona" @@ -199,7 +199,7 @@ def test_create_persona_returns_persona_info( display_name="Data Analyst", ) - persona = client.create_persona(request) + persona = client.personas.create(request) assert isinstance(persona, PersonaInfo) assert persona.name == "DataAnalyst" @@ -227,7 +227,7 @@ def test_create_persona_sends_correct_body(self, client, httpx_mock: HTTPXMock): display_name="New Persona", ) - client.create_persona(request) + client.personas.create(request) http_request = httpx_mock.get_request() assert http_request.method == "POST" @@ -253,7 +253,7 @@ async def test_acreate_persona_returns_persona_info( prompt="You are a data analyst who helps users understand their data.", ) - persona = await async_client.acreate_persona(request) + persona = await async_client.personas.acreate(request) assert isinstance(persona, PersonaInfo) assert persona.name == "DataAnalyst" @@ -263,33 +263,33 @@ class TestAsyncClientRequirement: """Tests for async client requirement.""" def test_alist_personas_without_async_raises_error(self, client): - """alist_personas raises RuntimeError without async enabled.""" - with pytest.raises(RuntimeError) as exc_info: - import asyncio + """alist raises RuntimeError without async enabled.""" + import asyncio - asyncio.get_event_loop().run_until_complete(client.alist_personas()) + with pytest.raises(RuntimeError) as exc_info: + asyncio.run(client.personas.alist()) assert "enable_async=True" in str(exc_info.value) def test_aget_persona_without_async_raises_error(self, client): - """aget_persona raises RuntimeError without async enabled.""" - with pytest.raises(RuntimeError) as exc_info: - import asyncio + """aget raises RuntimeError without async enabled.""" + import asyncio - asyncio.get_event_loop().run_until_complete(client.aget_persona("test")) + with pytest.raises(RuntimeError) as exc_info: + asyncio.run(client.personas.aget("test")) assert "enable_async=True" in str(exc_info.value) def test_acreate_persona_without_async_raises_error(self, client): - """acreate_persona raises RuntimeError without async enabled.""" + """acreate raises RuntimeError without async enabled.""" + import asyncio + + request = CreatePersonaRequest( + name="Test", + description="Test", + prompt="Test", + ) with pytest.raises(RuntimeError) as exc_info: - import asyncio - - request = CreatePersonaRequest( - name="Test", - description="Test", - prompt="Test", - ) - asyncio.get_event_loop().run_until_complete(client.acreate_persona(request)) + asyncio.run(client.personas.acreate(request)) assert "enable_async=True" in str(exc_info.value) diff --git a/python/tests/test_streaming_timeout.py b/python/tests/test_streaming_timeout.py new file mode 100644 index 0000000..2bf888b --- /dev/null +++ b/python/tests/test_streaming_timeout.py @@ -0,0 +1,145 @@ +"""Verify that streaming HTTP calls do not inherit the global request timeout. + +The non-streaming `timeout` is meant to bound short JSON requests. SSE streams +can run for many minutes (the agent run itself drives the response duration); +the stream client must therefore use a no-read-timeout configuration so a +slow-streaming endpoint does not raise `ReadTimeout` mid-stream. +""" + +from __future__ import annotations + +import http.server +import socket +import threading +import time +from collections.abc import Iterator + +import httpx +import pytest + +from ai_sdk._http import AsyncHTTPClient, HTTPClient +from ai_sdk.auth import TokenAuth + + +def _free_port() -> int: + """Return an OS-assigned free TCP port on localhost.""" + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +class _SlowSSEHandler(http.server.BaseHTTPRequestHandler): + """HTTP handler that emits SSE events spread over ~2 seconds.""" + + # Configurable on the server instance to keep the test fast but slower than `timeout`. + pre_event_delay: float = 1.5 + inter_event_delay: float = 0.3 + + def do_POST(self) -> None: + # Build the payload up-front so we can advertise a Content-Length, + # which lets httpx detect a clean end-of-stream regardless of how + # the underlying socket is torn down at test teardown. + payload = ( + b'event: message\ndata: {"content": "first"}\n\n' + b'event: message\ndata: {"content": "second"}\n\n' + ) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(payload))) + self.send_header("Connection", "close") + self.end_headers() + # Wait long enough that a 1s read timeout would fire BEFORE the first + # byte of the body arrives. Swallow connection errors when the client + # has already moved on (test teardown closes the connection). + try: + time.sleep(self.pre_event_delay) + self.wfile.write(payload[: len(payload) // 2]) + self.wfile.flush() + time.sleep(self.inter_event_delay) + self.wfile.write(payload[len(payload) // 2 :]) + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + return + + def log_message(self, *args: object) -> None: # type: ignore[override] + # Silence default logging during tests. + return + + +@pytest.fixture +def slow_sse_server() -> Iterator[str]: + """Spawn a localhost HTTP server that emits SSE events slowly.""" + port = _free_port() + server = http.server.ThreadingHTTPServer(("127.0.0.1", port), _SlowSSEHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{port}" + finally: + server.shutdown() + server.server_close() + + +def _collect_content(chunks: Iterator[bytes]) -> bytes: + """Concatenate streamed chunks into one bytes object.""" + return b"".join(chunks) + + +class TestSyncStreamTimeout: + """Sync streaming should ignore the configured (short) request timeout.""" + + def test_post_stream_completes_despite_short_timeout(self, slow_sse_server: str) -> None: + client = HTTPClient( + base_url=slow_sse_server, + auth=TokenAuth("test-token"), + timeout=1.0, + max_retries=0, + ) + try: + body = _collect_content(client.post_stream("/", json={})) + finally: + client.close() + + assert b'"first"' in body + assert b'"second"' in body + + def test_non_stream_post_does_use_short_timeout(self, slow_sse_server: str) -> None: + """Sanity check: non-streaming POST against the same slow endpoint + DOES time out — confirming the streaming path is the special case, + not the server.""" + client = HTTPClient( + base_url=slow_sse_server, + auth=TokenAuth("test-token"), + timeout=1.0, + max_retries=0, + ) + try: + with pytest.raises(httpx.ReadTimeout): + client.post("/", json={}) + finally: + client.close() + + +class TestAsyncStreamTimeout: + """Async streaming should also ignore the configured (short) request timeout.""" + + @pytest.mark.asyncio + async def test_post_stream_completes_despite_short_timeout(self, slow_sse_server: str) -> None: + client = AsyncHTTPClient( + base_url=slow_sse_server, + auth=TokenAuth("test-token"), + timeout=1.0, + max_retries=0, + ) + try: + chunks: list[bytes] = [] + async for chunk in client.post_stream("/", json={}): + chunks.append(chunk) + finally: + await client.close() + + body = b"".join(chunks) + assert b'"first"' in body + assert b'"second"' in body diff --git a/python/tests/test_url_encoding.py b/python/tests/test_url_encoding.py index 9a771d0..fee4b14 100644 --- a/python/tests/test_url_encoding.py +++ b/python/tests/test_url_encoding.py @@ -11,7 +11,7 @@ def test_get_bot_encodes_name_with_slash(): client._bots_http = MagicMock() client._bots_http.get.return_value = {"id": "123", "name": "test/bot"} - client.get_bot("test/bot") + client.bots.get("test/bot") client._bots_http.get.assert_called_once() call_args = client._bots_http.get.call_args @@ -25,7 +25,7 @@ def test_get_bot_encodes_name_with_spaces(): client._bots_http = MagicMock() client._bots_http.get.return_value = {"id": "123", "name": "my bot"} - client.get_bot("my bot") + client.bots.get("my bot") client._bots_http.get.assert_called_once() call_args = client._bots_http.get.call_args @@ -43,7 +43,7 @@ def test_get_persona_encodes_name_with_special_chars(): "provider": "user", } - client.get_persona("test&persona") + client.personas.get("test&persona") client._personas_http.get.assert_called_once() call_args = client._personas_http.get.call_args diff --git a/typescript/README.md b/typescript/README.md index 8866619..03f454e 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -21,7 +21,10 @@ const client = new AISdk({ token: 'your-bot-jwt-token', }); -// Invoke an agent +// default AskCollate agent +const defaultResponse = await client.agent().invoke('What tables have quality issues?'); + +// Named dynamic agent const response = await client.agent('DataQualityPlannerAgent') .invoke('What tables have quality issues?'); @@ -86,7 +89,7 @@ console.log(r2.response); ### List Available Agents ```typescript -const agents = await client.listAgents(); +const agents = await client.agents.list(); for (const agent of agents) { console.log(`${agent.displayName}: ${agent.description}`); @@ -94,7 +97,7 @@ for (const agent of agents) { } // With pagination -const moreAgents = await client.listAgents({ limit: 20, offset: 10 }); +const moreAgents = await client.agents.list({ limit: 20 }); ``` ### Get Agent Information @@ -112,10 +115,11 @@ console.log('API Enabled:', info.apiEnabled); ### Create Agents ```typescript -const newAgent = await client.createAgent({ +const newAgent = await client.agents.create({ name: 'MyCustomAgent', description: 'A custom agent for data analysis', persona: 'DataAnalyst', + mode: 'chat', apiEnabled: true, abilities: ['search', 'query'], }); @@ -127,13 +131,13 @@ console.log('Created agent:', newAgent.name); ```typescript // List all bots -const bots = await client.listBots(); +const bots = await client.bots.list(); for (const bot of bots) { console.log(`${bot.name}: ${bot.displayName}`); } // Get a specific bot -const bot = await client.getBot('my-bot-name'); +const bot = await client.bots.get('my-bot-name'); console.log('Bot:', bot.name); ``` @@ -141,16 +145,16 @@ console.log('Bot:', bot.name); ```typescript // List all personas -const personas = await client.listPersonas(); +const personas = await client.personas.list(); for (const persona of personas) { console.log(`${persona.name}: ${persona.description}`); } // Get a specific persona -const persona = await client.getPersona('DataAnalyst'); +const persona = await client.personas.get('DataAnalyst'); // Create a new persona -const newPersona = await client.createPersona({ +const newPersona = await client.personas.create({ name: 'CustomAnalyst', description: 'A specialized data analyst', prompt: 'You are an expert data analyst who helps users understand their data...', @@ -162,16 +166,51 @@ console.log('Created persona:', newPersona.name); ```typescript // List all abilities -const abilities = await client.listAbilities(); +const abilities = await client.abilities.list(); for (const ability of abilities) { console.log(`${ability.name}: ${ability.description}`); } // Get a specific ability -const ability = await client.getAbility('search'); +const ability = await client.abilities.get('search'); console.log('Ability:', ability.name); ``` +### Context Memories + +```typescript +// List memories +const memories = await client.memories.list(); + +// Filter by primary entity +const tableMemories = await client.memories.list({ + primaryEntityFqn: 'service.db.schema.tbl', +}); + +// Get one +const memory = await client.memories.get(memories[0].id); + +// Create +const created = await client.memories.create({ + name: 'preferred-tooling', + question: 'Which tool should I use for transformations?', + answer: 'Use dbt for SQL-first transformations.', + memoryType: 'Preference', + visibility: 'Shared', + tags: ['Preference.Tooling'], +}); + +// Hybrid NLQ search +const results = await client.memories.search('customer churn', { size: 10 }); +for (const hit of results.hits) { + console.log(`[${hit.score.toFixed(2)}] ${hit.memory.title}`); +} + +// Delete (soft delete by default) +await client.memories.delete(created.id); +await client.memories.delete(created.id, { hardDelete: true }); +``` + ## Configuration Options ```typescript @@ -181,7 +220,9 @@ const client = new AISdk({ token: 'your-jwt-token', // Bot JWT token // Optional - timeout: 120000, // Request timeout in ms (default: 120000) + timeout: 900000, // Non-streaming request timeout in ms (default: 900000). + // Streaming (`stream` / `streamContent`) ignores this and runs + // until the server closes the stream. maxRetries: 3, // Max retry attempts (default: 3) retryDelay: 1000, // Base retry delay in ms (default: 1000) }); diff --git a/typescript/package-lock.json b/typescript/package-lock.json index ad100e2..687337b 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openmetadata/ai-sdk", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openmetadata/ai-sdk", - "version": "0.1.0", + "version": "0.2.0", "license": "SEE LICENSE IN LICENSE", "devDependencies": { "@types/node": "^20.0.0", diff --git a/typescript/package.json b/typescript/package.json index ad6c05c..5695787 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@openmetadata/ai-sdk", - "version": "0.1.2", + "version": "0.2.0", "description": "OpenMetadata AI SDK for Node.js - Interact with OpenMetadata Dynamic Agents", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/typescript/src/agent.ts b/typescript/src/agent.ts index 9bea64a..db5cd2d 100644 --- a/typescript/src/agent.ts +++ b/typescript/src/agent.ts @@ -20,11 +20,12 @@ import { createStreamIterable } from './streaming.js'; /** * Convert API invoke response to InvokeResponse. */ -function mapInvokeResponse(data: ApiInvokeResponse): InvokeResponse { +export function mapInvokeResponse(data: ApiInvokeResponse): InvokeResponse { return { conversationId: data.conversationId, response: data.response, toolsUsed: data.toolsUsed || [], + thinkingSteps: data.thinkingSteps || [], usage: data.usage ? { promptTokens: data.usage.promptTokens || 0, @@ -267,3 +268,146 @@ export class AgentHandle { return `AgentHandle(name="${this.agentName}")`; } } + +const DEFAULT_AGENT_TYPE = 'PLANNER'; +const DEFAULT_AGENT_MODE = 'CHAT_MODE'; +const TITLE_MAX_LEN = 50; + +interface DefaultAgentInvokeBody { + message: string; + conversationId: string; + agentType: string; + agentMode: string; +} + +/** + * Handle for the platform's default agent. + * + * Auto-creates a chat conversation when one is not supplied, then invokes + * /v1/agents/invoke (sync) or /v1/agents/run (SSE) with agentType=PLANNER, + * agentMode=CHAT_MODE. + * + * @example + * ```typescript + * // Simple invocation (auto-creates a conversation) + * const response = await client.agent().invoke('Hello!'); + * console.log(response.response); + * + * // Reuse an existing conversation + * const r1 = await client.agent().invoke('Start conversation'); + * const r2 = await client.agent().invoke('Continue', { + * conversationId: r1.conversationId, + * }); + * + * // Streaming + * for await (const event of client.agent().stream('Hello!')) { + * if (event.type === 'content') { + * process.stdout.write(event.content || ''); + * } + * } + * ``` + */ +export class DefaultAgentHandle { + private readonly defaultHttp: HttpClient; + private readonly chatConvHttp: HttpClient; + + /** + * Create a new default agent handle. + * + * @param defaultHttp - HTTP client for /api/v1/agents endpoints + * @param chatConvHttp - HTTP client for /api/v1/assistants endpoints + * @internal This constructor is called by AISdk.agent() + */ + constructor(defaultHttp: HttpClient, chatConvHttp: HttpClient) { + this.defaultHttp = defaultHttp; + this.chatConvHttp = chatConvHttp; + } + + /** + * Get the agent name (always ''). + */ + get name(): string { + return ''; + } + + private async createConversation(title: string): Promise { + const body = { title: title.slice(0, TITLE_MAX_LEN) }; + const data = await this.chatConvHttp.post<{ id: string }>( + '/chatConversations', + body + ); + return data.id; + } + + private buildPayload( + message: string | undefined, + conversationId: string + ): DefaultAgentInvokeBody { + return { + message: message ?? '', + conversationId, + agentType: DEFAULT_AGENT_TYPE, + agentMode: DEFAULT_AGENT_MODE, + }; + } + + /** + * Invoke the default agent synchronously. + * + * @param message - Optional query or instruction for the agent + * @param options - Optional invoke options (conversationId) + * @returns Promise resolving to the complete response + */ + async invoke(message?: string, options?: InvokeOptions): Promise { + const conversationId = + options?.conversationId ?? + (await this.createConversation(message ?? 'New conversation')); + const payload = this.buildPayload(message, conversationId); + const data = await this.defaultHttp.post('/invoke', payload); + return mapInvokeResponse(data); + } + + /** + * Invoke the default agent with streaming response. + * + * @param message - Optional query or instruction for the agent + * @param options - Optional invoke options (conversationId) + * @returns Async iterable of stream events + */ + async *stream( + message?: string, + options?: InvokeOptions + ): AsyncGenerator { + const conversationId = + options?.conversationId ?? + (await this.createConversation(message ?? 'New conversation')); + const payload = this.buildPayload(message, conversationId); + const byteStream = await this.defaultHttp.postStream('/run', payload); + yield* createStreamIterable(byteStream); + } + + /** + * Stream only the text content from the default agent response. + * + * @param message - Optional query or instruction for the agent + * @param options - Optional invoke options (conversationId) + * @returns Async iterable of content strings + */ + async *streamContent( + message?: string, + options?: InvokeOptions + ): AsyncGenerator { + for await (const event of this.stream(message, options)) { + if (event.type === 'content' && event.content) { + yield event.content; + } + } + } + + /** + * String representation of the default agent handle. + */ + toString(): string { + return 'DefaultAgentHandle()'; + } +} diff --git a/typescript/src/api/abilities.ts b/typescript/src/api/abilities.ts new file mode 100644 index 0000000..38cf8ae --- /dev/null +++ b/typescript/src/api/abilities.ts @@ -0,0 +1,122 @@ +/** + * Abilities namespace. + * + * Backed by an HttpClient rooted at /api/v1/agents/abilities. + */ + +import type { HttpClient } from '../http.js'; +import type { PaginatedResponse } from '../models.js'; +import type { AbilityInfo } from '../types.js'; + +/** + * Options for list operations on the abilities namespace. + */ +export interface AbilitiesListOptions { + /** Maximum number of abilities to return. If omitted, returns all abilities. */ + limit?: number; +} + +/** + * API response for an ability. + * @internal + */ +interface ApiAbilityInfo { + id: string; + name: string; + displayName?: string; + description?: string; + provider?: string; + fullyQualifiedName?: string; + tools?: string[]; +} + +function mapAbilityInfo(data: ApiAbilityInfo): AbilityInfo { + return { + id: data.id, + name: data.name, + displayName: data.displayName, + description: data.description, + provider: data.provider, + fullyQualifiedName: data.fullyQualifiedName, + tools: data.tools || [], + }; +} + +/** + * Namespace for ability operations. + */ +export class AbilitiesApi { + private readonly http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + /** + * List all abilities. + * + * Automatically paginates through all results. + */ + async list(options?: AbilitiesListOptions): Promise { + return paginate( + (params) => this.http.get>('/', params), + mapAbilityInfo, + options?.limit + ); + } + + /** + * Get an ability by name. + * + * @throws {AbilityNotFoundError} If the ability is not found. + */ + async get(name: string): Promise { + const response = await this.http.get( + `/name/${encodeURIComponent(name)}`, + undefined, + undefined, + 'ability', + name + ); + return mapAbilityInfo(response); + } +} + +/** + * Paginate through all results from a list endpoint. + * @internal + */ +async function paginate( + fetcher: ( + params: Record + ) => Promise>, + mapper: (item: T) => U, + limit?: number, + pageSize: number = 100 +): Promise { + const results: U[] = []; + let after: string | undefined = undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + const params: Record = { limit: pageSize }; + if (after) { + params.after = after; + } + + const response = await fetcher(params); + const data = response.data || []; + results.push(...data.map(mapper)); + + if (limit !== undefined && results.length >= limit) { + return results.slice(0, limit); + } + + after = response.paging?.after; + if (!after) { + break; + } + } + + return results; +} diff --git a/typescript/src/api/agents.ts b/typescript/src/api/agents.ts new file mode 100644 index 0000000..432ce84 --- /dev/null +++ b/typescript/src/api/agents.ts @@ -0,0 +1,132 @@ +/** + * Agents namespace — CRUD for dynamic agents. + * + * Backed by an HttpClient rooted at /api/v1/agents/dynamic. + */ + +import type { HttpClient } from '../http.js'; +import { + mapAgentInfo, + type AgentInfo, + type ApiAgentInfo, + type PaginatedResponse, +} from '../models.js'; +import type { CreateAgentRequest } from '../types.js'; +import type { AISdk } from '../client.js'; + +/** + * Options for list operations on the agents namespace. + */ +export interface AgentsListOptions { + /** Maximum number of agents to return. If omitted, returns all agents. */ + limit?: number; +} + +/** + * Namespace for dynamic agent CRUD operations. + * + * Holds a back-reference to AISdk so that {@link create} can resolve + * persona/ability names to entity references through the personas/abilities + * namespaces. + */ +export class AgentsApi { + private readonly http: HttpClient; + private readonly client: AISdk; + + constructor(http: HttpClient, client: AISdk) { + this.http = http; + this.client = client; + } + + /** + * List all API-enabled dynamic agents. + * + * Automatically paginates through all results. + */ + async list(options?: AgentsListOptions): Promise { + return paginate( + (params) => + this.http.get>('/', { ...params, apiEnabled: 'true' }), + mapAgentInfo, + options?.limit + ); + } + + /** + * Create a new dynamic agent. + * + * Resolves the persona name and any ability names to entity references + * via {@link AISdk.personas} and {@link AISdk.abilities} before issuing + * the create request. + */ + async create(request: CreateAgentRequest): Promise { + const personaInfo = await this.client.personas.get(request.persona); + + let abilityRefs: Array<{ id: string; type: string }> | undefined; + if (request.abilities && request.abilities.length > 0) { + abilityRefs = []; + for (const abilityName of request.abilities) { + const abilityInfo = await this.client.abilities.get(abilityName); + abilityRefs.push({ id: abilityInfo.id, type: 'ability' }); + } + } + + const body = { + name: request.name, + description: request.description, + persona: { id: personaInfo.id, type: 'persona' }, + mode: request.mode, + displayName: request.displayName, + icon: request.icon, + botName: request.botName, + abilities: abilityRefs, + knowledge: request.knowledge, + prompt: request.prompt, + schedule: request.schedule, + apiEnabled: request.apiEnabled ?? false, + provider: request.provider ?? 'user', + }; + + const response = await this.http.post('/', body); + return mapAgentInfo(response); + } +} + +/** + * Paginate through all results from a list endpoint. + * @internal + */ +async function paginate( + fetcher: ( + params: Record + ) => Promise>, + mapper: (item: T) => U, + limit?: number, + pageSize: number = 100 +): Promise { + const results: U[] = []; + let after: string | undefined = undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + const params: Record = { limit: pageSize }; + if (after) { + params.after = after; + } + + const response = await fetcher(params); + const data = response.data || []; + results.push(...data.map(mapper)); + + if (limit !== undefined && results.length >= limit) { + return results.slice(0, limit); + } + + after = response.paging?.after; + if (!after) { + break; + } + } + + return results; +} diff --git a/typescript/src/api/bots.ts b/typescript/src/api/bots.ts new file mode 100644 index 0000000..97d00b8 --- /dev/null +++ b/typescript/src/api/bots.ts @@ -0,0 +1,118 @@ +/** + * Bots namespace. + * + * Backed by an HttpClient rooted at /api/v1/bots. + */ + +import type { HttpClient } from '../http.js'; +import type { PaginatedResponse } from '../models.js'; +import type { BotInfo, EntityReference } from '../types.js'; + +/** + * Options for list operations on the bots namespace. + */ +export interface BotsListOptions { + /** Maximum number of bots to return. If omitted, returns all bots. */ + limit?: number; +} + +/** + * API response for a bot. + * @internal + */ +interface ApiBotInfo { + id: string; + name: string; + displayName?: string; + description?: string; + botUser?: EntityReference; +} + +function mapBotInfo(data: ApiBotInfo): BotInfo { + return { + id: data.id, + name: data.name, + displayName: data.displayName, + description: data.description, + botUser: data.botUser, + }; +} + +/** + * Namespace for bot operations. + */ +export class BotsApi { + private readonly http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + /** + * List all bots. + * + * Automatically paginates through all results. + */ + async list(options?: BotsListOptions): Promise { + return paginate( + (params) => this.http.get>('/', params), + mapBotInfo, + options?.limit + ); + } + + /** + * Get a bot by name. + * + * @throws {BotNotFoundError} If the bot is not found. + */ + async get(name: string): Promise { + const response = await this.http.get( + `/name/${encodeURIComponent(name)}`, + undefined, + undefined, + 'bot', + name + ); + return mapBotInfo(response); + } +} + +/** + * Paginate through all results from a list endpoint. + * @internal + */ +async function paginate( + fetcher: ( + params: Record + ) => Promise>, + mapper: (item: T) => U, + limit?: number, + pageSize: number = 100 +): Promise { + const results: U[] = []; + let after: string | undefined = undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + const params: Record = { limit: pageSize }; + if (after) { + params.after = after; + } + + const response = await fetcher(params); + const data = response.data || []; + results.push(...data.map(mapper)); + + if (limit !== undefined && results.length >= limit) { + return results.slice(0, limit); + } + + after = response.paging?.after; + if (!after) { + break; + } + } + + return results; +} diff --git a/typescript/src/api/index.ts b/typescript/src/api/index.ts new file mode 100644 index 0000000..6859d44 --- /dev/null +++ b/typescript/src/api/index.ts @@ -0,0 +1,17 @@ +/** + * API namespace classes for the AI SDK. + * + * Each module exposes a small class holding the HttpClient(s) it needs and + * verb methods (list/get/create/delete/etc.). The top-level AISdk wires + * these together and exposes them as readonly fields. + */ + +export { AbilitiesApi, type AbilitiesListOptions } from './abilities.js'; +export { AgentsApi, type AgentsListOptions } from './agents.js'; +export { BotsApi, type BotsListOptions } from './bots.js'; +export { + MemoriesApi, + type MemoriesListOptions, + type MemoriesSearchOptions, +} from './memories.js'; +export { PersonasApi, type PersonasListOptions } from './personas.js'; diff --git a/typescript/src/api/memories.ts b/typescript/src/api/memories.ts new file mode 100644 index 0000000..31ce472 --- /dev/null +++ b/typescript/src/api/memories.ts @@ -0,0 +1,261 @@ +/** + * Memories namespace — CRUD + hybrid search for Context Center memories. + * + * Backed by two HttpClients: + * 1. memoriesHttp rooted at /api/v1/contextCenter/memories (CRUD). + * 2. searchHttp rooted at /api/v1, used for /hybrid/nlq/search. + */ + +import type { HttpClient } from '../http.js'; +import type { PaginatedResponse } from '../models.js'; +import type { + ContextMemory, + CreateContextMemoryRequest, + EntityReference, + MemoryScope, + MemorySearchHit, + MemorySearchResults, + MemoryType, + MemoryVisibility, +} from '../types.js'; + +/** + * Options for {@link MemoriesApi.list}. + */ +export interface MemoriesListOptions { + /** Filter to memories attached to this primary entity FQN. */ + primaryEntityFqn?: string; + /** Maximum number of memories to return. If omitted, returns all. */ + limit?: number; +} + +/** + * Options for {@link MemoriesApi.search}. + */ +export interface MemoriesSearchOptions { + /** + * Field filters. Map of field name -> list of values + * (e.g. `{ primaryEntityId: ["abc"], visibility: ["Shared"] }`). + */ + filters?: Record; + /** Number of results to return (1-100, default: 15). */ + size?: number; + /** Pagination offset (default: 0). */ + from?: number; +} + +/** + * Raw API shape for a memory document. Captures only the fields the SDK + * surfaces; everything else is ignored. + * @internal + */ +interface ApiContextMemory { + id: string; + name: string; + fullyQualifiedName?: string; + title?: string; + question?: string; + answer?: string; + summary?: string; + memoryType?: MemoryType; + memoryScope?: MemoryScope; + shareConfig?: { + visibility?: MemoryVisibility; + }; + primaryEntity?: EntityReference; + usageCount?: number; + lastUsedAt?: number; + deleted?: boolean; +} + +/** + * Raw OpenSearch-style response for the hybrid NLQ search endpoint. + * @internal + */ +interface ApiSearchResponse { + hits?: { + total?: number | { value?: number }; + hits?: Array<{ + _source?: ApiContextMemory; + _score?: number; + }>; + }; +} + +function mapContextMemory(data: ApiContextMemory): ContextMemory { + const visibility: MemoryVisibility = data.shareConfig?.visibility ?? 'Private'; + return { + id: data.id, + name: data.name, + fullyQualifiedName: data.fullyQualifiedName, + title: data.title, + question: data.question ?? '', + answer: data.answer ?? '', + summary: data.summary, + memoryType: data.memoryType ?? 'Note', + memoryScope: data.memoryScope ?? 'EntityScoped', + visibility, + primaryEntity: data.primaryEntity, + usageCount: data.usageCount ?? 0, + lastUsedAt: data.lastUsedAt, + deleted: data.deleted ?? false, + }; +} + +function buildCreateBody(request: CreateContextMemoryRequest): Record { + const body: Record = { + name: request.name, + question: request.question, + answer: request.answer, + memoryType: request.memoryType ?? 'Note', + memoryScope: request.memoryScope ?? 'EntityScoped', + shareConfig: { visibility: request.visibility ?? 'Private' }, + }; + if (request.title !== undefined) { + body.title = request.title; + } + if (request.description !== undefined) { + body.description = request.description; + } + if (request.primaryEntity !== undefined) { + body.primaryEntity = request.primaryEntity; + } + if (request.relatedEntities !== undefined) { + body.relatedEntities = request.relatedEntities; + } + if (request.tags !== undefined) { + body.tags = request.tags.map((tagFqn) => ({ + tagFQN: tagFqn, + labelType: 'Manual', + state: 'Confirmed', + source: 'Classification', + })); + } + return body; +} + +function mapSearchResults(data: ApiSearchResponse): MemorySearchResults { + const hitsBlock = data.hits ?? {}; + const totalRaw = hitsBlock.total ?? 0; + const total = + typeof totalRaw === 'number' ? totalRaw : Number(totalRaw.value ?? 0); + const rawHits = hitsBlock.hits ?? []; + const hits: MemorySearchHit[] = rawHits.map((hit) => ({ + memory: mapContextMemory(hit._source ?? ({} as ApiContextMemory)), + score: Number(hit._score ?? 0), + })); + return { total, hits }; +} + +/** + * Namespace for Context Center memory operations. + */ +export class MemoriesApi { + private readonly memoriesHttp: HttpClient; + private readonly searchHttp: HttpClient; + + constructor(memoriesHttp: HttpClient, searchHttp: HttpClient) { + this.memoriesHttp = memoriesHttp; + this.searchHttp = searchHttp; + } + + /** + * List Context Center memories. + * + * Automatically paginates through all results. + */ + async list(options?: MemoriesListOptions): Promise { + return paginateMemories( + this.memoriesHttp, + options?.primaryEntityFqn, + options?.limit + ); + } + + /** + * Get a memory by ID. + */ + async get(memoryId: string): Promise { + const response = await this.memoriesHttp.get(`/${memoryId}`); + return mapContextMemory(response); + } + + /** + * Create a new Context Center memory. + */ + async create(request: CreateContextMemoryRequest): Promise { + const body = buildCreateBody(request); + const response = await this.memoriesHttp.post('/', body); + return mapContextMemory(response); + } + + /** + * Delete a memory by ID. Soft delete by default; pass `hardDelete: true` + * to permanently remove. + */ + async delete(memoryId: string, options?: { hardDelete?: boolean }): Promise { + await this.memoriesHttp.delete(`/${memoryId}`, { + hardDelete: options?.hardDelete ?? false, + }); + } + + /** + * Hybrid NLQ search over Context Center memories. + */ + async search(query: string, options?: MemoriesSearchOptions): Promise { + const params: Record = { + q: query, + index: 'contextMemory', + size: options?.size ?? 15, + from: options?.from ?? 0, + }; + if (options?.filters) { + params.filters = JSON.stringify(options.filters); + } + const response = await this.searchHttp.get( + '/hybrid/nlq/search', + params + ); + return mapSearchResults(response); + } +} + +/** + * Paginate through memory list results. + * @internal + */ +async function paginateMemories( + http: HttpClient, + primaryEntityFqn: string | undefined, + limit: number | undefined, + pageSize: number = 100 +): Promise { + const results: ContextMemory[] = []; + let after: string | undefined = undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + const params: Record = { limit: pageSize }; + if (primaryEntityFqn !== undefined) { + params.primaryEntityFqn = primaryEntityFqn; + } + if (after) { + params.after = after; + } + + const response = await http.get>('/', params); + const data = response.data || []; + results.push(...data.map(mapContextMemory)); + + if (limit !== undefined && results.length >= limit) { + return results.slice(0, limit); + } + + after = response.paging?.after; + if (!after) { + break; + } + } + + return results; +} diff --git a/typescript/src/api/personas.ts b/typescript/src/api/personas.ts new file mode 100644 index 0000000..c4e3d97 --- /dev/null +++ b/typescript/src/api/personas.ts @@ -0,0 +1,137 @@ +/** + * Personas namespace. + * + * Backed by an HttpClient rooted at /api/v1/agents/personas. + */ + +import type { HttpClient } from '../http.js'; +import type { PaginatedResponse } from '../models.js'; +import type { CreatePersonaRequest, PersonaInfo } from '../types.js'; + +/** + * Options for list operations on the personas namespace. + */ +export interface PersonasListOptions { + /** Maximum number of personas to return. If omitted, returns all personas. */ + limit?: number; +} + +/** + * API response for a persona. + * @internal + */ +interface ApiPersonaInfo { + id: string; + name: string; + displayName?: string; + description?: string; + prompt?: string; + provider: string; +} + +function mapPersonaInfo(data: ApiPersonaInfo): PersonaInfo { + return { + id: data.id, + name: data.name, + displayName: data.displayName, + description: data.description, + prompt: data.prompt, + provider: data.provider, + }; +} + +/** + * Namespace for persona operations. + */ +export class PersonasApi { + private readonly http: HttpClient; + + constructor(http: HttpClient) { + this.http = http; + } + + /** + * List all personas. + * + * Automatically paginates through all results. + */ + async list(options?: PersonasListOptions): Promise { + return paginate( + (params) => this.http.get>('/', params), + mapPersonaInfo, + options?.limit + ); + } + + /** + * Get a persona by name. + * + * @throws {PersonaNotFoundError} If the persona is not found. + */ + async get(name: string): Promise { + const response = await this.http.get( + `/name/${encodeURIComponent(name)}`, + undefined, + undefined, + 'persona', + name + ); + return mapPersonaInfo(response); + } + + /** + * Create a new persona. + */ + async create(request: CreatePersonaRequest): Promise { + const body = { + name: request.name, + description: request.description, + prompt: request.prompt, + displayName: request.displayName, + provider: request.provider ?? 'user', + owners: request.owners, + }; + + const response = await this.http.post('/', body); + return mapPersonaInfo(response); + } +} + +/** + * Paginate through all results from a list endpoint. + * @internal + */ +async function paginate( + fetcher: ( + params: Record + ) => Promise>, + mapper: (item: T) => U, + limit?: number, + pageSize: number = 100 +): Promise { + const results: U[] = []; + let after: string | undefined = undefined; + + // eslint-disable-next-line no-constant-condition + while (true) { + const params: Record = { limit: pageSize }; + if (after) { + params.after = after; + } + + const response = await fetcher(params); + const data = response.data || []; + results.push(...data.map(mapper)); + + if (limit !== undefined && results.length >= limit) { + return results.slice(0, limit); + } + + after = response.paging?.after; + if (!after) { + break; + } + } + + return results; +} diff --git a/typescript/src/client.ts b/typescript/src/client.ts index 26ad712..0b20e9d 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -5,25 +5,17 @@ * for interacting with OpenMetadata Dynamic Agents. */ -import { AgentHandle } from './agent.js'; +import { AgentHandle, DefaultAgentHandle } from './agent.js'; +import { AbilitiesApi } from './api/abilities.js'; +import { AgentsApi } from './api/agents.js'; +import { BotsApi } from './api/bots.js'; +import { MemoriesApi } from './api/memories.js'; +import { PersonasApi } from './api/personas.js'; import { HttpClient } from './http.js'; -import { - mapAgentInfo, - type AgentInfo, - type ApiAgentInfo, - type AISdkOptions, - type PaginatedResponse, -} from './models.js'; -import type { - AbilityInfo, - BotInfo, - PersonaInfo, - CreatePersonaRequest, - CreateAgentRequest, -} from './types.js'; +import type { AISdkOptions } from './models.js'; -/** Default timeout in milliseconds (2 minutes) */ -const DEFAULT_TIMEOUT = 120000; +/** Default timeout in milliseconds (15 minutes) for non-streaming requests */ +const DEFAULT_TIMEOUT = 900000; /** Default number of retry attempts */ const DEFAULT_MAX_RETRIES = 3; @@ -31,138 +23,69 @@ const DEFAULT_MAX_RETRIES = 3; /** Default base delay between retries in milliseconds */ const DEFAULT_RETRY_DELAY = 1000; -/** - * API response for bot info. - * @internal - */ -interface ApiBotInfo { - id: string; - name: string; - displayName?: string; - description?: string; - botUser?: { - id: string; - type: string; - name?: string; - displayName?: string; - }; -} - -/** - * API response for persona info. - * @internal - */ -interface ApiPersonaInfo { - id: string; - name: string; - displayName?: string; - description?: string; - prompt?: string; - provider: string; -} - -/** - * API response for ability info. - * @internal - */ -interface ApiAbilityInfo { - id: string; - name: string; - displayName?: string; - description?: string; - provider?: string; - fullyQualifiedName?: string; - tools?: string[]; -} - -/** - * Convert API bot info response to BotInfo. - */ -function mapBotInfo(data: ApiBotInfo): BotInfo { - return { - id: data.id, - name: data.name, - displayName: data.displayName, - description: data.description, - botUser: data.botUser, - }; -} - -/** - * Convert API persona info response to PersonaInfo. - */ -function mapPersonaInfo(data: ApiPersonaInfo): PersonaInfo { - return { - id: data.id, - name: data.name, - displayName: data.displayName, - description: data.description, - prompt: data.prompt, - provider: data.provider, - }; -} - -/** - * Convert API ability info response to AbilityInfo. - */ -function mapAbilityInfo(data: ApiAbilityInfo): AbilityInfo { - return { - id: data.id, - name: data.name, - displayName: data.displayName, - description: data.description, - provider: data.provider, - fullyQualifiedName: data.fullyQualifiedName, - tools: data.tools || [], - }; -} - /** * Main client for interacting with Metadata AI agents. * * This client provides access to OpenMetadata Dynamic Agents, enabling you to * leverage semantic intelligence capabilities in your applications. * + * Entity CRUD operations live on per-entity namespaces: + * + * ```typescript + * client.agents.list(); + * client.bots.get('ingestion-bot'); + * client.personas.create(req); + * client.abilities.list(); + * client.memories.search('customer churn'); + * ``` + * + * The agent handle factory is unchanged: + * + * ```typescript + * client.agent('DataQualityPlannerAgent').invoke('Hello'); + * client.agent().invoke('Hello'); // platform default agent + * ``` + * * @example * ```typescript * import { AISdk } from '@openmetadata/ai-sdk'; * - * // Initialize the client * const client = new AISdk({ * host: 'https://openmetadata.example.com', * token: 'your-bot-jwt-token', * }); * - * // Get an agent handle - * const agent = client.agent('DataQualityPlannerAgent'); - * - * // Invoke synchronously - * const response = await agent.invoke('Analyze the customers table'); + * const response = await client.agent('DataQualityPlannerAgent') + * .invoke('Analyze the customers table'); * console.log(response.response); * - * // Or stream the response - * for await (const event of agent.stream('Analyze the customers table')) { - * if (event.type === 'content') { - * process.stdout.write(event.content || ''); - * } - * } - * - * // Multi-turn conversations - * const r1 = await agent.invoke('Analyze the orders table'); - * const r2 = await agent.invoke('Now create tests for the issues you found', { - * conversationId: r1.conversationId, - * }); - * - * // List available agents - * const agents = await client.listAgents(); - * for (const agentInfo of agents) { - * console.log(`${agentInfo.displayName}: ${agentInfo.description}`); + * const agents = await client.agents.list(); + * for (const info of agents) { + * console.log(`${info.displayName}: ${info.description}`); * } * ``` */ export class AISdk { private readonly hostUrl: string; - private readonly http: HttpClient; + private readonly agentsHttp: HttpClient; + private readonly botsHttp: HttpClient; + private readonly personasHttp: HttpClient; + private readonly abilitiesHttp: HttpClient; + private readonly memoriesHttp: HttpClient; + private readonly searchHttp: HttpClient; + private readonly defaultAgentHttp: HttpClient; + private readonly chatConvHttp: HttpClient; + + /** Namespace for dynamic agent CRUD operations. */ + public readonly agents: AgentsApi; + /** Namespace for bot operations. */ + public readonly bots: BotsApi; + /** Namespace for persona operations. */ + public readonly personas: PersonasApi; + /** Namespace for ability operations. */ + public readonly abilities: AbilitiesApi; + /** Namespace for Context Center memory operations (CRUD + hybrid search). */ + public readonly memories: MemoriesApi; /** * Create a new AISdk client. @@ -173,19 +96,9 @@ export class AISdk { * * @example * ```typescript - * // Basic initialization - * const client = new AISdk({ - * host: 'https://openmetadata.example.com', - * token: 'your-jwt-token', - * }); - * - * // With custom options * const client = new AISdk({ * host: 'https://openmetadata.example.com', * token: 'your-jwt-token', - * timeout: 60000, // 60 seconds - * maxRetries: 5, // More retries - * retryDelay: 2000, // Start with 2 second delay * }); * ``` */ @@ -200,14 +113,59 @@ export class AISdk { // Normalize host URL (remove trailing slash) this.hostUrl = options.host.replace(/\/$/, ''); - // Create HTTP client with base URL for agents API (consolidated endpoint) - this.http = new HttpClient({ - baseUrl: `${this.hostUrl}/api/v1/agents/dynamic`, + const httpDefaults = { token: options.token, timeout: options.timeout ?? DEFAULT_TIMEOUT, maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES, retryDelay: options.retryDelay ?? DEFAULT_RETRY_DELAY, + }; + + // Per-entity HTTP clients, each rooted at the entity's API path so that + // namespace classes can issue plain `/`, `/name/`, `/` calls. + this.agentsHttp = new HttpClient({ + baseUrl: `${this.hostUrl}/api/v1/agents/dynamic`, + ...httpDefaults, + }); + this.botsHttp = new HttpClient({ + baseUrl: `${this.hostUrl}/api/v1/bots`, + ...httpDefaults, + }); + this.personasHttp = new HttpClient({ + baseUrl: `${this.hostUrl}/api/v1/agents/personas`, + ...httpDefaults, + }); + this.abilitiesHttp = new HttpClient({ + baseUrl: `${this.hostUrl}/api/v1/agents/abilities`, + ...httpDefaults, + }); + this.memoriesHttp = new HttpClient({ + baseUrl: `${this.hostUrl}/api/v1/contextCenter/memories`, + ...httpDefaults, + }); + this.searchHttp = new HttpClient({ + baseUrl: `${this.hostUrl}/api/v1`, + ...httpDefaults, + }); + + // HTTP client for the default agent (/api/v1/agents/invoke and /api/v1/agents/run) + this.defaultAgentHttp = new HttpClient({ + baseUrl: `${this.hostUrl}/api/v1/agents`, + ...httpDefaults, + }); + // Note: base URL ends at /assistants so that POST '/chatConversations' lands on + // /api/v1/assistants/chatConversations (matches the Python SDK's pattern and + // avoids fetch's trailing-slash normalization quirks). + this.chatConvHttp = new HttpClient({ + baseUrl: `${this.hostUrl}/api/v1/assistants`, + ...httpDefaults, }); + + // Wire namespaces. + this.agents = new AgentsApi(this.agentsHttp, this); + this.bots = new BotsApi(this.botsHttp); + this.personas = new PersonasApi(this.personasHttp); + this.abilities = new AbilitiesApi(this.abilitiesHttp); + this.memories = new MemoriesApi(this.memoriesHttp, this.searchHttp); } /** @@ -218,356 +176,33 @@ export class AISdk { } /** - * Paginate through all results from a list endpoint. - * @internal - */ - private async paginateList( - fetcher: (params: Record) => Promise>, - mapper: (item: T) => U, - limit?: number, - pageSize: number = 100 - ): Promise { - const results: U[] = []; - let after: string | undefined = undefined; - - // eslint-disable-next-line no-constant-condition - while (true) { - const params: Record = { limit: pageSize }; - if (after) { - params.after = after; - } - - const response = await fetcher(params); - const data = response.data || []; - results.push(...data.map(mapper)); - - // Check if we've hit the requested limit - if (limit !== undefined && results.length >= limit) { - return results.slice(0, limit); - } - - // Check for more pages - after = response.paging?.after; - if (!after) { - break; - } - } - - return results; - } - - /** - * Get a handle to a specific agent. + * Get a handle to an agent. + * + * - With a name: handle for a named dynamic agent. + * - Without a name: handle for the platform's default agent (PLANNER/CHAT_MODE). + * Auto-creates a chat conversation when conversationId is not supplied. * - * @param name - The agent name (e.g., "DataQualityPlannerAgent") - * @returns AgentHandle for invoking the agent + * @param name - The agent name (e.g., "DataQualityPlannerAgent"). Omit to use the default agent. + * @returns AgentHandle or DefaultAgentHandle for invoking the agent * * @example * ```typescript + * // Named agent * const agent = client.agent('DataQualityPlannerAgent'); - * - * // Invoke the agent * const response = await agent.invoke('What tests should I add?'); - * ``` - */ - agent(name: string): AgentHandle { - return new AgentHandle(name, this.http); - } - - /** - * List all API-enabled agents. - * - * Automatically paginates through all results. - * - * @param options - Options - * @param options.limit - Maximum number of agents to return. If not specified, returns all agents. - * @returns Promise resolving to list of AgentInfo objects - * - * @example - * ```typescript - * // List all agents - * const agents = await client.listAgents(); - * - * // List with limit - * const agents = await client.listAgents({ limit: 20 }); - * - * // Display agent information - * for (const agent of agents) { - * console.log(`${agent.name}: ${agent.description}`); - * console.log(` Abilities: ${agent.abilities.join(', ')}`); - * } - * ``` - */ - async listAgents(options?: { limit?: number }): Promise { - return this.paginateList( - (params) => this.http.get>('/', { ...params, apiEnabled: 'true' }), - mapAgentInfo, - options?.limit - ); - } - - /** - * List all bots in the system. - * - * Automatically paginates through all results. * - * @param options - Options - * @param options.limit - Maximum number of bots to return. If not specified, returns all bots. - * @returns Promise resolving to list of BotInfo objects - * - * @example - * ```typescript - * // List all bots - * const bots = await client.listBots(); - * for (const bot of bots) { - * console.log(`${bot.name}: ${bot.description}`); - * } - * - * // List with limit - * const bots = await client.listBots({ limit: 5 }); - * ``` - */ - async listBots(options?: { limit?: number }): Promise { - return this.paginateList( - (params) => this.http.getAbsolute>('/api/v1/bots', params), - mapBotInfo, - options?.limit - ); - } - - /** - * Get a bot by name. - * - * @param name - The bot name - * @returns Promise resolving to BotInfo - * @throws {BotNotFoundError} If the bot is not found - * - * @example - * ```typescript - * const bot = await client.getBot('ingestion-bot'); - * console.log(`Bot: ${bot.displayName}`); - * console.log(`User: ${bot.botUser?.name}`); + * // Default agent (auto-creates a conversation) + * const defaultAgent = client.agent(); + * const response = await defaultAgent.invoke('Hello!'); * ``` */ - async getBot(name: string): Promise { - const response = await this.http.getAbsolute( - `/api/v1/bots/name/${encodeURIComponent(name)}`, - undefined, - 'bot', - name - ); - - return mapBotInfo(response); - } - - /** - * List all personas in the system. - * - * Automatically paginates through all results. - * - * @param options - Options - * @param options.limit - Maximum number of personas to return. If not specified, returns all personas. - * @returns Promise resolving to list of PersonaInfo objects - * - * @example - * ```typescript - * // List all personas - * const personas = await client.listPersonas(); - * for (const persona of personas) { - * console.log(`${persona.name}: ${persona.description}`); - * } - * - * // List with limit - * const personas = await client.listPersonas({ limit: 5 }); - * ``` - */ - async listPersonas(options?: { limit?: number }): Promise { - return this.paginateList( - (params) => - this.http.getAbsolute>('/api/v1/agents/personas', params), - mapPersonaInfo, - options?.limit - ); - } - - /** - * Get a persona by name. - * - * @param name - The persona name - * @returns Promise resolving to PersonaInfo - * @throws {PersonaNotFoundError} If the persona is not found - * - * @example - * ```typescript - * const persona = await client.getPersona('data-analyst'); - * console.log(`Persona: ${persona.displayName}`); - * console.log(`Prompt: ${persona.prompt}`); - * ``` - */ - async getPersona(name: string): Promise { - const response = await this.http.getAbsolute( - `/api/v1/agents/personas/name/${encodeURIComponent(name)}`, - undefined, - 'persona', - name - ); - - return mapPersonaInfo(response); - } - - /** - * Create a new persona. - * - * @param request - The persona creation request - * @returns Promise resolving to the created PersonaInfo - * - * @example - * ```typescript - * const persona = await client.createPersona({ - * name: 'custom-analyst', - * description: 'A custom data analyst persona', - * prompt: 'You are a helpful data analyst...', - * displayName: 'Custom Analyst', - * }); - * console.log(`Created persona: ${persona.id}`); - * ``` - */ - async createPersona(request: CreatePersonaRequest): Promise { - const body = { - name: request.name, - description: request.description, - prompt: request.prompt, - displayName: request.displayName, - provider: request.provider ?? 'user', - owners: request.owners, - }; - - const response = await this.http.postAbsolute( - '/api/v1/agents/personas', - body - ); - - return mapPersonaInfo(response); - } - - /** - * Create a new dynamic agent. - * - * @param request - The agent creation request - * @returns Promise resolving to the created AgentInfo - * - * @example - * ```typescript - * const agent = await client.createAgent({ - * name: 'my-custom-agent', - * description: 'A custom agent for data analysis', - * persona: 'data-analyst', - * mode: 'chat', - * displayName: 'My Custom Agent', - * abilities: ['analyze', 'report'], - * apiEnabled: true, - * }); - * console.log(`Created agent: ${agent.name}`); - * - * // Invoke the new agent - * const response = await client.agent(agent.name).invoke('Hello!'); - * ``` - */ - async createAgent(request: CreateAgentRequest): Promise { - // Resolve persona name to ID - const personaInfo = await this.getPersona(request.persona); - - // Resolve ability names to IDs if provided - let abilityRefs: Array<{ id: string; type: string }> | undefined; - if (request.abilities && request.abilities.length > 0) { - abilityRefs = []; - for (const abilityName of request.abilities) { - const abilityInfo = await this.getAbility(abilityName); - abilityRefs.push({ id: abilityInfo.id, type: 'ability' }); - } + agent(): DefaultAgentHandle; + agent(name: string): AgentHandle; + agent(name?: string): AgentHandle | DefaultAgentHandle { + if (name === undefined) { + return new DefaultAgentHandle(this.defaultAgentHttp, this.chatConvHttp); } - - const body = { - name: request.name, - description: request.description, - persona: { id: personaInfo.id, type: 'persona' }, - mode: request.mode, - displayName: request.displayName, - icon: request.icon, - botName: request.botName, - abilities: abilityRefs, - knowledge: request.knowledge, - prompt: request.prompt, - schedule: request.schedule, - apiEnabled: request.apiEnabled ?? false, - provider: request.provider ?? 'user', - }; - - const response = await this.http.postAbsolute( - '/api/v1/agents/dynamic', - body - ); - - return mapAgentInfo(response); - } - - /** - * List all abilities in the system. - * - * Automatically paginates through all results. - * - * @param options - Options - * @param options.limit - Maximum number of abilities to return. If not specified, returns all abilities. - * @returns Promise resolving to list of AbilityInfo objects - * - * @example - * ```typescript - * // List all abilities - * const abilities = await client.listAbilities(); - * for (const ability of abilities) { - * console.log(`${ability.name}: ${ability.description}`); - * console.log(` Tools: ${ability.tools.join(', ')}`); - * } - * - * // List with limit - * const abilities = await client.listAbilities({ limit: 5 }); - * ``` - */ - async listAbilities(options?: { limit?: number }): Promise { - return this.paginateList( - (params) => - this.http.getAbsolute>( - '/api/v1/agents/abilities', - params - ), - mapAbilityInfo, - options?.limit - ); - } - - /** - * Get an ability by name. - * - * @param name - The ability name - * @returns Promise resolving to AbilityInfo - * @throws {AbilityNotFoundError} If the ability is not found - * - * @example - * ```typescript - * const ability = await client.getAbility('analyze-data'); - * console.log(`Ability: ${ability.displayName}`); - * console.log(`Tools: ${ability.tools.join(', ')}`); - * ``` - */ - async getAbility(name: string): Promise { - const response = await this.http.getAbsolute( - `/api/v1/agents/abilities/name/${encodeURIComponent(name)}`, - undefined, - 'ability', - name - ); - - return mapAbilityInfo(response); + return new AgentHandle(name, this.agentsHttp); } /** diff --git a/typescript/src/http.ts b/typescript/src/http.ts index 9b06e08..f3823a3 100644 --- a/typescript/src/http.ts +++ b/typescript/src/http.ts @@ -59,13 +59,13 @@ export type EntityType = 'agent' | 'bot' | 'persona' | 'ability'; */ export interface RequestOptions { /** HTTP method */ - method: 'GET' | 'POST'; + method: 'GET' | 'POST' | 'DELETE'; /** Request path (appended to baseUrl) */ path: string; /** Request body (for POST requests) */ body?: unknown; - /** Query parameters (for GET requests) */ - params?: Record; + /** Query parameters (for GET/DELETE requests) */ + params?: Record; /** Agent name for error context */ agentName?: string; /** Entity type for error context (default: 'agent') */ @@ -81,8 +81,6 @@ export interface RequestOptions { */ export class HttpClient { private readonly baseUrl: string; - /** The root host URL (without API path) */ - public readonly hostUrl: string; private readonly token: string; private readonly timeout: number; private readonly maxRetries: number; @@ -91,8 +89,6 @@ export class HttpClient { constructor(options: HttpClientOptions) { this.baseUrl = options.baseUrl.replace(/\/$/, ''); - // Extract host URL from base URL (remove /api/v1/agents/dynamic suffix) - this.hostUrl = this.baseUrl.replace(/\/api\/v1\/agents\/dynamic$/, ''); this.token = options.token; this.timeout = options.timeout; this.maxRetries = options.maxRetries; @@ -115,7 +111,10 @@ export class HttpClient { /** * Build full URL with query parameters. */ - private buildUrl(path: string, params?: Record): string { + private buildUrl( + path: string, + params?: Record + ): string { // Construct full URL by appending path to base URL const fullPath = path.startsWith('/') ? path : `/${path}`; const urlString = `${this.baseUrl}${fullPath}`; @@ -229,62 +228,58 @@ export class HttpClient { */ async get( path: string, - params?: Record, - agentName?: string + params?: Record, + agentName?: string, + entityType?: EntityType, + entityName?: string ): Promise { return this.request({ method: 'GET', path, params, agentName, + entityType, + entityName, }); } /** * Make a POST request with retry support. */ - async post(path: string, body: unknown, agentName?: string): Promise { + async post( + path: string, + body: unknown, + agentName?: string, + entityType?: EntityType, + entityName?: string + ): Promise { return this.request({ method: 'POST', path, body, agentName, - }); - } - - /** - * Make a GET request to a custom API path (relative to host URL). - * This allows accessing endpoints outside the default /api/v1/agents/dynamic base. - */ - async getAbsolute( - apiPath: string, - params?: Record, - entityType?: EntityType, - entityName?: string - ): Promise { - return this.requestAbsolute({ - method: 'GET', - path: apiPath, - params, entityType, entityName, }); } /** - * Make a POST request to a custom API path (relative to host URL). - * This allows accessing endpoints outside the default /api/v1/agents/dynamic base. + * Make a DELETE request with retry support. + * + * Returns void for empty responses; otherwise the parsed JSON body. */ - async postAbsolute( - apiPath: string, - body: unknown, + async delete( + path: string, + params?: Record, + agentName?: string, entityType?: EntityType, entityName?: string ): Promise { - return this.requestAbsolute({ - method: 'POST', - path: apiPath, - body, + return this.request({ + method: 'DELETE', + path, + params, + agentName, entityType, entityName, }); @@ -294,6 +289,13 @@ export class HttpClient { * Make a streaming POST request. * * Note: Streaming requests don't support automatic retry. + * + * Unlike non-streaming methods, `postStream` intentionally does NOT apply + * `this.timeout` as a request deadline. SSE streams can run for many + * minutes (long agent runs with multiple tool calls), and the stream's + * own events signal liveness. A fixed deadline would cut the stream + * mid-run regardless of progress. Callers that need cancellation can + * abort externally (e.g. close the underlying ReadableStream / Ctrl+C). */ async postStream( path: string, @@ -304,19 +306,13 @@ export class HttpClient { const url = this.buildUrl(path); const headers = this.getHeaders(requestId, true); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeout); - try { const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), - signal: controller.signal, }); - clearTimeout(timeoutId); - if (!response.ok) { await this.handleError(response, agentName, requestId); } @@ -327,14 +323,13 @@ export class HttpClient { return response.body; } catch (error) { - clearTimeout(timeoutId); - if (error instanceof AISdkError) { throw error; } if (error instanceof Error) { if (error.name === 'AbortError') { + // Caller-initiated abort (no internal deadline is applied here). throw new TimeoutError(this.timeout); } throw new NetworkError(`Network error: ${error.message}`, error); @@ -345,104 +340,31 @@ export class HttpClient { } /** - * Internal request method with retry logic. + * Parse the response body. Returns undefined for empty / 204 responses + * (e.g. DELETE) so callers can declare a `void` return type. */ - private async request(options: RequestOptions): Promise { - const { method, path, body, params, agentName } = options; - const requestId = generateRequestId(); - const url = this.buildUrl(path, params); - const headers = this.getHeaders(requestId); - - let lastError: Error | null = null; - - for (let attempt = 0; attempt <= this.maxRetries; attempt++) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeout); - - try { - const response = await fetch(url, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (response.ok) { - return (await response.json()) as T; - } - - // Check if we should retry - if (this.shouldRetry(response.status, attempt)) { - const delay = this.getRetryDelay( - attempt, - response.headers.get('Retry-After') - ); - await this.wait(delay); - continue; - } - - // Non-retryable error - await this.handleError(response, agentName, requestId); - } catch (error) { - clearTimeout(timeoutId); - - if (error instanceof AISdkError) { - throw error; - } - - if (error instanceof Error) { - if (error.name === 'AbortError') { - lastError = new TimeoutError(this.timeout); - // Timeouts are retryable - if (attempt < this.maxRetries) { - const delay = this.retryDelay * Math.pow(2, attempt); - await this.wait(delay); - continue; - } - throw lastError; - } - lastError = new NetworkError(`Network error: ${error.message}`, error); - // Network errors are retryable - if (attempt < this.maxRetries) { - const delay = this.retryDelay * Math.pow(2, attempt); - await this.wait(delay); - continue; - } - throw lastError; - } - - throw new NetworkError('Unknown network error'); - } + private async parseBody(response: Response): Promise { + if (response.status === 204) { + return undefined as T; } - - // Should never reach here, but TypeScript needs this - throw lastError || new NetworkError('Request failed after retries'); - } - - /** - * Build URL with custom API path (relative to host URL). - */ - private buildAbsoluteUrl(apiPath: string, params?: Record): string { - const fullPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`; - const urlString = `${this.hostUrl}${fullPath}`; - const url = new URL(urlString); - if (params) { - for (const [key, value] of Object.entries(params)) { - url.searchParams.set(key, String(value)); - } + const contentLength = response.headers?.get?.('content-length'); + if (contentLength === '0') { + return undefined as T; + } + try { + return (await response.json()) as T; + } catch { + return undefined as T; } - return url.toString(); } /** - * Internal request method for absolute paths with retry logic. + * Internal request method with retry logic. */ - private async requestAbsolute(options: RequestOptions): Promise { - const { method, path, body, params, entityType, entityName } = options; + private async request(options: RequestOptions): Promise { + const { method, path, body, params, agentName, entityType, entityName } = options; const requestId = generateRequestId(); - const url = this.buildAbsoluteUrl(path, params); + const url = this.buildUrl(path, params); const headers = this.getHeaders(requestId); let lastError: Error | null = null; @@ -462,7 +384,7 @@ export class HttpClient { clearTimeout(timeoutId); if (response.ok) { - return (await response.json()) as T; + return await this.parseBody(response); } // Check if we should retry @@ -476,7 +398,7 @@ export class HttpClient { } // Non-retryable error - await this.handleError(response, undefined, requestId, entityType, entityName); + await this.handleError(response, agentName, requestId, entityType, entityName); } catch (error) { clearTimeout(timeoutId); diff --git a/typescript/src/index.ts b/typescript/src/index.ts index b308de8..fb7f1ad 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -26,30 +26,34 @@ * process.stdout.write(chunk); * } * - * // Streaming (advanced - all events) - * for await (const event of client.agent('DataQualityPlannerAgent') - * .stream('Analyze the orders table')) { - * if (event.type === 'content') { - * process.stdout.write(event.content || ''); - * } - * } - * - * // Multi-turn conversation - * const r1 = await client.agent('planner').invoke('Analyze orders'); - * const r2 = await client.agent('planner').invoke('Create tests', { - * conversationId: r1.conversationId, - * }); + * // List available agents (namespaced) + * const agents = await client.agents.list(); * - * // List available agents - * const agents = await client.listAgents(); + * // Search Context Center memories + * const results = await client.memories.search('customer churn'); * ``` */ // Main client export { AISdk } from './client.js'; -// Agent handle -export { AgentHandle } from './agent.js'; +// Agent handles +export { AgentHandle, DefaultAgentHandle } from './agent.js'; + +// Namespace classes +export { + AbilitiesApi, + AgentsApi, + BotsApi, + MemoriesApi, + PersonasApi, + type AbilitiesListOptions, + type AgentsListOptions, + type BotsListOptions, + type MemoriesListOptions, + type MemoriesSearchOptions, + type PersonasListOptions, +} from './api/index.js'; // Models and types export type { @@ -62,7 +66,7 @@ export type { AgentInfo, } from './models.js'; -// Extended types for bots, personas, agents, and abilities +// Extended types for bots, personas, agents, abilities, and memories export type { EntityReference, BotInfo, @@ -71,6 +75,13 @@ export type { KnowledgeScope, CreatePersonaRequest, CreateAgentRequest, + ContextMemory, + CreateContextMemoryRequest, + MemoryScope, + MemorySearchHit, + MemorySearchResults, + MemoryType, + MemoryVisibility, } from './types.js'; // Error classes diff --git a/typescript/src/models.ts b/typescript/src/models.ts index 6faed12..a57aec0 100644 --- a/typescript/src/models.ts +++ b/typescript/src/models.ts @@ -13,7 +13,10 @@ export interface AISdkOptions { host: string; /** JWT bot token for authentication */ token: string; - /** Request timeout in milliseconds (default: 120000) */ + /** Request timeout in milliseconds for non-streaming requests (default: 900000). + * Note: streaming requests (`stream` / `streamContent`) intentionally do not + * apply this deadline — SSE runs can last many minutes and the stream's own + * events signal liveness. */ timeout?: number; /** Whether to verify SSL certificates (default: true, limited in Node fetch) */ verifySsl?: boolean; @@ -55,6 +58,8 @@ export interface InvokeResponse { response: string; /** List of tools used by the agent during execution */ toolsUsed: string[]; + /** Intermediate reasoning steps emitted by the agent (Sender.SYSTEM messages) */ + thinkingSteps: string[]; /** Optional token usage statistics */ usage?: Usage; } @@ -154,6 +159,7 @@ export interface ApiInvokeResponse { conversationId: string; response: string; toolsUsed?: string[]; + thinkingSteps?: string[]; usage?: { promptTokens?: number; completionTokens?: number; diff --git a/typescript/src/types.ts b/typescript/src/types.ts index f70481d..8f071ce 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -143,3 +143,106 @@ export interface AbilityInfo { /** List of tools provided by this ability */ tools: string[]; } + +/** + * High-level type of a Context Center memory. + */ +export type MemoryType = 'Preference' | 'UseCase' | 'Note' | 'Runbook' | 'Faq'; + +/** + * Scope where a memory applies. + */ +export type MemoryScope = 'UserGlobal' | 'EntityScoped'; + +/** + * Visibility level for a memory. + */ +export type MemoryVisibility = 'Private' | 'Entity' | 'Shared'; + +/** + * Request body for creating a Context Center memory. + * + * `visibility` is flattened from the API's `shareConfig.visibility`; it is + * re-nested when the request is serialized to the wire format. + * + * `tags` accepts a list of tag FQN strings; each one is wrapped into the + * platform's TagLabel shape on the wire. + */ +export interface CreateContextMemoryRequest { + /** Stable system name for the memory */ + name: string; + /** Canonical question / instruction (required) */ + question: string; + /** Canonical answer / retained guidance (required) */ + answer: string; + /** Short title shown in Context Center */ + title?: string; + /** Optional markdown description */ + description?: string; + /** High-level memory type (default: 'Note') */ + memoryType?: MemoryType; + /** Scope the memory applies to (default: 'EntityScoped') */ + memoryScope?: MemoryScope; + /** Visibility level (default: 'Private') */ + visibility?: MemoryVisibility; + /** Primary entity this memory attaches to */ + primaryEntity?: EntityReference; + /** Additional related entities */ + relatedEntities?: EntityReference[]; + /** Tag FQN strings; wrapped into TagLabel objects on the wire */ + tags?: string[]; +} + +/** + * A Context Center memory. + */ +export interface ContextMemory { + /** Unique identifier */ + id: string; + /** Stable system name */ + name: string; + /** Fully qualified name */ + fullyQualifiedName?: string; + /** Short title */ + title?: string; + /** Canonical question / instruction */ + question: string; + /** Canonical answer / retained guidance */ + answer: string; + /** Optional summary */ + summary?: string; + /** High-level memory type */ + memoryType: MemoryType; + /** Scope the memory applies to */ + memoryScope: MemoryScope; + /** Visibility (extracted from shareConfig.visibility) */ + visibility: MemoryVisibility; + /** Primary entity this memory attaches to */ + primaryEntity?: EntityReference; + /** Number of times this memory has been used */ + usageCount: number; + /** Last-used timestamp in epoch milliseconds */ + lastUsedAt?: number; + /** Whether the memory is soft-deleted */ + deleted: boolean; +} + +/** + * A single hit from a hybrid memory search. + */ +export interface MemorySearchHit { + /** The matched memory */ + memory: ContextMemory; + /** Relevance score from the search engine */ + score: number; +} + +/** + * Results from a hybrid memory search. + */ +export interface MemorySearchResults { + /** Total number of matching memories */ + total: number; + /** Ranked search hits */ + hits: MemorySearchHit[]; +} diff --git a/typescript/tests/client.test.ts b/typescript/tests/client.test.ts index 93b1700..1f0b2ac 100644 --- a/typescript/tests/client.test.ts +++ b/typescript/tests/client.test.ts @@ -112,7 +112,7 @@ describe('AISdk', () => { token: 'test-token', }); - const agents = await client.listAgents(); + const agents = await client.agents.list(); expect(agents).toHaveLength(2); expect(agents[0].name).toBe('DataQualityPlannerAgent'); @@ -139,7 +139,7 @@ describe('AISdk', () => { token: 'test-token', }); - const agents = await client.listAgents({ limit: 5 }); + const agents = await client.agents.list({ limit: 5 }); expect(agents).toHaveLength(3); expect(mockFetch).toHaveBeenCalledTimes(1); @@ -359,6 +359,58 @@ describe('AgentHandle', () => { expect(chunks).toEqual([]); }); + + it('should not abort a slow stream after the configured non-streaming timeout', async () => { + // Configure a deliberately tiny client-level timeout (50ms). The stream + // emits its first event after 80ms and runs for ~250ms total, both well + // beyond the timeout. With the old behavior the AbortController would + // fire mid-stream and the iterator would error; with the fix the stream + // must complete normally and yield every content chunk. + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + const enqueueAfter = (ms: number, data: string) => + new Promise((resolve) => { + setTimeout(() => { + controller.enqueue(encoder.encode(data)); + resolve(); + }, ms); + }); + + (async () => { + await enqueueAfter(80, 'event: stream-start\ndata: {"conversationId": "slow-1"}\n\n'); + await enqueueAfter(80, 'event: message\ndata: {"content": "slow "}\n\n'); + await enqueueAfter(80, 'event: message\ndata: {"content": "stream"}\n\n'); + await enqueueAfter(20, 'event: stream-completed\ndata: {}\n\n'); + controller.close(); + })(); + }, + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + body: stream, + headers: new Headers({ 'content-type': 'text/event-stream' }), + }); + + const client = new AISdk({ + host: 'https://openmetadata.example.com', + token: 'test-token', + timeout: 50, + }); + + const chunks: string[] = []; + for await (const chunk of client.agent('TestAgent').streamContent('Slow run')) { + chunks.push(chunk); + } + + expect(chunks).toEqual(['slow ', 'stream']); + + // The fetch call must not have been given an AbortSignal that would have + // fired on the timeout — the SDK no longer wires one for streaming. + const fetchInit = mockFetch.mock.calls[0][1] as RequestInit; + expect(fetchInit.signal).toBeUndefined(); + }); }); describe('getInfo()', () => { @@ -581,7 +633,7 @@ describe('Bot operations', () => { token: 'test-token', }); - const bots = await client.listBots(); + const bots = await client.bots.list(); expect(bots).toHaveLength(2); expect(bots[0].name).toBe('ingestion-bot'); @@ -601,7 +653,7 @@ describe('Bot operations', () => { token: 'test-token', }); - await client.listBots({ limit: 5 }); + await client.bots.list({ limit: 5 }); const url = mockFetch.mock.calls[0][0] as string; expect(url).toContain('https://openmetadata.example.com/api/v1/bots'); @@ -634,7 +686,7 @@ describe('Bot operations', () => { token: 'test-token', }); - const bot = await client.getBot('ingestion-bot'); + const bot = await client.bots.get('ingestion-bot'); expect(bot.id).toBe('bot-1'); expect(bot.name).toBe('ingestion-bot'); @@ -656,7 +708,7 @@ describe('Bot operations', () => { token: 'test-token', }); - await client.getBot('my-bot'); + await client.bots.get('my-bot'); const url = mockFetch.mock.calls[0][0] as string; expect(url).toBe('https://openmetadata.example.com/api/v1/bots/name/my-bot'); @@ -676,7 +728,7 @@ describe('Bot operations', () => { token: 'test-token', }); - await expect(client.getBot('non-existent-bot')).rejects.toThrow( + await expect(client.bots.get('non-existent-bot')).rejects.toThrow( BotNotFoundError ); }); @@ -724,7 +776,7 @@ describe('Persona operations', () => { token: 'test-token', }); - const personas = await client.listPersonas(); + const personas = await client.personas.list(); expect(personas).toHaveLength(2); expect(personas[0].name).toBe('data-analyst'); @@ -744,7 +796,7 @@ describe('Persona operations', () => { token: 'test-token', }); - await client.listPersonas({ limit: 5 }); + await client.personas.list({ limit: 5 }); const url = mockFetch.mock.calls[0][0] as string; expect(url).toContain('https://openmetadata.example.com/api/v1/agents/personas'); @@ -774,7 +826,7 @@ describe('Persona operations', () => { token: 'test-token', }); - const persona = await client.getPersona('data-analyst'); + const persona = await client.personas.get('data-analyst'); expect(persona.id).toBe('persona-1'); expect(persona.name).toBe('data-analyst'); @@ -797,7 +849,7 @@ describe('Persona operations', () => { token: 'test-token', }); - await client.getPersona('my-persona'); + await client.personas.get('my-persona'); const url = mockFetch.mock.calls[0][0] as string; expect(url).toBe('https://openmetadata.example.com/api/v1/agents/personas/name/my-persona'); @@ -817,7 +869,7 @@ describe('Persona operations', () => { token: 'test-token', }); - await expect(client.getPersona('non-existent-persona')).rejects.toThrow( + await expect(client.personas.get('non-existent-persona')).rejects.toThrow( PersonaNotFoundError ); }); @@ -844,7 +896,7 @@ describe('Persona operations', () => { token: 'test-token', }); - const persona = await client.createPersona({ + const persona = await client.personas.create({ name: 'custom-analyst', description: 'A custom data analyst persona', prompt: 'You are a custom analyst...', @@ -872,7 +924,7 @@ describe('Persona operations', () => { token: 'test-token', }); - await client.createPersona({ + await client.personas.create({ name: 'test-persona', description: 'Test description', prompt: 'Test prompt', @@ -904,7 +956,7 @@ describe('Persona operations', () => { token: 'test-token', }); - await client.createPersona({ + await client.personas.create({ name: 'test-persona', description: 'Test', prompt: 'Test', @@ -916,6 +968,132 @@ describe('Persona operations', () => { }); }); +describe('default agent', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('client.agent() creates a conversation then calls invoke', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ id: '11111111-1111-1111-1111-111111111111' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + conversationId: '11111111-1111-1111-1111-111111111111', + response: 'hello', + toolsUsed: [], + }), + }); + + const client = new AISdk({ + host: 'https://metadata.example.com', + token: 'tkn', + }); + const response = await client.agent().invoke('Say hi'); + expect(response.response).toBe('hello'); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const firstUrl = mockFetch.mock.calls[0][0] as string; + expect(firstUrl).toContain('/api/v1/assistants/chatConversations'); + + const secondUrl = mockFetch.mock.calls[1][0] as string; + expect(secondUrl).toContain('/api/v1/agents/invoke'); + + const requestInit = mockFetch.mock.calls[1][1] as RequestInit; + const body = JSON.parse(requestInit.body as string); + expect(body.message).toBe('Say hi'); + expect(body.conversationId).toBe('11111111-1111-1111-1111-111111111111'); + expect(body.agentType).toBe('PLANNER'); + expect(body.agentMode).toBe('CHAT_MODE'); + }); + + it('client.agent() surfaces thinkingSteps from the response', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ id: '55555555-5555-5555-5555-555555555555' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + conversationId: '55555555-5555-5555-5555-555555555555', + response: 'Found it.', + toolsUsed: [], + thinkingSteps: ['Exploring assets...', 'Synthesizing answer...'], + }), + }); + + const client = new AISdk({ + host: 'https://metadata.example.com', + token: 'tkn', + }); + const response = await client.agent().invoke('question'); + expect(response.response).toBe('Found it.'); + expect(response.thinkingSteps).toEqual(['Exploring assets...', 'Synthesizing answer...']); + }); + + it('client.agent() defaults thinkingSteps to [] when absent', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ id: '11111111-1111-1111-1111-111111111111' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + conversationId: '11111111-1111-1111-1111-111111111111', + response: 'hello', + toolsUsed: [], + }), + }); + + const client = new AISdk({ + host: 'https://metadata.example.com', + token: 'tkn', + }); + const response = await client.agent().invoke('Say hi'); + expect(response.thinkingSteps).toEqual([]); + }); + + it('client.agent() reuses an existing conversation when conversationId is provided', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + conversationId: '22222222-2222-2222-2222-222222222222', + response: 'reuse ok', + toolsUsed: [], + }), + }); + + const client = new AISdk({ + host: 'https://metadata.example.com', + token: 'tkn', + }); + const response = await client + .agent() + .invoke('continue', { conversationId: '22222222-2222-2222-2222-222222222222' }); + + expect(response.response).toBe('reuse ok'); + expect(mockFetch).toHaveBeenCalledTimes(1); // no conversation creation + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toContain('/api/v1/agents/invoke'); + }); +}); + describe('Agent creation', () => { beforeEach(() => { mockFetch.mockReset(); @@ -975,7 +1153,7 @@ describe('Agent creation', () => { token: 'test-token', }); - const agent = await client.createAgent({ + const agent = await client.agents.create({ name: 'my-custom-agent', description: 'A custom agent for data analysis', persona: 'data-analyst', @@ -1021,7 +1199,7 @@ describe('Agent creation', () => { token: 'test-token', }); - await client.createAgent({ + await client.agents.create({ name: 'test-agent', description: 'Test description', persona: 'my-persona', @@ -1072,7 +1250,7 @@ describe('Agent creation', () => { token: 'test-token', }); - await client.createAgent({ + await client.agents.create({ name: 'test-agent', description: 'Test', persona: 'test-persona', @@ -1081,7 +1259,7 @@ describe('Agent creation', () => { // The POST request is the second call (after persona GET) const url = mockFetch.mock.calls[1][0] as string; - expect(url).toBe('https://openmetadata.example.com/api/v1/agents/dynamic'); + expect(url).toBe('https://openmetadata.example.com/api/v1/agents/dynamic/'); }); it('should include knowledge scope when provided', async () => { @@ -1114,7 +1292,7 @@ describe('Agent creation', () => { token: 'test-token', }); - await client.createAgent({ + await client.agents.create({ name: 'test-agent', description: 'Test', persona: 'test-persona', diff --git a/typescript/tests/integration.test.ts b/typescript/tests/integration.test.ts index b22e311..a4e3122 100644 --- a/typescript/tests/integration.test.ts +++ b/typescript/tests/integration.test.ts @@ -45,11 +45,11 @@ describe.skipIf(!shouldRun)('Integration Tests', () => { }); // Create a test agent with discoveryAndSearch ability for proper streaming tests - const personas = await client.listPersonas(); + const personas = await client.personas.list(); if (personas.length > 0) { const agentName = uniqueName('invoke-test-agent'); try { - await client.createAgent({ + await client.agents.create({ name: agentName, description: 'Auto-created agent for integration testing', persona: personas[0].name, @@ -61,7 +61,7 @@ describe.skipIf(!shouldRun)('Integration Tests', () => { } catch (e) { console.log(`Could not create test agent: ${e}`); // Fall back to first available agent - const agents = await client.listAgents(); + const agents = await client.agents.list(); if (agents.length > 0) { testAgentName = agents[0].name; } @@ -76,7 +76,7 @@ describe.skipIf(!shouldRun)('Integration Tests', () => { }); it('should list agents successfully', async () => { - const agents = await client.listAgents(); + const agents = await client.agents.list(); expect(Array.isArray(agents)).toBe(true); console.log(`Found ${agents.length} API-enabled agents`); }); @@ -87,7 +87,7 @@ describe.skipIf(!shouldRun)('Integration Tests', () => { token: 'invalid-token-12345', }); - await expect(badClient.listAgents()).rejects.toThrow(AuthenticationError); + await expect(badClient.agents.list()).rejects.toThrow(AuthenticationError); }); }); @@ -145,26 +145,26 @@ describe.skipIf(!shouldRun)('Integration Tests', () => { describe('Persona Operations', () => { it('should list personas', async () => { - const personas = await client.listPersonas(); + const personas = await client.personas.list(); expect(Array.isArray(personas)).toBe(true); console.log(`Found ${personas.length} personas`); }); it('should list personas with limit', async () => { - const personas = await client.listPersonas({ limit: 5 }); + const personas = await client.personas.list({ limit: 5 }); expect(Array.isArray(personas)).toBe(true); expect(personas.length).toBeLessThanOrEqual(5); }); it('should get persona by name', async () => { - const personas = await client.listPersonas(); + const personas = await client.personas.list(); if (personas.length === 0) { console.log('Skipping: No personas available'); return; } const personaName = personas[0].name; - const persona = await client.getPersona(personaName); + const persona = await client.personas.get(personaName); expect(persona).toBeDefined(); expect(persona.name).toBe(personaName); @@ -172,14 +172,14 @@ describe.skipIf(!shouldRun)('Integration Tests', () => { }); it('should throw PersonaNotFoundError for non-existent persona', async () => { - await expect(client.getPersona('non-existent-persona-12345')).rejects.toThrow( + await expect(client.personas.get('non-existent-persona-12345')).rejects.toThrow( PersonaNotFoundError ); }); it('should create a persona', async () => { const personaName = uniqueName('persona'); - const created = await client.createPersona({ + const created = await client.personas.create({ name: personaName, description: 'Integration test persona', prompt: 'You are a helpful test assistant.', @@ -195,26 +195,26 @@ describe.skipIf(!shouldRun)('Integration Tests', () => { describe('Bot Operations', () => { it('should list bots', async () => { - const bots = await client.listBots(); + const bots = await client.bots.list(); expect(Array.isArray(bots)).toBe(true); console.log(`Found ${bots.length} bots`); }); it('should list bots with limit', async () => { - const bots = await client.listBots({ limit: 5 }); + const bots = await client.bots.list({ limit: 5 }); expect(Array.isArray(bots)).toBe(true); expect(bots.length).toBeLessThanOrEqual(5); }); it('should get bot by name', async () => { - const bots = await client.listBots(); + const bots = await client.bots.list(); if (bots.length === 0) { console.log('Skipping: No bots available'); return; } const botName = bots[0].name; - const bot = await client.getBot(botName); + const bot = await client.bots.get(botName); expect(bot).toBeDefined(); expect(bot.name).toBe(botName); @@ -222,25 +222,25 @@ describe.skipIf(!shouldRun)('Integration Tests', () => { }); it('should throw BotNotFoundError for non-existent bot', async () => { - await expect(client.getBot('non-existent-bot-12345')).rejects.toThrow(BotNotFoundError); + await expect(client.bots.get('non-existent-bot-12345')).rejects.toThrow(BotNotFoundError); }); }); describe('Ability Operations', () => { it('should list abilities', async () => { - const abilities = await client.listAbilities(); + const abilities = await client.abilities.list(); expect(Array.isArray(abilities)).toBe(true); console.log(`Found ${abilities.length} abilities`); }); it('should list abilities with limit', async () => { - const abilities = await client.listAbilities({ limit: 5 }); + const abilities = await client.abilities.list({ limit: 5 }); expect(Array.isArray(abilities)).toBe(true); expect(abilities.length).toBeLessThanOrEqual(5); }); it('should have expected fields on abilities', async () => { - const abilities = await client.listAbilities(); + const abilities = await client.abilities.list(); if (abilities.length === 0) { console.log('Skipping: No abilities available'); return; @@ -254,14 +254,14 @@ describe.skipIf(!shouldRun)('Integration Tests', () => { describe('Agent CRUD Operations', () => { it('should create an agent', async () => { - const personas = await client.listPersonas(); + const personas = await client.personas.list(); if (personas.length === 0) { console.log('Skipping: No personas available to create agent'); return; } const agentName = uniqueName('agent'); - const created = await client.createAgent({ + const created = await client.agents.create({ name: agentName, description: 'Integration test agent', persona: personas[0].name, @@ -275,8 +275,8 @@ describe.skipIf(!shouldRun)('Integration Tests', () => { }); it('should create an agent with abilities', async () => { - const personas = await client.listPersonas(); - const abilities = await client.listAbilities(); + const personas = await client.personas.list(); + const abilities = await client.abilities.list(); if (personas.length === 0) { console.log('Skipping: No personas available'); @@ -290,7 +290,7 @@ describe.skipIf(!shouldRun)('Integration Tests', () => { const agentName = uniqueName('agent-abilities'); const abilityNames = abilities.slice(0, 2).map((a) => a.name); - const created = await client.createAgent({ + const created = await client.agents.create({ name: agentName, description: 'Integration test agent with abilities', persona: personas[0].name, diff --git a/typescript/tests/memories.test.ts b/typescript/tests/memories.test.ts new file mode 100644 index 0000000..4f69f83 --- /dev/null +++ b/typescript/tests/memories.test.ts @@ -0,0 +1,427 @@ +/** + * Tests for the MemoriesApi namespace. + * + * Mocks the underlying HttpClient and asserts the request shape and + * response parsing for each verb (list, get, create, delete, search). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MemoriesApi } from '../src/api/memories.js'; +import type { HttpClient } from '../src/http.js'; + +/** + * Build a minimal HttpClient mock with `get`, `post`, `delete` jest mocks. + */ +function createHttpMock(): { + get: ReturnType; + post: ReturnType; + delete: ReturnType; +} { + return { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }; +} + +function asHttpClient(mock: ReturnType): HttpClient { + return mock as unknown as HttpClient; +} + +describe('MemoriesApi', () => { + let memoriesHttp: ReturnType; + let searchHttp: ReturnType; + let api: MemoriesApi; + + beforeEach(() => { + memoriesHttp = createHttpMock(); + searchHttp = createHttpMock(); + api = new MemoriesApi(asHttpClient(memoriesHttp), asHttpClient(searchHttp)); + }); + + describe('list()', () => { + it('issues GET / with default page size and parses results', async () => { + memoriesHttp.get.mockResolvedValueOnce({ + data: [ + { + id: 'mem-1', + name: 'memory-one', + fullyQualifiedName: 'cm.memory-one', + title: 'First', + question: 'q1', + answer: 'a1', + memoryType: 'Note', + memoryScope: 'EntityScoped', + shareConfig: { visibility: 'Private' }, + usageCount: 3, + }, + { + id: 'mem-2', + name: 'memory-two', + question: 'q2', + answer: 'a2', + memoryType: 'Faq', + memoryScope: 'UserGlobal', + shareConfig: { visibility: 'Shared' }, + }, + ], + paging: {}, + }); + + const memories = await api.list(); + + expect(memoriesHttp.get).toHaveBeenCalledTimes(1); + const [path, params] = memoriesHttp.get.mock.calls[0]; + expect(path).toBe('/'); + expect(params).toEqual({ limit: 100 }); + expect(memories).toHaveLength(2); + expect(memories[0]).toMatchObject({ + id: 'mem-1', + name: 'memory-one', + title: 'First', + question: 'q1', + answer: 'a1', + memoryType: 'Note', + memoryScope: 'EntityScoped', + visibility: 'Private', + usageCount: 3, + deleted: false, + }); + expect(memories[1].visibility).toBe('Shared'); + expect(memories[1].memoryType).toBe('Faq'); + }); + + it('passes primaryEntityFqn filter through', async () => { + memoriesHttp.get.mockResolvedValueOnce({ data: [], paging: {} }); + + await api.list({ primaryEntityFqn: 'service.db.schema.tbl' }); + + expect(memoriesHttp.get).toHaveBeenCalledTimes(1); + const [, params] = memoriesHttp.get.mock.calls[0]; + expect(params).toEqual({ + limit: 100, + primaryEntityFqn: 'service.db.schema.tbl', + }); + }); + + it('honors limit option and slices the accumulated results', async () => { + memoriesHttp.get.mockResolvedValueOnce({ + data: [ + { id: 'mem-1', name: 'one', question: 'q', answer: 'a' }, + { id: 'mem-2', name: 'two', question: 'q', answer: 'a' }, + { id: 'mem-3', name: 'three', question: 'q', answer: 'a' }, + ], + paging: {}, + }); + + const memories = await api.list({ limit: 2 }); + + expect(memories).toHaveLength(2); + expect(memories.map((m) => m.id)).toEqual(['mem-1', 'mem-2']); + }); + + it('paginates with the after cursor until exhausted', async () => { + memoriesHttp.get + .mockResolvedValueOnce({ + data: [{ id: 'mem-1', name: 'one', question: 'q', answer: 'a' }], + paging: { after: 'cursor-1' }, + }) + .mockResolvedValueOnce({ + data: [{ id: 'mem-2', name: 'two', question: 'q', answer: 'a' }], + paging: {}, + }); + + const memories = await api.list(); + + expect(memoriesHttp.get).toHaveBeenCalledTimes(2); + expect(memoriesHttp.get.mock.calls[1][1]).toEqual({ limit: 100, after: 'cursor-1' }); + expect(memories.map((m) => m.id)).toEqual(['mem-1', 'mem-2']); + }); + }); + + describe('get()', () => { + it('issues GET / and parses the document', async () => { + memoriesHttp.get.mockResolvedValueOnce({ + id: 'mem-42', + name: 'memory-42', + question: 'why?', + answer: 'because', + memoryType: 'Runbook', + memoryScope: 'EntityScoped', + shareConfig: { visibility: 'Entity' }, + primaryEntity: { id: 'tbl-1', type: 'table', name: 'orders' }, + usageCount: 7, + lastUsedAt: 1700000000000, + }); + + const memory = await api.get('mem-42'); + + expect(memoriesHttp.get).toHaveBeenCalledWith('/mem-42'); + expect(memory).toMatchObject({ + id: 'mem-42', + name: 'memory-42', + memoryType: 'Runbook', + visibility: 'Entity', + primaryEntity: { id: 'tbl-1', type: 'table', name: 'orders' }, + usageCount: 7, + lastUsedAt: 1700000000000, + }); + }); + + it('defaults visibility to Private when shareConfig is missing', async () => { + memoriesHttp.get.mockResolvedValueOnce({ + id: 'mem-1', + name: 'lonely', + question: 'q', + answer: 'a', + }); + + const memory = await api.get('mem-1'); + + expect(memory.visibility).toBe('Private'); + expect(memory.memoryType).toBe('Note'); + expect(memory.memoryScope).toBe('EntityScoped'); + }); + }); + + describe('create()', () => { + it('issues POST / with the create body and parses the response', async () => { + memoriesHttp.post.mockResolvedValueOnce({ + id: 'mem-new', + name: 'preferred-tooling', + question: 'Which tool?', + answer: 'Use dbt.', + memoryType: 'Preference', + memoryScope: 'UserGlobal', + shareConfig: { visibility: 'Shared' }, + }); + + const memory = await api.create({ + name: 'preferred-tooling', + question: 'Which tool?', + answer: 'Use dbt.', + memoryType: 'Preference', + memoryScope: 'UserGlobal', + visibility: 'Shared', + }); + + expect(memoriesHttp.post).toHaveBeenCalledTimes(1); + const [path, body] = memoriesHttp.post.mock.calls[0]; + expect(path).toBe('/'); + expect(body).toEqual({ + name: 'preferred-tooling', + question: 'Which tool?', + answer: 'Use dbt.', + memoryType: 'Preference', + memoryScope: 'UserGlobal', + shareConfig: { visibility: 'Shared' }, + }); + expect(memory.id).toBe('mem-new'); + expect(memory.visibility).toBe('Shared'); + }); + + it('applies sensible defaults when optional fields are omitted', async () => { + memoriesHttp.post.mockResolvedValueOnce({ + id: 'mem-default', + name: 'minimal', + question: 'q', + answer: 'a', + }); + + await api.create({ + name: 'minimal', + question: 'q', + answer: 'a', + }); + + const [, body] = memoriesHttp.post.mock.calls[0]; + expect(body).toEqual({ + name: 'minimal', + question: 'q', + answer: 'a', + memoryType: 'Note', + memoryScope: 'EntityScoped', + shareConfig: { visibility: 'Private' }, + }); + }); + + it('wraps tag FQNs into TagLabel objects on the wire', async () => { + memoriesHttp.post.mockResolvedValueOnce({ + id: 'mem-tagged', + name: 'tagged-memory', + question: 'q', + answer: 'a', + }); + + await api.create({ + name: 'tagged-memory', + question: 'q', + answer: 'a', + tags: ['PII.Sensitive', 'Tier.Tier1'], + }); + + const [, body] = memoriesHttp.post.mock.calls[0]; + expect((body as { tags: unknown[] }).tags).toEqual([ + { + tagFQN: 'PII.Sensitive', + labelType: 'Manual', + state: 'Confirmed', + source: 'Classification', + }, + { + tagFQN: 'Tier.Tier1', + labelType: 'Manual', + state: 'Confirmed', + source: 'Classification', + }, + ]); + }); + + it('forwards primaryEntity and relatedEntities verbatim', async () => { + memoriesHttp.post.mockResolvedValueOnce({ + id: 'mem-with-entity', + name: 'attached', + question: 'q', + answer: 'a', + }); + + await api.create({ + name: 'attached', + question: 'q', + answer: 'a', + title: 'Attached Memory', + description: 'desc', + primaryEntity: { id: 'tbl-1', type: 'table', name: 'orders' }, + relatedEntities: [{ id: 'tbl-2', type: 'table' }], + }); + + const [, body] = memoriesHttp.post.mock.calls[0]; + expect(body).toMatchObject({ + title: 'Attached Memory', + description: 'desc', + primaryEntity: { id: 'tbl-1', type: 'table', name: 'orders' }, + relatedEntities: [{ id: 'tbl-2', type: 'table' }], + }); + }); + }); + + describe('delete()', () => { + it('issues DELETE / with hardDelete=false by default', async () => { + memoriesHttp.delete.mockResolvedValueOnce(undefined); + + await api.delete('mem-1'); + + expect(memoriesHttp.delete).toHaveBeenCalledTimes(1); + const [path, params] = memoriesHttp.delete.mock.calls[0]; + expect(path).toBe('/mem-1'); + expect(params).toEqual({ hardDelete: false }); + }); + + it('forwards hardDelete=true when requested', async () => { + memoriesHttp.delete.mockResolvedValueOnce(undefined); + + await api.delete('mem-1', { hardDelete: true }); + + const [, params] = memoriesHttp.delete.mock.calls[0]; + expect(params).toEqual({ hardDelete: true }); + }); + }); + + describe('search()', () => { + it('issues GET /hybrid/nlq/search with default size/from and parses hits', async () => { + searchHttp.get.mockResolvedValueOnce({ + hits: { + total: { value: 2 }, + hits: [ + { + _score: 1.42, + _source: { + id: 'mem-1', + name: 'first', + question: 'q1', + answer: 'a1', + memoryType: 'Note', + memoryScope: 'EntityScoped', + shareConfig: { visibility: 'Private' }, + }, + }, + { + _score: 0.81, + _source: { + id: 'mem-2', + name: 'second', + question: 'q2', + answer: 'a2', + }, + }, + ], + }, + }); + + const results = await api.search('customer churn'); + + expect(searchHttp.get).toHaveBeenCalledTimes(1); + const [path, params] = searchHttp.get.mock.calls[0]; + expect(path).toBe('/hybrid/nlq/search'); + expect(params).toEqual({ + q: 'customer churn', + index: 'contextMemory', + size: 15, + from: 0, + }); + expect(results.total).toBe(2); + expect(results.hits).toHaveLength(2); + expect(results.hits[0].score).toBe(1.42); + expect(results.hits[0].memory.id).toBe('mem-1'); + expect(results.hits[0].memory.visibility).toBe('Private'); + expect(results.hits[1].score).toBe(0.81); + }); + + it('serializes filters as JSON in the query string', async () => { + searchHttp.get.mockResolvedValueOnce({ + hits: { total: 0, hits: [] }, + }); + + await api.search('runbooks', { + filters: { + primaryEntityId: ['abc', 'def'], + visibility: ['Shared'], + }, + size: 25, + from: 50, + }); + + const [, params] = searchHttp.get.mock.calls[0]; + expect(params).toEqual({ + q: 'runbooks', + index: 'contextMemory', + size: 25, + from: 50, + filters: JSON.stringify({ + primaryEntityId: ['abc', 'def'], + visibility: ['Shared'], + }), + }); + }); + + it('handles a numeric total and missing hits gracefully', async () => { + searchHttp.get.mockResolvedValueOnce({ + hits: { total: 5, hits: undefined }, + }); + + const results = await api.search('q'); + + expect(results.total).toBe(5); + expect(results.hits).toEqual([]); + }); + + it('returns an empty result when the search response is empty', async () => { + searchHttp.get.mockResolvedValueOnce({}); + + const results = await api.search('nothing'); + + expect(results.total).toBe(0); + expect(results.hits).toEqual([]); + }); + }); +});