use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::client::catalog::{sm_ops_verbs, sm_session_verbs};
use crate::core::sm::control::{LaunchParams, SessionControl, SessionControlError, Submit};
const FINAL_ACTION: &str = "final";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActionDecision {
pub action: String,
#[serde(default)]
pub args: Value,
#[serde(default)]
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decided {
Verb {
verb: String,
args: Value,
},
Final {
message: String,
},
}
impl ActionDecision {
pub fn classify(self) -> Decided {
if is_final_action(self.action.trim()) || self.action.trim().is_empty() {
Decided::Final {
message: self.message.unwrap_or_default(),
}
} else {
Decided::Verb {
verb: self.action,
args: self.args,
}
}
}
}
pub fn parse_action(reply: &str) -> Decided {
let trimmed = reply.trim();
for candidate in candidate_objects(trimmed) {
if let Ok(decision) = serde_json::from_str::<ActionDecision>(&candidate) {
let decided = decision.classify();
if let Decided::Verb { ref verb, .. } = decided
&& verb.trim().is_empty()
{
continue;
}
return decided;
}
}
Decided::Final {
message: trimmed.to_string(),
}
}
fn candidate_objects(text: &str) -> Vec<String> {
let mut candidates = Vec::new();
if let Some(inner) = fenced_inner(text)
&& let Some((obj, _)) = next_balanced_object(&inner, 0)
{
candidates.push(obj);
}
let mut from = 0;
while let Some((obj, end)) = next_balanced_object(text, from) {
if !candidates.contains(&obj) {
candidates.push(obj);
}
from = end;
}
candidates
}
fn fenced_inner(text: &str) -> Option<String> {
const JSON_FENCE: &str = "```json";
const BARE_FENCE: &str = "```";
let (open_at, fence_len) = match text.find(JSON_FENCE) {
Some(i) => (i, JSON_FENCE.len()),
None => (text.find(BARE_FENCE)?, BARE_FENCE.len()),
};
let after_open = open_at + fence_len;
let body_start = match text[after_open..].find('\n') {
Some(nl) => after_open + nl + 1,
None => after_open,
};
let rest = &text[body_start..];
let inner = match rest.find(BARE_FENCE) {
Some(close) => &rest[..close],
None => rest,
};
Some(inner.to_string())
}
fn next_balanced_object(text: &str, from: usize) -> Option<(String, usize)> {
if from >= text.len() {
return None;
}
let rel = text[from..].find('{')?;
let start = from + rel;
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (offset, ch) in text[start..].char_indices() {
if in_string {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
match ch {
'"' => in_string = true,
'{' => depth += 1,
'}' => {
depth = depth.saturating_sub(1);
if depth == 0 {
let end = start + offset + ch.len_utf8();
return Some((text[start..end].to_string(), end));
}
}
_ => {}
}
}
None
}
pub fn action_instructions() -> String {
let mut out = String::from(
"# INLINE ACTIONS (machine-read)\n\n\
You can INVOKE managed-session verbs INLINE within this single chat turn. \
A verb you call EXECUTES NOW (synchronously, in-process) and its JSON result \
is fed straight back to you so you can decide your next step. This is NOT \
background delegation: there is no tracking goal, you act and observe in this \
very turn.\n\n\
Reply with EXACTLY ONE JSON object (optionally inside a ```json fence) and no \
other text. Choose ONE of two shapes:\n\n\
1. Call a verb (it runs now; you will see its result and may call another):\n \
{\"action\":\"<verb>\",\"args\":{ … }}\n\n\
2. Give your FINAL answer to the operator (ends the turn):\n \
{\"action\":\"final\",\"message\":\"<operator-facing reply>\"}\n\n\
The verbs available to you (call by exact name):\n",
);
for spec in sm_session_verbs().iter().chain(sm_ops_verbs()) {
out.push_str(&format!(
"- `{}` — {}\n",
spec.prompt_signature(),
spec.summary
));
}
out.push_str(
"\nArgument conventions: pass a session id as `args.session_id`; \
`sessions.send` also takes `args.text`; `sessions.launch` takes \
`args.workdir` (required) plus optional `args.model`, `args.prompt`, \
`args.goal_id`, `args.repo_url` (canonical git URL for provisioning, \
WI-A #1585), and `args.ref_` (git branch/tag/SHA to check out, \
WI-A #1585); `sessions.adopt` takes `args.tmux_name` and `args.cwd` \
(both required) plus optional `args.task`, `args.runtime`; \
`sessions.inject` takes `args.session_id`, `args.text`, and optional \
`args.submit` (one of `enter` [default], `no_submit`, `interrupt`); \
`sessions.decommission` takes `args.session_id` and fully tears down the \
session (kill runtime + tombstone record). When you have gathered enough \
to answer, emit the `final` shape — do not keep calling verbs once you \
can answer.",
);
out
}
pub async fn execute_verb(
control: &Arc<dyn SessionControl>,
verb: &str,
args: &Value,
) -> Result<Value, SessionControlError> {
match verb {
"sessions.list" => control.list().await,
"sessions.get" => control.get(require_session_id(args)?).await,
"sessions.send" => {
let id = require_session_id(args)?;
let text = args.get("text").and_then(Value::as_str).ok_or_else(|| {
SessionControlError::Backend("sessions.send requires args.text".to_string())
})?;
control.send(id, text).await
}
"sessions.stop" => control.stop(require_session_id(args)?).await,
"sessions.resume" => control.resume(require_session_id(args)?).await,
"sessions.kill" => control.kill(require_session_id(args)?).await,
"sessions.decommission" => control.decommission(require_session_id(args)?).await,
"sessions.inject" => {
let id = require_session_id(args)?;
let text = args.get("text").and_then(Value::as_str).ok_or_else(|| {
SessionControlError::Backend("sessions.inject requires args.text".to_string())
})?;
let submit = parse_submit_arg(args);
control
.inject_text(id, text, submit)
.await
.map(|()| serde_json::json!({ "ok": true }))
}
"sessions.health" => health_via(control).await,
"sessions.adopt" => {
let tmux_name = args
.get("tmux_name")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| {
SessionControlError::Backend(
"sessions.adopt requires args.tmux_name".to_string(),
)
})?;
let cwd = args
.get("cwd")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| {
SessionControlError::Backend("sessions.adopt requires args.cwd".to_string())
})?;
control
.adopt(
tmux_name,
cwd,
str_arg(args, "task").as_deref(),
str_arg(args, "runtime").as_deref(),
)
.await
}
"sessions.launch" => {
let workdir = args
.get("workdir")
.and_then(Value::as_str)
.ok_or_else(|| {
SessionControlError::Backend(
"sessions.launch requires args.workdir".to_string(),
)
})?
.to_string();
let params = LaunchParams {
workdir,
model: str_arg(args, "model"),
prompt: str_arg(args, "prompt"),
goal_id: str_arg(args, "goal_id"),
ephemeral: args.get("ephemeral").and_then(Value::as_bool),
repo_url: str_arg(args, "repo_url"),
ref_: str_arg(args, "ref_"),
};
control.launch(params).await
}
other => Err(SessionControlError::Backend(format!(
"unknown verb `{other}`; valid verbs: {}",
valid_verb_names()
))),
}
}
pub fn is_final_action(action: &str) -> bool {
action == FINAL_ACTION
}
fn require_session_id(args: &Value) -> Result<&str, SessionControlError> {
args.get("session_id")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| SessionControlError::NotFound("missing args.session_id".to_string()))
}
fn str_arg(args: &Value, key: &str) -> Option<String> {
args.get(key)
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.map(str::to_string)
}
fn parse_submit_arg(args: &Value) -> Submit {
match args
.get("submit")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("")
{
"no_submit" => Submit::NoSubmit,
"interrupt" => Submit::Interrupt,
_ => Submit::Enter,
}
}
fn valid_verb_names() -> String {
sm_session_verbs()
.iter()
.chain(sm_ops_verbs())
.map(|c| c.name)
.collect::<Vec<_>>()
.join(", ")
}
async fn health_via(control: &Arc<dyn SessionControl>) -> Result<Value, SessionControlError> {
match control.list().await {
Ok(listing) => {
let sessions = listing.get("sessions").and_then(Value::as_array);
let managed_total = sessions.map(Vec::len).unwrap_or(0);
let managed_pending_decisions = sessions
.map(|items| {
items
.iter()
.filter(|s| s.get("pending_decision").is_some_and(|v| !v.is_null()))
.count()
})
.unwrap_or(0);
Ok(serde_json::json!({
"reachable": true,
"status": "ok",
"managed_total": managed_total,
"managed_pending_decisions": managed_pending_decisions,
}))
}
Err(e) => Ok(serde_json::json!({
"reachable": false,
"status": "degraded",
"error": e.to_string(),
})),
}
}
#[cfg(test)]
#[path = "chat_action_tests.rs"]
mod chat_action_tests;