Expand GitHub WASM tool surface (#1884)

* Expand GitHub WASM tool surface

* Tighten GitHub tool input validation
This commit is contained in:
Henry Park
2026-04-01 14:54:26 -07:00
committed by GitHub
parent 0c89ac55ec
commit 97ccfd4a28
10 changed files with 1802 additions and 98 deletions

View File

@@ -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",

View File

@@ -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.

View File

@@ -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.

View File

@@ -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,
})

View File

@@ -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!({

View File

@@ -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;
}
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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