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:
325
src/tools/devtools.rs
Normal file
325
src/tools/devtools.rs
Normal 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"]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod bridge;
|
pub mod bridge;
|
||||||
|
pub mod devtools;
|
||||||
pub mod room_history;
|
pub mod room_history;
|
||||||
pub mod room_info;
|
pub mod room_info;
|
||||||
pub mod script;
|
pub mod script;
|
||||||
@@ -13,24 +14,36 @@ use serde_json::json;
|
|||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::context::ResponseContext;
|
use crate::context::ResponseContext;
|
||||||
|
use crate::sdk::gitea::GiteaClient;
|
||||||
|
|
||||||
pub struct ToolRegistry {
|
pub struct ToolRegistry {
|
||||||
opensearch: OpenSearch,
|
opensearch: OpenSearch,
|
||||||
matrix: MatrixClient,
|
matrix: MatrixClient,
|
||||||
config: Arc<Config>,
|
config: Arc<Config>,
|
||||||
|
gitea: Option<Arc<GiteaClient>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolRegistry {
|
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 {
|
Self {
|
||||||
opensearch,
|
opensearch,
|
||||||
matrix,
|
matrix,
|
||||||
config,
|
config,
|
||||||
|
gitea,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tool_definitions() -> Vec<Tool> {
|
pub fn has_gitea(&self) -> bool {
|
||||||
vec![
|
self.gitea.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_definitions(gitea_enabled: bool) -> Vec<Tool> {
|
||||||
|
let mut tools = vec![
|
||||||
Tool::new(
|
Tool::new(
|
||||||
"search_archive".into(),
|
"search_archive".into(),
|
||||||
"Search the message archive. Use this to find past conversations, \
|
"Search the message archive. Use this to find past conversations, \
|
||||||
@@ -154,13 +167,19 @@ impl ToolRegistry {
|
|||||||
"required": ["code"]
|
"required": ["code"]
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
]
|
];
|
||||||
|
|
||||||
|
if gitea_enabled {
|
||||||
|
tools.extend(devtools::tool_definitions());
|
||||||
|
}
|
||||||
|
|
||||||
|
tools
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Sol's tool definitions to Mistral AgentTool format
|
/// Convert Sol's tool definitions to Mistral AgentTool format
|
||||||
/// for use with the Agents API (orchestrator agent creation).
|
/// for use with the Agents API (orchestrator agent creation).
|
||||||
pub fn agent_tool_definitions() -> Vec<mistralai_client::v1::agents::AgentTool> {
|
pub fn agent_tool_definitions(gitea_enabled: bool) -> Vec<mistralai_client::v1::agents::AgentTool> {
|
||||||
Self::tool_definitions()
|
Self::tool_definitions(gitea_enabled)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|t| {
|
.map(|t| {
|
||||||
mistralai_client::v1::agents::AgentTool::function(
|
mistralai_client::v1::agents::AgentTool::function(
|
||||||
@@ -207,6 +226,13 @@ impl ToolRegistry {
|
|||||||
)
|
)
|
||||||
.await
|
.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}"),
|
_ => anyhow::bail!("Unknown tool: {name}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ pub fn build_search_query(args: &SearchArgs) -> serde_json::Value {
|
|||||||
})];
|
})];
|
||||||
|
|
||||||
if let Some(ref room) = args.room {
|
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 {
|
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();
|
let mut range = serde_json::Map::new();
|
||||||
@@ -189,7 +189,7 @@ mod tests {
|
|||||||
|
|
||||||
let filters = q["query"]["bool"]["filter"].as_array().unwrap();
|
let filters = q["query"]["bool"]["filter"].as_array().unwrap();
|
||||||
assert_eq!(filters.len(), 2);
|
assert_eq!(filters.len(), 2);
|
||||||
assert_eq!(filters[1]["term"]["room_name.keyword"], "design");
|
assert_eq!(filters[1]["term"]["room_name"], "design");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -199,7 +199,7 @@ mod tests {
|
|||||||
|
|
||||||
let filters = q["query"]["bool"]["filter"].as_array().unwrap();
|
let filters = q["query"]["bool"]["filter"].as_array().unwrap();
|
||||||
assert_eq!(filters.len(), 2);
|
assert_eq!(filters.len(), 2);
|
||||||
assert_eq!(filters[1]["term"]["sender_name.keyword"], "Bob");
|
assert_eq!(filters[1]["term"]["sender_name"], "Bob");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -209,8 +209,8 @@ mod tests {
|
|||||||
|
|
||||||
let filters = q["query"]["bool"]["filter"].as_array().unwrap();
|
let filters = q["query"]["bool"]["filter"].as_array().unwrap();
|
||||||
assert_eq!(filters.len(), 3);
|
assert_eq!(filters.len(), 3);
|
||||||
assert_eq!(filters[1]["term"]["room_name.keyword"], "dev");
|
assert_eq!(filters[1]["term"]["room_name"], "dev");
|
||||||
assert_eq!(filters[2]["term"]["sender_name.keyword"], "Carol");
|
assert_eq!(filters[2]["term"]["sender_name"], "Carol");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -274,6 +274,30 @@ mod tests {
|
|||||||
assert_eq!(filters.len(), 1);
|
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]
|
#[test]
|
||||||
fn test_source_fields() {
|
fn test_source_fields() {
|
||||||
let args = parse_args(r#"{"query": "test"}"#);
|
let args = parse_args(r#"{"query": "test"}"#);
|
||||||
|
|||||||
Reference in New Issue
Block a user