Skip to content
Closed
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
12 changes: 11 additions & 1 deletion apps/desktop/electron/main/models-dev-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import type {
ThinkingProtocol,
ModelBinding,
} from "@pi-desktop/shared";
import { genericModelConfig, modelConfigWithBinding, type ModelConfig } from "@pi-desktop/agent-runtime";
import { genericModelConfig, modelConfigWithBinding, transcriptConfigFromPi, type ModelConfig } from "@pi-desktop/agent-runtime";
import { resolveBindingLimits } from "@pi-desktop/shared";

export const MODELS_DEV_API_URL = "https://models.dev/api.json";
Expand Down Expand Up @@ -1424,6 +1424,16 @@ export class ModelsDevCatalog {
const thinking = this.anthropicThinkingFor(input.modelId);
if (thinking) baseline = { ...baseline, ...thinking };
}
// Pi owns wire capabilities, independently of models.dev's limits/prices.
// Use the original published record, never an account/relay projection.
const key = input.vendorKey?.trim().toLowerCase();
const vendor = (key ? PI_VENDOR_ALIASES[key] ?? key : undefined) ?? this.providerKeyForRow(input);
const transport = vendor ? this.operationModels.getModel(vendor, input.modelId) : undefined;
if (transport) {
const transcript = transcriptConfigFromPi(transport);
baseline = { ...baseline, transcriptBinding: transcript.transcriptBinding,
compat: { ...baseline.compat, ...transcript.compat } };
}
if (!binding) return baseline;
const limits = resolveBindingLimits(baseline, binding);
return modelConfigWithBinding(limits.catalogConfig, limits.binding);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/lib/assistant-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function messageThinking(message: UiMessage): string {
}

function isVisibleMessage(message: UiMessage): boolean {
if (message.role === "system" && message.modelSystem) return false;
return !(
message.role === "assistant" &&
!(message.content || "").trim() &&
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/lib/transcript-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ function shape(message: UiMessage): MessageShape {
const value = {
content,
thinking,
visible: message.role !== "assistant" || content || thinking || Boolean(message.hostedSearch || message.error),
visible: !(message.role === "system" && message.modelSystem) &&
(message.role !== "assistant" || content || thinking || Boolean(message.hostedSearch || message.error)),
answer: message.parentToolCallId
? content || Boolean(message.error)
: content || !thinking || Boolean(message.error),
Expand All @@ -72,7 +73,7 @@ function canReplace(previous: UiMessage, next: UiMessage): boolean {
previous.parentToolCallId !== next.parentToolCallId ||
previous.agentName !== next.agentName || previous.createdAt !== next.createdAt ||
previous.toolCallId !== next.toolCallId || previous.toolName !== next.toolName ||
previous.hostedSearch !== next.hostedSearch
previous.hostedSearch !== next.hostedSearch || Boolean(previous.modelSystem) !== Boolean(next.modelSystem)
) return false;
if (isDelegationStartTool(previous.toolName) && (
previous.toolArgs !== next.toolArgs || previous.toolResult !== next.toolResult
Expand Down
7 changes: 3 additions & 4 deletions apps/desktop/test/context-compaction.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,11 @@ test("the hard boundary is enforced by the host, with a model-side escape hatch"
assert.match(runtime, /function contextFallbackReminder\(/);
assert.match(runtime, /contextReminderClaimed/);
assert.match(runtime, /contextFallbackReminderClaimed/);
// pi 0.86 carries the canonical system prompt in the transcript. The
// reminder is appended as a per-turn system message, not only as the legacy
// AgentContext.systemPrompt field.
// The reminder is appended as a replaceable system section so compaction
// can expire it without rewriting earlier instructions.
assert.match(
runtime,
/messages: \[\s*\.\.\.context\.messages,\s*\{\s*role: "system",\s*content: reminder,/,
/messages: \[\s*\.\.\.context\.messages,\s*\{\s*role: "system",\s*content: "",\s*sections: \{ \[CONTEXT_BUDGET_SECTION\]: reminder \}/,
);
assert.match(hostPermissions, /"new_context"/);
});
Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/test/models-dev-catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { createModels, InMemoryModelsStore } from "@earendil-works/pi-ai";
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
import { buildProviderModel } from "../../../packages/agent-runtime/dist/provider-binding.js";
import {
apiStyleForAdapter,
bindingForCustomModelInfo,
catalogModelIdsMatch,
modelIdsMatch,
resolveApiStyle,
} from "@pi-desktop/shared";
import {
MODELS_DEV_API_URL,
Expand Down Expand Up @@ -103,6 +107,43 @@ async function loadFixtureCatalog(t, fixture = catalogFixture) {
return catalog;
}

test("published metadata retains exact Pi transcript transport bindings through runtime launch", async (t) => {
const pi = createModels({ modelsStore: new InMemoryModelsStore(),
authContext: { env: async () => undefined, fileExists: async () => false } });
for (const provider of builtinProviders()) pi.setProvider(provider);
for (const [vendorKey, modelId] of [
["deepseek", "deepseek-flash"], ["anthropic", "claude-opus-5-5"],
["openai", "gpt-6.1-sol"], ["openai-codex", "gpt-6.1-sol"],
]) {
const original = pi.getModel(vendorKey, modelId);
assert.ok(original, `${vendorKey}/${modelId}`);
const catalog = await loadFixtureCatalog(t, { [vendorKey]: {
name: vendorKey, api: original.baseUrl, models: { [modelId]: {
id: modelId, limit: { context: 543_210, output: 40_000 }, cost: { input: 1.25, output: 2.5 },
} },
} });
const target = { providerId: "account", vendorKey, modelId, baseUrl: original.baseUrl,
apiStyle: resolveApiStyle(original.api) };
const config = catalog.modelConfigFor(target);
assert.equal(config.source, "models.dev");
assert.equal(config.contextWindow, 543_210);
assert.equal(config.maxTokens, 40_000);
assert.equal(config.cost.input, 1.25);
assert.deepEqual(config.transcriptBinding, { modelId, api: original.api, baseUrl: original.baseUrl });
const provider = { ...target, id: "account", name: "Fixture", apiKey: "", authKind: "none",
supportsReasoning: false, supportedThinkingLevels: ["off"], modelConfig: config };
const wire = buildProviderModel(provider);
assert.equal(wire.compat.supportsMidConvoSystemMessages, true, vendorKey);
assert.equal(wire.compat.supportsMidConvoToolChanges, original.compat?.supportsMidConvoToolChanges === true);
assert.equal(wire.compat.supportsAdditionalTools, original.compat?.supportsAdditionalTools === true);
const relay = { ...target, baseUrl: "https://relay.invalid/v1" };
assert.equal(buildProviderModel({ ...provider, ...relay, modelConfig: catalog.modelConfigFor(relay) })
.compat.supportsMidConvoSystemMessages, false);
assert.equal(buildProviderModel({ ...provider, modelId: `${modelId}-alias` })
.compat.supportsMidConvoSystemMessages, false);
}
});

test("the bundled models.dev OpenAI record supplies the selected model limits", async () => {
const catalogPath = fileURLToPath(new URL("../resources/models.dev/api.json", import.meta.url));
const catalog = new ModelsDevCatalog({ catalogPath });
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/test/transcript-projection.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ const message = (id, role, content, extra = {}) => ({
id, role, content, createdAt: "2026-09-30T00:00:00.000Z", ...extra,
});

test("model system records remain hidden after loading and live projection updates", () => {
const state = message("state", "system", "", { modelSystem: {
version: 1, beforeMessageId: "user", messageJson: JSON.stringify({ role: "system", content: "Instructions", timestamp: 1 }),
} });
const user = message("user", "user", "Hello");
const notice = message("notice", "system", "Visible notice");
let rows = [state, user, notice];
assert.deepEqual(assertProjection(rows).visible.map((row) => row.id), ["user", "notice"]);
rows = upsertLiveSessionMessage(rows, message("answer", "assistant", "Hello back"));
assert.deepEqual(assertProjection(rows).visible.map((row) => row.id), ["user", "notice", "answer"]);
assert.equal(rows[0], state);
});

function assertProjection(messages, compactions) {
const actual = getTranscriptProjection(messages, compactions);
const expected = buildTranscriptEntries(messages, compactions);
Expand Down
1 change: 1 addition & 0 deletions crates/host-core/src/plugin_sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ fn parse_message(
nested_parent_tool_call_id: None,
agent_name: None,
hosted_search: None,
model_system: None,
session_message: None,
})
}
Expand Down
28 changes: 28 additions & 0 deletions crates/host-core/src/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use std::sync::{Mutex, OnceLock};
use crate::transcripts::{self, CompactionRecord, MessageRecord, RevisionRecord};

mod fork_files;
mod model_system;
mod usage;
pub use usage::record_usage;

Expand Down Expand Up @@ -242,6 +243,9 @@ pub struct UiMessage {
/// as an additive `hostedSearch` transcript block; no SQL migration.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hosted_search: Option<Value>,
/// Internal model-context state, preserved outside visible message text.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_system: Option<Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -321,6 +325,9 @@ fn is_default_title(title: &str) -> bool {
/// the search index row (None for tool rows, matching the FTS triggers).
pub(crate) fn ui_to_record(message: &UiMessage) -> (MessageRecord, Option<String>) {
let mut meta_obj = serde_json::Map::new();
if let Some(system) = &message.model_system {
meta_obj.insert("modelSystem".into(), system.clone());
}
if let Some(command) = &message.command {
meta_obj.insert("command".into(), json!(command));
}
Expand Down Expand Up @@ -473,6 +480,7 @@ pub(crate) fn record_to_ui(record: MessageRecord) -> UiMessage {
_ => Vec::new(),
};
let meta = record.meta.unwrap_or(Value::Null);
let model_system = meta.get("modelSystem").cloned();
let command = meta
.get("command")
.and_then(Value::as_str)
Expand Down Expand Up @@ -646,6 +654,7 @@ pub(crate) fn record_to_ui(record: MessageRecord) -> UiMessage {
nested_parent_tool_call_id,
agent_name,
hosted_search: hosted_search.clone(),
model_system: None,
}
} else {
let content = blocks
Expand Down Expand Up @@ -690,6 +699,7 @@ pub(crate) fn record_to_ui(record: MessageRecord) -> UiMessage {
nested_parent_tool_call_id,
agent_name,
hosted_search,
model_system,
}
}
}
Expand Down Expand Up @@ -909,6 +919,19 @@ fn clone_records_for_fork(
.cloned()
.unwrap_or_else(|| Uuid::new_v4().to_string());
if let Some(meta) = record.meta.as_mut().and_then(Value::as_object_mut) {
if let Some(system) = meta.get_mut("modelSystem").and_then(Value::as_object_mut) {
for key in ["beforeMessageId", "afterMessageId"] {
if let Some(new_id) = system
.get(key)
.and_then(Value::as_str)
.and_then(|id| message_ids.get(id))
{
system.insert(key.into(), json!(new_id));
} else {
system.remove(key);
}
}
}
meta.remove("revisionRootId");
meta.remove("revisionCount");
meta.remove("activeRevision");
Expand Down Expand Up @@ -1915,6 +1938,7 @@ pub fn append_message(
message: &UiMessage,
turn_id: Option<&str>,
) -> Result<()> {
model_system::validate(message)?;
let message = crate::session_collaboration::prepare_append(db, session_id, message, turn_id)?;
let session_created = ensure_session_for_append(db, session_id)?;
let (mut record, text) = ui_to_record(&message);
Expand Down Expand Up @@ -3970,6 +3994,7 @@ mod tests {
nested_parent_tool_call_id: None,
agent_name: None,
hosted_search: None,
model_system: None,
session_message: None,
}
}
Expand Down Expand Up @@ -4622,6 +4647,7 @@ mod tests {
nested_parent_tool_call_id: None,
agent_name: None,
hosted_search: None,
model_system: None,
session_message: None,
};
append_message(&db, &session.id, &tool, None).unwrap();
Expand Down Expand Up @@ -5137,6 +5163,7 @@ mod tests {
nested_parent_tool_call_id: None,
agent_name: None,
hosted_search: None,
model_system: None,
session_message: None,
};
append_message(&db, &session.id, &assistant, None).unwrap();
Expand Down Expand Up @@ -5216,6 +5243,7 @@ mod tests {
parent_tool_call_id: None,
nested_parent_tool_call_id: None,
agent_name: None,
model_system: None,
hosted_search: Some(json!({
"status": "completed",
"rounds": [
Expand Down
108 changes: 108 additions & 0 deletions crates/host-core/src/sessions/model_system.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
use super::UiMessage;
use anyhow::{anyhow, Result};
use serde_json::Value;

/// Internal model state uses existing transcript metadata, never visible chat text.
pub(super) fn validate(row: &UiMessage) -> Result<()> {
let Some(record) = &row.model_system else {
return Ok(());
};
let invalid = || anyhow!("Invalid model system record");
if row.role != "system" || !row.content.is_empty() || record["version"] != 1 {
return Err(invalid());
}
for key in ["beforeMessageId", "afterMessageId"] {
if record
.get(key)
.is_some_and(|value| value.as_str().is_none_or(str::is_empty))
{
return Err(invalid());
}
}
let message: Value = serde_json::from_str(record["messageJson"].as_str().ok_or_else(invalid)?)
.map_err(|_| invalid())?;
if message["role"] != "system"
|| !message["content"].is_string()
|| !message["timestamp"]
.as_f64()
.is_some_and(|value| (0.0..=8.64e15).contains(&value))
{
return Err(invalid());
}
if let Some(sections) = message.get("sections") {
let sections = sections.as_object().ok_or_else(invalid)?;
if sections
.values()
.any(|value| !value.is_null() && !value.is_string())
{
return Err(invalid());
}
}
for key in ["toolsAdded", "toolsRemoved"] {
if let Some(tools) = message.get(key) {
for tool in tools.as_array().ok_or_else(invalid)? {
if tool
.get("name")
.and_then(Value::as_str)
.is_none_or(str::is_empty)
|| (key == "toolsAdded"
&& (!tool["description"].is_string() || !tool["parameters"].is_object()))
{
return Err(invalid());
}
}
}
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

fn row() -> UiMessage {
serde_json::from_value(json!({
"id":"state", "role":"system", "content":"", "createdAt":"2026-10-01T00:00:00Z",
"modelSystem":{"version":1,"beforeMessageId":"user","messageJson":json!({
"role":"system","content":"","timestamp":1,
"sections":{"skills":"catalog"},"toolsAdded":[{"name":"Read","description":"read","parameters":{}}]
}).to_string()}
})).unwrap()
}

#[test]
fn state_roundtrips_in_canonical_metadata_and_remaps_fork_anchors() {
let row = row();
validate(&row).unwrap();
let (record, text) = super::super::ui_to_record(&row);
assert!(text.as_deref().unwrap_or("").is_empty());
assert_eq!(
super::super::record_to_ui(record.clone()).model_system,
row.model_system
);
let mut user = row.clone();
user.id = "user".into();
user.role = "user".into();
user.model_system = None;
let (records, ids, _) =
super::super::clone_records_for_fork(vec![record, super::super::ui_to_record(&user).0]);
assert_eq!(
records[0].meta.as_ref().unwrap()["modelSystem"]["beforeMessageId"],
ids["user"]
);
}

#[test]
fn rejects_invalid_state_or_hiding_a_user_message() {
let mut row = row();
row.role = "user".into();
assert!(validate(&row).is_err());
row.role = "system".into();
row.model_system.as_mut().unwrap()["version"] = json!(2);
assert!(validate(&row).is_err());
row.model_system.as_mut().unwrap()["version"] = json!(1);
row.model_system.as_mut().unwrap()["messageJson"] = json!("invalid JSON");
assert!(validate(&row).is_err());
}
}
11 changes: 6 additions & 5 deletions docs/adr/0039-plugin-skills-activation-and-devkit.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,12 @@ giving them the loop — scaffold, run, inspect, package.
instructions.** Later text carries more weight, so a user's own instruction
files keep the last word and an installed plugin can refine the built-in
guidance but never the reverse.
3. **Runtime reuse keys on the catalog digest, not on bodies.** Enabling a
plugin, revoking `agent.prompt.inject` or renaming a skill changes the text
the model reads, so it retires the idle runtime rather than reusing a stale
prompt. An edit to a body needs no retirement: the `Skill` tool reads the file
at call time.
3. **Catalog changes update an idle runtime's skills section.** The chronological
system-transcript decision supersedes the original digest-based retirement:
enabling a plugin, revoking `agent.prompt.inject`, or renaming a skill appends
a section update before the next request. The digest excludes bodies; the
`Skill` tool reads the file and rechecks permissions at call time. Removing
the last catalog entry also removes the executable `Skill` declaration.
4. **Plugin authoring ships as a first-party devkit, not as a plugin.**
`@pi-desktop/plugin-devkit` owns scaffold, check and pack; three surfaces
share that one implementation — the `pi-plugin` CLI, the `PluginScaffold` /
Expand Down
Loading
Loading