Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions examples/web-chat/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# @smooai/smooth-operator-web-chat-example

## 0.0.5

### Patch Changes

- Updated dependencies
- @smooai/smooth-operator@1.22.12

## 0.0.4

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion examples/web-chat/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@smooai/smooth-operator-web-chat-example",
"version": "0.0.4",
"version": "0.0.5",
"private": true,
"description": "A smooth-web-like Vite + React chat client that drives a running smooth-operator server over its WebSocket protocol — token streaming, inline tool-call/result blocks, a conversation sidebar, and oldest-first history, all on top of the published @smooai/smooth-operator SDK.",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion go/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ package e2e
// language artifacts. The real Go "publish" is a git tag (go/v<Version>); this
// constant is the anchor that scripts/sync-versions.mjs keeps in sync with the
// canonical npm version on every changeset release.
const Version = "1.22.11"
const Version = "1.22.12"
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "smooai-smooth-operator"
version = "1.22.11"
version = "1.22.12"
description = "Python protocol types and native async WebSocket client for the smooth-operator protocol. Generated from the language-neutral JSON Schemas in spec/."
readme = "README.md"
license = { text = "MIT" }
Expand Down
20 changes: 10 additions & 10 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ repository = "https://github.com/SmooAI/smooth-operator"
# external `cargo build` resolve the broken 0.16.0 and fail to compile. Pin the
# floor at 0.16.1. (The prior `git`/`rev = d03fa10` pin was a stopgap for exactly
# that unpublished-API gap, now closed.)
smooai-smooth-operator-core = "0.16.1"
smooai-smooth-operator-core = "0.16.2"
# Intra-workspace dep on the reference lib carries its version so the adapters /
# ingestion / server that depend on it are publishable (path = local dev,
# version = the crates.io requirement).
smooth-operator = { package = "smooai-smooth-operator", path = "smooth-operator", version = "1.22.11" }
smooth-operator = { package = "smooai-smooth-operator", path = "smooth-operator", version = "1.22.12" }

async-trait = "0.1"
anyhow = "1"
Expand Down
2 changes: 1 addition & 1 deletion rust/adapters/backplane-nats/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "smooai-smooth-operator-adapter-backplane-nats"
version = "1.22.11"
version = "1.22.12"
edition.workspace = true
license.workspace = true
repository.workspace = true
Expand Down
2 changes: 1 addition & 1 deletion rust/adapters/backplane-redis/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "smooai-smooth-operator-adapter-backplane-redis"
version = "1.22.11"
version = "1.22.12"
edition.workspace = true
license.workspace = true
repository.workspace = true
Expand Down
4 changes: 2 additions & 2 deletions rust/adapters/dynamodb/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "smooai-smooth-operator-adapter-dynamodb"
version = "1.22.11"
version = "1.22.12"
edition.workspace = true
license.workspace = true
repository.workspace = true
Expand All @@ -19,7 +19,7 @@ s3-vectors = ["dep:aws-sdk-s3vectors", "dep:aws-smithy-types"]
smooth-operator = { workspace = true }
smooai-smooth-operator-core = { workspace = true }
# IndexingStore / IndexingRun for the persistent admin indexing-runs store.
smooai-smooth-operator-ingestion = { path = "../../ingestion", version = "1.22.11" }
smooai-smooth-operator-ingestion = { path = "../../ingestion", version = "1.22.12" }
async-trait = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion rust/adapters/in-memory/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "smooai-smooth-operator-adapter-memory"
version = "1.22.11"
version = "1.22.12"
edition.workspace = true
license.workspace = true
repository.workspace = true
Expand Down
4 changes: 2 additions & 2 deletions rust/adapters/postgres/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "smooai-smooth-operator-adapter-postgres"
version = "1.22.11"
version = "1.22.12"
edition.workspace = true
license.workspace = true
repository.workspace = true
Expand All @@ -15,7 +15,7 @@ smooth-operator = { workspace = true }
# `postgres` feature pulls in PostgresCheckpointStore (sync r2d2 path).
smooai-smooth-operator-core = { workspace = true, features = ["postgres"] }
# IndexingStore / IndexingRun for the persistent admin indexing-runs store.
smooai-smooth-operator-ingestion = { path = "../../ingestion", version = "1.22.11" }
smooai-smooth-operator-ingestion = { path = "../../ingestion", version = "1.22.12" }
async-trait = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion rust/ingestion/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "smooai-smooth-operator-ingestion"
version = "1.22.11"
version = "1.22.12"
edition.workspace = true
license.workspace = true
repository.workspace = true
Expand Down
14 changes: 7 additions & 7 deletions rust/smooth-operator-server/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "smooai-smooth-operator-server"
version = "1.22.11"
version = "1.22.12"
edition.workspace = true
license.workspace = true
repository.workspace = true
Expand Down Expand Up @@ -35,22 +35,22 @@ smooth-operator = { workspace = true }
smooai-smooth-operator-core = { workspace = true }
# In-memory storage + backplane — ALWAYS included. The local-flavor default and
# the lean (`--no-default-features`) build run entirely on these.
smooai-smooth-operator-adapter-memory = { path = "../adapters/in-memory", version = "1.22.11" }
smooai-smooth-operator-adapter-memory = { path = "../adapters/in-memory", version = "1.22.12" }
# Persistent storage + admin-store backends, selected at runtime to match the
# configured storage backend (Postgres / DynamoDB; default in-memory). Optional:
# gated behind the `postgres` / `dynamodb` features so a lean local/embed build
# can exclude tokio-postgres / the AWS SDK. The `postgres` crate also provides the
# gateway-backed embedder/reranker, so the `postgres` feature additionally enables
# the semantic-retrieval path in `embedder.rs` / `reranker.rs`.
smooai-smooth-operator-adapter-postgres = { path = "../adapters/postgres", version = "1.22.11", optional = true }
smooai-smooth-operator-adapter-dynamodb = { path = "../adapters/dynamodb", version = "1.22.11", optional = true }
smooai-smooth-operator-adapter-postgres = { path = "../adapters/postgres", version = "1.22.12", optional = true }
smooai-smooth-operator-adapter-dynamodb = { path = "../adapters/dynamodb", version = "1.22.12", optional = true }
# Distributed Backplane backends for horizontal scale-out, selected at runtime
# via SMOOTH_AGENT_BACKPLANE (default in-memory / single-process). Optional: gated
# behind the `redis` / `nats` features so a lean build excludes their drivers.
smooai-smooth-operator-adapter-backplane-redis = { path = "../adapters/backplane-redis", version = "1.22.11", optional = true }
smooai-smooth-operator-adapter-backplane-nats = { path = "../adapters/backplane-nats", version = "1.22.11", optional = true }
smooai-smooth-operator-adapter-backplane-redis = { path = "../adapters/backplane-redis", version = "1.22.12", optional = true }
smooai-smooth-operator-adapter-backplane-nats = { path = "../adapters/backplane-nats", version = "1.22.12", optional = true }
# Admin API surfaces indexing-run status via the ingestion crate's IndexingStore.
smooai-smooth-operator-ingestion = { path = "../ingestion", version = "1.22.11" }
smooai-smooth-operator-ingestion = { path = "../ingestion", version = "1.22.12" }

async-trait = { workspace = true }
anyhow = { workspace = true }
Expand Down
119 changes: 118 additions & 1 deletion rust/smooth-operator-server/src/suggestions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,14 @@ Rules:
#[must_use]
pub fn extract_suggested_replies(reply: &str) -> (String, Vec<String>) {
let Some(start) = reply.rfind(MARKER_OPEN) else {
return (reply.to_string(), Vec::new());
// Some models (gpt-oss-120b) ignore the marker contract and instead end
// the reply with a markdown "Suggested replies:" list. Parse that as a
// fallback so chips still populate; otherwise leave the reply untouched.
// ponytail: responseParts is cleaned here; the raw list can still flash in
// the live token stream (MarkerSuppressor only hides <suggested_replies>) —
// pearl th-gptoss-chips-stream if that becomes visible enough to matter.
return extract_markdown_suggested_replies(reply)
.unwrap_or_else(|| (reply.to_string(), Vec::new()));
};
let after_open = start + MARKER_OPEN.len();
let (body, end) = match reply[after_open..].find(MARKER_CLOSE) {
Expand Down Expand Up @@ -87,6 +94,70 @@ pub fn extract_suggested_replies(reply: &str) -> (String, Vec<String>) {
(clean, suggestions)
}

/// Fallback for models that ignore the `<suggested_replies>` marker and instead
/// end the reply with a markdown `Suggested replies:` list (observed on
/// gpt-oss-120b, TP agent's default model). Returns the cleaned reply (list
/// stripped) + parsed items, or `None` when there is no such trailing list.
/// Conservative: fires only when a `suggested replies:` header is followed by a
/// pure bullet/numbered list to the end of the reply, so a normal list in the
/// body is never mistaken for chips.
fn extract_markdown_suggested_replies(reply: &str) -> Option<(String, Vec<String>)> {
let (start, header_len) = ["suggested replies:", "suggested_replies:"]
.iter()
.filter_map(|h| rfind_ci(reply, h).map(|p| (p, h.len())))
.max_by_key(|&(p, _)| p)?;
// The header must own its line — the list starts on the NEXT line. Anything
// else after the header on the same line means it's prose, not a trailer.
let after = &reply[start + header_len..];
let nl = after.find('\n')?;
let mut items = Vec::new();
for line in after[nl + 1..].lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
// Every non-empty line must be a bullet (-, *, •) or "N." / "N)" item;
// any prose line means this isn't a clean chip trailer.
let bulleted = t.trim_start_matches(['-', '*', '•']);
let item = if bulleted.len() < t.len() {
bulleted.trim_start()
} else if let Some(i) = t
.find(['.', ')'])
.filter(|&i| i > 0 && t[..i].bytes().all(|b| b.is_ascii_digit()))
{
t[i + 1..].trim_start()
} else {
return None;
};
let item = item.trim().trim_matches(['"', '\'', '`']).trim();
if item.is_empty() {
return None;
}
items.push(item.to_string());
}
if items.is_empty() {
return None;
}
let clean = reply[..start].trim_end().to_string();
Some((clean, items.into_iter().take(MAX_SUGGESTIONS).collect()))
}

/// Case-insensitive `rfind` returning a byte offset into `haystack` (ASCII
/// needle ⇒ the offset is always a char boundary). Avoids allocating a
/// lowercased copy, which would desync offsets on non-ASCII text.
fn rfind_ci(haystack: &str, needle_lower: &str) -> Option<usize> {
let (hb, nb) = (haystack.as_bytes(), needle_lower.as_bytes());
if nb.is_empty() || hb.len() < nb.len() {
return None;
}
(0..=hb.len() - nb.len()).rev().find(|&i| {
hb[i..i + nb.len()]
.iter()
.zip(nb)
.all(|(a, b)| a.eq_ignore_ascii_case(b))
})
}

/// Streaming hold-back so the trailer never flashes in the live token stream.
///
/// Feed each token delta through [`push`](Self::push) and forward only what it
Expand Down Expand Up @@ -194,6 +265,52 @@ mod tests {
assert!(sug.is_empty(), "truncated JSON parses to nothing");
}

#[test]
fn gptoss_markdown_list_becomes_chips() {
// Real gpt-oss-120b shape: ignores the marker, ends with a quoted list.
let (clean, sug) = extract_suggested_replies(
"Could you tell me your current process maturity level?\n\nsuggested_replies:\n- \"I'm at the initial stage.\"\n- \"We have defined processes.\"\n- \"Our processes are mature.\"\n- \"Explain the maturity model?\"",
);
assert_eq!(
clean,
"Could you tell me your current process maturity level?"
);
assert_eq!(
sug,
vec![
"I'm at the initial stage.",
"We have defined processes.",
"Our processes are mature.",
"Explain the maturity model?"
]
);
}

#[test]
fn markdown_list_handles_numbered_and_header_casing() {
let (clean, sug) =
extract_suggested_replies("Pick one.\n\n**Suggested Replies:**\n1. Yes\n2) No");
assert_eq!(clean, "Pick one.\n\n**"); // trailing "**" before the header is left as-is
assert_eq!(sug, vec!["Yes", "No"]);
}

#[test]
fn markdown_fallback_ignores_a_body_list_without_the_header() {
let reply = "Here are the steps:\n- do a\n- do b";
let (clean, sug) = extract_suggested_replies(reply);
assert_eq!(clean, reply); // no "suggested replies:" header ⇒ untouched
assert!(sug.is_empty());
}

#[test]
fn markdown_fallback_bails_when_header_is_followed_by_prose() {
// A header followed by a non-list line is not a clean chip trailer.
let reply = "Suggested replies: I don't have any right now.";
let (clean, sug) = extract_suggested_replies(reply);
assert_eq!(clean, reply);
assert!(sug.is_empty());
}

#[test]
fn suppressor_passes_plain_text_through() {
let mut s = MarkerSuppressor::new();
Expand Down
2 changes: 1 addition & 1 deletion rust/smooth-operator/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "smooai-smooth-operator"
version = "1.22.11"
version = "1.22.12"
edition.workspace = true
license.workspace = true
repository.workspace = true
Expand Down
Loading
Loading