skald/vendor/clawdforge/src/session.rs
Sulkta 8cbc9e04eb v0.2 scaffold: vendor clawdforge SDK + forge module + Whisper plan
The Rust SDK already existed at Sulkta-OSS/clawdforge clients/rust/ — async,
reqwest-based, bearer-auth, exposes Client::run() + Session for multi-turn.
Vendoring it into vendor/clawdforge so skald is self-contained: no
git-submodule + no needing the clawdforge repo cloned next to skald.
Trade-off accepted: updates require manual re-copy until both sides
stabilize and we publish to a private cargo registry.

What landed:

- vendor/clawdforge/ — full SDK source from Sulkta-OSS/clawdforge HEAD.
  Pinned in skald-core/Cargo.toml as a path dep.
- skald-core/src/forge.rs — three-pass orchestration shell. Forge wraps
  clawdforge::Client; generate() / cleanup() / audit() each build a
  RunRequest with the right system prompt + model alias (always opus),
  call client.run(), return a PassOutput.
  Prompt templates are TODO stubs (SYSTEM_GEN_TODO etc) — filling in the
  actual prose-craft prompts is its own deep session.
- skald-core/src/config.rs — ForgeConfig { base_url, app_token, model }.
  Resolved by the binary from env (CLAWDFORGE_URL + CLAWDFORGE_TOKEN);
  lib stays env-agnostic.
- skald-core::AuditFinding + AuditResponse — parse shape for what the
  third-Opus canon audit returns, ready to map onto audit_findings rows.
- docs/tts-pipeline.md — full plan for v0.2 narration + post-TTS audit
  chain. Whisper-large-v3 STT does text-to-text verification on every
  render; an optional Gemini Flash audio pass catches subjective issues
  (prosody, tone) Whisper can't see. Reroll loop on crit findings.

What's still stubbed:

- Prompt templates in forge.rs (gen / cleanup / audit) — placeholders
  that describe the role but don't constrain output shape yet.
- context.rs (assemble the LLM context blob from DB rows) — entire module
  TBD.
- No CLI subcommand yet for invoking forge — that comes after context.rs.

Naming note: in Rust 2024 'gen' is a reserved keyword (for generators),
so the method is Forge::generate(), not Forge::gen().
2026-05-13 10:18:56 -07:00

281 lines
9.4 KiB
Rust

//! Multi-turn session API (v0.2).
//!
//! v0.2 adds a parallel `/sessions/*` surface to clawdforge backed by ACPX. A
//! [`Session`] is a handle to one server-side session; [`Session::turn`]
//! dispatches a single prompt+files turn and returns the structured event
//! batch. Sessions are explicitly closed via [`Session::close`] (consumes the
//! handle, preventing use-after-close at compile time) or — as a last-resort
//! fallback — best-effort closed by [`Drop`] via `tokio::spawn`.
//!
//! v0.1 single-turn `Client::run` is unchanged; the v0.2 surface is purely
//! additive.
use std::sync::atomic::{AtomicBool, Ordering};
use serde::{Deserialize, Serialize};
use crate::client::Client;
use crate::error::Error;
/// Options passed to [`Client::new_session`].
///
/// `Default` produces `agent = None` (server picks `"claude"`) and `meta =
/// None`.
#[derive(Debug, Default, Clone, Serialize)]
pub struct SessionOptions {
/// Agent slug to dispatch to. `None` falls back to the server-side default
/// (`"claude"`).
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
/// Free-form metadata stored alongside the session ledger row.
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<serde_json::Value>,
}
/// Reply body from `POST /sessions`.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct SessionCreateResponse {
pub session_id: String,
pub agent: String,
pub created_at: i64,
}
/// One event in a turn's structured output.
///
/// `event_type` is one of `"thinking"`, `"text"`, `"tool_call"`, etc. (server
/// is the authority on the set).
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TurnEvent {
/// Event discriminator (`"text"`, `"thinking"`, `"tool_call"`, ...).
#[serde(rename = "type")]
pub event_type: String,
/// Text content for `"text"` and `"thinking"` events.
#[serde(default)]
pub content: Option<String>,
/// Tool name for `"tool_call"` events.
#[serde(default)]
pub name: Option<String>,
/// Tool arguments for `"tool_call"` events.
#[serde(default)]
pub args: Option<serde_json::Value>,
/// Tool result for `"tool_call"` events.
#[serde(default)]
pub result: Option<serde_json::Value>,
}
/// Successful response body from `POST /sessions/{id}/turn`.
#[derive(Debug, Clone, Deserialize)]
pub struct TurnResult {
/// Always `true` on a 200 reply.
pub ok: bool,
/// The session this turn belongs to.
pub session_id: String,
/// 1-based index of this turn within the session.
pub turn_index: i32,
/// Structured events emitted during the turn.
pub events: Vec<TurnEvent>,
/// Reason the agent stopped (`"end_turn"`, `"max_tokens"`, ...).
pub stop_reason: String,
/// Wall-clock duration of the turn.
pub duration_ms: i64,
}
impl TurnResult {
/// Concatenate all `"text"` event contents into a single string.
///
/// Non-text events (`thinking`, `tool_call`, ...) are skipped. If an event
/// is `"text"` but `content` is `None`, it contributes the empty string.
pub fn text(&self) -> String {
let mut out = String::new();
for ev in &self.events {
if ev.event_type == "text" {
if let Some(c) = ev.content.as_deref() {
out.push_str(c);
}
}
}
out
}
}
/// Reply body from `GET /sessions/{id}` and entries in `GET /sessions`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SessionState {
/// Server-issued session id.
pub session_id: String,
/// Agent slug bound to the session.
pub agent: String,
/// App / consumer name that owns the session.
pub app_name: String,
/// Unix epoch seconds when created.
pub created_at: i64,
/// Unix epoch seconds of the last successful turn (or `None` if zero turns
/// have been dispatched yet).
#[serde(default)]
pub last_turn_at: Option<i64>,
/// Number of turns dispatched.
pub turn_count: i32,
/// Unix epoch seconds when closed (or `None` if still open).
#[serde(default)]
pub closed_at: Option<i64>,
}
/// Reply body from `DELETE /sessions/{id}`.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct SessionCloseResponse {
#[allow(dead_code)]
pub ok: bool,
#[serde(default)]
#[allow(dead_code)]
pub already_closed: Option<bool>,
}
/// Reply body from `GET /sessions`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SessionList {
/// All sessions visible to the calling token.
pub sessions: Vec<SessionState>,
}
/// Request body for `POST /sessions/{id}/turn`.
#[derive(Debug, Serialize)]
struct TurnRequest<'a> {
prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
files: Option<&'a [String]>,
}
/// A handle to one server-side multi-turn session.
///
/// Construct via [`Client::new_session`]. Drop or [`Session::close`] to
/// release the server-side session. `close` consumes the value so use-after-
/// close is a compile error; `Drop` is a best-effort backstop that fires an
/// async DELETE via `tokio::spawn` and logs (does not panic) on failure.
///
/// `Debug` is hand-written and explicitly excludes the embedded [`Client`] so
/// no bearer can leak through `{:?}` formatting.
pub struct Session {
pub(crate) client: Client,
pub(crate) session_id: String,
pub(crate) agent: String,
pub(crate) created_at: i64,
pub(crate) closed: AtomicBool,
}
impl std::fmt::Debug for Session {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Deliberately omit `client` — its Debug already redacts tokens, but
// the spec-mandated shape is `Session { session_id, agent, closed }`
// to keep the surface minimal and audit-friendly.
f.debug_struct("Session")
.field("session_id", &self.session_id)
.field("agent", &self.agent)
.field("created_at", &self.created_at)
.field("closed", &self.closed.load(Ordering::Acquire))
.finish()
}
}
impl Session {
/// Server-issued session id.
pub fn id(&self) -> &str {
&self.session_id
}
/// Agent slug the server bound to this session.
pub fn agent(&self) -> &str {
&self.agent
}
/// Unix epoch seconds when the session was created server-side.
pub fn created_at(&self) -> i64 {
self.created_at
}
/// Whether the session has been explicitly closed already. Sessions closed
/// only via `Drop`'s spawn are still reported as closed once that future
/// has run; this getter reflects the in-memory flag.
pub fn is_closed(&self) -> bool {
self.closed.load(Ordering::Acquire)
}
/// Send a turn with no attached files.
///
/// Equivalent to `turn_with_files(prompt, &[])` but skips serializing the
/// `files` field on the wire.
pub async fn turn(&mut self, prompt: impl Into<String>) -> Result<TurnResult, Error> {
self.dispatch_turn(prompt.into(), None).await
}
/// Send a turn that references previously uploaded file tokens.
///
/// `files` is the list of `ff_*` tokens returned by [`Client::upload_file`].
///
/// [`Client::upload_file`]: crate::Client::upload_file
pub async fn turn_with_files(
&mut self,
prompt: impl Into<String>,
files: &[String],
) -> Result<TurnResult, Error> {
self.dispatch_turn(prompt.into(), Some(files)).await
}
async fn dispatch_turn(
&mut self,
prompt: String,
files: Option<&[String]>,
) -> Result<TurnResult, Error> {
if self.closed.load(Ordering::Acquire) {
return Err(Error::Config("session is closed".into()));
}
self.client
.turn_internal(&self.session_id, &TurnRequest { prompt, files })
.await
}
/// Explicitly close the session. Consumes `self` — use-after-close is a
/// compile error.
///
/// If the session is already closed in memory (e.g. via a prior failed
/// close or a prior dispatch path that flagged it), this short-circuits
/// without contacting the server.
pub async fn close(self) -> Result<(), Error> {
// Mark closed before the network call so a panic-mid-await on the
// request future cannot trigger Drop's spawn into a double-close.
if self.closed.swap(true, Ordering::AcqRel) {
return Ok(());
}
self.client.close_session_internal(&self.session_id).await
}
}
impl Drop for Session {
fn drop(&mut self) {
// If close() already ran, nothing to do.
if self.closed.swap(true, Ordering::AcqRel) {
return;
}
// tokio::spawn panics if no runtime is current. Guard against being
// dropped from a sync context (e.g. a forgotten value at the end of a
// sync `main`).
if tokio::runtime::Handle::try_current().is_err() {
tracing::warn!(
session_id = %self.session_id,
"Session dropped outside a tokio runtime; server-side session not closed"
);
return;
}
let client = self.client.clone();
let id = self.session_id.clone();
tokio::spawn(async move {
if let Err(e) = client.close_session_internal(&id).await {
tracing::warn!(
session_id = %id,
error = %e,
"best-effort drop close failed"
);
}
});
}
}