huskies: merge 1236 story Ask "what happened with X" and get a paged, subject-scoped history

This commit is contained in:
Huskies Agent
2026-07-20 14:44:32 +00:00
parent 30ca3ad463
commit c9804ecfdf
18 changed files with 911 additions and 25 deletions
+5 -2
View File
@@ -3,8 +3,8 @@
use serde_json::Value;
use super::{
agent_tools, diagnostics, git_tools, merge_tools, qa_tools, shell_tools, status_tools,
story_tools, timer_tools, trigger_tools, wizard_tools,
agent_tools, diagnostics, git_tools, history_tools, merge_tools, qa_tools, shell_tools,
status_tools, story_tools, timer_tools, trigger_tools, wizard_tools,
};
use crate::http::context::AppContext;
@@ -91,6 +91,9 @@ pub async fn dispatch_tool_call(
"get_token_usage" => diagnostics::tool_get_token_usage(&args, ctx),
// Chat turn telemetry (story 1209)
"chat_telemetry" => diagnostics::tool_chat_telemetry(&args),
// Subject-scoped history (story 1236)
"get_history" => history_tools::tool_get_history(&args),
"get_history_entry" => history_tools::tool_get_history_entry(&args),
// Delete story
"delete_story" => story_tools::tool_delete_story(&args, ctx).await,
// Purge story (CRDT tombstone — story 521)
+66
View File
@@ -0,0 +1,66 @@
//! MCP tools for subject-scoped history queries (story 1236).
//!
//! `get_history` returns a time-ordered, cursor-paged list of short typed
//! summaries for a subject (story, sled, or project); `get_history_entry`
//! resolves the `ref` from one of those summaries into its full payload.
use serde_json::{Value, json};
/// MCP tool: return a paged, subject-scoped history listing.
pub(crate) fn tool_get_history(args: &Value) -> Result<String, String> {
let subject_type = args
.get("subject_type")
.and_then(|v| v.as_str())
.ok_or("subject_type is required (one of: story, robot, project)")?;
let subject_id = args
.get("subject_id")
.and_then(|v| v.as_str())
.ok_or("subject_id is required")?;
if !matches!(subject_type, "story" | "sled" | "robot" | "project") {
return Err(format!(
"subject_type must be one of: story, robot, project (got '{subject_type}')"
));
}
let since = args.get("since").and_then(|v| v.as_i64());
let until = args.get("until").and_then(|v| v.as_i64());
let cursor = args.get("cursor").and_then(|v| v.as_str());
let limit = args
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(50);
let page = crate::history::get_history(subject_type, subject_id, since, until, cursor, limit);
let entries: Vec<Value> = page
.entries
.iter()
.map(|e| {
json!({
"ref": e.entry_ref,
"kind": e.kind,
"subject_type": e.subject_type,
"subject_id": e.subject_id,
"at": e.at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"summary": e.summary,
})
})
.collect();
serde_json::to_string_pretty(&json!({
"entries": entries,
"next_cursor": page.next_cursor,
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// MCP tool: resolve a `ref` from `get_history` into its full payload.
pub(crate) fn tool_get_history_entry(args: &Value) -> Result<String, String> {
let entry_ref = args
.get("ref")
.and_then(|v| v.as_str())
.ok_or("ref is required (from a get_history entry)")?;
crate::history::get_history_entry(entry_ref)
.ok_or_else(|| format!("No history entry found for ref '{entry_ref}'"))
}
+2
View File
@@ -12,6 +12,8 @@ pub mod diagnostics;
pub mod dispatch;
/// MCP tools for git operations scoped to agent worktrees.
pub mod git_tools;
/// MCP tools for subject-scoped history queries (`get_history`, `get_history_entry`).
pub mod history_tools;
/// MCP tools for merge status and merge-to-master operations.
pub mod merge_tools;
/// Task-local progress emitter used to deliver `notifications/progress`
+3 -1
View File
@@ -121,7 +121,9 @@ mod tests {
assert!(names.contains(&"gc"));
assert!(names.contains(&"chat_telemetry"));
assert!(names.contains(&"ask_question"));
assert_eq!(tools.len(), 89);
assert!(names.contains(&"get_history"));
assert!(names.contains(&"get_history_entry"));
assert_eq!(tools.len(), 91);
}
#[test]
@@ -120,6 +120,54 @@ pub(super) fn system_tools() -> Vec<Value> {
}
}
}),
json!({
"name": "get_history",
"description": "Return a time-ordered, cursor-paged history for a subject (story, robot, or project) over an optional time range. Each entry is a short typed summary (chat_turn, agent_run, or pipeline_transition) plus a 'ref' string; fetch the full payload for one entry with get_history_entry.",
"inputSchema": {
"type": "object",
"properties": {
"subject_type": {
"type": "string",
"description": "One of: story, robot, project"
},
"subject_id": {
"type": "string",
"description": "Story ID, robot (sled) hex ID, or project/persona name"
},
"since": {
"type": "integer",
"description": "Optional Unix-second lower bound (inclusive)"
},
"until": {
"type": "integer",
"description": "Optional Unix-second upper bound (inclusive)"
},
"cursor": {
"type": "string",
"description": "Opaque cursor from a previous page's next_cursor to resume from"
},
"limit": {
"type": "integer",
"description": "Maximum number of entries to return (default 50, max 500)"
}
},
"required": ["subject_type", "subject_id"]
}
}),
json!({
"name": "get_history_entry",
"description": "Resolve a 'ref' string returned by get_history into its full payload.",
"inputSchema": {
"type": "object",
"properties": {
"ref": {
"type": "string",
"description": "The 'ref' value from a get_history entry"
}
},
"required": ["ref"]
}
}),
json!({
"name": "run_command",
"description": "Execute a shell command in an agent's worktree directory. The working_dir must be inside .huskies/worktrees/. Returns stdout, stderr, exit_code, and timed_out. Supports SSE streaming (send Accept: text/event-stream) for long-running commands. Dangerous commands (rm -rf /, sudo, etc.) are blocked.",