From c928d20a1ae36e2cc27bf63207eb4aad6086eaab Mon Sep 17 00:00:00 2001 From: PerishCode Date: Tue, 23 Jun 2026 13:22:14 +0800 Subject: [PATCH 1/3] chat: add durable thinking summaries --- .env.example | 1 + AGENTS.md | 1 + README.md | 1 + apps/client/soma/web/src/App.tsx | 13 + .../soma/web/src/components/ChatShell.tsx | 63 +++- .../soma/web/src/components/Composer.tsx | 5 +- .../soma/web/src/components/InspectPanel.tsx | 3 + .../web/src/components/TimelineItemView.tsx | 141 +++++++++ .../soma/web/src/components/Transcript.tsx | 24 +- apps/client/soma/web/src/events/inspect.ts | 3 + apps/server/soma/crates/santi-api/src/main.rs | 9 + .../soma/crates/santi-core/src/model.rs | 62 ++++ .../soma/crates/santi-core/src/service.rs | 268 +++++++----------- .../crates/santi-core/src/service/thinking.rs | 131 +++++++++ .../crates/santi-core/src/service/tools.rs | 163 +++++++++++ .../soma/crates/santi-core/src/store.rs | 8 +- .../soma/crates/santi-core/src/store/db.rs | 121 ++------ .../santi-core/src/store/db/timeline.rs | 152 ++++++++++ .../soma/crates/santi-core/src/store/rows.rs | 56 +++- .../crates/santi-core/src/store/runtime.rs | 147 +++++++++- .../crates/santi-core/src/store/schema.rs | 23 +- .../soma/crates/santi-core/tests/store.rs | 71 ++++- .../soma/crates/santi-provider/src/openai.rs | 101 ++++++- .../crates/santi-provider/src/provider.rs | 8 + .../crates/santi-provider/tests/openai.rs | 49 +++- packages/components/src/atoms/Panel/Panel.tsx | 6 +- packages/contracts/openapi.json | 126 ++++++++ packages/contracts/src/openapi.ts | 54 ++++ packages/hooks/src/index.tsx | 31 +- packages/mqueue/src/events.ts | 59 +++- packages/mqueue/src/index.ts | 35 +++ packages/mqueue/src/projection.ts | 34 ++- packages/mqueue/src/stream.ts | 14 +- packages/mqueue/src/types.ts | 29 ++ 34 files changed, 1701 insertions(+), 311 deletions(-) create mode 100644 apps/server/soma/crates/santi-core/src/service/thinking.rs create mode 100644 apps/server/soma/crates/santi-core/src/service/tools.rs create mode 100644 apps/server/soma/crates/santi-core/src/store/db/timeline.rs diff --git a/.env.example b/.env.example index 0393fff..96d73c7 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ OPENAI_API_KEY= OPENAI_MODEL= OPENAI_RESPONSES_BASE_URL=https://api.openai.com/v1 OPENAI_REASONING_EFFORT= +OPENAI_REASONING_SUMMARY= OPENAI_MAX_OUTPUT_TOKENS= diff --git a/AGENTS.md b/AGENTS.md index a9a3199..a3674ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -395,6 +395,7 @@ OPENAI_API_KEY= OPENAI_MODEL= OPENAI_RESPONSES_BASE_URL=https://api.openai.com/v1 OPENAI_REASONING_EFFORT= +OPENAI_REASONING_SUMMARY= OPENAI_MAX_OUTPUT_TOKENS= ``` diff --git a/README.md b/README.md index 2150ab3..a073509 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ OPENAI_API_KEY= OPENAI_MODEL= OPENAI_RESPONSES_BASE_URL=https://api.openai.com/v1 OPENAI_REASONING_EFFORT= +OPENAI_REASONING_SUMMARY= OPENAI_MAX_OUTPUT_TOKENS= ``` 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..e015e0c 100644 --- a/apps/client/soma/web/src/components/InspectPanel.tsx +++ b/apps/client/soma/web/src/components/InspectPanel.tsx @@ -89,6 +89,7 @@ function MessageInspectDomain(props: InspectDomainPanelProps) { return ; case "session": case "turn": + case "thinking": case "tool_call": case "tool_result": case undefined: @@ -103,6 +104,7 @@ function ToolCallInspectDomain(props: InspectDomainPanelProps) { return ; case "message": case "session": + case "thinking": case "turn": case undefined: return ; @@ -117,6 +119,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..bc4736c 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; @@ -97,6 +144,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 +210,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 +231,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 +358,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..0ef1fe8 100644 --- a/apps/client/soma/web/src/components/Transcript.tsx +++ b/apps/client/soma/web/src/components/Transcript.tsx @@ -8,6 +8,7 @@ import { 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 +21,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 +40,7 @@ export function Transcript(props: { soulIdentity: SoulIdentity; timeline: TurnGr }); return ( - + {groups.map((group) => ( (...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 ( diff --git a/apps/client/soma/web/src/events/inspect.ts b/apps/client/soma/web/src/events/inspect.ts index ad887b4..1b624fe 100644 --- a/apps/client/soma/web/src/events/inspect.ts +++ b/apps/client/soma/web/src/events/inspect.ts @@ -2,6 +2,7 @@ export type InspectTarget = | { kind: "session"; sessionId: string } | { kind: "turn"; sessionId: string; turnId: string } | { kind: "message"; sessionId: string; messageId: string } + | { kind: "thinking"; sessionId: string; thinkingId: string } | { kind: "tool_call"; sessionId: string; toolCallId: string } | { kind: "tool_result"; sessionId: string; toolResultId: string }; @@ -113,6 +114,8 @@ function isInspectTarget(value: unknown): value is InspectTarget { return typeof target.sessionId === "string" && typeof target.turnId === "string"; case "message": return typeof target.sessionId === "string" && typeof target.messageId === "string"; + case "thinking": + return typeof target.sessionId === "string" && typeof target.thinkingId === "string"; case "tool_call": return typeof target.sessionId === "string" && typeof target.toolCallId === "string"; case "tool_result": diff --git a/apps/server/soma/crates/santi-api/src/main.rs b/apps/server/soma/crates/santi-api/src/main.rs index 1fc46ad..8a6d982 100644 --- a/apps/server/soma/crates/santi-api/src/main.rs +++ b/apps/server/soma/crates/santi-api/src/main.rs @@ -51,6 +51,7 @@ async fn serve() -> Result<(), 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"), + reasoning_summary: optional_env("OPENAI_REASONING_SUMMARY"), max_output_tokens: optional_env("OPENAI_MAX_OUTPUT_TOKENS") .map(|value| { value @@ -346,7 +347,11 @@ 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::TurnStarted { .. } => "turn_started", + SantiStreamPayload::TurnActivity { .. } => "turn_activity", SantiStreamPayload::TurnFailed { .. } => "turn_failed", } } @@ -446,9 +451,13 @@ impl IntoResponse for ApiError { 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-core/src/model.rs b/apps/server/soma/crates/santi-core/src/model.rs index 06db99e..a3fd1f6 100644 --- a/apps/server/soma/crates/santi-core/src/model.rs +++ b/apps/server/soma/crates/santi-core/src/model.rs @@ -222,6 +222,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 +282,7 @@ pub struct SessionEffect { pub enum SoulSessionTargetType { Message, Compact, + Thinking, ToolCall, ToolResult, } @@ -310,6 +341,7 @@ pub struct SendSessionResponse { pub turn: Turn, pub user_message: SessionMessage, pub assistant_message: SessionMessage, + pub thinking_spans: Vec, pub tool_calls: Vec, pub tool_results: Vec, } @@ -322,6 +354,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 +394,21 @@ pub enum SantiStreamPayload { ToolResultCreated { tool_result: ToolResult, }, + ThinkingCreated { + thinking: ThinkingSpan, + }, + ThinkingUpdated { + thinking: ThinkingSpan, + }, + ThinkingCompleted { + thinking: ThinkingSpan, + }, TurnStarted { turn: Turn, }, + TurnActivity { + activity: TurnActivity, + }, TurnFailed { turn_id: String, error: String, @@ -362,6 +423,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, diff --git a/apps/server/soma/crates/santi-core/src/service.rs b/apps/server/soma/crates/santi-core/src/service.rs index 6cbda71..5416127 100644 --- a/apps/server/soma/crates/santi-core/src/service.rs +++ b/apps/server/soma/crates/santi-core/src/service.rs @@ -1,11 +1,9 @@ +mod thinking; +mod tools; + use futures_util::StreamExt; -use santi_provider::{ - FunctionCallOutput, ProviderClient, ProviderEvent, ProviderFunctionCall, ProviderMessage, - ProviderRequest, -}; -use serde::Deserialize; -use serde_json::{Value, json}; -use std::{path::PathBuf, process::Command, sync::Arc}; +use santi_provider::{ProviderClient, ProviderEvent, ProviderMessage, ProviderRequest}; +use std::sync::Arc; use tokio::sync::broadcast; use crate::service_prompt::{ @@ -14,7 +12,8 @@ use crate::service_prompt::{ use crate::{ ActorType, CreateSessionResponse, MessageContent, MessageState, SantiStore, SantiStreamEvent, SantiStreamPayload, SendSessionRequest, SendSessionResponse, SessionDetail, - SessionRuntimeSnapshot, SessionSummary, UpdateSessionRequest, prefixed_id, timestamp_now, + SessionRuntimeSnapshot, SessionSummary, ThinkingCompletionReason, ThinkingSpan, + TurnActivityState, UpdateSessionRequest, prefixed_id, timestamp_now, }; #[derive(Clone)] @@ -212,6 +211,7 @@ impl SantiService { turn: completed_turn, user_message, assistant_message, + thinking_spans: self.store.thinking_spans_for_turn(&turn.id)?, tool_calls: self.store.tool_calls_for_turn(&turn.id)?, tool_results: self.store.tool_results_for_turn(&turn.id)?, }) @@ -249,13 +249,79 @@ impl SantiService { Some(function_call_outputs.clone()) }, }; + self.publish_turn_activity(session_id, turn_id, TurnActivityState::Requesting, None); let mut stream = self.provider.stream_response(request).await?; 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(); while let Some(event) = stream.next().await { - match event? { + let event = match event { + Ok(event) => event, + Err(error) => { + self.fail_current_thinking_span( + session_id, + &mut current_thinking_span, + error.clone(), + )?; + return Err(error); + } + }; + match event { + ProviderEvent::ResponseStarted { + provider_response_id, + } + | ProviderEvent::ResponseInProgress { + provider_response_id, + } => { + active_provider_response_id = provider_response_id.clone(); + 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, + turn_id, + TurnActivityState::Thinking, + provider_response_id, + ); + } + ProviderEvent::ReasoningSummaryDelta(delta) => { + reasoning_summary.push_str(&delta); + self.update_thinking_span_summary( + session_id, + &mut summary_thinking_span, + reasoning_summary.clone(), + )?; + } + ProviderEvent::ReasoningSummaryDone(summary) => { + reasoning_summary = summary; + self.update_thinking_span_summary( + session_id, + &mut summary_thinking_span, + reasoning_summary.clone(), + )?; + } ProviderEvent::TextDelta(delta) => { + if assistant_text.is_empty() { + self.complete_current_thinking_span( + session_id, + &mut current_thinking_span, + ThinkingCompletionReason::FirstTextDelta, + )?; + self.publish_turn_activity( + session_id, + turn_id, + TurnActivityState::Generating, + active_provider_response_id.clone(), + ); + } assistant_text.push_str(&delta); self.publish_stream( session_id, @@ -268,15 +334,39 @@ impl SantiService { ); } ProviderEvent::FunctionCallRequested(call) => { + 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, } => { + active_provider_response_id = provider_response_id.clone(); + 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) => { + self.fail_current_thinking_span( + session_id, + &mut current_thinking_span, + error.clone(), + )?; + return Err(error); + } } } @@ -286,6 +376,12 @@ impl SantiService { let mut outputs = Vec::new(); for call in calls { + self.publish_turn_activity( + session_id, + turn_id, + TurnActivityState::RunningTool, + active_provider_response_id.clone(), + ); outputs.push(self.handle_tool_call(session_id, soul_session_id, turn_id, call)?); } function_call_outputs.extend(outputs); @@ -339,156 +435,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/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/tools.rs b/apps/server/soma/crates/santi-core/src/service/tools.rs new file mode 100644 index 0000000..1c465e2 --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/service/tools.rs @@ -0,0 +1,163 @@ +use std::{path::PathBuf, process::Command}; + +use santi_provider::{FunctionCallOutput, ProviderFunctionCall}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::SantiStreamPayload; + +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())?, + }) + } + + 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(), + })) + } + + 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 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/store.rs b/apps/server/soma/crates/santi-core/src/store.rs index d805a30..1c49467 100644 --- a/apps/server/soma/crates/santi-core/src/store.rs +++ b/apps/server/soma/crates/santi-core/src/store.rs @@ -19,7 +19,7 @@ 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 = 6; const DEFAULT_ACCOUNT_ID: &str = "account_local"; const DEFAULT_SOUL_ID: &str = "soul_default"; @@ -71,6 +71,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; @@ -237,6 +238,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 { 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..befa9fe 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, + Soul, 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 @@ -331,105 +334,23 @@ pub(super) fn tool_result_by_id( .map_err(|error| error.to_string()) } -pub(super) fn turns_for_soul_session( +pub(super) fn thinking_span_by_id( 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( - 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( 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..782af5f 100644 --- a/apps/server/soma/crates/santi-core/src/store/rows.rs +++ b/apps/server/soma/crates/santi-core/src/store/rows.rs @@ -4,7 +4,8 @@ use serde_json::Value; use crate::{ ActorType, Compact, Message, MessageContent, 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 { @@ -148,6 +149,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)?, @@ -234,10 +253,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..3c6808f 100644 --- a/apps/server/soma/crates/santi-core/src/store/runtime.rs +++ b/apps/server/soma/crates/santi-core/src/store/runtime.rs @@ -5,13 +5,14 @@ use super::{ DEFAULT_SOUL_ID, 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, + soul_session_by_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, + Soul, SoulSession, SoulSessionEntry, SoulSessionTargetType, ThinkingCompletionReason, + ThinkingSpan, ThinkingSpanState, ToolCall, ToolResult, Turn, prefixed_id, timestamp_now, }; impl SantiStore { @@ -57,13 +58,111 @@ impl SantiStore { }); } } - "tool_call" | "tool_result" => {} + "thinking" | "tool_call" | "tool_result" => {} _ => {} } } Ok(input) } + pub fn append_thinking_span( + &self, + 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 + ) + 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( &self, turn_id: &str, @@ -229,6 +328,11 @@ impl SantiStore { 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 +350,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..e85d843 100644 --- a/apps/server/soma/crates/santi-core/src/store/schema.rs +++ b/apps/server/soma/crates/santi-core/src/store/schema.rs @@ -137,6 +137,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 +169,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 +193,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/tests/store.rs b/apps/server/soma/crates/santi-core/tests/store.rs index a749613..10ef323 100644 --- a/apps/server/soma/crates/santi-core/tests/store.rs +++ b/apps/server/soma/crates/santi-core/tests/store.rs @@ -1,5 +1,5 @@ use rusqlite::Connection; -use santi_core::{ActorType, MessageContent, MessageState, SantiStore}; +use santi_core::{ActorType, MessageContent, MessageState, SantiStore, ThinkingCompletionReason}; #[test] fn schema_matches_runtime() { @@ -23,6 +23,7 @@ fn schema_matches_runtime() { "turns", "tool_calls", "tool_results", + "thinking_spans", "compacts", "r_soul_session_messages", ] { @@ -70,6 +71,74 @@ fn appends_relations_in_order() { assert_eq!(input[0].content, "hello ordering"); } +#[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-provider/src/openai.rs b/apps/server/soma/crates/santi-provider/src/openai.rs index dc27f21..01c46c2 100644 --- a/apps/server/soma/crates/santi-provider/src/openai.rs +++ b/apps/server/soma/crates/santi-provider/src/openai.rs @@ -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)); @@ -196,11 +215,21 @@ fn parse_event( ) -> 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 +237,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 +268,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 +318,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") @@ -305,6 +374,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..a6cd19c 100644 --- a/apps/server/soma/crates/santi-provider/src/provider.rs +++ b/apps/server/soma/crates/santi-provider/src/provider.rs @@ -58,6 +58,14 @@ pub struct ProviderMetadata { #[derive(Debug, Clone, PartialEq)] pub enum ProviderEvent { + 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/openai.rs b/apps/server/soma/crates/santi-provider/tests/openai.rs index 6cb2c69..42a3073 100644 --- a/apps/server/soma/crates/santi-provider/tests/openai.rs +++ b/apps/server/soma/crates/santi-provider/tests/openai.rs @@ -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,53 @@ 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." + )); +} + 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")); @@ -181,6 +225,7 @@ async fn capture_events(lines: Vec<&'static str>) -> Vec { 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 || { 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..d68e9cc 100644 --- a/packages/contracts/openapi.json +++ b/packages/contracts/openapi.json @@ -641,6 +641,7 @@ "turn", "user_message", "assistant_message", + "thinking_spans", "tool_calls", "tool_results" ], @@ -657,6 +658,12 @@ "soul_session": { "$ref": "#/components/schemas/SoulSession" }, + "thinking_spans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThinkingSpan" + } + }, "tool_calls": { "type": "array", "items": { @@ -867,6 +874,7 @@ "profile", "messages", "turns", + "thinking_spans", "tool_calls", "tool_results", "compacts", @@ -917,6 +925,12 @@ } ] }, + "thinking_spans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThinkingSpan" + } + }, "tool_calls": { "type": "array", "items": { @@ -1049,6 +1063,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 +1265,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..0c8522d 100644 --- a/packages/contracts/src/openapi.ts +++ b/packages/contracts/src/openapi.ts @@ -141,6 +141,40 @@ export interface SoulSession { 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; @@ -197,6 +231,7 @@ export interface SendSessionResponse { session: SessionSummary; soul_profile: SoulProfile; soul_session: SoulSession; + thinking_spans: ThinkingSpan[]; tool_calls: ToolCall[]; tool_results: ToolResult[]; turn: Turn; @@ -233,11 +268,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; diff --git a/packages/hooks/src/index.tsx b/packages/hooks/src/index.tsx index 3e2d5d4..6650a5c 100644 --- a/packages/hooks/src/index.tsx +++ b/packages/hooks/src/index.tsx @@ -1,6 +1,7 @@ import { installSantiMqueue, type MessageConnectionState, + type MessageEvent, type MessagePart, type MessageProjection, type MqueueError, @@ -38,8 +39,14 @@ export type { SessionRuntimeSnapshot, SessionSummary, SoulSession, + ThinkingCompletionReason, + ThinkingSpan, + ThinkingSpanState, TimelineItem, Turn, + TurnActivity, + TurnActivityProjection, + TurnActivityState, TurnGroup, TurnStatus, } from "@mini-stim/mqueue"; @@ -119,7 +126,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) { @@ -194,7 +201,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 +222,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( diff --git a/packages/mqueue/src/events.ts b/packages/mqueue/src/events.ts index e9d7ee7..4295660 100644 --- a/packages/mqueue/src/events.ts +++ b/packages/mqueue/src/events.ts @@ -2,6 +2,7 @@ import type { ErrorResponse, MessageState, SessionMessage, + ThinkingSpan, ToolCall, ToolResult, Turn, @@ -20,6 +21,7 @@ import type { SessionProjection, StreamEvent, TimelineItem, + TurnActivityProjection, TurnGroup, } from "./types"; @@ -183,36 +185,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 +258,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 +273,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 +293,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 +340,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 +381,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 +420,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..485eaa0 100644 --- a/packages/mqueue/src/index.ts +++ b/packages/mqueue/src/index.ts @@ -73,10 +73,16 @@ export type { SessionSummary, SoulProfile, SoulSession, + ThinkingCompletionReason, + ThinkingSpan, + ThinkingSpanState, TimelineItem, ToolCall, ToolResult, Turn, + TurnActivity, + TurnActivityProjection, + TurnActivityState, TurnGroup, TurnStatus, } from "./types"; @@ -117,6 +123,8 @@ function createMqueueCore(target: Window): SantiMqueue { timelineBySessionId: {}, turnTimelineBySessionId: {}, turnsBySessionId: {}, + turnActivityBySessionId: {}, + thinkingSpansBySessionId: {}, toolCallsBySessionId: {}, toolResultsBySessionId: {}, connectionBySessionId: {}, @@ -128,7 +136,9 @@ function createMqueueCore(target: Window): SantiMqueue { markTurnFailed, removeTransient, setMessageProjection, + setTurnActivity, upsertMessages, + upsertThinkingSpans, upsertTools, upsertTurns, } = createProjectionWriter(state, messageState); @@ -307,6 +317,7 @@ 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); emitMessageProjection(runtimePayload.sessionId); dispatchSession(target, sessionEvent(action, "committed", runtime, "http")); @@ -352,6 +363,7 @@ function createMqueueCore(target: Window): SantiMqueue { response.assistant_message, ]); upsertTurns(summarySessionId(response.session), [response.turn]); + upsertThinkingSpans(summarySessionId(response.session), response.thinking_spans); upsertTools(summarySessionId(response.session), response.tool_calls, response.tool_results); state.messages = state.messagesBySessionId[summarySessionId(response.session)]; emitMessageProjection(summarySessionId(response.session)); @@ -402,6 +414,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,6 +444,14 @@ 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); + } + }, turnFailed: (payload) => { markTurnFailed(sessionId, payload.turn_id, payload.error); dispatchMessage( 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..219a9c0 100644 --- a/packages/mqueue/src/stream.ts +++ b/packages/mqueue/src/stream.ts @@ -3,9 +3,11 @@ import type { MessageDeltaPayload, SessionMessage, StreamPayload, + ThinkingSpan, ToolCall, ToolResult, Turn, + TurnActivity, } from "./types"; export interface SessionStreamHandlers { @@ -18,9 +20,13 @@ 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; 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 +37,13 @@ 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, "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 +52,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..a9434eb 100644 --- a/packages/mqueue/src/types.ts +++ b/packages/mqueue/src/types.ts @@ -3,9 +3,11 @@ import type { SessionMessage, SessionRuntimeSnapshot, SessionSummary, + ThinkingSpan, ToolCall, ToolResult, Turn, + TurnActivity, UpdateSessionRequest, } from "@mini-stim/contracts"; @@ -20,9 +22,14 @@ export type { SessionSummary, SoulProfile, SoulSession, + ThinkingCompletionReason, + ThinkingSpan, + ThinkingSpanState, ToolCall, ToolResult, Turn, + TurnActivity, + TurnActivityState, TurnStatus, } from "@mini-stim/contracts"; @@ -73,19 +80,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 +116,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 +128,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 +235,13 @@ 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: "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 { From 1778333379418774d832e6b2739c079dca11836b Mon Sep 17 00:00:00 2001 From: PerishCode Date: Tue, 23 Jun 2026 18:22:46 +0800 Subject: [PATCH 2/3] core: add soul identity and provider continuations --- .env.example | 9 + AGENTS.md | 28 +- README.md | 21 +- .../soma/web/src/components/InspectPanel.tsx | 19 +- .../InspectPanel/SessionInspectPanel.tsx | 27 +- apps/server/soma/crates/santi-api/src/main.rs | 77 +-- .../soma/crates/santi-api/src/provider.rs | 59 +++ .../crates/santi-core/src/assembly/input.rs | 17 + .../crates/santi-core/src/assembly/mod.rs | 2 + .../santi-core/src/assembly/system_prompt.rs | 174 +++++++ apps/server/soma/crates/santi-core/src/lib.rs | 1 + .../soma/crates/santi-core/src/model.rs | 51 +- .../soma/crates/santi-core/src/service.rs | 307 ++++++------ .../santi-core/src/service/materials.rs | 99 ++++ .../santi-core/src/service/text_delta.rs | 51 ++ .../crates/santi-core/src/service/timing.rs | 208 +++++++++ .../crates/santi-core/src/service/tools.rs | 72 ++- .../crates/santi-core/src/service_prompt.rs | 97 +--- .../soma/crates/santi-core/src/store.rs | 7 +- .../crates/santi-core/src/store/assembly.rs | 57 +++ .../soma/crates/santi-core/src/store/db.rs | 23 +- .../soma/crates/santi-core/src/store/rows.rs | 13 +- .../crates/santi-core/src/store/runtime.rs | 87 +--- .../crates/santi-core/src/store/schema.rs | 1 + .../soma/crates/santi-core/tests/service.rs | 96 +++- .../crates/santi-core/tests/system_prompt.rs | 142 ++++++ .../crates/santi-provider/src/deepseek.rs | 442 ++++++++++++++++++ .../soma/crates/santi-provider/src/lib.rs | 2 + .../soma/crates/santi-provider/src/openai.rs | 44 +- .../crates/santi-provider/src/provider.rs | 14 + .../crates/santi-provider/tests/deepseek.rs | 410 ++++++++++++++++ .../crates/santi-provider/tests/openai.rs | 54 ++- docs/README.md | 4 +- packages/contracts/openapi.json | 184 ++++++-- packages/contracts/src/openapi.ts | 224 ++++++--- packages/hooks/src/index.tsx | 19 + packages/mqueue/src/events.ts | 10 + packages/mqueue/src/index.ts | 82 ++-- packages/mqueue/src/stream.ts | 3 + packages/mqueue/src/types.ts | 10 + 40 files changed, 2639 insertions(+), 608 deletions(-) create mode 100644 apps/server/soma/crates/santi-api/src/provider.rs create mode 100644 apps/server/soma/crates/santi-core/src/assembly/input.rs create mode 100644 apps/server/soma/crates/santi-core/src/assembly/mod.rs create mode 100644 apps/server/soma/crates/santi-core/src/assembly/system_prompt.rs create mode 100644 apps/server/soma/crates/santi-core/src/service/materials.rs create mode 100644 apps/server/soma/crates/santi-core/src/service/text_delta.rs create mode 100644 apps/server/soma/crates/santi-core/src/service/timing.rs create mode 100644 apps/server/soma/crates/santi-core/src/store/assembly.rs create mode 100644 apps/server/soma/crates/santi-core/tests/system_prompt.rs create mode 100644 apps/server/soma/crates/santi-provider/src/deepseek.rs create mode 100644 apps/server/soma/crates/santi-provider/tests/deepseek.rs diff --git a/.env.example b/.env.example index 96d73c7..c8fd5b7 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,15 @@ +SANTI_PROVIDER=openai + OPENAI_API_KEY= OPENAI_MODEL= OPENAI_RESPONSES_BASE_URL=https://api.openai.com/v1 OPENAI_REASONING_EFFORT= OPENAI_REASONING_SUMMARY= OPENAI_MAX_OUTPUT_TOKENS= + +DEEPSEEK_API_KEY= +DEEPSEEK_MODEL=deepseek-v4-pro +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_THINKING= +DEEPSEEK_REASONING_EFFORT= +DEEPSEEK_MAX_TOKENS= diff --git a/AGENTS.md b/AGENTS.md index a3674ee..73a533c 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 @@ -113,8 +114,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,7 +389,13 @@ surface before considering any alternate browser layer. ## Environment -`.env` is local and ignored by git. Required OpenAI settings: +`.env` is local and ignored by git. Select the model provider with: + +```text +SANTI_PROVIDER=openai +``` + +OpenAI settings: ```text OPENAI_API_KEY= @@ -399,6 +406,17 @@ OPENAI_REASONING_SUMMARY= OPENAI_MAX_OUTPUT_TOKENS= ``` +DeepSeek settings: + +```text +DEEPSEEK_API_KEY= +DEEPSEEK_MODEL=deepseek-v4-pro +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_THINKING= +DEEPSEEK_REASONING_EFFORT= +DEEPSEEK_MAX_TOKENS= +``` + Server soma settings: ```text diff --git a/README.md b/README.md index a073509..05ccaf8 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: @@ -85,12 +85,21 @@ names its unlock condition so deferral stays distinguishable from neglect: Create `.env` from `.env.example` and fill: ```text +SANTI_PROVIDER=openai + OPENAI_API_KEY= OPENAI_MODEL= OPENAI_RESPONSES_BASE_URL=https://api.openai.com/v1 OPENAI_REASONING_EFFORT= OPENAI_REASONING_SUMMARY= OPENAI_MAX_OUTPUT_TOKENS= + +DEEPSEEK_API_KEY= +DEEPSEEK_MODEL=deepseek-v4-pro +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_THINKING= +DEEPSEEK_REASONING_EFFORT= +DEEPSEEK_MAX_TOKENS= ``` Install dependencies: diff --git a/apps/client/soma/web/src/components/InspectPanel.tsx b/apps/client/soma/web/src/components/InspectPanel.tsx index e015e0c..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) { @@ -93,7 +104,7 @@ function MessageInspectDomain(props: InspectDomainPanelProps) { case "tool_call": case "tool_result": case undefined: - return ; + return ; } } @@ -107,7 +118,7 @@ function ToolCallInspectDomain(props: InspectDomainPanelProps) { case "thinking": case "turn": case undefined: - return ; + return ; } } 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<(), String> { - dotenvy::dotenv().ok(); + dotenvy::dotenv_override().ok(); match env::args().nth(1).as_deref() { Some("export-openapi") => { println!( @@ -44,22 +45,7 @@ async fn main() -> Result<(), String> { } 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"), - reasoning_summary: optional_env("OPENAI_REASONING_SUMMARY"), - 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()?, - })); + let provider = provider::from_env()?; 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) @@ -110,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)) @@ -127,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( @@ -252,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, @@ -290,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) ) @@ -299,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 @@ -350,6 +355,7 @@ fn sse_event_name(payload: &SantiStreamPayload) -> &'static str { 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", @@ -424,6 +430,7 @@ impl IntoResponse for ApiError { get_session, update_session, list_messages, + session_material, send_session, runtime_snapshot, bucket::get_bucket_object @@ -432,10 +439,12 @@ impl IntoResponse for ApiError { CreateSessionResponse, ErrorResponse, HealthResponse, + MaterialRequest, SendSessionRequest, - SendSessionResponse, + SendSessionAcceptedResponse, Session, SessionDetail, + SessionMaterial, SessionProfile, SessionRuntimeSnapshot, SessionSummary, @@ -447,6 +456,8 @@ 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, 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..ccc606c --- /dev/null +++ b/apps/server/soma/crates/santi-api/src/provider.rs @@ -0,0 +1,59 @@ +use std::{env, sync::Arc}; + +use santi_provider::{ + DeepSeekProvider, DeepSeekProviderConfig, OpenAIProvider, OpenAIProviderConfig, ProviderClient, +}; + +pub(crate) fn from_env() -> Result, String> { + match env::var("SANTI_PROVIDER") + .unwrap_or_else(|_| "openai".to_string()) + .as_str() + { + "openai" => openai_provider(), + "deepseek" => deepseek_provider(), + provider => Err(format!("unsupported SANTI_PROVIDER: {provider}")), + } +} + +fn openai_provider() -> Result, String> { + Ok(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"), + reasoning_summary: optional_env("OPENAI_REASONING_SUMMARY"), + max_output_tokens: optional_u32_env("OPENAI_MAX_OUTPUT_TOKENS")?, + }))) +} + +fn deepseek_provider() -> Result, String> { + Ok(Arc::new(DeepSeekProvider::new(DeepSeekProviderConfig { + api_key: env::var("DEEPSEEK_API_KEY") + .map_err(|_| "DEEPSEEK_API_KEY is required".to_string())?, + model: env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| "deepseek-v4-pro".to_string()), + base_url: env::var("DEEPSEEK_BASE_URL") + .unwrap_or_else(|_| "https://api.deepseek.com".to_string()), + thinking: optional_env("DEEPSEEK_THINKING"), + reasoning_effort: optional_env("DEEPSEEK_REASONING_EFFORT"), + max_tokens: optional_u32_env("DEEPSEEK_MAX_TOKENS")?, + }))) +} + +fn optional_u32_env(name: &str) -> Result, String> { + optional_env(name) + .map(|value| { + value + .parse::() + .map_err(|_| format!("{name} must be an unsigned integer")) + }) + .transpose() +} + +fn optional_env(name: &str) -> Option { + env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} 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..57b5ed2 --- /dev/null +++ b/apps/server/soma/crates/santi-core/src/assembly/system_prompt.rs @@ -0,0 +1,174 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use crate::{SoulProfile, SoulSession, Timestamp, timestamp_from_system_time}; + +const SOUL_SOURCE: &str = "@soul/MEMORY.md"; +const SESSION_SOURCE: &str = "@session/MEMORY.md"; +const SANTI_CHANNEL: &str = "mini-stim"; +const SANTI_HINT: &str = "Use soul memory for global identity, values, worldview, methods, and self-understanding. Use session memory for local doing/todo. Keep memory concise and index workspace sources."; + +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 hint = render_hint(frontmatter_hint_state(&soul_memory.content)); + + Ok([ + "You are a distinct soul running inside this Santi instance.".to_string(), + render_meta(request, &hint), + render_memory_section("santi-soul", SOUL_SOURCE, &soul_memory), + render_memory_section("santi-session", SESSION_SOURCE, &session_memory), + ] + .join("\n\n")) +} + +fn render_meta(request: SystemPromptRequest<'_>, hint: &str) -> 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), + format!("hint: {hint}"), + ] + .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, + }) +} + +fn render_hint(state: HintState) -> String { + match state { + HintState::Enabled => { + format!("{SANTI_HINT} disable by set santi_hint: false in {SOUL_SOURCE}") + } + HintState::Hidden => { + format!("Hidden, enable by set santi_hint: true in {SOUL_SOURCE}") + } + HintState::InvalidFrontmatter => { + format!("Invalid frontmatter in {SOUL_SOURCE}. Use --- with santi_hint: true|false.") + } + HintState::InvalidValue => { + format!("Invalid santi_hint in {SOUL_SOURCE}. Use true|false.") + } + } +} + +fn frontmatter_hint_state(content: &str) -> HintState { + let frontmatter = match frontmatter(content) { + Frontmatter::Missing => return HintState::Hidden, + Frontmatter::Invalid => return HintState::InvalidFrontmatter, + Frontmatter::Present(frontmatter) => frontmatter, + }; + let value = match frontmatter_value(frontmatter, "santi_hint") { + FrontmatterValue::Missing => return HintState::Hidden, + FrontmatterValue::Invalid => return HintState::InvalidFrontmatter, + FrontmatterValue::Found(value) => value, + }; + match value { + "true" => HintState::Enabled, + "false" => HintState::Hidden, + _ => HintState::InvalidValue, + } +} + +fn frontmatter(content: &str) -> Frontmatter<'_> { + let mut lines = content.lines(); + if lines.next() != Some("---") { + return Frontmatter::Missing; + } + let body_start = 4; + for (offset, line) in content[body_start..].lines().enumerate() { + if line == "---" { + let end = content[body_start..] + .lines() + .take(offset) + .map(|line| line.len() + 1) + .sum::(); + return Frontmatter::Present(&content[body_start..body_start + end]); + } + } + Frontmatter::Invalid +} + +fn frontmatter_value<'a>(frontmatter: &'a str, key: &str) -> FrontmatterValue<'a> { + for line in frontmatter.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((candidate, value)) = line.split_once(':') else { + return FrontmatterValue::Invalid; + }; + if candidate.trim() == key { + return FrontmatterValue::Found(value.trim()); + } + } + FrontmatterValue::Missing +} + +#[derive(Debug, PartialEq, Eq)] +enum HintState { + Enabled, + Hidden, + InvalidFrontmatter, + InvalidValue, +} + +enum Frontmatter<'a> { + Missing, + Present(&'a str), + Invalid, +} + +enum FrontmatterValue<'a> { + Missing, + Found(&'a str), + Invalid, +} + +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..9c237fd 100644 --- a/apps/server/soma/crates/santi-core/src/lib.rs +++ b/apps/server/soma/crates/santi-core/src/lib.rs @@ -1,3 +1,4 @@ +mod assembly; mod model; mod object_store; mod service; diff --git a/apps/server/soma/crates/santi-core/src/model.rs b/apps/server/soma/crates/santi-core/src/model.rs index a3fd1f6..8a5c3c7 100644 --- a/apps/server/soma/crates/santi-core/src/model.rs +++ b/apps/server/soma/crates/santi-core/src/model.rs @@ -16,6 +16,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 +86,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, @@ -334,16 +362,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 thinking_spans: Vec, - pub tool_calls: Vec, - pub tool_results: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -403,6 +427,9 @@ pub enum SantiStreamPayload { ThinkingCompleted { thinking: ThinkingSpan, }, + MaterialUpdated { + material: MaterialUpdated, + }, TurnStarted { turn: Turn, }, @@ -447,6 +474,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/service.rs b/apps/server/soma/crates/santi-core/src/service.rs index 5416127..e03a78e 100644 --- a/apps/server/soma/crates/santi-core/src/service.rs +++ b/apps/server/soma/crates/santi-core/src/service.rs @@ -1,29 +1,40 @@ +mod materials; +mod text_delta; mod thinking; +mod timing; mod tools; use futures_util::StreamExt; -use santi_provider::{ProviderClient, ProviderEvent, ProviderMessage, ProviderRequest}; -use std::sync::Arc; +use santi_provider::{ProviderClient, ProviderEvent, ProviderRequest}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; 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, ThinkingCompletionReason, ThinkingSpan, - TurnActivityState, 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 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, @@ -42,6 +53,7 @@ impl SantiService { store, provider, config, + material_cache: Arc::new(Mutex::new(HashMap::new())), stream_events: broadcast::channel(1024).0, }) } @@ -94,7 +106,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()); @@ -134,87 +146,112 @@ 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(), - }, - ); - return Err(error); + self.fail_background_turn(&session_id, &turn_id, 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); + 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), + MessageState::Fixed, + ) { + Ok(message) => message.session_message, + Err(error) => { + self.fail_background_turn(&session_id, &turn_id, error); + 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); + 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); + 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, + fn fail_background_turn(&self, session_id: &str, turn_id: &str, error: String) { + let _ = self.store.fail_turn(turn_id, &error); + self.publish_stream( + session_id, + SantiStreamPayload::TurnFailed { + turn_id: turn_id.to_string(), + error, }, - soul_session, - soul_profile, - turn: completed_turn, - user_message, - assistant_message, - thinking_spans: self.store.thinking_spans_for_turn(&turn.id)?, - 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( @@ -225,21 +262,16 @@ impl SantiService { ) -> Result<(String, Option), String> { let mut assistant_text = String::new(); let mut function_call_outputs = Vec::new(); + let mut timing = ProviderTurnTiming::new(turn_id); + let mut round = 0; 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_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(self.system_prompt_text(session_id, soul_session_id)?), input, tools: Some(provider_tools()), previous_response_id: None, @@ -249,19 +281,40 @@ impl SantiService { Some(function_call_outputs.clone()) }, }; + 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 = self.provider.stream_response(request).await?; + 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(error); + } + }; 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 { let event = match event { Ok(event) => event, Err(error) => { + timing.failed(round, "sse_event", &error); self.fail_current_thinking_span( session_id, &mut current_thinking_span, @@ -270,7 +323,16 @@ impl SantiService { return Err(error); } }; + 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, } @@ -309,31 +371,20 @@ impl SantiService { )?; } ProviderEvent::TextDelta(delta) => { - if assistant_text.is_empty() { - self.complete_current_thinking_span( - session_id, - &mut current_thinking_span, - ThinkingCompletionReason::FirstTextDelta, - )?; - self.publish_turn_activity( - session_id, - turn_id, - TurnActivityState::Generating, - active_provider_response_id.clone(), - ); - } - assistant_text.push_str(&delta); - self.publish_stream( + let update = TextDeltaUpdate { session_id, - SantiStreamPayload::MessageDelta { - message_id: format!("stream_{turn_id}"), - turn_id: turn_id.to_string(), - role: ActorType::Soul, - text: delta, - }, - ); + 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, + }; + self.handle_text_delta(delta, update)?; } ProviderEvent::FunctionCallRequested(call) => { + timing.function_call_requested(round, &call.name); self.complete_current_thinking_span( session_id, &mut current_thinking_span, @@ -350,6 +401,7 @@ impl SantiService { ProviderEvent::Completed { provider_response_id, } => { + timing.completed(round); active_provider_response_id = provider_response_id.clone(); self.complete_current_thinking_span( session_id, @@ -375,6 +427,7 @@ impl SantiService { } let mut outputs = Vec::new(); + timing.tool_outputs_started(round, calls.len()); for call in calls { self.publish_turn_activity( session_id, @@ -382,52 +435,24 @@ impl SantiService { TurnActivityState::RunningTool, active_provider_response_id.clone(), ); - outputs.push(self.handle_tool_call(session_id, soul_session_id, turn_id, call)?); + let mut output = + 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(), 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/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 index 1c465e2..b7dfd69 100644 --- a/apps/server/soma/crates/santi-core/src/service/tools.rs +++ b/apps/server/soma/crates/santi-core/src/service/tools.rs @@ -1,4 +1,7 @@ -use std::{path::PathBuf, process::Command}; +use std::{ + path::{Component, Path, PathBuf}, + process::Command, +}; use santi_provider::{FunctionCallOutput, ProviderFunctionCall}; use serde::Deserialize; @@ -48,28 +51,18 @@ impl SantiService { "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, + _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) @@ -80,10 +73,9 @@ impl SantiService { 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(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 @@ -100,9 +92,23 @@ impl SantiService { "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()); + }; + if let Some(path) = cwd.strip_prefix("@soul") { + return aliased_path(self.soul_memory_dir(), path, "@soul"); + } + if let Some(path) = cwd.strip_prefix("@session") { + return aliased_path(self.session_memory_dir(session_id), path, "@session"); + } + Ok(PathBuf::from(cwd)) + } + pub(super) fn runtime_root(&self) -> PathBuf { PathBuf::from(&self.config.runtime_root) } @@ -115,17 +121,20 @@ impl SantiService { 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") } -} -#[derive(Debug, Deserialize)] -struct WriteMemoryArgs { - text: String, + pub(super) fn session_memory_file(&self, session_id: &str) -> PathBuf { + self.session_memory_dir(session_id).join("MEMORY.md") + } } #[derive(Debug, Deserialize)] @@ -158,6 +167,25 @@ fn default_shell_name() -> &'static str { if cfg!(windows) { "pwsh" } else { "bash" } } +fn aliased_path(root: PathBuf, suffix: &str, alias: &str) -> Result { + if suffix.is_empty() { + return Ok(root); + } + let Some(path) = suffix.strip_prefix('/') else { + return Err(format!("invalid cwd alias: {alias}{suffix}")); + }; + let path = Path::new(path); + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(format!("cwd alias cannot escape {alias}")); + } + Ok(root.join(path)) +} + 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..9c91b96 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,25 @@ 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") -} - 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." - } + vec![ProviderTool::Function(ProviderFunctionTool { + name: "shell".to_string(), + description: "Run a shell command. By default commands run in the current execution workspace. Use cwd \"@soul\" to work in the current soul memory workspace, where @soul/MEMORY.md is the global memory source. Use cwd \"@session\" to work in the current session memory workspace, where @session/MEMORY.md is the local doing/todo memory source. 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." }, - "required": ["command"], - "additionalProperties": false - }), + "cwd": { + "type": "string", + "description": "Optional working directory. Supports @soul, @soul/, @session, and @session/ aliases." + } + }, + "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 1c49467..ae7dec5 100644 --- a/apps/server/soma/crates/santi-core/src/store.rs +++ b/apps/server/soma/crates/santi-core/src/store.rs @@ -10,6 +10,7 @@ use crate::{ SoulSessionEntry, SoulSessionTargetType, Turn, prefixed_id, timestamp_now, }; +mod assembly; mod db; mod rows; mod runtime; @@ -19,7 +20,7 @@ use db::*; use rows::{actor_type_db, collect_rows, map_session_summary_row, message_state_db}; use schema::SCHEMA; -const SANTI_SCHEMA_VERSION: u32 = 6; +const SANTI_SCHEMA_VERSION: u32 = 7; const DEFAULT_ACCOUNT_ID: &str = "account_local"; const DEFAULT_SOUL_ID: &str = "soul_default"; @@ -118,9 +119,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], ) 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 befa9fe..d1774e3 100644 --- a/apps/server/soma/crates/santi-core/src/store/db.rs +++ b/apps/server/soma/crates/santi-core/src/store/db.rs @@ -4,8 +4,8 @@ use rusqlite::{Connection, OptionalExtension, params}; use crate::{ ActorType, Compact, Session, SessionEffect, SessionMessage, SessionProfile, SessionSummary, - Soul, SoulProfile, SoulSession, SoulSessionEntry, SoulSessionTargetType, ThinkingSpan, - ToolCall, ToolResult, Turn, timestamp_now, + SoulProfile, SoulSession, SoulSessionEntry, SoulSessionTargetType, ThinkingSpan, ToolCall, + ToolResult, Turn, timestamp_now, }; use super::rows::*; @@ -133,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 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 782af5f..fefdeea 100644 --- a/apps/server/soma/crates/santi-core/src/store/rows.rs +++ b/apps/server/soma/crates/santi-core/src/store/rows.rs @@ -50,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)?, }) } 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 3c6808f..860b35a 100644 --- a/apps/server/soma/crates/santi-core/src/store/runtime.rs +++ b/apps/server/soma/crates/santi-core/src/store/runtime.rs @@ -2,69 +2,19 @@ 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, 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, + 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, ThinkingCompletionReason, - ThinkingSpan, ThinkingSpanState, 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( - &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) - } - pub fn append_thinking_span( &self, turn_id: &str, @@ -233,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, 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 e85d843..359817d 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, diff --git a/apps/server/soma/crates/santi-core/tests/service.rs b/apps/server/soma/crates/santi-core/tests/service.rs index ff5ba17..873075c 100644 --- a/apps/server/soma/crates/santi-core/tests/service.rs +++ b/apps/server/soma/crates/santi-core/tests/service.rs @@ -9,6 +9,7 @@ use santi_provider::{ }; use serde_json::json; use std::sync::{Arc, Mutex}; +use tokio::time::{Duration, sleep}; #[derive(Clone, Default)] struct FakeProvider { @@ -41,13 +42,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" + }), })), Ok(ProviderEvent::Completed { provider_response_id: Some("resp_tool".to_string()), @@ -92,8 +95,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,9 +114,16 @@ async fn sends_with_runtime() { .instructions .as_deref() .expect("runtime instructions"); - assert!(instructions.contains("You are santi")); - assert!(instructions.contains("")); - assert!(instructions.contains("")); + 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("source: @soul/MEMORY.md")); + assert!(instructions.contains("source: @session/MEMORY.md")); + assert!(!instructions.contains("")); + assert!(!instructions.contains("")); let tool_names = requests[0] .tools .as_ref() @@ -117,20 +133,13 @@ async fn sends_with_runtime() { 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 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 +174,33 @@ 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 = format!("runtime/sessions/{}/memory", session.session.id); + assert!(stdout.contains(&session_memory_dir)); + let cwd = output + .get("cwd") + .and_then(|value| value.as_str()) + .expect("shell cwd"); + assert!(cwd.ends_with(&session_memory_dir)); let requests = provider.requests.lock().unwrap(); assert_eq!(requests.len(), 2); @@ -184,6 +208,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/system_prompt.rs b/apps/server/soma/crates/santi-core/tests/system_prompt.rs new file mode 100644 index 0000000..5227aa9 --- /dev/null +++ b/apps/server/soma/crates/santi-core/tests/system_prompt.rs @@ -0,0 +1,142 @@ +use std::{fs, sync::Arc}; + +use async_trait::async_trait; +use futures_util::stream; +use santi_core::{ + MaterialKind, MaterialRequest, SantiService, SantiServiceConfig, SessionMaterial, +}; +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("---\nsanti_hint: true\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("[santi-soul]")); + assert!(text.contains("[santi-session]")); + assert!(text.contains("hint: Use soul memory")); + assert!(text.contains("source: @soul/MEMORY.md")); + assert!(text.contains("source: @session/MEMORY.md")); + assert!(text.contains("content:\n---\nsanti_hint: true\n---\n# Soul")); + assert!(text.contains("content:\n# Session")); +} + +#[test] +fn reports_hidden_hint() { + let harness = PromptHarness::open(); + harness.write_soul("---\nsanti_hint: false\n---\n# Soul"); + + let text = harness.system_prompt().text; + + assert!(text.contains("hint: Hidden, enable by set santi_hint: true in @soul/MEMORY.md")); +} + +#[test] +fn reports_invalid_hint() { + let harness = PromptHarness::open(); + harness.write_soul("---\nsanti_hint: yes\n---\n# Soul"); + + let text = harness.system_prompt().text; + + assert!(text.contains("hint: Invalid santi_hint in @soul/MEMORY.md. Use true|false.")); +} + +#[test] +fn reports_invalid_frontmatter() { + let harness = PromptHarness::open(); + harness.write_soul("---\nsanti_hint true\n---\n# Soul"); + + let text = harness.system_prompt().text; + + assert!(text.contains("hint: Invalid frontmatter in @soul/MEMORY.md. Use --- with santi_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-provider/src/deepseek.rs b/apps/server/soma/crates/santi-provider/src/deepseek.rs new file mode 100644 index 0000000..7947b26 --- /dev/null +++ b/apps/server/soma/crates/santi-provider/src/deepseek.rs @@ -0,0 +1,442 @@ +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 DeepSeekProviderConfig { + 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 DeepSeekProvider { + config: DeepSeekProviderConfig, + client: Client, +} + +impl DeepSeekProvider { + pub fn new(config: DeepSeekProviderConfig) -> Self { + Self { + config, + client: Client::new(), + } + } +} + +#[async_trait] +impl ProviderClient for DeepSeekProvider { + fn metadata(&self) -> ProviderMetadata { + ProviderMetadata { + provider: Arc::from("deepseek"), + 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!( + "deepseek chat completions request failed: {status} {body}" + )); + } + Ok(Box::pin(parse_sse(response.bytes_stream()))) + } +} + +fn chat_body(config: &DeepSeekProviderConfig, 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 { + 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 deepseek 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 deepseek 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..968e273 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 deepseek; mod openai; mod provider; +pub use deepseek::{DeepSeekProvider, DeepSeekProviderConfig}; 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 01c46c2..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)] @@ -192,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(); @@ -200,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; } } @@ -209,6 +215,34 @@ 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, @@ -366,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 diff --git a/apps/server/soma/crates/santi-provider/src/provider.rs b/apps/server/soma/crates/santi-provider/src/provider.rs index a6cd19c..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,7 @@ pub struct ProviderMetadata { #[derive(Debug, Clone, PartialEq)] pub enum ProviderEvent { + StreamTrace(ProviderStreamTrace), ResponseStarted { provider_response_id: Option, }, diff --git a/apps/server/soma/crates/santi-provider/tests/deepseek.rs b/apps/server/soma/crates/santi-provider/tests/deepseek.rs new file mode 100644 index 0000000..2f0a6fa --- /dev/null +++ b/apps/server/soma/crates/santi-provider/tests/deepseek.rs @@ -0,0 +1,410 @@ +use std::{ + io::{Read, Write}, + net::TcpListener, + sync::mpsc, + thread, +}; + +use futures_util::StreamExt; +use santi_provider::{ + DeepSeekProvider, DeepSeekProviderConfig, FunctionCallOutput, ProviderClient, ProviderEvent, + ProviderFunctionCall, ProviderFunctionTool, ProviderMessage, ProviderRequest, + ProviderStreamTrace, ProviderTool, +}; +use serde_json::Value; + +#[tokio::test] +async fn maps_chat_body() { + let body = capture_body(DeepSeekProviderConfig { + 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 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: DeepSeekProviderConfig) -> 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 = DeepSeekProvider::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 = DeepSeekProviderConfig { + 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 = DeepSeekProvider::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 = DeepSeekProviderConfig { + 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 = DeepSeekProvider::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 = DeepSeekProviderConfig { + 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 = DeepSeekProvider::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 42a3073..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; @@ -124,6 +124,32 @@ async fn parses_summary_item_done() { )); } +#[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")); @@ -164,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"); @@ -209,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"); @@ -219,6 +245,14 @@ 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(), @@ -265,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..0e245f3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,8 +7,8 @@ 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 + Rust server, provider-abstracted model streaming with concrete OpenAI and + DeepSeek 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 diff --git a/packages/contracts/openapi.json b/packages/contracts/openapi.json index d68e9cc..af328d2 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,6 +561,42 @@ } } }, + "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": [ @@ -618,37 +712,16 @@ "fixed" ] }, - "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", - "thinking_spans", - "tool_calls", - "tool_results" + "user_message" ], "properties": { - "assistant_message": { - "$ref": "#/components/schemas/SessionMessage" - }, "session": { "$ref": "#/components/schemas/SessionSummary" }, @@ -658,24 +731,6 @@ "soul_session": { "$ref": "#/components/schemas/SoulSession" }, - "thinking_spans": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ThinkingSpan" - } - }, - "tool_calls": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToolCall" - } - }, - "tool_results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToolResult" - } - }, "turn": { "$ref": "#/components/schemas/Turn" }, @@ -684,6 +739,20 @@ } } }, + "SendSessionRequest": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessagePart" + } + } + } + }, "Session": { "type": "object", "required": [ @@ -793,6 +862,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": [ @@ -970,6 +1066,7 @@ "type": "object", "required": [ "soul_id", + "soul_name", "nickname", "avatar_seed", "created_at", @@ -1000,6 +1097,9 @@ "soul_id": { "type": "string" }, + "soul_name": { + "type": "string" + }, "updated_at": { "$ref": "#/components/schemas/String" } diff --git a/packages/contracts/src/openapi.ts b/packages/contracts/src/openapi.ts index 0c8522d..3c6d460 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; @@ -96,23 +112,6 @@ export interface Message { 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 +121,7 @@ export interface SoulProfile { desc?: string | null; nickname: string; soul_id: string; + soul_name: string; updated_at: String; } @@ -141,57 +141,6 @@ export interface SoulSession { 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 type TurnStatus = (typeof TurnStatus)[keyof typeof TurnStatus]; export const TurnStatus = { @@ -226,18 +175,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; - thinking_spans: ThinkingSpan[]; - tool_calls: ToolCall[]; - tool_results: ToolResult[]; turn: Turn; user_message: SessionMessage; } +export interface SendSessionRequest { + content: MessagePart[]; +} + export interface SessionDetail { messages: SessionMessage[]; profile: SessionProfile; @@ -260,6 +222,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[]; @@ -582,6 +603,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; @@ -687,7 +763,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 6650a5c..7558ca0 100644 --- a/packages/hooks/src/index.tsx +++ b/packages/hooks/src/index.tsx @@ -1,5 +1,6 @@ import { installSantiMqueue, + type MaterialKind, type MessageConnectionState, type MessageEvent, type MessagePart, @@ -8,6 +9,7 @@ import { type PubAck, type SantiMqueue, type SantiWindow, + type SessionMaterial, type SessionMessage, type SessionProjection, type SessionRuntimeSnapshot, @@ -27,6 +29,8 @@ import { export type { Compact, + MaterialKind, + MaterialUpdated, MessageConnectionState, MessagePart, MqueueError, @@ -34,6 +38,7 @@ export type { SantiMqueue, Session, SessionEffect, + SessionMaterial, SessionMessage, SessionProjection, SessionRuntimeSnapshot, @@ -62,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[]; @@ -165,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; } @@ -257,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 4295660..ed672d1 100644 --- a/packages/mqueue/src/events.ts +++ b/packages/mqueue/src/events.ts @@ -87,11 +87,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))) { @@ -171,6 +175,12 @@ export function cloneProjection(value: SessionProjection): SessionProjection { 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, diff --git a/packages/mqueue/src/index.ts b/packages/mqueue/src/index.ts index 485eaa0..33837bb 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,47 +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, - ThinkingCompletionReason, - ThinkingSpan, - ThinkingSpanState, - TimelineItem, - ToolCall, - ToolResult, - Turn, - TurnActivity, - TurnActivityProjection, - TurnActivityState, - TurnGroup, - TurnStatus, -} from "./types"; +export type * from "./types"; declare global { interface Window { @@ -113,6 +76,7 @@ function createMqueueCore(target: Window): SantiMqueue { sessions: [], selectedSessionId: null, messages: [], + materialsBySessionId: {}, messagesBySessionId: {}, runtimeBySessionId: {}, pending: 0, @@ -265,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; } @@ -277,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; @@ -294,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( @@ -319,6 +291,7 @@ function createMqueueCore(target: Window): SantiMqueue { 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; @@ -355,16 +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]); - upsertThinkingSpans(summarySessionId(response.session), response.thinking_spans); - upsertTools(summarySessionId(response.session), response.tool_calls, response.tool_results); state.messages = state.messagesBySessionId[summarySessionId(response.session)]; emitMessageProjection(summarySessionId(response.session)); return response; @@ -452,6 +420,9 @@ function createMqueueCore(target: Window): SantiMqueue { emitMessageProjection(sessionId); } }, + materialUpdated: (payload) => { + void ensureMaterial(sessionId, payload.material.kind); + }, turnFailed: (payload) => { markTurnFailed(sessionId, payload.turn_id, payload.error); dispatchMessage( @@ -479,6 +450,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/stream.ts b/packages/mqueue/src/stream.ts index 219a9c0..59cf08e 100644 --- a/packages/mqueue/src/stream.ts +++ b/packages/mqueue/src/stream.ts @@ -1,5 +1,6 @@ import { parseStreamEvent } from "./events"; import type { + MaterialUpdated, MessageDeltaPayload, SessionMessage, StreamPayload, @@ -23,6 +24,7 @@ export interface SessionStreamHandlers { 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; @@ -40,6 +42,7 @@ export function openSessionStream(sessionId: string, handlers: SessionStreamHand 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); diff --git a/packages/mqueue/src/types.ts b/packages/mqueue/src/types.ts index a9434eb..a5f0705 100644 --- a/packages/mqueue/src/types.ts +++ b/packages/mqueue/src/types.ts @@ -1,5 +1,8 @@ import type { + MaterialKind, + MaterialUpdated, MessagePart, + SessionMaterial, SessionMessage, SessionRuntimeSnapshot, SessionSummary, @@ -13,9 +16,12 @@ import type { export type { Compact, + MaterialKind, + MaterialUpdated, MessagePart, Session, SessionEffect, + SessionMaterial, SessionMessage, SessionProfile, SessionRuntimeSnapshot, @@ -37,6 +43,7 @@ export type SessionAction = | "create" | "get" | "list" + | "material" | "messages" | "runtime" | "select" @@ -49,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 }; @@ -66,6 +74,7 @@ export interface SessionProjection { sessions: SessionSummary[]; selectedSessionId: string | null; messages: SessionMessage[]; + materialsBySessionId: Record>>; messagesBySessionId: Record; runtimeBySessionId: Record; pending: number; @@ -238,6 +247,7 @@ export type StreamPayload = | { 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 } From 84b98b90cd1808804b842f2f2ab011425948cdd6 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Tue, 23 Jun 2026 21:19:19 +0800 Subject: [PATCH 3/3] core: model provider config and runtime messages --- .env.example | 17 +- .github/workflows/guard.yml | 7 +- .gitignore | 1 + AGENTS.md | 34 ++- Cargo.lock | 176 +++++++++++ Cargo.toml | 2 + README.md | 30 +- .../web/src/components/TimelineItemView.tsx | 57 +++- .../soma/web/src/components/Transcript.tsx | 47 +-- .../InspectPanel/MessageInspectPanel.tsx | 3 +- apps/server/soma/crates/santi-api/Cargo.toml | 2 + .../soma/crates/santi-api/src/config.rs | 284 ++++++++++++++++++ apps/server/soma/crates/santi-api/src/lib.rs | 2 + apps/server/soma/crates/santi-api/src/main.rs | 14 +- .../soma/crates/santi-api/src/provider.rs | 79 ++--- .../soma/crates/santi-api/tests/config.rs | 107 +++++++ .../santi-core/src/assembly/system_prompt.rs | 125 ++------ apps/server/soma/crates/santi-core/src/lib.rs | 5 + .../soma/crates/santi-core/src/model.rs | 94 +----- .../crates/santi-core/src/model/message.rs | 105 +++++++ .../soma/crates/santi-core/src/service.rs | 92 +++--- .../crates/santi-core/src/service/failure.rs | 93 ++++++ .../crates/santi-core/src/service/tools.rs | 39 +-- .../crates/santi-core/src/service_prompt.rs | 11 +- .../soma/crates/santi-core/src/store.rs | 47 ++- .../soma/crates/santi-core/src/store/db.rs | 21 +- .../soma/crates/santi-core/src/store/rows.rs | 35 ++- .../crates/santi-core/src/store/runtime.rs | 35 +++ .../crates/santi-core/src/store/schema.rs | 3 +- .../crates/santi-core/src/workspace_uri.rs | 74 +++++ .../soma/crates/santi-core/tests/failure.rs | 211 +++++++++++++ .../soma/crates/santi-core/tests/service.rs | 71 ++++- .../soma/crates/santi-core/tests/store.rs | 37 ++- .../crates/santi-core/tests/system_prompt.rs | 60 ++-- .../crates/santi-core/tests/workspace_uri.rs | 59 ++++ .../src/{deepseek.rs => chat_completions.rs} | 27 +- .../soma/crates/santi-provider/src/lib.rs | 4 +- .../{deepseek.rs => chat_completions.rs} | 46 ++- docs/README.md | 6 +- packages/contracts/openapi.json | 14 +- packages/contracts/src/openapi.ts | 9 + packages/mqueue/src/events.ts | 53 +--- packages/mqueue/src/index.ts | 1 + packages/mqueue/src/lib/helpers/message.ts | 57 ++++ santi.example.toml | 25 ++ 45 files changed, 1780 insertions(+), 541 deletions(-) create mode 100644 apps/server/soma/crates/santi-api/src/config.rs create mode 100644 apps/server/soma/crates/santi-api/src/lib.rs create mode 100644 apps/server/soma/crates/santi-api/tests/config.rs create mode 100644 apps/server/soma/crates/santi-core/src/model/message.rs create mode 100644 apps/server/soma/crates/santi-core/src/service/failure.rs create mode 100644 apps/server/soma/crates/santi-core/src/workspace_uri.rs create mode 100644 apps/server/soma/crates/santi-core/tests/failure.rs create mode 100644 apps/server/soma/crates/santi-core/tests/workspace_uri.rs rename apps/server/soma/crates/santi-provider/src/{deepseek.rs => chat_completions.rs} (94%) rename apps/server/soma/crates/santi-provider/tests/{deepseek.rs => chat_completions.rs} (88%) create mode 100644 packages/mqueue/src/lib/helpers/message.ts create mode 100644 santi.example.toml diff --git a/.env.example b/.env.example index c8fd5b7..f83e574 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,2 @@ -SANTI_PROVIDER=openai - -OPENAI_API_KEY= -OPENAI_MODEL= -OPENAI_RESPONSES_BASE_URL=https://api.openai.com/v1 -OPENAI_REASONING_EFFORT= -OPENAI_REASONING_SUMMARY= -OPENAI_MAX_OUTPUT_TOKENS= - -DEEPSEEK_API_KEY= -DEEPSEEK_MODEL=deepseek-v4-pro -DEEPSEEK_BASE_URL=https://api.deepseek.com -DEEPSEEK_THINKING= -DEEPSEEK_REASONING_EFFORT= -DEEPSEEK_MAX_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 73a533c..3c23679 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,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 @@ -389,32 +398,25 @@ surface before considering any alternate browser layer. ## Environment -`.env` is local and ignored by git. Select the model provider with: +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 -SANTI_PROVIDER=openai +cp santi.example.toml santi.toml ``` -OpenAI settings: +Config resolution is modeled by `mini-stim-server-soma`'s `ConfigService`. +Config path resolves as: ```text -OPENAI_API_KEY= -OPENAI_MODEL= -OPENAI_RESPONSES_BASE_URL=https://api.openai.com/v1 -OPENAI_REASONING_EFFORT= -OPENAI_REASONING_SUMMARY= -OPENAI_MAX_OUTPUT_TOKENS= +--config > SANTI_CONFIG > ./santi.toml ``` -DeepSeek settings: +Provider selection resolves as: ```text -DEEPSEEK_API_KEY= -DEEPSEEK_MODEL=deepseek-v4-pro -DEEPSEEK_BASE_URL=https://api.deepseek.com -DEEPSEEK_THINKING= -DEEPSEEK_REASONING_EFFORT= -DEEPSEEK_MAX_TOKENS= +--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 05ccaf8..55bd756 100644 --- a/README.md +++ b/README.md @@ -82,26 +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 -SANTI_PROVIDER=openai - -OPENAI_API_KEY= -OPENAI_MODEL= -OPENAI_RESPONSES_BASE_URL=https://api.openai.com/v1 -OPENAI_REASONING_EFFORT= -OPENAI_REASONING_SUMMARY= -OPENAI_MAX_OUTPUT_TOKENS= - -DEEPSEEK_API_KEY= -DEEPSEEK_MODEL=deepseek-v4-pro -DEEPSEEK_BASE_URL=https://api.deepseek.com -DEEPSEEK_THINKING= -DEEPSEEK_REASONING_EFFORT= -DEEPSEEK_MAX_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/components/TimelineItemView.tsx b/apps/client/soma/web/src/components/TimelineItemView.tsx index bc4736c..bd5ce50 100644 --- a/apps/client/soma/web/src/components/TimelineItemView.tsx +++ b/apps/client/soma/web/src/components/TimelineItemView.tsx @@ -105,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": { diff --git a/apps/client/soma/web/src/components/Transcript.tsx b/apps/client/soma/web/src/components/Transcript.tsx index 0ef1fe8..1d0a418 100644 --- a/apps/client/soma/web/src/components/Transcript.tsx +++ b/apps/client/soma/web/src/components/Transcript.tsx @@ -1,7 +1,6 @@ import { AnchoredContentGroup, AnchoredContentGroupDivider, - Notice, Stack, Text, Timestamp, @@ -49,7 +48,7 @@ export function Transcript(props: { soulIdentity={props.soulIdentity} /> ))} - + {empty ? : null} ); @@ -120,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)} ); } @@ -150,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/server/soma/crates/santi-api/Cargo.toml b/apps/server/soma/crates/santi-api/Cargo.toml index 75033bc..cc1f38e 100644 --- a/apps/server/soma/crates/santi-api/Cargo.toml +++ b/apps/server/soma/crates/santi-api/Cargo.toml @@ -7,6 +7,7 @@ version.workspace = true [dependencies] async-stream.workspace = true axum.workspace = true +clap.workspace = true dotenvy.workspace = true futures-core.workspace = true futures-util.workspace = true @@ -15,5 +16,6 @@ santi-provider.workspace = true serde.workspace = true serde_json.workspace = true tokio.workspace = true +toml.workspace = true tower-http.workspace = true utoipa.workspace = true diff --git a/apps/server/soma/crates/santi-api/src/config.rs b/apps/server/soma/crates/santi-api/src/config.rs new file mode 100644 index 0000000..23dfb9e --- /dev/null +++ b/apps/server/soma/crates/santi-api/src/config.rs @@ -0,0 +1,284 @@ +use std::{collections::BTreeMap, env, fs}; + +use clap::{Parser, Subcommand}; +use serde::Deserialize; + +const APP_CONFIG_PATH: &str = "santi.toml"; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum AppCommand { + #[default] + Serve, + ExportOpenApi, +} + +#[derive(Debug, Clone)] +pub struct ConfigService { + cli: Cli, +} + +impl ConfigService { + pub fn from_env_args() -> 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 399c409..660198b 100644 --- a/apps/server/soma/crates/santi-api/src/main.rs +++ b/apps/server/soma/crates/santi-api/src/main.rs @@ -11,6 +11,7 @@ use axum::{ routing::{get, post}, }; use futures_core::Stream; +use mini_stim_server_soma::{config, provider}; use santi_core::{ CreateSessionResponse, ErrorResponse, HealthResponse, MaterialRequest, SantiService, SantiServiceConfig, SantiStreamEvent, SantiStreamPayload, SendSessionAcceptedResponse, @@ -25,13 +26,13 @@ use tower_http::{ use utoipa::OpenApi; mod bucket; -mod provider; #[tokio::main] async fn main() -> Result<(), String> { dotenvy::dotenv_override().ok(); - match env::args().nth(1).as_deref() { - Some("export-openapi") => { + let config = config::ConfigService::from_env_args()?; + match config.command() { + config::AppCommand::ExportOpenApi => { println!( "{}", serde_json::to_string_pretty(&ApiDoc::openapi()) @@ -39,13 +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 = provider::from_env()?; +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) diff --git a/apps/server/soma/crates/santi-api/src/provider.rs b/apps/server/soma/crates/santi-api/src/provider.rs index ccc606c..5f52de6 100644 --- a/apps/server/soma/crates/santi-api/src/provider.rs +++ b/apps/server/soma/crates/santi-api/src/provider.rs @@ -1,59 +1,40 @@ -use std::{env, sync::Arc}; +use std::sync::Arc; use santi_provider::{ - DeepSeekProvider, DeepSeekProviderConfig, OpenAIProvider, OpenAIProviderConfig, ProviderClient, + ChatCompletionsProvider, ChatCompletionsProviderConfig, OpenAIProvider, OpenAIProviderConfig, + ProviderClient, }; -pub(crate) fn from_env() -> Result, String> { - match env::var("SANTI_PROVIDER") - .unwrap_or_else(|_| "openai".to_string()) - .as_str() - { - "openai" => openai_provider(), - "deepseek" => deepseek_provider(), - provider => Err(format!("unsupported SANTI_PROVIDER: {provider}")), - } -} +use crate::config::{ChatCompletionsConfig, OpenAiResponsesConfig, ProviderConfig}; -fn openai_provider() -> Result, String> { - Ok(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"), - reasoning_summary: optional_env("OPENAI_REASONING_SUMMARY"), - max_output_tokens: optional_u32_env("OPENAI_MAX_OUTPUT_TOKENS")?, - }))) -} - -fn deepseek_provider() -> Result, String> { - Ok(Arc::new(DeepSeekProvider::new(DeepSeekProviderConfig { - api_key: env::var("DEEPSEEK_API_KEY") - .map_err(|_| "DEEPSEEK_API_KEY is required".to_string())?, - model: env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| "deepseek-v4-pro".to_string()), - base_url: env::var("DEEPSEEK_BASE_URL") - .unwrap_or_else(|_| "https://api.deepseek.com".to_string()), - thinking: optional_env("DEEPSEEK_THINKING"), - reasoning_effort: optional_env("DEEPSEEK_REASONING_EFFORT"), - max_tokens: optional_u32_env("DEEPSEEK_MAX_TOKENS")?, - }))) +pub fn from_config(config: ProviderConfig) -> Arc { + match config { + ProviderConfig::OpenAiResponses(config) => openai_provider(config), + ProviderConfig::ChatCompletions(config) => chat_completions_provider(config), + } } -fn optional_u32_env(name: &str) -> Result, String> { - optional_env(name) - .map(|value| { - value - .parse::() - .map_err(|_| format!("{name} must be an unsigned integer")) - }) - .transpose() +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 optional_env(name: &str) -> Option { - env::var(name) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) +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/system_prompt.rs b/apps/server/soma/crates/santi-core/src/assembly/system_prompt.rs index 57b5ed2..2e2fa05 100644 --- a/apps/server/soma/crates/santi-core/src/assembly/system_prompt.rs +++ b/apps/server/soma/crates/santi-core/src/assembly/system_prompt.rs @@ -3,12 +3,12 @@ use std::{ path::{Path, PathBuf}, }; -use crate::{SoulProfile, SoulSession, Timestamp, timestamp_from_system_time}; +use crate::{ + SESSION_WORKSPACE_URI, SOUL_WORKSPACE_URI, SoulProfile, SoulSession, Timestamp, + session_memory_uri, soul_memory_uri, timestamp_from_system_time, +}; -const SOUL_SOURCE: &str = "@soul/MEMORY.md"; -const SESSION_SOURCE: &str = "@session/MEMORY.md"; const SANTI_CHANNEL: &str = "mini-stim"; -const SANTI_HINT: &str = "Use soul memory for global identity, values, worldview, methods, and self-understanding. Use session memory for local doing/todo. Keep memory concise and index workspace sources."; pub(crate) struct SystemPromptRequest<'a> { pub session_id: &'a str, @@ -21,25 +21,40 @@ pub(crate) struct SystemPromptRequest<'a> { 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 hint = render_hint(frontmatter_hint_state(&soul_memory.content)); + 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(), - render_meta(request, &hint), - render_memory_section("santi-soul", SOUL_SOURCE, &soul_memory), - render_memory_section("santi-session", SESSION_SOURCE, &session_memory), + 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_meta(request: SystemPromptRequest<'_>, hint: &str) -> String { +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), - format!("hint: {hint}"), ] .join("\n") } @@ -78,96 +93,6 @@ fn read_memory_material(path: &Path) -> Result { }) } -fn render_hint(state: HintState) -> String { - match state { - HintState::Enabled => { - format!("{SANTI_HINT} disable by set santi_hint: false in {SOUL_SOURCE}") - } - HintState::Hidden => { - format!("Hidden, enable by set santi_hint: true in {SOUL_SOURCE}") - } - HintState::InvalidFrontmatter => { - format!("Invalid frontmatter in {SOUL_SOURCE}. Use --- with santi_hint: true|false.") - } - HintState::InvalidValue => { - format!("Invalid santi_hint in {SOUL_SOURCE}. Use true|false.") - } - } -} - -fn frontmatter_hint_state(content: &str) -> HintState { - let frontmatter = match frontmatter(content) { - Frontmatter::Missing => return HintState::Hidden, - Frontmatter::Invalid => return HintState::InvalidFrontmatter, - Frontmatter::Present(frontmatter) => frontmatter, - }; - let value = match frontmatter_value(frontmatter, "santi_hint") { - FrontmatterValue::Missing => return HintState::Hidden, - FrontmatterValue::Invalid => return HintState::InvalidFrontmatter, - FrontmatterValue::Found(value) => value, - }; - match value { - "true" => HintState::Enabled, - "false" => HintState::Hidden, - _ => HintState::InvalidValue, - } -} - -fn frontmatter(content: &str) -> Frontmatter<'_> { - let mut lines = content.lines(); - if lines.next() != Some("---") { - return Frontmatter::Missing; - } - let body_start = 4; - for (offset, line) in content[body_start..].lines().enumerate() { - if line == "---" { - let end = content[body_start..] - .lines() - .take(offset) - .map(|line| line.len() + 1) - .sum::(); - return Frontmatter::Present(&content[body_start..body_start + end]); - } - } - Frontmatter::Invalid -} - -fn frontmatter_value<'a>(frontmatter: &'a str, key: &str) -> FrontmatterValue<'a> { - for line in frontmatter.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let Some((candidate, value)) = line.split_once(':') else { - return FrontmatterValue::Invalid; - }; - if candidate.trim() == key { - return FrontmatterValue::Found(value.trim()); - } - } - FrontmatterValue::Missing -} - -#[derive(Debug, PartialEq, Eq)] -enum HintState { - Enabled, - Hidden, - InvalidFrontmatter, - InvalidValue, -} - -enum Frontmatter<'a> { - Missing, - Present(&'a str), - Invalid, -} - -enum FrontmatterValue<'a> { - Missing, - Found(&'a str), - Invalid, -} - 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 9c237fd..f03f21a 100644 --- a/apps/server/soma/crates/santi-core/src/lib.rs +++ b/apps/server/soma/crates/santi-core/src/lib.rs @@ -5,8 +5,13 @@ 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 8a5c3c7..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, @@ -95,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, 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 e03a78e..2ed7f8c 100644 --- a/apps/server/soma/crates/santi-core/src/service.rs +++ b/apps/server/soma/crates/santi-core/src/service.rs @@ -1,3 +1,4 @@ +mod failure; mod materials; mod text_delta; mod thinking; @@ -21,6 +22,7 @@ use crate::{ ThinkingCompletionReason, ThinkingSpan, TurnActivityState, UpdateSessionRequest, prefixed_id, timestamp_now, }; +use failure::ProviderTurnFailure; use text_delta::TextDeltaUpdate; use timing::{ProviderTurnTiming, provider_event_name}; @@ -194,15 +196,20 @@ impl SantiService { let (assistant_text, provider_response_id) = match send_result { Ok(value) => value, - Err(error) => { - self.fail_background_turn(&session_id, &turn_id, error); + Err(failure) => { + self.fail_background_turn( + &session_id, + &turn_id, + failure.error, + failure.partial_assistant_text, + ); return; } }; if assistant_text.trim().is_empty() { let error = "provider completed without assistant output".to_string(); - self.fail_background_turn(&session_id, &turn_id, error); + self.fail_background_turn(&session_id, &turn_id, error, String::new()); return; } @@ -210,12 +217,12 @@ impl SantiService { &session_id, ActorType::Soul, self.store.default_soul_id(), - MessageContent::text(assistant_text), + MessageContent::text(assistant_text.clone()), MessageState::Fixed, ) { Ok(message) => message.session_message, Err(error) => { - self.fail_background_turn(&session_id, &turn_id, error); + self.fail_background_turn(&session_id, &turn_id, error, assistant_text); return; } }; @@ -223,7 +230,7 @@ impl SantiService { .store .append_message_ref(&soul_session_id, &assistant_message.message.id) { - self.fail_background_turn(&session_id, &turn_id, error); + self.fail_background_turn(&session_id, &turn_id, error, String::new()); return; } if let Err(error) = self.store.complete_turn( @@ -231,7 +238,7 @@ impl SantiService { assistant_message.relation.session_seq, provider_response_id, ) { - self.fail_background_turn(&session_id, &turn_id, error); + self.fail_background_turn(&session_id, &turn_id, error, String::new()); return; } self.publish_stream( @@ -243,35 +250,34 @@ impl SantiService { ); } - fn fail_background_turn(&self, session_id: &str, turn_id: &str, error: String) { - let _ = self.store.fail_turn(turn_id, &error); - self.publish_stream( - session_id, - SantiStreamPayload::TurnFailed { - turn_id: turn_id.to_string(), - error, - }, - ); - } - async fn run_provider_turn( &self, 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 { round += 1; - let input = provider_messages(&self.store, soul_session_id)?; + 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.system_prompt_text(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, @@ -298,7 +304,7 @@ impl SantiService { } Err(error) => { timing.failed(round, "http_response", &error); - return Err(error); + return Err(ProviderTurnFailure::new(error, &assistant_text)); } }; let mut calls = Vec::new(); @@ -315,12 +321,12 @@ impl SantiService { Ok(event) => event, Err(error) => { timing.failed(round, "sse_event", &error); - self.fail_current_thinking_span( + provider_try!(self.fail_current_thinking_span( session_id, &mut current_thinking_span, error.clone(), - )?; - return Err(error); + )); + return Err(ProviderTurnFailure::new(error, &assistant_text)); } }; if let ProviderEvent::StreamTrace(trace) = event { @@ -340,13 +346,13 @@ impl SantiService { provider_response_id, } => { active_provider_response_id = provider_response_id.clone(); - self.ensure_thinking_span( + 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, turn_id, @@ -356,19 +362,19 @@ impl SantiService { } ProviderEvent::ReasoningSummaryDelta(delta) => { reasoning_summary.push_str(&delta); - self.update_thinking_span_summary( + provider_try!(self.update_thinking_span_summary( session_id, &mut summary_thinking_span, reasoning_summary.clone(), - )?; + )); } ProviderEvent::ReasoningSummaryDone(summary) => { reasoning_summary = summary; - self.update_thinking_span_summary( + provider_try!(self.update_thinking_span_summary( session_id, &mut summary_thinking_span, reasoning_summary.clone(), - )?; + )); } ProviderEvent::TextDelta(delta) => { let update = TextDeltaUpdate { @@ -381,15 +387,15 @@ impl SantiService { current_thinking_span: &mut current_thinking_span, active_provider_response_id: &active_provider_response_id, }; - self.handle_text_delta(delta, update)?; + provider_try!(self.handle_text_delta(delta, update)); } ProviderEvent::FunctionCallRequested(call) => { timing.function_call_requested(round, &call.name); - self.complete_current_thinking_span( + provider_try!(self.complete_current_thinking_span( session_id, &mut current_thinking_span, ThinkingCompletionReason::ToolCallRequested, - )?; + )); self.publish_turn_activity( session_id, turn_id, @@ -403,21 +409,21 @@ impl SantiService { } => { timing.completed(round); active_provider_response_id = provider_response_id.clone(); - self.complete_current_thinking_span( + 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) => { - self.fail_current_thinking_span( + provider_try!(self.fail_current_thinking_span( session_id, &mut current_thinking_span, error.clone(), - )?; - return Err(error); + )); + return Err(ProviderTurnFailure::new(error, &assistant_text)); } } } @@ -435,8 +441,12 @@ impl SantiService { TurnActivityState::RunningTool, active_provider_response_id.clone(), ); - let mut output = - self.handle_tool_call(session_id, soul_session_id, turn_id, call)?; + 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()); } 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/tools.rs b/apps/server/soma/crates/santi-core/src/service/tools.rs index b7dfd69..da57e90 100644 --- a/apps/server/soma/crates/santi-core/src/service/tools.rs +++ b/apps/server/soma/crates/santi-core/src/service/tools.rs @@ -1,13 +1,10 @@ -use std::{ - path::{Component, Path, PathBuf}, - process::Command, -}; +use std::{path::PathBuf, process::Command}; use santi_provider::{FunctionCallOutput, ProviderFunctionCall}; use serde::Deserialize; use serde_json::{Value, json}; -use crate::SantiStreamPayload; +use crate::{SantiStreamPayload, WorkspaceRoot, parse_workspace_uri}; use super::SantiService; @@ -100,13 +97,12 @@ impl SantiService { let Some(cwd) = cwd else { return Ok(self.execution_root()); }; - if let Some(path) = cwd.strip_prefix("@soul") { - return aliased_path(self.soul_memory_dir(), path, "@soul"); - } - if let Some(path) = cwd.strip_prefix("@session") { - return aliased_path(self.session_memory_dir(session_id), path, "@session"); - } - Ok(PathBuf::from(cwd)) + 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 { @@ -167,25 +163,6 @@ fn default_shell_name() -> &'static str { if cfg!(windows) { "pwsh" } else { "bash" } } -fn aliased_path(root: PathBuf, suffix: &str, alias: &str) -> Result { - if suffix.is_empty() { - return Ok(root); - } - let Some(path) = suffix.strip_prefix('/') else { - return Err(format!("invalid cwd alias: {alias}{suffix}")); - }; - let path = Path::new(path); - if path.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }) { - return Err(format!("cwd alias cannot escape {alias}")); - } - Ok(root.join(path)) -} - 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 9c91b96..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,11 +1,16 @@ use santi_provider::{ProviderFunctionTool, ProviderTool}; use serde_json::json; +use crate::{SESSION_WORKSPACE_URI, SOUL_WORKSPACE_URI, session_memory_uri, soul_memory_uri}; + pub(crate) fn provider_tools() -> Vec { + let soul_memory_uri = soul_memory_uri(); + let session_memory_uri = session_memory_uri(); vec![ProviderTool::Function(ProviderFunctionTool { name: "shell".to_string(), - description: "Run a shell command. By default commands run in the current execution workspace. Use cwd \"@soul\" to work in the current soul memory workspace, where @soul/MEMORY.md is the global memory source. Use cwd \"@session\" to work in the current session memory workspace, where @session/MEMORY.md is the local doing/todo memory source. Unix-like systems use bash by default; Windows uses pwsh by default." - .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": { @@ -15,7 +20,7 @@ pub(crate) fn provider_tools() -> Vec { }, "cwd": { "type": "string", - "description": "Optional working directory. Supports @soul, @soul/, @session, and @session/ aliases." + "description": format!("Optional workspace URI. Supports {SOUL_WORKSPACE_URI}, {SOUL_WORKSPACE_URI}, {SESSION_WORKSPACE_URI}, and {SESSION_WORKSPACE_URI}.") } }, "required": ["command"], diff --git a/apps/server/soma/crates/santi-core/src/store.rs b/apps/server/soma/crates/santi-core/src/store.rs index ae7dec5..7b7cff6 100644 --- a/apps/server/soma/crates/santi-core/src/store.rs +++ b/apps/server/soma/crates/santi-core/src/store.rs @@ -6,8 +6,8 @@ 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; @@ -20,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 = 7; +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 { @@ -270,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())?; @@ -281,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/db.rs b/apps/server/soma/crates/santi-core/src/store/db.rs index d1774e3..fd1feee 100644 --- a/apps/server/soma/crates/santi-core/src/store/db.rs +++ b/apps/server/soma/crates/santi-core/src/store/db.rs @@ -3,9 +3,9 @@ mod timeline; use rusqlite::{Connection, OptionalExtension, params}; use crate::{ - ActorType, Compact, Session, SessionEffect, SessionMessage, SessionProfile, SessionSummary, - SoulProfile, SoulSession, SoulSessionEntry, SoulSessionTargetType, ThinkingSpan, 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::*; @@ -197,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 @@ -219,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 @@ -381,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/rows.rs b/apps/server/soma/crates/santi-core/src/store/rows.rs index fefdeea..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,7 +2,7 @@ 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, ThinkingCompletionReason, ThinkingSpan, ThinkingSpanState, ToolCall, ToolResult, Turn, TurnStatus, TurnTriggerType, @@ -78,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 { @@ -226,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, } } @@ -233,6 +251,7 @@ fn message_state_from_db(value: &str) -> MessageState { match value { "pending" => MessageState::Pending, "fixed" => MessageState::Fixed, + "aborted" => MessageState::Aborted, _ => MessageState::Fixed, } } 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 860b35a..2a152f0 100644 --- a/apps/server/soma/crates/santi-core/src/store/runtime.rs +++ b/apps/server/soma/crates/santi-core/src/store/runtime.rs @@ -246,6 +246,41 @@ 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) 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 359817d..34bfbee 100644 --- a/apps/server/soma/crates/santi-core/src/store/schema.rs +++ b/apps/server/soma/crates/santi-core/src/store/schema.rs @@ -44,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, 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 873075c..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,18 @@ 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)] @@ -42,14 +46,14 @@ impl ProviderClient for FakeProvider { "id": "item_tool", "call_id": "call_shell", "name": "shell", - "arguments": r#"{"command":"pwd && printf \"\\n$SANTI_SESSION_MEMORY_DIR\"","cwd":"@session"}"#, + "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":"pwd && printf \"\\n$SANTI_SESSION_MEMORY_DIR\"","cwd":"@session"}"#.to_string(), + 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" + "cwd": SESSION_WORKSPACE_URI }), })), Ok(ProviderEvent::Completed { @@ -120,20 +124,56 @@ async fn sends_with_runtime() { assert!(instructions.contains("soul_name: Liberte")); assert!(instructions.contains("[santi-soul]")); assert!(instructions.contains("[santi-session]")); - assert!(instructions.contains("source: @soul/MEMORY.md")); - assert!(instructions.contains("source: @session/MEMORY.md")); + 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 tool_names = requests[0] - .tools - .as_ref() - .expect("tools") + 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!["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) @@ -194,13 +234,16 @@ async fn dispatches_tools() { .get("stdout") .and_then(|value| value.as_str()) .expect("shell stdout"); - let session_memory_dir = format!("runtime/sessions/{}/memory", session.session.id); - assert!(stdout.contains(&session_memory_dir)); + 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!(cwd.ends_with(&session_memory_dir)); + assert!(Path::new(cwd).ends_with(&session_memory_dir)); let requests = provider.requests.lock().unwrap(); assert_eq!(requests.len(), 2); diff --git a/apps/server/soma/crates/santi-core/tests/store.rs b/apps/server/soma/crates/santi-core/tests/store.rs index 10ef323..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, ThinkingCompletionReason}; +use santi_core::{ + ActorType, MessageContent, MessageKind, MessageState, SantiStore, ThinkingCompletionReason, +}; #[test] fn schema_matches_runtime() { @@ -71,6 +73,39 @@ 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"); diff --git a/apps/server/soma/crates/santi-core/tests/system_prompt.rs b/apps/server/soma/crates/santi-core/tests/system_prompt.rs index 5227aa9..6baea62 100644 --- a/apps/server/soma/crates/santi-core/tests/system_prompt.rs +++ b/apps/server/soma/crates/santi-core/tests/system_prompt.rs @@ -3,7 +3,8 @@ use std::{fs, sync::Arc}; use async_trait::async_trait; use futures_util::stream; use santi_core::{ - MaterialKind, MaterialRequest, SantiService, SantiServiceConfig, SessionMaterial, + MaterialKind, MaterialRequest, SESSION_WORKSPACE_URI, SOUL_WORKSPACE_URI, SantiService, + SantiServiceConfig, SessionMaterial, session_memory_uri, soul_memory_uri, }; use santi_provider::{ProviderClient, ProviderMetadata, ProviderStream}; @@ -30,7 +31,7 @@ impl ProviderClient for FakeProvider { #[test] fn renders_material_shape() { let harness = PromptHarness::open(); - harness.write_soul("---\nsanti_hint: true\n---\n# Soul"); + harness.write_soul("---\nplain: value\n---\n# Soul"); harness.write_session("# Session"); let text = harness.system_prompt().text; @@ -39,43 +40,44 @@ fn renders_material_shape() { 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("hint: Use soul memory")); - assert!(text.contains("source: @soul/MEMORY.md")); - assert!(text.contains("source: @session/MEMORY.md")); - assert!(text.contains("content:\n---\nsanti_hint: true\n---\n# Soul")); + 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 reports_hidden_hint() { +fn leaves_frontmatter_plain() { let harness = PromptHarness::open(); - harness.write_soul("---\nsanti_hint: false\n---\n# Soul"); + harness.write_soul("---\nplain: value\n---\n# Soul"); let text = harness.system_prompt().text; - assert!(text.contains("hint: Hidden, enable by set santi_hint: true in @soul/MEMORY.md")); -} - -#[test] -fn reports_invalid_hint() { - let harness = PromptHarness::open(); - harness.write_soul("---\nsanti_hint: yes\n---\n# Soul"); - - let text = harness.system_prompt().text; - - assert!(text.contains("hint: Invalid santi_hint in @soul/MEMORY.md. Use true|false.")); -} - -#[test] -fn reports_invalid_frontmatter() { - let harness = PromptHarness::open(); - harness.write_soul("---\nsanti_hint true\n---\n# Soul"); - - let text = harness.system_prompt().text; - - assert!(text.contains("hint: Invalid frontmatter in @soul/MEMORY.md. Use --- with santi_hint")); + assert!(text.contains("content:\n---\nplain: value\n---\n# Soul")); + assert!(!text.contains("hint:")); } struct PromptHarness { 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/deepseek.rs b/apps/server/soma/crates/santi-provider/src/chat_completions.rs similarity index 94% rename from apps/server/soma/crates/santi-provider/src/deepseek.rs rename to apps/server/soma/crates/santi-provider/src/chat_completions.rs index 7947b26..09c3f22 100644 --- a/apps/server/soma/crates/santi-provider/src/deepseek.rs +++ b/apps/server/soma/crates/santi-provider/src/chat_completions.rs @@ -13,7 +13,8 @@ use crate::{ }; #[derive(Debug, Clone)] -pub struct DeepSeekProviderConfig { +pub struct ChatCompletionsProviderConfig { + pub provider: String, pub api_key: String, pub model: String, pub base_url: String, @@ -23,13 +24,13 @@ pub struct DeepSeekProviderConfig { } #[derive(Debug, Clone)] -pub struct DeepSeekProvider { - config: DeepSeekProviderConfig, +pub struct ChatCompletionsProvider { + config: ChatCompletionsProviderConfig, client: Client, } -impl DeepSeekProvider { - pub fn new(config: DeepSeekProviderConfig) -> Self { +impl ChatCompletionsProvider { + pub fn new(config: ChatCompletionsProviderConfig) -> Self { Self { config, client: Client::new(), @@ -38,10 +39,10 @@ impl DeepSeekProvider { } #[async_trait] -impl ProviderClient for DeepSeekProvider { +impl ProviderClient for ChatCompletionsProvider { fn metadata(&self) -> ProviderMetadata { ProviderMetadata { - provider: Arc::from("deepseek"), + provider: Arc::from(self.config.provider.clone()), model: self.config.model.clone(), } } @@ -62,14 +63,15 @@ impl ProviderClient for DeepSeekProvider { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!( - "deepseek chat completions request failed: {status} {body}" + "{} chat completions request failed: {status} {body}", + self.config.provider )); } Ok(Box::pin(parse_sse(response.bytes_stream()))) } } -fn chat_body(config: &DeepSeekProviderConfig, request: ProviderRequest) -> Value { +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)), @@ -347,7 +349,7 @@ impl ToolCallAccumulator { target.id = id; } if let Some(function) = tool_call.function { - if let Some(name) = function.name { + if let Some(name) = function.name.filter(|name| !name.is_empty()) { target.name = name; } if let Some(arguments) = function.arguments { @@ -358,7 +360,8 @@ impl ToolCallAccumulator { } fn finish(&mut self, response_id: Option) -> Result, String> { - let response_id = response_id.ok_or_else(|| "missing deepseek response id".to_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() @@ -382,7 +385,7 @@ impl AccumulatedToolCall { self.arguments }; let arguments = serde_json::from_str::(&arguments_raw) - .map_err(|error| format!("invalid deepseek tool arguments: {error}"))?; + .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()), diff --git a/apps/server/soma/crates/santi-provider/src/lib.rs b/apps/server/soma/crates/santi-provider/src/lib.rs index 968e273..f902cfd 100644 --- a/apps/server/soma/crates/santi-provider/src/lib.rs +++ b/apps/server/soma/crates/santi-provider/src/lib.rs @@ -1,7 +1,7 @@ -mod deepseek; +mod chat_completions; mod openai; mod provider; -pub use deepseek::{DeepSeekProvider, DeepSeekProviderConfig}; +pub use chat_completions::{ChatCompletionsProvider, ChatCompletionsProviderConfig}; pub use openai::{OpenAIProvider, OpenAIProviderConfig}; pub use provider::*; diff --git a/apps/server/soma/crates/santi-provider/tests/deepseek.rs b/apps/server/soma/crates/santi-provider/tests/chat_completions.rs similarity index 88% rename from apps/server/soma/crates/santi-provider/tests/deepseek.rs rename to apps/server/soma/crates/santi-provider/tests/chat_completions.rs index 2f0a6fa..64a8f4a 100644 --- a/apps/server/soma/crates/santi-provider/tests/deepseek.rs +++ b/apps/server/soma/crates/santi-provider/tests/chat_completions.rs @@ -7,15 +7,16 @@ use std::{ use futures_util::StreamExt; use santi_provider::{ - DeepSeekProvider, DeepSeekProviderConfig, FunctionCallOutput, ProviderClient, ProviderEvent, - ProviderFunctionCall, ProviderFunctionTool, ProviderMessage, ProviderRequest, + 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(DeepSeekProviderConfig { + 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(), @@ -120,6 +121,26 @@ async fn parses_streamed_tool_call() { )); } +#[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![ @@ -148,7 +169,7 @@ async fn emits_stream_trace_events() { })); } -async fn capture_body(mut config: DeepSeekProviderConfig) -> Value { +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(); @@ -158,7 +179,7 @@ async fn capture_body(mut config: DeepSeekProviderConfig) -> Value { vec![r#"data: {"id":"chatcmpl_test","choices":[{"delta":{},"finish_reason":"stop"}]}"#], ); - let provider = DeepSeekProvider::new(config); + let provider = ChatCompletionsProvider::new(config); let mut stream = provider .stream_response(base_request(provider.metadata().model, None)) .await @@ -172,7 +193,8 @@ async fn capture_body(mut config: DeepSeekProviderConfig) -> Value { async fn capture_with_outputs() -> Value { let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); - let config = DeepSeekProviderConfig { + 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")), @@ -187,7 +209,7 @@ async fn capture_with_outputs() -> Value { vec![r#"data: {"id":"chatcmpl_test","choices":[{"delta":{},"finish_reason":"stop"}]}"#], ); - let provider = DeepSeekProvider::new(config); + let provider = ChatCompletionsProvider::new(config); let mut stream = provider .stream_response(base_request( provider.metadata().model, @@ -218,7 +240,8 @@ async fn capture_with_outputs() -> Value { async fn capture_with_output_rounds() -> Value { let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); - let config = DeepSeekProviderConfig { + 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")), @@ -233,7 +256,7 @@ async fn capture_with_output_rounds() -> Value { vec![r#"data: {"id":"chatcmpl_test","choices":[{"delta":{},"finish_reason":"stop"}]}"#], ); - let provider = DeepSeekProvider::new(config); + let provider = ChatCompletionsProvider::new(config); let mut stream = provider .stream_response(base_request( provider.metadata().model, @@ -286,7 +309,8 @@ async fn capture_events(lines: Vec<&'static str>) -> Vec { 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 = DeepSeekProviderConfig { + 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")), @@ -297,7 +321,7 @@ async fn capture_all_events(lines: Vec<&'static str>) -> Vec { let (tx, rx) = mpsc::channel(); let server = response_server(listener, tx, lines); - let provider = DeepSeekProvider::new(config); + let provider = ChatCompletionsProvider::new(config); let mut stream = provider .stream_response(base_request(provider.metadata().model, None)) .await diff --git a/docs/README.md b/docs/README.md index 0e245f3..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 concrete OpenAI and - DeepSeek implementations, 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/contracts/openapi.json b/packages/contracts/openapi.json index af328d2..469650c 100644 --- a/packages/contracts/openapi.json +++ b/packages/contracts/openapi.json @@ -603,6 +603,7 @@ "id", "actor_type", "actor_id", + "message_kind", "content", "state", "version", @@ -635,6 +636,9 @@ "id": { "type": "string" }, + "message_kind": { + "$ref": "#/components/schemas/MessageKind" + }, "state": { "$ref": "#/components/schemas/MessageState" }, @@ -661,6 +665,13 @@ } } }, + "MessageKind": { + "type": "string", + "enum": [ + "text", + "santi_system" + ] + }, "MessagePart": { "oneOf": [ { @@ -709,7 +720,8 @@ "type": "string", "enum": [ "pending", - "fixed" + "fixed", + "aborted" ] }, "SendSessionAcceptedResponse": { diff --git a/packages/contracts/src/openapi.ts b/packages/contracts/src/openapi.ts index 3c6d460..b318cc6 100644 --- a/packages/contracts/src/openapi.ts +++ b/packages/contracts/src/openapi.ts @@ -93,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 { @@ -107,6 +115,7 @@ export interface Message { created_at: String; deleted_at?: null | String; id: string; + message_kind: MessageKind; state: MessageState; updated_at: String; version: number; diff --git a/packages/mqueue/src/events.ts b/packages/mqueue/src/events.ts index ed672d1..100b903 100644 --- a/packages/mqueue/src/events.ts +++ b/packages/mqueue/src/events.ts @@ -1,15 +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, @@ -25,7 +23,7 @@ import type { TurnGroup, } from "./types"; -const DEFAULT_ACTOR_ID = "account_local"; +export { appendText, dedupeMessages, transientMessage } from "./lib/helpers/message"; export function sessionEvent( action: Action, @@ -123,53 +121,6 @@ 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], diff --git a/packages/mqueue/src/index.ts b/packages/mqueue/src/index.ts index 33837bb..207a270 100644 --- a/packages/mqueue/src/index.ts +++ b/packages/mqueue/src/index.ts @@ -424,6 +424,7 @@ function createMqueueCore(target: Window): SantiMqueue { void ensureMaterial(sessionId, payload.material.kind); }, turnFailed: (payload) => { + removeTransient(sessionId, payload.turn_id); markTurnFailed(sessionId, payload.turn_id, payload.error); dispatchMessage( target, 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/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 = ""