Skip to content
Closed
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
98 changes: 88 additions & 10 deletions crates/tracedecay-agent-hosts/src/hooks/steering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ pub fn build_cursor_session_context(
staleness_hint: Option<&str>,
tokens_saved: Option<u64>,
) -> String {
let mut s = index_status_line(initialized, staleness_hint);
let mut s = index_status_line(
initialized,
staleness_hint.map(bounded_staleness_hint).as_deref(),
);
if initialized {
s.reserve(CURSOR_SESSION_CONTEXT_BUDGET.saturating_sub(s.len()));
append_tracedecay_bootstrap_context(&mut s);
Expand All @@ -49,7 +52,7 @@ pub fn build_cursor_session_context(
s.push_str(".\n");
}
}
s
enforce_context_budget(s, CURSOR_SESSION_CONTEXT_BUDGET)
}

/// One-line index freshness signal.
Expand Down Expand Up @@ -124,14 +127,16 @@ pub fn build_codex_session_context_for_workspace(
s.push('\n');
append_codex_recall_and_registry_guidance(&mut s);
match status {
HookWorkspaceStatus::Initialized => match staleness_hint {
Some(hint) => {
s.push_str("Index status: ");
s.push_str(hint);
s.push_str(".\n");
HookWorkspaceStatus::Initialized => {
match staleness_hint.map(bounded_staleness_hint) {
Some(hint) => {
s.push_str("Index status: ");
s.push_str(&hint);
s.push_str(".\n");
}
None => s.push_str("Index status: initialized.\n"),
}
None => s.push_str("Index status: initialized.\n"),
},
}
HookWorkspaceStatus::UnindexedProject => s.push_str(
"Index status: no project index found in this code workspace — \
run `tracedecay init` to enable tracedecay code-graph tools.\n",
Expand All @@ -155,7 +160,7 @@ pub fn build_codex_session_context_for_workspace(
"Continue authorized work through the requested outcome and relevant verification; \
pause for a missing decision or an external or destructive action outside that authority.\n",
);
s
enforce_context_budget(s, CODEX_SESSION_CONTEXT_BUDGET)
}

fn append_codex_recall_and_registry_guidance(s: &mut String) {
Expand All @@ -169,6 +174,54 @@ fn append_codex_recall_and_registry_guidance(s: &mut String) {
);
}

/// Freshness text is caller-supplied. Keep one short line so a diagnostic dump
/// cannot expand the session-start payload past its budget.
const MAX_STALENESS_HINT_CHARS: usize = 80;

fn bounded_staleness_hint(hint: &str) -> String {
let line = hint.lines().next().unwrap_or("").trim();
let mut chars = line.chars();
let bounded: String = chars.by_ref().take(MAX_STALENESS_HINT_CHARS).collect();
if chars.next().is_some() {
let mut shorter: String = line.chars().take(MAX_STALENESS_HINT_CHARS - 1).collect();
shorter.push('…');
shorter
} else {
bounded
}
}

/// Drop trailing lines, then a byte tail, until `text` fits `budget`.
///
/// The documented session budgets are production contracts. A caller-supplied
/// hint or a future prose addition must not overflow the injected context.
fn enforce_context_budget(mut text: String, budget: usize) -> String {
if text.len() <= budget {
return text;
}
while text.len() > budget {
let Some(idx) = text.rfind('\n') else {
break;
};
if idx == 0 {
break;
}
text.truncate(idx);
}
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
if text.len() <= budget {
return text;
}
let mut end = budget.min(text.len());
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
text
}

/// Formats a short relative-age staleness hint from a sync age in seconds.
pub fn cursor_staleness_hint(age_secs: i64) -> String {
let age = age_secs.max(0);
Expand Down Expand Up @@ -225,4 +278,29 @@ mod tests {
);
}
}

#[test]
fn session_context_bounds_an_unbounded_staleness_hint() {
let dump = "x".repeat(8_000);
let cursor = build_cursor_session_context(true, Some(&dump), Some(12_345));
assert!(
cursor.len() <= CURSOR_SESSION_CONTEXT_BUDGET,
"{}",
cursor.len()
);
assert!(cursor.contains("TraceDecay project hint:"));
assert!(!cursor.contains(&dump));

let codex = build_codex_session_context_for_workspace(
HookWorkspaceStatus::Initialized,
Some(&dump),
);
assert!(
codex.len() <= CODEX_SESSION_CONTEXT_BUDGET,
"{}",
codex.len()
);
assert!(codex.contains("TraceDecay project hint:"));
assert!(!codex.contains(&dump));
}
}
20 changes: 18 additions & 2 deletions crates/tracedecay-mcp-catalog/src/definitions/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,30 @@ pub(super) fn def_status() -> ToolDefinition {
def_always_load(
"tracedecay_status",
"Graph Status",
"Return aggregate statistics about the code graph (node/edge/file counts, DB size, etc.).",
"Return a compact summary of the code graph (counts and freshness). Full branch diagnostics are opt-in.",
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"include_branch_diagnostics": {
"type": "boolean",
"description": "Include the full tracked-branch diagnostic list (default: true). Disable for compact status consumers."
"default": false,
"description": "Full tracked-branch diagnostic list. Default false."
},
"include_storage_health": {
"type": "boolean",
"default": false,
"description": "Storage-health snapshot. Default false."
},
"include_session_ingest": {
"type": "boolean",
"default": false,
"description": "Session-ingest health. Default false."
},
"include_staleness": {
"type": "boolean",
"default": false,
"description": "Git staleness object. Default false."
}
}
}),
Expand Down
2 changes: 1 addition & 1 deletion crates/tracedecay-mcp-catalog/src/definitions/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ pub(super) fn def_retrieve() -> ToolDefinition {
def(
"tracedecay_retrieve",
"Retrieve Truncated Response",
"Use `tracedecay_retrieve` with required argument `handle` to retrieve one bounded page of the exact cached original text for a local response handle emitted by a truncated MCP response. Start with offset 0, then pass each returned next_offset until has_more is false; concatenating content in offset order reconstructs the original byte-exactly. max_chars is clamped to the response-frame budget. This does not re-run the source tool or read a file/session/node again; handles are scoped to the active project store, expire automatically, and never reference remote storage. If the original truncated response used project_selector.project_id, pass the same selector here. Only call it when the missing details are needed to answer the user's request.",
"Use `tracedecay_retrieve` with required argument `handle` to read one bounded page of a truncated MCP response's locally cached text. Pass offset and max_chars for the omitted span you need. Do not walk next_offset until has_more is false or concatenate pages back into the conversation: the stored body already exceeded the response budget. max_chars is clamped to the response-frame budget. This does not re-run the source tool or read a file/session/node again; handles are scoped to the active project store, expire automatically, and never reference remote storage. If the original truncated response used project_selector.project_id, pass the same selector here. Only call it when the missing details are needed to answer the user's request.",
json!({
"type": "object",
"properties": with_project_selector_properties(json!({
Expand Down
5 changes: 3 additions & 2 deletions crates/tracedecay-mcp-catalog/src/definitions/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ pub(super) fn def_skill_view() -> ToolDefinition {
def(
"tracedecay_skill_view",
"Skill View",
"Read one agent-managed skill package from the active TraceDecay profile. Returns full metadata, body text, usage summary, stale/archive and improvement recommendation evidence, and support files by default.",
"Read one agent-managed skill package from the active TraceDecay profile. Returns metadata, body text, usage summary, and support-file path summaries. Support-file bytes are omitted unless include_support_files is true.",
json!({
"type": "object",
"properties": {
Expand All @@ -45,7 +45,8 @@ pub(super) fn def_skill_view() -> ToolDefinition {
},
"include_support_files": {
"type": "boolean",
"description": "If false, omit support file byte payloads from the response (default: true)."
"default": false,
"description": "Include support-file byte payloads. Default false; the response still lists each path and byte length."
}
},
"required": ["id"]
Expand Down
67 changes: 67 additions & 0 deletions crates/tracedecay-mcp-catalog/src/definitions/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,70 @@ fn per_session_budget_does_not_leak_through_the_cached_registry() {
"an unbudgeted caller inherited another session's budget: {neutral_description}"
);
}

/// Always-loaded schemas enter the model prompt on every turn. The agreed cap
/// is the small core; growing it is a context-window decision, not a drive-by.
#[test]
fn always_loaded_tools_stay_inside_the_agreed_core() {
let definitions = get_maximal_tool_definitions().expect("tool definitions");
let mut always_loaded = definitions
.iter()
.filter(|definition| {
definition
.meta
.as_ref()
.and_then(|meta| meta.get("anthropic/alwaysLoad"))
.and_then(serde_json::Value::as_bool)
== Some(true)
})
.map(|definition| definition.name.as_str())
.collect::<Vec<_>>();
always_loaded.sort_unstable();
assert_eq!(
always_loaded,
vec![
"tracedecay_active_project",
"tracedecay_callers",
"tracedecay_context",
"tracedecay_grep",
"tracedecay_search",
"tracedecay_status",
"tracedecay_storage_status",
]
);
}

#[test]
fn status_and_skill_view_default_to_summaries() {
let definitions = get_tool_definitions().expect("tool definitions");
let status = definitions
.iter()
.find(|definition| definition.name == "tracedecay_status")
.expect("status");
for key in [
"include_branch_diagnostics",
"include_storage_health",
"include_session_ingest",
"include_staleness",
] {
assert_eq!(
status.input_schema["properties"][key]["default"],
serde_json::json!(false),
"{key}"
);
}
let view = definitions
.iter()
.find(|definition| definition.name == "tracedecay_skill_view")
.expect("skill view");
assert_eq!(
view.input_schema["properties"]["include_support_files"]["default"],
serde_json::json!(false)
);
let retrieve = definitions
.iter()
.find(|definition| definition.name == "tracedecay_retrieve")
.expect("retrieve");
assert!(retrieve.description.contains("Do not walk next_offset"));
assert!(!retrieve.description.contains("byte-exactly"));
}
12 changes: 8 additions & 4 deletions crates/tracedecay-mcp/src/handlers/info/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,14 @@ pub async fn handle_status(
));
}

let include_branch_diagnostics = status_arg_flag(&args, "include_branch_diagnostics", true);
let include_storage_health = status_arg_flag(&args, "include_storage_health", true);
let include_session_ingest = status_arg_flag(&args, "include_session_ingest", true);
let include_staleness = status_arg_flag(&args, "include_staleness", true);
// Compact by default. The CLI already skips these sections because they
// commonly push status over the response-frame budget, and the truncated
// body is not something the caller should reassemble into context. Opt in
// when the full diagnostic section is the thing being asked for.
let include_branch_diagnostics = status_arg_flag(&args, "include_branch_diagnostics", false);
let include_storage_health = status_arg_flag(&args, "include_storage_health", false);
let include_session_ingest = status_arg_flag(&args, "include_session_ingest", false);
let include_staleness = status_arg_flag(&args, "include_staleness", false);

let graph_statistics = graph_statistics_value(ctx.generation_census())?;
let mut output = json!({
Expand Down
20 changes: 19 additions & 1 deletion crates/tracedecay-mcp/src/handlers/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ fn parse_state(args: &Value) -> Result<Option<ManagedSkillState>> {
}
}

fn support_file_summaries(skill: &ManagedSkill) -> Vec<Value> {
skill
.support_files
.iter()
.map(|file| {
json!({
"path": file.path.display().to_string(),
"byte_len": file.bytes.len(),
})
})
.collect()
}

fn skill_summary(skill: &ManagedSkill, include_body: bool, usage_summary: &Value) -> Value {
let mut summary = json!({
"metadata": skill.metadata,
Expand Down Expand Up @@ -163,7 +176,10 @@ pub async fn handle_skill_view(
) -> Result<ToolResult> {
let profile_root = tracedecay_runtime_core::storage::default_profile_root()?;
sync_project_skill_analytics(cg, &profile_root, analytics_db).await?;
let include_support_files = optional_bool(&args, "include_support_files", true);
// Path summaries stay in the response either way. Byte payloads are a
// separate read the caller opts into, so a view does not inline unused
// support files into the context window.
let include_support_files = optional_bool(&args, "include_support_files", false);
let mut skill = hotpath::future!(
load_managed_skill(&profile_root, required_str(&args, "id")?),
label = "mcp.automation.skill_view.load"
Expand Down Expand Up @@ -212,6 +228,7 @@ pub async fn handle_skill_view(
skill_improvement_recommendations(std::slice::from_ref(&usage_summary))
.into_iter()
.next();
let support_file_summaries = support_file_summaries(&skill);
if !include_support_files {
skill.support_files.clear();
}
Expand All @@ -223,6 +240,7 @@ pub async fn handle_skill_view(
"stale_recommendation": stale_recommendation,
"improvement_recommendation": improvement_recommendation,
"support_files_included": include_support_files,
"support_file_summaries": support_file_summaries,
});
Ok(tool_json_with_md(
Some(cg.project_root()),
Expand Down
31 changes: 26 additions & 5 deletions crates/tracedecay-mcp/src/tools/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,29 @@ pub use tracedecay_runtime_core::text::format_relative_time;

const MARKDOWN_TRUNCATION_RESERVED_CHARS: usize = 2_048;

/// Agent-facing recovery text for a truncated response.
///
/// The stored body is larger than one response frame by definition. The
/// instruction must not tell the caller to page through and join that body
/// back into the conversation.
fn truncated_response_recovery_instruction(
preview_chars: usize,
original_chars: usize,
expires_at: i64,
handle: &str,
) -> String {
format!(
"This response was truncated: `preview` contains only the first {preview_chars} of \
{original_chars} characters. The original response is stored locally and expires at \
{expires_at} (TTL {RESPONSE_HANDLE_TTL_SECS} seconds). Use the preview. Do not page \
through and join the stored body back into the conversation. If one omitted detail \
is required, call `{RESPONSE_RETRIEVE_TOOL}` once with required argument `handle` \
set to `{handle}` and an offset/max_chars window for that span only. If the original \
call used `project_selector.project_id`, pass the same selector. Only call it if the \
missing details are needed."
)
}

fn parse_format(args: &Value) -> RequestedOutputFormat {
requested_output_format(args)
}
Expand Down Expand Up @@ -105,13 +128,11 @@ pub fn truncated_json_envelope_with_handle(project_root: Option<&Path>, formatte
);
object.insert(
"retrieve_instruction".to_string(),
serde_json::json!(format!(
"This response was truncated: `preview` contains only the first {} of {} characters. The full original response is stored locally in this project and expires at {} (TTL {} seconds). To recover it, call `{RESPONSE_RETRIEVE_TOOL}` with required argument `handle` set to `{}`. The stored body is by definition larger than one response frame, so each call returns one page: repeat the call with `offset` set to the `next_offset` the previous page reported until `has_more` is false, then join the pages in order. If the original call used `project_selector.project_id`, pass the same selector so the handle is read from that project cache. Only call it if the missing details are needed.",
serde_json::json!(truncated_response_recovery_instruction(
preview.chars().count(),
original_chars,
record.expires_at,
RESPONSE_HANDLE_TTL_SECS,
record.handle
&record.handle,
)),
);
} else if let Some(status) = &handle.unavailable {
Expand Down Expand Up @@ -403,7 +424,7 @@ fn render_markdown_truncation(
if let Some(record) = &handle.record {
let _ = writeln!(
rendered,
"Full response stored locally. Retrieve it with `{RESPONSE_RETRIEVE_TOOL}` using handle `{}` before {}.",
"Preview is the context to use. Do not join stored pages back into the conversation. If one omitted detail is required, call `{RESPONSE_RETRIEVE_TOOL}` once using handle `{}` before {}.",
record.handle, record.expires_at
);
} else if let Some(status) = &handle.unavailable {
Expand Down
Loading
Loading