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().
281 lines
9.4 KiB
Rust
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"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|