mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-02 23:56:24 +08:00
Expand GitHub WASM tool surface (#1884)
* Expand GitHub WASM tool surface * Tighten GitHub tool input validation
This commit is contained in:
@@ -4,13 +4,15 @@
|
||||
"kind": "tool",
|
||||
"version": "0.2.2",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "GitHub integration for issues, PRs, repos, and code search",
|
||||
"description": "GitHub integration for repositories, issues, pull requests, search, branches, file writes, releases, and workflows",
|
||||
"keywords": [
|
||||
"git",
|
||||
"code",
|
||||
"issues",
|
||||
"pull-requests",
|
||||
"repositories"
|
||||
"repositories",
|
||||
"search",
|
||||
"releases"
|
||||
],
|
||||
"source": {
|
||||
"dir": "tools-src/github",
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::tools::builtin::{
|
||||
ToolUpgradeTool, WriteFileTool,
|
||||
};
|
||||
use crate::tools::rate_limiter::RateLimiter;
|
||||
use crate::tools::tool::{ApprovalRequirement, Tool, ToolDomain};
|
||||
use crate::tools::tool::{ApprovalRequirement, Tool, ToolDiscoverySummary, ToolDomain};
|
||||
use crate::tools::wasm::{
|
||||
Capabilities, OAuthRefreshConfig, ResourceLimits, SharedCredentialRegistry, WasmError,
|
||||
WasmStorageError, WasmToolRuntime, WasmToolStore, WasmToolWrapper,
|
||||
@@ -674,6 +674,9 @@ impl ToolRegistry {
|
||||
if let Some(s) = reg.schema {
|
||||
wrapper = wrapper.with_schema(s);
|
||||
}
|
||||
if let Some(summary) = reg.discovery_summary {
|
||||
wrapper = wrapper.with_discovery_summary(summary);
|
||||
}
|
||||
if let Some(store) = reg.secrets_store {
|
||||
wrapper = wrapper.with_secrets_store(store);
|
||||
}
|
||||
@@ -748,6 +751,7 @@ impl ToolRegistry {
|
||||
limits: None,
|
||||
description: Some(&tool_with_binary.tool.description),
|
||||
schema: Some(tool_with_binary.tool.parameters_schema.clone()),
|
||||
discovery_summary: None,
|
||||
secrets_store: self.secrets_store.clone(),
|
||||
oauth_refresh: None,
|
||||
})
|
||||
@@ -791,6 +795,8 @@ pub struct WasmToolRegistration<'a> {
|
||||
pub description: Option<&'a str>,
|
||||
/// Optional parameter schema override.
|
||||
pub schema: Option<serde_json::Value>,
|
||||
/// Optional curated discovery guidance for `tool_info(detail: "summary")`.
|
||||
pub discovery_summary: Option<ToolDiscoverySummary>,
|
||||
/// Secrets store for credential injection at request time.
|
||||
pub secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
/// OAuth refresh configuration for auto-refreshing expired tokens.
|
||||
|
||||
@@ -33,6 +33,7 @@ use std::time::Duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::secrets::{CredentialLocation, CredentialMapping};
|
||||
use crate::tools::tool::ToolDiscoverySummary;
|
||||
use crate::tools::wasm::{
|
||||
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
|
||||
ToolInvokeCapability, WebhookCapability, WorkspaceCapability,
|
||||
@@ -47,6 +48,10 @@ pub struct CapabilitiesFile {
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Optional curated guidance surfaced by `tool_info(detail: "summary")`.
|
||||
#[serde(default)]
|
||||
pub discovery_summary: Option<ToolDiscoverySummary>,
|
||||
|
||||
/// Extension version (semver).
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
@@ -154,6 +159,7 @@ impl CapabilitiesFile {
|
||||
if let Some(inner) = self.capabilities.take() {
|
||||
let inner = inner.resolve_nested_inner(depth + 1);
|
||||
self.description = self.description.or(inner.description);
|
||||
self.discovery_summary = self.discovery_summary.or(inner.discovery_summary);
|
||||
self.http = self.http.or(inner.http);
|
||||
self.secrets = self.secrets.or(inner.secrets);
|
||||
self.tool_invoke = self.tool_invoke.or(inner.tool_invoke);
|
||||
@@ -1531,6 +1537,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discovery_summary_promoted_from_nested_capabilities() {
|
||||
let json = r#"{
|
||||
"capabilities": {
|
||||
"discovery_summary": {
|
||||
"always_required": ["action"],
|
||||
"notes": ["Use tool_info for full schema"]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let caps = CapabilitiesFile::from_json(json).unwrap();
|
||||
let summary = caps
|
||||
.discovery_summary
|
||||
.expect("discovery summary should be promoted");
|
||||
assert_eq!(summary.always_required, vec!["action".to_string()]);
|
||||
assert_eq!(
|
||||
summary.notes,
|
||||
vec!["Use tool_info for full schema".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test for issue #974: deeply nested capabilities wrappers
|
||||
/// must not cause stack overflow. resolve_nested should stop at
|
||||
/// MAX_NESTED_DEPTH and return gracefully.
|
||||
|
||||
@@ -128,48 +128,50 @@ impl WasmToolLoader {
|
||||
// capabilities file — it is auto-derived from the WASM module's
|
||||
// schema() export at prepare time (see WasmToolSchemas::compact_schema),
|
||||
// so no schema override is needed here.
|
||||
let (capabilities, oauth_refresh, description) = if let Some(cap_path) = capabilities_path {
|
||||
if cap_path.exists() {
|
||||
let cap_bytes = fs::read(cap_path).await?;
|
||||
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
|
||||
cap_file.validate(name);
|
||||
let (capabilities, oauth_refresh, description, discovery_summary) =
|
||||
if let Some(cap_path) = capabilities_path {
|
||||
if cap_path.exists() {
|
||||
let cap_bytes = fs::read(cap_path).await?;
|
||||
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
|
||||
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
|
||||
cap_file.validate(name);
|
||||
|
||||
// Check WIT version compatibility
|
||||
check_wit_version_compat(
|
||||
name,
|
||||
cap_file.wit_version.as_deref(),
|
||||
crate::tools::wasm::WIT_TOOL_VERSION,
|
||||
)?;
|
||||
// Check WIT version compatibility
|
||||
check_wit_version_compat(
|
||||
name,
|
||||
cap_file.wit_version.as_deref(),
|
||||
crate::tools::wasm::WIT_TOOL_VERSION,
|
||||
)?;
|
||||
|
||||
let caps = cap_file.to_capabilities();
|
||||
let oauth = resolve_oauth_refresh_config(&cap_file);
|
||||
let desc = cap_file.description.clone();
|
||||
if desc.is_none() {
|
||||
let caps = cap_file.to_capabilities();
|
||||
let oauth = resolve_oauth_refresh_config(&cap_file);
|
||||
let desc = cap_file.description.clone();
|
||||
let summary = cap_file.discovery_summary.clone();
|
||||
if desc.is_none() {
|
||||
tracing::warn!(
|
||||
tool = name,
|
||||
path = %cap_path.display(),
|
||||
"Capabilities file missing \"description\" field; \
|
||||
tool will use generic fallback description"
|
||||
);
|
||||
}
|
||||
(caps, oauth, desc, summary)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
tool = name,
|
||||
path = %cap_path.display(),
|
||||
"Capabilities file missing \"description\" field; \
|
||||
tool will use generic fallback description"
|
||||
"Capabilities file not found, using default (no permissions)"
|
||||
);
|
||||
(Capabilities::default(), None, None, None)
|
||||
}
|
||||
(caps, oauth, desc)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
tool = name,
|
||||
path = %cap_path.display(),
|
||||
"Capabilities file not found, using default (no permissions)"
|
||||
);
|
||||
(Capabilities::default(), None, None)
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
tool = name,
|
||||
"No capabilities file for WASM tool; \
|
||||
"No capabilities file for WASM tool; \
|
||||
tool will use generic fallback description"
|
||||
);
|
||||
(Capabilities::default(), None, None)
|
||||
};
|
||||
);
|
||||
(Capabilities::default(), None, None, None)
|
||||
};
|
||||
|
||||
// Register the tool
|
||||
self.registry
|
||||
@@ -181,6 +183,7 @@ impl WasmToolLoader {
|
||||
limits: None,
|
||||
description: description.as_deref(),
|
||||
schema: None,
|
||||
discovery_summary,
|
||||
secrets_store: self.secrets_store.clone(),
|
||||
oauth_refresh,
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::context::JobContext;
|
||||
use crate::llm::recording::{HttpExchangeRequest, HttpExchangeResponse, HttpInterceptor};
|
||||
use crate::safety::LeakDetector;
|
||||
use crate::secrets::{DecryptedSecret, SecretsStore};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
use crate::tools::tool::{Tool, ToolDiscoverySummary, ToolError, ToolOutput};
|
||||
use crate::tools::wasm::capabilities::Capabilities;
|
||||
use crate::tools::wasm::credential_injector::{
|
||||
InjectedCredentials, host_matches_pattern, inject_credential,
|
||||
@@ -585,6 +585,8 @@ pub struct WasmToolWrapper {
|
||||
description: String,
|
||||
/// Compact and discovery schemas for this tool.
|
||||
schemas: WasmToolSchemas,
|
||||
/// Optional curated discovery guidance surfaced by `tool_info`.
|
||||
discovery_summary: Option<ToolDiscoverySummary>,
|
||||
/// Injected credentials for HTTP requests (e.g., OAuth tokens).
|
||||
/// Keys are placeholder names like "GOOGLE_ACCESS_TOKEN".
|
||||
credentials: HashMap<String, String>,
|
||||
@@ -836,6 +838,7 @@ impl WasmToolWrapper {
|
||||
Self {
|
||||
description: prepared.description.clone(),
|
||||
schemas: WasmToolSchemas::new(prepared.schema.clone()),
|
||||
discovery_summary: None,
|
||||
runtime,
|
||||
prepared,
|
||||
capabilities,
|
||||
@@ -882,6 +885,12 @@ impl WasmToolWrapper {
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the curated discovery summary.
|
||||
pub fn with_discovery_summary(mut self, summary: ToolDiscoverySummary) -> Self {
|
||||
self.discovery_summary = Some(summary);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set credentials for HTTP request placeholder injection.
|
||||
pub fn with_credentials(mut self, credentials: HashMap<String, String>) -> Self {
|
||||
self.credentials = credentials;
|
||||
@@ -1098,6 +1107,10 @@ impl Tool for WasmToolWrapper {
|
||||
self.schemas.discovery()
|
||||
}
|
||||
|
||||
fn discovery_summary(&self) -> Option<ToolDiscoverySummary> {
|
||||
self.discovery_summary.clone()
|
||||
}
|
||||
|
||||
/// Compose the tool schema for LLM function calling.
|
||||
///
|
||||
/// When the advertised schema is permissive (no typed properties), appends
|
||||
@@ -1149,6 +1162,7 @@ impl Tool for WasmToolWrapper {
|
||||
let capabilities = self.capabilities.clone();
|
||||
let description = self.description.clone();
|
||||
let schemas = self.schemas.clone();
|
||||
let discovery_summary = self.discovery_summary.clone();
|
||||
let credentials = self.credentials.clone();
|
||||
|
||||
// Execute in blocking task with timeout
|
||||
@@ -1159,6 +1173,7 @@ impl Tool for WasmToolWrapper {
|
||||
capabilities,
|
||||
description,
|
||||
schemas,
|
||||
discovery_summary,
|
||||
credentials,
|
||||
secrets_store: None, // Not needed in blocking task
|
||||
oauth_refresh: None, // Already used above for pre-refresh
|
||||
@@ -3058,6 +3073,27 @@ mod tests {
|
||||
assert_eq!(wrapper.discovery_schema(), typed_schema); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wrapper_returns_curated_discovery_summary() {
|
||||
let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::for_testing()).unwrap()); // safety: test-only setup
|
||||
let prepared = runtime
|
||||
.prepare("github", b"\0asm\x0d\0\x01\0", None)
|
||||
.await
|
||||
.unwrap(); // safety: test-only setup
|
||||
|
||||
let summary = crate::tools::tool::ToolDiscoverySummary {
|
||||
always_required: vec!["action".into()],
|
||||
notes: vec!["Use tool_info for the full schema".into()],
|
||||
..crate::tools::tool::ToolDiscoverySummary::default()
|
||||
};
|
||||
|
||||
let wrapper =
|
||||
super::WasmToolWrapper::new(Arc::clone(&runtime), prepared, Capabilities::default())
|
||||
.with_discovery_summary(summary.clone());
|
||||
|
||||
assert_eq!(wrapper.discovery_summary(), Some(summary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_tool_usage_hint_detects_nullable_container_properties() {
|
||||
let schema = serde_json::json!({
|
||||
|
||||
@@ -46,6 +46,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn github_exchange(
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: Option<String>,
|
||||
response_body: &str,
|
||||
) -> HttpExchange {
|
||||
HttpExchange {
|
||||
request: HttpExchangeRequest {
|
||||
method: method.to_string(),
|
||||
url: url.to_string(),
|
||||
headers: vec![],
|
||||
body,
|
||||
},
|
||||
response: github_ok(response_body),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_trace(trace: LlmTrace, prompt: &str) {
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_wasm_tool("github", GITHUB_WASM, Some(GITHUB_CAPS.into()))
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message(prompt).await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
rig.shutdown();
|
||||
}
|
||||
|
||||
/// LLM sends `limit: "50"` (string) to `list_issues`. Coercion converts it
|
||||
/// to integer, and the WASM tool must call `GET /repos/.../issues?...&per_page=50`.
|
||||
#[tokio::test]
|
||||
@@ -110,18 +141,7 @@ mod tests {
|
||||
steps: Vec::new(),
|
||||
};
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_wasm_tool("github", GITHUB_WASM, Some(GITHUB_CAPS.into()))
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("List issues in nearai/ironclaw with limit 50")
|
||||
.await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
rig.shutdown();
|
||||
run_trace(trace, "List issues in nearai/ironclaw with limit 50").await;
|
||||
}
|
||||
|
||||
/// LLM sends `issue_number: "42"` (string) to `get_issue`. Coercion converts
|
||||
@@ -186,17 +206,7 @@ mod tests {
|
||||
steps: Vec::new(),
|
||||
};
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_wasm_tool("github", GITHUB_WASM, Some(GITHUB_CAPS.into()))
|
||||
.build()
|
||||
.await;
|
||||
|
||||
rig.send_message("Get issue 42 from nearai/ironclaw").await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
|
||||
rig.shutdown();
|
||||
run_trace(trace, "Get issue 42 from nearai/ironclaw").await;
|
||||
}
|
||||
|
||||
/// LLM sends `limit: "25"` (string) to `list_pull_requests`. URL must
|
||||
@@ -262,16 +272,449 @@ mod tests {
|
||||
steps: Vec::new(),
|
||||
};
|
||||
|
||||
let rig = TestRigBuilder::new()
|
||||
.with_trace(trace.clone())
|
||||
.with_wasm_tool("github", GITHUB_WASM, Some(GITHUB_CAPS.into()))
|
||||
.build()
|
||||
.await;
|
||||
run_trace(trace, "List PRs in nearai/ironclaw").await;
|
||||
}
|
||||
|
||||
rig.send_message("List PRs in nearai/ironclaw").await;
|
||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
||||
rig.verify_trace_expects(&trace, &responses);
|
||||
/// LLM sends pagination as strings to `search_code`. Coercion converts them
|
||||
/// to integers, and the tool must construct the expected search query.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-compiled WASM binary
|
||||
async fn wasm_github_search_code_coerces_string_pagination() {
|
||||
let expected_url = "https://api.github.com/search/code?q=repo%3Anearai%2Fironclaw%20path%3Asrc%20Tool&per_page=10&page=2&sort=indexed&order=desc";
|
||||
|
||||
rig.shutdown();
|
||||
let trace = LlmTrace {
|
||||
model_name: "test-wasm-coercion-search-code".to_string(),
|
||||
turns: vec![crate::support::trace_llm::TraceTurn {
|
||||
user_input: "Search code in nearai/ironclaw".to_string(),
|
||||
steps: vec![
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::ToolCalls {
|
||||
tool_calls: vec![TraceToolCall {
|
||||
id: "call_gh_4".to_string(),
|
||||
name: "github".to_string(),
|
||||
arguments: json!({
|
||||
"action": "search_code",
|
||||
"query": "repo:nearai/ironclaw path:src Tool",
|
||||
"limit": "10",
|
||||
"page": "2",
|
||||
"sort": "indexed",
|
||||
"order": "desc"
|
||||
}),
|
||||
}],
|
||||
input_tokens: 120,
|
||||
output_tokens: 40,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "Found matching code results.".to_string(),
|
||||
input_tokens: 180,
|
||||
output_tokens: 12,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
],
|
||||
expects: TraceExpects::default(),
|
||||
}],
|
||||
memory_snapshot: Vec::new(),
|
||||
http_exchanges: vec![github_exchange(
|
||||
"GET",
|
||||
expected_url,
|
||||
None,
|
||||
r#"{"total_count":1,"items":[{"name":"lib.rs","path":"src/lib.rs"}]}"#,
|
||||
)],
|
||||
expects: TraceExpects {
|
||||
tools_used: vec!["github".to_string()],
|
||||
all_tools_succeeded: Some(true),
|
||||
max_tool_calls: Some(1),
|
||||
min_responses: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
steps: Vec::new(),
|
||||
};
|
||||
|
||||
run_trace(trace, "Search code in nearai/ironclaw").await;
|
||||
}
|
||||
|
||||
/// `create_repo` must target the org repos endpoint and send the expected
|
||||
/// JSON body for repository creation.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-compiled WASM binary
|
||||
async fn wasm_github_create_repo_posts_expected_payload() {
|
||||
let expected_url = "https://api.github.com/orgs/nearai/repos";
|
||||
let expected_body = json!({
|
||||
"name": "github-tool-replay",
|
||||
"private": true,
|
||||
"auto_init": true,
|
||||
"description": "Replay-created repo",
|
||||
"gitignore_template": "Rust",
|
||||
"license_template": "mit"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let trace = LlmTrace {
|
||||
model_name: "test-wasm-create-repo".to_string(),
|
||||
turns: vec![crate::support::trace_llm::TraceTurn {
|
||||
user_input: "Create a private repo for nearai".to_string(),
|
||||
steps: vec![
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::ToolCalls {
|
||||
tool_calls: vec![TraceToolCall {
|
||||
id: "call_gh_5".to_string(),
|
||||
name: "github".to_string(),
|
||||
arguments: json!({
|
||||
"action": "create_repo",
|
||||
"name": "github-tool-replay",
|
||||
"description": "Replay-created repo",
|
||||
"private": true,
|
||||
"auto_init": true,
|
||||
"gitignore_template": "Rust",
|
||||
"license_template": "mit",
|
||||
"org": "nearai"
|
||||
}),
|
||||
}],
|
||||
input_tokens: 110,
|
||||
output_tokens: 40,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "Repository created.".to_string(),
|
||||
input_tokens: 150,
|
||||
output_tokens: 12,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
],
|
||||
expects: TraceExpects::default(),
|
||||
}],
|
||||
memory_snapshot: Vec::new(),
|
||||
http_exchanges: vec![github_exchange(
|
||||
"POST",
|
||||
expected_url,
|
||||
Some(expected_body),
|
||||
r#"{"name":"github-tool-replay","private":true}"#,
|
||||
)],
|
||||
expects: TraceExpects {
|
||||
tools_used: vec!["github".to_string()],
|
||||
all_tools_succeeded: Some(true),
|
||||
max_tool_calls: Some(1),
|
||||
min_responses: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
steps: Vec::new(),
|
||||
};
|
||||
|
||||
run_trace(trace, "Create a private repo for nearai").await;
|
||||
}
|
||||
|
||||
/// `create_branch` should fetch the source ref SHA and then create the new
|
||||
/// branch ref in a second request.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-compiled WASM binary
|
||||
async fn wasm_github_create_branch_replays_two_step_ref_flow() {
|
||||
let source_ref_url = "https://api.github.com/repos/nearai/ironclaw/git/ref/heads/main";
|
||||
let create_ref_url = "https://api.github.com/repos/nearai/ironclaw/git/refs";
|
||||
let create_ref_body = json!({
|
||||
"ref": "refs/heads/feature/replay-test",
|
||||
"sha": "abc123def4567890abc123def4567890abc123de"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let trace = LlmTrace {
|
||||
model_name: "test-wasm-create-branch".to_string(),
|
||||
turns: vec![crate::support::trace_llm::TraceTurn {
|
||||
user_input: "Create a replay branch from main".to_string(),
|
||||
steps: vec![
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::ToolCalls {
|
||||
tool_calls: vec![TraceToolCall {
|
||||
id: "call_gh_6".to_string(),
|
||||
name: "github".to_string(),
|
||||
arguments: json!({
|
||||
"action": "create_branch",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"branch": "feature/replay-test",
|
||||
"from_ref": "main"
|
||||
}),
|
||||
}],
|
||||
input_tokens: 100,
|
||||
output_tokens: 35,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "Branch created.".to_string(),
|
||||
input_tokens: 145,
|
||||
output_tokens: 10,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
],
|
||||
expects: TraceExpects::default(),
|
||||
}],
|
||||
memory_snapshot: Vec::new(),
|
||||
http_exchanges: vec![
|
||||
github_exchange(
|
||||
"GET",
|
||||
source_ref_url,
|
||||
None,
|
||||
r#"{"ref":"refs/heads/main","object":{"sha":"abc123def4567890abc123def4567890abc123de"}}"#,
|
||||
),
|
||||
github_exchange(
|
||||
"POST",
|
||||
create_ref_url,
|
||||
Some(create_ref_body),
|
||||
r#"{"ref":"refs/heads/feature/replay-test"}"#,
|
||||
),
|
||||
],
|
||||
expects: TraceExpects {
|
||||
tools_used: vec!["github".to_string()],
|
||||
all_tools_succeeded: Some(true),
|
||||
max_tool_calls: Some(1),
|
||||
min_responses: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
steps: Vec::new(),
|
||||
};
|
||||
|
||||
run_trace(trace, "Create a replay branch from main").await;
|
||||
}
|
||||
|
||||
/// `create_or_update_file` must target the contents API and send base64
|
||||
/// encoded file contents in the request body.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-compiled WASM binary
|
||||
async fn wasm_github_create_or_update_file_puts_contents_payload() {
|
||||
let expected_url = "https://api.github.com/repos/nearai/ironclaw/contents/docs/replay.md";
|
||||
let expected_body = json!({
|
||||
"message": "Add replay doc",
|
||||
"content": "IyBSZXBsYXkgZG9jCg==",
|
||||
"branch": "feature/replay-test",
|
||||
"committer": {
|
||||
"name": "IronClaw Bot",
|
||||
"email": "bot@example.com"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let trace = LlmTrace {
|
||||
model_name: "test-wasm-create-or-update-file".to_string(),
|
||||
turns: vec![crate::support::trace_llm::TraceTurn {
|
||||
user_input: "Write docs/replay.md".to_string(),
|
||||
steps: vec![
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::ToolCalls {
|
||||
tool_calls: vec![TraceToolCall {
|
||||
id: "call_gh_7".to_string(),
|
||||
name: "github".to_string(),
|
||||
arguments: json!({
|
||||
"action": "create_or_update_file",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"path": "docs/replay.md",
|
||||
"message": "Add replay doc",
|
||||
"content": "# Replay doc\n",
|
||||
"branch": "feature/replay-test",
|
||||
"committer": {
|
||||
"name": "IronClaw Bot",
|
||||
"email": "bot@example.com"
|
||||
}
|
||||
}),
|
||||
}],
|
||||
input_tokens: 120,
|
||||
output_tokens: 40,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "File written.".to_string(),
|
||||
input_tokens: 160,
|
||||
output_tokens: 10,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
],
|
||||
expects: TraceExpects::default(),
|
||||
}],
|
||||
memory_snapshot: Vec::new(),
|
||||
http_exchanges: vec![github_exchange(
|
||||
"PUT",
|
||||
expected_url,
|
||||
Some(expected_body),
|
||||
r#"{"content":{"path":"docs/replay.md"},"commit":{"sha":"deadbeef"}}"#,
|
||||
)],
|
||||
expects: TraceExpects {
|
||||
tools_used: vec!["github".to_string()],
|
||||
all_tools_succeeded: Some(true),
|
||||
max_tool_calls: Some(1),
|
||||
min_responses: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
steps: Vec::new(),
|
||||
};
|
||||
|
||||
run_trace(trace, "Write docs/replay.md").await;
|
||||
}
|
||||
|
||||
/// `delete_file` must call DELETE on the contents endpoint with the blob SHA
|
||||
/// and commit metadata in the request body.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-compiled WASM binary
|
||||
async fn wasm_github_delete_file_deletes_contents_with_sha() {
|
||||
let expected_url = "https://api.github.com/repos/nearai/ironclaw/contents/docs/replay.md";
|
||||
let expected_body = json!({
|
||||
"message": "Remove replay doc",
|
||||
"sha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
||||
"branch": "feature/replay-test"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let trace = LlmTrace {
|
||||
model_name: "test-wasm-delete-file".to_string(),
|
||||
turns: vec![crate::support::trace_llm::TraceTurn {
|
||||
user_input: "Delete docs/replay.md".to_string(),
|
||||
steps: vec![
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::ToolCalls {
|
||||
tool_calls: vec![TraceToolCall {
|
||||
id: "call_gh_8".to_string(),
|
||||
name: "github".to_string(),
|
||||
arguments: json!({
|
||||
"action": "delete_file",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"path": "docs/replay.md",
|
||||
"message": "Remove replay doc",
|
||||
"sha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
||||
"branch": "feature/replay-test"
|
||||
}),
|
||||
}],
|
||||
input_tokens: 110,
|
||||
output_tokens: 36,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "File deleted.".to_string(),
|
||||
input_tokens: 150,
|
||||
output_tokens: 10,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
],
|
||||
expects: TraceExpects::default(),
|
||||
}],
|
||||
memory_snapshot: Vec::new(),
|
||||
http_exchanges: vec![github_exchange(
|
||||
"DELETE",
|
||||
expected_url,
|
||||
Some(expected_body),
|
||||
r#"{"commit":{"sha":"feedface"}}"#,
|
||||
)],
|
||||
expects: TraceExpects {
|
||||
tools_used: vec!["github".to_string()],
|
||||
all_tools_succeeded: Some(true),
|
||||
max_tool_calls: Some(1),
|
||||
min_responses: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
steps: Vec::new(),
|
||||
};
|
||||
|
||||
run_trace(trace, "Delete docs/replay.md").await;
|
||||
}
|
||||
|
||||
/// `create_release` should post release metadata to the releases endpoint.
|
||||
#[tokio::test]
|
||||
#[ignore] // requires pre-compiled WASM binary
|
||||
async fn wasm_github_create_release_posts_expected_payload() {
|
||||
let expected_url = "https://api.github.com/repos/nearai/ironclaw/releases";
|
||||
let expected_body = json!({
|
||||
"tag_name": "v1.2.3",
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"generate_release_notes": true,
|
||||
"target_commitish": "main",
|
||||
"name": "Replay Release",
|
||||
"body": "Generated during replay testing"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let trace = LlmTrace {
|
||||
model_name: "test-wasm-create-release".to_string(),
|
||||
turns: vec![crate::support::trace_llm::TraceTurn {
|
||||
user_input: "Create a prerelease".to_string(),
|
||||
steps: vec![
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::ToolCalls {
|
||||
tool_calls: vec![TraceToolCall {
|
||||
id: "call_gh_9".to_string(),
|
||||
name: "github".to_string(),
|
||||
arguments: json!({
|
||||
"action": "create_release",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"tag_name": "v1.2.3",
|
||||
"target_commitish": "main",
|
||||
"name": "Replay Release",
|
||||
"body": "Generated during replay testing",
|
||||
"draft": false,
|
||||
"prerelease": true,
|
||||
"generate_release_notes": true
|
||||
}),
|
||||
}],
|
||||
input_tokens: 125,
|
||||
output_tokens: 40,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
TraceStep {
|
||||
request_hint: None,
|
||||
response: TraceResponse::Text {
|
||||
content: "Release created.".to_string(),
|
||||
input_tokens: 170,
|
||||
output_tokens: 10,
|
||||
},
|
||||
expected_tool_results: Vec::new(),
|
||||
},
|
||||
],
|
||||
expects: TraceExpects::default(),
|
||||
}],
|
||||
memory_snapshot: Vec::new(),
|
||||
http_exchanges: vec![github_exchange(
|
||||
"POST",
|
||||
expected_url,
|
||||
Some(expected_body),
|
||||
r#"{"id":1,"tag_name":"v1.2.3"}"#,
|
||||
)],
|
||||
expects: TraceExpects {
|
||||
tools_used: vec!["github".to_string()],
|
||||
all_tools_succeeded: Some(true),
|
||||
max_tool_calls: Some(1),
|
||||
min_responses: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
steps: Vec::new(),
|
||||
};
|
||||
|
||||
run_trace(trace, "Create a prerelease").await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
[package]
|
||||
name = "github-tool"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
edition = "2021"
|
||||
description = "GitHub integration tool for IronClaw (WASM component)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
wit-bindgen = "0.41.0"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
# GitHub Tool for IronClaw
|
||||
|
||||
WASM tool for GitHub integration - manage repos, issues, PRs, and workflows.
|
||||
WASM tool for GitHub integration. It covers repositories, issues, pull requests,
|
||||
search, branches, file reads and writes, releases, and workflows.
|
||||
|
||||
## Features
|
||||
|
||||
- **Repository Info** - Get repo details, list user repos
|
||||
- **Repositories** - Get repo details, list user repos, create repositories
|
||||
- **Search** - Search repositories, code, and issues/PRs
|
||||
- **Branches** - List branches and create new branches from an existing ref
|
||||
- **Issues** - List/create/get issues, list/add issue comments
|
||||
- **Pull Requests** - List/create/get PRs, review files, create reviews, list/reply review comments, merge PRs
|
||||
- **File Content** - Read files from repos
|
||||
- **File Content** - Read files and create/update/delete repository files
|
||||
- **Releases** - List releases and create new releases
|
||||
- **Workflows** - Trigger GitHub Actions, check run status
|
||||
|
||||
## Setup
|
||||
@@ -32,6 +36,18 @@ WASM tool for GitHub integration - manage repos, issues, PRs, and workflows.
|
||||
}
|
||||
```
|
||||
|
||||
### Create Repository
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create_repo",
|
||||
"name": "infra-playground",
|
||||
"description": "Scratch repo for release automation",
|
||||
"private": true,
|
||||
"auto_init": true
|
||||
}
|
||||
```
|
||||
|
||||
### List Open Issues
|
||||
|
||||
```json
|
||||
@@ -69,6 +85,26 @@ WASM tool for GitHub integration - manage repos, issues, PRs, and workflows.
|
||||
}
|
||||
```
|
||||
|
||||
### Search Code
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "search_code",
|
||||
"query": "repo:nearai/ironclaw tool_info",
|
||||
"limit": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Search Issues and Pull Requests
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "search_issues_pull_requests",
|
||||
"query": "repo:nearai/ironclaw is:pr label:bug",
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
### Review PR
|
||||
|
||||
```json
|
||||
@@ -190,6 +226,81 @@ WASM tool for GitHub integration - manage repos, issues, PRs, and workflows.
|
||||
}
|
||||
```
|
||||
|
||||
### Create or Update a File
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create_or_update_file",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"path": "docs/example.txt",
|
||||
"message": "docs: add example",
|
||||
"content": "Hello from IronClaw"
|
||||
}
|
||||
```
|
||||
|
||||
When updating an existing file, include the current blob `sha`.
|
||||
|
||||
### Delete a File
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "delete_file",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"path": "docs/example.txt",
|
||||
"message": "docs: remove example",
|
||||
"sha": "0123456789abcdef0123456789abcdef01234567"
|
||||
}
|
||||
```
|
||||
|
||||
### List Branches
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list_branches",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
### Create Branch
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create_branch",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"branch": "feature/github-tool-audit",
|
||||
"from_ref": "main"
|
||||
}
|
||||
```
|
||||
|
||||
### List Releases
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list_releases",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
### Create Release
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create_release",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"tag_name": "v1.2.3",
|
||||
"name": "v1.2.3",
|
||||
"generate_release_notes": true
|
||||
}
|
||||
```
|
||||
|
||||
### Trigger Workflow
|
||||
|
||||
```json
|
||||
|
||||
@@ -1,7 +1,45 @@
|
||||
{
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Manage GitHub repositories, issues, pull requests, reviews, and workflows. Supports listing, creating, commenting, merging PRs, and triggering GitHub Actions.",
|
||||
"description": "Manage GitHub repositories, issues, pull requests, search, branches, file reads and writes, releases, and workflows.",
|
||||
"discovery_summary": {
|
||||
"always_required": [
|
||||
"action"
|
||||
],
|
||||
"conditional_requirements": [
|
||||
"Repository-scoped actions require `owner` and `repo`.",
|
||||
"Search actions require `query`.",
|
||||
"File write actions require `path` and `message`; updates additionally need `sha`.",
|
||||
"Workflow dispatch requires `workflow_id` and `ref`.",
|
||||
"Branch creation requires `branch` and `from_ref`."
|
||||
],
|
||||
"notes": [
|
||||
"Use `tool_info(name: \"github\", detail: \"schema\")` for the full action schema before guessing fields.",
|
||||
"Supported families: repositories, issues, pull requests, reviews/comments, search, branches, code reads, file writes, releases, workflow dispatch/runs, and webhook normalization.",
|
||||
"Not supported yet: forks, labels, milestones, projects, org/team admin, GraphQL, release asset uploads, and repository deletion."
|
||||
],
|
||||
"examples": [
|
||||
{
|
||||
"action": "create_repo",
|
||||
"name": "infra-playground",
|
||||
"private": true,
|
||||
"auto_init": true
|
||||
},
|
||||
{
|
||||
"action": "search_code",
|
||||
"query": "repo:nearai/ironclaw tool_info",
|
||||
"limit": 5
|
||||
},
|
||||
{
|
||||
"action": "create_or_update_file",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"path": "docs/example.txt",
|
||||
"message": "docs: add example",
|
||||
"content": "Hello from IronClaw"
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"webhook": {
|
||||
"hmac_secret_name": "github_webhook_secret",
|
||||
@@ -16,7 +54,8 @@
|
||||
"methods": [
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT"
|
||||
"PUT",
|
||||
"DELETE"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user