Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
21 changes: 15 additions & 6 deletions docs/content/docs/(configuration)/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -463,18 +463,27 @@ At least one provider (legacy key or custom provider) must be configured.
| `compactor` | string | `anthropic/claude-haiku-4.5-20250514` | Model for summarization |
| `cortex` | string | `anthropic/claude-haiku-4.5-20250514` | Model for system observation |
| `rate_limit_cooldown_secs` | integer | 60 | How long to deprioritize a rate-limited model |
| `channel_thinking_effort` | string | `"auto"` | Internal reasoning effort hint for channel model (`"auto"`, `"max"`, `"high"`, `"medium"`, `"low"`) |
| `branch_thinking_effort` | string | `"auto"` | Internal reasoning effort hint for branch model (`"auto"`, `"max"`, `"high"`, `"medium"`, `"low"`) |
| `worker_thinking_effort` | string | `"auto"` | Internal reasoning effort hint for worker model (`"auto"`, `"max"`, `"high"`, `"medium"`, `"low"`) |
| `compactor_thinking_effort` | string | `"auto"` | Internal reasoning effort hint for compactor model (`"auto"`, `"max"`, `"high"`, `"medium"`, `"low"`) |
| `cortex_thinking_effort` | string | `"auto"` | Internal reasoning effort hint for cortex model (`"auto"`, `"max"`, `"high"`, `"medium"`, `"low"`) |

Routing selects providers by the prefix before the first `/` in the model name.

`*_thinking_effort` is provider-specific:
- Anthropic adaptive-thinking models use the value directly.
- `openai-chatgpt/*` models map this to Responses API `reasoning.effort` (`max|high -> high`, `medium -> medium`, `low -> low`, `auto -> omitted`).

Comment on lines +466 to +477

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Align the documented mapping with the runtime contract.

This section now documents max -> high for all openai-chatgpt/* models and omits minimal entirely. That will mislead users: the runtime already treats minimal as an alias, and GPT-5.4-family models need model-specific normalization rather than the generic max -> high rule. Please either document those exceptions here or tighten the parser to match the docs exactly.

Based on learnings: In src/llm/model.rs, GPT-5.4-family models intentionally support xhigh, and gpt-5.4-pro requires model-specific normalization rather than the generic low/medium/high mapping.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/`(configuration)/config.mdx around lines 466 - 477, Update
the docs to match the runtime behavior: explicitly list "minimal" as an accepted
alias for `*_thinking_effort`, document that `openai-chatgpt/*` models map
values to Responses API `reasoning.effort` with the noted normalization
(max|high -> high, medium -> medium, low -> low, auto -> omitted) but add
exceptions for GPT-5.4-family (which supports `xhigh`) and call out that
`gpt-5.4-pro` uses model-specific normalization rather than the generic mapping;
reference the runtime normalization logic in src/llm/model.rs so readers can see
the exact behavior or, if you prefer code-first, tighten the parser in
src/llm/model.rs to enforce the documented mapping instead of allowing the extra
aliases.

```toml
[defaults.routing]
channel = "my_openai/gpt-4o-mini"
worker = "custom_anthropic/claude-3-5-sonnet"
channel = "openai-chatgpt/gpt-5.3-codex"
worker = "openai-chatgpt/gpt-5.3-codex"
channel_thinking_effort = "high"
worker_thinking_effort = "medium"

[llm.provider.my_openai]
api_type = "openai_completions"
base_url = "https://api.openai.com"
api_key = "env:OPENAI_API_KEY"
[llm]
openai_key = "env:OPENAI_API_KEY"

[llm.provider.custom_anthropic]
api_type = "anthropic"
Expand Down
5 changes: 4 additions & 1 deletion prompts/en/branch.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,17 @@ Depending on why the channel branched, you might:
## Tools

### memory_recall
Search for relevant memories. Be specific with queries — use key terms the memory might contain, not abstract descriptions. You'll get curated results ranked by relevance. Use these to inform your conclusion.
Search for relevant memories. Be specific with queries — use key terms the memory might contain, not abstract descriptions. You'll get curated results ranked by relevance. Use these to inform your conclusion. For recency-oriented asks ("what did we discuss recently?", "last talk"), prefer `mode: "recent"` instead of hybrid.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Separate saved-memory recall from transcript recall.

Line 31 and Lines 39-40 give very similar examples to different tools. Since memory_recall searches saved long-term memories and channel_recall searches persisted transcript messages, this ambiguity can send the branch to the wrong store and miss recent discussion that was never saved as memory.

Proposed fix
 ### memory_recall
-Search for relevant memories. Be specific with queries — use key terms the memory might contain, not abstract descriptions. You'll get curated results ranked by relevance. Use these to inform your conclusion. For recency-oriented asks ("what did we discuss recently?", "last talk"), prefer `mode: "recent"` instead of hybrid.
+Search for relevant memories. Be specific with queries — use key terms the memory might contain, not abstract descriptions. You'll get curated results ranked by relevance. Use these to inform your conclusion. For recency-oriented asks about recently saved facts, decisions, preferences, goals, or todos, prefer `mode: "recent"` instead of hybrid.

 ### channel_recall
-Recall transcript history from any channel, including this one. Use this for "what did we talk about last time?" and similar requests where exact conversation history matters. This reads persisted conversation messages, not long-term memory summaries.
+Recall transcript history from any channel. Use this when the user wants exact prior conversation history rather than long-term memory summaries. For the current channel's active conversation, prefer the history you already have; use `channel_recall` when you need persisted transcript from another channel or older transcript outside your current context.

Also applies to: 39-40

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prompts/en/branch.md.j2` at line 31, Update the guidance in
prompts/en/branch.md.j2 to remove the ambiguity between memory_recall and
channel_recall: explicitly state that memory_recall searches saved long-term
memories while channel_recall searches persisted transcript messages, replace
the overlapping example on Line 31 (and similarly on Lines 39-40) with distinct,
tool-appropriate examples (e.g., use memory_recall with key-term queries for
historical facts and channel_recall with mode: "recent" for "what did we discuss
recently?" queries), and ensure the text instructs the branch to pick
channel_recall for recency-oriented asks and memory_recall for long-term memory
lookup.


### memory_save
Save something important that came up during your thinking. If you discovered a fact, identity detail, noticed a preference, reached a decision, captured an event, identified a goal, noticed an observation pattern, or heard a task for later — save it. The channel doesn't save memories — that's your job.

### memory_delete
Forget a memory by ID. Use this when the user wants something removed, or when you find memories that are wrong or outdated. Get memory IDs from memory_recall results. When asked to forget something, recall first to find the relevant memories, then delete them.

### channel_recall
Recall transcript history from any channel, including this one. Use this for "what did we talk about last time?" and similar requests where exact conversation history matters. This reads persisted conversation messages, not long-term memory summaries.

### spacebot_docs
Read embedded Spacebot docs, including `AGENTS.md`, `CHANGELOG.md`, and product docs from `docs/content/`. Use `action: "list"` to discover IDs, then `action: "read"` for the specific document.

Expand Down
2 changes: 1 addition & 1 deletion prompts/en/tools/memory_recall_description.md.j2
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Search and recall memories from the memory store. Supports multiple search modes: "hybrid" (semantic + keyword + graph search, requires a query), "recent" (most recent memories by time), "important" (highest importance memories), and "typed" (filter by memory type). Default mode is hybrid.
Search and recall memories from the memory store. Supports multiple search modes: "hybrid" (semantic + keyword + graph search, requires a query), "recent" (most recent memories by time), "important" (highest importance memories), and "typed" (filter by memory type). Default mode is hybrid. For "what did we discuss recently?" style queries, prefer "recent" mode.
40 changes: 40 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1854,4 +1854,44 @@ command = "/usr/bin/test"
// The mcp_servers data is silently dropped — verify it's not accessible
assert!(parsed.defaults.mcp.is_empty());
}

#[test]
fn top_level_memory_persistence_is_still_honored() {
let _guard = env_test_lock().lock();
let guard = EnvGuard::new();

let toml_content = r#"
[memory_persistence]
enabled = true
message_interval = 7
"#;
let config_path = guard.test_dir.join("config.toml");
std::fs::write(&config_path, toml_content).unwrap();

let config = Config::load_from_path(&config_path).unwrap();
assert!(config.defaults.memory_persistence.enabled);
assert_eq!(config.defaults.memory_persistence.message_interval, 7);
}

#[test]
fn defaults_memory_persistence_overrides_top_level_legacy() {
let _guard = env_test_lock().lock();
let guard = EnvGuard::new();

let toml_content = r#"
[memory_persistence]
enabled = false
message_interval = 99

[defaults.memory_persistence]
enabled = true
message_interval = 5
"#;
let config_path = guard.test_dir.join("config.toml");
std::fs::write(&config_path, toml_content).unwrap();

let config = Config::load_from_path(&config_path).unwrap();
assert!(config.defaults.memory_persistence.enabled);
assert_eq!(config.defaults.memory_persistence.message_interval, 5);
}
}
11 changes: 11 additions & 0 deletions src/config/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub fn set_resolve_secrets_store(store: std::sync::Arc<crate::secrets::store::Se
const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
"llm",
"defaults",
"memory_persistence",
"agents",
"links",
"groups",
Expand All @@ -90,6 +91,15 @@ pub(super) fn warn_unknown_config_keys(content: &str) {
};

for key in table.keys() {
if key == "memory_persistence" {
tracing::warn!(
"config.toml contains top-level key `memory_persistence`. \
This legacy location is still supported, but prefer \
[defaults.memory_persistence] for new configs."
);
continue;
}

if KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str()) {
continue;
}
Expand Down Expand Up @@ -1403,6 +1413,7 @@ impl Config {
memory_persistence: toml
.defaults
.memory_persistence
.or(toml.memory_persistence)
.map(|mp| MemoryPersistenceConfig {
enabled: mp
.enabled
Expand Down
2 changes: 2 additions & 0 deletions src/config/toml_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ pub(super) struct TomlConfig {
#[serde(default)]
pub(super) defaults: TomlDefaultsConfig,
#[serde(default)]
pub(super) memory_persistence: Option<TomlMemoryPersistenceConfig>,
#[serde(default)]
pub(super) agents: Vec<TomlAgentConfig>,
#[serde(default)]
pub(super) links: Vec<TomlLinkDef>,
Expand Down
53 changes: 48 additions & 5 deletions src/llm/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,18 +545,33 @@ impl CompletionModel for SpacebotModel {
}

impl SpacebotModel {
fn configured_thinking_effort(&self) -> &str {
let Some(routing) = self.routing.as_ref() else {
return "auto";
};

if let Some(process_type) = self.process_type.as_deref() {
return match process_type {
"channel" => routing.channel_thinking_effort.as_str(),
"branch" => routing.branch_thinking_effort.as_str(),
"worker" => routing.worker_thinking_effort.as_str(),
"compactor" => routing.compactor_thinking_effort.as_str(),
"cortex" => routing.cortex_thinking_effort.as_str(),
_ => routing.thinking_effort_for_model(&self.model_name),
};
}

routing.thinking_effort_for_model(&self.model_name)
}
Comment on lines +558 to +570

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like thinking_effort_for_model() is comparing against provider/model strings from routing, but this fallback is passing self.model_name (suffix only). Using self.full_model_name should make the fallback work when process_type is unknown/None.

Suggested change
if let Some(process_type) = self.process_type.as_deref() {
return match process_type {
"channel" => routing.channel_thinking_effort.as_str(),
"branch" => routing.branch_thinking_effort.as_str(),
"worker" => routing.worker_thinking_effort.as_str(),
"compactor" => routing.compactor_thinking_effort.as_str(),
"cortex" => routing.cortex_thinking_effort.as_str(),
_ => routing.thinking_effort_for_model(&self.model_name),
};
}
routing.thinking_effort_for_model(&self.model_name)
}
if let Some(process_type) = self.process_type.as_deref() {
return match process_type {
"channel" => routing.channel_thinking_effort.as_str(),
"branch" => routing.branch_thinking_effort.as_str(),
"worker" => routing.worker_thinking_effort.as_str(),
"compactor" => routing.compactor_thinking_effort.as_str(),
"cortex" => routing.cortex_thinking_effort.as_str(),
_ => routing.thinking_effort_for_model(&self.full_model_name),
};
}
routing.thinking_effort_for_model(&self.full_model_name)


async fn call_anthropic(
&self,
request: CompletionRequest,
provider_config: &ProviderConfig,
) -> Result<completion::CompletionResponse<RawResponse>, CompletionError> {
let api_key = provider_config.api_key.as_str();

let effort = self
.routing
.as_ref()
.map(|r| r.thinking_effort_for_model(&self.model_name))
.unwrap_or("auto");
let effort = self.configured_thinking_effort();
let anthropic_request = crate::llm::anthropic::build_anthropic_request(
self.llm_manager.http_client(),
api_key,
Expand Down Expand Up @@ -766,6 +781,12 @@ impl SpacebotModel {
body["stream"] = serde_json::json!(true);
}

if self.provider == "openai-chatgpt"
&& let Some(effort) = map_openai_reasoning_effort(self.configured_thinking_effort())
{
body["reasoning"] = serde_json::json!({ "effort": effort });
}

if !request.tools.is_empty() {
let tools: Vec<serde_json::Value> = request
.tools
Expand Down Expand Up @@ -2859,6 +2880,16 @@ fn provider_display_name(provider_id: &str) -> String {
}
}

fn map_openai_reasoning_effort(config_effort: &str) -> Option<&'static str> {
match config_effort.trim().to_ascii_lowercase().as_str() {
"auto" => None,
"max" | "high" => Some("high"),
"medium" => Some("medium"),
"low" | "minimal" => Some("low"),
_ => None,
}
Comment on lines +2938 to +2945

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep the OpenAI mapper model-aware.

This helper now collapses max to high for every openai-chatgpt model. That regresses the existing GPT-5.4 behavior: max should stay xhigh for the GPT-5.4 family, and gpt-5.4-pro also needs its inputs normalized to the subset it actually supports. Since Line 785 now funnels all ChatGPT reasoning through this helper, those configs will either undershoot or become invalid.

Proposed fix
-fn map_openai_reasoning_effort(config_effort: &str) -> Option<&'static str> {
-    match config_effort.trim().to_ascii_lowercase().as_str() {
+fn map_openai_reasoning_effort(model_name: &str, config_effort: &str) -> Option<&'static str> {
+    let normalized_model_name = model_name.trim().to_ascii_lowercase();
+    let is_gpt_5_4_pro = normalized_model_name.starts_with("gpt-5.4-pro");
+    let is_gpt_5_4_family = normalized_model_name.starts_with("gpt-5.4");
+
+    match config_effort.trim().to_ascii_lowercase().as_str() {
         "auto" => None,
-        "max" | "high" => Some("high"),
+        "max" if is_gpt_5_4_family => Some("xhigh"),
+        "high" | "max" => Some("high"),
         "medium" => Some("medium"),
-        "low" | "minimal" => Some("low"),
+        "low" | "minimal" if is_gpt_5_4_pro => Some("medium"),
+        "low" | "minimal" => Some("low"),
         _ => None,
     }
 }

Update the call site accordingly:

-        if self.provider == "openai-chatgpt"
-            && let Some(effort) = map_openai_reasoning_effort(self.configured_thinking_effort())
+        if self.provider == "openai-chatgpt"
+            && let Some(effort) =
+                map_openai_reasoning_effort(&self.model_name, self.configured_thinking_effort())
         {
             body["reasoning"] = serde_json::json!({ "effort": effort });
         }

Based on learnings: In src/llm/model.rs, the openai_reasoning_effort mapping must preserve GPT-5.4-family normalization, specifically max -> xhigh, and gpt-5.4-pro requires normalization to its supported subset.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/llm/model.rs` around lines 2883 - 2890, The helper
map_openai_reasoning_effort currently collapses "max" to "high" for all chat
models; update it to be model-aware by adding a model parameter (e.g., model_id:
&str) or by adding a separate branch/variant and use that when called from
openai_reasoning_effort so GPT-5.4-family preserves "max" -> "xhigh" while other
chat models keep "max" -> "high"; also ensure gpt-5.4-pro inputs are normalized
to the exact subset it supports (map unsupported strings to the closest
supported values for "gpt-5.4-pro"); change the call site that funnels ChatGPT
through map_openai_reasoning_effort (where openai_reasoning_effort is invoked)
to pass the model identifier so the helper can apply the GPT-5.4-specific rules.

}

fn remap_model_name_for_api(provider: &str, model_name: &str) -> String {
if provider == "zai-coding-plan" {
// Coding Plan endpoint expects plain model ids (e.g. "glm-5").
Expand Down Expand Up @@ -2916,6 +2947,18 @@ mod tests {
panic!("expected ToolCall");
}
}

#[test]
fn map_openai_reasoning_effort_maps_values() {
assert_eq!(map_openai_reasoning_effort("auto"), None);
assert_eq!(map_openai_reasoning_effort("max"), Some("high"));
assert_eq!(map_openai_reasoning_effort("high"), Some("high"));
assert_eq!(map_openai_reasoning_effort("medium"), Some("medium"));
assert_eq!(map_openai_reasoning_effort("low"), Some("low"));
assert_eq!(map_openai_reasoning_effort("minimal"), Some("low"));
assert_eq!(map_openai_reasoning_effort("invalid"), None);
}

#[test]
fn coding_plan_model_name_uses_plain_glm_id() {
assert_eq!(
Expand Down