diff --git a/.env.example b/.env.example index 0393fff..f83e574 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,2 @@ -OPENAI_API_KEY= -OPENAI_MODEL= -OPENAI_RESPONSES_BASE_URL=https://api.openai.com/v1 -OPENAI_REASONING_EFFORT= -OPENAI_MAX_OUTPUT_TOKENS= +SANTI_CONFIG= +SANTI_PROVIDER= diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml index 3f16c04..8d0e6aa 100644 --- a/.github/workflows/guard.yml +++ b/.github/workflows/guard.yml @@ -128,9 +128,10 @@ jobs: - name: Start API env: - OPENAI_API_KEY: ci-placeholder - OPENAI_MODEL: ci-placeholder - OPENAI_RESPONSES_BASE_URL: https://api.openai.com/v1 + SANTI_CONFIG: santi.example.toml + SANTI_PROVIDERS_OPENAI_API_KEY: ci-placeholder + SANTI_PROVIDERS_OPENAI_MODEL: ci-placeholder + SANTI_PROVIDERS_OPENAI_BASE_URL: https://api.openai.com/v1 SANTI_HOST: 127.0.0.1 SANTI_DB: .tmp/ci.sqlite SANTI_PORT: 43307 diff --git a/.gitignore b/.gitignore index 97d468b..9896036 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store .env .env.local +santi.toml .local/ .task/ .playwright-cli/ diff --git a/AGENTS.md b/AGENTS.md index a9a3199..3c23679 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,8 @@ Keep directory ownership strict while keeping the product semantics small. - single-person conversations with one configured AI assistant - normalized message and response-run persistence -- provider-abstracted model streaming with an OpenAI Responses implementation +- provider-abstracted model streaming with OpenAI Responses and DeepSeek Chat + Completions implementations - a Rust server with OpenAPI-exported contracts - a web client generated against those contracts @@ -35,7 +36,7 @@ Keep directory ownership strict while keeping the product semantics small. - delivery targets, delivery workers, or retry leases - product-specific sidecar semantics beyond the local cell/soma dev runtime - Tauri, native macOS projections, or packaged platform launchers -- legacy OpenAI completions or chat completions compatibility +- legacy OpenAI completions compatibility ## Repository Structure @@ -98,6 +99,15 @@ plane and must stay limited to cell lifecycle facts. diagnostic, log, database, and temp paths must come from store. - Inspect socket paths are transport addresses, not persistent app paths. They may use OS IPC locations but must not become storage roots. +- Workspace context uses core-owned URI schemes as the only semantic truth + source: + - `soul://` is the current soul workspace. + - `session://` is the current session workspace. + - `soul://MEMORY.md` and `session://MEMORY.md` are the canonical memory + resources rendered into the system prompt. + - Tool inputs, system prompt material, and UI/material source labels must use + these URI schemes through core constants/helpers, not hand-written strings. + - Do not use or accept old `@` workspace aliases. ## Execution Rules @@ -113,8 +123,8 @@ plane and must stay limited to cell lifecycle facts. `mini-stim-server-soma` bin. - `packages/contracts` must be generated from the Rust OpenAPI source of truth. - Do not hand-maintain divergent client/server DTOs. -- Use the provider boundary even when only OpenAI is configured. Do not add - legacy completions paths. +- Use the provider boundary even when only one concrete provider is configured. + Do not add legacy OpenAI completions paths. - If the expected local environment or command is unavailable, report the missing prerequisite directly and stop that path. Do not spend time inventing fallbacks or exploring unrelated environment workarounds unless the user asks. @@ -388,14 +398,25 @@ surface before considering any alternate browser layer. ## Environment -`.env` is local and ignored by git. Required OpenAI settings: +Local app config defaults to `/santi.toml`, created from the committed +`santi.example.toml`. `santi.toml` is ignored by git and owns local API keys, +model parameters, and the default active provider. ```text -OPENAI_API_KEY= -OPENAI_MODEL= -OPENAI_RESPONSES_BASE_URL=https://api.openai.com/v1 -OPENAI_REASONING_EFFORT= -OPENAI_MAX_OUTPUT_TOKENS= +cp santi.example.toml santi.toml +``` + +Config resolution is modeled by `mini-stim-server-soma`'s `ConfigService`. +Config path resolves as: + +```text +--config > SANTI_CONFIG > ./santi.toml +``` + +Provider selection resolves as: + +```text +--provider > config.provider > SANTI_PROVIDER > openai ``` Server soma settings: diff --git a/Cargo.lock b/Cargo.lock index 147e2a0..68b5b45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -190,6 +240,46 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.58" @@ -199,6 +289,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -688,6 +784,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -935,6 +1037,7 @@ version = "0.1.0" dependencies = [ "async-stream", "axum", + "clap", "dotenvy", "futures-core", "futures-util", @@ -943,6 +1046,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "toml", "tower-http", "utoipa", ] @@ -964,6 +1068,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -1504,6 +1614,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1588,6 +1707,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1733,6 +1858,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -1842,6 +2006,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "utoipa" version = "5.5.0" @@ -2228,6 +2398,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index d7401f5..5cc6945 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ async-stream = "0.3.6" async-trait = "0.1.89" axum = { version = "0.8.9", features = ["macros"] } bytes = "1.11.1" +clap = { version = "4.6.1", features = ["derive"] } dotenvy = "0.15.7" futures-core = "0.3.32" futures-util = "0.3.32" @@ -37,6 +38,7 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" tokio = { version = "1.52.3", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tokio-stream = "0.1.18" +toml = "1.1.2" tower-http = { version = "0.6.11", features = ["cors", "trace"] } utoipa = { version = "5.5.0", features = ["axum_extras"] } uuid = { version = "1.23.3", features = ["v4"] } diff --git a/README.md b/README.md index 2150ab3..55bd756 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,13 @@ packages/ components/ ``` -The only product loop is web client -> Rust server -> OpenAI Responses API +The only product loop is web client -> Rust server -> provider-abstracted model streaming -> SQLite transcript. `santi-core` owns the chat domain and persistence boundary. `santi-provider` -owns model-provider traits plus the concrete OpenAI provider implementation used -by the server soma. The root `sidecar.toml` starts project-local cells, and each -cell manages its sidecar-unaware soma. +owns model-provider traits plus concrete provider implementations used by the +server soma. The root `sidecar.toml` starts project-local cells, and each cell +manages its sidecar-unaware soma. Terminology: @@ -45,7 +45,7 @@ Terminology: ## Scope - Single-person conversations. -- OpenAI Responses API native streaming. +- Provider-abstracted model streaming. - Normalized local SQLite persistence. - Rust server soma with OpenAPI export. - Generated TypeScript contracts. @@ -57,7 +57,7 @@ Out of scope: - Multiplayer chat. - Agent registries, agent runtimes, delivery workers, product-specific sidecar semantics, Tauri, and native projections. -- Legacy completions or chat completions compatibility. +- Legacy OpenAI completions compatibility. Modeled in core but deliberately not yet surfaced in the web UI. Each entry names its unlock condition so deferral stays distinguishable from neglect: @@ -82,16 +82,22 @@ names its unlock condition so deferral stays distinguishable from neglect: ## Setup -Create `.env` from `.env.example` and fill: +Create local app config from `santi.example.toml`: -```text -OPENAI_API_KEY= -OPENAI_MODEL= -OPENAI_RESPONSES_BASE_URL=https://api.openai.com/v1 -OPENAI_REASONING_EFFORT= -OPENAI_MAX_OUTPUT_TOKENS= +```sh +cp santi.example.toml santi.toml ``` +Fill the selected profile in `santi.toml`. The file is ignored by git because +it owns local API keys and model parameters. + +The default config path is `/santi.toml`. Override it with `--config` or +`SANTI_CONFIG`. Provider selection resolves as +`--provider > config.provider > SANTI_PROVIDER > openai`. + +Use `provider = "siliconflow"` or `--provider siliconflow` for GLM-5.2 through +SiliconFlow. + Install dependencies: ```sh diff --git a/apps/client/soma/web/src/App.tsx b/apps/client/soma/web/src/App.tsx index eb34370..2418b08 100644 --- a/apps/client/soma/web/src/App.tsx +++ b/apps/client/soma/web/src/App.tsx @@ -157,6 +157,19 @@ export function App() { if (!running) { return "sending"; } + switch (running.activity?.state) { + case "requesting": + return "sending"; + case "thinking": + return "thinking"; + case "generating": + return "generating"; + case "calling_tool": + case "running_tool": + return "running tool"; + default: + break; + } if (running.items.some((item) => item.kind === "tool_call" && !item.toolResult)) { return "running tool"; } diff --git a/apps/client/soma/web/src/components/ChatShell.tsx b/apps/client/soma/web/src/components/ChatShell.tsx index 0674936..5d96052 100644 --- a/apps/client/soma/web/src/components/ChatShell.tsx +++ b/apps/client/soma/web/src/components/ChatShell.tsx @@ -1,4 +1,5 @@ import { Panel, useAppComponentRef } from "@mini-stim/components"; +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { STIM_APP_NAMESPACE } from "../appNamespace"; import { ChatHeader } from "./ChatHeader"; @@ -21,6 +22,9 @@ export function ChatShell(props: { timeline: Parameters[0]["timeline"]; draft: string; }) { + const scrollRef = useRef(null); + const transcriptRef = useRef(null); + const followTailRef = useRef(true); const shellRef = useAppComponentRef({ domain: "chat", id: "chat-shell", @@ -30,6 +34,48 @@ export function ChatShell(props: { projection: "primary panel", surface: "workspace", }); + const handleScroll = useCallback(() => { + const scrollElement = scrollRef.current; + if (!scrollElement) { + return; + } + followTailRef.current = distanceFromBottom(scrollElement) <= 48; + }, []); + const scrollToBottom = useCallback(() => { + const scrollElement = scrollRef.current; + if (!scrollElement) { + return; + } + requestAnimationFrame(() => { + scrollElement.scrollTop = scrollElement.scrollHeight; + }); + }, []); + + useLayoutEffect(() => { + if (followTailRef.current) { + scrollToBottom(); + } + }); + + useEffect(() => { + const transcriptElement = transcriptRef.current; + if (!transcriptElement) { + return undefined; + } + const observer = new ResizeObserver(() => { + if (followTailRef.current) { + scrollToBottom(); + } + }); + observer.observe(transcriptElement); + return () => observer.disconnect(); + }, [scrollToBottom]); + + function handleSend() { + followTailRef.current = true; + props.onSend(); + scrollToBottom(); + } return ( @@ -45,18 +91,27 @@ export function ChatShell(props: { titleValue={props.titleValue} /> - - + + ); } + +function distanceFromBottom(element: HTMLElement) { + return element.scrollHeight - element.scrollTop - element.clientHeight; +} diff --git a/apps/client/soma/web/src/components/Composer.tsx b/apps/client/soma/web/src/components/Composer.tsx index 6dc8915..00aba12 100644 --- a/apps/client/soma/web/src/components/Composer.tsx +++ b/apps/client/soma/web/src/components/Composer.tsx @@ -16,6 +16,7 @@ export function Composer(props: { error?: string | null; onChange: (value: string) => void; onSubmit: () => void; + submitting?: boolean; value: string; }) { const composerRef = useAppComponentRef({ @@ -48,7 +49,7 @@ export function Composer(props: { type="submit" tone="accent" size="lg" - disabled={props.disabled || !props.value.trim()} + disabled={props.disabled || props.submitting || !props.value.trim()} > Send @@ -69,7 +70,7 @@ export function Composer(props: { return; } event.preventDefault(); - if (!props.disabled && props.value.trim()) { + if (!props.disabled && !props.submitting && props.value.trim()) { props.onSubmit(); } }} diff --git a/apps/client/soma/web/src/components/InspectPanel.tsx b/apps/client/soma/web/src/components/InspectPanel.tsx index 0cfb744..1c09cb9 100644 --- a/apps/client/soma/web/src/components/InspectPanel.tsx +++ b/apps/client/soma/web/src/components/InspectPanel.tsx @@ -8,7 +8,9 @@ import { useAppComponentRef, } from "@mini-stim/components"; import type { SessionRuntimeSnapshot } from "@mini-stim/hooks"; +import { useSessionActions, useSessionMaterial } from "@mini-stim/hooks"; import type { ReactNode } from "react"; +import { useEffect } from "react"; import { STIM_APP_NAMESPACE } from "../appNamespace"; import { closeInspectPanel, type InspectTarget } from "../events/inspect"; @@ -19,6 +21,7 @@ import { ToolCallInspectPanel } from "./domains/tool-call/InspectPanel/ToolCallI type InspectDomain = "session" | "message" | "tool-call"; type InspectDomainPanelProps = { + systemPrompt: ReturnType; runtime: SessionRuntimeSnapshot; target: InspectTarget | null; }; @@ -36,6 +39,8 @@ export function InspectPanel(props: { target: InspectTarget | null; }) { const { runtime, target } = props; + const actions = useSessionActions(); + const systemPrompt = useSessionMaterial(runtime?.session.id, "system_prompt"); const panelRef = useAppComponentRef({ domain: "inspect", id: "inspect-panel", @@ -49,6 +54,12 @@ export function InspectPanel(props: { const domain = resolveInspectDomain(target); const DomainPanel = inspectDomainRegistry[domain]; + useEffect(() => { + if (runtime?.session.id && !systemPrompt) { + actions.refreshMaterial(runtime.session.id, "system_prompt"); + } + }, [actions, runtime?.session.id, systemPrompt]); + return ( @@ -68,7 +79,7 @@ export function InspectPanel(props: { {runtime ? ( - + ) : ( No runtime snapshot loaded for this session yet. @@ -80,7 +91,7 @@ export function InspectPanel(props: { } function SessionInspectDomain(props: InspectDomainPanelProps) { - return ; + return ; } function MessageInspectDomain(props: InspectDomainPanelProps) { @@ -89,10 +100,11 @@ function MessageInspectDomain(props: InspectDomainPanelProps) { return ; case "session": case "turn": + case "thinking": case "tool_call": case "tool_result": case undefined: - return ; + return ; } } @@ -103,9 +115,10 @@ function ToolCallInspectDomain(props: InspectDomainPanelProps) { return ; case "message": case "session": + case "thinking": case "turn": case undefined: - return ; + return ; } } @@ -117,6 +130,7 @@ function resolveInspectDomain(target: InspectTarget | null): InspectDomain { case "tool_result": return "tool-call"; case "session": + case "thinking": case "turn": case undefined: return "session"; diff --git a/apps/client/soma/web/src/components/TimelineItemView.tsx b/apps/client/soma/web/src/components/TimelineItemView.tsx index 6881dcd..bd5ce50 100644 --- a/apps/client/soma/web/src/components/TimelineItemView.tsx +++ b/apps/client/soma/web/src/components/TimelineItemView.tsx @@ -1,7 +1,10 @@ import { AnchoredContentGroupItem, MarkdownText, + Stack, + Surface, Text, + Timestamp, useAppComponentRef, } from "@mini-stim/components"; import type { TimelineItem } from "@mini-stim/hooks"; @@ -16,6 +19,8 @@ import { } from "./domains/tool-call/model/toolCallModel"; import { ToolCallWhisper, ToolResultWhisper } from "./domains/tool-call/transcript/ToolCallWhisper"; +type ThinkingTimelineItem = Extract; + export function TimelineItemView(props: { align?: "start" | "center" | "end"; item: TimelineItem; @@ -27,6 +32,16 @@ export function TimelineItemView(props: { switch (item.kind) { case "message": return ; + case "thinking": + return ( + selectInspectTarget(target)} + > + + + ); case "tool_call": return ( + + + {headline} + + {summary ? ( + + {summary} + + ) : null} + {failed && thinking.error_text ? ( + + {thinking.error_text} + + ) : null} + + + + ); +} + function MessageTimelineItem(props: { align: "start" | "center" | "end"; item: Extract; @@ -58,6 +105,8 @@ function MessageTimelineItem(props: { }) { const role = props.item.message.message.actor_type; const pending = props.item.message.message.state === "pending"; + const aborted = props.item.message.message.state === "aborted"; + const isSantiSystem = props.item.message.message.message_kind === "santi_system"; return ( selectInspectTarget(props.target)} > - - {props.item.message.content_text} - + {isSantiSystem ? ( + + ) : ( + + {props.item.message.content_text} + + )} {pending ? ( generating… ) : null} + {aborted ? ( + + aborted + + ) : null} ); } +function SantiSystemWhisper(props: { item: Extract }) { + const fields = santiSystemFields(props.item.message.content_text); + const kind = fields.get("kind") ?? "santi_system"; + const summary = fields.get("summary"); + const trace = fields.get("trace"); + + return ( + + + + {compactJoin("\u00a0·\u00a0", ["santi-system", kind])} + + {summary ? ( + + {summary} + + ) : null} + {trace ? ( + + {trace} + + ) : null} + + + + ); +} + +function santiSystemFields(content: string) { + const fields = new Map(); + for (const line of content.split(/\r?\n/)) { + const [key, ...rest] = line.split(":"); + if (!key || rest.length === 0) { + continue; + } + fields.set(key.trim(), rest.join(":").trim()); + } + return fields; +} + function registrationForItem(item: TimelineItem) { switch (item.kind) { case "message": { @@ -97,6 +195,24 @@ function registrationForItem(item: TimelineItem) { surface: "transcript", } as const; } + case "thinking": + return { + copyText: thinkingCopyText(item), + domain: "thinking", + id: `thinking-${item.thinking.id}`, + kind: "message", + label: "Thinking", + metadata: { + session_id: item.sessionId, + thinking_id: item.thinking.id, + turn_id: item.thinking.turn_id, + }, + namespace: STIM_APP_NAMESPACE, + projection: "thinking whisper", + role: "soul", + surface: "transcript", + variant: item.thinking.state, + } as const; case "tool_call": return { copyText: toolRunCopyText(item), @@ -145,6 +261,12 @@ function targetForItem(item: TimelineItem) { sessionId: item.sessionId, messageId: item.message.message.id, } as const; + case "thinking": + return { + kind: "thinking", + sessionId: item.sessionId, + thinkingId: item.thinking.id, + } as const; case "tool_call": return { kind: "tool_call", @@ -160,6 +282,63 @@ function targetForItem(item: TimelineItem) { } } +function thinkingCopyText(item: ThinkingTimelineItem) { + const duration = item.thinking.finished_at + ? durationLabel(item.thinking.created_at, item.thinking.finished_at) + : ""; + + return compactLines([ + thinkingHeadlineText(item, duration), + `status: ${thinkingStateLabel(item)}`, + duration ? `duration: ${duration}` : null, + item.thinking.summary ? `summary: ${item.thinking.summary}` : null, + item.thinking.error_text ? `error: ${item.thinking.error_text}` : null, + refsLine([ + ["session", item.sessionId], + ["turn", item.thinking.turn_id], + ["thinking", item.thinking.id], + ]), + ]); +} + +function thinkingStateLabel(item: ThinkingTimelineItem) { + switch (item.thinking.state) { + case "running": + return "waiting"; + case "completed": + return "ready"; + case "failed": + return "failed"; + } +} + +function thinkingHeadlineText(item: ThinkingTimelineItem, duration: string) { + const thinking = item.thinking; + if (thinking.state === "running") { + return "thinking · waiting"; + } + + if (thinking.state === "failed") { + return "thinking · failed"; + } + + const milestone = thinkingCompletionReasonLabel(thinking.completion_reason); + return compactJoin("\u00a0·\u00a0", ["thinking", milestone, duration]); +} + +function thinkingCompletionReasonLabel(reason: string | null | undefined) { + switch (reason) { + case "first_text_delta": + return "first\u00a0token"; + case "tool_call_requested": + return "tool\u00a0call"; + case "provider_completed": + return "provider\u00a0completed"; + default: + return null; + } +} + function toolRunLabel(toolName: string) { return isShellTool(toolName) ? "Shell Run" : "Tool Run"; } @@ -230,6 +409,19 @@ function toolResultCopyText(item: Extract ]); } +function durationLabel(start: string, end: string) { + const startMs = Date.parse(start); + const endMs = Date.parse(end); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) { + return ""; + } + const ms = endMs - startMs; + if (ms < 1000) { + return `${ms}ms`; + } + return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`; +} + function shellRunStatusLine(shell: ReturnType) { return compactJoin(" · ", [ `status: ${shell.status}`, diff --git a/apps/client/soma/web/src/components/Transcript.tsx b/apps/client/soma/web/src/components/Transcript.tsx index 87d197c..1d0a418 100644 --- a/apps/client/soma/web/src/components/Transcript.tsx +++ b/apps/client/soma/web/src/components/Transcript.tsx @@ -1,13 +1,13 @@ import { AnchoredContentGroup, AnchoredContentGroupDivider, - Notice, Stack, Text, Timestamp, useAppComponentRef, } from "@mini-stim/components"; import type { TurnGroup } from "@mini-stim/hooks"; +import type { Ref } from "react"; import { Fragment } from "react"; import { STIM_APP_NAMESPACE } from "../appNamespace"; @@ -20,7 +20,11 @@ export interface SoulIdentity { name: string; } -export function Transcript(props: { soulIdentity: SoulIdentity; timeline: TurnGroup[] }) { +export function Transcript(props: { + contentRef?: Ref; + soulIdentity: SoulIdentity; + timeline: TurnGroup[]; +}) { const items = flattenTimelineItems(props.timeline); const groups = buildTranscriptGroups(items); const empty = !props.timeline.some((group) => group.items.length || group.turn); @@ -35,7 +39,7 @@ export function Transcript(props: { soulIdentity: SoulIdentity; timeline: TurnGr }); return ( - + {groups.map((group) => ( ))} - + {empty ? : null} ); } +function mergeRefs(...refs: Array | undefined>) { + return (element: T | null) => { + for (const ref of refs) { + if (!ref) { + continue; + } + if (typeof ref === "function") { + ref(element); + } else { + ref.current = element; + } + } + }; +} + function TranscriptEmpty() { return ( @@ -100,27 +119,15 @@ function TranscriptGroupView(props: { ); } -function TurnStateNotices(props: { timeline: TurnGroup[] }) { +function RunningTurnNotices(props: { timeline: TurnGroup[] }) { return ( <> {props.timeline.map((group) => { const turn = group.turn; - if (turn?.status === "failed") { - return ( - - - - TURN FAILED - - {turn.error_text ?? "The turn failed without an error message."} - - - ); - } - if (turn?.status === "running" && !group.items.length) { + if (turn?.status === "running" && !hasRuntimeFeedback(group)) { return ( - Working… + {runningTurnLabel(group)} ); } @@ -130,6 +137,32 @@ function TurnStateNotices(props: { timeline: TurnGroup[] }) { ); } +function hasRuntimeFeedback(group: TurnGroup) { + return group.items.some((item) => { + if (item.kind !== "message") { + return true; + } + return item.message.message.actor_type !== "account"; + }); +} + +function runningTurnLabel(group: TurnGroup) { + switch (group.activity?.state) { + case "requesting": + return "waiting for model…"; + case "thinking": + return "thinking…"; + case "generating": + return "generating…"; + case "calling_tool": + return "calling tool…"; + case "running_tool": + return "running tool…"; + default: + return "working…"; + } +} + function identityForGroup( group: TranscriptGroup, items: ReturnType, diff --git a/apps/client/soma/web/src/components/domains/message/InspectPanel/MessageInspectPanel.tsx b/apps/client/soma/web/src/components/domains/message/InspectPanel/MessageInspectPanel.tsx index 030f64c..8fc8ee9 100644 --- a/apps/client/soma/web/src/components/domains/message/InspectPanel/MessageInspectPanel.tsx +++ b/apps/client/soma/web/src/components/domains/message/InspectPanel/MessageInspectPanel.tsx @@ -35,7 +35,8 @@ export function MessageInspectPanel(props: { messageId: string; runtime: Session - {message.message.actor_type} seq {message.relation.session_seq} + {message.message.actor_type} · {message.message.message_kind} · seq{" "} + {message.relation.session_seq} diff --git a/apps/client/soma/web/src/components/domains/session/InspectPanel/SessionInspectPanel.tsx b/apps/client/soma/web/src/components/domains/session/InspectPanel/SessionInspectPanel.tsx index 41c8bd4..039d395 100644 --- a/apps/client/soma/web/src/components/domains/session/InspectPanel/SessionInspectPanel.tsx +++ b/apps/client/soma/web/src/components/domains/session/InspectPanel/SessionInspectPanel.tsx @@ -9,14 +9,18 @@ import { Timestamp, useAppComponentRef, } from "@mini-stim/components"; -import type { SessionRuntimeSnapshot } from "@mini-stim/hooks"; +import type { SessionMaterial, SessionRuntimeSnapshot } from "@mini-stim/hooks"; import type { ReactNode } from "react"; import { STIM_APP_NAMESPACE } from "../../../../appNamespace"; -export function SessionInspectPanel(props: { runtime: SessionRuntimeSnapshot }) { - const { runtime } = props; +export function SessionInspectPanel(props: { + runtime: SessionRuntimeSnapshot; + systemPrompt: SessionMaterial | null; +}) { + const { runtime, systemPrompt } = props; const memory = runtime.soul_session?.session_memory.trim() ?? ""; + const promptText = systemPrompt?.text.trim() ?? ""; return ( @@ -32,6 +36,23 @@ export function SessionInspectPanel(props: { runtime: SessionRuntimeSnapshot }) + + ) : null + } + > + {promptText ? ( + {promptText} + ) : ( + + System prompt material has not been loaded yet. + + )} + + Result { + Cli::try_parse() + .map(|cli| Self { cli }) + .map_err(|error| error.to_string()) + } + + pub fn from_args(args: impl IntoIterator) -> Result { + Cli::try_parse_from(args) + .map(|cli| Self { cli }) + .map_err(|error| error.to_string()) + } + + pub fn command(&self) -> AppCommand { + match self.cli.command { + Some(CliCommand::Serve) | None => AppCommand::Serve, + Some(CliCommand::ExportOpenApi) => AppCommand::ExportOpenApi, + } + } + + pub fn provider_config(&self) -> Result { + let config_path = self.config_path(); + let config = AppConfigFile::read(&config_path)?; + let provider = trim_optional_string(&self.cli.provider) + .or_else(|| trim_optional_string(&config.provider)) + .or_else(|| optional_env("SANTI_PROVIDER")) + .unwrap_or_else(|| "openai".to_string()); + let profile = config + .providers + .get(&provider) + .ok_or_else(|| format!("provider {provider} is not defined in {config_path}"))?; + resolve_provider_config(&provider, profile) + } + + fn config_path(&self) -> String { + trim_optional_string(&self.cli.config) + .or_else(|| optional_env("SANTI_CONFIG")) + .unwrap_or_else(|| APP_CONFIG_PATH.to_string()) + } +} + +#[derive(Debug, Clone, Parser)] +#[command(disable_help_subcommand = true)] +struct Cli { + #[command(subcommand)] + command: Option, + #[arg(long, global = true)] + config: Option, + #[arg(long, global = true)] + provider: Option, +} + +#[derive(Debug, Clone, Copy, Subcommand)] +enum CliCommand { + Serve, + #[command(name = "export-openapi")] + ExportOpenApi, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderConfig { + OpenAiResponses(OpenAiResponsesConfig), + ChatCompletions(ChatCompletionsConfig), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenAiResponsesConfig { + pub api_key: String, + pub model: String, + pub base_url: String, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub max_output_tokens: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatCompletionsConfig { + pub provider: String, + pub api_key: String, + pub model: String, + pub base_url: String, + pub thinking: Option, + pub reasoning_effort: Option, + pub max_tokens: Option, +} + +#[derive(Debug, Deserialize)] +struct AppConfigFile { + #[serde(default)] + provider: Option, + providers: BTreeMap, +} + +impl AppConfigFile { + fn read(path: &str) -> Result { + let content = fs::read_to_string(path) + .map_err(|error| format!("failed to read app config {path}: {error}"))?; + toml::from_str(&content) + .map_err(|error| format!("failed to parse app config {path}: {error}")) + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum RawProviderProfile { + OpenaiResponses { + #[serde(default)] + api_key: Option, + #[serde(default)] + model: Option, + #[serde(default)] + base_url: Option, + #[serde(default)] + reasoning_effort: Option, + #[serde(default)] + reasoning_summary: Option, + #[serde(default)] + max_output_tokens: Option, + }, + ChatCompletions { + #[serde(default)] + api_key: Option, + #[serde(default)] + model: Option, + #[serde(default)] + base_url: Option, + #[serde(default)] + thinking: Option, + #[serde(default)] + reasoning_effort: Option, + #[serde(default)] + max_tokens: Option, + }, +} + +fn resolve_provider_config( + provider: &str, + profile: &RawProviderProfile, +) -> Result { + match profile { + RawProviderProfile::OpenaiResponses { .. } => resolve_openai(provider, profile), + RawProviderProfile::ChatCompletions { .. } => resolve_chat_completions(provider, profile), + } +} + +fn resolve_openai(provider: &str, profile: &RawProviderProfile) -> Result { + let RawProviderProfile::OpenaiResponses { + api_key, + model, + base_url, + reasoning_effort, + reasoning_summary, + max_output_tokens, + } = profile + else { + unreachable!("openai profile") + }; + Ok(ProviderConfig::OpenAiResponses(OpenAiResponsesConfig { + api_key: required_profile_string(api_key, provider, "api_key")?, + model: required_profile_string(model, provider, "model")?, + base_url: optional_profile_string(base_url, provider, "base_url") + .unwrap_or_else(|| "https://api.openai.com/v1".to_string()), + reasoning_effort: optional_profile_string(reasoning_effort, provider, "reasoning_effort"), + reasoning_summary: optional_profile_string( + reasoning_summary, + provider, + "reasoning_summary", + ), + max_output_tokens: optional_profile_u32(max_output_tokens, provider, "max_output_tokens")?, + })) +} + +fn resolve_chat_completions( + provider: &str, + profile: &RawProviderProfile, +) -> Result { + let RawProviderProfile::ChatCompletions { + api_key, + model, + base_url, + thinking, + reasoning_effort, + max_tokens, + } = profile + else { + unreachable!("chat completions profile") + }; + Ok(ProviderConfig::ChatCompletions(ChatCompletionsConfig { + provider: provider.to_string(), + api_key: required_profile_string(api_key, provider, "api_key")?, + model: required_profile_string(model, provider, "model")?, + base_url: required_profile_string(base_url, provider, "base_url")?, + thinking: optional_profile_string(thinking, provider, "thinking"), + reasoning_effort: optional_profile_string(reasoning_effort, provider, "reasoning_effort"), + max_tokens: optional_profile_u32(max_tokens, provider, "max_tokens")?, + })) +} + +fn required_profile_string( + value: &Option, + provider: &str, + field: &str, +) -> Result { + optional_profile_string(value, provider, field) + .ok_or_else(|| format!("provider {provider} field {field} is required")) +} + +fn optional_profile_string(value: &Option, provider: &str, field: &str) -> Option { + trim_optional_string(value).or_else(|| optional_env(&profile_env_name(provider, field))) +} + +fn optional_profile_u32( + value: &Option, + provider: &str, + field: &str, +) -> Result, String> { + match value { + Some(value) => Ok(Some(*value)), + None => optional_env(&profile_env_name(provider, field)) + .map(|value| { + value.parse::().map_err(|_| { + format!("provider {provider} field {field} must be an unsigned integer") + }) + }) + .transpose(), + } +} + +fn optional_env(name: &str) -> Option { + env::var(name).ok().and_then(|value| trim_string(&value)) +} + +fn trim_optional_string(value: &Option) -> Option { + value.as_ref().and_then(|value| trim_string(value)) +} + +fn trim_string(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +fn profile_env_name(provider: &str, field: &str) -> String { + format!( + "SANTI_PROVIDERS_{}_{}", + env_token(provider), + env_token(field) + ) +} + +fn env_token(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_uppercase() + } else { + '_' + } + }) + .collect() +} diff --git a/apps/server/soma/crates/santi-api/src/lib.rs b/apps/server/soma/crates/santi-api/src/lib.rs new file mode 100644 index 0000000..84c6e89 --- /dev/null +++ b/apps/server/soma/crates/santi-api/src/lib.rs @@ -0,0 +1,2 @@ +pub mod config; +pub mod provider; diff --git a/apps/server/soma/crates/santi-api/src/main.rs b/apps/server/soma/crates/santi-api/src/main.rs index 1fc46ad..660198b 100644 --- a/apps/server/soma/crates/santi-api/src/main.rs +++ b/apps/server/soma/crates/santi-api/src/main.rs @@ -1,4 +1,4 @@ -use std::{convert::Infallible, env, net::SocketAddr, path::PathBuf, sync::Arc}; +use std::{convert::Infallible, env, net::SocketAddr, path::PathBuf}; use axum::{ Json, Router, @@ -11,13 +11,14 @@ use axum::{ routing::{get, post}, }; use futures_core::Stream; +use mini_stim_server_soma::{config, provider}; use santi_core::{ - CreateSessionResponse, ErrorResponse, HealthResponse, SantiService, SantiServiceConfig, - SantiStreamEvent, SantiStreamPayload, SendSessionRequest, SendSessionResponse, Session, - SessionDetail, SessionProfile, SessionRuntimeSnapshot, SessionSummary, SoulProfile, - UpdateSessionRequest, prefixed_id, timestamp_now, + CreateSessionResponse, ErrorResponse, HealthResponse, MaterialRequest, SantiService, + SantiServiceConfig, SantiStreamEvent, SantiStreamPayload, SendSessionAcceptedResponse, + SendSessionRequest, Session, SessionDetail, SessionMaterial, SessionProfile, + SessionRuntimeSnapshot, SessionSummary, SoulProfile, UpdateSessionRequest, prefixed_id, + timestamp_now, }; -use santi_provider::{OpenAIProvider, OpenAIProviderConfig}; use tower_http::{ cors::{Any, CorsLayer}, trace::TraceLayer, @@ -28,9 +29,10 @@ mod bucket; #[tokio::main] async fn main() -> Result<(), String> { - dotenvy::dotenv().ok(); - match env::args().nth(1).as_deref() { - Some("export-openapi") => { + dotenvy::dotenv_override().ok(); + let config = config::ConfigService::from_env_args()?; + match config.command() { + config::AppCommand::ExportOpenApi => { println!( "{}", serde_json::to_string_pretty(&ApiDoc::openapi()) @@ -38,27 +40,12 @@ async fn main() -> Result<(), String> { ); Ok(()) } - Some("serve") | None => serve().await, - Some(command) => Err(format!("unknown command: {command}")), + config::AppCommand::Serve => serve(config).await, } } -async fn serve() -> Result<(), String> { - let provider = Arc::new(OpenAIProvider::new(OpenAIProviderConfig { - api_key: env::var("OPENAI_API_KEY") - .map_err(|_| "OPENAI_API_KEY is required".to_string())?, - model: env::var("OPENAI_MODEL").map_err(|_| "OPENAI_MODEL is required".to_string())?, - base_url: env::var("OPENAI_RESPONSES_BASE_URL") - .unwrap_or_else(|_| "https://api.openai.com/v1".to_string()), - reasoning_effort: optional_env("OPENAI_REASONING_EFFORT"), - max_output_tokens: optional_env("OPENAI_MAX_OUTPUT_TOKENS") - .map(|value| { - value - .parse::() - .map_err(|_| "OPENAI_MAX_OUTPUT_TOKENS must be an unsigned integer".to_string()) - }) - .transpose()?, - })); +async fn serve(config: config::ConfigService) -> Result<(), String> { + let provider = provider::from_config(config.provider_config()?); let database_path = env::var("SANTI_DB").map_err(|_| "SANTI_DB is required".to_string())?; let runtime_root = env::var("SANTI_RUNTIME_ROOT").unwrap_or_else(|_| { db_parent(&database_path) @@ -109,13 +96,6 @@ fn bind_addr_string() -> String { format!("{host}:{port}") } -fn optional_env(name: &str) -> Option { - env::var(name) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - fn router(service: SantiService) -> Router { Router::new() .route("/api/v1/health", get(health)) @@ -126,6 +106,10 @@ fn router(service: SantiService) -> Router { get(get_session).patch(update_session), ) .route("/api/v1/sessions/{session_id}/messages", get(list_messages)) + .route( + "/api/v1/sessions/{session_id}/materials", + post(session_material), + ) .route("/api/v1/sessions/{session_id}/events", get(session_events)) .route("/api/v1/sessions/{session_id}/send", post(send_session)) .route( @@ -251,6 +235,28 @@ async fn list_messages( .ok_or_else(|| ApiError::not_found("session not found")) } +#[utoipa::path( + post, + path = "/api/v1/sessions/{session_id}/materials", + params(("session_id" = String, Path)), + request_body = MaterialRequest, + responses( + (status = 200, body = SessionMaterial), + (status = 404, body = ErrorResponse), + (status = 500, body = ErrorResponse) + ) +)] +async fn session_material( + State(service): State, + Path(session_id): Path, + Json(request): Json, +) -> Result, ApiError> { + service + .session_material(&session_id, request) + .map(Json) + .map_err(ApiError::from_service) +} + async fn session_events( State(service): State, Path(session_id): Path, @@ -289,7 +295,7 @@ async fn session_events( params(("session_id" = String, Path)), request_body = SendSessionRequest, responses( - (status = 200, body = SendSessionResponse), + (status = 200, body = SendSessionAcceptedResponse), (status = 404, body = ErrorResponse), (status = 500, body = ErrorResponse) ) @@ -298,7 +304,7 @@ async fn send_session( State(service): State, Path(session_id): Path, Json(request): Json, -) -> Result, ApiError> { +) -> Result, ApiError> { service .send_session(&session_id, request) .await @@ -346,7 +352,12 @@ fn sse_event_name(payload: &SantiStreamPayload) -> &'static str { SantiStreamPayload::MessageCompleted { .. } => "message_completed", SantiStreamPayload::ToolCallCreated { .. } => "tool_call_created", SantiStreamPayload::ToolResultCreated { .. } => "tool_result_created", + SantiStreamPayload::ThinkingCreated { .. } => "thinking_created", + SantiStreamPayload::ThinkingUpdated { .. } => "thinking_updated", + SantiStreamPayload::ThinkingCompleted { .. } => "thinking_completed", + SantiStreamPayload::MaterialUpdated { .. } => "material_updated", SantiStreamPayload::TurnStarted { .. } => "turn_started", + SantiStreamPayload::TurnActivity { .. } => "turn_activity", SantiStreamPayload::TurnFailed { .. } => "turn_failed", } } @@ -419,6 +430,7 @@ impl IntoResponse for ApiError { get_session, update_session, list_messages, + session_material, send_session, runtime_snapshot, bucket::get_bucket_object @@ -427,10 +439,12 @@ impl IntoResponse for ApiError { CreateSessionResponse, ErrorResponse, HealthResponse, + MaterialRequest, SendSessionRequest, - SendSessionResponse, + SendSessionAcceptedResponse, Session, SessionDetail, + SessionMaterial, SessionProfile, SessionRuntimeSnapshot, SessionSummary, @@ -442,13 +456,19 @@ impl IntoResponse for ApiError { santi_core::MessageContent, santi_core::MessagePart, santi_core::MessageState, + santi_core::MaterialKind, + santi_core::MaterialUpdated, santi_core::SessionEffect, santi_core::SessionMessage, santi_core::SessionMessageRef, santi_core::SoulSession, + santi_core::ThinkingSpan, + santi_core::ThinkingSpanState, santi_core::ToolCall, santi_core::ToolResult, santi_core::Turn, + santi_core::TurnActivity, + santi_core::TurnActivityState, santi_core::TurnStatus, santi_core::TurnTriggerType )) diff --git a/apps/server/soma/crates/santi-api/src/provider.rs b/apps/server/soma/crates/santi-api/src/provider.rs new file mode 100644 index 0000000..5f52de6 --- /dev/null +++ b/apps/server/soma/crates/santi-api/src/provider.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use santi_provider::{ + ChatCompletionsProvider, ChatCompletionsProviderConfig, OpenAIProvider, OpenAIProviderConfig, + ProviderClient, +}; + +use crate::config::{ChatCompletionsConfig, OpenAiResponsesConfig, ProviderConfig}; + +pub fn from_config(config: ProviderConfig) -> Arc { + match config { + ProviderConfig::OpenAiResponses(config) => openai_provider(config), + ProviderConfig::ChatCompletions(config) => chat_completions_provider(config), + } +} + +fn openai_provider(config: OpenAiResponsesConfig) -> Arc { + Arc::new(OpenAIProvider::new(OpenAIProviderConfig { + api_key: config.api_key, + model: config.model, + base_url: config.base_url, + reasoning_effort: config.reasoning_effort, + reasoning_summary: config.reasoning_summary, + max_output_tokens: config.max_output_tokens, + })) +} + +fn chat_completions_provider(config: ChatCompletionsConfig) -> Arc { + Arc::new(ChatCompletionsProvider::new( + ChatCompletionsProviderConfig { + provider: config.provider, + api_key: config.api_key, + model: config.model, + base_url: config.base_url, + thinking: config.thinking, + reasoning_effort: config.reasoning_effort, + max_tokens: config.max_tokens, + }, + )) +} diff --git a/apps/server/soma/crates/santi-api/tests/config.rs b/apps/server/soma/crates/santi-api/tests/config.rs new file mode 100644 index 0000000..300c1eb --- /dev/null +++ b/apps/server/soma/crates/santi-api/tests/config.rs @@ -0,0 +1,107 @@ +use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +use mini_stim_server_soma::config::{ + AppCommand, ChatCompletionsConfig, ConfigService, ProviderConfig, +}; + +#[test] +fn resolves_chat_completions_profile() { + let path = write_config( + r#" + provider = "openai" + + [providers.openai] + kind = "openai_responses" + api_key = "openai-key" + model = "gpt-5.5" + + [providers.siliconflow] + kind = "chat_completions" + api_key = "test-key" + model = "zai-org/GLM-5.2" + base_url = "https://api.siliconflow.cn/v1" + thinking = "" + reasoning_effort = "" + max_tokens = 2048 + "#, + ); + + let service = ConfigService::from_args(args([ + "mini-stim-server-soma", + "serve", + "--config", + path.to_str().expect("config path"), + "--provider=siliconflow", + ])) + .expect("config service"); + + assert_eq!(service.command(), AppCommand::Serve); + assert_eq!( + service.provider_config().expect("provider config"), + ProviderConfig::ChatCompletions(ChatCompletionsConfig { + provider: "siliconflow".to_string(), + api_key: "test-key".to_string(), + model: "zai-org/GLM-5.2".to_string(), + base_url: "https://api.siliconflow.cn/v1".to_string(), + thinking: None, + reasoning_effort: None, + max_tokens: Some(2048), + }) + ); + + let _ = fs::remove_file(path); +} + +#[test] +fn parses_export_openapi_command() { + let service = ConfigService::from_args(args(["mini-stim-server-soma", "export-openapi"])) + .expect("config service"); + + assert_eq!(service.command(), AppCommand::ExportOpenApi); +} + +#[test] +fn reports_missing_field() { + let path = write_config( + r#" + provider = "openai" + + [providers.openai] + kind = "openai_responses" + api_key = "openai-key" + model = "" + "#, + ); + + let service = ConfigService::from_args(args([ + "mini-stim-server-soma", + "--config", + path.to_str().expect("config path"), + ])) + .expect("config service"); + + assert_eq!( + service.provider_config().expect_err("missing model"), + "provider openai field model is required" + ); + + let _ = fs::remove_file(path); +} + +fn args(values: [&str; N]) -> impl IntoIterator { + values.map(str::to_string) +} + +fn write_config(content: &str) -> PathBuf { + let id = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!("mini-stim-config-{id}.toml")); + fs::write(&path, content).expect("write config"); + path +} diff --git a/apps/server/soma/crates/santi-core/src/assembly/input.rs b/apps/server/soma/crates/santi-core/src/assembly/input.rs new file mode 100644 index 0000000..346dc37 --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/assembly/input.rs @@ -0,0 +1,17 @@ +use santi_provider::ProviderMessage; + +use crate::SantiStore; + +pub(crate) fn provider_messages( + store: &SantiStore, + soul_session_id: &str, +) -> Result, String> { + Ok(store + .assembly_input(soul_session_id)? + .into_iter() + .map(|message| ProviderMessage { + role: message.role, + content: message.content, + }) + .collect()) +} diff --git a/apps/server/soma/crates/santi-core/src/assembly/mod.rs b/apps/server/soma/crates/santi-core/src/assembly/mod.rs new file mode 100644 index 0000000..7232b81 --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/assembly/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod input; +pub(crate) mod system_prompt; diff --git a/apps/server/soma/crates/santi-core/src/assembly/system_prompt.rs b/apps/server/soma/crates/santi-core/src/assembly/system_prompt.rs new file mode 100644 index 0000000..2e2fa05 --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/assembly/system_prompt.rs @@ -0,0 +1,99 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use crate::{ + SESSION_WORKSPACE_URI, SOUL_WORKSPACE_URI, SoulProfile, SoulSession, Timestamp, + session_memory_uri, soul_memory_uri, timestamp_from_system_time, +}; + +const SANTI_CHANNEL: &str = "mini-stim"; + +pub(crate) struct SystemPromptRequest<'a> { + pub session_id: &'a str, + pub soul_session: &'a SoulSession, + pub soul_profile: &'a SoulProfile, + pub soul_memory_path: PathBuf, + pub session_memory_path: PathBuf, +} + +pub(crate) fn render_system_prompt(request: SystemPromptRequest<'_>) -> Result { + let soul_memory = read_memory_material(&request.soul_memory_path)?; + let session_memory = read_memory_material(&request.session_memory_path)?; + let soul_source = soul_memory_uri(); + let session_source = session_memory_uri(); + + Ok([ + "You are a distinct soul running inside this Santi instance.".to_string(), + format!("{soul_source} will always be displayed in [santi-soul]."), + format!("{session_source} will always be displayed in [santi-session]."), + format!( + "These files have no internal version history; save backups into {SOUL_WORKSPACE_URI} or {SESSION_WORKSPACE_URI} if needed." + ), + render_santi_system_description(), + render_meta(request), + render_memory_section("santi-soul", &soul_source, &soul_memory), + render_memory_section("santi-session", &session_source, &session_memory), + ] + .join("\n\n")) +} + +fn render_santi_system_description() -> String { + [ + " blocks describe Santi runtime facts in this session.", + "They are part of your context, not user speech or your natural-language reply.", + "Read them as session facts about the workspace, runtime, or provider flow.", + ] + .join("\n") +} + +fn render_meta(request: SystemPromptRequest<'_>) -> String { + [ + "[santi-meta]".to_string(), + format!("channel: {SANTI_CHANNEL}"), + format!("soul_id: {}", request.soul_session.soul_id), + format!("soul_name: {}", request.soul_profile.soul_name), + format!("session_id: {}", request.session_id), + ] + .join("\n") +} + +fn render_memory_section(name: &str, source: &str, memory: &MemoryMaterial) -> String { + [ + format!("[{name}]"), + format!("source: {source}"), + format!( + "updated_at: {}", + memory.updated_at.as_deref().unwrap_or("null") + ), + "content:".to_string(), + memory.content.clone(), + ] + .join("\n") +} + +fn read_memory_material(path: &Path) -> Result { + let content = match fs::read_to_string(path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error.to_string()), + }; + let updated_at = match fs::metadata(path) { + Ok(metadata) => metadata + .modified() + .ok() + .and_then(|modified| timestamp_from_system_time(modified).ok()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.to_string()), + }; + Ok(MemoryMaterial { + content, + updated_at, + }) +} + +struct MemoryMaterial { + content: String, + updated_at: Option, +} diff --git a/apps/server/soma/crates/santi-core/src/lib.rs b/apps/server/soma/crates/santi-core/src/lib.rs index db28e61..f03f21a 100644 --- a/apps/server/soma/crates/santi-core/src/lib.rs +++ b/apps/server/soma/crates/santi-core/src/lib.rs @@ -1,11 +1,17 @@ +mod assembly; mod model; mod object_store; mod service; mod service_bucket; mod service_prompt; mod store; +mod workspace_uri; pub use model::*; pub use object_store::{LocalObjectStore, ObjectBucket, ObjectMeta, ObjectPayload, ObjectUri}; pub use service::{SantiService, SantiServiceConfig}; pub use store::SantiStore; +pub use workspace_uri::{ + MEMORY_FILE, SESSION_WORKSPACE_URI, SOUL_WORKSPACE_URI, WorkspaceRoot, WorkspaceUri, + parse_workspace_uri, session_memory_uri, soul_memory_uri, workspace_uri, +}; diff --git a/apps/server/soma/crates/santi-core/src/model.rs b/apps/server/soma/crates/santi-core/src/model.rs index 06db99e..334051a 100644 --- a/apps/server/soma/crates/santi-core/src/model.rs +++ b/apps/server/soma/crates/santi-core/src/model.rs @@ -4,6 +4,9 @@ use utoipa::ToSchema; pub type Timestamp = String; +mod message; +pub use message::*; + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct HealthResponse { pub ok: bool, @@ -16,6 +19,33 @@ pub struct ErrorResponse { pub message: String, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum MaterialKind { + SystemPrompt, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct MaterialRequest { + pub kind: MaterialKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SessionMaterial { + pub session_id: String, + pub kind: MaterialKind, + pub content_type: String, + pub text: String, + pub updated_at: Timestamp, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct MaterialUpdated { + pub session_id: String, + pub kind: MaterialKind, + pub updated_at: Timestamp, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct Session { pub id: String, @@ -59,6 +89,7 @@ pub struct Soul { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct SoulProfile { pub soul_id: String, + pub soul_name: String, pub nickname: String, pub avatar_ref: Option, pub avatar_seed: String, @@ -67,97 +98,6 @@ pub struct SoulProfile { pub updated_at: Timestamp, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ActorType { - Account, - Soul, - System, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum MessageState { - Pending, - Fixed, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct MessageContent { - pub parts: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum MessagePart { - Text { - text: String, - }, - Image { - mime_type: String, - data_base64: String, - }, -} - -impl MessageContent { - pub fn text(text: impl Into) -> Self { - Self { - parts: vec![MessagePart::Text { text: text.into() }], - } - } - - pub fn content_text(&self) -> String { - self.parts - .iter() - .filter_map(|part| match part { - MessagePart::Text { text } => Some(text.as_str()), - MessagePart::Image { .. } => None, - }) - .collect::>() - .join("\n\n") - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct Message { - pub id: String, - pub actor_type: ActorType, - pub actor_id: String, - pub content: MessageContent, - pub state: MessageState, - pub version: i64, - pub deleted_at: Option, - pub created_at: Timestamp, - pub updated_at: Timestamp, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct SessionMessageRef { - pub session_id: String, - pub message_id: String, - pub session_seq: i64, - pub created_at: Timestamp, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct SessionMessage { - pub relation: SessionMessageRef, - pub message: Message, - pub content_text: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct MessageEvent { - pub id: String, - pub message_id: String, - pub action: String, - pub actor_type: ActorType, - pub actor_id: String, - pub base_version: i64, - pub payload: Value, - pub created_at: Timestamp, -} - #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct SoulSession { pub id: String, @@ -222,6 +162,36 @@ pub struct ToolResult { pub created_at: Timestamp, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ThinkingSpanState { + Running, + Completed, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ThinkingCompletionReason { + FirstTextDelta, + ToolCallRequested, + ProviderCompleted, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ThinkingSpan { + pub id: String, + pub turn_id: String, + pub provider_response_id: Option, + pub state: ThinkingSpanState, + pub summary: Option, + pub completion_reason: Option, + pub error_text: Option, + pub created_at: Timestamp, + pub updated_at: Timestamp, + pub finished_at: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct Compact { pub id: String, @@ -252,6 +222,7 @@ pub struct SessionEffect { pub enum SoulSessionTargetType { Message, Compact, + Thinking, ToolCall, ToolResult, } @@ -303,15 +274,12 @@ impl SendSessionRequest { } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct SendSessionResponse { +pub struct SendSessionAcceptedResponse { pub session: SessionSummary, pub soul_session: SoulSession, pub soul_profile: SoulProfile, pub turn: Turn, pub user_message: SessionMessage, - pub assistant_message: SessionMessage, - pub tool_calls: Vec, - pub tool_results: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -322,6 +290,23 @@ pub struct SantiStreamEvent { pub payload: SantiStreamPayload, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum TurnActivityState { + Requesting, + Thinking, + Generating, + CallingTool, + RunningTool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TurnActivity { + pub turn_id: String, + pub state: TurnActivityState, + pub provider_response_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SantiStreamPayload { @@ -345,9 +330,24 @@ pub enum SantiStreamPayload { ToolResultCreated { tool_result: ToolResult, }, + ThinkingCreated { + thinking: ThinkingSpan, + }, + ThinkingUpdated { + thinking: ThinkingSpan, + }, + ThinkingCompleted { + thinking: ThinkingSpan, + }, + MaterialUpdated { + material: MaterialUpdated, + }, TurnStarted { turn: Turn, }, + TurnActivity { + activity: TurnActivity, + }, TurnFailed { turn_id: String, error: String, @@ -362,6 +362,7 @@ pub struct SessionRuntimeSnapshot { pub soul_profile: Option, pub messages: Vec, pub turns: Vec, + pub thinking_spans: Vec, pub tool_calls: Vec, pub tool_results: Vec, pub compacts: Vec, @@ -385,6 +386,20 @@ pub fn timestamp_now() -> Timestamp { buf } +pub(crate) fn timestamp_from_system_time( + system_time: std::time::SystemTime, +) -> Result { + use jiff::fmt::temporal::DateTimePrinter; + + let timestamp = jiff::Timestamp::try_from(system_time).map_err(|error| error.to_string())?; + let mut buf = String::new(); + DateTimePrinter::new() + .precision(Some(3)) + .print_timestamp(×tamp, &mut buf) + .expect("formatting a timestamp into a String cannot fail"); + Ok(buf) +} + pub fn prefixed_id(prefix: &str) -> String { format!("{prefix}_{}", uuid::Uuid::new_v4().simple()) } diff --git a/apps/server/soma/crates/santi-core/src/model/message.rs b/apps/server/soma/crates/santi-core/src/model/message.rs new file mode 100644 index 0000000..a43f486 --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/model/message.rs @@ -0,0 +1,105 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use utoipa::ToSchema; + +use super::Timestamp; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ActorType { + Account, + Soul, + System, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MessageState { + Pending, + Fixed, + Aborted, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MessageKind { + Text, + SantiSystem, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct MessageContent { + pub parts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MessagePart { + Text { + text: String, + }, + Image { + mime_type: String, + data_base64: String, + }, +} + +impl MessageContent { + pub fn text(text: impl Into) -> Self { + Self { + parts: vec![MessagePart::Text { text: text.into() }], + } + } + + pub fn content_text(&self) -> String { + self.parts + .iter() + .filter_map(|part| match part { + MessagePart::Text { text } => Some(text.as_str()), + MessagePart::Image { .. } => None, + }) + .collect::>() + .join("\n\n") + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct Message { + pub id: String, + pub actor_type: ActorType, + pub actor_id: String, + pub message_kind: MessageKind, + pub content: MessageContent, + pub state: MessageState, + pub version: i64, + pub deleted_at: Option, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SessionMessageRef { + pub session_id: String, + pub message_id: String, + pub session_seq: i64, + pub created_at: Timestamp, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SessionMessage { + pub relation: SessionMessageRef, + pub message: Message, + pub content_text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct MessageEvent { + pub id: String, + pub message_id: String, + pub action: String, + pub actor_type: ActorType, + pub actor_id: String, + pub base_version: i64, + pub payload: Value, + pub created_at: Timestamp, +} diff --git a/apps/server/soma/crates/santi-core/src/service.rs b/apps/server/soma/crates/santi-core/src/service.rs index 6cbda71..2ed7f8c 100644 --- a/apps/server/soma/crates/santi-core/src/service.rs +++ b/apps/server/soma/crates/santi-core/src/service.rs @@ -1,30 +1,42 @@ +mod failure; +mod materials; +mod text_delta; +mod thinking; +mod timing; +mod tools; + use futures_util::StreamExt; -use santi_provider::{ - FunctionCallOutput, ProviderClient, ProviderEvent, ProviderFunctionCall, ProviderMessage, - ProviderRequest, +use santi_provider::{ProviderClient, ProviderEvent, ProviderRequest}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, }; -use serde::Deserialize; -use serde_json::{Value, json}; -use std::{path::PathBuf, process::Command, sync::Arc}; use tokio::sync::broadcast; -use crate::service_prompt::{ - provider_tools, render_self_assessment_instructions, tooling_instructions, -}; +use crate::assembly::input::provider_messages; +use crate::service_prompt::provider_tools; use crate::{ - ActorType, CreateSessionResponse, MessageContent, MessageState, SantiStore, SantiStreamEvent, - SantiStreamPayload, SendSessionRequest, SendSessionResponse, SessionDetail, - SessionRuntimeSnapshot, SessionSummary, UpdateSessionRequest, prefixed_id, timestamp_now, + ActorType, CreateSessionResponse, MaterialKind, MessageContent, MessageState, SantiStore, + SantiStreamEvent, SantiStreamPayload, SendSessionAcceptedResponse, SendSessionRequest, + SessionDetail, SessionMaterial, SessionRuntimeSnapshot, SessionSummary, + ThinkingCompletionReason, ThinkingSpan, TurnActivityState, UpdateSessionRequest, prefixed_id, + timestamp_now, }; +use failure::ProviderTurnFailure; +use text_delta::TextDeltaUpdate; +use timing::{ProviderTurnTiming, provider_event_name}; #[derive(Clone)] pub struct SantiService { pub(crate) store: SantiStore, provider: Arc, pub(crate) config: SantiServiceConfig, + material_cache: Arc>>, stream_events: broadcast::Sender, } +type MaterialCacheKey = (String, MaterialKind); + #[derive(Debug, Clone)] pub struct SantiServiceConfig { pub database_path: String, @@ -43,6 +55,7 @@ impl SantiService { store, provider, config, + material_cache: Arc::new(Mutex::new(HashMap::new())), stream_events: broadcast::channel(1024).0, }) } @@ -95,7 +108,7 @@ impl SantiService { &self, session_id: &str, request: SendSessionRequest, - ) -> Result { + ) -> Result { let text = request.text(); if text.trim().is_empty() { return Err("send content must contain text".to_string()); @@ -135,86 +148,106 @@ impl SantiService { SantiStreamPayload::TurnStarted { turn: turn.clone() }, ); + let snapshot = self + .store + .runtime_snapshot(session_id)? + .ok_or_else(|| "soul_session disappeared".to_string())?; + let accepted_soul_session = snapshot + .soul_session + .ok_or_else(|| "soul_session disappeared".to_string())?; + let soul_profile = snapshot + .soul_profile + .ok_or_else(|| "soul_profile disappeared".to_string())?; + let background = self.clone(); + let background_session_id = session_id.to_string(); + let background_soul_session_id = soul_session.id.clone(); + let background_turn_id = turn.id.clone(); + tokio::spawn(async move { + background + .complete_provider_turn( + background_session_id, + background_soul_session_id, + background_turn_id, + ) + .await; + }); + + Ok(SendSessionAcceptedResponse { + session: SessionSummary { + session: snapshot.session, + profile: snapshot.profile, + }, + soul_session: accepted_soul_session, + soul_profile, + turn, + user_message, + }) + } + + async fn complete_provider_turn( + &self, + session_id: String, + soul_session_id: String, + turn_id: String, + ) { let send_result = self - .run_provider_turn(session_id, &soul_session.id, &turn.id) + .run_provider_turn(&session_id, &soul_session_id, &turn_id) .await; let (assistant_text, provider_response_id) = match send_result { Ok(value) => value, - Err(error) => { - let _ = self.store.fail_turn(&turn.id, &error); - self.publish_stream( - session_id, - SantiStreamPayload::TurnFailed { - turn_id: turn.id.clone(), - error: error.clone(), - }, + Err(failure) => { + self.fail_background_turn( + &session_id, + &turn_id, + failure.error, + failure.partial_assistant_text, ); - return Err(error); + return; } }; if assistant_text.trim().is_empty() { let error = "provider completed without assistant output".to_string(); - let _ = self.store.fail_turn(&turn.id, &error); - self.publish_stream( - session_id, - SantiStreamPayload::TurnFailed { - turn_id: turn.id.clone(), - error: error.clone(), - }, - ); - return Err(error); + self.fail_background_turn(&session_id, &turn_id, error, String::new()); + return; } - let assistant_message = self + let assistant_message = match self.store.append_message( + &session_id, + ActorType::Soul, + self.store.default_soul_id(), + MessageContent::text(assistant_text.clone()), + MessageState::Fixed, + ) { + Ok(message) => message.session_message, + Err(error) => { + self.fail_background_turn(&session_id, &turn_id, error, assistant_text); + return; + } + }; + if let Err(error) = self .store - .append_message( - session_id, - ActorType::Soul, - self.store.default_soul_id(), - MessageContent::text(assistant_text), - MessageState::Fixed, - )? - .session_message; - self.store - .append_message_ref(&soul_session.id, &assistant_message.message.id)?; - let completed_turn = self.store.complete_turn( - &turn.id, + .append_message_ref(&soul_session_id, &assistant_message.message.id) + { + self.fail_background_turn(&session_id, &turn_id, error, String::new()); + return; + } + if let Err(error) = self.store.complete_turn( + &turn_id, assistant_message.relation.session_seq, provider_response_id, - )?; + ) { + self.fail_background_turn(&session_id, &turn_id, error, String::new()); + return; + } self.publish_stream( - session_id, + &session_id, SantiStreamPayload::MessageCompleted { - turn_id: turn.id.clone(), - message: assistant_message.clone(), + turn_id, + message: assistant_message, }, ); - - let snapshot = self - .store - .runtime_snapshot(session_id)? - .ok_or_else(|| "soul_session disappeared".to_string())?; - let soul_session = snapshot - .soul_session - .ok_or_else(|| "soul_session disappeared".to_string())?; - let soul_profile = snapshot - .soul_profile - .ok_or_else(|| "soul_profile disappeared".to_string())?; - Ok(SendSessionResponse { - session: SessionSummary { - session: snapshot.session, - profile: snapshot.profile, - }, - soul_session, - soul_profile, - turn: completed_turn, - user_message, - assistant_message, - tool_calls: self.store.tool_calls_for_turn(&turn.id)?, - tool_results: self.store.tool_results_for_turn(&turn.id)?, - }) } async fn run_provider_turn( @@ -222,24 +255,29 @@ impl SantiService { session_id: &str, soul_session_id: &str, turn_id: &str, - ) -> Result<(String, Option), String> { + ) -> Result<(String, Option), ProviderTurnFailure> { let mut assistant_text = String::new(); let mut function_call_outputs = Vec::new(); + let mut timing = ProviderTurnTiming::new(turn_id); + let mut round = 0; + macro_rules! provider_try { + ($expr:expr) => { + match $expr { + Ok(value) => value, + Err(error) => return Err(ProviderTurnFailure::new(error, &assistant_text)), + } + }; + } let final_response_id = loop { - let input = self - .store - .assembly_input(soul_session_id)? - .into_iter() - .map(|message| ProviderMessage { - role: message.role, - content: message.content, - }) - .collect(); + round += 1; + let input = provider_try!(provider_messages(&self.store, soul_session_id)); let metadata = self.provider.metadata(); let request = ProviderRequest { model: metadata.model, - instructions: Some(self.runtime_instructions(session_id, soul_session_id)?), + instructions: Some(provider_try!( + self.system_prompt_text(session_id, soul_session_id) + )), input, tools: Some(provider_tools()), previous_response_id: None, @@ -249,34 +287,144 @@ impl SantiService { Some(function_call_outputs.clone()) }, }; - let mut stream = self.provider.stream_response(request).await?; + timing.request_built( + round, + request.input.len(), + request.instructions.as_ref().map_or(0, |text| text.len()), + request + .function_call_outputs + .as_ref() + .map_or(0, |outputs| outputs.len()), + ); + self.publish_turn_activity(session_id, turn_id, TurnActivityState::Requesting, None); + let mut stream = match self.provider.stream_response(request).await { + Ok(stream) => { + timing.http_response_started(round); + stream + } + Err(error) => { + timing.failed(round, "http_response", &error); + return Err(ProviderTurnFailure::new(error, &assistant_text)); + } + }; let mut calls = Vec::new(); let mut completed_response_id = None; + let mut active_provider_response_id = None; + let mut current_thinking_span: Option = None; + let mut summary_thinking_span: Option = None; + let mut reasoning_summary = String::new(); + let mut round_assistant_text = String::new(); + let mut saw_sse_event = false; while let Some(event) = stream.next().await { - match event? { - ProviderEvent::TextDelta(delta) => { - assistant_text.push_str(&delta); - self.publish_stream( + let event = match event { + Ok(event) => event, + Err(error) => { + timing.failed(round, "sse_event", &error); + provider_try!(self.fail_current_thinking_span( + session_id, + &mut current_thinking_span, + error.clone(), + )); + return Err(ProviderTurnFailure::new(error, &assistant_text)); + } + }; + if let ProviderEvent::StreamTrace(trace) = event { + timing.provider_trace(round, trace); + continue; + } + if !saw_sse_event { + saw_sse_event = true; + timing.first_sse_event(round, provider_event_name(&event)); + } + match event { + ProviderEvent::StreamTrace(_) => {} + ProviderEvent::ResponseStarted { + provider_response_id, + } + | ProviderEvent::ResponseInProgress { + provider_response_id, + } => { + active_provider_response_id = provider_response_id.clone(); + provider_try!(self.ensure_thinking_span( + session_id, + turn_id, + &mut current_thinking_span, + &mut summary_thinking_span, + provider_response_id.clone(), + )); + self.publish_turn_activity( session_id, - SantiStreamPayload::MessageDelta { - message_id: format!("stream_{turn_id}"), - turn_id: turn_id.to_string(), - role: ActorType::Soul, - text: delta, - }, + turn_id, + TurnActivityState::Thinking, + provider_response_id, ); } + ProviderEvent::ReasoningSummaryDelta(delta) => { + reasoning_summary.push_str(&delta); + provider_try!(self.update_thinking_span_summary( + session_id, + &mut summary_thinking_span, + reasoning_summary.clone(), + )); + } + ProviderEvent::ReasoningSummaryDone(summary) => { + reasoning_summary = summary; + provider_try!(self.update_thinking_span_summary( + session_id, + &mut summary_thinking_span, + reasoning_summary.clone(), + )); + } + ProviderEvent::TextDelta(delta) => { + let update = TextDeltaUpdate { + session_id, + turn_id, + assistant_text: &mut assistant_text, + round_assistant_text: &mut round_assistant_text, + timing: &timing, + round, + current_thinking_span: &mut current_thinking_span, + active_provider_response_id: &active_provider_response_id, + }; + provider_try!(self.handle_text_delta(delta, update)); + } ProviderEvent::FunctionCallRequested(call) => { + timing.function_call_requested(round, &call.name); + provider_try!(self.complete_current_thinking_span( + session_id, + &mut current_thinking_span, + ThinkingCompletionReason::ToolCallRequested, + )); + self.publish_turn_activity( + session_id, + turn_id, + TurnActivityState::CallingTool, + active_provider_response_id.clone(), + ); calls.push(call); } ProviderEvent::Completed { provider_response_id, } => { + timing.completed(round); + active_provider_response_id = provider_response_id.clone(); + provider_try!(self.complete_current_thinking_span( + session_id, + &mut current_thinking_span, + ThinkingCompletionReason::ProviderCompleted, + )); completed_response_id = provider_response_id; break; } - ProviderEvent::Failed(error) => return Err(error), + ProviderEvent::Failed(error) => { + provider_try!(self.fail_current_thinking_span( + session_id, + &mut current_thinking_span, + error.clone(), + )); + return Err(ProviderTurnFailure::new(error, &assistant_text)); + } } } @@ -285,53 +433,36 @@ impl SantiService { } let mut outputs = Vec::new(); + timing.tool_outputs_started(round, calls.len()); for call in calls { - outputs.push(self.handle_tool_call(session_id, soul_session_id, turn_id, call)?); + self.publish_turn_activity( + session_id, + turn_id, + TurnActivityState::RunningTool, + active_provider_response_id.clone(), + ); + let mut output = provider_try!(self.handle_tool_call( + session_id, + soul_session_id, + turn_id, + call + )); + if !round_assistant_text.is_empty() { + output.assistant_content = Some(round_assistant_text.clone()); + } + if !reasoning_summary.is_empty() { + output.reasoning_content = Some(reasoning_summary.clone()); + } + outputs.push(output); } + timing.tool_outputs_completed(round, outputs.len()); function_call_outputs.extend(outputs); }; Ok((assistant_text, final_response_id)) } - fn runtime_instructions( - &self, - session_id: &str, - soul_session_id: &str, - ) -> Result { - let snapshot = self - .store - .runtime_snapshot(session_id)? - .ok_or_else(|| "session not found".to_string())?; - let soul_session = snapshot - .soul_session - .ok_or_else(|| "soul_session not found".to_string())?; - let metadata = self.provider.metadata(); - Ok( - [ - "You are santi, a customized personal agent service.".to_string(), - format!( - "\nsession_id: {session_id}\nsoul_id: {}\nhas_soul_memory: unknown\nhas_session_memory: {}\nhas_request_instructions: false\n", - soul_session.soul_id, - !soul_session.session_memory.trim().is_empty() - ), - render_self_assessment_instructions(), - format!( - "\nservice_name: santi\nassembly_mode: mini-stim-sidecar\nlaunch_profile: dev\nbind_addr: {}\nprovider_model: {}\nprovider_api: responses\nprovider_gateway_base_url: unknown\nSANTI_SOUL_MEMORY_DIR: {}\nSANTI_SESSION_MEMORY_DIR: {}\nfallback_cwd: {}\nsoul_session_id: {}\n", - self.config.bind_addr.as_deref().unwrap_or("unknown"), - metadata.model, - self.soul_memory_dir().display(), - self.session_memory_dir(session_id).display(), - self.execution_root().display(), - soul_session_id - ), - tooling_instructions(), - ] - .join("\n\n"), - ) - } - - fn publish_stream(&self, session_id: &str, payload: SantiStreamPayload) { + pub(crate) fn publish_stream(&self, session_id: &str, payload: SantiStreamPayload) { let _ = self.stream_events.send(SantiStreamEvent { event_id: prefixed_id("stream"), session_id: session_id.to_string(), @@ -339,156 +470,4 @@ impl SantiService { payload, }); } - - fn handle_tool_call( - &self, - session_id: &str, - soul_session_id: &str, - turn_id: &str, - call: ProviderFunctionCall, - ) -> Result { - let tool_call = - self.store - .append_tool_call(turn_id, &call.call_id, &call.name, &call.arguments)?; - self.publish_stream( - session_id, - SantiStreamPayload::ToolCallCreated { - tool_call: tool_call.clone(), - }, - ); - let dispatch = self.dispatch_tool(session_id, soul_session_id, &call); - let (output, error_text) = match dispatch { - Ok(output) => (Some(output), None), - Err(error) => (None, Some(error)), - }; - let result = - self.store - .append_tool_result(&call.call_id, output.clone(), error_text.clone())?; - self.publish_stream( - session_id, - SantiStreamPayload::ToolResultCreated { - tool_result: result.clone(), - }, - ); - Ok(FunctionCallOutput { - call_id: call.call_id.clone(), - call, - output: serde_json::to_string(&json!({ - "ok": error_text.is_none(), - "output": result.output, - "error": result.error_text, - })) - .map_err(|error| error.to_string())?, - }) - } - - fn dispatch_tool( - &self, - session_id: &str, - soul_session_id: &str, - call: &ProviderFunctionCall, - ) -> Result { - match call.name.as_str() { - "write_soul_memory" => { - let args = parse_tool_args::(&call.arguments)?; - let soul = self.store.write_soul_memory(&args.text)?; - Ok(json!({ "ok": true, "soul_id": soul.id })) - } - "write_session_memory" => { - let args = parse_tool_args::(&call.arguments)?; - let soul_session = self - .store - .write_session_memory(soul_session_id, &args.text)?; - Ok(json!({ "ok": true, "soul_session_id": soul_session.id })) - } - "shell" => { - let args = parse_tool_args::(&call.arguments)?; - self.run_shell(session_id, args) - } - name => Err(format!("unsupported tool: {name}")), - } - } - - fn run_shell(&self, session_id: &str, args: ShellArgs) -> Result { - std::fs::create_dir_all(self.soul_memory_dir()).map_err(|error| error.to_string())?; - let cwd = args - .cwd - .map(PathBuf::from) - .unwrap_or_else(|| self.execution_root()); - std::fs::create_dir_all(&cwd).map_err(|error| error.to_string())?; - let mut command = shell_command(&args.command); - let output = command - .current_dir(&cwd) - .env("SANTI_SOUL_MEMORY_DIR", self.soul_memory_dir()) - .env( - "SANTI_SESSION_MEMORY_DIR", - self.session_memory_dir(session_id), - ) - .output() - .map_err(|error| format!("failed to run shell: {error}"))?; - Ok(json!({ - "exit_code": output.status.code().unwrap_or(-1), - "stdout": String::from_utf8_lossy(&output.stdout), - "stderr": String::from_utf8_lossy(&output.stderr), - "shell": default_shell_name(), - })) - } - - fn runtime_root(&self) -> PathBuf { - PathBuf::from(&self.config.runtime_root) - } - - fn execution_root(&self) -> PathBuf { - PathBuf::from(&self.config.execution_root) - } - - fn soul_memory_dir(&self) -> PathBuf { - self.runtime_root().join("souls").join("memory") - } - - fn session_memory_dir(&self, session_id: &str) -> PathBuf { - self.runtime_root() - .join("sessions") - .join(session_id) - .join("memory") - } -} - -#[derive(Debug, Deserialize)] -struct WriteMemoryArgs { - text: String, -} - -#[derive(Debug, Deserialize)] -struct ShellArgs { - command: String, - cwd: Option, -} - -fn shell_command(command: &str) -> Command { - #[cfg(windows)] - { - let mut shell = Command::new("pwsh"); - shell - .arg("-NoLogo") - .arg("-NoProfile") - .arg("-Command") - .arg(command); - shell - } - - #[cfg(not(windows))] - { - let mut shell = Command::new("/bin/bash"); - shell.arg("-lc").arg(command); - shell - } -} - -fn default_shell_name() -> &'static str { - if cfg!(windows) { "pwsh" } else { "bash" } -} - -fn parse_tool_args Deserialize<'de>>(value: &Value) -> Result { - serde_json::from_value(value.clone()).map_err(|error| error.to_string()) } diff --git a/apps/server/soma/crates/santi-core/src/service/failure.rs b/apps/server/soma/crates/santi-core/src/service/failure.rs new file mode 100644 index 0000000..622db48 --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/service/failure.rs @@ -0,0 +1,93 @@ +use crate::{ActorType, MessageContent, MessageState, SantiStreamPayload}; + +use super::SantiService; + +#[derive(Debug)] +pub(super) struct ProviderTurnFailure { + pub(super) error: String, + pub(super) partial_assistant_text: String, +} + +impl ProviderTurnFailure { + pub(super) fn new(error: String, partial_assistant_text: &str) -> Self { + Self { + error, + partial_assistant_text: partial_assistant_text.to_string(), + } + } +} + +impl SantiService { + pub(super) fn fail_background_turn( + &self, + session_id: &str, + turn_id: &str, + error: String, + partial_assistant_text: String, + ) { + let mut last_seen_session_seq = None; + if let Ok(turn) = self.store.fail_turn(turn_id, &error) { + if !partial_assistant_text.trim().is_empty() + && let Ok(message) = self.store.append_message( + session_id, + ActorType::Soul, + self.store.default_soul_id(), + MessageContent::text(partial_assistant_text), + MessageState::Aborted, + ) + && self + .store + .append_message_ref(&turn.soul_session_id, &message.session_message.message.id) + .is_ok() + { + last_seen_session_seq = Some(message.session_message.relation.session_seq); + self.publish_stream( + session_id, + SantiStreamPayload::MessageCreated { + message: message.session_message, + }, + ); + } + if let Ok(message) = self + .store + .append_santi_system_message(session_id, failed_system_message(turn_id)) + && self + .store + .append_message_ref(&turn.soul_session_id, &message.session_message.message.id) + .is_ok() + { + last_seen_session_seq = Some(message.session_message.relation.session_seq); + self.publish_stream( + session_id, + SantiStreamPayload::MessageCreated { + message: message.session_message, + }, + ); + } + if let Some(seq) = last_seen_session_seq { + let _ = self.store.finish_failed_turn_context(turn_id, seq); + } + } + self.publish_stream( + session_id, + SantiStreamPayload::TurnFailed { + turn_id: turn_id.to_string(), + error, + }, + ); + } +} + +fn failed_system_message(turn_id: &str) -> MessageContent { + MessageContent::text( + [ + "".to_string(), + "kind: turn_failed".to_string(), + format!("turn_id: {turn_id}"), + format!("trace: log://turn/{turn_id}"), + "summary: Previous response attempt failed before completion.".to_string(), + "".to_string(), + ] + .join("\n"), + ) +} diff --git a/apps/server/soma/crates/santi-core/src/service/materials.rs b/apps/server/soma/crates/santi-core/src/service/materials.rs new file mode 100644 index 0000000..bcc16e8 --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/service/materials.rs @@ -0,0 +1,99 @@ +use crate::assembly::system_prompt::{SystemPromptRequest, render_system_prompt}; +use crate::{ + MaterialKind, MaterialRequest, MaterialUpdated, SantiStreamPayload, SessionMaterial, + SoulProfile, SoulSession, timestamp_now, +}; + +use super::{MaterialCacheKey, SantiService}; + +const TEXT_PLAIN_UTF8: &str = "text/plain; charset=utf-8"; + +impl SantiService { + pub fn session_material( + &self, + session_id: &str, + request: MaterialRequest, + ) -> Result { + match request.kind { + MaterialKind::SystemPrompt => { + let soul_session = self.store.acquire_soul_session(session_id)?.soul_session; + let snapshot = self + .store + .runtime_snapshot(session_id)? + .ok_or_else(|| "session not found".to_string())?; + let soul_profile = snapshot + .soul_profile + .ok_or_else(|| "soul_profile not found".to_string())?; + self.system_prompt_material(session_id, &soul_session, &soul_profile) + } + } + } + + pub(super) fn system_prompt_text( + &self, + session_id: &str, + soul_session_id: &str, + ) -> Result { + let snapshot = self + .store + .runtime_snapshot(session_id)? + .ok_or_else(|| "session not found".to_string())?; + let soul_session = snapshot + .soul_session + .ok_or_else(|| "soul_session not found".to_string())?; + if soul_session.id != soul_session_id { + return Err("soul_session mismatch".to_string()); + } + let soul_profile = snapshot + .soul_profile + .ok_or_else(|| "soul_profile not found".to_string())?; + Ok(self + .system_prompt_material(session_id, &soul_session, &soul_profile)? + .text) + } + + fn system_prompt_material( + &self, + session_id: &str, + soul_session: &SoulSession, + soul_profile: &SoulProfile, + ) -> Result { + let text = render_system_prompt(SystemPromptRequest { + session_id, + soul_session, + soul_profile, + soul_memory_path: self.soul_memory_file(), + session_memory_path: self.session_memory_file(session_id), + })?; + let key: MaterialCacheKey = (session_id.to_string(), MaterialKind::SystemPrompt); + let mut cache = self.material_cache.lock().unwrap(); + if let Some(existing) = cache.get(&key) + && existing.text == text + { + return Ok(existing.clone()); + } + + let updated_at = timestamp_now(); + let material = SessionMaterial { + session_id: session_id.to_string(), + kind: MaterialKind::SystemPrompt, + content_type: TEXT_PLAIN_UTF8.to_string(), + text, + updated_at: updated_at.clone(), + }; + cache.insert(key, material.clone()); + drop(cache); + + self.publish_stream( + session_id, + SantiStreamPayload::MaterialUpdated { + material: MaterialUpdated { + session_id: session_id.to_string(), + kind: MaterialKind::SystemPrompt, + updated_at, + }, + }, + ); + Ok(material) + } +} diff --git a/apps/server/soma/crates/santi-core/src/service/text_delta.rs b/apps/server/soma/crates/santi-core/src/service/text_delta.rs new file mode 100644 index 0000000..e9e2ba5 --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/service/text_delta.rs @@ -0,0 +1,51 @@ +use crate::{ + ActorType, SantiStreamPayload, ThinkingCompletionReason, ThinkingSpan, TurnActivityState, +}; + +use super::{SantiService, timing::ProviderTurnTiming}; + +pub(super) struct TextDeltaUpdate<'a, 'turn> { + pub(super) session_id: &'a str, + pub(super) turn_id: &'a str, + pub(super) assistant_text: &'a mut String, + pub(super) round_assistant_text: &'a mut String, + pub(super) timing: &'a ProviderTurnTiming<'turn>, + pub(super) round: usize, + pub(super) current_thinking_span: &'a mut Option, + pub(super) active_provider_response_id: &'a Option, +} + +impl SantiService { + pub(super) fn handle_text_delta( + &self, + delta: String, + update: TextDeltaUpdate<'_, '_>, + ) -> Result<(), String> { + if update.assistant_text.is_empty() { + update.timing.first_text_delta(update.round); + self.complete_current_thinking_span( + update.session_id, + update.current_thinking_span, + ThinkingCompletionReason::FirstTextDelta, + )?; + self.publish_turn_activity( + update.session_id, + update.turn_id, + TurnActivityState::Generating, + update.active_provider_response_id.clone(), + ); + } + update.assistant_text.push_str(&delta); + update.round_assistant_text.push_str(&delta); + self.publish_stream( + update.session_id, + SantiStreamPayload::MessageDelta { + message_id: format!("stream_{}", update.turn_id), + turn_id: update.turn_id.to_string(), + role: ActorType::Soul, + text: delta, + }, + ); + Ok(()) + } +} diff --git a/apps/server/soma/crates/santi-core/src/service/thinking.rs b/apps/server/soma/crates/santi-core/src/service/thinking.rs new file mode 100644 index 0000000..13faecb --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/service/thinking.rs @@ -0,0 +1,131 @@ +use crate::{ + SantiStreamPayload, ThinkingCompletionReason, ThinkingSpan, TurnActivity, TurnActivityState, +}; + +use super::SantiService; + +impl SantiService { + pub(super) fn ensure_thinking_span( + &self, + session_id: &str, + turn_id: &str, + current: &mut Option, + summary_target: &mut Option, + provider_response_id: Option, + ) -> Result<(), String> { + if let Some(thinking) = current { + if provider_response_id.is_some() + && thinking.provider_response_id != provider_response_id + && let Some(updated) = self + .store + .update_thinking_span_response(&thinking.id, provider_response_id)? + { + *thinking = updated.clone(); + *summary_target = Some(updated.clone()); + self.publish_stream( + session_id, + SantiStreamPayload::ThinkingUpdated { thinking: updated }, + ); + } + return Ok(()); + } + + let thinking = self + .store + .append_thinking_span(turn_id, provider_response_id)?; + self.publish_stream( + session_id, + SantiStreamPayload::ThinkingCreated { + thinking: thinking.clone(), + }, + ); + *summary_target = Some(thinking.clone()); + *current = Some(thinking); + Ok(()) + } + + pub(super) fn update_thinking_span_summary( + &self, + session_id: &str, + summary_target: &mut Option, + summary: String, + ) -> Result<(), String> { + if summary.trim().is_empty() { + return Ok(()); + } + let Some(thinking) = summary_target else { + return Ok(()); + }; + if let Some(updated) = self + .store + .update_thinking_span_summary(&thinking.id, summary)? + { + *thinking = updated.clone(); + self.publish_stream( + session_id, + SantiStreamPayload::ThinkingUpdated { thinking: updated }, + ); + } + Ok(()) + } + + pub(super) fn complete_current_thinking_span( + &self, + session_id: &str, + current: &mut Option, + completion_reason: ThinkingCompletionReason, + ) -> Result<(), String> { + let Some(thinking) = current.take() else { + return Ok(()); + }; + if let Some(completed) = self + .store + .complete_thinking_span(&thinking.id, completion_reason)? + { + self.publish_stream( + session_id, + SantiStreamPayload::ThinkingCompleted { + thinking: completed, + }, + ); + } + Ok(()) + } + + pub(super) fn fail_current_thinking_span( + &self, + session_id: &str, + current: &mut Option, + error_text: String, + ) -> Result<(), String> { + let Some(thinking) = current.take() else { + return Ok(()); + }; + if let Some(failed) = self.store.fail_thinking_span(&thinking.id, error_text)? { + self.publish_stream( + session_id, + SantiStreamPayload::ThinkingCompleted { thinking: failed }, + ); + } + Ok(()) + } + + pub(super) fn publish_turn_activity( + &self, + session_id: &str, + turn_id: &str, + state: TurnActivityState, + provider_response_id: Option, + ) { + self.publish_stream( + session_id, + SantiStreamPayload::TurnActivity { + activity: TurnActivity { + turn_id: turn_id.to_string(), + state, + provider_response_id, + }, + }, + ); + } +} diff --git a/apps/server/soma/crates/santi-core/src/service/timing.rs b/apps/server/soma/crates/santi-core/src/service/timing.rs new file mode 100644 index 0000000..607288d --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/service/timing.rs @@ -0,0 +1,208 @@ +use std::{ + collections::{HashMap, HashSet}, + time::Instant, +}; + +use santi_provider::{ProviderEvent, ProviderStreamTrace}; + +pub(super) struct ProviderTurnTiming<'a> { + turn_id: &'a str, + turn_started: Instant, + round_started: Option, + response_started: Option, + chunks: usize, + bytes: usize, + raw_events: usize, + raw_counts: HashMap, + seen_raw_types: HashSet, +} + +impl<'a> ProviderTurnTiming<'a> { + pub(super) fn new(turn_id: &'a str) -> Self { + let timing = Self { + turn_id, + turn_started: Instant::now(), + round_started: None, + response_started: None, + chunks: 0, + bytes: 0, + raw_events: 0, + raw_counts: HashMap::new(), + seen_raw_types: HashSet::new(), + }; + timing.log("turn_started", 0, ""); + timing + } + + pub(super) fn request_built( + &mut self, + round: usize, + input_len: usize, + instructions_len: usize, + function_outputs_len: usize, + ) { + self.round_started = Some(Instant::now()); + self.response_started = None; + self.log( + "request_built", + round, + &format!( + "input_len={input_len} instructions_len={instructions_len} function_outputs_len={function_outputs_len}" + ), + ); + } + + pub(super) fn http_response_started(&mut self, round: usize) { + self.response_started = Some(Instant::now()); + self.log("http_response_started", round, &self.round_elapsed()); + } + + pub(super) fn first_sse_event(&self, round: usize, event_name: &'static str) { + self.log( + "first_sse_event", + round, + &format!("event={event_name} {}", self.response_elapsed()), + ); + } + + pub(super) fn first_text_delta(&self, round: usize) { + self.log("first_text_delta", round, &self.response_elapsed()); + } + + pub(super) fn function_call_requested(&self, round: usize, name: &str) { + self.log( + "function_call_requested", + round, + &format!("name={name} {}", self.response_elapsed()), + ); + } + + pub(super) fn completed(&self, round: usize) { + self.log( + "provider_completed", + round, + &format!( + "{} chunks={} bytes={} raw_events={}", + self.response_elapsed(), + self.chunks, + self.bytes, + self.raw_events + ), + ); + } + + pub(super) fn tool_outputs_started(&self, round: usize, count: usize) { + self.log("tool_outputs_started", round, &format!("count={count}")); + } + + pub(super) fn tool_outputs_completed(&self, round: usize, count: usize) { + self.log("tool_outputs_completed", round, &format!("count={count}")); + } + + pub(super) fn failed(&self, round: usize, stage: &str, error: &str) { + self.log( + "failed", + round, + &format!( + "stage={stage} chunks={} bytes={} raw_events={} error={error}", + self.chunks, self.bytes, self.raw_events + ), + ); + } + + pub(super) fn provider_trace(&mut self, round: usize, trace: ProviderStreamTrace) { + match trace { + ProviderStreamTrace::Chunk { bytes } => { + self.chunks += 1; + self.bytes += bytes; + if self.chunks == 1 { + self.log( + "provider_chunk", + round, + &format!("chunk_bytes={bytes} total_bytes={}", self.bytes), + ); + } + } + ProviderStreamTrace::RawEvent { + raw_type, + mapped_events, + } => { + self.raw_events += 1; + let (count, is_first) = self.record_raw_event(&raw_type); + if should_log_raw_event(is_first, &mapped_events) { + self.log( + "provider_raw_event", + round, + &format!( + "raw_type={raw_type} raw_count={count} mapped={mapped} raw_events={}", + self.raw_events, + mapped = mapped_event_list(&mapped_events) + ), + ); + } + } + } + } + + fn record_raw_event(&mut self, raw_type: &str) -> (usize, bool) { + let count = { + let count = self.raw_counts.entry(raw_type.to_string()).or_insert(0); + *count += 1; + *count + }; + let is_first = self.seen_raw_types.insert(raw_type.to_string()); + (count, is_first) + } + + fn round_elapsed(&self) -> String { + self.round_started + .map(|started| format!("round_ms={}", started.elapsed().as_millis())) + .unwrap_or_default() + } + + fn response_elapsed(&self) -> String { + self.response_started + .map(|started| format!("response_ms={}", started.elapsed().as_millis())) + .unwrap_or_default() + } + + fn log(&self, event: &str, round: usize, fields: &str) { + eprintln!( + "santi-timing turn_id={} event={} round={} turn_ms={} {}", + self.turn_id, + event, + round, + self.turn_started.elapsed().as_millis(), + fields + ); + } +} + +fn should_log_raw_event(is_first: bool, mapped_events: &[String]) -> bool { + is_first + || mapped_events + .iter() + .any(|event| !matches!(event.as_str(), "reasoning_summary_delta" | "text_delta")) +} + +fn mapped_event_list(mapped_events: &[String]) -> String { + if mapped_events.is_empty() { + "none".to_string() + } else { + mapped_events.join(",") + } +} + +pub(super) fn provider_event_name(event: &ProviderEvent) -> &'static str { + match event { + ProviderEvent::ResponseStarted { .. } => "response_started", + ProviderEvent::ResponseInProgress { .. } => "response_in_progress", + ProviderEvent::ReasoningSummaryDelta(_) => "reasoning_summary_delta", + ProviderEvent::ReasoningSummaryDone(_) => "reasoning_summary_done", + ProviderEvent::TextDelta(_) => "text_delta", + ProviderEvent::FunctionCallRequested(_) => "function_call_requested", + ProviderEvent::Completed { .. } => "completed", + ProviderEvent::Failed(_) => "failed", + ProviderEvent::StreamTrace(_) => "stream_trace", + } +} diff --git a/apps/server/soma/crates/santi-core/src/service/tools.rs b/apps/server/soma/crates/santi-core/src/service/tools.rs new file mode 100644 index 0000000..da57e90 --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/service/tools.rs @@ -0,0 +1,168 @@ +use std::{path::PathBuf, process::Command}; + +use santi_provider::{FunctionCallOutput, ProviderFunctionCall}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{SantiStreamPayload, WorkspaceRoot, parse_workspace_uri}; + +use super::SantiService; + +impl SantiService { + pub(super) fn handle_tool_call( + &self, + session_id: &str, + soul_session_id: &str, + turn_id: &str, + call: ProviderFunctionCall, + ) -> Result { + let tool_call = + self.store + .append_tool_call(turn_id, &call.call_id, &call.name, &call.arguments)?; + self.publish_stream( + session_id, + SantiStreamPayload::ToolCallCreated { + tool_call: tool_call.clone(), + }, + ); + let dispatch = self.dispatch_tool(session_id, soul_session_id, &call); + let (output, error_text) = match dispatch { + Ok(output) => (Some(output), None), + Err(error) => (None, Some(error)), + }; + let result = + self.store + .append_tool_result(&call.call_id, output.clone(), error_text.clone())?; + self.publish_stream( + session_id, + SantiStreamPayload::ToolResultCreated { + tool_result: result.clone(), + }, + ); + Ok(FunctionCallOutput { + call_id: call.call_id.clone(), + call, + output: serde_json::to_string(&json!({ + "ok": error_text.is_none(), + "output": result.output, + "error": result.error_text, + })) + .map_err(|error| error.to_string())?, + assistant_content: None, + reasoning_content: None, + }) + } + + fn dispatch_tool( + &self, + session_id: &str, + _soul_session_id: &str, + call: &ProviderFunctionCall, + ) -> Result { + match call.name.as_str() { + "shell" => { + let args = parse_tool_args::(&call.arguments)?; + self.run_shell(session_id, args) + } + name => Err(format!("unsupported tool: {name}")), + } + } + + fn run_shell(&self, session_id: &str, args: ShellArgs) -> Result { + std::fs::create_dir_all(self.soul_memory_dir()).map_err(|error| error.to_string())?; + std::fs::create_dir_all(self.session_memory_dir(session_id)) + .map_err(|error| error.to_string())?; + let cwd = self.resolve_shell_cwd(session_id, args.cwd.as_deref())?; + std::fs::create_dir_all(&cwd).map_err(|error| error.to_string())?; + let mut command = shell_command(&args.command); + let output = command + .current_dir(&cwd) + .env("SANTI_SOUL_MEMORY_DIR", self.soul_memory_dir()) + .env( + "SANTI_SESSION_MEMORY_DIR", + self.session_memory_dir(session_id), + ) + .output() + .map_err(|error| format!("failed to run shell: {error}"))?; + Ok(json!({ + "exit_code": output.status.code().unwrap_or(-1), + "stdout": String::from_utf8_lossy(&output.stdout), + "stderr": String::from_utf8_lossy(&output.stderr), + "shell": default_shell_name(), + "cwd": cwd.display().to_string(), + })) + } + + fn resolve_shell_cwd(&self, session_id: &str, cwd: Option<&str>) -> Result { + let Some(cwd) = cwd else { + return Ok(self.execution_root()); + }; + let uri = parse_workspace_uri(cwd)?; + let root = match uri.root { + WorkspaceRoot::Soul => self.soul_memory_dir(), + WorkspaceRoot::Session => self.session_memory_dir(session_id), + }; + Ok(root.join(uri.path)) + } + + pub(super) fn runtime_root(&self) -> PathBuf { + PathBuf::from(&self.config.runtime_root) + } + + pub(super) fn execution_root(&self) -> PathBuf { + PathBuf::from(&self.config.execution_root) + } + + pub(super) fn soul_memory_dir(&self) -> PathBuf { + self.runtime_root().join("souls").join("memory") + } + + pub(super) fn soul_memory_file(&self) -> PathBuf { + self.soul_memory_dir().join("MEMORY.md") + } + + pub(super) fn session_memory_dir(&self, session_id: &str) -> PathBuf { + self.runtime_root() + .join("sessions") + .join(session_id) + .join("memory") + } + + pub(super) fn session_memory_file(&self, session_id: &str) -> PathBuf { + self.session_memory_dir(session_id).join("MEMORY.md") + } +} + +#[derive(Debug, Deserialize)] +struct ShellArgs { + command: String, + cwd: Option, +} + +fn shell_command(command: &str) -> Command { + #[cfg(windows)] + { + let mut shell = Command::new("pwsh"); + shell + .arg("-NoLogo") + .arg("-NoProfile") + .arg("-Command") + .arg(command); + shell + } + + #[cfg(not(windows))] + { + let mut shell = Command::new("/bin/bash"); + shell.arg("-lc").arg(command); + shell + } +} + +fn default_shell_name() -> &'static str { + if cfg!(windows) { "pwsh" } else { "bash" } +} + +fn parse_tool_args Deserialize<'de>>(value: &Value) -> Result { + serde_json::from_value(value.clone()).map_err(|error| error.to_string()) +} diff --git a/apps/server/soma/crates/santi-core/src/service_prompt.rs b/apps/server/soma/crates/santi-core/src/service_prompt.rs index 828e091..7655d7d 100644 --- a/apps/server/soma/crates/santi-core/src/service_prompt.rs +++ b/apps/server/soma/crates/santi-core/src/service_prompt.rs @@ -1,86 +1,30 @@ use santi_provider::{ProviderFunctionTool, ProviderTool}; use serde_json::json; -pub(crate) fn render_self_assessment_instructions() -> String { - [ - "", - "When asked to assess your own runtime or product capability:", - "- Ground the answer in visible facts from the santi-meta block, the santi-runtime block, the tool list, and the current conversation.", - "- When tool results are available, label tool-confirmed facts separately from runtime/context-only facts and unknowns.", - "- Treat missing facts as unknown; do not infer service health, permissions, durable product-ledger state, or external process state unless visible or tool-confirmed.", - "- Keep the next action tied to the integrated stim -> santi product loop.", - "", - ] - .join("\n") -} - -pub(crate) fn tooling_instructions() -> String { - [ - "", - "Available tools:", - "- write_soul_memory(text: string): replace the current soul_memory core index text.", - "- write_session_memory(text: string): replace the current session_memory core index text.", - "- shell(command: string, cwd?: string): run a shell command inside the current execution workspace. Unix-like systems use bash by default; Windows uses pwsh by default.", - "Rules:", - "- soul_memory and session_memory are replace-whole core indexes, not append-only note stores.", - "- Do not claim memory has been updated unless the tool call has completed.", - "- Use shell when the user asks you to inspect or run something in the local workspace.", - "", - ] - .join("\n") -} +use crate::{SESSION_WORKSPACE_URI, SOUL_WORKSPACE_URI, session_memory_uri, soul_memory_uri}; pub(crate) fn provider_tools() -> Vec { - vec![ - ProviderTool::Function(ProviderFunctionTool { - name: "write_soul_memory".to_string(), - description: "Replace the current soul_memory core index text.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The full replacement text for the current soul_memory core index." - } - }, - "required": ["text"], - "additionalProperties": false - }), - }), - ProviderTool::Function(ProviderFunctionTool { - name: "write_session_memory".to_string(), - description: "Replace the current session_memory core index text.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The full replacement text for the current session_memory core index." - } - }, - "required": ["text"], - "additionalProperties": false - }), - }), - ProviderTool::Function(ProviderFunctionTool { - name: "shell".to_string(), - description: "Run a shell command inside the current execution workspace. Unix-like systems use bash by default; Windows uses pwsh by default." - .to_string(), - parameters: json!({ - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The shell command to execute." - }, - "cwd": { - "type": "string", - "description": "Optional working directory." - } + let soul_memory_uri = soul_memory_uri(); + let session_memory_uri = session_memory_uri(); + vec![ProviderTool::Function(ProviderFunctionTool { + name: "shell".to_string(), + description: format!( + "Run a shell command. By default commands run in the current execution workspace. Use cwd \"{SOUL_WORKSPACE_URI}\" to work in the current soul workspace, where {soul_memory_uri} is always rendered live in [santi-soul]. Use cwd \"{SESSION_WORKSPACE_URI}\" to work in the current session workspace, where {session_memory_uri} is always rendered live in [santi-session]. Unix-like systems use bash by default; Windows uses pwsh by default." + ), + parameters: json!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The shell command to execute." }, - "required": ["command"], - "additionalProperties": false - }), + "cwd": { + "type": "string", + "description": format!("Optional workspace URI. Supports {SOUL_WORKSPACE_URI}, {SOUL_WORKSPACE_URI}, {SESSION_WORKSPACE_URI}, and {SESSION_WORKSPACE_URI}.") + } + }, + "required": ["command"], + "additionalProperties": false }), - ] + })] } diff --git a/apps/server/soma/crates/santi-core/src/store.rs b/apps/server/soma/crates/santi-core/src/store.rs index d805a30..7b7cff6 100644 --- a/apps/server/soma/crates/santi-core/src/store.rs +++ b/apps/server/soma/crates/santi-core/src/store.rs @@ -6,10 +6,11 @@ use std::{ use rusqlite::{Connection, params}; use crate::{ - ActorType, MessageContent, MessageState, Session, SessionMessage, SessionSummary, SoulSession, - SoulSessionEntry, SoulSessionTargetType, Turn, prefixed_id, timestamp_now, + ActorType, MessageContent, MessageKind, MessageState, Session, SessionMessage, SessionSummary, + SoulSession, SoulSessionEntry, SoulSessionTargetType, Turn, prefixed_id, timestamp_now, }; +mod assembly; mod db; mod rows; mod runtime; @@ -19,9 +20,10 @@ use db::*; use rows::{actor_type_db, collect_rows, map_session_summary_row, message_state_db}; use schema::SCHEMA; -const SANTI_SCHEMA_VERSION: u32 = 4; +const SANTI_SCHEMA_VERSION: u32 = 9; const DEFAULT_ACCOUNT_ID: &str = "account_local"; const DEFAULT_SOUL_ID: &str = "soul_default"; +const SANTI_SYSTEM_ACTOR_ID: &str = "santi"; #[derive(Clone)] pub struct SantiStore { @@ -71,6 +73,7 @@ impl SantiStore { DROP TABLE IF EXISTS conversations; DROP TABLE IF EXISTS r_soul_session_messages; DROP TABLE IF EXISTS compacts; + DROP TABLE IF EXISTS thinking_spans; DROP TABLE IF EXISTS tool_results; DROP TABLE IF EXISTS tool_calls; DROP TABLE IF EXISTS turns; @@ -117,9 +120,9 @@ impl SantiStore { conn.execute( r#" INSERT OR IGNORE INTO soul_profiles ( - soul_id, nickname, avatar_ref, avatar_seed, desc, created_at, updated_at + soul_id, soul_name, nickname, avatar_ref, avatar_seed, desc, created_at, updated_at ) - VALUES (?1, 'Santi', NULL, ?1, NULL, ?2, ?2) + VALUES (?1, 'Liberte', 'Santi', NULL, ?1, NULL, ?2, ?2) "#, params![DEFAULT_SOUL_ID, now], ) @@ -237,6 +240,11 @@ impl SantiStore { } else { Vec::new() }, + thinking_spans: if let Some(soul_session) = &soul_session { + soul_thinking_spans(&conn, &soul_session.id)? + } else { + Vec::new() + }, tool_calls: if let Some(soul_session) = &soul_session { soul_tool_calls(&conn, &soul_session.id)? } else { @@ -263,6 +271,40 @@ impl SantiStore { actor_id: &str, content: MessageContent, state: MessageState, + ) -> Result { + self.append_message_with_kind( + session_id, + actor_type, + actor_id, + MessageKind::Text, + content, + state, + ) + } + + pub fn append_santi_system_message( + &self, + session_id: &str, + content: MessageContent, + ) -> Result { + self.append_message_with_kind( + session_id, + ActorType::System, + SANTI_SYSTEM_ACTOR_ID, + MessageKind::SantiSystem, + content, + MessageState::Fixed, + ) + } + + fn append_message_with_kind( + &self, + session_id: &str, + actor_type: ActorType, + actor_id: &str, + message_kind: MessageKind, + content: MessageContent, + state: MessageState, ) -> Result { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction().map_err(|error| error.to_string())?; @@ -274,14 +316,16 @@ impl SantiStore { tx.execute( r#" INSERT INTO messages ( - id, actor_type, actor_id, content, state, version, deleted_at, created_at, updated_at + id, actor_type, actor_id, message_kind, content, state, version, deleted_at, + created_at, updated_at ) - VALUES (?1, ?2, ?3, ?4, ?5, 1, NULL, ?6, ?6) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, NULL, ?7, ?7) "#, params![ message_id, actor_type_db(&actor_type), actor_id, + rows::message_kind_db(&message_kind), content_json, message_state_db(&state), now diff --git a/apps/server/soma/crates/santi-core/src/store/assembly.rs b/apps/server/soma/crates/santi-core/src/store/assembly.rs new file mode 100644 index 0000000..3b22d3b --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/store/assembly.rs @@ -0,0 +1,57 @@ +use rusqlite::params; + +use super::{ + SantiStore, + db::{compact_by_id, session_message_to_provider}, +}; + +impl SantiStore { + pub fn assembly_input( + &self, + soul_session_id: &str, + ) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + r#" + SELECT target_type, target_id + FROM r_soul_session_messages + WHERE soul_session_id = ?1 + ORDER BY soul_session_seq ASC + "#, + ) + .map_err(|error| error.to_string())?; + let rows = stmt + .query_map(params![soul_session_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|error| error.to_string())?; + let mut input = Vec::new(); + for row in rows { + let (target_type, target_id) = row.map_err(|error| error.to_string())?; + match target_type.as_str() { + "message" => { + if let Some(message) = super::db::message_by_id(&conn, &target_id)? + && let Some(provider_message) = session_message_to_provider(&message) + { + input.push(provider_message); + } + } + "compact" => { + if let Some(compact) = compact_by_id(&conn, &target_id)? { + input.push(crate::ProviderInputMessage { + role: "system".to_string(), + content: format!( + "[compact {}-{}]\n{}", + compact.start_session_seq, compact.end_session_seq, compact.summary + ), + }); + } + } + "thinking" | "tool_call" | "tool_result" => {} + _ => {} + } + } + Ok(input) + } +} diff --git a/apps/server/soma/crates/santi-core/src/store/db.rs b/apps/server/soma/crates/santi-core/src/store/db.rs index f9c8833..fd1feee 100644 --- a/apps/server/soma/crates/santi-core/src/store/db.rs +++ b/apps/server/soma/crates/santi-core/src/store/db.rs @@ -1,12 +1,15 @@ +mod timeline; + use rusqlite::{Connection, OptionalExtension, params}; use crate::{ - ActorType, Compact, Session, SessionEffect, SessionMessage, SessionProfile, SessionSummary, - Soul, SoulProfile, SoulSession, SoulSessionEntry, SoulSessionTargetType, ToolCall, ToolResult, - Turn, timestamp_now, + ActorType, Compact, MessageKind, Session, SessionEffect, SessionMessage, SessionProfile, + SessionSummary, SoulProfile, SoulSession, SoulSessionEntry, SoulSessionTargetType, + ThinkingSpan, ToolCall, ToolResult, Turn, timestamp_now, }; use super::rows::*; +pub(super) use timeline::*; pub(super) fn ensure_session(conn: &Connection, session_id: &str) -> Result<(), String> { let exists = conn @@ -130,30 +133,13 @@ pub(super) fn session_summary_by_id( .map_err(|error| error.to_string()) } -pub(super) fn soul_by_id(conn: &Connection, soul_id: &str) -> Result, String> { - conn.query_row( - "SELECT id, memory, created_at, updated_at FROM souls WHERE id = ?1 LIMIT 1", - params![soul_id], - |row| { - Ok(Soul { - id: row.get(0)?, - memory: row.get(1)?, - created_at: row.get(2)?, - updated_at: row.get(3)?, - }) - }, - ) - .optional() - .map_err(|error| error.to_string()) -} - pub(super) fn soul_profile_by_id( conn: &Connection, soul_id: &str, ) -> Result, String> { conn.query_row( r#" - SELECT soul_id, nickname, avatar_ref, avatar_seed, desc, created_at, updated_at + SELECT soul_id, soul_name, nickname, avatar_ref, avatar_seed, desc, created_at, updated_at FROM soul_profiles WHERE soul_id = ?1 LIMIT 1 @@ -211,7 +197,7 @@ pub(super) fn message_by_id( conn.query_row( r#" SELECT r.session_id, r.message_id, r.session_seq, r.created_at, - m.id, m.actor_type, m.actor_id, m.content, m.state, m.version, + m.id, m.actor_type, m.actor_id, m.message_kind, m.content, m.state, m.version, m.deleted_at, m.created_at, m.updated_at FROM r_session_messages r JOIN messages m ON m.id = r.message_id @@ -233,7 +219,7 @@ pub(super) fn session_messages( .prepare( r#" SELECT r.session_id, r.message_id, r.session_seq, r.created_at, - m.id, m.actor_type, m.actor_id, m.content, m.state, m.version, + m.id, m.actor_type, m.actor_id, m.message_kind, m.content, m.state, m.version, m.deleted_at, m.created_at, m.updated_at FROM r_session_messages r JOIN messages m ON m.id = r.message_id @@ -331,105 +317,23 @@ pub(super) fn tool_result_by_id( .map_err(|error| error.to_string()) } -pub(super) fn turns_for_soul_session( - conn: &Connection, - soul_session_id: &str, -) -> Result, String> { - let mut stmt = conn - .prepare( - r#" - SELECT id, soul_session_id, trigger_type, trigger_ref, input_through_session_seq, - base_soul_session_seq, end_soul_session_seq, status, error_text, - created_at, updated_at, finished_at - FROM turns - WHERE soul_session_id = ?1 - ORDER BY created_at ASC - "#, - ) - .map_err(|error| error.to_string())?; - let rows = stmt - .query_map(params![soul_session_id], map_turn_row) - .map_err(|error| error.to_string())?; - collect_rows(rows) -} - -pub(super) fn soul_tool_calls( - conn: &Connection, - soul_session_id: &str, -) -> Result, String> { - let mut stmt = conn - .prepare( - r#" - SELECT c.id, c.turn_id, c.tool_name, c.arguments, c.created_at - FROM tool_calls c - JOIN turns t ON t.id = c.turn_id - WHERE t.soul_session_id = ?1 - ORDER BY c.created_at ASC - "#, - ) - .map_err(|error| error.to_string())?; - let rows = stmt - .query_map(params![soul_session_id], map_tool_call_row) - .map_err(|error| error.to_string())?; - collect_rows(rows) -} - -pub(super) fn tool_calls_for_turn( - conn: &Connection, - turn_id: &str, -) -> Result, String> { - let mut stmt = conn - .prepare( - "SELECT id, turn_id, tool_name, arguments, created_at FROM tool_calls WHERE turn_id = ?1 ORDER BY created_at ASC", - ) - .map_err(|error| error.to_string())?; - let rows = stmt - .query_map(params![turn_id], map_tool_call_row) - .map_err(|error| error.to_string())?; - collect_rows(rows) -} - -pub(super) fn soul_tool_results( +pub(super) fn thinking_span_by_id( conn: &Connection, - soul_session_id: &str, -) -> Result, String> { - let mut stmt = conn - .prepare( - r#" - SELECT r.id, r.tool_call_id, r.output, r.error_text, r.created_at - FROM tool_results r - JOIN tool_calls c ON c.id = r.tool_call_id - JOIN turns t ON t.id = c.turn_id - WHERE t.soul_session_id = ?1 - ORDER BY r.created_at ASC - "#, - ) - .map_err(|error| error.to_string())?; - let rows = stmt - .query_map(params![soul_session_id], map_tool_result_row) - .map_err(|error| error.to_string())?; - collect_rows(rows) -} - -pub(super) fn tool_results_for_turn( - conn: &Connection, - turn_id: &str, -) -> Result, String> { - let mut stmt = conn - .prepare( - r#" - SELECT r.id, r.tool_call_id, r.output, r.error_text, r.created_at - FROM tool_results r - JOIN tool_calls c ON c.id = r.tool_call_id - WHERE c.turn_id = ?1 - ORDER BY r.created_at ASC - "#, - ) - .map_err(|error| error.to_string())?; - let rows = stmt - .query_map(params![turn_id], map_tool_result_row) - .map_err(|error| error.to_string())?; - collect_rows(rows) + thinking_span_id: &str, +) -> Result, String> { + conn.query_row( + r#" + SELECT id, turn_id, provider_response_id, state, summary, completion_reason, + error_text, created_at, updated_at, finished_at + FROM thinking_spans + WHERE id = ?1 + LIMIT 1 + "#, + params![thinking_span_id], + map_thinking_span_row, + ) + .optional() + .map_err(|error| error.to_string()) } pub(super) fn compacts_for_soul_session( @@ -477,10 +381,13 @@ pub(super) fn session_effects( pub(super) fn session_message_to_provider( message: &SessionMessage, ) -> Option { - let role = match message.message.actor_type { - ActorType::Account => "user", - ActorType::Soul => "assistant", - ActorType::System => "system", + let role = match message.message.message_kind { + MessageKind::SantiSystem => "user", + MessageKind::Text => match message.message.actor_type { + ActorType::Account => "user", + ActorType::Soul => "assistant", + ActorType::System => "system", + }, }; let content = message.message.content.content_text(); if content.trim().is_empty() { diff --git a/apps/server/soma/crates/santi-core/src/store/db/timeline.rs b/apps/server/soma/crates/santi-core/src/store/db/timeline.rs new file mode 100644 index 0000000..689312d --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/store/db/timeline.rs @@ -0,0 +1,152 @@ +use rusqlite::{Connection, params}; + +use crate::{ThinkingSpan, ToolCall, ToolResult, Turn}; + +use super::{ + collect_rows, map_thinking_span_row, map_tool_call_row, map_tool_result_row, map_turn_row, +}; + +pub(in crate::store) fn turns_for_soul_session( + conn: &Connection, + soul_session_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + r#" + SELECT id, soul_session_id, trigger_type, trigger_ref, input_through_session_seq, + base_soul_session_seq, end_soul_session_seq, status, error_text, + created_at, updated_at, finished_at + FROM turns + WHERE soul_session_id = ?1 + ORDER BY created_at ASC + "#, + ) + .map_err(|error| error.to_string())?; + let rows = stmt + .query_map(params![soul_session_id], map_turn_row) + .map_err(|error| error.to_string())?; + collect_rows(rows) +} + +pub(in crate::store) fn soul_tool_calls( + conn: &Connection, + soul_session_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + r#" + SELECT c.id, c.turn_id, c.tool_name, c.arguments, c.created_at + FROM tool_calls c + JOIN turns t ON t.id = c.turn_id + WHERE t.soul_session_id = ?1 + ORDER BY c.created_at ASC + "#, + ) + .map_err(|error| error.to_string())?; + let rows = stmt + .query_map(params![soul_session_id], map_tool_call_row) + .map_err(|error| error.to_string())?; + collect_rows(rows) +} + +pub(in crate::store) fn tool_calls_for_turn( + conn: &Connection, + turn_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT id, turn_id, tool_name, arguments, created_at FROM tool_calls WHERE turn_id = ?1 ORDER BY created_at ASC", + ) + .map_err(|error| error.to_string())?; + let rows = stmt + .query_map(params![turn_id], map_tool_call_row) + .map_err(|error| error.to_string())?; + collect_rows(rows) +} + +pub(in crate::store) fn soul_thinking_spans( + conn: &Connection, + soul_session_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + r#" + SELECT s.id, s.turn_id, s.provider_response_id, s.state, s.summary, + s.completion_reason, s.error_text, s.created_at, s.updated_at, + s.finished_at + FROM thinking_spans s + JOIN turns t ON t.id = s.turn_id + WHERE t.soul_session_id = ?1 + ORDER BY s.created_at ASC + "#, + ) + .map_err(|error| error.to_string())?; + let rows = stmt + .query_map(params![soul_session_id], map_thinking_span_row) + .map_err(|error| error.to_string())?; + collect_rows(rows) +} + +pub(in crate::store) fn thinking_spans_for_turn( + conn: &Connection, + turn_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + r#" + SELECT id, turn_id, provider_response_id, state, summary, completion_reason, + error_text, created_at, updated_at, finished_at + FROM thinking_spans + WHERE turn_id = ?1 + ORDER BY created_at ASC + "#, + ) + .map_err(|error| error.to_string())?; + let rows = stmt + .query_map(params![turn_id], map_thinking_span_row) + .map_err(|error| error.to_string())?; + collect_rows(rows) +} + +pub(in crate::store) fn soul_tool_results( + conn: &Connection, + soul_session_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + r#" + SELECT r.id, r.tool_call_id, r.output, r.error_text, r.created_at + FROM tool_results r + JOIN tool_calls c ON c.id = r.tool_call_id + JOIN turns t ON t.id = c.turn_id + WHERE t.soul_session_id = ?1 + ORDER BY r.created_at ASC + "#, + ) + .map_err(|error| error.to_string())?; + let rows = stmt + .query_map(params![soul_session_id], map_tool_result_row) + .map_err(|error| error.to_string())?; + collect_rows(rows) +} + +pub(in crate::store) fn tool_results_for_turn( + conn: &Connection, + turn_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + r#" + SELECT r.id, r.tool_call_id, r.output, r.error_text, r.created_at + FROM tool_results r + JOIN tool_calls c ON c.id = r.tool_call_id + WHERE c.turn_id = ?1 + ORDER BY r.created_at ASC + "#, + ) + .map_err(|error| error.to_string())?; + let rows = stmt + .query_map(params![turn_id], map_tool_result_row) + .map_err(|error| error.to_string())?; + collect_rows(rows) +} diff --git a/apps/server/soma/crates/santi-core/src/store/rows.rs b/apps/server/soma/crates/santi-core/src/store/rows.rs index b04cd6f..c2036df 100644 --- a/apps/server/soma/crates/santi-core/src/store/rows.rs +++ b/apps/server/soma/crates/santi-core/src/store/rows.rs @@ -2,9 +2,10 @@ use rusqlite::Row; use serde_json::Value; use crate::{ - ActorType, Compact, Message, MessageContent, MessageState, Session, SessionEffect, + ActorType, Compact, Message, MessageContent, MessageKind, MessageState, Session, SessionEffect, SessionMessage, SessionMessageRef, SessionProfile, SessionSummary, SoulProfile, SoulSession, - SoulSessionTargetType, ToolCall, ToolResult, Turn, TurnStatus, TurnTriggerType, + SoulSessionTargetType, ThinkingCompletionReason, ThinkingSpan, ThinkingSpanState, ToolCall, + ToolResult, Turn, TurnStatus, TurnTriggerType, }; pub(super) fn map_session_row(row: &Row<'_>) -> rusqlite::Result { @@ -49,12 +50,13 @@ pub(super) fn map_session_summary_row(row: &Row<'_>) -> rusqlite::Result) -> rusqlite::Result { Ok(SoulProfile { soul_id: row.get(0)?, - nickname: row.get(1)?, - avatar_ref: row.get(2)?, - avatar_seed: row.get(3)?, - desc: row.get(4)?, - created_at: row.get(5)?, - updated_at: row.get(6)?, + soul_name: row.get(1)?, + nickname: row.get(2)?, + avatar_ref: row.get(3)?, + avatar_seed: row.get(4)?, + desc: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, }) } @@ -76,22 +78,24 @@ pub(super) fn map_soul_session_row(row: &Row<'_>) -> rusqlite::Result) -> rusqlite::Result { - let content_json: String = row.get(7)?; + let content_json: String = row.get(8)?; let content = serde_json::from_str::(&content_json).map_err(|error| { - rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(error)) + rusqlite::Error::FromSqlConversionFailure(8, rusqlite::types::Type::Text, Box::new(error)) })?; let actor_type = actor_type_from_db(row.get::<_, String>(5)?.as_str()); - let state = message_state_from_db(row.get::<_, String>(8)?.as_str()); + let message_kind = message_kind_from_db(row.get::<_, String>(7)?.as_str()); + let state = message_state_from_db(row.get::<_, String>(9)?.as_str()); let message = Message { id: row.get(4)?, actor_type, actor_id: row.get(6)?, + message_kind, content, state, - version: row.get(9)?, - deleted_at: row.get(10)?, - created_at: row.get(11)?, - updated_at: row.get(12)?, + version: row.get(10)?, + deleted_at: row.get(11)?, + created_at: row.get(12)?, + updated_at: row.get(13)?, }; let content_text = message.content.content_text(); Ok(SessionMessage { @@ -148,6 +152,24 @@ pub(super) fn map_tool_result_row(row: &Row<'_>) -> rusqlite::Result }) } +pub(super) fn map_thinking_span_row(row: &Row<'_>) -> rusqlite::Result { + Ok(ThinkingSpan { + id: row.get(0)?, + turn_id: row.get(1)?, + provider_response_id: row.get(2)?, + state: thinking_state_from_db(row.get::<_, String>(3)?.as_str()), + summary: row.get(4)?, + completion_reason: row + .get::<_, Option>(5)? + .as_deref() + .map(completion_reason_from_db), + error_text: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + finished_at: row.get(9)?, + }) +} + pub(super) fn map_compact_row(row: &Row<'_>) -> rusqlite::Result { Ok(Compact { id: row.get(0)?, @@ -206,6 +228,22 @@ pub(super) fn message_state_db(value: &MessageState) -> &'static str { match value { MessageState::Pending => "pending", MessageState::Fixed => "fixed", + MessageState::Aborted => "aborted", + } +} + +pub(super) fn message_kind_db(value: &MessageKind) -> &'static str { + match value { + MessageKind::Text => "text", + MessageKind::SantiSystem => "santi_system", + } +} + +fn message_kind_from_db(value: &str) -> MessageKind { + match value { + "text" => MessageKind::Text, + "santi_system" => MessageKind::SantiSystem, + _ => MessageKind::Text, } } @@ -213,6 +251,7 @@ fn message_state_from_db(value: &str) -> MessageState { match value { "pending" => MessageState::Pending, "fixed" => MessageState::Fixed, + "aborted" => MessageState::Aborted, _ => MessageState::Fixed, } } @@ -234,10 +273,45 @@ fn turn_status_from_db(value: &str) -> TurnStatus { } } +pub(super) fn thinking_span_state_db(value: &ThinkingSpanState) -> &'static str { + match value { + ThinkingSpanState::Running => "running", + ThinkingSpanState::Completed => "completed", + ThinkingSpanState::Failed => "failed", + } +} + +pub(super) fn thinking_completion_reason_db(value: &ThinkingCompletionReason) -> &'static str { + match value { + ThinkingCompletionReason::FirstTextDelta => "first_text_delta", + ThinkingCompletionReason::ToolCallRequested => "tool_call_requested", + ThinkingCompletionReason::ProviderCompleted => "provider_completed", + } +} + +fn completion_reason_from_db(value: &str) -> ThinkingCompletionReason { + match value { + "first_text_delta" => ThinkingCompletionReason::FirstTextDelta, + "tool_call_requested" => ThinkingCompletionReason::ToolCallRequested, + "provider_completed" => ThinkingCompletionReason::ProviderCompleted, + _ => ThinkingCompletionReason::ProviderCompleted, + } +} + +fn thinking_state_from_db(value: &str) -> ThinkingSpanState { + match value { + "running" => ThinkingSpanState::Running, + "completed" => ThinkingSpanState::Completed, + "failed" => ThinkingSpanState::Failed, + _ => ThinkingSpanState::Failed, + } +} + pub(super) fn entry_type_db(value: &SoulSessionTargetType) -> &'static str { match value { SoulSessionTargetType::Message => "message", SoulSessionTargetType::Compact => "compact", + SoulSessionTargetType::Thinking => "thinking", SoulSessionTargetType::ToolCall => "tool_call", SoulSessionTargetType::ToolResult => "tool_result", } diff --git a/apps/server/soma/crates/santi-core/src/store/runtime.rs b/apps/server/soma/crates/santi-core/src/store/runtime.rs index 1fa38a9..2a152f0 100644 --- a/apps/server/soma/crates/santi-core/src/store/runtime.rs +++ b/apps/server/soma/crates/santi-core/src/store/runtime.rs @@ -2,66 +2,115 @@ use rusqlite::params; use serde_json::{Value, json}; use super::{ - DEFAULT_SOUL_ID, SantiStore, + SantiStore, db::{ - append_entry_in_tx, call_soul_id, compact_by_id, session_message_to_provider, soul_by_id, - soul_session_by_id, tool_call_by_id, tool_calls_for_turn, tool_result_by_id, - tool_results_for_turn, turn_by_id, turn_soul_session_id, + append_entry_in_tx, call_soul_id, thinking_span_by_id, thinking_spans_for_turn, + tool_call_by_id, tool_calls_for_turn, tool_result_by_id, tool_results_for_turn, turn_by_id, + turn_soul_session_id, }, }; use crate::{ - Soul, SoulSession, SoulSessionEntry, SoulSessionTargetType, ToolCall, ToolResult, Turn, - prefixed_id, timestamp_now, + SoulSessionEntry, SoulSessionTargetType, ThinkingCompletionReason, ThinkingSpan, + ThinkingSpanState, ToolCall, ToolResult, Turn, prefixed_id, timestamp_now, }; impl SantiStore { - pub fn assembly_input( + pub fn append_thinking_span( &self, - soul_session_id: &str, - ) -> Result, String> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn - .prepare( - r#" - SELECT target_type, target_id - FROM r_soul_session_messages - WHERE soul_session_id = ?1 - ORDER BY soul_session_seq ASC - "#, + turn_id: &str, + provider_response_id: Option, + ) -> Result { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction().map_err(|error| error.to_string())?; + let thinking_id = prefixed_id("thinking"); + let now = timestamp_now(); + let soul_session_id = turn_soul_session_id(&tx, turn_id)?; + tx.execute( + r#" + INSERT INTO thinking_spans ( + id, turn_id, provider_response_id, state, summary, completion_reason, + error_text, created_at, updated_at, finished_at ) - .map_err(|error| error.to_string())?; - let rows = stmt - .query_map(params![soul_session_id], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .map_err(|error| error.to_string())?; - let mut input = Vec::new(); - for row in rows { - let (target_type, target_id) = row.map_err(|error| error.to_string())?; - match target_type.as_str() { - "message" => { - if let Some(message) = super::db::message_by_id(&conn, &target_id)? - && let Some(provider_message) = session_message_to_provider(&message) - { - input.push(provider_message); - } - } - "compact" => { - if let Some(compact) = compact_by_id(&conn, &target_id)? { - input.push(crate::ProviderInputMessage { - role: "system".to_string(), - content: format!( - "[compact {}-{}]\n{}", - compact.start_session_seq, compact.end_session_seq, compact.summary - ), - }); - } - } - "tool_call" | "tool_result" => {} - _ => {} - } - } - Ok(input) + VALUES (?1, ?2, ?3, 'running', NULL, NULL, NULL, ?4, ?4, NULL) + "#, + params![thinking_id, turn_id, provider_response_id, now], + ) + .map_err(|error| error.to_string())?; + append_entry_in_tx( + &tx, + &soul_session_id, + SoulSessionTargetType::Thinking, + &thinking_id, + )?; + tx.commit().map_err(|error| error.to_string())?; + thinking_span_by_id(&conn, &thinking_id)? + .ok_or_else(|| "created thinking_span missing".to_string()) + } + + pub fn update_thinking_span_response( + &self, + thinking_span_id: &str, + provider_response_id: Option, + ) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let now = timestamp_now(); + conn.execute( + r#" + UPDATE thinking_spans + SET provider_response_id = COALESCE(?2, provider_response_id), + updated_at = ?3 + WHERE id = ?1 AND state = 'running' + "#, + params![thinking_span_id, provider_response_id, now], + ) + .map_err(|error| error.to_string())?; + thinking_span_by_id(&conn, thinking_span_id) + } + + pub fn update_thinking_span_summary( + &self, + thinking_span_id: &str, + summary: String, + ) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let now = timestamp_now(); + conn.execute( + r#" + UPDATE thinking_spans + SET summary = ?2, + updated_at = ?3 + WHERE id = ?1 AND state <> 'failed' + "#, + params![thinking_span_id, summary, now], + ) + .map_err(|error| error.to_string())?; + thinking_span_by_id(&conn, thinking_span_id) + } + + pub fn complete_thinking_span( + &self, + thinking_span_id: &str, + completion_reason: ThinkingCompletionReason, + ) -> Result, String> { + self.finish_thinking_span( + thinking_span_id, + ThinkingSpanState::Completed, + Some(completion_reason), + None, + ) + } + + pub fn fail_thinking_span( + &self, + thinking_span_id: &str, + error_text: String, + ) -> Result, String> { + self.finish_thinking_span( + thinking_span_id, + ThinkingSpanState::Failed, + None, + Some(error_text), + ) } pub fn append_tool_call( @@ -134,33 +183,6 @@ impl SantiStore { .ok_or_else(|| "created tool_result missing".to_string()) } - pub fn write_soul_memory(&self, text: &str) -> Result { - let conn = self.conn.lock().unwrap(); - let now = timestamp_now(); - conn.execute( - "UPDATE souls SET memory = ?2, updated_at = ?3 WHERE id = ?1", - params![DEFAULT_SOUL_ID, text, now], - ) - .map_err(|error| error.to_string())?; - soul_by_id(&conn, DEFAULT_SOUL_ID)?.ok_or_else(|| "default soul missing".to_string()) - } - - pub fn write_session_memory( - &self, - soul_session_id: &str, - text: &str, - ) -> Result { - let conn = self.conn.lock().unwrap(); - let now = timestamp_now(); - conn.execute( - "UPDATE soul_sessions SET session_memory = ?2, updated_at = ?3 WHERE id = ?1", - params![soul_session_id, text, now], - ) - .map_err(|error| error.to_string())?; - soul_session_by_id(&conn, soul_session_id)? - .ok_or_else(|| "soul_session missing".to_string()) - } - pub fn complete_turn( &self, turn_id: &str, @@ -224,11 +246,51 @@ impl SantiStore { turn_by_id(&conn, turn_id)?.ok_or_else(|| "failed turn missing".to_string()) } + pub fn finish_failed_turn_context( + &self, + turn_id: &str, + last_seen_session_seq: i64, + ) -> Result { + let conn = self.conn.lock().unwrap(); + let now = timestamp_now(); + conn.execute( + r#" + UPDATE turns + SET end_soul_session_seq = ( + SELECT next_seq - 1 FROM soul_sessions WHERE id = turns.soul_session_id + ), + updated_at = ?2 + WHERE id = ?1 AND status = 'failed' + "#, + params![turn_id, now], + ) + .map_err(|error| error.to_string())?; + conn.execute( + r#" + UPDATE soul_sessions + SET last_seen_session_seq = CASE + WHEN last_seen_session_seq > ?2 THEN last_seen_session_seq + ELSE ?2 + END, + updated_at = ?3 + WHERE id = (SELECT soul_session_id FROM turns WHERE id = ?1) + "#, + params![turn_id, last_seen_session_seq, now], + ) + .map_err(|error| error.to_string())?; + turn_by_id(&conn, turn_id)?.ok_or_else(|| "failed turn missing".to_string()) + } + pub fn tool_calls_for_turn(&self, turn_id: &str) -> Result, String> { let conn = self.conn.lock().unwrap(); tool_calls_for_turn(&conn, turn_id) } + pub fn thinking_spans_for_turn(&self, turn_id: &str) -> Result, String> { + let conn = self.conn.lock().unwrap(); + thinking_spans_for_turn(&conn, turn_id) + } + pub fn tool_results_for_turn(&self, turn_id: &str) -> Result, String> { let conn = self.conn.lock().unwrap(); tool_results_for_turn(&conn, turn_id) @@ -246,4 +308,37 @@ impl SantiStore { tx.commit().map_err(|error| error.to_string())?; Ok(entry) } + + fn finish_thinking_span( + &self, + thinking_span_id: &str, + state: ThinkingSpanState, + completion_reason: Option, + error_text: Option, + ) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let now = timestamp_now(); + conn.execute( + r#" + UPDATE thinking_spans + SET state = ?2, + completion_reason = ?3, + error_text = ?4, + updated_at = ?5, + finished_at = ?5 + WHERE id = ?1 AND state = 'running' + "#, + params![ + thinking_span_id, + super::rows::thinking_span_state_db(&state), + completion_reason + .as_ref() + .map(super::rows::thinking_completion_reason_db), + error_text, + now + ], + ) + .map_err(|error| error.to_string())?; + thinking_span_by_id(&conn, thinking_span_id) + } } diff --git a/apps/server/soma/crates/santi-core/src/store/schema.rs b/apps/server/soma/crates/santi-core/src/store/schema.rs index 5e9b2df..34bfbee 100644 --- a/apps/server/soma/crates/santi-core/src/store/schema.rs +++ b/apps/server/soma/crates/santi-core/src/store/schema.rs @@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS souls ( CREATE TABLE IF NOT EXISTS soul_profiles ( soul_id TEXT PRIMARY KEY, + soul_name TEXT NOT NULL, nickname TEXT NOT NULL, avatar_ref TEXT, avatar_seed TEXT NOT NULL, @@ -43,8 +44,9 @@ CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, actor_type TEXT NOT NULL CHECK (actor_type IN ('account', 'soul', 'system')), actor_id TEXT NOT NULL, + message_kind TEXT NOT NULL DEFAULT 'text' CHECK (message_kind IN ('text', 'santi_system')), content TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('pending', 'fixed')), + state TEXT NOT NULL CHECK (state IN ('pending', 'fixed', 'aborted')), version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0), deleted_at TEXT, created_at TEXT NOT NULL, @@ -137,6 +139,26 @@ CREATE TABLE IF NOT EXISTS tool_results ( ) ); +CREATE TABLE IF NOT EXISTS thinking_spans ( + id TEXT PRIMARY KEY, + turn_id TEXT NOT NULL, + provider_response_id TEXT, + state TEXT NOT NULL CHECK (state IN ('running', 'completed', 'failed')), + summary TEXT, + completion_reason TEXT CHECK ( + completion_reason IS NULL OR + completion_reason IN ('first_text_delta', 'tool_call_requested', 'provider_completed') + ), + error_text TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + finished_at TEXT, + CHECK ( + (state = 'failed' AND error_text IS NOT NULL) OR + (state <> 'failed' AND error_text IS NULL) + ) +); + CREATE TABLE IF NOT EXISTS compacts ( id TEXT PRIMARY KEY, turn_id TEXT NOT NULL, @@ -149,7 +171,7 @@ CREATE TABLE IF NOT EXISTS compacts ( CREATE TABLE IF NOT EXISTS r_soul_session_messages ( soul_session_id TEXT NOT NULL, - target_type TEXT NOT NULL CHECK (target_type IN ('message', 'compact', 'tool_call', 'tool_result')), + target_type TEXT NOT NULL CHECK (target_type IN ('message', 'compact', 'thinking', 'tool_call', 'tool_result')), target_id TEXT NOT NULL, soul_session_seq INTEGER NOT NULL CHECK (soul_session_seq > 0), created_at TEXT NOT NULL, @@ -173,6 +195,7 @@ CREATE INDEX IF NOT EXISTS idx_turns_soul_session_created_at ON turns (soul_sess CREATE INDEX IF NOT EXISTS idx_turns_soul_session_status_created_at ON turns (soul_session_id, status, created_at); CREATE INDEX IF NOT EXISTS idx_tool_calls_turn_id_created_at ON tool_calls (turn_id, created_at); CREATE INDEX IF NOT EXISTS idx_tool_results_tool_call_id ON tool_results (tool_call_id); +CREATE INDEX IF NOT EXISTS idx_thinking_spans_turn_id_created_at ON thinking_spans (turn_id, created_at); CREATE INDEX IF NOT EXISTS idx_compacts_turn_id_created_at ON compacts (turn_id, created_at); CREATE INDEX IF NOT EXISTS idx_r_soul_session_messages_target_lookup ON r_soul_session_messages (target_type, target_id); CREATE INDEX IF NOT EXISTS idx_r_soul_session_messages_seq ON r_soul_session_messages (soul_session_id, soul_session_seq); diff --git a/apps/server/soma/crates/santi-core/src/workspace_uri.rs b/apps/server/soma/crates/santi-core/src/workspace_uri.rs new file mode 100644 index 0000000..3dfab63 --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/workspace_uri.rs @@ -0,0 +1,74 @@ +use std::path::{Component, Path, PathBuf}; + +pub const SOUL_WORKSPACE_URI: &str = "soul://"; +pub const SESSION_WORKSPACE_URI: &str = "session://"; +pub const MEMORY_FILE: &str = "MEMORY.md"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceRoot { + Soul, + Session, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceUri { + pub root: WorkspaceRoot, + pub path: PathBuf, +} + +pub fn soul_memory_uri() -> String { + workspace_uri(SOUL_WORKSPACE_URI, MEMORY_FILE) +} + +pub fn session_memory_uri() -> String { + workspace_uri(SESSION_WORKSPACE_URI, MEMORY_FILE) +} + +pub fn workspace_uri(root: &str, path: &str) -> String { + if path.is_empty() { + return root.to_string(); + } + format!("{root}{}", path.trim_start_matches('/')) +} + +pub fn parse_workspace_uri(value: &str) -> Result { + if let Some(path) = value.strip_prefix(SOUL_WORKSPACE_URI) { + return Ok(WorkspaceUri { + root: WorkspaceRoot::Soul, + path: safe_relative_path(path, SOUL_WORKSPACE_URI)?, + }); + } + if let Some(path) = value.strip_prefix(SESSION_WORKSPACE_URI) { + return Ok(WorkspaceUri { + root: WorkspaceRoot::Session, + path: safe_relative_path(path, SESSION_WORKSPACE_URI)?, + }); + } + if value.starts_with('@') { + return Err(format!( + "unsupported workspace alias: {value}; use {SOUL_WORKSPACE_URI} or {SESSION_WORKSPACE_URI}" + )); + } + if value.contains("://") { + return Err(format!("unsupported workspace uri: {value}")); + } + Err(format!( + "cwd must use {SOUL_WORKSPACE_URI} or {SESSION_WORKSPACE_URI}" + )) +} + +fn safe_relative_path(path: &str, root: &str) -> Result { + if path.is_empty() { + return Ok(PathBuf::new()); + } + let path = Path::new(path); + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(format!("workspace uri cannot escape {root}")); + } + Ok(path.to_path_buf()) +} diff --git a/apps/server/soma/crates/santi-core/tests/failure.rs b/apps/server/soma/crates/santi-core/tests/failure.rs new file mode 100644 index 0000000..fe6f78e --- /dev/null +++ b/apps/server/soma/crates/santi-core/tests/failure.rs @@ -0,0 +1,211 @@ +use async_trait::async_trait; +use futures_util::stream; +use santi_core::{ + ActorType, MessageKind, MessagePart, MessageState, SantiService, SantiServiceConfig, + SendSessionRequest, +}; +use santi_provider::{ + ProviderClient, ProviderEvent, ProviderMetadata, ProviderRequest, ProviderStream, +}; +use std::sync::{Arc, Mutex}; +use tokio::time::{Duration, sleep}; + +#[derive(Clone, Default)] +struct FailureProvider { + requests: Arc>>, + fail_with: Option, + stream_error_after_text: Option, +} + +#[async_trait] +impl ProviderClient for FailureProvider { + fn metadata(&self) -> ProviderMetadata { + ProviderMetadata { + provider: Arc::from("fake-provider"), + model: "fake-model".to_string(), + } + } + + async fn stream_response(&self, request: ProviderRequest) -> Result { + { + let mut requests = self.requests.lock().unwrap(); + requests.push(request); + } + if let Some(error) = &self.fail_with { + return Err(error.clone()); + } + if let Some(error) = &self.stream_error_after_text { + return Ok(Box::pin(stream::iter(vec![ + Ok(ProviderEvent::TextDelta( + "partial runtime output".to_string(), + )), + Err(error.clone()), + ]))); + } + Ok(Box::pin(stream::iter(vec![ + Ok(ProviderEvent::TextDelta("ok".to_string())), + Ok(ProviderEvent::Completed { + provider_response_id: Some("fake-response-id".to_string()), + }), + ]))) + } +} + +#[tokio::test] +async fn records_failed_system() { + let temp = tempfile::tempdir().expect("temp dir"); + let raw_error = "openai responses request failed: 401 Unauthorized secret detail".to_string(); + let provider = Arc::new(FailureProvider { + fail_with: Some(raw_error.clone()), + ..FailureProvider::default() + }); + let service = open_service(&temp, provider.clone()); + let session = service.create_session().expect("create session").session; + let response = send_text(&service, &session.session.id, "trigger failure").await; + + let runtime = wait_for_failed_turn(&service, &session.session.id, &response.turn.id).await; + let failed_turn = runtime + .turns + .iter() + .find(|turn| turn.id == response.turn.id) + .expect("failed turn"); + assert_eq!(failed_turn.error_text.as_deref(), Some(raw_error.as_str())); + + let system_message = runtime + .messages + .iter() + .find(|message| message.message.message_kind == MessageKind::SantiSystem) + .expect("santi system message"); + assert_eq!(system_message.message.actor_type, ActorType::System); + assert_eq!(system_message.message.actor_id, "santi"); + assert_eq!( + system_message.content_text, + format!( + "\nkind: turn_failed\nturn_id: {}\ntrace: log://turn/{}\nsummary: Previous response attempt failed before completion.\n", + response.turn.id, response.turn.id + ) + ); + assert!(!system_message.content_text.contains("401")); + assert!(!system_message.content_text.contains("secret detail")); + + let retry = send_text(&service, &session.session.id, "continue after failure").await; + wait_for_failed_turn(&service, &session.session.id, &retry.turn.id).await; + + let requests = provider.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert!( + requests[1] + .input + .iter() + .any(|message| message.role == "user" + && message.content.contains("") + && message.content.contains("kind: turn_failed")) + ); +} + +#[tokio::test] +async fn preserves_aborted_output() { + let temp = tempfile::tempdir().expect("temp dir"); + let provider = Arc::new(FailureProvider { + stream_error_after_text: Some("provider stream aborted".to_string()), + ..FailureProvider::default() + }); + let service = open_service(&temp, provider.clone()); + let session = service.create_session().expect("create session").session; + let response = send_text(&service, &session.session.id, "trigger stream failure").await; + + let runtime = wait_for_failed_turn(&service, &session.session.id, &response.turn.id).await; + let partial_message = runtime + .messages + .iter() + .find(|message| { + message.message.actor_type == ActorType::Soul + && message.message.state == MessageState::Aborted + }) + .expect("aborted partial assistant message"); + assert_eq!(partial_message.content_text, "partial runtime output"); + + let system_message = runtime + .messages + .iter() + .find(|message| message.message.message_kind == MessageKind::SantiSystem) + .expect("santi system failure message"); + assert!( + partial_message.relation.session_seq < system_message.relation.session_seq, + "partial output should precede failure fact" + ); + + let retry = send_text( + &service, + &session.session.id, + "continue with preserved partial", + ) + .await; + wait_for_failed_turn(&service, &session.session.id, &retry.turn.id).await; + + let requests = provider.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert!(requests[1].input.iter().any(|message| { + message.role == "assistant" && message.content == "partial runtime output" + })); + assert!(requests[1].input.iter().any(|message| { + message.role == "user" && message.content.contains("kind: turn_failed") + })); +} + +fn open_service(temp: &tempfile::TempDir, provider: Arc) -> SantiService { + SantiService::open( + SantiServiceConfig { + database_path: temp.path().join("santi.sqlite").display().to_string(), + runtime_root: temp.path().join("runtime").display().to_string(), + execution_root: temp.path().join("execution").display().to_string(), + bind_addr: Some("127.0.0.1:0".to_string()), + }, + provider, + ) + .expect("open service") +} + +async fn send_text( + service: &SantiService, + session_id: &str, + text: &str, +) -> santi_core::SendSessionAcceptedResponse { + service + .send_session( + session_id, + SendSessionRequest { + content: vec![MessagePart::Text { + text: text.to_string(), + }], + }, + ) + .await + .expect("send session") +} + +async fn wait_for_failed_turn( + service: &SantiService, + session_id: &str, + turn_id: &str, +) -> santi_core::SessionRuntimeSnapshot { + for _ in 0..50 { + let runtime = service + .runtime_snapshot(session_id) + .expect("runtime snapshot") + .expect("session runtime"); + let turn_failed = runtime + .turns + .iter() + .any(|turn| turn.id == turn_id && turn.status == santi_core::TurnStatus::Failed); + let system_recorded = runtime + .messages + .iter() + .any(|message| message.message.message_kind == MessageKind::SantiSystem); + if turn_failed && system_recorded { + return runtime; + } + sleep(Duration::from_millis(20)).await; + } + panic!("turn did not fail"); +} diff --git a/apps/server/soma/crates/santi-core/tests/service.rs b/apps/server/soma/crates/santi-core/tests/service.rs index ff5ba17..ab4ceff 100644 --- a/apps/server/soma/crates/santi-core/tests/service.rs +++ b/apps/server/soma/crates/santi-core/tests/service.rs @@ -1,14 +1,19 @@ use async_trait::async_trait; use futures_util::stream; use santi_core::{ - MessagePart, ObjectBucket, ObjectUri, SantiService, SantiServiceConfig, SendSessionRequest, + MessagePart, ObjectBucket, ObjectUri, SESSION_WORKSPACE_URI, SOUL_WORKSPACE_URI, SantiService, + SantiServiceConfig, SendSessionRequest, session_memory_uri, soul_memory_uri, }; use santi_provider::{ ProviderClient, ProviderEvent, ProviderFunctionCall, ProviderMetadata, ProviderRequest, ProviderStream, }; use serde_json::json; -use std::sync::{Arc, Mutex}; +use std::{ + path::Path, + sync::{Arc, Mutex}, +}; +use tokio::time::{Duration, sleep}; #[derive(Clone, Default)] struct FakeProvider { @@ -41,13 +46,15 @@ impl ProviderClient for FakeProvider { "id": "item_tool", "call_id": "call_shell", "name": "shell", - "arguments": r#"{"command":"printf \"$SANTI_SESSION_MEMORY_DIR\""}"#, + "arguments": r#"{"command":"pwd && printf \"\\n$SANTI_SESSION_MEMORY_DIR\"","cwd":"session://"}"#, }), call_id: "call_shell".to_string(), name: "shell".to_string(), - arguments_raw: r#"{"command":"printf \"$SANTI_SESSION_MEMORY_DIR\""}"# - .to_string(), - arguments: json!({ "command": "printf \"$SANTI_SESSION_MEMORY_DIR\"" }), + arguments_raw: r#"{"command":"pwd && printf \"\\n$SANTI_SESSION_MEMORY_DIR\"","cwd":"session://"}"#.to_string(), + arguments: json!({ + "command": "pwd && printf \"\\n$SANTI_SESSION_MEMORY_DIR\"", + "cwd": SESSION_WORKSPACE_URI + }), })), Ok(ProviderEvent::Completed { provider_response_id: Some("resp_tool".to_string()), @@ -92,8 +99,14 @@ async fn sends_with_runtime() { .expect("send session"); assert_eq!(response.user_message.content_text, "hello provider"); - assert_eq!(response.assistant_message.content_text, "hi from runtime"); - assert_eq!(response.turn.status, santi_core::TurnStatus::Completed); + assert_eq!(response.turn.status, santi_core::TurnStatus::Running); + let runtime = wait_for_completed_turn(&service, &session.session.id, &response.turn.id).await; + assert!( + runtime + .messages + .iter() + .any(|message| message.content_text == "hi from runtime") + ); let requests = provider.requests.lock().unwrap(); assert_eq!(requests.len(), 1); @@ -105,32 +118,68 @@ async fn sends_with_runtime() { .instructions .as_deref() .expect("runtime instructions"); - assert!(instructions.contains("You are santi")); - assert!(instructions.contains("")); - assert!(instructions.contains("")); - let tool_names = requests[0] - .tools - .as_ref() - .expect("tools") + assert!(instructions.contains("You are a distinct soul running inside this Santi instance.")); + assert!(instructions.contains("[santi-meta]")); + assert!(instructions.contains("channel: mini-stim")); + assert!(instructions.contains("soul_name: Liberte")); + assert!(instructions.contains("[santi-soul]")); + assert!(instructions.contains("[santi-session]")); + assert!(instructions.contains(&format!( + "{} will always be displayed in [santi-soul].", + soul_memory_uri() + ))); + assert!(instructions.contains(&format!( + "{} will always be displayed in [santi-session].", + session_memory_uri() + ))); + assert!(instructions.contains(&format!( + "These files have no internal version history; save backups into {SOUL_WORKSPACE_URI} or {SESSION_WORKSPACE_URI} if needed." + ))); + assert!( + instructions + .contains(" blocks describe Santi runtime facts in this session.") + ); + assert!(instructions.contains( + "They are part of your context, not user speech or your natural-language reply." + )); + assert!( + instructions + .contains("Read them as session facts about the workspace, runtime, or provider flow.") + ); + assert!(instructions.contains(&format!("source: {}", soul_memory_uri()))); + assert!(instructions.contains(&format!("source: {}", session_memory_uri()))); + assert!(!instructions.contains("hint:")); + assert!(!instructions.contains("@soul")); + assert!(!instructions.contains("@session")); + assert!(!instructions.contains("")); + assert!(!instructions.contains("")); + let tools = requests[0].tools.as_ref().expect("tools"); + let tool_names = tools .iter() .map(|tool| match tool { santi_provider::ProviderTool::Function(tool) => tool.name.as_str(), }) .collect::>(); - assert_eq!( - tool_names, - vec!["write_soul_memory", "write_session_memory", "shell"] - ); + assert_eq!(tool_names, vec!["shell"]); + let tool_descriptions = tools + .iter() + .map(|tool| match tool { + santi_provider::ProviderTool::Function(tool) => { + format!("{} {}", tool.description, tool.parameters) + } + }) + .collect::>() + .join("\n"); + assert!(tool_descriptions.contains(&soul_memory_uri())); + assert!(tool_descriptions.contains(&session_memory_uri())); + assert!(!tool_descriptions.contains("@soul")); + assert!(!tool_descriptions.contains("@session")); let detail = service .session(&session.session.id) .expect("load detail") .expect("session"); assert_eq!(detail.messages.len(), 2); - let runtime = service - .runtime_snapshot(&session.session.id) - .expect("runtime snapshot") - .expect("session runtime"); assert_eq!(runtime.turns.len(), 1); } @@ -165,18 +214,36 @@ async fn dispatches_tools() { .await .expect("send session"); - assert_eq!(response.assistant_message.content_text, "hi from runtime"); - assert_eq!(response.tool_calls.len(), 1); - assert_eq!(response.tool_calls[0].tool_name, "shell"); - assert_eq!(response.tool_results.len(), 1); - assert!(response.tool_results[0].error_text.is_none()); - let stdout = response.tool_results[0] + assert_eq!(response.turn.status, santi_core::TurnStatus::Running); + let runtime = wait_for_completed_turn(&service, &session.session.id, &response.turn.id).await; + assert!( + runtime + .messages + .iter() + .any(|message| message.content_text == "hi from runtime") + ); + assert_eq!(runtime.tool_calls.len(), 1); + assert_eq!(runtime.tool_calls[0].tool_name, "shell"); + assert_eq!(runtime.tool_results.len(), 1); + assert!(runtime.tool_results[0].error_text.is_none()); + let output = runtime.tool_results[0] .output .as_ref() - .and_then(|output| output.get("stdout")) + .expect("tool output"); + let stdout = output + .get("stdout") .and_then(|value| value.as_str()) .expect("shell stdout"); - assert!(stdout.ends_with(&format!("runtime/sessions/{}/memory", session.session.id))); + let session_memory_dir = Path::new("runtime") + .join("sessions") + .join(&session.session.id) + .join("memory"); + assert!(stdout.contains(&session_memory_dir.display().to_string())); + let cwd = output + .get("cwd") + .and_then(|value| value.as_str()) + .expect("shell cwd"); + assert!(Path::new(cwd).ends_with(&session_memory_dir)); let requests = provider.requests.lock().unwrap(); assert_eq!(requests.len(), 2); @@ -184,6 +251,28 @@ async fn dispatches_tools() { assert!(requests[1].function_call_outputs.is_some()); } +async fn wait_for_completed_turn( + service: &SantiService, + session_id: &str, + turn_id: &str, +) -> santi_core::SessionRuntimeSnapshot { + for _ in 0..50 { + let runtime = service + .runtime_snapshot(session_id) + .expect("runtime snapshot") + .expect("session runtime"); + if runtime + .turns + .iter() + .any(|turn| turn.id == turn_id && turn.status == santi_core::TurnStatus::Completed) + { + return runtime; + } + sleep(Duration::from_millis(20)).await; + } + panic!("turn did not complete"); +} + #[tokio::test] async fn bucket_objects_are_scoped() { let temp = tempfile::tempdir().expect("temp dir"); diff --git a/apps/server/soma/crates/santi-core/tests/store.rs b/apps/server/soma/crates/santi-core/tests/store.rs index a749613..05535e4 100644 --- a/apps/server/soma/crates/santi-core/tests/store.rs +++ b/apps/server/soma/crates/santi-core/tests/store.rs @@ -1,5 +1,7 @@ use rusqlite::Connection; -use santi_core::{ActorType, MessageContent, MessageState, SantiStore}; +use santi_core::{ + ActorType, MessageContent, MessageKind, MessageState, SantiStore, ThinkingCompletionReason, +}; #[test] fn schema_matches_runtime() { @@ -23,6 +25,7 @@ fn schema_matches_runtime() { "turns", "tool_calls", "tool_results", + "thinking_spans", "compacts", "r_soul_session_messages", ] { @@ -70,6 +73,107 @@ fn appends_relations_in_order() { assert_eq!(input[0].content, "hello ordering"); } +#[test] +fn maps_santi_system_input() { + let temp = tempfile::tempdir().expect("temp dir"); + let store = SantiStore::open(temp.path().join("santi.sqlite")).expect("open store"); + let session = store.create_session().expect("create session"); + let soul_session = store + .acquire_soul_session(&session.session.id) + .expect("acquire soul session") + .soul_session; + let message = store + .append_santi_system_message( + &session.session.id, + MessageContent::text("\nkind: note\n"), + ) + .expect("append santi system") + .session_message; + store + .append_message_ref(&soul_session.id, &message.message.id) + .expect("append message ref"); + + assert_eq!(message.message.actor_type, ActorType::System); + assert_eq!(message.message.message_kind, MessageKind::SantiSystem); + let input = store + .assembly_input(&soul_session.id) + .expect("assembly input"); + assert_eq!(input.len(), 1); + assert_eq!(input[0].role, "user"); + assert_eq!( + input[0].content, + "\nkind: note\n" + ); +} + +#[test] +fn thinking_spans_skip_input() { + let temp = tempfile::tempdir().expect("temp dir"); + let store = SantiStore::open(temp.path().join("santi.sqlite")).expect("open store"); + let session = store.create_session().expect("create session"); + let user = store + .append_message( + &session.session.id, + ActorType::Account, + store.default_account_id(), + MessageContent::text("hello thinking"), + MessageState::Fixed, + ) + .expect("append user") + .session_message; + let soul_session = store + .acquire_soul_session(&session.session.id) + .expect("acquire soul session") + .soul_session; + store + .append_message_ref(&soul_session.id, &user.message.id) + .expect("append message ref"); + let turn = store + .start_turn( + &soul_session.id, + &user.message.id, + user.relation.session_seq, + ) + .expect("start turn") + .turn; + let thinking = store + .append_thinking_span(&turn.id, Some("resp_test".to_string())) + .expect("append thinking"); + let thinking = store + .update_thinking_span_summary(&thinking.id, "Looked at the prompt.".to_string()) + .expect("update thinking summary") + .expect("thinking exists"); + let thinking = store + .complete_thinking_span(&thinking.id, ThinkingCompletionReason::FirstTextDelta) + .expect("complete thinking") + .expect("thinking exists"); + + let snapshot = store + .runtime_snapshot(&session.session.id) + .expect("runtime snapshot") + .expect("session exists"); + assert_eq!(snapshot.thinking_spans.len(), 1); + assert_eq!(snapshot.thinking_spans[0].id, thinking.id); + assert_eq!( + snapshot.thinking_spans[0].provider_response_id.as_deref(), + Some("resp_test") + ); + assert_eq!( + snapshot.thinking_spans[0].summary.as_deref(), + Some("Looked at the prompt.") + ); + assert_eq!( + snapshot.thinking_spans[0].completion_reason, + Some(ThinkingCompletionReason::FirstTextDelta) + ); + + let input = store + .assembly_input(&soul_session.id) + .expect("assembly input"); + assert_eq!(input.len(), 1); + assert_eq!(input[0].content, "hello thinking"); +} + #[test] fn titles_from_first_message() { let temp = tempfile::tempdir().expect("temp dir"); diff --git a/apps/server/soma/crates/santi-core/tests/system_prompt.rs b/apps/server/soma/crates/santi-core/tests/system_prompt.rs new file mode 100644 index 0000000..6baea62 --- /dev/null +++ b/apps/server/soma/crates/santi-core/tests/system_prompt.rs @@ -0,0 +1,144 @@ +use std::{fs, sync::Arc}; + +use async_trait::async_trait; +use futures_util::stream; +use santi_core::{ + MaterialKind, MaterialRequest, SESSION_WORKSPACE_URI, SOUL_WORKSPACE_URI, SantiService, + SantiServiceConfig, SessionMaterial, session_memory_uri, soul_memory_uri, +}; +use santi_provider::{ProviderClient, ProviderMetadata, ProviderStream}; + +#[derive(Clone)] +struct FakeProvider; + +#[async_trait] +impl ProviderClient for FakeProvider { + fn metadata(&self) -> ProviderMetadata { + ProviderMetadata { + provider: Arc::from("fake-provider"), + model: "fake-model".to_string(), + } + } + + async fn stream_response( + &self, + _request: santi_provider::ProviderRequest, + ) -> Result { + Ok(Box::pin(stream::empty())) + } +} + +#[test] +fn renders_material_shape() { + let harness = PromptHarness::open(); + harness.write_soul("---\nplain: value\n---\n# Soul"); + harness.write_session("# Session"); + + let text = harness.system_prompt().text; + + assert!(text.contains("You are a distinct soul running inside this Santi instance.")); + assert!(text.contains("[santi-meta]")); + assert!(text.contains("channel: mini-stim")); + assert!(text.contains("soul_name: Liberte")); + assert!(text.contains(&format!( + "{} will always be displayed in [santi-soul].", + soul_memory_uri() + ))); + assert!(text.contains(&format!( + "{} will always be displayed in [santi-session].", + session_memory_uri() + ))); + assert!(text.contains(&format!( + "These files have no internal version history; save backups into {SOUL_WORKSPACE_URI} or {SESSION_WORKSPACE_URI} if needed." + ))); + assert!(text.contains(" blocks describe Santi runtime facts in this session.")); + assert!(text.contains( + "They are part of your context, not user speech or your natural-language reply." + )); + assert!( + text.contains("Read them as session facts about the workspace, runtime, or provider flow.") + ); + assert!(text.contains("[santi-soul]")); + assert!(text.contains("[santi-session]")); + assert!(text.contains(&format!("source: {}", soul_memory_uri()))); + assert!(text.contains(&format!("source: {}", session_memory_uri()))); + assert!(text.contains("content:\n---\nplain: value\n---\n# Soul")); + assert!(text.contains("content:\n# Session")); + assert!(!text.contains("hint:")); + assert!(!text.contains("@soul")); + assert!(!text.contains("@session")); +} + +#[test] +fn leaves_frontmatter_plain() { + let harness = PromptHarness::open(); + harness.write_soul("---\nplain: value\n---\n# Soul"); + + let text = harness.system_prompt().text; + + assert!(text.contains("content:\n---\nplain: value\n---\n# Soul")); + assert!(!text.contains("hint:")); +} + +struct PromptHarness { + _temp: tempfile::TempDir, + service: SantiService, + session_id: String, + runtime_root: std::path::PathBuf, +} + +impl PromptHarness { + fn open() -> Self { + let temp = tempfile::tempdir().expect("temp dir"); + let runtime_root = temp.path().join("runtime"); + let service = SantiService::open( + SantiServiceConfig { + database_path: temp.path().join("santi.sqlite").display().to_string(), + runtime_root: runtime_root.display().to_string(), + execution_root: temp.path().join("execution").display().to_string(), + bind_addr: Some("127.0.0.1:0".to_string()), + }, + Arc::new(FakeProvider), + ) + .expect("open service"); + let session_id = service + .create_session() + .expect("create session") + .session + .session + .id; + Self { + _temp: temp, + service, + session_id, + runtime_root, + } + } + + fn write_soul(&self, text: &str) { + let path = self.runtime_root.join("souls").join("memory"); + fs::create_dir_all(&path).expect("create soul dir"); + fs::write(path.join("MEMORY.md"), text).expect("write soul"); + } + + fn write_session(&self, text: &str) { + let path = self + .runtime_root + .join("sessions") + .join(&self.session_id) + .join("memory"); + fs::create_dir_all(&path).expect("create session dir"); + fs::write(path.join("MEMORY.md"), text).expect("write session"); + } + + fn system_prompt(&self) -> SessionMaterial { + self.service + .session_material( + &self.session_id, + MaterialRequest { + kind: MaterialKind::SystemPrompt, + }, + ) + .expect("system prompt material") + } +} diff --git a/apps/server/soma/crates/santi-core/tests/workspace_uri.rs b/apps/server/soma/crates/santi-core/tests/workspace_uri.rs new file mode 100644 index 0000000..82ceda8 --- /dev/null +++ b/apps/server/soma/crates/santi-core/tests/workspace_uri.rs @@ -0,0 +1,59 @@ +use std::path::PathBuf; + +use santi_core::{ + SESSION_WORKSPACE_URI, SOUL_WORKSPACE_URI, WorkspaceRoot, parse_workspace_uri, + session_memory_uri, soul_memory_uri, workspace_uri, +}; + +#[test] +fn builds_memory_uris() { + assert_eq!(soul_memory_uri(), "soul://MEMORY.md"); + assert_eq!(session_memory_uri(), "session://MEMORY.md"); + assert_eq!( + workspace_uri(SOUL_WORKSPACE_URI, "notes/today.md"), + "soul://notes/today.md" + ); + assert_eq!( + workspace_uri(SESSION_WORKSPACE_URI, "/todo.md"), + "session://todo.md" + ); +} + +#[test] +fn parses_workspace_roots() { + let soul = parse_workspace_uri(SOUL_WORKSPACE_URI).expect("soul root"); + assert_eq!(soul.root, WorkspaceRoot::Soul); + assert_eq!(soul.path, PathBuf::new()); + + let session = parse_workspace_uri("session://notes/today.md").expect("session path"); + assert_eq!(session.root, WorkspaceRoot::Session); + assert_eq!(session.path, PathBuf::from("notes/today.md")); +} + +#[test] +fn rejects_old_aliases() { + assert_eq!( + parse_workspace_uri("@soul").expect_err("old soul alias"), + "unsupported workspace alias: @soul; use soul:// or session://" + ); + assert_eq!( + parse_workspace_uri("@session").expect_err("old session alias"), + "unsupported workspace alias: @session; use soul:// or session://" + ); +} + +#[test] +fn rejects_invalid_uris() { + assert_eq!( + parse_workspace_uri("file://tmp").expect_err("unknown scheme"), + "unsupported workspace uri: file://tmp" + ); + assert_eq!( + parse_workspace_uri("relative/path").expect_err("relative path"), + "cwd must use soul:// or session://" + ); + assert_eq!( + parse_workspace_uri("soul://../secret").expect_err("escape"), + "workspace uri cannot escape soul://" + ); +} diff --git a/apps/server/soma/crates/santi-provider/src/chat_completions.rs b/apps/server/soma/crates/santi-provider/src/chat_completions.rs new file mode 100644 index 0000000..09c3f22 --- /dev/null +++ b/apps/server/soma/crates/santi-provider/src/chat_completions.rs @@ -0,0 +1,445 @@ +use async_stream::try_stream; +use async_trait::async_trait; +use futures_core::Stream; +use futures_util::StreamExt; +use reqwest::Client; +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use std::sync::Arc; + +use crate::{ + FunctionCallOutput, ProviderClient, ProviderEvent, ProviderFunctionCall, ProviderMetadata, + ProviderRequest, ProviderStream, ProviderStreamTrace, ProviderTool, +}; + +#[derive(Debug, Clone)] +pub struct ChatCompletionsProviderConfig { + pub provider: String, + pub api_key: String, + pub model: String, + pub base_url: String, + pub thinking: Option, + pub reasoning_effort: Option, + pub max_tokens: Option, +} + +#[derive(Debug, Clone)] +pub struct ChatCompletionsProvider { + config: ChatCompletionsProviderConfig, + client: Client, +} + +impl ChatCompletionsProvider { + pub fn new(config: ChatCompletionsProviderConfig) -> Self { + Self { + config, + client: Client::new(), + } + } +} + +#[async_trait] +impl ProviderClient for ChatCompletionsProvider { + fn metadata(&self) -> ProviderMetadata { + ProviderMetadata { + provider: Arc::from(self.config.provider.clone()), + model: self.config.model.clone(), + } + } + + async fn stream_response(&self, request: ProviderRequest) -> Result { + let response = self + .client + .post(format!( + "{}/chat/completions", + self.config.base_url.trim_end_matches('/') + )) + .bearer_auth(&self.config.api_key) + .json(&chat_body(&self.config, request)) + .send() + .await + .map_err(|error| error.to_string())?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!( + "{} chat completions request failed: {status} {body}", + self.config.provider + )); + } + Ok(Box::pin(parse_sse(response.bytes_stream()))) + } +} + +fn chat_body(config: &ChatCompletionsProviderConfig, request: ProviderRequest) -> Value { + let mut body = Map::from_iter([ + ("model".to_string(), json!(request.model)), + ("messages".to_string(), messages(&request)), + ("stream".to_string(), json!(true)), + ]); + + if let Some(tools) = request.tools { + body.insert("tools".to_string(), json!(map_tools(tools))); + } + if let Some(thinking) = config + .thinking + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + body.insert("thinking".to_string(), json!({ "type": thinking })); + } + if let Some(reasoning_effort) = config + .reasoning_effort + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + body.insert("reasoning_effort".to_string(), json!(reasoning_effort)); + } + if let Some(max_tokens) = config.max_tokens { + body.insert("max_tokens".to_string(), json!(max_tokens)); + } + + Value::Object(body) +} + +fn messages(request: &ProviderRequest) -> Value { + let mut messages = Vec::new(); + if let Some(instructions) = request + .instructions + .as_ref() + .filter(|instructions| !instructions.trim().is_empty()) + { + messages.push(json!({ + "role": "system", + "content": instructions, + })); + } + messages.extend(request.input.iter().map(|message| { + json!({ + "role": message.role, + "content": message.content, + }) + })); + if let Some(outputs) = &request.function_call_outputs { + messages.extend(tool_messages(outputs)); + } + json!(messages) +} + +fn tool_messages(outputs: &[FunctionCallOutput]) -> Vec { + let mut messages = Vec::new(); + for group in output_groups(outputs) { + let mut assistant = Map::from_iter([ + ("role".to_string(), json!("assistant")), + ( + "content".to_string(), + group + .assistant_content + .filter(|content| !content.is_empty()) + .map(Value::String) + .unwrap_or(Value::Null), + ), + ( + "tool_calls".to_string(), + json!( + group + .outputs + .iter() + .map(|output| { + json!({ + "id": output.call_id, + "type": "function", + "function": { + "name": output.call.name, + "arguments": output.call.arguments_raw, + }, + }) + }) + .collect::>() + ), + ), + ]); + if let Some(reasoning_content) = group + .reasoning_content + .filter(|content| !content.is_empty()) + { + assistant.insert( + "reasoning_content".to_string(), + Value::String(reasoning_content), + ); + } + messages.push(Value::Object(assistant)); + messages.extend(group.outputs.iter().map(|output| { + json!({ + "role": "tool", + "tool_call_id": output.call_id, + "content": output.output, + }) + })); + } + messages +} + +struct OutputGroup<'a> { + outputs: Vec<&'a FunctionCallOutput>, + assistant_content: Option, + reasoning_content: Option, +} + +fn output_groups(outputs: &[FunctionCallOutput]) -> Vec> { + let mut groups: Vec> = Vec::new(); + for output in outputs { + let response_id = output.call.response_id.as_str(); + let current_response_id = groups + .last() + .and_then(|group| group.outputs.first()) + .map(|first| first.call.response_id.as_str()); + if current_response_id != Some(response_id) { + groups.push(OutputGroup { + outputs: Vec::new(), + assistant_content: output.assistant_content.clone(), + reasoning_content: output.reasoning_content.clone(), + }); + } + let group = groups.last_mut().expect("output group"); + if group.assistant_content.is_none() { + group.assistant_content = output.assistant_content.clone(); + } + if group.reasoning_content.is_none() { + group.reasoning_content = output.reasoning_content.clone(); + } + group.outputs.push(output); + } + groups +} + +fn map_tools(tools: Vec) -> Vec { + tools + .into_iter() + .map(|tool| match tool { + ProviderTool::Function(tool) => json!({ + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters, + }, + }), + }) + .collect() +} + +fn parse_sse( + mut bytes: impl Stream> + Unpin + Send + 'static, +) -> impl Stream> + Send + 'static { + try_stream! { + let mut buffer = String::new(); + let mut accumulator = ToolCallAccumulator::default(); + let mut response_id: Option = None; + while let Some(chunk) = bytes.next().await { + let chunk = chunk.map_err(|error| error.to_string())?; + yield ProviderEvent::StreamTrace(ProviderStreamTrace::Chunk { bytes: chunk.len() }); + buffer.push_str(&String::from_utf8_lossy(&chunk)); + while let Some(index) = buffer.find('\n') { + let line = buffer[..index].trim_end_matches('\r').to_string(); + buffer = buffer[index + 1..].to_string(); + if let Some(payload) = line.strip_prefix("data: ") { + if payload == "[DONE]" { + continue; + } + let events = parse_event(payload, &mut response_id, &mut accumulator)?; + yield ProviderEvent::StreamTrace(ProviderStreamTrace::RawEvent { + raw_type: raw_event_type(payload), + mapped_events: provider_event_names(&events), + }); + for event in events { + yield event; + } + } + } + } + } +} + +fn parse_event( + payload: &str, + response_id: &mut Option, + accumulator: &mut ToolCallAccumulator, +) -> Result, String> { + let chunk = serde_json::from_str::(payload).map_err(|error| error.to_string())?; + let mut events = Vec::new(); + if response_id.is_none() { + *response_id = Some(chunk.id.clone()); + events.push(ProviderEvent::ResponseStarted { + provider_response_id: response_id.clone(), + }); + } + for choice in chunk.choices { + let delta = choice.delta; + if let Some(reasoning) = delta.reasoning_content.filter(|value| !value.is_empty()) { + events.push(ProviderEvent::ReasoningSummaryDelta(reasoning)); + } + if let Some(content) = delta.content.filter(|value| !value.is_empty()) { + events.push(ProviderEvent::TextDelta(content)); + } + if let Some(tool_calls) = delta.tool_calls { + accumulator.push(tool_calls); + } + let finish_reason = choice.finish_reason.as_deref(); + if finish_reason == Some("tool_calls") { + events.extend(accumulator.finish(response_id.clone())?); + } else if finish_reason == Some("stop") || finish_reason == Some("length") { + events.push(ProviderEvent::Completed { + provider_response_id: response_id.clone(), + }); + } + } + Ok(events) +} + +fn raw_event_type(payload: &str) -> String { + serde_json::from_str::(payload) + .map(|chunk| { + chunk + .choices + .first() + .and_then(|choice| choice.finish_reason.clone()) + .map(|finish_reason| format!("chat.completion.chunk.{finish_reason}")) + .unwrap_or_else(|| "chat.completion.chunk".to_string()) + }) + .unwrap_or_else(|_| "invalid_json".to_string()) +} + +fn provider_event_names(events: &[ProviderEvent]) -> Vec { + events + .iter() + .map(provider_event_name) + .map(str::to_string) + .collect() +} + +fn provider_event_name(event: &ProviderEvent) -> &'static str { + match event { + ProviderEvent::StreamTrace(_) => "stream_trace", + ProviderEvent::ResponseStarted { .. } => "response_started", + ProviderEvent::ResponseInProgress { .. } => "response_in_progress", + ProviderEvent::ReasoningSummaryDelta(_) => "reasoning_summary_delta", + ProviderEvent::ReasoningSummaryDone(_) => "reasoning_summary_done", + ProviderEvent::TextDelta(_) => "text_delta", + ProviderEvent::FunctionCallRequested(_) => "function_call_requested", + ProviderEvent::Completed { .. } => "completed", + ProviderEvent::Failed(_) => "failed", + } +} + +#[derive(Debug, Default)] +struct ToolCallAccumulator { + calls: Vec, +} + +impl ToolCallAccumulator { + fn push(&mut self, tool_calls: Vec) { + for tool_call in tool_calls { + let index = tool_call.index; + while self.calls.len() <= index { + self.calls.push(AccumulatedToolCall::default()); + } + let target = &mut self.calls[index]; + if let Some(id) = tool_call.id { + target.id = id; + } + if let Some(function) = tool_call.function { + if let Some(name) = function.name.filter(|name| !name.is_empty()) { + target.name = name; + } + if let Some(arguments) = function.arguments { + target.arguments.push_str(&arguments); + } + } + } + } + + fn finish(&mut self, response_id: Option) -> Result, String> { + let response_id = + response_id.ok_or_else(|| "missing chat completions response id".to_string())?; + let calls = std::mem::take(&mut self.calls); + calls + .into_iter() + .map(|call| call.into_provider_event(&response_id)) + .collect() + } +} + +#[derive(Debug, Default)] +struct AccumulatedToolCall { + id: String, + name: String, + arguments: String, +} + +impl AccumulatedToolCall { + fn into_provider_event(self, response_id: &str) -> Result { + let arguments_raw = if self.arguments.trim().is_empty() { + "{}".to_string() + } else { + self.arguments + }; + let arguments = serde_json::from_str::(&arguments_raw) + .map_err(|error| format!("invalid chat completions tool arguments: {error}"))?; + Ok(ProviderEvent::FunctionCallRequested(ProviderFunctionCall { + response_id: response_id.to_string(), + item_id: Some(self.id.clone()), + item: json!({ + "type": "function_call", + "id": self.id, + "call_id": self.id, + "name": self.name, + "arguments": arguments_raw, + }), + call_id: self.id, + name: self.name, + arguments_raw, + arguments, + })) + } +} + +#[derive(Debug, Deserialize)] +struct ChatChunk { + id: String, + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct ChatChoice { + delta: ChatDelta, + #[serde(default)] + finish_reason: Option, +} + +#[derive(Debug, Deserialize)] +struct ChatDelta { + #[serde(default)] + content: Option, + #[serde(default)] + reasoning_content: Option, + #[serde(default)] + tool_calls: Option>, +} + +#[derive(Debug, Deserialize)] +struct ChatToolCallDelta { + index: usize, + #[serde(default)] + id: Option, + #[serde(default)] + function: Option, +} + +#[derive(Debug, Deserialize)] +struct ChatFunctionDelta { + #[serde(default)] + name: Option, + #[serde(default)] + arguments: Option, +} diff --git a/apps/server/soma/crates/santi-provider/src/lib.rs b/apps/server/soma/crates/santi-provider/src/lib.rs index 38549ff..f902cfd 100644 --- a/apps/server/soma/crates/santi-provider/src/lib.rs +++ b/apps/server/soma/crates/santi-provider/src/lib.rs @@ -1,5 +1,7 @@ +mod chat_completions; mod openai; mod provider; +pub use chat_completions::{ChatCompletionsProvider, ChatCompletionsProviderConfig}; pub use openai::{OpenAIProvider, OpenAIProviderConfig}; pub use provider::*; diff --git a/apps/server/soma/crates/santi-provider/src/openai.rs b/apps/server/soma/crates/santi-provider/src/openai.rs index dc27f21..7db8358 100644 --- a/apps/server/soma/crates/santi-provider/src/openai.rs +++ b/apps/server/soma/crates/santi-provider/src/openai.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use crate::{ FunctionCallOutput, ProviderClient, ProviderEvent, ProviderFunctionCall, ProviderMetadata, - ProviderRequest, ProviderStream, ProviderTool, + ProviderRequest, ProviderStream, ProviderStreamTrace, ProviderTool, }; #[derive(Debug, Clone)] @@ -18,6 +18,7 @@ pub struct OpenAIProviderConfig { pub model: String, pub base_url: String, pub reasoning_effort: Option, + pub reasoning_summary: Option, pub max_output_tokens: Option, } @@ -95,13 +96,8 @@ fn response_body(config: &OpenAIProviderConfig, request: ProviderRequest) -> Val json!(previous_response_id), ); } - if let Some(reasoning_effort) = &config.reasoning_effort { - body.insert( - "reasoning".to_string(), - json!({ - "effort": reasoning_effort - }), - ); + if let Some(reasoning) = reasoning_options(config) { + body.insert("reasoning".to_string(), reasoning); } if let Some(max_output_tokens) = config.max_output_tokens { body.insert("max_output_tokens".to_string(), json!(max_output_tokens)); @@ -110,6 +106,29 @@ fn response_body(config: &OpenAIProviderConfig, request: ProviderRequest) -> Val Value::Object(body) } +fn reasoning_options(config: &OpenAIProviderConfig) -> Option { + let mut reasoning = Map::new(); + if let Some(effort) = config + .reasoning_effort + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + reasoning.insert("effort".to_string(), json!(effort)); + } + if let Some(summary) = config + .reasoning_summary + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + reasoning.insert("summary".to_string(), json!(summary)); + } + if reasoning.is_empty() { + None + } else { + Some(Value::Object(reasoning)) + } +} + fn response_input(request: &ProviderRequest) -> Value { if let Some(outputs) = &request.function_call_outputs { return json!(map_function_call_outputs(outputs)); @@ -173,6 +192,7 @@ fn parse_sse( let mut current_response_id: Option = None; while let Some(chunk) = bytes.next().await { let chunk = chunk.map_err(|error| error.to_string())?; + yield ProviderEvent::StreamTrace(ProviderStreamTrace::Chunk { bytes: chunk.len() }); buffer.push_str(&String::from_utf8_lossy(&chunk)); while let Some(index) = buffer.find('\n') { let line = buffer[..index].trim_end_matches('\r').to_string(); @@ -181,7 +201,12 @@ fn parse_sse( if payload == "[DONE]" { continue; } - for event in parse_event(payload, &mut current_response_id)? { + let events = parse_event(payload, &mut current_response_id)?; + yield ProviderEvent::StreamTrace(ProviderStreamTrace::RawEvent { + raw_type: raw_event_type(payload), + mapped_events: provider_event_names(&events), + }); + for event in events { yield event; } } @@ -190,17 +215,55 @@ fn parse_sse( } } +fn raw_event_type(payload: &str) -> String { + serde_json::from_str::(payload) + .map(|event| event.event_type) + .unwrap_or_else(|_| "invalid_json".to_string()) +} + +fn provider_event_names(events: &[ProviderEvent]) -> Vec { + events + .iter() + .map(provider_event_name) + .map(str::to_string) + .collect() +} + +fn provider_event_name(event: &ProviderEvent) -> &'static str { + match event { + ProviderEvent::ResponseStarted { .. } => "response_started", + ProviderEvent::ResponseInProgress { .. } => "response_in_progress", + ProviderEvent::ReasoningSummaryDelta(_) => "reasoning_summary_delta", + ProviderEvent::ReasoningSummaryDone(_) => "reasoning_summary_done", + ProviderEvent::TextDelta(_) => "text_delta", + ProviderEvent::FunctionCallRequested(_) => "function_call_requested", + ProviderEvent::Completed { .. } => "completed", + ProviderEvent::Failed(_) => "failed", + ProviderEvent::StreamTrace(_) => "stream_trace", + } +} + fn parse_event( payload: &str, current_response_id: &mut Option, ) -> Result, String> { let value = serde_json::from_str::(payload).map_err(|error| error.to_string())?; match value.event_type.as_str() { - "response.created" | "response.in_progress" => { + "response.created" => { + if let Some(response_id) = value.response_id() { + *current_response_id = Some(response_id); + } + Ok(vec![ProviderEvent::ResponseStarted { + provider_response_id: current_response_id.clone(), + }]) + } + "response.in_progress" => { if let Some(response_id) = value.response_id() { *current_response_id = Some(response_id); } - Ok(Vec::new()) + Ok(vec![ProviderEvent::ResponseInProgress { + provider_response_id: current_response_id.clone(), + }]) } "response.output_text.delta" => Ok(value .delta @@ -208,6 +271,16 @@ fn parse_event( .map(|delta| vec![ProviderEvent::TextDelta(delta)]) .unwrap_or_default()), "response.output_text.done" => Ok(Vec::new()), + "response.reasoning_summary_text.delta" | "response.reasoning_summary.delta" => Ok(value + .delta + .filter(|delta| !delta.is_empty()) + .map(|delta| vec![ProviderEvent::ReasoningSummaryDelta(delta)]) + .unwrap_or_default()), + "response.reasoning_summary_text.done" | "response.reasoning_summary.done" => Ok(value + .text() + .filter(|text| !text.is_empty()) + .map(|text| vec![ProviderEvent::ReasoningSummaryDone(text)]) + .unwrap_or_default()), "response.output_item.done" => parse_output_item_done(value.raw, current_response_id), "response.completed" => Ok(vec![ProviderEvent::Completed { provider_response_id: value.response_id(), @@ -229,12 +302,24 @@ fn parse_output_item_done( let Some(item) = raw.get("item") else { return Ok(Vec::new()); }; - if item.get("type").and_then(Value::as_str) != Some("function_call") { - return Ok(Vec::new()); + match item.get("type").and_then(Value::as_str) { + Some("function_call") => parse_function_call_item(item, &raw, current_response_id), + Some("reasoning") => Ok(reasoning_summary_from_item(item) + .map(ProviderEvent::ReasoningSummaryDone) + .into_iter() + .collect()), + _ => Ok(Vec::new()), } +} + +fn parse_function_call_item( + item: &Value, + raw: &Value, + current_response_id: &Option, +) -> Result, String> { let response_id = current_response_id .clone() - .or_else(|| response_id_from_value(&raw)) + .or_else(|| response_id_from_value(raw)) .ok_or_else(|| "missing response id for function call".to_string())?; let call_id = item .get("call_id") @@ -267,6 +352,24 @@ fn parse_output_item_done( )]) } +fn reasoning_summary_from_item(item: &Value) -> Option { + let text = + item.get("summary")? + .as_array()? + .iter() + .fold(String::new(), |mut acc, summary_part| { + if let Some(text) = summary_part.get("text").and_then(Value::as_str) { + acc.push_str(text); + } + acc + }); + if text.trim().is_empty() { + None + } else { + Some(text) + } +} + fn response_id_from_value(value: &Value) -> Option { value .get("response") @@ -297,6 +400,12 @@ struct OpenAIEvent { raw: Value, } +#[derive(Debug, Deserialize)] +struct OpenAIEventKind { + #[serde(rename = "type")] + event_type: String, +} + impl OpenAIEvent { fn response_id(&self) -> Option { self.response @@ -305,6 +414,14 @@ impl OpenAIEvent { .or_else(|| self.response_id.clone()) .or_else(|| response_id_from_value(&self.raw)) } + + fn text(&self) -> Option { + self.raw + .get("text") + .and_then(Value::as_str) + .or_else(|| self.raw.get("summary").and_then(Value::as_str)) + .map(str::to_string) + } } #[derive(Debug, Deserialize)] diff --git a/apps/server/soma/crates/santi-provider/src/provider.rs b/apps/server/soma/crates/santi-provider/src/provider.rs index dd634e0..6be75c8 100644 --- a/apps/server/soma/crates/santi-provider/src/provider.rs +++ b/apps/server/soma/crates/santi-provider/src/provider.rs @@ -37,6 +37,8 @@ pub struct FunctionCallOutput { pub call: ProviderFunctionCall, pub call_id: String, pub output: String, + pub assistant_content: Option, + pub reasoning_content: Option, } #[derive(Debug, Clone, PartialEq)] @@ -50,6 +52,17 @@ pub struct ProviderFunctionCall { pub arguments: Value, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderStreamTrace { + Chunk { + bytes: usize, + }, + RawEvent { + raw_type: String, + mapped_events: Vec, + }, +} + #[derive(Debug, Clone)] pub struct ProviderMetadata { pub provider: Arc, @@ -58,6 +71,15 @@ pub struct ProviderMetadata { #[derive(Debug, Clone, PartialEq)] pub enum ProviderEvent { + StreamTrace(ProviderStreamTrace), + ResponseStarted { + provider_response_id: Option, + }, + ResponseInProgress { + provider_response_id: Option, + }, + ReasoningSummaryDelta(String), + ReasoningSummaryDone(String), TextDelta(String), FunctionCallRequested(ProviderFunctionCall), Completed { diff --git a/apps/server/soma/crates/santi-provider/tests/chat_completions.rs b/apps/server/soma/crates/santi-provider/tests/chat_completions.rs new file mode 100644 index 0000000..64a8f4a --- /dev/null +++ b/apps/server/soma/crates/santi-provider/tests/chat_completions.rs @@ -0,0 +1,434 @@ +use std::{ + io::{Read, Write}, + net::TcpListener, + sync::mpsc, + thread, +}; + +use futures_util::StreamExt; +use santi_provider::{ + ChatCompletionsProvider, ChatCompletionsProviderConfig, FunctionCallOutput, ProviderClient, + ProviderEvent, ProviderFunctionCall, ProviderFunctionTool, ProviderMessage, ProviderRequest, + ProviderStreamTrace, ProviderTool, +}; +use serde_json::Value; + +#[tokio::test] +async fn maps_chat_body() { + let body = capture_body(ChatCompletionsProviderConfig { + provider: "deepseek".to_string(), + api_key: "test-key".to_string(), + model: "deepseek-v4-pro".to_string(), + base_url: String::new(), + thinking: Some("disabled".to_string()), + reasoning_effort: Some("high".to_string()), + max_tokens: Some(512), + }) + .await; + + assert_eq!(body["model"], "deepseek-v4-pro"); + assert_eq!(body["stream"], true); + assert_eq!(body["thinking"]["type"], "disabled"); + assert_eq!(body["reasoning_effort"], "high"); + assert_eq!(body["max_tokens"], 512); + assert_eq!(body["messages"][0]["role"], "system"); + assert_eq!(body["messages"][1]["role"], "user"); + assert_eq!(body["tools"][0]["type"], "function"); + assert_eq!(body["tools"][0]["function"]["name"], "shell"); +} + +#[tokio::test] +async fn maps_tool_outputs() { + let body = capture_with_outputs().await; + + assert_eq!(body["messages"][2]["role"], "assistant"); + assert_eq!(body["messages"][2]["reasoning_content"], "need shell"); + assert_eq!(body["messages"][2]["content"], "checking"); + assert_eq!(body["messages"][2]["tool_calls"][0]["id"], "call_shell"); + assert_eq!( + body["messages"][2]["tool_calls"][0]["function"]["arguments"], + "{\"command\":\"pwd\"}" + ); + assert_eq!(body["messages"][3]["role"], "tool"); + assert_eq!(body["messages"][3]["tool_call_id"], "call_shell"); + assert_eq!(body["messages"][3]["content"], "/tmp"); +} + +#[tokio::test] +async fn maps_output_rounds() { + let body = capture_with_output_rounds().await; + + assert_eq!(body["messages"][2]["role"], "assistant"); + assert_eq!(body["messages"][2]["reasoning_content"], "round one"); + assert_eq!(body["messages"][2]["tool_calls"][0]["id"], "call_one"); + assert_eq!(body["messages"][3]["role"], "tool"); + assert_eq!(body["messages"][3]["tool_call_id"], "call_one"); + + assert_eq!(body["messages"][4]["role"], "assistant"); + assert_eq!(body["messages"][4]["reasoning_content"], "round two"); + assert_eq!(body["messages"][4]["tool_calls"][0]["id"], "call_two"); + assert_eq!(body["messages"][5]["role"], "tool"); + assert_eq!(body["messages"][5]["tool_call_id"], "call_two"); +} + +#[tokio::test] +async fn parses_reasoning_text() { + let events = capture_events(vec![ + r#"data: {"id":"chatcmpl_1","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}"#, + r#"data: {"id":"chatcmpl_1","choices":[{"delta":{"reasoning_content":"thinking"},"finish_reason":null}]}"#, + r#"data: {"id":"chatcmpl_1","choices":[{"delta":{"content":"ok"},"finish_reason":null}]}"#, + r#"data: {"id":"chatcmpl_1","choices":[{"delta":{},"finish_reason":"stop"}]}"#, + r#"data: [DONE]"#, + ]) + .await; + + assert!(matches!( + events.as_slice(), + [ + ProviderEvent::ResponseStarted { + provider_response_id: Some(response_id), + }, + ProviderEvent::ReasoningSummaryDelta(reasoning), + ProviderEvent::TextDelta(text), + ProviderEvent::Completed { + provider_response_id: Some(completed_id), + }, + ] if reasoning == "thinking" + && text == "ok" + && response_id == "chatcmpl_1" + && completed_id == "chatcmpl_1" + )); +} + +#[tokio::test] +async fn parses_streamed_tool_call() { + let events = capture_events(vec![ + r#"data: {"id":"chatcmpl_tool","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_shell","type":"function","function":{"name":"shell","arguments":"{\"command\""}}]},"finish_reason":null}]}"#, + r#"data: {"id":"chatcmpl_tool","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\"pwd\"}"}}]},"finish_reason":null}]}"#, + r#"data: {"id":"chatcmpl_tool","choices":[{"delta":{},"finish_reason":"tool_calls"}]}"#, + ]) + .await; + + assert!(matches!( + events.as_slice(), + [ + ProviderEvent::ResponseStarted { .. }, + ProviderEvent::FunctionCallRequested(call), + ] if call.response_id == "chatcmpl_tool" + && call.call_id == "call_shell" + && call.name == "shell" + && call.arguments["command"] == "pwd" + )); +} + +#[tokio::test] +async fn keeps_tool_name() { + let events = capture_events(vec![ + r#"data: {"id":"chatcmpl_tool","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_shell","type":"function","function":{"name":"shell","arguments":""}}]},"finish_reason":null}]}"#, + r#"data: {"id":"chatcmpl_tool","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"","arguments":"{\"command\""}}]},"finish_reason":null}]}"#, + r#"data: {"id":"chatcmpl_tool","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"","arguments":":\"pwd\"}"}}]},"finish_reason":null}]}"#, + r#"data: {"id":"chatcmpl_tool","choices":[{"delta":{},"finish_reason":"tool_calls"}]}"#, + ]) + .await; + + assert!(matches!( + events.as_slice(), + [ + ProviderEvent::ResponseStarted { .. }, + ProviderEvent::FunctionCallRequested(call), + ] if call.name == "shell" + && call.arguments["command"] == "pwd" + )); +} + +#[tokio::test] +async fn emits_stream_trace_events() { + let events = capture_all_events(vec![ + r#"data: {"id":"chatcmpl_1","choices":[{"delta":{"content":"ok"},"finish_reason":null}]}"#, + ]) + .await; + + assert!(events.iter().any(|event| { + matches!( + event, + ProviderEvent::StreamTrace(ProviderStreamTrace::Chunk { .. }) + ) + })); + assert!(events.iter().any(|event| { + matches!( + event, + ProviderEvent::StreamTrace(ProviderStreamTrace::RawEvent { + raw_type, + mapped_events, + }) if raw_type == "chat.completion.chunk" + && mapped_events == &vec![ + "response_started".to_string(), + "text_delta".to_string(), + ] + ) + })); +} + +async fn capture_body(mut config: ChatCompletionsProviderConfig) -> Value { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + config.base_url = format!("http://{}", listener.local_addr().expect("local address")); + let (tx, rx) = mpsc::channel(); + let server = response_server( + listener, + tx, + vec![r#"data: {"id":"chatcmpl_test","choices":[{"delta":{},"finish_reason":"stop"}]}"#], + ); + + let provider = ChatCompletionsProvider::new(config); + let mut stream = provider + .stream_response(base_request(provider.metadata().model, None)) + .await + .expect("stream response"); + assert_completed(&mut stream).await; + + let body = rx.recv().expect("receive request body"); + server.join().expect("server thread"); + serde_json::from_slice(&body).expect("json request") +} + +async fn capture_with_outputs() -> Value { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let config = ChatCompletionsProviderConfig { + provider: "deepseek".to_string(), + api_key: "test-key".to_string(), + model: "deepseek-v4-pro".to_string(), + base_url: format!("http://{}", listener.local_addr().expect("local address")), + thinking: None, + reasoning_effort: None, + max_tokens: None, + }; + let (tx, rx) = mpsc::channel(); + let server = response_server( + listener, + tx, + vec![r#"data: {"id":"chatcmpl_test","choices":[{"delta":{},"finish_reason":"stop"}]}"#], + ); + + let provider = ChatCompletionsProvider::new(config); + let mut stream = provider + .stream_response(base_request( + provider.metadata().model, + Some(vec![FunctionCallOutput { + call: ProviderFunctionCall { + response_id: "chatcmpl_prev".to_string(), + item_id: Some("call_shell".to_string()), + item: serde_json::json!({}), + call_id: "call_shell".to_string(), + name: "shell".to_string(), + arguments_raw: "{\"command\":\"pwd\"}".to_string(), + arguments: serde_json::json!({ "command": "pwd" }), + }, + call_id: "call_shell".to_string(), + output: "/tmp".to_string(), + assistant_content: Some("checking".to_string()), + reasoning_content: Some("need shell".to_string()), + }]), + )) + .await + .expect("stream response"); + assert_completed(&mut stream).await; + + let body = rx.recv().expect("receive request body"); + server.join().expect("server thread"); + serde_json::from_slice(&body).expect("json request") +} + +async fn capture_with_output_rounds() -> Value { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let config = ChatCompletionsProviderConfig { + provider: "deepseek".to_string(), + api_key: "test-key".to_string(), + model: "deepseek-v4-pro".to_string(), + base_url: format!("http://{}", listener.local_addr().expect("local address")), + thinking: Some("enabled".to_string()), + reasoning_effort: Some("high".to_string()), + max_tokens: None, + }; + let (tx, rx) = mpsc::channel(); + let server = response_server( + listener, + tx, + vec![r#"data: {"id":"chatcmpl_test","choices":[{"delta":{},"finish_reason":"stop"}]}"#], + ); + + let provider = ChatCompletionsProvider::new(config); + let mut stream = provider + .stream_response(base_request( + provider.metadata().model, + Some(vec![ + FunctionCallOutput { + call: function_call("resp_one", "call_one", "pwd"), + call_id: "call_one".to_string(), + output: "one".to_string(), + assistant_content: None, + reasoning_content: Some("round one".to_string()), + }, + FunctionCallOutput { + call: function_call("resp_two", "call_two", "ls"), + call_id: "call_two".to_string(), + output: "two".to_string(), + assistant_content: None, + reasoning_content: Some("round two".to_string()), + }, + ]), + )) + .await + .expect("stream response"); + assert_completed(&mut stream).await; + + let body = rx.recv().expect("receive request body"); + server.join().expect("server thread"); + serde_json::from_slice(&body).expect("json request") +} + +fn function_call(response_id: &str, call_id: &str, command: &str) -> ProviderFunctionCall { + let arguments_raw = format!(r#"{{"command":"{command}"}}"#); + ProviderFunctionCall { + response_id: response_id.to_string(), + item_id: Some(call_id.to_string()), + item: serde_json::json!({}), + call_id: call_id.to_string(), + name: "shell".to_string(), + arguments_raw, + arguments: serde_json::json!({ "command": command }), + } +} + +async fn capture_events(lines: Vec<&'static str>) -> Vec { + capture_all_events(lines) + .await + .into_iter() + .filter(|event| !matches!(event, ProviderEvent::StreamTrace(_))) + .collect() +} + +async fn capture_all_events(lines: Vec<&'static str>) -> Vec { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let config = ChatCompletionsProviderConfig { + provider: "deepseek".to_string(), + api_key: "test-key".to_string(), + model: "deepseek-v4-pro".to_string(), + base_url: format!("http://{}", listener.local_addr().expect("local address")), + thinking: None, + reasoning_effort: None, + max_tokens: None, + }; + let (tx, rx) = mpsc::channel(); + let server = response_server(listener, tx, lines); + + let provider = ChatCompletionsProvider::new(config); + let mut stream = provider + .stream_response(base_request(provider.metadata().model, None)) + .await + .expect("stream response"); + let mut events = Vec::new(); + while let Some(event) = stream.next().await { + events.push(event.expect("provider event")); + } + let _ = rx.recv().expect("receive request body"); + server.join().expect("server thread"); + events +} + +fn base_request( + model: String, + function_call_outputs: Option>, +) -> ProviderRequest { + ProviderRequest { + model, + instructions: Some("system guidance".to_string()), + input: vec![ProviderMessage { + role: "user".to_string(), + content: "hello".to_string(), + }], + tools: Some(vec![ProviderTool::Function(ProviderFunctionTool { + name: "shell".to_string(), + description: "run shell".to_string(), + parameters: serde_json::json!({ "type": "object" }), + })]), + previous_response_id: None, + function_call_outputs, + } +} + +fn response_server( + listener: TcpListener, + tx: mpsc::Sender>, + lines: Vec<&'static str>, +) -> thread::JoinHandle<()> { + thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let body = read_body(&mut stream); + tx.send(body).expect("send request body"); + let response_body = format!("{}\n\n", lines.join("\n\n")); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\n\r\n{}", + response_body.len(), + response_body + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + }) +} + +async fn next_business_event(stream: &mut santi_provider::ProviderStream) -> Option { + while let Some(event) = stream.next().await { + let event = event.expect("provider event"); + if !matches!(event, ProviderEvent::StreamTrace(_)) { + return Some(event); + } + } + None +} + +async fn assert_completed(stream: &mut santi_provider::ProviderStream) { + while let Some(event) = next_business_event(stream).await { + if matches!(event, ProviderEvent::Completed { .. }) { + return; + } + } + panic!("expected completed provider event"); +} + +fn read_body(stream: &mut impl Read) -> Vec { + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + loop { + let read = stream.read(&mut buffer).expect("read request"); + assert!(read > 0, "connection closed before headers"); + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + + let header_end = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("header end") + + 4; + let headers = String::from_utf8_lossy(&request[..header_end]); + let length = headers + .lines() + .find_map(|line| { + line.strip_prefix("content-length:") + .or_else(|| line.strip_prefix("Content-Length:")) + }) + .expect("content length") + .trim() + .parse::() + .expect("content length value"); + + while request.len() - header_end < length { + let read = stream.read(&mut buffer).expect("read body"); + assert!(read > 0, "connection closed before body"); + request.extend_from_slice(&buffer[..read]); + } + request[header_end..header_end + length].to_vec() +} diff --git a/apps/server/soma/crates/santi-provider/tests/openai.rs b/apps/server/soma/crates/santi-provider/tests/openai.rs index 6cb2c69..b6048ad 100644 --- a/apps/server/soma/crates/santi-provider/tests/openai.rs +++ b/apps/server/soma/crates/santi-provider/tests/openai.rs @@ -8,7 +8,7 @@ use std::{ use futures_util::StreamExt; use santi_provider::{ OpenAIProvider, OpenAIProviderConfig, ProviderClient, ProviderEvent, ProviderFunctionTool, - ProviderMessage, ProviderRequest, ProviderTool, + ProviderMessage, ProviderRequest, ProviderStreamTrace, ProviderTool, }; use serde_json::Value; @@ -19,11 +19,13 @@ async fn optional_params_sent() { model: "gpt-5.5".to_string(), base_url: String::new(), reasoning_effort: Some("medium".to_string()), + reasoning_summary: Some("auto".to_string()), max_output_tokens: Some(4096), }) .await; assert_eq!(body["reasoning"]["effort"], "medium"); + assert_eq!(body["reasoning"]["summary"], "auto"); assert_eq!(body["max_output_tokens"], 4096); assert_eq!(body["stream"], true); assert_eq!(body["store"], false); @@ -40,6 +42,7 @@ async fn optional_params_omitted() { model: "gpt-4.1".to_string(), base_url: String::new(), reasoning_effort: None, + reasoning_summary: None, max_output_tokens: None, }) .await; @@ -56,6 +59,7 @@ async fn plain_requests_unstored() { model: "gpt-4.1".to_string(), base_url: String::new(), reasoning_effort: None, + reasoning_summary: None, max_output_tokens: None, }) .await; @@ -73,13 +77,79 @@ async fn parses_call_response_id() { assert!(matches!( events.as_slice(), - [ProviderEvent::FunctionCallRequested(call)] - if call.response_id == "resp_tool" + [ + ProviderEvent::ResponseStarted { + provider_response_id: Some(response_id), + }, + ProviderEvent::FunctionCallRequested(call), + ] + if response_id == "resp_tool" + && call.response_id == "resp_tool" && call.call_id == "call_shell" && call.name == "shell" )); } +#[tokio::test] +async fn parses_summary_stream() { + let events = capture_events(vec![ + r#"data: {"type":"response.created","response":{"id":"resp_reasoning"}}"#, + r#"data: {"type":"response.reasoning_summary_text.delta","delta":"looking "}"#, + r#"data: {"type":"response.reasoning_summary_text.delta","delta":"closely"}"#, + r#"data: {"type":"response.reasoning_summary_text.done","text":"looking closely"}"#, + ]) + .await; + + assert!(matches!( + events.as_slice(), + [ + ProviderEvent::ResponseStarted { .. }, + ProviderEvent::ReasoningSummaryDelta(first), + ProviderEvent::ReasoningSummaryDelta(second), + ProviderEvent::ReasoningSummaryDone(done), + ] if first == "looking " && second == "closely" && done == "looking closely" + )); +} + +#[tokio::test] +async fn parses_summary_item_done() { + let events = capture_events(vec![ + r#"data: {"type":"response.output_item.done","item":{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"First. "},{"type":"summary_text","text":"Second."}]}}"#, + ]) + .await; + + assert!(matches!( + events.as_slice(), + [ProviderEvent::ReasoningSummaryDone(summary)] if summary == "First. Second." + )); +} + +#[tokio::test] +async fn emits_stream_trace_events() { + let events = capture_all_events(vec![ + r#"data: {"type":"response.created","response":{"id":"resp_trace"}}"#, + r#"data: {"type":"response.output_text.delta","delta":"ok"}"#, + ]) + .await; + + assert!(events.iter().any(|event| { + matches!( + event, + ProviderEvent::StreamTrace(ProviderStreamTrace::Chunk { .. }) + ) + })); + assert!(events.iter().any(|event| { + matches!( + event, + ProviderEvent::StreamTrace(ProviderStreamTrace::RawEvent { + raw_type, + mapped_events, + }) if raw_type == "response.created" + && mapped_events == &vec!["response_started".to_string()] + ) + })); +} + async fn capture_body(mut config: OpenAIProviderConfig) -> Value { let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); config.base_url = format!("http://{}", listener.local_addr().expect("local address")); @@ -120,8 +190,8 @@ async fn capture_body(mut config: OpenAIProviderConfig) -> Value { .await .expect("stream response"); assert!(matches!( - stream.next().await, - Some(Ok(ProviderEvent::Completed { .. })) + next_business_event(&mut stream).await, + Some(ProviderEvent::Completed { .. }) )); let body = rx.recv().expect("receive request body"); @@ -165,8 +235,8 @@ async fn capture_body_without_tools(mut config: OpenAIProviderConfig) -> Value { .await .expect("stream response"); assert!(matches!( - stream.next().await, - Some(Ok(ProviderEvent::Completed { .. })) + next_business_event(&mut stream).await, + Some(ProviderEvent::Completed { .. }) )); let body = rx.recv().expect("receive request body"); @@ -175,12 +245,21 @@ async fn capture_body_without_tools(mut config: OpenAIProviderConfig) -> Value { } async fn capture_events(lines: Vec<&'static str>) -> Vec { + capture_all_events(lines) + .await + .into_iter() + .filter(|event| !matches!(event, ProviderEvent::StreamTrace(_))) + .collect() +} + +async fn capture_all_events(lines: Vec<&'static str>) -> Vec { let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); let config = OpenAIProviderConfig { api_key: "test-key".to_string(), model: "gpt-5.5".to_string(), base_url: format!("http://{}", listener.local_addr().expect("local address")), reasoning_effort: None, + reasoning_summary: None, max_output_tokens: None, }; let server = thread::spawn(move || { @@ -220,6 +299,16 @@ async fn capture_events(lines: Vec<&'static str>) -> Vec { events } +async fn next_business_event(stream: &mut santi_provider::ProviderStream) -> Option { + while let Some(event) = stream.next().await { + let event = event.expect("provider event"); + if !matches!(event, ProviderEvent::StreamTrace(_)) { + return Some(event); + } + } + None +} + fn read_body(stream: &mut impl Read) -> Vec { let mut request = Vec::new(); let mut buffer = [0; 1024]; diff --git a/docs/README.md b/docs/README.md index cfdabf5..b6ca84a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,9 +7,9 @@ Use `.task/MAIN.md` for active planning and keep docs focused on settled boundar ## Index - Current durable boundary is documented in `AGENTS.md`: single-person web chat, - Rust server, provider-abstracted model streaming with a concrete OpenAI - implementation, generated OpenAPI contracts, and normalized local - persistence. + Rust server, provider-abstracted model streaming with concrete OpenAI + Responses and Chat Completions-compatible implementations, generated OpenAPI + contracts, and normalized local persistence. - The local dev runtime uses sidecar CLI with project-local cells and somas: `cell` is the managed control boundary, `soma` is the sidecar-unaware executable body, `store` is the namespace-local persistent storage root, and diff --git a/packages/components/src/atoms/Panel/Panel.tsx b/packages/components/src/atoms/Panel/Panel.tsx index 485767d..dfea186 100644 --- a/packages/components/src/atoms/Panel/Panel.tsx +++ b/packages/components/src/atoms/Panel/Panel.tsx @@ -1,4 +1,4 @@ -import type { ComponentPropsWithoutRef, ComponentPropsWithRef } from "react"; +import type { ComponentPropsWithoutRef, ComponentPropsWithRef, Ref } from "react"; import { cx } from "../../internal/cx"; import "./Panel.scss"; @@ -7,6 +7,7 @@ type PanelRootProps = ComponentPropsWithRef<"section">; type PanelSlotProps = ComponentPropsWithoutRef<"div">; type PanelBodyProps = PanelSlotProps & { + innerRef?: Ref; scroll?: boolean; tone?: "default" | "inset"; }; @@ -19,7 +20,7 @@ function Header({ className, ...props }: PanelSlotProps) { return
; } -function Body({ className, scroll = false, tone = "default", ...props }: PanelBodyProps) { +function Body({ className, innerRef, scroll = false, tone = "default", ...props }: PanelBodyProps) { return (
); } diff --git a/packages/contracts/openapi.json b/packages/contracts/openapi.json index 4e77f7a..469650c 100644 --- a/packages/contracts/openapi.json +++ b/packages/contracts/openapi.json @@ -261,6 +261,64 @@ } } }, + "/api/v1/sessions/{session_id}/materials": { + "post": { + "tags": [], + "operationId": "session_material", + "parameters": [ + { + "name": "session_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaterialRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionMaterial" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/sessions/{session_id}/messages": { "get": { "tags": [], @@ -390,7 +448,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SendSessionResponse" + "$ref": "#/components/schemas/SendSessionAcceptedResponse" } } } @@ -503,12 +561,49 @@ } } }, + "MaterialKind": { + "type": "string", + "enum": [ + "system_prompt" + ] + }, + "MaterialRequest": { + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "$ref": "#/components/schemas/MaterialKind" + } + } + }, + "MaterialUpdated": { + "type": "object", + "required": [ + "session_id", + "kind", + "updated_at" + ], + "properties": { + "kind": { + "$ref": "#/components/schemas/MaterialKind" + }, + "session_id": { + "type": "string" + }, + "updated_at": { + "$ref": "#/components/schemas/String" + } + } + }, "Message": { "type": "object", "required": [ "id", "actor_type", "actor_id", + "message_kind", "content", "state", "version", @@ -541,6 +636,9 @@ "id": { "type": "string" }, + "message_kind": { + "$ref": "#/components/schemas/MessageKind" + }, "state": { "$ref": "#/components/schemas/MessageState" }, @@ -567,6 +665,13 @@ } } }, + "MessageKind": { + "type": "string", + "enum": [ + "text", + "santi_system" + ] + }, "MessagePart": { "oneOf": [ { @@ -615,39 +720,20 @@ "type": "string", "enum": [ "pending", - "fixed" + "fixed", + "aborted" ] }, - "SendSessionRequest": { - "type": "object", - "required": [ - "content" - ], - "properties": { - "content": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MessagePart" - } - } - } - }, - "SendSessionResponse": { + "SendSessionAcceptedResponse": { "type": "object", "required": [ "session", "soul_session", "soul_profile", "turn", - "user_message", - "assistant_message", - "tool_calls", - "tool_results" + "user_message" ], "properties": { - "assistant_message": { - "$ref": "#/components/schemas/SessionMessage" - }, "session": { "$ref": "#/components/schemas/SessionSummary" }, @@ -657,18 +743,6 @@ "soul_session": { "$ref": "#/components/schemas/SoulSession" }, - "tool_calls": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToolCall" - } - }, - "tool_results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToolResult" - } - }, "turn": { "$ref": "#/components/schemas/Turn" }, @@ -677,6 +751,20 @@ } } }, + "SendSessionRequest": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessagePart" + } + } + } + }, "Session": { "type": "object", "required": [ @@ -786,6 +874,33 @@ } } }, + "SessionMaterial": { + "type": "object", + "required": [ + "session_id", + "kind", + "content_type", + "text", + "updated_at" + ], + "properties": { + "content_type": { + "type": "string" + }, + "kind": { + "$ref": "#/components/schemas/MaterialKind" + }, + "session_id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "updated_at": { + "$ref": "#/components/schemas/String" + } + } + }, "SessionMessage": { "type": "object", "required": [ @@ -867,6 +982,7 @@ "profile", "messages", "turns", + "thinking_spans", "tool_calls", "tool_results", "compacts", @@ -917,6 +1033,12 @@ } ] }, + "thinking_spans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThinkingSpan" + } + }, "tool_calls": { "type": "array", "items": { @@ -956,6 +1078,7 @@ "type": "object", "required": [ "soul_id", + "soul_name", "nickname", "avatar_seed", "created_at", @@ -986,6 +1109,9 @@ "soul_id": { "type": "string" }, + "soul_name": { + "type": "string" + }, "updated_at": { "$ref": "#/components/schemas/String" } @@ -1049,6 +1175,87 @@ "String": { "type": "string" }, + "ThinkingCompletionReason": { + "type": "string", + "enum": [ + "first_text_delta", + "tool_call_requested", + "provider_completed" + ] + }, + "ThinkingSpan": { + "type": "object", + "required": [ + "id", + "turn_id", + "state", + "created_at", + "updated_at" + ], + "properties": { + "completion_reason": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ThinkingCompletionReason" + } + ] + }, + "created_at": { + "$ref": "#/components/schemas/String" + }, + "error_text": { + "type": [ + "string", + "null" + ] + }, + "finished_at": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/String" + } + ] + }, + "id": { + "type": "string" + }, + "provider_response_id": { + "type": [ + "string", + "null" + ] + }, + "state": { + "$ref": "#/components/schemas/ThinkingSpanState" + }, + "summary": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "type": "string" + }, + "updated_at": { + "$ref": "#/components/schemas/String" + } + } + }, + "ThinkingSpanState": { + "type": "string", + "enum": [ + "running", + "completed", + "failed" + ] + }, "ToolCall": { "type": "object", "required": [ @@ -1170,6 +1377,37 @@ } } }, + "TurnActivity": { + "type": "object", + "required": [ + "turn_id", + "state" + ], + "properties": { + "provider_response_id": { + "type": [ + "string", + "null" + ] + }, + "state": { + "$ref": "#/components/schemas/TurnActivityState" + }, + "turn_id": { + "type": "string" + } + } + }, + "TurnActivityState": { + "type": "string", + "enum": [ + "requesting", + "thinking", + "generating", + "calling_tool", + "running_tool" + ] + }, "TurnStatus": { "type": "string", "enum": [ diff --git a/packages/contracts/src/openapi.ts b/packages/contracts/src/openapi.ts index 62967ea..b318cc6 100644 --- a/packages/contracts/src/openapi.ts +++ b/packages/contracts/src/openapi.ts @@ -62,6 +62,22 @@ export interface HealthResponse { service: string; } +export type MaterialKind = (typeof MaterialKind)[keyof typeof MaterialKind]; + +export const MaterialKind = { + system_prompt: "system_prompt", +} as const; + +export interface MaterialRequest { + kind: MaterialKind; +} + +export interface MaterialUpdated { + kind: MaterialKind; + session_id: string; + updated_at: String; +} + export type MessagePart = | { text: string; @@ -77,11 +93,19 @@ export interface MessageContent { parts: MessagePart[]; } +export type MessageKind = (typeof MessageKind)[keyof typeof MessageKind]; + +export const MessageKind = { + text: "text", + santi_system: "santi_system", +} as const; + export type MessageState = (typeof MessageState)[keyof typeof MessageState]; export const MessageState = { pending: "pending", fixed: "fixed", + aborted: "aborted", } as const; export interface Message { @@ -91,28 +115,12 @@ export interface Message { created_at: String; deleted_at?: null | String; id: string; + message_kind: MessageKind; state: MessageState; updated_at: String; version: number; } -export interface SendSessionRequest { - content: MessagePart[]; -} - -export interface SessionMessageRef { - created_at: String; - message_id: string; - session_id: string; - session_seq: number; -} - -export interface SessionMessage { - content_text: string; - message: Message; - relation: SessionMessageRef; -} - export interface SoulProfile { /** @nullable */ avatar_ref?: string | null; @@ -122,6 +130,7 @@ export interface SoulProfile { desc?: string | null; nickname: string; soul_id: string; + soul_name: string; updated_at: String; } @@ -141,23 +150,6 @@ export interface SoulSession { updated_at: String; } -export interface ToolCall { - arguments: unknown; - created_at: String; - id: string; - tool_name: string; - turn_id: string; -} - -export interface ToolResult { - created_at: String; - /** @nullable */ - error_text?: string | null; - id: string; - output?: unknown; - tool_call_id: string; -} - export type TurnStatus = (typeof TurnStatus)[keyof typeof TurnStatus]; export const TurnStatus = { @@ -192,17 +184,31 @@ export interface Turn { updated_at: String; } -export interface SendSessionResponse { - assistant_message: SessionMessage; +export interface SessionMessageRef { + created_at: String; + message_id: string; + session_id: string; + session_seq: number; +} + +export interface SessionMessage { + content_text: string; + message: Message; + relation: SessionMessageRef; +} + +export interface SendSessionAcceptedResponse { session: SessionSummary; soul_profile: SoulProfile; soul_session: SoulSession; - tool_calls: ToolCall[]; - tool_results: ToolResult[]; turn: Turn; user_message: SessionMessage; } +export interface SendSessionRequest { + content: MessagePart[]; +} + export interface SessionDetail { messages: SessionMessage[]; profile: SessionProfile; @@ -225,6 +231,65 @@ export interface SessionEffect { updated_at: String; } +export interface SessionMaterial { + content_type: string; + kind: MaterialKind; + session_id: string; + text: string; + updated_at: String; +} + +export type ThinkingCompletionReason = + (typeof ThinkingCompletionReason)[keyof typeof ThinkingCompletionReason]; + +export const ThinkingCompletionReason = { + first_text_delta: "first_text_delta", + tool_call_requested: "tool_call_requested", + provider_completed: "provider_completed", +} as const; + +export type ThinkingSpanState = + (typeof ThinkingSpanState)[keyof typeof ThinkingSpanState]; + +export const ThinkingSpanState = { + running: "running", + completed: "completed", + failed: "failed", +} as const; + +export interface ThinkingSpan { + completion_reason?: null | ThinkingCompletionReason; + created_at: String; + /** @nullable */ + error_text?: string | null; + finished_at?: null | String; + id: string; + /** @nullable */ + provider_response_id?: string | null; + state: ThinkingSpanState; + /** @nullable */ + summary?: string | null; + turn_id: string; + updated_at: String; +} + +export interface ToolCall { + arguments: unknown; + created_at: String; + id: string; + tool_name: string; + turn_id: string; +} + +export interface ToolResult { + created_at: String; + /** @nullable */ + error_text?: string | null; + id: string; + output?: unknown; + tool_call_id: string; +} + export interface SessionRuntimeSnapshot { compacts: Compact[]; effects: SessionEffect[]; @@ -233,11 +298,30 @@ export interface SessionRuntimeSnapshot { session: Session; soul_profile?: null | SoulProfile; soul_session?: null | SoulSession; + thinking_spans: ThinkingSpan[]; tool_calls: ToolCall[]; tool_results: ToolResult[]; turns: Turn[]; } +export type TurnActivityState = + (typeof TurnActivityState)[keyof typeof TurnActivityState]; + +export const TurnActivityState = { + requesting: "requesting", + thinking: "thinking", + generating: "generating", + calling_tool: "calling_tool", + running_tool: "running_tool", +} as const; + +export interface TurnActivity { + /** @nullable */ + provider_response_id?: string | null; + state: TurnActivityState; + turn_id: string; +} + export interface UpdateSessionRequest { /** @nullable */ title?: string | null; @@ -528,6 +612,61 @@ export const updateSession = async ( } as updateSessionResponse; }; +export type sessionMaterialResponse200 = { + data: SessionMaterial; + status: 200; +}; + +export type sessionMaterialResponse404 = { + data: ErrorResponse; + status: 404; +}; + +export type sessionMaterialResponse500 = { + data: ErrorResponse; + status: 500; +}; + +export type sessionMaterialResponseSuccess = sessionMaterialResponse200 & { + headers: Headers; +}; +export type sessionMaterialResponseError = ( + | sessionMaterialResponse404 + | sessionMaterialResponse500 +) & { + headers: Headers; +}; + +export type sessionMaterialResponse = + | sessionMaterialResponseSuccess + | sessionMaterialResponseError; + +export const getSessionMaterialUrl = (sessionId: string) => { + return `/api/v1/sessions/${sessionId}/materials`; +}; + +export const sessionMaterial = async ( + sessionId: string, + materialRequest: MaterialRequest, + options?: RequestInit, +): Promise => { + const res = await fetch(getSessionMaterialUrl(sessionId), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(materialRequest), + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: sessionMaterialResponse["data"] = body ? JSON.parse(body) : {}; + return { + data, + status: res.status, + headers: res.headers, + } as sessionMaterialResponse; +}; + export type listMessagesResponse200 = { data: SessionMessage[]; status: 200; @@ -633,7 +772,7 @@ export const runtimeSnapshot = async ( }; export type sendSessionResponse200 = { - data: SendSessionResponse; + data: SendSessionAcceptedResponse; status: 200; }; diff --git a/packages/hooks/src/index.tsx b/packages/hooks/src/index.tsx index 3e2d5d4..7558ca0 100644 --- a/packages/hooks/src/index.tsx +++ b/packages/hooks/src/index.tsx @@ -1,12 +1,15 @@ import { installSantiMqueue, + type MaterialKind, type MessageConnectionState, + type MessageEvent, type MessagePart, type MessageProjection, type MqueueError, type PubAck, type SantiMqueue, type SantiWindow, + type SessionMaterial, type SessionMessage, type SessionProjection, type SessionRuntimeSnapshot, @@ -26,6 +29,8 @@ import { export type { Compact, + MaterialKind, + MaterialUpdated, MessageConnectionState, MessagePart, MqueueError, @@ -33,13 +38,20 @@ export type { SantiMqueue, Session, SessionEffect, + SessionMaterial, SessionMessage, SessionProjection, SessionRuntimeSnapshot, SessionSummary, SoulSession, + ThinkingCompletionReason, + ThinkingSpan, + ThinkingSpanState, TimelineItem, Turn, + TurnActivity, + TurnActivityProjection, + TurnActivityState, TurnGroup, TurnStatus, } from "@mini-stim/mqueue"; @@ -55,6 +67,7 @@ interface SessionActions { create(): PubAck; get(sessionId: string): PubAck; list(): PubAck; + refreshMaterial(sessionId: string, kind: MaterialKind): PubAck; refreshRuntime(sessionId: string): PubAck; select(sessionId: string | null): PubAck; selectAndGet(sessionId: string): PubAck[]; @@ -119,7 +132,7 @@ export function useSessionMessages(sessionId?: string | null): SessionMessage[] export function useSessionTurnTimeline(sessionId?: string | null): TurnGroup[] { const mqueue = useSantiMqueue(); const selectedSessionId = useSelectedSessionId(); - const store = useMemo(() => createMessageStore(mqueue), [mqueue]); + const store = useMemo(() => createMessageStore(mqueue, isProjectionEvent), [mqueue]); const resolvedSessionId = sessionId ?? selectedSessionId; const projection = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); if (!resolvedSessionId) { @@ -158,6 +171,17 @@ export function useSessionRuntime(sessionId?: string | null): SessionRuntimeSnap return projection.runtimeBySessionId[resolvedSessionId] ?? null; } +export function useSessionMaterial( + sessionId: string | null | undefined, + kind: MaterialKind, +): SessionMaterial | null { + const projection = useSessionProjection(); + if (!sessionId) { + return null; + } + return projection.materialsBySessionId[sessionId]?.[kind] ?? null; +} + export function useSessionPending(): number { return useSessionProjection().pending; } @@ -194,7 +218,7 @@ export function useDebouncedValue( export function useMessageConnection(sessionId?: string | null): MessageConnectionState { const mqueue = useSantiMqueue(); const selectedSessionId = useSelectedSessionId(); - const store = useMemo(() => createMessageStore(mqueue), [mqueue]); + const store = useMemo(() => createMessageStore(mqueue, isConnectionEvent), [mqueue]); const resolvedSessionId = sessionId ?? selectedSessionId; const projection = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); if (!resolvedSessionId) { @@ -215,18 +239,34 @@ function createSessionStore(mqueue: SantiMqueue) { }; } -function createMessageStore(mqueue: SantiMqueue) { +function createMessageStore(mqueue: SantiMqueue, shouldUpdate: (event: MessageEvent) => boolean) { let snapshot: MessageProjection = mqueue.message.snapshot(); return { getSnapshot: () => snapshot, subscribe: (onStoreChange: () => void) => - mqueue.message.sub(() => { + mqueue.message.sub((event) => { + if (!shouldUpdate(event)) { + return; + } snapshot = mqueue.message.snapshot(); onStoreChange(); }), }; } +function isProjectionEvent(event: MessageEvent) { + return event.phase === "projection"; +} + +function isConnectionEvent(event: MessageEvent) { + return ( + event.phase === "connecting" || + event.phase === "open" || + event.phase === "closed" || + event.phase === "error" + ); +} + export function useSessionActions(): SessionActions { const mqueue = useSantiMqueue(); return useMemo( @@ -234,6 +274,8 @@ export function useSessionActions(): SessionActions { create: () => mqueue.session.pub("create"), get: (sessionId: string) => mqueue.session.pub("get", { sessionId }), list: () => mqueue.session.pub("list"), + refreshMaterial: (sessionId: string, kind: MaterialKind) => + mqueue.session.pub("material", { sessionId, kind }), refreshRuntime: (sessionId: string) => mqueue.session.pub("runtime", { sessionId }), select: (sessionId: string | null) => mqueue.session.pub("select", { sessionId }), selectAndGet: (sessionId: string) => [ diff --git a/packages/mqueue/src/events.ts b/packages/mqueue/src/events.ts index e9d7ee7..100b903 100644 --- a/packages/mqueue/src/events.ts +++ b/packages/mqueue/src/events.ts @@ -1,14 +1,13 @@ import type { ErrorResponse, - MessageState, SessionMessage, + ThinkingSpan, ToolCall, ToolResult, Turn, } from "@mini-stim/contracts"; - +import { DEFAULT_ACTOR_ID } from "./lib/helpers/message"; import type { - MessageDeltaPayload, MessageEvent, MessagePhase, MessageProjection, @@ -20,10 +19,11 @@ import type { SessionProjection, StreamEvent, TimelineItem, + TurnActivityProjection, TurnGroup, } from "./types"; -const DEFAULT_ACTOR_ID = "account_local"; +export { appendText, dedupeMessages, transientMessage } from "./lib/helpers/message"; export function sessionEvent( action: Action, @@ -85,11 +85,15 @@ export function validateSessionPayload( ) { switch (action) { case "get": + case "material": case "messages": case "runtime": if (!(payload as { sessionId?: unknown })?.sessionId) { throw new Error(`session.${action} requires sessionId`); } + if (action === "material" && !(payload as { kind?: unknown })?.kind) { + throw new Error("session.material requires kind"); + } return; case "select": if (!payload || !("sessionId" in (payload as object))) { @@ -117,58 +121,17 @@ export function parseStreamEvent(raw: Event): StreamEvent | null { } } -export function transientMessage(sessionId: string, payload: MessageDeltaPayload): SessionMessage { - const createdAt = new Date().toISOString(); - return { - relation: { - session_id: sessionId, - message_id: payload.message_id, - session_seq: Number.MAX_SAFE_INTEGER, - created_at: createdAt, - }, - message: { - id: payload.message_id, - actor_type: payload.role, - actor_id: payload.role === "soul" ? "soul_default" : "account_local", - content: { parts: [{ type: "text", text: payload.text }] }, - state: "pending" as MessageState, - version: 1, - deleted_at: null, - created_at: createdAt, - updated_at: createdAt, - }, - content_text: payload.text, - }; -} - -export function appendText(message: SessionMessage, text: string): SessionMessage { - const contentText = `${message.content_text}${text}`; - return { - ...message, - content_text: contentText, - message: { - ...message.message, - content: { parts: [{ type: "text", text: contentText }] }, - updated_at: new Date().toISOString(), - }, - }; -} - -export function dedupeMessages(messages: SessionMessage[]): SessionMessage[] { - const byId = new Map(); - for (const message of messages) { - byId.set(message.message.id, message); - } - return [...byId.values()].sort( - (left, right) => left.relation.session_seq - right.relation.session_seq, - ); -} - export function cloneProjection(value: SessionProjection): SessionProjection { return { sessions: [...value.sessions], selectedSessionId: value.selectedSessionId, messages: [...value.messages], + materialsBySessionId: Object.fromEntries( + Object.entries(value.materialsBySessionId ?? {}).map(([sessionId, materials]) => [ + sessionId, + { ...materials }, + ]), + ), messagesBySessionId: Object.fromEntries( Object.entries(value.messagesBySessionId).map(([sessionId, messages]) => [ sessionId, @@ -183,36 +146,51 @@ export function cloneProjection(value: SessionProjection): SessionProjection { export function cloneMessageProjection(value: MessageProjection): MessageProjection { return { - connectionBySessionId: { ...value.connectionBySessionId }, + connectionBySessionId: { ...(value.connectionBySessionId ?? {}) }, messagesBySessionId: Object.fromEntries( - Object.entries(value.messagesBySessionId).map(([sessionId, messages]) => [ + Object.entries(value.messagesBySessionId ?? {}).map(([sessionId, messages]) => [ sessionId, [...messages], ]), ), timelineBySessionId: Object.fromEntries( - Object.entries(value.timelineBySessionId).map(([sessionId, items]) => [ + Object.entries(value.timelineBySessionId ?? {}).map(([sessionId, items]) => [ sessionId, [...items], ]), ), turnTimelineBySessionId: Object.fromEntries( - Object.entries(value.turnTimelineBySessionId).map(([sessionId, groups]) => [ + Object.entries(value.turnTimelineBySessionId ?? {}).map(([sessionId, groups]) => [ sessionId, groups.map((group) => ({ ...group, items: [...group.items] })), ]), ), + turnActivityBySessionId: Object.fromEntries( + Object.entries(value.turnActivityBySessionId ?? {}).map(([sessionId, activities]) => [ + sessionId, + { ...activities }, + ]), + ), + thinkingSpansBySessionId: Object.fromEntries( + Object.entries(value.thinkingSpansBySessionId ?? {}).map(([sessionId, thinkingSpans]) => [ + sessionId, + [...thinkingSpans], + ]), + ), turnsBySessionId: Object.fromEntries( - Object.entries(value.turnsBySessionId).map(([sessionId, turns]) => [sessionId, [...turns]]), + Object.entries(value.turnsBySessionId ?? {}).map(([sessionId, turns]) => [ + sessionId, + [...turns], + ]), ), toolCallsBySessionId: Object.fromEntries( - Object.entries(value.toolCallsBySessionId).map(([sessionId, calls]) => [ + Object.entries(value.toolCallsBySessionId ?? {}).map(([sessionId, calls]) => [ sessionId, [...calls], ]), ), toolResultsBySessionId: Object.fromEntries( - Object.entries(value.toolResultsBySessionId).map(([sessionId, results]) => [ + Object.entries(value.toolResultsBySessionId ?? {}).map(([sessionId, results]) => [ sessionId, [...results], ]), @@ -241,6 +219,7 @@ const STREAM_MESSAGE_PREFIX = "stream_"; * Group a flat, time-sorted timeline into turn envelopes. * * Attribution rules, strongest first: + * - thinking: `turn_id` on the thinking span. * - tool_call / paired tool_result: `turn_id` on the call. * - transient streaming message: its id is `stream_` (core emits * MessageDelta with that synthetic id). @@ -255,7 +234,12 @@ const STREAM_MESSAGE_PREFIX = "stream_"; * items (just-started turns) still produce an empty group so the UI can * show a running envelope immediately. */ -export function turnGroups(sessionId: string, items: TimelineItem[], turns: Turn[]): TurnGroup[] { +export function turnGroups( + sessionId: string, + items: TimelineItem[], + turns: Turn[], + activities: Record = {}, +): TurnGroup[] { const sortedTurns = dedupeTurns(turns); const turnsById = new Map(sortedTurns.map((turn) => [turn.id, turn])); const groupsByTurnId = new Map(); @@ -270,6 +254,7 @@ export function turnGroups(sessionId: string, items: TimelineItem[], turns: Turn sessionId, createdAt: turn.created_at, turn, + activity: activities[turn.id], items: [], }; groupsByTurnId.set(turn.id, group); @@ -316,6 +301,9 @@ function attributeItem( if (item.kind === "tool_call") { return turnsById.get(item.toolCall.turn_id); } + if (item.kind === "thinking") { + return turnsById.get(item.thinking.turn_id); + } if (item.kind === "tool_result") { return item.toolCall ? turnsById.get(item.toolCall.turn_id) : undefined; } @@ -354,9 +342,16 @@ export function dedupeToolResults(results: ToolResult[]): ToolResult[] { ); } +export function dedupeThinkingSpans(thinkingSpans: ThinkingSpan[]): ThinkingSpan[] { + return [...new Map(thinkingSpans.map((thinking) => [thinking.id, thinking])).values()].sort( + (left, right) => left.created_at.localeCompare(right.created_at), + ); +} + export function timelineItems( sessionId: string, messages: SessionMessage[], + thinkingSpans: ThinkingSpan[], calls: ToolCall[], results: ToolResult[], ): TimelineItem[] { @@ -386,7 +381,14 @@ export function timelineItems( createdAt: message.relation.created_at, message, })); - return [...messageItems, ...callItems, ...orphanResults].sort((left, right) => + const thinkingItems: TimelineItem[] = thinkingSpans.map((thinking) => ({ + kind: "thinking", + id: thinking.id, + sessionId, + createdAt: thinking.created_at, + thinking, + })); + return [...messageItems, ...thinkingItems, ...callItems, ...orphanResults].sort((left, right) => left.createdAt.localeCompare(right.createdAt), ); } diff --git a/packages/mqueue/src/index.ts b/packages/mqueue/src/index.ts index 3c589f8..207a270 100644 --- a/packages/mqueue/src/index.ts +++ b/packages/mqueue/src/index.ts @@ -4,13 +4,16 @@ import { getSession, listMessages, listSessions, + type MaterialKind, runtimeSnapshot, - type SendSessionResponse, + type SendSessionAcceptedResponse, type SessionDetail, + type SessionMaterial, type SessionMessage, type SessionRuntimeSnapshot, type SessionSummary, sendSession, + sessionMaterial, updateSession, } from "@mini-stim/contracts"; @@ -45,41 +48,7 @@ import type { SessionSubOptions, } from "./types"; -export type { - Compact, - MessageConnectionState, - MessageDeltaPayload, - MessageEvent, - MessageMqueue, - MessagePart, - MessagePhase, - MessageProjection, - MqueueError, - PubAck, - SantiMqueue, - SantiWindow, - Session, - SessionAction, - SessionEffect, - SessionEvent, - SessionMessage, - SessionMqueue, - SessionPayloads, - SessionPhase, - SessionProfile, - SessionProjection, - SessionRuntimeSnapshot, - SessionSubOptions, - SessionSummary, - SoulProfile, - SoulSession, - TimelineItem, - ToolCall, - ToolResult, - Turn, - TurnGroup, - TurnStatus, -} from "./types"; +export type * from "./types"; declare global { interface Window { @@ -107,6 +76,7 @@ function createMqueueCore(target: Window): SantiMqueue { sessions: [], selectedSessionId: null, messages: [], + materialsBySessionId: {}, messagesBySessionId: {}, runtimeBySessionId: {}, pending: 0, @@ -117,6 +87,8 @@ function createMqueueCore(target: Window): SantiMqueue { timelineBySessionId: {}, turnTimelineBySessionId: {}, turnsBySessionId: {}, + turnActivityBySessionId: {}, + thinkingSpansBySessionId: {}, toolCallsBySessionId: {}, toolResultsBySessionId: {}, connectionBySessionId: {}, @@ -128,7 +100,9 @@ function createMqueueCore(target: Window): SantiMqueue { markTurnFailed, removeTransient, setMessageProjection, + setTurnActivity, upsertMessages, + upsertThinkingSpans, upsertTools, upsertTurns, } = createProjectionWriter(state, messageState); @@ -255,6 +229,7 @@ function createMqueueCore(target: Window): SantiMqueue { state.selectedSessionId = summarySessionId(session); state.messages = state.messagesBySessionId[summarySessionId(session)] ?? []; connectSessionEvents(summarySessionId(session)); + await ensureMaterial(summarySessionId(session), "system_prompt"); dispatchSession(target, sessionEvent(action, "committed", { session }, "http")); return; } @@ -267,6 +242,7 @@ function createMqueueCore(target: Window): SantiMqueue { state.messages = detail.messages; setMessageProjection(detail.session.id); connectSessionEvents(detail.session.id); + await ensureMaterial(detail.session.id, "system_prompt"); emitMessageProjection(detail.session.id); dispatchSession(target, sessionEvent(action, "committed", detail, "http")); return; @@ -284,6 +260,12 @@ function createMqueueCore(target: Window): SantiMqueue { dispatchSession(target, sessionEvent(action, "committed", { session }, "http")); return; } + case "material": { + const materialPayload = payload as SessionPayloads["material"]; + const material = await ensureMaterial(materialPayload.sessionId, materialPayload.kind); + dispatchSession(target, sessionEvent(action, "committed", { material }, "http")); + return; + } case "messages": { const messagesPayload = payload as SessionPayloads["messages"]; const messages = expectStatus( @@ -307,7 +289,9 @@ function createMqueueCore(target: Window): SantiMqueue { ) as SessionRuntimeSnapshot; state.runtimeBySessionId[runtimePayload.sessionId] = runtime; upsertTurns(runtimePayload.sessionId, runtime.turns); + upsertThinkingSpans(runtimePayload.sessionId, runtime.thinking_spans); upsertTools(runtimePayload.sessionId, runtime.tool_calls, runtime.tool_results); + await ensureMaterial(runtimePayload.sessionId, "system_prompt"); emitMessageProjection(runtimePayload.sessionId); dispatchSession(target, sessionEvent(action, "committed", runtime, "http")); return; @@ -344,15 +328,11 @@ function createMqueueCore(target: Window): SantiMqueue { const response = expectStatus( await sendSession(sessionId, { content: sendPayload.content }), 200, - ) as SendSessionResponse; + ) as SendSessionAcceptedResponse; upsertSession(response.session); state.selectedSessionId = summarySessionId(response.session); - upsertMessages(summarySessionId(response.session), [ - response.user_message, - response.assistant_message, - ]); + upsertMessages(summarySessionId(response.session), [response.user_message]); upsertTurns(summarySessionId(response.session), [response.turn]); - upsertTools(summarySessionId(response.session), response.tool_calls, response.tool_results); state.messages = state.messagesBySessionId[summarySessionId(response.session)]; emitMessageProjection(summarySessionId(response.session)); return response; @@ -402,6 +382,21 @@ function createMqueueCore(target: Window): SantiMqueue { emitProjection(); emitMessageProjection(sessionId); }, + thinkingCreated: (payload) => { + upsertThinkingSpans(sessionId, [payload.thinking]); + dispatchMessage(target, messageEvent("thinking_created", sessionId, payload, "sse")); + emitMessageProjection(sessionId); + }, + thinkingUpdated: (payload) => { + upsertThinkingSpans(sessionId, [payload.thinking]); + dispatchMessage(target, messageEvent("thinking_updated", sessionId, payload, "sse")); + emitMessageProjection(sessionId); + }, + thinkingCompleted: (payload) => { + upsertThinkingSpans(sessionId, [payload.thinking]); + dispatchMessage(target, messageEvent("thinking_completed", sessionId, payload, "sse")); + emitMessageProjection(sessionId); + }, toolCall: (payload) => { upsertTools(sessionId, [payload.tool_call], []); dispatchMessage(target, messageEvent("tool_call", sessionId, payload, "sse")); @@ -417,7 +412,19 @@ function createMqueueCore(target: Window): SantiMqueue { dispatchMessage(target, messageEvent("turn_started", sessionId, payload, "sse")); emitMessageProjection(sessionId); }, + turnActivity: (payload, createdAt) => { + const activity = { ...payload.activity, created_at: createdAt }; + const changed = setTurnActivity(sessionId, activity); + dispatchMessage(target, messageEvent("turn_activity", sessionId, activity, "sse")); + if (changed) { + emitMessageProjection(sessionId); + } + }, + materialUpdated: (payload) => { + void ensureMaterial(sessionId, payload.material.kind); + }, turnFailed: (payload) => { + removeTransient(sessionId, payload.turn_id); markTurnFailed(sessionId, payload.turn_id, payload.error); dispatchMessage( target, @@ -444,6 +451,19 @@ function createMqueueCore(target: Window): SantiMqueue { } } + async function ensureMaterial(sessionId: string, kind: MaterialKind): Promise { + const material = expectStatus( + await sessionMaterial(sessionId, { kind }), + 200, + ) as SessionMaterial; + state.materialsBySessionId[sessionId] = { + ...(state.materialsBySessionId[sessionId] ?? {}), + [kind]: material, + }; + emitProjection(); + return material; + } + function setConnection(sessionId: string, connection: MessageConnectionState) { messageState.connectionBySessionId[sessionId] = connection; dispatchMessage(target, messageEvent(connection, sessionId, { connection }, "mqueue")); diff --git a/packages/mqueue/src/lib/helpers/message.ts b/packages/mqueue/src/lib/helpers/message.ts new file mode 100644 index 0000000..ad2345e --- /dev/null +++ b/packages/mqueue/src/lib/helpers/message.ts @@ -0,0 +1,57 @@ +import type { MessageState, SessionMessage } from "@mini-stim/contracts"; + +import type { MessageDeltaPayload } from "../../types"; + +export const DEFAULT_ACTOR_ID = "account_local"; + +export function actorIdForRole(role: MessageDeltaPayload["role"]) { + return role === "soul" ? "soul_default" : role === "system" ? "santi" : DEFAULT_ACTOR_ID; +} + +export function transientMessage(sessionId: string, payload: MessageDeltaPayload): SessionMessage { + const createdAt = new Date().toISOString(); + return { + relation: { + session_id: sessionId, + message_id: payload.message_id, + session_seq: Number.MAX_SAFE_INTEGER, + created_at: createdAt, + }, + message: { + id: payload.message_id, + actor_type: payload.role, + actor_id: actorIdForRole(payload.role), + message_kind: "text", + content: { parts: [{ type: "text", text: payload.text }] }, + state: "pending" as MessageState, + version: 1, + deleted_at: null, + created_at: createdAt, + updated_at: createdAt, + }, + content_text: payload.text, + }; +} + +export function appendText(message: SessionMessage, text: string): SessionMessage { + const contentText = `${message.content_text}${text}`; + return { + ...message, + content_text: contentText, + message: { + ...message.message, + content: { parts: [{ type: "text", text: contentText }] }, + updated_at: new Date().toISOString(), + }, + }; +} + +export function dedupeMessages(messages: SessionMessage[]): SessionMessage[] { + const byId = new Map(); + for (const message of messages) { + byId.set(message.message.id, message); + } + return [...byId.values()].sort( + (left, right) => left.relation.session_seq - right.relation.session_seq, + ); +} diff --git a/packages/mqueue/src/projection.ts b/packages/mqueue/src/projection.ts index c20c19e..d77b156 100644 --- a/packages/mqueue/src/projection.ts +++ b/packages/mqueue/src/projection.ts @@ -1,6 +1,7 @@ import { appendText, dedupeMessages, + dedupeThinkingSpans, dedupeToolCalls, dedupeToolResults, dedupeTurns, @@ -13,9 +14,11 @@ import type { MessageProjection, SessionMessage, SessionProjection, + ThinkingSpan, ToolCall, ToolResult, Turn, + TurnActivityProjection, } from "./types"; /** @@ -29,6 +32,8 @@ export function createProjectionWriter(state: SessionProjection, messageState: M markTurnFailed, removeTransient, setMessageProjection, + upsertThinkingSpans, + setTurnActivity, upsertMessages, upsertTools, upsertTurns, @@ -98,6 +103,31 @@ export function createProjectionWriter(state: SessionProjection, messageState: M setMessageProjection(sessionId); } + function setTurnActivity(sessionId: string, activity: TurnActivityProjection): boolean { + const activities = messageState.turnActivityBySessionId[sessionId] ?? {}; + const existing = activities[activity.turn_id]; + if ( + existing?.state === activity.state && + existing.provider_response_id === activity.provider_response_id + ) { + return false; + } + messageState.turnActivityBySessionId[sessionId] = { + ...activities, + [activity.turn_id]: activity, + }; + setMessageProjection(sessionId); + return true; + } + + function upsertThinkingSpans(sessionId: string, thinkingSpans: ThinkingSpan[]) { + messageState.thinkingSpansBySessionId[sessionId] = dedupeThinkingSpans([ + ...(messageState.thinkingSpansBySessionId[sessionId] ?? []), + ...thinkingSpans, + ]); + setMessageProjection(sessionId); + } + function upsertTools(sessionId: string, calls: ToolCall[], results: ToolResult[]) { messageState.toolCallsBySessionId[sessionId] = dedupeToolCalls([ ...(messageState.toolCallsBySessionId[sessionId] ?? []), @@ -112,15 +142,17 @@ export function createProjectionWriter(state: SessionProjection, messageState: M function setMessageProjection(sessionId: string) { const messages = state.messagesBySessionId[sessionId] ?? []; + const thinkingSpans = messageState.thinkingSpansBySessionId[sessionId] ?? []; const calls = messageState.toolCallsBySessionId[sessionId] ?? []; const results = messageState.toolResultsBySessionId[sessionId] ?? []; messageState.messagesBySessionId[sessionId] = messages; - const timeline = timelineItems(sessionId, messages, calls, results); + const timeline = timelineItems(sessionId, messages, thinkingSpans, calls, results); messageState.timelineBySessionId[sessionId] = timeline; messageState.turnTimelineBySessionId[sessionId] = turnGroups( sessionId, timeline, messageState.turnsBySessionId[sessionId] ?? [], + messageState.turnActivityBySessionId[sessionId] ?? {}, ); } } diff --git a/packages/mqueue/src/stream.ts b/packages/mqueue/src/stream.ts index 2948826..59cf08e 100644 --- a/packages/mqueue/src/stream.ts +++ b/packages/mqueue/src/stream.ts @@ -1,11 +1,14 @@ import { parseStreamEvent } from "./events"; import type { + MaterialUpdated, MessageDeltaPayload, SessionMessage, StreamPayload, + ThinkingSpan, ToolCall, ToolResult, Turn, + TurnActivity, } from "./types"; export interface SessionStreamHandlers { @@ -18,9 +21,14 @@ export interface SessionStreamHandlers { turn_id: string; message: SessionMessage; }): void; + thinkingCreated(payload: { type: "thinking_created"; thinking: ThinkingSpan }): void; + thinkingUpdated(payload: { type: "thinking_updated"; thinking: ThinkingSpan }): void; + thinkingCompleted(payload: { type: "thinking_completed"; thinking: ThinkingSpan }): void; + materialUpdated(payload: { type: "material_updated"; material: MaterialUpdated }): void; toolCall(payload: { type: "tool_call_created"; tool_call: ToolCall }): void; toolResult(payload: { type: "tool_result_created"; tool_result: ToolResult }): void; turnStarted(payload: { type: "turn_started"; turn: Turn }): void; + turnActivity(payload: { type: "turn_activity"; activity: TurnActivity }, createdAt: string): void; turnFailed(payload: { type: "turn_failed"; turn_id: string; error: string }): void; } @@ -31,9 +39,14 @@ export function openSessionStream(sessionId: string, handlers: SessionStreamHand listen(source, "message_created", "message_created", handlers.messageCreated); listen(source, "message_delta", "message_delta", handlers.messageDelta); listen(source, "message_completed", "message_completed", handlers.messageCompleted); + listen(source, "thinking_created", "thinking_created", handlers.thinkingCreated); + listen(source, "thinking_updated", "thinking_updated", handlers.thinkingUpdated); + listen(source, "thinking_completed", "thinking_completed", handlers.thinkingCompleted); + listen(source, "material_updated", "material_updated", handlers.materialUpdated); listen(source, "tool_call_created", "tool_call_created", handlers.toolCall); listen(source, "tool_result_created", "tool_result_created", handlers.toolResult); listen(source, "turn_started", "turn_started", handlers.turnStarted); + listen(source, "turn_activity", "turn_activity", handlers.turnActivity); listen(source, "turn_failed", "turn_failed", handlers.turnFailed); return source; } @@ -42,12 +55,12 @@ function listen( source: EventSource, eventName: string, type: Type, - handler: (payload: Extract) => void, + handler: (payload: Extract, createdAt: string) => void, ) { source.addEventListener(eventName, (raw) => { const stream = parseStreamEvent(raw); if (stream?.payload.type === type) { - handler(stream.payload as Extract); + handler(stream.payload as Extract, stream.created_at); } }); } diff --git a/packages/mqueue/src/types.ts b/packages/mqueue/src/types.ts index 9150c8b..a5f0705 100644 --- a/packages/mqueue/src/types.ts +++ b/packages/mqueue/src/types.ts @@ -1,28 +1,41 @@ import type { + MaterialKind, + MaterialUpdated, MessagePart, + SessionMaterial, SessionMessage, SessionRuntimeSnapshot, SessionSummary, + ThinkingSpan, ToolCall, ToolResult, Turn, + TurnActivity, UpdateSessionRequest, } from "@mini-stim/contracts"; export type { Compact, + MaterialKind, + MaterialUpdated, MessagePart, Session, SessionEffect, + SessionMaterial, SessionMessage, SessionProfile, SessionRuntimeSnapshot, SessionSummary, SoulProfile, SoulSession, + ThinkingCompletionReason, + ThinkingSpan, + ThinkingSpanState, ToolCall, ToolResult, Turn, + TurnActivity, + TurnActivityState, TurnStatus, } from "@mini-stim/contracts"; @@ -30,6 +43,7 @@ export type SessionAction = | "create" | "get" | "list" + | "material" | "messages" | "runtime" | "select" @@ -42,6 +56,7 @@ export interface SessionPayloads { create: undefined; get: { sessionId: string }; list: undefined; + material: { kind: MaterialKind; sessionId: string }; messages: { sessionId: string }; runtime: { sessionId: string }; select: { sessionId: string | null }; @@ -59,6 +74,7 @@ export interface SessionProjection { sessions: SessionSummary[]; selectedSessionId: string | null; messages: SessionMessage[]; + materialsBySessionId: Record>>; messagesBySessionId: Record; runtimeBySessionId: Record; pending: number; @@ -73,19 +89,29 @@ export type MessagePhase = | "created" | "delta" | "completed" + | "thinking_created" + | "thinking_updated" + | "thinking_completed" | "tool_call" | "tool_result" | "turn_started" + | "turn_activity" | "failed" | "projection"; export type MessageConnectionState = "closed" | "connecting" | "open" | "error"; +export interface TurnActivityProjection extends TurnActivity { + created_at: string; +} + export interface MessageProjection { messagesBySessionId: Record; timelineBySessionId: Record; turnTimelineBySessionId: Record; turnsBySessionId: Record; + turnActivityBySessionId: Record>; + thinkingSpansBySessionId: Record; toolCallsBySessionId: Record; toolResultsBySessionId: Record; connectionBySessionId: Record; @@ -99,6 +125,7 @@ export interface TurnGroup { createdAt: string; /** Absent for the fallback group holding items with no resolvable turn. */ turn?: Turn; + activity?: TurnActivityProjection; items: TimelineItem[]; } @@ -110,6 +137,13 @@ export type TimelineItem = createdAt: string; message: SessionMessage; } + | { + kind: "thinking"; + id: string; + sessionId: string; + createdAt: string; + thinking: ThinkingSpan; + } | { kind: "tool_call"; id: string; @@ -210,9 +244,14 @@ export type StreamPayload = | { type: "message_created"; message: SessionMessage } | MessageDeltaPayload | { type: "message_completed"; turn_id: string; message: SessionMessage } + | { type: "thinking_created"; thinking: ThinkingSpan } + | { type: "thinking_updated"; thinking: ThinkingSpan } + | { type: "thinking_completed"; thinking: ThinkingSpan } + | { type: "material_updated"; material: MaterialUpdated } | { type: "tool_call_created"; tool_call: ToolCall } | { type: "tool_result_created"; tool_result: ToolResult } | { type: "turn_started"; turn: Turn } + | { type: "turn_activity"; activity: TurnActivity } | { type: "turn_failed"; turn_id: string; error: string }; export interface MessageDeltaPayload { diff --git a/santi.example.toml b/santi.example.toml new file mode 100644 index 0000000..93519a1 --- /dev/null +++ b/santi.example.toml @@ -0,0 +1,25 @@ +provider = "openai" + +[providers.openai] +kind = "openai_responses" +api_key = "" +model = "" +base_url = "https://api.openai.com/v1" +reasoning_effort = "" +reasoning_summary = "" + +[providers.deepseek] +kind = "chat_completions" +api_key = "" +model = "deepseek-v4-pro" +base_url = "https://api.deepseek.com" +thinking = "" +reasoning_effort = "" + +[providers.siliconflow] +kind = "chat_completions" +api_key = "" +model = "zai-org/GLM-5.2" +base_url = "https://api.siliconflow.cn/v1" +thinking = "" +reasoning_effort = ""