add devtools agent tools, fix search field mapping

7 gitea tools: list_repos, get_repo, list_issues, get_issue,
create_issue, list_pulls, get_file. all operate as the requesting
user via PAT impersonation.

tool registry conditionally includes gitea tools when configured.
dispatch uses prefix matching (gitea_*) for clean extension.

fixed search bug: room_name and sender_name filters used .keyword
subfield which doesn't exist on keyword-typed fields — queries
silently returned zero results for all room/sender-filtered searches.
This commit is contained in:
2026-03-22 15:00:06 +00:00
parent f479235a63
commit c9d4f7400d
3 changed files with 387 additions and 12 deletions

325
src/tools/devtools.rs Normal file
View File

@@ -0,0 +1,325 @@
use std::sync::Arc;
use serde_json::{json, Value};
use crate::context::{self, ResponseContext};
use crate::sdk::gitea::GiteaClient;
/// Execute a Gitea tool call. Returns a JSON string result.
pub async fn execute(
gitea: &Arc<GiteaClient>,
name: &str,
arguments: &str,
response_ctx: &ResponseContext,
) -> anyhow::Result<String> {
let args: Value = serde_json::from_str(arguments)
.map_err(|e| anyhow::anyhow!("Invalid tool arguments: {e}"))?;
let localpart = context::localpart(&response_ctx.matrix_user_id);
match name {
"gitea_list_repos" => {
let query = args["query"].as_str();
let org = args["org"].as_str();
let limit = args["limit"].as_u64().map(|n| n as u32);
match gitea.list_repos(localpart, query, org, limit).await {
Ok(repos) => Ok(serde_json::to_string(&repos).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_get_repo" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
match gitea.get_repo(localpart, owner, repo).await {
Ok(r) => Ok(serde_json::to_string(&r).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_list_issues" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let state = args["state"].as_str();
let labels = args["labels"].as_str();
let limit = args["limit"].as_u64().map(|n| n as u32);
match gitea
.list_issues(localpart, owner, repo, state, labels, limit)
.await
{
Ok(issues) => Ok(serde_json::to_string(&issues).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_get_issue" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let number = args["number"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("missing 'number'"))?;
match gitea.get_issue(localpart, owner, repo, number).await {
Ok(issue) => Ok(serde_json::to_string(&issue).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_create_issue" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let title = args["title"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'title'"))?;
let body = args["body"].as_str();
let labels: Option<Vec<String>> = args["labels"]
.as_array()
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
match gitea
.create_issue(localpart, owner, repo, title, body, labels.as_deref())
.await
{
Ok(issue) => Ok(serde_json::to_string(&issue).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_list_pulls" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let state = args["state"].as_str();
let limit = args["limit"].as_u64().map(|n| n as u32);
match gitea
.list_pulls(localpart, owner, repo, state, limit)
.await
{
Ok(prs) => Ok(serde_json::to_string(&prs).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
"gitea_get_file" => {
let owner = args["owner"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'owner'"))?;
let repo = args["repo"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'repo'"))?;
let path = args["path"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'path'"))?;
let git_ref = args["ref"].as_str();
match gitea
.get_file(localpart, owner, repo, path, git_ref)
.await
{
Ok(file) => Ok(serde_json::to_string(&file).unwrap_or_default()),
Err(e) => Ok(json!({"error": e}).to_string()),
}
}
_ => anyhow::bail!("Unknown devtools tool: {name}"),
}
}
/// Return Mistral tool definitions for Gitea tools.
pub fn tool_definitions() -> Vec<mistralai_client::v1::tool::Tool> {
use mistralai_client::v1::tool::Tool;
vec![
Tool::new(
"gitea_list_repos".into(),
"List repositories on Gitea. Can search by name or filter by organization.".into(),
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query to filter repos by name"
},
"org": {
"type": "string",
"description": "Organization name to list repos from"
},
"limit": {
"type": "integer",
"description": "Max results (default 20)"
}
}
}),
),
Tool::new(
"gitea_get_repo".into(),
"Get details about a specific repository.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner (user or org)"
},
"repo": {
"type": "string",
"description": "Repository name"
}
},
"required": ["owner", "repo"]
}),
),
Tool::new(
"gitea_list_issues".into(),
"List issues in a repository. Filter by state (open/closed) or labels.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"state": {
"type": "string",
"description": "Filter by state: open, closed, or all (default: open)"
},
"labels": {
"type": "string",
"description": "Comma-separated label names to filter by"
},
"limit": {
"type": "integer",
"description": "Max results (default 20)"
}
},
"required": ["owner", "repo"]
}),
),
Tool::new(
"gitea_get_issue".into(),
"Get details of a specific issue by number.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"number": {
"type": "integer",
"description": "Issue number"
}
},
"required": ["owner", "repo", "number"]
}),
),
Tool::new(
"gitea_create_issue".into(),
"Create a new issue in a repository. The issue will be authored by the \
person who asked (not the bot)."
.into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"title": {
"type": "string",
"description": "Issue title"
},
"body": {
"type": "string",
"description": "Issue body (markdown)"
},
"labels": {
"type": "array",
"items": { "type": "string" },
"description": "Label IDs to apply"
}
},
"required": ["owner", "repo", "title"]
}),
),
Tool::new(
"gitea_list_pulls".into(),
"List pull requests in a repository.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"state": {
"type": "string",
"description": "Filter by state: open, closed, or all (default: open)"
},
"limit": {
"type": "integer",
"description": "Max results (default 20)"
}
},
"required": ["owner", "repo"]
}),
),
Tool::new(
"gitea_get_file".into(),
"Get the contents of a file from a repository.".into(),
json!({
"type": "object",
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"repo": {
"type": "string",
"description": "Repository name"
},
"path": {
"type": "string",
"description": "File path within the repository"
},
"ref": {
"type": "string",
"description": "Branch, tag, or commit SHA (default: default branch)"
}
},
"required": ["owner", "repo", "path"]
}),
),
]
}

View File

@@ -1,4 +1,5 @@
pub mod bridge;
pub mod devtools;
pub mod room_history;
pub mod room_info;
pub mod script;
@@ -13,24 +14,36 @@ use serde_json::json;
use crate::config::Config;
use crate::context::ResponseContext;
use crate::sdk::gitea::GiteaClient;
pub struct ToolRegistry {
opensearch: OpenSearch,
matrix: MatrixClient,
config: Arc<Config>,
gitea: Option<Arc<GiteaClient>>,
}
impl ToolRegistry {
pub fn new(opensearch: OpenSearch, matrix: MatrixClient, config: Arc<Config>) -> Self {
pub fn new(
opensearch: OpenSearch,
matrix: MatrixClient,
config: Arc<Config>,
gitea: Option<Arc<GiteaClient>>,
) -> Self {
Self {
opensearch,
matrix,
config,
gitea,
}
}
pub fn tool_definitions() -> Vec<Tool> {
vec![
pub fn has_gitea(&self) -> bool {
self.gitea.is_some()
}
pub fn tool_definitions(gitea_enabled: bool) -> Vec<Tool> {
let mut tools = vec![
Tool::new(
"search_archive".into(),
"Search the message archive. Use this to find past conversations, \
@@ -154,13 +167,19 @@ impl ToolRegistry {
"required": ["code"]
}),
),
]
];
if gitea_enabled {
tools.extend(devtools::tool_definitions());
}
tools
}
/// Convert Sol's tool definitions to Mistral AgentTool format
/// for use with the Agents API (orchestrator agent creation).
pub fn agent_tool_definitions() -> Vec<mistralai_client::v1::agents::AgentTool> {
Self::tool_definitions()
pub fn agent_tool_definitions(gitea_enabled: bool) -> Vec<mistralai_client::v1::agents::AgentTool> {
Self::tool_definitions(gitea_enabled)
.into_iter()
.map(|t| {
mistralai_client::v1::agents::AgentTool::function(
@@ -207,6 +226,13 @@ impl ToolRegistry {
)
.await
}
name if name.starts_with("gitea_") => {
if let Some(ref gitea) = self.gitea {
devtools::execute(gitea, name, arguments, response_ctx).await
} else {
anyhow::bail!("Gitea integration not configured")
}
}
_ => anyhow::bail!("Unknown tool: {name}"),
}
}

View File

@@ -38,10 +38,10 @@ pub fn build_search_query(args: &SearchArgs) -> serde_json::Value {
})];
if let Some(ref room) = args.room {
filter.push(json!({ "term": { "room_name.keyword": room } }));
filter.push(json!({ "term": { "room_name": room } }));
}
if let Some(ref sender) = args.sender {
filter.push(json!({ "term": { "sender_name.keyword": sender } }));
filter.push(json!({ "term": { "sender_name": sender } }));
}
let mut range = serde_json::Map::new();
@@ -189,7 +189,7 @@ mod tests {
let filters = q["query"]["bool"]["filter"].as_array().unwrap();
assert_eq!(filters.len(), 2);
assert_eq!(filters[1]["term"]["room_name.keyword"], "design");
assert_eq!(filters[1]["term"]["room_name"], "design");
}
#[test]
@@ -199,7 +199,7 @@ mod tests {
let filters = q["query"]["bool"]["filter"].as_array().unwrap();
assert_eq!(filters.len(), 2);
assert_eq!(filters[1]["term"]["sender_name.keyword"], "Bob");
assert_eq!(filters[1]["term"]["sender_name"], "Bob");
}
#[test]
@@ -209,8 +209,8 @@ mod tests {
let filters = q["query"]["bool"]["filter"].as_array().unwrap();
assert_eq!(filters.len(), 3);
assert_eq!(filters[1]["term"]["room_name.keyword"], "dev");
assert_eq!(filters[2]["term"]["sender_name.keyword"], "Carol");
assert_eq!(filters[1]["term"]["room_name"], "dev");
assert_eq!(filters[2]["term"]["sender_name"], "Carol");
}
#[test]
@@ -274,6 +274,30 @@ mod tests {
assert_eq!(filters.len(), 1);
}
#[test]
fn test_wildcard_query_uses_match_all() {
let args = parse_args(r#"{"query": "*"}"#);
let q = build_search_query(&args);
assert!(q["query"]["bool"]["must"][0]["match_all"].is_object());
}
#[test]
fn test_empty_query_uses_match_all() {
let args = parse_args(r#"{"query": ""}"#);
let q = build_search_query(&args);
assert!(q["query"]["bool"]["must"][0]["match_all"].is_object());
}
#[test]
fn test_room_filter_uses_keyword_field() {
// room_name is mapped as "keyword" in OpenSearch — no .keyword subfield
let args = parse_args(r#"{"query": "test", "room": "general"}"#);
let q = build_search_query(&args);
let filters = q["query"]["bool"]["filter"].as_array().unwrap();
// Should be room_name, NOT room_name.keyword
assert_eq!(filters[1]["term"]["room_name"], "general");
}
#[test]
fn test_source_fields() {
let args = parse_args(r#"{"query": "test"}"#);