Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 84 additions & 10 deletions hives/claude-cli/src/graft.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@
//! and tendrils of the cell as assistant turns and each grasp as the tool
//! result that answered them. A tendril is named as the cell knew it, a
//! tool of the nest's `hum` MCP server.
//!
//! Tool results a held transcript lacks are found by the tool and what it
//! answered, not by their ids: claude names its own calls by its ids, and
//! a client knows them by the nest's. A transcript may hold answers a
//! client never saw, a tool the server ran or a call the nest gave up on.

use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -159,6 +164,14 @@ pub fn last_uuid(path: &Path) -> Option<String> {
None
}

pub fn ends_in_grasps(blooms: &[thrum_core::Bloom]) -> bool {
blooms
.last()
.and_then(|b| b.grew.as_ref())
.and_then(|grew| grew.iter().rev().find(|p| p.kind != thrum_core::PetalKind::Sepal.as_str()))
.is_some_and(|p| p.kind == thrum_core::PetalKind::Grasp.as_str())
}

pub fn tell(prior: &mut [Value], instructions: &str) -> bool {
let Some(first) = prior.iter_mut().find(|m| role_of(m) == "user") else { return false };
match first.get_mut("content") {
Expand Down Expand Up @@ -253,18 +266,18 @@ fn count_turns(messages: &[Value]) -> usize {
turns
}

/// Count completed turns in JSONL: user-with-text followed by an assistant
/// before another user.
fn opens_turn(entry: &Value) -> bool {
entry_type(entry) == "user"
&& message_content(entry).is_some_and(|c| {
c.iter().any(|p| p.get("type").and_then(Value::as_str) == Some("text"))
&& !c.iter().any(|p| p.get("text").and_then(Value::as_str) == Some(crate::stream::CONTINUE))
})
}

fn count_jsonl_turns(entries: &[Value]) -> usize {
let mut turns = 0;
for i in 0..entries.len() {
if entry_type(&entries[i]) != "user" {
continue;
}
let has_text = message_content(&entries[i])
.map(|c| c.iter().any(|p| p.get("type").and_then(Value::as_str) == Some("text")))
.unwrap_or(false);
if !has_text {
if !opens_turn(&entries[i]) {
continue;
}
for j in (i + 1)..entries.len() {
Expand Down Expand Up @@ -306,6 +319,59 @@ fn skip_turns(messages: &[Value], n: usize) -> usize {
messages.len()
}

fn parts_of<'a>(messages: &'a [Value], role: &'a str, kind: &'a str) -> impl Iterator<Item = &'a Value> {
messages
.iter()
.filter(move |m| role_of(m) == role)
.filter_map(|m| m.get("content").and_then(Value::as_array))
.flatten()
.filter(move |p| p.get("type").and_then(Value::as_str) == Some(kind))
}

fn answer_of(content: Option<&Value>) -> String {
match content {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(parts)) => parts.iter().filter_map(|p| p.get("text").and_then(Value::as_str)).collect(),
Some(other) => other.to_string(),
None => String::new(),
}
}

fn late_results(history: &[Value], entries: &[Value]) -> Vec<Value> {
let start = history.iter().rposition(|m| role_of(m) == "user").map_or(0, |i| i + 1);
let turn = &history[start..];
let opened = entries.iter().rposition(opens_turn).map_or(0, |i| i + 1);
let held_parts = || entries[opened..].iter().filter_map(message_content).flatten();
let kind = |p: &Value, k: &str| p.get("type").and_then(Value::as_str) == Some(k);
let named: HashMap<&str, &str> = held_parts()
.filter(|p| kind(p, "tool_use"))
.filter_map(|p| Some((p.get("id")?.as_str()?, p.get("name")?.as_str()?)))
.collect();
let mut held: Vec<(&str, String)> = held_parts()
.filter(|p| kind(p, "tool_result"))
.filter_map(|p| Some((*named.get(p.get("tool_use_id")?.as_str()?)?, answer_of(p.get("content")))))
.collect();
let calls: Vec<&Value> = parts_of(turn, "assistant", "tool-call").collect();
let mut late: Vec<(Value, Value)> = Vec::new();
for result in parts_of(turn, "tool", "tool-result") {
let id = result.get("toolCallId").and_then(Value::as_str);
let Some(call) = calls.iter().find(|c| id.is_some() && c.get("toolCallId").and_then(Value::as_str) == id) else { continue };
let name = call.get("toolName").and_then(Value::as_str).unwrap_or("");
let answer = answer_of(result.get("result"));
match held.iter().position(|(n, a)| *n == name && *a == answer) {
Some(i) => {
held.swap_remove(i);
}
None => late.push(((*call).clone(), result.clone())),
}
}
if late.is_empty() {
return Vec::new();
}
let (answered, results): (Vec<Value>, Vec<Value>) = late.into_iter().unzip();
vec![json!({ "role": "assistant", "content": answered }), json!({ "role": "tool", "content": results })]
}

/// Stitch a nestler's prior petals into the JSONL. UUID-anchored,
/// count-based — does not hash text. Returns the new tail uuid.
pub fn graft(
Expand Down Expand Up @@ -343,6 +409,14 @@ pub fn graft(
.any(|e| e.get("uuid").and_then(Value::as_str) == Some(anchor))
});

if j_users == p_users {
let late = late_results(&history, &existing);
if !late.is_empty() {
trace!(j_users, "graft.late-results");
let count = append_from_prompt(jsonl_path, session_id, &late, cwd)?;
return Ok(GraftResult { grafted: count, last_petal: last_uuid(jsonl_path) });
}
}
if anchored && j_users >= p_users {
trace!(anchor = ?last_synced, j_users, p_users, "graft.synced");
return Ok(GraftResult { grafted: 0, last_petal: last_synced.map(str::to_owned) });
Expand Down
67 changes: 66 additions & 1 deletion hives/claude-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
//!
//! A prompt's graft is stitched into the session's transcript before the
//! cell resumes it, so a cell that did not grow those blooms grows on as if
//! it had; a transcript that already holds them is left as it is.
//! it had, and a transcript is given only what it lacks.
//!
//! claude cannot be handed words to carry on, nor a `scion` to grow
//! toward, so a prompt with no words grows on only from tool results.
//!
//! claude signed in with a subscription takes no system prompt of a
//! caller's: `--system-prompt` and `--append-system-prompt` never reach the
Expand Down Expand Up @@ -106,6 +109,27 @@ pub const SESSION_NS: Uuid = Uuid::from_bytes([
0x68, 0x75, 0x6d, 0x2d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x2d, 0x6e, 0x73, 0x00, 0x01,
]);

fn refused(code: &str, message: &str) -> Cell {
let (feed, mut fed) = mpsc::channel::<Value>(1);
tokio::spawn(async move { while fed.recv().await.is_some() {} });
let (tx_evt, rx_evt) = mpsc::channel::<Value>(1);
let _ = tx_evt.try_send(serde_json::json!({ "chi": "error", "code": code, "message": message }));
let (exit, emerged) = tokio::sync::oneshot::channel();
let _ = exit.send(0);
Cell {
mark: None,
feed,
mmm: std::sync::Arc::new(Mutex::new(rx_evt)),
emerged,
ephemeral: false,
silence: Default::default(),
}
}

pub fn grows_on_from(prompt: &thrum_core::PromptBody) -> bool {
prompt.scion.is_none() && graft::ends_in_grasps(prompt.graft.as_deref().unwrap_or(&[]))
}

pub struct ClaudeCliNurse;

impl Default for ClaudeCliNurse {
Expand Down Expand Up @@ -211,6 +235,7 @@ pub fn build_env(spec: &Egg) -> Vec<(String, String)> {
impl Nurse for ClaudeCliNurse {
fn ephemeral(&self) -> bool { false }
fn propensity(&self) -> Propensity { Propensity::StatefulSession }
fn grows_on(&self) -> bool { true }

async fn curate(&self, sid: &hum_identity::HumId, cwd: &str) -> Result<hum_nest::CurateReport> {
let derived = session_of(sid);
Expand Down Expand Up @@ -238,6 +263,11 @@ impl Nurse for ClaudeCliNurse {
}

async fn raise(&self, spec: Egg) -> Result<Cell> {
let worded = spec.prompt.content.as_ref().is_some_and(|c| !c.is_empty())
|| spec.prompt.text.as_ref().is_some_and(|t| !t.is_empty());
if !worded && !grows_on_from(&spec.prompt) {
return Ok(refused("cannot-grow-on", "claude grows on only from tool results, and this graft does not end in them"));
}
let derived = session_of(&spec.sid);
let cwd = std::fs::canonicalize(&spec.cwd).unwrap_or_else(|_| spec.cwd.clone().into());
let path = graft::session_path(&cwd, &derived);
Expand Down Expand Up @@ -365,6 +395,41 @@ mod tests {
Egg::new(hum_identity::HumId::mint(), "claude-haiku-4-5", "/tmp")
}

fn graft_ending(last: serde_json::Value) -> Vec<thrum_core::Bloom> {
serde_json::from_value(serde_json::json!([{
"content": [{ "kind": "text", "text": "build it" }],
"grew": [{ "kind": "tendril", "callId": "c-1", "name": "bash", "args": {} }, last],
}]))
.unwrap()
}

#[test]
fn claude_grows_on_only_from_tool_results() {
let mut prompt = thrum_core::PromptBody {
graft: Some(graft_ending(serde_json::json!({ "kind": "grasp", "callId": "c-1", "result": "ok" }))),
..Default::default()
};
assert!(grows_on_from(&prompt));
prompt.scion = Some("}".into());
assert!(!grows_on_from(&prompt), "a scion is not met");
prompt.scion = None;
prompt.graft = Some(graft_ending(serde_json::json!({ "kind": "text", "text": "Buil" })));
assert!(!grows_on_from(&prompt), "words are not continued");
assert!(ClaudeCliNurse.grows_on());
}

#[tokio::test]
async fn a_prompt_it_cannot_grow_on_is_refused_without_a_claude() {
let mut spec = egg();
spec.prompt.graft = Some(graft_ending(serde_json::json!({ "kind": "text", "text": "Buil" })));
let cell = ClaudeCliNurse.raise(spec).await.unwrap();
let tone = cell.mmm.lock().await.recv().await.unwrap();
assert_eq!(tone["chi"], "error");
assert_eq!(tone["code"], "cannot-grow-on");
assert!(cell.mmm.lock().await.recv().await.is_none(), "and nothing else");
assert_eq!(cell.emerged.await.unwrap(), 0);
}

#[test]
fn argv_includes_basics() {
let argv = build_argv(&egg(), Session::Begin("s"));
Expand Down
17 changes: 17 additions & 0 deletions hives/claude-cli/src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
//! head; an answer to a tool claude called outside its sigil's tools as
//! that tool's result; a cancel as claude's own cancel request.
//!
//! claude takes a turn only from a user message, and resuming a transcript
//! that ends in tool results it first answers them with an empty "No
//! response requested." of its own, which [`CONTINUE`] disowns.
//!
//! [`Grower`] turns claude's events into tones. Its init is a `cell-ready`
//! pulse. Each content block unfurls as one petal by its index, text as
//! text and thinking as a sepal sealed by its signature; a tool use unfurls
Expand Down Expand Up @@ -54,6 +58,8 @@ pub fn said(petals: &[Petal]) -> Vec<Value> {
.collect()
}

pub const CONTINUE: &str = "Tool results came back after your turn ended. If a \"No response requested.\" sits between them and this line, the session added it when it resumed; you did not say it. Continue from where you left off.";

pub fn heard(tone: &Value, preamble: &mut Option<String>) -> Option<String> {
match tone.get("chi").and_then(Value::as_str)? {
"prompt" => {
Expand All @@ -62,6 +68,9 @@ pub fn heard(tone: &Value, preamble: &mut Option<String>) -> Option<String> {
body.text.into_iter().map(|t| Petal { kind: PetalKind::Text.as_str().into(), text: Some(t), ..Petal::default() }).collect()
});
let mut content = said(&petals);
if content.is_empty() {
content.push(json!({ "type": "text", "text": CONTINUE }));
}
if let Some(text) = preamble.take() {
content.insert(0, json!({ "type": "text", "text": text }));
}
Expand Down Expand Up @@ -213,6 +222,14 @@ mod tests {
assert_eq!(again["message"]["content"][0]["text"], "what is this?", "told once");
}

#[test]
fn a_prompt_with_no_words_is_heard_as_the_continue_line() {
let mut none = None;
let on: Value = serde_json::from_str(&heard(&json!({ "chi": "prompt", "sid": "s", "graft": [] }), &mut none).unwrap()).unwrap();
assert_eq!(on["type"], "user");
assert_eq!(on["message"]["content"], json!([{ "type": "text", "text": CONTINUE }]));
}

#[test]
fn an_answer_and_a_cancel_are_heard_as_claude_says_them() {
let mut none = None;
Expand Down
Loading
Loading