swarm repositories / source
aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-24 19:09:28 -0400
committermain <main@swarm.moe>2026-03-24 19:09:28 -0400
commit8b090c3d0daf8b336aab9074b0d8aa31a688e232 (patch)
tree175d2f2313ea34038278cbd066f10fae6bf244b3 /crates
parent57205cd29c41afe85c78a3b5c1962bb3d1f1b27c (diff)
downloadphone_opus-8b090c3d0daf8b336aab9074b0d8aa31a688e232.zip
Surface reusable consult context on failures
Diffstat (limited to 'crates')
-rw-r--r--crates/phone-opus/src/mcp/fault.rs82
-rw-r--r--crates/phone-opus/src/mcp/service.rs67
-rw-r--r--crates/phone-opus/tests/mcp_hardening.rs118
3 files changed, 262 insertions, 5 deletions
diff --git a/crates/phone-opus/src/mcp/fault.rs b/crates/phone-opus/src/mcp/fault.rs
index 5b23f79..4d438e3 100644
--- a/crates/phone-opus/src/mcp/fault.rs
+++ b/crates/phone-opus/src/mcp/fault.rs
@@ -1,7 +1,33 @@
+use std::collections::BTreeMap;
+
use libmcp::{Fault, FaultClass, FaultCode, Generation, RecoveryDirective, ToolErrorDetail};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
+#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
+pub(crate) struct FaultContext {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) consult: Option<ConsultFaultContext>,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub(crate) struct ConsultFaultContext {
+ pub(crate) cwd: String,
+ pub(crate) context_mode: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) reused_session_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) downstream_session_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) resume_session_id: Option<String>,
+ #[serde(default, skip_serializing_if = "is_false")]
+ pub(crate) quota_limited: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) quota_reset_hint: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) retry_hint: Option<String>,
+}
+
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub(crate) enum FaultStage {
Host,
@@ -18,6 +44,8 @@ pub(crate) struct FaultRecord {
pub(crate) stage: FaultStage,
pub(crate) operation: String,
pub(crate) jsonrpc_code: i64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) context: Option<Box<FaultContext>>,
pub(crate) retryable: bool,
pub(crate) retried: bool,
}
@@ -153,15 +181,59 @@ impl FaultRecord {
self
}
+ pub(crate) fn with_context(mut self, context: FaultContext) -> Self {
+ self.context = Some(Box::new(context));
+ self
+ }
+
pub(crate) fn message(&self) -> &str {
self.fault.detail.as_str()
}
+ fn rendered_message(&self) -> String {
+ let mut lines = vec![self.message().to_owned()];
+ let Some(consult) = self
+ .context
+ .as_ref()
+ .and_then(|context| context.consult.as_ref())
+ else {
+ return lines.join("\n");
+ };
+ let mut fields: BTreeMap<String, String> = BTreeMap::from([
+ ("cwd".to_owned(), consult.cwd.clone()),
+ ("context_mode".to_owned(), consult.context_mode.clone()),
+ ]);
+ if let Some(session_id) = consult.reused_session_id.as_ref() {
+ let _ = fields.insert("reused_session".to_owned(), session_id.clone());
+ }
+ if let Some(session_id) = consult.downstream_session_id.as_ref() {
+ let _ = fields.insert("downstream_session".to_owned(), session_id.clone());
+ }
+ if let Some(session_id) = consult.resume_session_id.as_ref() {
+ let _ = fields.insert("resume_session".to_owned(), session_id.clone());
+ }
+ if consult.quota_limited {
+ let _ = fields.insert("quota_limited".to_owned(), "true".to_owned());
+ }
+ if let Some(reset_hint) = consult.quota_reset_hint.as_ref() {
+ let _ = fields.insert("quota_reset".to_owned(), reset_hint.clone());
+ }
+ if let Some(retry_hint) = consult.retry_hint.as_ref() {
+ let _ = fields.insert("retry_hint".to_owned(), retry_hint.clone());
+ }
+ lines.extend(
+ fields
+ .into_iter()
+ .map(|(key, value)| format!("{key}: {value}")),
+ );
+ lines.join("\n")
+ }
+
pub(crate) fn error_detail(&self) -> ToolErrorDetail {
ToolErrorDetail {
code: Some(self.jsonrpc_code),
kind: Some(self.fault.code.as_str().to_owned()),
- message: Some(self.message().to_owned()),
+ message: Some(self.rendered_message()),
}
}
@@ -174,10 +246,11 @@ impl FaultRecord {
}
pub(crate) fn into_tool_result(self) -> Value {
+ let rendered_message = self.rendered_message();
json!({
"content": [{
"type": "text",
- "text": self.message(),
+ "text": rendered_message,
}],
"structuredContent": self,
"isError": true,
@@ -201,11 +274,16 @@ impl FaultRecord {
stage,
operation: operation.into(),
jsonrpc_code,
+ context: None,
retried: false,
}
}
}
+const fn is_false(value: &bool) -> bool {
+ !*value
+}
+
fn fault_code(code: &'static str) -> FaultCode {
match FaultCode::try_new(code.to_owned()) {
Ok(value) => value,
diff --git a/crates/phone-opus/src/mcp/service.rs b/crates/phone-opus/src/mcp/service.rs
index 993a0e4..c5c2d66 100644
--- a/crates/phone-opus/src/mcp/service.rs
+++ b/crates/phone-opus/src/mcp/service.rs
@@ -16,7 +16,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use users::get_current_uid;
use uuid::Uuid;
-use crate::mcp::fault::{FaultRecord, FaultStage};
+use crate::mcp::fault::{ConsultFaultContext, FaultContext, FaultRecord, FaultStage};
use crate::mcp::output::{
ToolOutput, fallback_detailed_tool_output, split_presentation, tool_success,
};
@@ -89,7 +89,7 @@ impl WorkerService {
let request = ConsultRequest::parse(args)
.map_err(|error| invalid_consult_request(self.generation, &operation, error))?;
let response = invoke_claude(&request)
- .map_err(|error| consult_fault(self.generation, &operation, error))?;
+ .map_err(|error| consult_fault(self.generation, &operation, &request, error))?;
consult_output(&request, &response, self.generation, &operation)?
}
other => {
@@ -844,9 +844,11 @@ fn invalid_consult_request(
fn consult_fault(
generation: Generation,
operation: &str,
+ request: &ConsultRequest,
error: ConsultInvocationError,
) -> FaultRecord {
- match error {
+ let context = consult_fault_context(request, &error);
+ let record = match error {
ConsultInvocationError::Spawn(source) => FaultRecord::process(
generation,
FaultStage::Claude,
@@ -857,7 +859,66 @@ fn consult_fault(
| ConsultInvocationError::Downstream(detail) => {
FaultRecord::downstream(generation, FaultStage::Claude, operation, detail)
}
+ };
+ record.with_context(context)
+}
+
+fn consult_fault_context(request: &ConsultRequest, error: &ConsultInvocationError) -> FaultContext {
+ let detail = match error {
+ ConsultInvocationError::Spawn(_) => None,
+ ConsultInvocationError::InvalidJson(detail)
+ | ConsultInvocationError::Downstream(detail) => Some(detail.as_str()),
+ };
+ let reused_session_id = request.reused_session_id();
+ let downstream_session_id = detail.and_then(downstream_session_id);
+ let resume_session_id = downstream_session_id
+ .clone()
+ .or_else(|| reused_session_id.clone());
+ let quota_reset_hint = detail.and_then(quota_reset_hint);
+ let quota_limited = quota_reset_hint.is_some();
+ let retry_hint = consult_retry_hint(quota_limited, resume_session_id.as_deref());
+ FaultContext {
+ consult: Some(ConsultFaultContext {
+ cwd: request.cwd.display(),
+ context_mode: request.context_mode().to_owned(),
+ reused_session_id,
+ downstream_session_id,
+ resume_session_id,
+ quota_limited,
+ quota_reset_hint,
+ retry_hint,
+ }),
+ }
+}
+
+fn downstream_session_id(detail: &str) -> Option<String> {
+ let value = serde_json::from_str::<Value>(detail).ok()?;
+ let session_id = value.get("session_id")?.as_str()?;
+ SessionHandle::parse(session_id).map(|session| session.display())
+}
+
+fn quota_reset_hint(detail: &str) -> Option<String> {
+ let (_, suffix) = detail.split_once("resets ")?;
+ let hint = suffix.trim();
+ (!hint.is_empty()).then(|| hint.to_owned())
+}
+
+fn consult_retry_hint(quota_limited: bool, resume_session_id: Option<&str>) -> Option<String> {
+ if quota_limited {
+ return Some(match resume_session_id {
+ Some(session_id) => format!(
+ "wait for the quota window to reset, then retry consult on the same cwd; phone_opus will reuse resume_session {session_id} automatically"
+ ),
+ None => {
+ "wait for the quota window to reset, then retry consult on the same cwd".to_owned()
+ }
+ });
}
+ resume_session_id.map(|session_id| {
+ format!(
+ "retry consult on the same cwd; phone_opus will reuse resume_session {session_id} automatically"
+ )
+ })
}
pub(crate) fn consult_job_tool_output(
diff --git a/crates/phone-opus/tests/mcp_hardening.rs b/crates/phone-opus/tests/mcp_hardening.rs
index 6a3130b..e9a664b 100644
--- a/crates/phone-opus/tests/mcp_hardening.rs
+++ b/crates/phone-opus/tests/mcp_hardening.rs
@@ -788,6 +788,124 @@ fn consult_surfaces_downstream_cli_failures() -> TestResult {
.as_str()
.is_some_and(|value| value.contains("permission denied by fake claude"))
);
+ assert_eq!(
+ tool_content(&consult)["context"]["consult"]["context_mode"].as_str(),
+ Some("fresh")
+ );
+ assert!(tool_content(&consult)["context"]["consult"]["reused_session_id"].is_null());
+ Ok(())
+}
+
+#[test]
+fn quota_failures_surface_resume_context_for_same_cwd() -> TestResult {
+ let root = temp_root("consult_quota_failure")?;
+ let state_home = root.join("state-home");
+ let sandbox = root.join("sandbox");
+ let caller_home = root.join("caller-home");
+ must(fs::create_dir_all(&state_home), "create state home")?;
+ must(fs::create_dir_all(&sandbox), "create sandbox")?;
+ must(fs::create_dir_all(&caller_home), "create caller home")?;
+ seed_caller_claude_home(&caller_home)?;
+
+ let fake_claude = root.join("claude");
+ let stdout_file = root.join("stdout.json");
+ let remembered_session = "84b9d462-5af9-4a4e-8e44-379a8d0c46d7";
+ write_fake_claude_script(&fake_claude)?;
+ write_fake_claude_stdout(&stdout_file, "ok", remembered_session, "uuid-remembered")?;
+
+ let claude_bin = fake_claude.display().to_string();
+ let stdout_path = stdout_file.display().to_string();
+ let caller_home_path = caller_home.display().to_string();
+ let env = [
+ ("HOME", caller_home_path.as_str()),
+ ("PHONE_OPUS_CLAUDE_BIN", claude_bin.as_str()),
+ ("PHONE_OPUS_TEST_STDOUT_FILE", stdout_path.as_str()),
+ ];
+ let mut harness = McpHarness::spawn(&state_home, &env)?;
+ let _ = harness.initialize()?;
+ harness.notify_initialized()?;
+
+ let first = harness.call_tool(
+ 3,
+ "consult",
+ json!({
+ "prompt": "seed remembered session",
+ "cwd": sandbox.display().to_string()
+ }),
+ )?;
+ assert_tool_ok(&first);
+ assert_eq!(
+ tool_content(&first)["session_id"].as_str(),
+ Some(remembered_session)
+ );
+
+ let quota_env = [
+ ("HOME", caller_home_path.as_str()),
+ ("PHONE_OPUS_CLAUDE_BIN", claude_bin.as_str()),
+ ("PHONE_OPUS_TEST_EXIT_CODE", "17"),
+ (
+ "PHONE_OPUS_TEST_STDERR",
+ "You've hit your limit · resets 4pm (America/New_York)",
+ ),
+ ];
+ drop(harness);
+ let mut harness = McpHarness::spawn(&state_home, &quota_env)?;
+ let _ = harness.initialize()?;
+ harness.notify_initialized()?;
+
+ let failed = harness.call_tool(
+ 4,
+ "consult",
+ json!({
+ "prompt": "quota me",
+ "cwd": sandbox.display().to_string()
+ }),
+ )?;
+ assert_tool_error(&failed);
+ assert_eq!(
+ tool_content(&failed)["fault"]["detail"].as_str(),
+ Some("You've hit your limit · resets 4pm (America/New_York)")
+ );
+ assert_eq!(
+ tool_content(&failed)["context"]["consult"]["cwd"].as_str(),
+ Some(sandbox.display().to_string().as_str())
+ );
+ assert_eq!(
+ tool_content(&failed)["context"]["consult"]["context_mode"].as_str(),
+ Some("reused")
+ );
+ assert_eq!(
+ tool_content(&failed)["context"]["consult"]["reused_session_id"].as_str(),
+ Some(remembered_session)
+ );
+ assert_eq!(
+ tool_content(&failed)["context"]["consult"]["resume_session_id"].as_str(),
+ Some(remembered_session)
+ );
+ assert_eq!(
+ tool_content(&failed)["context"]["consult"]["quota_limited"].as_bool(),
+ Some(true)
+ );
+ assert_eq!(
+ tool_content(&failed)["context"]["consult"]["quota_reset_hint"].as_str(),
+ Some("4pm (America/New_York)")
+ );
+ assert!(
+ tool_content(&failed)["context"]["consult"]["retry_hint"]
+ .as_str()
+ .is_some_and(|value| value.contains(remembered_session))
+ );
+ assert!(
+ failed["result"]["content"]
+ .as_array()
+ .into_iter()
+ .flatten()
+ .filter_map(|entry| entry["text"].as_str())
+ .any(|text| {
+ text.contains("resume_session: 84b9d462-5af9-4a4e-8e44-379a8d0c46d7")
+ && text.contains("quota_reset: 4pm (America/New_York)")
+ })
+ );
Ok(())
}