Move API code from user/ to agent/

Signed-off-by: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
Kent Overstreet 2026-04-04 00:29:11 -04:00
parent 021eafe6da
commit 9bebbcb635
22 changed files with 259 additions and 251 deletions

View file

@ -6,15 +6,18 @@
// Diagnostics: anomalies always logged to debug panel.
// Set POC_DEBUG=1 for verbose per-turn logging.
pub mod types;
mod openai;
pub use types::*;
use anyhow::Result;
use reqwest::Client;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use crate::user::types::*;
use crate::agent::tools::{ToolCall, ToolDef, FunctionCall};
use crate::user::ui_channel::{UiMessage, UiSender};
/// A JoinHandle that aborts its task when dropped.

View file

@ -8,7 +8,8 @@ use anyhow::Result;
use reqwest::Client;
use tokio::sync::mpsc;
use crate::user::types::*;
use crate::agent::tools::ToolDef;
use super::types::*;
use crate::user::ui_channel::{UiMessage, UiSender};
use super::StreamEvent;

View file

@ -1,4 +1,4 @@
// types.rs — OpenAI-compatible API types
// api/types.rs — OpenAI-compatible API types
//
// These mirror the OpenAI chat completion API, which is the de facto
// standard that OpenRouter, vLLM, llama.cpp, and most inference
@ -9,11 +9,7 @@
use chrono::Utc;
use serde::{Deserialize, Serialize};
// Re-export tool types that moved to agent::tools
pub use crate::agent::tools::{
ToolDef, ToolCall, ToolCallDelta, ToolOutput,
FunctionCall, FunctionDef, FunctionCallDelta,
};
use crate::agent::tools::{ToolCall, ToolCallDelta, ToolDef, FunctionDef};
/// Message content — either plain text or an array of content parts
/// (for multimodal messages with images). Serializes as a JSON string
@ -85,8 +81,6 @@ pub enum Role {
Tool,
}
// FunctionCall, FunctionDef moved to agent::tools
/// Chat completion request.
#[derive(Debug, Serialize)]
pub struct ChatRequest {
@ -180,8 +174,6 @@ pub struct Delta {
pub tool_calls: Option<Vec<ToolCallDelta>>,
}
// FunctionCallDelta moved to agent::tools
// --- Convenience constructors ---
impl Message {
@ -278,184 +270,3 @@ impl Message {
}
}
}
impl ToolDef {
pub fn new(name: &str, description: &str, parameters: serde_json::Value) -> Self {
Self {
tool_type: "function".to_string(),
function: FunctionDef {
name: name.to_string(),
description: description.to_string(),
parameters,
},
}
}
}
/// Mutable context state — the structured regions of the context window.
/// Conversation entry — either a regular message or memory content.
/// Memory entries preserve the original message for KV cache round-tripping.
#[derive(Debug, Clone)]
pub enum ConversationEntry {
Message(Message),
Memory { key: String, message: Message },
}
// Custom serde: serialize Memory with a "memory_key" field added to the message,
// plain messages serialize as-is. This keeps the conversation log readable.
impl Serialize for ConversationEntry {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
match self {
Self::Message(m) => m.serialize(s),
Self::Memory { key, message } => {
// Serialize message fields + memory_key
let json = serde_json::to_value(message).map_err(serde::ser::Error::custom)?;
let mut map = s.serialize_map(None)?;
if let serde_json::Value::Object(obj) = json {
for (k, v) in obj {
map.serialize_entry(&k, &v)?;
}
}
map.serialize_entry("memory_key", key)?;
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for ConversationEntry {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut json: serde_json::Value = serde_json::Value::deserialize(d)?;
if let Some(key) = json.as_object_mut().and_then(|o| o.remove("memory_key")) {
let key = key.as_str().unwrap_or("").to_string();
let message: Message = serde_json::from_value(json).map_err(serde::de::Error::custom)?;
Ok(Self::Memory { key, message })
} else {
let message: Message = serde_json::from_value(json).map_err(serde::de::Error::custom)?;
Ok(Self::Message(message))
}
}
}
impl ConversationEntry {
/// Get the API message for sending to the model.
pub fn api_message(&self) -> &Message {
match self {
Self::Message(m) => m,
Self::Memory { message, .. } => message,
}
}
pub fn is_memory(&self) -> bool {
matches!(self, Self::Memory { .. })
}
/// Get a reference to the inner message.
pub fn message(&self) -> &Message {
match self {
Self::Message(m) => m,
Self::Memory { message, .. } => message,
}
}
/// Get a mutable reference to the inner message.
pub fn message_mut(&mut self) -> &mut Message {
match self {
Self::Message(m) => m,
Self::Memory { message, .. } => message,
}
}
}
#[derive(Clone)]
pub struct ContextState {
pub system_prompt: String,
pub personality: Vec<(String, String)>,
pub journal: Vec<crate::agent::context::JournalEntry>,
pub working_stack: Vec<String>,
/// Conversation entries — messages and memory, interleaved in order.
/// Does NOT include system prompt, personality, or journal.
pub entries: Vec<ConversationEntry>,
}
// TODO: these should not be hardcoded absolute paths
pub fn working_stack_instructions_path() -> std::path::PathBuf {
dirs::home_dir().unwrap_or_default().join(".consciousness/config/working-stack.md")
}
pub fn working_stack_file_path() -> std::path::PathBuf {
dirs::home_dir().unwrap_or_default().join(".consciousness/working-stack.json")
}
impl ContextState {
/// Compute the context budget from typed sources.
pub fn budget(&self, count_str: &dyn Fn(&str) -> usize,
count_msg: &dyn Fn(&Message) -> usize,
window_tokens: usize) -> ContextBudget {
let id = count_str(&self.system_prompt)
+ self.personality.iter().map(|(_, c)| count_str(c)).sum::<usize>();
let jnl: usize = self.journal.iter().map(|e| count_str(&e.content)).sum();
let mut mem = 0;
let mut conv = 0;
for entry in &self.entries {
let tokens = count_msg(entry.api_message());
if entry.is_memory() { mem += tokens } else { conv += tokens }
}
ContextBudget {
identity_tokens: id,
memory_tokens: mem,
journal_tokens: jnl,
conversation_tokens: conv,
window_tokens,
}
}
pub fn render_context_message(&self) -> String {
let mut parts: Vec<String> = self.personality.iter()
.map(|(name, content)| format!("## {}\n\n{}", name, content))
.collect();
let instructions = std::fs::read_to_string(working_stack_instructions_path()).unwrap_or_default();
let mut stack_section = instructions;
if self.working_stack.is_empty() {
stack_section.push_str("\n## Current stack\n\n(empty)\n");
} else {
stack_section.push_str("\n## Current stack\n\n");
for (i, item) in self.working_stack.iter().enumerate() {
if i == self.working_stack.len() - 1 {
stack_section.push_str(&format!("{}\n", item));
} else {
stack_section.push_str(&format!(" [{}] {}\n", i, item));
}
}
}
parts.push(stack_section);
parts.join("\n\n---\n\n")
}
}
#[derive(Debug, Clone, Default)]
pub struct ContextBudget {
pub identity_tokens: usize,
pub memory_tokens: usize,
pub journal_tokens: usize,
pub conversation_tokens: usize,
pub window_tokens: usize,
}
impl ContextBudget {
pub fn used(&self) -> usize {
self.identity_tokens + self.memory_tokens + self.journal_tokens + self.conversation_tokens
}
pub fn free(&self) -> usize {
self.window_tokens.saturating_sub(self.used())
}
pub fn status_string(&self) -> String {
let total = self.window_tokens;
if total == 0 { return String::new(); }
let pct = |n: usize| if n == 0 { 0 } else { ((n * 100) / total).max(1) };
format!("id:{}% mem:{}% jnl:{}% conv:{}% free:{}%",
pct(self.identity_tokens), pct(self.memory_tokens),
pct(self.journal_tokens), pct(self.conversation_tokens), pct(self.free()))
}
}

View file

@ -4,10 +4,30 @@
// Journal entries are loaded from the memory graph store, not from
// a flat file — the parse functions are gone.
use crate::user::types::*;
use std::sync::{Arc, RwLock};
use crate::agent::api::types::*;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tiktoken_rs::CoreBPE;
/// A section of the context window, possibly with children.
#[derive(Debug, Clone)]
pub struct ContextSection {
pub name: String,
pub tokens: usize,
pub content: String,
pub children: Vec<ContextSection>,
}
/// Shared, live context state — agent writes, TUI reads for the debug screen.
pub type SharedContextState = Arc<RwLock<Vec<ContextSection>>>;
/// Create a new shared context state.
pub fn shared_context_state() -> SharedContextState {
Arc::new(RwLock::new(Vec::new()))
}
/// A single journal entry with its timestamp and content.
#[derive(Debug, Clone)]
pub struct JournalEntry {
@ -123,3 +143,171 @@ pub fn is_context_overflow(err: &anyhow::Error) -> bool {
pub fn is_stream_error(err: &anyhow::Error) -> bool {
err.to_string().contains("model stream error")
}
// --- Context state types ---
/// Conversation entry — either a regular message or memory content.
/// Memory entries preserve the original message for KV cache round-tripping.
#[derive(Debug, Clone)]
pub enum ConversationEntry {
Message(Message),
Memory { key: String, message: Message },
}
// Custom serde: serialize Memory with a "memory_key" field added to the message,
// plain messages serialize as-is. This keeps the conversation log readable.
impl Serialize for ConversationEntry {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
match self {
Self::Message(m) => m.serialize(s),
Self::Memory { key, message } => {
let json = serde_json::to_value(message).map_err(serde::ser::Error::custom)?;
let mut map = s.serialize_map(None)?;
if let serde_json::Value::Object(obj) = json {
for (k, v) in obj {
map.serialize_entry(&k, &v)?;
}
}
map.serialize_entry("memory_key", key)?;
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for ConversationEntry {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut json: serde_json::Value = serde_json::Value::deserialize(d)?;
if let Some(key) = json.as_object_mut().and_then(|o| o.remove("memory_key")) {
let key = key.as_str().unwrap_or("").to_string();
let message: Message = serde_json::from_value(json).map_err(serde::de::Error::custom)?;
Ok(Self::Memory { key, message })
} else {
let message: Message = serde_json::from_value(json).map_err(serde::de::Error::custom)?;
Ok(Self::Message(message))
}
}
}
impl ConversationEntry {
/// Get the API message for sending to the model.
pub fn api_message(&self) -> &Message {
match self {
Self::Message(m) => m,
Self::Memory { message, .. } => message,
}
}
pub fn is_memory(&self) -> bool {
matches!(self, Self::Memory { .. })
}
/// Get a reference to the inner message.
pub fn message(&self) -> &Message {
match self {
Self::Message(m) => m,
Self::Memory { message, .. } => message,
}
}
/// Get a mutable reference to the inner message.
pub fn message_mut(&mut self) -> &mut Message {
match self {
Self::Message(m) => m,
Self::Memory { message, .. } => message,
}
}
}
#[derive(Clone)]
pub struct ContextState {
pub system_prompt: String,
pub personality: Vec<(String, String)>,
pub journal: Vec<JournalEntry>,
pub working_stack: Vec<String>,
/// Conversation entries — messages and memory, interleaved in order.
/// Does NOT include system prompt, personality, or journal.
pub entries: Vec<ConversationEntry>,
}
// TODO: these should not be hardcoded absolute paths
pub fn working_stack_instructions_path() -> std::path::PathBuf {
dirs::home_dir().unwrap_or_default().join(".consciousness/config/working-stack.md")
}
pub fn working_stack_file_path() -> std::path::PathBuf {
dirs::home_dir().unwrap_or_default().join(".consciousness/working-stack.json")
}
impl ContextState {
/// Compute the context budget from typed sources.
pub fn budget(&self, count_str: &dyn Fn(&str) -> usize,
count_msg: &dyn Fn(&Message) -> usize,
window_tokens: usize) -> ContextBudget {
let id = count_str(&self.system_prompt)
+ self.personality.iter().map(|(_, c)| count_str(c)).sum::<usize>();
let jnl: usize = self.journal.iter().map(|e| count_str(&e.content)).sum();
let mut mem = 0;
let mut conv = 0;
for entry in &self.entries {
let tokens = count_msg(entry.api_message());
if entry.is_memory() { mem += tokens } else { conv += tokens }
}
ContextBudget {
identity_tokens: id,
memory_tokens: mem,
journal_tokens: jnl,
conversation_tokens: conv,
window_tokens,
}
}
pub fn render_context_message(&self) -> String {
let mut parts: Vec<String> = self.personality.iter()
.map(|(name, content)| format!("## {}\n\n{}", name, content))
.collect();
let instructions = std::fs::read_to_string(working_stack_instructions_path()).unwrap_or_default();
let mut stack_section = instructions;
if self.working_stack.is_empty() {
stack_section.push_str("\n## Current stack\n\n(empty)\n");
} else {
stack_section.push_str("\n## Current stack\n\n");
for (i, item) in self.working_stack.iter().enumerate() {
if i == self.working_stack.len() - 1 {
stack_section.push_str(&format!("{}\n", item));
} else {
stack_section.push_str(&format!(" [{}] {}\n", i, item));
}
}
}
parts.push(stack_section);
parts.join("\n\n---\n\n")
}
}
#[derive(Debug, Clone, Default)]
pub struct ContextBudget {
pub identity_tokens: usize,
pub memory_tokens: usize,
pub journal_tokens: usize,
pub conversation_tokens: usize,
pub window_tokens: usize,
}
impl ContextBudget {
pub fn used(&self) -> usize {
self.identity_tokens + self.memory_tokens + self.journal_tokens + self.conversation_tokens
}
pub fn free(&self) -> usize {
self.window_tokens.saturating_sub(self.used())
}
pub fn status_string(&self) -> String {
let total = self.window_tokens;
if total == 0 { return String::new(); }
let pct = |n: usize| if n == 0 { 0 } else { ((n * 100) / total).max(1) };
format!("id:{}% mem:{}% jnl:{}% conv:{}% free:{}%",
pct(self.identity_tokens), pct(self.memory_tokens),
pct(self.journal_tokens), pct(self.conversation_tokens), pct(self.free()))
}
}

View file

@ -13,6 +13,7 @@
// to send here. This module just handles single turns: prompt
// in, response out, tool calls dispatched.
pub mod api;
pub mod context;
pub mod tools;
pub mod training;
@ -20,13 +21,17 @@ pub mod training;
use anyhow::Result;
use tiktoken_rs::CoreBPE;
use crate::user::api::ApiClient;
use crate::agent::context as journal;
use api::{ApiClient, StreamEvent};
use context as journal;
use tools::{ToolCall, ToolDef, FunctionCall, summarize_args};
use crate::user::log::ConversationLog;
use crate::user::api::StreamEvent;
use crate::agent::tools::{ToolCall, ToolDef, FunctionCall, summarize_args};
use crate::user::types::*;
use crate::user::ui_channel::{ContextSection, SharedContextState, StatusInfo, StreamTarget, UiMessage, UiSender};
use crate::agent::api::types::*;
use crate::agent::context::{
ConversationEntry, ContextState, ContextBudget,
working_stack_instructions_path, working_stack_file_path,
};
use crate::user::ui_channel::{ContextSection, SharedContextState, StreamTarget, StatusInfo, UiMessage, UiSender};
/// Result of a single agent turn.
pub struct TurnResult {
@ -447,9 +452,7 @@ impl Agent {
let _ = ui_tx.send(UiMessage::TextDelta("\n".to_string(), target));
}
let msg = crate::user::api::build_response_message(content, tool_calls);
let msg = api::build_response_message(content, tool_calls);
if let Some(usage) = &usage {
self.last_prompt_tokens = usage.prompt_tokens;

View file

@ -7,7 +7,7 @@
use anyhow::{Context, Result};
use super::ToolOutput;
use crate::user::types::ToolDef;
use super::ToolDef;
pub(super) fn pause(_args: &serde_json::Value) -> Result<ToolOutput> {
Ok(ToolOutput {

View file

@ -5,13 +5,13 @@
// working_stack) and delegates everything else to thought::dispatch.
// Core tools
pub mod bash;
pub mod edit;
pub mod glob;
pub mod grep;
pub mod memory;
pub mod read;
pub mod write;
mod bash;
mod edit;
mod glob;
mod grep;
mod memory;
mod read;
mod write;
// Agent-specific tools
mod control;
@ -53,6 +53,19 @@ pub struct ToolDef {
pub function: FunctionDef,
}
impl ToolDef {
pub fn new(name: &str, description: &str, parameters: serde_json::Value) -> Self {
Self {
tool_type: "function".to_string(),
function: FunctionDef {
name: name.to_string(),
description: description.to_string(),
parameters,
},
}
}
}
/// A tool call requested by the model.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {

View file

@ -9,7 +9,7 @@ use base64::Engine;
use serde::Deserialize;
use super::ToolOutput;
use crate::user::types::ToolDef;
use super::ToolDef;
#[derive(Deserialize)]
struct Args {

View file

@ -4,7 +4,7 @@
// internal tool — the agent uses it to maintain context across turns
// and compaction. The model should never mention it to the user.
use crate::user::types::ToolDef;
use super::ToolDef;
use serde_json::json;
pub fn definition() -> ToolDef {

View file

@ -8,8 +8,10 @@
// Column sums = response memory-dependence (training candidates)
use std::time::Instant;
use crate::user::api::ApiClient;
use crate::user::types::*;
use super::api::ApiClient;
use crate::agent::api::types::*;
use crate::agent::context::{ConversationEntry, ContextState};
use crate::user::ui_channel::{UiMessage, UiSender};
/// Timeout for individual /v1/score API calls.

View file

@ -34,7 +34,9 @@ use poc_memory::dbglog;
use poc_memory::user::*;
use poc_memory::agent::{Agent, TurnResult};
use poc_memory::user::api::ApiClient;
use poc_memory::agent::api::ApiClient;
use poc_memory::agent::api::types as api_types;
use poc_memory::agent::context as ctx;
use poc_memory::user::tui::HotkeyAction;
use poc_memory::config::{self, AppConfig, SessionConfig};
use poc_memory::user::ui_channel::{ContextInfo, StatusInfo, StreamTarget, UiMessage};
@ -518,7 +520,7 @@ impl Session {
let entries = agent_guard.entries_mut();
let mut last_user_text = None;
while let Some(entry) = entries.last() {
if entry.message().role == poc_memory::user::types::Role::User {
if entry.message().role == api_types::Role::User {
last_user_text =
Some(entries.pop().unwrap().message().content_text().to_string());
break;
@ -1091,7 +1093,7 @@ fn drain_ui_messages(rx: &mut ui_channel::UiReceiver, app: &mut tui::App) -> boo
/// conversation history immediately on restart. Shows user input,
/// assistant responses, and brief tool call summaries. Skips the system
/// prompt, context message, DMN plumbing, and image injection messages.
fn replay_session_to_ui(entries: &[types::ConversationEntry], ui_tx: &ui_channel::UiSender) {
fn replay_session_to_ui(entries: &[ctx::ConversationEntry], ui_tx: &ui_channel::UiSender) {
use poc_memory::user::ui_channel::StreamTarget;
dbglog!("[replay] replaying {} entries to UI", entries.len());
@ -1111,8 +1113,8 @@ fn replay_session_to_ui(entries: &[types::ConversationEntry], ui_tx: &ui_channel
if entry.is_memory() { continue; }
let msg = entry.message();
match msg.role {
types::Role::System => {}
types::Role::User => {
api_types::Role::System => {}
api_types::Role::User => {
// Skip context message (always the first user message)
if !seen_first_user {
seen_first_user = true;
@ -1140,7 +1142,7 @@ fn replay_session_to_ui(entries: &[types::ConversationEntry], ui_tx: &ui_channel
let _ = ui_tx.send(UiMessage::UserInput(text.to_string()));
}
}
types::Role::Assistant => {
api_types::Role::Assistant => {
if let Some(ref calls) = msg.tool_calls {
for call in calls {
let _ = ui_tx.send(UiMessage::ToolCall {
@ -1156,7 +1158,7 @@ fn replay_session_to_ui(entries: &[types::ConversationEntry], ui_tx: &ui_channel
.send(UiMessage::TextDelta(format!("{}\n", text), target));
}
}
types::Role::Tool => {
api_types::Role::Tool => {
let text = msg.content_text();
let preview: String =
text.lines().take(3).collect::<Vec<_>>().join("\n");

View file

@ -7,9 +7,9 @@
//
// Activated when config has api_base_url set.
use crate::user::api::ApiClient;
use crate::user::types::*;
use crate::agent::tools::{self as agent_tools};
use crate::agent::api::ApiClient;
use crate::agent::api::types::*;
use crate::agent::tools::{self as agent_tools, ToolOutput};
use std::sync::OnceLock;

View file

@ -14,7 +14,7 @@ use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use crate::user::types::ConversationEntry;
use crate::agent::context::ConversationEntry;
pub struct ConversationLog {
path: PathBuf,

View file

@ -9,8 +9,6 @@
// - cli, context, dmn, identity, log, observe, parsing, tui
// Config moved to crate::config (unified with memory config)
pub mod api;
pub mod types;
pub mod ui_channel;
pub mod cli;
pub mod dmn;

View file

@ -11,7 +11,8 @@
// Also handles streaming artifacts: whitespace inside XML tags from
// token boundaries, </think> tags, etc.
use crate::user::types::*;
use crate::agent::api::types::*;
use crate::agent::tools::{ToolCall, ToolDef, FunctionCall};
/// Parse leaked tool calls from response text.
/// Looks for `<tool_call>...</tool_call>` blocks and tries both

View file

@ -15,11 +15,11 @@
// subconscious_screen.rs — F3 subconscious (consolidation agents)
// unconscious_screen.rs — F4 unconscious (memory daemon status)
mod main_screen;
mod context_screen;
mod subconscious_screen;
mod unconscious_screen;
mod thalamus_screen;
mod main;
mod context;
mod subconscious;
mod unconscious;
mod thalamus;
pub(crate) const SCREEN_LEGEND: &str = " F1=interact F2=conscious F3=subconscious F4=unconscious F5=thalamus ";
/// Subconscious agents — interact with conscious context

View file

@ -11,16 +11,11 @@
// The channel also fans out to a broadcast channel so the observation
// socket (observe.rs) can subscribe without touching the main path.
use std::sync::{Arc, RwLock};
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc};
/// Shared, live context state — agent writes, TUI reads for the debug screen.
pub type SharedContextState = Arc<RwLock<Vec<ContextSection>>>;
/// Create a new shared context state.
pub fn shared_context_state() -> SharedContextState {
Arc::new(RwLock::new(Vec::new()))
}
// Re-export context types that moved to agent::context
pub use crate::agent::context::{ContextSection, SharedContextState, shared_context_state};
// ActiveToolCall lives in agent::tools — re-export for TUI access
pub use crate::agent::tools::ActiveToolCall;
@ -57,15 +52,6 @@ pub struct StatusInfo {
pub context_budget: String,
}
/// A section of the context window, possibly with children.
#[derive(Debug, Clone)]
pub struct ContextSection {
pub name: String,
pub tokens: usize,
pub content: String,
pub children: Vec<ContextSection>,
}
/// Context loading details for the debug screen.
#[derive(Debug, Clone)]
pub struct ContextInfo {