feat: Externalize research tool as embedded skill

Replaces the built-in research/research_status tools with a portable
skill-based approach:

- Add embedded skills infrastructure (skills compiled into binary)
- Add repo-local skills/ directory support (highest priority)
- Create research skill with SKILL.md and g3-research shell script
- Script extraction to .g3/bin/ with version tracking
- Filesystem-based handoff via .g3/research/<id>/status.json
- Remove PendingResearchManager and all research tool code
- Update system prompt to reference skill instead of tool

Benefits:
- No special tool infrastructure needed (just shell + read_file)
- Context-efficient (reports stay on disk until needed)
- Crash-resilient (state persisted to filesystem)
- Portable (skill can be overridden per-workspace)

Breaking change: research tool calls now return a deprecation message
pointing to the research skill.
This commit is contained in:
Dhanji R. Prasanna
2026-02-05 13:23:26 +11:00
parent bf9e3dc878
commit 39e586982c
19 changed files with 949 additions and 1638 deletions

View File

@@ -12,7 +12,6 @@ use serde_json::json;
pub struct ToolConfig {
pub webdriver: bool,
pub computer_control: bool,
pub exclude_research: bool,
}
impl ToolConfig {
@@ -20,16 +19,8 @@ impl ToolConfig {
Self {
webdriver,
computer_control,
exclude_research: false,
}
}
/// Create a config with the research tool excluded.
/// Used for scout agent to prevent recursion.
pub fn with_research_excluded(mut self) -> Self {
self.exclude_research = true;
self
}
}
/// Create tool definitions for native tool calling providers.
@@ -37,7 +28,7 @@ impl ToolConfig {
/// Returns a vector of Tool definitions that describe the available tools
/// and their input schemas.
pub fn create_tool_definitions(config: ToolConfig) -> Vec<Tool> {
let mut tools = create_core_tools(config.exclude_research);
let mut tools = create_core_tools();
if config.webdriver {
tools.extend(create_webdriver_tools());
@@ -47,7 +38,7 @@ pub fn create_tool_definitions(config: ToolConfig) -> Vec<Tool> {
}
/// Create the core tools that are always available
fn create_core_tools(exclude_research: bool) -> Vec<Tool> {
fn create_core_tools() -> Vec<Tool> {
let mut tools = vec![
Tool {
name: "shell".to_string(),
@@ -195,40 +186,6 @@ fn create_core_tools(exclude_research: bool) -> Vec<Tool> {
},
];
// Conditionally add the research tool (excluded for scout agent to prevent recursion)
if !exclude_research {
tools.push(Tool {
name: "research".to_string(),
description: "Initiate web-based research on a topic. This tool is ASYNCHRONOUS - it spawns a research agent in the background and returns immediately with a research_id. Results are automatically injected into the conversation when ready. Use this when you need to research APIs, SDKs, libraries, approaches, bugs, or documentation. If you need the results before continuing, say so and yield the turn to the user. Check status with research_status tool.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The research question or topic to investigate. Be specific about what you need to know."
}
},
"required": ["query"]
}),
});
// research_status tool - check status of pending research
tools.push(Tool {
name: "research_status".to_string(),
description: "Check the status of pending research tasks. Call without arguments to list all pending research, or with a research_id to check a specific task. Use this to see if research has completed before it's automatically injected.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"research_id": {
"type": "string",
"description": "Optional: specific research_id to check. If omitted, lists all pending research tasks."
}
},
"required": []
}),
});
}
// Plan Mode tools
tools.push(Tool {
name: "plan_read".to_string(),
@@ -499,12 +456,12 @@ mod tests {
#[test]
fn test_core_tools_count() {
let tools = create_core_tools(false);
let tools = create_core_tools();
// Core tools: shell, background_process, read_file, read_image,
// write_file, str_replace, code_search,
// research, research_status, remember, plan_read, plan_write, plan_approve
// (14 total - memory is auto-loaded, only remember tool needed)
assert_eq!(tools.len(), 14);
assert_eq!(tools.len(), 12);
}
#[test]
@@ -518,7 +475,7 @@ mod tests {
fn test_create_tool_definitions_core_only() {
let config = ToolConfig::default();
let tools = create_tool_definitions(config);
assert_eq!(tools.len(), 14);
assert_eq!(tools.len(), 12);
}
#[test]
@@ -526,28 +483,16 @@ mod tests {
let config = ToolConfig::new(true, true);
let tools = create_tool_definitions(config);
// 14 core + 15 webdriver = 29
assert_eq!(tools.len(), 29);
assert_eq!(tools.len(), 27);
}
#[test]
fn test_tool_has_required_fields() {
let tools = create_core_tools(false);
let tools = create_core_tools();
for tool in tools {
assert!(!tool.name.is_empty(), "Tool name should not be empty");
assert!(!tool.description.is_empty(), "Tool description should not be empty");
assert!(tool.input_schema.is_object(), "Tool input_schema should be an object");
}
}
#[test]
fn test_research_tool_excluded() {
let tools_with_research = create_core_tools(false);
let tools_without_research = create_core_tools(true);
assert_eq!(tools_with_research.len(), 14);
assert_eq!(tools_without_research.len(), 12); // research + research_status both excluded
assert!(tools_with_research.iter().any(|t| t.name == "research"));
assert!(!tools_without_research.iter().any(|t| t.name == "research"));
}
}