From 33a1b44946acf316d5dd9226b547dfbf89f2624c Mon Sep 17 00:00:00 2001 From: vegard Date: Tue, 17 Mar 2026 19:20:17 +0000 Subject: [PATCH] Implementer Claude som chat-deltaker (Fase A: MVP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude er nå en agent-node i grafen som kan delta i samtaler. Når en bruker sender melding i en kommunikasjonsnode der Claude er deltaker, enqueues en agent_respond-jobb som kaller claude CLI direkte og skriver svaret tilbake til chatten. Nye filer: - migrations/007_agent_system.sql: agent_identities, agent_permissions, ai_usage_log - maskinrommet/src/agent.rs: agent_respond job handler - scripts/maskinrommet.service: systemd-tjeneste for native kjøring - scripts/maskinrommet-env.sh: genererer env med Docker container-IPs Endringer: - intentions.rs: trigger agent_respond ved melding i agent-chat - jobs.rs: dispatch agent_respond til agent-handler - frontend chat: bot-badge (🤖) og amber-farge på agent-meldinger - LiteLLM config: resonering-modellalias via OpenRouter Maskinrommet kjører nå direkte på hosten (ikke i Docker) for å ha tilgang til claude CLI. Caddy peker til host.docker.internal. Co-Authored-By: Claude Opus 4.6 (1M context) --- config/litellm/config.yaml | 9 + frontend/src/routes/chat/[id]/+page.svelte | 14 +- maskinrommet/src/agent.rs | 242 +++++++++++++++++++++ maskinrommet/src/intentions.rs | 45 ++++ maskinrommet/src/jobs.rs | 4 + maskinrommet/src/main.rs | 1 + migrations/007_agent_system.sql | 107 +++++++++ scripts/maskinrommet-env.sh | 27 +++ scripts/maskinrommet.service | 18 ++ 9 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 maskinrommet/src/agent.rs create mode 100644 migrations/007_agent_system.sql create mode 100755 scripts/maskinrommet-env.sh create mode 100644 scripts/maskinrommet.service diff --git a/config/litellm/config.yaml b/config/litellm/config.yaml index a262017..2cef0b9 100644 --- a/config/litellm/config.yaml +++ b/config/litellm/config.yaml @@ -20,6 +20,15 @@ model_list: model: "gemini/gemini-flash-latest" api_key: "os.environ/GEMINI_API_KEY" + - model_name: "resonering" + litellm_params: + model: "openrouter/anthropic/claude-sonnet-4" + api_key: "os.environ/OPENROUTER_API_KEY" + - model_name: "resonering" + litellm_params: + model: "openrouter/google/gemini-2.5-flash" + api_key: "os.environ/OPENROUTER_API_KEY" + router_settings: routing_strategy: "simple-shuffle" num_retries: 2 diff --git a/frontend/src/routes/chat/[id]/+page.svelte b/frontend/src/routes/chat/[id]/+page.svelte index b605db6..e4deb27 100644 --- a/frontend/src/routes/chat/[id]/+page.svelte +++ b/frontend/src/routes/chat/[id]/+page.svelte @@ -133,6 +133,13 @@ return sender?.title || sender?.nodeKind || 'Ukjent'; } + /** Check if message sender is an agent (bot) */ + function isAgentMessage(node: Node): boolean { + if (!node.createdBy) return false; + const sender = nodeStore.get(node.createdBy); + return sender?.nodeKind === 'agent'; + } + /** Check if this message is from the current user */ function isOwnMessage(node: Node): boolean { return !!nodeId && node.createdBy === nodeId; @@ -227,11 +234,12 @@ {#each messages as msg (msg.id)} {@const own = isOwnMessage(msg)} {@const audio = isAudioNode(msg)} + {@const bot = isAgentMessage(msg)}
-
+
{#if !own} -

- {senderName(msg)} +

+ {#if bot}🤖 {/if}{senderName(msg)}

{/if} {#if audio} diff --git a/maskinrommet/src/agent.rs b/maskinrommet/src/agent.rs new file mode 100644 index 0000000..4b9bdf8 --- /dev/null +++ b/maskinrommet/src/agent.rs @@ -0,0 +1,242 @@ +// Agent — Claude som chat-deltaker. +// +// Håndterer `agent_respond`-jobber fra jobbkøen. +// Kaller `claude -p` direkte som subprocess — maskinrommet kjører +// på hosten (ikke i Docker) og har tilgang til claude CLI. + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::jobs::JobRow; +use crate::stdb::StdbClient; + +#[derive(Debug)] +struct AgentConfig { + max_context_messages: i64, + max_consecutive_agent_messages: i64, + rate_limit_per_hour: i64, +} + +#[derive(sqlx::FromRow, Debug)] +struct MessageRow { + #[allow(dead_code)] + id: Uuid, + content: Option, + created_by: Uuid, + #[allow(dead_code)] + created_at: chrono::DateTime, +} + +#[derive(sqlx::FromRow, Debug)] +struct ParticipantRow { + id: Uuid, + title: Option, + node_kind: String, +} + +pub async fn handle_agent_respond( + job: &JobRow, + db: &PgPool, + stdb: &StdbClient, +) -> Result { + let communication_id: Uuid = job.payload["communication_id"] + .as_str().and_then(|s| s.parse().ok()) + .ok_or("Mangler communication_id")?; + let message_id: Uuid = job.payload["message_id"] + .as_str().and_then(|s| s.parse().ok()) + .ok_or("Mangler message_id")?; + let agent_node_id: Uuid = job.payload["agent_node_id"] + .as_str().and_then(|s| s.parse().ok()) + .ok_or("Mangler agent_node_id")?; + let sender_node_id: Uuid = job.payload["sender_node_id"] + .as_str().and_then(|s| s.parse().ok()) + .ok_or("Mangler sender_node_id")?; + + tracing::info!( + communication_id = %communication_id, + message_id = %message_id, + agent_node_id = %agent_node_id, + sender_node_id = %sender_node_id, + "Starter agent_respond" + ); + + let config = load_agent_config(db, agent_node_id).await?; + + // Kill switch + let is_active: bool = sqlx::query_scalar( + "SELECT is_active FROM agent_identities WHERE node_id = $1", + ).bind(agent_node_id).fetch_optional(db).await + .map_err(|e| format!("DB-feil: {e}"))?.unwrap_or(false); + if !is_active { + return Ok(serde_json::json!({"status": "skipped", "reason": "agent_inactive"})); + } + + // Rate limiting + let count: i64 = sqlx::query_scalar::<_, Option>( + "SELECT COUNT(*) FROM ai_usage_log WHERE agent_node_id = $1 AND created_at > now() - interval '1 hour'", + ).bind(agent_node_id).fetch_one(db).await + .map_err(|e| format!("DB-feil: {e}"))?.unwrap_or(0); + if count >= config.rate_limit_per_hour { + return Ok(serde_json::json!({"status": "skipped", "reason": "rate_limit"})); + } + + // Loop-prevensjon + let recent: Vec = sqlx::query_scalar( + "SELECT n.created_by FROM nodes n JOIN edges e ON e.source_id = n.id WHERE e.target_id = $1 AND e.edge_type = 'belongs_to' ORDER BY n.created_at DESC LIMIT $2", + ).bind(communication_id).bind(config.max_consecutive_agent_messages) + .fetch_all(db).await.map_err(|e| format!("DB-feil: {e}"))?; + if !recent.is_empty() && recent.iter().all(|s| *s == agent_node_id) { + return Ok(serde_json::json!({"status": "skipped", "reason": "loop_prevention"})); + } + + // Hent meldingshistorikk + let mut messages = sqlx::query_as::<_, MessageRow>( + "SELECT n.id, n.content, n.created_by, n.created_at FROM nodes n JOIN edges e ON e.source_id = n.id WHERE e.target_id = $1 AND e.edge_type = 'belongs_to' AND n.node_kind = 'content' ORDER BY n.created_at DESC LIMIT $2", + ).bind(communication_id).bind(config.max_context_messages) + .fetch_all(db).await.map_err(|e| format!("DB-feil: {e}"))?; + messages.reverse(); + if messages.is_empty() { + return Ok(serde_json::json!({"status": "skipped", "reason": "no_messages"})); + } + + // Kontekst + let comm_title: String = sqlx::query_scalar::<_, Option>( + "SELECT title FROM nodes WHERE id = $1", + ).bind(communication_id).fetch_optional(db).await + .map_err(|e| format!("DB-feil: {e}"))?.flatten() + .unwrap_or_else(|| "Samtale".to_string()); + + let participants = sqlx::query_as::<_, ParticipantRow>( + "SELECT n.id, n.title, n.node_kind FROM nodes n JOIN edges e ON e.source_id = n.id WHERE e.target_id = $1 AND e.edge_type IN ('owner', 'member_of')", + ).bind(communication_id).fetch_all(db).await + .map_err(|e| format!("DB-feil: {e}"))?; + + let permission: String = sqlx::query_scalar( + "SELECT permission FROM agent_permissions WHERE user_node_id = $1 AND agent_node_id = $2", + ).bind(sender_node_id).bind(agent_node_id) + .fetch_optional(db).await.map_err(|e| format!("DB-feil: {e}"))? + .unwrap_or_else(|| "none".to_string()); + + // Bygg prompt + let name_map: std::collections::HashMap = participants.iter() + .map(|p| (p.id, p.title.clone().unwrap_or_else(|| p.node_kind.clone()))).collect(); + + let participant_names: String = participants.iter() + .filter(|p| p.id != agent_node_id) + .map(|p| p.title.as_deref().unwrap_or("Ukjent")) + .collect::>().join(", "); + + let mut conversation = String::new(); + for m in &messages { + let name = name_map.get(&m.created_by).map(|s| s.as_str()).unwrap_or("Ukjent"); + let content = m.content.as_deref().unwrap_or(""); + if m.created_by == agent_node_id { + conversation.push_str(&format!("Claude: {content}\n")); + } else { + conversation.push_str(&format!("{name}: {content}\n")); + } + } + + let perm_desc = match permission.as_str() { + "direct" => "Brukeren har 'direct'-tilgang.", + "propose" => "Brukeren har 'propose'-tilgang.", + _ => "", + }; + + let prompt = format!( + r#"Du er Claude, en AI-assistent integrert i Synops-plattformen. +Du deltar i samtalen "{comm_title}" med {participant_names}. +Svar på norsk med mindre brukeren skriver på engelsk. +{perm_desc} +Svar konsist. Bruk vanlig tekst uten markdown-overskrifter. +Svar KUN med meldingsteksten. + +--- Samtalehistorikk --- +{conversation}--- Svar ---"# + ); + + // Kall claude CLI direkte + let claude_path = std::env::var("CLAUDE_PATH").unwrap_or_else(|_| "claude".to_string()); + let project_dir = std::env::var("PROJECT_DIR").unwrap_or_else(|_| "/home/vegard/synops".to_string()); + + tracing::info!(prompt_len = prompt.len(), "Kaller claude CLI"); + + let output = tokio::process::Command::new(&claude_path) + .arg("-p") + .arg(&prompt) + .arg("--output-format") + .arg("json") + .arg("--dangerously-skip-permissions") + .env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") + .current_dir(&project_dir) + .output() + .await + .map_err(|e| format!("Kunne ikke starte claude: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("claude feilet ({}): {}", output.status, &stderr[..stderr.len().min(500)])); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let response_text = match serde_json::from_str::(&stdout) { + Ok(json) => json["result"].as_str().unwrap_or("").to_string(), + Err(_) => stdout.trim().to_string(), + }; + + if response_text.is_empty() { + return Err("Tom respons fra claude".to_string()); + } + + tracing::info!(response_len = response_text.len(), "Fikk svar fra claude"); + + // Skriv svar + let reply_id = Uuid::now_v7(); + let edge_id = Uuid::now_v7(); + let agent_str = agent_node_id.to_string(); + let reply_str = reply_id.to_string(); + let edge_str = edge_id.to_string(); + let comm_str = communication_id.to_string(); + let empty = serde_json::json!({}).to_string(); + + stdb.create_node(&reply_str, "content", "", &response_text, "hidden", &empty, &agent_str) + .await.map_err(|e| format!("STDB: {e}"))?; + stdb.create_edge(&edge_str, &reply_str, &comm_str, "belongs_to", &empty, false, &agent_str) + .await.map_err(|e| format!("STDB: {e}"))?; + + let metadata = serde_json::json!({}); + sqlx::query("INSERT INTO nodes (id, node_kind, content, visibility, metadata, created_by) VALUES ($1, 'content', $2, 'hidden'::visibility, $3, $4)") + .bind(reply_id).bind(&response_text).bind(&metadata).bind(agent_node_id) + .execute(db).await.map_err(|e| format!("PG: {e}"))?; + sqlx::query("INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) VALUES ($1, $2, $3, 'belongs_to', '{}', false, $4)") + .bind(edge_id).bind(reply_id).bind(communication_id).bind(agent_node_id) + .execute(db).await.map_err(|e| format!("PG: {e}"))?; + sqlx::query("INSERT INTO ai_usage_log (agent_node_id, communication_id, job_id, model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens, job_type) VALUES ($1, $2, $3, 'claude-code', 'claude-code-cli', 0, 0, 0, 'agent_respond')") + .bind(agent_node_id).bind(communication_id).bind(job.id) + .execute(db).await.map_err(|e| format!("PG: {e}"))?; + + tracing::info!(reply_node_id = %reply_id, "Agent-svar persistert"); + + Ok(serde_json::json!({ + "status": "completed", + "reply_node_id": reply_id.to_string(), + "response_len": response_text.len() + })) +} + +async fn load_agent_config(db: &PgPool, agent_node_id: Uuid) -> Result { + let c: serde_json::Value = sqlx::query_scalar("SELECT config FROM agent_identities WHERE node_id = $1") + .bind(agent_node_id).fetch_optional(db).await + .map_err(|e| format!("DB-feil: {e}"))?.unwrap_or(serde_json::json!({})); + Ok(AgentConfig { + max_context_messages: c["max_context_messages"].as_i64().unwrap_or(50), + max_consecutive_agent_messages: c["max_consecutive_agent_messages"].as_i64().unwrap_or(3), + rate_limit_per_hour: c["rate_limit_per_hour"].as_i64().unwrap_or(60), + }) +} + +pub async fn find_agent_participant(db: &PgPool, communication_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_scalar( + "SELECT n.id FROM nodes n JOIN edges e ON e.source_id = n.id JOIN agent_identities ai ON ai.node_id = n.id WHERE e.target_id = $1 AND e.edge_type IN ('owner', 'member_of') AND n.node_kind = 'agent' AND ai.is_active = true LIMIT 1", + ).bind(communication_id).fetch_optional(db).await +} diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index 290609b..a7b9915 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -389,6 +389,51 @@ pub async fn create_node( None }; + // -- Agent-trigger: sjekk om kommunikasjonsnoden har en agent-deltaker -- + if let Some(ctx_id) = req.context_id { + let db_clone = state.db.clone(); + let user_node_id = user.node_id; + let created_node_id = node_id; + tokio::spawn(async move { + match crate::agent::find_agent_participant(&db_clone, ctx_id).await { + Ok(Some(agent_id)) if agent_id != effective_identity => { + // Agent funnet, og melding er ikke fra agenten selv + let payload = serde_json::json!({ + "communication_id": ctx_id.to_string(), + "message_id": created_node_id.to_string(), + "agent_node_id": agent_id.to_string(), + "sender_node_id": user_node_id.to_string() + }); + match crate::jobs::enqueue(&db_clone, "agent_respond", payload, None, 8).await { + Ok(job_id) => { + tracing::info!( + job_id = %job_id, + communication_id = %ctx_id, + agent_node_id = %agent_id, + "agent_respond-jobb lagt i kø" + ); + } + Err(e) => { + tracing::error!( + communication_id = %ctx_id, + error = %e, + "Kunne ikke legge agent_respond-jobb i kø" + ); + } + } + } + Ok(_) => {} // Ingen agent, eller melding fra agenten selv + Err(e) => { + tracing::error!( + communication_id = %ctx_id, + error = %e, + "Feil ved agent-deltaker-sjekk" + ); + } + } + }); + } + Ok(Json(CreateNodeResponse { node_id, belongs_to_edge_id })) } diff --git a/maskinrommet/src/jobs.rs b/maskinrommet/src/jobs.rs index cbd015d..0885372 100644 --- a/maskinrommet/src/jobs.rs +++ b/maskinrommet/src/jobs.rs @@ -8,6 +8,7 @@ use sqlx::PgPool; use uuid::Uuid; +use crate::agent; use crate::cas::CasStore; use crate::stdb::StdbClient; use crate::transcribe; @@ -151,6 +152,9 @@ async fn dispatch( "whisper_transcribe" => { transcribe::handle_whisper_job(job, db, stdb, cas, whisper_url).await } + "agent_respond" => { + agent::handle_agent_respond(job, db, stdb).await + } other => Err(format!("Ukjent jobbtype: {other}")), } } diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 440cecc..f2ca5d7 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -1,3 +1,4 @@ +pub mod agent; mod auth; pub mod cas; mod intentions; diff --git a/migrations/007_agent_system.sql b/migrations/007_agent_system.sql new file mode 100644 index 0000000..c059c79 --- /dev/null +++ b/migrations/007_agent_system.sql @@ -0,0 +1,107 @@ +-- 007_agent_system.sql — Agentsystem for Claude som chat-deltaker. +-- +-- Nye tabeller: +-- agent_identities — kobler agent-noder til config og nøkkel +-- agent_permissions — autorisasjonsnivåer (direct/propose) +-- ai_usage_log — tokenregnskap for AI-kall +-- +-- Seed: Claude-agentnode med agent_identity + tillatelse for Vegard. + +-- ============================================================================= +-- 1. Agent-identiteter +-- ============================================================================= + +CREATE TABLE agent_identities ( + node_id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE, + agent_key TEXT UNIQUE NOT NULL, -- 'claude-main' + agent_type TEXT NOT NULL, -- 'claude' + is_active BOOLEAN NOT NULL DEFAULT true, + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE agent_identities IS 'Agent-noder som kan delta i samtaler (Claude, fremtidige agenter)'; +COMMENT ON COLUMN agent_identities.agent_key IS 'Unik nøkkel for oppslag (claude-main)'; +COMMENT ON COLUMN agent_identities.config IS 'model_alias, system_prompt, max_context_messages, etc.'; + +-- ============================================================================= +-- 2. Agent-tillatelser +-- ============================================================================= + +CREATE TABLE agent_permissions ( + user_node_id UUID REFERENCES nodes(id) ON DELETE CASCADE, + agent_node_id UUID REFERENCES nodes(id) ON DELETE CASCADE, + permission TEXT NOT NULL CHECK (permission IN ('direct', 'propose')), + PRIMARY KEY (user_node_id, agent_node_id) +); + +COMMENT ON TABLE agent_permissions IS 'Hvem kan be agenter om hva (direct = implementer, propose = foreslå)'; + +-- ============================================================================= +-- 3. AI-brukslogg +-- ============================================================================= + +CREATE TABLE ai_usage_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + collection_node_id UUID REFERENCES nodes(id) ON DELETE SET NULL, + job_id UUID REFERENCES job_queue(id) ON DELETE SET NULL, + agent_node_id UUID REFERENCES nodes(id) ON DELETE SET NULL, + communication_id UUID REFERENCES nodes(id) ON DELETE SET NULL, + model_alias TEXT NOT NULL, + model_actual TEXT, + prompt_tokens INT NOT NULL DEFAULT 0, + completion_tokens INT NOT NULL DEFAULT 0, + total_tokens INT NOT NULL DEFAULT 0, + estimated_cost NUMERIC(10, 6), + job_type TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_ai_usage_created ON ai_usage_log (created_at); +CREATE INDEX idx_ai_usage_agent ON ai_usage_log (agent_node_id, created_at); + +COMMENT ON TABLE ai_usage_log IS 'Tokenforbruk per AI-kall — kostnadskontroll og analyse'; + +-- ============================================================================= +-- 4. Seed: Claude-agentnode +-- ============================================================================= + +-- Fast UUID for Claude (forutsigbar, enkel å referere til) +INSERT INTO nodes (id, node_kind, title, visibility, metadata, created_by) +VALUES ( + 'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44', + 'agent', + 'Claude', + 'discoverable', + '{"agent_type": "claude", "capabilities": ["chat", "code"]}', + 'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44' -- self-referencing +); + +INSERT INTO agent_identities (node_id, agent_key, agent_type, is_active, config) +VALUES ( + 'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44', + 'claude-main', + 'claude', + true, + '{ + "model_alias": "resonering", + "max_context_messages": 50, + "max_response_tokens": 2048, + "cooldown_seconds": 5, + "max_consecutive_agent_messages": 3, + "rate_limit_per_hour": 60 + }' +); + +-- Vegard har direct-tilgang (kan bestille endringer) +INSERT INTO agent_permissions (user_node_id, agent_node_id, permission) +VALUES ( + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', -- Vegard + 'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44', -- Claude + 'direct' +); + +-- Gi synops_reader tilgang til nye tabeller +GRANT SELECT ON agent_identities TO synops_reader; +GRANT SELECT ON agent_permissions TO synops_reader; +GRANT SELECT ON ai_usage_log TO synops_reader; diff --git a/scripts/maskinrommet-env.sh b/scripts/maskinrommet-env.sh new file mode 100755 index 0000000..5fefa58 --- /dev/null +++ b/scripts/maskinrommet-env.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Genererer /tmp/maskinrommet.env med Docker container-IP-er. +# Kjøres av systemd ExecStartPre. + +set -euo pipefail + +ENV_FILE="/srv/synops/.env" +read_env() { grep "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2; } +container_ip() { docker inspect "$1" --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'; } + +PG_IP=$(container_ip sidelinja-postgres-1) +STDB_IP=$(container_ip sidelinja-spacetimedb-1) +WHISPER_IP=$(container_ip sidelinja-faster-whisper-1 2>/dev/null || echo "") + +cat > /tmp/maskinrommet.env <